@nastechai/agent 0.16.0 → 0.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (98) hide show
  1. package/eslint.config.js +23 -0
  2. package/index.html +24 -0
  3. package/package.json +54 -26
  4. package/package.json.bak +89 -0
  5. package/package.json.pub +88 -0
  6. package/src/App.tsx +1173 -0
  7. package/src/components/AuthWidget.tsx +150 -0
  8. package/src/components/AutoField.tsx +206 -0
  9. package/src/components/Backdrop.tsx +93 -0
  10. package/src/components/ChatSidebar.tsx +394 -0
  11. package/src/components/DeleteConfirmDialog.tsx +40 -0
  12. package/src/components/LanguageSwitcher.tsx +186 -0
  13. package/src/components/Markdown.tsx +383 -0
  14. package/src/components/ModelInfoCard.tsx +112 -0
  15. package/src/components/ModelPickerDialog.tsx +470 -0
  16. package/src/components/OAuthLoginModal.tsx +374 -0
  17. package/src/components/OAuthProvidersCard.tsx +287 -0
  18. package/src/components/PlatformsCard.tsx +97 -0
  19. package/src/components/ScheduleBuilder.tsx +273 -0
  20. package/src/components/SidebarFooter.tsx +42 -0
  21. package/src/components/SidebarStatusStrip.tsx +72 -0
  22. package/src/components/SlashPopover.tsx +171 -0
  23. package/src/components/ThemeSwitcher.tsx +243 -0
  24. package/src/components/ToolCall.tsx +228 -0
  25. package/src/components/ToolsetConfigDrawer.tsx +448 -0
  26. package/src/contexts/PageHeaderProvider.tsx +139 -0
  27. package/src/contexts/SystemActions.tsx +120 -0
  28. package/src/contexts/page-header-context.ts +12 -0
  29. package/src/contexts/system-actions-context.ts +18 -0
  30. package/src/contexts/usePageHeader.ts +10 -0
  31. package/src/contexts/useSystemActions.ts +15 -0
  32. package/src/hooks/useModalBehavior.ts +44 -0
  33. package/src/hooks/useSidebarStatus.ts +27 -0
  34. package/src/i18n/af.ts +702 -0
  35. package/src/i18n/context.tsx +123 -0
  36. package/src/i18n/de.ts +701 -0
  37. package/src/i18n/en.ts +708 -0
  38. package/src/i18n/es.ts +701 -0
  39. package/src/i18n/fr.ts +701 -0
  40. package/src/i18n/ga.ts +702 -0
  41. package/src/i18n/hu.ts +702 -0
  42. package/src/i18n/index.ts +2 -0
  43. package/src/i18n/it.ts +701 -0
  44. package/src/i18n/ja.ts +702 -0
  45. package/src/i18n/ko.ts +702 -0
  46. package/src/i18n/pt.ts +702 -0
  47. package/src/i18n/ru.ts +702 -0
  48. package/src/i18n/tr.ts +702 -0
  49. package/src/i18n/types.ts +710 -0
  50. package/src/i18n/uk.ts +702 -0
  51. package/src/i18n/zh-hant.ts +702 -0
  52. package/src/i18n/zh.ts +698 -0
  53. package/src/index.css +274 -0
  54. package/src/lib/api.ts +1585 -0
  55. package/src/lib/dashboard-flags.ts +15 -0
  56. package/src/lib/format.ts +9 -0
  57. package/src/lib/fuzzy.ts +192 -0
  58. package/src/lib/gatewayClient.ts +253 -0
  59. package/src/lib/nested.ts +23 -0
  60. package/src/lib/resolve-page-title.ts +41 -0
  61. package/src/lib/schedule.ts +382 -0
  62. package/src/lib/slashExec.ts +163 -0
  63. package/src/lib/utils.ts +35 -0
  64. package/src/main.tsx +25 -0
  65. package/src/pages/AnalyticsPage.tsx +601 -0
  66. package/src/pages/ChannelsPage.tsx +772 -0
  67. package/src/pages/ChatPage.tsx +889 -0
  68. package/src/pages/ConfigPage.tsx +660 -0
  69. package/src/pages/CronPage.tsx +524 -0
  70. package/src/pages/DocsPage.tsx +69 -0
  71. package/src/pages/EnvPage.tsx +918 -0
  72. package/src/pages/LogsPage.tsx +246 -0
  73. package/src/pages/McpPage.tsx +757 -0
  74. package/src/pages/ModelsPage.tsx +994 -0
  75. package/src/pages/PairingPage.tsx +276 -0
  76. package/src/pages/PluginsPage.tsx +580 -0
  77. package/src/pages/ProfilesPage.tsx +559 -0
  78. package/src/pages/SessionsPage.tsx +936 -0
  79. package/src/pages/SkillsPage.tsx +557 -0
  80. package/src/pages/SystemPage.tsx +1259 -0
  81. package/src/pages/WebhooksPage.tsx +483 -0
  82. package/src/plugins/PluginPage.tsx +64 -0
  83. package/src/plugins/index.ts +6 -0
  84. package/src/plugins/registry.ts +151 -0
  85. package/src/plugins/sdk.d.ts +160 -0
  86. package/src/plugins/slots.ts +199 -0
  87. package/src/plugins/types.ts +37 -0
  88. package/src/plugins/usePlugins.ts +133 -0
  89. package/src/themes/context.tsx +443 -0
  90. package/src/themes/fonts.ts +160 -0
  91. package/src/themes/index.ts +3 -0
  92. package/src/themes/presets.ts +477 -0
  93. package/src/themes/types.ts +187 -0
  94. package/tsconfig.app.json +34 -0
  95. package/tsconfig.json +7 -0
  96. package/tsconfig.node.json +26 -0
  97. package/vite.config.ts +124 -0
  98. package/vite.config.ts.timestamp-1780999102396-af6b77b30ebd8.mjs +105 -0
