Build with clawstor cache / Cargo build (clawstor-cached) (pull_request) Failing after 4s
Two problems surfaced while checking on the fleet after the shutdown- prep button PR: 1. morpheus is configured with zfs_dataset = "none" (it has no ZFS pool -- warm tier is a plain directory on the LVM root volume), but nothing in the code actually implemented that as a sentinel. cmd_snapshot/cmd_replicate and the daemon's periodic snap/repl ticks always tried real zfs/zpool calls regardless, producing "zfs: command not found" errors on every hourly tick and in the shutdown-prep report. WarmConfig::zfs_enabled() now gates all four call sites; a non-ZFS node gets a clean "nothing to snapshot/replicate" instead of a raw shell error. 2. safe-shutdown-prep.sh's zpool-health step now checks `command -v zpool` first instead of leaking "zpool: command not found" into the report. 3. "we don't see the button" turned out to be page confusion: the shutdown-prep panel lives on the per-node detail page (/v2/nodes/<name>), not the root Fleet Health landing page. Added a small "view detail · maintenance & shutdown prep →" hint to the bottom of every NodeCard so it's discoverable without already knowing to click through. Verified against tank, architect, and morpheus -- morpheus's shutdown-prep --dry-run report is now clean (no "command not found" lines) both when run locally and via cross-node RPC from tank. Co-Authored-By: Claude Sonnet 5 <[email protected]>
144 lines
4.6 KiB
TypeScript
144 lines
4.6 KiB
TypeScript
import { Link } from 'wouter';
|
|
import { NodeStatusV2 } from '../lib/api';
|
|
import { StorageBar } from './StorageBar';
|
|
|
|
interface Props {
|
|
node: NodeStatusV2;
|
|
}
|
|
|
|
/// Human-oriented node card for the FleetHealth landing.
|
|
/// Shows: overall health traffic-light, storage bars, mount state,
|
|
/// cache hit rate, next scheduled job. No hex, no primitives.
|
|
export function NodeCard({ node }: Props) {
|
|
const health = healthOf(node);
|
|
const border = {
|
|
ok: 'border-emerald-700 hover:border-emerald-500',
|
|
warn: 'border-amber-700 hover:border-amber-500',
|
|
err: 'border-red-700 hover:border-red-500',
|
|
idle: 'border-slate-700 hover:border-slate-500',
|
|
}[health];
|
|
const dot = {
|
|
ok: 'bg-emerald-400',
|
|
warn: 'bg-amber-400',
|
|
err: 'bg-red-400',
|
|
idle: 'bg-slate-500',
|
|
}[health];
|
|
|
|
return (
|
|
<Link href={`/nodes/${node.node_name}`}>
|
|
<a
|
|
className={[
|
|
'block rounded-lg bg-slate-900 border transition-colors',
|
|
'p-5 space-y-4',
|
|
border,
|
|
].join(' ')}
|
|
>
|
|
{/* Header */}
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex items-center gap-2">
|
|
<span className={`inline-block w-2.5 h-2.5 rounded-full ${dot}`} />
|
|
<span className="text-lg font-semibold text-slate-100">
|
|
{node.node_name}
|
|
</span>
|
|
</div>
|
|
<span className="text-xs text-slate-500 font-mono">
|
|
{node.zone || '—'}
|
|
</span>
|
|
</div>
|
|
|
|
{/* Error banner */}
|
|
{!node.online && (
|
|
<div className="text-sm text-red-400 break-words">
|
|
{node.error ?? 'offline'}
|
|
</div>
|
|
)}
|
|
|
|
{node.online && (
|
|
<>
|
|
{/* Storage bars */}
|
|
{node.filesystem && (
|
|
<StorageBar
|
|
label="disk"
|
|
used={node.filesystem.used_bytes}
|
|
total={node.filesystem.total_bytes}
|
|
/>
|
|
)}
|
|
{node.hot && node.hot.max_bytes > 0 && (
|
|
<StorageBar
|
|
label="hot tier"
|
|
used={node.hot.used_bytes}
|
|
total={node.hot.max_bytes}
|
|
pinned={node.hot.pinned_bytes ?? undefined}
|
|
/>
|
|
)}
|
|
|
|
{/* One-liner facts */}
|
|
<div className="grid grid-cols-2 gap-y-1 text-sm">
|
|
<span className="text-slate-500">mount</span>
|
|
<span className="text-right font-mono text-xs">
|
|
{node.mount?.active ? (
|
|
<span className="text-emerald-300">✓ mounted</span>
|
|
) : (
|
|
<span className="text-slate-500">not mounted</span>
|
|
)}
|
|
</span>
|
|
<span className="text-slate-500">cache hit rate</span>
|
|
<span className="text-right font-mono text-xs">
|
|
{node.cache && node.cache.hits + node.cache.misses > 0
|
|
? `${Math.round(node.cache.hit_rate * 100)}%`
|
|
: <span className="text-slate-500">idle</span>}
|
|
</span>
|
|
<span className="text-slate-500">next scheduled job</span>
|
|
<span className="text-right font-mono text-xs">
|
|
{nextTimer(node)}
|
|
</span>
|
|
</div>
|
|
</>
|
|
)}
|
|
|
|
<div className="pt-1 border-t border-slate-800 text-xs text-slate-500">
|
|
view detail · maintenance & shutdown prep →
|
|
</div>
|
|
</a>
|
|
</Link>
|
|
);
|
|
}
|
|
|
|
function healthOf(n: NodeStatusV2): 'ok' | 'warn' | 'err' | 'idle' {
|
|
if (!n.online) return 'err';
|
|
const fsPct = n.filesystem
|
|
? n.filesystem.used_bytes / Math.max(n.filesystem.total_bytes, 1)
|
|
: 0;
|
|
const anyFailed = n.timers.some(
|
|
(t) => t.last_result && t.last_result !== 'success'
|
|
);
|
|
if (fsPct > 0.9 || anyFailed) return 'err';
|
|
if (fsPct > 0.75) return 'warn';
|
|
if (n.mount && !n.mount.active) return 'warn';
|
|
return 'ok';
|
|
}
|
|
|
|
function nextTimer(n: NodeStatusV2): React.ReactNode {
|
|
const next = n.timers
|
|
.filter((t) => t.next_fire_unix)
|
|
.sort((a, b) => (a.next_fire_unix ?? 0) - (b.next_fire_unix ?? 0))[0];
|
|
if (!next?.next_fire_unix) return <span className="text-slate-500">—</span>;
|
|
const label = next.unit
|
|
.replace(/^clawstor-/, '')
|
|
.replace(/\.timer$/, '');
|
|
const now = Math.floor(Date.now() / 1000);
|
|
const diff = next.next_fire_unix - now;
|
|
const when =
|
|
diff < 3600
|
|
? `in ${Math.max(0, Math.floor(diff / 60))}m`
|
|
: diff < 86400
|
|
? `in ${Math.floor(diff / 3600)}h`
|
|
: `in ${Math.floor(diff / 86400)}d`;
|
|
return (
|
|
<span>
|
|
<span className="text-slate-300">{label}</span>{' '}
|
|
<span className="text-slate-500">{when}</span>
|
|
</span>
|
|
);
|
|
}
|