use std::collections::BTreeMap;
use serde::Serialize;
use crate::facts::{KeyDescription, KeyShape, Registration};
#[derive(Debug, Clone, Serialize)]
pub struct TopicRow {
pub producer: String,
pub registry_version: String,
pub class: String,
pub path: String,
pub type_name: String,
pub open_ended: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub since: Option<String>,
#[serde(skip_serializing_if = "std::ops::Not::not")]
pub deprecated: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub deprecated_since: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub replaced_by: Option<String>,
}
#[derive(Debug, Clone, Serialize)]
pub struct TopicList {
pub subjects: Vec<TopicRow>,
}
#[derive(Debug, Clone, Serialize)]
pub struct TopicInfo {
pub key: String,
pub verdict: TopicVerdict,
pub note: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub origin: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub producer: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub class: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub subject: Option<String>,
#[serde(skip_serializing_if = "BTreeMap::is_empty", default)]
pub variables: BTreeMap<String, String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub payload_type: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub unit: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub qos: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub ttl_s: Option<i64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub rate: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub cardinality: Option<i64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub encoding: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub since: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum TopicVerdict {
Registered,
Unregistered,
NoSliceForProducer,
NotADataClass,
NotV1,
NotUnderBase,
RegistryNotLoaded,
}
impl TopicInfo {
pub fn from_description(d: &KeyDescription) -> TopicInfo {
let mut info = TopicInfo {
key: d.key.clone(),
verdict: TopicVerdict::NotV1,
note: String::new(),
origin: None,
producer: None,
class: None,
subject: None,
variables: BTreeMap::new(),
payload_type: None,
unit: None,
qos: None,
ttl_s: None,
rate: None,
cardinality: None,
encoding: None,
since: None,
description: None,
};
match &d.facts.shape {
KeyShape::NotUnderBase => {
info.verdict = TopicVerdict::NotUnderBase;
info.note = "under a different deployment base than the configured one \
(RFC 03 §1.1); `zenctl base list` discovers the bases in use"
.into();
return info;
}
KeyShape::Unparsed { reason } => {
info.verdict = TopicVerdict::NotV1;
info.note = format!(
"not a keyspace-v2 key — a fact, not an error (RFC 09 §5.1 O1): {reason}"
);
return info;
}
KeyShape::V1(v) => {
info.origin = Some(v.origin.clone());
info.class = Some(v.class.clone());
info.producer = v.producer.clone();
}
}
match &d.facts.registration {
Registration::Registered(s) => {
info.verdict = TopicVerdict::Registered;
info.subject = Some(s.path.clone());
info.variables = s.vars.iter().cloned().collect();
info.payload_type = Some(s.type_name.clone());
info.unit = s.unit.clone();
info.qos = s.qos.clone();
info.encoding = s.encoding.clone();
info.ttl_s = s.ttl_s;
}
Registration::Unregistered => {
info.verdict = TopicVerdict::Unregistered;
info.note = "parses as a v1 data key, but the producer's slice does not \
declare this subject — for a conforming producer, a subject \
that is not registered does not exist (RFC 08)"
.into();
}
Registration::NoSliceForProducer => {
info.verdict = TopicVerdict::NoSliceForProducer;
info.note = "no loaded registry slice covers this producer — `--registry \
<dir>` supplies slices offline; on-bus they come from \
introspect (RFC 08 §6)"
.into();
}
Registration::Unknown => {
info.verdict = TopicVerdict::RegistryNotLoaded;
info.note = "no registry loaded — \"not asked\" is not \"answered no\" \
(RFC 09 §5.1 O4)"
.into();
}
Registration::NotApplicable => {
info.verdict = TopicVerdict::NotADataClass;
info.note = "a verbatim plane, not a data class — there is no [[subject]] \
surface to describe (RFC 03 §1.4)"
.into();
}
}
info
}
}
#[derive(Debug, Clone, Serialize)]
pub struct ServiceRow {
pub producer: String,
pub registry_version: String,
pub kind: String,
pub path: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub request: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub reply: Option<String>,
}
#[derive(Debug, Clone, Serialize)]
pub struct ServiceList {
pub procedures: Vec<ServiceRow>,
}
#[derive(Debug, Clone, Serialize)]
pub struct InterfaceTypeRow {
pub name: String,
pub carriers: usize,
}
#[derive(Debug, Clone, Serialize)]
pub struct InterfaceList {
pub types: Vec<InterfaceTypeRow>,
}
#[derive(Debug, Clone, Serialize)]
pub struct CarrierRow {
pub producer: String,
pub class: String,
pub path: String,
}
#[derive(Debug, Clone, Serialize)]
pub struct InterfaceShow {
pub type_name: String,
pub carriers: Vec<CarrierRow>,
#[serde(skip_serializing_if = "Vec::is_empty", default)]
pub schemas: Vec<SchemaRow>,
}
#[derive(Debug, Clone, Serialize)]
pub struct SchemaRow {
pub producer: String,
pub type_name: String,
pub kind: String,
pub hash: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub document: Option<serde_json::Value>,
}
#[derive(Debug, Clone, Serialize)]
pub struct OriginLatency {
pub origin: String,
pub replies: usize,
pub min_ms: f64,
pub p50_ms: f64,
pub p95_ms: f64,
pub p99_ms: f64,
pub max_ms: f64,
}
#[derive(Debug, Clone, Serialize)]
pub struct BenchReport {
pub key: String,
pub requested: usize,
pub completed: usize,
pub concurrency: usize,
pub errors: usize,
pub silent: usize,
pub elapsed_s: f64,
pub calls_per_s: f64,
pub origins: Vec<OriginLatency>,
}
#[derive(Debug, Clone, Serialize)]
pub struct ProducerDiff {
pub producer: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub served_version: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub local_version: Option<String>,
pub findings: Vec<String>,
}
#[derive(Debug, Clone, Serialize)]
pub struct RegistryDiff {
pub producers: Vec<ProducerDiff>,
}
impl RegistryDiff {
pub fn disagreeing(&self) -> usize {
self.producers
.iter()
.filter(|p| !p.findings.is_empty())
.count()
}
}
#[derive(Debug, Clone, Serialize)]
pub struct SchemaDump {
pub producer: String,
pub served: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub app: Option<String>,
pub types: Vec<SchemaRow>,
#[serde(skip_serializing_if = "Vec::is_empty", default)]
pub missing: Vec<String>,
}
#[derive(Debug, Clone, Serialize)]
pub struct NodeRow {
pub origin: String,
pub producer: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub app: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub registry_version: Option<String>,
}
#[derive(Debug, Clone, Serialize)]
pub struct NodeList {
pub nodes: Vec<NodeRow>,
pub slices_joined: bool,
}
#[derive(Debug, Clone, Serialize)]
pub struct BaseList {
pub bases: Vec<crate::DiscoveredBase>,
}
#[derive(Debug, Clone, Serialize)]
pub struct StorageList {
pub storages: Vec<crate::StorageInfo>,
pub coverage: Vec<crate::CoverageRow>,
}
#[derive(Debug, Clone, Serialize)]
pub struct CallError {
pub name: String,
pub message: String,
}
#[derive(Debug, Clone, Serialize)]
pub struct CallAnswer {
pub origin: String,
pub ok: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub value: Option<serde_json::Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub text: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub attachment: Option<serde_json::Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub attachment_bytes: Option<usize>,
#[serde(skip_serializing_if = "Option::is_none")]
pub error: Option<CallError>,
}
#[derive(Debug, Clone, Serialize)]
pub struct CallReport {
pub key: String,
pub answers: Vec<CallAnswer>,
}
impl CallReport {
pub fn exit_code(&self) -> i32 {
if self.answers.is_empty() {
2
} else if self.answers.iter().any(|a| !a.ok) {
1
} else {
0
}
}
}
#[derive(Debug, Clone, Serialize)]
pub struct RateRow {
pub key: String,
pub count: u64,
pub bytes: u64,
pub sn_gaps: u64,
#[serde(skip_serializing_if = "Option::is_none")]
pub latency: Option<crate::stats::LatencySummary>,
pub unstamped: u64,
}
#[derive(Debug, Clone, Serialize)]
pub struct RateReport {
pub selector: String,
pub window_s: u64,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub rows: Vec<RateRow>,
pub total_count: u64,
pub total_bytes: u64,
pub keys: usize,
pub evicted: u64,
pub max_keys: usize,
#[serde(skip_serializing_if = "Option::is_none")]
pub sn_gaps: Option<u64>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum DoctorSeverity {
Error,
Warning,
Info,
}
#[derive(Debug, Clone, Serialize)]
pub struct DoctorFinding {
pub severity: DoctorSeverity,
pub check: String,
pub subject: String,
pub evidence: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub citation: Option<String>,
}
#[derive(Debug, Clone, Serialize)]
pub struct DoctorReport {
pub findings: Vec<DoctorFinding>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub synced: Vec<String>,
pub introspect_answered: usize,
pub live_producers: usize,
pub describe_served: usize,
pub describe_missing: usize,
pub routers: usize,
#[serde(skip_serializing_if = "Option::is_none")]
pub router_version: Option<String>,
pub deep: bool,
}
impl DoctorReport {
pub fn count(&self, severity: DoctorSeverity) -> usize {
self.findings
.iter()
.filter(|f| f.severity == severity)
.count()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "kebab-case")]
pub enum BlobListSource {
Bus,
RegistryDirs,
Union,
}
#[derive(Debug, Clone, Serialize)]
pub struct BlobTierRow {
pub producer: String,
pub registry_version: String,
pub tier: String,
pub known_tier: bool,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub endpoints: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub algo: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub reference: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub encoding: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub since: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub origins: Option<Vec<String>>,
}
#[derive(Debug, Clone, Serialize)]
pub struct BlobList {
pub tiers: Vec<BlobTierRow>,
pub source: BlobListSource,
pub slices_considered: usize,
pub slices_without_blob: usize,
}
#[derive(Debug, Clone, Serialize)]
pub struct BlobAvailability {
pub chunk_count: u32,
pub have: u32,
pub complete: bool,
}
#[derive(Debug, Clone, Serialize)]
pub struct BlobManifest {
pub id: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub filename: Option<String>,
pub total_len: u64,
pub chunk_size: u32,
pub chunk_count: u32,
pub root: String,
pub created_ms: i64,
}
#[derive(Debug, Clone, Serialize)]
pub struct BlobHolder {
pub origin: String,
pub key: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub availability: Option<BlobAvailability>,
#[serde(skip_serializing_if = "Option::is_none")]
pub manifest: Option<BlobManifest>,
#[serde(skip_serializing_if = "Option::is_none")]
pub note: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub unreadable: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub error: Option<CallError>,
}
#[derive(Debug, Clone, Serialize)]
pub struct BlobProbeReport {
pub target: String,
pub tier: String,
pub asked: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub not_probed: Option<String>,
pub holders: Vec<BlobHolder>,
pub answered: usize,
pub roots: Vec<String>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub declared_by: Vec<String>,
}
#[derive(Debug, Clone, Serialize)]
#[serde(tag = "event", rename_all = "kebab-case")]
pub enum BlobProgress {
Started {
total_len: u64,
chunk_count: u32,
},
Resumed {
received: u32,
total: u32,
},
Chunk {
index: u32,
received: u32,
total: u32,
bytes_received: u64,
},
Verifying,
Completed {
path: String,
},
Cancelled {
received: u32,
total: u32,
},
Failed {
error: String,
},
}
#[derive(Debug, Clone, Serialize)]
pub struct BlobFetchReport {
pub origin: String,
pub key: String,
pub dest: String,
pub bytes: u64,
pub chunks: u32,
pub chunks_resumed: u32,
pub rejected: u32,
pub retries: u32,
pub elapsed_ms: u64,
pub root: String,
pub root_pinned: bool,
pub priority: String,
}
#[derive(Debug, Clone, Serialize)]
pub struct BlobTreeIndexReport {
pub origin: String,
pub key: String,
pub root: String,
pub entries: usize,
pub files: usize,
pub total_size: u64,
pub chunks: usize,
pub elapsed_ms: u64,
pub priority: String,
}
#[cfg(test)]
mod tests {
use super::*;
use crate::facts::describe_key;
use crate::registry::SliceSet;
#[test]
fn call_exit_codes() {
let mut r = CallReport {
key: "k".into(),
answers: vec![],
};
assert_eq!(r.exit_code(), 2, "silence is its own exit code");
r.answers.push(CallAnswer {
origin: "h-1".into(),
ok: true,
value: None,
text: Some("x".into()),
attachment: None,
attachment_bytes: None,
error: None,
});
assert_eq!(r.exit_code(), 0);
r.answers.push(CallAnswer {
origin: "h-2".into(),
ok: false,
value: None,
text: None,
attachment: None,
attachment_bytes: None,
error: Some(CallError {
name: "error/busy".into(),
message: "later".into(),
}),
});
assert_eq!(r.exit_code(), 1, "any refusal fails the invocation");
}
#[test]
fn topic_info_is_partial_never_absent() {
let cases = [
("demo/example/foo", TopicVerdict::NotV1),
(
"v1/h-3fa9c2d41b7e/@rpc/sysinfo/introspect",
TopicVerdict::NotADataClass,
),
(
"v1/h-3fa9c2d41b7e/telemetry/sysinfo/cpu",
TopicVerdict::RegistryNotLoaded,
),
];
for (key, want) in cases {
let info = TopicInfo::from_description(&describe_key("", key, None));
assert_eq!(info.verdict, want, "{key}");
assert!(!info.note.is_empty(), "{key} must explain itself");
}
let info = TopicInfo::from_description(&describe_key(
"zensight",
"other/v1/h-3fa9c2d41b7e/state/x/y",
None,
));
assert_eq!(info.verdict, TopicVerdict::NotUnderBase);
assert!(info.origin.is_none() && info.payload_type.is_none());
let empty = SliceSet::default();
let info = TopicInfo::from_description(&describe_key(
"",
"v1/h-3fa9c2d41b7e/telemetry/sysinfo/cpu",
Some(&empty),
));
assert_eq!(info.verdict, TopicVerdict::NoSliceForProducer);
assert_eq!(info.origin.as_deref(), Some("h-3fa9c2d41b7e"));
assert!(info.payload_type.is_none());
}
#[test]
fn doctor_report_json_shape_is_pinned() {
let report = DoctorReport {
findings: vec![DoctorFinding {
severity: DoctorSeverity::Error,
check: "slice-sync".into(),
subject: "h-3fa9c2d41b7e/sysinfo".into(),
evidence: "registry version differs: served 1.0, local 2.0".into(),
citation: Some("RFC 08 §6".into()),
}],
synced: vec!["h-3fa9c2d41b7e/other (registry 1.0)".into()],
introspect_answered: 2,
live_producers: 3,
describe_served: 1,
describe_missing: 1,
routers: 1,
router_version: Some("1.9.0".into()),
deep: false,
};
let json = serde_json::to_value(&report).unwrap();
assert_eq!(
json,
serde_json::json!({
"findings": [{
"severity": "error",
"check": "slice-sync",
"subject": "h-3fa9c2d41b7e/sysinfo",
"evidence": "registry version differs: served 1.0, local 2.0",
"citation": "RFC 08 §6",
}],
"synced": ["h-3fa9c2d41b7e/other (registry 1.0)"],
"introspect_answered": 2,
"live_producers": 3,
"describe_served": 1,
"describe_missing": 1,
"routers": 1,
"router_version": "1.9.0",
"deep": false,
})
);
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum CutoverVerdict {
Pass,
OldStillSpeaks,
Unproven,
}
#[derive(Debug, Clone, Serialize)]
pub struct CutoverReport {
pub old_root: String,
pub new_prefix: String,
pub window_s: u64,
pub old_samples: u64,
pub old_keys_seen: usize,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub old_examples: Vec<String>,
pub new_samples: u64,
pub leak_samples: u64,
pub leaked_keys_seen: usize,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub leak_examples: Vec<String>,
pub dropped: u64,
pub verdict: CutoverVerdict,
}
#[derive(Debug, Clone, Serialize)]
pub struct ProbeReport {
pub input: String,
pub origin: String,
pub via: String,
pub call: CallReport,
}