Skip to content

Commit d82f719

Browse files
authored
feat(admin): add DO & reconcile events card to kiloclaw instance detail (#1392)
## Summary Add a "DO & Reconcile Events" card to the KiloClaw admin instance detail page that queries Cloudflare Analytics Engine for the 20 most recent `do` and `reconcile` delivery events for a given instance. - New API route (`/admin/api/kiloclaw-analytics`) queries the `kiloclaw_events` AE dataset, reusing existing `R2_ACCOUNT_ID` and `CF_ANALYTICS_ENGINE_TOKEN` credentials (same as gastown-analytics). - New React Query hook (`useKiloclawInstanceEvents`) auto-refreshes every 60s. - The card renders a table with Time, Event, Delivery (color-coded badge), Status, Label, Duration, and Error columns. Placed after the "Live Worker Status" card on the instance detail page. - `sandboxId` is validated against base64url charset before SQL interpolation to prevent injection. ## Verification - [x] `pnpm typecheck` — pass (root + all 29 workspace projects) - [x] `pnpm format` — pass (oxfmt) - [x] `pnpm lint` — pass (oxlint + eslint across all packages) - [x] Pre-push hooks (format:check, lint, typecheck) — pass - [ ] Manual verification against live Analytics Engine data ## Visual Changes | Before | After | | ------ | ----- | | No events card on instance detail page | New "DO & Reconcile Events" card with table between Live Worker Status and Machine Controls | ## Reviewer Notes - Follows the established `gastown-analytics` pattern (API route + hooks + component) exactly. - The query filters on `blob8` (sandboxId) and `blob3 IN ('do', 'reconcile')` with `LIMIT 20`. - No new env vars needed — reuses the existing CF Analytics Engine credentials.
2 parents f7a8243 + 9e31644 commit d82f719

3 files changed

Lines changed: 280 additions & 0 deletions

File tree

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
'use client';
2+
3+
import { useQuery } from '@tanstack/react-query';
4+
5+
export type KiloclawEventRow = {
6+
timestamp: string;
7+
event: string;
8+
delivery: string;
9+
route: string;
10+
error: string;
11+
fly_app_name: string;
12+
fly_machine_id: string;
13+
status: string;
14+
openclaw_version: string;
15+
image_tag: string;
16+
fly_region: string;
17+
label: string;
18+
duration_ms: number;
19+
value: number;
20+
};
21+
22+
type AnalyticsEngineResponse<T> = {
23+
data: T[];
24+
meta: { name: string; type: string }[];
25+
rows: number;
26+
};
27+
28+
export function useKiloclawInstanceEvents(sandboxId: string) {
29+
return useQuery<AnalyticsEngineResponse<KiloclawEventRow>>({
30+
queryKey: ['kiloclaw-analytics', 'instance-events', sandboxId],
31+
queryFn: async () => {
32+
const response = await fetch(
33+
`/admin/api/kiloclaw-analytics?query=instance-events&sandboxId=${encodeURIComponent(sandboxId)}`
34+
);
35+
if (!response.ok) {
36+
throw new Error('Failed to fetch kiloclaw instance events');
37+
}
38+
return response.json() as Promise<AnalyticsEngineResponse<KiloclawEventRow>>;
39+
},
40+
enabled: !!sandboxId,
41+
refetchInterval: 60000,
42+
});
43+
}
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
import type { NextRequest } from 'next/server';
2+
import { NextResponse } from 'next/server';
3+
import { getUserFromAuth } from '@/lib/user.server';
4+
import { getEnvVariable } from '@/lib/dotenvx';
5+
6+
type QueryType = 'instance-events';
7+
8+
const validQueryTypes = new Set<QueryType>(['instance-events']);
9+
10+
function buildQuery(queryType: QueryType, sandboxId: string): string {
11+
// sandboxId is validated as base64url [A-Za-z0-9_-]+ before reaching here
12+
switch (queryType) {
13+
case 'instance-events':
14+
return `SELECT
15+
timestamp,
16+
blob1 AS event,
17+
blob3 AS delivery,
18+
blob4 AS route,
19+
blob5 AS error,
20+
blob6 AS fly_app_name,
21+
blob7 AS fly_machine_id,
22+
blob9 AS status,
23+
blob10 AS openclaw_version,
24+
blob11 AS image_tag,
25+
blob12 AS fly_region,
26+
blob13 AS label,
27+
double1 AS duration_ms,
28+
double2 AS value
29+
FROM kiloclaw_events
30+
WHERE
31+
blob8 = '${sandboxId}'
32+
AND (blob3 = 'do' OR blob3 = 'reconcile')
33+
ORDER BY timestamp DESC
34+
LIMIT 20
35+
FORMAT JSON`;
36+
}
37+
}
38+
39+
type AnalyticsEngineResponse = {
40+
data: Record<string, unknown>[];
41+
meta: { name: string; type: string }[];
42+
rows: number;
43+
};
44+
45+
export async function GET(
46+
request: NextRequest
47+
): Promise<NextResponse<{ error: string } | AnalyticsEngineResponse>> {
48+
const { authFailedResponse } = await getUserFromAuth({ adminOnly: true });
49+
if (authFailedResponse) {
50+
return authFailedResponse;
51+
}
52+
53+
const { searchParams } = new URL(request.url);
54+
const queryType = searchParams.get('query');
55+
const sandboxId = searchParams.get('sandboxId');
56+
57+
if (!queryType || !validQueryTypes.has(queryType as QueryType)) {
58+
return NextResponse.json(
59+
{ error: `Invalid query type. Must be one of: ${[...validQueryTypes].join(', ')}` },
60+
{ status: 400 }
61+
);
62+
}
63+
64+
if (!sandboxId || !/^[A-Za-z0-9_-]+$/.test(sandboxId)) {
65+
return NextResponse.json({ error: 'Invalid or missing sandboxId' }, { status: 400 });
66+
}
67+
68+
const accountId = getEnvVariable('R2_ACCOUNT_ID');
69+
const token = getEnvVariable('CF_ANALYTICS_ENGINE_TOKEN');
70+
71+
if (!accountId || !token) {
72+
return NextResponse.json(
73+
{ error: 'Missing Cloudflare Analytics Engine configuration' },
74+
{ status: 500 }
75+
);
76+
}
77+
78+
const sqlQuery = buildQuery(queryType as QueryType, sandboxId);
79+
80+
const response = await fetch(
81+
`https://api.cloudflare.com/client/v4/accounts/${accountId}/analytics_engine/sql`,
82+
{
83+
method: 'POST',
84+
headers: { Authorization: `Bearer ${token}` },
85+
body: sqlQuery,
86+
}
87+
);
88+
89+
if (!response.ok) {
90+
const errorText = await response.text();
91+
console.error('Analytics Engine API error:', response.status, errorText);
92+
return NextResponse.json(
93+
{ error: `Analytics Engine API error: ${response.status}` },
94+
{ status: 500 }
95+
);
96+
}
97+
98+
const result: AnalyticsEngineResponse = await response.json();
99+
return NextResponse.json(result);
100+
}

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

Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,12 +56,17 @@ import {
5656
CheckCircle2,
5757
XCircle,
5858
ShieldAlert,
59+
Activity,
5960
} from 'lucide-react';
6061
import Link from 'next/link';
6162
import { formatDistanceToNow } from 'date-fns';
6263
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
6364
import { toast } from 'sonner';
6465
import { AdminFileEditor } from './AdminFileEditor';
66+
import {
67+
useKiloclawInstanceEvents,
68+
type KiloclawEventRow,
69+
} from '@/app/admin/api/kiloclaw-analytics/hooks';
6570

6671
function formatRelativeTime(timestamp: string | null): string {
6772
if (!timestamp) return '—';
@@ -844,6 +849,135 @@ function VolumeReassociationCard({
844849
);
845850
}
846851

852+
function DeliveryBadge({ delivery }: { delivery: string }) {
853+
switch (delivery) {
854+
case 'do':
855+
return (
856+
<Badge className="bg-blue-600 text-xs" variant="default">
857+
do
858+
</Badge>
859+
);
860+
case 'reconcile':
861+
return (
862+
<Badge className="bg-amber-600 text-xs" variant="default">
863+
reconcile
864+
</Badge>
865+
);
866+
default:
867+
return <Badge variant="outline">{delivery}</Badge>;
868+
}
869+
}
870+
871+
function formatDuration(ms: number): string {
872+
if (ms === 0) return '—';
873+
if (ms < 1000) return `${Math.round(ms)}ms`;
874+
return `${(ms / 1000).toFixed(1)}s`;
875+
}
876+
877+
function InstanceEventsCard({ sandboxId }: { sandboxId: string }) {
878+
const { data, isLoading, error } = useKiloclawInstanceEvents(sandboxId);
879+
880+
return (
881+
<Card>
882+
<CardHeader>
883+
<div className="flex items-center gap-2">
884+
<Activity className="h-5 w-5" />
885+
<div>
886+
<CardTitle>DO & Reconcile Events</CardTitle>
887+
<CardDescription>Recent events from Analytics Engine</CardDescription>
888+
</div>
889+
</div>
890+
</CardHeader>
891+
<CardContent>
892+
{isLoading && (
893+
<div className="flex items-center gap-2">
894+
<Loader2 className="h-4 w-4 animate-spin" />
895+
<span className="text-muted-foreground text-sm">Loading events...</span>
896+
</div>
897+
)}
898+
899+
{error && (
900+
<Alert>
901+
<AlertTriangle className="h-4 w-4" />
902+
<AlertDescription>
903+
{error instanceof Error ? error.message : 'Failed to load events'}
904+
</AlertDescription>
905+
</Alert>
906+
)}
907+
908+
{data && data.data.length === 0 && (
909+
<p className="text-muted-foreground text-sm">No DO or reconcile events found.</p>
910+
)}
911+
912+
{data && data.data.length > 0 && (
913+
<div className="overflow-x-auto">
914+
<table className="w-full text-sm">
915+
<thead>
916+
<tr className="text-muted-foreground border-b text-left text-xs">
917+
<th className="pr-4 pb-2">Time</th>
918+
<th className="pr-4 pb-2">Event</th>
919+
<th className="pr-4 pb-2">Delivery</th>
920+
<th className="pr-4 pb-2">Status</th>
921+
<th className="pr-4 pb-2">Label</th>
922+
<th className="pr-4 pb-2">Duration</th>
923+
<th className="pb-2">Error</th>
924+
</tr>
925+
</thead>
926+
<tbody>
927+
{data.data.map((row: KiloclawEventRow, i: number) => (
928+
<tr key={`${row.timestamp}-${i}`} className="border-b last:border-0">
929+
<td className="py-2 pr-4 whitespace-nowrap">
930+
<Tooltip>
931+
<TooltipTrigger asChild>
932+
<span className="text-xs">
933+
{formatDistanceToNow(new Date(row.timestamp), { addSuffix: true })}
934+
</span>
935+
</TooltipTrigger>
936+
<TooltipContent>{new Date(row.timestamp).toLocaleString()}</TooltipContent>
937+
</Tooltip>
938+
</td>
939+
<td className="py-2 pr-4">
940+
<code className="text-xs">{row.event}</code>
941+
</td>
942+
<td className="py-2 pr-4">
943+
<DeliveryBadge delivery={row.delivery} />
944+
</td>
945+
<td className="py-2 pr-4">
946+
<span className="text-xs">{row.status || '—'}</span>
947+
</td>
948+
<td className="py-2 pr-4">
949+
<span className="text-xs">{row.label || '—'}</span>
950+
</td>
951+
<td className="py-2 pr-4 whitespace-nowrap">
952+
<span className="text-xs">{formatDuration(row.duration_ms)}</span>
953+
</td>
954+
<td className="py-2">
955+
{row.error ? (
956+
<Tooltip>
957+
<TooltipTrigger asChild>
958+
<span className="text-destructive block max-w-[200px] truncate text-xs">
959+
{row.error}
960+
</span>
961+
</TooltipTrigger>
962+
<TooltipContent className="max-w-[400px]">
963+
<p className="break-words text-xs">{row.error}</p>
964+
</TooltipContent>
965+
</Tooltip>
966+
) : (
967+
<span className="text-muted-foreground text-xs"></span>
968+
)}
969+
</td>
970+
</tr>
971+
))}
972+
</tbody>
973+
</table>
974+
</div>
975+
)}
976+
</CardContent>
977+
</Card>
978+
);
979+
}
980+
847981
/** Strip ANSI escape codes so raw terminal output can render in a browser &lt;pre&gt;. */
848982
function stripAnsi(raw: string): string {
849983
// eslint-disable-next-line no-control-regex
@@ -1422,6 +1556,9 @@ export function KiloclawInstanceDetail({ instanceId }: { instanceId: string }) {
14221556
</CardContent>
14231557
</Card>
14241558

1559+
{/* DO & Reconcile Events */}
1560+
{data.sandbox_id && <InstanceEventsCard sandboxId={data.sandbox_id} />}
1561+
14251562
{/* Machine Controls */}
14261563
{isActive && machineControlsEnabled && (
14271564
<Card>

0 commit comments

Comments
 (0)