dashboard-v2: storage RPC + aggregated endpoints #97
@@ -193,6 +193,13 @@ pub enum Method {
|
|||||||
/// `payload`: empty.
|
/// `payload`: empty.
|
||||||
/// Reply: JSON `DashboardStatusReply`.
|
/// Reply: JSON `DashboardStatusReply`.
|
||||||
DashboardStatus = 0x1c,
|
DashboardStatus = 0x1c,
|
||||||
|
/// Dashboard-v2: storage-inventory payload (tags, snapshots,
|
||||||
|
/// ref-tracking, plus sampled blob + ref lists). Fed to the
|
||||||
|
/// aggregator's storage-browser tabs.
|
||||||
|
///
|
||||||
|
/// `payload`: empty.
|
||||||
|
/// Reply: JSON `DashboardStorageReply`.
|
||||||
|
DashboardStorage = 0x1d,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Method {
|
impl Method {
|
||||||
@@ -228,6 +235,7 @@ impl Method {
|
|||||||
0x1a => Some(Method::SetTagExpiry),
|
0x1a => Some(Method::SetTagExpiry),
|
||||||
0x1b => Some(Method::GetTagExpiry),
|
0x1b => Some(Method::GetTagExpiry),
|
||||||
0x1c => Some(Method::DashboardStatus),
|
0x1c => Some(Method::DashboardStatus),
|
||||||
|
0x1d => Some(Method::DashboardStorage),
|
||||||
_ => None,
|
_ => None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -346,6 +354,61 @@ pub struct PutManifestReply {
|
|||||||
///
|
///
|
||||||
/// Not `Clone` on its own — wrap in `Arc<RpcRouter>` so a single
|
/// Not `Clone` on its own — wrap in `Arc<RpcRouter>` so a single
|
||||||
/// instance backs the accept loop plus any explicit dispatch calls.
|
/// instance backs the accept loop plus any explicit dispatch calls.
|
||||||
|
/// Dashboard-v2 storage-inventory payload. Small-and-bounded lists
|
||||||
|
/// (tags, snapshots, ref-tracking) come back in full. Large lists
|
||||||
|
/// (blobs, refs) come back as bounded samples — pagination lives
|
||||||
|
/// in a follow-on RPC when it's actually needed.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct DashboardStorageReply {
|
||||||
|
pub node_name: String,
|
||||||
|
pub tags: Vec<DashboardTag>,
|
||||||
|
pub snapshots: Vec<DashboardSnapshot>,
|
||||||
|
pub ref_tracking: Vec<DashboardRefTracking>,
|
||||||
|
/// Up to 200 blob summaries (id + size + chunk count). Ordered
|
||||||
|
/// by blob-id hex ascending.
|
||||||
|
pub blobs_sample: Vec<DashboardBlob>,
|
||||||
|
pub blobs_sample_capped_at: usize,
|
||||||
|
/// Up to 200 (fingerprint, blob-id) pairs. Ordered by fp hex.
|
||||||
|
pub refs_sample: Vec<DashboardRef>,
|
||||||
|
pub refs_sample_capped_at: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct DashboardTag {
|
||||||
|
pub key: String,
|
||||||
|
pub value_hex: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct DashboardSnapshot {
|
||||||
|
pub name: String,
|
||||||
|
pub created_at_unix: u64,
|
||||||
|
pub blob_count: usize,
|
||||||
|
pub file_bytes: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct DashboardRefTracking {
|
||||||
|
pub fingerprint_hex: String,
|
||||||
|
pub repo: String,
|
||||||
|
pub refs: Vec<String>,
|
||||||
|
pub first_seen_unix: u64,
|
||||||
|
pub last_seen_unix: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct DashboardBlob {
|
||||||
|
pub blob_id_hex: String,
|
||||||
|
pub size_bytes: u64,
|
||||||
|
pub chunk_count: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct DashboardRef {
|
||||||
|
pub fingerprint_hex: String,
|
||||||
|
pub blob_id_hex: String,
|
||||||
|
}
|
||||||
|
|
||||||
/// Recursive byte count under a directory. Used by the dashboard
|
/// Recursive byte count under a directory. Used by the dashboard
|
||||||
/// handler for reporting only; silent on read errors.
|
/// handler for reporting only; silent on read errors.
|
||||||
fn dir_size_bytes(root: &std::path::Path) -> u64 {
|
fn dir_size_bytes(root: &std::path::Path) -> u64 {
|
||||||
@@ -574,6 +637,98 @@ impl RpcRouter {
|
|||||||
.context("encoding DashboardStatusReply as JSON")?;
|
.context("encoding DashboardStatusReply as JSON")?;
|
||||||
Ok(HandlerOutcome::Reply(json))
|
Ok(HandlerOutcome::Reply(json))
|
||||||
}
|
}
|
||||||
|
Method::DashboardStorage => {
|
||||||
|
const SAMPLE_CAP: usize = 200;
|
||||||
|
let blob_root = self.blob_store.as_ref().map(|s| s.root().to_path_buf());
|
||||||
|
let mut tags = Vec::new();
|
||||||
|
if let Some(ts) = &self.tag_store {
|
||||||
|
if let Ok(list) = ts.list().await {
|
||||||
|
for e in list {
|
||||||
|
tags.push(DashboardTag {
|
||||||
|
key: e.key,
|
||||||
|
value_hex: e.value_hex,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let mut snapshots = Vec::new();
|
||||||
|
if let Some(p) = &blob_root {
|
||||||
|
if let Ok(store) = crate::cluster::snapshot::SnapshotStore::open(p.clone()) {
|
||||||
|
if let Ok(list) = store.list().await {
|
||||||
|
for s in list {
|
||||||
|
snapshots.push(DashboardSnapshot {
|
||||||
|
name: s.name,
|
||||||
|
created_at_unix: s.created_at_unix,
|
||||||
|
blob_count: s.blob_count,
|
||||||
|
file_bytes: s.file_bytes,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let mut ref_tracking = Vec::new();
|
||||||
|
if let Some(p) = &blob_root {
|
||||||
|
if let Ok(store) = crate::cluster::ref_tracking::RefTracking::open(p.clone()) {
|
||||||
|
if let Ok(list) = store.list_all().await {
|
||||||
|
for e in list {
|
||||||
|
let mut hex = String::with_capacity(64);
|
||||||
|
for b in &e.fingerprint {
|
||||||
|
hex.push_str(&format!("{b:02x}"));
|
||||||
|
}
|
||||||
|
ref_tracking.push(DashboardRefTracking {
|
||||||
|
fingerprint_hex: hex,
|
||||||
|
repo: e.repo,
|
||||||
|
refs: e.refs,
|
||||||
|
first_seen_unix: e.first_seen_unix,
|
||||||
|
last_seen_unix: e.last_seen_unix,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let mut blobs_sample = Vec::new();
|
||||||
|
if let Some(store) = &self.blob_store {
|
||||||
|
if let Ok(mut ids) = store.list_blob_ids().await {
|
||||||
|
ids.sort();
|
||||||
|
for id in ids.into_iter().take(SAMPLE_CAP) {
|
||||||
|
let m = store.load_manifest(&id).await.ok().flatten();
|
||||||
|
blobs_sample.push(DashboardBlob {
|
||||||
|
blob_id_hex: id.to_hex(),
|
||||||
|
size_bytes: m.as_ref().map(|m| m.total_size).unwrap_or(0),
|
||||||
|
chunk_count: m.map(|m| m.chunks.len()).unwrap_or(0),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let mut refs_sample = Vec::new();
|
||||||
|
if let Some(store) = &self.ref_store {
|
||||||
|
if let Ok(list) = store.list().await {
|
||||||
|
for (k, v) in list.into_iter().take(SAMPLE_CAP) {
|
||||||
|
let mut kh = String::with_capacity(64);
|
||||||
|
for b in &k {
|
||||||
|
kh.push_str(&format!("{b:02x}"));
|
||||||
|
}
|
||||||
|
refs_sample.push(DashboardRef {
|
||||||
|
fingerprint_hex: kh,
|
||||||
|
blob_id_hex: crate::cluster::blob::BlobId::from_bytes(v).to_hex(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let reply = DashboardStorageReply {
|
||||||
|
node_name: self.local_name.clone(),
|
||||||
|
tags,
|
||||||
|
snapshots,
|
||||||
|
ref_tracking,
|
||||||
|
blobs_sample_capped_at: SAMPLE_CAP,
|
||||||
|
blobs_sample,
|
||||||
|
refs_sample_capped_at: SAMPLE_CAP,
|
||||||
|
refs_sample,
|
||||||
|
};
|
||||||
|
let json = serde_json::to_vec(&reply)
|
||||||
|
.context("encoding DashboardStorageReply as JSON")?;
|
||||||
|
Ok(HandlerOutcome::Reply(json))
|
||||||
|
}
|
||||||
Method::BlobStat => {
|
Method::BlobStat => {
|
||||||
let store = match &self.blob_store {
|
let store = match &self.blob_store {
|
||||||
Some(s) => s,
|
Some(s) => s,
|
||||||
|
|||||||
@@ -182,6 +182,16 @@ pub async fn call_dashboard_status(conn: &Connection) -> Result<DashboardStatusR
|
|||||||
serde_json::from_slice(&reply).context("decoding DashboardStatusReply JSON")
|
serde_json::from_slice(&reply).context("decoding DashboardStatusReply JSON")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn call_dashboard_storage(conn: &Connection) -> Result<DashboardStorageReply> {
|
||||||
|
let reply = rpc_call(conn, Method::DashboardStorage, &[]).await?;
|
||||||
|
if reply.len() == 1 {
|
||||||
|
if let Some(code) = decode_error(reply[0]) {
|
||||||
|
bail!("peer replied with error: {}", code.describe());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
serde_json::from_slice(&reply).context("decoding DashboardStorageReply JSON")
|
||||||
|
}
|
||||||
|
|
||||||
/// Recognise a single-byte reply as one of our error codes. Returns
|
/// Recognise a single-byte reply as one of our error codes. Returns
|
||||||
/// `None` for any other single-byte value (which is a valid reply,
|
/// `None` for any other single-byte value (which is a valid reply,
|
||||||
/// just an unusually short one).
|
/// just an unusually short one).
|
||||||
|
|||||||
+199
-6
@@ -22,7 +22,10 @@ use std::net::SocketAddr;
|
|||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use crate::cluster::rpc::{call_dashboard_status, DashboardStatusReply};
|
use crate::cluster::rpc::{
|
||||||
|
call_dashboard_status, call_dashboard_storage, DashboardStatusReply,
|
||||||
|
DashboardStorageReply,
|
||||||
|
};
|
||||||
use crate::cluster::transport::{NodeIdentity, QuicClient};
|
use crate::cluster::transport::{NodeIdentity, QuicClient};
|
||||||
use crate::config::{Config, PeerEntry};
|
use crate::config::{Config, PeerEntry};
|
||||||
|
|
||||||
@@ -34,7 +37,10 @@ use crate::config::{Config, PeerEntry};
|
|||||||
pub struct V2State {
|
pub struct V2State {
|
||||||
pub aggregator_name: String,
|
pub aggregator_name: String,
|
||||||
pub peers: Vec<PeerEntry>,
|
pub peers: Vec<PeerEntry>,
|
||||||
pub client: QuicClient,
|
/// QuicClient wraps a single UDP endpoint + a connection pool.
|
||||||
|
/// Arc so the storage handler's per-peer JoinSet can hand the
|
||||||
|
/// client to spawned tasks without re-creating the endpoint.
|
||||||
|
pub client: std::sync::Arc<QuicClient>,
|
||||||
/// Fallback RPC port when a peer entry has `lan_addr = "10.x:7701"`
|
/// Fallback RPC port when a peer entry has `lan_addr = "10.x:7701"`
|
||||||
/// (gossip port, not RPC). We assume RPC = gossip + 1 in that
|
/// (gossip port, not RPC). We assume RPC = gossip + 1 in that
|
||||||
/// case unless the operator sets an explicit `rpc_addr`.
|
/// case unless the operator sets an explicit `rpc_addr`.
|
||||||
@@ -58,7 +64,7 @@ impl V2State {
|
|||||||
Ok(Self {
|
Ok(Self {
|
||||||
aggregator_name: cfg.node.name.clone(),
|
aggregator_name: cfg.node.name.clone(),
|
||||||
peers: cluster.peers.clone(),
|
peers: cluster.peers.clone(),
|
||||||
client,
|
client: std::sync::Arc::new(client),
|
||||||
default_rpc_port_offset: 1,
|
default_rpc_port_offset: 1,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -86,6 +92,21 @@ impl V2State {
|
|||||||
|
|
||||||
/// Fetch a single node's dashboard payload via RPC.
|
/// Fetch a single node's dashboard payload via RPC.
|
||||||
async fn fetch_node(&self, peer: &PeerEntry) -> anyhow::Result<DashboardStatusReply> {
|
async fn fetch_node(&self, peer: &PeerEntry) -> anyhow::Result<DashboardStatusReply> {
|
||||||
|
let conn = self.dial(peer).await?;
|
||||||
|
let reply = call_dashboard_status(&conn).await?;
|
||||||
|
conn.close(quinn::VarInt::from_u32(0), b"done");
|
||||||
|
Ok(reply)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fetch a single node's storage inventory via RPC.
|
||||||
|
async fn fetch_storage(&self, peer: &PeerEntry) -> anyhow::Result<DashboardStorageReply> {
|
||||||
|
let conn = self.dial(peer).await?;
|
||||||
|
let reply = call_dashboard_storage(&conn).await?;
|
||||||
|
conn.close(quinn::VarInt::from_u32(0), b"done");
|
||||||
|
Ok(reply)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn dial(&self, peer: &PeerEntry) -> anyhow::Result<quinn::Connection> {
|
||||||
let addr = self.peer_rpc_addr(peer).ok_or_else(|| {
|
let addr = self.peer_rpc_addr(peer).ok_or_else(|| {
|
||||||
anyhow::anyhow!("peer {} has no reachable RPC address", peer.name)
|
anyhow::anyhow!("peer {} has no reachable RPC address", peer.name)
|
||||||
})?;
|
})?;
|
||||||
@@ -95,9 +116,7 @@ impl V2State {
|
|||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.map_err(|_| anyhow::anyhow!("connect to {} timed out", peer.name))??;
|
.map_err(|_| anyhow::anyhow!("connect to {} timed out", peer.name))??;
|
||||||
let reply = call_dashboard_status(&conn).await?;
|
Ok(conn)
|
||||||
conn.close(quinn::VarInt::from_u32(0), b"done");
|
|
||||||
Ok(reply)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -169,6 +188,49 @@ pub struct FleetSnapshot {
|
|||||||
pub nodes: Vec<NodeStatusV2>,
|
pub nodes: Vec<NodeStatusV2>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// One tag with the node it came from. Frontend renders `node` as a
|
||||||
|
/// column so operators see where each artifact lives.
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct TagRow {
|
||||||
|
pub node: String,
|
||||||
|
pub key: String,
|
||||||
|
pub value_hex: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct SnapshotRow {
|
||||||
|
pub node: String,
|
||||||
|
pub name: String,
|
||||||
|
pub created_at_unix: u64,
|
||||||
|
pub blob_count: usize,
|
||||||
|
pub file_bytes: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct RefTrackingRow {
|
||||||
|
pub node: String,
|
||||||
|
pub fingerprint_hex: String,
|
||||||
|
pub repo: String,
|
||||||
|
pub refs: Vec<String>,
|
||||||
|
pub first_seen_unix: u64,
|
||||||
|
pub last_seen_unix: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct BlobRow {
|
||||||
|
pub node: String,
|
||||||
|
pub blob_id_hex: String,
|
||||||
|
pub size_bytes: u64,
|
||||||
|
pub chunk_count: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct RefRow {
|
||||||
|
pub node: String,
|
||||||
|
pub fingerprint_hex: String,
|
||||||
|
pub blob_id_hex: String,
|
||||||
|
}
|
||||||
|
|
||||||
// ── handlers ─────────────────────────────────────────────────────
|
// ── handlers ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
async fn handle_fleet(State(s): State<Arc<V2State>>) -> Json<FleetSnapshot> {
|
async fn handle_fleet(State(s): State<Arc<V2State>>) -> Json<FleetSnapshot> {
|
||||||
@@ -227,10 +289,141 @@ async fn handle_node_status(
|
|||||||
Json(node)
|
Json(node)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── storage handlers (fleet-aggregated) ──────────────────────────
|
||||||
|
|
||||||
|
/// Fan out DashboardStorage to every peer, return `(node, reply)`
|
||||||
|
/// pairs where the RPC succeeded. Failures logged but skipped
|
||||||
|
/// silently — a browser view of "N nodes, one is down" is more
|
||||||
|
/// useful than a hard 500.
|
||||||
|
async fn gather_storage(s: &V2State) -> Vec<(String, DashboardStorageReply)> {
|
||||||
|
let mut set = tokio::task::JoinSet::new();
|
||||||
|
for p in &s.peers {
|
||||||
|
let name = p.name.clone();
|
||||||
|
let peer = p.clone();
|
||||||
|
let state = Arc::new(s.clone_shallow());
|
||||||
|
set.spawn(async move {
|
||||||
|
match state.fetch_storage(&peer).await {
|
||||||
|
Ok(r) => Some((name, r)),
|
||||||
|
Err(e) => {
|
||||||
|
tracing::debug!(peer = %name, error = %e, "DashboardStorage RPC failed");
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let mut out = Vec::new();
|
||||||
|
while let Some(res) = set.join_next().await {
|
||||||
|
if let Ok(Some(pair)) = res {
|
||||||
|
out.push(pair);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn handle_tags(State(s): State<Arc<V2State>>) -> Json<Vec<TagRow>> {
|
||||||
|
let mut rows = Vec::new();
|
||||||
|
for (node, r) in gather_storage(&s).await {
|
||||||
|
for t in r.tags {
|
||||||
|
rows.push(TagRow {
|
||||||
|
node: node.clone(),
|
||||||
|
key: t.key,
|
||||||
|
value_hex: t.value_hex,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rows.sort_by(|a, b| a.key.cmp(&b.key).then_with(|| a.node.cmp(&b.node)));
|
||||||
|
Json(rows)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn handle_snapshots(State(s): State<Arc<V2State>>) -> Json<Vec<SnapshotRow>> {
|
||||||
|
let mut rows = Vec::new();
|
||||||
|
for (node, r) in gather_storage(&s).await {
|
||||||
|
for snap in r.snapshots {
|
||||||
|
rows.push(SnapshotRow {
|
||||||
|
node: node.clone(),
|
||||||
|
name: snap.name,
|
||||||
|
created_at_unix: snap.created_at_unix,
|
||||||
|
blob_count: snap.blob_count,
|
||||||
|
file_bytes: snap.file_bytes,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Oldest first — matches CLI convention.
|
||||||
|
rows.sort_by_key(|s| s.created_at_unix);
|
||||||
|
Json(rows)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn handle_ref_tracking(State(s): State<Arc<V2State>>) -> Json<Vec<RefTrackingRow>> {
|
||||||
|
let mut rows = Vec::new();
|
||||||
|
for (node, r) in gather_storage(&s).await {
|
||||||
|
for e in r.ref_tracking {
|
||||||
|
rows.push(RefTrackingRow {
|
||||||
|
node: node.clone(),
|
||||||
|
fingerprint_hex: e.fingerprint_hex,
|
||||||
|
repo: e.repo,
|
||||||
|
refs: e.refs,
|
||||||
|
first_seen_unix: e.first_seen_unix,
|
||||||
|
last_seen_unix: e.last_seen_unix,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rows.sort_by(|a, b| a.repo.cmp(&b.repo).then_with(|| a.node.cmp(&b.node)));
|
||||||
|
Json(rows)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn handle_blobs(State(s): State<Arc<V2State>>) -> Json<Vec<BlobRow>> {
|
||||||
|
let mut rows = Vec::new();
|
||||||
|
for (node, r) in gather_storage(&s).await {
|
||||||
|
for b in r.blobs_sample {
|
||||||
|
rows.push(BlobRow {
|
||||||
|
node: node.clone(),
|
||||||
|
blob_id_hex: b.blob_id_hex,
|
||||||
|
size_bytes: b.size_bytes,
|
||||||
|
chunk_count: b.chunk_count,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rows.sort_by(|a, b| a.blob_id_hex.cmp(&b.blob_id_hex));
|
||||||
|
Json(rows)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn handle_refs(State(s): State<Arc<V2State>>) -> Json<Vec<RefRow>> {
|
||||||
|
let mut rows = Vec::new();
|
||||||
|
for (node, r) in gather_storage(&s).await {
|
||||||
|
for x in r.refs_sample {
|
||||||
|
rows.push(RefRow {
|
||||||
|
node: node.clone(),
|
||||||
|
fingerprint_hex: x.fingerprint_hex,
|
||||||
|
blob_id_hex: x.blob_id_hex,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rows.sort_by(|a, b| a.fingerprint_hex.cmp(&b.fingerprint_hex));
|
||||||
|
Json(rows)
|
||||||
|
}
|
||||||
|
|
||||||
|
impl V2State {
|
||||||
|
/// Cheap clone for JoinSet handoff — reuses the underlying
|
||||||
|
/// QuicClient (Arc'd inside) + clones the small peer list.
|
||||||
|
fn clone_shallow(&self) -> Self {
|
||||||
|
Self {
|
||||||
|
aggregator_name: self.aggregator_name.clone(),
|
||||||
|
peers: self.peers.clone(),
|
||||||
|
client: self.client.clone(),
|
||||||
|
default_rpc_port_offset: self.default_rpc_port_offset,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── route registration ──────────────────────────────────────────
|
// ── route registration ──────────────────────────────────────────
|
||||||
|
|
||||||
pub fn routes() -> Router<Arc<V2State>> {
|
pub fn routes() -> Router<Arc<V2State>> {
|
||||||
Router::new()
|
Router::new()
|
||||||
.route("/api/v2/fleet", get(handle_fleet))
|
.route("/api/v2/fleet", get(handle_fleet))
|
||||||
.route("/api/v2/node/:name/status", get(handle_node_status))
|
.route("/api/v2/node/:name/status", get(handle_node_status))
|
||||||
|
.route("/api/v2/storage/blobs", get(handle_blobs))
|
||||||
|
.route("/api/v2/storage/tags", get(handle_tags))
|
||||||
|
.route("/api/v2/storage/refs", get(handle_refs))
|
||||||
|
.route("/api/v2/storage/snapshots", get(handle_snapshots))
|
||||||
|
.route("/api/v2/storage/ref-tracking", get(handle_ref_tracking))
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user