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);
|
||
}}
|
||
/>
|
||
);
|
||
}
|