Files
AgentHub/client/src/pages/Settings.tsx
T
nntrivi2001 e07993c536 perf: hourly update check only with a browser open; opt-in retention, incremental auto-vacuum and Compact
- update-scheduler: default interval 1 h (DASHBOARD_UPDATE_CHECK_INTERVAL_MS and its 60 s floor kept);
  a scheduled tick with no /ws client is skipped, and the next browser to connect gets one catch-up
  check when the last one is stale (websocket.js onConnection). Manual check unchanged.
- db-retention.js: shared session purge (used by POST /api/settings/cleanup too), opt-in
  AGENTHUB_RETENTION_DAYS applied by the hourly maintenance in 25-session transactions with yields
  (max 5000/run), bounded incremental_vacuum when the freelist is large, and a manual Compact
  (VACUUM in a worker, converting to auto_vacuum=INCREMENTAL).
- db.js: new databases are INCREMENTAL; an existing one up to 32 MB is converted at startup.
- GET/PUT /api/settings/retention, POST /api/settings/compact (confirm: true); Settings → Data →
  Session cleanup gets the Automatic cleanup control and Compact now (en + vi).
2026-10-16 10:20:38 +07:00

1362 lines
55 KiB
TypeScript

/**
* @file Settings.tsx
* @description The install-wide settings, as three pages of the Settings nav (`SettingsHub.tsx`): `section="data"` — total cost, database overview, workspace housekeeping (dry run first, confirm before a real run), import, remote sources, push ingest (`components/settings/PushIngestSettings.tsx`), privacy (`components/settings/PrivacySettings.tsx`), the daily database backup (`components/settings/BackupSettings.tsx`), export, this browser's preferences backup, session cleanup (whole days ≥ 1, a dry-run count in its confirm; under it the opt-in automatic cleanup and Compact, `components/settings/RetentionSettings.tsx`) and the danger zone (typed confirmation) —, `section="about"` (the System page) — the start-at-login switch and Quit (`AutostartSettings`), then the server process (release, uptime, platform, CPU, memory, live connections), how AgentHub is installed (the update check's cached status) and the database file — and `section="notifications"` — browser notifications (per-browser, with a reset) and alerts (server-wide). Server info (`/api/settings/info`) is read only by Data and About, refreshed on activity at most every 10 s. Language, theme and the pet live on the Appearance 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`
* - `../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 { Checkbox } from "../components/Checkbox";
import { useEffect, useRef, useState, useCallback, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import {
DollarSign,
RefreshCw,
Database,
HardDrive,
AlertTriangle,
RotateCcw,
Bell,
BellOff,
BellRing,
FileDown,
Eraser,
Zap,
Play,
GitBranch,
ShieldCheck,
ShieldAlert,
ShieldX,
Activity,
Users,
Layers,
Coins,
BarChart3,
Upload,
} from "lucide-react";
import { api } from "../lib/api";
import { eventBus } from "../lib/eventBus";
import { isRemoteDataRefreshMessage } from "../lib/remoteDataEvents";
import { fmt, fmtCost, formatBytes } from "../lib/format";
import { subscribeToPush, unsubscribeFromPush } from "../lib/push";
import {
defaultNotifPrefs,
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 { PushIngestSettings } from "../components/settings/PushIngestSettings";
import { PrivacySettings } from "../components/settings/PrivacySettings";
import { BackupSettings } from "../components/settings/BackupSettings";
import { RetentionSettings } from "../components/settings/RetentionSettings";
import { AutostartSettings } from "../components/settings/AutostartSettings";
import { usePermissions } from "../components/AuthGate";
import { Skeleton } from "../components/Skeleton";
import { AlertsNotifications } from "../components/AlertsNotifications";
import {
SettingsRow,
SettingsSection,
SettingsSegmented,
SettingsSwitchRow,
} from "../components/settings/SettingsRow";
import type { UpdateStatusPayload, WSMessage } from "../lib/types";
import { confirmDialog } from "../lib/confirm";
import { toast } from "../lib/toast";
import { useDialog } from "../hooks/useDialog";
import { useSpin } from "../hooks/useSpin";
import { exportPrefs, importPrefs } from "../lib/prefsBackup";
/** The fewest milliseconds between two activity-driven `/settings/info` reads:
* it COUNTs whole tables, and hooks fire many times a second while sessions run. */
export const INFO_MIN_MS = 10_000;
/** A whole number ≥ 1 typed into a field, else null. */
function wholeAtLeastOne(raw: string): number | null {
const n = Number(raw);
return raw.trim() !== "" && Number.isInteger(n) && n >= 1 ? n : null;
}
// 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 ───
type SystemInfo = Awaited<ReturnType<typeof api.settings.info>>;
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 = "notifications",
}: {
/** Which Settings page this renders (`/settings/<section>`). */
section?: "data" | "about" | "notifications";
} = {}) {
const { t } = useTranslation("settings");
const { isAdmin, seesAll } = usePermissions();
// Only Data and About show server info; General and Notifications never read it.
const needsInfo = section === "data" || section === "about";
const [loading, setLoading] = useState(needsInfo);
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 [notifPrefs, setNotifPrefs] = useState<NotifPrefs>(loadNotifPrefs);
const [abandonHours, setAbandonHours] = useState("24");
const [purgeDays, setPurgeDays] = useState("90");
const [gcDryRun, setGcDryRun] = useState(true);
const [clearOpen, setClearOpen] = useState(false);
const animatedTotalCost = useCountUp(totalCost);
const load = useCallback(async () => {
if (!needsInfo) return;
try {
const [costRes, infoRes] = await Promise.all([
section === "data" ? api.pricing.totalCost() : Promise.resolve(null),
api.settings.info(),
]);
if (costRes) setTotalCost(costRes.total_cost);
setSysInfo(infoRes);
setError(null);
} catch (err) {
setError(err instanceof Error ? err.message : t("messages.failedLoad"));
} finally {
setLoading(false);
}
}, [t, needsInfo, section]);
const [spinning, refresh] = useSpin(load);
useEffect(() => {
load();
}, [load]);
// Activity refreshes the counts, throttled to one read per INFO_MIN_MS (a
// burst of hook events coalesces into one trailing read); a slow beat keeps
// the uptime moving while nothing happens.
const lastInfoAt = useRef(0);
useEffect(() => {
if (!needsInfo) return;
lastInfoAt.current = Date.now();
let timer: number | undefined;
const soon = () => {
// Hidden tab: no reads; onVis catches up once when it is shown.
if (timer !== undefined || document.visibilityState !== "visible") return;
const wait = Math.max(0, INFO_MIN_MS - (Date.now() - lastInfoAt.current));
timer = window.setTimeout(() => {
timer = undefined;
lastInfoAt.current = Date.now();
api.settings
.info()
.then(setSysInfo)
.catch(() => {});
}, wait);
};
const beat = window.setInterval(soon, 30_000);
const onVis = () => soon();
document.addEventListener("visibilitychange", onVis);
const off = 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"
)
soon();
if (isRemoteDataRefreshMessage(msg)) load();
});
return () => {
off();
document.removeEventListener("visibilitychange", onVis);
window.clearInterval(beat);
window.clearTimeout(timer);
};
}, [needsInfo, 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;
});
};
// Bumped after a permission prompt so the badge re-reads Notification.permission.
const [, setPermTick] = useState(0);
const requestNotifPermission = async () => {
if (!("Notification" in window)) return;
const perm = await Notification.requestPermission();
setPermTick((n) => n + 1);
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(() => {});
} else if (perm === "denied") {
toast.error(t("notifications.deniedHelp"));
}
};
const runAction = async (key: string, fn: () => Promise<string>) => {
setActionLoading(key);
setActionResult(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 () => {
setClearOpen(false);
const res = await api.settings.clearData();
const total = Object.values(res.cleared).reduce((s, n) => s + n, 0);
return t("danger.clearedResult", { count: total });
});
// What a housekeeping run touches, as one readable line per item.
const gcLines = (res: { reaped: number[]; capped: { path: string; sizeBefore: number }[] }) => [
...res.reaped.map((pid) => t("lanesSection.gcReapLine", { pid })),
...res.capped.map((c) =>
t("lanesSection.gcCapLine", { path: c.path, size: formatBytes(c.sizeBefore) })
),
];
// A real run always previews first and asks, listing what it would do.
const handleGc = async () => {
if (!gcDryRun) {
let preview;
try {
preview = await api.settings.gc({ dryRun: true });
} catch (err) {
toast.error(err);
return;
}
const lines = gcLines(preview);
if (lines.length === 0) {
setActionResult({ key: "gc", isError: false, message: t("lanesSection.gcNothing") });
return;
}
const ok = await confirmDialog({
title: t("lanesSection.gcConfirmTitle"),
// ConfirmModal renders one paragraph, so the list is inline.
message: `${t("lanesSection.gcConfirmBody")} ${[
...lines.slice(0, 8),
...(lines.length > 8 ? [t("lanesSection.gcMore", { count: lines.length - 8 })] : []),
].join(" • ")}`,
confirmLabel: t("lanesSection.runHousekeeping"),
});
if (!ok) return;
}
await runAction("gc", async () => {
const res = await api.settings.gc({ dryRun: gcDryRun });
const lines = gcLines(res);
const head = gcDryRun
? t("lanesSection.gcPreviewResult", {
reaped: res.reaped.length,
capped: res.capped.length,
})
: t("lanesSection.gcResult", { reaped: res.reaped.length, capped: res.capped.length });
return lines.length ? `${head}\n${lines.join("\n")}` : t("lanesSection.gcNothing");
});
};
// Each field may be left blank to skip that half; anything typed must be a
// whole number ≥ 1 (the server refuses a fractional purge too).
const abandonN = wholeAtLeastOne(abandonHours);
const purgeN = wholeAtLeastOne(purgeDays);
const abandonBad = abandonHours.trim() !== "" && abandonN === null;
const purgeBad = purgeDays.trim() !== "" && purgeN === null;
const cleanupReady = !abandonBad && !purgeBad && (abandonN !== null || purgeN !== null);
// Count first, then ask with the numbers: a purge cannot be undone.
const handleCleanup = async () => {
if (!cleanupReady) return;
const params: { abandon_hours?: number; purge_days?: number } = {};
if (abandonN !== null) params.abandon_hours = abandonN;
if (purgeN !== null) params.purge_days = purgeN;
let preview;
try {
preview = await api.settings.cleanup({ ...params, dry_run: true });
} catch (err) {
toast.error(err);
return;
}
if (preview.abandoned === 0 && preview.purged_sessions === 0) {
setActionResult({ key: "cleanup", isError: false, message: t("data.nothingToClean") });
return;
}
const ok = await confirmDialog({
title: t("data.cleanupConfirmTitle"),
message: t("data.cleanupConfirmBody", {
abandoned: preview.abandoned,
sessions: preview.purged_sessions,
events: preview.purged_events,
agents: preview.purged_agents,
}),
confirmLabel: t("data.runCleanup"),
});
if (!ok) return;
await runAction("cleanup", async () => {
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 resetNotifPrefs = async () => {
const ok = await confirmDialog({
title: t("notifications.resetTitle"),
message: t("notifications.resetBody"),
confirmLabel: t("notifications.reset"),
});
if (!ok) return;
const next = { ...defaultNotifPrefs, enabled: notifPrefs.enabled };
saveNotifPrefs(next);
setNotifPrefs(next);
toast.success(t("notifications.resetDone"));
};
const downloadPrefs = () => {
const blob = new Blob([JSON.stringify(exportPrefs(), null, 2)], { type: "application/json" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `agenthub-browser-prefs-${new Date().toISOString().slice(0, 10)}.json`;
a.click();
URL.revokeObjectURL(url);
};
const uploadPrefs = async (file: File) => {
const ok = await confirmDialog({
title: t("prefsBackup.importTitle"),
message: t("prefsBackup.importBody"),
confirmLabel: t("prefsBackup.import"),
destructive: false,
});
if (!ok) return;
try {
const n = importPrefs(await file.text());
toast.success(t("prefsBackup.imported", { count: n }));
window.setTimeout(() => window.location.reload(), 600);
} catch {
toast.error(t("prefsBackup.badFile"));
}
};
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"
}`}
>
<span className="whitespace-pre-line">{match.message}</span>
</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>
);
}
const notifSupported = "Notification" in window;
const permission = notifSupported ? Notification.permission : "denied";
return (
<div className="animate-fade-in space-y-8">
{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 === "notifications" && (
<>
<p className="text-xs text-fg-muted">{t("notifications.scopeNote")}</p>
{/* ─── NOTIFICATIONS ─── */}
<section id="notifications" className="scroll-mt-24">
<SettingsSection
title={
<span className="flex items-center gap-2">
<Bell className="w-4 h-4 text-fg-muted" />
{t("notifications.title")}
</span>
}
description={t("notifications.description")}
>
<div className="space-y-5 pt-2">
{!window.isSecureContext && (
<p
role="note"
className="rounded-lg border border-status-warning/30 bg-status-warning/10 px-3 py-2 text-xs text-status-warning"
>
{t("notifications.insecureOrigin", { origin: window.location.origin })}
</p>
)}
{!notifSupported && (
<p role="note" className="text-xs text-fg-muted">
{t("notifications.unsupported")}
</p>
)}
{notifSupported && permission === "denied" && (
<p
role="note"
className="rounded-lg border border-status-danger/30 bg-status-danger/10 px-3 py-2 text-xs text-status-danger"
>
{t("notifications.deniedHelp")}
</p>
)}
<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-accent/10 border border-accent/20"
: "bg-surface-2 border border-border"
}`}
>
{notifPrefs.enabled ? (
<BellRing className="w-5 h-5 text-accent" />
) : (
<BellOff className="w-5 h-5 text-fg-muted" />
)}
</div>
<div className="min-w-0 flex-1">
<SettingsSwitchRow
checked={notifPrefs.enabled}
onChange={async (v) => {
try {
if (v) {
if (notifSupported && Notification.permission !== "granted") {
await requestNotifPermission();
} else {
updateNotifPrefs({ enabled: true });
await subscribeToPush();
}
} else {
updateNotifPrefs({ enabled: false });
await unsubscribeFromPush();
}
} catch (err) {
// The switch already holds the user's choice; only the
// push subscription failed, and the local fallback still shows.
toast.error(
t("notifications.pushFailed", {
message: err instanceof Error ? err.message : String(err),
})
);
}
}}
label={t("notifications.enable")}
/>
</div>
</div>
{notifSupported && (
<span
className={`inline-flex items-center gap-1.5 text-xs font-medium px-2.5 py-1 rounded-full ${
permission === "granted"
? "text-status-success bg-status-success/10 border border-status-success/20"
: 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"
}`}
>
{permission === "granted" ? (
<ShieldCheck className="w-3 h-3" />
) : permission === "denied" ? (
<ShieldX className="w-3 h-3" />
) : (
<ShieldAlert className="w-3 h-3" />
)}
{permission === "granted"
? t("notifications.granted")
: 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="select py-1 text-xs"
>
{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 className="flex justify-end">
<button type="button" onClick={resetNotifPrefs} className="btn-ghost text-xs">
<RotateCcw className="w-3.5 h-3.5" />
{t("notifications.reset")}
</button>
</div>
</div>
</SettingsSection>
</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 === "data" && (
<>
{/* ─── DATA MANAGEMENT ─── */}
<section id="data" className="scroll-mt-24">
<div className="mb-4 flex flex-wrap items-start justify-between gap-2">
<div>
<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">{t("data.description")}</p>
</div>
<div className="flex items-center 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={refresh}
disabled={spinning}
aria-busy={spinning}
className="btn-ghost"
>
<RefreshCw className={`w-4 h-4 ${spinning ? "animate-spin" : ""}`} />{" "}
{t("common:refresh")}
</button>
</div>
</div>
<div className="space-y-4">
{/* 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>
<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-accent" />,
agents: <Users className="w-4 h-4 text-status-success" />,
events: <Activity className="w-4 h-4 text-syntax-keyword" />,
token_usage: <Coins className="w-4 h-4 text-status-warning" />,
model_pricing: <BarChart3 className="w-4 h-4 text-syntax-function" />,
};
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-accent/20",
agents: "border-status-success/20",
events: "border-border-light",
token_usage: "border-status-warning/20",
model_pricing: "border-border-light",
};
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-border-light">
<div className="flex items-center gap-2 mb-1.5">
<HardDrive className="w-4 h-4 text-syntax-property" />
<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>
</div>
</section>
{/* ─── WORKSPACE HOUSEKEEPING ─── */}
<section id="lanes" className="scroll-mt-24">
<SettingsSection
title={
<span className="flex items-center gap-2">
<GitBranch className="w-4 h-4 text-fg-muted" />
{t("lanesSection.title")}
</span>
}
description={t("lanesSection.explain")}
>
<div className="space-y-4 pt-2">
<div className="flex items-center justify-between flex-wrap gap-3">
<Checkbox
checked={gcDryRun}
onChange={setGcDryRun}
label={t("lanesSection.dryRun")}
/>
<button
type="button"
onClick={() => void 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" />
)}
{gcDryRun ? t("lanesSection.preview") : t("lanesSection.runHousekeeping")}
</button>
</div>
{actionBanner(["gc"])}
</div>
</SettingsSection>
</section>
{/* ─── IMPORT HISTORY ─── */}
<section id="import" className="scroll-mt-24">
<ImportHistory />
</section>
{/* ─── REMOTE DATA SOURCES (staff only: the server answers 403 to a developer) ─── */}
{seesAll && (
<section id="remote-sources" className="scroll-mt-24">
<RemoteSources />
</section>
)}
{/* ─── PUSH INGEST + PRIVACY (each its own anchored card) ─── */}
<PushIngestSettings />
<PrivacySettings />
<BackupSettings />
{/* ─── SESSION CLEANUP ─── */}
<section id="cleanup" className="card p-5 space-y-4 scroll-mt-24">
<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>
<h3 className="text-sm font-medium text-fg-secondary">
{t("data.sessionCleanup")}
</h3>
<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
htmlFor="cleanup-abandon-hours"
className="text-xs text-fg-secondary block mb-2"
>
{t("data.abandonAfter")}
</label>
<div className="flex items-center gap-2">
<input
id="cleanup-abandon-hours"
type="number"
min="1"
step="1"
inputMode="numeric"
value={abandonHours}
onChange={(e) => setAbandonHours(e.target.value)}
aria-invalid={abandonBad || undefined}
aria-describedby={abandonBad ? "cleanup-abandon-error" : undefined}
className="input w-20 text-sm text-right font-mono"
/>
<span className="text-xs text-fg-muted">{t("common:hours")}</span>
</div>
{abandonBad && (
<p id="cleanup-abandon-error" className="mt-1 text-[11px] text-status-danger">
{t("data.wholeNumber")}
</p>
)}
</div>
<div className="bg-surface-2 rounded-lg px-4 py-3">
<label
htmlFor="cleanup-purge-days"
className="text-xs text-fg-secondary block mb-2"
>
{t("data.purgeAfter")}
</label>
<div className="flex items-center gap-2">
<input
id="cleanup-purge-days"
type="number"
min="1"
step="1"
inputMode="numeric"
value={purgeDays}
onChange={(e) => setPurgeDays(e.target.value)}
aria-invalid={purgeBad || undefined}
aria-describedby={purgeBad ? "cleanup-purge-error" : undefined}
className="input w-20 text-sm text-right font-mono"
/>
<span className="text-xs text-fg-muted">{t("common:days")}</span>
</div>
{purgeBad && (
<p id="cleanup-purge-error" className="mt-1 text-[11px] text-status-danger">
{t("data.wholeNumber")}
</p>
)}
</div>
</div>
<button
type="button"
onClick={() => void handleCleanup()}
disabled={actionLoading !== null || !cleanupReady}
className="text-xs px-3 py-1.5 rounded-md transition-colors disabled:opacity-50 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" />
)}
{t("data.runCleanup")}
</button>
{actionBanner(["cleanup"])}
<RetentionSettings />
</section>
{/* ─── THIS BROWSER'S PREFERENCES ─── */}
<section id="prefs" className="scroll-mt-24">
<SettingsSection
title={t("prefsBackup.title")}
description={t("prefsBackup.description")}
>
<div className="flex flex-wrap items-center gap-2 pt-2">
<button type="button" onClick={downloadPrefs} className="btn-ghost text-xs">
<FileDown className="w-3.5 h-3.5" />
{t("prefsBackup.export")}
</button>
<label className="btn-ghost text-xs cursor-pointer focus-within:ring-2 focus-within:ring-accent/40">
<Upload className="w-3.5 h-3.5" />
{t("prefsBackup.import")}
<input
type="file"
accept="application/json,.json"
className="sr-only"
onChange={(e) => {
const f = e.target.files?.[0];
e.target.value = "";
if (f) void uploadPrefs(f);
}}
/>
</label>
</div>
</SettingsSection>
</section>
{/* ─── DANGER ZONE ─── */}
<section id="danger" className="card p-5 space-y-4 border-status-danger/30 scroll-mt-24">
<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>
<h3 className="text-sm font-medium text-status-danger">{t("danger.title")}</h3>
<p className="text-xs text-fg-muted">{t("danger.description")}</p>
</div>
</div>
<button
type="button"
onClick={() => setClearOpen(true)}
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/30 transition-colors disabled:opacity-50"
>
<AlertTriangle className="w-3.5 h-3.5 inline mr-1" />
{t("danger.clearAllData")}
</button>
{actionBanner(["clear"])}
</section>
{clearOpen && (
<ClearDataDialog
busy={actionLoading === "clear"}
onCancel={() => setClearOpen(false)}
onConfirm={() => void handleClearData()}
/>
)}
</>
)}
{section === "about" && (
<div id="about" className="space-y-6 scroll-mt-24">
{/* Start at login, the icon, Quit and Uninstall act on the host: an admin's. */}
{isAdmin && <AutostartSettings />}
{sysInfo ? (
<SystemDetails info={sysInfo} />
) : (
<p className="text-xs text-fg-muted">{t("about.loadingInfo")}</p>
)}
</div>
)}
</div>
);
}
/** A read-only value on the right of a System row. */
function Value({ children, mono = false }: { children: ReactNode; mono?: boolean }) {
return (
<span className={`text-sm text-fg-primary ${mono ? "font-mono text-xs" : ""}`}>{children}</span>
);
}
/**
* The System page's facts: the server process, how AgentHub is installed (from
* the update check's last answer — it never fetches), and the database file.
*/
function SystemDetails({ info }: { info: SystemInfo }) {
const { t } = useTranslation("settings");
const [upd, setUpd] = useState<UpdateStatusPayload | null>(null);
useEffect(() => {
api.updates
.status()
.then(setUpd)
.catch(() => {});
}, []);
const srv = info.server;
const mem = srv.memory;
const heapLimit = mem ? (mem.heapLimit ?? mem.heapTotal) : 0;
const load = srv.cpu_load?.map((n) => n.toFixed(2)).join(" / ");
const kind = upd?.install_kind;
return (
<>
<SettingsSection id="server" title={t("about.server")}>
<SettingsRow
label={t("about.release")}
description={
srv.version !== __APP_VERSION__ ? (
<span className="text-status-warning/90">
{t("about.uiBuild", { version: __APP_VERSION__ })}
</span>
) : undefined
}
control={<Value mono>v{srv.version}</Value>}
/>
<SettingsRow
label={t("about.uptime")}
control={<Value>{formatUptime(srv.uptime)}</Value>}
/>
<SettingsRow
label={t("about.platform")}
control={
<Value mono>
{srv.platform}
{srv.arch ? ` · ${srv.arch}` : ""} · Node.js {srv.node_version}
</Value>
}
/>
{srv.cpus > 0 && (
<SettingsRow
label={t("about.cpu")}
control={<Value>{t("about.cpuValue", { count: srv.cpus, load })}</Value>}
/>
)}
{mem && (
<SettingsRow
label={t("about.memory")}
control={
<Value>
{t("about.memoryValue", {
rss: formatBytes(mem.rss),
used: formatBytes(mem.heapUsed),
limit: formatBytes(heapLimit),
})}
</Value>
}
/>
)}
{srv.total_mem > 0 && (
<SettingsRow
label={t("about.systemMemory")}
control={
<Value>
{t("about.systemMemoryValue", {
used: formatBytes(srv.total_mem - srv.free_mem),
total: formatBytes(srv.total_mem),
})}
</Value>
}
/>
)}
<SettingsRow
label={t("about.wsClients")}
description={t("about.wsClientsDesc")}
control={<Value>{srv.ws_connections}</Value>}
/>
</SettingsSection>
{upd && (
<SettingsSection id="install" title={t("about.install")}>
<SettingsRow
label={kind ? t(`about.installKind.${kind}`) : t("about.unknown")}
description={
upd.repo_root ? (
<span className="font-mono break-all">{upd.repo_root}</span>
) : undefined
}
control={upd.local_version ? <Value mono>v{upd.local_version}</Value> : null}
/>
{upd.latest_release && (
<SettingsRow
label={t("about.latest")}
control={
<span className="flex items-center gap-2">
<Value mono>{upd.latest_release}</Value>
<span
className={`rounded-full px-2 py-0.5 text-[11px] font-medium ${
upd.update_available
? "bg-status-warning/10 text-status-warning"
: "bg-status-success/10 text-status-success"
}`}
>
{upd.update_available ? t("about.updateAvailable") : t("about.upToDate")}
</span>
</span>
}
/>
)}
</SettingsSection>
)}
<SettingsSection id="database" title={t("about.database")}>
<SettingsRow
label={t("about.dbFile")}
description={<span className="font-mono break-all">{info.db.path}</span>}
control={<Value>{formatBytes(info.db.size)}</Value>}
/>
{info.db.pragmas && (
<SettingsRow
label={t("about.journal")}
control={<Value mono>{info.db.pragmas.journal_mode}</Value>}
/>
)}
{info.db.load_stats && (
<SettingsRow
label={t("about.activity")}
control={<Value>{t("about.activityValue", info.db.load_stats)}</Value>}
/>
)}
</SettingsSection>
</>
);
}
/** "Clear all data": the one action here that empties the whole database, so
* it asks for a typed phrase (not a second click) and offers an export first. */
function ClearDataDialog({
busy,
onCancel,
onConfirm,
}: {
busy: boolean;
onCancel: () => void;
onConfirm: () => void;
}) {
const { t } = useTranslation("settings");
const [typed, setTyped] = useState("");
const panelRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
useDialog(true, panelRef, busy ? undefined : onCancel, inputRef);
const phrase = t("danger.phrase");
const ok = typed.trim().toLowerCase() === phrase.toLowerCase();
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-surface-0/70 p-4"
onClick={busy ? undefined : onCancel}
>
<div
ref={panelRef}
role="dialog"
aria-modal="true"
aria-labelledby="clear-data-title"
aria-describedby="clear-data-body"
className="card max-h-full w-full max-w-md space-y-4 overflow-y-auto border-status-danger/40 p-5"
onClick={(e) => e.stopPropagation()}
>
<h2
id="clear-data-title"
className="flex items-center gap-2 text-sm font-semibold text-status-danger"
>
<AlertTriangle className="h-4 w-4" />
{t("danger.dialogTitle")}
</h2>
<p id="clear-data-body" className="text-xs leading-relaxed text-fg-secondary">
{t("danger.warning")}{" "}
<a href={api.settings.exportData()} download className="text-accent underline">
{t("danger.exportFirst")}
</a>
</p>
<label className="block text-xs text-fg-secondary">
{t("danger.typeToConfirm", { phrase })}
<input
ref={inputRef}
value={typed}
onChange={(e) => setTyped(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && ok && !busy) onConfirm();
}}
autoComplete="off"
className="input mt-1 w-full font-mono text-sm"
/>
</label>
<div className="flex justify-end gap-2">
<button type="button" onClick={onCancel} disabled={busy} className="btn-ghost text-xs">
{t("common:cancel")}
</button>
<button
type="button"
onClick={onConfirm}
disabled={!ok || busy}
className="rounded-md border border-status-danger/40 bg-status-danger/15 px-3 py-1.5 text-xs font-medium text-status-danger transition-colors hover:bg-status-danger/25 disabled:opacity-50"
>
{busy && <RefreshCw className="mr-1 inline h-3.5 w-3.5 animate-spin" />}
{t("danger.yesClearAll")}
</button>
</div>
</div>
</div>
);
}