b71024f244
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.
1022 lines
43 KiB
TypeScript
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>
|
|
);
|
|
}
|