diff --git a/cloudflare-gastown/container/src/agent-runner.ts b/cloudflare-gastown/container/src/agent-runner.ts index ec9c0fa8a5..8cf6ac785e 100644 --- a/cloudflare-gastown/container/src/agent-runner.ts +++ b/cloudflare-gastown/container/src/agent-runner.ts @@ -439,8 +439,8 @@ export async function runAgent(originalRequest: StartAgentRequest): Promise { userId: rigConfig.userId, agentId: triageAgent.id, agentName: triageAgent.name, - // Use 'triage' role so the container skips the git clone entirely. - // Triage work is purely reasoning — no code changes needed. - role: 'triage', + role: 'polecat', identity: triageAgent.identity, beadId: triageBead.bead_id, beadTitle: triageBead.title, @@ -2654,6 +2652,7 @@ export class TownDO extends DurableObject { townConfig, systemPromptOverride: systemPrompt, platformIntegrationId: rigConfig.platformIntegrationId, + lightweight: true, }); if (started) { diff --git a/cloudflare-gastown/src/dos/town/beads.ts b/cloudflare-gastown/src/dos/town/beads.ts index 03e0abd974..90419cab96 100644 --- a/cloudflare-gastown/src/dos/town/beads.ts +++ b/cloudflare-gastown/src/dos/town/beads.ts @@ -671,18 +671,11 @@ export function getConvoyDependencyEdges( } /** - * Find the convoy a bead belongs to (if any). - * - * Two cases: - * 1. Normal source bead: tracked by a convoy via bead_dependencies - * (bead_id = sourceBeadId, depends_on_bead_id = convoyId, type = 'tracks'). - * Returns the convoy bead_id. - * 2. The bead IS the convoy (e.g. for the final landing MR where processConvoyLandings - * passes the convoy bead_id as the source). Returns beadId itself. + * Find the convoy a bead belongs to (if any) via 'tracks' dependencies. + * Returns the convoy bead_id or null. */ export function getConvoyForBead(sql: SqlStorage, beadId: string): string | null { - // Case 1: bead is tracked by a convoy - const trackRows = [ + const rows = [ ...query( sql, /* sql */ ` @@ -694,24 +687,8 @@ export function getConvoyForBead(sql: SqlStorage, beadId: string): string | null [beadId] ), ]; - if (trackRows.length > 0) { - return z.object({ depends_on_bead_id: z.string() }).parse(trackRows[0]).depends_on_bead_id; - } - - // Case 2: bead is itself a convoy (has convoy_metadata) - const metaRows = [ - ...query( - sql, - /* sql */ ` - SELECT 1 FROM ${convoy_metadata} - WHERE ${convoy_metadata.bead_id} = ? - `, - [beadId] - ), - ]; - if (metaRows.length > 0) return beadId; - - return null; + if (rows.length === 0) return null; + return z.object({ depends_on_bead_id: z.string() }).parse(rows[0]).depends_on_bead_id; } /** diff --git a/cloudflare-gastown/src/dos/town/container-dispatch.ts b/cloudflare-gastown/src/dos/town/container-dispatch.ts index 51c6b67ed7..158e95b710 100644 --- a/cloudflare-gastown/src/dos/town/container-dispatch.ts +++ b/cloudflare-gastown/src/dos/town/container-dispatch.ts @@ -240,6 +240,8 @@ export async function startAgentInContainer( platformIntegrationId?: string; /** For convoy beads: the convoy's feature branch to branch from instead of defaultBranch. */ convoyFeatureBranch?: string; + /** Skip repo clone — use a lightweight workspace (for reasoning-only agents like triage). */ + lightweight?: boolean; /** All rigs in the town (mayor only) — used to set up browse worktrees on fresh containers. */ rigs?: Array<{ rigId: string; @@ -346,6 +348,7 @@ export async function startAgentInContainer( // For convoy agents, start from the convoy's feature branch so the // worktree includes all previously merged convoy work. startPoint: params.convoyFeatureBranch ? `origin/${params.convoyFeatureBranch}` : undefined, + lightweight: params.lightweight, rigs: params.rigs, }), }); diff --git a/cloudflare-gastown/src/dos/town/review-queue.ts b/cloudflare-gastown/src/dos/town/review-queue.ts index f595d78c48..edaf7f44d2 100644 --- a/cloudflare-gastown/src/dos/town/review-queue.ts +++ b/cloudflare-gastown/src/dos/town/review-queue.ts @@ -277,13 +277,10 @@ export function completeReviewWithResult( const mergeTimestamp = now(); closeBead(sql, entry.bead_id, entry.agent_id); - // Explicitly trigger convoy progress for the source bead after the MR closes. - // closeBead → updateBeadStatus → updateConvoyProgress, but only if the source - // bead's status actually changes. If the polecat already closed the source bead - // before submitting to the review queue, the guard in updateBeadStatus short- - // circuits and updateConvoyProgress is never called. Calling it here directly - // ensures the convoy recounts after the MR bead is now closed (not in-flight), - // so the source bead passes the NOT EXISTS guard and counts toward closedCount. + // closeBead → updateBeadStatus short-circuits when completeReview already + // set the status to 'closed' via direct SQL, so updateConvoyProgress is + // never reached transitively. Call it explicitly to ensure the convoy + // recounts after the MR bead is closed. updateConvoyProgress(sql, entry.bead_id, mergeTimestamp); // If this was a convoy landing MR, also set landed_at on the convoy metadata diff --git a/cloudflare-gastown/src/trpc/init.ts b/cloudflare-gastown/src/trpc/init.ts index dd0f33792b..0885e09e17 100644 --- a/cloudflare-gastown/src/trpc/init.ts +++ b/cloudflare-gastown/src/trpc/init.ts @@ -36,3 +36,15 @@ export const gastownProcedure = procedure.use(async ({ ctx, next }) => { } return next({ ctx }); }); + +/** + * Admin-only procedure — requires `isAdmin` on the JWT. Used for admin + * panel endpoints that bypass per-user ownership checks (e.g. town-wide + * bead/agent listing for support diagnostics). + */ +export const adminProcedure = procedure.use(async ({ ctx, next }) => { + if (!ctx.isAdmin) { + throw new TRPCError({ code: 'FORBIDDEN', message: 'Admin access required' }); + } + return next({ ctx }); +}); diff --git a/cloudflare-gastown/src/trpc/router.ts b/cloudflare-gastown/src/trpc/router.ts index 32df123799..c804c702b9 100644 --- a/cloudflare-gastown/src/trpc/router.ts +++ b/cloudflare-gastown/src/trpc/router.ts @@ -8,7 +8,7 @@ /* eslint-disable @typescript-eslint/await-thenable -- DO RPC stubs return Rpc.Promisified which is thenable at runtime */ import { TRPCError } from '@trpc/server'; import { z } from 'zod'; -import { router, gastownProcedure } from './init'; +import { router, gastownProcedure, adminProcedure } from './init'; import { getTownDOStub } from '../dos/Town.do'; import { getTownContainerStub } from '../dos/TownContainer.do'; import { getGastownUserStub } from '../dos/GastownUser.do'; @@ -598,6 +598,104 @@ export const gastownRouter = router({ const status = await townStub.getConvoyStatus(input.convoyId); return status ?? { ...convoy, beads: [] }; }), + + // ── Admin-only routes (bypass ownership checks) ────────────────────── + + adminListBeads: adminProcedure + .input( + z.object({ + townId: z.string().uuid(), + status: z.enum(['open', 'in_progress', 'closed', 'failed']).optional(), + type: z + .enum(['issue', 'message', 'escalation', 'merge_request', 'convoy', 'molecule', 'agent']) + .optional(), + limit: z.number().int().positive().max(500).default(200), + }) + ) + .output(z.array(RpcBeadOutput)) + .query(async ({ ctx, input }) => { + const townStub = getTownDOStub(ctx.env, input.townId); + return townStub.listBeads({ + status: input.status, + type: input.type, + limit: input.limit, + }); + }), + + adminListAgents: adminProcedure + .input(z.object({ townId: z.string().uuid() })) + .output(z.array(RpcAgentOutput)) + .query(async ({ ctx, input }) => { + const townStub = getTownDOStub(ctx.env, input.townId); + return townStub.listAgents({}); + }), + + adminForceRestartContainer: adminProcedure + .input(z.object({ townId: z.string().uuid() })) + .mutation(async ({ ctx, input }) => { + const containerStub = getTownContainerStub(ctx.env, input.townId); + await containerStub.destroy(); + }), + + adminForceResetAgent: adminProcedure + .input(z.object({ townId: z.string().uuid(), agentId: z.string().uuid() })) + .mutation(async ({ ctx, input }) => { + const townStub = getTownDOStub(ctx.env, input.townId); + await townStub.unhookBead(input.agentId); + await townStub.updateAgentStatus(input.agentId, 'idle'); + }), + + adminForceCloseBead: adminProcedure + .input(z.object({ townId: z.string().uuid(), beadId: z.string().uuid() })) + .output(RpcBeadOutput) + .mutation(async ({ ctx, input }) => { + const townStub = getTownDOStub(ctx.env, input.townId); + return townStub.closeBead(input.beadId, 'admin'); + }), + + adminForceFailBead: adminProcedure + .input(z.object({ townId: z.string().uuid(), beadId: z.string().uuid() })) + .output(RpcBeadOutput) + .mutation(async ({ ctx, input }) => { + const townStub = getTownDOStub(ctx.env, input.townId); + return townStub.updateBeadStatus(input.beadId, 'failed', 'admin'); + }), + + adminGetAlarmStatus: adminProcedure + .input(z.object({ townId: z.string().uuid() })) + .output(RpcAlarmStatusOutput) + .query(async ({ ctx, input }) => { + const townStub = getTownDOStub(ctx.env, input.townId); + await townStub.setTownId(input.townId); + return townStub.getAlarmStatus(); + }), + + adminGetTownEvents: adminProcedure + .input( + z.object({ + townId: z.string().uuid(), + beadId: z.string().uuid().optional(), + since: z.string().optional(), + limit: z.number().int().positive().max(500).default(100), + }) + ) + .output(z.array(RpcBeadEventOutput)) + .query(async ({ ctx, input }) => { + const townStub = getTownDOStub(ctx.env, input.townId); + return townStub.listBeadEvents({ + beadId: input.beadId, + since: input.since, + limit: input.limit, + }); + }), + + adminGetBead: adminProcedure + .input(z.object({ townId: z.string().uuid(), beadId: z.string().uuid() })) + .output(RpcBeadOutput.nullable()) + .query(async ({ ctx, input }) => { + const townStub = getTownDOStub(ctx.env, input.townId); + return townStub.getBeadAsync(input.beadId); + }), }); export type GastownRouter = typeof gastownRouter; diff --git a/src/app/admin/components/AppSidebar.tsx b/src/app/admin/components/AppSidebar.tsx index 151aa1ea19..9618d3a51d 100644 --- a/src/app/admin/components/AppSidebar.tsx +++ b/src/app/admin/components/AppSidebar.tsx @@ -22,6 +22,7 @@ import { Upload, Bell, Server, + Network, } from 'lucide-react'; import { useSession } from 'next-auth/react'; import type { Session } from 'next-auth'; @@ -144,6 +145,11 @@ const productEngineeringItems: MenuItem[] = [ url: '/admin/code-indexing', icon: () => , }, + { + title: () => 'Gastown', + url: '/admin/gastown', + icon: () => , + }, ]; const analyticsObservabilityItems: MenuItem[] = [ diff --git a/src/app/admin/components/UserAdmin/UserAdminDashboard.tsx b/src/app/admin/components/UserAdmin/UserAdminDashboard.tsx index ca472237d9..ba6ec04062 100644 --- a/src/app/admin/components/UserAdmin/UserAdminDashboard.tsx +++ b/src/app/admin/components/UserAdmin/UserAdminDashboard.tsx @@ -21,6 +21,7 @@ import { } from '@/components/ui/breadcrumb'; import { UserAdminOrganizations } from '@/app/admin/components/UserAdmin/UserAdminOrganizations'; import { UserAdminKiloPass } from '@/app/admin/components/UserAdmin/UserAdminKiloPass'; +import { UserAdminGastown } from '@/app/admin/components/UserAdmin/UserAdminGastown'; export function UserAdminDashboard({ ...user }: UserDetailProps) { const breadcrumbs = ( @@ -59,6 +60,8 @@ export function UserAdminDashboard({ ...user }: UserDetailProps) { + + ); diff --git a/src/app/admin/components/UserAdmin/UserAdminGastown.tsx b/src/app/admin/components/UserAdmin/UserAdminGastown.tsx new file mode 100644 index 0000000000..fd7c8dd824 --- /dev/null +++ b/src/app/admin/components/UserAdmin/UserAdminGastown.tsx @@ -0,0 +1,245 @@ +'use client'; + +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table'; +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import Link from 'next/link'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { useTRPC } from '@/lib/trpc/utils'; +import { formatDate } from '@/lib/admin-utils'; +import { toast } from 'sonner'; +import { ExternalLink, RotateCcw } from 'lucide-react'; + +type HealthDotProps = { status: 'green' | 'yellow' | 'red' | 'unknown' }; + +function HealthDot({ status }: HealthDotProps) { + const colors = { + green: 'bg-green-500', + yellow: 'bg-yellow-400', + red: 'bg-red-500', + unknown: 'bg-gray-400', + }; + const labels = { + green: 'Healthy', + yellow: 'Degraded', + red: 'Critical', + unknown: 'Unknown', + }; + return ( + + + {labels[status]} + + ); +} + +function deriveHealthStatus( + health: { + agents: { working: number; stalled: number; dead: number }; + beads: { failed: number }; + patrol: { guppEscalations: number; stalledAgents: number }; + } | null +): 'green' | 'yellow' | 'red' | 'unknown' { + if (!health) return 'unknown'; + const { agents, beads, patrol } = health; + if (agents.dead > 0 || patrol.guppEscalations > 0) return 'red'; + if (agents.stalled > 0 || beads.failed > 0 || patrol.stalledAgents > 0) return 'yellow'; + return 'green'; +} + +function TownRow({ town }: { town: { id: string; name: string; created_at: string } }) { + const trpc = useTRPC(); + const queryClient = useQueryClient(); + + const healthQuery = useQuery({ + ...trpc.admin.gastown.getTownHealth.queryOptions({ townId: town.id }), + retry: false, + }); + + const restartMutation = useMutation( + trpc.admin.gastown.forceRestartContainer.mutationOptions({ + onSuccess: () => { + toast.success(`Container restart requested for town ${town.name}`); + void queryClient.invalidateQueries( + trpc.admin.gastown.getTownHealth.queryOptions({ townId: town.id }) + ); + }, + onError: err => { + toast.error(`Failed to restart container: ${err.message}`); + }, + }) + ); + + const health = healthQuery.data ?? null; + const healthStatus = deriveHealthStatus(health); + + return ( + + +
{town.name}
+
{town.id}
+
+ + {healthQuery.isLoading ? ( + Loading… + ) : ( + + )} + + + {health ? ( + + {health.agents.working} working / {health.agents.idle} idle + + ) : ( + + )} + + + {health ? ( + + {health.beads.open + health.beads.inProgress} open + + ) : ( + + )} + + {formatDate(town.created_at)} + +
+ + +
+
+
+ ); +} + +export function UserAdminGastown({ userId }: { userId: string }) { + const trpc = useTRPC(); + + const townsQuery = useQuery({ + ...trpc.admin.gastown.getUserTowns.queryOptions({ userId }), + retry: false, + }); + + const rigsQuery = useQuery({ + ...trpc.admin.gastown.getUserRigs.queryOptions({ userId }), + retry: false, + }); + + return ( + + + Gastown + Towns and rigs owned by this user + + + {/* Towns */} +
+

+ Towns +

+ {townsQuery.isLoading &&

Loading towns…

} + {townsQuery.error &&

Failed to load towns

} + {townsQuery.data && townsQuery.data.length === 0 && ( +

No towns found

+ )} + {townsQuery.data && townsQuery.data.length > 0 && ( + + + + Town + Health + Agents + Beads + Created + Actions + + + + {townsQuery.data.map(town => ( + + ))} + +
+ )} +
+ + {/* Rigs */} +
+

+ Rigs +

+ {rigsQuery.isLoading &&

Loading rigs…

} + {rigsQuery.error &&

Failed to load rigs

} + {rigsQuery.data && rigsQuery.data.length === 0 && ( +

No rigs found

+ )} + {rigsQuery.data && rigsQuery.data.length > 0 && ( + + + + Rig Name + Git URL + Integration + Created + + + + {rigsQuery.data.map(rig => ( + + +
{rig.name}
+
{rig.id}
+
+ + + {rig.git_url} + + + + {rig.platform_integration_id ? ( + + Linked + + ) : ( + + Not linked + + )} + + + {formatDate(rig.created_at)} + +
+ ))} +
+
+ )} +
+
+
+ ); +} diff --git a/src/app/admin/gastown/page.tsx b/src/app/admin/gastown/page.tsx new file mode 100644 index 0000000000..20f6163c6d --- /dev/null +++ b/src/app/admin/gastown/page.tsx @@ -0,0 +1,12 @@ +import { redirect } from 'next/navigation'; +import { getUserFromAuth } from '@/lib/user.server'; + +export default async function GastownIndexPage() { + const { authFailedResponse } = await getUserFromAuth({ adminOnly: true }); + if (authFailedResponse) { + redirect('/admin/unauthorized'); + } + + // Gastown data is scoped to a user — navigate to a user first. + redirect('/admin/users'); +} diff --git a/src/app/admin/gastown/towns/[townId]/AgentsTab.tsx b/src/app/admin/gastown/towns/[townId]/AgentsTab.tsx new file mode 100644 index 0000000000..6253a030bd --- /dev/null +++ b/src/app/admin/gastown/towns/[townId]/AgentsTab.tsx @@ -0,0 +1,177 @@ +'use client'; + +import { useState } from 'react'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { useTRPC } from '@/lib/trpc/utils'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { toast } from 'sonner'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { Badge } from '@/components/ui/badge'; +import Link from 'next/link'; +import { formatDistanceToNow } from 'date-fns'; + +const AGENT_STATUS_COLORS: Record = { + working: 'bg-yellow-500/10 text-yellow-400 border-yellow-500/20', + idle: 'bg-blue-500/10 text-blue-400 border-blue-500/20', + dead: 'bg-red-500/10 text-red-400 border-red-500/20', + stalled: 'bg-orange-500/10 text-orange-400 border-orange-500/20', +}; + +export function AgentsTab({ townId }: { townId: string }) { + const trpc = useTRPC(); + const queryClient = useQueryClient(); + + const [agentToReset, setAgentToReset] = useState<{ id: string; name: string } | null>(null); + + const agentsQuery = useQuery(trpc.admin.gastown.listAgents.queryOptions({ townId })); + + const forceResetMutation = useMutation( + trpc.admin.gastown.forceResetAgent.mutationOptions({ + onSuccess: () => { + void queryClient.invalidateQueries(trpc.admin.gastown.listAgents.queryFilter({ townId })); + setAgentToReset(null); + toast.success('Agent reset successfully'); + }, + onError: err => { + toast.error(`Failed to reset agent: ${err.message}`); + }, + }) + ); + + const agents = agentsQuery.data ?? []; + + return ( + + + Agents + + + {agentsQuery.isLoading && ( +

Loading agents…

+ )} + {agentsQuery.isError && ( +

+ Failed to load agents: {agentsQuery.error.message} +

+ )} + {!agentsQuery.isLoading && agents.length === 0 && ( +

+ No agents found in this town. +

+ )} + {agents.length > 0 && ( +
+ + + + + + + + + + + + + + {agents.map(agent => ( + + + + + + + + + + ))} + +
AgentRoleStatusHooked BeadDispatchesLast ActiveActions
+ +
{agent.name}
+
+ {agent.id.slice(0, 8)}… +
+ +
+ {agent.role} + + + {agent.status} + + + {agent.current_hook_bead_id ? ( + + {agent.current_hook_bead_id.slice(0, 8)}… + + ) : ( + + )} + + {agent.dispatch_attempts} + + {agent.last_activity_at + ? formatDistanceToNow(new Date(agent.last_activity_at), { + addSuffix: true, + }) + : '—'} + + +
+
+ )} +
+ + setAgentToReset(null)}> + + + Force Reset Agent + + This will reset agent {agentToReset?.name} to + idle status and unhook any hooked bead. This action is logged in the audit trail. + + + + + + + + +
+ ); +} diff --git a/src/app/admin/gastown/towns/[townId]/BeadsTab.tsx b/src/app/admin/gastown/towns/[townId]/BeadsTab.tsx new file mode 100644 index 0000000000..2c071e4aa3 --- /dev/null +++ b/src/app/admin/gastown/towns/[townId]/BeadsTab.tsx @@ -0,0 +1,295 @@ +'use client'; + +import { useState } from 'react'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { useTRPC } from '@/lib/trpc/utils'; +import { toast } from 'sonner'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { Badge } from '@/components/ui/badge'; +import Link from 'next/link'; +import { formatDistanceToNow } from 'date-fns'; + +const beadStatuses = ['open', 'in_progress', 'closed', 'failed'] as const; +type BeadStatus = (typeof beadStatuses)[number]; + +const beadTypes = [ + 'issue', + 'message', + 'escalation', + 'merge_request', + 'convoy', + 'molecule', + 'agent', +] as const; +type BeadType = (typeof beadTypes)[number]; + +const STATUS_COLORS: Record = { + open: 'bg-blue-500/10 text-blue-400 border-blue-500/20', + in_progress: 'bg-yellow-500/10 text-yellow-400 border-yellow-500/20', + closed: 'bg-green-500/10 text-green-400 border-green-500/20', + failed: 'bg-red-500/10 text-red-400 border-red-500/20', +}; + +type ConfirmAction = { + type: 'close' | 'fail'; + beadId: string; + title: string; +}; + +export function BeadsTab({ townId }: { townId: string }) { + const trpc = useTRPC(); + const queryClient = useQueryClient(); + + const [statusFilter, setStatusFilter] = useState('all'); + const [typeFilter, setTypeFilter] = useState('all'); + const [confirmAction, setConfirmAction] = useState(null); + + const beadsQuery = useQuery( + trpc.admin.gastown.listBeads.queryOptions({ + townId, + status: statusFilter === 'all' ? undefined : statusFilter, + type: typeFilter === 'all' ? undefined : typeFilter, + }) + ); + + const forceCloseMutation = useMutation( + trpc.admin.gastown.forceCloseBead.mutationOptions({ + onSuccess: () => { + void queryClient.invalidateQueries(trpc.admin.gastown.listBeads.queryFilter({ townId })); + setConfirmAction(null); + toast.success('Bead closed successfully'); + }, + onError: err => { + toast.error(`Failed to close bead: ${err.message}`); + }, + }) + ); + + const forceFailMutation = useMutation( + trpc.admin.gastown.forceFailBead.mutationOptions({ + onSuccess: () => { + void queryClient.invalidateQueries(trpc.admin.gastown.listBeads.queryFilter({ townId })); + setConfirmAction(null); + toast.success('Bead marked as failed'); + }, + onError: err => { + toast.error(`Failed to fail bead: ${err.message}`); + }, + }) + ); + + const handleConfirm = () => { + if (!confirmAction) return; + if (confirmAction.type === 'close') { + forceCloseMutation.mutate({ townId, beadId: confirmAction.beadId }); + } else { + forceFailMutation.mutate({ townId, beadId: confirmAction.beadId }); + } + }; + + const beads = beadsQuery.data ?? []; + const isPending = forceCloseMutation.isPending || forceFailMutation.isPending; + + return ( + + +
+ Beads +
+ + + +
+
+
+ + {beadsQuery.isLoading && ( +

Loading beads…

+ )} + {beadsQuery.isError && ( +

+ Failed to load beads: {beadsQuery.error.message} +

+ )} + {!beadsQuery.isLoading && beads.length === 0 && ( +

+ No beads found matching the current filters. +

+ )} + {beads.length > 0 && ( +
+ + + + + + + + + + + + + {beads.map(bead => ( + + + + + + + + + ))} + +
BeadTypeStatusAgentCreatedActions
+ + {bead.bead_id.slice(0, 8)}… + {bead.title && {bead.title}} + + + {bead.type} + + + {bead.status} + + + {bead.assignee_agent_bead_id ? ( + + {bead.assignee_agent_bead_id.slice(0, 8)}… + + ) : ( + + )} + + {formatDistanceToNow(new Date(bead.created_at), { addSuffix: true })} + +
+ + +
+
+
+ )} +
+ + setConfirmAction(null)}> + + + + {confirmAction?.type === 'close' ? 'Force Close Bead' : 'Force Fail Bead'} + + + This will {confirmAction?.type === 'close' ? 'force-close' : 'force-fail'} bead{' '} + {confirmAction?.beadId.slice(0, 8)}… + {confirmAction?.title ? ` (${confirmAction.title})` : ''}. This action is logged in + the audit trail. + + + + + + + + +
+ ); +} diff --git a/src/app/admin/gastown/towns/[townId]/ConfigTab.tsx b/src/app/admin/gastown/towns/[townId]/ConfigTab.tsx new file mode 100644 index 0000000000..15a8fa429c --- /dev/null +++ b/src/app/admin/gastown/towns/[townId]/ConfigTab.tsx @@ -0,0 +1,385 @@ +'use client'; + +import { useState } from 'react'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { useTRPC } from '@/lib/trpc/utils'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { formatDistanceToNow } from 'date-fns'; + +export function ConfigTab({ townId }: { townId: string }) { + const trpc = useTRPC(); + const queryClient = useQueryClient(); + + const configQuery = useQuery(trpc.admin.gastown.getTownConfig.queryOptions({ townId })); + const credEventsQuery = useQuery( + trpc.admin.gastown.listCredentialEvents.queryOptions({ townId }) + ); + + const updateConfigMutation = useMutation( + trpc.admin.gastown.updateTownConfig.mutationOptions({ + onSuccess: () => { + void queryClient.invalidateQueries( + trpc.admin.gastown.getTownConfig.queryFilter({ townId }) + ); + setEditingField(null); + }, + }) + ); + + const [editingField, setEditingField] = useState(null); + const [editValue, setEditValue] = useState(''); + + const config = configQuery.data; + const credEvents = credEventsQuery.data ?? []; + + const startEdit = (field: string, currentValue: string) => { + setEditingField(field); + setEditValue(currentValue); + }; + + const saveField = (field: string) => { + if (field === 'default_model') { + updateConfigMutation.mutate({ townId, update: { default_model: editValue || null } }); + } else if (field === 'small_model') { + updateConfigMutation.mutate({ townId, update: { small_model: editValue || null } }); + } else if (field === 'max_polecats_per_rig') { + const num = parseInt(editValue, 10); + if (!isNaN(num) && num > 0) { + updateConfigMutation.mutate({ townId, update: { max_polecats_per_rig: num } }); + } + } else if (field === 'merge_strategy') { + const validStrategies = ['direct', 'pr'] as const; + const strategy = validStrategies.find(s => s === editValue); + if (strategy) { + updateConfigMutation.mutate({ townId, update: { merge_strategy: strategy } }); + } + } + }; + + return ( +
+ {/* Town Config */} + + + Town Config + + + {configQuery.isLoading && ( +

Loading config…

+ )} + {configQuery.isError && ( +

+ Failed to load config: {configQuery.error.message} +

+ )} + {config && ( +
+ {/* Merge Strategy */} +
+
+ +

How branches are merged

+
+ {editingField === 'merge_strategy' ? ( +
+ + + +
+ ) : ( +
+ {config.merge_strategy} + +
+ )} +
+ + {/* Default Model */} +
+
+ +

Primary AI model for agents

+
+ {editingField === 'default_model' ? ( +
+ setEditValue(e.target.value)} + className="w-64" + placeholder="e.g. anthropic/claude-sonnet-4" + /> + + +
+ ) : ( +
+ + {config.default_model ?? '—'} + + +
+ )} +
+ + {/* Small Model */} +
+
+ +

+ Lightweight model for simple tasks +

+
+ {editingField === 'small_model' ? ( +
+ setEditValue(e.target.value)} + className="w-64" + placeholder="e.g. anthropic/claude-haiku" + /> + + +
+ ) : ( +
+ + {config.small_model ?? '—'} + + +
+ )} +
+ + {/* Max Polecats */} +
+
+ +

Concurrency limit per rig

+
+ {editingField === 'max_polecats_per_rig' ? ( +
+ setEditValue(e.target.value)} + className="w-24" + min={1} + /> + + +
+ ) : ( +
+ + {config.max_polecats_per_rig ?? '—'} + + +
+ )} +
+ + {/* Refinery Config */} + {config.refinery && ( +
+ +
+
+ Auto Merge: + {String(config.refinery.auto_merge)} +
+
+ Require Clean Merge: + {String(config.refinery.require_clean_merge)} +
+
+ Gates: + {config.refinery.gates.join(', ') || '—'} +
+
+
+ )} + + {/* Git Auth */} +
+ +
+ {config.git_auth.platform_integration_id && ( +
+ Platform Integration: + {config.git_auth.platform_integration_id.slice(0, 16)}… +
+ )} + {config.git_auth.github_token && ( +
+ GitHub Token: + •••••••• +
+ )} + {config.git_auth.gitlab_token && ( +
+ GitLab Token: + •••••••• +
+ )} + {config.git_auth.gitlab_instance_url && ( +
+ GitLab Instance URL: + {config.git_auth.gitlab_instance_url} +
+ )} + {!config.git_auth.github_token && + !config.git_auth.gitlab_token && + !config.git_auth.platform_integration_id && ( + No git auth configured + )} +
+
+ + {updateConfigMutation.isError && ( +

+ Failed to update config: {updateConfigMutation.error.message} +

+ )} +
+ )} +
+
+ + {/* Credential Events */} + + + Credential Events + + + {credEventsQuery.isLoading && ( +

Loading credential events…

+ )} + {!credEventsQuery.isLoading && credEvents.length === 0 && ( +

+ No credential events. (Requires bead 0 admin endpoints.) +

+ )} + {credEvents.length > 0 && ( +
+ {credEvents.map(event => ( +
+
+ {event.event_type} + {event.rig_id && ( + + rig: {event.rig_id.slice(0, 8)}… + + )} +
+ + {formatDistanceToNow(new Date(event.created_at), { addSuffix: true })} + +
+ ))} +
+ )} +
+
+
+ ); +} diff --git a/src/app/admin/gastown/towns/[townId]/ContainerTab.tsx b/src/app/admin/gastown/towns/[townId]/ContainerTab.tsx new file mode 100644 index 0000000000..6b7532b721 --- /dev/null +++ b/src/app/admin/gastown/towns/[townId]/ContainerTab.tsx @@ -0,0 +1,230 @@ +'use client'; + +import { useState } from 'react'; +import { useQuery, useMutation } from '@tanstack/react-query'; +import { useTRPC } from '@/lib/trpc/utils'; +import { toast } from 'sonner'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { Badge } from '@/components/ui/badge'; +import { formatDistanceToNow } from 'date-fns'; + +export function ContainerTab({ townId }: { townId: string }) { + const trpc = useTRPC(); + + const [showRestartDialog, setShowRestartDialog] = useState(false); + + const healthQuery = useQuery(trpc.admin.gastown.getTownHealth.queryOptions({ townId })); + const eventsQuery = useQuery(trpc.admin.gastown.listContainerEvents.queryOptions({ townId })); + const configQuery = useQuery(trpc.admin.gastown.getTownConfig.queryOptions({ townId })); + + const forceRestartMutation = useMutation( + trpc.admin.gastown.forceRestartContainer.mutationOptions({ + onSuccess: () => { + setShowRestartDialog(false); + toast.success('Container restart requested'); + }, + onError: err => { + toast.error(`Failed to restart container: ${err.message}`); + }, + }) + ); + + const health = healthQuery.data; + const containerEvents = eventsQuery.data ?? []; + const envVars = configQuery.data?.env_vars ?? {}; + + // Derive container health status from the alarm status snapshot + let containerStatus: 'running' | 'stopped' | 'unknown' = 'unknown'; + if (health) { + const hasDeadAgents = health.agents.dead > 0; + const hasWorkingAgents = health.agents.working > 0; + if (hasWorkingAgents) { + containerStatus = 'running'; + } else if (hasDeadAgents) { + containerStatus = 'stopped'; + } else { + containerStatus = 'running'; // idle but alive + } + } + + const healthBadgeClass = + containerStatus === 'running' + ? 'bg-green-500/10 text-green-400 border-green-500/20' + : containerStatus === 'stopped' + ? 'bg-red-500/10 text-red-400 border-red-500/20' + : 'bg-gray-500/10 text-gray-400 border-gray-500/20'; + + return ( +
+ {/* Health & Actions */} + + +
+ Container Health + +
+
+ + {healthQuery.isLoading && ( +

Loading health status…

+ )} + {healthQuery.isError && ( +

+ Failed to load container health: {healthQuery.error.message} +

+ )} + {!healthQuery.isLoading && !healthQuery.isError && ( +
+
+ Status: + + {containerStatus} + +
+ {health && ( +
+
+
Working Agents
+
{health.agents.working}
+
+
+
Idle Agents
+
{health.agents.idle}
+
+
+
Dead Agents
+
+ {health.agents.dead} +
+
+
+
Open Beads
+
{health.beads.open}
+
+
+ )} + {health && ( +
+
Alarm interval
+
{health.alarm.intervalLabel}
+ {health.alarm.nextFireAt && ( +
+ Next:{' '} + {formatDistanceToNow(new Date(health.alarm.nextFireAt), { addSuffix: true })} +
+ )} +
+ )} +
+ )} +
+
+ + {/* Recent Events */} + + + Container Events + + + {eventsQuery.isLoading && ( +

Loading events…

+ )} + {!eventsQuery.isLoading && containerEvents.length === 0 && ( +

+ No container events. (Requires bead 0 admin endpoints.) +

+ )} + {containerEvents.length > 0 && ( +
+ {containerEvents.map(event => ( +
+
+ {event.event_type} + {event.data && Object.keys(event.data).length > 0 && ( +
+                        {JSON.stringify(event.data, null, 2)}
+                      
+ )} +
+ + {formatDistanceToNow(new Date(event.created_at), { addSuffix: true })} + +
+ ))} +
+ )} +
+
+ + {/* Environment Variables */} + + + Environment Variables + + + {configQuery.isLoading && ( +

Loading config…

+ )} + {!configQuery.isLoading && Object.keys(envVars).length === 0 && ( +

No environment variables configured.

+ )} + {Object.keys(envVars).length > 0 && ( +
+ {Object.keys(envVars) + .sort() + .map(key => ( +
+ {key} + •••••••• +
+ ))} +
+ )} +
+
+ + + + + Force Restart Container + + This will force-restart the Gastown container for this town. All running agents will + be interrupted. This action is logged in the audit trail. + + + + + + + + +
+ ); +} diff --git a/src/app/admin/gastown/towns/[townId]/EventsTab.tsx b/src/app/admin/gastown/towns/[townId]/EventsTab.tsx new file mode 100644 index 0000000000..4d4b47d837 --- /dev/null +++ b/src/app/admin/gastown/towns/[townId]/EventsTab.tsx @@ -0,0 +1,189 @@ +'use client'; + +import { useState } from 'react'; +import { useQuery } from '@tanstack/react-query'; +import { useTRPC } from '@/lib/trpc/utils'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Input } from '@/components/ui/input'; +import { Button } from '@/components/ui/button'; +import Link from 'next/link'; +import { formatDistanceToNow, format } from 'date-fns'; +import { ChevronDown, ChevronUp } from 'lucide-react'; + +type SortDir = 'asc' | 'desc'; + +export function EventsTab({ townId }: { townId: string }) { + const trpc = useTRPC(); + + const [sortDir, setSortDir] = useState('desc'); + const [beadFilter, setBeadFilter] = useState(''); + const [agentFilter, setAgentFilter] = useState(''); + const [limit, setLimit] = useState(100); + + const eventsQuery = useQuery(trpc.admin.gastown.getBeadEvents.queryOptions({ townId, limit })); + + const events = eventsQuery.data ?? []; + + const filtered = events.filter(e => { + if (beadFilter && !e.bead_id.toLowerCase().includes(beadFilter.toLowerCase())) return false; + if (agentFilter && e.agent_id && !e.agent_id.toLowerCase().includes(agentFilter.toLowerCase())) + return false; + return true; + }); + + const sorted = [...filtered].sort((a, b) => { + const diff = new Date(a.created_at).getTime() - new Date(b.created_at).getTime(); + return sortDir === 'desc' ? -diff : diff; + }); + + const toggleSort = () => setSortDir(d => (d === 'desc' ? 'asc' : 'desc')); + + return ( + + +
+ Events Timeline +
+ setBeadFilter(e.target.value)} + className="h-8 w-48 text-xs" + /> + setAgentFilter(e.target.value)} + className="h-8 w-48 text-xs" + /> +
+
+
+ + {eventsQuery.isLoading && ( +

Loading events…

+ )} + {eventsQuery.isError && ( +

+ Failed to load events: {eventsQuery.error.message} +

+ )} + {!eventsQuery.isLoading && sorted.length === 0 && ( +

+ {events.length === 0 + ? 'No events found for this town.' + : 'No events match the current filters.'} +

+ )} + {sorted.length > 0 && ( +
+ + + + + + + + + + + + {sorted.map(event => ( + + + + + + + + ))} + +
+ + EventBeadAgentChange
+ + {formatDistanceToNow(new Date(event.created_at), { addSuffix: true })} + + + + {event.event_type} + + + + {event.bead_id.slice(0, 8)}… + + {event.rig_name && ( + + ({event.rig_name}) + + )} + + {event.agent_id ? ( + + {event.agent_id.slice(0, 8)}… + + ) : ( + + )} + + {(event.old_value ?? event.new_value) ? ( +
+ {event.old_value && ( + + {event.old_value.length > 30 + ? `${event.old_value.slice(0, 30)}…` + : event.old_value} + + )} + {event.old_value && event.new_value && ( + + )} + {event.new_value && ( + + {event.new_value.length > 30 + ? `${event.new_value.slice(0, 30)}…` + : event.new_value} + + )} +
+ ) : ( + + )} +
+
+ )} + {events.length >= limit && ( +
+ +
+ )} +
+
+ ); +} diff --git a/src/app/admin/gastown/towns/[townId]/ReviewQueueTab.tsx b/src/app/admin/gastown/towns/[townId]/ReviewQueueTab.tsx new file mode 100644 index 0000000000..0d9a5ac6e3 --- /dev/null +++ b/src/app/admin/gastown/towns/[townId]/ReviewQueueTab.tsx @@ -0,0 +1,214 @@ +'use client'; + +import { useState } from 'react'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { useTRPC } from '@/lib/trpc/utils'; +import { toast } from 'sonner'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { Badge } from '@/components/ui/badge'; +import Link from 'next/link'; +import { formatDistanceToNow } from 'date-fns'; +import { ExternalLink } from 'lucide-react'; + +const STATUS_COLORS: Record = { + open: 'bg-blue-500/10 text-blue-400 border-blue-500/20', + in_progress: 'bg-yellow-500/10 text-yellow-400 border-yellow-500/20', + closed: 'bg-green-500/10 text-green-400 border-green-500/20', + failed: 'bg-red-500/10 text-red-400 border-red-500/20', +}; + +type EntryToRetry = { id: string; title: string }; + +export function ReviewQueueTab({ townId }: { townId: string }) { + const trpc = useTRPC(); + const queryClient = useQueryClient(); + + const [entryToRetry, setEntryToRetry] = useState(null); + + // Fetch in-progress merge request beads as the review queue + const inProgressQuery = useQuery( + trpc.admin.gastown.listBeads.queryOptions({ + townId, + type: 'merge_request', + status: 'in_progress', + }) + ); + + const openQuery = useQuery( + trpc.admin.gastown.listBeads.queryOptions({ + townId, + type: 'merge_request', + status: 'open', + }) + ); + + const forceRetryMutation = useMutation( + trpc.admin.gastown.forceRetryReview.mutationOptions({ + onSuccess: () => { + void queryClient.invalidateQueries(trpc.admin.gastown.listBeads.queryFilter({ townId })); + setEntryToRetry(null); + toast.success('Review retry requested'); + }, + onError: err => { + toast.error(`Failed to retry review: ${err.message}`); + }, + }) + ); + + const queueEntries = [...(inProgressQuery.data ?? []), ...(openQuery.data ?? [])]; + const isLoading = inProgressQuery.isLoading || openQuery.isLoading; + const isError = inProgressQuery.isError || openQuery.isError; + + return ( + + + Review Queue + + + {isLoading && ( +

Loading review queue…

+ )} + {isError && ( +

Failed to load review queue.

+ )} + {!isLoading && queueEntries.length === 0 && ( +

+ No active review queue entries. +

+ )} + {queueEntries.length > 0 && ( +
+ + + + + + + + + + + + + {queueEntries.map(entry => { + const prUrl = + typeof entry.metadata['pr_url'] === 'string' && + /^https?:\/\//.test(entry.metadata['pr_url']) + ? entry.metadata['pr_url'] + : null; + return ( + + + + + + + + + ); + })} + +
BeadPR URL + Assigned Agent + + Time in Queue + StatusActions
+ +
{entry.title}
+
+ {entry.bead_id.slice(0, 8)}… +
+ +
+ {prUrl ? ( + + {prUrl} + + + ) : ( + + )} + + {entry.assignee_agent_bead_id ? ( + + {entry.assignee_agent_bead_id.slice(0, 8)}… + + ) : ( + + )} + + {formatDistanceToNow(new Date(entry.created_at), { addSuffix: false })} + + + {entry.status} + + + +
+
+ )} +
+ + setEntryToRetry(null)}> + + + Force Retry Review + + This will force-retry the review for{' '} + {entryToRetry?.title}. This action is logged in + the audit trail. + + + + + + + + +
+ ); +} diff --git a/src/app/admin/gastown/towns/[townId]/TownInspectorDashboard.tsx b/src/app/admin/gastown/towns/[townId]/TownInspectorDashboard.tsx new file mode 100644 index 0000000000..f802fe7b24 --- /dev/null +++ b/src/app/admin/gastown/towns/[townId]/TownInspectorDashboard.tsx @@ -0,0 +1,102 @@ +'use client'; + +import { useSearchParams, useRouter, usePathname } from 'next/navigation'; +import { useCallback } from 'react'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; +import { Button } from '@/components/ui/button'; +import { BeadsTab } from './BeadsTab'; +import { AgentsTab } from './AgentsTab'; +import { ReviewQueueTab } from './ReviewQueueTab'; +import { ContainerTab } from './ContainerTab'; +import { ConfigTab } from './ConfigTab'; +import { EventsTab } from './EventsTab'; +import Link from 'next/link'; + +const VALID_TABS = ['beads', 'agents', 'review', 'container', 'config', 'events'] as const; +type Tab = (typeof VALID_TABS)[number]; + +function isValidTab(value: string | null): value is Tab { + return value !== null && (VALID_TABS as readonly string[]).includes(value); +} + +const tabTriggerClass = + 'text-muted-foreground hover:text-foreground data-[state=active]:border-foreground data-[state=active]:text-foreground rounded-none border-b-2 border-transparent px-0 py-3 text-sm font-medium transition-colors data-[state=active]:border-0 data-[state=active]:border-b-2 data-[state=active]:bg-transparent data-[state=active]:shadow-none'; + +export function TownInspectorDashboard({ townId }: { townId: string }) { + const searchParams = useSearchParams(); + const router = useRouter(); + const pathname = usePathname(); + + const tabParam = searchParams.get('tab'); + const activeTab: Tab = isValidTab(tabParam) ? tabParam : 'beads'; + + const onTabChange = useCallback( + (value: string) => { + const params = new URLSearchParams(searchParams.toString()); + if (value === 'beads') { + params.delete('tab'); + } else { + params.set('tab', value); + } + const qs = params.toString(); + router.replace(`${pathname}${qs ? `?${qs}` : ''}`, { scroll: false }); + }, + [searchParams, router, pathname] + ); + + return ( +
+
+
+

Town Inspector

+

{townId}

+
+ +
+ + + + + Beads + + + Agents + + + Review Queue + + + Container + + + Config + + + Events + + + + + + + + + + + + + + + + + + + + + + +
+ ); +} diff --git a/src/app/admin/gastown/towns/[townId]/agents/[agentId]/AgentInspectorDashboard.tsx b/src/app/admin/gastown/towns/[townId]/agents/[agentId]/AgentInspectorDashboard.tsx new file mode 100644 index 0000000000..76af8aa56c --- /dev/null +++ b/src/app/admin/gastown/towns/[townId]/agents/[agentId]/AgentInspectorDashboard.tsx @@ -0,0 +1,523 @@ +'use client'; + +import { useState } from 'react'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { useTRPC } from '@/lib/trpc/utils'; +import { toast } from 'sonner'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; +import { Input } from '@/components/ui/input'; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from '@/components/ui/alert-dialog'; +import Link from 'next/link'; +import { formatDistanceToNow, format } from 'date-fns'; +import { ArrowLeft, ChevronDown, ChevronUp } from 'lucide-react'; + +const AGENT_STATUS_COLORS: Record = { + working: 'bg-yellow-500/10 text-yellow-400 border-yellow-500/20', + idle: 'bg-blue-500/10 text-blue-400 border-blue-500/20', + dead: 'bg-red-500/10 text-red-400 border-red-500/20', + stalled: 'bg-orange-500/10 text-orange-400 border-orange-500/20', +}; + +type SortDir = 'asc' | 'desc'; + +function toRecord(value: unknown): Record { + return typeof value === 'object' && value !== null ? (value as Record) : {}; +} + +function safeString(value: unknown): string { + return typeof value === 'string' ? value : ''; +} + +function safeTimestamp(value: unknown): number { + if (typeof value !== 'string') return 0; + const t = new Date(value).getTime(); + return Number.isNaN(t) ? 0 : t; +} + +export function AgentInspectorDashboard({ townId, agentId }: { townId: string; agentId: string }) { + const trpc = useTRPC(); + const queryClient = useQueryClient(); + + const [confirmReset, setConfirmReset] = useState(false); + const [eventSearch, setEventSearch] = useState(''); + const [eventSortDir, setEventSortDir] = useState('desc'); + const [eventLimit, setEventLimit] = useState(500); + + // Fetch agent from list + const agentsQuery = useQuery(trpc.admin.gastown.listAgents.queryOptions({ townId })); + const agent = (agentsQuery.data ?? []).find(a => a.id === agentId) ?? null; + + const agentEventsQuery = useQuery( + trpc.admin.gastown.getAgentEvents.queryOptions({ townId, agentId, limit: eventLimit }) + ); + + const beadEventsQuery = useQuery( + trpc.admin.gastown.getBeadEvents.queryOptions({ townId, limit: 500 }) + ); + + const dispatchAttemptsQuery = useQuery( + trpc.admin.gastown.listDispatchAttempts.queryOptions({ townId, agentId }) + ); + + const forceResetMutation = useMutation( + trpc.admin.gastown.forceResetAgent.mutationOptions({ + onSuccess: () => { + void queryClient.invalidateQueries(trpc.admin.gastown.listAgents.queryFilter({ townId })); + setConfirmReset(false); + toast.success('Agent reset successfully'); + }, + onError: err => { + toast.error(`Failed to reset agent: ${err.message}`); + }, + }) + ); + + const agentEvents = agentEventsQuery.data ?? []; + const allBeadEvents = beadEventsQuery.data ?? []; + const dispatchAttempts = dispatchAttemptsQuery.data ?? []; + + // Events related to this agent from bead events + const agentBeadEvents = allBeadEvents.filter(e => e.agent_id === agentId); + + // Deduplicated bead IDs that this agent was ever hooked to + const hookedBeadIds = Array.from(new Set(agentBeadEvents.map(e => e.bead_id))); + + // Filtered + sorted agent events + const filteredAgentEvents = agentEvents + .filter(e => { + if (!eventSearch) return true; + return JSON.stringify(e).toLowerCase().includes(eventSearch.toLowerCase()); + }) + .sort((a, b) => { + const ta = safeTimestamp(toRecord(a)['created_at']); + const tb = safeTimestamp(toRecord(b)['created_at']); + return eventSortDir === 'desc' ? tb - ta : ta - tb; + }); + + return ( +
+ {/* Header */} +
+
+ + + Back to Town Inspector + +

Agent Inspector

+

{agentId}

+
+ {agent && ( + + {agent.status} + + )} +
+ + {agentsQuery.isLoading && ( +

Loading agent…

+ )} + + {agent && ( + <> + {/* Agent overview */} + + + Overview + + +
+
+
+ Name +
+
{agent.name}
+
+
+
+ Role +
+
{agent.role}
+
+
+
+ Identity +
+
{agent.identity}
+
+
+
+ Dispatch Attempts +
+
{agent.dispatch_attempts}
+
+
+
+ Last Active +
+
+ {agent.last_activity_at + ? formatDistanceToNow(new Date(agent.last_activity_at), { + addSuffix: true, + }) + : '—'} +
+
+
+
+ Created +
+
+ {formatDistanceToNow(new Date(agent.created_at), { addSuffix: true })} +
+
+ {agent.current_hook_bead_id && ( +
+
+ Current Hooked Bead +
+
+ + {agent.current_hook_bead_id.slice(0, 8)}… + +
+
+ )} + {agent.rig_id && ( +
+
+ Rig +
+
{agent.rig_id.slice(0, 8)}…
+
+ )} +
+
+
+ + {/* Admin Actions */} + + + Admin Actions + + + + + + + )} + + {/* Current/Past Hooked Beads */} + + + Hooked Beads + + + {beadEventsQuery.isLoading && ( +

Loading…

+ )} + {!beadEventsQuery.isLoading && hookedBeadIds.length === 0 && ( +

+ No bead history found for this agent. +

+ )} + {hookedBeadIds.length > 0 && ( +
+ + + + + + + + + + {hookedBeadIds.map(bid => { + const beadEvs = agentBeadEvents.filter(e => e.bead_id === bid); + const latest = beadEvs.reduce((a, b) => + new Date(a.created_at) > new Date(b.created_at) ? a : b + ); + return ( + + + + + + ); + })} + +
BeadEventsLast Event
+ + {bid.slice(0, 8)}… + + + {beadEvs.length} + + {formatDistanceToNow(new Date(latest.created_at), { + addSuffix: true, + })} +
+
+ )} +
+
+ + {/* Status Timeline */} + + +
+ Status Timeline +
+ setEventSearch(e.target.value)} + className="h-8 w-48 text-xs" + /> + +
+
+
+ + {agentEventsQuery.isLoading && ( +

Loading events…

+ )} + {!agentEventsQuery.isLoading && filteredAgentEvents.length === 0 && ( +

+ {agentEvents.length === 0 + ? 'No agent events found. (Requires bead 0 admin endpoints.)' + : 'No events match the current search.'} +

+ )} + {filteredAgentEvents.length > 0 && ( +
+ + + + + + + + + + {filteredAgentEvents.map((rawEvent, idx) => { + const ev = toRecord(rawEvent); + const eventId = + safeString(ev['id']) || safeString(ev['agent_event_id']) || String(idx); + const createdAt = safeString(ev['created_at']); + const eventType = safeString(ev['event_type']); + const oldValue = typeof ev['old_value'] === 'string' ? ev['old_value'] : null; + const newValue = typeof ev['new_value'] === 'string' ? ev['new_value'] : null; + return ( + + + + + + ); + })} + +
TimeEventChange
+ {createdAt + ? formatDistanceToNow(new Date(createdAt), { addSuffix: true }) + : '—'} + + + {eventType} + + + {(oldValue ?? newValue) ? ( +
+ {oldValue && ( + + {String(oldValue).length > 40 + ? `${String(oldValue).slice(0, 40)}…` + : String(oldValue)} + + )} + {oldValue && newValue && ( + + )} + {newValue && ( + + {String(newValue).length > 40 + ? `${String(newValue).slice(0, 40)}…` + : String(newValue)} + + )} +
+ ) : ( + + )} +
+
+ )} + {agentEvents.length >= eventLimit && ( +
+ +
+ )} +
+
+ + {/* Dispatch Attempts */} + + + Dispatch Attempt History + + + {dispatchAttemptsQuery.isLoading && ( +

+ Loading dispatch attempts… +

+ )} + {!dispatchAttemptsQuery.isLoading && dispatchAttempts.length === 0 && ( +

+ No dispatch attempts found. +

+ )} + {dispatchAttempts.length > 0 && ( +
+ + + + + + + + + + + {dispatchAttempts.map(attempt => ( + + + + + + + ))} + +
TimeResultBeadError
+ {formatDistanceToNow(new Date(attempt.attempted_at), { + addSuffix: true, + })} + + + {attempt.success ? 'Success' : 'Failed'} + + + {attempt.bead_id ? ( + + {attempt.bead_id.slice(0, 8)}… + + ) : ( + + )} + + {attempt.error_message ? ( + + {attempt.error_message.length > 80 + ? `${attempt.error_message.slice(0, 80)}…` + : attempt.error_message} + + ) : ( + + )} +
+
+ )} +
+
+ + {/* Confirm Reset AlertDialog */} + + + + Force Reset Agent + + This will reset agent{' '} + {agent?.name ?? agentId.slice(0, 8)} to idle + status and unhook any hooked bead. This action is logged in the audit trail. + + + + Cancel + forceResetMutation.mutate({ townId, agentId })} + disabled={forceResetMutation.isPending} + className="bg-destructive text-destructive-foreground hover:bg-destructive/90" + > + {forceResetMutation.isPending ? 'Resetting…' : 'Force Reset'} + + + + +
+ ); +} diff --git a/src/app/admin/gastown/towns/[townId]/agents/[agentId]/page.tsx b/src/app/admin/gastown/towns/[townId]/agents/[agentId]/page.tsx new file mode 100644 index 0000000000..90471a150f --- /dev/null +++ b/src/app/admin/gastown/towns/[townId]/agents/[agentId]/page.tsx @@ -0,0 +1,40 @@ +import AdminPage from '@/app/admin/components/AdminPage'; +import { + BreadcrumbItem, + BreadcrumbLink, + BreadcrumbPage, + BreadcrumbSeparator, +} from '@/components/ui/breadcrumb'; +import { AgentInspectorDashboard } from './AgentInspectorDashboard'; + +export default async function AgentInspectorPage({ + params, +}: { + params: Promise<{ townId: string; agentId: string }>; +}) { + const { townId, agentId } = await params; + + const breadcrumbs = ( + <> + + Gastown + + + + + Town {townId.slice(0, 8)}… + + + + + Agent {agentId.slice(0, 8)}… + + + ); + + return ( + + + + ); +} diff --git a/src/app/admin/gastown/towns/[townId]/audit/AuditLogDashboard.tsx b/src/app/admin/gastown/towns/[townId]/audit/AuditLogDashboard.tsx new file mode 100644 index 0000000000..4cf4001e65 --- /dev/null +++ b/src/app/admin/gastown/towns/[townId]/audit/AuditLogDashboard.tsx @@ -0,0 +1,227 @@ +'use client'; + +import React, { useState } from 'react'; +import { useQuery } from '@tanstack/react-query'; +import { useTRPC } from '@/lib/trpc/utils'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import Link from 'next/link'; +import { formatDistanceToNow, format } from 'date-fns'; +import { ArrowLeft, ChevronDown, ChevronUp } from 'lucide-react'; + +type SortDir = 'asc' | 'desc'; + +export function AuditLogDashboard({ townId }: { townId: string }) { + const trpc = useTRPC(); + + const [search, setSearch] = useState(''); + const [sortDir, setSortDir] = useState('desc'); + const [expandedIds, setExpandedIds] = useState>(new Set()); + + const auditQuery = useQuery(trpc.admin.gastown.listAuditLog.queryOptions({ townId })); + const entries = auditQuery.data ?? []; + + const filtered = entries.filter(entry => { + if (!search) return true; + const q = search.toLowerCase(); + return ( + entry.admin_user_id.toLowerCase().includes(q) || + entry.action.toLowerCase().includes(q) || + (entry.target_type ?? '').toLowerCase().includes(q) || + (entry.target_id ?? '').toLowerCase().includes(q) + ); + }); + + const sorted = [...filtered].sort((a, b) => { + const diff = new Date(a.performed_at).getTime() - new Date(b.performed_at).getTime(); + return sortDir === 'desc' ? -diff : diff; + }); + + const toggleExpand = (id: string) => { + setExpandedIds(prev => { + const next = new Set(prev); + if (next.has(id)) { + next.delete(id); + } else { + next.add(id); + } + return next; + }); + }; + + return ( +
+ {/* Header */} +
+ + + Back to Town Inspector + +

Audit Log

+

{townId}

+
+ + + +
+ Admin Actions + setSearch(e.target.value)} + className="h-8 w-64 text-xs" + /> +
+
+ + {auditQuery.isLoading && ( +

Loading audit log…

+ )} + {auditQuery.isError && ( +

+ Failed to load audit log: {auditQuery.error.message} +

+ )} + {!auditQuery.isLoading && sorted.length === 0 && ( +

+ {entries.length === 0 + ? 'No audit log entries found. (Requires bead 0 admin endpoints.)' + : 'No entries match the current search.'} +

+ )} + {sorted.length > 0 && ( +
+ + + + + + + + + + + + + {sorted.map(entry => { + const isExpanded = expandedIds.has(entry.id); + const hasDetail = entry.detail != null && Object.keys(entry.detail).length > 0; + return ( + + + + + + + + + + {isExpanded && hasDetail && ( + + + + )} + + ); + })} + +
+ + AdminAction + Target Type + Target IDDetail
+ {formatDistanceToNow(new Date(entry.performed_at), { + addSuffix: true, + })} + + {entry.admin_user_id} + + + {entry.action} + + + {entry.target_type ?? '—'} + + {entry.target_id ? ( + + ) : ( + + )} + + {hasDetail ? ( + + ) : ( + + )} +
+
+                                {JSON.stringify(entry.detail, null, 2)}
+                              
+
+
+ )} +
+
+
+ ); +} + +function TargetLink({ + townId, + targetType, + targetId, +}: { + townId: string; + targetType: string | null; + targetId: string; +}) { + if (targetType === 'bead') { + return ( + + {targetId.slice(0, 8)}… + + ); + } + if (targetType === 'agent') { + return ( + + {targetId.slice(0, 8)}… + + ); + } + return {targetId.slice(0, 8)}…; +} diff --git a/src/app/admin/gastown/towns/[townId]/audit/page.tsx b/src/app/admin/gastown/towns/[townId]/audit/page.tsx new file mode 100644 index 0000000000..b2620195ce --- /dev/null +++ b/src/app/admin/gastown/towns/[townId]/audit/page.tsx @@ -0,0 +1,36 @@ +import AdminPage from '@/app/admin/components/AdminPage'; +import { + BreadcrumbItem, + BreadcrumbLink, + BreadcrumbPage, + BreadcrumbSeparator, +} from '@/components/ui/breadcrumb'; +import { AuditLogDashboard } from './AuditLogDashboard'; + +export default async function AuditLogPage({ params }: { params: Promise<{ townId: string }> }) { + const { townId } = await params; + + const breadcrumbs = ( + <> + + Gastown + + + + + Town {townId.slice(0, 8)}… + + + + + Audit Log + + + ); + + return ( + + + + ); +} diff --git a/src/app/admin/gastown/towns/[townId]/beads/[beadId]/BeadInspectorDashboard.tsx b/src/app/admin/gastown/towns/[townId]/beads/[beadId]/BeadInspectorDashboard.tsx new file mode 100644 index 0000000000..36a0a5f83d --- /dev/null +++ b/src/app/admin/gastown/towns/[townId]/beads/[beadId]/BeadInspectorDashboard.tsx @@ -0,0 +1,632 @@ +'use client'; + +import { useState } from 'react'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { useTRPC } from '@/lib/trpc/utils'; +import { toast } from 'sonner'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from '@/components/ui/alert-dialog'; +import Link from 'next/link'; +import { formatDistanceToNow, format } from 'date-fns'; +import { ArrowLeft } from 'lucide-react'; + +const STATUS_COLORS: Record = { + open: 'bg-blue-500/10 text-blue-400 border-blue-500/20', + in_progress: 'bg-yellow-500/10 text-yellow-400 border-yellow-500/20', + closed: 'bg-green-500/10 text-green-400 border-green-500/20', + failed: 'bg-red-500/10 text-red-400 border-red-500/20', +}; + +type ConfirmActionType = 'close' | 'fail' | 'reset_agent'; + +type ConfirmAction = { + type: ConfirmActionType; + label: string; + description: string; +}; + +export function BeadInspectorDashboard({ townId, beadId }: { townId: string; beadId: string }) { + const trpc = useTRPC(); + const queryClient = useQueryClient(); + const [confirmAction, setConfirmAction] = useState(null); + + // Fetch the specific bead by ID (not limited by pagination). + const beadQuery = useQuery(trpc.admin.gastown.getBead.queryOptions({ townId, beadId })); + const bead = beadQuery.data ?? null; + + // Fetch all beads for computing the dependency graph. + const allBeadsQuery = useQuery(trpc.admin.gastown.listBeads.queryOptions({ townId })); + const allBeads = allBeadsQuery.data ?? []; + + const eventsQuery = useQuery( + trpc.admin.gastown.getBeadEvents.queryOptions({ townId, beadId, limit: 500 }) + ); + + const dispatchAttemptsQuery = useQuery( + trpc.admin.gastown.listDispatchAttempts.queryOptions({ townId, beadId }) + ); + + const invalidateAll = () => { + void queryClient.invalidateQueries(trpc.admin.gastown.getBead.queryFilter({ townId, beadId })); + void queryClient.invalidateQueries(trpc.admin.gastown.listBeads.queryFilter({ townId })); + void queryClient.invalidateQueries(trpc.admin.gastown.getBeadEvents.queryFilter({ townId })); + }; + + const forceCloseMutation = useMutation( + trpc.admin.gastown.forceCloseBead.mutationOptions({ + onSuccess: () => { + invalidateAll(); + setConfirmAction(null); + toast.success('Bead closed successfully'); + }, + onError: err => { + toast.error(`Failed to close bead: ${err.message}`); + }, + }) + ); + + const forceFailMutation = useMutation( + trpc.admin.gastown.forceFailBead.mutationOptions({ + onSuccess: () => { + invalidateAll(); + setConfirmAction(null); + toast.success('Bead marked as failed'); + }, + onError: err => { + toast.error(`Failed to fail bead: ${err.message}`); + }, + }) + ); + + const forceResetAgentMutation = useMutation( + trpc.admin.gastown.forceResetAgent.mutationOptions({ + onSuccess: () => { + invalidateAll(); + setConfirmAction(null); + toast.success('Agent reset successfully'); + }, + onError: err => { + toast.error(`Failed to reset agent: ${err.message}`); + }, + }) + ); + + const handleConfirm = () => { + if (!confirmAction) return; + if (confirmAction.type === 'close') { + forceCloseMutation.mutate({ townId, beadId }); + } else if (confirmAction.type === 'fail') { + forceFailMutation.mutate({ townId, beadId }); + } else if (confirmAction.type === 'reset_agent' && bead?.assignee_agent_bead_id) { + forceResetAgentMutation.mutate({ townId, agentId: bead.assignee_agent_bead_id }); + } + }; + + const isMutating = + forceCloseMutation.isPending || + forceFailMutation.isPending || + forceResetAgentMutation.isPending; + + // Filter events to only those for this bead + const events = (eventsQuery.data ?? []).filter(e => e.bead_id === beadId); + const dispatchAttempts = dispatchAttemptsQuery.data ?? []; + + // Dependency graph: reads from metadata.depends_on as a temporary fallback. + // The canonical source is the bead_dependencies table, but that requires a + // dedicated admin endpoint (bead 0). Once available, switch to querying + // bead_dependencies for accurate blocker/convoy/tracks edges. + const getMetaDeps = (meta: Record): string[] => { + const raw = meta['depends_on']; + return Array.isArray(raw) ? raw.filter((v): v is string => typeof v === 'string') : []; + }; + + const thisBeadDeps = bead != null ? getMetaDeps(bead.metadata) : []; + const dependsOnBeads = allBeads.filter(b => thisBeadDeps.includes(b.bead_id)); + + // Beads that depend on this bead + const dependentBeads = allBeads.filter(b => getMetaDeps(b.metadata).includes(beadId)); + + // Agent history from events (deduplicated) + const agentHistory = Array.from( + new Map(events.filter(e => e.agent_id != null).map(e => [e.agent_id, e])).values() + ); + + // Convoy membership + const rawConvoyId = bead != null ? bead.metadata['convoy_id'] : undefined; + const convoyId = typeof rawConvoyId === 'string' ? rawConvoyId : undefined; + const convoyBead = convoyId ? allBeads.find(b => b.bead_id === convoyId) : undefined; + + return ( +
+ {/* Header */} +
+
+ + + Back to Town Inspector + +

Bead Inspector

+

{beadId}

+
+ {bead && ( + + {bead.status} + + )} +
+ + {beadQuery.isLoading && ( +

Loading bead…

+ )} + {beadQuery.isError && ( +

+ Failed to load bead: {beadQuery.error.message} +

+ )} + + {bead && ( + <> + {/* Bead summary */} + + + Overview + + +
+
+
+ Type +
+
{bead.type}
+
+
+
+ Priority +
+
{bead.priority}
+
+
+
+ Created +
+
+ {formatDistanceToNow(new Date(bead.created_at), { addSuffix: true })} +
+
+ {bead.closed_at && ( +
+
+ Closed +
+
+ {formatDistanceToNow(new Date(bead.closed_at), { addSuffix: true })} +
+
+ )} + {bead.assignee_agent_bead_id && ( +
+
+ Assigned Agent +
+
+ + {bead.assignee_agent_bead_id.slice(0, 8)}… + +
+
+ )} + {bead.rig_id && ( +
+
+ Rig +
+
{bead.rig_id.slice(0, 8)}…
+
+ )} + {bead.labels.length > 0 && ( +
+
+ Labels +
+
+ {bead.labels.map(label => ( + + {label} + + ))} +
+
+ )} + {bead.title && ( +
+
+ Title +
+
{bead.title}
+
+ )} +
+
+
+ + {/* Admin Actions */} + + + Admin Actions + + +
+ + + +
+
+
+ + )} + + {/* State History Timeline */} + + + State History + + + {eventsQuery.isLoading && ( +

Loading events…

+ )} + {eventsQuery.isError && ( +

+ Failed to load events: {eventsQuery.error.message} +

+ )} + {!eventsQuery.isLoading && events.length === 0 && ( +

No events found.

+ )} + {events.length > 0 && ( +
+ {[...events] + .sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime()) + .map((event, idx) => ( +
+ {/* Timeline line + dot */} +
+
+ {idx < events.length - 1 &&
} +
+
+
+ + {event.event_type} + + {event.agent_id && ( + + agent {event.agent_id.slice(0, 8)}… + + )} + + {formatDistanceToNow(new Date(event.created_at), { addSuffix: true })} + +
+ {(event.old_value ?? event.new_value) && ( +
+ {event.old_value && ( + + {event.old_value} + + )} + {event.old_value && event.new_value && ( + + )} + {event.new_value && ( + + {event.new_value} + + )} +
+ )} +
+
+ ))} +
+ )} + + + + {/* Assigned Agent History */} + + + Assigned Agent History + + + {agentHistory.length === 0 ? ( +

+ No agent assignments found in event history. +

+ ) : ( +
+ + + + + + + + + + {agentHistory.map(event => ( + + + + + + ))} + +
AgentEventTime
+ + {event.agent_id?.slice(0, 8)}… + + + + {event.event_type} + + + {formatDistanceToNow(new Date(event.created_at), { addSuffix: true })} +
+
+ )} +
+
+ + {/* Dependency Graph */} + + + Dependencies + + + {convoyBead && ( +
+

+ Convoy +

+ + {convoyBead.bead_id.slice(0, 8)}… + {convoyBead.title && {convoyBead.title}} + +
+ )} + +
+

