Files
Codewalkers/apps/web/src/routes/settings/health.tsx
Lukas May a94e72ccbc feat: Wire AddAccountDialog and UpdateCredentialsDialog into health page and AccountCard
- health.tsx: Add Account button + AddAccountDialog, Refresh button with
  tooltip and spinner calling refreshAccounts mutation, inline account
  error state instead of full-page error, updated empty state text, and
  active-agent warning on delete confirm
- AccountCard.tsx: Show KeyRound button when credentials/token invalid,
  opens UpdateCredentialsDialog pre-populated with account identity

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-06 13:35:14 +01:00

247 lines
8.0 KiB
TypeScript

import { useState } from 'react'
import { createFileRoute } from '@tanstack/react-router'
import {
CheckCircle2,
XCircle,
RefreshCw,
Server,
Plus,
RotateCcw,
Loader2,
} from 'lucide-react'
import { toast } from 'sonner'
import { trpc } from '@/lib/trpc'
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Skeleton } from '@/components/Skeleton'
import { AccountCard } from '@/components/AccountCard'
import { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } from '@/components/ui/tooltip'
import { AddAccountDialog } from '@/components/AddAccountDialog'
export const Route = createFileRoute('/settings/health')({
component: HealthCheckPage,
})
function formatUptime(seconds: number): string {
const d = Math.floor(seconds / 86400)
const h = Math.floor((seconds % 86400) / 3600)
const m = Math.floor((seconds % 3600) / 60)
const s = Math.floor(seconds % 60)
const parts: string[] = []
if (d > 0) parts.push(`${d}d`)
if (h > 0) parts.push(`${h}h`)
if (m > 0) parts.push(`${m}m`)
if (s > 0 || parts.length === 0) parts.push(`${s}s`)
return parts.join(' ')
}
function HealthCheckPage() {
const utils = trpc.useUtils()
const healthQuery = trpc.systemHealthCheck.useQuery(undefined, {
refetchInterval: 30_000,
})
const removeAccount = trpc.removeAccount.useMutation({
onSuccess: () => {
void utils.systemHealthCheck.invalidate()
toast.success('Account removed')
},
onError: (err) => {
toast.error(`Failed to remove account: ${err.message}`)
},
})
const [addAccountOpen, setAddAccountOpen] = useState(false)
const refreshAccounts = trpc.refreshAccounts.useMutation({
onSuccess: (data) => {
const msg =
data.cleared === 0
? 'No expired flags to clear.'
: `Cleared ${data.cleared} expired exhaustion flag(s).`
toast.success(msg)
void utils.systemHealthCheck.invalidate()
},
onError: (err) => {
toast.error(`Failed to refresh: ${err.message}`)
},
})
const { data, isLoading, isError, refetch } = healthQuery
// Loading state
if (isLoading) {
return (
<div className="space-y-6">
<div className="flex justify-end">
<Skeleton className="h-9 w-24" />
</div>
<Skeleton className="h-32 w-full" />
<Skeleton className="h-48 w-full" />
<Skeleton className="h-32 w-full" />
</div>
)
}
const server = data?.server
const accounts = data?.accounts ?? []
const projects = data?.projects ?? []
return (
<div className="space-y-6">
{/* Refresh button */}
<div className="flex justify-end">
<Button
variant="outline"
size="sm"
onClick={() => void refetch()}
>
<RefreshCw className="mr-2 h-4 w-4" />
Refresh
</Button>
</div>
{/* Server Status */}
{server && (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 font-display text-lg">
<Server className="h-5 w-5" />
Server Status
</CardTitle>
</CardHeader>
<CardContent>
<div className="flex items-center gap-3">
<CheckCircle2 className="h-5 w-5 text-status-success-dot" />
<div>
<p className="text-sm font-medium">Running</p>
<p className="text-xs text-muted-foreground">
Uptime: {formatUptime(server.uptime)}
{server.startedAt && (
<>
{' '}
&middot; Started{' '}
{new Date(server.startedAt).toLocaleString()}
</>
)}
</p>
</div>
</div>
</CardContent>
</Card>
)}
{/* Accounts */}
<div className="space-y-3">
<div className="flex items-center justify-between">
<h2 className="font-display text-lg font-semibold">Accounts</h2>
<div className="flex items-center gap-2">
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
size="sm"
onClick={() => refreshAccounts.mutate()}
disabled={refreshAccounts.isPending}
>
{refreshAccounts.isPending
? <Loader2 className="h-4 w-4 animate-spin" />
: <RotateCcw className="h-4 w-4" />}
<span className="sr-only">Refresh</span>
</Button>
</TooltipTrigger>
<TooltipContent>Clear expired exhaustion flags</TooltipContent>
</Tooltip>
</TooltipProvider>
<Button
size="sm"
onClick={() => setAddAccountOpen(true)}
>
<Plus className="mr-2 h-4 w-4" />
Add Account
</Button>
</div>
</div>
{isError ? (
<Card>
<CardContent className="py-6">
<p className="text-center text-sm text-status-error-fg">
Could not load account status. Retrying
</p>
</CardContent>
</Card>
) : accounts.length === 0 ? (
<Card>
<CardContent className="py-6">
<p className="text-center text-sm text-muted-foreground">
No accounts configured. Use{' '}
<code className="rounded bg-muted px-1 py-0.5 text-xs">
cw account add
</code>{' '}
to register one, or click 'Add Account' above.
</p>
</CardContent>
</Card>
) : (
accounts.map((account) => (
<AccountCard
key={account.id}
account={account}
onDelete={(e) => {
if (
e.shiftKey ||
(account.activeAgentCount > 0
? window.confirm(
`This account has ${account.activeAgentCount} active agent(s). Deleting it will not stop those agents but they will lose their account association. Continue?`
)
: window.confirm(`Remove account "${account.email}"?`))
) {
removeAccount.mutate({ id: account.id })
}
}}
/>
))
)}
</div>
{/* Projects */}
<div className="space-y-3">
<h2 className="font-display text-lg font-semibold">Projects</h2>
{projects.length === 0 ? (
<Card>
<CardContent className="py-6">
<p className="text-center text-sm text-muted-foreground">
No projects registered yet.
</p>
</CardContent>
</Card>
) : (
projects.map((project) => (
<Card key={project.id}>
<CardContent className="flex items-center gap-3 py-4">
{project.repoExists ? (
<CheckCircle2 className="h-5 w-5 shrink-0 text-status-success-dot" />
) : (
<XCircle className="h-5 w-5 shrink-0 text-status-error-fg" />
)}
<div className="min-w-0 flex-1">
<p className="text-sm font-medium">{project.name}</p>
<p className="truncate text-xs text-muted-foreground">
{project.url}
</p>
</div>
<span className="shrink-0 text-xs text-muted-foreground">
{project.repoExists ? 'Clone found' : 'Clone missing'}
</span>
</CardContent>
</Card>
))
)}
</div>
<AddAccountDialog open={addAccountOpen} onOpenChange={setAddAccountOpen} />
</div>
)
}