initial
This commit is contained in:
468
frontend/src/pages/AdminPanelPage.tsx
Normal file
468
frontend/src/pages/AdminPanelPage.tsx
Normal 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);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user