+ Depends On +

+ {dependsOnBeads.length === 0 ? ( +

None

+ ) : ( +
    + {dependsOnBeads.map(b => ( +
  • + + {b.bead_id.slice(0, 8)}… + {b.title && {b.title}} + + + {b.status} + +
  • + ))} +
+ )} +
+ +
+

+ Depended On By +

+ {dependentBeads.length === 0 ? ( +

None

+ ) : ( +
    + {dependentBeads.map(b => ( +
  • + + {b.bead_id.slice(0, 8)}… + {b.title && {b.title}} + + + {b.status} + +
  • + ))} +
+ )} +
+
+
+ + {/* Dispatch Attempts */} + + + Dispatch Attempts + + + {dispatchAttemptsQuery.isLoading && ( +

+ Loading dispatch attempts… +

+ )} + {!dispatchAttemptsQuery.isLoading && dispatchAttempts.length === 0 && ( +

+ No dispatch attempts found. +

+ )} + {dispatchAttempts.length > 0 && ( +
+ + + + + + + + + + + {dispatchAttempts.map(attempt => ( + + + + + + + ))} + +
TimeResultAgentError
+ {formatDistanceToNow(new Date(attempt.attempted_at), { + addSuffix: true, + })} + + + {attempt.success ? 'Success' : 'Failed'} + + + {attempt.agent_id ? ( + + {attempt.agent_id.slice(0, 8)}… + + ) : ( + + )} + + {attempt.error_message ?? } +
+
+ )} +
+
+ + {/* Confirm AlertDialog */} + setConfirmAction(null)}> + + + {confirmAction?.label} + {confirmAction?.description} + + + Cancel + + {isMutating ? 'Processing…' : confirmAction?.label} + + + + +
+ ); +} diff --git a/src/app/admin/gastown/towns/[townId]/beads/[beadId]/page.tsx b/src/app/admin/gastown/towns/[townId]/beads/[beadId]/page.tsx new file mode 100644 index 0000000000..e061320614 --- /dev/null +++ b/src/app/admin/gastown/towns/[townId]/beads/[beadId]/page.tsx @@ -0,0 +1,40 @@ +import AdminPage from '@/app/admin/components/AdminPage'; +import { + BreadcrumbItem, + BreadcrumbLink, + BreadcrumbPage, + BreadcrumbSeparator, +} from '@/components/ui/breadcrumb'; +import { BeadInspectorDashboard } from './BeadInspectorDashboard'; + +export default async function BeadInspectorPage({ + params, +}: { + params: Promise<{ townId: string; beadId: string }>; +}) { + const { townId, beadId } = await params; + + const breadcrumbs = ( + <> + + Gastown + + + + + Town {townId.slice(0, 8)}… + + + + + Bead {beadId.slice(0, 8)}… + + + ); + + return ( + + + + ); +} diff --git a/src/app/admin/gastown/towns/[townId]/page.tsx b/src/app/admin/gastown/towns/[townId]/page.tsx new file mode 100644 index 0000000000..6533c171a1 --- /dev/null +++ b/src/app/admin/gastown/towns/[townId]/page.tsx @@ -0,0 +1,34 @@ +import AdminPage from '@/app/admin/components/AdminPage'; +import { + BreadcrumbItem, + BreadcrumbLink, + BreadcrumbPage, + BreadcrumbSeparator, +} from '@/components/ui/breadcrumb'; +import { TownInspectorDashboard } from './TownInspectorDashboard'; + +export default async function TownInspectorPage({ + params, +}: { + params: Promise<{ townId: string }>; +}) { + const { townId } = await params; + + const breadcrumbs = ( + <> + + Gastown + + + + Town {townId.slice(0, 8)}… + + + ); + + return ( + + + + ); +} diff --git a/src/components/ui/alert-dialog.tsx b/src/components/ui/alert-dialog.tsx new file mode 100644 index 0000000000..bc243d1c1d --- /dev/null +++ b/src/components/ui/alert-dialog.tsx @@ -0,0 +1,68 @@ +'use client'; + +// AlertDialog built on top of the existing Dialog primitive. +// Mirrors the shadcn/ui AlertDialog API surface so callers can use the standard +// AlertDialog, AlertDialogContent, AlertDialogHeader, etc. naming. + +import * as React from 'react'; +import { + Dialog, + DialogClose, + DialogContent, + DialogHeader, + DialogFooter, + DialogTitle, + DialogDescription, +} from '@/components/ui/dialog'; +import { Button } from '@/components/ui/button'; +import { cn } from '@/lib/utils'; + +const AlertDialog = Dialog; + +// AlertDialogContent hides the default close button so users must use Cancel/Action +const AlertDialogContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AlertDialogContent.displayName = 'AlertDialogContent'; + +const AlertDialogHeader = DialogHeader; +const AlertDialogFooter = DialogFooter; +const AlertDialogTitle = DialogTitle; +const AlertDialogDescription = DialogDescription; + +type AlertDialogCancelProps = React.ComponentPropsWithoutRef; + +const AlertDialogCancel = React.forwardRef( + ({ className, ...props }, ref) => ( + +