dashboard-v2 PR 2: frontend SPA + serve integration #93
@@ -64,8 +64,13 @@ enum Cmd {
|
||||
Serve {
|
||||
#[arg(long, default_value = "7700")]
|
||||
port: u16,
|
||||
/// Legacy dashboard static assets (served under `/`).
|
||||
#[arg(long)]
|
||||
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)
|
||||
Pin { project: String },
|
||||
@@ -350,8 +355,8 @@ async fn main() -> Result<()> {
|
||||
Cmd::ListSnapshots { project } => cmd_list_snapshots(&cfg, &zfs, &project)?,
|
||||
Cmd::Restore { project, snapshot } => cmd_restore(&cfg, &zfs, &project, &snapshot)?,
|
||||
Cmd::Replicate => cmd_replicate(&cfg, &zfs)?,
|
||||
Cmd::Serve { port, static_dir } =>
|
||||
serve::run_server(cfg, manifest, port, static_dir).await?,
|
||||
Cmd::Serve { port, static_dir, v2_static_dir } =>
|
||||
serve::run_server(cfg, manifest, port, static_dir, v2_static_dir).await?,
|
||||
Cmd::Pin { project } => cmd_set_pin(&manifest_path, &project, true)?,
|
||||
Cmd::Unpin { project } => cmd_set_pin(&manifest_path, &project, false)?,
|
||||
Cmd::ClusterProbe { peer } => cmd_cluster_probe(&cfg, &peer).await?,
|
||||
|
||||
+28
-3
@@ -664,7 +664,20 @@ async fn auth_middleware(
|
||||
// ── server entry point ────────────────────────────────────────────────────────
|
||||
|
||||
/// 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 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
|
||||
// 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_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()
|
||||
.merge(api.layer(cors.clone()).with_state(state))
|
||||
@@ -709,9 +733,10 @@ pub async fn run_server(
|
||||
_manifest: Manifest,
|
||||
port: u16,
|
||||
static_dir: Option<PathBuf>,
|
||||
v2_static_dir: Option<PathBuf>,
|
||||
) -> Result<()> {
|
||||
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));
|
||||
tracing::info!("claw-store API server listening on {}", addr);
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
node_modules/
|
||||
dist/
|
||||
@@ -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>
|
||||
Generated
+2121
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
@@ -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" }) })] }) })] }));
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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) })] }))] }) }));
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 })] }));
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
body {
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
}
|
||||
@@ -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`;
|
||||
}
|
||||
@@ -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`;
|
||||
}
|
||||
@@ -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, {}) }));
|
||||
@@ -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>
|
||||
);
|
||||
@@ -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" })] })] }))] }));
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 ?? '—' })] })] }))] }));
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 }));
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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: [],
|
||||
};
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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"}
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
@@ -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-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-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. |
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user