- 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>
247 lines
8.0 KiB
TypeScript
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 && (
|
|
<>
|
|
{' '}
|
|
· 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>
|
|
)
|
|
}
|