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:
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 > 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>
|
||||||
|
|||||||
Reference in New Issue
Block a user