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 inMetricStore.series). It is anOttoModel(extra='forbid') because otto is the only thing that builds it: the live append path usesmodel_construct(no validation, hot loop) and the import path usesmodel_validate.MetricRecord/EventRecord/LogEventRecord— flat records at theformat:1JSON 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', viaRowModel): an unknown column from a newer schema is dropped, not rejected, exactly as the old.get()/[]parsing did. Field names follow the JSON spelling; avalidation_aliasalso 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 reusesMetricRecord/EventRecord/LogEventRecord/SessionMetaverbatim rather than mirroring their fields under new names, so the fragment cannot drift from theformat:1payload 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
ValueErrorif it is below the floor.
-
class otto.models.monitor.MetricPoint(*, ts: datetime, value: float, meta: dict[str, Any] | None =
None)¶ Bases:
OttoModelA single charted sample: timestamp, numeric value, optional hover meta.
Replaces the
(datetime, float, dict | None)tuple stored per series. Consumers read.ts/.value/.metainstead of unpacking.
-
class otto.models.monitor.ChartSpec(*, label: str, y_title: str, unit: str, command: str, chart: str, interval: float | None =
None)¶ Bases:
OttoModelOne 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
ChartSpecRecordinsideSessionMetafor themonitor_sessions/SSE wire (seeotto.monitor.export.session_meta()).
-
class otto.models.monitor.TabSpec(*, id: str, label: str, metrics: list[str], kind: 'charts' | 'table' =
'charts', columns: list[str] | None =None)¶ Bases:
OttoModelOne 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 incolumns) instead of charts, and carrymetrics=[]. Not served at any endpoint of its own — seeChartSpecfor the wire path.
-
class otto.models.monitor.MonitorMeta(*, hosts: list[str], live: bool, metrics: list[ChartSpec], tabs: list[TabSpec], interval: float | None =
None)¶ Bases:
OttoModelThe 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, whoseformat:1shape carries this same information reshaped into each session’sSessionMeta.intervalis the global collection interval in seconds —Noneuntilrun()has recorded one (a collector that has not started live collection). Reviewed data (loaded fromotto monitor <source>) carries this in its ownSessionMetainstead — seeotto.monitor.export.session_meta().
- class otto.models.monitor.RowModel¶
Bases:
BaseModelLenient 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:
RowModelOne
metricsrow at theformat:1JSON / v2 SQLite import-export boundary.The JSON export format spells the time key
timestamp; the SQLitemetricstable column ists. Thevalidation_aliasaccepts both, so a single model validates either seam.hostis optional for the pre-host-column schema;metarides only in JSON (the DB has no meta column). Exporting withmodel_dump(mode='json', exclude_none=True)emits the JSON spelling and omitsmetawhenNone(host=''is still emitted — empty string is notNone).
-
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:
RowModelOne
eventsrow at the JSON / SQLite import boundary.Mirrors the
MonitorEventfields. Used to validate external event data before constructing the (unchanged, mutable)MonitorEventdataclass — event export staysMonitorEvent.to_dict().timestampis required (a row without one is skipped, as before); everything else defaults.idisNonewhen absent so the collector can assign its running id.
- class otto.models.monitor.LogEventRecord(*, timestamp: ~datetime.datetime, host: str = '', tab: str = '', fields: dict[str, str] = <factory>)¶
Bases:
RowModelOne
log_eventsrow at theformat:1JSON / v2 SQLite import-export boundary.Mirrors the parser-emitted
LogEventplus the host/tab the collector attaches. The JSON export format spells the time keytimestamp; the SQLite column ists(itsfieldscolumn is JSON-decoded by the loader before validation).
-
class otto.models.monitor.ElementRecord(*, id: str, type: 'physical' | 'logical' =
'logical', description: str | None =None)¶ Bases:
RowModelOne optional
lab.elementsentry in the export snapshot.idis the element name — the same string member hosts carry inHostSnapshot.element. Elements not listed are derived from hosts (any member with aslot→ physical presentation; a single member → singleton behavior). An explicit entry with zero member hosts renders as an empty element (e.g. an unpopulated chassis).singletonis always derived from membership count, never stored (spec 2026-07-10 §2).
- 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:
RowModelThe view-relevant subset of a host’s config, frozen into a session.
Deliberately never credentials (spec 2026-07-10 §3.1).
interfacesis flattened tonetdev -> ip(the frontend needs no more). Lenient read-back like every export row (RowModel).
-
class otto.models.monitor.LinkEndpointSnapshot(*, host: str, interface: str | None =
None, ip: str ='', port: int | None =None)¶ Bases:
RowModelOne end of a snapshotted link (mirrors
otto.link.model.LinkEndpoint).
-
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:
RowModelOne static link frozen into a session’s lab snapshot.
Mirrors the runtime
otto.link.model.Link. Real exporters write onlyimplicit+declaredprovenances — the snapshot is a static-config document and dynamic tunnels are runtime state (spec 2026-07-10 §2); thedynamicvalue stays for parity with the runtime enum (and the live topology view).impairis the declared in-path middlebox host id — static config, unlike applied netem parameters.- endpoints : list[LinkEndpointSnapshot]¶
- 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:
RowModelA session’s lab config as it was at run time (spec 2026-07-10 §3).
- elements : list[ElementRecord]¶
- hosts : list[HostSnapshot]¶
- links : list[LinkSnapshot]¶
-
class otto.models.monitor.ChartSpecRecord(*, label: str, y_title: str, unit: str, command: str, chart: str, interval: float | None =
None)¶ Bases:
ChartSpecLenient read-back variant of
ChartSpecfor export documents.Same fields;
extra="ignore"so an older otto can read exports written by a newer one whose chart specs carry new fields (theRowModelboundary philosophy).ChartSpecitself staysextra="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:
TabSpecLenient read-back variant of
TabSpec(seeChartSpecRecord).
- class otto.models.monitor.SessionMeta(*, interval: float | None = None, charts: list[~otto.models.monitor.ChartSpecRecord] = <factory>, tabs: list[~otto.models.monitor.TabSpecRecord] = <factory>)¶
Bases:
RowModelPresentation 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
*Recordspec variants, not the strict live-meta classes.- 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:
RowModelOne self-contained monitoring session: config snapshot + data.
end=Nonemeans a still-open session.chart_mapmaps bare series labels to chart keys (ChartSpec.label), as the dashboard does today via themonitor_sessions/SSE wire.- lab : LabSnapshot¶
- meta : SessionMeta¶
- metrics : list[MetricRecord]¶
- events : list[EventRecord]¶
- log_events : list[LogEventRecord]¶
- class otto.models.monitor.MonitorExport(*, format: 1, sessions: list[SessionRecord])¶
Bases:
RowModelThe versioned historical-export document (spec 2026-07-10 §3).
formatis 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.- 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:
RowModelAn 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.metricsvsSessionMeta.charts), invisible to the type checker because both sides werestrat 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_idsis the one thing a partial record cannot express by presence, so it is explicit. Event updates need no separate kind — the client upserts byid, so an edited event is just an event.- metrics : list[MetricRecord]¶
- events : list[EventRecord]¶
- log_events : list[LogEventRecord]¶
- meta : SessionMeta | None¶