diff --git a/kiloclaw/src/durable-objects/kiloclaw-instance.ts b/kiloclaw/src/durable-objects/kiloclaw-instance.ts index 0a532847b..7fdee18b8 100644 --- a/kiloclaw/src/durable-objects/kiloclaw-instance.ts +++ b/kiloclaw/src/durable-objects/kiloclaw-instance.ts @@ -681,6 +681,31 @@ export class KiloClawInstance extends DurableObject { }; } + /** + * Run `openclaw doctor --fix --non-interactive` on the machine and return the output. + * Requires the machine to be running. + */ + async runDoctor(): Promise<{ success: boolean; output: string }> { + await this.loadState(); + + const { flyMachineId } = this; + if (this.status !== 'running' || !flyMachineId) { + return { success: false, output: 'Instance is not running' }; + } + + const flyConfig = this.getFlyConfig(); + + const result = await fly.execCommand( + flyConfig, + flyMachineId, + ['/usr/bin/env', 'HOME=/root', 'openclaw', 'doctor', '--fix', '--non-interactive'], + 60 + ); + + const output = result.stdout + (result.stderr ? '\n' + result.stderr : ''); + return { success: result.exit_code === 0, output }; + } + /** * Start the Fly Machine. */ diff --git a/kiloclaw/src/routes/platform.ts b/kiloclaw/src/routes/platform.ts index 73fa14b0e..f7f95cb94 100644 --- a/kiloclaw/src/routes/platform.ts +++ b/kiloclaw/src/routes/platform.ts @@ -217,6 +217,25 @@ platform.post('/pairing/approve', async c => { } }); +// POST /api/platform/doctor +platform.post('/doctor', async c => { + const result = await parseBody(c, UserIdRequestSchema); + if ('error' in result) return result.error; + + try { + const doctor = await withDORetry( + instanceStubFactory(c.env, result.data.userId), + stub => stub.runDoctor(), + 'runDoctor' + ); + return c.json(doctor, 200); + } catch (err) { + const message = err instanceof Error ? err.message : 'Unknown error'; + console.error('[platform] doctor failed:', message); + return c.json({ error: message }, 500); + } +}); + // POST /api/platform/start platform.post('/start', async c => { const result = await parseBody(c, UserIdRequestSchema); diff --git a/src/app/(app)/claw/components/ChangelogCard.tsx b/src/app/(app)/claw/components/ChangelogCard.tsx index a6c10ba63..4f48bf103 100644 --- a/src/app/(app)/claw/components/ChangelogCard.tsx +++ b/src/app/(app)/claw/components/ChangelogCard.tsx @@ -1,11 +1,15 @@ 'use client'; +import { useState } from 'react'; import { format, parseISO } from 'date-fns'; -import { Bug, History, Sparkles } from 'lucide-react'; +import { Bug, ChevronDown, ChevronUp, History, Sparkles } from 'lucide-react'; import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { CHANGELOG_ENTRIES, type ChangelogEntry } from './changelog-data'; +const COLLAPSED_COUNT = 4; + const CATEGORY_STYLES = { feature: 'border-emerald-500/30 bg-emerald-500/15 text-emerald-400', bugfix: 'border-amber-500/30 bg-amber-500/15 text-amber-400', @@ -55,8 +59,13 @@ function ChangelogRow({ entry }: { entry: ChangelogEntry }) { } export function ChangelogCard() { + const [expanded, setExpanded] = useState(false); + if (CHANGELOG_ENTRIES.length === 0) return null; + const hasMore = CHANGELOG_ENTRIES.length > COLLAPSED_COUNT; + const visibleEntries = expanded ? CHANGELOG_ENTRIES : CHANGELOG_ENTRIES.slice(0, COLLAPSED_COUNT); + return ( @@ -66,10 +75,32 @@ export function ChangelogCard() { Recent changes and updates to the KiloClaw platform. - - {CHANGELOG_ENTRIES.map((entry, i) => ( - - ))} + +
+ {visibleEntries.map((entry, i) => ( + + ))} +
+ {hasMore && ( + + )}
); diff --git a/src/app/(app)/claw/components/InstanceControls.tsx b/src/app/(app)/claw/components/InstanceControls.tsx index 81e7377c7..e9a2bcbe9 100644 --- a/src/app/(app)/claw/components/InstanceControls.tsx +++ b/src/app/(app)/claw/components/InstanceControls.tsx @@ -1,11 +1,13 @@ 'use client'; -import { Play, RotateCw, Square } from 'lucide-react'; +import { useState } from 'react'; +import { Play, RotateCw, Square, Stethoscope } from 'lucide-react'; import { usePostHog } from 'posthog-js/react'; import { toast } from 'sonner'; import type { KiloClawDashboardStatus } from '@/lib/kiloclaw/types'; import { Button } from '@/components/ui/button'; import type { useKiloClawMutations } from '@/hooks/useKiloClaw'; +import { RunDoctorDialog } from './RunDoctorDialog'; type ClawMutations = ReturnType; @@ -20,6 +22,7 @@ export function InstanceControls({ const isRunning = status.status === 'running'; const isStopped = status.status === 'stopped' || status.status === 'provisioned'; const isDestroying = status.status === 'destroying'; + const [doctorOpen, setDoctorOpen] = useState(false); return (
@@ -71,9 +74,27 @@ export function InstanceControls({ }} > - {mutations.restartGateway.isPending ? 'Restarting...' : 'Restart Gateway'} + {mutations.restartGateway.isPending ? 'Redeploying...' : 'Redeploy'} + +
+ ); } diff --git a/src/app/(app)/claw/components/RunDoctorDialog.tsx b/src/app/(app)/claw/components/RunDoctorDialog.tsx new file mode 100644 index 000000000..2ee6cff17 --- /dev/null +++ b/src/app/(app)/claw/components/RunDoctorDialog.tsx @@ -0,0 +1,102 @@ +'use client'; + +import { useEffect, useRef } from 'react'; +import { CheckCircle2, Loader2, XCircle } from 'lucide-react'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { Button } from '@/components/ui/button'; +import { Textarea } from '@/components/ui/textarea'; +import type { useKiloClawMutations } from '@/hooks/useKiloClaw'; + +type DoctorMutation = ReturnType['runDoctor']; + +export function RunDoctorDialog({ + open, + onOpenChange, + mutation, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; + mutation: DoctorMutation; +}) { + const hasFired = useRef(false); + const mutationRef = useRef(mutation); + mutationRef.current = mutation; + + useEffect(() => { + if (open && !hasFired.current) { + hasFired.current = true; + mutationRef.current.mutate(undefined); + } + if (!open) { + hasFired.current = false; + mutationRef.current.reset(); + } + }, [open]); + + const result = mutation.data; + const isPending = mutation.isPending; + const isError = mutation.isError; + + return ( + + + + OpenClaw Doctor + + Running diagnostics and applying fixes on your instance. + + + + {isPending && ( +
+ +

Running diagnostics...

+
+ )} + + {isError && ( +
+ +

+ {mutation.error?.message || 'Failed to run doctor'} +

+
+ )} + + {result && !isPending && ( +
+
+ {result.success ? ( + + ) : ( + + )} + + {result.success ? 'Executed successfully' : 'Issues detected'} + +
+