dashboard: surface sync_queue_depth + sync_queue_stuck

Extends the React UI to render the two new NodeStatus fields shipped
in commit 29a6616.

NodeCard bottom row grew from a 3-col grid (snapshots / active /
peer) to 4 cols with a new 'queue' column that reads:
  - green + '0' when the queue is empty
  - amber + N when jobs are pending
  - red + N/S⚠ when at least one job has exceeded SYNC_STUCK_ATTEMPTS
So an operator can see at a glance which node is backing up.

SyncQueueCard now distinguishes stuck jobs (attempts >= 12) from
regular pending ones: red badge, red-tinted attempt counter, header
icon flips from CheckCircle → AlertCircle → AlertTriangle as
severity climbs, and a summary line ('N jobs stuck — peer
unreachable > 1h') appears when any job is stuck.

The SYNC_STUCK_ATTEMPTS constant is exported from api.ts and
kept in-sync with the server-side constant in sync.rs.
This commit is contained in:
Omar Sobh
2026-07-02 15:05:14 -07:00
parent 29a6616c59
commit 17640a7bb1
3 changed files with 54 additions and 11 deletions
+10
View File
@@ -6,7 +6,17 @@ export interface NodeStatus {
warm_dataset: string; warm_used_gb: number; warm_avail_gb: number; warm_snapshot_count: number warm_dataset: string; warm_used_gb: number; warm_avail_gb: number; warm_snapshot_count: number
cold_path?: string; peer_host?: string; peer_reachable: boolean cold_path?: string; peer_host?: string; peer_reachable: boolean
zfs_pool_state: string; active_project_count: number zfs_pool_state: string; active_project_count: number
/** Pending git-sync notifications waiting for the peer. 0 = healthy. */
sync_queue_depth?: number
/** Jobs that have exceeded the escalation threshold (~1h of retries).
* Non-zero means the peer has been unreachable long enough that the
* server has moved these from WARN to ERROR logs — surface loudly. */
sync_queue_stuck?: number
} }
/** Match SYNC_STUCK_ATTEMPTS in claw-store/src/sync.rs. Jobs with attempts
* at-or-above this get the red "stuck" treatment; below it is amber. */
export const SYNC_STUCK_ATTEMPTS = 12
export interface ProjectInfo { export interface ProjectInfo {
name: string; warm_path: string; hot_target_path: string name: string; warm_path: string; hot_target_path: string
is_active: boolean; hot_size_mb: number is_active: boolean; hot_size_mb: number
+17 -1
View File
@@ -32,7 +32,7 @@ export function NodeCard({ status, loading }: Props) {
<TierGauge label="HOT — NVMe" used={status.hot_used_gb} total={status.hot_max_gb} path={status.hot_path}/> <TierGauge label="HOT — NVMe" used={status.hot_used_gb} total={status.hot_max_gb} path={status.hot_path}/>
<TierGauge label="WARM — ZFS SSD" used={status.warm_used_gb} total={warmTotal} path={status.warm_dataset} status={status.zfs_pool_state}/> <TierGauge label="WARM — ZFS SSD" used={status.warm_used_gb} total={warmTotal} path={status.warm_dataset} status={status.zfs_pool_state}/>
{status.cold_path && <TierGauge label="COLD — HDD" used={0} total={7270} path={status.cold_path}/>} {status.cold_path && <TierGauge label="COLD — HDD" used={0} total={7270} path={status.cold_path}/>}
<div className="grid grid-cols-3 gap-2 pt-3 border-t border-zinc-800"> <div className="grid grid-cols-4 gap-2 pt-3 border-t border-zinc-800">
<div className="text-center"> <div className="text-center">
<p className="text-xl font-bold text-zinc-100">{status.warm_snapshot_count}</p> <p className="text-xl font-bold text-zinc-100">{status.warm_snapshot_count}</p>
<p className="text-xs text-zinc-500">snapshots</p> <p className="text-xs text-zinc-500">snapshots</p>
@@ -47,6 +47,22 @@ export function NodeCard({ status, loading }: Props) {
</p> </p>
<p className="text-xs text-zinc-500">peer</p> <p className="text-xs text-zinc-500">peer</p>
</div> </div>
<div className="text-center">
<p className={`text-xl font-bold ${
(status.sync_queue_stuck ?? 0) > 0
? "text-red-400"
: (status.sync_queue_depth ?? 0) > 0
? "text-amber-400"
: "text-emerald-400"
}`}>
{status.sync_queue_depth ?? 0}
{(status.sync_queue_stuck ?? 0) > 0 &&
<span className="text-sm ml-1">/{status.sync_queue_stuck}⚠</span>}
</p>
<p className="text-xs text-zinc-500">
{(status.sync_queue_stuck ?? 0) > 0 ? "queue / stuck" : "queue"}
</p>
</div>
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
+27 -10
View File
@@ -1,30 +1,47 @@
import type { SyncJob } from "../api" import type { SyncJob } from "../api"
import { SYNC_STUCK_ATTEMPTS } from "../api"
import { Card, CardHeader, CardTitle, CardContent } from "./ui/card" import { Card, CardHeader, CardTitle, CardContent } from "./ui/card"
import { Badge } from "./ui/badge" import { Badge } from "./ui/badge"
import { CheckCircle2, AlertCircle } from "lucide-react" import { CheckCircle2, AlertCircle, AlertTriangle } from "lucide-react"
interface Props { jobs: SyncJob[] } interface Props { jobs: SyncJob[] }
export function SyncQueueCard({ jobs }: Props) { export function SyncQueueCard({ jobs }: Props) {
const stuckCount = jobs.filter(j => j.attempts >= SYNC_STUCK_ATTEMPTS).length
const headerIcon =
jobs.length === 0 ? <CheckCircle2 className="h-4 w-4 text-emerald-500"/>
: stuckCount > 0 ? <AlertTriangle className="h-4 w-4 text-red-500"/>
: <AlertCircle className="h-4 w-4 text-amber-400"/>
return ( return (
<Card> <Card>
<CardHeader> <CardHeader>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<CardTitle>Sync Queue</CardTitle> <CardTitle>Sync Queue</CardTitle>
{jobs.length===0 ? <CheckCircle2 className="h-4 w-4 text-emerald-500"/> : <AlertCircle className="h-4 w-4 text-amber-400"/>} {headerIcon}
</div> </div>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
{jobs.length===0 {jobs.length===0
? <p className="text-sm text-zinc-500">All clear — no pending cross-node syncs</p> ? <p className="text-sm text-zinc-500">All clear — no pending cross-node syncs</p>
: <div className="space-y-2"> : <div className="space-y-2">
{jobs.map(j=>( {stuckCount > 0 &&
<div key={j.project} className="flex items-center justify-between rounded-md bg-zinc-800 px-3 py-2"> <p className="text-xs text-red-400 -mt-1 mb-1">
<span className="text-sm font-mono text-zinc-200">{j.project}</span> {stuckCount} job{stuckCount>1?"s":""} stuck — peer unreachable &gt; 1h
<div className="flex items-center gap-2"> </p>}
<span className="text-xs text-zinc-500">attempt {j.attempts}</span> {jobs.map(j=>{
<Badge variant="warning">pending</Badge> const isStuck = j.attempts >= SYNC_STUCK_ATTEMPTS
return (
<div key={j.project} className="flex items-center justify-between rounded-md bg-zinc-800 px-3 py-2">
<span className="text-sm font-mono text-zinc-200">{j.project}</span>
<div className="flex items-center gap-2">
<span className={`text-xs ${isStuck ? "text-red-400 font-semibold" : "text-zinc-500"}`}>
attempt {j.attempts}
</span>
<Badge variant={isStuck ? "destructive" : "warning"}>
{isStuck ? "stuck" : "pending"}
</Badge>
</div>
</div> </div>
</div> )
))} })}
</div> </div>
} }
</CardContent> </CardContent>