models.monitor

Pydantic boundary models for the monitor subsystem.

Three seams:

  • MetricPoint — the in-memory series element (replaces the old (ts, value, meta) 3-tuple in MetricStore.series). It is an OttoModel (extra='forbid') because otto is the only thing that builds it: the live append path uses model_construct (no validation, hot loop) and the import path uses model_validate.

  • MetricRecord / EventRecord / LogEventRecord — flat records at the format:1 JSON export and v2 SQLite session-archive import/export boundary (otto monitor <source>/otto monitor --live --db). These read historical, external data, so they are deliberately lenient (extra='ignore', via RowModel): an unknown column from a newer schema is dropped, not rejected, exactly as the old .get()/[] parsing did. Field names follow the JSON spelling; a validation_alias also accepts the SQLite column spelling (ts/end_ts) so one model validates both seams.

  • MonitorSessionFragment — the live SSE wire boundary (spec 2026-07-12 §The stream speaks format:1). It reuses MetricRecord / EventRecord / LogEventRecord / SessionMeta verbatim rather than mirroring their fields under new names, so the fragment cannot drift from the format:1 payload it appends to.

MIN_INTERVAL_SECONDS / validate_interval() also live here rather than in otto.monitor — see their docstrings for why.

Leaf isolation: this module imports only otto.models.base, pydantic, and the stdlib — no runtime or otto.monitor edge — so it stays a pure leaf inside the models package. This matters beyond tidiness: otto.cli.test needs MIN_INTERVAL_SECONDS for its --monitor-interval option but must NOT pay for importing the monitor runtime package (collector/db/snmp/aiosqlite, …) just to render --help — that regressed the import-budget guard once already (spec 2026-07-12 §monitor-live-streaming, the otto.monitor.interval module). Keeping the floor in this already-leaf module gives every caller (CLI, library, pytest plugin) one definition without paying that cost.

otto.models.monitor.MIN_INTERVAL_SECONDS : float = 1.0

The collection-interval floor — one home for the constant and the check.

An interval below one second is not meaningful in practice: a host must be given time to answer every query in the interval without being taxed by the polling itself. The floor is enforced where a human names an interval — the CLI, the library, the pytest plugin — and NOT in MetricCollector, which is the mechanism rather than a knob: the monitor tests drive it at 0.01-0.2s against fake hosts, where no real host is ever polled.

otto.models.monitor.validate_interval(seconds: float) float

Return seconds, or raise ValueError if it is below the floor.

class otto.models.monitor.MetricPoint(*, ts: datetime, value: float, meta: dict[str, Any] | None = None)

Bases: OttoModel

A single charted sample: timestamp, numeric value, optional hover meta.

Replaces the (datetime, float, dict | None) tuple stored per series. Consumers read .ts / .value / .meta instead of unpacking.

ts : datetime
value : float
meta : dict[str, Any] | None
class otto.models.monitor.ChartSpec(*, label: str, y_title: str, unit: str, command: str, chart: str, interval: float | None = None)

Bases: OttoModel

One dashboard chart descriptor: otto’s typed, internal parser-catalog view.

The declarative contract the frontend renders from; TS types are generated from this schema in Phase 2. Not served at any endpoint of its own — it is reshaped into ChartSpecRecord inside SessionMeta for the monitor_sessions/SSE wire (see otto.monitor.export.session_meta()).

label : str
y_title : str
unit : str
command : str
chart : str
interval : float | None
class otto.models.monitor.TabSpec(*, id: str, label: str, metrics: list[str], kind: 'charts' | 'table' = 'charts', columns: list[str] | None = None)

Bases: OttoModel

One dashboard tab descriptor: otto’s typed, internal parser-catalog view.

The declarative contract the frontend renders from; TS types are generated from this schema in Phase 2. kind="table" tabs render an event table (schema in columns) instead of charts, and carry metrics=[]. Not served at any endpoint of its own — see ChartSpec for the wire path.

id : str
label : str
metrics : list[str]
kind : Literal['charts', 'table']
columns : list[str] | None
class otto.models.monitor.MonitorMeta(*, hosts: list[str], live: bool, metrics: list[ChartSpec], tabs: list[TabSpec], interval: float | None = None)

Bases: OttoModel

The typed internal contract: hosts, chart specs, and tab layout.

The declarative contract the frontend renders from; TS types are generated from this schema in Phase 2. This model is never served directly — every dashboard boot (live or review) hydrates from GET /api/monitor_sessions, whose format:1 shape carries this same information reshaped into each session’s SessionMeta.

interval is the global collection interval in seconds — None until run() has recorded one (a collector that has not started live collection). Reviewed data (loaded from otto monitor <source>) carries this in its own SessionMeta instead — see otto.monitor.export.session_meta().

hosts : list[str]
live : bool
metrics : list[ChartSpec]
tabs : list[TabSpec]
interval : float | None
class otto.models.monitor.RowModel

