/** * @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`), export, this browser's preferences backup, session cleanup (whole days ≥ 1, a dry-run count in its confirm) and the danger zone (typed confirmation) —, `section="about"` — the start-at-login switch (`AutostartSettings`) and system information — 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/`. * @author Nguyễn Ngọc Trí Vĩ */ /* ============================================================================= * 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 } 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, Upload, } from "lucide-react"; import { api } from "../lib/api"; import { eventBus } from "../lib/eventBus"; import { isRemoteDataRefreshMessage } from "../lib/remoteDataEvents"; import { fmt, fmtCost } 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 { AutostartSettings } from "../components/settings/AutostartSettings"; import { Skeleton } from "../components/Skeleton"; import { AlertsNotifications } from "../components/AlertsNotifications"; import { SettingsRow, SettingsSection, SettingsSegmented, SettingsSwitchRow, } from "../components/settings/SettingsRow"; import type { WSMessage } from "../lib/types"; import { confirmDialog } from "../lib/confirm"; import { toast } from "../lib/toast"; import { useDialog } from "../hooks/useDialog"; 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 ─── interface SystemInfo { db: { path: string; size: number; counts: Record }; hooks: { installed: boolean; path: string; hooks: Record }; 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 = "notifications", }: { /** Which Settings page this renders (`/settings/
`). */ section?: "data" | "about" | "notifications"; } = {}) { const { t } = useTranslation("settings"); // 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(null); const [totalCost, setTotalCost] = useState(null); const [sysInfo, setSysInfo] = useState(null); const [actionLoading, setActionLoading] = useState(null); const [actionResult, setActionResult] = useState<{ key: string; message: string; isError: boolean; } | null>(null); const [notifPrefs, setNotifPrefs] = useState(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]); 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 = () => { if (timer !== undefined) 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 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(); 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) => { 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) => { 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 (
{match.message}
); }; if (loading) { return (
{Array.from({ length: 3 }).map((_, i) => (
))}
); } const notifSupported = "Notification" in window; const permission = notifSupported ? Notification.permission : "denied"; return (
{error && (
{error}
)} {section === "notifications" && ( <>

{t("notifications.scopeNote")}

{/* ─── NOTIFICATIONS ─── */}
{t("notifications.title")} } description={t("notifications.description")} >
{!window.isSecureContext && (

{t("notifications.insecureOrigin", { origin: window.location.origin })}

)} {!notifSupported && (

{t("notifications.unsupported")}

)} {notifSupported && permission === "denied" && (

{t("notifications.deniedHelp")}

)}
{notifPrefs.enabled ? ( ) : ( )}
{ 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")} />
{notifSupported && ( {permission === "granted" ? ( ) : permission === "denied" ? ( ) : ( )} {permission === "granted" ? t("notifications.granted") : permission === "denied" ? t("notifications.blocked") : t("notifications.required")} )}
{notifPrefs.enabled && (
} />

{t("notifications.notifyWhen")}

{NOTIF_TYPES.map(([type, labelKey]) => (
updateNotifPrefs({ [type]: v })} label={t(`notifications.${labelKey}`)} /> {notifPrefs[type] && (
{ 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}`), })), ]} />
)}
))}
{actionBanner(["notif-test"])}
)} {!notifPrefs.enabled && (
{t("notifications.disabledInfo")}
)}
{/* ─── ALERTS ─── */}

{t("alertsHub.title")}

{t("alertsHub.description")}

)} {section === "data" && ( <> {/* ─── DATA MANAGEMENT ─── */}

{t("data.title")}

{t("data.description")}

{t("exportData")}
{/* Cost summary card */}

{t("common:cost.totalEstimatedCost")}

{totalCost !== null ? fmtCost(animatedTotalCost) : "$-.--"}

{t("acrossSessions")}

{t("basedOnUsage")}

{t("data.dbOverview")}

{sysInfo && (
{sysInfo.db.path}
)}
{sysInfo ? (
{(() => { const tableIcons: Record = { sessions: , agents: , events: , token_usage: , model_pricing: , }; const tableLabels: Record = { 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 = { 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]) => (
{tableIcons[table] || }

{tableLabels[table] || table.replace(/_/g, " ")}

{fmt(count)}

)); })()}

{t("data.dbSize")}

{formatBytes(sysInfo.db.size)}

) : (

{t("data.loadingDb")}

)}
{/* ─── WORKSPACE HOUSEKEEPING ─── */}
{t("lanesSection.title")} } description={t("lanesSection.explain")} >
{actionBanner(["gc"])}
{/* ─── IMPORT HISTORY ─── */}
{/* ─── REMOTE DATA SOURCES ─── */}
{/* ─── PUSH INGEST + PRIVACY (each its own anchored card) ─── */} {/* ─── SESSION CLEANUP ─── */}

{t("data.sessionCleanup")}

{t("data.cleanupDesc")}

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" /> {t("common:hours")}
{abandonBad && (

{t("data.wholeNumber")}

)}
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" /> {t("common:days")}
{purgeBad && (

{t("data.wholeNumber")}

)}
{actionBanner(["cleanup"])}
{/* ─── THIS BROWSER'S PREFERENCES ─── */}
{/* ─── DANGER ZONE ─── */}

{t("danger.title")}

{t("danger.description")}

{actionBanner(["clear"])}
{clearOpen && ( setClearOpen(false)} onConfirm={() => void handleClearData()} /> )} )} {section === "about" && (

{t("about.description")}

{sysInfo ? (

{t("about.release")}

v{sysInfo.server.version}

{sysInfo.server.version !== __APP_VERSION__ && (

{t("about.uiBuild", { version: __APP_VERSION__ })}

)}

{t("about.uptime")}

{formatUptime(sysInfo.server.uptime)}

{t("about.nodejs")}

{sysInfo.server.node_version}

{t("about.platform")}

{sysInfo.server.platform}

{t("about.wsClients")}

{sysInfo.server.ws_connections}

) : (

{t("about.loadingInfo")}

)}
)} ); } /** "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(null); const inputRef = useRef(null); useDialog(true, panelRef, busy ? undefined : onCancel, inputRef); const phrase = t("danger.phrase"); const ok = typed.trim().toLowerCase() === phrase.toLowerCase(); return (
e.stopPropagation()} >

{t("danger.dialogTitle")}

{t("danger.warning")}{" "} {t("danger.exportFirst")}

); }