dashboard-v2 PR 2: frontend SPA + serve integration #93

Merged
osobh merged 1 commits from dashboard-v2-frontend into main 2026-07-14 22:59:53 +00:00
32 changed files with 3264 additions and 5 deletions
+7 -2
View File
@@ -64,8 +64,13 @@ enum Cmd {
Serve { Serve {
#[arg(long, default_value = "7700")] #[arg(long, default_value = "7700")]
port: u16, port: u16,
/// Legacy dashboard static assets (served under `/`).
#[arg(long)] #[arg(long)]
static_dir: Option<PathBuf>, static_dir: Option<PathBuf>,
/// dashboard-v2 static assets (served under `/v2/*`).
/// See docs/dashboard-v2.md.
#[arg(long)]
v2_static_dir: Option<PathBuf>,
}, },
/// Mark a project as pinned — survives every GC pass (stale + LRU) /// Mark a project as pinned — survives every GC pass (stale + LRU)
Pin { project: String }, Pin { project: String },
@@ -350,8 +355,8 @@ async fn main() -> Result<()> {
Cmd::ListSnapshots { project } => cmd_list_snapshots(&cfg, &zfs, &project)?, Cmd::ListSnapshots { project } => cmd_list_snapshots(&cfg, &zfs, &project)?,
Cmd::Restore { project, snapshot } => cmd_restore(&cfg, &zfs, &project, &snapshot)?, Cmd::Restore { project, snapshot } => cmd_restore(&cfg, &zfs, &project, &snapshot)?,
Cmd::Replicate => cmd_replicate(&cfg, &zfs)?, Cmd::Replicate => cmd_replicate(&cfg, &zfs)?,
Cmd::Serve { port, static_dir } => Cmd::Serve { port, static_dir, v2_static_dir } =>
serve::run_server(cfg, manifest, port, static_dir).await?, serve::run_server(cfg, manifest, port, static_dir, v2_static_dir).await?,
Cmd::Pin { project } => cmd_set_pin(&manifest_path, &project, true)?, Cmd::Pin { project } => cmd_set_pin(&manifest_path, &project, true)?,
Cmd::Unpin { project } => cmd_set_pin(&manifest_path, &project, false)?, Cmd::Unpin { project } => cmd_set_pin(&manifest_path, &project, false)?,
Cmd::ClusterProbe { peer } => cmd_cluster_probe(&cfg, &peer).await?, Cmd::ClusterProbe { peer } => cmd_cluster_probe(&cfg, &peer).await?,
+28 -3
View File
@@ -664,7 +664,20 @@ async fn auth_middleware(
// ── server entry point ──────────────────────────────────────────────────────── // ── server entry point ────────────────────────────────────────────────────────
/// Builds the Axum Router. Extracted so tests can call it without binding a port. /// Builds the Axum Router. Extracted so tests can call it without binding a port.
pub fn build_app(cfg: Config, manifest_path: PathBuf, static_dir: Option<PathBuf>) -> Router { pub fn build_app(
cfg: Config,
manifest_path: PathBuf,
static_dir: Option<PathBuf>,
) -> Router {
build_app_with_v2(cfg, manifest_path, static_dir, None)
}
pub fn build_app_with_v2(
cfg: Config,
manifest_path: PathBuf,
static_dir: Option<PathBuf>,
v2_static_dir: Option<PathBuf>,
) -> Router {
let state = Arc::new(AppState { cfg, manifest_path }); let state = Arc::new(AppState { cfg, manifest_path });
let cors = CorsLayer::new() let cors = CorsLayer::new()
@@ -697,7 +710,18 @@ pub fn build_app(cfg: Config, manifest_path: PathBuf, static_dir: Option<PathBuf
// the legacy /api/* routes above so the cutover doesn't break the // the legacy /api/* routes above so the cutover doesn't break the
// old dashboard while the new one is being iterated on. // old dashboard while the new one is being iterated on.
let v2_state = std::sync::Arc::new(crate::serve_v2::V2State::from_config(&state.cfg)); let v2_state = std::sync::Arc::new(crate::serve_v2::V2State::from_config(&state.cfg));
let v2_routes = crate::serve_v2::routes().with_state(v2_state); let mut v2_routes = crate::serve_v2::routes();
if let Some(dir) = v2_static_dir {
// Serve the compiled SPA at /v2/*. Any unknown /v2/* path
// falls back to index.html so client-side wouter routing works.
v2_routes = v2_routes.nest_service(
"/v2",
tower_http::services::ServeDir::new(&dir).fallback(
tower_http::services::ServeFile::new(dir.join("index.html")),
),
);
}
let v2_routes = v2_routes.with_state(v2_state);
Router::new() Router::new()
.merge(api.layer(cors.clone()).with_state(state)) .merge(api.layer(cors.clone()).with_state(state))
@@ -709,9 +733,10 @@ pub async fn run_server(
_manifest: Manifest, _manifest: Manifest,
port: u16, port: u16,
static_dir: Option<PathBuf>, static_dir: Option<PathBuf>,
v2_static_dir: Option<PathBuf>,
) -> Result<()> { ) -> Result<()> {
let manifest_path = Manifest::default_path(); let manifest_path = Manifest::default_path();
let app = build_app(cfg, manifest_path, static_dir); let app = build_app_with_v2(cfg, manifest_path, static_dir, v2_static_dir);
let addr = std::net::SocketAddr::from(([0, 0, 0, 0], port)); let addr = std::net::SocketAddr::from(([0, 0, 0, 0], port));
tracing::info!("claw-store API server listening on {}", addr); tracing::info!("claw-store API server listening on {}", addr);
+2
View File
@@ -0,0 +1,2 @@
node_modules/
dist/
+12
View File
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en" class="dark">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>clawstor · command center</title>
</head>
<body class="bg-slate-950 text-slate-100 font-sans">
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+2121
View File
File diff suppressed because it is too large Load Diff
+26
View File
@@ -0,0 +1,26 @@
{
"name": "clawstor-dashboard-v2",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview"
},
"dependencies": {
"react": "^19.2.6",
"react-dom": "^19.2.6",
"wouter": "^3.7.1"
},
"devDependencies": {
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.1",
"autoprefixer": "^10.5.0",
"postcss": "^8.5.15",
"tailwindcss": "^3.4.19",
"typescript": "~6.0.2",
"vite": "^8.0.12"
}
}
+6
View File
@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
+19
View File
@@ -0,0 +1,19 @@
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
import { Route, Switch, Link, useLocation } from 'wouter';
import { CommandCenter } from './pages/CommandCenter';
import { NodeDetail } from './pages/NodeDetail';
import { StorageBrowser } from './pages/StorageBrowser';
import { RefTrackingPage } from './pages/RefTrackingPage';
export default function App() {
const [loc] = useLocation();
const tab = (path, label) => {
const active = loc === path || (path !== '/' && loc.startsWith(path));
return (_jsx(Link, { href: path, children: _jsx("a", { className: [
'px-3 py-2 text-sm border-b-2 transition-colors',
active
? 'border-emerald-400 text-emerald-300'
: 'border-transparent text-slate-400 hover:text-slate-100',
].join(' '), children: label }) }));
};
return (_jsxs("div", { className: "min-h-screen", children: [_jsx("header", { className: "border-b border-slate-800 bg-slate-950/80 backdrop-blur sticky top-0 z-10", children: _jsxs("div", { className: "max-w-7xl mx-auto px-4 flex items-center gap-6", children: [_jsx(Link, { href: "/", children: _jsx("a", { className: "font-mono text-lg text-emerald-300 py-3", children: "clawstor \u00B7 command center" }) }), _jsxs("nav", { className: "flex", children: [tab('/', 'Overview'), tab('/storage/blobs', 'Blobs'), tab('/storage/tags', 'Tags'), tab('/storage/refs', 'Refs'), tab('/storage/snapshots', 'Snapshots'), tab('/refs/tracking', 'Ref-tracking')] })] }) }), _jsx("main", { className: "max-w-7xl mx-auto p-4", children: _jsxs(Switch, { children: [_jsx(Route, { path: "/", component: CommandCenter }), _jsx(Route, { path: "/nodes/:name", children: (params) => _jsx(NodeDetail, { name: params.name }) }), _jsx(Route, { path: "/storage/:tab", children: (params) => _jsx(StorageBrowser, { tab: params.tab }) }), _jsx(Route, { path: "/refs/tracking", component: RefTrackingPage }), _jsx(Route, { children: _jsx("div", { className: "text-slate-400 py-12 text-center", children: "404" }) })] }) })] }));
}
+62
View File
@@ -0,0 +1,62 @@
import { Route, Switch, Link, useLocation } from 'wouter';
import { CommandCenter } from './pages/CommandCenter';
import { NodeDetail } from './pages/NodeDetail';
import { StorageBrowser } from './pages/StorageBrowser';
import { RefTrackingPage } from './pages/RefTrackingPage';
export default function App() {
const [loc] = useLocation();
const tab = (path: string, label: string) => {
const active = loc === path || (path !== '/' && loc.startsWith(path));
return (
<Link href={path}>
<a
className={[
'px-3 py-2 text-sm border-b-2 transition-colors',
active
? 'border-emerald-400 text-emerald-300'
: 'border-transparent text-slate-400 hover:text-slate-100',
].join(' ')}
>
{label}
</a>
</Link>
);
};
return (
<div className="min-h-screen">
<header className="border-b border-slate-800 bg-slate-950/80 backdrop-blur sticky top-0 z-10">
<div className="max-w-7xl mx-auto px-4 flex items-center gap-6">
<Link href="/">
<a className="font-mono text-lg text-emerald-300 py-3">
clawstor · command center
</a>
</Link>
<nav className="flex">
{tab('/', 'Overview')}
{tab('/storage/blobs', 'Blobs')}
{tab('/storage/tags', 'Tags')}
{tab('/storage/refs', 'Refs')}
{tab('/storage/snapshots', 'Snapshots')}
{tab('/refs/tracking', 'Ref-tracking')}
</nav>
</div>
</header>
<main className="max-w-7xl mx-auto p-4">
<Switch>
<Route path="/" component={CommandCenter} />
<Route path="/nodes/:name">
{(params) => <NodeDetail name={params.name} />}
</Route>
<Route path="/storage/:tab">
{(params) => <StorageBrowser tab={params.tab as any} />}
</Route>
<Route path="/refs/tracking" component={RefTrackingPage} />
<Route>
<div className="text-slate-400 py-12 text-center">404</div>
</Route>
</Switch>
</main>
</div>
);
}
+17
View File
@@ -0,0 +1,17 @@
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
import { Link } from 'wouter';
import { fmtBytes } from '../lib/api';
export function NodeCard({ node, loading, error }) {
const health = error ? 'err' : loading ? 'idle' : 'ok';
const ring = {
ok: 'ring-emerald-500/60',
warn: 'ring-amber-500/60',
err: 'ring-red-500/60',
idle: 'ring-slate-700',
}[health];
return (_jsx(Link, { href: `/nodes/${node.node_name}`, children: _jsxs("a", { className: [
'block rounded-lg border border-slate-800 bg-slate-900 p-5',
'ring-1 hover:bg-slate-800/70 transition-colors',
ring,
].join(' '), children: [_jsxs("div", { className: "flex items-baseline justify-between", children: [_jsx("div", { className: "text-lg font-semibold text-slate-100", children: node.node_name }), _jsx("div", { className: "text-xs text-slate-500 font-mono", children: error ? 'error' : loading ? 'loading' : 'online' })] }), error ? (_jsx("div", { className: "mt-3 text-sm text-red-400", children: error })) : (_jsxs("div", { className: "mt-3 grid grid-cols-2 gap-x-4 gap-y-1 text-sm", children: [_jsx("div", { className: "text-slate-500", children: "blobs" }), _jsx("div", { className: "font-mono text-right", children: node.blob_count.toLocaleString() }), _jsx("div", { className: "text-slate-500", children: "tags" }), _jsx("div", { className: "font-mono text-right", children: node.tag_count }), _jsx("div", { className: "text-slate-500", children: "refs" }), _jsx("div", { className: "font-mono text-right", children: node.ref_count }), _jsx("div", { className: "text-slate-500", children: "snapshots" }), _jsx("div", { className: "font-mono text-right", children: node.snapshot_count }), _jsx("div", { className: "text-slate-500", children: "ref-tracking" }), _jsx("div", { className: "font-mono text-right", children: node.ref_tracking_count }), _jsx("div", { className: "text-slate-500", children: "store size" }), _jsx("div", { className: "font-mono text-right", children: fmtBytes(node.blob_store_bytes) })] }))] }) }));
}
+54
View File
@@ -0,0 +1,54 @@
import { Link } from 'wouter';
import { NodeStatusV2, fmtBytes } from '../lib/api';
interface Props {
node: NodeStatusV2;
loading?: boolean;
error?: string | null;
}
export function NodeCard({ node, loading, error }: Props) {
const health = error ? 'err' : loading ? 'idle' : 'ok';
const ring = {
ok: 'ring-emerald-500/60',
warn: 'ring-amber-500/60',
err: 'ring-red-500/60',
idle: 'ring-slate-700',
}[health];
return (
<Link href={`/nodes/${node.node_name}`}>
<a
className={[
'block rounded-lg border border-slate-800 bg-slate-900 p-5',
'ring-1 hover:bg-slate-800/70 transition-colors',
ring,
].join(' ')}
>
<div className="flex items-baseline justify-between">
<div className="text-lg font-semibold text-slate-100">{node.node_name}</div>
<div className="text-xs text-slate-500 font-mono">
{error ? 'error' : loading ? 'loading' : 'online'}
</div>
</div>
{error ? (
<div className="mt-3 text-sm text-red-400">{error}</div>
) : (
<div className="mt-3 grid grid-cols-2 gap-x-4 gap-y-1 text-sm">
<div className="text-slate-500">blobs</div>
<div className="font-mono text-right">{node.blob_count.toLocaleString()}</div>
<div className="text-slate-500">tags</div>
<div className="font-mono text-right">{node.tag_count}</div>
<div className="text-slate-500">refs</div>
<div className="font-mono text-right">{node.ref_count}</div>
<div className="text-slate-500">snapshots</div>
<div className="font-mono text-right">{node.snapshot_count}</div>
<div className="text-slate-500">ref-tracking</div>
<div className="font-mono text-right">{node.ref_tracking_count}</div>
<div className="text-slate-500">store size</div>
<div className="font-mono text-right">{fmtBytes(node.blob_store_bytes)}</div>
</div>
)}
</a>
</Link>
);
}
+14
View File
@@ -0,0 +1,14 @@
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
export function StatTile({ label, value, hint, color = 'idle' }) {
const ring = {
ok: 'ring-emerald-500/40',
warn: 'ring-amber-500/40',
err: 'ring-red-500/40',
idle: 'ring-slate-700',
}[color];
return (_jsxs("div", { className: [
'rounded-lg border border-slate-800 bg-slate-900/60 p-4',
'ring-1',
ring,
].join(' '), children: [_jsx("div", { className: "text-xs uppercase tracking-wider text-slate-500", children: label }), _jsx("div", { className: "mt-1 text-2xl font-semibold text-slate-100 font-mono", children: value }), hint && _jsx("div", { className: "mt-1 text-xs text-slate-500", children: hint })] }));
}
+28
View File
@@ -0,0 +1,28 @@
interface Props {
label: string;
value: string | number;
hint?: string;
color?: 'ok' | 'warn' | 'err' | 'idle';
}
export function StatTile({ label, value, hint, color = 'idle' }: Props) {
const ring = {
ok: 'ring-emerald-500/40',
warn: 'ring-amber-500/40',
err: 'ring-red-500/40',
idle: 'ring-slate-700',
}[color];
return (
<div
className={[
'rounded-lg border border-slate-800 bg-slate-900/60 p-4',
'ring-1',
ring,
].join(' ')}
>
<div className="text-xs uppercase tracking-wider text-slate-500">{label}</div>
<div className="mt-1 text-2xl font-semibold text-slate-100 font-mono">{value}</div>
{hint && <div className="mt-1 text-xs text-slate-500">{hint}</div>}
</div>
);
}
+9
View File
@@ -0,0 +1,9 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
body {
-webkit-font-smoothing: antialiased;
}
}
+44
View File
@@ -0,0 +1,44 @@
// Thin fetch wrappers around /api/v2/*. Kept minimal — no state
// library; each page owns its own useEffect + useState.
// The backend runs on the same origin the SPA loads from, so
// absolute URLs are unnecessary. Dev proxy handles the :5173 →
// :7700 hop.
async function get(path) {
const resp = await fetch(path, { headers: { Accept: 'application/json' } });
if (!resp.ok) {
throw new Error(`${path}${resp.status} ${resp.statusText}`);
}
return resp.json();
}
export const api = {
nodeStatus: (name = 'local') => get(`/api/v2/node/${name}/status`),
blobs: (limit = 200, offset = 0) => get(`/api/v2/storage/blobs?limit=${limit}&offset=${offset}`),
tags: (prefix = '') => get(`/api/v2/storage/tags?prefix=${encodeURIComponent(prefix)}`),
refs: (limit = 200, offset = 0) => get(`/api/v2/storage/refs?limit=${limit}&offset=${offset}`),
snapshots: () => get(`/api/v2/storage/snapshots`),
refTracking: (repo = '') => get(`/api/v2/storage/ref-tracking?repo=${encodeURIComponent(repo)}`),
};
/** Format bytes as MB / GB / TB as needed. */
export function fmtBytes(n) {
if (n < 1024)
return `${n} B`;
if (n < 1024 * 1024)
return `${(n / 1024).toFixed(1)} KiB`;
if (n < 1024 * 1024 * 1024)
return `${(n / (1024 * 1024)).toFixed(1)} MiB`;
if (n < 1024 * 1024 * 1024 * 1024)
return `${(n / (1024 * 1024 * 1024)).toFixed(1)} GiB`;
return `${(n / (1024 * 1024 * 1024 * 1024)).toFixed(2)} TiB`;
}
/** Human-friendly relative time. */
export function fmtAge(unix) {
const now = Math.floor(Date.now() / 1000);
const diff = now - unix;
if (diff < 60)
return `${diff}s ago`;
if (diff < 3600)
return `${Math.floor(diff / 60)}m ago`;
if (diff < 86400)
return `${Math.floor(diff / 3600)}h ago`;
return `${Math.floor(diff / 86400)}d ago`;
}
+87
View File
@@ -0,0 +1,87 @@
// Thin fetch wrappers around /api/v2/*. Kept minimal — no state
// library; each page owns its own useEffect + useState.
export interface NodeStatusV2 {
node_name: string;
blob_store_root: string | null;
blob_count: number;
tag_count: number;
ref_count: number;
snapshot_count: number;
ref_tracking_count: number;
blob_store_bytes: number;
}
export interface BlobSummary {
blob_id_hex: string;
size_bytes: number;
chunk_count: number;
}
export interface TagSummary {
key: string;
value_hex: string;
}
export interface RefSummary {
fingerprint_hex: string;
blob_id_hex: string;
}
export interface SnapshotSummary {
name: string;
created_at_unix: number;
blob_count: number;
file_bytes: number;
}
export interface RefTrackingItem {
fingerprint_hex: string;
repo: string;
refs: string[];
first_seen_unix: number;
last_seen_unix: number;
}
// The backend runs on the same origin the SPA loads from, so
// absolute URLs are unnecessary. Dev proxy handles the :5173 →
// :7700 hop.
async function get<T>(path: string): Promise<T> {
const resp = await fetch(path, { headers: { Accept: 'application/json' } });
if (!resp.ok) {
throw new Error(`${path}${resp.status} ${resp.statusText}`);
}
return resp.json();
}
export const api = {
nodeStatus: (name = 'local') => get<NodeStatusV2>(`/api/v2/node/${name}/status`),
blobs: (limit = 200, offset = 0) =>
get<BlobSummary[]>(`/api/v2/storage/blobs?limit=${limit}&offset=${offset}`),
tags: (prefix = '') =>
get<TagSummary[]>(`/api/v2/storage/tags?prefix=${encodeURIComponent(prefix)}`),
refs: (limit = 200, offset = 0) =>
get<RefSummary[]>(`/api/v2/storage/refs?limit=${limit}&offset=${offset}`),
snapshots: () => get<SnapshotSummary[]>(`/api/v2/storage/snapshots`),
refTracking: (repo = '') =>
get<RefTrackingItem[]>(`/api/v2/storage/ref-tracking?repo=${encodeURIComponent(repo)}`),
};
/** Format bytes as MB / GB / TB as needed. */
export function fmtBytes(n: number): string {
if (n < 1024) return `${n} B`;
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KiB`;
if (n < 1024 * 1024 * 1024) return `${(n / (1024 * 1024)).toFixed(1)} MiB`;
if (n < 1024 * 1024 * 1024 * 1024) return `${(n / (1024 * 1024 * 1024)).toFixed(1)} GiB`;
return `${(n / (1024 * 1024 * 1024 * 1024)).toFixed(2)} TiB`;
}
/** Human-friendly relative time. */
export function fmtAge(unix: number): string {
const now = Math.floor(Date.now() / 1000);
const diff = now - unix;
if (diff < 60) return `${diff}s ago`;
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`;
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`;
return `${Math.floor(diff / 86400)}d ago`;
}
+7
View File
@@ -0,0 +1,7 @@
import { jsx as _jsx } from "react/jsx-runtime";
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
// @ts-expect-error — CSS side-effect import; tsc doesn't have a type for it.
import './index.css';
ReactDOM.createRoot(document.getElementById('root')).render(_jsx(React.StrictMode, { children: _jsx(App, {}) }));
+11
View File
@@ -0,0 +1,11 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
// @ts-expect-error — CSS side-effect import; tsc doesn't have a type for it.
import './index.css';
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>
);
+41
View File
@@ -0,0 +1,41 @@
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
import { useEffect, useState } from 'react';
import { api, fmtBytes } from '../lib/api';
import { NodeCard } from '../components/NodeCard';
import { StatTile } from '../components/StatTile';
// Fleet-wide command center. Reads `/api/v2/node/local/status` from
// this node. Cross-node fan-out is server-side once PR 3 lands;
// until then we assume the operator hits any node's dashboard and
// sees that node's storage state plus quick nav to the others.
//
// Poll cadence: 10 s. Cheap: 5 filesystem walks.
export function CommandCenter() {
const [me, setMe] = useState(null);
const [err, setErr] = useState(null);
const [tick, setTick] = useState(0);
useEffect(() => {
api
.nodeStatus('local')
.then((n) => {
setMe(n);
setErr(null);
})
.catch((e) => setErr(String(e)));
}, [tick]);
useEffect(() => {
const id = setInterval(() => setTick((t) => t + 1), 10_000);
return () => clearInterval(id);
}, []);
return (_jsxs("div", { className: "space-y-6", children: [_jsxs("div", { children: [_jsx("h1", { className: "text-2xl font-semibold text-slate-100", children: "Fleet" }), _jsxs("div", { className: "text-sm text-slate-500", children: ["this dashboard was served by", ' ', _jsx("span", { className: "font-mono text-slate-300", children: me?.node_name ?? '…' }), ' · click any node card to drill in'] })] }), err && (_jsx("div", { className: "rounded border border-red-800 bg-red-950/40 p-3 text-red-300 text-sm", children: err })), _jsxs("section", { className: "grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4", children: [me && (_jsx(NodeCard, { node: me, loading: false, error: null })), ['tank', 'architect', 'morpheus']
.filter((n) => n !== me?.node_name)
.map((n) => (_jsx(NodeCard, { node: {
node_name: n,
blob_store_root: null,
blob_count: 0,
tag_count: 0,
ref_count: 0,
snapshot_count: 0,
ref_tracking_count: 0,
blob_store_bytes: 0,
}, loading: true, error: null }, n)))] }), me && (_jsxs("section", { children: [_jsx("h2", { className: "text-lg font-semibold text-slate-100 mb-3", children: "This node" }), _jsxs("div", { className: "grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 gap-3", children: [_jsx(StatTile, { label: "blobs", value: me.blob_count.toLocaleString(), color: "ok" }), _jsx(StatTile, { label: "tags", value: me.tag_count, color: me.tag_count > 0 ? 'ok' : 'idle' }), _jsx(StatTile, { label: "refs", value: me.ref_count, color: me.ref_count > 0 ? 'ok' : 'idle' }), _jsx(StatTile, { label: "snapshots", value: me.snapshot_count, color: "ok" }), _jsx(StatTile, { label: "ref-tracking", value: me.ref_tracking_count, color: me.ref_tracking_count > 0 ? 'ok' : 'idle' }), _jsx(StatTile, { label: "store size", value: fmtBytes(me.blob_store_bytes), hint: me.blob_store_root ?? undefined, color: "ok" })] })] }))] }));
}
+114
View File
@@ -0,0 +1,114 @@
import { useEffect, useState } from 'react';
import { api, NodeStatusV2, fmtBytes } from '../lib/api';
import { NodeCard } from '../components/NodeCard';
import { StatTile } from '../components/StatTile';
// Fleet-wide command center. Reads `/api/v2/node/local/status` from
// this node. Cross-node fan-out is server-side once PR 3 lands;
// until then we assume the operator hits any node's dashboard and
// sees that node's storage state plus quick nav to the others.
//
// Poll cadence: 10 s. Cheap: 5 filesystem walks.
export function CommandCenter() {
const [me, setMe] = useState<NodeStatusV2 | null>(null);
const [err, setErr] = useState<string | null>(null);
const [tick, setTick] = useState(0);
useEffect(() => {
api
.nodeStatus('local')
.then((n) => {
setMe(n);
setErr(null);
})
.catch((e) => setErr(String(e)));
}, [tick]);
useEffect(() => {
const id = setInterval(() => setTick((t) => t + 1), 10_000);
return () => clearInterval(id);
}, []);
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-semibold text-slate-100">Fleet</h1>
<div className="text-sm text-slate-500">
this dashboard was served by{' '}
<span className="font-mono text-slate-300">
{me?.node_name ?? '…'}
</span>
{' · click any node card to drill in'}
</div>
</div>
{err && (
<div className="rounded border border-red-800 bg-red-950/40 p-3 text-red-300 text-sm">
{err}
</div>
)}
<section className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{me && (
<NodeCard
node={me}
loading={false}
error={null}
/>
)}
{['tank', 'architect', 'morpheus']
.filter((n) => n !== me?.node_name)
.map((n) => (
<NodeCard
key={n}
node={{
node_name: n,
blob_store_root: null,
blob_count: 0,
tag_count: 0,
ref_count: 0,
snapshot_count: 0,
ref_tracking_count: 0,
blob_store_bytes: 0,
}}
loading
error={null}
/>
))}
</section>
{me && (
<section>
<h2 className="text-lg font-semibold text-slate-100 mb-3">
This node
</h2>
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 gap-3">
<StatTile label="blobs" value={me.blob_count.toLocaleString()} color="ok" />
<StatTile
label="tags"
value={me.tag_count}
color={me.tag_count > 0 ? 'ok' : 'idle'}
/>
<StatTile
label="refs"
value={me.ref_count}
color={me.ref_count > 0 ? 'ok' : 'idle'}
/>
<StatTile label="snapshots" value={me.snapshot_count} color="ok" />
<StatTile
label="ref-tracking"
value={me.ref_tracking_count}
color={me.ref_tracking_count > 0 ? 'ok' : 'idle'}
/>
<StatTile
label="store size"
value={fmtBytes(me.blob_store_bytes)}
hint={me.blob_store_root ?? undefined}
color="ok"
/>
</div>
</section>
)}
</div>
);
}
+19
View File
@@ -0,0 +1,19 @@
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
import { useEffect, useState } from 'react';
import { Link } from 'wouter';
import { api, fmtBytes } from '../lib/api';
import { StatTile } from '../components/StatTile';
export function NodeDetail({ name }) {
const [status, setStatus] = useState(null);
const [err, setErr] = useState(null);
useEffect(() => {
api
.nodeStatus(name)
.then((n) => {
setStatus(n);
setErr(null);
})
.catch((e) => setErr(String(e)));
}, [name]);
return (_jsxs("div", { className: "space-y-6", children: [_jsxs("div", { children: [_jsx(Link, { href: "/", children: _jsx("a", { className: "text-sm text-slate-500 hover:text-slate-300", children: "\u2190 fleet" }) }), _jsx("h1", { className: "text-2xl font-semibold text-slate-100 mt-2", children: name })] }), err && (_jsxs("div", { className: "rounded border border-amber-800 bg-amber-950/40 p-3 text-amber-300 text-sm", children: [err, _jsxs("div", { className: "mt-2 text-xs text-slate-400", children: ["Cross-node lookup lands in a follow-on PR. Until then this page shows detail only when you're already viewing the dashboard hosted by ", name, ". Try opening", ' ', _jsxs("span", { className: "font-mono", children: ["http://", name, ":7700/v2/#/nodes/", name] }), ' ', "directly."] })] })), status && (_jsxs(_Fragment, { children: [_jsxs("div", { className: "grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 gap-3", children: [_jsx(StatTile, { label: "blobs", value: status.blob_count.toLocaleString(), color: "ok" }), _jsx(StatTile, { label: "tags", value: status.tag_count, color: "ok" }), _jsx(StatTile, { label: "refs", value: status.ref_count, color: "ok" }), _jsx(StatTile, { label: "snapshots", value: status.snapshot_count, color: "ok" }), _jsx(StatTile, { label: "ref-tracking", value: status.ref_tracking_count, color: "ok" }), _jsx(StatTile, { label: "store size", value: fmtBytes(status.blob_store_bytes), color: "ok" })] }), _jsxs("div", { className: "rounded border border-slate-800 bg-slate-900/40 p-4 text-sm", children: [_jsx("div", { className: "text-slate-500 uppercase text-xs tracking-wider", children: "blob store root" }), _jsx("div", { className: "font-mono mt-1", children: status.blob_store_root ?? '—' })] })] }))] }));
}
+74
View File
@@ -0,0 +1,74 @@
import { useEffect, useState } from 'react';
import { Link } from 'wouter';
import { api, NodeStatusV2, fmtBytes } from '../lib/api';
import { StatTile } from '../components/StatTile';
interface Props {
name: string;
}
export function NodeDetail({ name }: Props) {
const [status, setStatus] = useState<NodeStatusV2 | null>(null);
const [err, setErr] = useState<string | null>(null);
useEffect(() => {
api
.nodeStatus(name)
.then((n) => {
setStatus(n);
setErr(null);
})
.catch((e) => setErr(String(e)));
}, [name]);
return (
<div className="space-y-6">
<div>
<Link href="/">
<a className="text-sm text-slate-500 hover:text-slate-300">
fleet
</a>
</Link>
<h1 className="text-2xl font-semibold text-slate-100 mt-2">
{name}
</h1>
</div>
{err && (
<div className="rounded border border-amber-800 bg-amber-950/40 p-3 text-amber-300 text-sm">
{err}
<div className="mt-2 text-xs text-slate-400">
Cross-node lookup lands in a follow-on PR. Until then this
page shows detail only when you're already viewing the
dashboard hosted by {name}. Try opening{' '}
<span className="font-mono">http://{name}:7700/v2/#/nodes/{name}</span>{' '}
directly.
</div>
</div>
)}
{status && (
<>
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 gap-3">
<StatTile label="blobs" value={status.blob_count.toLocaleString()} color="ok" />
<StatTile label="tags" value={status.tag_count} color="ok" />
<StatTile label="refs" value={status.ref_count} color="ok" />
<StatTile label="snapshots" value={status.snapshot_count} color="ok" />
<StatTile label="ref-tracking" value={status.ref_tracking_count} color="ok" />
<StatTile
label="store size"
value={fmtBytes(status.blob_store_bytes)}
color="ok"
/>
</div>
<div className="rounded border border-slate-800 bg-slate-900/40 p-4 text-sm">
<div className="text-slate-500 uppercase text-xs tracking-wider">
blob store root
</div>
<div className="font-mono mt-1">{status.blob_store_root ?? ''}</div>
</div>
</>
)}
</div>
);
}
+25
View File
@@ -0,0 +1,25 @@
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
import { useEffect, useMemo, useState } from 'react';
import { api, fmtAge } from '../lib/api';
export function RefTrackingPage() {
const [rows, setRows] = useState([]);
const [repoFilter, setRepoFilter] = useState('');
const [err, setErr] = useState(null);
useEffect(() => {
api
.refTracking(repoFilter)
.then(setRows)
.catch((e) => setErr(String(e)));
}, [repoFilter]);
// Group by repo for a cleaner display.
const grouped = useMemo(() => {
const g = new Map();
for (const r of rows) {
const arr = g.get(r.repo) ?? [];
arr.push(r);
g.set(r.repo, arr);
}
return [...g.entries()].sort((a, b) => a[0].localeCompare(b[0]));
}, [rows]);
return (_jsxs("div", { className: "space-y-4", children: [_jsxs("div", { children: [_jsx("h1", { className: "text-2xl font-semibold text-slate-100", children: "Ref-tracking" }), _jsxs("p", { className: "text-sm text-slate-500 mt-1", children: ["Every cached fingerprint's producing ", _jsx("span", { className: "font-mono", children: "(repo, git-ref)" }), ". Feeds the nightly ", _jsx("span", { className: "font-mono", children: "cluster-ref-sweep" }), " that reaps fingerprints whose refs are gone from Gitea."] })] }), _jsx("input", { value: repoFilter, onChange: (e) => setRepoFilter(e.target.value), placeholder: "filter repo \u2014 e.g. clawverse/clawstor", className: "w-full sm:w-96 rounded bg-slate-900 border border-slate-800 px-3 py-2 text-sm font-mono" }), err && (_jsx("div", { className: "rounded border border-red-800 bg-red-950/40 p-3 text-red-300 text-sm", children: err })), grouped.length === 0 && (_jsx("div", { className: "text-slate-500 text-sm py-6", children: "no ref-tracking entries yet" })), grouped.map(([repo, items]) => (_jsxs("div", { className: "rounded border border-slate-800 overflow-hidden", children: [_jsxs("div", { className: "bg-slate-900/60 px-4 py-2 flex items-baseline justify-between", children: [_jsx("span", { className: "font-mono text-sm text-emerald-300", children: repo }), _jsxs("span", { className: "text-xs text-slate-500", children: [items.length, " fingerprint", items.length === 1 ? '' : 's'] })] }), _jsxs("table", { className: "w-full text-sm", children: [_jsx("thead", { className: "text-slate-500 uppercase text-xs tracking-wider", children: _jsxs("tr", { children: [_jsx("th", { className: "text-left px-4 py-2 font-normal", children: "fingerprint" }), _jsx("th", { className: "text-left px-4 py-2 font-normal", children: "refs" }), _jsx("th", { className: "text-left px-4 py-2 font-normal", children: "first seen" }), _jsx("th", { className: "text-left px-4 py-2 font-normal", children: "last seen" })] }) }), _jsx("tbody", { children: items.map((it) => (_jsxs("tr", { className: "border-t border-slate-900", children: [_jsxs("td", { className: "px-4 py-2 font-mono text-xs", children: [it.fingerprint_hex.slice(0, 24), "\u2026"] }), _jsx("td", { className: "px-4 py-2 font-mono text-xs", children: it.refs.join(', ') }), _jsx("td", { className: "px-4 py-2 text-slate-400", children: fmtAge(it.first_seen_unix) }), _jsx("td", { className: "px-4 py-2 text-slate-400", children: fmtAge(it.last_seen_unix) })] }, it.fingerprint_hex))) })] })] }, repo)))] }));
}
@@ -0,0 +1,95 @@
import { useEffect, useMemo, useState } from 'react';
import { api, RefTrackingItem, fmtAge } from '../lib/api';
export function RefTrackingPage() {
const [rows, setRows] = useState<RefTrackingItem[]>([]);
const [repoFilter, setRepoFilter] = useState('');
const [err, setErr] = useState<string | null>(null);
useEffect(() => {
api
.refTracking(repoFilter)
.then(setRows)
.catch((e) => setErr(String(e)));
}, [repoFilter]);
// Group by repo for a cleaner display.
const grouped = useMemo(() => {
const g = new Map<string, RefTrackingItem[]>();
for (const r of rows) {
const arr = g.get(r.repo) ?? [];
arr.push(r);
g.set(r.repo, arr);
}
return [...g.entries()].sort((a, b) => a[0].localeCompare(b[0]));
}, [rows]);
return (
<div className="space-y-4">
<div>
<h1 className="text-2xl font-semibold text-slate-100">Ref-tracking</h1>
<p className="text-sm text-slate-500 mt-1">
Every cached fingerprint's producing <span className="font-mono">(repo, git-ref)</span>.
Feeds the nightly <span className="font-mono">cluster-ref-sweep</span> that reaps
fingerprints whose refs are gone from Gitea.
</p>
</div>
<input
value={repoFilter}
onChange={(e) => setRepoFilter(e.target.value)}
placeholder="filter repo — e.g. clawverse/clawstor"
className="w-full sm:w-96 rounded bg-slate-900 border border-slate-800 px-3 py-2 text-sm font-mono"
/>
{err && (
<div className="rounded border border-red-800 bg-red-950/40 p-3 text-red-300 text-sm">
{err}
</div>
)}
{grouped.length === 0 && (
<div className="text-slate-500 text-sm py-6">no ref-tracking entries yet</div>
)}
{grouped.map(([repo, items]) => (
<div key={repo} className="rounded border border-slate-800 overflow-hidden">
<div className="bg-slate-900/60 px-4 py-2 flex items-baseline justify-between">
<span className="font-mono text-sm text-emerald-300">{repo}</span>
<span className="text-xs text-slate-500">
{items.length} fingerprint{items.length === 1 ? '' : 's'}
</span>
</div>
<table className="w-full text-sm">
<thead className="text-slate-500 uppercase text-xs tracking-wider">
<tr>
<th className="text-left px-4 py-2 font-normal">fingerprint</th>
<th className="text-left px-4 py-2 font-normal">refs</th>
<th className="text-left px-4 py-2 font-normal">first seen</th>
<th className="text-left px-4 py-2 font-normal">last seen</th>
</tr>
</thead>
<tbody>
{items.map((it) => (
<tr key={it.fingerprint_hex} className="border-t border-slate-900">
<td className="px-4 py-2 font-mono text-xs">
{it.fingerprint_hex.slice(0, 24)}…
</td>
<td className="px-4 py-2 font-mono text-xs">
{it.refs.join(', ')}
</td>
<td className="px-4 py-2 text-slate-400">
{fmtAge(it.first_seen_unix)}
</td>
<td className="px-4 py-2 text-slate-400">
{fmtAge(it.last_seen_unix)}
</td>
</tr>
))}
</tbody>
</table>
</div>
))}
</div>
);
}
+63
View File
@@ -0,0 +1,63 @@
import { jsxs as _jsxs, jsx as _jsx, Fragment as _Fragment } from "react/jsx-runtime";
import { useEffect, useState } from 'react';
import { api, fmtBytes, fmtAge, } from '../lib/api';
export function StorageBrowser({ tab }) {
return (_jsxs("div", { className: "space-y-4", children: [_jsxs("h1", { className: "text-2xl font-semibold text-slate-100", children: ["Storage \u00B7 ", tab] }), tab === 'blobs' && _jsx(BlobsList, {}), tab === 'tags' && _jsx(TagsList, {}), tab === 'refs' && _jsx(RefsList, {}), tab === 'snapshots' && _jsx(SnapshotsList, {})] }));
}
function BlobsList() {
const [rows, setRows] = useState([]);
const [err, setErr] = useState(null);
useEffect(() => {
api.blobs().then(setRows).catch((e) => setErr(String(e)));
}, []);
return (_jsxs(_Fragment, { children: [err && _jsx(ErrorBox, { msg: err }), _jsx(Table, { headers: ['blob-id', 'size', 'chunks'], rows: rows.map((r) => [
_jsxs("span", { className: "font-mono text-xs", children: [r.blob_id_hex.slice(0, 24), "\u2026"] }, "hex"),
fmtBytes(r.size_bytes),
r.chunk_count.toString(),
]) }), _jsx(Footer, { count: rows.length })] }));
}
function TagsList() {
const [rows, setRows] = useState([]);
const [prefix, setPrefix] = useState('');
const [err, setErr] = useState(null);
useEffect(() => {
api.tags(prefix).then(setRows).catch((e) => setErr(String(e)));
}, [prefix]);
return (_jsxs(_Fragment, { children: [_jsx("input", { value: prefix, onChange: (e) => setPrefix(e.target.value), placeholder: "filter prefix \u2014 e.g. clawverse:", className: "w-full sm:w-96 rounded bg-slate-900 border border-slate-800 px-3 py-2 text-sm font-mono" }), err && _jsx(ErrorBox, { msg: err }), _jsx(Table, { headers: ['key', 'blob-id'], rows: rows.map((r) => [
_jsx("span", { className: "font-mono text-sm", children: r.key }, "k"),
_jsxs("span", { className: "font-mono text-xs text-slate-400", children: [r.value_hex.slice(0, 24), "\u2026"] }, "v"),
]) }), _jsx(Footer, { count: rows.length })] }));
}
function RefsList() {
const [rows, setRows] = useState([]);
const [err, setErr] = useState(null);
useEffect(() => {
api.refs().then(setRows).catch((e) => setErr(String(e)));
}, []);
return (_jsxs(_Fragment, { children: [err && _jsx(ErrorBox, { msg: err }), _jsx(Table, { headers: ['fingerprint', 'blob-id'], rows: rows.map((r) => [
_jsxs("span", { className: "font-mono text-xs", children: [r.fingerprint_hex.slice(0, 24), "\u2026"] }, "fp"),
_jsxs("span", { className: "font-mono text-xs text-slate-400", children: [r.blob_id_hex.slice(0, 24), "\u2026"] }, "b"),
]) }), _jsx(Footer, { count: rows.length })] }));
}
function SnapshotsList() {
const [rows, setRows] = useState([]);
const [err, setErr] = useState(null);
useEffect(() => {
api.snapshots().then(setRows).catch((e) => setErr(String(e)));
}, []);
return (_jsxs(_Fragment, { children: [err && _jsx(ErrorBox, { msg: err }), _jsx(Table, { headers: ['name', 'created', 'blobs', 'json size'], rows: rows.map((r) => [
_jsx("span", { className: "font-mono text-sm", children: r.name }, "n"),
fmtAge(r.created_at_unix),
r.blob_count.toString(),
fmtBytes(r.file_bytes),
]) }), _jsx(Footer, { count: rows.length })] }));
}
function Table({ headers, rows, }) {
return (_jsx("div", { className: "rounded border border-slate-800 overflow-hidden", children: _jsxs("table", { className: "w-full text-sm", children: [_jsx("thead", { className: "bg-slate-900/60 text-slate-500 uppercase text-xs tracking-wider", children: _jsx("tr", { children: headers.map((h) => (_jsx("th", { className: "text-left px-4 py-2 font-normal", children: h }, h))) }) }), _jsxs("tbody", { children: [rows.length === 0 && (_jsx("tr", { children: _jsx("td", { className: "px-4 py-6 text-center text-slate-500", colSpan: headers.length, children: "nothing here yet" }) })), rows.map((row, i) => (_jsx("tr", { className: "border-t border-slate-900 hover:bg-slate-900/40", children: row.map((cell, j) => (_jsx("td", { className: "px-4 py-2", children: cell }, j))) }, i)))] })] }) }));
}
function Footer({ count }) {
return (_jsxs("div", { className: "text-xs text-slate-500 mt-2 font-mono", children: [count, " row", count === 1 ? '' : 's'] }));
}
function ErrorBox({ msg }) {
return (_jsx("div", { className: "rounded border border-red-800 bg-red-950/40 p-3 text-red-300 text-sm mb-2", children: msg }));
}
+192
View File
@@ -0,0 +1,192 @@
import { useEffect, useState, type ReactNode } from 'react';
import {
api,
BlobSummary,
TagSummary,
RefSummary,
SnapshotSummary,
fmtBytes,
fmtAge,
} from '../lib/api';
type Tab = 'blobs' | 'tags' | 'refs' | 'snapshots';
interface Props {
tab: Tab;
}
export function StorageBrowser({ tab }: Props) {
return (
<div className="space-y-4">
<h1 className="text-2xl font-semibold text-slate-100">Storage · {tab}</h1>
{tab === 'blobs' && <BlobsList />}
{tab === 'tags' && <TagsList />}
{tab === 'refs' && <RefsList />}
{tab === 'snapshots' && <SnapshotsList />}
</div>
);
}
function BlobsList() {
const [rows, setRows] = useState<BlobSummary[]>([]);
const [err, setErr] = useState<string | null>(null);
useEffect(() => {
api.blobs().then(setRows).catch((e) => setErr(String(e)));
}, []);
return (
<>
{err && <ErrorBox msg={err} />}
<Table
headers={['blob-id', 'size', 'chunks']}
rows={rows.map((r) => [
<span key="hex" className="font-mono text-xs">
{r.blob_id_hex.slice(0, 24)}
</span>,
fmtBytes(r.size_bytes),
r.chunk_count.toString(),
])}
/>
<Footer count={rows.length} />
</>
);
}
function TagsList() {
const [rows, setRows] = useState<TagSummary[]>([]);
const [prefix, setPrefix] = useState('');
const [err, setErr] = useState<string | null>(null);
useEffect(() => {
api.tags(prefix).then(setRows).catch((e) => setErr(String(e)));
}, [prefix]);
return (
<>
<input
value={prefix}
onChange={(e) => setPrefix(e.target.value)}
placeholder="filter prefix — e.g. clawverse:"
className="w-full sm:w-96 rounded bg-slate-900 border border-slate-800 px-3 py-2 text-sm font-mono"
/>
{err && <ErrorBox msg={err} />}
<Table
headers={['key', 'blob-id']}
rows={rows.map((r) => [
<span key="k" className="font-mono text-sm">
{r.key}
</span>,
<span key="v" className="font-mono text-xs text-slate-400">
{r.value_hex.slice(0, 24)}
</span>,
])}
/>
<Footer count={rows.length} />
</>
);
}
function RefsList() {
const [rows, setRows] = useState<RefSummary[]>([]);
const [err, setErr] = useState<string | null>(null);
useEffect(() => {
api.refs().then(setRows).catch((e) => setErr(String(e)));
}, []);
return (
<>
{err && <ErrorBox msg={err} />}
<Table
headers={['fingerprint', 'blob-id']}
rows={rows.map((r) => [
<span key="fp" className="font-mono text-xs">
{r.fingerprint_hex.slice(0, 24)}
</span>,
<span key="b" className="font-mono text-xs text-slate-400">
{r.blob_id_hex.slice(0, 24)}
</span>,
])}
/>
<Footer count={rows.length} />
</>
);
}
function SnapshotsList() {
const [rows, setRows] = useState<SnapshotSummary[]>([]);
const [err, setErr] = useState<string | null>(null);
useEffect(() => {
api.snapshots().then(setRows).catch((e) => setErr(String(e)));
}, []);
return (
<>
{err && <ErrorBox msg={err} />}
<Table
headers={['name', 'created', 'blobs', 'json size']}
rows={rows.map((r) => [
<span key="n" className="font-mono text-sm">
{r.name}
</span>,
fmtAge(r.created_at_unix),
r.blob_count.toString(),
fmtBytes(r.file_bytes),
])}
/>
<Footer count={rows.length} />
</>
);
}
function Table({
headers,
rows,
}: {
headers: string[];
rows: ReactNode[][];
}) {
return (
<div className="rounded border border-slate-800 overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-slate-900/60 text-slate-500 uppercase text-xs tracking-wider">
<tr>
{headers.map((h) => (
<th key={h} className="text-left px-4 py-2 font-normal">
{h}
</th>
))}
</tr>
</thead>
<tbody>
{rows.length === 0 && (
<tr>
<td className="px-4 py-6 text-center text-slate-500" colSpan={headers.length}>
nothing here yet
</td>
</tr>
)}
{rows.map((row, i) => (
<tr key={i} className="border-t border-slate-900 hover:bg-slate-900/40">
{row.map((cell, j) => (
<td key={j} className="px-4 py-2">
{cell}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
);
}
function Footer({ count }: { count: number }) {
return (
<div className="text-xs text-slate-500 mt-2 font-mono">
{count} row{count === 1 ? '' : 's'}
</div>
);
}
function ErrorBox({ msg }: { msg: string }) {
return (
<div className="rounded border border-red-800 bg-red-950/40 p-3 text-red-300 text-sm mb-2">
{msg}
</div>
);
}
+22
View File
@@ -0,0 +1,22 @@
/** @type {import('tailwindcss').Config} */
export default {
content: ['./index.html', './src/**/*.{ts,tsx}'],
theme: {
extend: {
colors: {
// clawstor palette — muted greens for health, amber for
// warning, red only for actual failures.
health: {
ok: '#22c55e',
warn: '#f59e0b',
err: '#ef4444',
idle: '#6b7280',
},
},
fontFamily: {
mono: ['JetBrains Mono', 'ui-monospace', 'monospace'],
},
},
},
plugins: [],
};
+19
View File
@@ -0,0 +1,19 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "bundler",
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"allowSyntheticDefaultImports": true,
"esModuleInterop": true,
"isolatedModules": true,
"skipLibCheck": true,
"resolveJsonModule": true
},
"include": ["src"]
}
+1
View File
@@ -0,0 +1 @@
{"root":["./src/app.tsx","./src/main.tsx","./src/components/nodecard.tsx","./src/components/stattile.tsx","./src/lib/api.ts","./src/pages/commandcenter.tsx","./src/pages/nodedetail.tsx","./src/pages/reftrackingpage.tsx","./src/pages/storagebrowser.tsx"],"version":"6.0.3"}
+24
View File
@@ -0,0 +1,24 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
// dashboard-v2 → served by `claw-store serve` under /v2/*.
// Base path lines up with the cutover plan in docs/dashboard-v2.md.
// During local dev the daemon proxies /api/v2/* on :7700 so
// `vite dev` on :5173 can hit it via server.proxy.
export default defineConfig({
base: '/v2/',
plugins: [react()],
server: {
port: 5173,
proxy: {
'/api/v2': {
target: 'http://127.0.0.1:7700',
changeOrigin: true,
},
},
},
build: {
outDir: 'dist',
emptyOutDir: true,
},
});
+1
View File
@@ -8,6 +8,7 @@ Systemd **user** units for clawstor.
|---|---| |---|---|
| `clawstor-cluster.service` | daemon (gossip + RPC + Prometheus + build cache). Not shipped here — deployed per-node from the fleet playbook. | | `clawstor-cluster.service` | daemon (gossip + RPC + Prometheus + build cache). Not shipped here — deployed per-node from the fleet playbook. |
| `clawstor-fuse.service` | Phase 6 read-only FUSE mount at `~/clawstor-mount/`. Depends on `clawstor-cluster.service`. | | `clawstor-fuse.service` | Phase 6 read-only FUSE mount at `~/clawstor-mount/`. Depends on `clawstor-cluster.service`. |
| `clawstor-dashboard.service` | HTTP dashboard on `:7700`. Serves the v2 SPA at `/v2/*` + `/api/v2/*`. See `docs/dashboard-v2.md`. |
| `clawstor-scrub.service` + `clawstor-scrub.timer` | Weekly (Sun 04:00) BLAKE3 verify every chunk against its manifest. Non-zero exit = integrity failure — surfaced by systemd status. | | `clawstor-scrub.service` + `clawstor-scrub.timer` | Weekly (Sun 04:00) BLAKE3 verify every chunk against its manifest. Non-zero exit = integrity failure — surfaced by systemd status. |
| `clawstor-gc.service` + `clawstor-gc.timer` | Nightly (03:30) orphan-chunk sweep. Override ExecStart via drop-in to add `--evict-to-gb N` for size-cap fleets. Sequenced before the Sunday scrub so scrub reads a fresh layout. | | `clawstor-gc.service` + `clawstor-gc.timer` | Nightly (03:30) orphan-chunk sweep. Override ExecStart via drop-in to add `--evict-to-gb N` for size-cap fleets. Sequenced before the Sunday scrub so scrub reads a fresh layout. |
| `clawstor-ref-sweep.service` + `clawstor-ref-sweep.timer` | Nightly (03:15) Gitea live-refs poll + stale-fingerprint report. Set `GITEA_TOKEN` via a drop-in for private repos. Report-only (dry-run) — deletion is a follow-on. | | `clawstor-ref-sweep.service` + `clawstor-ref-sweep.timer` | Nightly (03:15) Gitea live-refs poll + stale-fingerprint report. Set `GITEA_TOKEN` via a drop-in for private repos. Report-only (dry-run) — deletion is a follow-on. |
+20
View File
@@ -0,0 +1,20 @@
[Unit]
Description=Clawstor dashboard HTTP server (legacy + dashboard-v2)
Documentation=https://git.redclaw.dev/clawverse/clawstor
After=network-online.target clawstor-cluster.service
Wants=clawstor-cluster.service
[Service]
Type=simple
# Serves:
# / → legacy dashboard (from --static-dir)
# /v2/* → dashboard-v2 SPA (from --v2-static-dir)
# /api/* → legacy REST
# /api/v2/* → v2 REST
# Retarget via `systemctl --user edit clawstor-dashboard.service`.
ExecStart=%h/clawstor-deploy/claw-store --config %h/clawstor-deploy/config.toml serve --port 7700 --v2-static-dir %h/clawstor-deploy/dashboard-v2
Restart=on-failure
RestartSec=5
[Install]
WantedBy=default.target