Bases: BaseModel

Lenient base for historical-data import/export rows.

Unlike OttoModel (extra='forbid', which exists to turn a config typo into an error), data read-back is tolerant: an unexpected key/column from a newer schema is ignored rather than rejected. This matches the pre-pydantic .get()/[] parsing and keeps older otto builds able to import exports written by newer ones.

class otto.models.monitor.MetricRecord(*, timestamp: datetime, host: str = '', label: str, value: float, meta: dict[str, Any] | None = None, source: str | None = None)

Bases: RowModel

One metrics row at the format:1 JSON / v2 SQLite import-export boundary.

The JSON export format spells the time key timestamp; the SQLite metrics table column is ts. The validation_alias accepts both, so a single model validates either seam. host is optional for the pre-host-column schema; meta rides only in JSON (the DB has no meta column). Exporting with model_dump(mode='json', exclude_none=True) emits the JSON spelling and omits meta when None (host='' is still emitted — empty string is not None).

timestamp : datetime
host : str
label : str
value : float
meta : dict[str, Any] | None
source : str | None

Host id of the reporting host when this series came from an external management host (spec 2026-07-10 §3.1); None/absent = self-reported. Rides only in JSON for now — the SQLite metrics table gains its column with the backend catch-up (spec §7).

class otto.models.monitor.EventRecord(*, id: int | None = None, timestamp: datetime, end_timestamp: datetime | None = None, label: str = '', source: str = 'manual', color: str = '#888888', dash: str = 'dash')

Bases: RowModel

One events row at the JSON / SQLite import boundary.

Mirrors the MonitorEvent fields. Used to validate external event data before constructing the (unchanged, mutable) MonitorEvent dataclass — event export stays MonitorEvent.to_dict(). timestamp is required (a row without one is skipped, as before); everything else defaults. id is None when absent so the collector can assign its running id.

id : int | None
timestamp : datetime
end_timestamp : datetime | None
label : str
source : str
color : str
dash : str
class otto.models.monitor.LogEventRecord(*, timestamp: ~datetime.datetime, host: str = '', tab: str = '', fields: dict[str, str] = <factory>)

Bases: RowModel

One log_events row at the format:1 JSON / v2 SQLite import-export boundary.

Mirrors the parser-emitted LogEvent plus the host/tab the collector attaches. The JSON export format spells the time key timestamp; the SQLite column is ts (its fields column is JSON-decoded by the loader before validation).

timestamp : datetime
host : str
tab : str
fields : dict[str, str]
class otto.models.monitor.ElementRecord(*, id: str, type: 'physical' | 'logical' = 'logical', description: str | None = None)

Bases: RowModel

One optional lab.elements entry in the export snapshot.

id is the element name — the same string member hosts carry in HostSnapshot.element. Elements not listed are derived from hosts (any member with a slot → physical presentation; a single member → singleton behavior). An explicit entry with zero member hosts renders as an empty element (e.g. an unpopulated chassis). singleton is always derived from membership count, never stored (spec 2026-07-10 §2).

id : str
type : Literal['physical', 'logical']
description : str | None
class otto.models.monitor.HostSnapshot(*, id: str, element: str, name: str | None = None, board: str | None = None, slot: int | None = None, hop: str | None = None, os_type: str = 'unix', os_name: str | None = None, os_version: str | None = None, ip: str = '', interfaces: dict[str, str] = <factory>, labs: list[str] = <factory>, is_virtual: bool = False)

Bases: RowModel

The view-relevant subset of a host’s config, frozen into a session.

Deliberately never credentials (spec 2026-07-10 §3.1). interfaces is flattened to netdev -> ip (the frontend needs no more). Lenient read-back like every export row (RowModel).

id : str
element : str
name : str | None
board : str | None
slot : int | None
hop : str | None
os_type : str
os_name : str | None
os_version : str | None
ip : str
interfaces : dict[str, str]
labs : list[str]
is_virtual : bool
class otto.models.monitor.LinkEndpointSnapshot(*, host: str, interface: str | None = None, ip: str = '', port: int | None = None)

Bases: RowModel

One end of a snapshotted link (mirrors otto.link.model.LinkEndpoint).

host : str
interface : str | None
ip : str
port : int | None
class otto.models.monitor.LinkSnapshot(*, id: str, endpoints: Annotated[list[LinkEndpointSnapshot], MinLen(min_length=2), MaxLen(max_length=2)], protocol: str = 'tcp', provenance: 'implicit' | 'declared' | 'dynamic' = 'declared', name: str | None = None, impair: str | None = None)

Bases: RowModel

One static link frozen into a session’s lab snapshot.

Mirrors the runtime otto.link.model.Link. Real exporters write only implicit + declared provenances — the snapshot is a static-config document and dynamic tunnels are runtime state (spec 2026-07-10 §2); the dynamic value stays for parity with the runtime enum (and the live topology view). impair is the declared in-path middlebox host id — static config, unlike applied netem parameters.

