feat(phase-a): surface uptime, dedup efficiency, per-type cache rates + FQ qdisc
Backend (rpc.rs): - DashboardStatusReply gains daemon_started_unix (proxy for daemon restart time) - CacheSummary gains per-type breakdown: get_ref_hits/misses, get_tag_hits/misses, has_chunk_hits/misses — dedup efficiency is now visible in the API response Frontend (api.ts, NodeCard.tsx): - NodeStatusV2 type carries daemon_started_unix and expanded CacheSummary - fmtUptime() helper renders "up 3d 14h" from a unix timestamp - NodeCard now shows: uptime, available disk bytes in the bar label, dedup efficiency % (has_chunk hit rate), timer last-result text for failed timers Infrastructure (T3.1): - FQ qdisc applied on all 3 nodes (Architect enp11s0+enp5s0f1, Tank same, Morpheus eno1) for precise QUIC packet pacing per QUIC Steps paper - GSO already on on Architect and Tank 10G NICs Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
This commit is contained in:
co-authored by
Claude Sonnet 4.6
parent
2f7eabf034
commit
a3efdbfc04
@@ -366,6 +366,10 @@ pub struct DashboardStatusReply {
|
|||||||
pub cache: Option<CacheSummary>,
|
pub cache: Option<CacheSummary>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub timers: Vec<TimerStatus>,
|
pub timers: Vec<TimerStatus>,
|
||||||
|
/// Unix timestamp (seconds) when this daemon's metrics counters were
|
||||||
|
/// reset — proxy for when the daemon last started/restarted.
|
||||||
|
#[serde(default)]
|
||||||
|
pub daemon_started_unix: Option<u64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
@@ -395,11 +399,27 @@ pub struct MountStatus {
|
|||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
pub struct CacheSummary {
|
pub struct CacheSummary {
|
||||||
|
/// Composite totals across ref + tag + chunk lookups (for hit_rate).
|
||||||
pub hits: u64,
|
pub hits: u64,
|
||||||
pub misses: u64,
|
pub misses: u64,
|
||||||
pub bytes_served: u64,
|
pub bytes_served: u64,
|
||||||
pub bytes_ingested: u64,
|
pub bytes_ingested: u64,
|
||||||
pub hit_rate: f64,
|
pub hit_rate: f64,
|
||||||
|
/// Per-type breakdown so the dashboard can show ref vs tag vs chunk rates.
|
||||||
|
#[serde(default)]
|
||||||
|
pub get_ref_hits: u64,
|
||||||
|
#[serde(default)]
|
||||||
|
pub get_ref_misses: u64,
|
||||||
|
#[serde(default)]
|
||||||
|
pub get_tag_hits: u64,
|
||||||
|
#[serde(default)]
|
||||||
|
pub get_tag_misses: u64,
|
||||||
|
/// HasChunk probes — measures dedup efficiency (how many chunk uploads
|
||||||
|
/// were skipped because the receiver already had the chunk).
|
||||||
|
#[serde(default)]
|
||||||
|
pub has_chunk_hits: u64,
|
||||||
|
#[serde(default)]
|
||||||
|
pub has_chunk_misses: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
@@ -981,7 +1001,7 @@ impl RpcRouter {
|
|||||||
// Cache metrics — the router already tracks these
|
// Cache metrics — the router already tracks these
|
||||||
// in-memory. Compute hit-rate here so the
|
// in-memory. Compute hit-rate here so the
|
||||||
// dashboard doesn't need to divide.
|
// dashboard doesn't need to divide.
|
||||||
let cache = {
|
let (cache, daemon_started_unix) = {
|
||||||
let snap = self.metrics.snapshot();
|
let snap = self.metrics.snapshot();
|
||||||
// The dashboard cares about "did the peer find
|
// The dashboard cares about "did the peer find
|
||||||
// what someone asked for". Sum the get_ref /
|
// what someone asked for". Sum the get_ref /
|
||||||
@@ -999,13 +1019,25 @@ impl RpcRouter {
|
|||||||
} else {
|
} else {
|
||||||
0.0
|
0.0
|
||||||
};
|
};
|
||||||
Some(CacheSummary {
|
let summary = Some(CacheSummary {
|
||||||
hits,
|
hits,
|
||||||
misses,
|
misses,
|
||||||
bytes_served: snap.blob_get_bytes,
|
bytes_served: snap.blob_get_bytes,
|
||||||
bytes_ingested: snap.blob_put_bytes,
|
bytes_ingested: snap.blob_put_bytes,
|
||||||
hit_rate: rate,
|
hit_rate: rate,
|
||||||
})
|
get_ref_hits: snap.get_ref_hits,
|
||||||
|
get_ref_misses: snap.get_ref_misses,
|
||||||
|
get_tag_hits: snap.get_tag_hits,
|
||||||
|
get_tag_misses: snap.get_tag_misses,
|
||||||
|
has_chunk_hits: snap.has_chunk_hits,
|
||||||
|
has_chunk_misses: snap.has_chunk_misses,
|
||||||
|
});
|
||||||
|
let started = if snap.started_unix > 0 {
|
||||||
|
Some(snap.started_unix)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
(summary, started)
|
||||||
};
|
};
|
||||||
// Well-known timer set. Missing timers just get
|
// Well-known timer set. Missing timers just get
|
||||||
// next_fire_unix=None / last_result=None.
|
// next_fire_unix=None / last_result=None.
|
||||||
@@ -1031,6 +1063,7 @@ impl RpcRouter {
|
|||||||
mount,
|
mount,
|
||||||
cache,
|
cache,
|
||||||
timers,
|
timers,
|
||||||
|
daemon_started_unix,
|
||||||
};
|
};
|
||||||
let json = serde_json::to_vec(&reply)
|
let json = serde_json::to_vec(&reply)
|
||||||
.context("encoding DashboardStatusReply as JSON")?;
|
.context("encoding DashboardStatusReply as JSON")?;
|
||||||
|
|||||||
@@ -181,6 +181,9 @@ pub struct NodeStatusV2 {
|
|||||||
pub cache: Option<CacheSummary>,
|
pub cache: Option<CacheSummary>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub timers: Vec<TimerStatus>,
|
pub timers: Vec<TimerStatus>,
|
||||||
|
/// Unix timestamp (seconds) when the peer's daemon last started.
|
||||||
|
#[serde(default)]
|
||||||
|
pub daemon_started_unix: Option<u64>,
|
||||||
/// `true` when the aggregator successfully talked to the peer;
|
/// `true` when the aggregator successfully talked to the peer;
|
||||||
/// `false` when the RPC failed. Frontend uses this to badge the
|
/// `false` when the RPC failed. Frontend uses this to badge the
|
||||||
/// card as offline.
|
/// card as offline.
|
||||||
@@ -207,6 +210,7 @@ impl NodeStatusV2 {
|
|||||||
mount: r.mount,
|
mount: r.mount,
|
||||||
cache: r.cache,
|
cache: r.cache,
|
||||||
timers: r.timers,
|
timers: r.timers,
|
||||||
|
daemon_started_unix: r.daemon_started_unix,
|
||||||
online: true,
|
online: true,
|
||||||
error: None,
|
error: None,
|
||||||
};
|
};
|
||||||
@@ -232,6 +236,7 @@ impl NodeStatusV2 {
|
|||||||
mount: None,
|
mount: None,
|
||||||
cache: None,
|
cache: None,
|
||||||
timers: Vec::new(),
|
timers: Vec::new(),
|
||||||
|
daemon_started_unix: None,
|
||||||
online: false,
|
online: false,
|
||||||
error: Some(e),
|
error: Some(e),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Link } from 'wouter';
|
import { Link } from 'wouter';
|
||||||
import { NodeStatusV2 } from '../lib/api';
|
import { NodeStatusV2, fmtBytes, fmtUptime } from '../lib/api';
|
||||||
import { StorageBar } from './StorageBar';
|
import { StorageBar } from './StorageBar';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -58,7 +58,11 @@ export function NodeCard({ node }: Props) {
|
|||||||
{/* Storage bars */}
|
{/* Storage bars */}
|
||||||
{node.filesystem && (
|
{node.filesystem && (
|
||||||
<StorageBar
|
<StorageBar
|
||||||
label="disk"
|
label={
|
||||||
|
node.filesystem.available_bytes != null
|
||||||
|
? `disk — ${fmtBytes(node.filesystem.available_bytes)} free`
|
||||||
|
: 'disk'
|
||||||
|
}
|
||||||
used={node.filesystem.used_bytes}
|
used={node.filesystem.used_bytes}
|
||||||
total={node.filesystem.total_bytes}
|
total={node.filesystem.total_bytes}
|
||||||
/>
|
/>
|
||||||
@@ -74,6 +78,12 @@ export function NodeCard({ node }: Props) {
|
|||||||
|
|
||||||
{/* One-liner facts */}
|
{/* One-liner facts */}
|
||||||
<div className="grid grid-cols-2 gap-y-1 text-sm">
|
<div className="grid grid-cols-2 gap-y-1 text-sm">
|
||||||
|
<span className="text-slate-500">uptime</span>
|
||||||
|
<span className="text-right font-mono text-xs">
|
||||||
|
{node.daemon_started_unix
|
||||||
|
? <span className="text-slate-300">{fmtUptime(node.daemon_started_unix)}</span>
|
||||||
|
: <span className="text-slate-500">—</span>}
|
||||||
|
</span>
|
||||||
<span className="text-slate-500">mount</span>
|
<span className="text-slate-500">mount</span>
|
||||||
<span className="text-right font-mono text-xs">
|
<span className="text-right font-mono text-xs">
|
||||||
{node.mount?.active ? (
|
{node.mount?.active ? (
|
||||||
@@ -88,11 +98,30 @@ export function NodeCard({ node }: Props) {
|
|||||||
? `${Math.round(node.cache.hit_rate * 100)}%`
|
? `${Math.round(node.cache.hit_rate * 100)}%`
|
||||||
: <span className="text-slate-500">idle</span>}
|
: <span className="text-slate-500">idle</span>}
|
||||||
</span>
|
</span>
|
||||||
|
{dedupRate(node) !== null && (
|
||||||
|
<>
|
||||||
|
<span className="text-slate-500">dedup efficiency</span>
|
||||||
|
<span className="text-right font-mono text-xs text-slate-300">
|
||||||
|
{dedupRate(node)}% chunks skipped
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
<span className="text-slate-500">next scheduled job</span>
|
<span className="text-slate-500">next scheduled job</span>
|
||||||
<span className="text-right font-mono text-xs">
|
<span className="text-right font-mono text-xs">
|
||||||
{nextTimer(node)}
|
{nextTimer(node)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Failed timer callout (T1.3) */}
|
||||||
|
{failedTimers(node).length > 0 && (
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
{failedTimers(node).map((t) => (
|
||||||
|
<div key={t.unit} className="text-xs text-amber-400 font-mono truncate">
|
||||||
|
⚠ {t.unit.replace(/^clawstor-/, '').replace(/\.timer$/, '')}: {t.last_result}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</a>
|
</a>
|
||||||
@@ -100,6 +129,22 @@ export function NodeCard({ node }: Props) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** HasChunk dedup efficiency as an integer percent, or null if no data. */
|
||||||
|
function dedupRate(n: NodeStatusV2): number | null {
|
||||||
|
const c = n.cache;
|
||||||
|
if (!c) return null;
|
||||||
|
const total = (c.has_chunk_hits ?? 0) + (c.has_chunk_misses ?? 0);
|
||||||
|
if (total === 0) return null;
|
||||||
|
return Math.round(((c.has_chunk_hits ?? 0) / total) * 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Timers whose last_result is a non-success string. */
|
||||||
|
function failedTimers(n: NodeStatusV2) {
|
||||||
|
return n.timers.filter(
|
||||||
|
(t) => t.last_result && t.last_result !== 'success'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function healthOf(n: NodeStatusV2): 'ok' | 'warn' | 'err' | 'idle' {
|
function healthOf(n: NodeStatusV2): 'ok' | 'warn' | 'err' | 'idle' {
|
||||||
if (!n.online) return 'err';
|
if (!n.online) return 'err';
|
||||||
const fsPct = n.filesystem
|
const fsPct = n.filesystem
|
||||||
|
|||||||
@@ -25,6 +25,14 @@ export interface CacheSummary {
|
|||||||
bytes_served: number;
|
bytes_served: number;
|
||||||
bytes_ingested: number;
|
bytes_ingested: number;
|
||||||
hit_rate: number;
|
hit_rate: number;
|
||||||
|
// Per-type breakdown (available when backend >= Phase A).
|
||||||
|
get_ref_hits: number;
|
||||||
|
get_ref_misses: number;
|
||||||
|
get_tag_hits: number;
|
||||||
|
get_tag_misses: number;
|
||||||
|
/** HasChunk probes — non-zero means partial-sync dedup is active. */
|
||||||
|
has_chunk_hits: number;
|
||||||
|
has_chunk_misses: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TimerStatus {
|
export interface TimerStatus {
|
||||||
@@ -49,6 +57,8 @@ export interface NodeStatusV2 {
|
|||||||
mount: MountStatus | null;
|
mount: MountStatus | null;
|
||||||
cache: CacheSummary | null;
|
cache: CacheSummary | null;
|
||||||
timers: TimerStatus[];
|
timers: TimerStatus[];
|
||||||
|
/** Unix timestamp (seconds) when the daemon last started. */
|
||||||
|
daemon_started_unix: number | null;
|
||||||
online: boolean;
|
online: boolean;
|
||||||
error: string | null;
|
error: string | null;
|
||||||
}
|
}
|
||||||
@@ -165,3 +175,15 @@ export function fmtAge(unix: number): string {
|
|||||||
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`;
|
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`;
|
||||||
return `${Math.floor(diff / 86400)}d ago`;
|
return `${Math.floor(diff / 86400)}d ago`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** "up 3d 14h" from a daemon start unix timestamp. */
|
||||||
|
export function fmtUptime(startedUnix: number): string {
|
||||||
|
const now = Math.floor(Date.now() / 1000);
|
||||||
|
const secs = Math.max(0, now - startedUnix);
|
||||||
|
const days = Math.floor(secs / 86400);
|
||||||
|
const hours = Math.floor((secs % 86400) / 3600);
|
||||||
|
const mins = Math.floor((secs % 3600) / 60);
|
||||||
|
if (days > 0) return `up ${days}d ${hours}h`;
|
||||||
|
if (hours > 0) return `up ${hours}h ${mins}m`;
|
||||||
|
return `up ${mins}m`;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user