Files
AgentHub/client/src/pages/Settings.tsx
T
nntrivi2001 b71024f244 feat(ui): Orca Dark / Orca Light themes and notification sounds
Two optional named themes from Orca's neutral palette join the Appearance
picker (Orca Dark keeps a blue accent so white-on-accent buttons stay
legible; Orca Light's status colours are darkened to clear 4.5:1 on their
chips). A shown notification can play a sound — None (default), Chime, Pop
or Alert — chosen globally and per notification type in Settings →
Notifications; the WAVs are generated by scripts/gen-notification-sounds.js.
2026-10-01 17:10:46 +07:00

1022 lines
43 KiB
TypeScript

/**
* @file Settings.tsx
* @description The install-wide settings, as two pages of the Settings nav (`SettingsHub.tsx`): `section="general"` — total cost, workspaces housekeeping, import, remote sources, Sagi, data management and system information — and `section="notifications"` — browser notifications and alerts. Appearance is its own page (`components/settings/AppearanceSettings.tsx`); per-tool settings live in that tool's page under `/settings/agents/<id>`.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
/* =============================================================================
* MODULE_GUIDE — extended in-file reference (comments only; safe to read, never executed)
* =============================================================================
* **Purpose:** Dashboard module consumed by the React client, MCP tools, or desktop shell depending on deployment mode.
*
* ## Design constraints
* - Local-first: no telemetry leaves the machine unless the user configures webhooks.
* - Fail-safe hooks path on the server must never block Claude Code; UI mirrors that
* philosophy by degrading gracefully (empty states, stale badges, reconnect loops).
* - Destructive flows stay behind explicit confirmation modals and server-side gates.
* - Internationalization: user-visible strings belong in i18n JSON, not literals here.
*
* ## Remote data & SSH
* Remote Data Sources let operators aggregate multiple machines. SSH entries describe
* how to reach a peer dashboard; the global data scope (`dataScope.ts`) narrows every
* scoped GET via `?sources=`. Health checks and import history surface in Settings.
*
* ## Internal dependencies
* - `../lib/api`
* - `../lib/eventBus`
* - `../components/Sagi/prefs`
* - `../lib/format`
* - `../lib/push`
* - `../components/Tip`
* - `../components/ImportHistory`
* - `../components/RemoteSources`
* - `../components/Skeleton`
* - `../components/AlertsNotifications`
* - `../lib/types`
*
* ## Public surface
* - `Settings` — exported API; see TSDoc on the symbol for behavior.
*
* ## Testing pointers
* - Prefer colocated `__tests__` with Vitest + Testing Library for UI.
* - Server contract changes require `npm run test:server` and OpenAPI sync.
*
* ## Related docs
* - `ARCHITECTURE.md` — hooks → API → SQLite → WebSocket → UI pipeline.
* - `docs/API.md` — REST reference.
* - `skills/file-headers/` — mandatory `@author` header policy.
* ============================================================================= */
/* -----------------------------------------------------------------------------
* EXPORT CATALOG — quick index of symbols defined below (documentation only).
* -----------------------------------------------------------------------------
* **Settings**
* Part of this module's public contract. Downstream imports should treat
* the signature and return type as stable unless release notes say otherwise.
* When behavior changes, update the `@file` overview and relevant tests.
*
* ----------------------------------------------------------------------------- */
import { useEffect, useState, useCallback } from "react";
import { useTranslation } from "react-i18next";
import {
DollarSign,
RefreshCw,
Database,
HardDrive,
AlertTriangle,
RotateCcw,
Server,
Bell,
BellOff,
BellRing,
FileDown,
Eraser,
Zap,
Play,
GitBranch,
ShieldCheck,
ShieldAlert,
ShieldX,
Clock,
Cpu,
Globe,
Wifi,
Activity,
Users,
Layers,
Coins,
BarChart3,
} from "lucide-react";
import { api } from "../lib/api";
import { eventBus } from "../lib/eventBus";
import { isRemoteDataRefreshMessage } from "../lib/remoteDataEvents";
import { sagiPrefs } from "../components/Sagi/prefs";
import { fmt, fmtCost } from "../lib/format";
import { subscribeToPush, unsubscribeFromPush } from "../lib/push";
import {
loadNotifPrefs,
saveNotifPrefs,
NOTIF_SOUNDS,
type NotifPrefs,
type NotifSound,
type NotifType,
} from "../lib/notifPrefs";
import { notify, playSound } from "../lib/notify";
import { Tip } from "../components/Tip";
import { ImportHistory } from "../components/ImportHistory";
import { RemoteSources } from "../components/RemoteSources";
import { Skeleton } from "../components/Skeleton";
import { AlertsNotifications } from "../components/AlertsNotifications";
import {
SettingsRow,
SettingsSegmented,
SettingsSwitchRow,
} from "../components/settings/SettingsRow";
import type { WSMessage } from "../lib/types";
// The per-type notification switches, in the order Settings lists them.
const NOTIF_TYPES: [NotifType, string][] = [
["onAwaitingInput", "awaitingInput"],
["onNewSession", "newSession"],
["onTurnComplete", "turnComplete"],
["onSessionComplete", "sessionComplete"],
["onSessionError", "sessionError"],
["onSubagentSpawn", "subagentSpawned"],
];
// ─── Helpers ───
interface SystemInfo {
db: { path: string; size: number; counts: Record<string, number> };
hooks: { installed: boolean; path: string; hooks: Record<string, boolean> };
server: {
version: string;
uptime: number;
node_version: string;
platform: string;
ws_connections: number;
};
}
function formatBytes(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
function formatUptime(seconds: number): string {
const d = Math.floor(seconds / 86400);
const h = Math.floor((seconds % 86400) / 3600);
const m = Math.floor((seconds % 3600) / 60);
if (d > 0) return `${d}d ${h}h ${m}m`;
if (h > 0) return `${h}h ${m}m`;
return `${m}m`;
}
function useCountUp(end: number | null, durationMs = 1000) {
const [count, setCount] = useState(0);
useEffect(() => {
if (end === null) {
setCount(0);
return;
}
let startTimestamp: number | null = null;
let animationFrameId: number;
const startValue = count;
const step = (timestamp: number) => {
if (!startTimestamp) startTimestamp = timestamp;
const progress = Math.min((timestamp - startTimestamp) / durationMs, 1);
// easeOutQuart
const easeProgress = 1 - Math.pow(1 - progress, 4);
setCount(startValue + (end - startValue) * easeProgress);
if (progress < 1) {
animationFrameId = window.requestAnimationFrame(step);
} else {
setCount(end);
}
};
animationFrameId = window.requestAnimationFrame(step);
return () => window.cancelAnimationFrame(animationFrameId);
}, [end, durationMs]);
return count;
}
// ─── Main component ───
export function Settings({
section = "general",
}: {
/** Which of the two Settings pages this renders (`/settings/general` or `/settings/notifications`). */
section?: "general" | "notifications";
} = {}) {
const { t } = useTranslation("settings");
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [totalCost, setTotalCost] = useState<number | null>(null);
const [sysInfo, setSysInfo] = useState<SystemInfo | null>(null);
const [actionLoading, setActionLoading] = useState<string | null>(null);
const [actionResult, setActionResult] = useState<{
key: string;
message: string;
isError: boolean;
} | null>(null);
const [confirmAction, setConfirmAction] = useState<string | null>(null);
const [notifPrefs, setNotifPrefs] = useState<NotifPrefs>(loadNotifPrefs);
const [sagiEnabled, setSagiEnabled] = useState(() => sagiPrefs.getEnabled());
const setSagi = useCallback((v: boolean) => {
sagiPrefs.setEnabled(v);
setSagiEnabled(v);
}, []);
const [abandonHours, setAbandonHours] = useState("24");
const [purgeDays, setPurgeDays] = useState("90");
const [gcDryRun, setGcDryRun] = useState(false);
const animatedTotalCost = useCountUp(totalCost);
const load = useCallback(async () => {
try {
const [costRes, infoRes] = await Promise.all([api.pricing.totalCost(), api.settings.info()]);
setTotalCost(costRes.total_cost);
setSysInfo(infoRes);
setError(null);
} catch (err) {
setError(err instanceof Error ? err.message : t("messages.failedLoad"));
} finally {
setLoading(false);
}
}, [t]);
useEffect(() => {
load();
}, [load]);
useEffect(() => {
const refreshInfo = () =>
api.settings
.info()
.then(setSysInfo)
.catch(() => {});
const interval = setInterval(refreshInfo, 10000);
return () => clearInterval(interval);
}, []);
useEffect(() => {
return eventBus.subscribe((msg: WSMessage) => {
if (
msg.type === "session_created" ||
msg.type === "session_updated" ||
msg.type === "agent_created" ||
msg.type === "agent_updated" ||
msg.type === "new_event"
) {
api.settings
.info()
.then(setSysInfo)
.catch(() => {});
}
if (isRemoteDataRefreshMessage(msg)) {
load();
}
});
}, [load]);
useEffect(() => {
if (!actionResult) return;
const timeout = setTimeout(() => setActionResult(null), 5000);
return () => clearTimeout(timeout);
}, [actionResult]);
const updateNotifPrefs = (patch: Partial<NotifPrefs>) => {
setNotifPrefs((prev) => {
const next = { ...prev, ...patch };
saveNotifPrefs(next);
return next;
});
};
const requestNotifPermission = async () => {
if (!("Notification" in window)) return;
const perm = await Notification.requestPermission();
if (perm === "granted") {
updateNotifPrefs({ enabled: true });
// A browser with no service worker / on a non-secure origin rejects here;
// notifications still work through the local fallback in useNotifications,
// so this must not blow up the enable flow.
await subscribeToPush().catch(() => {});
}
};
const runAction = async (key: string, fn: () => Promise<string>) => {
setActionLoading(key);
setActionResult(null);
setConfirmAction(null);
try {
const message = await fn();
setActionResult({ key, message, isError: false });
await load();
} catch (err) {
setActionResult({
key,
message: t("messages.actionFailed", {
message: err instanceof Error ? err.message : t("messages.unknownError"),
}),
isError: true,
});
} finally {
setActionLoading(null);
}
};
const handleClearData = () =>
runAction("clear", async () => {
const res = await api.settings.clearData();
const total = Object.values(res.cleared).reduce((s, n) => s + n, 0);
return t("danger.clearedResult", { count: total });
});
const handleGc = () =>
runAction("gc", async () => {
const res = await api.settings.gc({ dryRun: gcDryRun });
return t("lanesSection.gcResult", { reaped: res.reaped.length, capped: res.capped.length });
});
const handleCleanup = () =>
runAction("cleanup", async () => {
const params: { abandon_hours?: number; purge_days?: number } = {};
const ah = parseFloat(abandonHours);
const pd = parseFloat(purgeDays);
if (ah > 0) params.abandon_hours = ah;
if (pd > 0) params.purge_days = pd;
const res = await api.settings.cleanup(params);
const parts = [];
if (res.abandoned > 0) parts.push(`${res.abandoned}${t("data.abandonedResult")}`);
if (res.purged_sessions > 0)
parts.push(
`${res.purged_sessions}${t("data.purgedResult", { events: res.purged_events, agents: res.purged_agents })}`
);
return parts.length > 0 ? parts.join(". ") : t("data.nothingToClean");
});
const actionBanner = (keys: string[]) => {
const match = actionResult && keys.includes(actionResult.key) ? actionResult : null;
if (!match) return null;
return (
<div
className={`px-3 py-2 rounded-lg text-xs ${
match.isError
? "bg-status-danger/10 border border-status-danger/20 text-status-danger"
: "bg-status-success/10 border border-status-success/20 text-status-success"
}`}
>
{match.message}
</div>
);
};
if (loading) {
return (
<div className="animate-fade-in space-y-8" aria-busy="true">
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex items-center gap-3">
<Skeleton className="w-9 h-9" rounded="lg" />
<div className="space-y-2">
<Skeleton className="h-4 w-32" />
<Skeleton className="h-3 w-48" />
</div>
</div>
<Skeleton className="h-8 w-32" />
</div>
<div className="card p-6 flex items-center gap-4">
<Skeleton className="w-12 h-12" rounded="lg" />
<div className="space-y-2 flex-1">
<Skeleton className="h-3 w-40" />
<Skeleton className="h-7 w-32" />
</div>
</div>
{Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="card p-5 space-y-3">
<Skeleton className="h-4 w-40" />
<Skeleton className="h-3 w-full" />
<Skeleton className="h-3 w-5/6" />
</div>
))}
</div>
);
}
return (
<div className="animate-fade-in space-y-8">
{section === "general" && (
<div className="flex items-center justify-end gap-2">
<a
href={api.settings.exportData()}
download
className="inline-flex items-center gap-1.5 text-xs px-3 py-1.5 rounded-md border border-border text-fg-secondary hover:text-fg-primary hover:border-border-light transition-colors"
>
<FileDown className="w-3.5 h-3.5" />
{t("exportData")}
</a>
<button onClick={load} className="btn-ghost">
<RefreshCw className="w-4 h-4" /> {t("common:refresh")}
</button>
</div>
)}
{error && (
<div className="px-4 py-2.5 bg-status-danger/10 border border-status-danger/20 rounded-lg text-sm text-status-danger">
{error}
</div>
)}
{section === "general" && (
<>
{/* Cost summary card */}
<div className="card p-6">
<div className="flex items-center justify-between flex-wrap gap-4">
<div className="flex items-center gap-4">
<div className="w-12 h-12 rounded-xl bg-status-success/10 border border-status-success/20 flex items-center justify-center">
<DollarSign className="w-6 h-6 text-status-success" />
</div>
<div>
<p className="text-sm text-fg-muted">{t("common:cost.totalEstimatedCost")}</p>
<p className="text-2xl font-semibold text-fg-primary">
<Tip
raw={
totalCost !== null
? `$${totalCost.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`
: undefined
}
>
{totalCost !== null ? fmtCost(animatedTotalCost) : "$-.--"}
</Tip>
</p>
</div>
</div>
<div className="text-right text-xs text-fg-muted">
<p>{t("acrossSessions")}</p>
<p>{t("basedOnUsage")}</p>
</div>
</div>
</div>
{/* ─── LANES ─── */}
<section id="lanes" className="scroll-mt-24">
<h3 className="text-sm font-medium text-fg-secondary flex items-center gap-2 mb-1">
<GitBranch className="w-4 h-4 text-fg-muted" />
{t("lanesSection.title")}
</h3>
<p className="text-xs text-fg-muted mb-4">{t("lanesSection.description")}</p>
<div className="card p-5 space-y-4">
<div className="flex items-center justify-between flex-wrap gap-3">
<label className="flex items-center gap-2 text-xs text-fg-secondary">
<input
type="checkbox"
checked={gcDryRun}
onChange={(e) => setGcDryRun(e.target.checked)}
/>
{t("lanesSection.dryRun")}
</label>
<button
onClick={handleGc}
disabled={actionLoading !== null}
className="btn-ghost text-xs disabled:opacity-50"
>
{actionLoading === "gc" ? (
<RefreshCw className="w-3.5 h-3.5 animate-spin" />
) : (
<RotateCcw className="w-3.5 h-3.5" />
)}
{t("lanesSection.runHousekeeping")}
</button>
</div>
{actionBanner(["gc"])}
</div>
</section>
{/* ─── IMPORT HISTORY ─── */}
<section id="import" className="scroll-mt-24">
<ImportHistory />
</section>
{/* ─── REMOTE DATA SOURCES ─── */}
<section id="remote-sources" className="scroll-mt-24">
<RemoteSources />
</section>
{/* ─── SAGI COMPANION ─── */}
<section id="sagi" className="scroll-mt-24">
<h3 className="text-sm font-medium text-fg-secondary flex items-center gap-2 mb-1">
<span className="text-base leading-none" aria-hidden>
🦸
</span>
{t("sagi.title", "Sagi companion")}
</h3>
<p className="text-xs text-fg-muted mb-4">
{t("sagi.description", "A floating mascot that reacts to your live sessions.")}
</p>
<div className="card p-5">
<div className="flex items-center gap-3">
<div
className={`w-10 h-10 rounded-xl flex items-center justify-center transition-colors ${
sagiEnabled
? "bg-blue-600/10 border border-blue-600/20"
: "bg-surface-2 border border-border"
}`}
>
<span className="text-lg leading-none" aria-hidden>
🦸
</span>
</div>
<div className="min-w-0 flex-1">
<SettingsSwitchRow
checked={sagiEnabled}
onChange={setSagi}
label={t("sagi.enable", "Show Sagi")}
description={t(
"sagi.enableDesc",
"Display the corner companion across the dashboard (⌘B to open)"
)}
/>
</div>
</div>
</div>
</section>
</>
)}
{section === "notifications" && (
<>
{/* ─── NOTIFICATIONS ─── */}
<section id="notifications" className="scroll-mt-24">
<h3 className="text-sm font-medium text-fg-secondary flex items-center gap-2 mb-1">
<Bell className="w-4 h-4 text-fg-muted" />
{t("notifications.title")}
</h3>
<p className="text-xs text-fg-muted mb-4">{t("notifications.description")}</p>
<div className="card p-5 space-y-5">
<div className="flex items-center justify-between flex-wrap gap-3">
<div className="flex items-center gap-3">
<div
className={`w-10 h-10 rounded-xl flex items-center justify-center transition-colors ${
notifPrefs.enabled
? "bg-blue-600/10 border border-blue-600/20"
: "bg-surface-2 border border-border"
}`}
>
{notifPrefs.enabled ? (
<BellRing className="w-5 h-5 text-blue-500" />
) : (
<BellOff className="w-5 h-5 text-fg-muted" />
)}
</div>
<div className="min-w-0 flex-1">
<SettingsSwitchRow
checked={notifPrefs.enabled}
onChange={async (v) => {
if (v) {
if ("Notification" in window && Notification.permission !== "granted") {
requestNotifPermission();
} else {
updateNotifPrefs({ enabled: true });
await subscribeToPush();
}
} else {
updateNotifPrefs({ enabled: false });
await unsubscribeFromPush();
}
}}
label={t("notifications.enable")}
/>
</div>
</div>
{"Notification" in window && (
<span
className={`inline-flex items-center gap-1.5 text-xs font-medium px-2.5 py-1 rounded-full ${
Notification.permission === "granted"
? "text-status-success bg-status-success/10 border border-status-success/20"
: Notification.permission === "denied"
? "text-status-danger bg-status-danger/10 border border-status-danger/20"
: "text-status-warning bg-status-warning/10 border border-status-warning/20"
}`}
>
{Notification.permission === "granted" ? (
<ShieldCheck className="w-3 h-3" />
) : Notification.permission === "denied" ? (
<ShieldX className="w-3 h-3" />
) : (
<ShieldAlert className="w-3 h-3" />
)}
{Notification.permission === "granted"
? t("notifications.granted")
: Notification.permission === "denied"
? t("notifications.blocked")
: t("notifications.required")}
</span>
)}
</div>
{notifPrefs.enabled && (
<div className="space-y-3 pt-4 border-t border-border">
<SettingsRow
label={t("notifications.sound.title")}
description={t("notifications.sound.description")}
control={
<div className="flex items-center gap-2">
<select
aria-label={t("notifications.sound.title")}
value={notifPrefs.sound}
onChange={(e) =>
updateNotifPrefs({ sound: e.target.value as NotifSound })
}
className="rounded-md border border-border bg-surface-2 px-2 py-1 text-xs text-fg-primary"
>
{NOTIF_SOUNDS.map((k) => (
<option key={k} value={k}>
{t(`notifications.sound.${k}`)}
</option>
))}
</select>
<button
type="button"
onClick={() => playSound(notifPrefs.sound)}
disabled={notifPrefs.sound === "none"}
className="btn-ghost text-xs disabled:opacity-50"
>
<Play className="w-3 h-3" />
{t("notifications.sound.preview")}
</button>
</div>
}
/>
<p className="text-xs text-fg-muted uppercase tracking-wider font-semibold">
{t("notifications.notifyWhen")}
</p>
<div>
{NOTIF_TYPES.map(([type, labelKey]) => (
<div key={type} className="border-b border-border/60 last:border-b-0">
<SettingsSwitchRow
checked={notifPrefs[type]}
onChange={(v) => updateNotifPrefs({ [type]: v })}
label={t(`notifications.${labelKey}`)}
/>
{notifPrefs[type] && (
<div className="pb-2">
<SettingsSegmented
ariaLabel={`${t(`notifications.${labelKey}`)} — ${t("notifications.sound.forType")}`}
value={notifPrefs.soundByType?.[type] ?? "default"}
onChange={(v) => {
const next = { ...notifPrefs.soundByType };
if (v === "default") delete next[type];
else next[type] = v;
updateNotifPrefs({ soundByType: next });
if (v !== "default") playSound(v);
}}
options={[
{
value: "default" as const,
label: t("notifications.sound.useDefault"),
},
...NOTIF_SOUNDS.map((k) => ({
value: k,
label: t(`notifications.sound.${k}`),
})),
]}
/>
</div>
)}
</div>
))}
</div>
<div className="pt-3 border-t border-border">
<button
onClick={async () => {
// Same notify() the live events use, so the test proves the
// real path — and reports WHERE it landed, since a push that
// reaches nobody used to look exactly like success.
const r = await notify(
t("notifications.testTitle"),
t("notifications.testBody")
);
const reason = r.errors
.map((e) => [e.statusCode, e.message].filter(Boolean).join(" "))
.join("; ");
setActionResult({
key: "notif-test",
isError: r.via === "none" || r.via === "blocked",
message:
r.via === "push" || r.via === "native"
? t("notifications.testSent")
: r.via === "local"
? t("notifications.testLocal")
: r.via === "blocked"
? t("notifications.testBlocked")
: `${t("notifications.testFailed")}${reason ? ` (${reason})` : ""}`,
});
}}
className="inline-flex items-center gap-1.5 text-xs px-3 py-1.5 rounded-md text-fg-secondary hover:text-fg-primary hover:bg-surface-4 border border-border transition-colors"
>
<Zap className="w-3 h-3" />
{t("notifications.sendTest")}
</button>
<div className="mt-2">{actionBanner(["notif-test"])}</div>
</div>
</div>
)}
{!notifPrefs.enabled && (
<div className="flex items-center gap-2 text-xs text-fg-muted">
<BellOff className="w-3.5 h-3.5" />
{t("notifications.disabledInfo")}
</div>
)}
</div>
</section>
{/* ─── ALERTS ─── */}
<section id="alerts" className="scroll-mt-24">
<h3 className="text-sm font-medium text-fg-secondary flex items-center gap-2 mb-1">
<BellRing className="w-4 h-4 text-fg-muted" />
{t("alertsHub.title")}
</h3>
<p className="text-xs text-fg-muted mb-4">{t("alertsHub.description")}</p>
<AlertsNotifications />
</section>
</>
)}
{section === "general" && (
<>
{/* ─── DATA MANAGEMENT ─── */}
<section id="data" className="scroll-mt-24">
<h3 className="text-sm font-medium text-fg-secondary flex items-center gap-2 mb-1">
<Database className="w-4 h-4 text-fg-muted" />
{t("data.title")}
</h3>
<p className="text-xs text-fg-muted mb-4">{t("data.description")}</p>
<div className="space-y-4">
<div className="card p-5 space-y-4">
<div className="flex flex-wrap items-center justify-between gap-2">
<p className="text-xs text-fg-muted uppercase tracking-wider font-semibold flex-shrink-0">
{t("data.dbOverview")}
</p>
{sysInfo && (
<div className="flex items-center gap-1.5 text-[11px] text-fg-muted font-mono bg-surface-2 px-2.5 py-1 rounded-md min-w-0">
<HardDrive className="w-3 h-3 flex-shrink-0" />
<span className="truncate">{sysInfo.db.path}</span>
</div>
)}
</div>
{sysInfo ? (
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 gap-2">
{(() => {
const tableIcons: Record<string, React.ReactNode> = {
sessions: <Layers className="w-4 h-4 text-blue-500" />,
agents: <Users className="w-4 h-4 text-status-success" />,
events: (
<Activity className="w-4 h-4 text-violet-700 dark:text-violet-400" />
),
token_usage: <Coins className="w-4 h-4 text-status-warning" />,
model_pricing: (
<BarChart3 className="w-4 h-4 text-cyan-700 dark:text-cyan-400" />
),
};
const tableLabels: Record<string, string> = {
sessions: t("tables.sessions"),
agents: t("tables.agents"),
events: t("tables.events"),
token_usage: t("tables.sessionsWithCost"),
model_pricing: t("tables.pricingRules"),
};
const tableColors: Record<string, string> = {
sessions: "border-blue-600/20",
agents: "border-status-success/20",
events: "border-violet-500/20",
token_usage: "border-status-warning/20",
model_pricing: "border-cyan-500/20",
};
return Object.entries(sysInfo.db.counts).map(([table, count]) => (
<div
key={table}
className={`bg-surface-2 rounded-lg px-3 py-3 border-l-2 ${tableColors[table] || "border-border-light/20"}`}
>
<div className="flex items-center gap-2 mb-1.5">
{tableIcons[table] || <Database className="w-4 h-4 text-fg-muted" />}
<p className="text-[11px] text-fg-muted uppercase tracking-wider">
{tableLabels[table] || table.replace(/_/g, " ")}
</p>
</div>
<p className="text-xl font-semibold text-fg-secondary">
<Tip raw={count.toLocaleString()}>{fmt(count)}</Tip>
</p>
</div>
));
})()}
<div className="bg-surface-2 rounded-lg px-3 py-3 border-l-2 border-indigo-500/20">
<div className="flex items-center gap-2 mb-1.5">
<HardDrive className="w-4 h-4 text-indigo-700 dark:text-indigo-400" />
<p className="text-[11px] text-fg-muted uppercase tracking-wider">
{t("data.dbSize")}
</p>
</div>
<p className="text-xl font-semibold text-fg-secondary">
{formatBytes(sysInfo.db.size)}
</p>
</div>
</div>
) : (
<p className="text-xs text-fg-muted">{t("data.loadingDb")}</p>
)}
</div>
{/* Session Cleanup */}
<div className="card p-5 space-y-4">
<div className="flex items-center gap-3">
<div className="w-9 h-9 rounded-lg bg-status-warning/10 border border-status-warning/20 flex items-center justify-center">
<Eraser className="w-4 h-4 text-status-warning" />
</div>
<div>
<p className="text-sm font-medium text-fg-secondary">
{t("data.sessionCleanup")}
</p>
<p className="text-xs text-fg-muted">{t("data.cleanupDesc")}</p>
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div className="bg-surface-2 rounded-lg px-4 py-3">
<label className="text-xs text-fg-secondary block mb-2">
{t("data.abandonAfter")}
</label>
<div className="flex items-center gap-2">
<input
type="number"
min="1"
value={abandonHours}
onChange={(e) => setAbandonHours(e.target.value)}
className="input w-20 text-sm text-right font-mono"
/>
<span className="text-xs text-fg-muted">{t("common:hours")}</span>
</div>
</div>
<div className="bg-surface-2 rounded-lg px-4 py-3">
<label className="text-xs text-fg-secondary block mb-2">
{t("data.purgeAfter")}
</label>
<div className="flex items-center gap-2">
<input
type="number"
min="1"
value={purgeDays}
onChange={(e) => setPurgeDays(e.target.value)}
className="input w-20 text-sm text-right font-mono"
/>
<span className="text-xs text-fg-muted">{t("common:days")}</span>
</div>
</div>
</div>
<button
onClick={() =>
confirmAction === "cleanup" ? handleCleanup() : setConfirmAction("cleanup")
}
disabled={actionLoading !== null}
className={`text-xs px-3 py-1.5 rounded-md transition-colors disabled:opacity-50 ${
confirmAction === "cleanup"
? "bg-status-warning/20 text-status-warning border border-status-warning/30"
: "text-fg-secondary hover:text-fg-primary hover:bg-surface-4 border border-border"
}`}
>
{actionLoading === "cleanup" ? (
<RefreshCw className="w-3 h-3 animate-spin inline mr-1" />
) : (
<Eraser className="w-3 h-3 inline mr-1" />
)}
{confirmAction === "cleanup" ? t("data.confirmCleanup") : t("data.runCleanup")}
</button>
{actionBanner(["cleanup"])}
</div>
{/* Danger zone */}
<div className="card p-5 space-y-4 border-status-danger/10">
<div className="flex items-center gap-3">
<div className="w-9 h-9 rounded-lg bg-status-danger/10 border border-status-danger/20 flex items-center justify-center">
<AlertTriangle className="w-4 h-4 text-status-danger" />
</div>
<div>
<p className="text-sm font-medium text-status-danger">{t("danger.title")}</p>
<p className="text-xs text-fg-muted">{t("danger.description")}</p>
</div>
</div>
{confirmAction === "clear" ? (
<div className="bg-status-danger/5 border border-status-danger/20 rounded-lg px-4 py-3 flex items-center justify-between flex-wrap gap-3">
<span className="text-xs text-status-warning">{t("danger.warning")}</span>
<div className="flex items-center gap-2">
<button
onClick={handleClearData}
disabled={actionLoading !== null}
className="text-xs px-3 py-1.5 rounded-md bg-status-danger/20 text-status-danger border border-status-danger/30 hover:bg-status-danger/30 transition-colors disabled:opacity-50"
>
{actionLoading === "clear" ? (
<RefreshCw className="w-3.5 h-3.5 animate-spin inline mr-1" />
) : null}
{t("danger.yesClearAll")}
</button>
<button
onClick={() => setConfirmAction(null)}
className="text-xs px-3 py-1.5 rounded-md text-fg-secondary hover:bg-surface-4 transition-colors"
>
{t("common:cancel")}
</button>
</div>
</div>
) : (
<button
onClick={() => setConfirmAction("clear")}
disabled={actionLoading !== null}
className="text-xs px-3 py-1.5 rounded-md text-status-danger hover:bg-status-danger/10 border border-status-danger/20 transition-colors disabled:opacity-50"
>
<AlertTriangle className="w-3.5 h-3.5 inline mr-1" />
{t("danger.clearAllData")}
</button>
)}
{actionBanner(["clear"])}
</div>
</div>
</section>
{/* ─── ABOUT ─── */}
<section id="about" className="scroll-mt-24">
<h3 className="text-sm font-medium text-fg-secondary flex items-center gap-2 mb-1">
<Server className="w-4 h-4 text-fg-muted" />
{t("about.title")}
</h3>
<p className="text-xs text-fg-muted mb-4">{t("about.description")}</p>
{sysInfo ? (
<div className="card p-5">
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-3">
<div className="bg-surface-2 rounded-lg px-4 py-3">
<div className="flex items-center gap-2 mb-1.5">
<Server className="w-4 h-4 text-indigo-700 dark:text-indigo-400" />
<p className="text-[11px] text-fg-muted uppercase tracking-wider">
{t("about.release")}
</p>
</div>
<p className="text-sm font-semibold text-fg-secondary font-mono">
v{sysInfo.server.version}
</p>
{sysInfo.server.version !== __APP_VERSION__ && (
<p className="text-[10px] text-status-warning/90 mt-1">
{t("about.uiBuild", { version: __APP_VERSION__ })}
</p>
)}
</div>
<div className="bg-surface-2 rounded-lg px-4 py-3">
<div className="flex items-center gap-2 mb-1.5">
<Clock className="w-4 h-4 text-blue-500" />
<p className="text-[11px] text-fg-muted uppercase tracking-wider">
{t("about.uptime")}
</p>
</div>
<p className="text-sm font-semibold text-fg-secondary">
{formatUptime(sysInfo.server.uptime)}
</p>
</div>
<div className="bg-surface-2 rounded-lg px-4 py-3">
<div className="flex items-center gap-2 mb-1.5">
<Cpu className="w-4 h-4 text-status-success" />
<p className="text-[11px] text-fg-muted uppercase tracking-wider">
{t("about.nodejs")}
</p>
</div>
<p className="text-sm font-semibold text-fg-secondary font-mono">
{sysInfo.server.node_version}
</p>
</div>
<div className="bg-surface-2 rounded-lg px-4 py-3">
<div className="flex items-center gap-2 mb-1.5">
<Globe className="w-4 h-4 text-violet-700 dark:text-violet-400" />
<p className="text-[11px] text-fg-muted uppercase tracking-wider">
{t("about.platform")}
</p>
</div>
<p className="text-sm font-semibold text-fg-secondary">
{sysInfo.server.platform}
</p>
</div>
<div className="bg-surface-2 rounded-lg px-4 py-3">
<div className="flex items-center gap-2 mb-1.5">
<Wifi className="w-4 h-4 text-status-warning" />
<p className="text-[11px] text-fg-muted uppercase tracking-wider">
{t("about.wsClients")}
</p>
</div>
<p className="text-sm font-semibold text-fg-secondary">
{sysInfo.server.ws_connections}
</p>
</div>
</div>
</div>
) : (
<p className="text-xs text-fg-muted">{t("about.loadingInfo")}</p>
)}
</section>
</>
)}
</div>
);
}