id : str
endpoints : list[LinkEndpointSnapshot]
protocol : str
provenance : Literal['implicit', 'declared', 'dynamic']
name : str | None
impair : str | None
class otto.models.monitor.LabSnapshot(*, elements: list[~otto.models.monitor.ElementRecord] = <factory>, hosts: list[~otto.models.monitor.HostSnapshot] = <factory>, links: list[~otto.models.monitor.LinkSnapshot] = <factory>)

Bases: RowModel

A session’s lab config as it was at run time (spec 2026-07-10 §3).

elements : list[ElementRecord]
hosts : list[HostSnapshot]
class otto.models.monitor.ChartSpecRecord(*, label: str, y_title: str, unit: str, command: str, chart: str, interval: float | None = None)

Bases: ChartSpec

Lenient read-back variant of ChartSpec for export documents.

Same fields; extra="ignore" so an older otto can read exports written by a newer one whose chart specs carry new fields (the RowModel boundary philosophy). ChartSpec itself stays extra="forbid" as the otto-built internal parser-catalog contract.

class otto.models.monitor.TabSpecRecord(*, id: str, label: str, metrics: list[str], kind: 'charts' | 'table' = 'charts', columns: list[str] | None = None)

Bases: TabSpec

Lenient read-back variant of TabSpec (see ChartSpecRecord).

class otto.models.monitor.SessionMeta(*, interval: float | None = None, charts: list[~otto.models.monitor.ChartSpecRecord] = <factory>, tabs: list[~otto.models.monitor.TabSpecRecord] = <factory>)

Bases: RowModel

Presentation meta frozen at run time: chart/tab specs + intervals.

Client-side Import has no parser catalog to rebuild specs from, derived health needs per-series cadences, and chart definitions drift over months exactly like lab configs (spec 2026-07-10 §2, §4) — hence the lenient *Record spec variants, not the strict live-meta classes.

interval : float | None
charts : list[ChartSpecRecord]
tabs : list[TabSpecRecord]
class otto.models.monitor.SessionRecord(*, id: str, label: str | None = None, note: str | None = None, start: ~datetime.datetime, end: ~datetime.datetime | None = None, lab: ~otto.models.monitor.LabSnapshot = <factory>, meta: ~otto.models.monitor.SessionMeta = <factory>, metrics: list[~otto.models.monitor.MetricRecord] = <factory>, events: list[~otto.models.monitor.EventRecord] = <factory>, log_events: list[~otto.models.monitor.LogEventRecord] = <factory>, chart_map: dict[str, str] = <factory>)

Bases: RowModel

One self-contained monitoring session: config snapshot + data.

end=None means a still-open session. chart_map maps bare series labels to chart keys (ChartSpec.label), as the dashboard does today via the monitor_sessions/SSE wire.

id : str
label : str | None
note : str | None
start : datetime
end : datetime | None
lab : LabSnapshot
meta : SessionMeta
metrics : list[MetricRecord]
events : list[EventRecord]
log_events : list[LogEventRecord]
chart_map : dict[str, str]
class otto.models.monitor.MonitorExport(*, format: 1, sessions: list[SessionRecord])

Bases: RowModel

The versioned historical-export document (spec 2026-07-10 §3).

format is required with no default: a legacy unversioned document (the field’s absence is its marker) must fail loud here, never validate as an empty modern one. Literal[1] rejects future formats loud too.

format : Literal[1]
sessions : list[SessionRecord]
class otto.models.monitor.MonitorSessionFragment(*, format: ~typing.Literal[1] = 1, session: str, metrics: list[~otto.models.monitor.MetricRecord] = <factory>, events: list[~otto.models.monitor.EventRecord] = <factory>, log_events: list[~otto.models.monitor.LogEventRecord] = <factory>, deleted_event_ids: list[int] = <factory>, chart_map: dict[str, str] = <factory>, meta: ~otto.models.monitor.SessionMeta | None = None)

Bases: RowModel

An incremental update to ONE live monitor session.

Spec 2026-07-12 §The stream speaks format:1.

A fragment is a partial SessionRecord: every payload field is optional and carries the SAME name and type as its counterpart there, so the client appends rather than translates. This is deliberate — Plan 5a lost three fix waves to a rename across a lenient boundary model (MonitorMeta.metrics vs SessionMeta.charts), invisible to the type checker because both sides were str at the seam. The strongest defence is not a mapping function but the absence of a second model: these ARE the payload’s models.

deleted_event_ids is the one thing a partial record cannot express by presence, so it is explicit. Event updates need no separate kind — the client upserts by id, so an edited event is just an event.

format : Literal[1]
session : str
metrics : list[MetricRecord]
events : list[EventRecord]
log_events : list[LogEventRecord]
deleted_event_ids : list[int]
chart_map : dict[str, str]
meta : SessionMeta | None