@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.
- package/eslint.config.js +23 -0
- package/index.html +24 -0
- package/package.json +54 -26
- package/package.json.bak +89 -0
- package/package.json.pub +88 -0
- package/src/App.tsx +1173 -0
- package/src/components/AuthWidget.tsx +150 -0
- package/src/components/AutoField.tsx +206 -0
- package/src/components/Backdrop.tsx +93 -0
- package/src/components/ChatSidebar.tsx +394 -0
- package/src/components/DeleteConfirmDialog.tsx +40 -0
- package/src/components/LanguageSwitcher.tsx +186 -0
- package/src/components/Markdown.tsx +383 -0
- package/src/components/ModelInfoCard.tsx +112 -0
- package/src/components/ModelPickerDialog.tsx +470 -0
- package/src/components/OAuthLoginModal.tsx +374 -0
- package/src/components/OAuthProvidersCard.tsx +287 -0
- package/src/components/PlatformsCard.tsx +97 -0
- package/src/components/ScheduleBuilder.tsx +273 -0
- package/src/components/SidebarFooter.tsx +42 -0
- package/src/components/SidebarStatusStrip.tsx +72 -0
- package/src/components/SlashPopover.tsx +171 -0
- package/src/components/ThemeSwitcher.tsx +243 -0
- package/src/components/ToolCall.tsx +228 -0
- package/src/components/ToolsetConfigDrawer.tsx +448 -0
- package/src/contexts/PageHeaderProvider.tsx +139 -0
- package/src/contexts/SystemActions.tsx +120 -0
- package/src/contexts/page-header-context.ts +12 -0
- package/src/contexts/system-actions-context.ts +18 -0
- package/src/contexts/usePageHeader.ts +10 -0
- package/src/contexts/useSystemActions.ts +15 -0
- package/src/hooks/useModalBehavior.ts +44 -0
- package/src/hooks/useSidebarStatus.ts +27 -0
- package/src/i18n/af.ts +702 -0
- package/src/i18n/context.tsx +123 -0
- package/src/i18n/de.ts +701 -0
- package/src/i18n/en.ts +708 -0
- package/src/i18n/es.ts +701 -0
- package/src/i18n/fr.ts +701 -0
- package/src/i18n/ga.ts +702 -0
- package/src/i18n/hu.ts +702 -0
- package/src/i18n/index.ts +2 -0
- package/src/i18n/it.ts +701 -0
- package/src/i18n/ja.ts +702 -0
- package/src/i18n/ko.ts +702 -0
- package/src/i18n/pt.ts +702 -0
- package/src/i18n/ru.ts +702 -0
- package/src/i18n/tr.ts +702 -0
- package/src/i18n/types.ts +710 -0
- package/src/i18n/uk.ts +702 -0
- package/src/i18n/zh-hant.ts +702 -0
- package/src/i18n/zh.ts +698 -0
- package/src/index.css +274 -0
- package/src/lib/api.ts +1585 -0
- package/src/lib/dashboard-flags.ts +15 -0
- package/src/lib/format.ts +9 -0
- package/src/lib/fuzzy.ts +192 -0
- package/src/lib/gatewayClient.ts +253 -0
- package/src/lib/nested.ts +23 -0
- package/src/lib/resolve-page-title.ts +41 -0
- package/src/lib/schedule.ts +382 -0
- package/src/lib/slashExec.ts +163 -0
- package/src/lib/utils.ts +35 -0
- package/src/main.tsx +25 -0
- package/src/pages/AnalyticsPage.tsx +601 -0
- package/src/pages/ChannelsPage.tsx +772 -0
- package/src/pages/ChatPage.tsx +889 -0
- package/src/pages/ConfigPage.tsx +660 -0
- package/src/pages/CronPage.tsx +524 -0
- package/src/pages/DocsPage.tsx +69 -0
- package/src/pages/EnvPage.tsx +918 -0
- package/src/pages/LogsPage.tsx +246 -0
- package/src/pages/McpPage.tsx +757 -0
- package/src/pages/ModelsPage.tsx +994 -0
- package/src/pages/PairingPage.tsx +276 -0
- package/src/pages/PluginsPage.tsx +580 -0
- package/src/pages/ProfilesPage.tsx +559 -0
- package/src/pages/SessionsPage.tsx +936 -0
- package/src/pages/SkillsPage.tsx +557 -0
- package/src/pages/SystemPage.tsx +1259 -0
- package/src/pages/WebhooksPage.tsx +483 -0
- package/src/plugins/PluginPage.tsx +64 -0
- package/src/plugins/index.ts +6 -0
- package/src/plugins/registry.ts +151 -0
- package/src/plugins/sdk.d.ts +160 -0
- package/src/plugins/slots.ts +199 -0
- package/src/plugins/types.ts +37 -0
- package/src/plugins/usePlugins.ts +133 -0
- package/src/themes/context.tsx +443 -0
- package/src/themes/fonts.ts +160 -0
- package/src/themes/index.ts +3 -0
- package/src/themes/presets.ts +477 -0
- package/src/themes/types.ts +187 -0
- package/tsconfig.app.json +34 -0
- package/tsconfig.json +7 -0
- package/tsconfig.node.json +26 -0
- package/vite.config.ts +124 -0
- 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
|
+
}
|