diff --git a/apps/web/src/app/(app)/claw/components/InstanceControls.tsx b/apps/web/src/app/(app)/claw/components/InstanceControls.tsx index d0dca4aa4..8ee32e426 100644 --- a/apps/web/src/app/(app)/claw/components/InstanceControls.tsx +++ b/apps/web/src/app/(app)/claw/components/InstanceControls.tsx @@ -1,6 +1,7 @@ 'use client'; import { useCallback, useEffect, useMemo, useState } from 'react'; +import { useRouter } from 'next/navigation'; import { ArrowUpCircle, Check, @@ -32,6 +33,8 @@ import { import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'; import { Label } from '@/components/ui/label'; import type { useKiloClawMutations } from '@/hooks/useKiloClaw'; +import { selectCurrentCliRun } from '@/lib/kiloclaw/cli-run-selection'; +import { useClawKiloCliRunHistory } from '../hooks/useClawHooks'; import { useClawUpdateAvailable } from '../hooks/useClawUpdateAvailable'; import { useGatewayUrl } from '../hooks/useGatewayUrl'; import { ConfirmActionDialog } from './ConfirmActionDialog'; @@ -66,6 +69,7 @@ export function InstanceControls({ onUpgradeHandled?: () => void; gatewayReady?: boolean; }) { + const router = useRouter(); const posthog = usePostHog(); const gatewayUrl = useGatewayUrl(status); const isRunning = status.status === 'running'; @@ -86,7 +90,7 @@ export function InstanceControls({ const [confirmRestart, setConfirmRestart] = useState(false); const [confirmRedeploy, setConfirmRedeploy] = useState(false); const [redeployMode, setRedeployMode] = useState<'redeploy' | 'upgrade'>('redeploy'); - + const kiloCliRunHistory = useClawKiloCliRunHistory(isRunning); const { updateAvailable, catalogNewerThanImage, latestAvailableVersion, latestVersion } = useClawUpdateAvailable(status); @@ -213,41 +217,41 @@ export function InstanceControls({ - {showUpgradeBanner && ( - - - - - - - {catalogNewerThanImage - ? `A newer OpenClaw version (${latestAvailableVersion}) is available` - : `A newer image (${latestVersion?.imageTag ?? 'unknown'}) is available`} - - - Upgrade your instance to get the latest features and fixes. - - - { - setRedeployMode('upgrade'); - setConfirmRedeploy(true); - }} - > - Upgrade now - - - - )} -
+
+ {showUpgradeBanner && ( + + + + + + + {catalogNewerThanImage + ? `A newer OpenClaw version (${latestAvailableVersion}) is available` + : `A newer image (${latestVersion?.imageTag ?? 'unknown'}) is available`} + + + Upgrade your instance to get the latest features and fixes. + + + { + setRedeployMode('upgrade'); + setConfirmRedeploy(true); + }} + > + Upgrade now + + + + )} { + onClick={async () => { posthog?.capture('claw_kilo_run_clicked', { instance_status: status.status }); + try { + const { data } = await kiloCliRunHistory.refetch(); + const latestRun = selectCurrentCliRun(data?.runs, status.instanceId); + if (latestRun?.status === 'running') { + router.push(`/claw/kilo-cli-run/${latestRun.id}`); + return; + } + } catch { + // Best-effort pre-check; fall through to open the dialog + } setKiloRunOpen(true); }} > diff --git a/apps/web/src/app/(app)/claw/components/StartKiloCliRunDialog.tsx b/apps/web/src/app/(app)/claw/components/StartKiloCliRunDialog.tsx index 2254498e7..a962928b6 100644 --- a/apps/web/src/app/(app)/claw/components/StartKiloCliRunDialog.tsx +++ b/apps/web/src/app/(app)/claw/components/StartKiloCliRunDialog.tsx @@ -18,16 +18,15 @@ import { useKiloClawMutations } from '@/hooks/useKiloClaw'; import type { PlatformStatusResponse } from '@/lib/kiloclaw/types'; import { AnimatedDots } from './AnimatedDots'; +function getErrorUpstreamCode(error: unknown): string | undefined { + if (typeof error !== 'object' || error === null || !('data' in error)) return undefined; + const { data } = error; + if (typeof data !== 'object' || data === null || !('upstreamCode' in data)) return undefined; + return typeof data.upstreamCode === 'string' ? data.upstreamCode : undefined; +} + function isNeedsRedeployError(error: unknown): boolean { - return ( - typeof error === 'object' && - error !== null && - 'data' in error && - typeof (error as { data?: unknown }).data === 'object' && - (error as { data?: { upstreamCode?: unknown } }).data !== null && - (error as { data: { upstreamCode?: unknown } }).data.upstreamCode === - 'controller_route_unavailable' - ); + return getErrorUpstreamCode(error) === 'controller_route_unavailable'; } export function StartKiloCliRunDialog({ @@ -67,6 +66,8 @@ export function StartKiloCliRunDialog({ onOpenChange(false); router.push(`/claw/kilo-cli-run/${data.id}`); }, + onError: err => + toast.error(err.message || 'Failed to start recovery run', { duration: 10000 }), } ); }; @@ -168,7 +169,7 @@ export function StartKiloCliRunDialog({ onKeyDown={e => { if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) { e.preventDefault(); - handleStart(); + void handleStart(); } }} /> @@ -183,7 +184,7 @@ export function StartKiloCliRunDialog({ Cancel @@ -123,7 +123,7 @@ export default function KiloCliRunPage() {

{statusQuery.error?.message ?? 'Failed to load run status'}

-
diff --git a/apps/web/src/app/admin/components/KiloclawCliRuns/KiloclawCliRunsTab.tsx b/apps/web/src/app/admin/components/KiloclawCliRuns/KiloclawCliRunsTab.tsx index bce512a07..16a1d2538 100644 --- a/apps/web/src/app/admin/components/KiloclawCliRuns/KiloclawCliRunsTab.tsx +++ b/apps/web/src/app/admin/components/KiloclawCliRuns/KiloclawCliRunsTab.tsx @@ -30,6 +30,7 @@ import { stripAnsi } from '@/lib/stripAnsi'; const PAGE_SIZE = 25; type RunStatus = 'all' | 'running' | 'completed' | 'failed' | 'cancelled'; +type InitiatedBy = 'all' | 'admin' | 'user'; function StatusBadge({ status }: { status: string }) { switch (status) { @@ -82,6 +83,7 @@ export function CliRunsTab() { const [search, setSearch] = useState(''); const [debouncedSearch, setDebouncedSearch] = useState(''); const [statusFilter, setStatusFilter] = useState('all'); + const [initiatedByFilter, setInitiatedByFilter] = useState('all'); const [selectedRunId, setSelectedRunId] = useState(null); const [debounceTimer, setDebounceTimer] = useState | null>(null); @@ -103,6 +105,7 @@ export function CliRunsTab() { limit: PAGE_SIZE, search: debouncedSearch || undefined, status: statusFilter, + initiatedBy: initiatedByFilter, }, { staleTime: 10_000 } ) @@ -140,6 +143,22 @@ export function CliRunsTab() { Cancelled + {isLoading && } {pagination && ( @@ -167,9 +186,19 @@ export function CliRunsTab() { )} >
- - {run.user_email ?? run.user_id} - +
+ + {run.user_email ?? run.user_id} + + {run.initiated_by_admin_id && ( + + Admin + + )} +

@@ -247,6 +276,8 @@ function RunDetail({ id: string; user_id: string; user_email: string | null; + initiated_by_admin_id: string | null; + initiated_by_admin_email: string | null; prompt: string; status: string; exit_code: number | null; @@ -268,6 +299,11 @@ function RunDetail({ {run.user_email ?? run.user_id}

+ {run.initiated_by_admin_id && ( +

+ Initiated by {run.initiated_by_admin_email ?? 'Admin'} +

+ )}

{run.prompt}

Started {formatDistanceToNow(new Date(run.started_at), { addSuffix: true })} diff --git a/apps/web/src/app/admin/components/KiloclawInstances/KiloCliRunCard.tsx b/apps/web/src/app/admin/components/KiloclawInstances/KiloCliRunCard.tsx new file mode 100644 index 000000000..1a59a1c30 --- /dev/null +++ b/apps/web/src/app/admin/components/KiloclawInstances/KiloCliRunCard.tsx @@ -0,0 +1,263 @@ +'use client'; + +import { useState } from 'react'; +import { useMutation, useQuery } from '@tanstack/react-query'; +import { Loader2, Play, RefreshCw, Square, Terminal, AlertTriangle } from 'lucide-react'; +import { toast } from 'sonner'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { Alert, AlertDescription } from '@/components/ui/alert'; +import { Textarea } from '@/components/ui/textarea'; +import { useTRPC } from '@/lib/trpc/utils'; +import { selectCurrentCliRun } from '@/lib/kiloclaw/cli-run-selection'; +import { stripAnsi } from '@/lib/stripAnsi'; +import { formatRelativeTime, DetailField } from './shared'; + +const STATUS_BADGE_CLASSES: Record = { + running: 'bg-blue-500', + completed: 'bg-green-600', + failed: 'bg-red-600', + cancelled: 'bg-amber-600', +}; + +function getStatusBadgeClass(status: string | null): string { + return status ? (STATUS_BADGE_CLASSES[status] ?? '') : ''; +} + +export function KiloCliRunCard({ userId, instanceId }: { userId: string; instanceId: string }) { + const trpc = useTRPC(); + const [prompt, setPrompt] = useState(''); + const [showOutput, setShowOutput] = useState(true); + const [runId, setRunId] = useState(null); + + const { data: latestRuns, refetch: refetchRuns } = useQuery( + trpc.admin.kiloclawInstances.listKiloCliRuns.queryOptions( + { userId, limit: 20 }, + { staleTime: 10_000, refetchInterval: 3000 } + ) + ); + + const runningRun = latestRuns?.runs.find( + run => run.instance_id === instanceId && run.status === 'running' + ); + const selectedRunId = + runningRun?.id ?? runId ?? selectCurrentCliRun(latestRuns?.runs, instanceId)?.id ?? null; + + const { + data: runStatus, + isLoading: statusLoading, + refetch: refetchStatus, + } = useQuery({ + // tRPC queryOptions requires Zod-valid input for cache key generation even + // when the query is disabled. The nil UUID is inert — enabled:false prevents + // any request. + ...trpc.admin.kiloclawInstances.getKiloCliRunStatus.queryOptions( + selectedRunId + ? { userId, instanceId, runId: selectedRunId } + : { userId, instanceId, runId: '00000000-0000-0000-0000-000000000000' } + ), + enabled: selectedRunId !== null, + refetchInterval: query => (query?.state?.data?.status === 'running' ? 3000 : false), + }); + + const isRunning = runningRun !== undefined || runStatus?.status === 'running'; + const isUserRun = runStatus?.initiatedBy === 'user'; + + const { mutateAsync: startRun, isPending: isStarting } = useMutation( + trpc.admin.kiloclawInstances.startKiloCliRun.mutationOptions({ + onSuccess: result => { + toast.success('CLI run started'); + // Select the new run immediately instead of waiting for the next latest-runs poll. + setRunId(result.id); + setShowOutput(true); + void refetchRuns(); + void refetchStatus(); + }, + onError: err => { + toast.error(`Failed to start CLI run: ${err.message}`); + }, + }) + ); + + const { mutateAsync: cancelRun, isPending: isCancelling } = useMutation( + trpc.admin.kiloclawInstances.cancelKiloCliRun.mutationOptions({ + onSuccess: () => { + toast.success('CLI run cancelled'); + void refetchRuns(); + void refetchStatus(); + }, + onError: err => { + toast.error(`Failed to cancel CLI run: ${err.message}`); + }, + }) + ); + + const handleStart = () => { + if (!prompt.trim()) return; + void startRun({ userId, instanceId, prompt: prompt.trim() }); + }; + + return ( + + +
+ +
+ Kilo CLI Run + + Run an autonomous agent task on this instance via kilo run --auto + +
+
+
+ + {isRunning && isUserRun && ( + + + + A user-initiated CLI run is currently in progress on this instance. You can monitor or + cancel it below. + + + )} + +
+ +