1b561c22d9
- Changes card beside lane detail with five modes: Files tree, overview, Review (whole diff, per-file collapse), single file, commit history. - LaneDiffView: unified / side-by-side with independent scroll on both axes and mirrored scrollLeft, expand/collapse context, intra-line emphasis. - Staged-only commit box; git actions (fetch/pull/push/stage/unstage/ discard/commit/switch/branch-create/branch-delete) guarded in git-ops.js. - Server: git-diff.js + git-ops.js behind GET /api/lanes/:id/git/* and POST /api/lanes/:id/git/:action; gitFacts gains upstream/ahead/behind. - Add lane: new/existing branch, current branch, worktree list, prefill from the git bar's "New lane here"; /api/run/cwds gains a repo kind. - GlobalTooltip upgrades every native title into a themed anchored bubble; chart/info popovers use theme tokens.
1933 lines
78 KiB
TypeScript
1933 lines
78 KiB
TypeScript
/**
|
|
* @file Settings.tsx
|
|
* @description Provides a settings page for managing model pricing rules, notification preferences, and system information with real-time updates and actionable controls for data management and hook configuration.
|
|
* @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, useRef, useSyncExternalStore, Fragment } from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
import {
|
|
DollarSign,
|
|
Plus,
|
|
Pencil,
|
|
Trash2,
|
|
Check,
|
|
X,
|
|
RefreshCw,
|
|
Database,
|
|
Plug,
|
|
HardDrive,
|
|
AlertTriangle,
|
|
RotateCcw,
|
|
CheckCircle,
|
|
HelpCircle,
|
|
MessageSquare,
|
|
XCircle,
|
|
Server,
|
|
Bell,
|
|
BellOff,
|
|
BellRing,
|
|
FileDown,
|
|
Eraser,
|
|
Play,
|
|
Zap,
|
|
AlertCircle,
|
|
GitBranch,
|
|
ShieldCheck,
|
|
ShieldAlert,
|
|
ShieldX,
|
|
Clock,
|
|
Cpu,
|
|
Globe,
|
|
Wifi,
|
|
Activity,
|
|
Users,
|
|
Layers,
|
|
Coins,
|
|
BarChart3,
|
|
Settings as SettingsIcon,
|
|
FolderOpen,
|
|
Info,
|
|
PiggyBank,
|
|
History,
|
|
ChevronLeft,
|
|
ChevronRight,
|
|
Cloud,
|
|
} 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, getCurrentLocale } from "../lib/format";
|
|
import { subscribeToPush, unsubscribeFromPush } from "../lib/push";
|
|
import { loadNotifPrefs, saveNotifPrefs, type NotifPrefs } from "../lib/notifPrefs";
|
|
import { notify } 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 type { ModelPricing, WSMessage } from "../lib/types";
|
|
|
|
// In-page navigation for the (dense) Settings screen. Each entry maps to a
|
|
// `<section id>` rendered below; the TOC scroll-spies the active one.
|
|
const SETTINGS_SECTIONS: {
|
|
id: string;
|
|
labelKey: string;
|
|
fallback?: string;
|
|
Icon: typeof DollarSign;
|
|
}[] = [
|
|
{ id: "pricing", labelKey: "pricing.title", Icon: DollarSign },
|
|
{ id: "hooks", labelKey: "hooks.title", Icon: Plug },
|
|
{ id: "lanes", labelKey: "lanesSection.title", Icon: GitBranch },
|
|
{ id: "claude-home", labelKey: "claudeHome.title", Icon: FolderOpen },
|
|
{ id: "import", labelKey: "import.title", fallback: "Import", Icon: History },
|
|
{
|
|
id: "remote-sources",
|
|
labelKey: "remoteSources.title",
|
|
fallback: "Remote Data Sources",
|
|
Icon: Cloud,
|
|
},
|
|
{ id: "sagi", labelKey: "sagi.title", fallback: "Sagi", Icon: PiggyBank },
|
|
{ id: "notifications", labelKey: "notifications.title", Icon: Bell },
|
|
{ id: "alerts", labelKey: "alertsHub.title", Icon: BellRing },
|
|
{ id: "data", labelKey: "data.title", Icon: Database },
|
|
{ id: "about", labelKey: "about.title", Icon: Server },
|
|
];
|
|
|
|
// ─── Helpers ───
|
|
|
|
interface EditRow {
|
|
model_pattern: string;
|
|
display_name: string;
|
|
input_per_mtok: string;
|
|
output_per_mtok: string;
|
|
cache_read_per_mtok: string;
|
|
cache_write_per_mtok: string;
|
|
cache_write_1h_per_mtok: string;
|
|
fast_input_per_mtok: string;
|
|
fast_output_per_mtok: string;
|
|
// Time-limited introductory rates. intro_until empty ⇒ no promo (the intro_*
|
|
// values are ignored). Generic: any model can carry a promo window.
|
|
intro_until: string;
|
|
intro_input_per_mtok: string;
|
|
intro_output_per_mtok: string;
|
|
intro_cache_read_per_mtok: string;
|
|
intro_cache_write_per_mtok: string;
|
|
intro_cache_write_1h_per_mtok: string;
|
|
}
|
|
|
|
const emptyRow: EditRow = {
|
|
model_pattern: "",
|
|
display_name: "",
|
|
input_per_mtok: "0",
|
|
output_per_mtok: "0",
|
|
cache_read_per_mtok: "0",
|
|
cache_write_per_mtok: "0",
|
|
cache_write_1h_per_mtok: "0",
|
|
fast_input_per_mtok: "0",
|
|
fast_output_per_mtok: "0",
|
|
intro_until: "",
|
|
intro_input_per_mtok: "0",
|
|
intro_output_per_mtok: "0",
|
|
intro_cache_read_per_mtok: "0",
|
|
intro_cache_write_per_mtok: "0",
|
|
intro_cache_write_1h_per_mtok: "0",
|
|
};
|
|
|
|
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 formatTimestamp(iso: string): string {
|
|
const normalized =
|
|
/[Zz]$/.test(iso) || /[+-]\d{2}:\d{2}$/.test(iso) ? iso : iso.replace(" ", "T") + "Z";
|
|
const d = new Date(normalized);
|
|
return d.toLocaleString(getCurrentLocale(), {
|
|
year: "numeric",
|
|
month: "short",
|
|
day: "numeric",
|
|
hour: "2-digit",
|
|
minute: "2-digit",
|
|
});
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
// ─── Toggle component ───
|
|
|
|
function Toggle({
|
|
checked,
|
|
onChange,
|
|
label,
|
|
description,
|
|
}: {
|
|
checked: boolean;
|
|
onChange: (v: boolean) => void;
|
|
label: string;
|
|
description?: string;
|
|
}) {
|
|
return (
|
|
<label className="flex items-center justify-between gap-3 cursor-pointer group">
|
|
<div className="min-w-0">
|
|
<p className="text-sm text-fg-secondary group-hover:text-fg-secondary transition-colors">
|
|
{label}
|
|
</p>
|
|
{description && <p className="text-xs text-fg-muted mt-0.5">{description}</p>}
|
|
</div>
|
|
<button
|
|
type="button"
|
|
role="switch"
|
|
aria-checked={checked}
|
|
onClick={() => onChange(!checked)}
|
|
className={`relative inline-flex h-5 w-9 flex-shrink-0 rounded-full border-2 border-transparent transition-colors duration-200 ${
|
|
checked ? "bg-blue-600" : "bg-surface-4"
|
|
}`}
|
|
>
|
|
<span
|
|
className={`pointer-events-none inline-block h-4 w-4 rounded-full bg-white shadow transform transition-transform duration-200 ${
|
|
checked ? "translate-x-4" : "translate-x-0"
|
|
}`}
|
|
/>
|
|
</button>
|
|
</label>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Info popover for the Model Pricing section. Hover or focus the icon to see a
|
|
* three-section explanation: how prices are applied, how pattern matching
|
|
* works, and a reminder that prices must be edited manually when Anthropic
|
|
* publishes new rates. All copy is i18n-driven (settings.pricing.tooltip.*).
|
|
*
|
|
* The popover is fixed-positioned and clamped to the viewport so it never
|
|
* gets clipped by the sidebar or screen edges, mirroring the pattern used by
|
|
* the Workflows stat tooltips.
|
|
*/
|
|
function PricingInfoTooltip() {
|
|
const { t } = useTranslation("settings");
|
|
const [open, setOpen] = useState(false);
|
|
const buttonRef = useRef<HTMLButtonElement>(null);
|
|
const popoverRef = useRef<HTMLDivElement>(null);
|
|
const [pos, setPos] = useState<{ left: number; top: number }>({ left: 0, top: 0 });
|
|
|
|
const positionPopover = useCallback(() => {
|
|
const btn = buttonRef.current;
|
|
const pop = popoverRef.current;
|
|
if (!btn) return;
|
|
const r = btn.getBoundingClientRect();
|
|
const w = pop?.offsetWidth ?? 320;
|
|
const h = pop?.offsetHeight ?? 240;
|
|
const margin = 8;
|
|
|
|
let left = r.right - w; // right-align with the icon
|
|
if (left < margin) left = margin;
|
|
if (left + w > window.innerWidth - margin) left = window.innerWidth - w - margin;
|
|
let top = r.bottom + 8;
|
|
if (top + h > window.innerHeight - margin) {
|
|
top = Math.max(margin, r.top - h - 8);
|
|
}
|
|
setPos({ left, top });
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (!open) return;
|
|
positionPopover();
|
|
const onScroll = () => positionPopover();
|
|
const onResize = () => positionPopover();
|
|
window.addEventListener("scroll", onScroll, true);
|
|
window.addEventListener("resize", onResize);
|
|
const raf = requestAnimationFrame(positionPopover);
|
|
return () => {
|
|
cancelAnimationFrame(raf);
|
|
window.removeEventListener("scroll", onScroll, true);
|
|
window.removeEventListener("resize", onResize);
|
|
};
|
|
}, [open, positionPopover]);
|
|
|
|
return (
|
|
<>
|
|
<button
|
|
ref={buttonRef}
|
|
type="button"
|
|
aria-label={t("pricing.tooltip.title")}
|
|
onMouseEnter={() => setOpen(true)}
|
|
onMouseLeave={() => setOpen(false)}
|
|
onFocus={() => setOpen(true)}
|
|
onBlur={() => setOpen(false)}
|
|
className="inline-flex items-center justify-center rounded-full p-0.5 text-fg-muted hover:text-fg-secondary focus:outline-none focus:ring-1 focus:ring-accent/40"
|
|
>
|
|
<Info className="w-3.5 h-3.5" />
|
|
</button>
|
|
{open && (
|
|
<div
|
|
ref={popoverRef}
|
|
role="tooltip"
|
|
className="fixed z-50 p-3 bg-surface-2 border border-border rounded-lg shadow-2xl text-[11px] text-fg-secondary pointer-events-none"
|
|
style={{ left: pos.left, top: pos.top, width: 320 }}
|
|
>
|
|
<p className="text-xs font-semibold text-fg-primary mb-2">{t("pricing.tooltip.title")}</p>
|
|
|
|
<p className="font-semibold text-fg-secondary uppercase tracking-wider text-[9px] mb-1">
|
|
{t("pricing.tooltip.howItWorks")}
|
|
</p>
|
|
<p className="text-fg-secondary leading-snug mb-2.5">
|
|
{t("pricing.tooltip.howItWorksBody")}
|
|
</p>
|
|
|
|
<p className="font-semibold text-fg-secondary uppercase tracking-wider text-[9px] mb-1">
|
|
{t("pricing.tooltip.patternsTitle")}
|
|
</p>
|
|
<p className="text-fg-secondary leading-snug mb-2.5">
|
|
{t("pricing.tooltip.patternsBody")}
|
|
</p>
|
|
|
|
<p className="font-semibold text-status-warning uppercase tracking-wider text-[9px] mb-1">
|
|
{t("pricing.tooltip.manualUpdates")}
|
|
</p>
|
|
<p className="text-fg-secondary leading-snug mb-2.5">
|
|
{t("pricing.tooltip.manualUpdatesBody")}
|
|
</p>
|
|
|
|
<p className="font-semibold text-status-warning uppercase tracking-wider text-[9px] mb-1">
|
|
{t("pricing.tooltip.apiPricing")}
|
|
</p>
|
|
<p className="text-fg-secondary leading-snug">{t("pricing.tooltip.apiPricingBody")}</p>
|
|
</div>
|
|
)}
|
|
</>
|
|
);
|
|
}
|
|
|
|
// ─── Main component ───
|
|
|
|
export function Settings() {
|
|
const { t } = useTranslation("settings");
|
|
const [pricing, setPricing] = useState<ModelPricing[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [editingPattern, setEditingPattern] = useState<string | null>(null);
|
|
const [editRow, setEditRow] = useState<EditRow>(emptyRow);
|
|
const [adding, setAdding] = useState(false);
|
|
const [saving, setSaving] = useState(false);
|
|
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 [claudeHome, setClaudeHomeState] = useState("");
|
|
const [claudeHomeInput, setClaudeHomeInput] = useState("");
|
|
const [claudeHomeSaving, setClaudeHomeSaving] = useState(false);
|
|
const [claudeHomeError, setClaudeHomeError] = useState<string | null>(null);
|
|
const [activeSection, setActiveSection] = useState<string>("pricing");
|
|
const tocRef = useRef<HTMLDivElement | null>(null);
|
|
const [tocOverflow, setTocOverflow] = useState({ left: false, right: false });
|
|
|
|
const wsConnected = useSyncExternalStore(eventBus.onConnection, () => eventBus.connected);
|
|
const animatedTotalCost = useCountUp(totalCost);
|
|
|
|
// Scroll-spy: highlight the TOC entry for the section nearest the top.
|
|
useEffect(() => {
|
|
if (loading) return;
|
|
const els = SETTINGS_SECTIONS.map((s) => document.getElementById(s.id)).filter(
|
|
(e): e is HTMLElement => !!e
|
|
);
|
|
if (els.length === 0) return;
|
|
const observer = new IntersectionObserver(
|
|
(entries) => {
|
|
const visible = entries
|
|
.filter((e) => e.isIntersecting)
|
|
.sort((a, b) => a.boundingClientRect.top - b.boundingClientRect.top);
|
|
if (visible[0]) setActiveSection(visible[0].target.id);
|
|
},
|
|
{ rootMargin: "-88px 0px -65% 0px", threshold: 0 }
|
|
);
|
|
els.forEach((el) => observer.observe(el));
|
|
return () => observer.disconnect();
|
|
}, [loading]);
|
|
|
|
// Show chevron affordances on the TOC when its chips overflow horizontally.
|
|
const recomputeTocOverflow = useCallback(() => {
|
|
const el = tocRef.current;
|
|
if (!el) return;
|
|
const left = el.scrollLeft > 1;
|
|
const right = el.scrollLeft + el.clientWidth < el.scrollWidth - 1;
|
|
setTocOverflow((prev) => (prev.left === left && prev.right === right ? prev : { left, right }));
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (loading) return;
|
|
recomputeTocOverflow();
|
|
const el = tocRef.current;
|
|
if (!el) return;
|
|
const onScroll = () => recomputeTocOverflow();
|
|
el.addEventListener("scroll", onScroll, { passive: true });
|
|
const ro =
|
|
typeof ResizeObserver !== "undefined" ? new ResizeObserver(recomputeTocOverflow) : null;
|
|
ro?.observe(el);
|
|
window.addEventListener("resize", recomputeTocOverflow);
|
|
return () => {
|
|
el.removeEventListener("scroll", onScroll);
|
|
ro?.disconnect();
|
|
window.removeEventListener("resize", recomputeTocOverflow);
|
|
};
|
|
}, [loading, recomputeTocOverflow]);
|
|
|
|
const scrollTocBy = useCallback((delta: number) => {
|
|
tocRef.current?.scrollBy({ left: delta, behavior: "smooth" });
|
|
}, []);
|
|
|
|
const load = useCallback(async () => {
|
|
try {
|
|
const [pricingRes, costRes, infoRes, claudeHomeRes] = await Promise.all([
|
|
api.pricing.list(),
|
|
api.pricing.totalCost(),
|
|
api.settings.info(),
|
|
api.settings.claudeHome.get(),
|
|
]);
|
|
setPricing(pricingRes.pricing);
|
|
setTotalCost(costRes.total_cost);
|
|
setSysInfo(infoRes);
|
|
setClaudeHomeState(claudeHomeRes.claude_home);
|
|
setClaudeHomeInput(claudeHomeRes.claude_home);
|
|
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 startEdit = (rule: ModelPricing) => {
|
|
setAdding(false);
|
|
setEditingPattern(rule.model_pattern);
|
|
setEditRow({
|
|
model_pattern: rule.model_pattern,
|
|
display_name: rule.display_name,
|
|
input_per_mtok: String(rule.input_per_mtok),
|
|
output_per_mtok: String(rule.output_per_mtok),
|
|
cache_read_per_mtok: String(rule.cache_read_per_mtok),
|
|
cache_write_per_mtok: String(rule.cache_write_per_mtok),
|
|
cache_write_1h_per_mtok: String(rule.cache_write_1h_per_mtok),
|
|
fast_input_per_mtok: String(rule.fast_input_per_mtok),
|
|
fast_output_per_mtok: String(rule.fast_output_per_mtok),
|
|
intro_until: rule.intro_until ?? "",
|
|
intro_input_per_mtok: String(rule.intro_input_per_mtok ?? 0),
|
|
intro_output_per_mtok: String(rule.intro_output_per_mtok ?? 0),
|
|
intro_cache_read_per_mtok: String(rule.intro_cache_read_per_mtok ?? 0),
|
|
intro_cache_write_per_mtok: String(rule.intro_cache_write_per_mtok ?? 0),
|
|
intro_cache_write_1h_per_mtok: String(rule.intro_cache_write_1h_per_mtok ?? 0),
|
|
});
|
|
};
|
|
|
|
const startAdd = () => {
|
|
setEditingPattern(null);
|
|
setAdding(true);
|
|
setEditRow({ ...emptyRow });
|
|
};
|
|
|
|
const cancelEdit = () => {
|
|
setEditingPattern(null);
|
|
setAdding(false);
|
|
setError(null);
|
|
};
|
|
|
|
const saveEdit = async () => {
|
|
if (!editRow.model_pattern.trim() || !editRow.display_name.trim()) {
|
|
setError(t("pricing.validationRequired"));
|
|
return;
|
|
}
|
|
const introUntil = editRow.intro_until.trim();
|
|
if (introUntil && !/^\d{4}-\d{2}-\d{2}$/.test(introUntil)) {
|
|
setError(t("pricing.introUntilInvalid"));
|
|
return;
|
|
}
|
|
setSaving(true);
|
|
setError(null);
|
|
try {
|
|
await api.pricing.upsert({
|
|
model_pattern: editRow.model_pattern.trim(),
|
|
display_name: editRow.display_name.trim(),
|
|
input_per_mtok: parseFloat(editRow.input_per_mtok) || 0,
|
|
output_per_mtok: parseFloat(editRow.output_per_mtok) || 0,
|
|
cache_read_per_mtok: parseFloat(editRow.cache_read_per_mtok) || 0,
|
|
cache_write_per_mtok: parseFloat(editRow.cache_write_per_mtok) || 0,
|
|
cache_write_1h_per_mtok: parseFloat(editRow.cache_write_1h_per_mtok) || 0,
|
|
fast_input_per_mtok: parseFloat(editRow.fast_input_per_mtok) || 0,
|
|
fast_output_per_mtok: parseFloat(editRow.fast_output_per_mtok) || 0,
|
|
// Always send the intro block so the UI is authoritative for it: an
|
|
// empty date clears the promo, a valid date persists the intro rates.
|
|
intro_until: introUntil || null,
|
|
intro_input_per_mtok: parseFloat(editRow.intro_input_per_mtok) || 0,
|
|
intro_output_per_mtok: parseFloat(editRow.intro_output_per_mtok) || 0,
|
|
intro_cache_read_per_mtok: parseFloat(editRow.intro_cache_read_per_mtok) || 0,
|
|
intro_cache_write_per_mtok: parseFloat(editRow.intro_cache_write_per_mtok) || 0,
|
|
intro_cache_write_1h_per_mtok: parseFloat(editRow.intro_cache_write_1h_per_mtok) || 0,
|
|
});
|
|
setEditingPattern(null);
|
|
setAdding(false);
|
|
await load();
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : t("messages.failedSave"));
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
};
|
|
|
|
const deleteRule = async (pattern: string) => {
|
|
try {
|
|
await api.pricing.delete(pattern);
|
|
await load();
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : t("messages.failedDelete"));
|
|
}
|
|
};
|
|
|
|
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 handleReinstallHooks = () =>
|
|
runAction("hooks", async () => {
|
|
const res = await api.settings.reinstallHooks();
|
|
return res.ok ? t("hooks.success") : t("hooks.failed");
|
|
});
|
|
|
|
const handleSkillsInstall = () =>
|
|
runAction("skills-install", async () => {
|
|
const res = await api.settings.skillsInstall();
|
|
return t("lanesSection.installSkillSuccess", { path: res.path });
|
|
});
|
|
|
|
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 handleResetPricing = () =>
|
|
runAction("reset-pricing", async () => {
|
|
const res = await api.settings.resetPricing();
|
|
return t("pricing.resetResult", { count: res.pricing.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 handleSaveClaudeHome = async () => {
|
|
if (claudeHomeInput === claudeHome) return;
|
|
setClaudeHomeSaving(true);
|
|
setClaudeHomeError(null);
|
|
try {
|
|
const res = await api.settings.claudeHome.set(claudeHomeInput);
|
|
setClaudeHomeState(res.claude_home);
|
|
setClaudeHomeInput(res.claude_home);
|
|
} catch (err) {
|
|
setClaudeHomeError(err instanceof Error ? err.message : t("claudeHome.saveFailed"));
|
|
} finally {
|
|
setClaudeHomeSaving(false);
|
|
}
|
|
};
|
|
|
|
const lastUpdated =
|
|
pricing.length > 0
|
|
? pricing.reduce(
|
|
(latest, p) => (p.updated_at > latest ? p.updated_at : latest),
|
|
pricing[0]!.updated_at
|
|
)
|
|
: null;
|
|
|
|
const isEditing = editingPattern !== null || adding;
|
|
|
|
const renderEditCells = () => (
|
|
<>
|
|
<td className="px-4 py-3">
|
|
<input
|
|
type="text"
|
|
value={editRow.model_pattern}
|
|
onChange={(e) => setEditRow((r) => ({ ...r, model_pattern: e.target.value }))}
|
|
placeholder={t("pricing.patternPlaceholder")}
|
|
disabled={editingPattern !== null}
|
|
className="input w-full text-sm font-mono disabled:opacity-50"
|
|
autoFocus={adding}
|
|
/>
|
|
</td>
|
|
<td className="px-4 py-3">
|
|
<input
|
|
type="text"
|
|
value={editRow.display_name}
|
|
onChange={(e) => setEditRow((r) => ({ ...r, display_name: e.target.value }))}
|
|
placeholder={t("pricing.namePlaceholder")}
|
|
className="input w-full text-sm"
|
|
/>
|
|
</td>
|
|
<td className="px-4 py-3">
|
|
<input
|
|
type="number"
|
|
step="0.01"
|
|
min="0"
|
|
value={editRow.input_per_mtok}
|
|
onChange={(e) => setEditRow((r) => ({ ...r, input_per_mtok: e.target.value }))}
|
|
className="input w-full text-sm text-right font-mono"
|
|
/>
|
|
</td>
|
|
<td className="px-4 py-3">
|
|
<input
|
|
type="number"
|
|
step="0.01"
|
|
min="0"
|
|
value={editRow.output_per_mtok}
|
|
onChange={(e) => setEditRow((r) => ({ ...r, output_per_mtok: e.target.value }))}
|
|
className="input w-full text-sm text-right font-mono"
|
|
/>
|
|
</td>
|
|
<td className="px-4 py-3">
|
|
<input
|
|
type="number"
|
|
step="0.01"
|
|
min="0"
|
|
value={editRow.cache_read_per_mtok}
|
|
onChange={(e) => setEditRow((r) => ({ ...r, cache_read_per_mtok: e.target.value }))}
|
|
className="input w-full text-sm text-right font-mono"
|
|
/>
|
|
</td>
|
|
<td className="px-4 py-3">
|
|
<input
|
|
type="number"
|
|
step="0.01"
|
|
min="0"
|
|
value={editRow.cache_write_per_mtok}
|
|
onChange={(e) => setEditRow((r) => ({ ...r, cache_write_per_mtok: e.target.value }))}
|
|
className="input w-full text-sm text-right font-mono"
|
|
/>
|
|
</td>
|
|
<td className="px-4 py-3">
|
|
<input
|
|
type="number"
|
|
step="0.01"
|
|
min="0"
|
|
value={editRow.cache_write_1h_per_mtok}
|
|
onChange={(e) => setEditRow((r) => ({ ...r, cache_write_1h_per_mtok: e.target.value }))}
|
|
className="input w-full text-sm text-right font-mono"
|
|
/>
|
|
</td>
|
|
<td className="px-4 py-3">
|
|
<input
|
|
type="number"
|
|
step="0.01"
|
|
min="0"
|
|
value={editRow.fast_input_per_mtok}
|
|
onChange={(e) => setEditRow((r) => ({ ...r, fast_input_per_mtok: e.target.value }))}
|
|
className="input w-full text-sm text-right font-mono"
|
|
/>
|
|
</td>
|
|
<td className="px-4 py-3">
|
|
<input
|
|
type="number"
|
|
step="0.01"
|
|
min="0"
|
|
value={editRow.fast_output_per_mtok}
|
|
onChange={(e) => setEditRow((r) => ({ ...r, fast_output_per_mtok: e.target.value }))}
|
|
className="input w-full text-sm text-right font-mono"
|
|
/>
|
|
</td>
|
|
<td className="px-4 py-3">
|
|
<div className="flex items-center gap-1">
|
|
<button
|
|
onClick={saveEdit}
|
|
disabled={saving}
|
|
className="p-1.5 rounded-md text-status-success hover:bg-status-success/10 transition-colors disabled:opacity-50"
|
|
title={t("common:save")}
|
|
>
|
|
<Check className="w-4 h-4" />
|
|
</button>
|
|
<button
|
|
onClick={cancelEdit}
|
|
className="p-1.5 rounded-md text-fg-secondary hover:bg-surface-4 transition-colors"
|
|
title={t("common:cancel")}
|
|
>
|
|
<X className="w-4 h-4" />
|
|
</button>
|
|
</div>
|
|
</td>
|
|
</>
|
|
);
|
|
|
|
// Second edit row: the time-limited introductory-rate block. Rendered under
|
|
// the standard-rate cells whenever a row is being edited/added. Leaving the
|
|
// date empty means "no promo" — the rate inputs are then ignored. This is the
|
|
// ONLY place intro rates are entered, and it works for any model pattern (not
|
|
// just Sonnet 5), so a future model with a launch promo needs no code change.
|
|
const introField = (key: keyof EditRow, labelKey: string, opts: { date?: boolean } = {}) => (
|
|
<label className="flex flex-col gap-1">
|
|
<span className="text-[10px] uppercase tracking-wider text-violet-300/70">{t(labelKey)}</span>
|
|
<input
|
|
type={opts.date ? "text" : "number"}
|
|
{...(opts.date ? { placeholder: "YYYY-MM-DD" } : { step: "0.01", min: "0" })}
|
|
value={editRow[key]}
|
|
onChange={(e) => setEditRow((r) => ({ ...r, [key]: e.target.value }))}
|
|
className={`input text-sm font-mono ${opts.date ? "w-36" : "w-24 text-right"}`}
|
|
/>
|
|
</label>
|
|
);
|
|
|
|
const renderIntroEditRow = () => (
|
|
<tr className="bg-surface-3">
|
|
<td colSpan={10} className="px-4 pb-3 pt-2">
|
|
<div className="mt-2 rounded-md border border-violet-500/20 bg-violet-500/[0.04] px-3 py-2.5">
|
|
<div className="flex items-center gap-1.5 mb-2">
|
|
<span className="text-[11px] font-semibold text-violet-300 uppercase tracking-wider">
|
|
{t("pricing.introRatesTitle")}
|
|
</span>
|
|
<span className="text-[11px] text-fg-muted">{t("pricing.introRatesHint")}</span>
|
|
</div>
|
|
<div className="flex flex-wrap items-end gap-3">
|
|
{introField("intro_until", "pricing.introUntil", { date: true })}
|
|
{introField("intro_input_per_mtok", "common:token.input")}
|
|
{introField("intro_output_per_mtok", "common:token.output")}
|
|
{introField("intro_cache_read_per_mtok", "common:token.cacheRead")}
|
|
{introField("intro_cache_write_per_mtok", "pricing.cacheWrite5m")}
|
|
{introField("intro_cache_write_1h_per_mtok", "pricing.cacheWrite1h")}
|
|
</div>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
);
|
|
|
|
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">
|
|
{/* Header */}
|
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
|
<div className="flex items-center gap-3">
|
|
<div className="w-9 h-9 rounded-xl bg-accent/15 flex items-center justify-center">
|
|
<SettingsIcon className="w-4.5 h-4.5 text-accent" />
|
|
</div>
|
|
<div>
|
|
<div className="flex items-center gap-2">
|
|
<h1 className="text-lg font-semibold text-fg-primary">{t("title")}</h1>
|
|
{wsConnected ? (
|
|
<span className="flex items-center gap-1.5 text-[11px] text-status-success bg-status-success/10 border border-status-success/20 px-2 py-0.5 rounded-full">
|
|
<span className="w-1.5 h-1.5 rounded-full bg-status-success animate-pulse-dot" />
|
|
{t("common:live")}
|
|
</span>
|
|
) : (
|
|
<span className="flex items-center gap-1.5 text-[11px] text-fg-secondary bg-surface-4/10 border border-border-light/20 px-2 py-0.5 rounded-full">
|
|
<span className="w-1.5 h-1.5 rounded-full bg-surface-4" />
|
|
{t("common:offline")}
|
|
</span>
|
|
)}
|
|
</div>
|
|
<p className="text-xs text-fg-muted">{t("subtitle")}</p>
|
|
</div>
|
|
</div>
|
|
<div className="flex items-center gap-2 flex-shrink-0">
|
|
<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>
|
|
|
|
{/* In-page section navigation - Settings is dense, so this TOC jumps to
|
|
and scroll-spies each section. */}
|
|
<nav className="sticky top-0 z-20 -mx-1 !mt-2 px-1 py-2 bg-surface-0/85 backdrop-blur border-b border-border/60 flex items-center gap-1.5">
|
|
<span className="flex-shrink-0 pl-1 text-[10px] font-semibold uppercase tracking-wider text-fg-muted">
|
|
{t("jumpTo", "Jump to")}
|
|
</span>
|
|
{tocOverflow.left && (
|
|
<button
|
|
type="button"
|
|
onClick={() => scrollTocBy(-180)}
|
|
aria-label="Scroll left"
|
|
className="flex-shrink-0 flex items-center justify-center w-6 h-7 rounded-md border border-border text-fg-secondary hover:text-fg-primary hover:bg-surface-3 transition-colors"
|
|
>
|
|
<ChevronLeft className="w-3.5 h-3.5" />
|
|
</button>
|
|
)}
|
|
<div ref={tocRef} className="flex items-center gap-1.5 overflow-x-auto no-scrollbar flex-1">
|
|
{SETTINGS_SECTIONS.map(({ id, labelKey, fallback, Icon }) => {
|
|
const active = activeSection === id;
|
|
return (
|
|
<button
|
|
key={id}
|
|
type="button"
|
|
onClick={() =>
|
|
document
|
|
.getElementById(id)
|
|
?.scrollIntoView({ behavior: "smooth", block: "start" })
|
|
}
|
|
className={`inline-flex items-center gap-1.5 text-xs whitespace-nowrap px-2.5 py-1.5 rounded-lg border transition-colors flex-shrink-0 ${
|
|
active
|
|
? "bg-accent/15 border-accent/30 text-accent"
|
|
: "border-border text-fg-secondary hover:text-fg-primary hover:bg-surface-3"
|
|
}`}
|
|
>
|
|
<Icon className="w-3.5 h-3.5" />
|
|
{t(labelKey, fallback ?? "")}
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
{tocOverflow.right && (
|
|
<button
|
|
type="button"
|
|
onClick={() => scrollTocBy(180)}
|
|
aria-label="Scroll right"
|
|
className="flex-shrink-0 flex items-center justify-center w-6 h-7 rounded-md border border-border text-fg-secondary hover:text-fg-primary hover:bg-surface-3 transition-colors"
|
|
>
|
|
<ChevronRight className="w-3.5 h-3.5" />
|
|
</button>
|
|
)}
|
|
</nav>
|
|
|
|
{/* 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>
|
|
|
|
{/* ─── MODEL PRICING ─── */}
|
|
<section id="pricing" className="scroll-mt-24">
|
|
<div className="flex flex-wrap items-center justify-between gap-3 mb-4">
|
|
<div>
|
|
<h3 className="text-sm font-medium text-fg-secondary flex items-center gap-2">
|
|
<DollarSign className="w-4 h-4 text-fg-muted" />
|
|
{t("pricing.title")}
|
|
<PricingInfoTooltip />
|
|
</h3>
|
|
<p className="text-xs text-fg-muted mt-0.5">{t("pricing.description")}</p>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<button
|
|
onClick={() =>
|
|
confirmAction === "reset-pricing"
|
|
? handleResetPricing()
|
|
: setConfirmAction("reset-pricing")
|
|
}
|
|
disabled={isEditing || actionLoading !== null}
|
|
className={`text-xs px-3 py-1.5 rounded-md transition-colors disabled:opacity-50 inline-flex items-center gap-1.5 ${
|
|
confirmAction === "reset-pricing"
|
|
? "bg-status-warning/20 text-status-warning border border-status-warning/30"
|
|
: "text-fg-secondary hover:text-fg-primary hover:bg-surface-4"
|
|
}`}
|
|
>
|
|
<RotateCcw className="w-3 h-3" />
|
|
{confirmAction === "reset-pricing"
|
|
? t("pricing.resetConfirm")
|
|
: t("pricing.resetDefaults")}
|
|
</button>
|
|
<button
|
|
onClick={startAdd}
|
|
disabled={isEditing}
|
|
className="btn-primary text-xs disabled:opacity-50"
|
|
>
|
|
<Plus className="w-3.5 h-3.5" /> {t("pricing.addModel")}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{error && (
|
|
<div className="mb-4 px-4 py-2.5 bg-status-danger/10 border border-status-danger/20 rounded-lg text-sm text-status-danger">
|
|
{error}
|
|
</div>
|
|
)}
|
|
|
|
{actionBanner(["reset-pricing"])}
|
|
|
|
<div className="card overflow-x-auto mt-4">
|
|
<table className="w-full min-w-[1000px]">
|
|
<thead>
|
|
<tr className="border-b border-border text-left">
|
|
<th className="px-4 py-3 text-[11px] font-semibold text-fg-muted uppercase tracking-wider">
|
|
{t("pricing.pattern")}
|
|
</th>
|
|
<th className="px-4 py-3 text-[11px] font-semibold text-fg-muted uppercase tracking-wider">
|
|
{t("common:cost.model")}
|
|
</th>
|
|
<th className="px-4 py-3 text-[11px] font-semibold text-fg-muted uppercase tracking-wider text-right">
|
|
{t("common:token.input")}
|
|
</th>
|
|
<th className="px-4 py-3 text-[11px] font-semibold text-fg-muted uppercase tracking-wider text-right">
|
|
{t("common:token.output")}
|
|
</th>
|
|
<th className="px-4 py-3 text-[11px] font-semibold text-fg-muted uppercase tracking-wider text-right">
|
|
{t("common:token.cacheRead")}
|
|
</th>
|
|
<th className="px-4 py-3 text-[11px] font-semibold text-fg-muted uppercase tracking-wider text-right">
|
|
{t("pricing.cacheWrite5m")}
|
|
</th>
|
|
<th className="px-4 py-3 text-[11px] font-semibold text-fg-muted uppercase tracking-wider text-right">
|
|
{t("pricing.cacheWrite1h")}
|
|
</th>
|
|
<th className="px-4 py-3 text-[11px] font-semibold text-fg-muted uppercase tracking-wider text-right">
|
|
{t("pricing.fastInput")}
|
|
</th>
|
|
<th className="px-4 py-3 text-[11px] font-semibold text-fg-muted uppercase tracking-wider text-right">
|
|
{t("pricing.fastOutput")}
|
|
</th>
|
|
<th className="w-24 px-4 py-3 text-[11px] font-semibold text-fg-muted uppercase tracking-wider">
|
|
{t("common:actions")}
|
|
</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-border">
|
|
{pricing.map((rule) =>
|
|
editingPattern === rule.model_pattern ? (
|
|
<Fragment key={rule.model_pattern}>
|
|
<tr className="bg-surface-3">{renderEditCells()}</tr>
|
|
{renderIntroEditRow()}
|
|
</Fragment>
|
|
) : (
|
|
<tr
|
|
key={rule.model_pattern}
|
|
className="hover:bg-surface-4 transition-colors group"
|
|
>
|
|
<td className="px-4 py-3 text-sm font-mono text-fg-secondary">
|
|
{rule.model_pattern}
|
|
</td>
|
|
<td className="px-4 py-3 text-sm text-fg-secondary">
|
|
{rule.display_name}
|
|
{rule.intro_until && (
|
|
<div className="text-[11px] font-normal text-violet-400/80 mt-0.5">
|
|
{t("pricing.introNote", {
|
|
input: rule.intro_input_per_mtok,
|
|
output: rule.intro_output_per_mtok,
|
|
until: rule.intro_until,
|
|
})}
|
|
</div>
|
|
)}
|
|
</td>
|
|
<td className="px-4 py-3 text-sm text-fg-secondary text-right font-mono">
|
|
${rule.input_per_mtok}
|
|
{rule.intro_until && (
|
|
<span className="block text-[11px] text-violet-400/80">
|
|
${rule.intro_input_per_mtok}
|
|
</span>
|
|
)}
|
|
</td>
|
|
<td className="px-4 py-3 text-sm text-fg-secondary text-right font-mono">
|
|
${rule.output_per_mtok}
|
|
{rule.intro_until && (
|
|
<span className="block text-[11px] text-violet-400/80">
|
|
${rule.intro_output_per_mtok}
|
|
</span>
|
|
)}
|
|
</td>
|
|
<td className="px-4 py-3 text-sm text-fg-secondary text-right font-mono">
|
|
${rule.cache_read_per_mtok}
|
|
{rule.intro_until && (
|
|
<span className="block text-[11px] text-violet-400/80">
|
|
${rule.intro_cache_read_per_mtok}
|
|
</span>
|
|
)}
|
|
</td>
|
|
<td className="px-4 py-3 text-sm text-fg-secondary text-right font-mono">
|
|
${rule.cache_write_per_mtok}
|
|
{rule.intro_until && (
|
|
<span className="block text-[11px] text-violet-400/80">
|
|
${rule.intro_cache_write_per_mtok}
|
|
</span>
|
|
)}
|
|
</td>
|
|
<td className="px-4 py-3 text-sm text-fg-secondary text-right font-mono">
|
|
${rule.cache_write_1h_per_mtok}
|
|
{rule.intro_until && (
|
|
<span className="block text-[11px] text-violet-400/80">
|
|
${rule.intro_cache_write_1h_per_mtok}
|
|
</span>
|
|
)}
|
|
</td>
|
|
<td className="px-4 py-3 text-sm text-fg-secondary text-right font-mono">
|
|
{rule.fast_input_per_mtok ? `$${rule.fast_input_per_mtok}` : "-"}
|
|
</td>
|
|
<td className="px-4 py-3 text-sm text-fg-secondary text-right font-mono">
|
|
{rule.fast_output_per_mtok ? `$${rule.fast_output_per_mtok}` : "-"}
|
|
</td>
|
|
<td className="px-4 py-3">
|
|
<div className="flex items-center gap-1 transition-opacity">
|
|
<button
|
|
onClick={() => startEdit(rule)}
|
|
disabled={isEditing}
|
|
className="p-1.5 rounded-md text-fg-secondary hover:text-blue-500 hover:bg-blue-600/10 transition-colors disabled:opacity-30"
|
|
title={t("common:edit")}
|
|
>
|
|
<Pencil className="w-3.5 h-3.5" />
|
|
</button>
|
|
<button
|
|
onClick={() => deleteRule(rule.model_pattern)}
|
|
disabled={isEditing}
|
|
className="p-1.5 rounded-md text-fg-secondary hover:text-status-danger hover:bg-status-danger/10 transition-colors disabled:opacity-30"
|
|
title={t("common:delete")}
|
|
>
|
|
<Trash2 className="w-3.5 h-3.5" />
|
|
</button>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
)
|
|
)}
|
|
{adding && (
|
|
<>
|
|
<tr className="bg-surface-3">{renderEditCells()}</tr>
|
|
{renderIntroEditRow()}
|
|
</>
|
|
)}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
{lastUpdated && (
|
|
<p className="text-xs text-fg-muted mt-3">
|
|
{t("pricing.lastUpdated")}
|
|
{formatTimestamp(lastUpdated)}
|
|
</p>
|
|
)}
|
|
</section>
|
|
|
|
{/* ─── HOOK CONFIGURATION ─── */}
|
|
<section id="hooks" className="scroll-mt-24">
|
|
<h3 className="text-sm font-medium text-fg-secondary flex items-center gap-2 mb-1">
|
|
<Plug className="w-4 h-4 text-fg-muted" />
|
|
{t("hooks.title")}
|
|
</h3>
|
|
<p className="text-xs text-fg-muted mb-4">{t("hooks.description")}</p>
|
|
|
|
<div className="card p-5 space-y-4">
|
|
<div className="flex items-center justify-between flex-wrap gap-3">
|
|
<div className="flex items-center gap-3">
|
|
{sysInfo?.hooks.installed ? (
|
|
<span className="inline-flex items-center gap-1.5 text-xs font-medium text-status-success bg-status-success/10 border border-status-success/20 px-2.5 py-1 rounded-full">
|
|
<CheckCircle className="w-3.5 h-3.5" /> {t("hooks.allInstalled")}
|
|
</span>
|
|
) : (
|
|
<span className="inline-flex items-center gap-1.5 text-xs font-medium text-status-warning bg-status-warning/10 border border-status-warning/20 px-2.5 py-1 rounded-full">
|
|
<AlertTriangle className="w-3.5 h-3.5" /> {t("hooks.incomplete")}
|
|
</span>
|
|
)}
|
|
</div>
|
|
<button
|
|
onClick={handleReinstallHooks}
|
|
disabled={actionLoading !== null}
|
|
className="btn-ghost text-xs disabled:opacity-50"
|
|
>
|
|
{actionLoading === "hooks" ? (
|
|
<RefreshCw className="w-3.5 h-3.5 animate-spin" />
|
|
) : (
|
|
<RotateCcw className="w-3.5 h-3.5" />
|
|
)}
|
|
{t("hooks.reinstall")}
|
|
</button>
|
|
</div>
|
|
|
|
{actionBanner(["hooks"])}
|
|
|
|
{sysInfo && (
|
|
<>
|
|
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-5 gap-2">
|
|
{Object.entries(sysInfo.hooks.hooks).map(([hook, active]) => (
|
|
<div
|
|
key={hook}
|
|
className="flex items-center gap-1.5 text-xs px-2.5 py-1.5 rounded-md bg-surface-2"
|
|
>
|
|
{active ? (
|
|
<CheckCircle className="w-3 h-3 text-status-success flex-shrink-0" />
|
|
) : (
|
|
<XCircle className="w-3 h-3 text-status-danger flex-shrink-0" />
|
|
)}
|
|
<span className="text-fg-secondary truncate">{hook}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
<p className="text-[11px] text-fg-muted font-mono truncate">{sysInfo.hooks.path}</p>
|
|
</>
|
|
)}
|
|
</div>
|
|
</section>
|
|
|
|
{/* ─── 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">
|
|
<span className="text-xs text-fg-secondary">{t("lanesSection.installSkill")}</span>
|
|
<button
|
|
onClick={handleSkillsInstall}
|
|
disabled={actionLoading !== null}
|
|
className="btn-ghost text-xs disabled:opacity-50"
|
|
>
|
|
{actionLoading === "skills-install" ? (
|
|
<RefreshCw className="w-3.5 h-3.5 animate-spin" />
|
|
) : (
|
|
<RotateCcw className="w-3.5 h-3.5" />
|
|
)}
|
|
{t("lanesSection.installSkill")}
|
|
</button>
|
|
</div>
|
|
{actionBanner(["skills-install"])}
|
|
|
|
<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>
|
|
|
|
{/* ─── CLAUDE HOME ─── */}
|
|
<section id="claude-home" className="scroll-mt-24">
|
|
<h3 className="text-sm font-medium text-fg-secondary flex items-center gap-2 mb-1">
|
|
<FolderOpen className="w-4 h-4 text-fg-muted" />
|
|
{t("claudeHome.title")}
|
|
</h3>
|
|
<p className="text-xs text-fg-muted mb-1">{t("claudeHome.description")}</p>
|
|
<p className="text-[11px] text-fg-muted italic mb-4 leading-snug">{t("cursorPathsNote")}</p>
|
|
|
|
<div className="card p-5 space-y-4">
|
|
<div className="flex items-center gap-3">
|
|
<input
|
|
type="text"
|
|
value={claudeHomeInput}
|
|
onChange={(e) => {
|
|
setClaudeHomeInput(e.target.value);
|
|
setClaudeHomeError(null);
|
|
}}
|
|
className="flex-1 bg-surface-4 border border-surface-3 rounded-lg px-3 py-2 text-sm text-fg-secondary font-mono focus:outline-none focus:border-violet-500/50"
|
|
placeholder={t("claudeHome.placeholder")}
|
|
/>
|
|
<button
|
|
onClick={handleSaveClaudeHome}
|
|
disabled={claudeHomeSaving || claudeHomeInput === claudeHome}
|
|
className="btn-primary px-4 py-2 text-sm disabled:opacity-50 disabled:cursor-not-allowed"
|
|
>
|
|
{claudeHomeSaving ? t("claudeHome.saving") : t("claudeHome.save")}
|
|
</button>
|
|
</div>
|
|
{claudeHomeError && <p className="text-xs text-status-danger">{claudeHomeError}</p>}
|
|
{claudeHome && (
|
|
<p className="text-xs text-fg-muted">
|
|
{t("claudeHome.current")} <code className="text-fg-secondary">{claudeHome}</code>
|
|
</p>
|
|
)}
|
|
</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>
|
|
<Toggle
|
|
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>
|
|
</section>
|
|
|
|
{/* ─── 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>
|
|
<Toggle
|
|
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>
|
|
{"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">
|
|
<p className="text-xs text-fg-muted uppercase tracking-wider font-semibold">
|
|
{t("notifications.notifyWhen")}
|
|
</p>
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
|
<div className="flex items-center gap-3 bg-surface-2 rounded-lg px-3.5 py-3">
|
|
<HelpCircle className="w-4 h-4 text-status-warning flex-shrink-0" />
|
|
<Toggle
|
|
checked={notifPrefs.onAwaitingInput}
|
|
onChange={(v) => updateNotifPrefs({ onAwaitingInput: v })}
|
|
label={t("notifications.awaitingInput")}
|
|
/>
|
|
</div>
|
|
<div className="flex items-center gap-3 bg-surface-2 rounded-lg px-3.5 py-3">
|
|
<Play className="w-4 h-4 text-status-success flex-shrink-0" />
|
|
<Toggle
|
|
checked={notifPrefs.onNewSession}
|
|
onChange={(v) => updateNotifPrefs({ onNewSession: v })}
|
|
label={t("notifications.newSession")}
|
|
/>
|
|
</div>
|
|
<div className="flex items-center gap-3 bg-surface-2 rounded-lg px-3.5 py-3">
|
|
<MessageSquare className="w-4 h-4 text-accent flex-shrink-0" />
|
|
<Toggle
|
|
checked={notifPrefs.onTurnComplete}
|
|
onChange={(v) => updateNotifPrefs({ onTurnComplete: v })}
|
|
label={t("notifications.turnComplete")}
|
|
/>
|
|
</div>
|
|
<div className="flex items-center gap-3 bg-surface-2 rounded-lg px-3.5 py-3">
|
|
<CheckCircle className="w-4 h-4 text-violet-400 flex-shrink-0" />
|
|
<Toggle
|
|
checked={notifPrefs.onSessionComplete}
|
|
onChange={(v) => updateNotifPrefs({ onSessionComplete: v })}
|
|
label={t("notifications.sessionComplete")}
|
|
/>
|
|
</div>
|
|
<div className="flex items-center gap-3 bg-surface-2 rounded-lg px-3.5 py-3">
|
|
<AlertCircle className="w-4 h-4 text-status-danger flex-shrink-0" />
|
|
<Toggle
|
|
checked={notifPrefs.onSessionError}
|
|
onChange={(v) => updateNotifPrefs({ onSessionError: v })}
|
|
label={t("notifications.sessionError")}
|
|
/>
|
|
</div>
|
|
<div className="flex items-center gap-3 bg-surface-2 rounded-lg px-3.5 py-3">
|
|
<GitBranch className="w-4 h-4 text-blue-500 flex-shrink-0" />
|
|
<Toggle
|
|
checked={notifPrefs.onSubagentSpawn}
|
|
onChange={(v) => updateNotifPrefs({ onSubagentSpawn: v })}
|
|
label={t("notifications.subagentSpawned")}
|
|
/>
|
|
</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>
|
|
|
|
{/* ─── 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-400" />,
|
|
token_usage: <Coins className="w-4 h-4 text-status-warning" />,
|
|
model_pricing: <BarChart3 className="w-4 h-4 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-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-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-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>
|
|
);
|
|
}
|