af014d9829
A workspace could not be created on Windows: "cwd must be an absolute path" for D:\…, because createLane/canonicalCwd tested for a leading "/". - lanes.js: path.isAbsolute/path.resolve; canonicalCwd stops at any root (a missing drive looped forever); resolveLaneByCwd and /ensure accept "\" too - native-dialog.js: the Windows picker gets an invisible TopMost owner form, shown first — an ownerless dialog from the background server opened behind the browser, or hidden - windows-hide.js: every child process on Windows starts with windowsHide, required first by the server and the detached scripts (a console window flashed per git/npm call) - Confirm/update/clear-data modals and the quota panel scroll instead of overflowing a short window
1279 lines
53 KiB
TypeScript
1279 lines
53 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`), 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/<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 } 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<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 = "notifications",
|
|
}: {
|
|
/** Which Settings page this renders (`/settings/<section>`). */
|
|
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<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]);
|
|
|
|
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<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={load} className="btn-ghost">
|
|
<RefreshCw className="w-4 h-4" /> {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 ─── */}
|
|
<section id="remote-sources" className="scroll-mt-24">
|
|
<RemoteSources />
|
|
</section>
|
|
|
|
{/* ─── PUSH INGEST + PRIVACY (each its own anchored card) ─── */}
|
|
<PushIngestSettings />
|
|
<PrivacySettings />
|
|
|
|
{/* ─── 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"])}
|
|
</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" && (
|
|
<section id="about" className="scroll-mt-24">
|
|
<div className="mb-4">
|
|
<AutostartSettings />
|
|
</div>
|
|
<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-accent" />
|
|
<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-accent" />
|
|
<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-syntax-keyword" />
|
|
<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>
|
|
);
|
|
}
|
|
|
|
/** "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>
|
|
);
|
|
}
|