Skip to content

Commit a5cdc7e

Browse files
authored
feat(admin): add dev-only nuke all button to KiloClaw admin page (#1321)
## Summary Add a "Nuke All" button to the KiloClaw admin instances page that destroys all active KiloClaw instances, including their Fly machines via the worker. The button and its backend endpoint are both gated to development mode only (`process.env.NODE_ENV !== 'development'`). - Backend: new `devNukeAll` mutation on `adminKiloclawInstancesRouter` that iterates active instances, marks each destroyed in Postgres, then calls `client.destroy()` on the worker. Uses the same mark-then-revert pattern as the existing single-instance `destroy` endpoint — if the worker call fails, the DB row is restored so state stays consistent. - Frontend: `DevNukeAllButton` component renders `null` outside development mode. Shows a destructive button with confirmation dialog. Reports results including any partial failures. ## Verification - [x] `pnpm typecheck` — 28 packages pass, 0 failures - [x] `pnpm format` — no formatting issues - [x] `git push` — pre-push hooks (format:check, lint, typecheck) all pass ## Visual Changes N/A ## Reviewer Notes - The button is tree-shaken from production builds via the `process.env.NODE_ENV !== 'development'` early return. The server endpoint also hard-gates with the same check and returns `FORBIDDEN`. - The destroy loop is sequential per instance to avoid overwhelming the worker. Each instance is independently mark/destroy/revert so partial failures don't affect other instances.
2 parents 8086ddb + 646d71a commit a5cdc7e

2 files changed

Lines changed: 119 additions & 2 deletions

File tree

src/app/admin/components/KiloclawInstances/KiloclawInstancesPage.tsx

Lines changed: 74 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
import { useCallback, useMemo, useState } from 'react';
44
import { useRouter, useSearchParams } from 'next/navigation';
5-
import { useQuery } from '@tanstack/react-query';
5+
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
66
import { useTRPC } from '@/lib/trpc/utils';
77
import {
88
Table,
@@ -23,7 +23,17 @@ import {
2323
SelectValue,
2424
} from '@/components/ui/select';
2525
import { Badge } from '@/components/ui/badge';
26-
import { ChevronLeft, ChevronRight, X } from 'lucide-react';
26+
import {
27+
AlertDialog,
28+
AlertDialogAction,
29+
AlertDialogCancel,
30+
AlertDialogContent,
31+
AlertDialogDescription,
32+
AlertDialogFooter,
33+
AlertDialogHeader,
34+
AlertDialogTitle,
35+
} from '@/components/ui/alert-dialog';
36+
import { ChevronLeft, ChevronRight, X, Bomb } from 'lucide-react';
2737
import Link from 'next/link';
2838
import { formatDistanceToNow, format, parseISO } from 'date-fns';
2939
import {
@@ -219,6 +229,66 @@ function DailyChart({ data }: { data: DailyChartData[] }) {
219229
);
220230
}
221231

232+
// --- Dev Nuke All Button ---
233+
234+
function DevNukeAllButton() {
235+
if (process.env.NODE_ENV !== 'development') return null;
236+
237+
const trpc = useTRPC();
238+
const queryClient = useQueryClient();
239+
const [open, setOpen] = useState(false);
240+
241+
const nukeAll = useMutation(
242+
trpc.admin.kiloclawInstances.devNukeAll.mutationOptions({
243+
onSuccess(data) {
244+
void queryClient.invalidateQueries({
245+
queryKey: trpc.admin.kiloclawInstances.list.queryKey(),
246+
});
247+
void queryClient.invalidateQueries({
248+
queryKey: trpc.admin.kiloclawInstances.stats.queryKey(),
249+
});
250+
const errorSuffix =
251+
data.errors.length > 0
252+
? `\n${data.errors.length} failed:\n${data.errors.map(e => ` ${e.userId}: ${e.error}`).join('\n')}`
253+
: '';
254+
alert(`Destroyed ${data.destroyed}/${data.total} instances${errorSuffix}`);
255+
},
256+
})
257+
);
258+
259+
return (
260+
<>
261+
<Button variant="destructive" onClick={() => setOpen(true)} disabled={nukeAll.isPending}>
262+
<Bomb className="mr-2 h-4 w-4" />
263+
{nukeAll.isPending ? 'Nuking...' : 'Nuke All'}
264+
</Button>
265+
<AlertDialog open={open} onOpenChange={setOpen}>
266+
<AlertDialogContent>
267+
<AlertDialogHeader>
268+
<AlertDialogTitle>Nuke all KiloClaw instances?</AlertDialogTitle>
269+
<AlertDialogDescription>
270+
This will destroy every active KiloClaw instance. This action cannot be undone. Only
271+
available in development mode.
272+
</AlertDialogDescription>
273+
</AlertDialogHeader>
274+
<AlertDialogFooter>
275+
<AlertDialogCancel>Cancel</AlertDialogCancel>
276+
<AlertDialogAction
277+
onClick={() => {
278+
nukeAll.mutate();
279+
setOpen(false);
280+
}}
281+
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
282+
>
283+
Nuke All
284+
</AlertDialogAction>
285+
</AlertDialogFooter>
286+
</AlertDialogContent>
287+
</AlertDialog>
288+
</>
289+
);
290+
}
291+
222292
// --- Main Page ---
223293

224294
export function KiloclawInstancesPage() {
@@ -378,6 +448,8 @@ export function KiloclawInstancesPage() {
378448
<SelectItem value="destroyed">Destroyed Only</SelectItem>
379449
</SelectContent>
380450
</Select>
451+
452+
<DevNukeAllButton />
381453
</div>
382454

383455
{/* Table */}

src/routers/admin-kiloclaw-instances-router.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -637,6 +637,51 @@ export const adminKiloclawInstancesRouter = createTRPCRouter({
637637
}
638638
}),
639639

640+
devNukeAll: adminProcedure.mutation(async ({ ctx }) => {
641+
if (process.env.NODE_ENV !== 'development') {
642+
throw new TRPCError({
643+
code: 'FORBIDDEN',
644+
message: 'This endpoint is only available in development mode',
645+
});
646+
}
647+
648+
const activeInstances = await db
649+
.select({
650+
id: kiloclaw_instances.id,
651+
user_id: kiloclaw_instances.user_id,
652+
})
653+
.from(kiloclaw_instances)
654+
.where(isNull(kiloclaw_instances.destroyed_at));
655+
656+
console.log(
657+
`[admin-kiloclaw] DevNukeAll triggered by admin ${ctx.user.id} (${ctx.user.google_user_email}): ${activeInstances.length} active instances`
658+
);
659+
660+
const client = new KiloClawInternalClient();
661+
let destroyed = 0;
662+
const errors: Array<{ userId: string; error: string }> = [];
663+
664+
for (const instance of activeInstances) {
665+
const destroyedRow = await markActiveInstanceDestroyed(instance.user_id);
666+
try {
667+
await client.destroy(instance.user_id);
668+
destroyed++;
669+
} catch (err) {
670+
if (destroyedRow) {
671+
await restoreDestroyedInstance(destroyedRow.id);
672+
}
673+
const message = err instanceof Error ? err.message : 'Unknown error';
674+
errors.push({ userId: instance.user_id, error: message });
675+
console.error(
676+
`[admin-kiloclaw] DevNukeAll: failed to destroy instance ${instance.id} (user: ${instance.user_id}):`,
677+
err
678+
);
679+
}
680+
}
681+
682+
return { total: activeInstances.length, destroyed, errors };
683+
}),
684+
640685
reassociateVolume: adminProcedure
641686
.input(
642687
z.object({

0 commit comments

Comments
 (0)