469 lines
18 KiB
TypeScript
469 lines
18 KiB
TypeScript
|
|
import { useEffect, useState } from "react";
|
|||
|
|
import { useTranslation } from "react-i18next";
|
|||
|
|
import { useNavigate } from "react-router-dom";
|
|||
|
|
import { adminApi } from "@/api";
|
|||
|
|
import type { LlmLog, SettingsOut } from "@/types";
|
|||
|
|
import { Button } from "@/components/ui/Button";
|
|||
|
|
import { Input } from "@/components/ui/Input";
|
|||
|
|
import { Card, CardBody, CardHeader } from "@/components/ui/Card";
|
|||
|
|
import { Save, ArrowLeft, Activity, Users, Zap } from "lucide-react";
|
|||
|
|
|
|||
|
|
export function AdminPanelPage() {
|
|||
|
|
const { t } = useTranslation();
|
|||
|
|
const navigate = useNavigate();
|
|||
|
|
const [settings, setSettings] = useState<SettingsOut | null>(null);
|
|||
|
|
const [values, setValues] = useState<Record<string, any>>({});
|
|||
|
|
const [logs, setLogs] = useState<LlmLog[]>([]);
|
|||
|
|
const [users, setUsers] = useState<any[]>([]);
|
|||
|
|
const [tab, setTab] = useState<"settings" | "logs" | "users">("settings");
|
|||
|
|
const [loading, setLoading] = useState(true);
|
|||
|
|
const [saving, setSaving] = useState(false);
|
|||
|
|
const [saved, setSaved] = useState(false);
|
|||
|
|
const [error, setError] = useState("");
|
|||
|
|
const [embeddingTest, setEmbeddingTest] = useState<null | { ok: boolean; msg: string }>(null);
|
|||
|
|
const [testingEmbeddings, setTestingEmbeddings] = useState(false);
|
|||
|
|
|
|||
|
|
useEffect(() => {
|
|||
|
|
(async () => {
|
|||
|
|
try {
|
|||
|
|
const s = await adminApi.getSettings();
|
|||
|
|
setSettings(s);
|
|||
|
|
setValues(s.values);
|
|||
|
|
const l = await adminApi.listLlmLogs(50);
|
|||
|
|
setLogs(l);
|
|||
|
|
const u = await adminApi.listUsers();
|
|||
|
|
setUsers(u);
|
|||
|
|
} catch (err: any) {
|
|||
|
|
setError(err.response?.data?.detail || t("errors.unknown"));
|
|||
|
|
} finally {
|
|||
|
|
setLoading(false);
|
|||
|
|
}
|
|||
|
|
})();
|
|||
|
|
}, []);
|
|||
|
|
|
|||
|
|
const save = async () => {
|
|||
|
|
setError("");
|
|||
|
|
setSaving(true);
|
|||
|
|
setSaved(false);
|
|||
|
|
try {
|
|||
|
|
// Strip masked api_key unless user typed a new one
|
|||
|
|
const payload: Record<string, any> = { ...values };
|
|||
|
|
for (const k of ["llm.api_key", "embedding.api_key"]) {
|
|||
|
|
if (typeof payload[k] === "string" && payload[k].includes("***")) {
|
|||
|
|
delete payload[k];
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
const s = await adminApi.updateSettings(payload);
|
|||
|
|
setSettings(s);
|
|||
|
|
setValues(s.values);
|
|||
|
|
setSaved(true);
|
|||
|
|
setTimeout(() => setSaved(false), 2000);
|
|||
|
|
} catch (err: any) {
|
|||
|
|
setError(err.response?.data?.detail || t("errors.unknown"));
|
|||
|
|
} finally {
|
|||
|
|
setSaving(false);
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const testEmbeddings = async () => {
|
|||
|
|
setError("");
|
|||
|
|
setTestingEmbeddings(true);
|
|||
|
|
setEmbeddingTest(null);
|
|||
|
|
try {
|
|||
|
|
// Build overrides from current form values (excluding masked api_key)
|
|||
|
|
const overrides: Record<string, any> = {};
|
|||
|
|
for (const k of [
|
|||
|
|
"embedding.provider",
|
|||
|
|
"embedding.base_url",
|
|||
|
|
"embedding.api_key",
|
|||
|
|
"embedding.model",
|
|||
|
|
"embedding.dim",
|
|||
|
|
"embedding.request_timeout",
|
|||
|
|
"llm.base_url",
|
|||
|
|
"llm.api_key",
|
|||
|
|
]) {
|
|||
|
|
const v = values[k];
|
|||
|
|
if (v !== undefined && v !== null && !(typeof v === "string" && v.includes("***"))) {
|
|||
|
|
overrides[k] = v;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
const r = await adminApi.testEmbeddings(overrides);
|
|||
|
|
if (r.ok) {
|
|||
|
|
setEmbeddingTest({
|
|||
|
|
ok: true,
|
|||
|
|
msg: t("admin.embedding_test_ok", {
|
|||
|
|
provider: r.provider,
|
|||
|
|
dim: r.dim,
|
|||
|
|
norm: r.sample_norm,
|
|||
|
|
}),
|
|||
|
|
});
|
|||
|
|
} else {
|
|||
|
|
setEmbeddingTest({ ok: false, msg: t("admin.embedding_test_fail", { error: r.error || "unknown" }) });
|
|||
|
|
}
|
|||
|
|
} catch (err: any) {
|
|||
|
|
setEmbeddingTest({
|
|||
|
|
ok: false,
|
|||
|
|
msg: t("admin.embedding_test_fail", { error: err.response?.data?.detail || err.message }),
|
|||
|
|
});
|
|||
|
|
} finally {
|
|||
|
|
setTestingEmbeddings(false);
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
if (loading) {
|
|||
|
|
return <div className="p-8 text-center text-ink-400">{t("common.loading")}</div>;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return (
|
|||
|
|
<div className="max-w-5xl mx-auto px-4 py-6">
|
|||
|
|
<div className="flex items-center justify-between mb-6">
|
|||
|
|
<div>
|
|||
|
|
<h1 className="text-2xl font-serif text-ink-100">{t("admin.title")}</h1>
|
|||
|
|
</div>
|
|||
|
|
<Button variant="ghost" onClick={() => navigate("/dashboard")}>
|
|||
|
|
<ArrowLeft size={14} className="mr-1" />
|
|||
|
|
{t("admin.back")}
|
|||
|
|
</Button>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
{error && <p className="text-sm text-red-400 mb-4">{error}</p>}
|
|||
|
|
|
|||
|
|
{/* Tabs */}
|
|||
|
|
<div className="flex gap-1 mb-4">
|
|||
|
|
<TabButton active={tab === "settings"} onClick={() => setTab("settings")}>
|
|||
|
|
{t("admin.settings")}
|
|||
|
|
</TabButton>
|
|||
|
|
<TabButton active={tab === "logs"} onClick={() => setTab("logs")}>
|
|||
|
|
<Activity size={12} className="mr-1" />
|
|||
|
|
{t("admin.llm_logs")}
|
|||
|
|
</TabButton>
|
|||
|
|
<TabButton active={tab === "users"} onClick={() => setTab("users")}>
|
|||
|
|
<Users size={12} className="mr-1" />
|
|||
|
|
{t("admin.users")}
|
|||
|
|
</TabButton>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
{tab === "settings" && settings && (
|
|||
|
|
<div className="space-y-4">
|
|||
|
|
<Card>
|
|||
|
|
<CardHeader title={t("admin.settings")} />
|
|||
|
|
<CardBody className="space-y-3">
|
|||
|
|
<Input
|
|||
|
|
label={t("admin.base_url")}
|
|||
|
|
value={values["llm.base_url"] || ""}
|
|||
|
|
onChange={(e) => setValues({ ...values, "llm.base_url": e.target.value })}
|
|||
|
|
placeholder="http://localhost:1234/v1"
|
|||
|
|
/>
|
|||
|
|
<Input
|
|||
|
|
label={t("admin.api_key")}
|
|||
|
|
type="password"
|
|||
|
|
value={values["llm.api_key"] || ""}
|
|||
|
|
onChange={(e) => setValues({ ...values, "llm.api_key": e.target.value })}
|
|||
|
|
placeholder="sk-..."
|
|||
|
|
/>
|
|||
|
|
<Input
|
|||
|
|
label={t("admin.model")}
|
|||
|
|
value={values["llm.model"] || ""}
|
|||
|
|
onChange={(e) => setValues({ ...values, "llm.model": e.target.value })}
|
|||
|
|
placeholder="local-model"
|
|||
|
|
/>
|
|||
|
|
<div className="grid grid-cols-2 gap-3">
|
|||
|
|
<NumberInput
|
|||
|
|
label={t("admin.temperature")}
|
|||
|
|
value={values["llm.temperature"]}
|
|||
|
|
onChange={(v) => setValues({ ...values, "llm.temperature": v })}
|
|||
|
|
step={0.1}
|
|||
|
|
/>
|
|||
|
|
<NumberInput
|
|||
|
|
label={t("admin.step_temperature")}
|
|||
|
|
value={values["llm.step_temperature"]}
|
|||
|
|
onChange={(v) => setValues({ ...values, "llm.step_temperature": v })}
|
|||
|
|
step={0.1}
|
|||
|
|
/>
|
|||
|
|
<NumberInput
|
|||
|
|
label={t("admin.summary_temperature")}
|
|||
|
|
value={values["llm.summary_temperature"]}
|
|||
|
|
onChange={(v) => setValues({ ...values, "llm.summary_temperature": v })}
|
|||
|
|
step={0.1}
|
|||
|
|
/>
|
|||
|
|
<NumberInput
|
|||
|
|
label={t("admin.max_tokens")}
|
|||
|
|
value={values["llm.max_tokens"]}
|
|||
|
|
onChange={(v) => setValues({ ...values, "llm.max_tokens": v })}
|
|||
|
|
/>
|
|||
|
|
<NumberInput
|
|||
|
|
label={t("admin.request_timeout")}
|
|||
|
|
value={values["llm.request_timeout"]}
|
|||
|
|
onChange={(v) => setValues({ ...values, "llm.request_timeout": v })}
|
|||
|
|
/>
|
|||
|
|
<label className="flex items-center gap-2 text-xs text-ink-300 self-end mb-2">
|
|||
|
|
<input
|
|||
|
|
type="checkbox"
|
|||
|
|
checked={!!values["llm.streaming"]}
|
|||
|
|
onChange={(e) => setValues({ ...values, "llm.streaming": e.target.checked })}
|
|||
|
|
className="rounded"
|
|||
|
|
/>
|
|||
|
|
{t("admin.streaming")}
|
|||
|
|
</label>
|
|||
|
|
</div>
|
|||
|
|
</CardBody>
|
|||
|
|
</Card>
|
|||
|
|
|
|||
|
|
{/* === Embeddings / RAG === */}
|
|||
|
|
<Card>
|
|||
|
|
<CardHeader
|
|||
|
|
title={t("admin.embedding_settings")}
|
|||
|
|
subtitle="hash = offline fallback · openai = real semantic search"
|
|||
|
|
/>
|
|||
|
|
<CardBody className="space-y-3">
|
|||
|
|
<div>
|
|||
|
|
<label className="block text-xs text-ink-400 mb-1">{t("admin.embedding_provider")}</label>
|
|||
|
|
<select
|
|||
|
|
className="w-full bg-ink-900 border border-ink-700 rounded px-2 py-1.5 text-sm text-ink-100"
|
|||
|
|
value={values["embedding.provider"] || "hash"}
|
|||
|
|
onChange={(e) => setValues({ ...values, "embedding.provider": e.target.value })}
|
|||
|
|
>
|
|||
|
|
<option value="hash">{t("admin.embedding_provider_hash")}</option>
|
|||
|
|
<option value="openai">{t("admin.embedding_provider_openai")}</option>
|
|||
|
|
</select>
|
|||
|
|
</div>
|
|||
|
|
<Input
|
|||
|
|
label={t("admin.embedding_base_url")}
|
|||
|
|
value={values["embedding.base_url"] || ""}
|
|||
|
|
onChange={(e) => setValues({ ...values, "embedding.base_url": e.target.value })}
|
|||
|
|
placeholder="http://localhost:1234/v1"
|
|||
|
|
/>
|
|||
|
|
<Input
|
|||
|
|
label={t("admin.embedding_api_key")}
|
|||
|
|
type="password"
|
|||
|
|
value={values["embedding.api_key"] || ""}
|
|||
|
|
onChange={(e) => setValues({ ...values, "embedding.api_key": e.target.value })}
|
|||
|
|
placeholder="sk-... (пусто = как у LLM)"
|
|||
|
|
/>
|
|||
|
|
<div className="grid grid-cols-3 gap-3">
|
|||
|
|
<Input
|
|||
|
|
label={t("admin.embedding_model")}
|
|||
|
|
value={values["embedding.model"] || ""}
|
|||
|
|
onChange={(e) => setValues({ ...values, "embedding.model": e.target.value })}
|
|||
|
|
placeholder="text-embedding-3-small"
|
|||
|
|
/>
|
|||
|
|
<NumberInput
|
|||
|
|
label={t("admin.embedding_dim")}
|
|||
|
|
value={values["embedding.dim"]}
|
|||
|
|
onChange={(v) => setValues({ ...values, "embedding.dim": v })}
|
|||
|
|
/>
|
|||
|
|
<NumberInput
|
|||
|
|
label={t("admin.embedding_timeout")}
|
|||
|
|
value={values["embedding.request_timeout"]}
|
|||
|
|
onChange={(v) => setValues({ ...values, "embedding.request_timeout": v })}
|
|||
|
|
/>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div className="flex items-center gap-2 pt-1">
|
|||
|
|
<Button variant="ghost" onClick={testEmbeddings} disabled={testingEmbeddings}>
|
|||
|
|
<Zap size={14} className="mr-1" />
|
|||
|
|
{testingEmbeddings ? t("admin.embedding_testing") : t("admin.embedding_test")}
|
|||
|
|
</Button>
|
|||
|
|
{embeddingTest && (
|
|||
|
|
<span className={`text-xs ${embeddingTest.ok ? "text-green-400" : "text-red-400"}`}>
|
|||
|
|
{embeddingTest.msg}
|
|||
|
|
</span>
|
|||
|
|
)}
|
|||
|
|
</div>
|
|||
|
|
</CardBody>
|
|||
|
|
</Card>
|
|||
|
|
|
|||
|
|
<Card>
|
|||
|
|
<CardHeader title={t("admin.context_settings")} />
|
|||
|
|
<CardBody>
|
|||
|
|
<div className="grid grid-cols-2 gap-3">
|
|||
|
|
<NumberInput
|
|||
|
|
label={t("admin.recent_messages")}
|
|||
|
|
value={values["context.recent_messages"]}
|
|||
|
|
onChange={(v) => setValues({ ...values, "context.recent_messages": v })}
|
|||
|
|
/>
|
|||
|
|
<NumberInput
|
|||
|
|
label={t("admin.compress_threshold")}
|
|||
|
|
value={values["context.compress_threshold"]}
|
|||
|
|
onChange={(v) => setValues({ ...values, "context.compress_threshold": v })}
|
|||
|
|
/>
|
|||
|
|
<NumberInput
|
|||
|
|
label={t("admin.summary_messages")}
|
|||
|
|
value={values["context.summary_messages"]}
|
|||
|
|
onChange={(v) => setValues({ ...values, "context.summary_messages": v })}
|
|||
|
|
/>
|
|||
|
|
<NumberInput
|
|||
|
|
label={t("admin.max_tokens_total")}
|
|||
|
|
value={values["context.max_tokens_total"]}
|
|||
|
|
onChange={(v) => setValues({ ...values, "context.max_tokens_total": v })}
|
|||
|
|
/>
|
|||
|
|
</div>
|
|||
|
|
</CardBody>
|
|||
|
|
</Card>
|
|||
|
|
|
|||
|
|
<Card>
|
|||
|
|
<CardHeader title={t("admin.trigger_settings")} />
|
|||
|
|
<CardBody>
|
|||
|
|
<div className="grid grid-cols-2 gap-3 items-end">
|
|||
|
|
<label className="flex items-center gap-2 text-xs text-ink-300">
|
|||
|
|
<input
|
|||
|
|
type="checkbox"
|
|||
|
|
checked={!!values["triggers.enabled"]}
|
|||
|
|
onChange={(e) => setValues({ ...values, "triggers.enabled": e.target.checked })}
|
|||
|
|
/>
|
|||
|
|
{t("admin.triggers_enabled")}
|
|||
|
|
</label>
|
|||
|
|
<NumberInput
|
|||
|
|
label={t("admin.triggers_check_interval")}
|
|||
|
|
value={values["triggers.check_interval"]}
|
|||
|
|
onChange={(v) => setValues({ ...values, "triggers.check_interval": v })}
|
|||
|
|
/>
|
|||
|
|
</div>
|
|||
|
|
</CardBody>
|
|||
|
|
</Card>
|
|||
|
|
|
|||
|
|
<div className="flex justify-end gap-2">
|
|||
|
|
{saved && <span className="text-sm text-green-400 self-center">{t("admin.saved")}</span>}
|
|||
|
|
<Button onClick={save} disabled={saving}>
|
|||
|
|
<Save size={14} className="mr-1" />
|
|||
|
|
{saving ? t("common.loading") : t("admin.save")}
|
|||
|
|
</Button>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
|
|||
|
|
{tab === "logs" && (
|
|||
|
|
<Card>
|
|||
|
|
<CardHeader title={t("admin.llm_logs")} subtitle={`${logs.length} recent calls`} />
|
|||
|
|
<CardBody>
|
|||
|
|
<div className="overflow-x-auto">
|
|||
|
|
<table className="w-full text-xs">
|
|||
|
|
<thead>
|
|||
|
|
<tr className="text-left text-ink-400 border-b border-ink-800">
|
|||
|
|
<th className="py-2 pr-3">Time</th>
|
|||
|
|
<th className="py-2 pr-3">Purpose</th>
|
|||
|
|
<th className="py-2 pr-3">Model</th>
|
|||
|
|
<th className="py-2 pr-3 text-right">Tokens</th>
|
|||
|
|
<th className="py-2 pr-3 text-right">Latency</th>
|
|||
|
|
<th className="py-2">Status</th>
|
|||
|
|
</tr>
|
|||
|
|
</thead>
|
|||
|
|
<tbody>
|
|||
|
|
{logs.map((l) => (
|
|||
|
|
<tr key={l.id} className="border-b border-ink-900 hover:bg-ink-900/50">
|
|||
|
|
<td className="py-2 pr-3 text-ink-400">
|
|||
|
|
{new Date(l.created_at).toLocaleTimeString()}
|
|||
|
|
</td>
|
|||
|
|
<td className="py-2 pr-3 text-ink-100">{l.purpose}</td>
|
|||
|
|
<td className="py-2 pr-3 text-ink-300">{l.model}</td>
|
|||
|
|
<td className="py-2 pr-3 text-right text-ink-300">
|
|||
|
|
{l.total_tokens ?? "—"}
|
|||
|
|
</td>
|
|||
|
|
<td className="py-2 pr-3 text-right text-ink-300">
|
|||
|
|
{l.latency_ms ? `${l.latency_ms}ms` : "—"}
|
|||
|
|
</td>
|
|||
|
|
<td className="py-2">
|
|||
|
|
{l.error ? (
|
|||
|
|
<span className="text-red-400">err</span>
|
|||
|
|
) : (
|
|||
|
|
<span className="text-green-400">ok</span>
|
|||
|
|
)}
|
|||
|
|
</td>
|
|||
|
|
</tr>
|
|||
|
|
))}
|
|||
|
|
</tbody>
|
|||
|
|
</table>
|
|||
|
|
</div>
|
|||
|
|
</CardBody>
|
|||
|
|
</Card>
|
|||
|
|
)}
|
|||
|
|
|
|||
|
|
{tab === "users" && (
|
|||
|
|
<Card>
|
|||
|
|
<CardHeader title={t("admin.users")} subtitle={`${users.length} users`} />
|
|||
|
|
<CardBody>
|
|||
|
|
<div className="overflow-x-auto">
|
|||
|
|
<table className="w-full text-xs">
|
|||
|
|
<thead>
|
|||
|
|
<tr className="text-left text-ink-400 border-b border-ink-800">
|
|||
|
|
<th className="py-2 pr-3">Email</th>
|
|||
|
|
<th className="py-2 pr-3">Username</th>
|
|||
|
|
<th className="py-2 pr-3">Role</th>
|
|||
|
|
<th className="py-2 pr-3">Active</th>
|
|||
|
|
<th className="py-2">Created</th>
|
|||
|
|
</tr>
|
|||
|
|
</thead>
|
|||
|
|
<tbody>
|
|||
|
|
{users.map((u) => (
|
|||
|
|
<tr key={u.id} className="border-b border-ink-900">
|
|||
|
|
<td className="py-2 pr-3 text-ink-100">{u.email}</td>
|
|||
|
|
<td className="py-2 pr-3 text-ink-300">{u.username}</td>
|
|||
|
|
<td className="py-2 pr-3">
|
|||
|
|
{u.is_admin ? (
|
|||
|
|
<span className="text-accent-400">admin</span>
|
|||
|
|
) : (
|
|||
|
|
<span className="text-ink-400">user</span>
|
|||
|
|
)}
|
|||
|
|
</td>
|
|||
|
|
<td className="py-2 pr-3">
|
|||
|
|
{u.is_active ? (
|
|||
|
|
<span className="text-green-400">●</span>
|
|||
|
|
) : (
|
|||
|
|
<span className="text-red-400">●</span>
|
|||
|
|
)}
|
|||
|
|
</td>
|
|||
|
|
<td className="py-2 text-ink-400">
|
|||
|
|
{new Date(u.created_at).toLocaleDateString()}
|
|||
|
|
</td>
|
|||
|
|
</tr>
|
|||
|
|
))}
|
|||
|
|
</tbody>
|
|||
|
|
</table>
|
|||
|
|
</div>
|
|||
|
|
</CardBody>
|
|||
|
|
</Card>
|
|||
|
|
)}
|
|||
|
|
</div>
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function TabButton({ active, onClick, children }: { active: boolean; onClick: () => void; children: React.ReactNode }) {
|
|||
|
|
return (
|
|||
|
|
<button
|
|||
|
|
onClick={onClick}
|
|||
|
|
className={`text-xs px-3 py-1.5 rounded-t border-b-2 transition-colors ${
|
|||
|
|
active
|
|||
|
|
? "border-accent-500 text-accent-400 bg-ink-900/50"
|
|||
|
|
: "border-transparent text-ink-400 hover:text-ink-200"
|
|||
|
|
}`}
|
|||
|
|
>
|
|||
|
|
{children}
|
|||
|
|
</button>
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function NumberInput({
|
|||
|
|
label,
|
|||
|
|
value,
|
|||
|
|
onChange,
|
|||
|
|
step,
|
|||
|
|
}: {
|
|||
|
|
label: string;
|
|||
|
|
value: any;
|
|||
|
|
onChange: (v: number) => void;
|
|||
|
|
step?: number;
|
|||
|
|
}) {
|
|||
|
|
return (
|
|||
|
|
<Input
|
|||
|
|
label={label}
|
|||
|
|
type="number"
|
|||
|
|
step={step}
|
|||
|
|
value={value ?? 0}
|
|||
|
|
onChange={(e) => {
|
|||
|
|
const v = parseFloat(e.target.value);
|
|||
|
|
onChange(isNaN(v) ? 0 : v);
|
|||
|
|
}}
|
|||
|
|
/>
|
|||
|
|
);
|
|||
|
|
}
|