@@ -0,0 +1,1259 @@
1
+ import { useCallback, useEffect, useRef, useState } from "react";
2
+ import { Link } from "react-router-dom";
3
+ import {
4
+ Activity,
5
+ Brain,
6
+ Check,
7
+ Clock,
8
+ Copy,
9
+ Cpu,
10
+ Database,
11
+ Download,
12
+ Globe,
13
+ HardDrive,
14
+ KeyRound,
15
+ Link2,
16
+ Play,
17
+ Plus,
18
+ Power,
19
+ RotateCw,
20
+ Server,
21
+ Share2,
22
+ ShieldCheck,
23
+ Sparkles,
24
+ Stethoscope,
25
+ Terminal,
26
+ Trash2,
27
+ X,
28
+ } from "lucide-react";
29
+ import { Badge } from "@nastechai/ui/ui/components/badge";
30
+ import { Button } from "@nastechai/ui/ui/components/button";
31
+ import { Spinner } from "@nastechai/ui/ui/components/spinner";
32
+ import { H2 } from "@nastechai/ui/ui/components/typography/h2";
33
+ import { Card, CardContent } from "@nastechai/ui/ui/components/card";
34
+ import { Input } from "@nastechai/ui/ui/components/input";
35
+ import { Label } from "@nastechai/ui/ui/components/label";
36
+ import { Select, SelectOption } from "@nastechai/ui/ui/components/select";
37
+ import { Toast } from "@nastechai/ui/ui/components/toast";
38
+ import { useToast } from "@nastechai/ui/hooks/use-toast";
39
+ import { useConfirmDelete } from "@nastechai/ui/hooks/use-confirm-delete";
40
+ import { ConfirmDialog } from "@nastechai/ui/ui/components/confirm-dialog";
41
+ import { useModalBehavior } from "@/hooks/useModalBehavior";
42
+ import { DeleteConfirmDialog } from "@/components/DeleteConfirmDialog";
43
+ import { cn, themedBody } from "@/lib/utils";
44
+ import { api } from "@/lib/api";
45
+ import type {
46
+ StatusResponse,
47
+ MemoryStatus,
48
+ CredentialPoolProvider,
49
+ CheckpointsResponse,
50
+ HooksResponse,
51
+ HookEntry,
52
+ SystemStats,
53
+ UpdateCheckResponse,
54
+ CuratorStatus,
55
+ PortalStatus,
56
+ DebugShareResponse,
57
+ } from "@/lib/api";
58
+
59
+ function formatBytes(n: number): string {
60
+ if (n < 1024) return `${n} B`;
61
+ if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
62
+ if (n < 1024 * 1024 * 1024) return `${(n / (1024 * 1024)).toFixed(1)} MB`;
63
+ return `${(n / (1024 * 1024 * 1024)).toFixed(1)} GB`;
64
+ }
65
+
66
+ function formatDuration(seconds: number): string {
67
+ const d = Math.floor(seconds / 86400);
68
+ const h = Math.floor((seconds % 86400) / 3600);
69
+ const m = Math.floor((seconds % 3600) / 60);
70
+ if (d > 0) return `${d}d ${h}h ${m}m`;
71
+ if (h > 0) return `${h}h ${m}m`;
72
+ return `${m}m`;
73
+ }
74
+
75
+ /**
76
+ * Live action-log viewer for the spawn-based admin actions (doctor, audit,
77
+ * backup, import, skills update, checkpoints prune, gateway start/stop).
78
+ * Polls /api/actions/<name>/status until the process exits.
79
+ */
80
+ function ActionLogViewer({
81
+ action,
82
+ onClose,
83
+ }: {
84
+ action: string;
85
+ onClose: () => void;
86
+ }) {
87
+ const [lines, setLines] = useState<string[]>([]);
88
+ const [running, setRunning] = useState(true);
89
+ const [exitCode, setExitCode] = useState<number | null>(null);
90
+ const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
91
+
92
+ useEffect(() => {
93
+ let cancelled = false;
94
+ const poll = async () => {
95
+ try {
96
+ const st = await api.getActionStatus(action, 400);
97
+ if (cancelled) return;
98
+ setLines(st.lines);
99
+ setRunning(st.running);
100
+ setExitCode(st.exit_code);
101
+ if (st.running) timer.current = setTimeout(poll, 1200);
102
+ } catch {
103
+ if (!cancelled) setRunning(false);
104
+ }
105
+ };
106
+ poll();
107
+ return () => {
108
+ cancelled = true;
109
+ if (timer.current) clearTimeout(timer.current);
110
+ };
111
+ }, [action]);
112
+
113
+ return (
114
+ <Card>
115
+ <CardContent className="py-4">
116
+ <div className="flex items-center justify-between mb-2">
117
+ <div className="flex items-center gap-2">
118
+ <Terminal className="h-4 w-4 text-muted-foreground" />
119
+ <span className="font-mono text-sm">{action}</span>
120
+ {running ? (
121
+ <Badge tone="warning">running</Badge>
122
+ ) : (
123
+ <Badge tone={exitCode === 0 ? "success" : "destructive"}>
124
+ {exitCode === 0 ? "done" : `exit ${exitCode}`}
125
+ </Badge>
126
+ )}
127
+ </div>
128
+ <Button ghost size="icon" onClick={onClose} aria-label="Close log">
129
+ <X />
130
+ </Button>
131
+ </div>
132
+ <pre className="max-h-72 overflow-auto whitespace-pre-wrap break-words bg-background/50 border border-border p-3 text-xs font-mono text-muted-foreground">
133
+ {lines.length ? lines.join("\n") : "Starting…"}
134
+ </pre>
135
+ </CardContent>
136
+ </Card>
137
+ );
138
+ }
139
+
140
+ const HOOK_EVENTS_FALLBACK = [
141
+ "pre_tool_call",
142
+ "post_tool_call",
143
+ "pre_llm_call",
144
+ "post_llm_call",
145
+ "on_session_start",
146
+ "on_session_end",
147
+ ];
148
+
149
+ export default function SystemPage() {
150
+ const { toast, showToast } = useToast();
151
+
152
+ const [status, setStatus] = useState<StatusResponse | null>(null);
153
+ const [stats, setStats] = useState<SystemStats | null>(null);
154
+ const [memory, setMemory] = useState<MemoryStatus | null>(null);
155
+ const [pool, setPool] = useState<CredentialPoolProvider[]>([]);
156
+ const [checkpoints, setCheckpoints] = useState<CheckpointsResponse | null>(
157
+ null,
158
+ );
159
+ const [hooks, setHooks] = useState<HooksResponse | null>(null);
160
+ const [curator, setCurator] = useState<CuratorStatus | null>(null);
161
+ const [portal, setPortal] = useState<PortalStatus | null>(null);
162
+ const [loading, setLoading] = useState(true);
163
+
164
+ const [activeAction, setActiveAction] = useState<string | null>(null);
165
+
166
+ // Add-credential form.
167
+ const [credProvider, setCredProvider] = useState("openrouter");
168
+ const [credKey, setCredKey] = useState("");
169
+ const [credLabel, setCredLabel] = useState("");
170
+ const [addingCred, setAddingCred] = useState(false);
171
+
172
+ const [importPath, setImportPath] = useState("");
173
+
174
+ // Create-hook modal.
175
+ const [hookModalOpen, setHookModalOpen] = useState(false);
176
+ const closeHookModal = useCallback(() => setHookModalOpen(false), []);
177
+ const hookModalRef = useModalBehavior({
178
+ open: hookModalOpen,
179
+ onClose: closeHookModal,
180
+ });
181
+ const [hookEvent, setHookEvent] = useState("pre_tool_call");
182
+ const [hookCommand, setHookCommand] = useState("");
183
+ const [hookMatcher, setHookMatcher] = useState("");
184
+ const [hookTimeout, setHookTimeout] = useState("");
185
+ const [hookApprove, setHookApprove] = useState(true);
186
+ const [creatingHook, setCreatingHook] = useState(false);
187
+
188
+ // ── Update check ───────────────────────────────────────────────────
189
+ const [updateInfo, setUpdateInfo] = useState<UpdateCheckResponse | null>(
190
+ null,
191
+ );
192
+ const [checkingUpdate, setCheckingUpdate] = useState(false);
193
+ const [updateConfirmOpen, setUpdateConfirmOpen] = useState(false);
194
+
195
+ const loadAll = useCallback(() => {
196
+ Promise.allSettled([
197
+ api.getStatus(),
198
+ api.getSystemStats(),
199
+ api.getMemory(),
200
+ api.getCredentialPool(),
201
+ api.getCheckpoints(),
202
+ api.getHooks(),
203
+ api.getCurator(),
204
+ api.getPortal(),
205
+ // Cached (non-forced) check so the version row shows update status on
206
+ // load without a separate effect / a forced network round-trip.
207
+ api.checkNasTechUpdate(false),
208
+ ])
209
+ .then(([s, st, m, p, c, h, cur, prt, upd]) => {
210
+ if (s.status === "fulfilled") setStatus(s.value);
211
+ if (st.status === "fulfilled") setStats(st.value);
212
+ if (m.status === "fulfilled") setMemory(m.value);
213
+ if (p.status === "fulfilled") setPool(p.value.providers);
214
+ if (c.status === "fulfilled") setCheckpoints(c.value);
215
+ if (h.status === "fulfilled") setHooks(h.value);
216
+ if (cur.status === "fulfilled") setCurator(cur.value);
217
+ if (prt.status === "fulfilled") setPortal(prt.value);
218
+ if (upd.status === "fulfilled") setUpdateInfo(upd.value);
219
+ })
220
+ .finally(() => setLoading(false));
221
+ }, []);
222
+
223
+ useEffect(() => {
224
+ loadAll();
225
+ }, [loadAll]);
226
+
227
+ // ── Gateway lifecycle ──────────────────────────────────────────────
228
+ const runGateway = async (verb: "start" | "stop" | "restart") => {
229
+ try {
230
+ if (verb === "start") {
231
+ await api.startGateway();
232
+ setActiveAction("gateway-start");
233
+ } else if (verb === "stop") {
234
+ await api.stopGateway();
235
+ setActiveAction("gateway-stop");
236
+ } else {
237
+ await api.restartGateway();
238
+ setActiveAction("gateway-restart");
239
+ }
240
+ showToast(`Gateway ${verb} started`, "success");
241
+ setTimeout(loadAll, 3000);
242
+ } catch (e) {
243
+ showToast(`Gateway ${verb} failed: ${e}`, "error");
244
+ }
245
+ };
246
+
247
+ // ── Curator ────────────────────────────────────────────────────────
248
+ const toggleCuratorPaused = async () => {
249
+ if (!curator) return;
250
+ try {
251
+ await api.setCuratorPaused(!curator.paused);
252
+ showToast(curator.paused ? "Curator resumed" : "Curator paused", "success");
253
+ loadAll();
254
+ } catch (e) {
255
+ showToast(`Curator toggle failed: ${e}`, "error");
256
+ }
257
+ };
258
+
259
+ // ── Memory ─────────────────────────────────────────────────────────
260
+ // Memory provider selection lives on the /plugins page now (see the
261
+ // read-only display + link below); the dropdown was intentionally
262
+ // dropped from this card during the admin-panel refresh.
263
+ const memoryReset = useConfirmDelete({
264
+ onDelete: useCallback(
265
+ async (target: string) => {
266
+ try {
267
+ const res = await api.resetMemory(
268
+ target as "all" | "memory" | "user",
269
+ );
270
+ showToast(`Reset: ${res.deleted.join(", ") || "nothing"}`, "success");
271
+ loadAll();
272
+ } catch (e) {
273
+ showToast(`Reset failed: ${e}`, "error");
274
+ throw e;
275
+ }
276
+ },
277
+ [loadAll, showToast],
278
+ ),
279
+ });
280
+
281
+ // ── Credential pool ────────────────────────────────────────────────
282
+ const addCredential = async () => {
283
+ if (!credProvider.trim() || !credKey.trim()) {
284
+ showToast("Provider and API key required", "error");
285
+ return;
286
+ }
287
+ setAddingCred(true);
288
+ try {
289
+ await api.addCredentialPoolEntry(
290
+ credProvider.trim(),
291
+ credKey.trim(),
292
+ credLabel.trim() || undefined,
293
+ );
294
+ showToast("Credential added", "success");
295
+ setCredKey("");
296
+ setCredLabel("");
297
+ loadAll();
298
+ } catch (e) {
299
+ showToast(`Failed to add credential: ${e}`, "error");
300
+ } finally {
301
+ setAddingCred(false);
302
+ }
303
+ };
304
+
305
+ const credDelete = useConfirmDelete({
306
+ onDelete: useCallback(
307
+ async (key: string) => {
308
+ const [provider, idxStr] = key.split("|");
309
+ try {
310
+ await api.removeCredentialPoolEntry(provider, Number(idxStr));
311
+ showToast("Credential removed", "success");
312
+ loadAll();
313
+ } catch (e) {
314
+ showToast(`Failed to remove: ${e}`, "error");
315
+ throw e;
316
+ }
317
+ },
318
+ [loadAll, showToast],
319
+ ),
320
+ });
321
+
322
+ // ── Operations ─────────────────────────────────────────────────────
323
+ const runOp = async (fn: () => Promise<{ name: string }>, label: string) => {
324
+ try {
325
+ const res = await fn();
326
+ setActiveAction(res.name);
327
+ showToast(`${label} started`, "success");
328
+ } catch (e) {
329
+ showToast(`${label} failed: ${e}`, "error");
330
+ }
331
+ };
332
+
333
+ // ── Debug share ────────────────────────────────────────────────────
334
+ // Unlike the fire-and-forget ops above, `debug share` produces shareable
335
+ // paste URLs that are the whole point — so we surface them as real,
336
+ // copyable links rather than a log tail.
337
+ const [shareRedact, setShareRedact] = useState(true);
338
+ const [sharing, setSharing] = useState(false);
339
+ const [shareResult, setShareResult] = useState<DebugShareResponse | null>(
340
+ null,
341
+ );
342
+ const [copiedLabel, setCopiedLabel] = useState<string | null>(null);
343
+
344
+ const copyToClipboard = useCallback(
345
+ async (text: string, label: string) => {
346
+ try {
347
+ await navigator.clipboard.writeText(text);
348
+ setCopiedLabel(label);
349
+ setTimeout(
350
+ () => setCopiedLabel((cur) => (cur === label ? null : cur)),
351
+ 1500,
352
+ );
353
+ } catch {
354
+ showToast("Couldn't copy to clipboard", "error");
355
+ }
356
+ },
357
+ [showToast],
358
+ );
359
+
360
+ const runDebugShare = useCallback(async () => {
361
+ setSharing(true);
362
+ setShareResult(null);
363
+ try {
364
+ const res = await api.runDebugShare({ redact: shareRedact });
365
+ setShareResult(res);
366
+ const n = Object.keys(res.urls).length;
367
+ showToast(
368
+ `Uploaded ${n} paste${n === 1 ? "" : "s"}${
369
+ res.redacted ? " (redacted)" : ""
370
+ }`,
371
+ "success",
372
+ );
373
+ } catch (e) {
374
+ showToast(`Debug share failed: ${e}`, "error");
375
+ } finally {
376
+ setSharing(false);
377
+ }
378
+ }, [shareRedact, showToast]);
379
+
380
+
381
+ // ── Update check / apply ───────────────────────────────────────────
382
+ const checkForUpdate = useCallback(
383
+ async (force = false) => {
384
+ setCheckingUpdate(true);
385
+ try {
386
+ const info = await api.checkNasTechUpdate(force);
387
+ setUpdateInfo(info);
388
+ if (force) {
389
+ if (info.update_available) {
390
+ showToast(
391
+ info.behind && info.behind > 0
392
+ ? `Update available — ${info.behind} commit${info.behind === 1 ? "" : "s"} behind`
393
+ : "Update available",
394
+ "success",
395
+ );
396
+ } else if (info.behind === 0) {
397
+ showToast("You're on the latest version", "success");
398
+ } else if (info.message) {
399
+ showToast(info.message, "error");
400
+ }
401
+ }
402
+ } catch (e) {
403
+ showToast(`Update check failed: ${e}`, "error");
404
+ } finally {
405
+ setCheckingUpdate(false);
406
+ }
407
+ },
408
+ [showToast],
409
+ );
410
+
411
+ // Auto-check (cached) runs inside loadAll on mount; this is the
412
+ // user-triggered forced re-check from the "Check for updates" button.
413
+ const applyUpdate = async () => {
414
+ setUpdateConfirmOpen(false);
415
+ try {
416
+ const resp = await api.updateNasTech();
417
+ if (!resp.ok && resp.error === "docker_update_unsupported") {
418
+ showToast(
419
+ resp.message ??
420
+ "Updates don't apply inside Docker — re-pull the image instead.",
421
+ "error",
422
+ );
423
+ return;
424
+ }
425
+ setActiveAction(resp.name ?? "nastech-update");
426
+ showToast("Update started", "success");
427
+ } catch (e) {
428
+ showToast(`Update failed: ${e}`, "error");
429
+ }
430
+ };
431
+
432
+ const checkpointsPrune = useConfirmDelete({
433
+ onDelete: useCallback(async () => {
434
+ try {
435
+ const res = await api.pruneCheckpoints();
436
+ setActiveAction(res.name);
437
+ showToast("Checkpoint prune started", "success");
438
+ } catch (e) {
439
+ showToast(`Prune failed: ${e}`, "error");
440
+ throw e;
441
+ }
442
+ }, [showToast]),
443
+ });
444
+
445
+ // ── Hooks ──────────────────────────────────────────────────────────
446
+ const createHook = async () => {
447
+ if (!hookCommand.trim()) {
448
+ showToast("Command is required", "error");
449
+ return;
450
+ }
451
+ setCreatingHook(true);
452
+ try {
453
+ await api.createHook({
454
+ event: hookEvent,
455
+ command: hookCommand.trim(),
456
+ matcher: hookMatcher.trim() || undefined,
457
+ timeout: hookTimeout.trim() ? Number(hookTimeout) : undefined,
458
+ approve: hookApprove,
459
+ });
460
+ showToast("Hook created", "success");
461
+ setHookCommand("");
462
+ setHookMatcher("");
463
+ setHookTimeout("");
464
+ setHookModalOpen(false);
465
+ loadAll();
466
+ } catch (e) {
467
+ showToast(`Failed to create hook: ${e}`, "error");
468
+ } finally {
469
+ setCreatingHook(false);
470
+ }
471
+ };
472
+
473
+ const hookDelete = useConfirmDelete({
474
+ onDelete: useCallback(
475
+ async (key: string) => {
476
+ const sep = key.indexOf("|");
477
+ const event = key.slice(0, sep);
478
+ const command = key.slice(sep + 1);
479
+ try {
480
+ await api.deleteHook(event, command);
481
+ showToast("Hook removed", "success");
482
+ loadAll();
483
+ } catch (e) {
484
+ showToast(`Failed to remove hook: ${e}`, "error");
485
+ throw e;
486
+ }
487
+ },
488
+ [loadAll, showToast],
489
+ ),
490
+ });
491
+
492
+ if (loading) {
493
+ return (
494
+ <div className="flex items-center justify-center py-24">
495
+ <Spinner className="text-2xl text-primary" />
496
+ </div>
497
+ );
498
+ }
499
+
500
+ const gatewayRunning = status?.gateway_running;
501
+ const validEvents = hooks?.valid_events?.length
502
+ ? hooks.valid_events
503
+ : HOOK_EVENTS_FALLBACK;
504
+
505
+ return (
506
+ <div className="flex flex-col gap-8">
507
+ <Toast toast={toast} />
508
+
509
+ <ConfirmDialog
510
+ open={updateConfirmOpen}
511
+ onCancel={() => setUpdateConfirmOpen(false)}
512
+ onConfirm={() => void applyUpdate()}
513
+ title="Update NasTech?"
514
+ description={
515
+ updateInfo && updateInfo.behind && updateInfo.behind > 0
516
+ ? `This will run 'nastech update' (${updateInfo.update_command}) and pull ${updateInfo.behind} new commit${updateInfo.behind === 1 ? "" : "s"}. The gateway restarts when the update finishes; the current session keeps its prompt cache until then.`
517
+ : `This will run 'nastech update' (${updateInfo?.update_command ?? "nastech update"}) and restart the gateway when it finishes.`
518
+ }
519
+ confirmLabel="Update now"
520
+ />
521
+
522
+ <DeleteConfirmDialog
523
+ open={memoryReset.isOpen}
524
+ onCancel={memoryReset.cancel}
525
+ onConfirm={memoryReset.confirm}
526
+ title="Reset memory"
527
+ description="This permanently erases the selected built-in memory files. This cannot be undone."
528
+ loading={memoryReset.isDeleting}
529
+ />
530
+ <DeleteConfirmDialog
531
+ open={credDelete.isOpen}
532
+ onCancel={credDelete.cancel}
533
+ onConfirm={credDelete.confirm}
534
+ title="Remove credential"
535
+ description="Remove this pooled API key? The agent will no longer rotate through it."
536
+ loading={credDelete.isDeleting}
537
+ />
538
+ <DeleteConfirmDialog
539
+ open={checkpointsPrune.isOpen}
540
+ onCancel={checkpointsPrune.cancel}
541
+ onConfirm={checkpointsPrune.confirm}
542
+ title="Prune checkpoints"
543
+ description="Delete the rollback checkpoint shadow store? Existing /rollback points will be lost."
544
+ loading={checkpointsPrune.isDeleting}
545
+ />
546
+ <DeleteConfirmDialog
547
+ open={hookDelete.isOpen}
548
+ onCancel={hookDelete.cancel}
549
+ onConfirm={hookDelete.confirm}
550
+ title="Remove shell hook"
551
+ description="Remove this hook from config and revoke its consent? It stops firing on the next restart."
552
+ loading={hookDelete.isDeleting}
553
+ />
554
+
555
+ {/* Create-hook modal */}
556
+ {hookModalOpen && (
557
+ <div
558
+ ref={hookModalRef}
559
+ className="fixed inset-0 z-[100] flex items-center justify-center bg-background/85 backdrop-blur-sm p-4"
560
+ onClick={(e) => e.target === e.currentTarget && setHookModalOpen(false)}
561
+ role="dialog"
562
+ aria-modal="true"
563
+ >
564
+ <div className={cn(themedBody, "relative w-full max-w-lg border border-border bg-card shadow-2xl flex flex-col")}>
565
+ <Button
566
+ ghost
567
+ size="icon"
568
+ onClick={() => setHookModalOpen(false)}
569
+ className="absolute right-2 top-2 text-muted-foreground hover:text-foreground"
570
+ aria-label="Close"
571
+ >
572
+ <X />
573
+ </Button>
574
+ <header className="p-5 pb-3 border-b border-border">
575
+ <h2 className="font-mondwest text-display text-base tracking-wider">
576
+ New shell hook
577
+ </h2>
578
+ </header>
579
+ <div className="p-5 grid gap-4">
580
+ <div className="grid gap-2">
581
+ <Label htmlFor="hook-event">Event</Label>
582
+ <Select
583
+ id="hook-event"
584
+ value={hookEvent}
585
+ onValueChange={(v) => setHookEvent(v)}
586
+ >
587
+ {validEvents.map((ev) => (
588
+ <SelectOption key={ev} value={ev}>
589
+ {ev}
590
+ </SelectOption>
591
+ ))}
592
+ </Select>
593
+ </div>
594
+ <div className="grid gap-2">
595
+ <Label htmlFor="hook-command">Command (absolute path)</Label>
596
+ <Input
597
+ id="hook-command"
598
+ autoFocus
599
+ placeholder="/usr/local/bin/my-hook.sh"
600
+ value={hookCommand}
601
+ onChange={(e) => setHookCommand(e.target.value)}
602
+ />
603
+ </div>
604
+ <div className="grid grid-cols-2 gap-4">
605
+ <div className="grid gap-2">
606
+ <Label htmlFor="hook-matcher">Matcher (optional)</Label>
607
+ <Input
608
+ id="hook-matcher"
609
+ placeholder="e.g. terminal"
610
+ value={hookMatcher}
611
+ onChange={(e) => setHookMatcher(e.target.value)}
612
+ />
613
+ </div>
614
+ <div className="grid gap-2">
615
+ <Label htmlFor="hook-timeout">Timeout (s)</Label>
616
+ <Input
617
+ id="hook-timeout"
618
+ placeholder="10"
619
+ value={hookTimeout}
620
+ onChange={(e) => setHookTimeout(e.target.value)}
621
+ />
622
+ </div>
623
+ </div>
624
+ <label className="flex items-center gap-2 text-sm text-muted-foreground">
625
+ <input
626
+ type="checkbox"
627
+ checked={hookApprove}
628
+ onChange={(e) => setHookApprove(e.target.checked)}
629
+ />
630
+ Approve now (grant consent so it fires; otherwise it stays
631
+ configured but inactive)
632
+ </label>
633
+ <p className="text-xs text-warning">
634
+ Shell hooks run arbitrary commands on this host. Only add scripts
635
+ you trust. Takes effect on the next gateway/session restart.
636
+ </p>
637
+ <div className="flex justify-end">
638
+ <Button
639
+ className="uppercase"
640
+ size="sm"
641
+ onClick={createHook}
642
+ disabled={creatingHook}
643
+ prefix={creatingHook ? <Spinner /> : undefined}
644
+ >
645
+ {creatingHook ? "Creating" : "Create hook"}
646
+ </Button>
647
+ </div>
648
+ </div>
649
+ </div>
650
+ </div>
651
+ )}
652
+
653
+ {/* Live action log */}
654
+ {activeAction && (
655
+ <ActionLogViewer
656
+ action={activeAction}
657
+ onClose={() => setActiveAction(null)}
658
+ />
659
+ )}
660
+
661
+ {/* ── Host / system stats ───────────────────────────────────── */}
662
+ <section className="flex flex-col gap-3">
663
+ <H2 variant="sm" className="flex items-center gap-2 text-muted-foreground">
664
+ <Server className="h-4 w-4" /> Host
665
+ </H2>
666
+ <Card>
667
+ <CardContent className="py-4">
668
+ <div className="grid grid-cols-2 sm:grid-cols-3 gap-y-3 gap-x-6 text-sm">
669
+ <div>
670
+ <div className="text-xs uppercase tracking-wider text-muted-foreground">OS</div>
671
+ <div>{stats?.os} {stats?.os_release}</div>
672
+ </div>
673
+ <div>
674
+ <div className="text-xs uppercase tracking-wider text-muted-foreground">Arch</div>
675
+ <div>{stats?.arch}</div>
676
+ </div>
677
+ <div>
678
+ <div className="text-xs uppercase tracking-wider text-muted-foreground">Host</div>
679
+ <div className="truncate">{stats?.hostname}</div>
680
+ </div>
681
+ <div>
682
+ <div className="text-xs uppercase tracking-wider text-muted-foreground">Python</div>
683
+ <div>{stats?.python_impl} {stats?.python_version}</div>
684
+ </div>
685
+ <div>
686
+ <div className="text-xs uppercase tracking-wider text-muted-foreground">NasTech</div>
687
+ <div className="flex items-center gap-2">
688
+ <span>v{stats?.nastech_version}</span>
689
+ {updateInfo &&
690
+ (updateInfo.update_available ? (
691
+ <Badge tone="warning">
692
+ {updateInfo.behind && updateInfo.behind > 0
693
+ ? `${updateInfo.behind} behind`
694
+ : "update available"}
695
+ </Badge>
696
+ ) : updateInfo.behind === 0 ? (
697
+ <Badge tone="success">latest</Badge>
698
+ ) : null)}
699
+ </div>
700
+ </div>
701
+ <div>
702
+ <div className="text-xs uppercase tracking-wider text-muted-foreground flex items-center gap-1">
703
+ <Cpu className="h-3 w-3" /> CPU
704
+ </div>
705
+ <div>
706
+ {stats?.cpu_count ?? "—"} cores
707
+ {typeof stats?.cpu_percent === "number"
708
+ ? ` · ${stats.cpu_percent.toFixed(0)}%`
709
+ : ""}
710
+ </div>
711
+ </div>
712
+ {stats?.memory && (
713
+ <div>
714
+ <div className="text-xs uppercase tracking-wider text-muted-foreground">Memory</div>
715
+ <div>
716
+ {formatBytes(stats.memory.used)} / {formatBytes(stats.memory.total)} ({stats.memory.percent}%)
717
+ </div>
718
+ </div>
719
+ )}
720
+ {stats?.disk && (
721
+ <div>
722
+ <div className="text-xs uppercase tracking-wider text-muted-foreground flex items-center gap-1">
723
+ <HardDrive className="h-3 w-3" /> Disk
724
+ </div>
725
+ <div>
726
+ {formatBytes(stats.disk.used)} / {formatBytes(stats.disk.total)} ({stats.disk.percent}%)
727
+ </div>
728
+ </div>
729
+ )}
730
+ {typeof stats?.uptime_seconds === "number" && (
731
+ <div>
732
+ <div className="text-xs uppercase tracking-wider text-muted-foreground">Uptime</div>
733
+ <div>{formatDuration(stats.uptime_seconds)}</div>
734
+ </div>
735
+ )}
736
+ {stats?.load_avg && stats.load_avg.length >= 3 && (
737
+ <div>
738
+ <div className="text-xs uppercase tracking-wider text-muted-foreground">Load avg</div>
739
+ <div>{stats.load_avg.map((n) => n.toFixed(2)).join(" / ")}</div>
740
+ </div>
741
+ )}
742
+ </div>
743
+ {stats && !stats.psutil && (
744
+ <p className="mt-3 text-xs text-muted-foreground">
745
+ Install the <span className="font-mono">psutil</span> extra for
746
+ CPU / memory / disk metrics.
747
+ </p>
748
+ )}
749
+ <div className="mt-4 flex flex-wrap items-center gap-2 border-t border-border pt-4">
750
+ <Button
751
+ size="sm"
752
+ ghost
753
+ disabled={checkingUpdate}
754
+ prefix={
755
+ checkingUpdate ? (
756
+ <Spinner className="h-3.5 w-3.5" />
757
+ ) : (
758
+ <RotateCw className="h-3.5 w-3.5" />
759
+ )
760
+ }
761
+ onClick={() => void checkForUpdate(true)}
762
+ >
763
+ Check for updates
764
+ </Button>
765
+ {updateInfo?.update_available && updateInfo.can_apply && (
766
+ <Button
767
+ size="sm"
768
+ prefix={<Download className="h-3.5 w-3.5" />}
769
+ onClick={() => setUpdateConfirmOpen(true)}
770
+ >
771
+ Update now
772
+ </Button>
773
+ )}
774
+ {updateInfo &&
775
+ !updateInfo.can_apply &&
776
+ updateInfo.update_available && (
777
+ <span className="text-xs text-muted-foreground">
778
+ Update with{" "}
779
+ <span className="font-mono">{updateInfo.update_command}</span>
780
+ </span>
781
+ )}
782
+ {updateInfo?.message && !updateInfo.update_available && (
783
+ <span className="text-xs text-muted-foreground">
784
+ {updateInfo.message}
785
+ </span>
786
+ )}
787
+ </div>
788
+ </CardContent>
789
+ </Card>
790
+ </section>
791
+
792
+ {/* ── Portal ────────────────────────────────────────────────── */}
793
+ <section className="flex flex-col gap-3">
794
+ <H2 variant="sm" className="flex items-center gap-2 text-muted-foreground">
795
+ <Globe className="h-4 w-4" /> NasTech Portal
796
+ </H2>
797
+ <Card>
798
+ <CardContent className="flex flex-col gap-3 py-4">
799
+ <div className="flex items-center gap-3">
800
+ <Badge tone={portal?.logged_in ? "success" : "secondary"}>
801
+ {portal?.logged_in ? "logged in" : "not logged in"}
802
+ </Badge>
803
+ {portal?.provider && (
804
+ <span className="text-sm text-muted-foreground">
805
+ inference provider: {portal.provider}
806
+ </span>
807
+ )}
808
+ <a
809
+ href={portal?.subscription_url || "https://portal.nastech.ai/manage-subscription"}
810
+ target="_blank"
811
+ rel="noreferrer"
812
+ className="ml-auto text-xs text-primary underline"
813
+ >
814
+ Manage subscription
815
+ </a>
816
+ </div>
817
+ {portal?.features && portal.features.length > 0 && (
818
+ <div className="flex flex-col gap-1 border-t border-border pt-3">
819
+ <span className="text-xs uppercase tracking-wider text-muted-foreground">
820
+ Tool Gateway routing
821
+ </span>
822
+ {portal.features.map((f) => (
823
+ <div key={f.label} className="flex items-center justify-between text-sm">
824
+ <span>{f.label}</span>
825
+ <span className="text-muted-foreground">{f.state}</span>
826
+ </div>
827
+ ))}
828
+ </div>
829
+ )}
830
+ {!portal?.logged_in && (
831
+ <p className="text-xs text-muted-foreground">
832
+ Log in with <span className="font-mono">nastech portal</span>.
833
+ </p>
834
+ )}
835
+ </CardContent>
836
+ </Card>
837
+ </section>
838
+
839
+ {/* ── Curator ───────────────────────────────────────────────── */}
840
+ <section className="flex flex-col gap-3">
841
+ <H2 variant="sm" className="flex items-center gap-2 text-muted-foreground">
842
+ <Sparkles className="h-4 w-4" /> Skill curator
843
+ </H2>
844
+ <Card>
845
+ <CardContent className="flex items-center justify-between py-4">
846
+ <div className="flex items-center gap-3">
847
+ <Badge tone={curator?.paused ? "warning" : curator?.enabled ? "success" : "secondary"}>
848
+ {curator?.paused ? "paused" : curator?.enabled ? "active" : "disabled"}
849
+ </Badge>
850
+ <span className="text-sm text-muted-foreground">
851
+ {curator?.interval_hours ? `every ${curator.interval_hours}h` : ""}
852
+ {curator?.last_run_at ? ` · last run ${new Date(curator.last_run_at).toLocaleString()}` : " · never run"}
853
+ </span>
854
+ </div>
855
+ <div className="flex items-center gap-2">
856
+ <Button size="sm" ghost onClick={toggleCuratorPaused}>
857
+ {curator?.paused ? "Resume" : "Pause"}
858
+ </Button>
859
+ <Button
860
+ size="sm"
861
+ ghost
862
+ prefix={<Play className="h-3.5 w-3.5" />}
863
+ onClick={() => runOp(api.runCurator, "Curator review")}
864
+ >
865
+ Run now
866
+ </Button>
867
+ </div>
868
+ </CardContent>
869
+ </Card>
870
+ </section>
871
+
872
+ {/* ── Gateway ───────────────────────────────────────────────── */}
873
+ <section className="flex flex-col gap-3">
874
+ <H2 variant="sm" className="flex items-center gap-2 text-muted-foreground">
875
+ <Power className="h-4 w-4" /> Gateway
876
+ </H2>
877
+ <Card>
878
+ <CardContent className="flex items-center justify-between py-4">
879
+ <div className="flex items-center gap-3">
880
+ <Badge tone={gatewayRunning ? "success" : "secondary"}>
881
+ {gatewayRunning ? "running" : "stopped"}
882
+ </Badge>
883
+ <span className="text-sm text-muted-foreground">
884
+ {status?.gateway_state ?? "—"}
885
+ {status?.gateway_pid ? ` · pid ${status.gateway_pid}` : ""}
886
+ </span>
887
+ </div>
888
+ <div className="flex items-center gap-2">
889
+ <Button
890
+ size="sm"
891
+ className="uppercase"
892
+ onClick={() => runGateway("start")}
893
+ disabled={gatewayRunning}
894
+ prefix={<Play className="h-3.5 w-3.5" />}
895
+ >
896
+ Start
897
+ </Button>
898
+ <Button
899
+ size="sm"
900
+ className="uppercase"
901
+ onClick={() => runGateway("restart")}
902
+ prefix={<RotateCw className="h-3.5 w-3.5" />}
903
+ >
904
+ Restart
905
+ </Button>
906
+ <Button
907
+ size="sm"
908
+ className="uppercase text-warning"
909
+ ghost
910
+ onClick={() => runGateway("stop")}
911
+ disabled={!gatewayRunning}
912
+ prefix={<Power className="h-3.5 w-3.5" />}
913
+ >
914
+ Stop
915
+ </Button>
916
+ </div>
917
+ </CardContent>
918
+ </Card>
919
+ </section>
920
+
921
+ {/* ── Memory ────────────────────────────────────────────────── */}
922
+ <section className="flex flex-col gap-3">
923
+ <H2 variant="sm" className="flex items-center gap-2 text-muted-foreground">
924
+ <Brain className="h-4 w-4" /> Memory
925
+ </H2>
926
+ <Card>
927
+ <CardContent className="flex flex-col gap-4 py-4">
928
+ <div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-muted-foreground">
929
+ <span>
930
+ External provider:{" "}
931
+ <span className="font-mono text-foreground">
932
+ {memory?.active || "built-in only"}
933
+ </span>
934
+ </span>
935
+ <Link to="/plugins" className="underline">
936
+ Change in Plugins →
937
+ </Link>
938
+ <span className="ml-auto">
939
+ New credentials:{" "}
940
+ <span className="font-mono">nastech memory setup</span>
941
+ </span>
942
+ </div>
943
+
944
+ <div className="flex flex-wrap items-center gap-3 border-t border-border pt-3">
945
+ <span className="text-xs text-muted-foreground">
946
+ Built-in files — MEMORY.md:{" "}
947
+ {formatBytes(memory?.builtin_files?.memory ?? 0)} · USER.md:{" "}
948
+ {formatBytes(memory?.builtin_files?.user ?? 0)}
949
+ </span>
950
+ <div className="flex items-center gap-2 ml-auto">
951
+ <Button size="sm" ghost className="text-destructive" onClick={() => memoryReset.requestDelete("memory")}>
952
+ Reset MEMORY.md
953
+ </Button>
954
+ <Button size="sm" ghost className="text-destructive" onClick={() => memoryReset.requestDelete("user")}>
955
+ Reset USER.md
956
+ </Button>
957
+ <Button size="sm" ghost className="text-destructive" onClick={() => memoryReset.requestDelete("all")}>
958
+ Reset all
959
+ </Button>
960
+ </div>
961
+ </div>
962
+ </CardContent>
963
+ </Card>
964
+ </section>
965
+
966
+ {/* ── Credential pool ───────────────────────────────────────── */}
967
+ <section className="flex flex-col gap-3">
968
+ <H2 variant="sm" className="flex items-center gap-2 text-muted-foreground">
969
+ <KeyRound className="h-4 w-4" /> Credential pool
970
+ </H2>
971
+ <Card>
972
+ <CardContent className="flex flex-col gap-4 py-4">
973
+ <div className="grid grid-cols-1 sm:grid-cols-4 gap-3 items-end">
974
+ <div className="grid gap-2">
975
+ <Label htmlFor="cred-provider">Provider</Label>
976
+ <Input id="cred-provider" value={credProvider} onChange={(e) => setCredProvider(e.target.value)} placeholder="openrouter" />
977
+ </div>
978
+ <div className="grid gap-2 sm:col-span-2">
979
+ <Label htmlFor="cred-key">API key</Label>
980
+ <Input id="cred-key" type="password" value={credKey} onChange={(e) => setCredKey(e.target.value)} placeholder="sk-…" />
981
+ </div>
982
+ <div className="grid gap-2">
983
+ <Label htmlFor="cred-label">Label</Label>
984
+ <Input id="cred-label" value={credLabel} onChange={(e) => setCredLabel(e.target.value)} placeholder="optional" />
985
+ </div>
986
+ </div>
987
+ <div className="flex justify-end">
988
+ <Button size="sm" className="uppercase" onClick={addCredential} disabled={addingCred} prefix={addingCred ? <Spinner /> : undefined}>
989
+ Add key
990
+ </Button>
991
+ </div>
992
+ {pool.length === 0 && (
993
+ <p className="text-sm text-muted-foreground">
994
+ No pooled credentials. Add one above to enable key rotation.
995
+ </p>
996
+ )}
997
+ {pool.map((prov) => (
998
+ <div key={prov.provider} className="flex flex-col gap-2">
999
+ <span className="text-xs uppercase tracking-wider text-muted-foreground">
1000
+ {prov.provider}
1001
+ </span>
1002
+ {prov.entries.map((entry) => (
1003
+ <div key={`${prov.provider}-${entry.index}`} className="flex items-center gap-3 border border-border bg-background/40 px-3 py-2">
1004
+ <span className="text-sm font-medium">{entry.label}</span>
1005
+ <span className="font-mono text-xs text-muted-foreground">{entry.token_preview}</span>
1006
+ <Badge tone="outline">{entry.auth_type}</Badge>
1007
+ {entry.last_status && <Badge tone="secondary">{entry.last_status}</Badge>}
1008
+ <Button ghost size="icon" className="ml-auto text-destructive" aria-label="Remove credential" onClick={() => credDelete.requestDelete(`${prov.provider}|${entry.index}`)}>
1009
+ <Trash2 />
1010
+ </Button>
1011
+ </div>
1012
+ ))}
1013
+ </div>
1014
+ ))}
1015
+ </CardContent>
1016
+ </Card>
1017
+ </section>
1018
+
1019
+ {/* ── Operations ────────────────────────────────────────────── */}
1020
+ <section className="flex flex-col gap-3">
1021
+ <H2 variant="sm" className="flex items-center gap-2 text-muted-foreground">
1022
+ <Activity className="h-4 w-4" /> Operations
1023
+ </H2>
1024
+ <Card>
1025
+ <CardContent className="flex flex-wrap gap-2 py-4">
1026
+ <Button size="sm" ghost prefix={<Stethoscope className="h-3.5 w-3.5" />} onClick={() => runOp(api.runDoctor, "Doctor")}>
1027
+ Run doctor
1028
+ </Button>
1029
+ <Button size="sm" ghost prefix={<ShieldCheck className="h-3.5 w-3.5" />} onClick={() => runOp(api.runSecurityAudit, "Security audit")}>
1030
+ Security audit
1031
+ </Button>
1032
+ <Button size="sm" ghost prefix={<Database className="h-3.5 w-3.5" />} onClick={() => runOp(() => api.runBackup(), "Backup")}>
1033
+ Create backup
1034
+ </Button>
1035
+ <Button size="sm" ghost prefix={<RotateCw className="h-3.5 w-3.5" />} onClick={() => runOp(api.updateSkillsFromHub, "Skills update")}>
1036
+ Update skills
1037
+ </Button>
1038
+ <Button size="sm" ghost prefix={<Activity className="h-3.5 w-3.5" />} onClick={() => runOp(api.runPromptSize, "Prompt size")}>
1039
+ Prompt size
1040
+ </Button>
1041
+ <Button size="sm" ghost prefix={<Database className="h-3.5 w-3.5" />} onClick={() => runOp(api.runDump, "Support dump")}>
1042
+ Support dump
1043
+ </Button>
1044
+ <Button size="sm" ghost prefix={<RotateCw className="h-3.5 w-3.5" />} onClick={() => runOp(api.runConfigMigrate, "Config migrate")}>
1045
+ Migrate config
1046
+ </Button>
1047
+ </CardContent>
1048
+ </Card>
1049
+
1050
+ {/* Debug share — uploads a redacted report + logs, returns shareable
1051
+ links. Separated from the buttons above because its output is
1052
+ persistent, copyable URLs, not a fire-and-forget log tail. */}
1053
+ <Card>
1054
+ <CardContent className="flex flex-col gap-3 py-4">
1055
+ <div className="flex flex-wrap items-center justify-between gap-3">
1056
+ <div className="flex items-start gap-2">
1057
+ <Share2 className="h-4 w-4 mt-0.5 text-muted-foreground" />
1058
+ <div className="flex flex-col">
1059
+ <span className="text-sm font-medium">Share debug report</span>
1060
+ <span className="text-xs text-muted-foreground max-w-prose">
1061
+ Uploads system info + logs to a public paste service and
1062
+ returns links to send the NasTech team. Pastes auto-delete
1063
+ after 6 hours.
1064
+ </span>
1065
+ </div>
1066
+ </div>
1067
+ <Button
1068
+ size="sm"
1069
+ disabled={sharing}
1070
+ prefix={
1071
+ sharing ? (
1072
+ <Spinner className="h-3.5 w-3.5" />
1073
+ ) : (
1074
+ <Share2 className="h-3.5 w-3.5" />
1075
+ )
1076
+ }
1077
+ onClick={() => void runDebugShare()}
1078
+ >
1079
+ {sharing ? "Uploading…" : "Generate share link"}
1080
+ </Button>
1081
+ </div>
1082
+
1083
+ <label className="flex items-center gap-2 text-xs text-muted-foreground select-none">
1084
+ <input
1085
+ type="checkbox"
1086
+ className="accent-current"
1087
+ checked={shareRedact}
1088
+ disabled={sharing}
1089
+ onChange={(e) => setShareRedact(e.target.checked)}
1090
+ />
1091
+ Redact credential-shaped tokens before upload (recommended)
1092
+ </label>
1093
+
1094
+ {shareResult && (
1095
+ <div className="flex flex-col gap-2 border-t border-border pt-3">
1096
+ <div className="flex items-center justify-between">
1097
+ <div className="flex items-center gap-2">
1098
+ <Badge tone="success">uploaded</Badge>
1099
+ {shareResult.redacted ? (
1100
+ <Badge tone="outline">redacted</Badge>
1101
+ ) : (
1102
+ <Badge tone="warning">not redacted</Badge>
1103
+ )}
1104
+ <span className="flex items-center gap-1 text-xs text-muted-foreground">
1105
+ <Clock className="h-3 w-3" />
1106
+ auto-deletes in{" "}
1107
+ {Math.round(shareResult.auto_delete_seconds / 3600)}h
1108
+ </span>
1109
+ </div>
1110
+ {Object.keys(shareResult.urls).length > 1 && (
1111
+ <Button
1112
+ size="sm"
1113
+ ghost
1114
+ prefix={
1115
+ copiedLabel === "__all__" ? (
1116
+ <Check className="h-3.5 w-3.5" />
1117
+ ) : (
1118
+ <Copy className="h-3.5 w-3.5" />
1119
+ )
1120
+ }
1121
+ onClick={() =>
1122
+ void copyToClipboard(
1123
+ Object.entries(shareResult.urls)
1124
+ .map(([label, url]) => `${label}: ${url}`)
1125
+ .join("\n"),
1126
+ "__all__",
1127
+ )
1128
+ }
1129
+ >
1130
+ Copy all
1131
+ </Button>
1132
+ )}
1133
+ </div>
1134
+
1135
+ {Object.entries(shareResult.urls).map(([label, url]) => (
1136
+ <div
1137
+ key={label}
1138
+ className="flex items-center gap-2 bg-background/50 border border-border px-3 py-2"
1139
+ >
1140
+ <Link2 className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
1141
+ <span className="font-mono text-xs shrink-0 w-24 truncate text-muted-foreground">
1142
+ {label}
1143
+ </span>
1144
+ <a
1145
+ href={url}
1146
+ target="_blank"
1147
+ rel="noreferrer"
1148
+ className="font-mono text-xs truncate flex-1 text-primary hover:underline"
1149
+ >
1150
+ {url}
1151
+ </a>
1152
+ <Button
1153
+ ghost
1154
+ size="icon"
1155
+ aria-label={`Copy ${label} link`}
1156
+ onClick={() => void copyToClipboard(url, label)}
1157
+ >
1158
+ {copiedLabel === label ? <Check /> : <Copy />}
1159
+ </Button>
1160
+ </div>
1161
+ ))}
1162
+
1163
+ {shareResult.failures.length > 0 && (
1164
+ <span className="text-xs text-destructive">
1165
+ Some logs failed to upload: {shareResult.failures.join("; ")}
1166
+ </span>
1167
+ )}
1168
+ </div>
1169
+ )}
1170
+ </CardContent>
1171
+ </Card>
1172
+ <Card>
1173
+ <CardContent className="flex flex-col gap-3 py-4 sm:flex-row sm:items-end">
1174
+ <div className="grid gap-2 flex-1">
1175
+ <Label htmlFor="import-path">Restore from backup archive</Label>
1176
+ <Input id="import-path" value={importPath} onChange={(e) => setImportPath(e.target.value)} placeholder="/path/to/nastech-backup.zip" />
1177
+ </div>
1178
+ <Button
1179
+ size="sm"
1180
+ ghost
1181
+ disabled={!importPath.trim()}
1182
+ onClick={() => {
1183
+ if (!importPath.trim()) return;
1184
+ runOp(() => api.runImport(importPath.trim()), "Import");
1185
+ }}
1186
+ >
1187
+ Import
1188
+ </Button>
1189
+ </CardContent>
1190
+ </Card>
1191
+ </section>
1192
+
1193
+ {/* ── Checkpoints ───────────────────────────────────────────── */}
1194
+ <section className="flex flex-col gap-3">
1195
+ <H2 variant="sm" className="flex items-center gap-2 text-muted-foreground">
1196
+ <Database className="h-4 w-4" /> Checkpoints
1197
+ </H2>
1198
+ <Card>
1199
+ <CardContent className="flex items-center justify-between py-4">
1200
+ <span className="text-sm text-muted-foreground">
1201
+ {checkpoints?.sessions.length ?? 0} session(s) ·{" "}
1202
+ {formatBytes(checkpoints?.total_bytes ?? 0)}
1203
+ </span>
1204
+ <Button size="sm" ghost className="text-destructive" disabled={!checkpoints?.sessions.length} prefix={<Trash2 className="h-3.5 w-3.5" />} onClick={() => checkpointsPrune.requestDelete("all")}>
1205
+ Prune
1206
+ </Button>
1207
+ </CardContent>
1208
+ </Card>
1209
+ </section>
1210
+
1211
+ {/* ── Shell hooks ───────────────────────────────────────────── */}
1212
+ <section className="flex flex-col gap-3">
1213
+ <div className="flex items-center justify-between">
1214
+ <H2 variant="sm" className="flex items-center gap-2 text-muted-foreground">
1215
+ <Terminal className="h-4 w-4" /> Shell hooks
1216
+ </H2>
1217
+ <Button size="sm" className="uppercase" prefix={<Plus className="h-3.5 w-3.5" />} onClick={() => setHookModalOpen(true)}>
1218
+ New hook
1219
+ </Button>
1220
+ </div>
1221
+ {(!hooks || hooks.hooks.length === 0) && (
1222
+ <Card>
1223
+ <CardContent className="py-6 text-center text-sm text-muted-foreground">
1224
+ No shell hooks configured.
1225
+ </CardContent>
1226
+ </Card>
1227
+ )}
1228
+ {hooks?.hooks.map((h: HookEntry, i) => (
1229
+ <Card key={`${h.event}-${i}`}>
1230
+ <CardContent className="flex items-center gap-3 py-3">
1231
+ <Badge tone="outline">{h.event}</Badge>
1232
+ {h.matcher && (
1233
+ <span className="text-xs text-muted-foreground">matcher: {h.matcher}</span>
1234
+ )}
1235
+ <span className="font-mono text-xs truncate flex-1">{h.command}</span>
1236
+ {h.executable === false && (
1237
+ <Badge tone="destructive">not executable</Badge>
1238
+ )}
1239
+ <Badge tone={h.allowed ? "success" : "warning"}>
1240
+ {h.allowed ? "allowed" : "not approved"}
1241
+ </Badge>
1242
+ <Button
1243
+ ghost
1244
+ size="icon"
1245
+ className="text-destructive"
1246
+ aria-label="Remove hook"
1247
+ onClick={() =>
1248
+ hookDelete.requestDelete(`${h.event}|${h.command ?? ""}`)
1249
+ }
1250
+ >
1251
+ <Trash2 />
1252
+ </Button>
1253
+ </CardContent>
1254
+ </Card>
1255
+ ))}
1256
+ </section>
1257
+ </div>
1258
+ );
1259
+ }