kaptein_viewmodel/render.rs
1//! Layer 1 — the data plane.
2//!
3//! A virtualized, queryable source that emits deltas. It never materializes the world:
4//! the frontend asks for a `Page` and receives `RowPatch` deltas keyed by stable `RowId`.
5//!
6//! The trait is deliberately **object-safe, async, fallible, and streaming** so it can
7//! cross the `serve`/gRPC-Web boundary (ADR-0002): the time machine replays historical
8//! state, test fixtures feed recordings, `serve` proxies over the network, and the fleet
9//! layer aggregates several clusters — four runtime-swappable implementations.
10
11use std::ops::Range;
12use std::pin::Pin;
13
14use futures_util::Stream;
15use serde::{Deserialize, Serialize};
16
17use crate::error::Error;
18
19/// A monotonically increasing revision of the underlying store.
20///
21/// Consumers use it to detect staleness: if a held revision is older than the latest,
22/// they re-query rather than assuming their snapshot is current.
23#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
24pub struct Revision(pub u64);
25
26/// A stable row identity — a Kubernetes `uid`, or a `group/kind/namespace/name` tuple
27/// when a `uid` is unavailable. Never a positional index.
28#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
29pub struct RowId(pub String);
30
31/// A lazy, virtualized query against the data plane.
32///
33/// `start`/`end`, `sort`, and `filter` describe what the frontend wants *now* — the
34/// store returns a bounded page, never a full materialization of every object. The
35/// window is a plain `start`/`end` pair (not `std::ops::Range<usize>`) so it serializes
36/// stably over the wire.
37#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
38pub struct Query {
39 /// Inclusive window start (e.g. 400 for a virtualized table window).
40 pub start: usize,
41 /// Exclusive window end (e.g. 460).
42 pub end: usize,
43 /// Sort key and direction (column id + descending flag).
44 pub sort: Option<SortSpec>,
45 /// Filter predicate in a stable, serializable form (not a closure — it must cross
46 /// the `serve`/gRPC-Web boundary).
47 pub filter: Option<Filter>,
48}
49
50impl Query {
51 pub fn range(&self) -> Range<usize> {
52 self.start..self.end
53 }
54}
55
56#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
57pub struct SortSpec {
58 pub column: String,
59 pub descending: bool,
60}
61
62#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
63pub struct Filter {
64 /// A string in a documented query language (e.g. `metadata.labels.app = "foo"`).
65 pub expression: String,
66}
67
68/// A single cell value. Typed and redaction-aware (secrets never reach a cell as a
69/// plaintext value — the semantic layer already replaced them with a marker).
70///
71/// Variants use `#[serde(tag = "type")]` for a stable wire representation.
72#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
73#[serde(tag = "type", rename_all = "snake_case")]
74pub enum Cell {
75 Text {
76 value: String,
77 },
78 Number {
79 value: i64,
80 },
81 /// A typed instant (unix epoch millis) — the frontend formats and localizes, so
82 /// sorting and localization stay possible.
83 Timestamp {
84 millis: i64,
85 },
86 /// A redacted value — the frontend renders a mask, never the secret.
87 Redacted,
88 /// A typed status chip; `level` drives color, `label_key` is localized by the
89 /// frontend. Status inference lives in the semantic layer, not in string matching.
90 Status {
91 level: StatusLevel,
92 label_key: String,
93 },
94}
95
96/// The severity of a status cell.
97#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
98#[serde(rename_all = "snake_case")]
99pub enum StatusLevel {
100 Ok,
101 Info,
102 Warning,
103 Error,
104 Pending,
105}
106
107/// One row of the virtualized table, keyed by a stable identity.
108#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
109pub struct Row {
110 pub id: RowId,
111 pub cells: Vec<Cell>,
112}
113
114/// A page of rows plus enough metadata to render it correctly.
115#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
116pub struct Page {
117 pub rows: Vec<Row>,
118 /// Total number of matching rows (for scrollbar sizing), not just this page.
119 pub total: usize,
120 /// The revision this page reflects.
121 pub revision: Revision,
122}
123
124/// A delta to a specific row, keyed by `RowId` — never by position. Position is
125/// geometry, which the frontend owns; keying by identity keeps the patch stream
126/// idempotent and reorder-safe (a reconnect can re-apply the same patch without harm).
127#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
128pub enum RowPatch {
129 Upsert { id: RowId, row: Row },
130 Remove { id: RowId },
131}
132
133/// The data plane exposed to every frontend.
134///
135/// Object-safe (`Box<dyn DataPlane>`), async, fallible, and streaming — matching ADR-0002
136/// (browser → `serve` → `kaptein-core`) and the unified error enum.
137#[async_trait::async_trait]
138pub trait DataPlane: Send + Sync {
139 /// Execute a lazy query and return a bounded page.
140 async fn query(&self, query: &Query) -> Result<Page, Error>;
141
142 /// Subscribe to deltas from the given revision onward.
143 fn subscribe(&self, from: Revision) -> Pin<Box<dyn Stream<Item = RowPatch> + Send>>;
144}