This commit is contained in:
Mikan
2026-06-19 11:28:04 +03:00
commit 53c89829a8
80 changed files with 12482 additions and 0 deletions

View File

@@ -0,0 +1,468 @@
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);
}}
/>
);
}