From 087fc76d55e4e7e6dab7ad30d0e1c83fbabdc248 Mon Sep 17 00:00:00 2001 From: Jonas Scholl <43819845+JonasScholl@users.noreply.github.com> Date: Thu, 18 Jun 2026 04:16:39 +0200 Subject: [PATCH 01/34] drafts --- .vscode/settings.json | 2 +- README.md | 1 + docs/domain-architecture.md | 548 ++++++++++++++++++ docs/inspect-app/README.md | 56 ++ docs/inspect-app/concepts.md | 330 +++++++++++ .../decisions/0001-api-paradigm.md | 47 ++ .../decisions/0002-loading-and-state.md | 57 ++ .../decisions/0003-websocket-subscriptions.md | 45 ++ .../decisions/0004-async-strategy.md | 49 ++ .../inspect-app/decisions/0005-e2e-testing.md | 38 ++ .../decisions/0006-commit-write-model.md | 229 ++++++++ docs/inspect-app/decisions/README.md | 49 ++ 12 files changed, 1450 insertions(+), 1 deletion(-) create mode 100644 docs/domain-architecture.md create mode 100644 docs/inspect-app/README.md create mode 100644 docs/inspect-app/concepts.md create mode 100644 docs/inspect-app/decisions/0001-api-paradigm.md create mode 100644 docs/inspect-app/decisions/0002-loading-and-state.md create mode 100644 docs/inspect-app/decisions/0003-websocket-subscriptions.md create mode 100644 docs/inspect-app/decisions/0004-async-strategy.md create mode 100644 docs/inspect-app/decisions/0005-e2e-testing.md create mode 100644 docs/inspect-app/decisions/0006-commit-write-model.md create mode 100644 docs/inspect-app/decisions/README.md diff --git a/.vscode/settings.json b/.vscode/settings.json index 4f8d0bf..5bf2cbe 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -21,7 +21,7 @@ "python.analysis.extraPaths": [ "${workspaceFolder}/.venv/*" ], - "python.languageServer": "Pylance", + "python.languageServer": "None", "python.analysis.addImport.exactMatchOnly": true, "python.analysis.autoFormatStrings": true, "python.analysis.fixAll": [ diff --git a/README.md b/README.md index 591a6a9..1bcc230 100644 --- a/README.md +++ b/README.md @@ -82,6 +82,7 @@ except Exception as e: - [Python Module Architecture](https://github.com/SWR-MoIP/VideoIPath-Automation-Tool/blob/main/docs/python-module-architecture.md) - [Driver Versioning](https://github.com/SWR-MoIP/VideoIPath-Automation-Tool/blob/main/docs/driver-versioning.md) - [Development and Release](https://github.com/SWR-MoIP/VideoIPath-Automation-Tool/blob/main/docs/development-and-release.md) +- [Inspect App — Architecture & Concept Docs](https://github.com/SWR-MoIP/VideoIPath-Automation-Tool/blob/main/docs/inspect-app/README.md) (planning) ## Feedback & Contributions diff --git a/docs/domain-architecture.md b/docs/domain-architecture.md new file mode 100644 index 0000000..a228d99 --- /dev/null +++ b/docs/domain-architecture.md @@ -0,0 +1,548 @@ +# Unified Domain Architecture (proposed) + +> Status: **Draft / Vision** · Last updated: 2026-06-18 +> +> This document proposes a **package-wide** re-think: present **one unified +> domain model** in which the user simply **creates devices, configures them, +> and connects them** — without ever needing to know about the `inventory`, +> `topology`, or `inspect` apps, which VideoIPath (VIP) store or endpoint is +> involved, or how the work is split across planes. +> +> It stands in deliberate contrast to the current +> [`python-module-architecture.md`](./python-module-architecture.md), whose +> stated goal is to *"maintain a structure similar to VideoIPath."* That goal is +> exactly what this proposal revisits. The VIP wire format and per-app research +> this builds on lives in [`inspect-app/`](./inspect-app/README.md) +> (`concepts.md` and the ADRs); those documents remain the **faithful map of the +> server**, while this one is the **map we want to present to users**. +> +> Nothing here is implemented yet. This is the target picture and the decisions +> required to get there. + +## 1. The idea in one sentence + +> The user works with a **network of devices and connections**. *Where* that +> lives in VideoIPath — Inventory onboarding, Topology placement, Inspect +> monitoring, which REST/RPC endpoint, which store — is an **implementation +> detail** the package owns, not the user. + +Today the user must learn the VIP product structure and drive it step by step: + +```python +# Today: the user orchestrates VIP's app/plane split by hand +staged = app.inventory.create_device(driver="com.nevion.NMOS_multidevice-0.1.0") +staged.configuration.address = "10.100.100.1" +device = app.inventory.add_device(staged) # plane 1: Inventory (RPC) + +topo = app.topology.get_device(device.device_id) # plane 2: Topology (nGraphElements) +topo.configuration.position_x = 100 +app.topology.update_device(topo) + +edges = app.topology.create_edges(...) # plane 2/3: edges +# ... monitor via the collector aggregate ... # plane 3: Inspect +``` + +The proposed surface: + +```python +# Proposed: one domain, the package orchestrates the planes +dev = app.add_device(driver="...", address="10.100.100.1", label="Cam-1", position=Position(x=100, y=200)) +app.connect_devices(dev.port("Eth 1.10"), other.port("Eth 1.11"), bandwidth=Gbps(10)) +``` + +## 2. Why a unified domain + +The package today is a **transparent client**: its public surface speaks VIP's +own vocabulary — `BaseDevice`, `IpVertex`, `UnidirectionalEdge`, +`nGraphElements`, `_rev`, `fromId::toId` — and exposes VIP's **app split** +(`inventory` / `topology` / `inspect`). The user must know *which VIP app does +what*: onboard in Inventory → place/connect in Topology → monitor in Inspect, +while Inspect writes land back in `nGraphElements`. + +This is awkward for reasons confirmed against a real server (see +[`inspect-app/concepts.md`](./inspect-app/concepts.md) and the captured +`collector` payload): + +1. **The app split is VIP's internal structure, not the user's mental model.** + "Onboard, then place, then connect, then monitor" is a property of VIP's + architecture. Users think "I have devices and I want to connect them." +2. **VIP already hides its own stores behind facades.** The `collector` + aggregate composes the raw stores server-side. Mirroring the raw stores in + Python re-exposes a detail the vendor themselves chose to hide. +3. **The wire format is unstable and version-gated** (the package version-gates + endpoints and carries many `[VERIFY]` items). A transparent client passes + that instability straight through to users; one domain surface absorbs it in + a single place. +4. **One real device is fragmented across three models today** — + `InventoryDevice`, `TopologyDevice`, and the Inspect node — each a partial + view. A unified `Device` re-assembles the whole. +5. **The same entity has three+ wire representations** — the `collector` read + shape, the `updateTopology` delta, and the `nGraphElements` store. A + user-facing model that picks **one** domain representation and translates + internally is strictly simpler. + +This is a textbook case for an **anti-corruption layer (ACL)**: a stable domain +model with a hard boundary, behind which all VIP-specific translation lives. + +## 3. Verdict & guiding principle + +**Abstract VIP away — but as an explicit layered ACL, not a thin rename.** The +discipline: + +- The **domain layer never imports or returns a wire/app type.** Translation + lives only in the ACL. +- The domain model is **stable**; version churn is absorbed by ACL adapters, + selected by the existing version-check machinery — never by branching in user + code or in the domain types. +- We deliberately decide, per concept, **what to hide vs. what to expose** (§13). + Some semantics (commit, validation, conflict) are first-class domain + behaviour; others (edge pairing, key formats, namespaces, the app split) are + hidden. +- The existing apps remain the documented **escape hatch** for raw access — + keeping the change **additive and non-breaking**. + +> **What an ACL can and cannot hide.** Renaming fields (`unidirectionalEdge` → +> `Connection`) is trivial. Hiding *semantics* is the hard part and must be a +> conscious choice: a connection being two paired unidirectional edges (hide), +> commit validation that can fail after HTTP success (expose), optimistic +> concurrency via revisions (expose, but as an opaque token), driver-specific +> configuration schemas (cannot be hidden — see §12). A good ACL chooses; it +> does not pretend the semantics don't exist. + +## 4. Target architecture + +```text +┌────────────────────────────────────────────────────────────────┐ +│ Unified Domain API (NEW default surface) │ ← what users touch +│ app.network: Device, Port, Connection, Service, Status │ one model, one vocabulary +│ lifecycle: discover → onboard → place → connect → monitor │ stable, version-independent +├────────────────────────────────────────────────────────────────┤ +│ Orchestration + Anti-corruption layer │ ← sequences plane operations, +│ domain ⇄ {inventory RPC, topology PATCH, collector/updateTopo} │ translates & maps ids; no I/O state +├────────────────────────────────────────────────────────────────┤ +│ Existing apps (now the LOW-LEVEL layer / escape hatch) │ ← unchanged, still public +│ app.inventory · app.topology · app.inspect · app.profile · … │ faithful to VIP +├────────────────────────────────────────────────────────────────┤ +│ Connector (REST v2 + RPC) │ +└────────────────────────────────────────────────────────────────┘ +``` + +Key properties: + +- **`app.network` is the new default**; the per-app surfaces are demoted to a + documented **low-level layer / escape hatch**, not removed. +- **Orchestration is a core responsibility**: a single domain call may fan out + to **several planes in sequence** (e.g. `add_device` = Inventory RPC add → + optional Topology placement). +- The ACL is **pure translation** — no I/O, no transport state. It maps between + domain objects and wire DTOs, so the wire models can change shape without + touching the domain types. + +## 5. The device lifecycle + +The hardest part of a package-wide abstraction is owning the **onboarding +lifecycle**, which today is an explicit, ordered prerequisite chain (devices are +onboarded in Inventory first, only then placed/connected). + +Model the lifecycle explicitly on the domain `Device` so the user can reason +about *where* a device is without knowing *which app*: + +| Domain state | Meaning | VIP reality (hidden) | +| ------------ | ------- | -------------------- | +| `Discovered` | Auto-found, not yet managed | Inventory discovered devices | +| `Onboarded` | Known/credentialed, driver bound | Inventory `config/devman/devices` (RPC `/api/updateDevices`) | +| `Placed` | Positioned in the network graph | Topology `baseDevice` in `nGraphElements` | +| `Connected` | Has connections to other devices | `unidirectionalEdge` / `updateTopology` | +| `Monitored` | Live status/services available | `collector` aggregate + WebSocket | + +`add_device(...)` advances a device from nothing → `Onboarded` (and optionally +→ `Placed` in the same call). `connect(...)` advances to `Connected`. The user +sees one object moving through states; the package picks the endpoints. + +> **This is also where the abstraction is hardest** — see §12 (limits). Some +> lifecycle inputs (driver choice, credentials, per-driver custom settings) are +> genuinely device-specific and cannot be fully hidden. + +## 6. The unified domain model + +One `Device` re-assembles what is today three partial models. The whole +vertex/edge/element taxonomy collapses into a handful of nouns. These are +**proposals**, not final signatures: + +```python +# Domain model — intentionally NOT mirroring nGraphElements / collector DTOs. + +class Device: + id: DeviceId # opaque; internally "device34" / "virtual.3" + label: str + driver: DriverRef # required for onboarding (see §12) + connection: ConnectionInfo # address(es), credentials, driver custom settings + state: LifecycleState # §5 + position: Point | None # placement; hides maps[]/meta.coordinates/inspect_app_format + ports: list[Port] # capabilities (merges vertices + collector ports) + status: DeviceStatus # merges inventory status + collector node status + sync: SyncState # from nodeStatus.syncSeverity + tags: list[str] + +class Port: # hides ipVertex/codecVertex/genericVertex + vertexInfo single|double + id: PortId + label: str + direction: Direction # IN | OUT | BIDIRECTIONAL (hides .in/.out vertex split) + kind: PortKind # IP | CODEC | GENERIC + is_endpoint: bool + status: PortStatus + +class Connection: # ONE user-facing connection == the paired unidirectional edges + id: ConnectionId # "deviceA::deviceB" pair identity + a: PortRef + b: PortRef + forward: DirectionState # primary: bandwidth, status + reverse: DirectionState # secondary: bandwidth, status (may differ!) + redundancy: Redundancy + status: ConnectionStatus # rolled up from alarm/bandwidth/maintenance/ptp + +class Service: # a booking/path (collector inspect.paths) + id: ServiceId # "100001" + label: str # "Encoder 1.1 -> Decoder 1.5" + endpoints: tuple[Endpoint, Endpoint] + is_main: bool + status: ServiceStatus +``` + +Design principles: + +- **Opaque ids** (`DeviceId`, `PortId`, `ConnectionId`) are value objects, not + raw strings. They internally carry every wire form (§7). Users never construct + ids by string-mangling. +- **A `Connection` is singular** — the single most valuable abstraction. Hide + that an inter-device connection is two `unidirectionalEdge`s plus internal + fan-out. Carry **per-direction state** because the two directions are + genuinely asymmetric (the capture shows forward `bandwidth: 20.0`, reverse + `0.0`). +- **Status is a first-class, read-only projection**, separate from config. This + matches the config-plane vs. status-plane split + ([ADR-0001](./inspect-app/decisions/0001-api-paradigm.md)) and keeps the live + story clean. +- **Make illegal states unrepresentable** where cheap (enums for `Direction`, + `PortKind`, `Redundancy`, `LifecycleState`, `SyncState`). + +## 7. Identifiers — the "id zoo" and why ids must be opaque + +A single physical port appears under **four to five** id schemes, all visible in +one collector payload (and inventory adds its own device-id assignment on top). +For device0's "Eth 1.10": + +| Form | Example | Where it appears | +| ---- | ------- | ---------------- | +| Port pid | `device0.dev.1.10` | `nodeStatus` module/port keys, `context.portPid` (note the `.dev.` infix) | +| Vertex id (in/out) | `device0.1.10.in` / `device0.1.10.out` | `vertexInfo.in/out.id`, edge keys (**no** `.dev.`) | +| Resource id | `device:device0.dev.1.10` | `resourceId`, `security.*` | +| Topo endpoint ref | `topo:device0.1.1` | `serviceFields.from` / `.to` | +| Connection pair key | `device0::device1` | `externalEdgesByDeviceKey._id` | +| Edge key | `device0.1.10.out::device1.1.11.in` | edge ids, `replaceEdges` | +| Service id | `100001::main` | `inspect.paths`, `pathDescriptions` keys | + +Translating "the port a user names" → "the vertex id an edge key needs" is a +non-trivial, lossy-if-sloppy transform (`device0.dev.1.10` → +`device0.1.10.{in|out}`). **Therefore ids are value objects that hold all +forms.** String-mangling these in user code — especially the `.dev.` infix — +is the most likely source of subtle bugs. + +## 8. Reads — the collector snapshot as the aggregate root + +The `collector` aggregate (`GET …/data/status/collector/**`) returns the +**entire connected graph in one internally-consistent request**: devices, ports, +inter-device connections, services, security, profiles, tags. This eliminates +the N+1 / per-device fan-out and the torn data/rev split of the topology read +path. For "linked devices that have connections too," the server hands you the +whole component at once — **do not** rebuild the domain graph from N per-device +fetches. For onboarding-only attributes not in the collector (driver, +credentials, custom settings), the ACL supplements with an Inventory read. + +The cost is **heavy denormalization**. A single service (`100001::main`) +appears in at least six places in the snapshot, each with full status +sub-objects: + +- `inspect.paths._items[]` (canonical path + `serviceFields`) +- `externalEdgesByDeviceKey[].primary.data[…].pathDescriptions` +- `nodeStatus` source-endpoint port (`device0.dev.1.1`) +- `nodeStatus` egress port (`device0.dev.1.10`) +- `nodeStatus` ingress port (`device1.dev.1.11`) +- `nodeStatus` sink-endpoint port (`device1.dev.1.5`) + +**Rule:** pick **one canonical source per domain object**; treat the rest as +drill-down breadcrumbs. Building a domain object from each occurrence yields +divergent copies. + +| Domain object | Canonical source | +| ------------- | ---------------- | +| `Device` / `Port` | `inspect.nodeStatus._items[]` → `modules.*.ports.*` (+ inventory for onboarding fields) | +| `Connection` | `externalEdgesByDeviceKey._items[]` | +| `Service` | `inspect.paths._items[]` (+ `serviceFields`) | +| port ↔ service drill-down | `ports.*.pathDescriptions` (reference only — do not re-model) | + +### 8.1 Connections are pre-paired by the server + +`externalEdgesByDeviceKey._items[]` already groups the two unidirectional edges +under one bidirectional key: + +- `_id: "device0::device1"` — the connection identity +- `primary` → `device0.1.10.out::device1.1.11.in` (forward direction) +- `secondary` → `device1.1.11.out::device0.1.10.in` (reverse direction) + +So the `Connection` deduplication problem is solved on the read side: the `a::b` +key is the identity, and `primary` / `secondary` are the two `DirectionState`s. +The directions carry **independent** `bandwidth` / `fromStatus` / `toStatus` / +`status`, so the domain `Connection` must keep both, plus the top-level +aggregate `status` the collector provides. + +## 9. Operation → plane mapping (orchestration) + +Each domain operation expands to an ordered sequence of existing wire +operations. The ACL owns this table; the user never sees it. + +| Domain operation | Orchestrated VIP sequence | +| ---------------- | ------------------------- | +| `network.discover()` | Inventory discovered-devices read | +| `network.add_device(driver, connection, …)` | Inventory RPC `/api/updateDevices` (assigns `deviceN`) → optional Topology placement PATCH | +| `device.configure(...)` | Inventory custom-settings update (RPC) and/or Topology vertex/capability PATCH — by field | +| `device.place(x, y)` | Topology `baseDevice` `maps[]` PATCH (revisioned) | +| `network.connect(a, b, …)` | Resolve port→vertex ids → `updateTopology` / `nGraphElements` edge upsert (paired edges) | +| `network.get(...)` / `list_*()` | `collector` snapshot read (+ inventory status where needed) | +| `network.disconnect(...)` / `remove_device(...)` | `updateTopology` remove and/or Inventory RPC remove | +| `device.refresh()` / `network.refresh()` | Re-fetch `collector` snapshot (§11.3) | + +## 10. The configuration / write interface + +Make the **change set the unit of work** — this is genuinely how the server +behaves for topology/connection edits +([ADR-0006](./inspect-app/decisions/0006-commit-write-model.md)), so it is a +semantic to **expose**, not hide. Adopt ADR-0006 option 3 (explicit change set + +convenience auto-commit), with a context manager as the ergonomic default. +Proposed shape: + +```python +# Batched, atomic — the primary path +with app.network.change_set() as cs: + cs.place(device_id, at=Point(x, y)) + conn = cs.connect(port_a, port_b, bandwidth=Gbps(10), redundancy=Redundancy.ANY) + cs.remove(old_connection_id) + result = cs.validate() # maps data.validation.result; surfaces affected services + if result.ok: + cs.commit() # one updateTopology POST +# on exception → auto-discard; on clean exit without commit → configurable + +# Convenience — single change auto-commits +app.network.connect(port_a, port_b, bandwidth=Gbps(10)) +``` + +What the domain layer owns (and the ACL translates): + +- **Commit ≠ HTTP success.** A captured failed delete returned `header.ok: true` + but `data.res.ok: false` / `data.validation.result.ok: false` (ADR-0006). + This must surface as a typed `CommitResult` / `CommitFailed` carrying the + per-entity validation details (`status`, `rev`, `resolvable`, message) — + never a raw envelope. +- **Affected-services check** — already a precedent in + `TopologyApp.list_services_affected_by_device_update`. In the domain model it + becomes `change_set.validate()` returning structured impact. +- **Diffing stays internal.** Users describe *intent* (`connect`, `remove`); + the ACL computes the delta. The existing diff logic becomes an implementation + detail of staging. + +## 11. Writes & freshness across planes + +The Inspect-era findings hold and become **more pronounced** because three +planes are now in play. + +### 11.1 Writes span three concurrency models + +The three planes do **not** share a write/consistency model: + +| Plane | Write mechanism | Concurrency control | +| ----- | --------------- | ------------------- | +| Inventory | RPC `/api/updateDevices` | No revision/strict mode (RPC semantics) | +| Topology | `PATCH nGraphElements` | `_rev` optimistic locking, `mode: strict` | +| Inspect | `updateTopology` action | Commit-time validation; `_rev` behaviour **[VERIFY]** (ADR-0006) | + +So a single domain write that touches multiple planes has **no single +transaction and no uniform conflict story**. The ACL must define, per +orchestrated operation: ordering, what happens on partial failure midway through +the sequence, and how each plane's conflict surfaces as **one** domain error. +Lean on the change-set/commit model for the topology/inspect portion; onboarding +(Inventory RPC) is a separate step that must be sequenced and compensated +explicitly if a later step fails. + +### 11.2 The collector carries no `_rev` + +Every collector `_items[]` entry has `_id` / `_vid` and **no `_rev`** — it is a +pure status-plane projection. The revisioned source of truth lives only in the +config plane (`nGraphElements`). Consequences: + +1. **Status changes move no token.** Alarms, `ptp` severity, `bandwidth`, + `syncSeverity` are not backed by any revision. There is **no cheap "did + anything change?" probe** for status — the only ways to learn current status + are to re-fetch the collector (whole or subtree) or subscribe via WebSocket + ([ADR-0003](./inspect-app/decisions/0003-websocket-subscriptions.md)). +2. **Config-plane rev-polling detects config edits only.** Cheaply polling + `nGraphElements .../id,rev` catches "someone re-wired," but never "a + connection went into alarm." For monitoring, the latter is the majority of + interesting events. So rev-polling is necessary-but-insufficient. +3. **Read and write tokens live in different planes.** A safe optimistic write + needs the `nGraphElements` `_rev` (or the `updateTopology` per-entity `rev`), + which is **absent from the collector read**. A domain object built from the + collector **cannot, by itself, support an optimistic write** — the write path + must resolve revisions separately at commit time. + +### 11.3 Freshness strategy — re-snapshot, not rev-diff + +The collector deliberately trades per-entity revisioning for a single +globally-consistent snapshot — excellent for **read correctness** (no torn +graph), poor for **incremental freshness**. Therefore: + +- Treat the collector snapshot as an **immutable value object**, replaced + wholesale on `refresh()` — not mutated in place. +- `refresh()` means "fetch a new snapshot," not "diff revisions." Stamp each + snapshot with a **client-side fetch time** as the freshness marker, since the + server provides no token. +- If projection/filtering behind the `/**` wildcard turns out to be supported + (open `[VERIFY]`, not proven by the capture), a scoped re-snapshot of a + subtree is the optimisation — still a re-snapshot, not a diff. +- This is the strongest argument for making **WebSocket the real freshness + channel for status**, while config edits keep the rev-based path + ([ADR-0001](./inspect-app/decisions/0001-api-paradigm.md), + [ADR-0002](./inspect-app/decisions/0002-loading-and-state.md), + [ADR-0003](./inspect-app/decisions/0003-websocket-subscriptions.md)). + +### 11.4 Separate the read snapshot from the write handle + +Because the snapshot has no revisions, a domain mutation (`connect`, `remove`) +must, at commit time, resolve the affected entities' `nGraphElements` revisions +itself (or go through `updateTopology` and surface its validation result). Do +**not** let a read object pretend it can optimistically write. The clean split: + +- **Read snapshot** — immutable, rev-less, globally consistent, replace wholesale. +- **Write handle / change set** — resolves revisions at commit, owns the + commit/validate/discard/conflict lifecycle. + +## 12. Limits of the abstraction + +A package-wide abstraction hits boundaries that a thin rename cannot erase. Name +them explicitly so the abstraction stays trustworthy: + +- **Driver selection is irreducible.** A device *is* a specific driver with a + specific capability/custom-settings schema (NMOS port, "indices in IDs", SNMP + config, …). The package even **generates per-driver models**. The domain can + unify the *lifecycle, identity, connection, and status model*, but + `configure(...)` of driver-specific settings is inherently driver-shaped. + Proposed: a generic `Device.settings` entry point typed by driver, surfaced via + the existing per-driver model generation — abstracted *entry point*, not + abstracted *schema*. +- **Onboarding inputs are real.** Address(es), credentials, and driver choice + must be supplied at `add_device`; they cannot be inferred. +- **Virtual vs. physical devices** differ (virtual id allocation, no driver + contact). The lifecycle must accommodate both. +- **Capabilities come from the driver**, not the user. Ports/vertices are + largely driver-defined; the user configures and connects them but does not + invent them. +- **The escape hatch must stay first-class.** Power users will need raw + `nGraphElement` / `InventoryDevice` access; the low-level apps remain public + and documented for exactly this. + +The honest framing: **unify the lifecycle, identity, connection, and status +model; do not pretend driver-specific configuration is uniform.** + +## 13. Hide vs. expose — the key design ledger + +The single most important artifact of this approach. Proposed starting point +(to be ratified as an ADR): + +| Concept | Decision | Rationale | +| ------- | -------- | --------- | +| VIP app/namespace split (inventory/topology/collector planes) | **Hide** | The whole point of the abstraction | +| Edge pairing (two unidirectional edges → one `Connection`) | **Hide** | Server already groups via `externalEdgesByDeviceKey` | +| Id/key formats (`.dev.` infix, `a::b`, `topo:`/`device:` prefixes) | **Hide** behind opaque id value objects | Pure mechanical detail; error-prone in user code | +| Internal fan-out edges (`capacity: 1`) | **Hide** | Implementation detail of a device's internal wiring | +| Device lifecycle (onboard → place → connect → monitor) | **Expose** as `LifecycleState` | Users must reason about *where* a device is, not *which app* | +| Change set / commit | **Expose** (first-class) | Genuine server semantic; enables atomic multi-edits | +| Commit validation result & affected services | **Expose** (typed result) | `header.ok` lies; users must see real outcome | +| Optimistic concurrency / revisions | **Expose as opaque token** | Needed for safe writes; but never raw `_rev` strings | +| Multi-dimensional status (`alarm`/`bandwidth`/`maintenance`/`ptp`, `sa`/`severity`) | **Expose** (preserve dimensions + provide rollup) | Lossy to collapse; monitoring users need the detail | +| Snapshot freshness (no `_rev`) | **Expose** via explicit `refresh()` + fetch-time stamp | Honest about the lack of a server token | +| Driver-specific custom settings | **Cannot hide** — abstract the entry point only | A device *is* its driver schema (§12) | + +## 14. Migration & coexistence + +The change can be **additive and non-breaking**: + +1. **Build `app.network` on top of the existing apps.** No removal; the domain + facade calls the current `inventory` / `topology` / `inspect` APIs + internally. Reuse the existing wire models as the ACL's persisted form — e.g. + domain `Connection` → ACL → existing `UnidirectionalEdge` → `updateTopology` + (one wire model, not two). +2. **Ship read-first.** `app.network.get/list_*` over the `collector` snapshot + (+ inventory supplements), returning unified `Device` / `Connection` objects. +3. **Add lifecycle writes incrementally** — `add_device` (onboard [+ place]), + `place`, `connect`/`disconnect`, `configure` — each backed by the + orchestration table (§9) and the change-set commit model. +4. **Keep the low-level apps public** as the escape hatch; document them as such. +5. **Optionally** add async + WebSocket per the existing ADRs once the sync + surface settles. + +Replacing the existing apps outright (a breaking, single-surface package) is the +alternative to the additive approach — see §15. + +## 15. Open decisions + +| Decision | Options | Note | +| -------- | ------- | ---- | +| Anti-corruption layer vs. transparent client | ACL (this doc) **vs.** keep mirroring VIP | The overarching decision; ratify as an ADR | +| Migration style | **Additive `app.network` facade** vs. breaking replacement of the apps | Recommend additive; lowest risk | +| Entry-point name | `app.network` · `app.fabric` · `app` (top-level) | Neutral, non-vendor name preferred | +| Driver-config abstraction | Generic `settings` entry point with per-driver typed schema **vs.** explicit per-driver objects | §12: entry point can unify; schema cannot | +| Multi-plane write semantics | Best-effort sequence + compensation **vs.** topology/inspect change-set only, inventory separate | §11.1; define partial-failure behaviour | +| Lifecycle model surface | Explicit `LifecycleState` enum **vs.** implicit (methods just work) | §5; explicit aids reasoning & errors | +| Completeness bar for v1 | Full coverage **vs.** 80% happy path + escape hatch for the rest | Recommend the latter; lowest cost | +| Hide-vs-expose ledger (§13) | Ratify explicitly | The most consequential artifact | +| Snapshot vs. WS for status freshness | re-snapshot now, WS as the real channel | §11.3; ties into ADR-0002 / ADR-0003 | +| Read-snapshot ↔ write-handle revision bridge | How the change set resolves `_rev` at commit | §11.4; ties into ADR-0006 | +| Relationship to existing docs | Supersede `python-module-architecture.md` design goal **vs.** coexist as "current vs. target" | Currently coexists as the target picture | + +## 16. Field-mapping reference (wire → domain) + +Indicative mapping from the captured `collector` payload (and Inventory, for +onboarding fields) to the proposed domain types. To be completed during +discovery and turned into fixtures. + +| Domain field | Source | Notes | +| ------------ | ------ | ----- | +| `Device.id` | `nodeStatus._items[]._id` | e.g. `device0` | +| `Device.label` | `nodeStatus[].descriptor.label` | fallback `fDescriptor` if empty | +| `Device.driver` / `Device.connection` | Inventory `config/devman/devices` | onboarding fields, not in collector | +| `Device.state` | derived | from presence across inventory / topology / collector (§5) | +| `Device.position` | `nodeStatus[].meta.coordinates.{x,y}` | float; hides `maps[]`/`inspect_app_format` | +| `Device.sync` | `nodeStatus[].syncSeverity` | map severity → `SyncState` | +| `Device.status` | `nodeStatus[].status.{sa,severity}` + `ptpDeviceStatus` (+ inventory status) | multi-dimensional | +| `Port.id` | module/port key `device0.dev.1.10` | translate to vertex id for edges | +| `Port.label` | `ports.*.descriptor.label` | | +| `Port.direction` | `ports.*.vertexInfo.type/vertexType` | `single`+`In/Out` or `double` → `BIDIRECTIONAL` | +| `Port.is_endpoint` | `ports.*.vertexInfo.fields.isEndpoint` | | +| `Port.status` | `ports.*.status` + `ptpPortStatus` | | +| `Connection.id` | `externalEdgesByDeviceKey[]._id` | `device0::device1` | +| `Connection.forward` | `…primary.data[edgeKey].{bandwidth,status,fromStatus,toStatus}` | | +| `Connection.reverse` | `…secondary.data[edgeKey].{…}` | may differ from forward | +| `Connection.status` | `externalEdgesByDeviceKey[].status` | `{alarm,bandwidth,maintenance,ptp}` | +| `Service.id` | `inspect.paths._items[]._id` | `100001::main` | +| `Service.label` | `…serviceFields.generic.descriptor.label` | | +| `Service.endpoints` | `…serviceFields.{from,fromLabel}` / `{to,toLabel}` | `topo:` refs | +| `Service.status` | `…serviceFields.serviceStatus.{config,total}` | | +| `Service.is_main` | `…serviceFields.isMain` | | + +## 17. Relationship to existing documents + +- [`python-module-architecture.md`](./python-module-architecture.md) — describes + the **current** mirror-VIP architecture. This document proposes the **target**. +- [`inspect-app/`](./inspect-app/README.md) — `concepts.md` (the VIP wire-format + research, including the `collector` aggregate and `nGraphElements` store) and + the ADRs (API paradigm, loading, WebSocket, async, testing, commit model). The + decisions there apply package-wide and are referenced throughout this document. diff --git a/docs/inspect-app/README.md b/docs/inspect-app/README.md new file mode 100644 index 0000000..49c21c2 --- /dev/null +++ b/docs/inspect-app/README.md @@ -0,0 +1,56 @@ +# Inspect App — Architecture & Concept Docs + +Planning and architecture-decision documents for adding the VideoIPath **Inspect** +app to the package. In the VideoIPath product, Inspect is the newer app that +**replaces the Topology app** — building topologies and connecting devices — and +adds service monitoring with live (WebSocket-based) updates. It does **not** +replace **Inventory**: devices are still onboarded in Inventory first, then placed +and connected in Inspect. Inspect also uses a **commit-style** write model, where +create/edit/delete actions are gathered into a change set and committed together. + +In **this package**, `app.inspect` is **purely additive**: it is added alongside +the existing apps. `app.topology` and `app.inventory` keep working **unchanged** — +there is **no deprecation and no migration** planned. + +These docs are **living documents** — meant to be edited and refined as the +design firms up and as the real Inspect API is reverse-engineered. The official +[VideoIPath Public API 2025 LTS](https://documenter.getpostman.com/view/11222813/2sBXihpCS8#intro) +reference is now a primary source. Nothing here is implemented yet; this is the plan. + +## Reading order + +1. **[concepts.md](./concepts.md)** — what Inspect is, its domain model, how it + maps onto the existing package, and the endpoint/WebSocket discovery template + (the `[VERIFY]` items to confirm against a real server). Cross-references the + [Public API 2025 LTS](https://documenter.getpostman.com/view/11222813/2sBXihpCS8#intro) + reference. +2. **[decisions/](./decisions/)** — the architecture decisions (ADRs), one per + topic. Start with [the index](./decisions/README.md). +3. **[implementation-plan.md](./implementation-plan.md)** — phased, non-breaking + rollout, target package layout, milestones, and risks. + +> **Wider context:** the package-wide re-think that grew out of this Inspect +> work — one unified `Device` / `Connection` domain model that hides the +> Inventory / Topology / Inspect split entirely — lives in +> [`../domain-architecture.md`](../domain-architecture.md). + +## Decision log + +| Question (from the brief) | Decision record | Status | +| -------------------------------------------------- | ------------------------------------------------------------ | -------- | +| Data-driven vs. event/action-driven API? | [ADR-0001](./decisions/0001-api-paradigm.md) | Open | +| Always sync/load vs. lazy load vs. cached state? | [ADR-0002](./decisions/0002-loading-and-state.md) | Open | +| Use WebSockets for event subscriptions? | [ADR-0003](./decisions/0003-websocket-subscriptions.md) | Open | +| Make the package async-ready? Support non-async? | [ADR-0004](./decisions/0004-async-strategy.md) | Open | +| How to test E2E (low-effort, stable, trustworthy)? | [ADR-0005](./decisions/0005-e2e-testing.md) | Open | +| How are config writes applied (immediate vs. commit)? | [ADR-0006](./decisions/0006-commit-write-model.md) | Open | + +## Status legend + +- **Draft** — concept/plan being shaped. +- **Proposed / Accepted / Superseded** — for ADRs, see + [the decisions index](./decisions/README.md). + +> ADRs currently capture **context and options only** — decisions are open. +> Promote an ADR to **Accepted** once agreed, and tick off `[VERIFY]` items in +> [concepts.md](./concepts.md) as they are confirmed. diff --git a/docs/inspect-app/concepts.md b/docs/inspect-app/concepts.md new file mode 100644 index 0000000..e368bfc --- /dev/null +++ b/docs/inspect-app/concepts.md @@ -0,0 +1,330 @@ +# Inspect App — Concepts & Technical Model + +> Status: **Draft** · Last updated: 2026-06-18 +> +> This document captures what the *Inspect* app is, how it maps onto the +> existing package, and which technical details still need to be verified +> against a real server. Anything not yet confirmed is marked **[VERIFY]**. +> The official +> [VideoIPath Public API 2025 LTS](https://documenter.getpostman.com/view/11222813/2sBXihpCS8#intro) +> reference is a primary source for the wire format. + +## 1. What Inspect is + +Nevion describes Inspect as an *"advanced monitoring application that allows +the operator to perform high-level monitoring of services combined with the +ability to drill-down and inspect details to pinpoint service-affecting +problems."* + +In recent VideoIPath releases the Inspect app **replaces the Topology app**: it +becomes the entry point for building the network connectivity model (vertices, +edges, device placement), connecting devices, and watching their live +operational status — including **WebSocket event subscriptions** for live +updates. Inspect applies configuration changes with a **commit-style** model: +create/edit/delete actions are gathered into a change set and committed together +(see [ADR-0006](./decisions/0006-commit-write-model.md)). + +Inspect does **not** replace the **Inventory** app. Devices are still onboarded +in Inventory first; only then can they be placed and connected in Inspect. So +Inventory remains a separate, required prerequisite. + +In **this package**, `app.inspect` is added **additively** — `app.topology` and +`app.inventory` keep working unchanged, with no deprecation planned. + +## 2. Architecture: the `collector` facade + +Inspect is built around a server-side **`collector` facade** — a distinct REST +v2 API surface under the `status` namespace. The server composes reads from +(and applies writes to) the underlying VideoIPath data store; the **API +contract is mostly net-new** relative to what `app.topology` and +`app.inventory` use today. + +**Reads** — one server-built aggregate: + +- `GET /rest/v2/data/status/collector/**` → `data.status.collector` (§3.1) +- Bundles topology nodes, inter-device edges, services/paths, live status, and + security context in a single tree with `_items[]` collections. + +**Writes** — one bulk action per commit: + +- `POST /rest/v2/actions/status/collector/updateTopology` + ([ADR-0006](./decisions/0006-commit-write-model.md)) +- Sends a client-assembled delta: `replaceDevices`, `replaceVertices`, + `replaceEdges`, `replaceResourceTransforms`, `addExternalEdges`, `remove`, + `force`. +- Validation runs at commit time; success/failure is determined by + `data.res.ok` / `data.validation.result.ok`, not `header.ok`. + +**Namespace** — the `collector` API entry points live under `status` +(`data/status/collector` for reads, `actions/status/collector` for writes), but +the **underlying store is the existing config plane**: `updateTopology` mutations +land in `config/network/nGraphElements`. Confirmed by capture — the edge +`device0.1.10.out::device1.1.11.in` set to `weight: 1` via `updateTopology` +appears in `GET …/data/config/network/nGraphElements/**` with `weight: 1` and a +bumped `_rev`. So the collector is a **facade**: a status-namespace read/action +surface in front of the revisioned `nGraphElements` config store (§3.3). + +**Shared with existing apps** — the underlying store and its models, not the +collector wire shape: + +- The config store `nGraphElements` is **the same one `app.topology` already + reads and models** (`BaseDevice`, `IpVertex`, `UnidirectionalEdge`, + `codecVertex`). Inspect edits land there, revisioned with `_rev` (§3.3). +- REST v2 envelope, session/XSRF auth, pid/id formats +- Vertex ids (`device0.1.10.out`), edge keys (`fromId::toId`) +- `descriptor` / `fDescriptor` objects, `sa` / `severity` status semantics +- Device positions as float coordinates — `meta.coordinates` in the collector + aggregate, `maps[].x/y` in `nGraphElements`, same *Inspect Topology* format as + `inspect_app_format` in the topology app + +**Net-new for `app.inspect`:** + +| Layer | Responsibility | +| ----- | -------------- | +| Collector read/parse | Model and parse `data.status.collector` | +| Change-set / commit write | Assemble `updateTopology` payloads, handle validation responses | +| Live / events | WebSocket subscriptions for status deltas | + +Inventory onboarding stays on the existing path (`config/devman/devices`, +`/api/updateDevices`). `app.topology` and `app.inventory` remain unchanged; +`app.inspect` is additive. + +## 3. Domain model + +How Inspect concepts map onto existing package concepts: + +| Inspect concept | Inspect API (collector) | Existing model / app | +| ---------------------- | ------------------------------------------------------ | --------------------------------------------- | +| Device (inventory) | Prerequisite — not part of collector; onboard via `config/devman/devices` | `InventoryDevice` (`apps/inventory`) | +| Device (topology node) | Read: `collector.inspect.nodeStatus`; stored as `baseDevice` in `nGraphElements` | `TopologyDevice` (`apps/topology`) — store reuses existing model | +| Vertices / Edges | Read: `nodeStatus` `vertexInfo` / `externalEdgesByDeviceKey`; stored as `ipVertex` / `codecVertex` / `unidirectionalEdge` in `nGraphElements` | `BaseDevice`, `IpVertex`, `UnidirectionalEdge` — store reuses existing models | +| Change set / commit | `POST …/actions/status/collector/updateTopology` → writes `nGraphElements` ([ADR-0006](./decisions/0006-commit-write-model.md)) | _commit flow net-new; target store is existing `nGraphElements`_ | +| Services / paths | `collector.inspect.paths`, `pathDescriptions` on nodes/edges | _none — net-new_ | +| Device / edge status | Embedded in collector (`status`, `sa`/`severity`, bandwidth, PTP) | `inventory.model.device_status`, `status/network/*` — partial overlap | +| Sync status | `syncSeverity` on nodeStatus items | `TopologySynchronize` via `status/network/nGraphSyncStatus` | +| Live events | WebSocket delta subscriptions | _none — net-new_ | +| Connections / Partial connections | **[VERIFY]** — may relate to `pathDescriptions` / bookings | _none yet_ | + +### 3.1 Collector aggregate — primary read surface + +Inspect reads a single server-built aggregate under the `collector` namespace +— the same namespace used for writes +(`actions/status/collector/updateTopology`, [ADR-0006](./decisions/0006-commit-write-model.md)): + +| Item | Value | +| ---- | ----- | +| Method / path | `GET /rest/v2/data/status/collector/**` | +| Root | `data.status.collector` | +| List shape | Collections use `_items[]` entries with `_id` / `_vid` | + +Top-level sections under `data.status.collector`: + +| Section | Purpose | +| ------- | ------- | +| `inspect.nodeStatus` | Topology nodes: devices → modules → ports, with live status, `meta.coordinates`, `vertexInfo`, and embedded `pathDescriptions` | +| `inspect.paths` | Service/path list: booking segments, endpoint labels, aggregated `serviceFields` | +| `externalEdgesByDeviceKey` | Inter-device link status grouped by device pair; `primary` / `secondary` each hold edges keyed by `"fromId::toId"` | +| `maintenanceBookings` | Maintenance bookings (empty in capture) | +| `security` | Security context for devices, profiles, matrices, … | +| `superProfiles` | Routing profiles | +| `tagInfo` | Tag → profile mappings | + +**ID conventions** (consistent across read and write): + +| Concept | Example | Notes | +| ------- | ------- | ----- | +| Device | `device0` | `deviceId`, `pid`, `_id` on nodeStatus items | +| Module pid | `device0.dev.1` | Nested under `modules` | +| Port pid | `device0.dev.1.10` | Nested under `ports` | +| Vertex id | `device0.1.10.out` | Shorter form in `vertexInfo`; used in edge keys | +| Edge id | `device0.1.10.out::device1.1.11.in` | Same key as `replaceEdges` in `updateTopology` | +| Device pair | `device0::device1` | Key for `externalEdgesByDeviceKey` items | +| Service / path | `100001::main` | `bookingId` + path role; appears in `pathDescriptions` and `inspect.paths` | +| Resource id | `device:device0.dev.1.1` | Prefixed resource references | +| Topo endpoint ref | `topo:device0.1.1` | Used in `serviceFields.from` / `.to` | + +**`vertexInfo`** on ports describes topology vertices: + +- `type: "single"` — one vertex (`id`, `vertexType`: `"In"` / `"Out"`, `fields`: + `isActive`, `isControlled`, `isEndpoint`) +- `type: "double"` — bidirectional port with separate `in` / `out` ids and labels + +**Drill-down / service linkage:** `pathDescriptions` on ports and edges embed +both `deviceLevel` (local hop: input → output within a device) and +`serviceLevel` (end-to-end service: `bookingId`, `serviceLabel`, `fromStatus` / +`toStatus`, `isMain`, `serviceStatus`). This is how the UI connects topology +elements to booked services — e.g. booking `100001` `"Encoder 1.1 -> Decoder 1.5"` +traverses `device0.1.10.out::device1.1.11.in`. + +**Edge live status** (`externalEdgesByDeviceKey`): each edge carries +`bandwidth`, `fromStatus` / `toStatus`, `pathDescriptions`, and aggregate +`status` (`alarm`, `bandwidth`, `maintenance`, `ptp` severities). + +**Node live status** (`inspect.nodeStatus`): hierarchical `status` with +`sa` / `severity` at device, module, port; plus `ptpDeviceStatus`, `syncSeverity`, +`hasEndpoints`, domains, tags. + +**Package implications:** + +- `app.inspect` uses `GET …/data/status/collector/**` as its canonical read + entry point. +- Edge keys and vertex ids from reads map directly onto `updateTopology` write + payloads. +- Commit validation references the same `bookingId`s visible in + `pathDescriptions` (e.g. failed delete for booking `100001` / main path edge). +- **[VERIFY]** exact sub-paths behind the `/**` wildcard, projection/filter + support, and pagination for large topologies. + +### 3.2 API planes — existing apps vs. Inspect + +The VideoIPath backend separates **config** (mutable, revisioned) and +**status** (read-only, subscription-friendly) +([Public API 2025 LTS](https://documenter.getpostman.com/view/11222813/2sBXihpCS8#intro)). +Inspect's `collector` API entry points sit under `status`, but its topology +edits resolve to the **config** plane (`nGraphElements`): + +| Plane | Used by | Read | Write | +| ----- | ------- | ---- | ----- | +| Config | `app.topology`, `app.inventory`, **Inspect (effective store)** | `GET …/data/config/…` | `PATCH …/data/config/…` (revisioned) or RPC | +| Status | Inventory status reads | `GET …/data/status/…` | — | +| Collector (facade) | `app.inspect` | `GET …/data/status/collector/**` (composed aggregate) | `POST …/actions/status/collector/updateTopology` → `nGraphElements` | + +So Inspect's read aggregate is status-namespace and net-new in shape, but its +writes are commit-time-validated bulk actions that land in the revisioned +`config/network/nGraphElements` store (§3.3). The client gathers edits locally +until commit; server-side staging / change-set ids are **[VERIFY]** +([ADR-0006](./decisions/0006-commit-write-model.md)). + +Status-plane **WebSocket subscriptions** deliver event-based deltas — the live +update channel for Inspect ([ADR-0003](./decisions/0003-websocket-subscriptions.md)). + +This framing underpins the API-paradigm and loading decisions +([ADR-0001](./decisions/0001-api-paradigm.md), +[ADR-0002](./decisions/0002-loading-and-state.md)). + +### 3.3 Config store — `nGraphElements` (write target) + +`GET /rest/v2/data/config/network/nGraphElements/**` → +`data.config.network.nGraphElements._items[]`. This is the **revisioned source +of truth** for topology that Inspect's `updateTopology` writes into, and it is +already modelled by `app.topology`. + +| Field | Notes | +| ----- | ----- | +| `_id` / `_vid` | Element id; edges use the `fromId::toId` key (same as collector and `replaceEdges`) | +| `_rev` | CouchDB-style revision `N-` for optimistic concurrency | +| `type` | Element kind (see below) | +| `descriptor` / `fDescriptor` | User label/desc vs. fallback (device-reported) label/desc | + +Element `type` values seen in capture: + +| `type` | Represents | Key fields | +| ------ | ---------- | ---------- | +| `baseDevice` | Topology device node | `maps[]` (`cType: "Topology"`, integer `x`/`y`), `iconType`, `sdpStrategy`, `isVirtual` | +| `codecVertex` | Codec/SDI endpoint vertex | `vertexType` (`In`/`Out`), `codecFormat`, `useAsEndpoint`, `control`, SIPS/SDP fields | +| `ipVertex` | Ethernet/IP port vertex (`.in` / `.out`) | `vertexType`, `gpid.pointId`, `supports*Cfg` capability flags | +| `unidirectionalEdge` | Directed link/route between vertices | `fromId`, `toId`, `weight`, `capacity`, `bandwidth`, `redundancyMode`, `weightFactors`, `conflictPri` | + +**Write round-trip confirmed:** the edge `device0.1.10.out::device1.1.11.in` +edited via `updateTopology` (ADR-0006 example, `weight: 1`) is present here with +`weight: 1` and `_rev: "3-…"`, and inter-device links appear as paired +unidirectional edges (`device0.1.10.out::device1.1.11.in` and +`device1.1.11.out::device0.1.10.in`, each `capacity: 65535`). Internal +fan-out edges (vertex→vertex within a device) use `capacity: 1`. + +**Implication:** Inspect's underlying topology model is the existing +`nGraphElements`, so the package's `BaseDevice` / `IpVertex` / +`UnidirectionalEdge` / codec-vertex models are reusable for the **store**, even +though the **collector read aggregate** (§3.1) has its own status-oriented +shape. **[VERIFY]** whether `updateTopology` performs `_rev` checks server-side +or last-writer-wins. + +## 4. How the transport works today (recap) + +So the new layer fits the existing patterns rather than reinventing them: + +- `connector/` is a thin sync HTTP client built on `requests`, with two + sub-connectors: REST v2 (`GET`/`PATCH`/`POST`) and RPC (`POST /api/*`). Basic + auth, gzip, per-method timeouts. +- Each connector enforces an **allow-list of URL prefixes** (`ALLOWED_URLS` / + `ALLOWED_EXACT_MATCHES`). New Inspect endpoints must be added there. +- Responses are wrapped in a common envelope (`ResponseV2Get`/`…Patch`/`…Post`) + with a `header` (`code`, `auth`, …) and a `data`/`result` body, validated by + Pydantic. +- Apps are **lazy-loaded** off `VideoIPathApp` and are **stateless**: every call + re-fetches from the server (e.g. `topology.get_device` issues several `GET`s + and rebuilds the aggregate each time). + +## 5. Endpoint discovery — how to fill in the **[VERIFY]** gaps + +Two complementary sources: the **official reference** (authoritative for the +documented surface) and **browser capture** (authoritative for what the Inspect +GUI actually does, including undocumented calls and WebSocket frames). + +0. **Start from the official reference.** The + [VideoIPath Public API 2025 LTS](https://documenter.getpostman.com/view/11222813/2sBXihpCS8#intro) + collection documents `Connections`, `Partial Connections`, and + `Import and Export (preview)`, plus the `config` / `status` / `experimental` + top-level split. Use it as the primary map of the documented surface. + > Inspect uses `/rest/v2/` for collector read/write. Other resources may + > still use v1 paths documented in the public reference — **[VERIFY]** per + > resource during capture. +1. **Capture browser traffic.** Open the Inspect app in the VideoIPath web UI + with the browser DevTools → Network tab. Record a full session (HAR export): + open a device, connect two devices, **commit a change set**, drill into a + service, watch live updates. Note REST calls *and* the WebSocket (`WS` + filter) frames. +2. **Replay through the package logger.** The connector logs every response at + `DEBUG` (`_log_response`). Set `log_level="DEBUG"` and call candidate + endpoints to confirm payload shapes. +3. **Diff against known resources.** Compare captured paths to the prefixes the + package already allows. New prefixes ⇒ new Inspect resources. +4. **Save representative payloads as fixtures.** Store sanitised JSON under + `tests/fixtures/inspect//…`. These feed the offline tests in + [ADR-0005](./decisions/0005-e2e-testing.md) and double as living + documentation of the wire format. + +### 5.1 Discovery checklist (fill in as confirmed) + +REST: + +- [x] Base path: Inspect uses `/rest/v2/` for collector read/write (`data/status/collector/**`, `actions/status/collector/updateTopology`) +- [x] Service / monitoring aggregate: `GET /rest/v2/data/status/collector/**` → `data.status.collector` with `inspect.nodeStatus`, `inspect.paths`, `externalEdgesByDeviceKey`, `security`, `superProfiles`, `tagInfo` (§3.1) +- [x] Drill-down: services linked via `pathDescriptions` on ports/edges (`deviceLevel` + `serviceLevel`) and `inspect.paths._items[]` (`bookingId`, path segments, `serviceFields`) +- [ ] Collector sub-paths: exact URLs behind the `/**` wildcard; projection/filter/pagination support +- [ ] Connections / Partial Connections: resource paths, shapes, lifecycle +- [x] Change set / commit endpoint: `POST /rest/v2/actions/status/collector/updateTopology` — bulk delta with `replaceDevices`, `replaceVertices`, `replaceEdges` (key `"fromId::toId"`), `replaceResourceTransforms`, `addExternalEdges`, `remove`, `force` ([ADR-0006](./decisions/0006-commit-write-model.md)) +- [ ] Change set staging: server-side change-set id vs. client-only gather until commit +- [x] Commit failure response: check `data.res.ok` / `data.validation.result.ok` (not `header.ok`); `validation.details[id]` carries `status`, `rev`, `resolvable`, `type`; failed delete example: `"A required edge was not found. (main)"`, `items: []` ([ADR-0006](./decisions/0006-commit-write-model.md)) +- [ ] Commit success response: `data.items` shape when validation passes +- [ ] Commit semantics: partial apply after validation pass, discard/rollback of abandoned client-side change sets +- [ ] Import / Export (preview): scope and payload format +- [x] Write/action endpoint: `/rest/v2/actions/status/collector/updateTopology` (others under `…/actions/status/collector/*` still **[VERIFY]**) +- [x] Config store / write target: `updateTopology` lands in `GET /rest/v2/data/config/network/nGraphElements/**` (`_items[]`, `_rev`, `type` ∈ `baseDevice` / `codecVertex` / `ipVertex` / `unidirectionalEdge`); reuses existing topology models (§3.3) +- [ ] `_rev` handling on commit: does `updateTopology` enforce optimistic concurrency or last-writer-wins? +- [ ] Version gating: first VideoIPath version that exposes each endpoint + +WebSocket (see [ADR-0003](./decisions/0003-websocket-subscriptions.md)): + +- [ ] URL / scheme (`ws://` vs `wss://`, path, port) +- [ ] Auth handshake (Basic header, cookie/session, query token?) +- [ ] Subscribe / unsubscribe message format (which resources can be watched?) +- [ ] Event/patch frame format (full snapshot vs. delta/patch; ids & revisions) +- [ ] Heartbeat / keep-alive, server-side timeouts, reconnect & resync semantics +- [ ] Back-pressure / message ordering / dropped-update guarantees + +## 6. Open questions + +- **Collector sub-paths and scaling** — exact URLs behind `/**`; projection, + filtering, and pagination for large topologies + ([ADR-0002](./decisions/0002-loading-and-state.md)). +- **WebSocket scope** — generic subscription to any `status/...` path, or + Inspect-specific streams? ([ADR-0003](./decisions/0003-websocket-subscriptions.md)) +- **Commit model** ([ADR-0006](./decisions/0006-commit-write-model.md)) — + server-side change-set id vs. client-only gather; successful response shape + (`data.items`); all-or-nothing apply after validation; meaning of validation + `status` codes and `resolvable: true` cases. +- **Commit concurrency** — `updateTopology` writes bump `nGraphElements` `_rev` + (§3.3); does the action enforce `_rev` checks or last-writer-wins? +- **Connections / Partial Connections** — relationship to `pathDescriptions`, + bookings, and the Public API `Connections` resources. diff --git a/docs/inspect-app/decisions/0001-api-paradigm.md b/docs/inspect-app/decisions/0001-api-paradigm.md new file mode 100644 index 0000000..a7aa344 --- /dev/null +++ b/docs/inspect-app/decisions/0001-api-paradigm.md @@ -0,0 +1,47 @@ +# ADR-0001: API paradigm — data-driven core + event layer + +> Status: **Proposed** +> Date: 2026-06-15 · Deciders: Paul Winterstein, Jonas Scholl + +## Context + +The package today is **data-driven**: the user fetches a configuration +aggregate (`InventoryDevice`, `TopologyDevice`), mutates the object locally, and +calls `update_*`, which diffs against the server state and `PATCH`es only the +changed elements (revision-checked). It is stateless and request/response based. + +Inspect adds a **monitoring** dimension (services, live status, drill-down) and +a **WebSocket** channel. Monitoring is naturally **event/observation-driven**: +the client reacts to changes it did not initiate. So the question is whether to: + +- keep everything data-driven (poll for status), or +- move to an event/action-driven model, or +- combine the two. + +This maps cleanly onto the config-plane vs. status-plane split described in +[concepts.md](../concepts.md#3-domain-model). + +## Options + +1. **Data-driven only (status via polling).** + - Pros: zero conceptual change; consistent with existing apps; trivial to + test; no new dependencies. + - Cons: polling is chatty and laggy for live monitoring; ignores the new + WebSocket capability that motivated the work. + +2. **Event/action-driven only.** + - Pros: best fit for live monitoring; matches Inspect's UX. + - Cons: poor fit for configuration CRUD (which is inherently + request/response + revisioned); large departure from existing apps; high + migration cost; harder to reason about for simple scripts. + +3. **Hybrid: data-driven CRUD for the config plane, event-driven observation for the status plane.** + - Pros: each plane uses the model that fits it; preserves the existing CRUD + surface and tests; isolates the new event machinery; lets users opt into + live features only when needed. + - Cons: two interaction styles in one app; must document clearly which + methods are "read/modify/write" vs. "subscribe/observe". + +## Decision + +_To be decided._ diff --git a/docs/inspect-app/decisions/0002-loading-and-state.md b/docs/inspect-app/decisions/0002-loading-and-state.md new file mode 100644 index 0000000..163e5f5 --- /dev/null +++ b/docs/inspect-app/decisions/0002-loading-and-state.md @@ -0,0 +1,57 @@ +# ADR-0002: Loading & state model + +> Status: **Proposed** +> Date: 2026-06-15 · Deciders: Paul Winterstein, Jonas Scholl + +## Context + +This ADR is about **how data is fetched from the VideoIPath API** — eager vs. +lazy, how much is pulled per call, and whether anything is cached client-side. +It is **not** about app-object construction; the lazy `app.inspect` property +(built on first access, like the other apps) is a separate, settled detail and +out of scope here. + +Existing apps are **stateless** and **fetch-on-demand**: each call re-reads from +the server and rebuilds objects. A single read can be chatty — e.g. +`topology.get_device` issues several `GET`s and reassembles the aggregate every +time. + +**Usage context matters:** this package is used for **automations, pipelines and +bulk operations** — typically short-lived, scripted, do-and-exit runs — not +long-lived dashboards or applications. That favours **predictability and +freshness** over client-side caching, and makes **efficient bulk reads** the +real performance concern. + +Two sub-questions follow: + +1. **Per read** — fetch the full aggregate eagerly, or lazily/narrowly? +2. **Across reads** — stay stateless, or cache client-side? + +## Options + +1. **Stateless, eager per-entity (status quo).** Each call fetches the full + aggregate for the requested entity. + - Pros: predictable, always fresh, no cache-invalidation, easy to test, + matches existing apps; yields one coherent object. + - Cons: chatty; pulls more than a bulk job often needs; N+1 when iterating + many entities. + +2. **Stateless + field/section projection & bulk helpers.** Default to eager per + entity, but let callers request only the fields/sections they need and offer + list-with-projection helpers. (REST v2 already supports projections, e.g. + `/id,rev,vid`, `/maps/0/x,y`.) + - Pros: keeps statelessness & freshness; cuts payload and request count for + pipelines/bulk ops; still simple to test. + - Cons: more method surface (projection params); caller must know which + sections it needs. + +3. **Client-side cache / long-lived snapshot.** Hydrate once and serve + subsequent reads locally (optionally refreshed via WebSocket). + - Pros: cheap repeated reads for a long-running process. + - Cons: stateful; staleness & cache-invalidation risk; consistency issues vs. + concurrent writers; little benefit for short-lived automations; harder to + test. + +## Decision + +_To be decided._ diff --git a/docs/inspect-app/decisions/0003-websocket-subscriptions.md b/docs/inspect-app/decisions/0003-websocket-subscriptions.md new file mode 100644 index 0000000..db3ecd1 --- /dev/null +++ b/docs/inspect-app/decisions/0003-websocket-subscriptions.md @@ -0,0 +1,45 @@ +# ADR-0003: WebSocket event subscriptions + +> Status: **Proposed** +> Date: 2026-06-15 · Deciders: Paul Winterstein, Jonas Scholl + +## Context + +Inspect adds a **WebSocket channel** for live status/event updates. The package +has no WebSocket client today; the connector layer is sync `requests`. We want +live updates without destabilising the synchronous CRUD core, and without +forcing every user into `asyncio` (see [ADR-0004](./0004-async-strategy.md)). + +The [Public API 2025 LTS](https://documenter.getpostman.com/view/11222813/2sBXihpCS8#intro) +reference **confirms** the high-level model: the `status` plane is "frequently +updated and a good candidate for subscriptions", and subscription handling uses +**event-based (delta) change reporting** from server to client. So the *shape* +(deltas over a persistent channel on the status plane) is settled; the **exact +protocol details** (URL, auth handshake, subscribe message, concrete frame +format, heartbeat, reconnect) remain **[VERIFY]** — capture them per +[concepts.md §5](../concepts.md#5-endpoint-discovery--how-to-fill-in-the-verify-gaps). + +## Options + +1. **Skip WebSocket; poll status endpoints.** + - Pros: no new dependency or concurrency; trivial. + - Cons: laggy, chatty, misses the feature's point; doesn't scale to many + watched entities. + +2. **Async-native WebSocket only** (e.g. `websockets`/`httpx-ws`), exposed via + `async`/`await` + async iterators. + - Pros: idiomatic, efficient, composes with other async I/O. + - Cons: forces asyncio on all consumers unless wrapped; bigger change now. + +3. **Sync-friendly WebSocket with a background thread** (e.g. + `websocket-client`) exposing callbacks / a blocking iterator / a `queue`. + - Pros: no asyncio required by users; drops into existing sync scripts. + - Cons: thread lifecycle management; not as clean for high-concurrency. + +4. **Both, behind one façade:** a transport-agnostic subscription API with a + sync default (thread-backed) and an async implementation, sharing typed event + models. + +## Decision + +_To be decided._ diff --git a/docs/inspect-app/decisions/0004-async-strategy.md b/docs/inspect-app/decisions/0004-async-strategy.md new file mode 100644 index 0000000..8ec46f7 --- /dev/null +++ b/docs/inspect-app/decisions/0004-async-strategy.md @@ -0,0 +1,49 @@ +# ADR-0004: Async readiness & migration + +> Status: **Proposed** +> Date: 2026-06-15 · Deciders: Paul Winterstein, Jonas Scholl + +## Context + +The package is **sync**, built on `requests`. Two issues forces push toward async: + +1. **WebSocket subscriptions** ([ADR-0003](./0003-websocket-subscriptions.md)) + are naturally async. +2. **Bulk/concurrent I/O** (Inspect's combined data set, large topologies) is + far more efficient with concurrency. + +But there is an **existing sync user base and sync test suite**, and a published +PyPI package (`0.8.x`). We must not break sync users, and we should avoid a +risky big-bang rewrite. Python is `>=3.11`, so modern async primitives +(`TaskGroup`, `asyncio.timeout`) are available. + +Key structural fact in our favour: the **Pydantic models and the diff/patch +logic are I/O-agnostic**. Only the connector and the api/app methods are +I/O-bound. So async-ification is mostly a transport + method-coloring problem, +not a domain-logic rewrite. + +## Options + +1. **Stay sync-only; thread the WebSocket.** + - Pros: lowest risk; no breaking changes; ships now. + - Cons: no concurrency for bulk ops; doesn't future-proof. + +2. **Async-first, sync as a thin shim** (`asyncio.run` / run-in-thread wrappers). + - Pros: one source of truth (async); future-proof. + - Cons: breaking for current sync users; sync-over-async shims have real + footguns (event-loop reentrancy, nested loops in notebooks, thread-pool + surprises); large immediate change. + +3. **Dual-stack on a shared core:** migrate the transport to + `httpx` (one library, sync **and** async clients with the same API), keep the + Pydantic models + diff logic shared, and provide both a sync and an async API + surface. Maintain the two surfaces from one source using a code transform + (e.g. [`unasync`](https://github.com/python-trio/unasync)) rather than by + hand. + - Pros: sync users keep working; async users get first-class support; + WebSocket fits the async side; minimal duplicated logic. + - Cons: build-time codegen/tooling to set up; both surfaces must be tested. + +## Decision + +_To be decided._ diff --git a/docs/inspect-app/decisions/0005-e2e-testing.md b/docs/inspect-app/decisions/0005-e2e-testing.md new file mode 100644 index 0000000..4a7a900 --- /dev/null +++ b/docs/inspect-app/decisions/0005-e2e-testing.md @@ -0,0 +1,38 @@ +# ADR-0005: E2E testing strategy + +> Status: **Proposed** +> Date: 2026-06-15 · Deciders: Paul Winterstein, Jonas Scholl + +## Context + +Inspect spans config CRUD, status reads, and a WebSocket channel against a +proprietary server whose payloads vary by version. Tests must be **low-effort, +stable, and trustworthy** — i.e. they should fail only for real regressions, and +when green they should genuinely mean "this works against VideoIPath". + +Today the suite is small (validators) and a live server is configured via +`tests/.env.test` + `pytest-dotenv`. There is precedent for "validate our +assumptions against the live server": `advanced_driver_schema_check` compares +local vs. server driver schemas. + +The tension: a **live-only** suite is the most trustworthy but is slow, flaky, +stateful, and needs infrastructure; a **mock-only** suite is fast and stable but +only tests our assumptions, not reality. + +## Options + +- **Live server only** — trustworthy, but flaky/stateful/slow; bad for CI on + every push. +- **Recorded HTTP interactions** (`respx` for `httpx` / `vcrpy` for `requests`) + — record real request/response pairs once, replay offline. Fast, stable, + realistic — as long as cassettes are periodically re-recorded. +- **Fake VideoIPath server** (small FastAPI app emulating endpoints + WS) — more + setup, but enables deterministic **end-to-end incl. WebSocket** flows that + cassettes can't easily replay. +- **Fixture/contract tests** — assert our Pydantic models parse real captured + payloads and our diff logic produces expected patches. Cheap, deterministic, + catches model drift. + +## Decision + +_To be decided._ diff --git a/docs/inspect-app/decisions/0006-commit-write-model.md b/docs/inspect-app/decisions/0006-commit-write-model.md new file mode 100644 index 0000000..b47e00d --- /dev/null +++ b/docs/inspect-app/decisions/0006-commit-write-model.md @@ -0,0 +1,229 @@ +# ADR-0006: Commit-style write model (change sets) + +> Status: **Proposed** +> Date: 2026-06-18 · Deciders: Paul Winterstein, Jonas Scholl + +## Context + +Inspect applies configuration changes with a **commit-style** model: when the +operator creates, edits, or deletes topology elements / connections, the actions +are first **gathered into a change set** and then **committed together**, rather +than each change hitting the server immediately. + +This differs from the existing apps. Today the write path is **immediate and +per-call**: + +- Topology: `get_device` → mutate locally → `update_device` diffs against the + server and applies the changes with a revisioned + `PATCH /rest/v2/data/config/network/nGraphElements` (mode `strict`, `_rev` + checked). +- Inventory: `add_device` / `update_device` go through the RPC + `/api/updateDevices` call. + +There is **no commit, transaction, or staging concept** in the package today +(the topology "staged device" is only an in-memory diff input, not a server-side +batch). So Inspect's commit behaviour is a **net-new write path**, not a reuse of +the existing one. + +### Verified wire format (browser capture, 2026-06-18) + +Inspect applies topology edits with a **single bulk action** — not the existing +revisioned `PATCH …/data/config/network/nGraphElements` path: + +| Item | Value | +| ---- | ----- | +| Method / path | `POST /rest/v2/actions/status/collector/updateTopology` | +| Auth | Session cookies (`VipathSession`, …) + `XSRF-TOKEN` cookie and matching `x-xsrf-token` header | +| Envelope | Standard v2 `{ "header": { "id": 0 }, "data": { … } }` | + +The `data` object carries the **entire delta** in one request. Empty maps / +arrays mean “no change” for that category: + +| Field | Shape | Purpose | +| ----- | ----- | ------- | +| `replaceDevices` | `Record` | Upsert devices | +| `replaceVertices` | `Record` | Upsert vertices | +| `replaceEdges` | `Record<"fromId::toId", edge>` | Upsert edges (composite key) | +| `replaceResourceTransforms` | `Record` | Upsert resource transforms | +| `addExternalEdges` | `edge[]` | Add external edges | +| `remove` | `string[]` | Remove entities by id | +| `force` | `boolean` | Force apply (example used `false`) | + +**Edge key:** `"::"` — e.g. +`device0.1.10.out::device1.1.11.in`. + +**Minimal example** (set edge `weight` to `1`; other categories empty): + +```json +{ + "header": { "id": 0 }, + "data": { + "replaceDevices": {}, + "replaceVertices": {}, + "replaceEdges": { + "device0.1.10.out::device1.1.11.in": { + "fromId": "device0.1.10.out", + "toId": "device1.1.11.in", + "descriptor": { "label": "Eth 1.10 (out) -> Eth 1.11 (in)", "desc": "" }, + "fDescriptor": { "label": "", "desc": "" }, + "tags": [], + "active": true, + "weight": 1, + "capacity": 65535, + "bandwidth": -1, + "weightFactors": { + "bandwidth": { "weight": 0 }, + "service": { "weight": 0, "max": 100 } + }, + "redundancyMode": "Any", + "conflictPri": 0, + "includeFormats": [], + "excludeFormats": [] + } + }, + "replaceResourceTransforms": {}, + "addExternalEdges": [], + "remove": [], + "force": false + } +} +``` + +This confirms the **gather-then-commit** mental model at the API boundary: the +Inspect UI accumulates edits client-side, then sends one `updateTopology` payload +with populated `replace*` / `add*` / `remove` sections. Reads use the matching +**collector** namespace: `GET …/data/status/collector/**` returns the composed +topology + status + services tree ([concepts.md §3.1](../concepts.md#31-collector-aggregate--primary-inspect-read-surface)). +Edge keys (`fromId::toId`) and vertex ids are consistent between read and write. + +#### Response shape — failed commit (browser capture, 2026-06-18) + +**Important:** HTTP / envelope success is **not** commit success. On a failed +delete-device attempt the top-level `header` still reported success: + +| Field | Failed-commit value | Meaning | +| ----- | ------------------- | ------- | +| `header.ok` | `true` | Transport / auth succeeded | +| `header.code` | `"OK"` | Envelope accepted | +| `data.res.ok` | `false` | Commit rejected | +| `data.res.msg` | `["Validation failed"]` | High-level failure reason | +| `data.validation.result.ok` | `false` | Validation gate failed | +| `data.validation.result.msg` | e.g. `["A required edge was not found. (main)"]` | Actionable validation message | +| `data.items` | `[]` | No applied changes returned | + +Per-entity validation details live in `data.validation.details`, keyed by id +(e.g. `"100001"`): + +| Field | Example | Notes | +| ----- | ------- | ----- | +| `status` | `-22` | Server-specific error code | +| `rev` | `"2-2026-06-15T13:03:50.631612311Z[UTC]"` | Entity revision at validation time | +| `resolvable` | `false` | Whether the client can fix/retry in-place | +| `type` | `"generic"` | Error category | +| `isCancel` / `isProduct` | `false` | Flags on the validation item | + +```json +{ + "header": { + "auth": true, + "caption": "Operation Successful", + "code": "OK", + "ok": true, + "user": "sysadmin" + }, + "data": { + "items": [], + "res": { + "msg": ["Validation failed"], + "ok": false + }, + "validation": { + "createIds": [], + "details": { + "100001": { + "isCancel": false, + "isProduct": false, + "resolvable": false, + "rev": "2-2026-06-15T13:03:50.631612311Z[UTC]", + "status": -22, + "type": "generic" + } + }, + "result": { + "msg": ["A required edge was not found. (main)"], + "ok": false + } + } + } +} +``` + +The package must treat a commit as failed when `data.res.ok` or +`data.validation.result.ok` is `false`, even if `header.ok` is `true`. Revisions +appear on validation detail entries (`rev`), not as a top-level `_rev` conflict +like the existing `PATCH nGraphElements` path — **[VERIFY]** whether revision +mismatch surfaces the same way on other failure modes. + +### Write target — the `nGraphElements` config store (confirmed) + +`updateTopology` is a status-namespace **action**, but it persists to the +existing **`config/network/nGraphElements`** store. Confirmed by capture: the +edge `device0.1.10.out::device1.1.11.in` set to `weight: 1` via this action +appears in `GET /rest/v2/data/config/network/nGraphElements/**` with `weight: 1` +and a bumped `_rev` (`3-…`). Elements are revisioned (`_rev` = `N-`) +and typed (`baseDevice`, `codecVertex`, `ipVertex`, `unidirectionalEdge`) — the +same model `app.topology` already uses. Inter-device links persist as **paired +unidirectional edges** (`a::b` and `b::a`, `capacity: 65535`); the failed-delete +error *"A required edge was not found. (main)"* refers to this edge model. See +[concepts.md §3.3](../concepts.md#33-config-store--ngraphelements-write-target). + +This means the change-set write layer does **not** need new topology element +models — it can reuse the existing ones for the persisted form, while the +collector read aggregate keeps its own status-oriented shape. **[VERIFY]** +whether `updateTopology` enforces `_rev` optimistic concurrency or is +last-writer-wins. + +What remains **[VERIFY]**: + +- Whether the server exposes a **separate staging / change-set id** API, or + staging is purely client-side until this POST. +- **Successful** commit response shape (`data.items` contents when `ok: true`). +- Whether multi-category edits that pass validation can still **partially apply** + (the failed example returned empty `items`, suggesting reject-before-apply). +- Other `…/actions/status/collector/*` endpoints (discard, validate-only, …). +- Meaning of validation `status` codes (e.g. `-22`) and when `resolvable` is + `true`. +- Cross-check against the + [Public API 2025 LTS](https://documenter.getpostman.com/view/11222813/2sBXihpCS8#intro) + (`Connections` / `Partial Connections` / `Import and Export`). + +## Options + +1. **Auto-commit per call (hide the change set).** Each `add/update/delete` + transparently opens a change set, applies one action, and commits — mimicking + the existing immediate-write UX. + - Pros: familiar; matches the current topology/inventory mental model; simple + for one-off scripts. + - Cons: defeats the point of batching (atomic multi-element changes, fewer + round-trips, single validation/commit); doesn't expose commit-time conflict + handling; may not even match how the server expects connections to be built. + +2. **Explicit change set only.** Force callers to open a change set, stage + actions, then commit (or discard). + - Pros: faithful to the server model; enables atomic multi-step edits and a + single pre-commit validation; explicit conflict/rollback handling. + - Cons: more ceremony for the trivial single-change case; easy to leak an + un-committed change set. + +3. **Hybrid: explicit change set with a convenience auto-commit default.** + Provide a first-class change-set object (ideally a context manager) for + batched, atomic edits, *and* a convenience path where a single + `add/update/delete` auto-commits when no change set is open. + - Pros: ergonomic for both one-off scripts and batched pipeline edits; + exposes commit/validate/discard when needed; degrades to simple usage. + - Cons: two usage styles to document; must define what happens to an open + change set on error / context exit. + +## Decision + +_To be decided._ diff --git a/docs/inspect-app/decisions/README.md b/docs/inspect-app/decisions/README.md new file mode 100644 index 0000000..987859e --- /dev/null +++ b/docs/inspect-app/decisions/README.md @@ -0,0 +1,49 @@ +# Architecture Decision Records — Inspect App + +Each ADR captures the **context, research, and options** for one architectural +question. Once a choice is made, the **Decision** and **Consequences** sections +are filled in and the status moves to **Accepted**. ADRs are immutable once +accepted — to change a decision, add a new ADR that supersedes the old one +(update the `Status` line of both). + +## Status legend + +- **Proposed** — context and options documented; decision not yet made. +- **Accepted** — agreed; implement accordingly. +- **Superseded by ADR-XXXX** — replaced; kept for history. +- **Deprecated** — no longer relevant. + +## Index + +| ADR | Title | Status | +| ----------------------------------------------------- | ------------------------------------ | -------- | +| [0001](./0001-api-paradigm.md) | API paradigm: data-driven + events | Proposed | +| [0002](./0002-loading-and-state.md) | Loading & state model | Proposed | +| [0003](./0003-websocket-subscriptions.md) | WebSocket event subscriptions | Proposed | +| [0004](./0004-async-strategy.md) | Async readiness & migration | Proposed | +| [0005](./0005-e2e-testing.md) | E2E testing strategy | Proposed | +| [0006](./0006-commit-write-model.md) | Commit-style write model (change sets) | Proposed | + +## Template + +```markdown +# ADR-XXXX: + +> Status: Proposed | Accepted | Superseded by ADR-YYYY +> Date: YYYY-MM-DD · Deciders: + +## Context +What problem are we solving? What constraints apply (existing code, users, +versions)? + +## Options +1. Option A — pros / cons +2. Option B — pros / cons +3. ... + +## Decision +_To be decided._ (fill in once a choice is made) + +## Consequences +_Add once a decision is made._ +``` From 602879e0be1dd607d17660949c7c9c2bb1368262 Mon Sep 17 00:00:00 2001 From: Jonas Scholl <43819845+JonasScholl@users.noreply.github.com> Date: Thu, 25 Jun 2026 07:12:24 +0200 Subject: [PATCH 02/34] model draft --- docs/{ => future}/domain-architecture.md | 0 docs/inspect-app/README.md | 8 +- docs/inspect-app/concepts.md | 162 +-- .../decisions/0001-api-paradigm.md | 40 +- .../decisions/0002-loading-and-state.md | 21 +- .../decisions/0003-websocket-subscriptions.md | 31 +- .../decisions/0004-async-strategy.md | 41 +- .../inspect-app/decisions/0005-e2e-testing.md | 34 +- .../decisions/0006-commit-write-model.md | 40 +- docs/inspect-app/decisions/README.md | 16 +- docs/inspect-app/endpoints.md | 974 ++++++++++++++++++ docs/inspect-app/models.md | 602 +++++++++++ .../apps/__init__.py | 1 + .../apps/inspect/__init__.py | 3 + .../apps/inspect/domain/__init__.py | 11 + .../apps/inspect/domain/device.py | 71 ++ .../apps/inspect/domain/edge.py | 77 ++ .../apps/inspect/domain/port.py | 76 ++ .../apps/inspect/domain/service.py | 113 ++ .../apps/inspect/model/__init__.py | 5 + .../apps/inspect/model/actions.py | 102 ++ .../apps/inspect/model/collector.py | 264 +++++ .../apps/inspect/model/common.py | 87 ++ .../apps/inspect/model/ngraph.py | 147 +++ .../apps/inspect/model/update_topology.py | 88 ++ .../apps/inspect/snapshot.py | 408 ++++++++ 26 files changed, 3285 insertions(+), 137 deletions(-) rename docs/{ => future}/domain-architecture.md (100%) create mode 100644 docs/inspect-app/endpoints.md create mode 100644 docs/inspect-app/models.md create mode 100644 src/videoipath_automation_tool/apps/inspect/__init__.py create mode 100644 src/videoipath_automation_tool/apps/inspect/domain/__init__.py create mode 100644 src/videoipath_automation_tool/apps/inspect/domain/device.py create mode 100644 src/videoipath_automation_tool/apps/inspect/domain/edge.py create mode 100644 src/videoipath_automation_tool/apps/inspect/domain/port.py create mode 100644 src/videoipath_automation_tool/apps/inspect/domain/service.py create mode 100644 src/videoipath_automation_tool/apps/inspect/model/__init__.py create mode 100644 src/videoipath_automation_tool/apps/inspect/model/actions.py create mode 100644 src/videoipath_automation_tool/apps/inspect/model/collector.py create mode 100644 src/videoipath_automation_tool/apps/inspect/model/common.py create mode 100644 src/videoipath_automation_tool/apps/inspect/model/ngraph.py create mode 100644 src/videoipath_automation_tool/apps/inspect/model/update_topology.py create mode 100644 src/videoipath_automation_tool/apps/inspect/snapshot.py diff --git a/docs/domain-architecture.md b/docs/future/domain-architecture.md similarity index 100% rename from docs/domain-architecture.md rename to docs/future/domain-architecture.md diff --git a/docs/inspect-app/README.md b/docs/inspect-app/README.md index 49c21c2..44c7bbb 100644 --- a/docs/inspect-app/README.md +++ b/docs/inspect-app/README.md @@ -24,9 +24,13 @@ reference is now a primary source. Nothing here is implemented yet; this is the (the `[VERIFY]` items to confirm against a real server). Cross-references the [Public API 2025 LTS](https://documenter.getpostman.com/view/11222813/2sBXihpCS8#intro) reference. -2. **[decisions/](./decisions/)** — the architecture decisions (ADRs), one per +2. **[models.md](./models.md)** — practical model guide for transport `InspectApi*` + DTOs, `InspectSnapshot`, and user-facing domain objects such as `InspectDevice`. +3. **[endpoints.md](./endpoints.md)** — anonymized endpoint reference with + concrete request and response shapes captured from a local test instance. +4. **[decisions/](./decisions/)** — the architecture decisions (ADRs), one per topic. Start with [the index](./decisions/README.md). -3. **[implementation-plan.md](./implementation-plan.md)** — phased, non-breaking +5. **[implementation-plan.md](./implementation-plan.md)** — phased, non-breaking rollout, target package layout, milestones, and risks. > **Wider context:** the package-wide re-think that grew out of this Inspect diff --git a/docs/inspect-app/concepts.md b/docs/inspect-app/concepts.md index e368bfc..8c076c0 100644 --- a/docs/inspect-app/concepts.md +++ b/docs/inspect-app/concepts.md @@ -1,6 +1,6 @@ # Inspect App — Concepts & Technical Model -> Status: **Draft** · Last updated: 2026-06-18 +> Status: **Draft** · Last updated: 2026-06-25 > > This document captures what the *Inspect* app is, how it maps onto the > existing package, and which technical details still need to be verified @@ -16,13 +16,16 @@ the operator to perform high-level monitoring of services combined with the ability to drill-down and inspect details to pinpoint service-affecting problems."* -In recent VideoIPath releases the Inspect app **replaces the Topology app**: it -becomes the entry point for building the network connectivity model (vertices, -edges, device placement), connecting devices, and watching their live -operational status — including **WebSocket event subscriptions** for live -updates. Inspect applies configuration changes with a **commit-style** model: -create/edit/delete actions are gathered into a change set and committed together -(see [ADR-0006](./decisions/0006-commit-write-model.md)). +In recent VideoIPath releases the Inspect app **replaces the Topology app** in +the product UI: it becomes the entry point for building the network connectivity +model (vertices, edges, device placement), connecting devices, and watching +operational status. The server exposes live update capabilities, but this +package deliberately stays request/response only (see +[ADR-0001](./decisions/0001-api-paradigm.md) and +[ADR-0003](./decisions/0003-websocket-subscriptions.md)). Inspect applies +configuration changes with a **commit-style** model: create/edit/delete actions +are gathered into a client-side change set and committed together (see +[ADR-0006](./decisions/0006-commit-write-model.md)). Inspect does **not** replace the **Inventory** app. Devices are still onboarded in Inventory first; only then can they be placed and connected in Inspect. So @@ -42,7 +45,7 @@ contract is mostly net-new** relative to what `app.topology` and **Reads** — one server-built aggregate: - `GET /rest/v2/data/status/collector/**` → `data.status.collector` (§3.1) -- Bundles topology nodes, inter-device edges, services/paths, live status, and +- Bundles topology nodes, inter-device edges, services/paths, status, and security context in a single tree with `_items[]` collections. **Writes** — one bulk action per commit: @@ -58,20 +61,21 @@ contract is mostly net-new** relative to what `app.topology` and **Namespace** — the `collector` API entry points live under `status` (`data/status/collector` for reads, `actions/status/collector` for writes), but the **underlying store is the existing config plane**: `updateTopology` mutations -land in `config/network/nGraphElements`. Confirmed by capture — the edge -`device0.1.10.out::device1.1.11.in` set to `weight: 1` via `updateTopology` -appears in `GET …/data/config/network/nGraphElements/**` with `weight: 1` and a +land in `config/network/nGraphElements`. Confirmed by capture — an anonymized +edge updated via `updateTopology` appeared in +`GET …/data/config/network/nGraphElements/**` with the changed value and a bumped `_rev`. So the collector is a **facade**: a status-namespace read/action surface in front of the revisioned `nGraphElements` config store (§3.3). -**Shared with existing apps** — the underlying store and its models, not the -collector wire shape: +**Shared with existing apps** — the underlying store and wire conventions, not +model classes: - The config store `nGraphElements` is **the same one `app.topology` already - reads and models** (`BaseDevice`, `IpVertex`, `UnidirectionalEdge`, - `codecVertex`). Inspect edits land there, revisioned with `_rev` (§3.3). + reads and models**. Inspect edits land there, revisioned with `_rev` (§3.3), + but the Inspect package keeps its own `InspectApi*` DTOs and does **not** import + or subclass topology/inventory model classes. - REST v2 envelope, session/XSRF auth, pid/id formats -- Vertex ids (`device0.1.10.out`), edge keys (`fromId::toId`) +- Vertex ids (`device-a.module-1.port-out-1.out`), edge keys (`fromId::toId`) - `descriptor` / `fDescriptor` objects, `sa` / `severity` status semantics - Device positions as float coordinates — `meta.coordinates` in the collector aggregate, `maps[].x/y` in `nGraphElements`, same *Inspect Topology* format as @@ -81,9 +85,9 @@ collector wire shape: | Layer | Responsibility | | ----- | -------------- | -| Collector read/parse | Model and parse `data.status.collector` | -| Change-set / commit write | Assemble `updateTopology` payloads, handle validation responses | -| Live / events | WebSocket subscriptions for status deltas | +| Collector read/parse | Parse `data.status.collector` with `InspectApi*` transport DTOs, then expose user-facing `InspectDevice` / `InspectService` objects via `InspectSnapshot` | +| Change-set / commit write | Assemble `updateTopology` payloads with `InspectApi*` DTOs, handle validation responses | +| Lookup / network actions | Model captured lookup, add-device, and sync-device action envelopes with `InspectApi*` DTOs | Inventory onboarding stays on the existing path (`config/devman/devices`, `/api/updateDevices`). `app.topology` and `app.inventory` remain unchanged; @@ -96,13 +100,13 @@ How Inspect concepts map onto existing package concepts: | Inspect concept | Inspect API (collector) | Existing model / app | | ---------------------- | ------------------------------------------------------ | --------------------------------------------- | | Device (inventory) | Prerequisite — not part of collector; onboard via `config/devman/devices` | `InventoryDevice` (`apps/inventory`) | -| Device (topology node) | Read: `collector.inspect.nodeStatus`; stored as `baseDevice` in `nGraphElements` | `TopologyDevice` (`apps/topology`) — store reuses existing model | -| Vertices / Edges | Read: `nodeStatus` `vertexInfo` / `externalEdgesByDeviceKey`; stored as `ipVertex` / `codecVertex` / `unidirectionalEdge` in `nGraphElements` | `BaseDevice`, `IpVertex`, `UnidirectionalEdge` — store reuses existing models | +| Device (topology node) | Read: `collector.inspect.nodeStatus`; stored as `baseDevice` in `nGraphElements` | Store shape overlaps with Topology, but Inspect uses `InspectApiBaseDevice` | +| Vertices / Edges | Read: `nodeStatus` `vertexInfo` / `externalEdgesByDeviceKey`; stored as `ipVertex` / `codecVertex` / `unidirectionalEdge` in `nGraphElements` | Store shape overlaps with Topology, but Inspect uses `InspectApi*` nGraph DTOs | | Change set / commit | `POST …/actions/status/collector/updateTopology` → writes `nGraphElements` ([ADR-0006](./decisions/0006-commit-write-model.md)) | _commit flow net-new; target store is existing `nGraphElements`_ | | Services / paths | `collector.inspect.paths`, `pathDescriptions` on nodes/edges | _none — net-new_ | | Device / edge status | Embedded in collector (`status`, `sa`/`severity`, bandwidth, PTP) | `inventory.model.device_status`, `status/network/*` — partial overlap | | Sync status | `syncSeverity` on nodeStatus items | `TopologySynchronize` via `status/network/nGraphSyncStatus` | -| Live events | WebSocket delta subscriptions | _none — net-new_ | +| Lookup / network actions | `lookupInspectDevice`, `lookupSyncInfo`, `addDevices`, `syncDevices` | request/response envelopes captured and modelled | | Connections / Partial connections | **[VERIFY]** — may relate to `pathDescriptions` / bookings | _none yet_ | ### 3.1 Collector aggregate — primary read surface @@ -133,15 +137,15 @@ Top-level sections under `data.status.collector`: | Concept | Example | Notes | | ------- | ------- | ----- | -| Device | `device0` | `deviceId`, `pid`, `_id` on nodeStatus items | -| Module pid | `device0.dev.1` | Nested under `modules` | -| Port pid | `device0.dev.1.10` | Nested under `ports` | -| Vertex id | `device0.1.10.out` | Shorter form in `vertexInfo`; used in edge keys | -| Edge id | `device0.1.10.out::device1.1.11.in` | Same key as `replaceEdges` in `updateTopology` | -| Device pair | `device0::device1` | Key for `externalEdgesByDeviceKey` items | -| Service / path | `100001::main` | `bookingId` + path role; appears in `pathDescriptions` and `inspect.paths` | -| Resource id | `device:device0.dev.1.1` | Prefixed resource references | -| Topo endpoint ref | `topo:device0.1.1` | Used in `serviceFields.from` / `.to` | +| Device | `device-a` | `deviceId`, `pid`, `_id` on nodeStatus items | +| Module pid | `device-a.dev.module-1` | Nested under `modules` | +| Port pid | `device-a.dev.module-1.port-out-1` | Nested under `ports` | +| Vertex id | `device-a.module-1.port-out-1.out` | Shorter form in `vertexInfo`; used in edge keys | +| Edge id | `device-a.module-1.port-out-1.out::device-b.module-1.port-in-1.in` | Same key as `replaceEdges` in `updateTopology` | +| Device pair | `device-a::device-b` | Key for `externalEdgesByDeviceKey` items | +| Service / path | `booking-1001::main` | `bookingId` + path role; appears in `pathDescriptions` and `inspect.paths` | +| Resource id | `device:device-a.dev.module-1.port-out-1` | Prefixed resource references | +| Topo endpoint ref | `topo:device-a.module-1.port-out-1` | Used in `serviceFields.from` / `.to` | **`vertexInfo`** on ports describes topology vertices: @@ -153,8 +157,7 @@ Top-level sections under `data.status.collector`: both `deviceLevel` (local hop: input → output within a device) and `serviceLevel` (end-to-end service: `bookingId`, `serviceLabel`, `fromStatus` / `toStatus`, `isMain`, `serviceStatus`). This is how the UI connects topology -elements to booked services — e.g. booking `100001` `"Encoder 1.1 -> Decoder 1.5"` -traverses `device0.1.10.out::device1.1.11.in`. +elements to booked services. **Edge live status** (`externalEdgesByDeviceKey`): each edge carries `bandwidth`, `fromStatus` / `toStatus`, `pathDescriptions`, and aggregate @@ -171,7 +174,8 @@ traverses `device0.1.10.out::device1.1.11.in`. - Edge keys and vertex ids from reads map directly onto `updateTopology` write payloads. - Commit validation references the same `bookingId`s visible in - `pathDescriptions` (e.g. failed delete for booking `100001` / main path edge). + `pathDescriptions` (e.g. failed delete for an anonymized booking / main path + edge). - **[VERIFY]** exact sub-paths behind the `/**` wildcard, projection/filter support, and pagination for large topologies. @@ -181,22 +185,27 @@ The VideoIPath backend separates **config** (mutable, revisioned) and **status** (read-only, subscription-friendly) ([Public API 2025 LTS](https://documenter.getpostman.com/view/11222813/2sBXihpCS8#intro)). Inspect's `collector` API entry points sit under `status`, but its topology -edits resolve to the **config** plane (`nGraphElements`): +edits resolve to the **config** plane (`nGraphElements`). Network action +endpoints under `actions/status/network/*` are also relevant for device add/sync +workflows: | Plane | Used by | Read | Write | | ----- | ------- | ---- | ----- | | Config | `app.topology`, `app.inventory`, **Inspect (effective store)** | `GET …/data/config/…` | `PATCH …/data/config/…` (revisioned) or RPC | -| Status | Inventory status reads | `GET …/data/status/…` | — | +| Status | Inventory status reads, Inspect status reads | `GET …/data/status/…` | — | | Collector (facade) | `app.inspect` | `GET …/data/status/collector/**` (composed aggregate) | `POST …/actions/status/collector/updateTopology` → `nGraphElements` | +| Network actions | `app.inspect` device topology workflows | — | `POST …/actions/status/network/addDevices`, `POST …/actions/status/network/syncDevices` | So Inspect's read aggregate is status-namespace and net-new in shape, but its writes are commit-time-validated bulk actions that land in the revisioned `config/network/nGraphElements` store (§3.3). The client gathers edits locally -until commit; server-side staging / change-set ids are **[VERIFY]** -([ADR-0006](./decisions/0006-commit-write-model.md)). +until commit; ADR-0006 accepts that there is no separate server-side change-set +id for the verified `updateTopology` flow. -Status-plane **WebSocket subscriptions** deliver event-based deltas — the live -update channel for Inspect ([ADR-0003](./decisions/0003-websocket-subscriptions.md)). +The server can expose status-plane subscriptions, but WebSockets are out of +scope for this package. Fresh status is obtained by explicit re-fetches +([ADR-0001](./decisions/0001-api-paradigm.md), +[ADR-0003](./decisions/0003-websocket-subscriptions.md)). This framing underpins the API-paradigm and loading decisions ([ADR-0001](./decisions/0001-api-paradigm.md), @@ -206,8 +215,9 @@ This framing underpins the API-paradigm and loading decisions `GET /rest/v2/data/config/network/nGraphElements/**` → `data.config.network.nGraphElements._items[]`. This is the **revisioned source -of truth** for topology that Inspect's `updateTopology` writes into, and it is -already modelled by `app.topology`. +of truth** for topology that Inspect's `updateTopology` writes into. Its wire +shape overlaps with the Topology app, but the Inspect package models it with +standalone `InspectApi*` DTOs. | Field | Notes | | ----- | ----- | @@ -225,19 +235,17 @@ Element `type` values seen in capture: | `ipVertex` | Ethernet/IP port vertex (`.in` / `.out`) | `vertexType`, `gpid.pointId`, `supports*Cfg` capability flags | | `unidirectionalEdge` | Directed link/route between vertices | `fromId`, `toId`, `weight`, `capacity`, `bandwidth`, `redundancyMode`, `weightFactors`, `conflictPri` | -**Write round-trip confirmed:** the edge `device0.1.10.out::device1.1.11.in` -edited via `updateTopology` (ADR-0006 example, `weight: 1`) is present here with -`weight: 1` and `_rev: "3-…"`, and inter-device links appear as paired -unidirectional edges (`device0.1.10.out::device1.1.11.in` and -`device1.1.11.out::device0.1.10.in`, each `capacity: 65535`). Internal -fan-out edges (vertex→vertex within a device) use `capacity: 1`. +**Write round-trip confirmed:** an anonymized edge edited via `updateTopology` +is present here with the changed value and a bumped `_rev`, and inter-device +links appear as paired unidirectional edges (one in each direction, typically +with `capacity: 65535`). Internal fan-out edges (vertex→vertex within a device) +use `capacity: 1`. -**Implication:** Inspect's underlying topology model is the existing -`nGraphElements`, so the package's `BaseDevice` / `IpVertex` / -`UnidirectionalEdge` / codec-vertex models are reusable for the **store**, even -though the **collector read aggregate** (§3.1) has its own status-oriented -shape. **[VERIFY]** whether `updateTopology` performs `_rev` checks server-side -or last-writer-wins. +**Implication:** Inspect's underlying topology store is `nGraphElements`, but +the package keeps a separate Inspect model namespace (`InspectApiBaseDevice`, +`InspectApiIpVertex`, `InspectApiUnidirectionalEdge`, …). Do not reuse topology app +model classes in Inspect DTOs. **[VERIFY]** whether `updateTopology` performs +`_rev` checks server-side or last-writer-wins. ## 4. How the transport works today (recap) @@ -253,13 +261,19 @@ So the new layer fits the existing patterns rather than reinventing them: Pydantic. - Apps are **lazy-loaded** off `VideoIPathApp` and are **stateless**: every call re-fetches from the server (e.g. `topology.get_device` issues several `GET`s - and rebuilds the aggregate each time). + and rebuilds the aggregate each time). Inspect should follow that pattern. +- Inspect models live in two layers: + - `apps/inspect/model` — `InspectApi*` transport DTOs for HTTP payloads + - `apps/inspect/domain` and `apps/inspect/snapshot.py` — user-facing read + models backed by a collector snapshot and internal indexes +- App/API methods should own fetching, staging, committing, and error handling. ## 5. Endpoint discovery — how to fill in the **[VERIFY]** gaps Two complementary sources: the **official reference** (authoritative for the documented surface) and **browser capture** (authoritative for what the Inspect -GUI actually does, including undocumented calls and WebSocket frames). +GUI actually does, including undocumented calls). WebSocket frames can be useful +product context, but they are out of scope for the package per ADR-0003. 0. **Start from the official reference.** The [VideoIPath Public API 2025 LTS](https://documenter.getpostman.com/view/11222813/2sBXihpCS8#intro) @@ -271,9 +285,8 @@ GUI actually does, including undocumented calls and WebSocket frames). > resource during capture. 1. **Capture browser traffic.** Open the Inspect app in the VideoIPath web UI with the browser DevTools → Network tab. Record a full session (HAR export): - open a device, connect two devices, **commit a change set**, drill into a - service, watch live updates. Note REST calls *and* the WebSocket (`WS` - filter) frames. + open a device, add/sync devices, connect two devices, **commit a change set**, + and drill into a service. Note REST calls and payloads. 2. **Replay through the package logger.** The connector logs every response at `DEBUG` (`_log_response`). Set `log_level="DEBUG"` and call candidate endpoints to confirm payload shapes. @@ -294,36 +307,29 @@ REST: - [ ] Collector sub-paths: exact URLs behind the `/**` wildcard; projection/filter/pagination support - [ ] Connections / Partial Connections: resource paths, shapes, lifecycle - [x] Change set / commit endpoint: `POST /rest/v2/actions/status/collector/updateTopology` — bulk delta with `replaceDevices`, `replaceVertices`, `replaceEdges` (key `"fromId::toId"`), `replaceResourceTransforms`, `addExternalEdges`, `remove`, `force` ([ADR-0006](./decisions/0006-commit-write-model.md)) -- [ ] Change set staging: server-side change-set id vs. client-only gather until commit +- [x] Change set staging: client-side gather until `updateTopology`; no separate server-side change-set id for the verified flow (ADR-0006) - [x] Commit failure response: check `data.res.ok` / `data.validation.result.ok` (not `header.ok`); `validation.details[id]` carries `status`, `rev`, `resolvable`, `type`; failed delete example: `"A required edge was not found. (main)"`, `items: []` ([ADR-0006](./decisions/0006-commit-write-model.md)) -- [ ] Commit success response: `data.items` shape when validation passes +- [x] Commit success response for no-op: `data.items: []`, `data.res.ok: true`, `data.validation.result.ok: true` ([endpoints.md](./endpoints.md#post-restv2actionsstatuscollectorupdatetopology)) +- [ ] Commit success response with real applied changes: `data.items` contents when validation passes and changes are applied - [ ] Commit semantics: partial apply after validation pass, discard/rollback of abandoned client-side change sets - [ ] Import / Export (preview): scope and payload format -- [x] Write/action endpoint: `/rest/v2/actions/status/collector/updateTopology` (others under `…/actions/status/collector/*` still **[VERIFY]**) -- [x] Config store / write target: `updateTopology` lands in `GET /rest/v2/data/config/network/nGraphElements/**` (`_items[]`, `_rev`, `type` ∈ `baseDevice` / `codecVertex` / `ipVertex` / `unidirectionalEdge`); reuses existing topology models (§3.3) +- [x] Write/action endpoint: `/rest/v2/actions/status/collector/updateTopology` +- [x] Collector action payloads captured and modelled: `/rest/v2/actions/status/collector/lookupInspectDevice`, `/rest/v2/actions/status/collector/lookupSyncInfo` +- [x] Network action request shapes, normal action responses, and validation-error responses captured: `/rest/v2/actions/status/network/addDevices`, `/rest/v2/actions/status/network/syncDevices` +- [x] Config store / write target: `updateTopology` lands in `GET /rest/v2/data/config/network/nGraphElements/**` (`_items[]`, `_rev`, `type` ∈ `baseDevice` / `codecVertex` / `ipVertex` / `unidirectionalEdge`); model with standalone `InspectApi*` DTOs (§3.3) - [ ] `_rev` handling on commit: does `updateTopology` enforce optimistic concurrency or last-writer-wins? - [ ] Version gating: first VideoIPath version that exposes each endpoint - -WebSocket (see [ADR-0003](./decisions/0003-websocket-subscriptions.md)): - -- [ ] URL / scheme (`ws://` vs `wss://`, path, port) -- [ ] Auth handshake (Basic header, cookie/session, query token?) -- [ ] Subscribe / unsubscribe message format (which resources can be watched?) -- [ ] Event/patch frame format (full snapshot vs. delta/patch; ids & revisions) -- [ ] Heartbeat / keep-alive, server-side timeouts, reconnect & resync semantics -- [ ] Back-pressure / message ordering / dropped-update guarantees +- [x] DTO coverage: add typed request/response models for captured lookup, action result, and validation-error endpoint payloads in [endpoints.md](./endpoints.md) ## 6. Open questions - **Collector sub-paths and scaling** — exact URLs behind `/**`; projection, filtering, and pagination for large topologies ([ADR-0002](./decisions/0002-loading-and-state.md)). -- **WebSocket scope** — generic subscription to any `status/...` path, or - Inspect-specific streams? ([ADR-0003](./decisions/0003-websocket-subscriptions.md)) - **Commit model** ([ADR-0006](./decisions/0006-commit-write-model.md)) — - server-side change-set id vs. client-only gather; successful response shape - (`data.items`); all-or-nothing apply after validation; meaning of validation - `status` codes and `resolvable: true` cases. + successful applied-change response shape (`data.items`); all-or-nothing apply + after validation; meaning of validation `status` codes and `resolvable: true` + cases. - **Commit concurrency** — `updateTopology` writes bump `nGraphElements` `_rev` (§3.3); does the action enforce `_rev` checks or last-writer-wins? - **Connections / Partial Connections** — relationship to `pathDescriptions`, diff --git a/docs/inspect-app/decisions/0001-api-paradigm.md b/docs/inspect-app/decisions/0001-api-paradigm.md index a7aa344..c123cdb 100644 --- a/docs/inspect-app/decisions/0001-api-paradigm.md +++ b/docs/inspect-app/decisions/0001-api-paradigm.md @@ -1,6 +1,6 @@ -# ADR-0001: API paradigm — data-driven core + event layer +# ADR-0001: API paradigm — data-driven, request/response -> Status: **Proposed** +> Status: **Accepted** > Date: 2026-06-15 · Deciders: Paul Winterstein, Jonas Scholl ## Context @@ -10,15 +10,16 @@ aggregate (`InventoryDevice`, `TopologyDevice`), mutates the object locally, and calls `update_*`, which diffs against the server state and `PATCH`es only the changed elements (revision-checked). It is stateless and request/response based. -Inspect adds a **monitoring** dimension (services, live status, drill-down) and -a **WebSocket** channel. Monitoring is naturally **event/observation-driven**: -the client reacts to changes it did not initiate. So the question is whether to: +Inspect adds a **monitoring** dimension (services, status, drill-down). The +primary consumers are **deterministic pipeline automations** — short-lived, +scripted runs that load state, apply changes, and exit. For that usage model, +live event streams and WebSocket subscriptions add complexity without clear +benefit: each run should start from an explicit server read and produce a known +outcome. -- keep everything data-driven (poll for status), or -- move to an event/action-driven model, or -- combine the two. - -This maps cleanly onto the config-plane vs. status-plane split described in +The question was whether to keep everything data-driven, move to an +event/action-driven model, or combine the two. This maps onto the config-plane +vs. status-plane split described in [concepts.md](../concepts.md#3-domain-model). ## Options @@ -44,4 +45,21 @@ This maps cleanly onto the config-plane vs. status-plane split described in ## Decision -_To be decided._ +**Option 1: data-driven only (request/response).** + +The package stays fully data-driven. Reads fetch aggregates from the server; +writes apply changes via explicit API calls. No WebSocket subscriptions, no live +update layer, no event-driven observation API. + +Status reads use the same request/response model as configuration CRUD. If +freshness is needed, the caller re-fetches explicitly. + +## Consequences + +- Consistent with existing apps and the automation/pipeline usage model. +- No WebSocket client, subscription machinery, or dual interaction styles to + build or document. +- Live monitoring UX (as in the Inspect UI) is out of scope for the package; + automations get predictable, reproducible runs instead. +- See [ADR-0003](./0003-websocket-subscriptions.md) (deprecated) and + [ADR-0004](./0004-async-strategy.md) for related decisions. diff --git a/docs/inspect-app/decisions/0002-loading-and-state.md b/docs/inspect-app/decisions/0002-loading-and-state.md index 163e5f5..adf9260 100644 --- a/docs/inspect-app/decisions/0002-loading-and-state.md +++ b/docs/inspect-app/decisions/0002-loading-and-state.md @@ -1,6 +1,6 @@ # ADR-0002: Loading & state model -> Status: **Proposed** +> Status: **Accepted** > Date: 2026-06-15 · Deciders: Paul Winterstein, Jonas Scholl ## Context @@ -54,4 +54,21 @@ Two sub-questions follow: ## Decision -_To be decided._ +**Option 1: stateless, eager per-entity — always load the full aggregate.** + +Each read fetches the **complete device aggregate** for the requested entity, +including edges, connections, vertices, and related sections. No lazy partial +objects, no field/section projection as a default path, and no client-side cache +or long-lived snapshots. + +Bulk helpers may be added later to reduce N+1 when iterating many entities, but +each entity returned is still a full aggregate. + +## Consequences + +- Predictable object shape: callers always receive a coherent, complete device. +- Always fresh on each read; no cache-invalidation logic. +- Chatty per-entity reads remain a known cost; internal parallelism for + multi-request reads is handled separately ([ADR-0004](./0004-async-strategy.md)). +- Projection/narrow reads and client-side caching are deferred unless a concrete + pipeline need emerges. diff --git a/docs/inspect-app/decisions/0003-websocket-subscriptions.md b/docs/inspect-app/decisions/0003-websocket-subscriptions.md index db3ecd1..598cd9c 100644 --- a/docs/inspect-app/decisions/0003-websocket-subscriptions.md +++ b/docs/inspect-app/decisions/0003-websocket-subscriptions.md @@ -1,14 +1,18 @@ # ADR-0003: WebSocket event subscriptions -> Status: **Proposed** +> Status: **Deprecated** > Date: 2026-06-15 · Deciders: Paul Winterstein, Jonas Scholl ## Context -Inspect adds a **WebSocket channel** for live status/event updates. The package -has no WebSocket client today; the connector layer is sync `requests`. We want -live updates without destabilising the synchronous CRUD core, and without -forcing every user into `asyncio` (see [ADR-0004](./0004-async-strategy.md)). +The VideoIPath server exposes a **WebSocket channel** for live status/event +updates. This ADR originally explored how the Python package might consume +those subscriptions. + +The package has no WebSocket client today; the connector layer is sync +`requests`. [ADR-0001](./0001-api-paradigm.md) now accepts a fully +data-driven, request/response model for deterministic pipeline automations, +which makes WebSocket support unnecessary for the package's scope. The [Public API 2025 LTS](https://documenter.getpostman.com/view/11222813/2sBXihpCS8#intro) reference **confirms** the high-level model: the `status` plane is "frequently @@ -42,4 +46,19 @@ format, heartbeat, reconnect) remain **[VERIFY]** — capture them per ## Decision -_To be decided._ +**Option 1: skip WebSocket; no live subscriptions in the package.** + +WebSocket event subscriptions are **out of scope**. The package does not +implement a subscription API, background listener thread, or async event stream. +Callers that need updated status re-fetch via the normal read methods. + +This ADR is **deprecated** — kept for historical context on the server +capability, not as an active design direction. + +## Consequences + +- No new WebSocket dependency or concurrency machinery. +- Removes the primary driver for an async public API surface. +- Server-side subscription capability remains documented in + [concepts.md](../concepts.md) for reference; a future ADR would be needed to + revisit this if long-lived monitoring clients become a package goal. diff --git a/docs/inspect-app/decisions/0004-async-strategy.md b/docs/inspect-app/decisions/0004-async-strategy.md index 8ec46f7..fcb69c4 100644 --- a/docs/inspect-app/decisions/0004-async-strategy.md +++ b/docs/inspect-app/decisions/0004-async-strategy.md @@ -1,26 +1,21 @@ # ADR-0004: Async readiness & migration -> Status: **Proposed** +> Status: **Accepted** > Date: 2026-06-15 · Deciders: Paul Winterstein, Jonas Scholl ## Context -The package is **sync**, built on `requests`. Two issues forces push toward async: +The package is **sync**, built on `requests`. Bulk reads (e.g. assembling a full +device aggregate) can benefit from **concurrent I/O** when a single high-level +action requires multiple API requests. -1. **WebSocket subscriptions** ([ADR-0003](./0003-websocket-subscriptions.md)) - are naturally async. -2. **Bulk/concurrent I/O** (Inspect's combined data set, large topologies) is - far more efficient with concurrency. - -But there is an **existing sync user base and sync test suite**, and a published +There is an **existing sync user base and sync test suite**, and a published PyPI package (`0.8.x`). We must not break sync users, and we should avoid a -risky big-bang rewrite. Python is `>=3.11`, so modern async primitives -(`TaskGroup`, `asyncio.timeout`) are available. +risky big-bang rewrite. WebSocket subscriptions ([ADR-0003](./0003-websocket-subscriptions.md)) +are out of scope, removing the main reason for an async public API. -Key structural fact in our favour: the **Pydantic models and the diff/patch -logic are I/O-agnostic**. Only the connector and the api/app methods are -I/O-bound. So async-ification is mostly a transport + method-coloring problem, -not a domain-logic rewrite. +Key structural fact: the **Pydantic models and diff/patch logic are +I/O-agnostic**. Only the connector and api/app methods are I/O-bound. ## Options @@ -46,4 +41,20 @@ not a domain-logic rewrite. ## Decision -_To be decided._ +**Stay sync at the package boundary; use internal parallelism only where a +single action needs multiple API requests.** + +The public API remains synchronous — no `async`/`await` surface, no dual-stack +codegen, no migration to `httpx` async clients. When one high-level operation +(e.g. loading a full device aggregate) requires several independent `GET`s, +those requests may be issued **in parallel internally** (e.g. thread pool) to +reduce latency. This is an implementation detail of the connector/read path, not +a new interaction model for callers. + +## Consequences + +- Zero breaking change for existing sync users and scripts. +- No async test matrix, no `unasync` tooling, no sync-over-async footguns. +- Performance gains are limited to multi-request reads/writes inside the + library; callers do not manage concurrency themselves. +- A future async public API would require a new ADR; it is not planned now. diff --git a/docs/inspect-app/decisions/0005-e2e-testing.md b/docs/inspect-app/decisions/0005-e2e-testing.md index 4a7a900..d86b6c3 100644 --- a/docs/inspect-app/decisions/0005-e2e-testing.md +++ b/docs/inspect-app/decisions/0005-e2e-testing.md @@ -1,23 +1,22 @@ # ADR-0005: E2E testing strategy -> Status: **Proposed** +> Status: **Accepted** > Date: 2026-06-15 · Deciders: Paul Winterstein, Jonas Scholl ## Context -Inspect spans config CRUD, status reads, and a WebSocket channel against a -proprietary server whose payloads vary by version. Tests must be **low-effort, -stable, and trustworthy** — i.e. they should fail only for real regressions, and -when green they should genuinely mean "this works against VideoIPath". +Inspect spans config CRUD and status reads against a proprietary server whose +payloads vary by version. Tests must be **low-effort and trustworthy** — when +green they should genuinely mean "this works against VideoIPath". Today the suite is small (validators) and a live server is configured via `tests/.env.test` + `pytest-dotenv`. There is precedent for "validate our assumptions against the live server": `advanced_driver_schema_check` compares local vs. server driver schemas. -The tension: a **live-only** suite is the most trustworthy but is slow, flaky, -stateful, and needs infrastructure; a **mock-only** suite is fast and stable but -only tests our assumptions, not reality. +We want to keep testing **simple** for now: one layer, run locally by the +developer against a real instance — not a multi-tier strategy with mocks, +cassettes, and fake servers. ## Options @@ -35,4 +34,21 @@ only tests our assumptions, not reality. ## Decision -_To be decided._ +**Live server E2E only — developer-run, locally, against a real VideoIPath instance.** + +E2E tests use the Python package to execute full test scenarios against a live +server. The developer provides credentials and connection details (via +`tests/.env.test` or equivalent). No recorded HTTP cassettes, no fake VideoIPath +server, and no separate contract/fixture test layer for now. + +These tests are **not** required on every CI push; they are run locally when a +developer has an instance available. + +## Consequences + +- Highest confidence: tests exercise the real API, auth, and payload shapes. +- Simple setup: one test style, one configuration path, no cassette maintenance. +- Tests are stateful, environment-dependent, and slower — acceptable trade-off + for the current team size and Inspect scope. +- Mock/cassette/fake-server layers can be revisited via a new ADR if CI + automation or faster feedback loops become a priority. diff --git a/docs/inspect-app/decisions/0006-commit-write-model.md b/docs/inspect-app/decisions/0006-commit-write-model.md index b47e00d..902b997 100644 --- a/docs/inspect-app/decisions/0006-commit-write-model.md +++ b/docs/inspect-app/decisions/0006-commit-write-model.md @@ -1,6 +1,6 @@ # ADR-0006: Commit-style write model (change sets) -> Status: **Proposed** +> Status: **Accepted** > Date: 2026-06-18 · Deciders: Paul Winterstein, Jonas Scholl ## Context @@ -25,6 +25,11 @@ There is **no commit, transaction, or staging concept** in the package today batch). So Inspect's commit behaviour is a **net-new write path**, not a reuse of the existing one. +**Data vs. operations:** Pydantic models / DTOs hold **data only** — fields, +nested structures, validation. They do not expose `add`, `update`, `delete`, or +`commit` methods. All mutations go through the **app instance** or an optional +**transaction** object that stages changes and commits them to the server. + ### Verified wire format (browser capture, 2026-06-18) Inspect applies topology edits with a **single bulk action** — not the existing @@ -112,7 +117,7 @@ delete-device attempt the top-level `header` still reported success: | `data.items` | `[]` | No applied changes returned | Per-entity validation details live in `data.validation.details`, keyed by id -(e.g. `"100001"`): +(e.g. `"booking-1001"`): | Field | Example | Notes | | ----- | ------- | ----- | @@ -129,7 +134,7 @@ Per-entity validation details live in `data.validation.details`, keyed by id "caption": "Operation Successful", "code": "OK", "ok": true, - "user": "sysadmin" + "user": "api-user" }, "data": { "items": [], @@ -140,11 +145,11 @@ Per-entity validation details live in `data.validation.details`, keyed by id "validation": { "createIds": [], "details": { - "100001": { + "booking-1001": { "isCancel": false, "isProduct": false, "resolvable": false, - "rev": "2-2026-06-15T13:03:50.631612311Z[UTC]", + "rev": "2-2026-06-15T13:03:50.000000000Z[UTC]", "status": -22, "type": "generic" } @@ -226,4 +231,27 @@ What remains **[VERIFY]**: ## Decision -_To be decided._ +**Option 3 (hybrid): explicit transaction via context manager, plus direct write +operations on the app.** + +- **Data-only DTOs** — models represent server payloads; no behaviour methods. +- **App-level direct writes** — `add` / `update` / `delete` on the inspect app + apply and commit immediately (one change set, one `updateTopology` call), for + simple one-off scripts. +- **Optional transaction** — a context manager (e.g. `with app.inspect.transaction()` + or similar) stages multiple actions and commits them atomically on exit; discard + on error or explicit cancel. + +Staging is **client-side** until the `POST …/updateTopology` commit; there is no +separate server-side change-set id (per verified wire format below). + +## Consequences + +- Two documented usage styles, but both map to the same underlying commit + payload shape. +- Context manager must define exit behaviour: commit on success, discard/raise + on failure; document what happens to an uncommitted transaction. +- DTOs stay portable and serializable; business logic lives in the app/transaction + layer, consistent with existing topology/inventory patterns. +- Single-change scripts stay ergonomic via direct writes; pipeline edits that + touch multiple elements use the transaction path for atomicity. diff --git a/docs/inspect-app/decisions/README.md b/docs/inspect-app/decisions/README.md index 987859e..39746b2 100644 --- a/docs/inspect-app/decisions/README.md +++ b/docs/inspect-app/decisions/README.md @@ -15,14 +15,14 @@ accepted — to change a decision, add a new ADR that supersedes the old one ## Index -| ADR | Title | Status | -| ----------------------------------------------------- | ------------------------------------ | -------- | -| [0001](./0001-api-paradigm.md) | API paradigm: data-driven + events | Proposed | -| [0002](./0002-loading-and-state.md) | Loading & state model | Proposed | -| [0003](./0003-websocket-subscriptions.md) | WebSocket event subscriptions | Proposed | -| [0004](./0004-async-strategy.md) | Async readiness & migration | Proposed | -| [0005](./0005-e2e-testing.md) | E2E testing strategy | Proposed | -| [0006](./0006-commit-write-model.md) | Commit-style write model (change sets) | Proposed | +| ADR | Title | Status | +| ----------------------------------------------------- | ------------------------------------ | ---------- | +| [0001](./0001-api-paradigm.md) | API paradigm: data-driven | Accepted | +| [0002](./0002-loading-and-state.md) | Loading & state model | Accepted | +| [0003](./0003-websocket-subscriptions.md) | WebSocket event subscriptions | Deprecated | +| [0004](./0004-async-strategy.md) | Async readiness & migration | Accepted | +| [0005](./0005-e2e-testing.md) | E2E testing strategy | Accepted | +| [0006](./0006-commit-write-model.md) | Commit-style write model (change sets) | Accepted | ## Template diff --git a/docs/inspect-app/endpoints.md b/docs/inspect-app/endpoints.md new file mode 100644 index 0000000..94f408b --- /dev/null +++ b/docs/inspect-app/endpoints.md @@ -0,0 +1,974 @@ +# Inspect App Endpoint Reference + +Captured against a local VideoIPath test instance. All hostnames, usernames, +device IDs, booking IDs, labels, endpoint IDs, IP addresses, multicast +addresses, UUIDs, and revisions in this document are anonymized examples. Only +read-only requests and one empty no-op `updateTopology` POST were executed. + +The Inspect package scope follows the accepted ADRs: + +- Request/response only; no WebSocket subscription API. +- Stateless reads; callers re-fetch when they need fresh status. +- Data-only DTOs; write/commit behaviour belongs in the future app/transaction + layer, not in the model classes. + +## Common Envelope + +All observed REST v2 responses use the standard envelope: + +```json +{ + "data": {}, + "header": { + "auth": true, + "caption": "Operation Successful", + "code": "OK", + "errorCodes": [], + "errorDetails": [], + "id": "", + "msg": [], + "ok": true, + "user": "api-user" + } +} +``` + +`header.ok` only describes transport/envelope success. For `updateTopology`, +commit success must also check `data.res.ok` and +`data.validation.result.ok`. + +## `GET /rest/v2/data/status/system/about/version` + +Purpose: identify the server version used for endpoint and payload capture. + +Request: + +```http +GET /rest/v2/data/status/system/about/version +Authorization: Basic +Accept: application/json +``` + +Response example: + +```json +{ + "data": { + "status": { + "system": { + "about": { + "version": "2025.4.x" + } + } + } + }, + "header": { + "auth": true, + "caption": "Operation Successful", + "code": "OK", + "errorCodes": [], + "errorDetails": [], + "id": "", + "msg": [], + "ok": true, + "user": "api-user" + } +} +``` + +## `GET /rest/v2/data/status/collector/**` + +Purpose: canonical Inspect read aggregate. This is the primary read surface for +services, path drill-down, external edge status, and optional topology node +status. + +Observed top-level sections: + +- `inspect.nodeStatus` +- `inspect.paths` +- `externalEdgesByDeviceKey` +- `maintenanceBookings` +- `superProfiles` +- `tagInfo` + +`security/**` returned an empty `collector` object on this instance. + +Request: + +```http +GET /rest/v2/data/status/collector/** +Authorization: Basic +Accept: application/json +``` + +Response shape example: + +```json +{ + "data": { + "status": { + "collector": { + "externalEdgesByDeviceKey": { "_items": [] }, + "inspect": { + "nodeStatus": { "_items": [] }, + "paths": { "_items": [] } + }, + "maintenanceBookings": { "_items": [] }, + "superProfiles": { "_items": [] }, + "tagInfo": { "_items": [] } + } + } + }, + "header": { + "auth": true, + "caption": "Operation Successful", + "code": "OK", + "errorCodes": [], + "errorDetails": [], + "id": "", + "msg": [], + "ok": true, + "user": "api-user" + } +} +``` + +## `GET /rest/v2/data/status/collector/inspect/paths/**` + +Purpose: list service/path records with endpoint labels, service state, and +per-hop path structures. + +Request: + +```http +GET /rest/v2/data/status/collector/inspect/paths/** +Authorization: Basic +Accept: application/json +``` + +Response item example: + +```json +{ + "_id": "booking-1001::main", + "_vid": "_:booking-1001::main", + "serviceFields": { + "bid": "booking-1001", + "ctype": 2, + "formatSubState": 0, + "from": "topo:device-a.module-1.port-out-1", + "fromLabel": "Source Endpoint A", + "fromPid": "device-a.dev.module-1.port-out-1", + "fromStatus": { "sa": 0, "severity": 1 }, + "generic": { + "allocationState": 0, + "cancelTime": null, + "descriptor": { + "desc": "", + "label": "Source Endpoint A -> Destination Endpoint B" + }, + "locked": false, + "state": 1, + "tags": [] + }, + "isMain": true, + "serviceStatus": { + "config": { "sa": 0, "severity": 1 }, + "total": { "sa": 0, "severity": 1 } + }, + "to": "topo:device-b.module-2.port-in-1", + "toLabel": "Destination Endpoint B", + "toPid": "device-b.dev.module-2.port-in-1", + "toStatus": { "sa": 0, "severity": 1 } + }, + "path": [ + { + "bid": "booking-1001", + "ipDesc": ":", + "structure": { + "deviceId": "device-a", + "deviceLabel": "Example Source Device", + "devicePid": "device-a", + "expectConfig": true, + "inputStatus": { + "context": { + "devicePid": "device-a", + "modulePid": "device-a.dev.module-1", + "portPid": "device-a.dev.module-1.port-out-1" + }, + "label": "Source Endpoint A", + "pid": "device-a.dev.module-1.port-out-1", + "status": { "sa": 0, "severity": 1 } + }, + "moduleAndDeviceStatus": { "sa": 0, "severity": 1 }, + "outputStatus": { + "context": { + "devicePid": "device-a", + "modulePid": "device-a.dev.network-module", + "portPid": "device-a.dev.network-module.port-1" + }, + "label": "Network Port A", + "pid": "device-a.dev.network-module.port-1", + "status": { "sa": 0, "severity": 1 } + } + } + } + ] +} +``` + +## `GET /rest/v2/data/status/collector/externalEdgesByDeviceKey/**` + +Purpose: live inter-device link status grouped by device pair. + +Request: + +```http +GET /rest/v2/data/status/collector/externalEdgesByDeviceKey/** +Authorization: Basic +Accept: application/json +``` + +Response item example: + +```json +{ + "_id": "device-a::device-b", + "_vid": "device-a::device-b", + "status": { + "alarm": 1, + "bandwidth": null, + "maintenance": null, + "ptp": 1 + }, + "primary": { + "devicePid": "device-a", + "label": "Example Source Device", + "data": { + "edge-uuid-0001": { + "bandwidth": 0.0, + "fromStatus": { + "context": { + "devicePid": "device-a", + "modulePid": "device-a.dev.module-1", + "portPid": "device-a.dev.module-1.port-out-1" + }, + "label": "Port A (out)", + "pid": "device-a.dev.module-1.port-out-1", + "status": { "sa": 0, "severity": 1 } + }, + "id": "edge-uuid-0001", + "maxBandwidth": null, + "pathDescriptions": {}, + "ratio": 0.0, + "status": { + "alarm": 1, + "bandwidth": null, + "maintenance": null, + "ptp": 1 + }, + "toStatus": { + "context": { + "devicePid": "device-b", + "modulePid": "device-b.dev.module-1", + "portPid": "device-b.dev.module-1.port-in-1" + }, + "label": "Port B (in)", + "pid": "device-b.dev.module-1.port-in-1", + "status": { "sa": 0, "severity": 1 } + } + } + } + }, + "secondary": { + "devicePid": "device-b", + "label": "Example Destination Device", + "data": {} + } +} +``` + +## `GET /rest/v2/data/status/collector/inspect/nodeStatus/**` + +Purpose: topology node/device status, including modules, ports, +`vertexInfo`, coordinates, statuses, and embedded path descriptions when the +server/user exposes them. + +In the sanitized capture, the endpoint was valid but returned no items: + +```json +{ + "data": { + "status": { + "collector": { + "inspect": { + "nodeStatus": { + "_items": [] + } + } + } + } + }, + "header": { + "auth": true, + "caption": "Operation Successful", + "code": "OK", + "errorCodes": [], + "errorDetails": [], + "id": "", + "msg": [], + "ok": true, + "user": "api-user" + } +} +``` + +The model still includes `InspectApiNodeStatusItem` based on the accepted concept +document and the known Inspect UI payload shape. + +## `GET /rest/v2/data/status/collector/maintenanceBookings/**` + +Purpose: maintenance booking status relevant to Inspect service/link display. + +Observed response on this instance: + +```json +{ + "data": { + "status": { + "collector": { + "maintenanceBookings": { + "_items": [] + } + } + } + } +} +``` + +## `GET /rest/v2/data/status/collector/superProfiles/**` + +Purpose: routing/super-profile status data used by the Inspect aggregate. + +Observed response on this instance: + +```json +{ + "data": { + "status": { + "collector": { + "superProfiles": { + "_items": [] + } + } + } + } +} +``` + +## `GET /rest/v2/data/status/collector/tagInfo/**` + +Purpose: tag/profile metadata used by the Inspect aggregate. + +Observed response on this instance: + +```json +{ + "data": { + "status": { + "collector": { + "tagInfo": { + "_items": [] + } + } + } + } +} +``` + +## `GET /rest/v2/data/status/network/edgesByDevice/**` + +Purpose: existing status-plane edge view. This is not the collector facade, but +it is useful for cross-checking edge payload fields when +`config/network/nGraphElements` is unavailable or permission-filtered. + +Request: + +```http +GET /rest/v2/data/status/network/edgesByDevice/** +Authorization: Basic +Accept: application/json +``` + +Response fragment: + +```json +{ + "data": { + "status": { + "network": { + "edgesByDevice": { + "_items": [ + { + "_id": "device-a", + "_vid": "device-a", + "edge-uuid-0001": { + "active": true, + "bandwidth": -1.0, + "capacity": 65535, + "conflictPri": 0, + "descriptor": { "desc": "", "label": "" }, + "excludeFormats": [], + "fromId": "device-a.module-1.port-out-1", + "includeFormats": [], + "redundancyMode": "Any", + "tags": [], + "toId": "device-b.module-1.port-in-1", + "type": "unidirectionalEdge", + "weight": 1, + "weightFactors": { + "bandwidth": { "weight": 0 }, + "service": { "max": 100, "weight": 0 } + } + } + } + ] + } + } + } + } +} +``` + +## `GET /rest/v2/data/config/network/nGraphElements/**` + +Purpose: revisioned config store that `updateTopology` persists into. Inspect +models include independent `InspectApi*` nGraph DTOs for this persisted shape, but +they do not import or subclass topology app models. + +In the sanitized capture, the endpoint was valid but returned no items: + +```json +{ + "data": { + "config": { + "network": { + "nGraphElements": { + "_items": [] + } + } + } + }, + "header": { + "auth": true, + "caption": "Operation Successful", + "code": "OK", + "errorCodes": [], + "errorDetails": [], + "id": "", + "msg": [], + "ok": true, + "user": "api-user" + } +} +``` + +Relevant type-filtered forms: + +```http +GET /rest/v2/data/config/network/nGraphElements/* where type='baseDevice' /** +GET /rest/v2/data/config/network/nGraphElements/* where type='ipVertex' /** +GET /rest/v2/data/config/network/nGraphElements/* where type='codecVertex' /** +GET /rest/v2/data/config/network/nGraphElements/* where type='genericVertex' /** +GET /rest/v2/data/config/network/nGraphElements/* where type='unidirectionalEdge' /** +GET /rest/v2/data/config/network/nGraphElements/* where type='nGraphResourceTransform' /** +``` + +## Known Action Endpoints Needing Payload Capture + +Manual endpoint discovery also identified the following Inspect-related action +endpoints. The examples below are anonymized and were captured with safe lookup +or no-op requests. + +### `POST /rest/v2/actions/status/collector/lookupInspectDevice` + +Purpose: collector lookup for one Inspect device/topology node. + +Request: + +```http +POST /rest/v2/actions/status/collector/lookupInspectDevice +Authorization: Basic +Content-Type: application/json +Accept: application/json +``` + +```json +{ + "header": { "id": 0 }, + "data": "device-a" +} +``` + +Response example: + +```json +{ + "data": { + "assignedTags": { + "all": [], + "inherited": {}, + "inheritedConflict": false, + "local": {} + }, + "fields": { + "coordinates": { + "x": 500, + "y": 8150 + }, + "descriptor": { + "desc": "Example device description", + "label": "Example Source Device" + }, + "iconSize": "medium", + "iconType": "gateway", + "localAssignedTags": [], + "sdpStrategy": "always", + "siteId": null, + "tags": ["#example-tag-a", "#example-tag-b"], + "virtualDeviceFields": null + } + }, + "header": { + "auth": true, + "caption": "Operation Successful", + "code": "OK", + "errorCodes": [], + "errorDetails": [], + "id": "0", + "msg": [], + "ok": true, + "user": "api-user" + } +} +``` + +Model coverage: + +- `InspectApiLookupInspectDeviceRequest` +- `InspectApiLookupInspectDeviceResponse` +- `InspectApiAssignedTags` +- `InspectApiLookupInspectDeviceFields` + +Invalid object-style request example: + +```json +{ + "header": { + "auth": true, + "caption": "Invalid Request", + "code": "INVALID_REQUEST", + "errorDetails": [ + { + "msg": "Can't convert { object } to String", + "path": [], + "type": "conversionError" + } + ], + "id": "0", + "msg": ["Can't convert { object } to String"], + "ok": false, + "user": "api-user" + } +} +``` + +### `POST /rest/v2/actions/status/collector/lookupSyncInfo` + +Purpose: collector lookup for synchronization information. + +Request: + +```http +POST /rest/v2/actions/status/collector/lookupSyncInfo +Authorization: Basic +Content-Type: application/json +Accept: application/json +``` + +```json +{ + "header": { "id": 0 }, + "data": ["device-a"] +} +``` + +Response example: + +```json +{ + "data": { + "device-a": { + "add": {}, + "label": "Example Source Device", + "remove": {}, + "severity": 0, + "update": {} + } + }, + "header": { + "auth": true, + "caption": "Operation Successful", + "code": "OK", + "errorCodes": [], + "errorDetails": [], + "id": "0", + "msg": [], + "ok": true, + "user": "api-user" + } +} +``` + +Model coverage: + +- `InspectApiLookupSyncInfoRequest` +- `InspectApiLookupSyncInfoResponse` +- `InspectApiLookupSyncInfoItem` + +The device list must be non-empty. An empty list produced: + +```json +{ + "header": { + "auth": true, + "caption": "Invalid Request", + "code": "INVALID_REQUEST", + "errorDetails": [ + { + "msg": "Cannot create NonEmptyList from empty list", + "path": [], + "type": "conversionError" + } + ], + "id": "0", + "msg": ["Cannot create NonEmptyList from empty list"], + "ok": false, + "user": "api-user" + } +} +``` + +### `POST /rest/v2/actions/status/network/addDevices` + +Purpose: network action used by Inspect topology workflows to add devices. + +Request shape: + +```http +POST /rest/v2/actions/status/network/addDevices +Authorization: Basic +Content-Type: application/json +Accept: application/json +``` + +```json +{ + "header": { "id": 0 }, + "data": [ + { + "id": "device-a", + "x": 500, + "y": 8150 + } + ] +} +``` + +No-op request used for safe capture: + +```json +{ + "header": { "id": 0 }, + "data": [] +} +``` + +No-op response: + +```json +{ + "data": { + "msg": [], + "ok": true + }, + "header": { + "auth": true, + "caption": "Operation Successful", + "code": "OK", + "errorCodes": [], + "errorDetails": [], + "id": "0", + "msg": [], + "ok": true, + "user": "api-user" + } +} +``` + +Model coverage: + +- `InspectApiAddDevicesRequest` +- `InspectApiAddDevicesItem` +- `InspectApiSimpleActionResponse` +- `InspectApiActionValidationErrorResponse` + +Non-empty request against a device outside the caller's writable domains returned +an HTTP 422 validation envelope and did not create any `nGraphElements`: + +```json +{ + "header": { + "auth": true, + "caption": "The request could not be processed due to e.g. validation errors", + "code": "VALIDATION_ERROR", + "errorCodes": [], + "errorDetails": [ + { + "cause": "general", + "msg": "Operation 'update' not allowed for resource type 'device' in domain 'GroupDomainId(example-domain)'", + "path": ["device-a"], + "type": "validationError" + } + ], + "id": "", + "msg": [ + "Operation 'update' not allowed for resource type 'device' in domain 'GroupDomainId(example-domain)'" + ], + "ok": false, + "user": "api-user" + } +} +``` + +Non-empty request against a disposable, non-driver device ID returned the normal +action response envelope with `data.ok: false` and did not create any +`nGraphElements`: + +```json +{ + "data": { + "msg": ["No topology reported by the device"], + "ok": false + }, + "header": { + "auth": true, + "caption": "Operation Successful", + "code": "OK", + "errorCodes": [], + "errorDetails": [], + "id": "0", + "msg": [], + "ok": true, + "user": "api-user" + } +} +``` + +### `POST /rest/v2/actions/status/network/syncDevices` + +Purpose: network action used by Inspect topology workflows to synchronize +devices. + +Request: + +```http +POST /rest/v2/actions/status/network/syncDevices +Authorization: Basic +Content-Type: application/json +Accept: application/json +``` + +```json +{ + "header": { "id": 0 }, + "data": { + "ids": ["device-a"], + "addOnly": true, + "conflictStrategy": 0 + } +} +``` + +`conflictStrategy` values observed in the frontend bundle: + +| Value | Meaning | +| --- | --- | +| `0` | Strict | +| `1` | Invalidate services | +| `2` | Cancel services | + +No-op request used for safe capture: + +```json +{ + "header": { "id": 0 }, + "data": { + "ids": [], + "addOnly": true, + "conflictStrategy": 0 + } +} +``` + +No-op response: + +```json +{ + "data": { + "msg": [], + "ok": true + }, + "header": { + "auth": true, + "caption": "Operation Successful", + "code": "OK", + "errorCodes": [], + "errorDetails": [], + "id": "0", + "msg": [], + "ok": true, + "user": "api-user" + } +} +``` + +Model coverage: + +- `InspectApiSyncDevicesRequest` +- `InspectApiSyncDevicesRequestData` +- `InspectApiSimpleActionResponse` + +Non-empty request against a disposable, non-driver device ID returned: + +```json +{ + "data": { + "msg": [ + "A device sync requires the device to be present both in the graph and in the driver" + ], + "ok": false + }, + "header": { + "auth": true, + "caption": "Operation Successful", + "code": "OK", + "errorCodes": [], + "errorDetails": [], + "id": "0", + "msg": [], + "ok": true, + "user": "api-user" + } +} +``` + +## `POST /rest/v2/actions/status/collector/updateTopology` + +Purpose: Inspect commit endpoint. The client sends a full change set; empty +maps/lists mean no changes in that category. Staging is client-side until this +POST. + +No-op request used for this capture: + +```http +POST /rest/v2/actions/status/collector/updateTopology +Authorization: Basic +Content-Type: application/json +Accept: application/json +``` + +```json +{ + "header": { "id": 0 }, + "data": { + "replaceDevices": {}, + "replaceVertices": {}, + "replaceEdges": {}, + "replaceResourceTransforms": {}, + "addExternalEdges": [], + "remove": [], + "force": false + } +} +``` + +No-op response: + +```json +{ + "data": { + "items": [], + "res": { + "msg": [], + "ok": true + }, + "validation": { + "createIds": [], + "details": {}, + "result": { + "msg": [], + "ok": true + } + } + }, + "header": { + "auth": true, + "caption": "Operation Successful", + "code": "OK", + "errorCodes": [], + "errorDetails": [], + "id": "0", + "msg": [], + "ok": true, + "user": "api-user" + } +} +``` + +Successful commit detection: + +```python +committed = response.header.ok and response.data.res.ok and response.data.validation.result.ok +``` + +Known failure shape from the accepted ADR: + +```json +{ + "data": { + "items": [], + "res": { + "msg": ["Validation failed"], + "ok": false + }, + "validation": { + "createIds": [], + "details": { + "booking-1001": { + "isCancel": false, + "isProduct": false, + "resolvable": false, + "rev": "2-2026-01-01T00:00:00.000000000Z[UTC]", + "status": -22, + "type": "generic" + } + }, + "result": { + "msg": ["A required edge was not found. (main)"], + "ok": false + } + } + } +} +``` diff --git a/docs/inspect-app/models.md b/docs/inspect-app/models.md new file mode 100644 index 0000000..c6400a6 --- /dev/null +++ b/docs/inspect-app/models.md @@ -0,0 +1,602 @@ +# Inspect App Data Model + +This document describes Inspect data as package users should think about it: +services, devices, ports, edges, status, and topology changes. + +The implementation uses two layers: + +- **Transport DTOs** in `src/videoipath_automation_tool/apps/inspect/model/` mirror + HTTP request and response payloads. These classes are prefixed with `InspectApi` + and are intended for direct API communication and parsing. +- **Domain models** in `src/videoipath_automation_tool/apps/inspect/domain/` are + the objects package users work with: `InspectDevice`, `InspectPort`, + `InspectEdge`, and `InspectService`. They are built from an `InspectSnapshot` + and resolve relations from internal indexes instead of making extra HTTP calls. + +Wire-shape examples and endpoint references live in +[endpoints.md](./endpoints.md). All examples below are anonymized. + +## Two Layers + +```mermaid +flowchart LR + Http[HTTP collector response] --> ApiDto[InspectApiCollectorResponse] + ApiDto --> Snapshot[InspectSnapshot] + Snapshot --> Device[InspectDevice] + Device --> Ports[InspectPort] + Device --> Edges[InspectEdge] + Device --> Services[InspectService] +``` + +Typical read flow: + +```python +response = InspectApiCollectorResponse.model_validate(payload) +snapshot = InspectSnapshot.from_response(response) +device = snapshot.get_device_by_id("device-a") +ports = device.ports +edges = device.edges +services = device.services +linked = device.linked_devices + +port = ports[0] +if port.edge is not None: + peer = port.edge.to_device + peer_port = port.edge.to_port +``` + +Relation getters resolve full domain objects from snapshot indexes. Repeated +access returns the same cached instance for a given device, port, edge, or +service. + +Refresh data by fetching a new collector response and building a new snapshot. +Relation getters never perform hidden HTTP requests. + +## Mental Model + +Inspect is built from two data surfaces: + +- A **collector snapshot** describes what the Inspect UI can show right now: + services, path hops, devices, ports, external edges, sync hints, and status. +- A **topology change set** describes what the client wants to commit to the + persisted graph store. + +```mermaid +flowchart LR + Service[Service booking] --> Path[Path hops] + Path --> DeviceA[Device A] + Path --> DeviceB[Device B] + DeviceA --> PortA[Input/output ports] + DeviceB --> PortB[Input/output ports] + PortA --> Edge[External edge status] + Edge --> PortB + DeviceA --> Sync[Sync information] + ChangeSet[Topology change set] --> Graph[nGraphElements store] +``` + +## Collector Snapshot + +The collector response is a REST v2 envelope with a `header` and a `data` object. +The useful Inspect content is under `data.status.collector`. + +```json +{ + "data": { + "status": { + "collector": { + "inspect": { + "paths": { "_items": [] }, + "nodeStatus": { "_items": [] } + }, + "externalEdgesByDeviceKey": { "_items": [] }, + "maintenanceBookings": { "_items": [] }, + "superProfiles": { "_items": [] }, + "tagInfo": { "_items": [] } + } + } + }, + "header": { + "auth": true, + "caption": "Operation Successful", + "code": "OK", + "id": "", + "msg": [], + "ok": true, + "user": "api-user" + } +} +``` + +VideoIPath collections use `_items`. Transport DTOs preserve that wire shape. +`InspectSnapshot` indexes the parsed collector data once and exposes user-facing +objects from those indexes. + +## User-Facing Domain Objects + +These are the classes package users should prefer for read-side workflows. + +### `InspectDevice` + +Represents one topology device/node with status, sync hints, and relations. + +| Field / property | Meaning | +| --- | --- | +| `id` | Device identifier, for example `device-a` | +| `label` | Display label | +| `status` | Device status summary | +| `sync_severity` | Sync severity from node status | +| `tags` | Assigned tags | +| `coordinates` | Topology map position when available | +| `ports` | Ports on this device | +| `edges` | External edges touching this device | +| `services` | Services whose path includes this device | +| `linked_devices` | Neighbour devices via edges or shared service paths | + +Lookup: + +```python +device = snapshot.get_device_by_id("device-a") +matches = snapshot.find_devices_by_name("Example Device A") +``` + +### `InspectPort` + +Represents one module port with status, optional vertex linkage, and an optional +external edge to another device. + +| Field / property | Meaning | +| --- | --- | +| `id` | Port pid | +| `label` | Display label | +| `device` | Owning `InspectDevice` | +| `module_id` | Owning module | +| `status` | Port status summary | +| `vertex_id` | Linked topology vertex when available | +| `edge` | External edge when this port connects to another device; otherwise `None` | + +### `InspectEdge` + +Represents one external edge status entry between two endpoint devices/ports. + +| Field / property | Meaning | +| --- | --- | +| `id` | Edge identifier | +| `from_device` / `to_device` | Endpoint devices | +| `from_port` / `to_port` | Endpoint ports | +| `bandwidth` / `max_bandwidth` | Bandwidth values | +| `status` | Alarm, bandwidth, maintenance, and PTP summary | +| `services` | Services touching either endpoint device | + +### `InspectService` + +Represents one service/booking path across devices and ports. + +| Field / property | Meaning | +| --- | --- | +| `booking_id` | Booking identifier | +| `label` | Service label when available | +| `source` / `destination` | Endpoint labels | +| `source_device` / `destination_device` | Endpoint devices | +| `source_port` / `destination_port` | Endpoint ports | +| `status` | Service status summary | +| `path_devices` | Ordered devices in the path | +| `path_ports` | Ports encountered in the path | + +Lookup: + +```python +service = snapshot.get_service_by_booking_id("booking-1001") +all_services = snapshot.get_services() +``` + +## Transport DTO Examples + +A path item links one service or booking to the devices and ports used to carry +it. This is the best starting point when a caller wants to answer: "Which devices +does this service traverse?" + +```json +{ + "_id": "booking-1001::main", + "_vid": "_:booking-1001::main", + "serviceFields": { + "bid": "booking-1001", + "from": "endpoint-a", + "fromLabel": "Example Source", + "fromPid": "endpoint-a-pid", + "to": "endpoint-b", + "toLabel": "Example Destination", + "toPid": "endpoint-b-pid", + "isMain": true, + "serviceStatus": { + "config": { "sa": 0, "severity": 0 }, + "total": { "sa": 0, "severity": 0 } + } + }, + "path": [ + { + "bid": "booking-1001", + "ipDesc": ":", + "structure": { + "deviceId": "device-a", + "devicePid": "device-a", + "deviceLabel": "Example Device A", + "expectConfig": true, + "inputStatus": { + "pid": "port-a-in", + "label": "Input Port", + "context": { + "devicePid": "device-a", + "modulePid": "module-a", + "portPid": "port-a-in" + }, + "status": { "sa": 0, "severity": 0 } + }, + "outputStatus": { + "pid": "port-a-out", + "label": "Output Port", + "context": { + "devicePid": "device-a", + "modulePid": "module-a", + "portPid": "port-a-out" + }, + "status": { "sa": 0, "severity": 0 } + }, + "moduleAndDeviceStatus": { "sa": 0, "severity": 0 } + } + } + ] +} +``` + +In Python this maps to `InspectApiPathItem`, `InspectApiPathServiceFields`, +`InspectApiPathSegment`, and `InspectApiPathStructure`. + +## Devices, Modules, And Ports + +Node status describes a device-like node as the Inspect UI sees it. It can +include coordinates, modules, ports, status, tags, sync severity, and embedded +references back to service paths. + +```json +{ + "_id": "node-device-a", + "_vid": "_:node-device-a", + "deviceId": "device-a", + "pid": "device-a", + "label": "Example Device A", + "meta": { + "coordinates": { "x": 500, "y": 8150 } + }, + "status": { "sa": 0, "severity": 0 }, + "syncSeverity": 0, + "tags": ["#example-tag"], + "modules": { + "module-a": { + "_id": "module-a", + "pid": "module-a", + "label": "Example Module", + "status": { "sa": 0, "severity": 0 }, + "ports": { + "port-a-out": { + "_id": "port-a-out", + "pid": "port-a-out", + "label": "Output Port", + "status": { "sa": 0, "severity": 0 }, + "vertexInfo": { + "type": "single", + "id": "vertex-a-out", + "label": "Output Vertex", + "vertexType": "ip", + "fields": { + "isActive": true, + "isControlled": true, + "isEndpoint": false + } + } + } + } + } + } +} +``` + +In Python this maps to `InspectApiNodeStatusItem`, `InspectApiModuleStatus`, +`InspectApiPortStatus`, `InspectApiSingleVertexInfo`, and `InspectApiDoubleVertexInfo`. + +## External Edges + +External edge status groups live connectivity between two devices. Each group has +two sides, and each side can contain one or more edge entries keyed by edge ID. + +```json +{ + "_id": "device-a::device-b", + "_vid": "device-a::device-b", + "primary": { + "devicePid": "device-a", + "label": "Example Device A", + "data": { + "edge-a-b-0001": { + "id": "edge-a-b-0001", + "bandwidth": 1000, + "maxBandwidth": 10000, + "ratio": 0.1, + "fromStatus": { + "pid": "port-a-out", + "label": "Output Port", + "status": { "sa": 0, "severity": 0 } + }, + "toStatus": { + "pid": "port-b-in", + "label": "Input Port", + "status": { "sa": 0, "severity": 0 } + }, + "status": { + "alarm": 0, + "bandwidth": 0, + "maintenance": 0, + "ptp": 0 + } + } + } + }, + "secondary": { + "devicePid": "device-b", + "label": "Example Device B", + "data": {} + }, + "status": { + "alarm": 0, + "bandwidth": 0, + "maintenance": 0, + "ptp": 0 + } +} +``` + +In Python this maps to `InspectApiExternalEdgesByDeviceKeyItem`, +`InspectApiExternalEdgeSide`, `InspectApiExternalEdgeStatus`, and +`InspectApiExternalEdgeLiveStatus`. + +## Lookup And Sync Actions + +Action endpoints return focused views for UI workflows. + +`lookupInspectDevice` returns editable/display fields for one device: + +```json +{ + "data": { + "assignedTags": { + "all": [], + "inherited": {}, + "inheritedConflict": false, + "local": {} + }, + "fields": { + "coordinates": { "x": 500, "y": 8150 }, + "descriptor": { + "desc": "Example device description", + "label": "Example Device A" + }, + "iconSize": "medium", + "iconType": "gateway", + "localAssignedTags": [], + "sdpStrategy": "always", + "siteId": null, + "tags": ["#example-tag"], + "virtualDeviceFields": null + } + } +} +``` + +`lookupSyncInfo` returns per-device sync differences: + +```json +{ + "data": { + "device-a": { + "add": {}, + "label": "Example Device A", + "remove": {}, + "severity": 0, + "update": {} + } + } +} +``` + +`addDevices` and `syncDevices` use the same normal action-result shape when the +action runs: + +```json +{ + "data": { + "msg": [], + "ok": true + }, + "header": { + "auth": true, + "caption": "Operation Successful", + "code": "OK", + "id": "0", + "msg": [], + "ok": true, + "user": "api-user" + } +} +``` + +Some invalid `addDevices` requests can be rejected before an action result is +returned. Those responses contain only the REST header with validation details. + +## Persisted Topology Graph + +The collector snapshot is a status view. Committed Inspect topology data is stored +in `config.network.nGraphElements._items[]`. Each item has an ID, optional +revision, display descriptors, tags, and a `type` value that tells callers which +shape to parse. + +```mermaid +flowchart LR + BaseDevice[baseDevice: device node] + IpVertex[ipVertex: IP port/vertex] + CodecVertex[codecVertex: media endpoint vertex] + GenericVertex[genericVertex: generic vertex] + Edge[unidirectionalEdge: connection] + Transform[nGraphResourceTransform: resource mapping] + + BaseDevice --> IpVertex + BaseDevice --> CodecVertex + BaseDevice --> GenericVertex + IpVertex --> Edge + Edge --> IpVertex + Transform --> Edge +``` + +Example persisted graph elements: + +```json +[ + { + "_id": "device-a", + "_vid": "device-a", + "_rev": "1-example-revision", + "type": "baseDevice", + "descriptor": { + "label": "Example Device A", + "desc": "Example device description" + }, + "fDescriptor": { + "label": "Example Device A", + "desc": "Example device description" + }, + "iconSize": "medium", + "iconType": "gateway", + "isVirtual": false, + "maps": [], + "sdpStrategy": "always", + "siteId": null, + "tags": ["#example-tag"] + }, + { + "_id": "vertex-a-out", + "_vid": "vertex-a-out", + "type": "ipVertex", + "deviceId": "device-a", + "descriptor": { "label": "Output Vertex", "desc": "" }, + "fDescriptor": { "label": "Output Vertex", "desc": "" }, + "gpid": { + "component": 1, + "pointId": ["device-a", "module-a", "port-a-out"] + }, + "ipAddress": "198.51.100.10", + "ipNetmask": "255.255.255.0", + "supportsIgmpCfg": true, + "tags": [] + }, + { + "_id": "edge-a-b-0001", + "_vid": "edge-a-b-0001", + "type": "unidirectionalEdge", + "fromId": "vertex-a-out", + "toId": "vertex-b-in", + "active": true, + "bandwidth": -1, + "capacity": 65535, + "redundancyMode": "Any", + "weight": 0, + "weightFactors": { + "bandwidth": { "weight": 0 }, + "service": { "max": 100, "weight": 0 } + }, + "tags": [] + } +] +``` + +In Python these shapes live in `ngraph.py`. + +## Committing Changes + +`updateTopology` sends the whole change set in one request. Empty maps/lists are a +valid no-op. Non-empty maps upsert graph elements by ID, `addExternalEdges` adds +edge objects, and `remove` deletes graph elements by ID. + +```json +{ + "header": { "id": 0 }, + "data": { + "replaceDevices": { + "device-a": { + "_id": "device-a", + "_vid": "device-a", + "type": "baseDevice", + "descriptor": { "label": "Example Device A", "desc": "" }, + "fDescriptor": { "label": "Example Device A", "desc": "" }, + "tags": [] + } + }, + "replaceVertices": { + "vertex-a-out": { + "_id": "vertex-a-out", + "_vid": "vertex-a-out", + "type": "ipVertex", + "deviceId": "device-a", + "descriptor": { "label": "Output Vertex", "desc": "" }, + "fDescriptor": { "label": "Output Vertex", "desc": "" }, + "tags": [] + } + }, + "replaceEdges": { + "vertex-a-out::vertex-b-in": { + "_id": "edge-a-b-0001", + "_vid": "edge-a-b-0001", + "type": "unidirectionalEdge", + "fromId": "vertex-a-out", + "toId": "vertex-b-in", + "descriptor": { "label": "", "desc": "" }, + "fDescriptor": { "label": "", "desc": "" }, + "tags": [] + } + }, + "replaceResourceTransforms": {}, + "addExternalEdges": [], + "remove": [], + "force": false + } +} +``` + +A commit is successful only when all three success flags are true: + +```python +response.header.ok and response.data.res.ok and response.data.validation.result.ok +``` + +Validation failures can still arrive with `header.ok == true`, so callers must +inspect `data.res`, `data.validation.result`, and `data.validation.details`. + +## Python Module Layout + +Transport DTOs are split by payload area under `apps/inspect/model/`: + +- `common.py` — shared envelopes, descriptors, status summaries, action wrappers +- `collector.py` — collector snapshot wire models +- `ngraph.py` — persisted `nGraphElements` wire models +- `actions.py` — lookup/add/sync action request and response DTOs +- `update_topology.py` — `updateTopology` change-set and commit response DTOs + +User-facing read models live alongside the snapshot: + +- `snapshot.py` — `InspectSnapshot` and internal indexes +- `domain/device.py` — `InspectDevice` +- `domain/port.py` — `InspectPort` +- `domain/edge.py` — `InspectEdge` +- `domain/service.py` — `InspectService` + +When adding transport fields, prefer extending the nearest existing `InspectApi*` +DTO. When adding user-facing behaviour, extend the domain layer and snapshot +indexes instead of exposing raw HTTP nesting to callers. diff --git a/src/videoipath_automation_tool/apps/__init__.py b/src/videoipath_automation_tool/apps/__init__.py index 624c414..dd26ecb 100644 --- a/src/videoipath_automation_tool/apps/__init__.py +++ b/src/videoipath_automation_tool/apps/__init__.py @@ -1,4 +1,5 @@ from videoipath_automation_tool.apps.inventory import * +from videoipath_automation_tool.apps.inspect import * from videoipath_automation_tool.apps.preferences import * from videoipath_automation_tool.apps.profile import * from videoipath_automation_tool.apps.topology import * diff --git a/src/videoipath_automation_tool/apps/inspect/__init__.py b/src/videoipath_automation_tool/apps/inspect/__init__.py new file mode 100644 index 0000000..b4bc85c --- /dev/null +++ b/src/videoipath_automation_tool/apps/inspect/__init__.py @@ -0,0 +1,3 @@ +from videoipath_automation_tool.apps.inspect.domain import * +from videoipath_automation_tool.apps.inspect.model import * +from videoipath_automation_tool.apps.inspect.snapshot import InspectSnapshot as InspectSnapshot diff --git a/src/videoipath_automation_tool/apps/inspect/domain/__init__.py b/src/videoipath_automation_tool/apps/inspect/domain/__init__.py new file mode 100644 index 0000000..b65cbc5 --- /dev/null +++ b/src/videoipath_automation_tool/apps/inspect/domain/__init__.py @@ -0,0 +1,11 @@ +from videoipath_automation_tool.apps.inspect.domain.device import InspectDevice +from videoipath_automation_tool.apps.inspect.domain.edge import InspectEdge +from videoipath_automation_tool.apps.inspect.domain.port import InspectPort +from videoipath_automation_tool.apps.inspect.domain.service import InspectService + +__all__ = [ + "InspectDevice", + "InspectEdge", + "InspectPort", + "InspectService", +] diff --git a/src/videoipath_automation_tool/apps/inspect/domain/device.py b/src/videoipath_automation_tool/apps/inspect/domain/device.py new file mode 100644 index 0000000..3b49ce2 --- /dev/null +++ b/src/videoipath_automation_tool/apps/inspect/domain/device.py @@ -0,0 +1,71 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import TYPE_CHECKING + +from videoipath_automation_tool.apps.inspect.model.common import InspectApiStatusSummary +from videoipath_automation_tool.apps.inspect.snapshot import _DeviceRecord + +if TYPE_CHECKING: + from videoipath_automation_tool.apps.inspect.domain.edge import InspectEdge + from videoipath_automation_tool.apps.inspect.domain.port import InspectPort + from videoipath_automation_tool.apps.inspect.domain.service import InspectService + from videoipath_automation_tool.apps.inspect.snapshot import InspectSnapshot + + +@dataclass(frozen=True, slots=True) +class InspectDevice: + snapshot: InspectSnapshot + record: _DeviceRecord + + @property + def id(self) -> str: + return self.record.device_id + + @property + def label(self) -> str | None: + return self.record.label + + @property + def pid(self) -> str | None: + return self.record.pid + + @property + def status(self) -> InspectApiStatusSummary | None: + if self.record.node is None: + return None + return self.record.node.status + + @property + def sync_severity(self) -> int | str | None: + if self.record.node is None: + return None + return self.record.node.syncSeverity + + @property + def tags(self) -> tuple[str, ...]: + if self.record.node is None: + return () + return tuple(self.record.node.tags) + + @property + def coordinates(self) -> dict[str, float | int | str | None] | None: + if self.record.node is None or self.record.node.meta is None: + return None + return self.record.node.meta.coordinates + + @property + def ports(self) -> list[InspectPort]: + return self.snapshot.get_ports_for_device(self.id) + + @property + def edges(self) -> list[InspectEdge]: + return self.snapshot.get_edges_for_device(self.id) + + @property + def services(self) -> list[InspectService]: + return self.snapshot.get_services_for_device(self.id) + + @property + def linked_devices(self) -> list[InspectDevice]: + return self.snapshot.get_linked_devices(self.id) diff --git a/src/videoipath_automation_tool/apps/inspect/domain/edge.py b/src/videoipath_automation_tool/apps/inspect/domain/edge.py new file mode 100644 index 0000000..60d94cb --- /dev/null +++ b/src/videoipath_automation_tool/apps/inspect/domain/edge.py @@ -0,0 +1,77 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import TYPE_CHECKING + +from videoipath_automation_tool.apps.inspect.model.collector import InspectApiExternalEdgeLiveStatus +from videoipath_automation_tool.apps.inspect.snapshot import _IndexedEdge + +if TYPE_CHECKING: + from videoipath_automation_tool.apps.inspect.domain.device import InspectDevice + from videoipath_automation_tool.apps.inspect.domain.port import InspectPort + from videoipath_automation_tool.apps.inspect.domain.service import InspectService + from videoipath_automation_tool.apps.inspect.snapshot import InspectSnapshot + + +@dataclass(frozen=True, slots=True) +class InspectEdge: + snapshot: InspectSnapshot + indexed: _IndexedEdge + + @property + def id(self) -> str: + return self.indexed.edge_id + + @property + def pair_id(self) -> str: + return self.indexed.pair_id + + @property + def from_device(self) -> InspectDevice | None: + if self.indexed.from_device_id is None: + return None + return self.snapshot.get_device_by_id(self.indexed.from_device_id) + + @property + def from_port(self) -> InspectPort | None: + if self.indexed.from_device_id is None or self.indexed.from_port_id is None: + return None + return self.snapshot.get_port(self.indexed.from_device_id, self.indexed.from_port_id) + + @property + def to_device(self) -> InspectDevice | None: + if self.indexed.to_device_id is None: + return None + return self.snapshot.get_device_by_id(self.indexed.to_device_id) + + @property + def to_port(self) -> InspectPort | None: + if self.indexed.to_device_id is None or self.indexed.to_port_id is None: + return None + return self.snapshot.get_port(self.indexed.to_device_id, self.indexed.to_port_id) + + @property + def bandwidth(self) -> float | int | None: + return self.indexed.edge.bandwidth + + @property + def max_bandwidth(self) -> float | int | None: + return self.indexed.edge.maxBandwidth + + @property + def status(self) -> InspectApiExternalEdgeLiveStatus | None: + return self.indexed.edge.status + + @property + def services(self) -> list[InspectService]: + services: list[InspectService] = [] + seen_booking_ids: set[str] = set() + for device in (self.from_device, self.to_device): + if device is None: + continue + for service in self.snapshot.get_services_for_device(device.id): + if service.booking_id in seen_booking_ids: + continue + seen_booking_ids.add(service.booking_id) + services.append(service) + return services diff --git a/src/videoipath_automation_tool/apps/inspect/domain/port.py b/src/videoipath_automation_tool/apps/inspect/domain/port.py new file mode 100644 index 0000000..5120e6a --- /dev/null +++ b/src/videoipath_automation_tool/apps/inspect/domain/port.py @@ -0,0 +1,76 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import TYPE_CHECKING + +from videoipath_automation_tool.apps.inspect.model.collector import ( + InspectApiSingleVertexInfo, + InspectPortStatus, +) +from videoipath_automation_tool.apps.inspect.model.common import InspectApiStatusSummary +from videoipath_automation_tool.apps.inspect.snapshot import _IndexedPort, _port_id_from_status + +if TYPE_CHECKING: + from videoipath_automation_tool.apps.inspect.domain.device import InspectDevice + from videoipath_automation_tool.apps.inspect.domain.edge import InspectEdge + from videoipath_automation_tool.apps.inspect.snapshot import InspectSnapshot + + +@dataclass(frozen=True, slots=True) +class InspectPort: + snapshot: InspectSnapshot + indexed: _IndexedPort + + @property + def id(self) -> str | None: + return _port_id_from_status(self.indexed.port) + + @property + def label(self) -> str | None: + return self.indexed.port.label + + @property + def device(self) -> InspectDevice | None: + return self.snapshot.get_device_by_id(self.indexed.device_id) + + @property + def module_id(self) -> str | None: + return self.indexed.module_id + + @property + def status(self) -> InspectApiStatusSummary | None: + return self.indexed.port.status + + @property + def vertex_id(self) -> str | None: + return self._vertex_id_from(self.indexed.port) + + @property + def edge(self) -> InspectEdge | None: + port_id = self.id + if not port_id: + return None + return self.snapshot.get_edge_for_port(self.indexed.device_id, port_id) + + @staticmethod + def _vertex_id_from(port: InspectPortStatus) -> str | None: + vertex_info = port.vertexInfo + if vertex_info is None: + return None + if isinstance(vertex_info, InspectApiSingleVertexInfo): + return vertex_info.id + if isinstance(vertex_info, dict): + vertex_type = vertex_info.get("type") + if vertex_type == "single": + single = vertex_info.get("id") + return single if isinstance(single, str) else None + if vertex_type == "double": + for key in ("in", "out"): + side = vertex_info.get(key) + if isinstance(side, dict) and isinstance(side.get("id"), str): + return side["id"] + else: + for candidate in (getattr(vertex_info, "out", None), getattr(vertex_info, "in_", None)): + if candidate is not None and candidate.id: + return candidate.id + return None diff --git a/src/videoipath_automation_tool/apps/inspect/domain/service.py b/src/videoipath_automation_tool/apps/inspect/domain/service.py new file mode 100644 index 0000000..3263a17 --- /dev/null +++ b/src/videoipath_automation_tool/apps/inspect/domain/service.py @@ -0,0 +1,113 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import TYPE_CHECKING + +from videoipath_automation_tool.apps.inspect.model.collector import InspectApiPathItem, InspectServiceStatus +from videoipath_automation_tool.apps.inspect.snapshot import _port_id_from_endpoint + +if TYPE_CHECKING: + from videoipath_automation_tool.apps.inspect.domain.device import InspectDevice + from videoipath_automation_tool.apps.inspect.domain.port import InspectPort + from videoipath_automation_tool.apps.inspect.snapshot import InspectSnapshot + + +@dataclass(frozen=True, slots=True) +class InspectService: + snapshot: InspectSnapshot + path_item: InspectApiPathItem + + @property + def booking_id(self) -> str: + return self.path_item.serviceFields.bid + + @property + def label(self) -> str | None: + generic = self.path_item.serviceFields.generic + if generic is not None and generic.descriptor is not None: + return generic.descriptor.label or None + return self.path_item.serviceFields.fromLabel or self.path_item.serviceFields.toLabel + + @property + def source(self) -> str | None: + return self.path_item.serviceFields.fromLabel + + @property + def destination(self) -> str | None: + return self.path_item.serviceFields.toLabel + + @property + def source_port(self) -> InspectPort | None: + return self._resolve_endpoint_port(self.path_item.serviceFields.fromPid) + + @property + def destination_port(self) -> InspectPort | None: + return self._resolve_endpoint_port(self.path_item.serviceFields.toPid) + + @property + def source_device(self) -> InspectDevice | None: + port = self.source_port + if port is not None: + return port.device + devices = self.path_devices + return devices[0] if devices else None + + @property + def destination_device(self) -> InspectDevice | None: + port = self.destination_port + if port is not None: + return port.device + devices = self.path_devices + return devices[-1] if devices else None + + @property + def is_main(self) -> bool | None: + return self.path_item.serviceFields.isMain + + @property + def status(self) -> InspectServiceStatus | None: + return self.path_item.serviceFields.serviceStatus + + @property + def path_devices(self) -> list[InspectDevice]: + devices: list[InspectDevice] = [] + seen: set[str] = set() + for segment in self.path_item.path: + structure = segment.structure + if structure is None or not structure.deviceId or structure.deviceId in seen: + continue + device = self.snapshot.get_device_by_id(structure.deviceId) + if device is not None: + devices.append(device) + seen.add(structure.deviceId) + return devices + + @property + def path_ports(self) -> list[InspectPort]: + ports: list[InspectPort] = [] + seen: set[tuple[str, str]] = set() + for segment in self.path_item.path: + structure = segment.structure + if structure is None or not structure.deviceId: + continue + for endpoint in (structure.inputStatus, structure.outputStatus): + port_id = _port_id_from_endpoint(endpoint) + if not port_id: + continue + key = (structure.deviceId, port_id) + if key in seen: + continue + port = self.snapshot.get_port(structure.deviceId, port_id) + if port is not None: + ports.append(port) + seen.add(key) + return ports + + def _resolve_endpoint_port(self, port_id: str | None) -> InspectPort | None: + if not port_id: + return None + for device in self.path_devices: + port = self.snapshot.get_port(device.id, port_id) + if port is not None: + return port + return self.snapshot.find_port_by_id(port_id) diff --git a/src/videoipath_automation_tool/apps/inspect/model/__init__.py b/src/videoipath_automation_tool/apps/inspect/model/__init__.py new file mode 100644 index 0000000..8b773e0 --- /dev/null +++ b/src/videoipath_automation_tool/apps/inspect/model/__init__.py @@ -0,0 +1,5 @@ +from videoipath_automation_tool.apps.inspect.model.actions import * +from videoipath_automation_tool.apps.inspect.model.collector import * +from videoipath_automation_tool.apps.inspect.model.common import * +from videoipath_automation_tool.apps.inspect.model.ngraph import * +from videoipath_automation_tool.apps.inspect.model.update_topology import * diff --git a/src/videoipath_automation_tool/apps/inspect/model/actions.py b/src/videoipath_automation_tool/apps/inspect/model/actions.py new file mode 100644 index 0000000..e0f329a --- /dev/null +++ b/src/videoipath_automation_tool/apps/inspect/model/actions.py @@ -0,0 +1,102 @@ +from __future__ import annotations + +from typing import Any, Literal + +from pydantic import Field + +from videoipath_automation_tool.apps.inspect.model.common import ( + InspectApiBaseModel, + InspectApiDescriptor, + InspectApiPostRequestHeader, + InspectApiRestV2Header, +) + + +class InspectApiLookupInspectDeviceRequest(InspectApiBaseModel): + header: InspectApiPostRequestHeader = Field(default_factory=InspectApiPostRequestHeader) + data: str + + +class InspectApiAssignedTags(InspectApiBaseModel): + all: list[str] = Field(default_factory=list) + inherited: dict[str, Any] = Field(default_factory=dict) + inheritedConflict: bool = False + local: dict[str, Any] = Field(default_factory=dict) + + +class InspectApiLookupInspectDeviceFields(InspectApiBaseModel): + coordinates: dict[str, float | int] | None = None + descriptor: InspectApiDescriptor + iconSize: str | None = None + iconType: str | None = None + localAssignedTags: list[str] = Field(default_factory=list) + sdpStrategy: str | None = None + siteId: str | None = None + tags: list[str] = Field(default_factory=list) + virtualDeviceFields: dict[str, Any] | None = None + + +class InspectApiLookupInspectDeviceResponseData(InspectApiBaseModel): + assignedTags: InspectApiAssignedTags + fields: InspectApiLookupInspectDeviceFields + + +class InspectApiLookupInspectDeviceResponse(InspectApiBaseModel): + data: InspectApiLookupInspectDeviceResponseData + header: InspectApiRestV2Header + + +class InspectApiLookupSyncInfoRequest(InspectApiBaseModel): + header: InspectApiPostRequestHeader = Field(default_factory=InspectApiPostRequestHeader) + data: list[str] + + +class InspectApiLookupSyncInfoItem(InspectApiBaseModel): + add: dict[str, Any] = Field(default_factory=dict) + label: str + remove: dict[str, Any] = Field(default_factory=dict) + severity: int | str | None = None + update: dict[str, Any] = Field(default_factory=dict) + + +class InspectApiLookupSyncInfoResponse(InspectApiBaseModel): + data: dict[str, InspectApiLookupSyncInfoItem] + header: InspectApiRestV2Header + + +class InspectApiAddDevicesItem(InspectApiBaseModel): + id: str + x: float | int + y: float | int + + +class InspectApiAddDevicesRequest(InspectApiBaseModel): + header: InspectApiPostRequestHeader = Field(default_factory=InspectApiPostRequestHeader) + data: list[InspectApiAddDevicesItem] + + +class InspectApiSyncDevicesRequestData(InspectApiBaseModel): + ids: list[str] = Field(default_factory=list) + addOnly: bool + conflictStrategy: Literal[0, 1, 2] + + +class InspectApiSyncDevicesRequest(InspectApiBaseModel): + header: InspectApiPostRequestHeader = Field(default_factory=InspectApiPostRequestHeader) + data: InspectApiSyncDevicesRequestData + + +__all__ = [ + "InspectApiAddDevicesItem", + "InspectApiAddDevicesRequest", + "InspectApiAssignedTags", + "InspectApiLookupInspectDeviceFields", + "InspectApiLookupInspectDeviceRequest", + "InspectApiLookupInspectDeviceResponse", + "InspectApiLookupInspectDeviceResponseData", + "InspectApiLookupSyncInfoItem", + "InspectApiLookupSyncInfoRequest", + "InspectApiLookupSyncInfoResponse", + "InspectApiSyncDevicesRequest", + "InspectApiSyncDevicesRequestData", +] diff --git a/src/videoipath_automation_tool/apps/inspect/model/collector.py b/src/videoipath_automation_tool/apps/inspect/model/collector.py new file mode 100644 index 0000000..1939fa7 --- /dev/null +++ b/src/videoipath_automation_tool/apps/inspect/model/collector.py @@ -0,0 +1,264 @@ +from __future__ import annotations + +from typing import Any, Literal + +from pydantic import Field + +from videoipath_automation_tool.apps.inspect.model.common import ( + InspectApiBaseModel, + InspectApiCollection, + InspectApiDescriptor, + InspectApiEndpointStatus, + InspectApiRestV2Header, + InspectServiceStatus, + InspectApiStatusSummary, +) + + +class InspectApiGenericServiceFields(InspectApiBaseModel): + allocationState: int | None = None + cancelTime: str | None = None + descriptor: InspectApiDescriptor | None = None + locked: bool | None = None + state: int | None = None + tags: list[str] = Field(default_factory=list) + + +class InspectApiPathServiceFields(InspectApiBaseModel): + bid: str + ctype: int | None = None + formatSubState: int | None = None + from_: str | None = Field(default=None, alias="from") + fromLabel: str | None = None + fromPid: str | None = None + fromStatus: InspectApiStatusSummary | None = None + generic: InspectApiGenericServiceFields | None = None + isMain: bool | None = None + serviceStatus: InspectServiceStatus | None = None + to: str | None = None + toLabel: str | None = None + toPid: str | None = None + toStatus: InspectApiStatusSummary | None = None + + +class InspectApiPathStructure(InspectApiBaseModel): + deviceId: str | None = None + deviceLabel: str | None = None + devicePid: str | None = None + expectConfig: bool | None = None + inputStatus: InspectApiEndpointStatus | None = None + moduleAndDeviceStatus: InspectApiStatusSummary | None = None + outputStatus: InspectApiEndpointStatus | None = None + + +class InspectApiPathSegment(InspectApiBaseModel): + bid: str + ipDesc: str | None = None + structure: InspectApiPathStructure | None = None + + +class InspectApiPathItem(InspectApiBaseModel): + id: str = Field(alias="_id") + vid: str = Field(alias="_vid") + path: list[InspectApiPathSegment] = Field(default_factory=list) + serviceFields: InspectApiPathServiceFields + + +class InspectApiNodeMeta(InspectApiBaseModel): + coordinates: dict[str, float | int | str | None] | None = None + + +class InspectApiVertexInfoFields(InspectApiBaseModel): + isActive: bool | None = None + isControlled: bool | None = None + isEndpoint: bool | None = None + + +class InspectApiSingleVertexInfo(InspectApiBaseModel): + type: Literal["single"] = "single" + id: str | None = None + label: str | None = None + vertexType: str | None = None + fields: InspectApiVertexInfoFields | None = None + + +class InspectApiDoubleVertexInfo(InspectApiBaseModel): + type: Literal["double"] = "double" + in_: InspectApiSingleVertexInfo | None = Field(default=None, alias="in") + out: InspectApiSingleVertexInfo | None = None + + +class InspectApiPathDescriptionItem(InspectApiBaseModel): + bookingId: str | None = None + deviceLevel: dict[str, Any] | None = None + fromStatus: InspectApiStatusSummary | None = None + isMain: bool | None = None + serviceLabel: str | None = None + serviceLevel: dict[str, Any] | None = None + serviceStatus: InspectServiceStatus | InspectApiStatusSummary | None = None + toStatus: InspectApiStatusSummary | None = None + + +class InspectPortStatus(InspectApiBaseModel): + id: str | None = Field(default=None, alias="_id") + vid: str | None = Field(default=None, alias="_vid") + label: str | None = None + pid: str | None = None + status: InspectApiStatusSummary | None = None + vertexInfo: InspectApiSingleVertexInfo | InspectApiDoubleVertexInfo | dict[str, Any] | None = None + pathDescriptions: dict[str, InspectApiPathDescriptionItem] = Field(default_factory=dict) + + +class InspectApiModuleStatus(InspectApiBaseModel): + id: str | None = Field(default=None, alias="_id") + vid: str | None = Field(default=None, alias="_vid") + label: str | None = None + pid: str | None = None + ports: dict[str, InspectPortStatus] | list[InspectPortStatus] = Field(default_factory=dict) + status: InspectApiStatusSummary | None = None + + +class InspectApiNodeStatusItem(InspectApiBaseModel): + id: str = Field(alias="_id") + vid: str | None = Field(default=None, alias="_vid") + deviceId: str | None = None + hasEndpoints: bool | None = None + label: str | None = None + meta: InspectApiNodeMeta | None = None + modules: dict[str, InspectApiModuleStatus] | list[InspectApiModuleStatus] = Field(default_factory=dict) + pathDescriptions: dict[str, InspectApiPathDescriptionItem] = Field(default_factory=dict) + pid: str | None = None + ptpDeviceStatus: dict[str, Any] | None = None + status: InspectApiStatusSummary | None = None + syncSeverity: int | str | None = None + tags: list[str] = Field(default_factory=list) + + +class InspectApiExternalEdgeLiveStatus(InspectApiBaseModel): + alarm: int | str | None = None + bandwidth: int | float | str | None = None + maintenance: int | str | None = None + ptp: int | str | None = None + + +class InspectApiExternalEdgeStatus(InspectApiBaseModel): + bandwidth: float | int | None = None + fromStatus: InspectApiEndpointStatus | None = None + id: str + maxBandwidth: float | int | None = None + pathDescriptions: dict[str, InspectApiPathDescriptionItem] = Field(default_factory=dict) + ratio: float | int | None = None + status: InspectApiExternalEdgeLiveStatus | None = None + toStatus: InspectApiEndpointStatus | None = None + + +class InspectApiExternalEdgeSide(InspectApiBaseModel): + data: dict[str, InspectApiExternalEdgeStatus] = Field(default_factory=dict) + devicePid: str | None = None + label: str | None = None + + +class InspectApiExternalEdgesByDeviceKeyItem(InspectApiBaseModel): + id: str = Field(alias="_id") + vid: str = Field(alias="_vid") + primary: InspectApiExternalEdgeSide + secondary: InspectApiExternalEdgeSide + status: InspectApiExternalEdgeLiveStatus | None = None + + +class InspectApiMaintenanceBookingItem(InspectApiBaseModel): + id: str = Field(alias="_id") + vid: str | None = Field(default=None, alias="_vid") + + +class InspectApiSuperProfileItem(InspectApiBaseModel): + id: str = Field(alias="_id") + vid: str | None = Field(default=None, alias="_vid") + + +class InspectApiTagInfoItem(InspectApiBaseModel): + id: str = Field(alias="_id") + vid: str | None = Field(default=None, alias="_vid") + + +class InspectApiCollectorInspect(InspectApiBaseModel): + nodeStatus: InspectApiCollection = Field(default_factory=InspectApiCollection) + paths: InspectApiCollection = Field(default_factory=InspectApiCollection) + + @property + def node_status_items(self) -> list[InspectApiNodeStatusItem]: + return [InspectApiNodeStatusItem.model_validate(item) for item in self.nodeStatus.items] + + @property + def path_items(self) -> list[InspectApiPathItem]: + return [InspectApiPathItem.model_validate(item) for item in self.paths.items] + + +class InspectApiCollector(InspectApiBaseModel): + inspect: InspectApiCollectorInspect = Field(default_factory=InspectApiCollectorInspect) + externalEdgesByDeviceKey: InspectApiCollection = Field(default_factory=InspectApiCollection) + maintenanceBookings: InspectApiCollection = Field(default_factory=InspectApiCollection) + security: dict[str, Any] = Field(default_factory=dict) + superProfiles: InspectApiCollection = Field(default_factory=InspectApiCollection) + tagInfo: InspectApiCollection = Field(default_factory=InspectApiCollection) + + @property + def external_edges_by_device_key_items(self) -> list[InspectApiExternalEdgesByDeviceKeyItem]: + return [ + InspectApiExternalEdgesByDeviceKeyItem.model_validate(item) + for item in self.externalEdgesByDeviceKey.items + ] + + @property + def maintenance_booking_items(self) -> list[InspectApiMaintenanceBookingItem]: + return [InspectApiMaintenanceBookingItem.model_validate(item) for item in self.maintenanceBookings.items] + + @property + def super_profile_items(self) -> list[InspectApiSuperProfileItem]: + return [InspectApiSuperProfileItem.model_validate(item) for item in self.superProfiles.items] + + @property + def tag_info_items(self) -> list[InspectApiTagInfoItem]: + return [InspectApiTagInfoItem.model_validate(item) for item in self.tagInfo.items] + + +class InspectApiCollectorStatus(InspectApiBaseModel): + collector: InspectApiCollector = Field(default_factory=InspectApiCollector) + + +class InspectApiCollectorResponseData(InspectApiBaseModel): + status: InspectApiCollectorStatus + + +class InspectApiCollectorResponse(InspectApiBaseModel): + data: InspectApiCollectorResponseData + header: InspectApiRestV2Header + + +__all__ = [ + "InspectApiCollector", + "InspectApiCollectorInspect", + "InspectApiCollectorResponse", + "InspectApiCollectorResponseData", + "InspectApiCollectorStatus", + "InspectApiDoubleVertexInfo", + "InspectApiExternalEdgeLiveStatus", + "InspectApiExternalEdgeSide", + "InspectApiExternalEdgeStatus", + "InspectApiExternalEdgesByDeviceKeyItem", + "InspectApiGenericServiceFields", + "InspectApiMaintenanceBookingItem", + "InspectApiModuleStatus", + "InspectApiNodeMeta", + "InspectApiNodeStatusItem", + "InspectApiPathDescriptionItem", + "InspectApiPathItem", + "InspectApiPathSegment", + "InspectApiPathServiceFields", + "InspectApiPathStructure", + "InspectPortStatus", + "InspectApiSingleVertexInfo", + "InspectApiSuperProfileItem", + "InspectApiTagInfoItem", + "InspectApiVertexInfoFields", +] diff --git a/src/videoipath_automation_tool/apps/inspect/model/common.py b/src/videoipath_automation_tool/apps/inspect/model/common.py new file mode 100644 index 0000000..68e9539 --- /dev/null +++ b/src/videoipath_automation_tool/apps/inspect/model/common.py @@ -0,0 +1,87 @@ +from __future__ import annotations + +from typing import Any + +from pydantic import BaseModel, ConfigDict, Field + + +class InspectApiBaseModel(BaseModel): + model_config = ConfigDict(populate_by_name=True, validate_assignment=True, extra="allow") + + +class InspectApiDescriptor(InspectApiBaseModel): + desc: str = "" + label: str = "" + + +class InspectApiCollection(InspectApiBaseModel): + items: list[dict[str, Any]] = Field(default_factory=list, alias="_items") + + +class InspectApiStatusSummary(InspectApiBaseModel): + sa: int | str | None = None + severity: int | str | None = None + + +class InspectApiStatusContext(InspectApiBaseModel): + devicePid: str | None = None + modulePid: str | None = None + portPid: str | None = None + + +class InspectApiEndpointStatus(InspectApiBaseModel): + context: InspectApiStatusContext | None = None + label: str | None = None + pid: str | None = None + status: InspectApiStatusSummary | None = None + + +class InspectServiceStatus(InspectApiBaseModel): + config: InspectApiStatusSummary | None = None + total: InspectApiStatusSummary | None = None + + +class InspectApiRestV2Header(InspectApiBaseModel): + auth: bool + caption: str + code: str + errorCodes: list[Any] = Field(default_factory=list) + errorDetails: list[Any] = Field(default_factory=list) + id: str + msg: list[str] = Field(default_factory=list) + ok: bool + user: str + + +class InspectApiPostRequestHeader(InspectApiBaseModel): + id: int = 0 + + +class InspectApiSimpleActionResult(InspectApiBaseModel): + msg: list[str] = Field(default_factory=list) + ok: bool + + +class InspectApiSimpleActionResponse(InspectApiBaseModel): + data: InspectApiSimpleActionResult + header: InspectApiRestV2Header + + +class InspectApiActionValidationErrorResponse(InspectApiBaseModel): + header: InspectApiRestV2Header + + +__all__ = [ + "InspectApiActionValidationErrorResponse", + "InspectApiBaseModel", + "InspectApiCollection", + "InspectApiDescriptor", + "InspectApiEndpointStatus", + "InspectApiPostRequestHeader", + "InspectApiRestV2Header", + "InspectServiceStatus", + "InspectApiSimpleActionResponse", + "InspectApiSimpleActionResult", + "InspectApiStatusContext", + "InspectApiStatusSummary", +] diff --git a/src/videoipath_automation_tool/apps/inspect/model/ngraph.py b/src/videoipath_automation_tool/apps/inspect/model/ngraph.py new file mode 100644 index 0000000..80fb466 --- /dev/null +++ b/src/videoipath_automation_tool/apps/inspect/model/ngraph.py @@ -0,0 +1,147 @@ +from __future__ import annotations + +from typing import Any, Literal + +from pydantic import Field + +from videoipath_automation_tool.apps.inspect.model.common import ( + InspectApiBaseModel, + InspectApiDescriptor, +) + + +InspectApiNGraphElementType = Literal[ + "baseDevice", + "codecVertex", + "genericVertex", + "ipVertex", + "nGraphResourceTransform", + "unidirectionalEdge", +] + + +class InspectApiGpid(InspectApiBaseModel): + component: int | None = None + pointId: list[str] = Field(default_factory=list) + + +class InspectApiMapElement(InspectApiBaseModel): + cType: str = "Topology" + id: str = "" + name: str = "" + visible: bool = True + x: float = 0.0 + y: float = 0.0 + + +class InspectApiNGraphElement(InspectApiBaseModel): + id: str = Field(alias="_id") + rev: str | None = Field(default=None, alias="_rev") + vid: str | None = Field(default=None, alias="_vid") + descriptor: InspectApiDescriptor = Field(default_factory=InspectApiDescriptor) + fDescriptor: InspectApiDescriptor = Field(default_factory=InspectApiDescriptor) + tags: list[str] = Field(default_factory=list) + type: InspectApiNGraphElementType + + +class InspectApiBaseDevice(InspectApiNGraphElement): + type: Literal["baseDevice"] = "baseDevice" + iconSize: str = "medium" + iconType: str = "default" + isVirtual: bool = False + maps: list[InspectApiMapElement] = Field(default_factory=list) + sdpStrategy: str = "always" + siteId: str | None = None + + +class InspectApiVertex(InspectApiNGraphElement): + deviceId: str + gpid: InspectApiGpid | None = None + configPriority: str | int | None = None + control: str | int | None = None + custom: dict[str, Any] = Field(default_factory=dict) + extraAlertFilters: list[Any] = Field(default_factory=list) + imgUrl: str | None = None + isVirtual: bool = False + maps: list[InspectApiMapElement] = Field(default_factory=list) + sipsMode: str | None = None + useAsEndpoint: bool | None = None + vertexType: str | None = None + + +class InspectApiIpVertex(InspectApiVertex): + type: Literal["ipVertex"] = "ipVertex" + ipAddress: str | None = None + ipNetmask: str | None = None + public: bool | None = None + supportsCpipeCfg: bool | None = None + supportsIgmpCfg: bool | None = None + supportsMacForwardingCfg: bool | None = None + supportsNsoCfg: bool | None = None + supportsOpenflowCfg: bool | None = None + supportsStaticIgmpCfg: bool | None = None + supportsVlanCfg: bool | None = None + supportsVplsCfg: bool | None = None + vlanId: str | None = None + vrfId: str | None = None + + +class InspectApiCodecVertex(InspectApiVertex): + type: Literal["codecVertex"] = "codecVertex" + codecFormat: str | None = None + codecType: str | None = None + + +class InspectApiGenericVertex(InspectApiVertex): + type: Literal["genericVertex"] = "genericVertex" + + +class InspectApiWeightFactorBandwidth(InspectApiBaseModel): + weight: int = 0 + + +class InspectApiWeightFactorService(InspectApiBaseModel): + max: int = 100 + weight: int = 0 + + +class InspectApiWeightFactors(InspectApiBaseModel): + bandwidth: InspectApiWeightFactorBandwidth = Field(default_factory=InspectApiWeightFactorBandwidth) + service: InspectApiWeightFactorService = Field(default_factory=InspectApiWeightFactorService) + + +class InspectApiUnidirectionalEdge(InspectApiNGraphElement): + type: Literal["unidirectionalEdge"] = "unidirectionalEdge" + active: bool = True + bandwidth: float | int = -1.0 + capacity: int = 65535 + conflictPri: int | str = 0 + excludeFormats: list[str] = Field(default_factory=list) + fromId: str + includeFormats: list[str] = Field(default_factory=list) + redundancyMode: str = "Any" + toId: str + weight: int = 0 + weightFactors: InspectApiWeightFactors = Field(default_factory=InspectApiWeightFactors) + + +class InspectApiNGraphResourceTransform(InspectApiNGraphElement): + type: Literal["nGraphResourceTransform"] = "nGraphResourceTransform" + + +__all__ = [ + "InspectApiBaseDevice", + "InspectApiCodecVertex", + "InspectApiGenericVertex", + "InspectApiGpid", + "InspectApiIpVertex", + "InspectApiMapElement", + "InspectApiNGraphElement", + "InspectApiNGraphElementType", + "InspectApiNGraphResourceTransform", + "InspectApiUnidirectionalEdge", + "InspectApiVertex", + "InspectApiWeightFactorBandwidth", + "InspectApiWeightFactorService", + "InspectApiWeightFactors", +] diff --git a/src/videoipath_automation_tool/apps/inspect/model/update_topology.py b/src/videoipath_automation_tool/apps/inspect/model/update_topology.py new file mode 100644 index 0000000..f15af97 --- /dev/null +++ b/src/videoipath_automation_tool/apps/inspect/model/update_topology.py @@ -0,0 +1,88 @@ +from __future__ import annotations + +from typing import Any + +from pydantic import Field + +from videoipath_automation_tool.apps.inspect.model.common import ( + InspectApiBaseModel, + InspectApiPostRequestHeader, + InspectApiRestV2Header, +) +from videoipath_automation_tool.apps.inspect.model.ngraph import ( + InspectApiBaseDevice, + InspectApiCodecVertex, + InspectApiGenericVertex, + InspectApiIpVertex, + InspectApiNGraphResourceTransform, + InspectApiUnidirectionalEdge, +) + + +InspectApiReplaceVertex = InspectApiIpVertex | InspectApiCodecVertex | InspectApiGenericVertex | dict[str, Any] +InspectApiReplaceDevice = InspectApiBaseDevice | dict[str, Any] +InspectApiReplaceResourceTransform = InspectApiNGraphResourceTransform | dict[str, Any] + + +class InspectApiUpdateTopologyData(InspectApiBaseModel): + replaceDevices: dict[str, InspectApiReplaceDevice] = Field(default_factory=dict) + replaceVertices: dict[str, InspectApiReplaceVertex] = Field(default_factory=dict) + replaceEdges: dict[str, InspectApiUnidirectionalEdge] = Field(default_factory=dict) + replaceResourceTransforms: dict[str, InspectApiReplaceResourceTransform] = Field(default_factory=dict) + addExternalEdges: list[InspectApiUnidirectionalEdge] = Field(default_factory=list) + remove: list[str] = Field(default_factory=list) + force: bool = False + + +class InspectApiUpdateTopologyRequest(InspectApiBaseModel): + header: InspectApiPostRequestHeader = Field(default_factory=InspectApiPostRequestHeader) + data: InspectApiUpdateTopologyData = Field(default_factory=InspectApiUpdateTopologyData) + + +class InspectApiUpdateTopologyResult(InspectApiBaseModel): + msg: list[str] = Field(default_factory=list) + ok: bool + + +class InspectApiUpdateTopologyValidationDetail(InspectApiBaseModel): + isCancel: bool | None = None + isProduct: bool | None = None + resolvable: bool | None = None + rev: str | None = None + status: int | str | None = None + type: str | None = None + + +class InspectApiUpdateTopologyValidation(InspectApiBaseModel): + createIds: list[str] = Field(default_factory=list) + details: dict[str, InspectApiUpdateTopologyValidationDetail] = Field(default_factory=dict) + result: InspectApiUpdateTopologyResult + + +class InspectApiUpdateTopologyResponseData(InspectApiBaseModel): + items: list[dict[str, Any]] = Field(default_factory=list) + res: InspectApiUpdateTopologyResult + validation: InspectApiUpdateTopologyValidation + + +class InspectApiUpdateTopologyResponse(InspectApiBaseModel): + data: InspectApiUpdateTopologyResponseData + header: InspectApiRestV2Header + + @property + def committed(self) -> bool: + return self.header.ok and self.data.res.ok and self.data.validation.result.ok + + +__all__ = [ + "InspectApiReplaceDevice", + "InspectApiReplaceResourceTransform", + "InspectApiReplaceVertex", + "InspectApiUpdateTopologyData", + "InspectApiUpdateTopologyRequest", + "InspectApiUpdateTopologyResponse", + "InspectApiUpdateTopologyResponseData", + "InspectApiUpdateTopologyResult", + "InspectApiUpdateTopologyValidation", + "InspectApiUpdateTopologyValidationDetail", +] diff --git a/src/videoipath_automation_tool/apps/inspect/snapshot.py b/src/videoipath_automation_tool/apps/inspect/snapshot.py new file mode 100644 index 0000000..b685d5c --- /dev/null +++ b/src/videoipath_automation_tool/apps/inspect/snapshot.py @@ -0,0 +1,408 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import TYPE_CHECKING, Any, Iterator + +from videoipath_automation_tool.apps.inspect.model.collector import ( + InspectApiCollectorResponse, + InspectApiExternalEdgeStatus, + InspectApiModuleStatus, + InspectApiNodeStatusItem, + InspectApiPathItem, + InspectPortStatus, +) + +if TYPE_CHECKING: + from videoipath_automation_tool.apps.inspect.domain.device import InspectDevice + from videoipath_automation_tool.apps.inspect.domain.edge import InspectEdge + from videoipath_automation_tool.apps.inspect.domain.port import InspectPort + from videoipath_automation_tool.apps.inspect.domain.service import InspectService + + +@dataclass(frozen=True, slots=True) +class _DeviceRecord: + device_id: str + label: str | None + pid: str | None + node: InspectApiNodeStatusItem | None + + +@dataclass(frozen=True, slots=True) +class _IndexedPort: + device_id: str + module_id: str | None + port: InspectPortStatus + + +@dataclass(frozen=True, slots=True) +class _IndexedEdge: + edge_id: str + pair_id: str + edge: InspectApiExternalEdgeStatus + primary_device_id: str | None + secondary_device_id: str | None + from_device_id: str | None + from_port_id: str | None + to_device_id: str | None + to_port_id: str | None + + +class InspectSnapshot: + def __init__(self, response: InspectApiCollectorResponse) -> None: + self._response = response + collector = response.data.status.collector + + self._node_status_items = collector.inspect.node_status_items + self._path_items = collector.inspect.path_items + self._external_edge_items = collector.external_edges_by_device_key_items + + self._devices_by_id: dict[str, _DeviceRecord] = {} + self._devices_by_label: dict[str, list[str]] = {} + self._paths_by_booking_id: dict[str, InspectApiPathItem] = {} + self._services_by_device_id: dict[str, list[str]] = {} + self._ports_by_device_id: dict[str, list[_IndexedPort]] = {} + self._port_by_key: dict[tuple[str, str], _IndexedPort] = {} + self._ports_by_pid: dict[str, list[_IndexedPort]] = {} + self._edges_by_device_id: dict[str, list[_IndexedEdge]] = {} + self._edge_by_port_key: dict[tuple[str, str], _IndexedEdge] = {} + + self._device_cache: dict[str, InspectDevice] = {} + self._port_cache: dict[tuple[str, str], InspectPort] = {} + self._edge_cache: dict[str, InspectEdge] = {} + self._service_cache: dict[str, InspectService] = {} + self._ports_for_device_cache: dict[str, list[InspectPort]] = {} + self._edges_for_device_cache: dict[str, list[InspectEdge]] = {} + self._services_for_device_cache: dict[str, list[InspectService]] = {} + + self._build_device_indexes() + self._build_path_indexes() + self._build_port_indexes() + self._build_edge_indexes() + + @property + def raw_response(self) -> InspectApiCollectorResponse: + return self._response + + @classmethod + def from_response(cls, response: InspectApiCollectorResponse) -> InspectSnapshot: + return cls(response) + + def get_device_by_id(self, device_id: str) -> InspectDevice | None: + record = self._devices_by_id.get(device_id) + if record is None: + return None + return self._wrap_device(record) + + def find_devices_by_name(self, label: str) -> list[InspectDevice]: + devices: list[InspectDevice] = [] + for device_id in self._devices_by_label.get(label, []): + device = self.get_device_by_id(device_id) + if device is not None: + devices.append(device) + return devices + + def get_devices(self) -> list[InspectDevice]: + return [self._wrap_device(record) for record in self._devices_by_id.values()] + + def get_service_by_booking_id(self, booking_id: str) -> InspectService | None: + path_item = self._paths_by_booking_id.get(booking_id) + if path_item is None: + return None + return self._wrap_service(path_item) + + def get_services(self) -> list[InspectService]: + return [self._wrap_service(item) for item in self._path_items] + + def get_port(self, device_id: str, port_id: str) -> InspectPort | None: + indexed = self._port_by_key.get((device_id, port_id)) + if indexed is None: + return None + return self._wrap_port(indexed) + + def find_port_by_id(self, port_id: str) -> InspectPort | None: + indexed_ports = self._ports_by_pid.get(port_id) + if not indexed_ports: + return None + return self._wrap_port(indexed_ports[0]) + + def get_ports_for_device(self, device_id: str) -> list[InspectPort]: + cached = self._ports_for_device_cache.get(device_id) + if cached is not None: + return cached + ports = [self._wrap_port(indexed) for indexed in self._ports_by_device_id.get(device_id, [])] + self._ports_for_device_cache[device_id] = ports + return ports + + def get_edge_for_port(self, device_id: str, port_id: str) -> InspectEdge | None: + indexed = self._edge_by_port_key.get((device_id, port_id)) + if indexed is None: + return None + return self._wrap_edge(indexed) + + def get_edges(self) -> list[InspectEdge]: + seen: set[str] = set() + edges: list[InspectEdge] = [] + for indexed_edges in self._edges_by_device_id.values(): + for indexed in indexed_edges: + if indexed.edge_id in seen: + continue + seen.add(indexed.edge_id) + edges.append(self._wrap_edge(indexed)) + return edges + + def get_edges_for_device(self, device_id: str) -> list[InspectEdge]: + cached = self._edges_for_device_cache.get(device_id) + if cached is not None: + return cached + seen: set[str] = set() + edges: list[InspectEdge] = [] + for indexed in self._edges_by_device_id.get(device_id, []): + if indexed.edge_id in seen: + continue + seen.add(indexed.edge_id) + edges.append(self._wrap_edge(indexed)) + self._edges_for_device_cache[device_id] = edges + return edges + + def get_services_for_device(self, device_id: str) -> list[InspectService]: + cached = self._services_for_device_cache.get(device_id) + if cached is not None: + return cached + services: list[InspectService] = [] + for booking_id in self._services_by_device_id.get(device_id, []): + service = self.get_service_by_booking_id(booking_id) + if service is not None: + services.append(service) + self._services_for_device_cache[device_id] = services + return services + + def get_linked_devices(self, device_id: str) -> list[InspectDevice]: + linked: set[str] = set() + for indexed in self._edges_by_device_id.get(device_id, []): + for candidate in (indexed.from_device_id, indexed.to_device_id): + if candidate and candidate != device_id: + linked.add(candidate) + for booking_id in self._services_by_device_id.get(device_id, []): + path_item = self._paths_by_booking_id.get(booking_id) + if path_item is None: + continue + for segment in path_item.path: + structure = segment.structure + if structure and structure.deviceId and structure.deviceId != device_id: + linked.add(structure.deviceId) + return [device for linked_id in sorted(linked) if (device := self.get_device_by_id(linked_id)) is not None] + + def _wrap_device(self, record: _DeviceRecord) -> InspectDevice: + from videoipath_automation_tool.apps.inspect.domain.device import InspectDevice + + cached = self._device_cache.get(record.device_id) + if cached is not None: + return cached + device = InspectDevice(snapshot=self, record=record) + self._device_cache[record.device_id] = device + return device + + def _wrap_port(self, indexed: _IndexedPort) -> InspectPort: + from videoipath_automation_tool.apps.inspect.domain.port import InspectPort + + port_id = _port_id_from_status(indexed.port) + if port_id is not None: + key = (indexed.device_id, port_id) + cached = self._port_cache.get(key) + if cached is not None: + return cached + port = InspectPort(snapshot=self, indexed=indexed) + self._port_cache[key] = port + return port + return InspectPort(snapshot=self, indexed=indexed) + + def _wrap_edge(self, indexed: _IndexedEdge) -> InspectEdge: + from videoipath_automation_tool.apps.inspect.domain.edge import InspectEdge + + cached = self._edge_cache.get(indexed.edge_id) + if cached is not None: + return cached + edge = InspectEdge(snapshot=self, indexed=indexed) + self._edge_cache[indexed.edge_id] = edge + return edge + + def _wrap_service(self, path_item: InspectApiPathItem) -> InspectService: + from videoipath_automation_tool.apps.inspect.domain.service import InspectService + + booking_id = path_item.serviceFields.bid + cached = self._service_cache.get(booking_id) + if cached is not None: + return cached + service = InspectService(snapshot=self, path_item=path_item) + self._service_cache[booking_id] = service + return service + + def _build_device_indexes(self) -> None: + for node in self._node_status_items: + device_id = node.deviceId or node.pid or node.id + if not device_id: + continue + self._upsert_device_record( + _DeviceRecord( + device_id=device_id, + label=node.label, + pid=node.pid, + node=node, + ) + ) + + for path_item in self._path_items: + for segment in path_item.path: + structure = segment.structure + if structure is None or not structure.deviceId: + continue + existing = self._devices_by_id.get(structure.deviceId) + if existing is not None and existing.node is not None: + continue + self._upsert_device_record( + _DeviceRecord( + device_id=structure.deviceId, + label=structure.deviceLabel, + pid=structure.devicePid, + node=existing.node if existing else None, + ) + ) + + def _upsert_device_record(self, record: _DeviceRecord) -> None: + existing = self._devices_by_id.get(record.device_id) + if existing is not None and existing.node is not None and record.node is None: + merged = existing + elif existing is not None and record.node is not None: + merged = _DeviceRecord( + device_id=record.device_id, + label=record.label or existing.label, + pid=record.pid or existing.pid, + node=record.node, + ) + else: + merged = record + + self._devices_by_id[merged.device_id] = merged + if merged.label: + labels = self._devices_by_label.setdefault(merged.label, []) + if merged.device_id not in labels: + labels.append(merged.device_id) + + def _build_path_indexes(self) -> None: + for path_item in self._path_items: + booking_id = path_item.serviceFields.bid + self._paths_by_booking_id[booking_id] = path_item + device_ids: set[str] = set() + for segment in path_item.path: + structure = segment.structure + if structure and structure.deviceId: + device_ids.add(structure.deviceId) + for device_id in device_ids: + booking_ids = self._services_by_device_id.setdefault(device_id, []) + if booking_id not in booking_ids: + booking_ids.append(booking_id) + + def _build_port_indexes(self) -> None: + for node in self._node_status_items: + device_id = node.deviceId or node.pid or node.id + if not device_id: + continue + for module in _iter_modules(node.modules): + module_id = module.pid or module.id + for port in _iter_ports(module.ports): + indexed = _IndexedPort(device_id=device_id, module_id=module_id, port=port) + self._ports_by_device_id.setdefault(device_id, []).append(indexed) + port_id = _port_id_from_status(port) + if port_id is None: + continue + key = (device_id, port_id) + self._port_by_key[key] = indexed + self._ports_by_pid.setdefault(port_id, []).append(indexed) + + def _build_edge_indexes(self) -> None: + for pair_item in self._external_edge_items: + primary_device_id = pair_item.primary.devicePid + secondary_device_id = pair_item.secondary.devicePid + for side, device_id in ( + (pair_item.primary, primary_device_id), + (pair_item.secondary, secondary_device_id), + ): + if not device_id: + continue + for edge in side.data.values(): + from_device_id = ( + _device_id_from_context(edge.fromStatus.context if edge.fromStatus else None) + or primary_device_id + ) + from_port_id = _port_id_from_endpoint(edge.fromStatus) + to_device_id = ( + _device_id_from_context(edge.toStatus.context if edge.toStatus else None) + or secondary_device_id + ) + to_port_id = _port_id_from_endpoint(edge.toStatus) + indexed = _IndexedEdge( + edge_id=edge.id, + pair_id=pair_item.id, + edge=edge, + primary_device_id=primary_device_id, + secondary_device_id=secondary_device_id, + from_device_id=from_device_id, + from_port_id=from_port_id, + to_device_id=to_device_id, + to_port_id=to_port_id, + ) + self._edges_by_device_id.setdefault(device_id, []).append(indexed) + for endpoint_device_id, port_id in ( + (from_device_id, from_port_id), + (to_device_id, to_port_id), + ): + if endpoint_device_id and port_id: + self._edge_by_port_key[(endpoint_device_id, port_id)] = indexed + + +def _device_id_from_context(context: Any) -> str | None: + if context is None: + return None + device_pid = getattr(context, "devicePid", None) + if device_pid: + return device_pid + if isinstance(context, dict): + value = context.get("devicePid") + return value if isinstance(value, str) else None + return None + + +def _port_id_from_endpoint(endpoint: Any) -> str | None: + if endpoint is None: + return None + pid = getattr(endpoint, "pid", None) + if isinstance(pid, str) and pid: + return pid + context = getattr(endpoint, "context", None) + if context is not None: + if isinstance(context, dict): + port_pid = context.get("portPid") + else: + port_pid = getattr(context, "portPid", None) + if isinstance(port_pid, str) and port_pid: + return port_pid + return None + + +def _port_id_from_status(port: InspectPortStatus) -> str | None: + port_id = port.pid or port.id + return port_id if port_id else None + + +def _iter_modules(modules: dict[str, InspectApiModuleStatus] | list[InspectApiModuleStatus]) -> Iterator[InspectApiModuleStatus]: + if isinstance(modules, dict): + yield from modules.values() + return + yield from modules + + +def _iter_ports(ports: dict[str, InspectPortStatus] | list[InspectPortStatus]) -> Iterator[InspectPortStatus]: + if isinstance(ports, dict): + yield from ports.values() + return + yield from ports From d59c663c3b1cf055c59069e9aa263b7d0eda69c6 Mon Sep 17 00:00:00 2001 From: Jonas Scholl <43819845+JonasScholl@users.noreply.github.com> Date: Wed, 8 Jul 2026 15:02:00 +0200 Subject: [PATCH 03/34] research & concept refinement progress --- AGENTS.md | 128 + docs/future/domain-architecture.md | 134 +- docs/inspect-app/README.md | 19 +- docs/inspect-app/concepts.md | 180 +- .../decisions/0002-loading-and-state.md | 9 +- .../decisions/0003-websocket-subscriptions.md | 16 +- .../decisions/0006-commit-write-model.md | 77 +- .../decisions/0007-lazy-snapshot-loading.md | 126 + .../0008-collector-only-endpoints.md | 100 + .../decisions/0009-write-consistency.md | 125 + .../0010-post-commit-snapshot-refresh.md | 100 + docs/inspect-app/decisions/README.md | 6 +- docs/inspect-app/endpoints.md | 584 ++++- docs/inspect-app/models.md | 160 +- .../2025.4.9/action_schema_collector.json | 97 + .../device_hydration_modules_ports.json | 73 + .../inspect/2025.4.9/edge_skeleton.json | 2259 +++++++++++++++++ .../2025.4.9/inspect_paths_limit5.json | 75 + .../2025.4.9/lookup_inspect_edges_by_ids.json | 49 + .../2025.4.9/lookup_inspect_vertex_by_id.json | 60 + .../2025.4.9/nodestatus_noid_collection.json | 265 ++ .../2025.4.9/skeleton_nodestatus_short.json | 268 ++ .../update_topology_fail_booking.json | 49 + .../2025.4.9/update_topology_fail_remove.json | 30 + .../2025.4.9/update_topology_success.json | 40 + 25 files changed, 4884 insertions(+), 145 deletions(-) create mode 100644 AGENTS.md create mode 100644 docs/inspect-app/decisions/0007-lazy-snapshot-loading.md create mode 100644 docs/inspect-app/decisions/0008-collector-only-endpoints.md create mode 100644 docs/inspect-app/decisions/0009-write-consistency.md create mode 100644 docs/inspect-app/decisions/0010-post-commit-snapshot-refresh.md create mode 100644 tests/fixtures/inspect/2025.4.9/action_schema_collector.json create mode 100644 tests/fixtures/inspect/2025.4.9/device_hydration_modules_ports.json create mode 100644 tests/fixtures/inspect/2025.4.9/edge_skeleton.json create mode 100644 tests/fixtures/inspect/2025.4.9/inspect_paths_limit5.json create mode 100644 tests/fixtures/inspect/2025.4.9/lookup_inspect_edges_by_ids.json create mode 100644 tests/fixtures/inspect/2025.4.9/lookup_inspect_vertex_by_id.json create mode 100644 tests/fixtures/inspect/2025.4.9/nodestatus_noid_collection.json create mode 100644 tests/fixtures/inspect/2025.4.9/skeleton_nodestatus_short.json create mode 100644 tests/fixtures/inspect/2025.4.9/update_topology_fail_booking.json create mode 100644 tests/fixtures/inspect/2025.4.9/update_topology_fail_remove.json create mode 100644 tests/fixtures/inspect/2025.4.9/update_topology_success.json diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..4556da3 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,128 @@ +# AGENTS.md + +This file provides guidance for AI coding agents working in this repository. + +## Data anonymization (mandatory) + +**All concrete data committed to this repository must be anonymized.** This applies everywhere: test fixtures, documentation examples, scripts, comments, and any other artifacts that contain VideoIPath or customer-specific information. + +Before adding or modifying data, replace real identifiers with generic placeholders: + +| Category | Do not commit | Use instead | +|---|---|---| +| Hostnames / FQDNs | `vip-prod.example.customer.com` | `vip-server.example` or `device-host-a` | +| IP addresses | Real network addresses | `10.0.0.1`, `192.0.2.1` (RFC 5737 documentation ranges) | +| Usernames / passwords | Real credentials | `test-user`, `test-password` | +| Device / module / port names | Customer site labels | `device-a`, `module-1`, `port-out-1` | +| Service / path / edge labels | Production naming | `service-a`, `path-1`, `edge-a` | +| Organization / site names | Customer or internal names | `example-org`, `site-a` | +| MAC addresses, serial numbers | Real hardware IDs | Synthetic values with no production mapping | + +Rules: + +1. **Never commit live API responses as-is.** Capture from a real server only in a local, untracked workflow; anonymize before staging. +2. **Apply the same rules to docs and inline examples** — a markdown code block with a real hostname is as sensitive as a JSON fixture. +3. **Preserve structure, not identity.** Keep IDs, relationships, and field shapes realistic so tests and docs remain meaningful; only replace identifying values. +4. **Review diffs for leaks.** Scan new files for hostnames, email addresses, customer abbreviations, and internal project codenames. +5. **When in doubt, generalize.** Prefer `device-a` / `port-out-1` style names already used under `tests/fixtures/`. + +Inspect fixtures can be anonymized with `scripts/anonymize_inspect_fixtures.py` when available. + +## Commands + +```bash +# Install all dependencies (including dev and test groups) +poetry install --with dev,test + +# Run all tests +poetry run pytest + +# Run a single test file +poetry run pytest tests/validators/test_device_id.py + +# Lint with auto-fix +poetry run ruff check --fix src/ tests/ + +# Format +poetry run ruff format src/ tests/ + +# Install pre-commit hooks (runs ruff lint+format on commit) +pre-commit install + +# Driver schema CLI tools (after package install) +set-videoipath-version # e.g. 2024.3.3 +get-videoipath-version +list-videoipath-versions +``` + +## Architecture + +### Three-layer design + +``` +VideoIPathApp (public entry point — src/videoipath_automation_tool/apps/videoipath_app.py) + ├── inventory → InventoryApp + ├── topology → TopologyApp + ├── preferences → PreferencesApp + ├── profile → ProfileApp + └── security → SecurityApp + +Each App: + App class (user-facing methods, business logic) + └── *API class (raw API calls, response parsing) + └── VideoIPathConnector (src/videoipath_automation_tool/connector/) + ├── VideoIPathRestConnector (REST v2 GET/PATCH) + └── VideoIPathRPCConnector (RPC POST) +``` + +`VideoIPathApp` lazily initializes each sub-app on first property access. When `VIPAT_ENVIRONMENT=DEV`, the internal `*_api` objects are also exposed directly on the `VideoIPathApp` instance for easier exploration. + +### Connector layer + +`VideoIPathConnector` (`connector/vip_connector.py`) wraps two low-level connectors for the two VideoIPath API styles: +- **REST connector**: `/rest/v2/data/…` endpoints (GET, PATCH) +- **RPC connector**: RPC POST calls + +Response models live in `connector/models/` as Pydantic models. + +### Inventory app structure + +`InventoryApp` (`apps/inventory/`) uses Python mixins to split its methods across files: +- `app/app.py` — composes `InventoryCreateDeviceMixin`, `InventoryGetDeviceMixin`, etc. +- `inventory_api.py` — raw API methods +- `model/drivers.py` — auto-generated driver schemas; `SELECTED_SCHEMA_VERSION` and `AVAILABLE_SCHEMA_VERSIONS` control which VideoIPath server version is targeted + +### Inspect app (in-progress) + +`apps/inspect/` follows a different, read-only pattern built around `InspectSnapshot`: +- `snapshot.py` — builds in-memory indexes from a bulk API response; domain objects (`InspectDevice`, `InspectPort`, `InspectEdge`, `InspectService`) are created lazily and cached on the snapshot +- `domain/` — thin view objects that hold a back-reference to the snapshot for cross-entity lookups +- `model/` — raw Pydantic models for the API response (`collector.py`, etc.) + +### Driver versioning + +Driver schemas (Pydantic models for device `custom_settings`) are auto-generated from the VideoIPath API's JSON schema and live under `apps/inventory/model/drivers.py`. Run `set-videoipath-version ` to regenerate them for a different server version. The CLI scripts are in `src/vipat_cli_scripts/`. + +### Settings and environment variables + +All configuration is loaded via `Settings` (`settings.py`, backed by `pydantic-settings`). Variables are prefixed `VIPAT_`. Copy `.env.example` to `.env` for local development. Tests use `tests/.env.test`. + +Key variables: +| Variable | Default | Notes | +|---|---|---| +| `VIPAT_ENVIRONMENT` | `PROD` | `DEV` exposes internal APIs on `VideoIPathApp` | +| `VIPAT_VIDEOIPATH_SERVER_ADDRESS` | — | Required | +| `VIPAT_VIDEOIPATH_USERNAME` | — | Required | +| `VIPAT_VIDEOIPATH_PASSWORD` | — | Required | +| `VIPAT_USE_HTTPS` | `true` | | +| `VIPAT_VERIFY_SSL_CERT` | `true` | | +| `VIPAT_LOG_LEVEL` | _(root logger)_ | `DEBUG`/`INFO`/`WARNING`/`ERROR`/`CRITICAL` | +| `VIPAT_ADVANCED_DRIVER_SCHEMA_CHECK` | `true` | Compares local vs. server driver schema on init | + +## Release process + +1. Update `version` in `pyproject.toml` +2. Create a GitHub Release with a `v*` tag +3. CI builds and publishes to PyPI automatically + +Hotfix branches must use the `hotfix/` prefix. Development builds are published automatically for every commit on `main` or open PRs with a `.dev` suffix. diff --git a/docs/future/domain-architecture.md b/docs/future/domain-architecture.md index a228d99..a37b489 100644 --- a/docs/future/domain-architecture.md +++ b/docs/future/domain-architecture.md @@ -9,10 +9,10 @@ > involved, or how the work is split across planes. > > It stands in deliberate contrast to the current -> [`python-module-architecture.md`](./python-module-architecture.md), whose +> [`python-module-architecture.md`](../python-module-architecture.md), whose > stated goal is to *"maintain a structure similar to VideoIPath."* That goal is > exactly what this proposal revisits. The VIP wire format and per-app research -> this builds on lives in [`inspect-app/`](./inspect-app/README.md) +> this builds on lives in [`inspect-app/`](../inspect-app/README.md) > (`concepts.md` and the ADRs); those documents remain the **faithful map of the > server**, while this one is the **map we want to present to users**. > @@ -60,7 +60,7 @@ what*: onboard in Inventory → place/connect in Topology → monitor in Inspect while Inspect writes land back in `nGraphElements`. This is awkward for reasons confirmed against a real server (see -[`inspect-app/concepts.md`](./inspect-app/concepts.md) and the captured +[`inspect-app/concepts.md`](../inspect-app/concepts.md) and the captured `collector` payload): 1. **The app split is VIP's internal structure, not the user's mental model.** @@ -221,7 +221,7 @@ Design principles: `0.0`). - **Status is a first-class, read-only projection**, separate from config. This matches the config-plane vs. status-plane split - ([ADR-0001](./inspect-app/decisions/0001-api-paradigm.md)) and keeps the live + ([ADR-0001](../inspect-app/decisions/0001-api-paradigm.md)) and keeps the live story clean. - **Make illegal states unrepresentable** where cheap (enums for `Direction`, `PortKind`, `Redundancy`, `LifecycleState`, `SyncState`). @@ -250,14 +250,17 @@ is the most likely source of subtle bugs. ## 8. Reads — the collector snapshot as the aggregate root -The `collector` aggregate (`GET …/data/status/collector/**`) returns the -**entire connected graph in one internally-consistent request**: devices, ports, -inter-device connections, services, security, profiles, tags. This eliminates -the N+1 / per-device fan-out and the torn data/rev split of the topology read -path. For "linked devices that have connections too," the server hands you the -whole component at once — **do not** rebuild the domain graph from N per-device -fetches. For onboarding-only attributes not in the collector (driver, -credentials, custom settings), the ACL supplements with an Inventory read. +The `collector` tree is the aggregate root for reads. Loading follows +[ADR-0007](../inspect-app/decisions/0007-lazy-snapshot-loading.md): +a **skeleton** query pair fetches the whole graph structure (all devices +without module/port detail + all edges with a lean projection) in one +consistent round, and per-device subtrees are **lazily hydrated** on demand. +The rule: never rebuild the *graph structure* from N per-device fetches — the +skeleton delivers it at once; per-device fetches are only for drill-down +detail. The full `GET …/data/status/collector/**` aggregate remains the eager +mode when a point-in-time view of everything is needed. For onboarding-only +attributes not in the collector (driver, credentials, custom settings), the +ACL supplements with an Inventory read. The cost is **heavy denormalization**. A single service (`100001::main`) appears in at least six places in the snapshot, each with full status @@ -304,19 +307,26 @@ operations. The ACL owns this table; the user never sees it. | Domain operation | Orchestrated VIP sequence | | ---------------- | ------------------------- | | `network.discover()` | Inventory discovered-devices read | -| `network.add_device(driver, connection, …)` | Inventory RPC `/api/updateDevices` (assigns `deviceN`) → optional Topology placement PATCH | -| `device.configure(...)` | Inventory custom-settings update (RPC) and/or Topology vertex/capability PATCH — by field | -| `device.place(x, y)` | Topology `baseDevice` `maps[]` PATCH (revisioned) | -| `network.connect(a, b, …)` | Resolve port→vertex ids → `updateTopology` / `nGraphElements` edge upsert (paired edges) | +| `network.add_device(driver, connection, …)` | Inventory RPC `/api/updateDevices` (assigns `deviceN`) → optional placement via `addDevices` network action / `updateTopology` `replaceDevices` | +| `device.configure(...)` | Inventory custom-settings update (RPC) and/or `updateTopology` `replaceVertices` — by field | +| `device.place(x, y)` | `updateTopology` `replaceDevices` (coordinates) | +| `network.connect(a, b, …)` | Resolve port→vertex ids → `updateTopology` `replaceEdges` / `addExternalEdges` (paired edges) | | `network.get(...)` / `list_*()` | `collector` snapshot read (+ inventory status where needed) | -| `network.disconnect(...)` / `remove_device(...)` | `updateTopology` remove and/or Inventory RPC remove | -| `device.refresh()` / `network.refresh()` | Re-fetch `collector` snapshot (§11.3) | +| `network.disconnect(...)` / `remove_device(...)` | `updateTopology` `remove` and/or Inventory RPC remove | +| `device.refresh()` / `network.refresh()` | Re-fetch `collector` snapshot (§11.3); after own commits: targeted refresh ([ADR-0010](../inspect-app/decisions/0010-post-commit-snapshot-refresh.md)) | + +All topology-plane operations go through the **Inspect surface only** +(`updateTopology`, collector reads, `addDevices`/`syncDevices`) — never through +`PATCH nGraphElements` +([ADR-0008](../inspect-app/decisions/0008-collector-only-endpoints.md)). The +legacy Topology path remains available solely via the `app.topology` escape +hatch. ## 10. The configuration / write interface Make the **change set the unit of work** — this is genuinely how the server behaves for topology/connection edits -([ADR-0006](./inspect-app/decisions/0006-commit-write-model.md)), so it is a +([ADR-0006](../inspect-app/decisions/0006-commit-write-model.md)), so it is a semantic to **expose**, not hide. Adopt ADR-0006 option 3 (explicit change set + convenience auto-commit), with a context manager as the ergonomic default. Proposed shape: @@ -327,9 +337,9 @@ with app.network.change_set() as cs: cs.place(device_id, at=Point(x, y)) conn = cs.connect(port_a, port_b, bandwidth=Gbps(10), redundancy=Redundancy.ANY) cs.remove(old_connection_id) - result = cs.validate() # maps data.validation.result; surfaces affected services + result = cs.validate() # client-side conflict check (ADR-0009); server validation runs at commit if result.ok: - cs.commit() # one updateTopology POST + cs.commit() # conflict re-check → one updateTopology POST → targeted refresh (ADR-0010) # on exception → auto-discard; on clean exit without commit → configurable # Convenience — single change auto-commits @@ -362,8 +372,8 @@ The three planes do **not** share a write/consistency model: | Plane | Write mechanism | Concurrency control | | ----- | --------------- | ------------------- | | Inventory | RPC `/api/updateDevices` | No revision/strict mode (RPC semantics) | -| Topology | `PATCH nGraphElements` | `_rev` optimistic locking, `mode: strict` | -| Inspect | `updateTopology` action | Commit-time validation; `_rev` behaviour **[VERIFY]** (ADR-0006) | +| Topology (escape hatch only, [ADR-0008](../inspect-app/decisions/0008-collector-only-endpoints.md)) | `PATCH nGraphElements` | `_rev` optimistic locking, `mode: strict` | +| Inspect | `updateTopology` action | Commit-time validation; **last-writer-wins** (verified 2025.4.9 — `_rev` ignored); client-side compare-and-commit ([ADR-0009](../inspect-app/decisions/0009-write-consistency.md)) | So a single domain write that touches multiple planes has **no single transaction and no uniform conflict story**. The ACL must define, per @@ -383,16 +393,19 @@ config plane (`nGraphElements`). Consequences: `syncSeverity` are not backed by any revision. There is **no cheap "did anything change?" probe** for status — the only ways to learn current status are to re-fetch the collector (whole or subtree) or subscribe via WebSocket - ([ADR-0003](./inspect-app/decisions/0003-websocket-subscriptions.md)). -2. **Config-plane rev-polling detects config edits only.** Cheaply polling - `nGraphElements .../id,rev` catches "someone re-wired," but never "a - connection went into alarm." For monitoring, the latter is the majority of - interesting events. So rev-polling is necessary-but-insufficient. -3. **Read and write tokens live in different planes.** A safe optimistic write - needs the `nGraphElements` `_rev` (or the `updateTopology` per-entity `rev`), - which is **absent from the collector read**. A domain object built from the - collector **cannot, by itself, support an optimistic write** — the write path - must resolve revisions separately at commit time. + ([ADR-0003](../inspect-app/decisions/0003-websocket-subscriptions.md)). +2. **Config-plane rev-polling is off the table anyway.** Polling + `nGraphElements .../id,rev` could catch "someone re-wired" (never "a + connection went into alarm"), but the package does not call that surface + ([ADR-0008](../inspect-app/decisions/0008-collector-only-endpoints.md)) — + and it would still miss the status changes that matter for monitoring. +3. **There is no write token at all on the Inspect surface.** The collector + read is rev-less and `updateTopology` ignores `_rev` (last-writer-wins, + verified 2025.4.9) — so revision-based optimistic writes are impossible, + not merely inconvenient. A domain object built from the collector **cannot + support an optimistic write**; concurrent-edit detection is the change + set's job via stage-time baselines + pre-commit compare + ([ADR-0009](../inspect-app/decisions/0009-write-consistency.md)). ### 11.3 Freshness strategy — re-snapshot, not rev-diff @@ -400,30 +413,42 @@ The collector deliberately trades per-entity revisioning for a single globally-consistent snapshot — excellent for **read correctness** (no torn graph), poor for **incremental freshness**. Therefore: -- Treat the collector snapshot as an **immutable value object**, replaced - wholesale on `refresh()` — not mutated in place. +- The collector snapshot is **replaced wholesale on `refresh()`** — never + patched by diffing. Within its lifetime it *accretes*: skeleton-first, with + lazily hydrated subtrees merged in + ([ADR-0007](../inspect-app/decisions/0007-lazy-snapshot-loading.md)). - `refresh()` means "fetch a new snapshot," not "diff revisions." Stamp each - snapshot with a **client-side fetch time** as the freshness marker, since the - server provides no token. -- If projection/filtering behind the `/**` wildcard turns out to be supported - (open `[VERIFY]`, not proven by the capture), a scoped re-snapshot of a - subtree is the optimisation — still a re-snapshot, not a diff. + entity/section with a **client-side fetch time** as the freshness marker, + since the server provides no token. +- Projection/filtering on collector sub-paths is **proven** (Inspect UI + WebSocket capture — see + [endpoints.md](../inspect-app/endpoints.md#collector-scoped-queries-captured-from-the-inspect-ui)): + scoped re-snapshots of a subtree are the freshness optimisation — still a + re-snapshot, not a diff. +- **After the package's own commits**, freshness is cheaper: the change set + knows what it touched, so the snapshot is updated by targeted invalidation + + scoped re-fetch instead of a full re-snapshot + ([ADR-0010](../inspect-app/decisions/0010-post-commit-snapshot-refresh.md)). - This is the strongest argument for making **WebSocket the real freshness channel for status**, while config edits keep the rev-based path - ([ADR-0001](./inspect-app/decisions/0001-api-paradigm.md), - [ADR-0002](./inspect-app/decisions/0002-loading-and-state.md), - [ADR-0003](./inspect-app/decisions/0003-websocket-subscriptions.md)). + ([ADR-0001](../inspect-app/decisions/0001-api-paradigm.md), + [ADR-0002](../inspect-app/decisions/0002-loading-and-state.md), + [ADR-0003](../inspect-app/decisions/0003-websocket-subscriptions.md)). ### 11.4 Separate the read snapshot from the write handle -Because the snapshot has no revisions, a domain mutation (`connect`, `remove`) -must, at commit time, resolve the affected entities' `nGraphElements` revisions -itself (or go through `updateTopology` and surface its validation result). Do -**not** let a read object pretend it can optimistically write. The clean split: +Because the snapshot has no revisions (and the write path enforces none), a +domain mutation (`connect`, `remove`) must not pretend a read object can +optimistically write. The clean split: -- **Read snapshot** — immutable, rev-less, globally consistent, replace wholesale. -- **Write handle / change set** — resolves revisions at commit, owns the - commit/validate/discard/conflict lifecycle. +- **Read snapshot** — rev-less, replace wholesale on refresh; accretes lazily + hydrated detail within its lifetime (ADR-0007); after own commits it is + updated by targeted scoped re-reads + ([ADR-0010](../inspect-app/decisions/0010-post-commit-snapshot-refresh.md)). +- **Write handle / change set** — fetches stage-time baselines via + Inspect-surface lookups, re-checks them immediately before the + `updateTopology` POST, and owns the commit/validate/discard/conflict + lifecycle ([ADR-0009](../inspect-app/decisions/0009-write-consistency.md)). ## 12. Limits of the abstraction @@ -466,9 +491,10 @@ The single most important artifact of this approach. Proposed starting point | Device lifecycle (onboard → place → connect → monitor) | **Expose** as `LifecycleState` | Users must reason about *where* a device is, not *which app* | | Change set / commit | **Expose** (first-class) | Genuine server semantic; enables atomic multi-edits | | Commit validation result & affected services | **Expose** (typed result) | `header.ok` lies; users must see real outcome | -| Optimistic concurrency / revisions | **Expose as opaque token** | Needed for safe writes; but never raw `_rev` strings | +| Concurrent-write conflicts | **Expose** as explicit conflict check + typed conflict error ([ADR-0009](../inspect-app/decisions/0009-write-consistency.md)) | No rev token exists on the Inspect surface (`updateTopology` is last-writer-wins); pretending otherwise would fake a guarantee | | Multi-dimensional status (`alarm`/`bandwidth`/`maintenance`/`ptp`, `sa`/`severity`) | **Expose** (preserve dimensions + provide rollup) | Lossy to collapse; monitoring users need the detail | | Snapshot freshness (no `_rev`) | **Expose** via explicit `refresh()` + fetch-time stamp | Honest about the lack of a server token | +| Lazy hydration on property access ([ADR-0007](../inspect-app/decisions/0007-lazy-snapshot-loading.md)) | **Expose** (documented behaviour) | Getters may perform one fetch and raise connector errors; hiding it would misrepresent cost and failure modes | | Driver-specific custom settings | **Cannot hide** — abstract the entry point only | A device *is* its driver schema (§12) | ## 14. Migration & coexistence @@ -505,7 +531,7 @@ alternative to the additive approach — see §15. | Completeness bar for v1 | Full coverage **vs.** 80% happy path + escape hatch for the rest | Recommend the latter; lowest cost | | Hide-vs-expose ledger (§13) | Ratify explicitly | The most consequential artifact | | Snapshot vs. WS for status freshness | re-snapshot now, WS as the real channel | §11.3; ties into ADR-0002 / ADR-0003 | -| Read-snapshot ↔ write-handle revision bridge | How the change set resolves `_rev` at commit | §11.4; ties into ADR-0006 | +| Read-snapshot ↔ write-handle bridge | ~~How the change set resolves `_rev` at commit~~ **Decided**: stage-time baselines + pre-commit compare ([ADR-0009](../inspect-app/decisions/0009-write-consistency.md)); post-commit targeted refresh ([ADR-0010](../inspect-app/decisions/0010-post-commit-snapshot-refresh.md)) | §11.4 | | Relationship to existing docs | Supersede `python-module-architecture.md` design goal **vs.** coexist as "current vs. target" | Currently coexists as the target picture | ## 16. Field-mapping reference (wire → domain) @@ -540,9 +566,9 @@ discovery and turned into fixtures. ## 17. Relationship to existing documents -- [`python-module-architecture.md`](./python-module-architecture.md) — describes +- [`python-module-architecture.md`](../python-module-architecture.md) — describes the **current** mirror-VIP architecture. This document proposes the **target**. -- [`inspect-app/`](./inspect-app/README.md) — `concepts.md` (the VIP wire-format +- [`inspect-app/`](../inspect-app/README.md) — `concepts.md` (the VIP wire-format research, including the `collector` aggregate and `nGraphElements` store) and the ADRs (API paradigm, loading, WebSocket, async, testing, commit model). The decisions there apply package-wide and are referenced throughout this document. diff --git a/docs/inspect-app/README.md b/docs/inspect-app/README.md index 44c7bbb..f34d474 100644 --- a/docs/inspect-app/README.md +++ b/docs/inspect-app/README.md @@ -19,15 +19,19 @@ reference is now a primary source. Nothing here is implemented yet; this is the ## Reading order -1. **[concepts.md](./concepts.md)** — what Inspect is, its domain model, how it - maps onto the existing package, and the endpoint/WebSocket discovery template - (the `[VERIFY]` items to confirm against a real server). Cross-references the +1. **[concepts.md](./concepts.md)** — what Inspect is, its domain model (including + the Inspect-vs-Topology tagging split: vertex tags in `device_tags`, not + `nGraphElements`), how it maps onto the existing package, and the + endpoint/WebSocket discovery template (the `[VERIFY]` items to confirm against + a real server). Cross-references the [Public API 2025 LTS](https://documenter.getpostman.com/view/11222813/2sBXihpCS8#intro) reference. 2. **[models.md](./models.md)** — practical model guide for transport `InspectApi*` DTOs, `InspectSnapshot`, and user-facing domain objects such as `InspectDevice`. 3. **[endpoints.md](./endpoints.md)** — anonymized endpoint reference with - concrete request and response shapes captured from a local test instance. + concrete request and response shapes captured from a local test instance, + including the live-server verification results (VideoIPath 2025.4.9, + 2026-07-08). 4. **[decisions/](./decisions/)** — the architecture decisions (ADRs), one per topic. Start with [the index](./decisions/README.md). 5. **[implementation-plan.md](./implementation-plan.md)** — phased, non-breaking @@ -36,18 +40,21 @@ reference is now a primary source. Nothing here is implemented yet; this is the > **Wider context:** the package-wide re-think that grew out of this Inspect > work — one unified `Device` / `Connection` domain model that hides the > Inventory / Topology / Inspect split entirely — lives in -> [`../domain-architecture.md`](../domain-architecture.md). +> [`../future/domain-architecture.md`](../future/domain-architecture.md). ## Decision log | Question (from the brief) | Decision record | Status | | -------------------------------------------------- | ------------------------------------------------------------ | -------- | | Data-driven vs. event/action-driven API? | [ADR-0001](./decisions/0001-api-paradigm.md) | Open | -| Always sync/load vs. lazy load vs. cached state? | [ADR-0002](./decisions/0002-loading-and-state.md) | Open | +| Always sync/load vs. lazy load vs. cached state? | [ADR-0002](./decisions/0002-loading-and-state.md) → [ADR-0007](./decisions/0007-lazy-snapshot-loading.md) | Decided (ADR-0007) | | Use WebSockets for event subscriptions? | [ADR-0003](./decisions/0003-websocket-subscriptions.md) | Open | | Make the package async-ready? Support non-async? | [ADR-0004](./decisions/0004-async-strategy.md) | Open | | How to test E2E (low-effort, stable, trustworthy)? | [ADR-0005](./decisions/0005-e2e-testing.md) | Open | | How are config writes applied (immediate vs. commit)? | [ADR-0006](./decisions/0006-commit-write-model.md) | Open | +| Which API surface may the package call? | [ADR-0008](./decisions/0008-collector-only-endpoints.md) | Decided (collector-only) | +| How are concurrent writes detected (no server `_rev` check)? | [ADR-0009](./decisions/0009-write-consistency.md) | Decided (compare-and-commit) | +| How does the snapshot catch up after a commit? | [ADR-0010](./decisions/0010-post-commit-snapshot-refresh.md) | Decided (targeted refresh) | ## Status legend diff --git a/docs/inspect-app/concepts.md b/docs/inspect-app/concepts.md index 8c076c0..f69dd5c 100644 --- a/docs/inspect-app/concepts.md +++ b/docs/inspect-app/concepts.md @@ -1,6 +1,6 @@ # Inspect App — Concepts & Technical Model -> Status: **Draft** · Last updated: 2026-06-25 +> Status: **Draft** · Last updated: 2026-07-08 > > This document captures what the *Inspect* app is, how it maps onto the > existing package, and which technical details still need to be verified @@ -42,11 +42,20 @@ v2 API surface under the `status` namespace. The server composes reads from contract is mostly net-new** relative to what `app.topology` and `app.inventory` use today. -**Reads** — one server-built aggregate: +**Reads** — scoped queries against the collector tree +([ADR-0007](./decisions/0007-lazy-snapshot-loading.md)): +- The collector sub-paths accept `* where ` filters, `limit N`, and deep + field projections — confirmed by a WebSocket capture of the Inspect UI, which + never loads the full tree (see + [endpoints.md — Collector Scoped Queries](./endpoints.md#collector-scoped-queries-captured-from-the-inspect-ui)). +- Default loading model: a **skeleton** read (all devices without modules/ports + + all edges with a lean projection) followed by **lazy per-device hydration** + and section-level loads for services. - `GET /rest/v2/data/status/collector/**` → `data.status.collector` (§3.1) -- Bundles topology nodes, inter-device edges, services/paths, status, and - security context in a single tree with `_items[]` collections. + remains the eager/fallback mode: the whole tree — topology nodes, + inter-device edges, services/paths, status, and security context — in a + single response with `_items[]` collections. **Writes** — one bulk action per commit: @@ -102,12 +111,13 @@ How Inspect concepts map onto existing package concepts: | Device (inventory) | Prerequisite — not part of collector; onboard via `config/devman/devices` | `InventoryDevice` (`apps/inventory`) | | Device (topology node) | Read: `collector.inspect.nodeStatus`; stored as `baseDevice` in `nGraphElements` | Store shape overlaps with Topology, but Inspect uses `InspectApiBaseDevice` | | Vertices / Edges | Read: `nodeStatus` `vertexInfo` / `externalEdgesByDeviceKey`; stored as `ipVertex` / `codecVertex` / `unidirectionalEdge` in `nGraphElements` | Store shape overlaps with Topology, but Inspect uses `InspectApi*` nGraph DTOs | +| Vertex tags | Read: per-port `tagsInfo` on hydrated `nodeStatus`; editable form via `lookupInspectVertexByIds` (`assignedTags`, `fields.tags`, `fields.localAssignedTags`) | **Not in `nGraphElements`** — `app.topology` has no vertex-tag concept; server-side bindings live in `videoipath_docs.device_tags`, not the `ngraph` table (§3.4) | | Change set / commit | `POST …/actions/status/collector/updateTopology` → writes `nGraphElements` ([ADR-0006](./decisions/0006-commit-write-model.md)) | _commit flow net-new; target store is existing `nGraphElements`_ | | Services / paths | `collector.inspect.paths`, `pathDescriptions` on nodes/edges | _none — net-new_ | | Device / edge status | Embedded in collector (`status`, `sa`/`severity`, bandwidth, PTP) | `inventory.model.device_status`, `status/network/*` — partial overlap | | Sync status | `syncSeverity` on nodeStatus items | `TopologySynchronize` via `status/network/nGraphSyncStatus` | -| Lookup / network actions | `lookupInspectDevice`, `lookupSyncInfo`, `addDevices`, `syncDevices` | request/response envelopes captured and modelled | -| Connections / Partial connections | **[VERIFY]** — may relate to `pathDescriptions` / bookings | _none yet_ | +| Lookup / network actions | `lookupInspectDevice`, `lookupInspectEdgesByIds`, `lookupInspectVertexByIds`, `lookupSyncInfo`, `addDevices`, `syncDevices` | request/response envelopes captured and modelled | +| Connections / Partial connections | `collector.inspect.paths` + `conman.services`; linked via `serviceFields.bid` / `bookingId` in `pathDescriptions` ([endpoints.md](./endpoints.md#get-restv2datastatuscollectorinspectpaths)) | _read via collector + conman; no separate Connections REST on this instance_ | ### 3.1 Collector aggregate — primary read surface @@ -165,19 +175,36 @@ elements to booked services. **Node live status** (`inspect.nodeStatus`): hierarchical `status` with `sa` / `severity` at device, module, port; plus `ptpDeviceStatus`, `syncSeverity`, -`hasEndpoints`, domains, tags. +`hasEndpoints`, domains, and tags. Device-level tags appear on the node itself +(`tags`, `meta.tags`, `tagsInfo`); **vertex-level tag bindings** appear on +hydrated ports (`tagsInfo` with `assigned` / `inherited` / `local` / `custom` +subtrees — see §3.4). The skeleton projection only carries device-level tagging; +port `tagsInfo` requires per-device hydration (`modules/*`). **Package implications:** -- `app.inspect` uses `GET …/data/status/collector/**` as its canonical read - entry point. +- `app.inspect` reads the collector through **scoped queries** + ([ADR-0007](./decisions/0007-lazy-snapshot-loading.md)): skeleton first + (`inspect/nodeStatus` with `modules/"_noId"`, `externalEdgesByDeviceKey` + with a lean projection), then per-device hydration (`modules/*` detail + projection) and section-level loads (`inspect/paths`). The full + `GET …/data/status/collector/**` fetch is the eager/fallback mode. +- The collector query language is confirmed via UI capture: `* where ` + (with `and`/`or`, `contains()`, `lower()`), `limit N`, field projections + with `/.../` up-navigation, `**` subtrees, and the `"_noId"` + expansion-suppressor (see + [endpoints.md](./endpoints.md#collector-scoped-queries-captured-from-the-inspect-ui)). - Edge keys and vertex ids from reads map directly onto `updateTopology` write payloads. - Commit validation references the same `bookingId`s visible in `pathDescriptions` (e.g. failed delete for an anonymized booking / main path edge). -- **[VERIFY]** exact sub-paths behind the `/**` wildcard, projection/filter - support, and pagination for large topologies. +- Scoped collector queries work as REST GETs when the URL fits within the server + URI limit ([endpoints.md](./endpoints.md#collector-scoped-queries-captured-from-the-inspect-ui)). The full UI + projection hits **HTTP 414**; use a trimmed skeleton projection or `/**` + fallback. Both `nodeStatus//…` and + `* where deviceId='…' limit 1/…` work for single-device hydration. Omitting + `where` does **not** require `limit`. ### 3.2 API planes — existing apps vs. Inspect @@ -193,7 +220,7 @@ workflows: | ----- | ------- | ---- | ----- | | Config | `app.topology`, `app.inventory`, **Inspect (effective store)** | `GET …/data/config/…` | `PATCH …/data/config/…` (revisioned) or RPC | | Status | Inventory status reads, Inspect status reads | `GET …/data/status/…` | — | -| Collector (facade) | `app.inspect` | `GET …/data/status/collector/**` (composed aggregate) | `POST …/actions/status/collector/updateTopology` → `nGraphElements` | +| Collector (facade) | `app.inspect` | Scoped queries on `…/data/status/collector/…` (skeleton + hydration, ADR-0007); `GET …/**` as eager/fallback | `POST …/actions/status/collector/updateTopology` → `nGraphElements` | | Network actions | `app.inspect` device topology workflows | — | `POST …/actions/status/network/addDevices`, `POST …/actions/status/network/syncDevices` | So Inspect's read aggregate is status-namespace and net-new in shape, but its @@ -202,6 +229,18 @@ writes are commit-time-validated bulk actions that land in the revisioned until commit; ADR-0006 accepts that there is no separate server-side change-set id for the verified `updateTopology` flow. +**Endpoint policy** ([ADR-0008](./decisions/0008-collector-only-endpoints.md)): +the Inspect package calls **only** the Inspect surface — collector data reads, +collector actions, and the `addDevices`/`syncDevices` network actions. The +config-plane row above is context, not a call path: the package never issues +`GET`/`PATCH …/config/network/nGraphElements` (that stays `app.topology`'s +surface). Consequence: no `_rev` is available to Inspect, and since +`updateTopology` ignores revisions anyway (last-writer-wins, verified), +concurrent-write detection is client-side compare-and-commit +([ADR-0009](./decisions/0009-write-consistency.md)); after a commit the +snapshot catches up via targeted scoped re-reads +([ADR-0010](./decisions/0010-post-commit-snapshot-refresh.md)). + The server can expose status-plane subscriptions, but WebSockets are out of scope for this package. Fresh status is obtained by explicit re-fetches ([ADR-0001](./decisions/0001-api-paradigm.md), @@ -219,6 +258,11 @@ of truth** for topology that Inspect's `updateTopology` writes into. Its wire shape overlaps with the Topology app, but the Inspect package models it with standalone `InspectApi*` DTOs. +> Documented here as store/background knowledge only — the package does **not** +> read or write this endpoint at runtime +> ([ADR-0008](./decisions/0008-collector-only-endpoints.md)). The persisted +> element *shape* still matters: `updateTopology` `replace*` payloads carry it. + | Field | Notes | | ----- | ----- | | `_id` / `_vid` | Element id; edges use the `fromId::toId` key (same as collector and `replaceEdges`) | @@ -230,9 +274,9 @@ Element `type` values seen in capture: | `type` | Represents | Key fields | | ------ | ---------- | ---------- | -| `baseDevice` | Topology device node | `maps[]` (`cType: "Topology"`, integer `x`/`y`), `iconType`, `sdpStrategy`, `isVirtual` | -| `codecVertex` | Codec/SDI endpoint vertex | `vertexType` (`In`/`Out`), `codecFormat`, `useAsEndpoint`, `control`, SIPS/SDP fields | -| `ipVertex` | Ethernet/IP port vertex (`.in` / `.out`) | `vertexType`, `gpid.pointId`, `supports*Cfg` capability flags | +| `baseDevice` | Topology device node | `maps[]` (`cType: "Topology"`, integer `x`/`y`), `iconType`, `sdpStrategy`, `isVirtual`, `tags` (device-level only) | +| `ipVertex` | Ethernet/IP port vertex (`.in` / `.out`) | `vertexType`, `gpid.pointId`, `supports*Cfg` capability flags — **not** vertex tag bindings (§3.4) | +| `codecVertex` | Codec/SDI endpoint vertex | `vertexType` (`In`/`Out`), `codecFormat`, `useAsEndpoint`, `control`, SIPS/SDP fields — **not** vertex tag bindings (§3.4) | | `unidirectionalEdge` | Directed link/route between vertices | `fromId`, `toId`, `weight`, `capacity`, `bandwidth`, `redundancyMode`, `weightFactors`, `conflictPri` | **Write round-trip confirmed:** an anonymized edge edited via `updateTopology` @@ -244,8 +288,35 @@ use `capacity: 1`. **Implication:** Inspect's underlying topology store is `nGraphElements`, but the package keeps a separate Inspect model namespace (`InspectApiBaseDevice`, `InspectApiIpVertex`, `InspectApiUnidirectionalEdge`, …). Do not reuse topology app -model classes in Inspect DTOs. **[VERIFY]** whether `updateTopology` performs -`_rev` checks server-side or last-writer-wins. +model classes in Inspect DTOs. `updateTopology` is **last-writer-wins** — a +stale `_rev` in the payload is ignored ([endpoints.md](./endpoints.md#post-restv2actionsstatuscollectorupdatetopology)). + +### 3.4 Tagging — device vs. vertex (Inspect vs. Topology) + +Inspect distinguishes two tag scopes. This is a **key difference from +`app.topology`**, which only models tags on topology **devices** (`baseDevice` +entries in `nGraphElements`). + +| Scope | What is tagged | Topology / `nGraphElements` | Inspect read surface | Server storage | +| ----- | -------------- | --------------------------- | -------------------- | -------------- | +| **Device** | Topology node (`baseDevice`) | `tags` on the `baseDevice` item | `nodeStatus` `tags` / `meta.tags` / `tagsInfo`; `lookupInspectDevice` | `ngraph` / `nGraphElements` | +| **Vertex** | Individual port vertex (`ipVertex`, `codecVertex`, …) | **Not present** — no vertex-tag field on persisted graph elements | Hydrated port `tagsInfo`; editable form in `lookupInspectVertexByIds` | `videoipath_docs.device_tags` (separate from `ngraph`) | + +**Implications for the package:** + +- `app.topology` reads/writes device tags via `nGraphElements` only. It has no + API for binding tags to a vertex id such as + `device-a.module-1.port-out-1.out`. +- `app.inspect` must treat vertex tags as a **separate concern** from the + persisted graph element shape. Do not assume a vertex's `tags` array in an + `nGraphElements` `ipVertex` / `codecVertex` item (if present at all) is the + source of truth for tag bindings — confirmed empty in captures while + `lookupInspectVertexByIds` carries `assignedTags` and `fields.tags`. +- Stage-time baselines and compare-and-commit for vertex edits must use + `lookupInspectVertexByIds` for tag fields ([ADR-0009](./decisions/0009-write-consistency.md)), + not `nGraphElements` or the collector skeleton. +- Collector `tagInfo` provides tag → profile metadata for the aggregate; it does + not replace per-vertex `assignedTags` on the lookup response. ## 4. How the transport works today (recap) @@ -255,13 +326,19 @@ So the new layer fits the existing patterns rather than reinventing them: sub-connectors: REST v2 (`GET`/`PATCH`/`POST`) and RPC (`POST /api/*`). Basic auth, gzip, per-method timeouts. - Each connector enforces an **allow-list of URL prefixes** (`ALLOWED_URLS` / - `ALLOWED_EXACT_MATCHES`). New Inspect endpoints must be added there. + `ALLOWED_EXACT_MATCHES`). New Inspect endpoints must be added there — but + only Inspect-surface prefixes; `config/network/nGraphElements` is not added + for the Inspect app ([ADR-0008](./decisions/0008-collector-only-endpoints.md)). - Responses are wrapped in a common envelope (`ResponseV2Get`/`…Patch`/`…Post`) with a `header` (`code`, `auth`, …) and a `data`/`result` body, validated by Pydantic. - Apps are **lazy-loaded** off `VideoIPathApp` and are **stateless**: every call re-fetches from the server (e.g. `topology.get_device` issues several `GET`s - and rebuilds the aggregate each time). Inspect should follow that pattern. + and rebuilds the aggregate each time). Inspect deviates deliberately: state + is **snapshot-scoped** — a skeleton is loaded up front, detail is lazily + hydrated into the same snapshot, and freshness means building a new snapshot + ([ADR-0007](./decisions/0007-lazy-snapshot-loading.md)). There is still no + cache across snapshots. - Inspect models live in two layers: - `apps/inspect/model` — `InspectApi*` transport DTOs for HTTP payloads - `apps/inspect/domain` and `apps/inspect/snapshot.py` — user-facing read @@ -272,8 +349,13 @@ So the new layer fits the existing patterns rather than reinventing them: Two complementary sources: the **official reference** (authoritative for the documented surface) and **browser capture** (authoritative for what the Inspect -GUI actually does, including undocumented calls). WebSocket frames can be useful -product context, but they are out of scope for the package per ADR-0003. +GUI actually does, including undocumented calls). WebSocket *subscriptions* +stay out of scope for the package per ADR-0003, but captured WS frames are a +first-class **discovery source**: the subscription `path`s address the same +data tree as REST v2 and revealed the collector's query language and the UI's +skeleton-first load strategy (see +[endpoints.md — Collector Scoped Queries](./endpoints.md#collector-scoped-queries-captured-from-the-inspect-ui), +[ADR-0007](./decisions/0007-lazy-snapshot-loading.md)). 0. **Start from the official reference.** The [VideoIPath Public API 2025 LTS](https://documenter.getpostman.com/view/11222813/2sBXihpCS8#intro) @@ -304,33 +386,53 @@ REST: - [x] Base path: Inspect uses `/rest/v2/` for collector read/write (`data/status/collector/**`, `actions/status/collector/updateTopology`) - [x] Service / monitoring aggregate: `GET /rest/v2/data/status/collector/**` → `data.status.collector` with `inspect.nodeStatus`, `inspect.paths`, `externalEdgesByDeviceKey`, `security`, `superProfiles`, `tagInfo` (§3.1) - [x] Drill-down: services linked via `pathDescriptions` on ports/edges (`deviceLevel` + `serviceLevel`) and `inspect.paths._items[]` (`bookingId`, path segments, `serviceFields`) -- [ ] Collector sub-paths: exact URLs behind the `/**` wildcard; projection/filter/pagination support -- [ ] Connections / Partial Connections: resource paths, shapes, lifecycle +- [x] Collector sub-paths & query language: confirmed via Inspect UI WebSocket capture — `* where `, `limit N`, deep projections with `/.../` up-navigation, `**`, `"_noId"` expansion-suppressor; UI loads skeleton-first (`modules/"_noId"`) — decoded queries in [endpoints.md](./endpoints.md#collector-scoped-queries-captured-from-the-inspect-ui) ([ADR-0007](./decisions/0007-lazy-snapshot-loading.md)) +- [x] Scoped-query REST GET equivalence: confirmed for practical query lengths; full UI projection → HTTP 414 ([endpoints.md](./endpoints.md#collector-scoped-queries-captured-from-the-inspect-ui)) +- [x] Single-device hydration form: both `nodeStatus//…` and `* where deviceId='…' limit 1/…` work; prefer direct id +- [x] Exact `"_noId"` semantics: subtree expansion suppressor → `modules: {}` at collection level, key omitted on single-device queries +- [x] Populated `modules/*` detail payload: confirmed on synced devices; `syncSeverity=2` devices return empty modules until synced +- [x] Skeleton vs. full aggregate: ~92 KB skeleton (27 devices + 40 edges) vs ~19 MB `/**` on this instance; `limit` not required when `where` is omitted +- [x] Connections / Partial Connections: services via `collector.inspect.paths` + `conman.services`; `bid` ↔ `connection.id` - [x] Change set / commit endpoint: `POST /rest/v2/actions/status/collector/updateTopology` — bulk delta with `replaceDevices`, `replaceVertices`, `replaceEdges` (key `"fromId::toId"`), `replaceResourceTransforms`, `addExternalEdges`, `remove`, `force` ([ADR-0006](./decisions/0006-commit-write-model.md)) - [x] Change set staging: client-side gather until `updateTopology`; no separate server-side change-set id for the verified flow (ADR-0006) - [x] Commit failure response: check `data.res.ok` / `data.validation.result.ok` (not `header.ok`); `validation.details[id]` carries `status`, `rev`, `resolvable`, `type`; failed delete example: `"A required edge was not found. (main)"`, `items: []` ([ADR-0006](./decisions/0006-commit-write-model.md)) - [x] Commit success response for no-op: `data.items: []`, `data.res.ok: true`, `data.validation.result.ok: true` ([endpoints.md](./endpoints.md#post-restv2actionsstatuscollectorupdatetopology)) -- [ ] Commit success response with real applied changes: `data.items` contents when validation passes and changes are applied -- [ ] Commit semantics: partial apply after validation pass, discard/rollback of abandoned client-side change sets -- [ ] Import / Export (preview): scope and payload format +- [x] Commit success response with real applied changes: `data.items[]` with `{id, idx, res.ok}` per applied entity ([endpoints.md](./endpoints.md#post-restv2actionsstatuscollectorupdatetopology)) +- [x] Commit semantics: reject-before-apply; invalid ops return `items: []`; client-side staging only (no server change-set id) +- [x] Import / Export (preview): **unregistered** on 2025.4.9 — empty data namespaces; GET action schema empty; POST → `No action node in request` - [x] Write/action endpoint: `/rest/v2/actions/status/collector/updateTopology` - [x] Collector action payloads captured and modelled: `/rest/v2/actions/status/collector/lookupInspectDevice`, `/rest/v2/actions/status/collector/lookupSyncInfo` - [x] Network action request shapes, normal action responses, and validation-error responses captured: `/rest/v2/actions/status/network/addDevices`, `/rest/v2/actions/status/network/syncDevices` - [x] Config store / write target: `updateTopology` lands in `GET /rest/v2/data/config/network/nGraphElements/**` (`_items[]`, `_rev`, `type` ∈ `baseDevice` / `codecVertex` / `ipVertex` / `unidirectionalEdge`); model with standalone `InspectApi*` DTOs (§3.3) -- [ ] `_rev` handling on commit: does `updateTopology` enforce optimistic concurrency or last-writer-wins? -- [ ] Version gating: first VideoIPath version that exposes each endpoint +- [x] `_rev` handling on commit: last-writer-wins; `_rev` in `replaceEdges` payload is ignored +- [x] Version gating: collector read/write and `updateTopology` verified on **2025.4.9** - [x] DTO coverage: add typed request/response models for captured lookup, action result, and validation-error endpoint payloads in [endpoints.md](./endpoints.md) +Write consistency & post-commit refresh ([ADR-0009](./decisions/0009-write-consistency.md), [ADR-0010](./decisions/0010-post-commit-snapshot-refresh.md)) — verified live on 2025.4.9 (2026-07-08): + +- [x] Persisted-element lookups: `lookupInspectEdgesByIds` returns the **full persisted edge form** (batched); `lookupInspectVertexById`/`…ByIds` and `lookupInspectDevice` return editable forms; **no `_rev` in any lookup response**; `lookupGraphElement` does **not** exist; `lookupNodeInfo`/`lookupEdgeInfo`/`lookupDeviceVertices` are display-oriented ([endpoints.md](./endpoints.md#post-restv2actionsstatuscollectorlookupinspectedgesbyids)) +- [x] Vertex tag bindings: separate from `nGraphElements` — stored server-side in `videoipath_docs.device_tags`; surfaced on `lookupInspectVertexByIds` (`assignedTags`, `fields.tags`, `fields.localAssignedTags`) and hydrated port `tagsInfo` in `nodeStatus`; not modelled by `app.topology` (§3.4) +- [x] UI edit/commit flows avoid `nGraphElements`: the Inspect UI bundle contains zero references; the edge edit flow calls `lookupInspectEdgesByIds` with both pair directions ([ADR-0008](./decisions/0008-collector-only-endpoints.md)) +- [x] Batched lookups for compare-and-commit baselines: `…ByIds` actions take id lists natively +- [x] Direct edge-pair addressing for targeted refresh: `externalEdgesByDeviceKey//` returns the single pair item +- [ ] Collector propagation delay: time between `updateTopology` OK and the change being visible in collector reads (drives the ADR-0010 retry window) — needs a live write test (coordinate nudge + revert) +- [ ] Re-check on every server upgrade: does `updateTopology` gain `_rev` enforcement (would allow replacing client-side compare-and-commit) + ## 6. Open questions -- **Collector sub-paths and scaling** — exact URLs behind `/**`; projection, - filtering, and pagination for large topologies - ([ADR-0002](./decisions/0002-loading-and-state.md)). -- **Commit model** ([ADR-0006](./decisions/0006-commit-write-model.md)) — - successful applied-change response shape (`data.items`); all-or-nothing apply - after validation; meaning of validation `status` codes and `resolvable: true` - cases. -- **Commit concurrency** — `updateTopology` writes bump `nGraphElements` `_rev` - (§3.3); does the action enforce `_rev` checks or last-writer-wins? -- **Connections / Partial Connections** — relationship to `pathDescriptions`, - bookings, and the Public API `Connections` resources. +_All VERIFY items are resolved (results merged into +[endpoints.md](./endpoints.md) and the §5.1 checklist) or documented as +confirmed limitations / unregistered stubs — with one exception:_ + +- **Collector propagation delay after a commit** + ([ADR-0010](./decisions/0010-post-commit-snapshot-refresh.md)) — requires a + live write test (coordinate nudge + revert) to size the post-commit retry + window. + +Remaining follow-up (only if a future server version registers new actions): + +- Re-run `GET /rest/v2/actions/status/collector/` schema check after + upgrade; probe POST only for newly registered actions — and re-check whether + `updateTopology` gains `_rev` enforcement (ADR-0009 would be revisited). +- Capture a `resolvable: true` validation case if one appears in the UI during + normal operator workflows. diff --git a/docs/inspect-app/decisions/0002-loading-and-state.md b/docs/inspect-app/decisions/0002-loading-and-state.md index adf9260..90a79f6 100644 --- a/docs/inspect-app/decisions/0002-loading-and-state.md +++ b/docs/inspect-app/decisions/0002-loading-and-state.md @@ -1,7 +1,14 @@ # ADR-0002: Loading & state model -> Status: **Accepted** +> Status: **Superseded by [ADR-0007](./0007-lazy-snapshot-loading.md)** > Date: 2026-06-15 · Deciders: Paul Winterstein, Jonas Scholl +> +> The per-read decision below ("always load the full aggregate") did not hold +> up for large environments; a WebSocket capture of the Inspect UI later +> confirmed collector projection/filter support and showed the vendor UI +> loading skeleton-first. ADR-0007 replaces the per-read model with +> skeleton-first loading + lazy hydration. The "no client-side cache across +> reads/snapshots" part of this ADR still stands. ## Context diff --git a/docs/inspect-app/decisions/0003-websocket-subscriptions.md b/docs/inspect-app/decisions/0003-websocket-subscriptions.md index 598cd9c..c24fcaa 100644 --- a/docs/inspect-app/decisions/0003-websocket-subscriptions.md +++ b/docs/inspect-app/decisions/0003-websocket-subscriptions.md @@ -17,10 +17,18 @@ which makes WebSocket support unnecessary for the package's scope. The [Public API 2025 LTS](https://documenter.getpostman.com/view/11222813/2sBXihpCS8#intro) reference **confirms** the high-level model: the `status` plane is "frequently updated and a good candidate for subscriptions", and subscription handling uses -**event-based (delta) change reporting** from server to client. So the *shape* -(deltas over a persistent channel on the status plane) is settled; the **exact -protocol details** (URL, auth handshake, subscribe message, concrete frame -format, heartbeat, reconnect) remain **[VERIFY]** — capture them per +**event-based (delta) change reporting** from server to client. Protocol details +verified on 2025.4.9 ([endpoints.md — subscription transport](../endpoints.md#observed-subscription-transport-reference-only--out-of-package-scope)): + +| Item | Value | +| ---- | ----- | +| URL | `{ws\|wss}:///rest/v2/sessions/me/ws?exclusive=false` | +| Auth | Session cookies on handshake | +| Subscribe | `{"channel":"subsc","messageId":N,"path":"…","id":""}` | +| Reply | `{"channel":"subsc","id":"…","messageId":N,"payload":{"_id":"…","_ver":[1,1],"data":{…}}}` | +| Unsubscribe | `{"channel":"unsubsc","messageId":N,"id":""}` | + +Captured frames also in `websocket-messages.txt`. See [concepts.md §5](../concepts.md#5-endpoint-discovery--how-to-fill-in-the-verify-gaps). ## Options diff --git a/docs/inspect-app/decisions/0006-commit-write-model.md b/docs/inspect-app/decisions/0006-commit-write-model.md index 902b997..12edd17 100644 --- a/docs/inspect-app/decisions/0006-commit-write-model.md +++ b/docs/inspect-app/decisions/0006-commit-write-model.md @@ -2,6 +2,11 @@ > Status: **Accepted** > Date: 2026-06-18 · Deciders: Paul Winterstein, Jonas Scholl +> +> Concurrency handling for this write model is decided in +> [ADR-0009](./0009-write-consistency.md) (compare-and-commit; the server is +> last-writer-wins). Post-commit snapshot refresh is decided in +> [ADR-0010](./0010-post-commit-snapshot-refresh.md). ## Context @@ -98,9 +103,35 @@ This confirms the **gather-then-commit** mental model at the API boundary: the Inspect UI accumulates edits client-side, then sends one `updateTopology` payload with populated `replace*` / `add*` / `remove` sections. Reads use the matching **collector** namespace: `GET …/data/status/collector/**` returns the composed -topology + status + services tree ([concepts.md §3.1](../concepts.md#31-collector-aggregate--primary-inspect-read-surface)). +topology + status + services tree ([concepts.md §3.1](../concepts.md#31-collector-aggregate--primary-read-surface)). Edge keys (`fromId::toId`) and vertex ids are consistent between read and write. +#### Response shape — successful commit (verified 2026-07-08, VideoIPath 2025.4.9) + +Edge `weight` change applied via `replaceEdges`: + +```json +{ + "data": { + "items": [ + { + "external": null, + "id": "device-a.dev.module-1.port-out-1.out::device-b.dev.module-1.port-in-1.in", + "idx": 0, + "res": { "msg": [""], "ok": true } + } + ], + "res": { "msg": [], "ok": true }, + "validation": { + "createIds": [], + "details": {}, + "result": { "msg": [], "ok": true } + } + }, + "header": { "ok": true, "code": "OK" } +} +``` + #### Response shape — failed commit (browser capture, 2026-06-18) **Important:** HTTP / envelope success is **not** commit success. On a failed @@ -166,8 +197,9 @@ Per-entity validation details live in `data.validation.details`, keyed by id The package must treat a commit as failed when `data.res.ok` or `data.validation.result.ok` is `false`, even if `header.ok` is `true`. Revisions appear on validation detail entries (`rev`), not as a top-level `_rev` conflict -like the existing `PATCH nGraphElements` path — **[VERIFY]** whether revision -mismatch surfaces the same way on other failure modes. +like the existing `PATCH nGraphElements` path. On 2025.4.9, `updateTopology` is +**last-writer-wins** — a stale `_rev` in the payload is ignored +([endpoints.md](../endpoints.md#post-restv2actionsstatuscollectorupdatetopology)). ### Write target — the `nGraphElements` config store (confirmed) @@ -184,23 +216,28 @@ error *"A required edge was not found. (main)"* refers to this edge model. See This means the change-set write layer does **not** need new topology element models — it can reuse the existing ones for the persisted form, while the -collector read aggregate keeps its own status-oriented shape. **[VERIFY]** -whether `updateTopology` enforces `_rev` optimistic concurrency or is -last-writer-wins. - -What remains **[VERIFY]**: - -- Whether the server exposes a **separate staging / change-set id** API, or - staging is purely client-side until this POST. -- **Successful** commit response shape (`data.items` contents when `ok: true`). -- Whether multi-category edits that pass validation can still **partially apply** - (the failed example returned empty `items`, suggesting reject-before-apply). -- Other `…/actions/status/collector/*` endpoints (discard, validate-only, …). -- Meaning of validation `status` codes (e.g. `-22`) and when `resolvable` is - `true`. -- Cross-check against the - [Public API 2025 LTS](https://documenter.getpostman.com/view/11222813/2sBXihpCS8#intro) - (`Connections` / `Partial Connections` / `Import and Export`). +collector read aggregate keeps its own status-oriented shape. **Confirmed +last-writer-wins** on 2025.4.9 — `updateTopology` does not enforce `_rev` +checks; stale `_rev` in `replaceEdges` is ignored +([endpoints.md](../endpoints.md#post-restv2actionsstatuscollectorupdatetopology)). + +What remains unverified: + +- ~~Whether the server exposes a **separate staging / change-set id** API~~ + **Confirmed client-side only** on 2025.4.9. +- ~~**Successful** commit response shape~~ **Confirmed** (see above). +- ~~**Partial apply** after validation~~ **Confirmed reject-before-apply**. +- ~~Validation `status` codes and `resolvable`~~ **Confirmed** for booking-blocked + delete (`status: -22`, `resolvable: false`). `resolvable: true` not observed. +- ~~`validateTopology` / `discardTopology` / `importTopology` / `exportTopology`~~ + **Confirmed unregistered** on 2025.4.9 via + `GET /rest/v2/actions/status/collector/` schema (empty `collector: {}`); + 27+ POST payload variants tried. See + [endpoints.md — Action registration discovery](../endpoints.md#action-registration-discovery). +- ~~Import / Export (preview)~~ **Confirmed unregistered** — empty data + namespaces and empty GET action schema. +- Other `…/actions/status/collector/*` lookup endpoints — captured in + [endpoints.md](../endpoints.md). ## Options diff --git a/docs/inspect-app/decisions/0007-lazy-snapshot-loading.md b/docs/inspect-app/decisions/0007-lazy-snapshot-loading.md new file mode 100644 index 0000000..670ee20 --- /dev/null +++ b/docs/inspect-app/decisions/0007-lazy-snapshot-loading.md @@ -0,0 +1,126 @@ +# ADR-0007: Skeleton-first snapshot loading with lazy hydration + +> Status: **Accepted** — supersedes the per-read decision of +> [ADR-0002](./0002-loading-and-state.md) +> Date: 2026-07-08 · Deciders: Jonas Scholl + +## Context + +[ADR-0002](./0002-loading-and-state.md) decided "stateless, eager per-entity — +always load the full aggregate." For the Inspect snapshot this means +`GET /rest/v2/data/status/collector/**`: one request whose payload is +O(entire network) and dominated by module/port subtrees and the heavily +denormalized `pathDescriptions`. On large environments (thousands of nodes) +this is too slow for the common automation case, which typically touches the +graph structure of all devices but the *detail* of only a few. + +Two facts changed since ADR-0002 was accepted (see +[endpoints.md — Collector Scoped Queries](../endpoints.md#collector-scoped-queries-captured-from-the-inspect-ui)): + +1. A WebSocket capture of the Inspect UI proves the collector supports + **scoped queries** on its sub-paths: `* where ` filters, `limit N`, + `order by`, and deep field projections — the `[VERIFY]` item from + ADR-0002/concepts §6 is resolved. +2. The **vendor UI itself loads skeleton-first**: its main topology query + suppresses the module subtree (`modules/"_noId"` → `modules: {}`) and its + edge query projects only ids, labels, and status severities. Full module/ + port detail is fetched by separate, narrower queries. + +The usage context from ADR-0002 still holds: short-lived automation runs that +favour predictability and freshness — but the "efficient bulk reads" concern +now outweighs "one request returns everything." + +## Options + +1. **Eager full aggregate (status quo, ADR-0002).** One `/**` fetch per + snapshot. + - Pros: single request; point-in-time consistent; simplest. + - Cons: O(network) payload; seconds–minutes on large plants; most of the + data is never touched. + +2. **Skeleton + transparent per-entity lazy hydration into one accreting + snapshot state.** + - Pros: initial load ~proportional to device/edge count, not port count; + detail cost paid only for entities actually inspected; mirrors the vendor + UI's own strategy; domain surface unchanged (property access just works). + - Cons: hidden HTTP on property access; snapshot no longer single-point-in- + time; N+1 risk when iterating details; more bookkeeping. + +3. **Skeleton + explicit `load_details()` calls.** + - Pros: no hidden I/O; explicit cost model. + - Cons: leaks the loading mechanics into every user workflow; unloaded + property access needs error or `None` semantics — worse ergonomics. + +4. **Per-field projections on every access.** + - Pros: minimal bytes per read. + - Cons: extremely chatty; complex merge bookkeeping; no coherent entity + objects. + +## Decision + +**Option 2: skeleton-first snapshot with transparent per-entity lazy +hydration.** The internal-state concept of `InspectSnapshot` is unchanged — +one snapshot, one set of indexes, domain objects resolve through it — but the +state is allowed to be **partially populated** and accretes as details are +fetched. + +- **Skeleton load.** A snapshot is built from two parallel scoped GETs, + using the queries captured from the UI (endpoints.md): + - *Device skeleton*: `inspect/nodeStatus` with the UI's projection and + `modules/"_noId"` — identity, descriptor, `meta`/coordinates, `status`, + `syncSeverity`, tags, `ptpDeviceStatus`; no modules/ports. + - *Edge skeleton*: `externalEdgesByDeviceKey` with the lean projection — + device pair, edge ids, endpoint port `context`/labels, status severities; + no `pathDescriptions`, no bandwidth values. + + The graph structure (all devices, all edges) is therefore complete from the + start; module/port detail and services are not. + +- **Per-entity hydration.** First access to an unloaded device property + (`ports`, port status, path drill-down, …) fetches that one device's + `nodeStatus` subtree using the UI's detail projection (`modules/*`) scoped + to the device. The parsed item replaces the skeleton record; port indexes + for that device are built incrementally. Hydration is idempotent and cached + — repeated access is local. + +- **Section-level lazy loads.** Services are cross-device, so `inspect/paths` + loads as a whole section (UI service-list projection) on first touch of + `get_services()` / `device.services`. `maintenanceBookings`, + `superProfiles`, and `tagInfo` load the same way if and when exposed. + +- **Snapshot construction.** The snapshot holds a reference to the inspect + API layer to perform hydration fetches. Fixture-built snapshots + ([ADR-0005](./0005-e2e-testing.md)) are constructed from a full collector + response with lazy loading inert. An eager mode + (e.g. `get_snapshot(load="full")` → `/**` fetch) is retained for small + environments and for callers needing point-in-time consistency. Scoped + queries are verified to work as REST GETs on 2025.4.9; only the full + UI-length projection hits HTTP 414 — use a trimmed skeleton projection + ([endpoints.md](../endpoints.md#collector-scoped-queries-captured-from-the-inspect-ui)). + +What ADR-0002 got right still stands: **no client-side cache across +snapshots**. All accreted state lives inside one snapshot's lifetime; fresh +data means building a new snapshot. + +## Consequences + +- **Hidden HTTP on property access.** Domain getters may perform one + hydration request and can therefore raise connector errors and add latency. + This is documented, deliberate behaviour (models.md). +- **No single point in time.** Skeleton and hydrated subtrees are fetched at + different moments. The snapshot records a fetch timestamp per entity/ + section; `refresh()` still means "build a new snapshot," never + re-fetch-and-diff. +- **N+1 returns for detail iteration.** Touching detail on every device in a + loop degenerates to one request per device. Mitigation: bulk preload + helpers (e.g. `snapshot.preload_devices([...])`, `get_devices(detail=True)`) + that batch or parallelize hydration + ([ADR-0004](./0004-async-strategy.md)); the skeleton alone answers most + bulk questions (inventory of nodes, connectivity, sync/status severities). +- **Verification status**: all core loading items are confirmed on 2025.4.9 — + GET equivalence, `"_noId"` semantics, single-device hydration forms, + populated `modules/*`, payload sizes (~92 KB skeleton vs ~19 MB full). The + only accepted limitation is HTTP 414 for the untrimmed UI projection. See + [endpoints.md](../endpoints.md#collector-scoped-queries-captured-from-the-inspect-ui) + and the checklist in + [concepts.md §5.1](../concepts.md#51-discovery-checklist-fill-in-as-confirmed). diff --git a/docs/inspect-app/decisions/0008-collector-only-endpoints.md b/docs/inspect-app/decisions/0008-collector-only-endpoints.md new file mode 100644 index 0000000..b93348b --- /dev/null +++ b/docs/inspect-app/decisions/0008-collector-only-endpoints.md @@ -0,0 +1,100 @@ +# ADR-0008: Collector-only endpoint policy (no legacy topology API) + +> Status: **Accepted** +> Date: 2026-07-08 · Deciders: Jonas Scholl + +## Context + +The Inspect package sits on top of two API generations that can both read and +write the same topology data: + +- **Legacy topology surface** (used by `app.topology` today): + `GET/PATCH /rest/v2/data/config/network/nGraphElements/**` — revisioned + (`_rev`, `mode: strict`) — plus older status views such as + `GET /rest/v2/data/status/network/edgesByDevice/**`. +- **Inspect surface**: collector data reads + (`GET /rest/v2/data/status/collector/…`, scoped or `/**`), collector actions + (`POST /rest/v2/actions/status/collector/*` — `updateTopology`, + `lookupInspectDevice`, `lookupSyncInfo`, …), and the network actions used by + Inspect workflows (`POST /rest/v2/actions/status/network/{addDevices,syncDevices}`). + +In the product, Inspect **replaces** the Topology app; `nGraphElements` remains +the underlying store (`updateTopology` writes land there, ADR-0006), but it is +an implementation detail behind the Inspect facade. Mixing the two surfaces in +one package layer would mean two write models (revisioned strict PATCH vs. +commit-time-validated bulk action), two read shapes for the same entities, and +a version-gating story spanning both. + +The Inspect UI's captured traffic (initial load) uses **only** the Inspect +surface, and the UI bundle (`/assets/index-*.js`, 2025.4.9) contains **zero +references to `nGraphElements`** — the edit and commit flows are built on the +collector lookups (`lookupInspectEdgesByIds`, `lookupInspectVertexByIds`, …) +and `updateTopology` +([endpoints.md](../endpoints.md#action-registration-discovery)). The vendor's +own client never touches the legacy surface. + +## Options + +1. **Collector-only (Inspect surface only).** The package never calls + `nGraphElements` or other legacy topology endpoints at runtime. + - Pros: one API generation; one write model; matches what the vendor UI + does; simple version gating; no accidental coupling to the app Inspect + replaces. + - Cons: gives up the only **server-enforced** optimistic locking + (`PATCH nGraphElements` strict mode) and the only revision-bearing read — + consistency must be solved client-side (ADR-0009). + +2. **Hybrid: Inspect surface + `nGraphElements` reads for `_rev`.** + - Pros: real revision tokens for conflict detection. + - Cons: reads and writes disagree (`_rev` from config plane is **ignored** + by `updateTopology` — verified last-writer-wins on 2025.4.9, so the token + buys detection only, not enforcement); couples the package to the legacy + surface anyway. + +3. **Hybrid: `updateTopology` for bulk, strict `PATCH nGraphElements` for + single-entity writes needing hard concurrency guarantees.** + - Pros: server-enforced locking where it matters. + - Cons: two write paths with different validation semantics (`updateTopology` + runs booking/service validation; a raw PATCH bypasses it) — dangerous, not + just inconsistent. + +## Decision + +**Option 1: the Inspect package uses only the Inspect surface.** Allowed at +runtime: + +| Kind | Endpoints | +| ---- | --------- | +| Data reads | `GET /rest/v2/data/status/collector/…` (scoped queries and `/**`) | +| Collector actions | `POST /rest/v2/actions/status/collector/*` (`updateTopology`, `lookupInspectDevice`, `lookupSyncInfo`, and further registered lookups) | +| Network actions | `POST /rest/v2/actions/status/network/addDevices`, `…/syncDevices` | +| System probes | `GET /rest/v2/data/status/system/about/…` (version gating) | + +Explicitly **not called** by the package: + +- `GET`/`PATCH /rest/v2/data/config/network/nGraphElements/**` +- `GET /rest/v2/data/status/network/edgesByDevice/**` +- RPC topology calls + +These stay documented in [endpoints.md](../endpoints.md) as **store +documentation and discovery/cross-check references** only. `app.topology` +remains the public escape hatch for raw, revisioned `nGraphElements` access — +unchanged and out of scope here. + +## Consequences + +- **No `_rev` is available to the Inspect package** (collector items and all + verified lookup responses carry none), and the write path enforces none + (last-writer-wins, verified 2025.4.9). Write consistency is therefore + solved client-side — [ADR-0009](./0009-write-consistency.md). +- The persisted form of an element (all config fields needed to build full + `replace*` payloads) comes from collector-namespace lookups — verified on + 2025.4.9: `lookupInspectDevice` (devices), `lookupInspectVertexById`/`…ByIds` + (vertices), `lookupInspectEdgesByIds` (edges, full persisted form, batched; + see [endpoints.md](../endpoints.md#post-restv2actionsstatuscollectorlookupinspectedgesbyids)) + — not from `nGraphElements` reads. (`lookupGraphElement` does not exist.) +- The connector URL allow-list for Inspect gains only Inspect-surface prefixes; + `config/network/nGraphElements` is not added for the Inspect app. +- If a future server version changes the Inspect surface (e.g. registers + `validateTopology`, adds rev enforcement to `updateTopology`), the policy is + re-evaluated per version — never by silently reaching for the legacy API. diff --git a/docs/inspect-app/decisions/0009-write-consistency.md b/docs/inspect-app/decisions/0009-write-consistency.md new file mode 100644 index 0000000..072cf56 --- /dev/null +++ b/docs/inspect-app/decisions/0009-write-consistency.md @@ -0,0 +1,125 @@ +# ADR-0009: Write consistency — client-side compare-and-commit + +> Status: **Accepted** — complements [ADR-0006](./0006-commit-write-model.md) +> (commit model) under the [ADR-0008](./0008-collector-only-endpoints.md) +> endpoint policy +> Date: 2026-07-08 · Deciders: Jonas Scholl + +## Context + +Requirement: a write must not silently overwrite changes somebody else made +between our read and our commit (lost update). What the server gives us +(verified 2025.4.9, see +[endpoints.md — `updateTopology`](../endpoints.md#post-restv2actionsstatuscollectorupdatetopology)): + +- **`updateTopology` is last-writer-wins.** A stale `_rev` in `replace*` + payloads is **ignored** — there is no server-enforced optimistic locking on + the Inspect write path. Commit-time validation protects *service integrity* + (booking-blocked deletes, dangling edge refs), **not** concurrent edits. +- **Collector reads carry no `_rev`.** The only revisioned surface is + `nGraphElements`, which the package does not call + ([ADR-0008](./0008-collector-only-endpoints.md)). +- **`replace*` entries are full-object upserts** (ADR-0006): committing an edge + weight change means sending the *complete* edge. A payload built from stale + state clobbers every other field — so a write is exposed to lost updates even + when the caller only intends to touch one field. +- **No server-side staging**: `validateTopology`/`discardTopology` are + unregistered stubs; the change set exists only client-side until the one + `updateTopology` POST (atomic, reject-before-apply). + +## Options + +1. **Accept last-writer-wins.** Document the risk, do nothing. + - Pros: zero cost. + - Cons: violates the requirement; full-object upserts make silent clobbering + likely, not just possible. + +2. **Client-side compare-and-commit.** Record a baseline of every touched + entity at staging time; immediately before the commit POST, re-read the + entities and compare against the baseline; abort on any drift. + - Pros: detects concurrent modification with Inspect-surface endpoints only; + the stage-time read is needed anyway to build full `replace*` payloads; + conflicts surface as one typed error before anything is written. + - Cons: a race window remains between the pre-commit re-read and the POST + (TOCTOU) — detection, not enforcement; costs one extra scoped read per + touched entity at commit time. + +3. **Rev tokens from `nGraphElements`.** + - Cons: rejected by ADR-0008; and pointless as enforcement — `updateTopology` + ignores `_rev`, so the token would still only enable client-side detection + (same guarantee as option 2, plus a legacy-surface dependency). + +4. **Strict `PATCH nGraphElements` for writes.** + - Cons: rejected by ADR-0008; bypasses `updateTopology`'s booking/service + validation — trades one integrity mechanism for another. + +## Decision + +**Option 2: compare-and-commit, built into the change-set lifecycle +(ADR-0006's transaction / direct-write paths both get it).** + +1. **Baseline at staging time.** When an entity is first staged (replace or + remove), the change set fetches and stores its current form via + Inspect-surface lookups (all verified 2025.4.9, payloads in + [endpoints.md](../endpoints.md#post-restv2actionsstatuscollectorlookupinspectedgesbyids); + none of them exposes a `_rev`): + - edges: `lookupInspectEdgesByIds` — the **full persisted edge form** + (every `replaceEdges` field), batched; the UI's own edit flow calls it + with both directions of a connection. + - vertices: `lookupInspectVertexByIds` (batched) — editable vertex form + (`fields` incl. label, tags, `typeFields` capability flags, + `useAsEndpoint`). Vertex tag bindings come from this lookup (and hydrated + port `tagsInfo`), **not** from `nGraphElements` — they are stored in + `videoipath_docs.device_tags` server-side + ([concepts.md §3.4](../concepts.md#34-tagging--device-vs-vertex-inspect-vs-topology)). + - devices: `lookupInspectDevice` — editable device form (coordinates, + descriptor, iconType, sdpStrategy, tags). + + Edges come back in the persisted `replaceEdges` shape directly; for + devices and vertices the lookup returns the *edit form*, and the ACL maps + it to the persisted `replace*` shape (e.g. `coordinates` ↔ `maps[]`) — the + same mapping the UI performs. The baseline serves double duty: it is the + basis for building the full `replace*` payload (caller mutations applied + on top) *and* the reference for conflict detection, compared on the + editable fields. For `remove` entries the baseline is the element's + existence + form. + +2. **Pre-commit conflict check.** `commit()` re-fetches the same entities and + deep-compares against the baselines over the fields the package models + (volatile status fields excluded). Any mismatch aborts the whole commit — + consistent with the server's all-or-nothing apply — and raises a typed + conflict error carrying the entity ids and per-field diffs. The caller + decides: re-stage on top of fresh state, or override. + +3. **Override is explicit.** A caller can skip the check + (`commit(check_conflicts=False)` or equivalent) — that is deliberate + last-writer-wins, stated in the call. The server's `force` flag is + unrelated (it does not bypass apply-gate errors; verified) and stays an + independent, documented option. + +4. **Commit result still rules.** Compare-and-commit runs *before* the POST; + `data.res.ok` / `data.validation.result.ok` evaluation after the POST is + unchanged (ADR-0006). + +## Consequences + +- **Honest guarantee: detection, not enforcement.** The re-read→POST window + cannot be closed with the current server. This is the strongest guarantee + available on the Inspect surface; state it plainly in user docs. Re-check + per server version whether `updateTopology` gains rev enforcement + (**[VERIFY]** on upgrades; concepts.md §5.1). +- One extra lookup round per stage and per commit — bounded by change-set + size, not network size, and batchable: `lookupInspectEdgesByIds` and + `lookupInspectVertexByIds` take id lists natively (verified). +- The conflict check needs a stable field-level equality over the persisted + form — DTO comparison must exclude server-managed/volatile fields; document + the excluded set alongside the DTOs. +- Snapshot data (rev-less, possibly stale) is explicitly **not** used as the + baseline; baselines always come from fresh lookups at stage time. The + snapshot is a read surface, not a write token + ([ADR-0007](./0007-lazy-snapshot-loading.md), + ADR-0010 for post-commit refresh). +- The device/vertex lookups return *edit forms*, not raw persisted elements — + the ACL's edit-form ↔ `replace*`-shape mapping must be exact, or replaces + clobber unmapped fields. Fixture-test the round-trip per element kind + (edges need none; devices/vertices do). diff --git a/docs/inspect-app/decisions/0010-post-commit-snapshot-refresh.md b/docs/inspect-app/decisions/0010-post-commit-snapshot-refresh.md new file mode 100644 index 0000000..c06f04d --- /dev/null +++ b/docs/inspect-app/decisions/0010-post-commit-snapshot-refresh.md @@ -0,0 +1,100 @@ +# ADR-0010: Post-commit snapshot maintenance — targeted invalidation + scoped re-fetch + +> Status: **Accepted** — extends [ADR-0007](./0007-lazy-snapshot-loading.md) +> to the write path ([ADR-0006](./0006-commit-write-model.md) commit model) +> Date: 2026-07-08 · Deciders: Jonas Scholl + +## Context + +After a successful `updateTopology` commit, the caller's `InspectSnapshot` is +stale exactly where the commit touched it. Automation flows typically continue +working with the snapshot right after a write ("connect, then assert the edge +exists"), so the state must catch up — efficiently: + +- A full re-snapshot costs ~19 MB (`/**`) on the 30-device test instance + (measured sizes in + [endpoints.md](../endpoints.md#observed-subscription-transport-reference-only--out-of-package-scope)) + and scales with network size — unacceptable per write. +- Scoped reads are cheap: one device subtree ≈ 21 KB, the complete edge + skeleton ≈ 84 KB. +- The change set knows precisely which entities it touched, and the commit + response confirms them (`data.items[]` with per-item `res.ok`). +- The collector is a **status-plane projection** of the config store the + commit writes to; the change may become visible only after a propagation + delay (**[VERIFY]** below). + +## Options + +1. **Full re-snapshot after every commit.** Correct but O(network) per write — + defeats ADR-0007. + +2. **Optimistic local apply.** Translate the committed write DTOs into the + snapshot's read shapes and patch the indexes locally, no re-read. + - Pros: zero extra requests. + - Cons: write shapes (`nGraphElements` element form) ≠ collector read shapes + (status projection with `vertexInfo`, live status, `pathDescriptions`); + the translation re-implements server logic and cannot produce the + server-derived fields — the snapshot would hold fabricated entries. + +3. **Targeted invalidation + scoped re-fetch of affected entities.** Reuse the + ADR-0007 hydration machinery: mark what the commit touched as stale, re-read + only that. + +4. **Invalidate-only (fully lazy).** Like 3, but never re-fetch eagerly; next + property access re-hydrates. + - Pros: cheapest when the caller never looks again. + - Cons: "commit, then assert" — the common automation pattern — hits the + propagation delay unmanaged, on an access that looks local. + +## Decision + +**Option 3, with lazy fallback (option 4) for sections.** After a commit whose +result is success (`res.ok` and `validation.result.ok`): + +1. **Compute the affected set** from the change set and the commit response + `items[]`: + - *devices*: keys of `replaceDevices`, removed device ids, plus the owning + device of every replaced/removed vertex and edge endpoint (derivable from + the id conventions — vertex `device-a.module-1.port-out-1.out` → device + `device-a`). + - *edge pairs*: `replaceEdges` / `addExternalEdges` keys and removed edge + ids, mapped to their `deviceA::deviceB` pair keys (both directions belong + to one pair item). +2. **Apply removes locally**: deleted entities leave the indexes and caches + immediately (no read needed to know they are gone). +3. **Invalidate and eagerly re-fetch** the remaining affected entities with the + existing scoped queries: `nodeStatus//…` per affected device and + the edge-pair item per affected pair — direct pair addressing + `externalEdgesByDeviceKey//` is **verified** + on 2025.4.9 (returns the single pair item). Cached domain objects for these + ids are dropped or updated so subsequent property access sees the new + state; per-entity fetch timestamps are updated (ADR-0007). +4. **Sections go stale, not eager**: if the services section (`inspect/paths`) + or other section-level data was loaded, mark it stale and let the next + access re-load it (ADR-0007 lazy path). Commits change services only + indirectly; eager re-reads here are usually wasted. +5. **Propagation handling**: the re-fetch verifies the committed change is + visible (e.g. a replaced field has the committed value) and retries briefly + if the projection lags; give up after a small bounded window and surface + the staleness (log + fetch timestamp), never hang. + **[VERIFY]** the actual delay between `updateTopology` OK and collector + visibility on a real instance — if it is consistently ~0, drop the retry. + +A failed commit changes nothing server-side (reject-before-apply, verified) — +the snapshot is left untouched. + +## Consequences + +- Post-commit cost is proportional to the **change set size**, not the network: + N affected devices ⇒ ~N × 21 KB + one edge query. +- The snapshot stays a pure read model built from collector responses — no + fabricated entries (option 2 rejected), no divergence between "what we wrote" + and "what the server derived". +- The refresh step needs the affected-set derivation to be exact; the id + conventions it relies on are already documented (concepts.md §3.1) and + fixture-tested. +- Remaining `[VERIFY]` (tracked in concepts.md §5.1): the collector + propagation delay after a commit — requires a live write test (coordinate + nudge + revert) to size the retry window. +- Together with ADR-0009: `commit()` = pre-commit conflict check → POST → + result evaluation → targeted refresh. One documented lifecycle. diff --git a/docs/inspect-app/decisions/README.md b/docs/inspect-app/decisions/README.md index 39746b2..cd8e8f2 100644 --- a/docs/inspect-app/decisions/README.md +++ b/docs/inspect-app/decisions/README.md @@ -18,11 +18,15 @@ accepted — to change a decision, add a new ADR that supersedes the old one | ADR | Title | Status | | ----------------------------------------------------- | ------------------------------------ | ---------- | | [0001](./0001-api-paradigm.md) | API paradigm: data-driven | Accepted | -| [0002](./0002-loading-and-state.md) | Loading & state model | Accepted | +| [0002](./0002-loading-and-state.md) | Loading & state model | Superseded by ADR-0007 | | [0003](./0003-websocket-subscriptions.md) | WebSocket event subscriptions | Deprecated | | [0004](./0004-async-strategy.md) | Async readiness & migration | Accepted | | [0005](./0005-e2e-testing.md) | E2E testing strategy | Accepted | | [0006](./0006-commit-write-model.md) | Commit-style write model (change sets) | Accepted | +| [0007](./0007-lazy-snapshot-loading.md) | Skeleton-first loading & lazy hydration | Accepted | +| [0008](./0008-collector-only-endpoints.md) | Collector-only endpoint policy | Accepted | +| [0009](./0009-write-consistency.md) | Write consistency (compare-and-commit) | Accepted | +| [0010](./0010-post-commit-snapshot-refresh.md) | Post-commit snapshot refresh | Accepted | ## Template diff --git a/docs/inspect-app/endpoints.md b/docs/inspect-app/endpoints.md index 94f408b..492ec9c 100644 --- a/docs/inspect-app/endpoints.md +++ b/docs/inspect-app/endpoints.md @@ -7,8 +7,12 @@ read-only requests and one empty no-op `updateTopology` POST were executed. The Inspect package scope follows the accepted ADRs: -- Request/response only; no WebSocket subscription API. -- Stateless reads; callers re-fetch when they need fresh status. +- Request/response only; no WebSocket subscription API. Subscription *captures* + are still used as an endpoint-discovery source — see + [Collector Scoped Queries](#collector-scoped-queries-captured-from-the-inspect-ui). +- Snapshot-scoped reads: a snapshot loads a skeleton first and lazily hydrates + detail; fresh status means building a new snapshot + ([ADR-0007](./decisions/0007-lazy-snapshot-loading.md)). - Data-only DTOs; write/commit behaviour belongs in the future app/transaction layer, not in the model classes. @@ -78,9 +82,14 @@ Response example: ## `GET /rest/v2/data/status/collector/**` -Purpose: canonical Inspect read aggregate. This is the primary read surface for -services, path drill-down, external edge status, and optional topology node -status. +Purpose: full Inspect read aggregate — services, path drill-down, external edge +status, and topology node status in one response. + +> Since [ADR-0007](./decisions/0007-lazy-snapshot-loading.md) this full fetch is +> the **eager/fallback mode** only. The default read path uses the scoped +> queries documented in +> [Collector Scoped Queries](#collector-scoped-queries-captured-from-the-inspect-ui), +> which is also how the vendor's Inspect UI loads its data. Observed top-level sections: @@ -133,6 +142,276 @@ Response shape example: } ``` +## Collector Scoped Queries (captured from the Inspect UI) + +Source: a browser WebSocket capture of the Inspect app's **initial load** +(2026-07-08, VideoIPath 2025.4.x test instance, 27 devices / 40 edge pairs). +The UI never fetches `/status/collector/**`. It opens ~8 **parallel scoped +subscriptions**, each addressing a collector sub-path with a filter and a deep +field projection. These queries are the concrete basis for the skeleton + +lazy-hydration loading model ([ADR-0007](./decisions/0007-lazy-snapshot-loading.md)). + +All ids, labels, and addresses below are anonymized. Decoded paths are shown +with URL encoding removed (`%20` → space, `%22` → `"`, `%2C` → `,`, +`%3D` → `=`, `%3C` → `<`). + +### Observed subscription transport (reference only — out of package scope) + +Each subscription is one WebSocket message; the `path` addresses the same data +tree as REST v2: + +```json +{"channel": "subsc", "messageId": 14, "path": "/status/collector/externalEdgesByDeviceKey/", "id": ""} +``` + +The initial reply carries the full query result; the `data` object mirrors the +REST v2 `data` tree exactly (`_items[]`, `_id`, `_vid`): + +```json +{"channel": "subsc", "id": "", "messageId": 14, "payload": {"_id": "", "_ver": [1, 1], "data": {"status": {"collector": {"externalEdgesByDeviceKey": {"_items": ["..."]}}}}}} +``` + +Protocol details (verified by live connection and the UI bundle on 2025.4.9): + +| Item | Value | +| ---- | ----- | +| URL | `{ws\|wss}:///rest/v2/sessions/me/ws?exclusive=false` | +| Auth | Session cookies on the WebSocket handshake (same as REST) | +| Subscribe | `{"channel":"subsc","messageId":N,"path":"","id":""}` | +| Initial reply | `{"channel":"subsc","id":"…","messageId":N,"payload":{"_id":"…","_ver":[1,1],"data":{…}}}` | +| Unsubscribe | `{"channel":"unsubsc","messageId":N,"id":""}` | +| Delta frames | `payload.data._e` (observed in the UI decoder) | +| Reconnect | UI re-opens when the socket is gone and the tab is visible; no dedicated heartbeat channel | + +The package stays request/response ([ADR-0001](./decisions/0001-api-paradigm.md), +[ADR-0003](./decisions/0003-websocket-subscriptions.md)): the same query paths +work as `GET /rest/v2/data`. **Confirmed** on VideoIPath 2025.4.9 +for practical query lengths; URL encoding of spaces (`%20`), quotes (`%22`), +parentheses (`%28`/`%29`), and `=` (`%3D`) works as captured. The **full** UI +projection below returns **HTTP 414** (URI Too Long) as a REST GET — a +proxy/URL-length constraint, not a server bug — so the package uses a trimmed +skeleton projection or the `/**` fallback. + +Measured payload sizes (2025.4.9 instance, 30 devices / 40 edge pairs): + +| Query | Size | +| ----- | ---- | +| `GET …/status/collector/**` (eager fallback) | **19.3 MB** | +| Trimmed device skeleton + edge skeleton | **~92 KB** | +| Minimal `nodeStatus` projection with `"_noId"` (all 30 devices) | **8 KB** | +| Single device `/**` (hydration) | **21 KB** | + +### Query language observed on collector paths + +| Construct | Example | Meaning | +| --------- | ------- | ------- | +| `*` | `nodeStatus/*` | Select all items of a collection | +| `""` | `fromStatus/generic/alias/"a0"` | Select one child by literal key | +| `"_noId"` | `modules/"_noId"` | Subtree expansion suppressor — collection queries return `"modules": {}`; single-device queries omit the `modules` key. Also used on `inspect/paths/"_noId"/…` | +| `* where ` | `* where syncSeverity=2` | Filter items; operators seen: `=`, `<=`, `and`, `or`, parentheses, `contains(,'')`, `lower()` | +| `limit N` | `* where … limit 1000000` | Cap the number of returned items (UI uses `1000` for side lists, `1000000` for the full topology) | +| `order by asc(en) alphanum` | `panelConfigs/* order by label asc(en) alphanum` | Server-side sort (seen outside the collector) | +| `/field1,field2/` | `/deviceId,resourceId/` | Project only the listed fields at the current level | +| `/.../` | `…/coordinates/x,y/.../...` | Pop one level back up in the projection tree | +| `/**` | `status,syncSeverity/**` | Include the full subtree below the selected fields | +| *(no projection)* | `externalEdgesByDeviceKey` | **No expansion**: the bare collection root returns no items (observed, msg 15) | + +### Device skeleton — `nodeStatus` without modules (UI main topology load) + +The UI's primary topology query. Note `modules/"_noId"`: **the vendor UI itself +loads devices without module/port detail**. All 27 captured items came back +with `modules: {}`. + +Decoded subscription path (one line, msg 13): + +```text +/status/collector/inspect/nodeStatus/* where ((syncSeverity=0) or (syncSeverity=1)) or (syncSeverity=3) limit 1000000/deviceId,resourceId/.../context/devicePid,modulePid,portPid/.../.../descriptor/desc,label/.../.../meta/hwPanelType,isCore,isVirtual,siteId/.../coordinates/x,y/.../.../iconSize,iconType,sdpStrategy/**/.../.../tags/*/.../.../.../relatedNodeTags/*/.../.../status,syncSeverity/**/.../.../tags/*/.../.../modules/"_noId"/resourceId/.../context/devicePid,modulePid,portPid/.../.../descriptor/desc,label/.../.../ptpStatus,status/**/.../.../relatedNodeTags/*/.../.../tags/*/.../.../ports/*/pid,resourceId/.../context/devicePid,modulePid,portPid/.../.../descriptor/desc,label/.../.../relatedNodeTags/*/.../.../status/**/.../.../tags/*/.../.../vertexInfo/id,type,vertexType/.../fields/isActive,isControlled,isEndpoint/.../.../in,out/id,label/.../.../.../ptpPortStatus/info/clockType,domain,identity/.../.../status/**/.../.../.../tagsInfo/assigned/inheritedConflict/.../all/*/.../.../inherited/*/label/.../ancestors/*/.../.../path/*/.../.../.../.../local/*/label/.../path/*/.../.../.../.../.../custom/*/.../.../.../pathDescriptions/*/deviceLevel/deviceId,deviceLabel,devicePid,expectConfig/.../inputStatus,outputStatus/label,pid/.../context/devicePid,modulePid,portPid/.../.../status/**/.../.../.../moduleAndDeviceStatus/**/.../.../.../serviceLevel/bookingId,isMain,serviceLabel/.../fromStatus,toStatus/label,pid/.../context/devicePid,modulePid,portPid/.../.../status/**/.../.../.../serviceStatus/config,total/**/.../.../.../.../.../.../.../.../tagsInfo/assigned/inheritedConflict/.../all/*/.../.../inherited/*/label/.../ancestors/*/.../.../path/*/.../.../.../.../local/*/label/.../path/*/.../.../.../.../.../custom/*/.../.../.../.../.../ptpDeviceStatus/info/clockType,domain,identity/.../.../status/**/.../.../.../tagsInfo/assigned/inheritedConflict/.../all/*/.../.../inherited/*/label/.../ancestors/*/.../.../path/*/.../.../.../.../local/*/label/.../path/*/.../.../.../.../.../custom/* +``` + +Effective skeleton selection per device (the module subtree is projected in +full but suppressed by `"_noId"`): + +- identity: `deviceId`, `resourceId`, `context{devicePid,modulePid,portPid}` +- display: `descriptor{desc,label}`, + `meta{hwPanelType,isCore,isVirtual,siteId,coordinates{x,y},iconSize,iconType,sdpStrategy,tags}` +- state: `status/**`, `syncSeverity`, `ptpDeviceStatus{info,status}` +- tagging: `tags`, `relatedNodeTags`, `tagsInfo{assigned,custom}` + +The UI splits `nodeStatus` by sync state into three subscriptions with the same +projection: this one (`syncSeverity` 0/1/3, topology map), a sync-pending list +(msg 9, below), and a label-search variant +(`* where (syncSeverity=3) and (contains(lower(descriptor.label),'')) limit 1000`, +msg 10). A package skeleton load can drop the `where` clause and fetch all +devices in one query. **Confirmed:** omitting `where` does **not** require +`limit` (30 devices returned with or without `limit 1000000` on 2025.4.9). + +Anonymized response item (skeleton shape — note `modules: {}`): + +```json +{ + "_id": "device-a", + "_vid": "device-a", + "context": { "devicePid": "device-a", "modulePid": null, "portPid": null }, + "descriptor": { + "desc": "Type: example_multidevice\nIP: ", + "label": "Example Device A" + }, + "deviceId": "device-a", + "meta": { + "coordinates": { "x": 1600.0, "y": 9050.0 }, + "hwPanelType": null, + "iconSize": "medium", + "iconType": "default", + "isCore": false, + "isVirtual": false, + "sdpStrategy": "always", + "siteId": null, + "tags": [] + }, + "modules": {}, + "ptpDeviceStatus": { + "info": null, + "status": { "sa": 2, "severity": 6 } + }, + "relatedNodeTags": ["Format~~example"], + "resourceId": "device:device-a", + "status": { "sa": 2, "severity": 6 }, + "syncSeverity": 0, + "tags": [], + "tagsInfo": { + "assigned": { "all": [], "inherited": {}, "inheritedConflict": false, "local": {} }, + "custom": [] + } +} +``` + +### Device detail — `nodeStatus` with `modules/*` (hydration template) + +The sync-pending subscription (msg 9) is **byte-identical** to the skeleton +query except for two segments: + +```text +- * where ((syncSeverity=0) or (syncSeverity=1)) or (syncSeverity=3) limit 1000000 ++ * where syncSeverity=2 limit 1000 +- modules/"_noId" ++ modules/* +``` + +With `modules/*` the projection expands modules → ports → `vertexInfo`, +`ptpPortStatus`, per-port `status/**`, `tagsInfo`, and `pathDescriptions` +(`deviceLevel` + `serviceLevel`) — the full drill-down detail. + +This is the template for **per-device lazy hydration** +([ADR-0007](./decisions/0007-lazy-snapshot-loading.md)): reuse the detail +projection but scope it to one device. **Confirmed** on 2025.4.9 — both work; +prefer the shorter direct-id form: + +```http +GET /rest/v2/data/status/collector/inspect/nodeStatus//modules/*/… +GET /rest/v2/data/status/collector/inspect/nodeStatus/* where deviceId='' limit 1/modules/*/… +``` + +**Confirmed** populated `modules/*` on synced devices (`syncSeverity` 0/1/3): +module keys map to child objects whose expansion follows the projection depth +(e.g. `…/modules/*/ports/*/pid,descriptor/label,status` yields ports with +`pid`, `descriptor.label`, and `status`). Devices with `syncSeverity=2` +(sync-pending) return `"modules": {}` even with `modules/*` — modules populate +only after sync completes; the earlier capture was not a projection bug. +`GET …/nodeStatus//**` returns the full subtree (~21 KB for one +device) — fixture: +`tests/fixtures/inspect/2025.4.9/device_hydration_modules_ports.json`. + +### Edge skeleton — `externalEdgesByDeviceKey` lean projection + +The UI loads **all** edge pairs with a lean projection (msg 14): endpoint +device pids/labels, edge ids, endpoint port labels + `context`, and the +pair-level status severities — **no `pathDescriptions`, no bandwidth numbers** +(only the `bandwidth` *severity* inside `status`). + +Decoded subscription path (one line, msg 14): + +```text +/status/collector/externalEdgesByDeviceKey/* limit 1000000/primary,secondary/devicePid,label/.../.../status/alarm,bandwidth,maintenance,ptp/.../.../primary/data/*/id/.../fromStatus,toStatus/label/.../context/devicePid,modulePid,portPid/.../.../.../.../.../.../secondary/data/*/id/.../fromStatus,toStatus/label/.../context/devicePid,modulePid,portPid +``` + +Anonymized response item: + +```json +{ + "_id": "device-a::device-b", + "_vid": "device-a::device-b", + "primary": { + "data": { + "edge-uuid-0001": { + "id": "edge-uuid-0001", + "fromStatus": { + "context": { + "devicePid": "device-a", + "modulePid": "device-a.dev.module-1", + "portPid": "device-a.dev.module-1.port-out-1" + }, + "label": "Port A (out)" + }, + "toStatus": { + "context": { + "devicePid": "device-b", + "modulePid": "device-b.dev.module-1", + "portPid": "device-b.dev.module-1.port-in-1" + }, + "label": "Port B (in)" + } + } + }, + "devicePid": "device-a", + "label": "Example Device A" + }, + "secondary": { + "data": {}, + "devicePid": "device-b", + "label": "Example Device B" + }, + "status": { "alarm": 1, "bandwidth": null, "maintenance": null, "ptp": 1 } +} +``` + +A parallel subscription to the **bare collection root** +(`/status/collector/externalEdgesByDeviceKey`, msg 15) returned `_items: []` +while the projected query returned 40 items — without a projection or `/**` +there is no expansion. Edge detail (bandwidth values, `pathDescriptions`) stays +in the full per-pair shape documented under +[`GET …/externalEdgesByDeviceKey/**`](#get-restv2datastatuscollectorexternaledgesbydevicekey). + +### Service list — `inspect/paths` projection + +The UI subscribes to `inspect/paths` with a projection covering `serviceFields` +and the per-hop `path` structure (msg 12; the capture returned no items — no +active services at capture time; the item shape is documented under +[`GET …/inspect/paths/**`](#get-restv2datastatuscollectorinspectpaths)): + +```text +/status/collector/inspect/paths/"_noId"/serviceFields/bid,from,fromLabel,isMain,to,toLabel/.../fromStatus,toStatus/**/.../.../generic/descriptor/desc,label/.../.../.../serviceStatus/config,total/**/.../.../.../.../path/*/bid,ipDesc/.../structure/deviceId,deviceLabel,devicePid,expectConfig/.../inputStatus,outputStatus/label,pid/.../context/devicePid,modulePid,portPid/.../.../status/**/.../.../.../moduleAndDeviceStatus/** +``` + +Selected: `serviceFields{bid,from,fromLabel,isMain,to,toLabel,fromStatus, +toStatus,generic.descriptor,serviceStatus{config,total}}` plus +`path[]{bid,ipDesc,structure{deviceId,deviceLabel,devicePid,expectConfig, +inputStatus,outputStatus,moduleAndDeviceStatus}}` — everything the snapshot's +service index needs, without the full raw records. + +### Auxiliary section queries + +Also part of the UI's initial load (initial replies at capture time in +parentheses): + +| Sub-path (decoded) | Purpose | msg | +| ------------------ | ------- | --- | +| `/status/collector/maintenanceBookings/* where ((contains(lower(generic.descriptor.label),'')) or (contains(tags,''))) and (generic.state<=1)/` | Active maintenance bookings (empty) | 16 | +| `/status/collector/superProfiles` *(projection not captured; reply held profile records)* | Routing profiles (populated) | 3 | +| `/status/collector/tagInfo` *(projection not captured; reply held `profileTags._items[]`)* | Tag → profile mappings (populated) | 4 | +| `/status/conman/services/"_noId" where connection.generic.state<=1/` | Rich service list for the Services panel — **not** a collector path; reference only (empty) | 11 | +| `/status/system/about/copyright,gitHead,version` | Version probe | 17 | +| `/status/system/status/serverState/broadcastAddress,hostname,mode,role` | Server role/state | 19 | + ## `GET /rest/v2/data/status/collector/inspect/paths/**` Purpose: list service/path records with endpoint labels, service state, and @@ -388,8 +667,11 @@ Observed response on this instance: ## `GET /rest/v2/data/status/network/edgesByDevice/**` +> **Reference only — not called by the package** +> ([ADR-0008](./decisions/0008-collector-only-endpoints.md)). + Purpose: existing status-plane edge view. This is not the collector facade, but -it is useful for cross-checking edge payload fields when +it is useful for cross-checking edge payload fields during discovery when `config/network/nGraphElements` is unavailable or permission-filtered. Request: @@ -442,10 +724,27 @@ Response fragment: ## `GET /rest/v2/data/config/network/nGraphElements/**` +> **Reference only — not called by the package** +> ([ADR-0008](./decisions/0008-collector-only-endpoints.md)). This is +> `app.topology`'s surface; it is documented here because `updateTopology` +> persists into it and the `replace*` payloads carry the persisted element +> shape. Its `_rev` is irrelevant to Inspect writes — `updateTopology` ignores +> revisions (last-writer-wins; see +> [ADR-0009](./decisions/0009-write-consistency.md)). + Purpose: revisioned config store that `updateTopology` persists into. Inspect models include independent `InspectApi*` nGraph DTOs for this persisted shape, but they do not import or subclass topology app models. +> **Vertex tags are not stored here.** Device-level tags appear on `baseDevice` +> items, but tag bindings on individual vertices live in +> `videoipath_docs.device_tags` (separate from the `ngraph` table). Inspect +> surfaces vertex tags via `lookupInspectVertexByIds` and hydrated port +> `tagsInfo` in `nodeStatus` — see +> [concepts.md §3.4](./concepts.md#34-tagging--device-vs-vertex-inspect-vs-topology). +> `app.topology` only knows the `nGraphElements` shape and therefore has no +> vertex-tag API. + In the sanitized capture, the endpoint was valid but returned no items: ```json @@ -490,6 +789,145 @@ Manual endpoint discovery also identified the following Inspect-related action endpoints. The examples below are anonymized and were captured with safe lookup or no-op requests. +### `POST /rest/v2/actions/status/collector/lookupInspectEdgesByIds` + +**Verified 2025.4.9.** The Inspect-surface source of an edge's **full persisted +form** — every field a `replaceEdges` payload needs (`weight`, `capacity`, +`bandwidth`, `redundancyMode`, `weightFactors`, `descriptor`, `fDescriptor`, +`tags`, `conflictPri`, `includeFormats`, `excludeFormats`). Batched by design. +This is the stage-time baseline read for compare-and-commit +([ADR-0009](./decisions/0009-write-consistency.md)). The UI bundle's edge edit +flow calls it with **both directions of a connection** +(`[edgeId, pairedEdgeId]`) before opening the dialog. **No `_rev` anywhere in +the response.** + +> A `lookupGraphElement` action does **not** exist on 2025.4.9 (POST → +> `No action node in request`); earlier references to it were wrong. + +Request: + +```json +{ + "header": { "id": 0 }, + "data": ["device-a.module-1.port-out-1.out::device-b.module-1.port-in-1.in"] +} +``` + +Response (keyed by requested edge id): + +```json +{ + "data": { + "device-a.module-1.port-out-1.out::device-b.module-1.port-in-1.in": { + "edge": { + "active": true, + "bandwidth": -1.0, + "capacity": 65535, + "conflictPri": 0, + "descriptor": { "desc": "", "label": "" }, + "excludeFormats": [], + "fDescriptor": { "desc": "", "label": "" }, + "fromId": "device-a.module-1.port-out-1.out", + "includeFormats": [], + "redundancyMode": "Any", + "tags": [], + "toId": "device-b.module-1.port-in-1.in", + "weight": 1, + "weightFactors": { + "bandwidth": { "weight": 0 }, + "service": { "max": 100, "weight": 0 } + } + }, + "fromDevice": "device-a", + "toDevice": "device-b" + } + }, + "header": { "ok": true, "code": "OK" } +} +``` + +Fixture: `tests/fixtures/inspect/2025.4.9/lookup_inspect_edges_by_ids.json`. + +### `POST /rest/v2/actions/status/collector/lookupInspectVertexById` / `…ByIds` + +**Verified 2025.4.9.** Editable vertex form for one id (`data: ""`) +or batched (`…ByIds`, `data: ["", …]`, response keyed by id). The UI bundle +uses only the batched form. **No `_rev`.** Together with +`lookupInspectDevice` (devices, above) and `lookupInspectEdgesByIds` this +covers all three `replace*` element kinds for stage-time baselines +(ADR-0009). + +> **Vertex tags.** Tag bindings on a vertex are **not** part of the topology +> `nGraphElements` store (`app.topology` has no equivalent). Server-side they +> live in `videoipath_docs.device_tags`, separate from the `ngraph` table. +> This lookup is the Inspect-surface source for vertex tag state: +> `assignedTags` (with `all`, `inherited`, `local`, `inheritedConflict`) and +> `fields.tags` / `fields.localAssignedTags`. Hydrated `nodeStatus` ports carry +> the same bindings under `tagsInfo` for read-side display +> ([concepts.md §3.4](./concepts.md#34-tagging--device-vs-vertex-inspect-vs-topology)). + +Response (single form; `…ByIds` nests this per id): + +```json +{ + "data": { + "assignedTags": { "all": [], "inherited": {}, "inheritedConflict": false, "local": {} }, + "context": { + "devicePid": "device-a", + "modulePid": "device-a.dev.module-1", + "portPid": "device-a.dev.module-1.port-out-1" + }, + "customSchemas": {}, + "fields": { + "active": true, + "controlProps": null, + "custom": {}, + "desc": "", + "destinationMonitorLeader": false, + "extraAlertFilters": [], + "label": "Port A (out)", + "localAssignedTags": [], + "queueable": false, + "sipsMode": "NONE", + "tags": [], + "typeFields": { + "ipAddress": null, + "ipNetmask": null, + "public": false, + "supportsCpipeCfg": false, + "supportsIgmpCfg": false, + "supportsMacForwardingCfg": false, + "supportsNsoCfg": false, + "supportsOpenflowCfg": false, + "supportsStaticIgmpCfg": false, + "supportsVlanCfg": false, + "supportsVplsCfg": false, + "type": "ip", + "vlanId": null, + "vrfId": null + }, + "useAsEndpoint": false + }, + "id": "device-a.module-1.port-out-1.out", + "isVirtual": false, + "vertexType": "Out" + }, + "header": { "ok": true, "code": "OK" } +} +``` + +Fixture: `tests/fixtures/inspect/2025.4.9/lookup_inspect_vertex_by_id.json`. + +### `POST /rest/v2/actions/status/collector/lookupNodeInfo` / `…/lookupEdgeInfo` / `…/lookupDeviceVertices` + +**Verified 2025.4.9 — display-oriented**, not baselines. `lookupNodeInfo` +(`data: ""`) and `lookupEdgeInfo` (`data: ""`) return +info-panel section lists (`[{header, content: [{label, field: {type, value}, +meta}]}]`) — the drill-down side panels, with port `context` pids in `meta`. +`lookupDeviceVertices` takes `{"primary": "", "secondary": +""}` and returns the connectable vertices per side in the same +label/value style (the connect dialog's port lists). + ### `POST /rest/v2/actions/status/collector/lookupInspectDevice` Purpose: collector lookup for one Inspect device/topology node. @@ -942,7 +1380,85 @@ Successful commit detection: committed = response.header.ok and response.data.res.ok and response.data.validation.result.ok ``` -Known failure shape from the accepted ADR: +Concurrency: the action performs **no revision check** — a stale `_rev` in +`replace*` payloads is ignored (last-writer-wins, verified 2025.4.9). Conflict +detection is client-side compare-and-commit +([ADR-0009](./decisions/0009-write-consistency.md)); after a successful commit +the snapshot is refreshed with targeted scoped reads +([ADR-0010](./decisions/0010-post-commit-snapshot-refresh.md)). + +Failure modes (verified 2025.4.9): + +| Failure mode | `data.res.ok` | `validation.result.ok` | Example | +| ------------ | ------------- | ---------------------- | ------- | +| Validation gate | `false` | `false` | Booking-blocked device remove (`status: -22`, `resolvable: false`) | +| Apply gate | `false` | `true` | `remove: ["unknown-id"]` → *Cannot remove non-existent object* | +| Bad edge reference | `false` | `true` | `replaceEdges` with non-existent `fromId` | + +- **No partial apply**: mixing a valid `replaceEdges` with an invalid `remove` + in one commit fails entirely (`items: []`) — reject-before-apply. +- **`force: true` does not bypass apply-gate errors** (`res.ok` stays `false` + for non-existent remove keys). +- Writes appear in `nGraphElements` under the composite `fromId::toId` key with + a bumped `_rev`; a parallel UUID-keyed document may also exist for the same + edge. +- `resolvable: true` has not been observed on 2025.4.9. +- Fixtures: `tests/fixtures/inspect/2025.4.9/update_topology_success.json`, + `…/update_topology_fail_remove.json`, `…/update_topology_fail_booking.json`. + +Applied-change response (verified on 2025.4.9 — edge `weight` change): + +```json +{ + "data": { + "items": [ + { + "external": null, + "id": "device-a.dev.module-1.port-out-1.out::device-b.dev.module-1.port-in-1.in", + "idx": 0, + "res": { "msg": [""], "ok": true } + } + ], + "res": { "msg": [], "ok": true }, + "validation": { + "createIds": [], + "details": {}, + "result": { "msg": [], "ok": true } + } + }, + "header": { "ok": true, "code": "OK" } +} +``` + +Known failure shape from the accepted ADR (booking-blocked device delete, +verified 2025.4.9): + +```json +{ + "data": { + "items": [], + "res": { "msg": ["Validation failed"], "ok": false }, + "validation": { + "details": { + "booking-1001": { + "isCancel": false, + "isProduct": false, + "resolvable": false, + "rev": "2-2026-06-10T19:54:01.297948842Z[UTC]", + "status": -22, + "type": "generic" + } + }, + "result": { + "msg": ["A required edge was not found. (main); A required edge was not found. (redundant)"], + "ok": false + } + } + } +} +``` + +Earlier anonymized failure shape (browser capture, 2026-06-18): ```json { @@ -972,3 +1488,57 @@ Known failure shape from the accepted ADR: } } ``` + +## Action registration discovery + +`GET /rest/v2/actions/status/collector/` returns whether an action +is registered on the server (verified 2025.4.9): + +```http +GET /rest/v2/actions/status/collector/updateTopology +``` + +```json +{ + "actions": { + "status": { + "collector": { + "updateTopology": { "desc": "", "label": "UpdateTopology" } + } + } + }, + "header": { "ok": true, "code": "OK" } +} +``` + +The complete registered set (`GET /rest/v2/actions/status/collector/**`, +enumerated live on 2025.4.9): + +```text +findContext lookupInspectDevice lookupPathHistoryTimes +lookupConfigDesc lookupInspectEdgesByIds lookupPathNodeAlarms +lookupDeviceAlarms lookupInspectVertexById lookupResourceSummary +lookupDeviceVertices lookupInspectVertexByIds lookupResourceTransformInfo +lookupEdgeInfo lookupInstancesStatus lookupServiceInfo + lookupNodeInfo lookupSyncInfo + lookupPathHistory lookupVertexAlarms + restoreHistoricalPath + updateTopology +``` + +Unregistered actions return an empty `collector` object; POST to those URLs +responds with `No action node in request` regardless of payload. On 2025.4.9 +`lookupGraphElement`, `validateTopology`, `discardTopology`, `importTopology`, +`exportTopology`, and `importExport/{import,export}` are **not registered** +(27+ POST payload variants tried for `validateTopology`; the UI bundle +references none of them; `importExport` data namespaces are empty under +`status`/`config`/`experimental`). These are unregistered server stubs — no +further payload probing is warranted unless a future version registers them +(re-check the GET schema after upgrades). Fixture: +`tests/fixtures/inspect/2025.4.9/action_schema_collector.json`. + +Cross-check against the UI bundle (`/assets/index-*.js`, 2025.4.9): it +references `updateTopology`, `addDevices`, `syncDevices`, `lookupSyncInfo`, +`lookupInspectEdgesByIds`, `lookupInspectVertexByIds`, `lookupEdgeInfo`, +`lookupNodeInfo`, and `lookupConfigDesc` — and contains **zero references to +`nGraphElements`** ([ADR-0008](./decisions/0008-collector-only-endpoints.md)). diff --git a/docs/inspect-app/models.md b/docs/inspect-app/models.md index c6400a6..0ac0e20 100644 --- a/docs/inspect-app/models.md +++ b/docs/inspect-app/models.md @@ -20,23 +20,32 @@ Wire-shape examples and endpoint references live in ```mermaid flowchart LR - Http[HTTP collector response] --> ApiDto[InspectApiCollectorResponse] - ApiDto --> Snapshot[InspectSnapshot] + Skeleton[Skeleton fetch: devices + edges] --> Snapshot[InspectSnapshot] Snapshot --> Device[InspectDevice] + Device -- unloaded property --> Hydrate[Per-device detail fetch] + Hydrate -- merged into state --> Snapshot Device --> Ports[InspectPort] Device --> Edges[InspectEdge] Device --> Services[InspectService] ``` +A snapshot is built **skeleton-first** +([ADR-0007](./decisions/0007-lazy-snapshot-loading.md)): two parallel scoped +collector queries load all devices (without modules/ports) and all external +edges (lean projection). Detail is **lazily hydrated** — accessing an unloaded +property fetches that one device's full `nodeStatus` subtree (or, for +services, the `inspect/paths` section once) and merges it into the snapshot's +internal state. The concrete queries are documented in +[endpoints.md](./endpoints.md#collector-scoped-queries-captured-from-the-inspect-ui). + Typical read flow: ```python -response = InspectApiCollectorResponse.model_validate(payload) -snapshot = InspectSnapshot.from_response(response) -device = snapshot.get_device_by_id("device-a") -ports = device.ports -edges = device.edges -services = device.services +snapshot = app.inspect.get_snapshot() # skeleton fetch (devices + edges) +device = snapshot.get_device_by_id("device-a") # local: skeleton-backed +ports = device.ports # first access: hydrates device-a, then local +edges = device.edges # local: edge skeleton +services = device.services # first access: loads the paths section, then local linked = device.linked_devices port = ports[0] @@ -49,8 +58,21 @@ Relation getters resolve full domain objects from snapshot indexes. Repeated access returns the same cached instance for a given device, port, edge, or service. -Refresh data by fetching a new collector response and building a new snapshot. -Relation getters never perform hidden HTTP requests. +The loading contract: + +- A getter performs **at most one hydration fetch** per entity (device + subtree) or section (services, maintenance bookings, …); after that, access + is local. Hydrated data is merged into the same snapshot state and indexes. +- Because hydration is HTTP, touching an unloaded property can add latency and + raise connector errors — this is deliberate, documented behaviour. +- Iterating detail over many devices hydrates one device per iteration (N+1); + use bulk preload helpers (e.g. `snapshot.preload_devices(...)` / + `get_devices(detail=True)`) for that pattern. +- The snapshot is **not** a single point in time: skeleton and hydrated + subtrees carry their own fetch timestamps. + +Refresh data by building a new snapshot; the state accretes within one +snapshot's lifetime but is never reused across snapshots. ## Mental Model @@ -108,8 +130,29 @@ The useful Inspect content is under `data.status.collector`. ``` VideoIPath collections use `_items`. Transport DTOs preserve that wire shape. -`InspectSnapshot` indexes the parsed collector data once and exposes user-facing -objects from those indexes. +`InspectSnapshot` indexes the skeleton data at construction and extends its +indexes incrementally as entities and sections are hydrated. Scoped queries +return the same wire shapes as the full aggregate, just filtered/projected — +one set of DTOs covers both (skeleton items simply have `modules: {}` and +omitted fields). + +## Loaded vs. Unloaded State + +The snapshot tracks, per device and per section, whether detail has been +hydrated and when it was fetched. + +| Data | Backing | Loaded when | +| --- | --- | --- | +| Device identity, `label`, `pid`, `coordinates`, icon/meta, `status`, `sync_severity`, `tags` | Device skeleton query | Snapshot construction | +| Edge connectivity, endpoint ports/labels, status severities | Edge skeleton query | Snapshot construction | +| Device `ports` (modules, port status, `vertexInfo`, port `tagsInfo`, PTP), port-level path drill-down | Per-device `nodeStatus` subtree (`modules/*` projection) | First access on that device | +| `services`, service path structures | `inspect/paths` section query | First access to any service data | +| Maintenance bookings, super profiles, tag info | Section queries | First access, if exposed | +| Edge bandwidth values, edge `pathDescriptions` | Full per-pair edge shape | Not in the skeleton; loaded with the owning section/entity detail | + +An eager snapshot (`load="full"`, one `GET …/collector/**`) starts fully +hydrated; fixture-built snapshots used in offline tests behave the same, with +lazy loading inert. ## User-Facing Domain Objects @@ -152,6 +195,7 @@ external edge to another device. | `module_id` | Owning module | | `status` | Port status summary | | `vertex_id` | Linked topology vertex when available | +| `tags` | Vertex tag bindings when hydrated (`tagsInfo` from `nodeStatus`) | | `edge` | External edge when this port connects to another device; otherwise `None` | ### `InspectEdge` @@ -392,6 +436,40 @@ Action endpoints return focused views for UI workflows. } ``` +`lookupInspectVertexByIds` returns the editable vertex form, including **vertex +tag bindings** (not available from `nGraphElements` or `app.topology`): + +```json +{ + "data": { + "device-a.module-1.port-out-1.out": { + "assignedTags": { + "all": ["#example-tag"], + "inherited": {}, + "inheritedConflict": false, + "local": { "#example-tag": { "label": "#example-tag", "path": [] } } + }, + "context": { + "devicePid": "device-a", + "modulePid": "device-a.dev.module-1", + "portPid": "device-a.dev.module-1.port-out-1" + }, + "fields": { + "label": "Port A (out)", + "localAssignedTags": ["#example-tag"], + "tags": ["#example-tag"], + "typeFields": { "type": "ip" } + }, + "id": "device-a.module-1.port-out-1.out", + "vertexType": "Out" + } + } +} +``` + +This is the stage-time baseline for vertex tag fields in compare-and-commit +([ADR-0009](./decisions/0009-write-consistency.md)). + `lookupSyncInfo` returns per-device sync differences: ```json @@ -436,9 +514,20 @@ returned. Those responses contain only the REST header with validation details. The collector snapshot is a status view. Committed Inspect topology data is stored in `config.network.nGraphElements._items[]`. Each item has an ID, optional -revision, display descriptors, tags, and a `type` value that tells callers which +revision, display descriptors, and a `type` value that tells callers which shape to parse. +> **Vertex tags are not persisted here.** Device-level tags live on `baseDevice` +> items, but bindings of tags to individual vertices (`ipVertex`, `codecVertex`, +> …) are stored server-side in `videoipath_docs.device_tags`, not in the +> `ngraph` / `nGraphElements` store. `app.topology` therefore has no vertex-tag +> concept. Inspect reads vertex tags from hydrated port `tagsInfo` in +> `nodeStatus` and from `lookupInspectVertexByIds` (`assignedTags`, +> `fields.tags`, `fields.localAssignedTags`) — see +> [concepts.md §3.4](./concepts.md#34-tagging--device-vs-vertex-inspect-vs-topology). +> A `tags` field may appear on vertex elements in `nGraphElements` wire examples +> but is not the authoritative store for vertex tag bindings. + ```mermaid flowchart LR BaseDevice[baseDevice: device node] @@ -579,6 +668,51 @@ response.header.ok and response.data.res.ok and response.data.validation.result. Validation failures can still arrive with `header.ok == true`, so callers must inspect `data.res`, `data.validation.result`, and `data.validation.details`. +### Consistency Against Concurrent Writers + +`updateTopology` enforces no revisions (last-writer-wins), `replace*` entries +are full-object upserts, and collector reads carry no `_rev` — so the change +set protects callers itself +([ADR-0009](./decisions/0009-write-consistency.md)): + +- **Staging an entity fetches its baseline**: the current form, read via + Inspect-surface lookups (`lookupInspectDevice` for devices, + `lookupInspectVertexByIds` for vertices, `lookupInspectEdgesByIds` for edges + — the latter returns the full persisted edge form, batched; see + [endpoints.md](./endpoints.md#post-restv2actionsstatuscollectorlookupinspectedgesbyids)). + The caller's mutations are applied on top of the baseline, so the committed + payload never clobbers fields built from stale state. +- **`commit()` re-checks before posting**: the touched entities are re-read + and compared against their baselines; any third-party change aborts the whole + commit with a typed conflict error (entity ids + field diffs). Skipping the + check is an explicit opt-in (deliberate last-writer-wins). +- The check is **detection, not enforcement** — a small race window between + re-read and POST remains; the server offers nothing stronger on the Inspect + surface. + +Snapshot data is never used as a baseline — it is a read projection without +revisions, possibly stale by design (lazy hydration). + +### Snapshot Refresh After a Commit + +A successful commit leaves the caller's `InspectSnapshot` stale exactly where +the change set touched it. Instead of a full re-snapshot (~MBs), the snapshot +catches up with **targeted scoped re-reads** +([ADR-0010](./decisions/0010-post-commit-snapshot-refresh.md)): + +- removed entities are dropped from the indexes locally; +- affected devices and edge pairs (derived from the change-set keys and the + commit response `items[]`) are re-fetched with the same per-device / + per-pair queries the lazy-hydration path uses, replacing their records and + fetch timestamps; +- loaded sections (e.g. services) are marked stale and re-load lazily on next + access; +- the refresh verifies the committed values are visible and retries briefly if + the collector projection lags the config store. + +A failed commit changes nothing server-side (reject-before-apply), so the +snapshot is left untouched. + ## Python Module Layout Transport DTOs are split by payload area under `apps/inspect/model/`: diff --git a/tests/fixtures/inspect/2025.4.9/action_schema_collector.json b/tests/fixtures/inspect/2025.4.9/action_schema_collector.json new file mode 100644 index 0000000..335c4ee --- /dev/null +++ b/tests/fixtures/inspect/2025.4.9/action_schema_collector.json @@ -0,0 +1,97 @@ +{ + "updateTopology": { + "actions": { + "status": { + "collector": { + "updateTopology": { + "desc": "", + "label": "UpdateTopology" + } + } + } + }, + "header": { + "auth": true, + "caption": "Operation Successful", + "code": "OK", + "errorCodes": [], + "errorDetails": [], + "id": "", + "msg": [], + "ok": true, + "user": "api-user" + } + }, + "validateTopology": { + "actions": { + "status": { + "collector": {} + } + }, + "header": { + "auth": true, + "caption": "Operation Successful", + "code": "OK", + "errorCodes": [], + "errorDetails": [], + "id": "", + "msg": [], + "ok": true, + "user": "api-user" + } + }, + "exportTopology": { + "actions": { + "status": { + "collector": {} + } + }, + "header": { + "auth": true, + "caption": "Operation Successful", + "code": "OK", + "errorCodes": [], + "errorDetails": [], + "id": "", + "msg": [], + "ok": true, + "user": "api-user" + } + }, + "importTopology": { + "actions": { + "status": { + "collector": {} + } + }, + "header": { + "auth": true, + "caption": "Operation Successful", + "code": "OK", + "errorCodes": [], + "errorDetails": [], + "id": "", + "msg": [], + "ok": true, + "user": "api-user" + } + }, + "discardTopology": { + "actions": { + "status": { + "collector": {} + } + }, + "header": { + "auth": true, + "caption": "Operation Successful", + "code": "OK", + "errorCodes": [], + "errorDetails": [], + "id": "", + "msg": [], + "ok": true, + "user": "api-user" + } + } +} diff --git a/tests/fixtures/inspect/2025.4.9/device_hydration_modules_ports.json b/tests/fixtures/inspect/2025.4.9/device_hydration_modules_ports.json new file mode 100644 index 0000000..2aa9e50 --- /dev/null +++ b/tests/fixtures/inspect/2025.4.9/device_hydration_modules_ports.json @@ -0,0 +1,73 @@ +{ + "data": { + "status": { + "collector": { + "inspect": { + "nodeStatus": { + "_items": [ + { + "_id": "device-a", + "_vid": "device-a", + "modules": { + "device-a.dev.0": { + "ports": { + "device-a.dev.module-1.port-out-1": { + "descriptor": { + "label": "port-out-1 (out)" + }, + "pid": "device-a.dev.module-1.port-out-1" + } + } + }, + "device-a.dev.uuid-0001": { + "ports": { + "device-a.dev.uuid-0001.S00000001-0000-4000-8000-000000000001": { + "descriptor": { + "label": "Example Stream" + }, + "pid": "device-a.dev.uuid-0001.S00000001-0000-4000-8000-000000000001" + }, + "device-a.dev.uuid-0001.S00000002-0000-4000-8000-000000000002": { + "descriptor": { + "label": "Example Stream" + }, + "pid": "device-a.dev.uuid-0001.S00000002-0000-4000-8000-000000000002" + } + } + }, + "device-a.dev.uuid-0002": { + "ports": { + "device-a.dev.uuid-0002.S00000003-0000-4000-8000-000000000003": { + "descriptor": { + "label": "Example Pipeline" + }, + "pid": "device-a.dev.uuid-0002.S00000003-0000-4000-8000-000000000003" + }, + "device-a.dev.uuid-0002.S00000004-0000-4000-8000-000000000004": { + "descriptor": { + "label": "Example Pipeline" + }, + "pid": "device-a.dev.uuid-0002.S00000004-0000-4000-8000-000000000004" + } + } + } + } + } + ] + } + } + } + } + }, + "header": { + "auth": true, + "caption": "Operation Successful", + "code": "OK", + "errorCodes": [], + "errorDetails": [], + "id": "", + "msg": [], + "ok": true, + "user": "api-user" + } +} diff --git a/tests/fixtures/inspect/2025.4.9/edge_skeleton.json b/tests/fixtures/inspect/2025.4.9/edge_skeleton.json new file mode 100644 index 0000000..1f360d6 --- /dev/null +++ b/tests/fixtures/inspect/2025.4.9/edge_skeleton.json @@ -0,0 +1,2259 @@ +{ + "data": { + "status": { + "collector": { + "externalEdgesByDeviceKey": { + "_items": [ + { + "_id": "device-h::device-a", + "_vid": "device-h::device-a", + "primary": { + "data": { + "uuid-0003": { + "fromStatus": { + "context": { + "devicePid": "device-h", + "modulePid": "device-h.dev.0", + "portPid": "device-a.dev.module-1.port-out-1" + }, + "label": "port-out-1 (out)" + }, + "id": "uuid-0003", + "toStatus": { + "context": { + "devicePid": "device-a", + "modulePid": "device-a.dev.1", + "portPid": "device-a.dev.1.port-in-1" + }, + "label": "port-in-1" + } + } + }, + "devicePid": "device-h", + "label": "Example Device A" + }, + "secondary": { + "data": {}, + "devicePid": "device-a", + "label": "Example Device" + }, + "status": { + "alarm": 1, + "bandwidth": null, + "maintenance": null, + "ptp": 1 + } + }, + { + "_id": "device-i::device-m", + "_vid": "device-i::device-m", + "primary": { + "data": { + "device-i.1.3.out::device-m.1.Ethernet4_42.in": { + "fromStatus": { + "context": { + "devicePid": "device-i", + "modulePid": "device-i.dev.1", + "portPid": "device-i.dev.1.3" + }, + "label": "eth-0-3" + }, + "id": "device-i.1.3.out::device-m.1.Ethernet4_42.in", + "toStatus": { + "context": { + "devicePid": "device-m", + "modulePid": "device-m.dev.1", + "portPid": "device-m.dev.1.Ethernet4_42" + }, + "label": "port-in-1/42" + } + } + }, + "devicePid": "device-i", + "label": "Example Device" + }, + "secondary": { + "data": { + "device-m.1.Ethernet4_42.out::device-i.1.3.in": { + "fromStatus": { + "context": { + "devicePid": "device-m", + "modulePid": "device-m.dev.1", + "portPid": "device-m.dev.1.Ethernet4_42" + }, + "label": "port-in-1/42" + }, + "id": "device-m.1.Ethernet4_42.out::device-i.1.3.in", + "toStatus": { + "context": { + "devicePid": "device-i", + "modulePid": "device-i.dev.1", + "portPid": "device-i.dev.1.3" + }, + "label": "eth-0-3" + } + } + }, + "devicePid": "device-m", + "label": "Example Device" + }, + "status": { + "alarm": 1, + "bandwidth": 1, + "maintenance": null, + "ptp": 1 + } + }, + { + "_id": "device-i::device-p", + "_vid": "device-i::device-p", + "primary": { + "data": { + "uuid-0002": { + "fromStatus": { + "context": { + "devicePid": "device-i", + "modulePid": "device-i.dev.1", + "portPid": "device-i.dev.1.51" + }, + "label": "eth-0-51" + }, + "id": "uuid-0002", + "toStatus": { + "context": { + "devicePid": "device-p", + "modulePid": "device-p.dev.0", + "portPid": "device-p.dev.0.NMS-RED" + }, + "label": "NMS-RED" + } + } + }, + "devicePid": "device-i", + "label": "Example Device" + }, + "secondary": { + "data": { + "uuid-0005": { + "fromStatus": { + "context": { + "devicePid": "device-p", + "modulePid": "device-p.dev.0", + "portPid": "device-p.dev.0.NMS-RED" + }, + "label": "NMS-RED" + }, + "id": "uuid-0005", + "toStatus": { + "context": { + "devicePid": "device-i", + "modulePid": "device-i.dev.1", + "portPid": "device-i.dev.1.51" + }, + "label": "eth-0-51" + } + } + }, + "devicePid": "device-p", + "label": "Example Device" + }, + "status": { + "alarm": 1, + "bandwidth": 1, + "maintenance": null, + "ptp": 1 + } + }, + { + "_id": "device-i::virtual.0", + "_vid": "device-i::virtual.0", + "primary": { + "data": { + "uuid-0010": { + "fromStatus": { + "context": { + "devicePid": "device-i", + "modulePid": "device-i.dev.1", + "portPid": "device-i.dev.1.51" + }, + "label": "eth-0-51" + }, + "id": "uuid-0010", + "toStatus": { + "context": { + "devicePid": "virtual-0", + "modulePid": "virtual-0.virt.0", + "portPid": "virtual-0.virt.0.1" + }, + "label": "Ethernet 1" + } + } + }, + "devicePid": "device-i", + "label": "Example Device" + }, + "secondary": { + "data": { + "uuid-0006": { + "fromStatus": { + "context": { + "devicePid": "virtual-0", + "modulePid": "virtual-0.virt.0", + "portPid": "virtual-0.virt.0.3" + }, + "label": "Ethernet 1" + }, + "id": "uuid-0006", + "toStatus": { + "context": { + "devicePid": "device-i", + "modulePid": "device-i.dev.1", + "portPid": "device-i.dev.1.51" + }, + "label": "eth-0-51" + } + } + }, + "devicePid": "virtual-0", + "label": "Virtual Device 1" + }, + "status": { + "alarm": 1, + "bandwidth": null, + "maintenance": null, + "ptp": 1 + } + }, + { + "_id": "device-j::device-d", + "_vid": "device-j::device-d", + "primary": { + "data": { + "device-j.0.P1.out::device-b.dev.module-1.port-in-1.in": { + "fromStatus": { + "context": { + "devicePid": "device-j", + "modulePid": "device-j.dev.0", + "portPid": "device-j.dev.0.P1" + }, + "label": "P1 (out)" + }, + "id": "device-j.0.P1.out::device-b.dev.module-1.port-in-1.in", + "toStatus": { + "context": { + "devicePid": "device-d", + "modulePid": "device-d.dev.1", + "portPid": "device-d.dev.1.port-in-1" + }, + "label": "port-in-1" + } + } + }, + "devicePid": "device-j", + "label": "Example Device" + }, + "secondary": { + "data": {}, + "devicePid": "device-d", + "label": "Example Device" + }, + "status": { + "alarm": 1, + "bandwidth": null, + "maintenance": null, + "ptp": 1 + } + }, + { + "_id": "device-a::device-j", + "_vid": "device-a::device-j", + "primary": { + "data": {}, + "devicePid": "device-a", + "label": "Example Device" + }, + "secondary": { + "data": { + "device-j.0.P2.out::device-b.dev.module-1.port-in-1.in": { + "fromStatus": { + "context": { + "devicePid": "device-j", + "modulePid": "device-j.dev.0", + "portPid": "device-j.dev.0.P2" + }, + "label": "P2 (out)" + }, + "id": "device-j.0.P2.out::device-b.dev.module-1.port-in-1.in", + "toStatus": { + "context": { + "devicePid": "device-a", + "modulePid": "device-a.dev.1", + "portPid": "device-a.dev.1.port-in-1" + }, + "label": "port-in-1" + } + } + }, + "devicePid": "device-j", + "label": "Example Device" + }, + "status": { + "alarm": 1, + "bandwidth": null, + "maintenance": null, + "ptp": 1 + } + }, + { + "_id": "device-a::device-k", + "_vid": "device-a::device-k", + "primary": { + "data": { + "device-b.dev.module-1.port-in-1.out::device-k.0.P2.in": { + "fromStatus": { + "context": { + "devicePid": "device-a", + "modulePid": "device-a.dev.1", + "portPid": "device-a.dev.1.port-in-1" + }, + "label": "port-in-1" + }, + "id": "device-b.dev.module-1.port-in-1.out::device-k.0.P2.in", + "toStatus": { + "context": { + "devicePid": "device-k", + "modulePid": "device-k.dev.0", + "portPid": "device-k.dev.0.P2" + }, + "label": "P2 (in)" + } + } + }, + "devicePid": "device-a", + "label": "Example Device" + }, + "secondary": { + "data": {}, + "devicePid": "device-k", + "label": "Example Device" + }, + "status": { + "alarm": 1, + "bandwidth": null, + "maintenance": null, + "ptp": 1 + } + }, + { + "_id": "device-a::device-l", + "_vid": "device-a::device-l", + "primary": { + "data": { + "device-a.1.Ethernet54_1.out::device-l.4.Ethernet3_37_1.in": { + "fromStatus": { + "context": { + "devicePid": "device-a", + "modulePid": "device-a.dev.1", + "portPid": "device-a.dev.1.Ethernet54_1" + }, + "label": "port-in-1/1" + }, + "id": "device-a.1.Ethernet54_1.out::device-l.4.Ethernet3_37_1.in", + "toStatus": { + "context": { + "devicePid": "device-l", + "modulePid": "device-l.dev.4", + "portPid": "device-l.dev.4.Ethernet3_37_1" + }, + "label": "port-in-1/37/1" + } + }, + "device-a.1.Ethernet56_1.out::device-l.4.Ethernet3_39_1.in": { + "fromStatus": { + "context": { + "devicePid": "device-a", + "modulePid": "device-a.dev.1", + "portPid": "device-a.dev.1.Ethernet56_1" + }, + "label": "port-in-1/1" + }, + "id": "device-a.1.Ethernet56_1.out::device-l.4.Ethernet3_39_1.in", + "toStatus": { + "context": { + "devicePid": "device-l", + "modulePid": "device-l.dev.4", + "portPid": "device-l.dev.4.Ethernet3_39_1" + }, + "label": "port-in-1/39/1" + } + } + }, + "devicePid": "device-a", + "label": "Example Device" + }, + "secondary": { + "data": { + "device-l.4.Ethernet3_37_1.out::device-a.1.Ethernet54_1.in": { + "fromStatus": { + "context": { + "devicePid": "device-l", + "modulePid": "device-l.dev.4", + "portPid": "device-l.dev.4.Ethernet3_37_1" + }, + "label": "port-in-1/37/1" + }, + "id": "device-l.4.Ethernet3_37_1.out::device-a.1.Ethernet54_1.in", + "toStatus": { + "context": { + "devicePid": "device-a", + "modulePid": "device-a.dev.1", + "portPid": "device-a.dev.1.Ethernet54_1" + }, + "label": "port-in-1/1" + } + }, + "device-l.4.Ethernet3_39_1.out::device-a.1.Ethernet56_1.in": { + "fromStatus": { + "context": { + "devicePid": "device-l", + "modulePid": "device-l.dev.4", + "portPid": "device-l.dev.4.Ethernet3_39_1" + }, + "label": "port-in-1/39/1" + }, + "id": "device-l.4.Ethernet3_39_1.out::device-a.1.Ethernet56_1.in", + "toStatus": { + "context": { + "devicePid": "device-a", + "modulePid": "device-a.dev.1", + "portPid": "device-a.dev.1.Ethernet56_1" + }, + "label": "port-in-1/1" + } + } + }, + "devicePid": "device-l", + "label": "Example Device" + }, + "status": { + "alarm": 1, + "bandwidth": 1, + "maintenance": null, + "ptp": 1 + } + }, + { + "_id": "device-a::device-r", + "_vid": "device-a::device-r", + "primary": { + "data": { + "uuid-0004": { + "fromStatus": { + "context": { + "devicePid": "device-a", + "modulePid": "device-a.dev.1", + "portPid": "device-a.dev.1.port-in-1" + }, + "label": "port-in-1" + }, + "id": "uuid-0004", + "toStatus": { + "context": { + "devicePid": "device-r", + "modulePid": "device-r.dev.0", + "portPid": "device-r.dev.0.P2" + }, + "label": "P2 (in)" + } + } + }, + "devicePid": "device-a", + "label": "Example Device" + }, + "secondary": { + "data": {}, + "devicePid": "device-r", + "label": "Example Device" + }, + "status": { + "alarm": 1, + "bandwidth": null, + "maintenance": null, + "ptp": 1 + } + }, + { + "_id": "device-a::device-s", + "_vid": "device-a::device-s", + "primary": { + "data": { + "device-b.dev.module-1.port-in-1.out::device-s.0.P2.in": { + "fromStatus": { + "context": { + "devicePid": "device-a", + "modulePid": "device-a.dev.1", + "portPid": "device-a.dev.1.port-in-1" + }, + "label": "port-in-1" + }, + "id": "device-b.dev.module-1.port-in-1.out::device-s.0.P2.in", + "toStatus": { + "context": { + "devicePid": "device-s", + "modulePid": "device-s.dev.0", + "portPid": "device-s.dev.0.P2" + }, + "label": "P2 (in)" + } + } + }, + "devicePid": "device-a", + "label": "Example Device" + }, + "secondary": { + "data": {}, + "devicePid": "device-s", + "label": "Example Device" + }, + "status": { + "alarm": 1, + "bandwidth": 1, + "maintenance": null, + "ptp": 1 + } + }, + { + "_id": "device-a::device-t", + "_vid": "device-a::device-t", + "primary": { + "data": { + "device-b.dev.module-1.port-in-1.out::device-t.0.mmc1.in": { + "fromStatus": { + "context": { + "devicePid": "device-a", + "modulePid": "device-a.dev.1", + "portPid": "device-a.dev.1.port-in-1" + }, + "label": "port-in-1" + }, + "id": "device-b.dev.module-1.port-in-1.out::device-t.0.mmc1.in", + "toStatus": { + "context": { + "devicePid": "device-t", + "modulePid": "device-t.dev.0", + "portPid": "device-t.dev.0.mmc1" + }, + "label": "mmc1 (in)" + } + } + }, + "devicePid": "device-a", + "label": "Example Device" + }, + "secondary": { + "data": {}, + "devicePid": "device-t", + "label": "Example Device" + }, + "status": { + "alarm": 1, + "bandwidth": 1, + "maintenance": null, + "ptp": 1 + } + }, + { + "_id": "device-a::device-u", + "_vid": "device-a::device-u", + "primary": { + "data": { + "device-b.dev.module-1.port-in-1.out::device-u.3.10006.in": { + "fromStatus": { + "context": { + "devicePid": "device-a", + "modulePid": "device-a.dev.1", + "portPid": "device-a.dev.1.port-in-1" + }, + "label": "port-in-1" + }, + "id": "device-b.dev.module-1.port-in-1.out::device-u.3.10006.in", + "toStatus": { + "context": { + "devicePid": "device-u", + "modulePid": "device-u.dev.3", + "portPid": "device-u.dev.3.10006" + }, + "label": "Ethernet 3.4" + } + } + }, + "devicePid": "device-a", + "label": "Example Device" + }, + "secondary": { + "data": { + "device-u.3.10006.out::device-b.dev.module-1.port-in-1.in": { + "fromStatus": { + "context": { + "devicePid": "device-u", + "modulePid": "device-u.dev.3", + "portPid": "device-u.dev.3.10006" + }, + "label": "Ethernet 3.4" + }, + "id": "device-u.3.10006.out::device-b.dev.module-1.port-in-1.in", + "toStatus": { + "context": { + "devicePid": "device-a", + "modulePid": "device-a.dev.1", + "portPid": "device-a.dev.1.port-in-1" + }, + "label": "port-in-1" + } + } + }, + "devicePid": "device-u", + "label": "Example Device" + }, + "status": { + "alarm": 1, + "bandwidth": 1, + "maintenance": null, + "ptp": 1 + } + }, + { + "_id": "device-a::device-f", + "_vid": "device-a::device-f", + "primary": { + "data": { + "device-b.dev.module-1.port-in-1.out::device-f.1.10006.in": { + "fromStatus": { + "context": { + "devicePid": "device-a", + "modulePid": "device-a.dev.1", + "portPid": "device-a.dev.1.port-in-1" + }, + "label": "port-in-1" + }, + "id": "device-b.dev.module-1.port-in-1.out::device-f.1.10006.in", + "toStatus": { + "context": { + "devicePid": "device-f", + "modulePid": "device-f.dev.1", + "portPid": "device-f.dev.1.10006" + }, + "label": "Ethernet 1.4" + } + } + }, + "devicePid": "device-a", + "label": "Example Device" + }, + "secondary": { + "data": { + "device-f.1.10006.out::device-b.dev.module-1.port-in-1.in": { + "fromStatus": { + "context": { + "devicePid": "device-f", + "modulePid": "device-f.dev.1", + "portPid": "device-f.dev.1.10006" + }, + "label": "Ethernet 1.4" + }, + "id": "device-f.1.10006.out::device-b.dev.module-1.port-in-1.in", + "toStatus": { + "context": { + "devicePid": "device-a", + "modulePid": "device-a.dev.1", + "portPid": "device-a.dev.1.port-in-1" + }, + "label": "port-in-1" + } + } + }, + "devicePid": "device-f", + "label": "Example Device" + }, + "status": { + "alarm": 1, + "bandwidth": 1, + "maintenance": null, + "ptp": 1 + } + }, + { + "_id": "device-a::device-g", + "_vid": "device-a::device-g", + "primary": { + "data": { + "device-b.dev.module-1.port-in-1.out::device-g.6.10006.in": { + "fromStatus": { + "context": { + "devicePid": "device-a", + "modulePid": "device-a.dev.1", + "portPid": "device-a.dev.1.port-in-1" + }, + "label": "port-in-1" + }, + "id": "device-b.dev.module-1.port-in-1.out::device-g.6.10006.in", + "toStatus": { + "context": { + "devicePid": "device-g", + "modulePid": "device-g.dev.6", + "portPid": "device-g.dev.6.10006" + }, + "label": "Ethernet 6.4" + } + } + }, + "devicePid": "device-a", + "label": "Example Device" + }, + "secondary": { + "data": { + "device-g.6.10006.out::device-b.dev.module-1.port-in-1.in": { + "fromStatus": { + "context": { + "devicePid": "device-g", + "modulePid": "device-g.dev.6", + "portPid": "device-g.dev.6.10006" + }, + "label": "Ethernet 6.4" + }, + "id": "device-g.6.10006.out::device-b.dev.module-1.port-in-1.in", + "toStatus": { + "context": { + "devicePid": "device-a", + "modulePid": "device-a.dev.1", + "portPid": "device-a.dev.1.port-in-1" + }, + "label": "port-in-1" + } + } + }, + "devicePid": "device-g", + "label": "Example Device" + }, + "status": { + "alarm": 1, + "bandwidth": 1, + "maintenance": null, + "ptp": 1 + } + }, + { + "_id": "device-k::device-d", + "_vid": "device-k::device-d", + "primary": { + "data": {}, + "devicePid": "device-k", + "label": "Example Device" + }, + "secondary": { + "data": { + "device-b.dev.module-1.port-in-1.out::device-k.0.P1.in": { + "fromStatus": { + "context": { + "devicePid": "device-d", + "modulePid": "device-d.dev.1", + "portPid": "device-d.dev.1.port-in-1" + }, + "label": "port-in-1" + }, + "id": "device-b.dev.module-1.port-in-1.out::device-k.0.P1.in", + "toStatus": { + "context": { + "devicePid": "device-k", + "modulePid": "device-k.dev.0", + "portPid": "device-k.dev.0.P1" + }, + "label": "P1 (in)" + } + } + }, + "devicePid": "device-d", + "label": "Example Device" + }, + "status": { + "alarm": 1, + "bandwidth": null, + "maintenance": null, + "ptp": 1 + } + }, + { + "_id": "device-l::device-n", + "_vid": "device-l::device-n", + "primary": { + "data": { + "device-l.4.Ethernet3_47_1.out::device-n.1.49.in": { + "fromStatus": { + "context": { + "devicePid": "device-l", + "modulePid": "device-l.dev.4", + "portPid": "device-l.dev.4.Ethernet3_47_1" + }, + "label": "port-in-1/47/1" + }, + "id": "device-l.4.Ethernet3_47_1.out::device-n.1.49.in", + "toStatus": { + "context": { + "devicePid": "device-n", + "modulePid": "device-n.dev.1", + "portPid": "device-n.dev.1.49" + }, + "label": "eth-0-49" + } + } + }, + "devicePid": "device-l", + "label": "Example Device" + }, + "secondary": { + "data": { + "device-n.1.49.out::device-l.4.Ethernet3_47_1.in": { + "fromStatus": { + "context": { + "devicePid": "device-n", + "modulePid": "device-n.dev.1", + "portPid": "device-n.dev.1.49" + }, + "label": "eth-0-49" + }, + "id": "device-n.1.49.out::device-l.4.Ethernet3_47_1.in", + "toStatus": { + "context": { + "devicePid": "device-l", + "modulePid": "device-l.dev.4", + "portPid": "device-l.dev.4.Ethernet3_47_1" + }, + "label": "port-in-1/47/1" + } + } + }, + "devicePid": "device-n", + "label": "Example Device" + }, + "status": { + "alarm": 1, + "bandwidth": 1, + "maintenance": null, + "ptp": 1 + } + }, + { + "_id": "device-l::device-o", + "_vid": "device-l::device-o", + "primary": { + "data": { + "device-l.4.Ethernet3_1_1.out::device-o.0.p9p1.in": { + "fromStatus": { + "context": { + "devicePid": "device-l", + "modulePid": "device-l.dev.4", + "portPid": "device-l.dev.4.Ethernet3_1_1" + }, + "label": "port-in-1/1/1" + }, + "id": "device-l.4.Ethernet3_1_1.out::device-o.0.p9p1.in", + "toStatus": { + "context": { + "devicePid": "device-o", + "modulePid": "device-o.dev.0", + "portPid": "device-o.dev.0.p9p1" + }, + "label": "p9p1" + } + } + }, + "devicePid": "device-l", + "label": "Example Device" + }, + "secondary": { + "data": { + "device-o.0.p9p1.out::device-l.4.Ethernet3_1_1.in": { + "fromStatus": { + "context": { + "devicePid": "device-o", + "modulePid": "device-o.dev.0", + "portPid": "device-o.dev.0.p9p1" + }, + "label": "p9p1" + }, + "id": "device-o.0.p9p1.out::device-l.4.Ethernet3_1_1.in", + "toStatus": { + "context": { + "devicePid": "device-l", + "modulePid": "device-l.dev.4", + "portPid": "device-l.dev.4.Ethernet3_1_1" + }, + "label": "port-in-1/1/1" + } + } + }, + "devicePid": "device-o", + "label": "Example Device" + }, + "status": { + "alarm": 1, + "bandwidth": 1, + "maintenance": null, + "ptp": 1 + } + }, + { + "_id": "device-l::device-v", + "_vid": "device-l::device-v", + "primary": { + "data": { + "device-l.4.Ethernet3_7_1.out::device-v.0.data1B.in": { + "fromStatus": { + "context": { + "devicePid": "device-l", + "modulePid": "device-l.dev.4", + "portPid": "device-l.dev.4.Ethernet3_7_1" + }, + "label": "port-in-1/7/1" + }, + "id": "device-l.4.Ethernet3_7_1.out::device-v.0.data1B.in", + "toStatus": { + "context": { + "devicePid": "device-v", + "modulePid": "device-v.dev.0", + "portPid": "device-v.dev.0.data1B" + }, + "label": "data1B" + } + } + }, + "devicePid": "device-l", + "label": "Example Device" + }, + "secondary": { + "data": { + "device-v.0.data1B.out::device-l.4.Ethernet3_7_1.in": { + "fromStatus": { + "context": { + "devicePid": "device-v", + "modulePid": "device-v.dev.0", + "portPid": "device-v.dev.0.data1B" + }, + "label": "data1B" + }, + "id": "device-v.0.data1B.out::device-l.4.Ethernet3_7_1.in", + "toStatus": { + "context": { + "devicePid": "device-l", + "modulePid": "device-l.dev.4", + "portPid": "device-l.dev.4.Ethernet3_7_1" + }, + "label": "port-in-1/7/1" + } + } + }, + "devicePid": "device-v", + "label": "Example Device" + }, + "status": { + "alarm": 1, + "bandwidth": null, + "maintenance": null, + "ptp": 1 + } + }, + { + "_id": "device-l::device-y", + "_vid": "device-l::device-y", + "primary": { + "data": { + "device-l.1.Ethernet4_1.out::device-y.0.port-102.in": { + "fromStatus": { + "context": { + "devicePid": "device-l", + "modulePid": "device-l.dev.1", + "portPid": "device-l.dev.1.Ethernet4_1" + }, + "label": "port-in-1/1" + }, + "id": "device-l.1.Ethernet4_1.out::device-y.0.port-102.in", + "toStatus": { + "context": { + "devicePid": "device-y", + "modulePid": "device-y.dev.0", + "portPid": "device-y.dev.0.port-102" + }, + "label": "port-102" + } + } + }, + "devicePid": "device-l", + "label": "Example Device" + }, + "secondary": { + "data": { + "device-y.0.port-102.out::device-l.1.Ethernet4_1.in": { + "fromStatus": { + "context": { + "devicePid": "device-y", + "modulePid": "device-y.dev.0", + "portPid": "device-y.dev.0.port-102" + }, + "label": "port-102" + }, + "id": "device-y.0.port-102.out::device-l.1.Ethernet4_1.in", + "toStatus": { + "context": { + "devicePid": "device-l", + "modulePid": "device-l.dev.1", + "portPid": "device-l.dev.1.Ethernet4_1" + }, + "label": "port-in-1/1" + } + } + }, + "devicePid": "device-y", + "label": "Example Device" + }, + "status": { + "alarm": 1, + "bandwidth": null, + "maintenance": null, + "ptp": 1 + } + }, + { + "_id": "device-l::device-e", + "_vid": "device-l::device-e", + "primary": { + "data": { + "device-l.4.Ethernet3_2_1.out::device-e.1000.1.in": { + "fromStatus": { + "context": { + "devicePid": "device-l", + "modulePid": "device-l.dev.4", + "portPid": "device-l.dev.4.Ethernet3_2_1" + }, + "label": "port-in-1/2/1" + }, + "id": "device-l.4.Ethernet3_2_1.out::device-e.1000.1.in", + "toStatus": { + "context": { + "devicePid": "device-e", + "modulePid": "device-e.dev.1000", + "portPid": "device-e.dev.1000.1" + }, + "label": "IP WAN 1" + } + } + }, + "devicePid": "device-l", + "label": "Example Device" + }, + "secondary": { + "data": { + "device-e.1000.1.out::device-l.4.Ethernet3_2_1.in": { + "fromStatus": { + "context": { + "devicePid": "device-e", + "modulePid": "device-e.dev.1000", + "portPid": "device-e.dev.1000.1" + }, + "label": "IP WAN 1" + }, + "id": "device-e.1000.1.out::device-l.4.Ethernet3_2_1.in", + "toStatus": { + "context": { + "devicePid": "device-l", + "modulePid": "device-l.dev.4", + "portPid": "device-l.dev.4.Ethernet3_2_1" + }, + "label": "port-in-1/2/1" + } + } + }, + "devicePid": "device-e", + "label": "Example Device" + }, + "status": { + "alarm": 1, + "bandwidth": 1, + "maintenance": null, + "ptp": 1 + } + }, + { + "_id": "device-m::device-o", + "_vid": "device-m::device-o", + "primary": { + "data": { + "device-m.4.Ethernet3_1_1.out::device-o.0.p9p2.in": { + "fromStatus": { + "context": { + "devicePid": "device-m", + "modulePid": "device-m.dev.4", + "portPid": "device-m.dev.4.Ethernet3_1_1" + }, + "label": "port-in-1/1/1" + }, + "id": "device-m.4.Ethernet3_1_1.out::device-o.0.p9p2.in", + "toStatus": { + "context": { + "devicePid": "device-o", + "modulePid": "device-o.dev.0", + "portPid": "device-o.dev.0.p9p2" + }, + "label": "p9p2" + } + } + }, + "devicePid": "device-m", + "label": "Example Device" + }, + "secondary": { + "data": { + "device-o.0.p9p2.out::device-m.4.Ethernet3_1_1.in": { + "fromStatus": { + "context": { + "devicePid": "device-o", + "modulePid": "device-o.dev.0", + "portPid": "device-o.dev.0.p9p2" + }, + "label": "p9p2" + }, + "id": "device-o.0.p9p2.out::device-m.4.Ethernet3_1_1.in", + "toStatus": { + "context": { + "devicePid": "device-m", + "modulePid": "device-m.dev.4", + "portPid": "device-m.dev.4.Ethernet3_1_1" + }, + "label": "port-in-1/1/1" + } + } + }, + "devicePid": "device-o", + "label": "Example Device" + }, + "status": { + "alarm": 1, + "bandwidth": 1, + "maintenance": null, + "ptp": 1 + } + }, + { + "_id": "device-m::device-c", + "_vid": "device-m::device-c", + "primary": { + "data": { + "device-m.1.Ethernet4_43.out::device-b.dev.module-1.port-in-1.in": { + "fromStatus": { + "context": { + "devicePid": "device-m", + "modulePid": "device-m.dev.1", + "portPid": "device-m.dev.1.Ethernet4_43" + }, + "label": "port-in-1/43" + }, + "id": "device-m.1.Ethernet4_43.out::device-b.dev.module-1.port-in-1.in", + "toStatus": { + "context": { + "devicePid": "device-c", + "modulePid": "device-c.dev.1", + "portPid": "device-c.dev.1.port-in-1" + }, + "label": "port-in-1" + } + } + }, + "devicePid": "device-m", + "label": "Example Device" + }, + "secondary": { + "data": { + "device-b.dev.module-1.port-in-1.out::device-m.1.Ethernet4_43.in": { + "fromStatus": { + "context": { + "devicePid": "device-c", + "modulePid": "device-c.dev.1", + "portPid": "device-c.dev.1.port-in-1" + }, + "label": "port-in-1" + }, + "id": "device-b.dev.module-1.port-in-1.out::device-m.1.Ethernet4_43.in", + "toStatus": { + "context": { + "devicePid": "device-m", + "modulePid": "device-m.dev.1", + "portPid": "device-m.dev.1.Ethernet4_43" + }, + "label": "port-in-1/43" + } + } + }, + "devicePid": "device-c", + "label": "Example Device" + }, + "status": { + "alarm": 1, + "bandwidth": 1, + "maintenance": null, + "ptp": 1 + } + }, + { + "_id": "device-m::device-v", + "_vid": "device-m::device-v", + "primary": { + "data": { + "device-m.4.Ethernet3_7_1.out::device-v.0.data1A.in": { + "fromStatus": { + "context": { + "devicePid": "device-m", + "modulePid": "device-m.dev.4", + "portPid": "device-m.dev.4.Ethernet3_7_1" + }, + "label": "port-in-1/7/1" + }, + "id": "device-m.4.Ethernet3_7_1.out::device-v.0.data1A.in", + "toStatus": { + "context": { + "devicePid": "device-v", + "modulePid": "device-v.dev.0", + "portPid": "device-v.dev.0.data1A" + }, + "label": "data1A" + } + } + }, + "devicePid": "device-m", + "label": "Example Device" + }, + "secondary": { + "data": { + "device-v.0.data1A.out::device-m.4.Ethernet3_7_1.in": { + "fromStatus": { + "context": { + "devicePid": "device-v", + "modulePid": "device-v.dev.0", + "portPid": "device-v.dev.0.data1A" + }, + "label": "data1A" + }, + "id": "device-v.0.data1A.out::device-m.4.Ethernet3_7_1.in", + "toStatus": { + "context": { + "devicePid": "device-m", + "modulePid": "device-m.dev.4", + "portPid": "device-m.dev.4.Ethernet3_7_1" + }, + "label": "port-in-1/7/1" + } + } + }, + "devicePid": "device-v", + "label": "Example Device" + }, + "status": { + "alarm": 1, + "bandwidth": null, + "maintenance": null, + "ptp": 1 + } + }, + { + "_id": "device-m::device-y", + "_vid": "device-m::device-y", + "primary": { + "data": { + "device-m.1.Ethernet4_1.out::device-y.0.port-110.in": { + "fromStatus": { + "context": { + "devicePid": "device-m", + "modulePid": "device-m.dev.1", + "portPid": "device-m.dev.1.Ethernet4_1" + }, + "label": "port-in-1/1" + }, + "id": "device-m.1.Ethernet4_1.out::device-y.0.port-110.in", + "toStatus": { + "context": { + "devicePid": "device-y", + "modulePid": "device-y.dev.0", + "portPid": "device-y.dev.0.port-110" + }, + "label": "port-110" + } + } + }, + "devicePid": "device-m", + "label": "Example Device" + }, + "secondary": { + "data": { + "device-y.0.port-110.out::device-m.1.Ethernet4_1.in": { + "fromStatus": { + "context": { + "devicePid": "device-y", + "modulePid": "device-y.dev.0", + "portPid": "device-y.dev.0.port-110" + }, + "label": "port-110" + }, + "id": "device-y.0.port-110.out::device-m.1.Ethernet4_1.in", + "toStatus": { + "context": { + "devicePid": "device-m", + "modulePid": "device-m.dev.1", + "portPid": "device-m.dev.1.Ethernet4_1" + }, + "label": "port-in-1/1" + } + } + }, + "devicePid": "device-y", + "label": "Example Device" + }, + "status": { + "alarm": 1, + "bandwidth": null, + "maintenance": null, + "ptp": 1 + } + }, + { + "_id": "device-m::device-d", + "_vid": "device-m::device-d", + "primary": { + "data": { + "device-m.4.Ethernet3_37_1.out::device-d.1.Ethernet53_1.in": { + "fromStatus": { + "context": { + "devicePid": "device-m", + "modulePid": "device-m.dev.4", + "portPid": "device-m.dev.4.Ethernet3_37_1" + }, + "label": "port-in-1/37/1" + }, + "id": "device-m.4.Ethernet3_37_1.out::device-d.1.Ethernet53_1.in", + "toStatus": { + "context": { + "devicePid": "device-d", + "modulePid": "device-d.dev.1", + "portPid": "device-d.dev.1.Ethernet53_1" + }, + "label": "port-in-1/1" + } + }, + "device-m.4.Ethernet3_39_1.out::device-d.1.Ethernet55_1.in": { + "fromStatus": { + "context": { + "devicePid": "device-m", + "modulePid": "device-m.dev.4", + "portPid": "device-m.dev.4.Ethernet3_39_1" + }, + "label": "port-in-1/39/1" + }, + "id": "device-m.4.Ethernet3_39_1.out::device-d.1.Ethernet55_1.in", + "toStatus": { + "context": { + "devicePid": "device-d", + "modulePid": "device-d.dev.1", + "portPid": "device-d.dev.1.Ethernet55_1" + }, + "label": "port-in-1/1" + } + } + }, + "devicePid": "device-m", + "label": "Example Device" + }, + "secondary": { + "data": { + "device-d.1.Ethernet53_1.out::device-m.4.Ethernet3_37_1.in": { + "fromStatus": { + "context": { + "devicePid": "device-d", + "modulePid": "device-d.dev.1", + "portPid": "device-d.dev.1.Ethernet53_1" + }, + "label": "port-in-1/1" + }, + "id": "device-d.1.Ethernet53_1.out::device-m.4.Ethernet3_37_1.in", + "toStatus": { + "context": { + "devicePid": "device-m", + "modulePid": "device-m.dev.4", + "portPid": "device-m.dev.4.Ethernet3_37_1" + }, + "label": "port-in-1/37/1" + } + }, + "device-d.1.Ethernet55_1.out::device-m.4.Ethernet3_39_1.in": { + "fromStatus": { + "context": { + "devicePid": "device-d", + "modulePid": "device-d.dev.1", + "portPid": "device-d.dev.1.Ethernet55_1" + }, + "label": "port-in-1/1" + }, + "id": "device-d.1.Ethernet55_1.out::device-m.4.Ethernet3_39_1.in", + "toStatus": { + "context": { + "devicePid": "device-m", + "modulePid": "device-m.dev.4", + "portPid": "device-m.dev.4.Ethernet3_39_1" + }, + "label": "port-in-1/39/1" + } + } + }, + "devicePid": "device-d", + "label": "Example Device" + }, + "status": { + "alarm": 1, + "bandwidth": 1, + "maintenance": null, + "ptp": 1 + } + }, + { + "_id": "device-m::device-e", + "_vid": "device-m::device-e", + "primary": { + "data": { + "device-m.4.Ethernet3_2_1.out::device-e.1000.1.in": { + "fromStatus": { + "context": { + "devicePid": "device-m", + "modulePid": "device-m.dev.4", + "portPid": "device-m.dev.4.Ethernet3_2_1" + }, + "label": "port-in-1/2/1" + }, + "id": "device-m.4.Ethernet3_2_1.out::device-e.1000.1.in", + "toStatus": { + "context": { + "devicePid": "device-e", + "modulePid": "device-e.dev.1000", + "portPid": "device-e.dev.1000.1" + }, + "label": "IP WAN 1" + } + } + }, + "devicePid": "device-m", + "label": "Example Device" + }, + "secondary": { + "data": { + "device-e.1000.1.out::device-m.4.Ethernet3_2_1.in": { + "fromStatus": { + "context": { + "devicePid": "device-e", + "modulePid": "device-e.dev.1000", + "portPid": "device-e.dev.1000.1" + }, + "label": "IP WAN 1" + }, + "id": "device-e.1000.1.out::device-m.4.Ethernet3_2_1.in", + "toStatus": { + "context": { + "devicePid": "device-m", + "modulePid": "device-m.dev.4", + "portPid": "device-m.dev.4.Ethernet3_2_1" + }, + "label": "port-in-1/2/1" + } + } + }, + "devicePid": "device-e", + "label": "Example Device" + }, + "status": { + "alarm": 1, + "bandwidth": 1, + "maintenance": null, + "ptp": 1 + } + }, + { + "_id": "device-n::device-p", + "_vid": "device-n::device-p", + "primary": { + "data": { + "uuid-0001": { + "fromStatus": { + "context": { + "devicePid": "device-n", + "modulePid": "device-n.dev.1", + "portPid": "device-n.dev.1.51" + }, + "label": "eth-0-51" + }, + "id": "uuid-0001", + "toStatus": { + "context": { + "devicePid": "device-p", + "modulePid": "device-p.dev.0", + "portPid": "device-p.dev.0.NMS-BLUE" + }, + "label": "NMS-BLUE" + } + } + }, + "devicePid": "device-n", + "label": "Example Device" + }, + "secondary": { + "data": { + "uuid-0008": { + "fromStatus": { + "context": { + "devicePid": "device-p", + "modulePid": "device-p.dev.0", + "portPid": "device-p.dev.0.NMS-BLUE" + }, + "label": "NMS-BLUE" + }, + "id": "uuid-0008", + "toStatus": { + "context": { + "devicePid": "device-n", + "modulePid": "device-n.dev.1", + "portPid": "device-n.dev.1.51" + }, + "label": "eth-0-51" + } + } + }, + "devicePid": "device-p", + "label": "Example Device" + }, + "status": { + "alarm": 1, + "bandwidth": 1, + "maintenance": null, + "ptp": 1 + } + }, + { + "_id": "device-n::virtual.0", + "_vid": "device-n::virtual.0", + "primary": { + "data": { + "uuid-0007": { + "fromStatus": { + "context": { + "devicePid": "device-n", + "modulePid": "device-n.dev.1", + "portPid": "device-n.dev.1.51" + }, + "label": "eth-0-51" + }, + "id": "uuid-0007", + "toStatus": { + "context": { + "devicePid": "virtual-0", + "modulePid": "virtual-0.virt.0", + "portPid": "virtual-0.virt.0.2" + }, + "label": "Ethernet 2" + } + } + }, + "devicePid": "device-n", + "label": "Example Device" + }, + "secondary": { + "data": { + "uuid-0009": { + "fromStatus": { + "context": { + "devicePid": "virtual-0", + "modulePid": "virtual-0.virt.0", + "portPid": "virtual-0.virt.0.4" + }, + "label": "Ethernet 2" + }, + "id": "uuid-0009", + "toStatus": { + "context": { + "devicePid": "device-n", + "modulePid": "device-n.dev.1", + "portPid": "device-n.dev.1.51" + }, + "label": "eth-0-51" + } + } + }, + "devicePid": "virtual-0", + "label": "Virtual Device 1" + }, + "status": { + "alarm": 1, + "bandwidth": null, + "maintenance": null, + "ptp": 1 + } + }, + { + "_id": "device-q::device-c", + "_vid": "device-q::device-c", + "primary": { + "data": { + "device-q.0.Intel(R) Ethernet Controller (3) I225-LM #2.out::device-b.dev.module-1.port-in-1.in": { + "fromStatus": { + "context": { + "devicePid": "device-q", + "modulePid": "device-q.dev.0", + "portPid": "device-q.dev.0.Intel(R)-Ethernet-Controller-(3)-I225-LM-#2" + }, + "label": "Intel(R) Ethernet Controller (3) I225-LM #2 (out)" + }, + "id": "device-q.0.Intel(R) Ethernet Controller (3) I225-LM #2.out::device-b.dev.module-1.port-in-1.in", + "toStatus": { + "context": { + "devicePid": "device-c", + "modulePid": "device-c.dev.1", + "portPid": "device-c.dev.1.port-in-1" + }, + "label": "port-in-1" + } + } + }, + "devicePid": "device-q", + "label": "Example Device" + }, + "secondary": { + "data": {}, + "devicePid": "device-c", + "label": "Example Device" + }, + "status": { + "alarm": 1, + "bandwidth": null, + "maintenance": null, + "ptp": 1 + } + }, + { + "_id": "device-b::device-l", + "_vid": "device-b::device-l", + "primary": { + "data": { + "device-b.dev.module-1.port-in-1.out::device-l.1.Ethernet4_43.in": { + "fromStatus": { + "context": { + "devicePid": "device-b", + "modulePid": "device-b.dev.1", + "portPid": "device-b.dev.1.port-in-1" + }, + "label": "port-in-1" + }, + "id": "device-b.dev.module-1.port-in-1.out::device-l.1.Ethernet4_43.in", + "toStatus": { + "context": { + "devicePid": "device-l", + "modulePid": "device-l.dev.1", + "portPid": "device-l.dev.1.Ethernet4_43" + }, + "label": "port-in-1/43" + } + } + }, + "devicePid": "device-b", + "label": "Example Device" + }, + "secondary": { + "data": { + "device-l.1.Ethernet4_43.out::device-b.dev.module-1.port-in-1.in": { + "fromStatus": { + "context": { + "devicePid": "device-l", + "modulePid": "device-l.dev.1", + "portPid": "device-l.dev.1.Ethernet4_43" + }, + "label": "port-in-1/43" + }, + "id": "device-l.1.Ethernet4_43.out::device-b.dev.module-1.port-in-1.in", + "toStatus": { + "context": { + "devicePid": "device-b", + "modulePid": "device-b.dev.1", + "portPid": "device-b.dev.1.port-in-1" + }, + "label": "port-in-1" + } + } + }, + "devicePid": "device-l", + "label": "Example Device" + }, + "status": { + "alarm": 1, + "bandwidth": 1, + "maintenance": null, + "ptp": 1 + } + }, + { + "_id": "device-b::device-q", + "_vid": "device-b::device-q", + "primary": { + "data": {}, + "devicePid": "device-b", + "label": "Example Device" + }, + "secondary": { + "data": { + "device-q.0.Intel(R) Ethernet Controller (3) I225-LM.out::device-b.dev.module-1.port-in-1.in": { + "fromStatus": { + "context": { + "devicePid": "device-q", + "modulePid": "device-q.dev.0", + "portPid": "device-q.dev.0.Intel(R)-Ethernet-Controller-(3)-I225-LM" + }, + "label": "Intel(R) Ethernet Controller (3) I225-LM (out)" + }, + "id": "device-q.0.Intel(R) Ethernet Controller (3) I225-LM.out::device-b.dev.module-1.port-in-1.in", + "toStatus": { + "context": { + "devicePid": "device-b", + "modulePid": "device-b.dev.1", + "portPid": "device-b.dev.1.port-in-1" + }, + "label": "port-in-1" + } + } + }, + "devicePid": "device-q", + "label": "Example Device" + }, + "status": { + "alarm": 1, + "bandwidth": null, + "maintenance": null, + "ptp": 1 + } + }, + { + "_id": "device-b::device-w", + "_vid": "device-b::device-w", + "primary": { + "data": { + "device-b.dev.module-1.port-in-1.out::device-w.0.eth1.in": { + "fromStatus": { + "context": { + "devicePid": "device-b", + "modulePid": "device-b.dev.1", + "portPid": "device-b.dev.1.port-in-1" + }, + "label": "port-in-1" + }, + "id": "device-b.dev.module-1.port-in-1.out::device-w.0.eth1.in", + "toStatus": { + "context": { + "devicePid": "device-w", + "modulePid": "device-w.dev.0", + "portPid": "device-w.dev.0.eth1" + }, + "label": "eth1" + } + } + }, + "devicePid": "device-b", + "label": "Example Device" + }, + "secondary": { + "data": { + "device-w.0.eth1.out::device-b.dev.module-1.port-in-1.in": { + "fromStatus": { + "context": { + "devicePid": "device-w", + "modulePid": "device-w.dev.0", + "portPid": "device-w.dev.0.eth1" + }, + "label": "eth1" + }, + "id": "device-w.0.eth1.out::device-b.dev.module-1.port-in-1.in", + "toStatus": { + "context": { + "devicePid": "device-b", + "modulePid": "device-b.dev.1", + "portPid": "device-b.dev.1.port-in-1" + }, + "label": "port-in-1" + } + } + }, + "devicePid": "device-w", + "label": "Example Device" + }, + "status": { + "alarm": 1, + "bandwidth": null, + "maintenance": null, + "ptp": 1 + } + }, + { + "_id": "device-b::device-x", + "_vid": "device-b::device-x", + "primary": { + "data": { + "device-b.dev.module-1.port-in-1.out::device-x.0.eth1.in": { + "fromStatus": { + "context": { + "devicePid": "device-b", + "modulePid": "device-b.dev.1", + "portPid": "device-b.dev.1.port-in-1" + }, + "label": "port-in-1" + }, + "id": "device-b.dev.module-1.port-in-1.out::device-x.0.eth1.in", + "toStatus": { + "context": { + "devicePid": "device-x", + "modulePid": "device-x.dev.0", + "portPid": "device-x.dev.0.eth1" + }, + "label": "eth1" + } + } + }, + "devicePid": "device-b", + "label": "Example Device" + }, + "secondary": { + "data": { + "device-x.0.eth1.out::device-b.dev.module-1.port-in-1.in": { + "fromStatus": { + "context": { + "devicePid": "device-x", + "modulePid": "device-x.dev.0", + "portPid": "device-x.dev.0.eth1" + }, + "label": "eth1" + }, + "id": "device-x.0.eth1.out::device-b.dev.module-1.port-in-1.in", + "toStatus": { + "context": { + "devicePid": "device-b", + "modulePid": "device-b.dev.1", + "portPid": "device-b.dev.1.port-in-1" + }, + "label": "port-in-1" + } + } + }, + "devicePid": "device-x", + "label": "Example Device" + }, + "status": { + "alarm": 1, + "bandwidth": null, + "maintenance": null, + "ptp": 1 + } + }, + { + "_id": "device-s::device-d", + "_vid": "device-s::device-d", + "primary": { + "data": {}, + "devicePid": "device-s", + "label": "Example Device" + }, + "secondary": { + "data": { + "device-b.dev.module-1.port-in-1.out::device-s.0.P1.in": { + "fromStatus": { + "context": { + "devicePid": "device-d", + "modulePid": "device-d.dev.1", + "portPid": "device-d.dev.1.port-in-1" + }, + "label": "port-in-1" + }, + "id": "device-b.dev.module-1.port-in-1.out::device-s.0.P1.in", + "toStatus": { + "context": { + "devicePid": "device-s", + "modulePid": "device-s.dev.0", + "portPid": "device-s.dev.0.P1" + }, + "label": "P1 (in)" + } + } + }, + "devicePid": "device-d", + "label": "Example Device" + }, + "status": { + "alarm": 1, + "bandwidth": 1, + "maintenance": null, + "ptp": 1 + } + }, + { + "_id": "device-t::device-d", + "_vid": "device-t::device-d", + "primary": { + "data": {}, + "devicePid": "device-t", + "label": "Example Device" + }, + "secondary": { + "data": { + "device-b.dev.module-1.port-in-1.out::device-t.0.mmc0.in": { + "fromStatus": { + "context": { + "devicePid": "device-d", + "modulePid": "device-d.dev.1", + "portPid": "device-d.dev.1.port-in-1" + }, + "label": "port-in-1" + }, + "id": "device-b.dev.module-1.port-in-1.out::device-t.0.mmc0.in", + "toStatus": { + "context": { + "devicePid": "device-t", + "modulePid": "device-t.dev.0", + "portPid": "device-t.dev.0.mmc0" + }, + "label": "mmc0 (in)" + } + } + }, + "devicePid": "device-d", + "label": "Example Device" + }, + "status": { + "alarm": 1, + "bandwidth": 1, + "maintenance": null, + "ptp": 1 + } + }, + { + "_id": "device-u::device-d", + "_vid": "device-u::device-d", + "primary": { + "data": { + "device-u.3.10004.out::device-b.dev.module-1.port-in-1.in": { + "fromStatus": { + "context": { + "devicePid": "device-u", + "modulePid": "device-u.dev.3", + "portPid": "device-u.dev.3.10004" + }, + "label": "Ethernet 3.3" + }, + "id": "device-u.3.10004.out::device-b.dev.module-1.port-in-1.in", + "toStatus": { + "context": { + "devicePid": "device-d", + "modulePid": "device-d.dev.1", + "portPid": "device-d.dev.1.port-in-1" + }, + "label": "port-in-1" + } + } + }, + "devicePid": "device-u", + "label": "Example Device" + }, + "secondary": { + "data": { + "device-b.dev.module-1.port-in-1.out::device-u.3.10004.in": { + "fromStatus": { + "context": { + "devicePid": "device-d", + "modulePid": "device-d.dev.1", + "portPid": "device-d.dev.1.port-in-1" + }, + "label": "port-in-1" + }, + "id": "device-b.dev.module-1.port-in-1.out::device-u.3.10004.in", + "toStatus": { + "context": { + "devicePid": "device-u", + "modulePid": "device-u.dev.3", + "portPid": "device-u.dev.3.10004" + }, + "label": "Ethernet 3.3" + } + } + }, + "devicePid": "device-d", + "label": "Example Device" + }, + "status": { + "alarm": 1, + "bandwidth": 1, + "maintenance": null, + "ptp": 1 + } + }, + { + "_id": "device-c::device-w", + "_vid": "device-c::device-w", + "primary": { + "data": { + "device-b.dev.module-1.port-in-1.out::device-w.0.eth0.in": { + "fromStatus": { + "context": { + "devicePid": "device-c", + "modulePid": "device-c.dev.1", + "portPid": "device-c.dev.1.port-in-1" + }, + "label": "port-in-1" + }, + "id": "device-b.dev.module-1.port-in-1.out::device-w.0.eth0.in", + "toStatus": { + "context": { + "devicePid": "device-w", + "modulePid": "device-w.dev.0", + "portPid": "device-w.dev.0.eth0" + }, + "label": "eth0" + } + } + }, + "devicePid": "device-c", + "label": "Example Device" + }, + "secondary": { + "data": { + "device-w.0.eth0.out::device-b.dev.module-1.port-in-1.in": { + "fromStatus": { + "context": { + "devicePid": "device-w", + "modulePid": "device-w.dev.0", + "portPid": "device-w.dev.0.eth0" + }, + "label": "eth0" + }, + "id": "device-w.0.eth0.out::device-b.dev.module-1.port-in-1.in", + "toStatus": { + "context": { + "devicePid": "device-c", + "modulePid": "device-c.dev.1", + "portPid": "device-c.dev.1.port-in-1" + }, + "label": "port-in-1" + } + } + }, + "devicePid": "device-w", + "label": "Example Device" + }, + "status": { + "alarm": 1, + "bandwidth": null, + "maintenance": null, + "ptp": 1 + } + }, + { + "_id": "device-c::device-x", + "_vid": "device-c::device-x", + "primary": { + "data": { + "device-b.dev.module-1.port-in-1.out::device-x.0.eth0.in": { + "fromStatus": { + "context": { + "devicePid": "device-c", + "modulePid": "device-c.dev.1", + "portPid": "device-c.dev.1.port-in-1" + }, + "label": "port-in-1" + }, + "id": "device-b.dev.module-1.port-in-1.out::device-x.0.eth0.in", + "toStatus": { + "context": { + "devicePid": "device-x", + "modulePid": "device-x.dev.0", + "portPid": "device-x.dev.0.eth0" + }, + "label": "eth0" + } + } + }, + "devicePid": "device-c", + "label": "Example Device" + }, + "secondary": { + "data": { + "device-x.0.eth0.out::device-b.dev.module-1.port-in-1.in": { + "fromStatus": { + "context": { + "devicePid": "device-x", + "modulePid": "device-x.dev.0", + "portPid": "device-x.dev.0.eth0" + }, + "label": "eth0" + }, + "id": "device-x.0.eth0.out::device-b.dev.module-1.port-in-1.in", + "toStatus": { + "context": { + "devicePid": "device-c", + "modulePid": "device-c.dev.1", + "portPid": "device-c.dev.1.port-in-1" + }, + "label": "port-in-1" + } + } + }, + "devicePid": "device-x", + "label": "Example Device" + }, + "status": { + "alarm": 1, + "bandwidth": null, + "maintenance": null, + "ptp": 1 + } + }, + { + "_id": "device-d::device-f", + "_vid": "device-d::device-f", + "primary": { + "data": { + "device-b.dev.module-1.port-in-1.out::device-f.1.10004.in": { + "fromStatus": { + "context": { + "devicePid": "device-d", + "modulePid": "device-d.dev.1", + "portPid": "device-d.dev.1.port-in-1" + }, + "label": "port-in-1" + }, + "id": "device-b.dev.module-1.port-in-1.out::device-f.1.10004.in", + "toStatus": { + "context": { + "devicePid": "device-f", + "modulePid": "device-f.dev.1", + "portPid": "device-f.dev.1.10004" + }, + "label": "Ethernet 1.3" + } + } + }, + "devicePid": "device-d", + "label": "Example Device" + }, + "secondary": { + "data": { + "device-f.1.10004.out::device-b.dev.module-1.port-in-1.in": { + "fromStatus": { + "context": { + "devicePid": "device-f", + "modulePid": "device-f.dev.1", + "portPid": "device-f.dev.1.10004" + }, + "label": "Ethernet 1.3" + }, + "id": "device-f.1.10004.out::device-b.dev.module-1.port-in-1.in", + "toStatus": { + "context": { + "devicePid": "device-d", + "modulePid": "device-d.dev.1", + "portPid": "device-d.dev.1.port-in-1" + }, + "label": "port-in-1" + } + } + }, + "devicePid": "device-f", + "label": "Example Device" + }, + "status": { + "alarm": 1, + "bandwidth": 1, + "maintenance": null, + "ptp": 1 + } + }, + { + "_id": "device-d::device-g", + "_vid": "device-d::device-g", + "primary": { + "data": { + "device-b.dev.module-1.port-in-1.out::device-g.6.10004.in": { + "fromStatus": { + "context": { + "devicePid": "device-d", + "modulePid": "device-d.dev.1", + "portPid": "device-d.dev.1.port-in-1" + }, + "label": "port-in-1" + }, + "id": "device-b.dev.module-1.port-in-1.out::device-g.6.10004.in", + "toStatus": { + "context": { + "devicePid": "device-g", + "modulePid": "device-g.dev.6", + "portPid": "device-g.dev.6.10004" + }, + "label": "Ethernet 6.3" + } + } + }, + "devicePid": "device-d", + "label": "Example Device" + }, + "secondary": { + "data": { + "device-g.6.10004.out::device-b.dev.module-1.port-in-1.in": { + "fromStatus": { + "context": { + "devicePid": "device-g", + "modulePid": "device-g.dev.6", + "portPid": "device-g.dev.6.10004" + }, + "label": "Ethernet 6.3" + }, + "id": "device-g.6.10004.out::device-b.dev.module-1.port-in-1.in", + "toStatus": { + "context": { + "devicePid": "device-d", + "modulePid": "device-d.dev.1", + "portPid": "device-d.dev.1.port-in-1" + }, + "label": "port-in-1" + } + } + }, + "devicePid": "device-g", + "label": "Example Device" + }, + "status": { + "alarm": 1, + "bandwidth": 1, + "maintenance": null, + "ptp": 1 + } + } + ] + } + } + } + }, + "header": { + "auth": true, + "caption": "Operation Successful", + "code": "OK", + "errorCodes": [], + "errorDetails": [], + "id": "", + "msg": [], + "ok": true, + "user": "api-user" + } +} diff --git a/tests/fixtures/inspect/2025.4.9/inspect_paths_limit5.json b/tests/fixtures/inspect/2025.4.9/inspect_paths_limit5.json new file mode 100644 index 0000000..038b879 --- /dev/null +++ b/tests/fixtures/inspect/2025.4.9/inspect_paths_limit5.json @@ -0,0 +1,75 @@ +{ + "data": { + "status": { + "collector": { + "inspect": { + "paths": { + "_items": [ + { + "_id": "booking-1001::main", + "_vid": "_:booking-1001::main", + "serviceFields": { + "bid": "booking-1001", + "from": "topo:device-a.1.101S", + "isMain": true, + "to": "topo:device-b.uuid-0001.R00000001-0000-4000-8000-000000000001" + } + }, + { + "_id": "booking-1001::spare", + "_vid": "_:booking-1001::spare", + "serviceFields": { + "bid": "booking-1001", + "from": "topo:device-a.1.101S", + "isMain": false, + "to": "topo:device-b.uuid-0001.R00000001-0000-4000-8000-000000000001" + } + }, + { + "_id": "booking-1002::main", + "_vid": "_:booking-1002::main", + "serviceFields": { + "bid": "booking-1002", + "from": "topo:device-b.uuid-0001.S00000005-0000-4000-8000-000000000005", + "isMain": true, + "to": "topo:device-c.3.3000000" + } + }, + { + "_id": "booking-1002::spare", + "_vid": "_:booking-1002::spare", + "serviceFields": { + "bid": "booking-1002", + "from": "topo:device-b.uuid-0001.S00000005-0000-4000-8000-000000000005", + "isMain": false, + "to": "topo:device-c.3.3000000" + } + }, + { + "_id": "booking-1003::main", + "_vid": "_:booking-1003::main", + "serviceFields": { + "bid": "booking-1003", + "from": "topo:device-b.uuid-0001.S00000006-0000-4000-8000-000000000006", + "isMain": true, + "to": "topo:device-c.3.5000000" + } + } + ] + } + } + } + } + }, + "header": { + "auth": true, + "caption": "Operation Successful", + "code": "OK", + "errorCodes": [], + "errorDetails": [], + "id": "", + "msg": [], + "ok": true, + "user": "api-user" + } +} diff --git a/tests/fixtures/inspect/2025.4.9/lookup_inspect_edges_by_ids.json b/tests/fixtures/inspect/2025.4.9/lookup_inspect_edges_by_ids.json new file mode 100644 index 0000000..a536022 --- /dev/null +++ b/tests/fixtures/inspect/2025.4.9/lookup_inspect_edges_by_ids.json @@ -0,0 +1,49 @@ +{ + "data": { + "device-a.module-1.port-out-1.out::device-b.module-1.port-in-1.in": { + "edge": { + "active": true, + "bandwidth": -1.0, + "capacity": 65535, + "conflictPri": 0, + "descriptor": { + "desc": "", + "label": "" + }, + "excludeFormats": [], + "fDescriptor": { + "desc": "", + "label": "" + }, + "fromId": "device-a.module-1.port-out-1.out", + "includeFormats": [], + "redundancyMode": "Any", + "tags": [], + "toId": "device-b.module-1.port-in-1.in", + "weight": 1, + "weightFactors": { + "bandwidth": { + "weight": 0 + }, + "service": { + "max": 100, + "weight": 0 + } + } + }, + "fromDevice": "device-a", + "toDevice": "device-b" + } + }, + "header": { + "auth": true, + "caption": "Operation Successful", + "code": "OK", + "errorCodes": [], + "errorDetails": [], + "id": "0", + "msg": [], + "ok": true, + "user": "api-user" + } +} \ No newline at end of file diff --git a/tests/fixtures/inspect/2025.4.9/lookup_inspect_vertex_by_id.json b/tests/fixtures/inspect/2025.4.9/lookup_inspect_vertex_by_id.json new file mode 100644 index 0000000..60aa756 --- /dev/null +++ b/tests/fixtures/inspect/2025.4.9/lookup_inspect_vertex_by_id.json @@ -0,0 +1,60 @@ +{ + "data": { + "assignedTags": { + "all": [], + "inherited": {}, + "inheritedConflict": false, + "local": {} + }, + "context": { + "devicePid": "device-a", + "modulePid": "device-a.dev.module-1", + "portPid": "device-a.dev.module-1.port-out-1" + }, + "customSchemas": {}, + "fields": { + "active": true, + "controlProps": null, + "custom": {}, + "desc": "", + "destinationMonitorLeader": false, + "extraAlertFilters": [], + "label": "Port A (out)", + "localAssignedTags": [], + "queueable": false, + "sipsMode": "NONE", + "tags": [], + "typeFields": { + "ipAddress": null, + "ipNetmask": null, + "public": false, + "supportsCpipeCfg": false, + "supportsIgmpCfg": false, + "supportsMacForwardingCfg": false, + "supportsNsoCfg": false, + "supportsOpenflowCfg": false, + "supportsStaticIgmpCfg": false, + "supportsVlanCfg": false, + "supportsVplsCfg": false, + "type": "ip", + "vlanId": null, + "vrfId": null + }, + "useAsEndpoint": false + }, + "id": "device-a.module-1.port-out-1.out", + "isVirtual": false, + "vertexType": "Out" + }, + "header": { + "auth": true, + "caption": "Operation Successful", + "code": "OK", + "errorCodes": [], + "errorDetails": [], + "id": "0", + "msg": [], + "ok": true, + "user": "api-user" + } +} \ No newline at end of file diff --git a/tests/fixtures/inspect/2025.4.9/nodestatus_noid_collection.json b/tests/fixtures/inspect/2025.4.9/nodestatus_noid_collection.json new file mode 100644 index 0000000..5ad0c1a --- /dev/null +++ b/tests/fixtures/inspect/2025.4.9/nodestatus_noid_collection.json @@ -0,0 +1,265 @@ +{ + "data": { + "status": { + "collector": { + "inspect": { + "nodeStatus": { + "_items": [ + { + "_id": "device-h", + "_vid": "device-h", + "descriptor": { + "label": "Example Device A" + }, + "deviceId": "device-h" + }, + { + "_id": "device-i", + "_vid": "device-i", + "descriptor": { + "label": "Example Device" + }, + "deviceId": "device-i" + }, + { + "_id": "device-j", + "_vid": "device-j", + "descriptor": { + "label": "Example Device" + }, + "deviceId": "device-j" + }, + { + "_id": "device-a", + "_vid": "device-a", + "descriptor": { + "label": "Example Device" + }, + "deviceId": "device-a" + }, + { + "_id": "device-k", + "_vid": "device-k", + "descriptor": { + "label": "Example Device" + }, + "deviceId": "device-k" + }, + { + "_id": "device-b", + "_vid": "device-b", + "descriptor": { + "label": "Example Device" + }, + "deviceId": "device-b" + }, + { + "_id": "device-l", + "_vid": "device-l", + "descriptor": { + "label": "Example Device" + }, + "deviceId": "device-l" + }, + { + "_id": "device-m", + "_vid": "device-m", + "descriptor": { + "label": "Example Device" + }, + "deviceId": "device-m" + }, + { + "_id": "device-n", + "_vid": "device-n", + "descriptor": { + "label": "Example Device" + }, + "deviceId": "device-n" + }, + { + "_id": "device-o", + "_vid": "device-o", + "descriptor": { + "label": "Example Device" + }, + "deviceId": "device-o" + }, + { + "_id": "device-p", + "_vid": "device-p", + "descriptor": { + "label": "Example Device" + }, + "deviceId": "device-p" + }, + { + "_id": "device-q", + "_vid": "device-q", + "descriptor": { + "label": "Example Device" + }, + "deviceId": "device-q" + }, + { + "_id": "device-r", + "_vid": "device-r", + "descriptor": { + "label": "Example Device" + }, + "deviceId": "device-r" + }, + { + "_id": "device-s", + "_vid": "device-s", + "descriptor": { + "label": "Example Device" + }, + "deviceId": "device-s" + }, + { + "_id": "device-c", + "_vid": "device-c", + "descriptor": { + "label": "Example Device" + }, + "deviceId": "device-c" + }, + { + "_id": "device-t", + "_vid": "device-t", + "descriptor": { + "label": "Example Device" + }, + "deviceId": "device-t" + }, + { + "_id": "device-u", + "_vid": "device-u", + "descriptor": { + "label": "Example Device" + }, + "deviceId": "device-u" + }, + { + "_id": "device-v", + "_vid": "device-v", + "descriptor": { + "label": "Example Device" + }, + "deviceId": "device-v" + }, + { + "_id": "device-w", + "_vid": "device-w", + "descriptor": { + "label": "Example Device" + }, + "deviceId": "device-w" + }, + { + "_id": "device-x", + "_vid": "device-x", + "descriptor": { + "label": "Example Device" + }, + "deviceId": "device-x" + }, + { + "_id": "device-y", + "_vid": "device-y", + "descriptor": { + "label": "Example Device" + }, + "deviceId": "device-y" + }, + { + "_id": "device-z", + "_vid": "device-z", + "descriptor": { + "label": "Example Device" + }, + "deviceId": "device-z" + }, + { + "_id": "device-1", + "_vid": "device-1", + "descriptor": { + "label": "Example Device" + }, + "deviceId": "device-1" + }, + { + "_id": "device-2", + "_vid": "device-2", + "descriptor": { + "label": "Example Device" + }, + "deviceId": "device-2" + }, + { + "_id": "device-d", + "_vid": "device-d", + "descriptor": { + "label": "Example Device" + }, + "deviceId": "device-d" + }, + { + "_id": "device-e", + "_vid": "device-e", + "descriptor": { + "label": "Example Device" + }, + "deviceId": "device-e" + }, + { + "_id": "device-f", + "_vid": "device-f", + "descriptor": { + "label": "Example Device" + }, + "deviceId": "device-f" + }, + { + "_id": "device-g", + "_vid": "device-g", + "descriptor": { + "label": "Example Device" + }, + "deviceId": "device-g" + }, + { + "_id": "virtual-0", + "_vid": "virtual-0", + "descriptor": { + "label": "Virtual Device 1" + }, + "deviceId": "virtual.0" + }, + { + "_id": "virtual-1", + "_vid": "virtual-1", + "descriptor": { + "label": "Virtual Device 1" + }, + "deviceId": "virtual.1" + } + ] + } + } + } + } + }, + "header": { + "auth": true, + "caption": "Operation Successful", + "code": "OK", + "errorCodes": [], + "errorDetails": [], + "id": "", + "msg": [], + "ok": true, + "user": "api-user" + } +} diff --git a/tests/fixtures/inspect/2025.4.9/skeleton_nodestatus_short.json b/tests/fixtures/inspect/2025.4.9/skeleton_nodestatus_short.json new file mode 100644 index 0000000..323db04 --- /dev/null +++ b/tests/fixtures/inspect/2025.4.9/skeleton_nodestatus_short.json @@ -0,0 +1,268 @@ +{ + "data": { + "status": { + "collector": { + "inspect": { + "nodeStatus": { + "_items": [ + { + "_id": "device-h", + "_vid": "device-h", + "descriptor": { + "label": "Example Device A" + }, + "deviceId": "device-h", + "resourceId": "device:device-h" + }, + { + "_id": "device-i", + "_vid": "device-i", + "descriptor": { + "label": "Example Device" + }, + "deviceId": "device-i", + "resourceId": "device:device-i" + }, + { + "_id": "device-a", + "_vid": "device-a", + "descriptor": { + "label": "Example Device" + }, + "deviceId": "device-a", + "resourceId": "device:device-a" + }, + { + "_id": "device-j", + "_vid": "device-j", + "descriptor": { + "label": "Example Device" + }, + "deviceId": "device-j", + "resourceId": "device:device-j" + }, + { + "_id": "device-b", + "_vid": "device-b", + "descriptor": { + "label": "Example Device" + }, + "deviceId": "device-b", + "resourceId": "device:device-b" + }, + { + "_id": "device-k", + "_vid": "device-k", + "descriptor": { + "label": "Example Device" + }, + "deviceId": "device-k", + "resourceId": "device:device-k" + }, + { + "_id": "device-l", + "_vid": "device-l", + "descriptor": { + "label": "Example Device" + }, + "deviceId": "device-l", + "resourceId": "device:device-l" + }, + { + "_id": "device-m", + "_vid": "device-m", + "descriptor": { + "label": "Example Device" + }, + "deviceId": "device-m", + "resourceId": "device:device-m" + }, + { + "_id": "device-n", + "_vid": "device-n", + "descriptor": { + "label": "Example Device" + }, + "deviceId": "device-n", + "resourceId": "device:device-n" + }, + { + "_id": "device-o", + "_vid": "device-o", + "descriptor": { + "label": "Example Device" + }, + "deviceId": "device-o", + "resourceId": "device:device-o" + }, + { + "_id": "device-p", + "_vid": "device-p", + "descriptor": { + "label": "Example Device" + }, + "deviceId": "device-p", + "resourceId": "device:device-p" + }, + { + "_id": "device-q", + "_vid": "device-q", + "descriptor": { + "label": "Example Device" + }, + "deviceId": "device-q", + "resourceId": "device:device-q" + }, + { + "_id": "device-c", + "_vid": "device-c", + "descriptor": { + "label": "Example Device" + }, + "deviceId": "device-c", + "resourceId": "device:device-c" + }, + { + "_id": "device-r", + "_vid": "device-r", + "descriptor": { + "label": "Example Device" + }, + "deviceId": "device-r", + "resourceId": "device:device-r" + }, + { + "_id": "device-s", + "_vid": "device-s", + "descriptor": { + "label": "Example Device" + }, + "deviceId": "device-s", + "resourceId": "device:device-s" + }, + { + "_id": "device-t", + "_vid": "device-t", + "descriptor": { + "label": "Example Device" + }, + "deviceId": "device-t", + "resourceId": "device:device-t" + }, + { + "_id": "device-u", + "_vid": "device-u", + "descriptor": { + "label": "Example Device" + }, + "deviceId": "device-u", + "resourceId": "device:device-u" + }, + { + "_id": "device-v", + "_vid": "device-v", + "descriptor": { + "label": "Example Device" + }, + "deviceId": "device-v", + "resourceId": "device:device-v" + }, + { + "_id": "device-w", + "_vid": "device-w", + "descriptor": { + "label": "Example Device" + }, + "deviceId": "device-w", + "resourceId": "device:device-w" + }, + { + "_id": "device-x", + "_vid": "device-x", + "descriptor": { + "label": "Example Device" + }, + "deviceId": "device-x", + "resourceId": "device:device-x" + }, + { + "_id": "device-y", + "_vid": "device-y", + "descriptor": { + "label": "Example Device" + }, + "deviceId": "device-y", + "resourceId": "device:device-y" + }, + { + "_id": "device-d", + "_vid": "device-d", + "descriptor": { + "label": "Example Device" + }, + "deviceId": "device-d", + "resourceId": "device:device-d" + }, + { + "_id": "device-e", + "_vid": "device-e", + "descriptor": { + "label": "Example Device" + }, + "deviceId": "device-e", + "resourceId": "device:device-e" + }, + { + "_id": "device-f", + "_vid": "device-f", + "descriptor": { + "label": "Example Device" + }, + "deviceId": "device-f", + "resourceId": "device:device-f" + }, + { + "_id": "device-g", + "_vid": "device-g", + "descriptor": { + "label": "Example Device" + }, + "deviceId": "device-g", + "resourceId": "device:device-g" + }, + { + "_id": "virtual-0", + "_vid": "virtual-0", + "descriptor": { + "label": "Virtual Device 1" + }, + "deviceId": "virtual.0", + "resourceId": "device:virtual-0" + }, + { + "_id": "virtual-1", + "_vid": "virtual-1", + "descriptor": { + "label": "Virtual Device 1" + }, + "deviceId": "virtual.1", + "resourceId": "device:virtual-1" + } + ] + } + } + } + } + }, + "header": { + "auth": true, + "caption": "Operation Successful", + "code": "OK", + "errorCodes": [], + "errorDetails": [], + "id": "", + "msg": [], + "ok": true, + "user": "api-user" + } +} diff --git a/tests/fixtures/inspect/2025.4.9/update_topology_fail_booking.json b/tests/fixtures/inspect/2025.4.9/update_topology_fail_booking.json new file mode 100644 index 0000000..8fa64a7 --- /dev/null +++ b/tests/fixtures/inspect/2025.4.9/update_topology_fail_booking.json @@ -0,0 +1,49 @@ +{ + "data": { + "items": [], + "res": { + "msg": [ + "Validation failed" + ], + "ok": false + }, + "validation": { + "createIds": [], + "details": { + "booking-1001": { + "isCancel": false, + "isProduct": false, + "resolvable": false, + "rev": "2-2026-06-10T19:54:01.297948842Z[UTC]", + "status": -22, + "type": "generic" + }, + "booking-1002": { + "isCancel": false, + "isProduct": false, + "resolvable": false, + "rev": "2-2026-06-16T14:47:24.964022664Z[UTC]", + "status": -22, + "type": "generic" + } + }, + "result": { + "msg": [ + "A required edge was not found. (main); A required edge was not found. (redundant)" + ], + "ok": false + } + } + }, + "header": { + "auth": true, + "caption": "Operation Successful", + "code": "OK", + "errorCodes": [], + "errorDetails": [], + "id": "0", + "msg": [], + "ok": true, + "user": "api-user" + } +} diff --git a/tests/fixtures/inspect/2025.4.9/update_topology_fail_remove.json b/tests/fixtures/inspect/2025.4.9/update_topology_fail_remove.json new file mode 100644 index 0000000..9beb95e --- /dev/null +++ b/tests/fixtures/inspect/2025.4.9/update_topology_fail_remove.json @@ -0,0 +1,30 @@ +{ + "data": { + "items": [], + "res": { + "msg": [ + "Failed to update local edges: Cannot remove non-existent object with key nonexistent-edge-id-xyz!" + ], + "ok": false + }, + "validation": { + "createIds": [], + "details": {}, + "result": { + "msg": [], + "ok": true + } + } + }, + "header": { + "auth": true, + "caption": "Operation Successful", + "code": "OK", + "errorCodes": [], + "errorDetails": [], + "id": "0", + "msg": [], + "ok": true, + "user": "api-user" + } +} diff --git a/tests/fixtures/inspect/2025.4.9/update_topology_success.json b/tests/fixtures/inspect/2025.4.9/update_topology_success.json new file mode 100644 index 0000000..30681d9 --- /dev/null +++ b/tests/fixtures/inspect/2025.4.9/update_topology_success.json @@ -0,0 +1,40 @@ +{ + "data": { + "items": [ + { + "external": null, + "id": "device-a.dev.module-1.port-out-1.out::device-b.dev.module-1.port-in-1.in", + "idx": 0, + "res": { + "msg": [ + "" + ], + "ok": true + } + } + ], + "res": { + "msg": [], + "ok": true + }, + "validation": { + "createIds": [], + "details": {}, + "result": { + "msg": [], + "ok": true + } + } + }, + "header": { + "auth": true, + "caption": "Operation Successful", + "code": "OK", + "errorCodes": [], + "errorDetails": [], + "id": "0", + "msg": [], + "ok": true, + "user": "api-user" + } +} From e0ee9b8eb138a84e6f18991258065d4cfaf94d31 Mon Sep 17 00:00:00 2001 From: Jonas Scholl <43819845+JonasScholl@users.noreply.github.com> Date: Thu, 9 Jul 2026 10:35:31 +0200 Subject: [PATCH 04/34] inspect app implementation --- docs/getting-started-guide/05_Inspect.md | 170 ++++ docs/getting-started-guide/README.md | 3 +- docs/inspect-app/README.md | 15 +- docs/inspect-app/concepts.md | 11 +- .../decisions/0006-commit-write-model.md | 6 +- .../decisions/0009-write-consistency.md | 30 +- .../0010-post-commit-snapshot-refresh.md | 24 +- docs/inspect-app/endpoints.md | 60 ++ docs/inspect-app/implementation-plan.md | 286 +++++++ docs/inspect-app/models.md | 76 +- pyproject.toml | 6 +- .../apps/inspect/__init__.py | 9 +- .../apps/inspect/app/__init__.py | 0 .../apps/inspect/app/actions.py | 97 +++ .../apps/inspect/app/read.py | 134 ++++ .../apps/inspect/app/write.py | 135 ++++ .../apps/inspect/changeset.py | 550 +++++++++++++ .../apps/inspect/domain/device.py | 57 +- .../apps/inspect/domain/edge.py | 4 +- .../apps/inspect/domain/port.py | 2 +- .../apps/inspect/errors.py | 103 +++ .../apps/inspect/inspect_api.py | 166 ++++ .../apps/inspect/inspect_app.py | 70 ++ .../apps/inspect/model/actions.py | 121 +++ .../apps/inspect/model/collector.py | 40 + .../apps/inspect/model/update_topology.py | 26 +- .../apps/inspect/queries.py | 131 ++++ .../apps/inspect/snapshot.py | 737 ++++++++++++------ .../apps/videoipath_app.py | 10 + .../connector/vip_rest_connector.py | 17 +- tests/e2e/__init__.py | 0 tests/e2e/conftest.py | 39 + tests/e2e/inspect/__init__.py | 0 tests/e2e/inspect/scenario.py | 215 +++++ tests/e2e/inspect/test_e2e_leaf_spine.py | 189 +++++ .../update_topology_replace_devices.json | 72 ++ .../update_topology_replace_vertices.json | 80 ++ tests/inspect/__init__.py | 0 tests/inspect/conftest.py | 23 + tests/inspect/test_actions.py | 68 ++ tests/inspect/test_api.py | 101 +++ tests/inspect/test_changeset.py | 345 ++++++++ tests/inspect/test_models.py | 119 +++ tests/inspect/test_queries.py | 47 ++ tests/inspect/test_snapshot.py | 261 +++++++ 45 files changed, 4308 insertions(+), 347 deletions(-) create mode 100644 docs/getting-started-guide/05_Inspect.md create mode 100644 docs/inspect-app/implementation-plan.md create mode 100644 src/videoipath_automation_tool/apps/inspect/app/__init__.py create mode 100644 src/videoipath_automation_tool/apps/inspect/app/actions.py create mode 100644 src/videoipath_automation_tool/apps/inspect/app/read.py create mode 100644 src/videoipath_automation_tool/apps/inspect/app/write.py create mode 100644 src/videoipath_automation_tool/apps/inspect/changeset.py create mode 100644 src/videoipath_automation_tool/apps/inspect/errors.py create mode 100644 src/videoipath_automation_tool/apps/inspect/inspect_api.py create mode 100644 src/videoipath_automation_tool/apps/inspect/inspect_app.py create mode 100644 src/videoipath_automation_tool/apps/inspect/queries.py create mode 100644 tests/e2e/__init__.py create mode 100644 tests/e2e/conftest.py create mode 100644 tests/e2e/inspect/__init__.py create mode 100644 tests/e2e/inspect/scenario.py create mode 100644 tests/e2e/inspect/test_e2e_leaf_spine.py create mode 100644 tests/fixtures/inspect/2025.4.9/update_topology_replace_devices.json create mode 100644 tests/fixtures/inspect/2025.4.9/update_topology_replace_vertices.json create mode 100644 tests/inspect/__init__.py create mode 100644 tests/inspect/conftest.py create mode 100644 tests/inspect/test_actions.py create mode 100644 tests/inspect/test_api.py create mode 100644 tests/inspect/test_changeset.py create mode 100644 tests/inspect/test_models.py create mode 100644 tests/inspect/test_queries.py create mode 100644 tests/inspect/test_snapshot.py diff --git a/docs/getting-started-guide/05_Inspect.md b/docs/getting-started-guide/05_Inspect.md new file mode 100644 index 0000000..94774fa --- /dev/null +++ b/docs/getting-started-guide/05_Inspect.md @@ -0,0 +1,170 @@ +# Inspect App + +## 1. Introduction + +The **Inspect App** (`app.inspect`) is the read/write interface to VideoIPath's newer *Inspect* +surface: it builds a live view of the topology (devices, ports, edges, and services) and applies +topology changes with a **commit-style** write model. + +It is **purely additive** — `app.topology` and `app.inventory` keep working unchanged. Devices are +still onboarded in Inventory first; Inspect then places, connects, and monitors them. + +Two ideas shape the API: + +- **Skeleton-first snapshots** — a snapshot loads only the minimal topology (all devices and edges, + without per-port detail) up front, then *lazily hydrates* detail the first time you touch it. This + keeps the initial read fast even in large environments. A snapshot is never a single point in + time; each device and section carries its own fetch timestamp. +- **Commit-style writes** — changes are staged and applied atomically. Before sending, the change + set re-checks that nobody else modified the affected entities (compare-and-commit); after a + successful commit it refreshes only the touched entities. + +> Inspect is verified against VideoIPath **2025.4** and newer. Against older servers the app logs a +> warning; behaviour is unverified. + +The app keeps a single topology view internally — you never handle a "snapshot" object. It loads on +your first read and stays current across writes; call `app.inspect.refresh()` to reload it. + +## 2. Reading the topology + +### 2.1. Devices, ports, and edges + +Everything is read straight off `app.inspect`. Skeleton fields are available without any per-device +I/O: + +```python +dev = app.inspect.get_device("device10") +dev = app.inspect.find_device_by_label("BORDERLEAF-26B") + +print(dev.label, dev.coordinates, dev.status, dev.sync_severity, dev.tags) + +for dev in app.inspect.devices: # all devices + print(dev.id, dev.label) +``` + +The first access to a device's **ports** hydrates that one device (a single scoped read), then +serves from local state: + +```python +for port in dev.ports: # triggers one hydration fetch for this device + print(port.label, port.vertex_id, port.status) + edge = port.edge # local edge-skeleton lookup, no I/O + if edge: + print("connected to", edge.to_device.label) + +for edge in dev.edges: # local, no hydration + print(edge.from_port, "->", edge.to_port, edge.status) + +for other in dev.linked_devices: # local graph walk + print(other.label) + +for edge in app.inspect.edges: # all external edges + print(edge.id, edge.status) +``` + +Hydrate many devices at once (parallel) to avoid N+1 reads: + +```python +app.inspect.preload() # all devices +app.inspect.preload(["device10", "device11"]) # a subset +``` + +### 2.2. Services + +Services load once as a section, on first access: + +```python +for service in app.inspect.services: # loads the paths section on first touch + print(service.booking_id) +``` + +### 2.3. Refreshing + +The view updates itself after your own writes. To pick up external changes, reload it: + +```python +app.inspect.refresh() # reload (skeleton; lazy detail) +app.inspect.refresh(load="full") # reload eagerly in one request +``` + +## 3. Writing to the topology + +### 3.1. Direct writes (auto-commit) + +Each direct method opens a one-change transaction and commits it immediately. If the internal view +is already loaded, the change is reflected into it via targeted refresh: + +```python +app.inspect.place_device("device12", x=1600, y=9050) +app.inspect.update_device("device12", label="BU-LEAF-A", icon_type="ipSwitchRouter") +app.inspect.update_vertex("device12.1.Ethernet1.out", use_as_endpoint=True) +app.inspect.update_edge(edge_id, weight=10) + +app.inspect.connect( + "device12.1.Ethernet1.out", + "device7.0.swp1.in", + bidirectional=True, # also stages the reverse edge + capacity=65535, +) +app.inspect.disconnect("device12.1.Ethernet1.out", "device7.0.swp1.in") +app.inspect.remove_device_from_topology("device12") +``` + +### 3.2. Batched, atomic changes (transaction) + +Use a transaction to stage several changes and commit them together: + +```python +with app.inspect.transaction() as tx: + tx.place_device("device12", x=100, y=200) + tx.connect(a_out, b_in, bidirectional=True) + tx.remove(edge_id) + result = tx.commit() # conflict check → POST → targeted refresh of the internal view + +print(result.ok, result.applied_ids) +``` + +Exiting the `with` block **without** committing discards the staged changes (and logs a warning). + +### 3.3. Handling concurrent changes + +If another user changed a staged entity since you staged it, `commit()` raises +`InspectCommitConflictError` and sends nothing: + +```python +from videoipath_automation_tool.apps.inspect import InspectCommitConflictError + +try: + tx.commit() +except InspectCommitConflictError as exc: + for conflict in exc.conflicts: + print(conflict.entity_id, conflict.field_diffs) + tx.rebase() # re-fetch baselines, keep your intents + tx.commit() + +# or explicitly force last-writer-wins: +tx.commit(check_conflicts=False) +``` + +A server-rejected commit (validation or apply gate) raises `InspectCommitError`, which carries the +typed `validation` details. + +## 4. Onboarding devices into the topology + +```python +from videoipath_automation_tool.apps.inspect import ConflictStrategy + +app.inspect.add_devices_to_topology([("device12", 100, 200), "device13"]) +info = app.inspect.get_sync_info(["device12"]) +app.inspect.sync_devices(["device12"], add_only=True, conflict_strategy=ConflictStrategy.STRICT) +``` + +## 5. Notes + +- Inspect uses **only** the collector API surface at runtime; it never calls the legacy + `nGraphElements` / `edgesByDevice` endpoints. +- The topology view is loaded lazily and kept internal to `app.inspect`; a pure-write workflow never + triggers a read. Reads and hydration are internally consistent under concurrent access, but a + single `VideoIPathApp` is otherwise intended for single-owner use. +- For the design rationale, see the architecture docs under + [`docs/inspect-app/`](../inspect-app/README.md). diff --git a/docs/getting-started-guide/README.md b/docs/getting-started-guide/README.md index 46d7fc6..955b7eb 100644 --- a/docs/getting-started-guide/README.md +++ b/docs/getting-started-guide/README.md @@ -1,6 +1,6 @@ # Getting Started Guide -This guide will help you get started with the VideoIPath Automation Tool. It will show you how to establish a connection to the VideoIPath server and how to manage devices in the inventory and topology. Also, it demonstrates how to configure multicast pools. +This guide will help you get started with the VideoIPath Automation Tool. It will show you how to establish a connection to the VideoIPath server and how to manage devices in the inventory and topology. Also, it demonstrates how to configure multicast pools and read/write the topology through the Inspect app. ## Table of Contents @@ -8,3 +8,4 @@ This guide will help you get started with the VideoIPath Automation Tool. It wil 2. [Managing Devices in the Inventory](02_Inventory.md) 3. [Managing Devices in the Topology](03_Topology.md) 4. [Configuring Multicast Pools](04_Multicast_Pools.md) +5. [Inspecting and Editing the Topology (Inspect App)](05_Inspect.md) diff --git a/docs/inspect-app/README.md b/docs/inspect-app/README.md index f34d474..1a12658 100644 --- a/docs/inspect-app/README.md +++ b/docs/inspect-app/README.md @@ -15,7 +15,13 @@ there is **no deprecation and no migration** planned. These docs are **living documents** — meant to be edited and refined as the design firms up and as the real Inspect API is reverse-engineered. The official [VideoIPath Public API 2025 LTS](https://documenter.getpostman.com/view/11222813/2sBXihpCS8#intro) -reference is now a primary source. Nothing here is implemented yet; this is the plan. +reference is now a primary source. + +> **Status: implemented.** The `app.inspect` surface described here is shipped +> (`src/videoipath_automation_tool/apps/inspect/`), with offline unit tests under +> `tests/inspect/` and a developer-run live E2E suite under `tests/e2e/inspect/`. +> All ten ADRs are Accepted. For usage, see the +> [Inspect getting-started page](../getting-started-guide/05_Inspect.md). ## Reading order @@ -62,6 +68,7 @@ reference is now a primary source. Nothing here is implemented yet; this is the - **Proposed / Accepted / Superseded** — for ADRs, see [the decisions index](./decisions/README.md). -> ADRs currently capture **context and options only** — decisions are open. -> Promote an ADR to **Accepted** once agreed, and tick off `[VERIFY]` items in -> [concepts.md](./concepts.md) as they are confirmed. +> All ADRs are **Accepted** and the `[VERIFY]` items in +> [concepts.md](./concepts.md) / [endpoints.md](./endpoints.md) were confirmed +> against VideoIPath 2025.4.9. The app is implemented; these docs are retained as +> the design record. diff --git a/docs/inspect-app/concepts.md b/docs/inspect-app/concepts.md index f69dd5c..80cf72d 100644 --- a/docs/inspect-app/concepts.md +++ b/docs/inspect-app/concepts.md @@ -415,19 +415,16 @@ Write consistency & post-commit refresh ([ADR-0009](./decisions/0009-write-consi - [x] UI edit/commit flows avoid `nGraphElements`: the Inspect UI bundle contains zero references; the edge edit flow calls `lookupInspectEdgesByIds` with both pair directions ([ADR-0008](./decisions/0008-collector-only-endpoints.md)) - [x] Batched lookups for compare-and-commit baselines: `…ByIds` actions take id lists natively - [x] Direct edge-pair addressing for targeted refresh: `externalEdgesByDeviceKey//` returns the single pair item -- [ ] Collector propagation delay: time between `updateTopology` OK and the change being visible in collector reads (drives the ADR-0010 retry window) — needs a live write test (coordinate nudge + revert) +- [x] Collector propagation delay: measured via device coordinate commit + revert (three samples) — change visible on the **first poll ~25 ms after the POST returned**; no post-commit retry window needed ([ADR-0010](./decisions/0010-post-commit-snapshot-refresh.md)) +- [x] `replaceDevices` payload shape: the **edit form** (`lookupInspectDevice.fields`; `coordinates`/`localAssignedTags` mandatory) — the raw persisted `baseDevice` element is rejected with HTTP 400; round-trip verified byte-identical except `_rev` ([endpoints.md](./endpoints.md#post-restv2actionsstatuscollectorupdatetopology)) +- [x] `replaceVertices` payload shape: the **vertex edit form** (`lookupInspectVertexById.fields`) — verified via desc round-trip on a live vertex (byte-identical revert). **Update-only**: an unknown vertex id fails validation with *"Vertex with id … was not found in graph"* — standalone vertices cannot be created via `updateTopology` (they originate from device sync / virtual-device definitions) ([endpoints.md](./endpoints.md#post-restv2actionsstatuscollectorupdatetopology)) - [ ] Re-check on every server upgrade: does `updateTopology` gain `_rev` enforcement (would allow replacing client-side compare-and-commit) ## 6. Open questions _All VERIFY items are resolved (results merged into [endpoints.md](./endpoints.md) and the §5.1 checklist) or documented as -confirmed limitations / unregistered stubs — with one exception:_ - -- **Collector propagation delay after a commit** - ([ADR-0010](./decisions/0010-post-commit-snapshot-refresh.md)) — requires a - live write test (coordinate nudge + revert) to size the post-commit retry - window. +confirmed limitations / unregistered stubs._ Remaining follow-up (only if a future server version registers new actions): diff --git a/docs/inspect-app/decisions/0006-commit-write-model.md b/docs/inspect-app/decisions/0006-commit-write-model.md index 12edd17..6f97054 100644 --- a/docs/inspect-app/decisions/0006-commit-write-model.md +++ b/docs/inspect-app/decisions/0006-commit-write-model.md @@ -51,9 +51,9 @@ arrays mean “no change” for that category: | Field | Shape | Purpose | | ----- | ----- | ------- | -| `replaceDevices` | `Record` | Upsert devices | -| `replaceVertices` | `Record` | Upsert vertices | -| `replaceEdges` | `Record<"fromId::toId", edge>` | Upsert edges (composite key) | +| `replaceDevices` | `Record` — the `lookupInspectDevice.fields` shape (`coordinates`, `localAssignedTags` mandatory; raw `baseDevice` element rejected — verified 2025.4.9) | Upsert devices | +| `replaceVertices` | `Record` — the `lookupInspectVertexById.fields` shape (verified 2025.4.9); **update-only**: unknown ids fail validation | Update vertices | +| `replaceEdges` | `Record<"fromId::toId", edge>` — raw persisted edge form (composite key) | Upsert edges | | `replaceResourceTransforms` | `Record` | Upsert resource transforms | | `addExternalEdges` | `edge[]` | Add external edges | | `remove` | `string[]` | Remove entities by id | diff --git a/docs/inspect-app/decisions/0009-write-consistency.md b/docs/inspect-app/decisions/0009-write-consistency.md index 072cf56..9c05fb2 100644 --- a/docs/inspect-app/decisions/0009-write-consistency.md +++ b/docs/inspect-app/decisions/0009-write-consistency.md @@ -75,10 +75,17 @@ between our read and our commit (lost update). What the server gives us - devices: `lookupInspectDevice` — editable device form (coordinates, descriptor, iconType, sdpStrategy, tags). - Edges come back in the persisted `replaceEdges` shape directly; for - devices and vertices the lookup returns the *edit form*, and the ACL maps - it to the persisted `replace*` shape (e.g. `coordinates` ↔ `maps[]`) — the - same mapping the UI performs. The baseline serves double duty: it is the + The lookup forms **are** the write shapes — no client-side mapping + (all three verified 2025.4.9 by live commit + byte-identical revert): + `replaceEdges` takes the persisted edge form exactly as + `lookupInspectEdgesByIds` returns it; `replaceDevices` takes exactly + `lookupInspectDevice`'s `fields` object (`coordinates`/`localAssignedTags` + mandatory; the raw persisted `baseDevice` element is rejected with HTTP + 400 — the server maps `coordinates` → `maps[]` itself); `replaceVertices` + takes exactly `lookupInspectVertexById`'s `fields` object. Note + `replaceVertices` is **update-only**: an unknown vertex id fails + validation (*"Vertex … was not found in graph"*) — vertices originate from + device sync, not from commits. The baseline serves double duty: it is the basis for building the full `replace*` payload (caller mutations applied on top) *and* the reference for conflict detection, compared on the editable fields. For `remove` entries the baseline is the element's @@ -119,7 +126,14 @@ between our read and our commit (lost update). What the server gives us snapshot is a read surface, not a write token ([ADR-0007](./0007-lazy-snapshot-loading.md), ADR-0010 for post-commit refresh). -- The device/vertex lookups return *edit forms*, not raw persisted elements — - the ACL's edit-form ↔ `replace*`-shape mapping must be exact, or replaces - clobber unmapped fields. Fixture-test the round-trip per element kind - (edges need none; devices/vertices do). +- The lookup→write round-trip is exact for devices **and vertices** (both + verified: commit + revert left the persisted element byte-identical except + `_rev`) and trivial for edges. One caveat, applying to devices and vertices + alike: the lookups (and the collector) return the **effective** label — the + persisted `descriptor` merged with the `fDescriptor` fallback. + `descriptor` is stored verbatim on commit, so a client that round-trips the + lookup unchanged pins the fallback label into `descriptor` (the label stops + tracking device-reported names). The persisted-vs-fallback distinction is + not observable on the Inspect surface; the UI has the same property. + Document it; don't touch `descriptor`/label fields unless the caller set + them. diff --git a/docs/inspect-app/decisions/0010-post-commit-snapshot-refresh.md b/docs/inspect-app/decisions/0010-post-commit-snapshot-refresh.md index c06f04d..c970fac 100644 --- a/docs/inspect-app/decisions/0010-post-commit-snapshot-refresh.md +++ b/docs/inspect-app/decisions/0010-post-commit-snapshot-refresh.md @@ -20,8 +20,9 @@ exists"), so the state must catch up — efficiently: - The change set knows precisely which entities it touched, and the commit response confirms them (`data.items[]` with per-item `res.ok`). - The collector is a **status-plane projection** of the config store the - commit writes to; the change may become visible only after a propagation - delay (**[VERIFY]** below). + commit writes to; measured on 2025.4.9, the projection updates effectively + synchronously with the commit (~25 ms to first-poll visibility — see + Decision, point 5). ## Options @@ -73,12 +74,12 @@ result is success (`res.ok` and `validation.result.ok`): or other section-level data was loaded, mark it stale and let the next access re-load it (ADR-0007 lazy path). Commits change services only indirectly; eager re-reads here are usually wasted. -5. **Propagation handling**: the re-fetch verifies the committed change is - visible (e.g. a replaced field has the committed value) and retries briefly - if the projection lags; give up after a small bounded window and surface - the staleness (log + fetch timestamp), never hang. - **[VERIFY]** the actual delay between `updateTopology` OK and collector - visibility on a real instance — if it is consistently ~0, drop the retry. +5. **Propagation handling**: measured on 2025.4.9 (device coordinate commit, + three samples): the change was visible in collector reads on the **first + poll ~25 ms after the POST returned** — the projection updates effectively + synchronously with the commit. No retry window is needed; the targeted + re-fetch doubles as the verification read (log if the committed value is + unexpectedly absent, don't loop). A failed commit changes nothing server-side (reject-before-apply, verified) — the snapshot is left untouched. @@ -93,8 +94,9 @@ the snapshot is left untouched. - The refresh step needs the affected-set derivation to be exact; the id conventions it relies on are already documented (concepts.md §3.1) and fixture-tested. -- Remaining `[VERIFY]` (tracked in concepts.md §5.1): the collector - propagation delay after a commit — requires a live write test (coordinate - nudge + revert) to size the retry window. +- All refresh mechanics are verified on 2025.4.9: direct pair addressing, + scoped single-device reads, and ~synchronous collector propagation + (~25 ms to first-poll visibility). Re-measure propagation if a future + server version decouples the collector projection from the commit path. - Together with ADR-0009: `commit()` = pre-commit conflict check → POST → result evaluation → targeted refresh. One documented lifecycle. diff --git a/docs/inspect-app/endpoints.md b/docs/inspect-app/endpoints.md index 492ec9c..b4aecc6 100644 --- a/docs/inspect-app/endpoints.md +++ b/docs/inspect-app/endpoints.md @@ -918,6 +918,13 @@ Response (single form; `…ByIds` nests this per id): Fixture: `tests/fixtures/inspect/2025.4.9/lookup_inspect_vertex_by_id.json`. +The `fields` object is **exactly the accepted `replaceVertices` payload shape** +(verified 2025.4.9 — see +[`updateTopology`](#post-restv2actionsstatuscollectorupdatetopology); +update-only). The same effective-label caveat as `lookupInspectDevice` +applies: `label` here can carry the `fDescriptor` fallback while the persisted +`descriptor.label` is empty. + ### `POST /rest/v2/actions/status/collector/lookupNodeInfo` / `…/lookupEdgeInfo` / `…/lookupDeviceVertices` **Verified 2025.4.9 — display-oriented**, not baselines. `lookupNodeInfo` @@ -998,6 +1005,13 @@ Model coverage: - `InspectApiAssignedTags` - `InspectApiLookupInspectDeviceFields` +The `fields` object is **exactly the accepted `replaceDevices` payload shape** +(verified 2025.4.9 — see +[`updateTopology`](#post-restv2actionsstatuscollectorupdatetopology)). Note +that `descriptor` here carries the *effective* label (persisted `descriptor` +merged with the `fDescriptor` fallback); committing it verbatim pins the +fallback label into the persisted `descriptor`. + Invalid object-style request example: ```json @@ -1399,11 +1413,57 @@ Failure modes (verified 2025.4.9): in one commit fails entirely (`items: []`) — reject-before-apply. - **`force: true` does not bypass apply-gate errors** (`res.ok` stays `false` for non-existent remove keys). +- **`replaceDevices` takes the edit form, not the persisted element** + (verified 2025.4.9 via a device coordinate commit + revert): sending the raw + `baseDevice` element (`maps[]`, `fDescriptor`, `type`, …) is rejected with + HTTP 400 conversion errors — `coordinates` and `localAssignedTags` are + **mandatory**. The accepted shape is exactly `lookupInspectDevice`'s + `fields` object; the server maps `coordinates` → `maps[]` itself: + + ```json + { + "replaceDevices": { + "device-a": { + "coordinates": { "x": 1600.0, "y": 9050.0 }, + "descriptor": { "desc": "", "label": "" }, + "iconSize": "medium", + "iconType": "default", + "localAssignedTags": [], + "sdpStrategy": "always", + "siteId": null, + "tags": [], + "virtualDeviceFields": null + } + } + } + ``` + + `descriptor` is stored **verbatim** (an empty persisted descriptor stayed + empty across commit + revert; the element round-tripped byte-identical + except `_rev`). Caveat: the lookups return the *effective* label (persisted + `descriptor` merged with the `fDescriptor` fallback) — a client that + round-trips them unchanged pins the fallback label into `descriptor`. + `replaceEdges` takes the raw persisted edge form (captured UI commit + + verified apply). +- **`replaceVertices` takes the vertex edit form and is update-only** + (verified 2025.4.9 via a `desc` round-trip on a live vertex, byte-identical + revert): the accepted shape is exactly `lookupInspectVertexById`'s `fields` + object. Committing a **new** vertex id passes schema conversion but fails + validation with *"Vertex with id … was not found in graph"* — standalone + vertices cannot be created through `updateTopology`; they originate from + device sync (`syncDevices`) or virtual-device definitions. +- **Collector propagation**: a committed change is visible in collector reads + ~25 ms after the POST returns (three samples, first poll each time) — the + projection updates synchronously with the commit for practical purposes. - Writes appear in `nGraphElements` under the composite `fromId::toId` key with a bumped `_rev`; a parallel UUID-keyed document may also exist for the same edge. - `resolvable: true` has not been observed on 2025.4.9. - Fixtures: `tests/fixtures/inspect/2025.4.9/update_topology_success.json`, + `…/update_topology_replace_devices.json` (edit-form request, success + response, and the raw-element rejection error), + `…/update_topology_replace_vertices.json` (vertex edit-form request, + success response, and the update-only validation failure), `…/update_topology_fail_remove.json`, `…/update_topology_fail_booking.json`. Applied-change response (verified on 2025.4.9 — edge `weight` change): diff --git a/docs/inspect-app/implementation-plan.md b/docs/inspect-app/implementation-plan.md new file mode 100644 index 0000000..d1d5370 --- /dev/null +++ b/docs/inspect-app/implementation-plan.md @@ -0,0 +1,286 @@ +# Inspect App — Implementation Plan + +> Status: **Implemented** · Last updated: 2026-07-09 +> +> Turns the concept phase ([concepts.md](./concepts.md), [endpoints.md](./endpoints.md), +> [ADRs 0001–0010](./decisions/README.md)) into the shipped `app.inspect` surface. +> All wire facts referenced below are live-verified against VideoIPath 2025.4.9. +> +> **Shipped:** code under `src/videoipath_automation_tool/apps/inspect/`; offline tests under +> `tests/inspect/`; developer-run live E2E suite (`-m e2e`) under `tests/e2e/inspect/`; user guide +> at [getting-started 05_Inspect](../getting-started-guide/05_Inspect.md). Milestones M1–M5 below +> are complete. + +## 1. Context + +The inspect-app concept phase is complete: all wire formats are captured and live-verified against VideoIPath 2025.4.9 ([endpoints.md](./endpoints.md)), and ten ADRs pin the architecture. This plan turns the concepts into the shipped `app.inspect` package surface. The existing code under `src/videoipath_automation_tool/apps/inspect/` (snapshot.py, domain/, model/) is a **draft** — it predates ADR-0007/0009/0010 (it parses one full collector response, no lazy hydration, no writes) and will be reworked in place. + +Governing decisions (all Accepted): + +| ADR | Decision (implementation-relevant core) | +| --- | --- | +| [0001](./decisions/0001-api-paradigm.md)/[0003](./decisions/0003-websocket-subscriptions.md) | Request/response only; no WebSocket API | +| [0004](./decisions/0004-async-strategy.md) | Sync public API; internal thread-pool parallelism for multi-request operations | +| [0005](./decisions/0005-e2e-testing.md) | E2E = developer-run live-server tests via `tests/.env.test`; offline fixture tests feed from `tests/fixtures/inspect//` | +| [0006](./decisions/0006-commit-write-model.md) | Commit-style writes: hybrid — direct auto-commit methods + explicit transaction context manager; success = `header.ok ∧ data.res.ok ∧ data.validation.result.ok` | +| [0007](./decisions/0007-lazy-snapshot-loading.md) | Skeleton-first snapshot (devices + edges scoped queries), transparent per-entity lazy hydration, section-level lazy loads, accreting state, `load="full"` eager mode | +| [0008](./decisions/0008-collector-only-endpoints.md) | Collector-only endpoints: never call `nGraphElements` / `edgesByDevice` at runtime | +| [0009](./decisions/0009-write-consistency.md) | Client-side compare-and-commit; baselines from `lookupInspectDevice` / `lookupInspectVertexByIds` / `lookupInspectEdgesByIds` (lookup forms **are** the write shapes); typed conflict error; explicit override | +| [0010](./decisions/0010-post-commit-snapshot-refresh.md) | Post-commit targeted refresh: local removes, per-device/per-pair scoped re-reads (~25 ms propagation, no retry loop), sections marked stale | + +Verified server facts the code must encode: `replaceDevices`/`replaceVertices` take **edit forms** (`coordinates`+`localAssignedTags` mandatory for devices), `replaceVertices` is **update-only**, `replaceEdges` takes the raw persisted edge form, last-writer-wins (no `_rev` anywhere on the Inspect surface), reject-before-apply atomicity, HTTP 414 for over-long projections, `"_noId"` suppresses expansion, effective-label merging in lookups/collector. + +## 2. Public API (the user-facing contract — design this first, code to it) + +### 2.1 Entry point + +```python +app = VideoIPathApp(...) +snapshot = app.inspect.get_snapshot() # skeleton: all devices + edges, 2 parallel GETs +snapshot = app.inspect.get_snapshot(load="full") # eager /** fallback (point-in-time) +``` + +### 2.2 Reads (snapshot + domain objects, ADR-0007) + +```python +dev = snapshot.get_device("device12") # skeleton-backed, no I/O +dev = snapshot.find_device_by_label("BU-LEAF-A") # exact; find_devices_by_label for lists +devs = snapshot.devices # list[InspectDevice] +edges = snapshot.edges # list[InspectEdge] (pair-level) +svcs = snapshot.services # lazy: loads inspect/paths section on first touch + +dev.label, dev.id, dev.coordinates, dev.status, dev.sync_severity, dev.tags # skeleton fields +dev.ports # first access → one GET nodeStatus//, then local +dev.edges # local (edge skeleton) +dev.services # triggers paths section load once +dev.linked_devices # local graph walk +port.vertex_id, port.status, port.edge, port.device +edge.from_device, edge.to_port, edge.status, edge.pair_id + +snapshot.preload(devices=[...]) # batch hydration, thread pool (ADR-0004) +snapshot.refresh() # returns a NEW snapshot (never mutates) +snapshot.fetched_at(entity_or_section) # freshness introspection +``` + +Contract (already documented in models.md): at most one hydration fetch per entity/section; getters can raise connector errors; snapshot is not a single point in time. + +### 2.3 Writes (change set, ADR-0006/0009/0010) + +```python +# Convenience direct writes (auto-commit, one updateTopology each): +app.inspect.place_device("device12", x=1600, y=9050) +app.inspect.update_device("device12", label="BU-LEAF-A", icon_type="switch") +app.inspect.update_vertex("device12.1.Ethernet1.out", use_as_endpoint=True) +app.inspect.update_edge(edge_id, weight=10) +app.inspect.connect("device12.1.Ethernet1.out", "device7.0.swp1.in", + bidirectional=True, capacity=65535) # paired edges +app.inspect.disconnect(edge_or_pair_id) +app.inspect.remove_device_from_topology("device12") # remove baseDevice element + +# Batched, atomic (primary path for pipelines): +with app.inspect.transaction() as tx: + tx.place_device("device12", x=100, y=200) + tx.connect(a_out, b_in, bidirectional=True) + tx.remove(edge_id) + result = tx.commit() # conflict check → POST → targeted snapshot refresh +# exit without commit → discard + warning log; exception → discard + +result.ok, result.applied_ids, result.validation # typed CommitResult + +# Conflict handling (ADR-0009): +try: + tx.commit() +except InspectCommitConflictError as e: + e.conflicts # [{entity_id, kind, field_diffs}] + tx.rebase() # re-fetch baselines, re-apply staged intents; then commit again +tx.commit(check_conflicts=False) # explicit last-writer-wins +``` + +Semantics: staging an entity fetches its baseline via the lookups (batched); caller mutations are field-level *intents* applied onto the baseline at commit-build time; `remove` baselines assert existence. Direct writes are sugar: open implicit tx → stage → commit. + +### 2.4 Topology device/sync actions (network actions) + +```python +app.inspect.add_devices_to_topology([("device12", 100, 200), ...]) # addDevices action +app.inspect.sync_devices(["device12"], add_only=True, conflict_strategy=ConflictStrategy.STRICT) +info = app.inspect.get_sync_info(["device12"]) # lookupSyncInfo +``` + +### 2.5 Errors (new `apps/inspect/errors.py`) + +`InspectError` (base) → `InspectCommitError` (carries `CommitResult` with `res.msg` + `validation` details), `InspectCommitConflictError`, `InspectEntityNotFoundError`, `InspectQueryTooLongError` (HTTP 414 → message points at `load="full"`). Connector-level errors pass through unchanged. + +## 3. Module layout (target) + +``` +apps/inspect/ +├── __init__.py # public re-exports +├── inspect_app.py # InspectApp: composes read + write + actions mixins +├── inspect_api.py # raw API layer: all HTTP calls, typed responses +├── queries.py # scoped-query/projection builder + frozen projection constants +├── errors.py +├── app/ # user-facing method mixins (inventory-app pattern) +│ ├── read.py # get_snapshot, find_* helpers +│ ├── write.py # direct writes + transaction() +│ └── actions.py # add/sync devices, sync info +├── snapshot.py # InspectSnapshot: state, indexes, hydration, refresh hooks +├── changeset.py # InspectTransaction, staging entries, baselines, commit +├── domain/ # InspectDevice/Port/Edge/Service (extend existing) +└── model/ # InspectApi* DTOs (extend existing: collector.py, actions.py, + # update_topology.py, ngraph.py, common.py) +``` + +Follows the established package idioms: `*App` composed from mixins (like `InventoryApp`), `*API` raw layer, Pydantic DTOs under `model/`, snake_case fields with wire aliases. + +## 4. Implementation detail per component + +### 4.1 Connector changes (small, prerequisite) + +`connector/vip_rest_connector.py`: +- **POST allow-list**: add prefix `/rest/v2/actions/status/network/` (for `addDevices`/`syncDevices`). GET already covers `/rest/v2/data/status/`; POST already covers `…/actions/status/collector/`. +- **`/...` wildcard block**: GET currently raises on any `"/..."` in the path — scoped projections *require* `/.../` segments. Move this check behind the existing `node_check`/`url_validation` flags or add `allow_projection: bool = False` param that InspectAPI sets. Do not weaken behavior for existing callers. +- **`node_check=False`** for scoped reads (partial trees don't contain all envelope nodes). +- URL-encode query paths (space `%20`, `"` `%22`, parens, `=`) in `queries.py`, not in the connector. + +### 4.2 `queries.py` — the projection catalogue + +Single home for every verified query string (they are contract, not ad-hoc): +- `DEVICE_SKELETON` — trimmed msg-13 projection with `modules/"_noId"` (the full UI projection 414s; the trimmed variant is verified at ~8.6 KB/27 devices). +- `DEVICE_DETAIL(device_id)` — direct-id form `nodeStatus//…` with `modules/*` detail projection. +- `EDGE_SKELETON` — msg-14 lean projection; `EDGE_PAIR(pair_id)` — direct pair-key form. +- `PATHS_SECTION` — msg-12 serviceFields+path projection. +- Encoding helper `encode_query(path) -> str` + length guard raising `InspectQueryTooLongError` before sending (proxy limit). +Unit-test each constant against the fixtures (round-trip: query → fixture response parses into DTOs). + +### 4.3 `inspect_api.py` — raw layer (one method per endpoint) + +| Method | Endpoint | +| ------ | -------- | +| `get_collector_full()` | `GET …/status/collector/**` | +| `get_device_skeleton()` / `get_device_detail(id)` | scoped nodeStatus queries | +| `get_edge_skeleton()` / `get_edge_pair(pair_id)` | scoped externalEdgesByDeviceKey | +| `get_paths_section()` | scoped inspect/paths | +| `lookup_inspect_device(id)` | POST lookupInspectDevice | +| `lookup_vertices(ids)` | POST lookupInspectVertexByIds | +| `lookup_edges(ids)` | POST lookupInspectEdgesByIds | +| `lookup_sync_info(ids)` | POST lookupSyncInfo | +| `update_topology(delta)` | POST updateTopology | +| `add_devices(items)` / `sync_devices(req)` | POST network actions | +| `get_registered_actions()` | GET actions schema (version gating) | + +Each returns typed DTOs; each logs at DEBUG. No business logic here. + +### 4.4 DTO additions (`model/`) + +- `collector.py`: already covers nodeStatus/paths/edges — verify field coverage against the skeleton/detail fixtures; add missing fields (`relatedNodeTags`, `ptpDeviceStatus`, `tagsInfo`, `meta` extras) as **optional** so skeleton and full payloads parse with one model set (skeleton items simply have `modules={}` / fields absent). +- `actions.py`: add `InspectApiLookupEdgesByIdsRequest/Response(+Item)`, `InspectApiLookupVertexByIdRequest/Response`, `InspectApiVertexEditForm` (the `fields` object incl. `typeFields`), reuse existing lookup DTOs. +- `update_topology.py`: encode the **verified per-kind shapes** — `InspectApiDeviceEditForm` (== lookupInspectDevice.fields; used in `replaceDevices`), `InspectApiVertexEditForm` (`replaceVertices`), persisted edge model (`replaceEdges`, reuse/align with `ngraph.py` edge). `CommitResponse` with `items[]`, `res`, `validation{createIds, details, result}`. +- All DTOs: `model_config = ConfigDict(extra="allow")` on read models (unknown-field tolerance across server versions), strict on write payload models. + +### 4.5 `snapshot.py` rework (keep public getters, change internals) + +State per ADR-0007/0010: +- `_records`: skeleton-indexed devices/edges as today, plus `_hydration: dict[str, _EntityState]` where `_EntityState = (level: SKELETON|FULL, fetched_at: datetime)`; `_sections: dict[str, _SectionState]` (paths, maintenanceBookings…). +- Constructor takes `fetcher: InspectAPI | None` + parsed skeleton payloads. `from_full_response(response)` classmethod → everything marked FULL, fetcher optional (fixtures/offline: lazy loading inert; raise a clear error if an unloaded section is touched with no fetcher). +- `_ensure_device_detail(device_id)`: no-op if FULL; else `get_device_detail`, re-parse item, replace record, rebuild that device's port index entries, drop that device's cached domain wrappers, stamp `fetched_at`. Same pattern `_ensure_section("paths")`. +- **Concurrency**: a `threading.Lock` around merge operations (preload uses a pool); document snapshot as not-thread-safe for general use, but hydration merges are internally consistent. +- `preload(devices=None)`: ThreadPoolExecutor (bounded, e.g. 8) over `_ensure_device_detail` (ADR-0004). +- **Post-commit hooks** (called by changeset, ADR-0010): `_apply_removals(ids)`, `_refresh_devices(ids)`, `_refresh_edge_pairs(pair_ids)`, `_mark_section_stale("paths")`. +- Keep `raw_response` only for `load="full"` snapshots; skeleton snapshots expose `raw_skeleton` parts instead. + +### 4.6 Domain objects (`domain/`) + +Property → data-source mapping (drives which getter triggers hydration): + +| Property | Source | Hydrates? | +| -------- | ------ | --------- | +| `InspectDevice.id/label/pid/coordinates/status/sync_severity/tags/icon` | skeleton | no | +| `InspectDevice.ports`, `InspectPort.*` | device detail | yes (device) | +| `InspectDevice.edges`, `InspectEdge.*` | edge skeleton | no | +| `InspectDevice.services`, `InspectService.*`, `InspectEdge.services` | paths section | yes (section) | +| `InspectPort.edge` | edge skeleton index | no | + +Add `InspectDevice.is_hydrated` / `snapshot.fetched_at(...)` for introspection. Keep frozen-dataclass wrappers + snapshot back-reference pattern from the draft. + +### 4.7 `changeset.py` — transaction, baselines, commit + +- `_StagedEntry(kind: DEVICE|VERTEX|EDGE, id, baseline: DTO, intents: dict[field, value] | REMOVE)`. +- **Staging**: first touch of an entity fetches baselines via *batched* lookups (`lookup_edges`, `lookup_vertices` accept lists; device lookup is per-id). Staging validates the entity exists (translate lookup miss → `InspectEntityNotFoundError`); vertices/devices cannot be *created* (update-only — enforced client-side with a clear message). +- `connect(a_vertex, b_vertex, bidirectional=True, **edge_fields)`: builds one/two `replaceEdges` entries keyed `a::b` (and `b::a`) from a default edge template (capacity 65535, redundancyMode "Any", weightFactors default — the verified persisted-edge defaults); no baseline needed for *new* edge keys, but stage a lookup to detect "already exists" and require explicit `overwrite=True`. +- **Payload build**: intents applied onto baselines → per-kind write DTOs (devices/vertices: edit forms verbatim — **no** field mapping; edges: persisted form). Label/desc caveat: `descriptor` fields are only included in the diff-intents if the caller set them (never round-trip the effective label silently). +- **Commit sequence** (one method, well-logged): + 1. re-fetch baselines (batched), deep-compare vs staged baselines over editable fields (exclude volatile: statuses, `assignedTags.inherited`); mismatch → `InspectCommitConflictError` (all conflicts collected, nothing sent); + 2. POST `update_topology`; + 3. evaluate three-flag success; failure → `InspectCommitError` with typed validation details; + 4. targeted snapshot refresh (if the tx was created from a snapshot): removes applied locally, affected devices/pairs re-fetched, paths section marked stale. Affected-set derivation from staged keys + `items[]` using the id conventions (vertex id → owning device; edge key → pair id). +- `rebase()`: refresh baselines, keep intents, drop conflicts resolution to caller when intent-field itself conflicts. +- Transaction is single-use; `discard()` clears; context-manager exit without commit logs a warning (explicitly decided: **no auto-commit on exit** — commit must be visible in user code). + +### 4.8 `InspectApp` + package integration + +- `inspect_app.py`: composes mixins; ctor `(vip_connector, logger)` like the other apps. +- `videoipath_app.py`: add lazy `inspect` property + `self._inspect_api = self.inspect._inspect_api` in the DEV block; placeholder `self._inspect = None`. +- **Version gating**: on `InspectApp` init, check `get_server_version()` ≥ 2025.4 (first verified: 2025.4.9); below → log warning "Inspect API surface unverified for this server version". Optionally probe `get_registered_actions()` at DEBUG. +- `apps/inspect/__init__.py`: export `InspectApp`, domain classes, `InspectSnapshot`, errors, `ConflictStrategy`. + +## 5. Milestones (PR-sized, each independently shippable) + +1. **M1 – Connector + queries + API layer (read)**: connector changes, `queries.py`, `inspect_api.py` read methods, DTO gap-fill; offline fixture tests green. +2. **M2 – Snapshot rework + domain (read path complete)**: hydration states, sections, preload, `from_full_response`; `app.inspect.get_snapshot()`; VideoIPathApp property; unit tests with fake fetcher (hydration counts, merge idempotency). +3. **M3 – Lookups + actions**: lookup API methods + DTOs, `get_sync_info`, `add_devices_to_topology`, `sync_devices`. +4. **M4 – Change set + writes**: `changeset.py`, direct write sugar, commit lifecycle incl. conflict check + targeted refresh; unit tests with mocked API (payload-shape assertions against the four updateTopology fixtures; conflict scenarios; refresh-hook calls). +5. **M5 – E2E suite** (below) + getting-started doc page (`docs/getting-started-guide/0X_Inspect.md`) + [models.md](./models.md)/README sync. + +## 6. Testing + +### 6.1 Offline (runs in CI, every push) + +- **Fixture contract tests** (`tests/inspect/test_models.py`): every fixture in `tests/fixtures/inspect/2025.4.9/` parses into its DTO; write-payload builders reproduce the fixture requests byte-for-byte (devices/vertices edit forms, edge form, no-op). +- **Snapshot unit tests** (`tests/inspect/test_snapshot.py`): fake fetcher returning fixture payloads — skeleton indexes, exactly-one hydration fetch per device, section laziness, preload fan-out, post-commit hooks (removal drops indexes; refresh replaces records; stale section reloads). +- **Changeset unit tests** (`tests/inspect/test_changeset.py`): staging→baseline capture, intent application, conflict detection (mutated baseline → error with field diffs), `check_conflicts=False` bypass, three-flag result evaluation incl. the captured failure fixtures, affected-set derivation. +- **Query tests**: encoding, 414 length guard, projection constants stability. + +### 6.2 E2E — live instance, leaf-spine scenario (ADR-0005) + +Location `tests/e2e/inspect/`, pytest marker `e2e` (excluded by default via `addopts = -m "not e2e"`), gated on `VIPAT_E2E_ENABLED=1` in `tests/.env.test` **plus** a server-version allowlist check; every created element uses label prefix `E2E-` and tag `#vipat-e2e` so setup/teardown is idempotent and safe on a shared local instance. + +**Scenario: `LeafSpineScenario` (module `tests/e2e/inspect/scenario.py`)** — a fixed dataclass-defined network modelled on the real rig (red/blue planes, leaf-spine, endpoints): + +| Role | Devices (virtual) | Ports (ip vertices, in/out pairs) | +| ---- | ----------------- | -------------------------------- | +| Spines | `E2E-SPINE-A`, `E2E-SPINE-B` | 4 × downlink (`swp1..4`) | +| Leaves | `E2E-LEAF-A1`, `E2E-LEAF-A2` (red), `E2E-LEAF-B1`, `E2E-LEAF-B2` (blue) | 2 × uplink, 4 × host port | +| Endpoints | `E2E-ENC-1`, `E2E-ENC-2`, `E2E-DEC-1`, `E2E-DEC-2` | `eth-a` (red), `eth-b` (blue) | + +Edge matrix (all bidirectional pairs): each leaf ↔ its plane's spine (uplinks, capacity 65535), each endpoint `eth-a` ↔ a red leaf host port and `eth-b` ↔ a blue leaf host port. Grid coordinates per role (spines y=0, leaves y=200, endpoints y=400). + +**Build path** (respects verified server semantics): virtual devices + their vertices are created via the *topology app* (`create_virtual_device()` + `update_device` — vertices cannot be created through `updateTopology`, and virtual devices need no hardware); everything Inspect *owns* is then done through `app.inspect`: placement, connect (paired edges), edits, removals. Scenario object exposes `build(app)`, `teardown(app)` (delete by tag/prefix — edges first, then devices), `assert_clean(app)`. + +**Test list** (`test_e2e_leaf_spine.py`, ordered by dependency, session-scoped scenario fixture): +1. `test_build_and_skeleton_read` — build; skeleton snapshot sees all 10 devices with labels/coordinates; edge count == matrix size; **no** hydration fetches occurred (spy counter). +2. `test_lazy_hydration` — access `dev.ports` on one leaf → exactly one detail fetch; ports/vertex ids match scenario; other devices still skeleton. +3. `test_connectivity_graph` — `linked_devices` of each endpoint == its two leaves; leaf ↔ spine adjacency matches the matrix; `port.edge.to_device` round-trips. +4. `test_edge_pair_refresh` — `update_edge(weight=…)` on one uplink via direct write; assert commit result + snapshot pair record updated (targeted refresh) without full reload. +5. `test_transaction_atomicity` — tx with one valid edge edit + one `remove` of a non-existent id → `InspectCommitError`, nothing applied (weight unchanged on server). +6. `test_conflict_detection` — stage edit for edge E; mutate E out-of-band (second `VideoIPathApp` instance); `commit()` → `InspectCommitConflictError` listing the field; `rebase()` + commit succeeds. +7. `test_device_placement_roundtrip` — `place_device` new coordinates; re-snapshot skeleton shows them; revert. +8. `test_disconnect_connect_cycle` — disconnect one endpoint pair, assert gone from snapshot + collector; reconnect; assert identical to scenario matrix. +9. `test_full_vs_skeleton_equivalence` — `load="full"` snapshot and skeleton+preload produce identical device/port/edge index contents for scenario entities. +10. `test_teardown` — teardown; snapshot contains no `E2E-` devices/edges; runs also as autouse session-finalizer so aborted runs clean up. + +Never touched: non-`E2E-` devices, bookings/services (read-only asserts only), profiles/security sections. + +## 7. Risks & mitigations + +- **Connector `/...`-block relaxation** could affect existing callers → new opt-in parameter only; existing default behavior unchanged; unit test both. +- **Skeleton projection length vs. proxies** (414 verified for full UI projection) → pre-flight length guard + documented `load="full"` fallback. +- **Version drift** (all verification on 2025.4.9) → version gate + `get_registered_actions()` probe; concepts.md upgrade checklist already mandates re-verification; read DTOs `extra="allow"`. +- **Draft-code compatibility**: `InspectSnapshot.from_response()` exists in the draft — keep as alias of `from_full_response` (nothing published depends on it yet; package unreleased for inspect, so breaking the draft internals is acceptable). +- **Shared-instance E2E safety** → tag/prefix discipline, env gating, teardown-finalizer, no writes outside `E2E-` namespace (test 4/6 operate on scenario-owned edges only). + +## 8. Verification of the implementation + +- `poetry run pytest` (offline suite) green in CI. +- `poetry run pytest -m e2e tests/e2e/inspect` against the local 2025.4.9 instance (`tests/.env.test`) — full scenario build → tests → teardown leaves the instance in its pre-run state (asserted by test 10). +- `poetry run ruff check src/ tests/` clean. +- Manual smoke in DEV env: `app._inspect_api` exposure, DEBUG logs show skeleton (2 GETs) then exactly one hydration GET on first `ports` access. diff --git a/docs/inspect-app/models.md b/docs/inspect-app/models.md index 0ac0e20..6e5c999 100644 --- a/docs/inspect-app/models.md +++ b/docs/inspect-app/models.md @@ -13,6 +13,11 @@ The implementation uses two layers: `InspectEdge`, and `InspectService`. They are built from an `InspectSnapshot` and resolve relations from internal indexes instead of making extra HTTP calls. +`InspectSnapshot` is an **internal** component: `InspectApp` owns a single instance, +builds it lazily on the first read, and keeps it current across writes. Users never +construct or hold it — all reads and writes go through `app.inspect` (`get_device`, +`devices`, `edges`, `services`, `refresh`, …), the same way as the other apps. + Wire-shape examples and endpoint references live in [endpoints.md](./endpoints.md). All examples below are anonymized. @@ -41,8 +46,7 @@ internal state. The concrete queries are documented in Typical read flow: ```python -snapshot = app.inspect.get_snapshot() # skeleton fetch (devices + edges) -device = snapshot.get_device_by_id("device-a") # local: skeleton-backed +device = app.inspect.get_device("device-a") # loads the internal view lazily; skeleton-backed ports = device.ports # first access: hydrates device-a, then local edges = device.edges # local: edge skeleton services = device.services # first access: loads the paths section, then local @@ -178,8 +182,8 @@ Represents one topology device/node with status, sync hints, and relations. Lookup: ```python -device = snapshot.get_device_by_id("device-a") -matches = snapshot.find_devices_by_name("Example Device A") +device = app.inspect.get_device("device-a") +matches = app.inspect.find_devices_by_label("Example Device A") ``` ### `InspectPort` @@ -614,41 +618,52 @@ In Python these shapes live in `ngraph.py`. valid no-op. Non-empty maps upsert graph elements by ID, `addExternalEdges` adds edge objects, and `remove` deletes graph elements by ID. +The per-kind payload shapes differ (all verified 2025.4.9, +[endpoints.md](./endpoints.md#post-restv2actionsstatuscollectorupdatetopology)): +**devices use the edit form** (`lookupInspectDevice.fields`; `coordinates` and +`localAssignedTags` are mandatory — the raw persisted `baseDevice` element is +rejected), **vertices use the edit form** (`lookupInspectVertexById.fields`) +and are **update-only** (unknown ids fail validation — vertices come from +device sync, not commits), and **edges use the raw persisted edge form** +(`lookupInspectEdgesByIds` returns it directly). + ```json { "header": { "id": 0 }, "data": { "replaceDevices": { "device-a": { - "_id": "device-a", - "_vid": "device-a", - "type": "baseDevice", - "descriptor": { "label": "Example Device A", "desc": "" }, - "fDescriptor": { "label": "Example Device A", "desc": "" }, - "tags": [] - } - }, - "replaceVertices": { - "vertex-a-out": { - "_id": "vertex-a-out", - "_vid": "vertex-a-out", - "type": "ipVertex", - "deviceId": "device-a", - "descriptor": { "label": "Output Vertex", "desc": "" }, - "fDescriptor": { "label": "Output Vertex", "desc": "" }, - "tags": [] + "coordinates": { "x": 1600.0, "y": 9050.0 }, + "descriptor": { "label": "", "desc": "" }, + "iconSize": "medium", + "iconType": "default", + "localAssignedTags": [], + "sdpStrategy": "always", + "siteId": null, + "tags": [], + "virtualDeviceFields": null } }, + "replaceVertices": {}, "replaceEdges": { - "vertex-a-out::vertex-b-in": { - "_id": "edge-a-b-0001", - "_vid": "edge-a-b-0001", - "type": "unidirectionalEdge", - "fromId": "vertex-a-out", - "toId": "vertex-b-in", + "device-a.module-1.port-out-1.out::device-b.module-1.port-in-1.in": { + "active": true, + "bandwidth": -1.0, + "capacity": 65535, + "conflictPri": 0, "descriptor": { "label": "", "desc": "" }, + "excludeFormats": [], "fDescriptor": { "label": "", "desc": "" }, - "tags": [] + "fromId": "device-a.module-1.port-out-1.out", + "includeFormats": [], + "redundancyMode": "Any", + "tags": [], + "toId": "device-b.module-1.port-in-1.in", + "weight": 1, + "weightFactors": { + "bandwidth": { "weight": 0 }, + "service": { "max": 100, "weight": 0 } + } } }, "replaceResourceTransforms": {}, @@ -707,8 +722,9 @@ catches up with **targeted scoped re-reads** fetch timestamps; - loaded sections (e.g. services) are marked stale and re-load lazily on next access; -- the refresh verifies the committed values are visible and retries briefly if - the collector projection lags the config store. +- the collector projection updates effectively synchronously with the commit + (measured ~25 ms to visibility on 2025.4.9), so the targeted re-read doubles + as the verification — no retry loop. A failed commit changes nothing server-side (reject-before-apply), so the snapshot is left untouched. diff --git a/pyproject.toml b/pyproject.toml index cb03eae..9b0f3fd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -49,10 +49,12 @@ pytest-cov = "^7.1.0" pytest-dotenv = "^0.5.2" [tool.pytest.ini_options] -addopts = "-x -p no:warnings --cov-report=term --cov-report=term-missing --no-cov-on-fail --cov=src --ignore=__intern" +addopts = "-x -p no:warnings --cov-report=term --cov-report=term-missing --no-cov-on-fail --cov=src --ignore=__intern -m \"not e2e\"" env_override_existing_values = 0 env_files = ["tests/.env.test"] - +markers = [ + "e2e: developer-run tests against a live VideoIPath instance (gated on VIPAT_E2E_ENABLED=1; excluded by default). Run with '-m e2e'.", +] [virtualenvs] in-project = true diff --git a/src/videoipath_automation_tool/apps/inspect/__init__.py b/src/videoipath_automation_tool/apps/inspect/__init__.py index b4bc85c..57ca1c8 100644 --- a/src/videoipath_automation_tool/apps/inspect/__init__.py +++ b/src/videoipath_automation_tool/apps/inspect/__init__.py @@ -1,3 +1,10 @@ +from videoipath_automation_tool.apps.inspect.app.actions import ConflictStrategy as ConflictStrategy +from videoipath_automation_tool.apps.inspect.changeset import CommitResult as CommitResult +from videoipath_automation_tool.apps.inspect.changeset import InspectTransaction as InspectTransaction from videoipath_automation_tool.apps.inspect.domain import * +from videoipath_automation_tool.apps.inspect.errors import * +from videoipath_automation_tool.apps.inspect.inspect_app import InspectApp as InspectApp from videoipath_automation_tool.apps.inspect.model import * -from videoipath_automation_tool.apps.inspect.snapshot import InspectSnapshot as InspectSnapshot + +# InspectSnapshot is an internal implementation detail of InspectApp ([ADR-0007]); it is not part of +# the public API. Interact with the topology entirely through ``app.inspect``. diff --git a/src/videoipath_automation_tool/apps/inspect/app/__init__.py b/src/videoipath_automation_tool/apps/inspect/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/videoipath_automation_tool/apps/inspect/app/actions.py b/src/videoipath_automation_tool/apps/inspect/app/actions.py new file mode 100644 index 0000000..7464ba4 --- /dev/null +++ b/src/videoipath_automation_tool/apps/inspect/app/actions.py @@ -0,0 +1,97 @@ +"""Topology device/sync network actions (addDevices, syncDevices, lookupSyncInfo). + +These wrap the ``actions/status/network/*`` and ``lookupSyncInfo`` endpoints used by the Inspect +device-onboarding-into-topology workflows. Placement and connections themselves go through the +change set ([ADR-0006]); these actions handle bringing a device's driver-reported topology into +the graph and keeping it in sync. +""" + +from __future__ import annotations + +import logging +from enum import IntEnum +from typing import Iterable, Protocol, Union + +from videoipath_automation_tool.apps.inspect.inspect_api import InspectAPI +from videoipath_automation_tool.apps.inspect.model.actions import ( + InspectApiAddDevicesItem, + InspectApiLookupSyncInfoItem, +) + + +class ConflictStrategy(IntEnum): + """Conflict handling for ``syncDevices`` (values verified from the Inspect UI bundle).""" + + STRICT = 0 + INVALIDATE_SERVICES = 1 + CANCEL_SERVICES = 2 + + +# (device_id, x, y) or just device_id (placed at 0,0). +AddDeviceSpec = Union[str, tuple[str, float, float]] + + +class _HasInspectApi(Protocol): + _inspect_api: InspectAPI + _logger: logging.Logger + + +class InspectActionsMixin: + _inspect_api: InspectAPI + _logger: logging.Logger + + def get_sync_info(self: _HasInspectApi, device_ids: list[str]) -> dict[str, InspectApiLookupSyncInfoItem]: + """Per-device sync differences (what would be added/removed/updated on the next sync).""" + if not device_ids: + raise ValueError("device_ids must not be empty.") + return self._inspect_api.lookup_sync_info(device_ids).data + + def add_devices_to_topology(self: _HasInspectApi, devices: Iterable[AddDeviceSpec]) -> bool: + """Add onboarded devices to the topology graph (``addDevices`` network action). + + Args: + devices: device ids, or ``(device_id, x, y)`` tuples to place them. + + Returns: + bool: whether the action reported success (``data.ok``). + """ + items = [_to_add_item(spec) for spec in devices] + response = self._inspect_api.add_devices(items) + if not response.data.ok: + self._logger.warning(f"addDevices reported failure: {response.data.msg}") + return response.data.ok + + def sync_devices( + self: _HasInspectApi, + device_ids: list[str], + add_only: bool = True, + conflict_strategy: ConflictStrategy = ConflictStrategy.STRICT, + ) -> bool: + """Synchronize devices' driver-reported topology into the graph (``syncDevices`` action). + + Args: + device_ids: devices to synchronize. + add_only: only add new elements; do not remove/update existing ones. + conflict_strategy: how to handle conflicts with active services. + + Returns: + bool: whether the action reported success (``data.ok``). + """ + if not device_ids: + raise ValueError("device_ids must not be empty.") + response = self._inspect_api.sync_devices( + device_ids, add_only=add_only, conflict_strategy=int(conflict_strategy) + ) + if not response.data.ok: + self._logger.warning(f"syncDevices reported failure: {response.data.msg}") + return response.data.ok + + +def _to_add_item(spec: AddDeviceSpec) -> InspectApiAddDevicesItem: + if isinstance(spec, str): + return InspectApiAddDevicesItem(id=spec, x=0, y=0) + device_id, x, y = spec + return InspectApiAddDevicesItem(id=device_id, x=x, y=y) + + +__all__ = ["InspectActionsMixin", "ConflictStrategy", "AddDeviceSpec"] diff --git a/src/videoipath_automation_tool/apps/inspect/app/read.py b/src/videoipath_automation_tool/apps/inspect/app/read.py new file mode 100644 index 0000000..00bcdb2 --- /dev/null +++ b/src/videoipath_automation_tool/apps/inspect/app/read.py @@ -0,0 +1,134 @@ +"""Read-side user methods for the Inspect app. + +The Inspect app owns a single internal :class:`InspectSnapshot` ([ADR-0007]); users never handle it +directly. It is built lazily on the first read and reused (skeleton-first, then hydrated on demand). +Writes update it in place; :meth:`refresh` rebuilds it from the server. +""" + +from __future__ import annotations + +import logging +from concurrent.futures import ThreadPoolExecutor +from datetime import datetime +from typing import TYPE_CHECKING, Literal, Optional, Protocol + +from videoipath_automation_tool.apps.inspect.inspect_api import InspectAPI +from videoipath_automation_tool.apps.inspect.snapshot import InspectSnapshot + +if TYPE_CHECKING: + from videoipath_automation_tool.apps.inspect.domain.device import InspectDevice + from videoipath_automation_tool.apps.inspect.domain.edge import InspectEdge + from videoipath_automation_tool.apps.inspect.domain.service import InspectService + +LoadMode = Literal["skeleton", "full"] + + +class _HasInspectState(Protocol): + _inspect_api: InspectAPI + _logger: logging.Logger + _snapshot: Optional[InspectSnapshot] + _load_mode: LoadMode + + +class InspectReadMixin: + _inspect_api: InspectAPI + _logger: logging.Logger + _snapshot: Optional[InspectSnapshot] + _load_mode: LoadMode + + # --- Internal snapshot lifecycle --- + + def _get_snapshot(self: _HasInspectState) -> InspectSnapshot: + """Return the internal snapshot, building it on first access ([ADR-0007]).""" + if self._snapshot is None: + self._snapshot = self._load_snapshot(self._load_mode) + return self._snapshot + + def _load_snapshot(self: _HasInspectState, load: LoadMode) -> InspectSnapshot: + if load == "full": + self._logger.debug("Loading full (eager) Inspect snapshot.") + return InspectSnapshot.from_full_response( + self._inspect_api.get_collector_full(), fetcher=self._inspect_api + ) + self._logger.debug("Loading skeleton Inspect snapshot (devices + edges in parallel).") + with ThreadPoolExecutor(max_workers=2) as pool: + devices_future = pool.submit(self._inspect_api.get_device_skeleton) + edges_future = pool.submit(self._inspect_api.get_edge_skeleton) + devices = devices_future.result() + edges = edges_future.result() + return InspectSnapshot(fetcher=self._inspect_api, device_items=devices, edge_items=edges) + + def refresh(self: _HasInspectState, load: Optional[LoadMode] = None) -> None: + """Reload the topology from the server, discarding the current internal view. + + Args: + load: ``"skeleton"`` (fast; lazy detail) or ``"full"`` (eager, point-in-time). Defaults + to the mode the app was last using. + """ + if load is not None: + self._load_mode = load + self._snapshot = self._load_snapshot(self._load_mode) + + # --- Devices --- + + @property + def devices(self: _HasInspectState) -> list["InspectDevice"]: + """All devices in the topology (skeleton-backed; no per-device detail I/O).""" + return self._get_snapshot().devices + + def get_device(self: _HasInspectState, device_id: str) -> Optional["InspectDevice"]: + """A device by id, or ``None`` if it is not in the topology.""" + return self._get_snapshot().get_device(device_id) + + # Backwards-compatible alias. + get_device_by_id = get_device + + def find_device_by_label(self: _HasInspectState, label: str) -> Optional["InspectDevice"]: + """The first device whose (effective) label matches exactly, or ``None``.""" + return self._get_snapshot().find_device_by_label(label) + + def find_devices_by_label(self: _HasInspectState, label: str) -> list["InspectDevice"]: + """All devices whose (effective) label matches exactly.""" + return self._get_snapshot().find_devices_by_label(label) + + def find_device_id_by_label(self: _HasInspectState, label: str) -> Optional[str]: + """Resolve a device id from its display label.""" + device = self._get_snapshot().find_device_by_label(label) + return device.id if device is not None else None + + def preload(self: _HasInspectState, devices: Optional[list[str]] = None) -> None: + """Hydrate device detail for many devices in parallel (avoids N+1 on bulk detail access).""" + self._get_snapshot().preload(devices) + + def is_device_hydrated(self: _HasInspectState, device_id: str) -> bool: + """Whether a device's full detail (modules/ports) has been loaded.""" + return self._get_snapshot().is_device_hydrated(device_id) + + def fetched_at(self: _HasInspectState, device_id: str) -> Optional[datetime]: + """When the given device's current data was fetched (freshness introspection).""" + return self._get_snapshot().fetched_at(device_id) + + # --- Edges --- + + @property + def edges(self: _HasInspectState) -> list["InspectEdge"]: + """All external edges (device-pair connectivity).""" + return self._get_snapshot().edges + + # --- Services --- + + @property + def services(self: _HasInspectState) -> list["InspectService"]: + """All services/paths (loads the services section on first access).""" + return self._get_snapshot().services + + def get_service_by_booking_id(self: _HasInspectState, booking_id: str) -> Optional["InspectService"]: + """A service by its booking id, or ``None``.""" + return self._get_snapshot().get_service_by_booking_id(booking_id) + + def get_services_for_device(self: _HasInspectState, device_id: str) -> list["InspectService"]: + """All services whose path traverses the given device.""" + return self._get_snapshot().get_services_for_device(device_id) + + +__all__ = ["InspectReadMixin", "LoadMode"] diff --git a/src/videoipath_automation_tool/apps/inspect/app/write.py b/src/videoipath_automation_tool/apps/inspect/app/write.py new file mode 100644 index 0000000..cc39602 --- /dev/null +++ b/src/videoipath_automation_tool/apps/inspect/app/write.py @@ -0,0 +1,135 @@ +"""User-facing topology writes: direct auto-commit sugar + the explicit transaction ([ADR-0006]). + +Direct methods (``place_device``, ``update_device``, ``connect`` …) each open a single-change +transaction and commit it immediately. For batched, atomic changes use ``transaction()`` as a +context manager and call ``commit()`` explicitly. + +Every write is bound to the app's internal snapshot: on a successful commit the touched entities are +refreshed in place ([ADR-0010]) — but only if the snapshot has already been loaded, so a pure-write +workflow never triggers an unnecessary topology read. +""" + +from __future__ import annotations + +import logging +from typing import Any, Optional, Protocol + +from videoipath_automation_tool.apps.inspect.changeset import CommitResult, InspectTransaction +from videoipath_automation_tool.apps.inspect.inspect_api import InspectAPI +from videoipath_automation_tool.apps.inspect.snapshot import InspectSnapshot + + +class _HasInspectState(Protocol): + _inspect_api: InspectAPI + _logger: logging.Logger + _snapshot: Optional[InspectSnapshot] + + +class InspectWriteMixin: + _inspect_api: InspectAPI + _logger: logging.Logger + _snapshot: Optional[InspectSnapshot] + + def transaction(self: _HasInspectState) -> InspectTransaction: + """Open a batched, atomic change set bound to the app's internal snapshot.""" + return InspectTransaction(self._inspect_api, snapshot=self._snapshot, logger=self._logger) + + def place_device(self: _HasInspectState, device_id: str, x: float, y: float) -> CommitResult: + """Move a device to grid coordinates (single auto-committed change).""" + with self.transaction() as tx: + tx.place_device(device_id, x, y) + return tx.commit() + + def update_device( + self: _HasInspectState, + device_id: str, + *, + label: Optional[str] = None, + icon_type: Optional[str] = None, + sdp_strategy: Optional[str] = None, + tags: Optional[list[str]] = None, + coordinates: Optional[dict[str, float]] = None, + ) -> CommitResult: + """Edit a device's placement/appearance fields (single auto-committed change).""" + with self.transaction() as tx: + tx.update_device( + device_id, + label=label, + icon_type=icon_type, + sdp_strategy=sdp_strategy, + tags=tags, + coordinates=coordinates, + ) + return tx.commit() + + def update_vertex( + self: _HasInspectState, + vertex_id: str, + *, + use_as_endpoint: Optional[bool] = None, + label: Optional[str] = None, + tags: Optional[list[str]] = None, + ) -> CommitResult: + """Edit a vertex (single auto-committed change; update-only).""" + with self.transaction() as tx: + tx.update_vertex(vertex_id, use_as_endpoint=use_as_endpoint, label=label, tags=tags) + return tx.commit() + + def update_edge( + self: _HasInspectState, + edge_id: str, + *, + weight: Optional[int] = None, + capacity: Optional[int] = None, + bandwidth: Optional[float] = None, + redundancy_mode: Optional[str] = None, + active: Optional[bool] = None, + tags: Optional[list[str]] = None, + ) -> CommitResult: + """Edit an existing edge (single auto-committed change).""" + with self.transaction() as tx: + tx.update_edge( + edge_id, + weight=weight, + capacity=capacity, + bandwidth=bandwidth, + redundancy_mode=redundancy_mode, + active=active, + tags=tags, + ) + return tx.commit() + + def connect( + self: _HasInspectState, + from_vertex: str, + to_vertex: str, + *, + bidirectional: bool = True, + overwrite: bool = False, + **edge_fields: Any, + ) -> CommitResult: + """Create an edge (and its reverse if bidirectional) between two vertices.""" + with self.transaction() as tx: + tx.connect(from_vertex, to_vertex, bidirectional=bidirectional, overwrite=overwrite, **edge_fields) + return tx.commit() + + def disconnect( + self: _HasInspectState, + from_vertex: str, + to_vertex: str, + *, + bidirectional: bool = True, + ) -> CommitResult: + """Remove the edge (and its reverse if bidirectional) between two vertices.""" + with self.transaction() as tx: + tx.disconnect(from_vertex, to_vertex, bidirectional=bidirectional) + return tx.commit() + + def remove_device_from_topology(self: _HasInspectState, device_id: str) -> CommitResult: + """Remove a device (its baseDevice element) from the topology graph.""" + with self.transaction() as tx: + tx.remove_device(device_id) + return tx.commit() + + +__all__ = ["InspectWriteMixin"] diff --git a/src/videoipath_automation_tool/apps/inspect/changeset.py b/src/videoipath_automation_tool/apps/inspect/changeset.py new file mode 100644 index 0000000..6e3e7cd --- /dev/null +++ b/src/videoipath_automation_tool/apps/inspect/changeset.py @@ -0,0 +1,550 @@ +"""Change set / transaction for Inspect topology writes ([ADR-0006]/[ADR-0009]/[ADR-0010]). + +A transaction stages topology changes, captures a per-entity baseline via the lookup endpoints +(the lookup forms *are* the ``updateTopology`` write shapes — [ADR-0009]), and applies them +atomically on ``commit()``. Commit runs a client-side compare-and-commit conflict check against +freshly re-fetched baselines, then a single ``updateTopology`` POST, then a three-flag success +evaluation ([ADR-0006]). On success it drives a targeted snapshot refresh ([ADR-0010]) instead of +a full reload. + +Caller mutations are field-level *intents* recorded against the staged baseline and applied at +commit-build time, so ``rebase()`` can re-fetch baselines and re-apply the same intents. + +Verified server facts encoded here (2025.4.9): +- ``replaceDevices`` / ``replaceVertices`` take the lookup *edit form*; ``replaceVertices`` is + update-only (vertices cannot be created via ``updateTopology``). +- ``descriptor`` is *mandatory* in the device edit form, so it is always round-tripped from the + baseline; ``descriptor.label`` is only changed when the caller sets a label explicitly (the + Inspect UI has the same behaviour — the persisted descriptor is not distinguishable from the + effective one on the collector surface). +- ``replaceEdges`` takes the raw persisted edge form; there is no ``_rev`` anywhere (last-writer-wins). +- Apply is reject-before-apply (all-or-nothing), so a detected conflict aborts the whole commit. +""" + +from __future__ import annotations + +import logging +from dataclasses import dataclass, field +from typing import TYPE_CHECKING, Any, Optional + +from videoipath_automation_tool.apps.inspect.errors import ( + InspectCommitConflictError, + InspectCommitError, + InspectConflict, + InspectEntityNotFoundError, +) +from videoipath_automation_tool.apps.inspect.model.actions import ( + InspectApiEdgeForm, + InspectApiLookupInspectDeviceFields, + InspectApiVertexEditForm, +) +from videoipath_automation_tool.apps.inspect.model.update_topology import ( + InspectApiUpdateTopologyData, + InspectApiUpdateTopologyResponse, +) + +if TYPE_CHECKING: + from videoipath_automation_tool.apps.inspect.inspect_api import InspectAPI + from videoipath_automation_tool.apps.inspect.snapshot import InspectSnapshot + +# Staged-entry kinds. +_DEVICE = "device" +_VERTEX = "vertex" +_EDGE = "edge" + + +@dataclass(frozen=True) +class CommitResult: + """Outcome of a successful commit ([ADR-0006]); a failed commit raises ``InspectCommitError``.""" + + applied_ids: list[str] + created_ids: list[str] + response: InspectApiUpdateTopologyResponse + + @property + def ok(self) -> bool: + return True + + @property + def validation(self) -> Any: + return self.response.data.validation + + +@dataclass +class _Staged: + kind: str + entity_id: str + # The write-shape baseline (edit/edge form) as fetched at stage time; None for a raw remove. + baseline_form: Any | None = None + # JSON dump of the baseline at stage time, used for the compare-and-commit conflict check. + baseline_dump: dict[str, Any] | None = None + # Field-level intents (wire field names; dotted for one level of nesting, e.g. "descriptor.label"). + intents: dict[str, Any] = field(default_factory=dict) + remove: bool = False + is_new: bool = False + + +def _device_of(entity_id: str) -> str: + """Owning device id of a vertex/port id (``device12.1.Ethernet1.out`` -> ``device12``).""" + return entity_id.split(".", 1)[0] + + +def _reverse_vertex(vertex_id: str) -> str: + """Flip the trailing direction of an IP vertex id (``.out`` <-> ``.in``).""" + if vertex_id.endswith(".out"): + return vertex_id[: -len(".out")] + ".in" + if vertex_id.endswith(".in"): + return vertex_id[: -len(".in")] + ".out" + return vertex_id + + +def _edge_key(from_id: str, to_id: str) -> str: + return f"{from_id}::{to_id}" + + +def _apply_intents(form: Any, intents: dict[str, Any]) -> None: + for key, value in intents.items(): + if "." in key: + head, tail = key.split(".", 1) + setattr(getattr(form, head), tail, value) + else: + setattr(form, key, value) + + +class InspectTransaction: + """Single-use, atomic batch of Inspect topology changes. + + Stage changes with the ``place_device`` / ``update_*`` / ``connect`` / ``disconnect`` / ``remove`` + methods, then call ``commit()``. The transaction cannot be reused after commit or discard; use + it as a context manager to guarantee cleanup (exit without commit discards and logs a warning). + """ + + def __init__( + self, + api: "InspectAPI", + snapshot: Optional["InspectSnapshot"] = None, + logger: Optional[logging.Logger] = None, + ) -> None: + self._api = api + self._snapshot = snapshot + self._logger = logger or logging.getLogger("videoipath_automation_tool_inspect_txn") + self._entries: dict[tuple[str, str], _Staged] = {} + self._committed = False + self._discarded = False + + # --- Context manager --- + + def __enter__(self) -> "InspectTransaction": + return self + + def __exit__(self, exc_type: Any, exc: Any, tb: Any) -> None: + if exc_type is not None: + self.discard() + return + if not self._committed and not self._discarded: + if self._entries: + self._logger.warning( + "Inspect transaction exited with %d staged change(s) and no commit(); discarding.", + len(self._entries), + ) + self.discard() + + # --- Introspection --- + + @property + def staged(self) -> list[tuple[str, str]]: + return list(self._entries) + + def __len__(self) -> int: + return len(self._entries) + + # --- Staging: devices --- + + def place_device(self, device_id: str, x: float, y: float) -> "InspectTransaction": + """Move a device to grid coordinates (``replaceDevices``).""" + entry = self._stage_device(device_id) + entry.intents["coordinates"] = {"x": x, "y": y} + return self + + def update_device( + self, + device_id: str, + *, + label: Optional[str] = None, + icon_type: Optional[str] = None, + sdp_strategy: Optional[str] = None, + tags: Optional[list[str]] = None, + coordinates: Optional[dict[str, float]] = None, + ) -> "InspectTransaction": + """Edit a device's placement/appearance fields (``replaceDevices``). + + ``descriptor`` is always round-tripped from the baseline (it is mandatory server-side); + ``label`` is applied to ``descriptor.label`` only when set here. + """ + entry = self._stage_device(device_id) + if label is not None: + entry.intents["descriptor.label"] = label + if icon_type is not None: + entry.intents["iconType"] = icon_type + if sdp_strategy is not None: + entry.intents["sdpStrategy"] = sdp_strategy + if tags is not None: + entry.intents["tags"] = list(tags) + if coordinates is not None: + entry.intents["coordinates"] = dict(coordinates) + return self + + # --- Staging: vertices --- + + def update_vertex( + self, + vertex_id: str, + *, + use_as_endpoint: Optional[bool] = None, + label: Optional[str] = None, + tags: Optional[list[str]] = None, + ) -> "InspectTransaction": + """Edit a vertex (``replaceVertices``; update-only — vertices cannot be created here).""" + entry = self._stage_vertex(vertex_id) + if use_as_endpoint is not None: + entry.intents["useAsEndpoint"] = use_as_endpoint + if label is not None: + entry.intents["label"] = label + if tags is not None: + entry.intents["tags"] = list(tags) + return self + + # --- Staging: edges --- + + def update_edge( + self, + edge_id: str, + *, + weight: Optional[int] = None, + capacity: Optional[int] = None, + bandwidth: Optional[float] = None, + redundancy_mode: Optional[str] = None, + active: Optional[bool] = None, + tags: Optional[list[str]] = None, + ) -> "InspectTransaction": + """Edit an existing edge (``replaceEdges``).""" + entry = self._stage_edge(edge_id) + if weight is not None: + entry.intents["weight"] = weight + if capacity is not None: + entry.intents["capacity"] = capacity + if bandwidth is not None: + entry.intents["bandwidth"] = bandwidth + if redundancy_mode is not None: + entry.intents["redundancyMode"] = redundancy_mode + if active is not None: + entry.intents["active"] = active + if tags is not None: + entry.intents["tags"] = list(tags) + return self + + def connect( + self, + from_vertex: str, + to_vertex: str, + *, + bidirectional: bool = True, + overwrite: bool = False, + **edge_fields: Any, + ) -> "InspectTransaction": + """Create an edge from an out-vertex to an in-vertex (``replaceEdges``). + + With ``bidirectional`` (default) the reverse edge (``to``'s out -> ``from``'s in) is staged + too. Set ``overwrite=True`` to replace an edge that already exists. ``edge_fields`` override + the persisted-edge defaults (e.g. ``weight``, ``capacity``, ``bandwidth``, ``redundancyMode``). + """ + self._stage_new_edge(from_vertex, to_vertex, overwrite=overwrite, edge_fields=edge_fields) + if bidirectional: + self._stage_new_edge( + _reverse_vertex(to_vertex), _reverse_vertex(from_vertex), overwrite=overwrite, edge_fields=edge_fields + ) + return self + + def disconnect(self, from_vertex: str, to_vertex: str, *, bidirectional: bool = True) -> "InspectTransaction": + """Remove the edge from ``from_vertex`` to ``to_vertex`` (and its reverse if bidirectional).""" + self.remove(_edge_key(from_vertex, to_vertex)) + if bidirectional: + self.remove(_edge_key(_reverse_vertex(to_vertex), _reverse_vertex(from_vertex))) + return self + + # --- Staging: removals --- + + def remove(self, entity_id: str) -> "InspectTransaction": + """Remove any entity by id (device / vertex / edge key). Last-writer-wins (no conflict check).""" + self._ensure_open() + kind = _EDGE if "::" in entity_id else (_VERTEX if "." in entity_id else _DEVICE) + self._entries[(kind, entity_id)] = _Staged(kind=kind, entity_id=entity_id, remove=True) + return self + + def remove_device(self, device_id: str) -> "InspectTransaction": + """Remove a device (its baseDevice element) from the topology graph.""" + return self.remove(device_id) + + # --- Commit lifecycle --- + + def commit(self, check_conflicts: bool = True) -> CommitResult: + """Validate, send, and (on success) refresh. Raises on conflict or server rejection. + + Raises: + InspectCommitConflictError: a staged entity changed on the server since staging. + InspectCommitError: the server rejected the commit (validation or apply gate). + """ + self._ensure_open() + if not self._entries: + raise ValueError("Nothing staged; commit aborted.") + + if check_conflicts: + self._check_conflicts() + + delta = self._build_delta() + response = self._api.update_topology(delta) + if not response.committed: + raise InspectCommitError(response) + + self._committed = True + applied_ids = [e.entity_id for e in self._entries.values()] + result = CommitResult( + applied_ids=applied_ids, + created_ids=list(response.data.validation.createIds), + response=response, + ) + self._refresh_snapshot() + self._logger.debug("Inspect commit applied %d change(s): %s", len(applied_ids), applied_ids) + return result + + def rebase(self) -> "InspectTransaction": + """Re-fetch baselines for all staged entities, keeping the recorded intents. + + Use after ``InspectCommitConflictError`` to move the staged changes onto current server + state; then ``commit()`` again. Intents that themselves target a concurrently-changed field + will overwrite that change (last-writer-wins for the intent). + """ + self._ensure_open() + for entry in self._entries.values(): + if entry.remove or entry.is_new or entry.baseline_form is None: + continue + fresh = self._fetch_baseline(entry.kind, entry.entity_id) + entry.baseline_form = fresh + entry.baseline_dump = fresh.model_dump(mode="json") + return self + + def discard(self) -> None: + """Drop all staged changes; the transaction can no longer be committed.""" + self._entries.clear() + self._discarded = True + + # --- Internal: staging helpers --- + + def _ensure_open(self) -> None: + if self._committed: + raise RuntimeError("This transaction was already committed; open a new one.") + if self._discarded: + raise RuntimeError("This transaction was discarded; open a new one.") + + def _stage_device(self, device_id: str) -> _Staged: + return self._stage(_DEVICE, device_id) + + def _stage_vertex(self, vertex_id: str) -> _Staged: + return self._stage(_VERTEX, vertex_id) + + def _stage_edge(self, edge_id: str) -> _Staged: + return self._stage(_EDGE, edge_id) + + def _stage(self, kind: str, entity_id: str) -> _Staged: + self._ensure_open() + key = (kind, entity_id) + existing = self._entries.get(key) + if existing is not None and not existing.remove: + return existing + baseline = self._fetch_baseline(kind, entity_id) + entry = _Staged( + kind=kind, + entity_id=entity_id, + baseline_form=baseline, + baseline_dump=baseline.model_dump(mode="json"), + ) + self._entries[key] = entry + return entry + + def _stage_new_edge( + self, from_vertex: str, to_vertex: str, *, overwrite: bool, edge_fields: dict[str, Any] + ) -> None: + self._ensure_open() + edge_id = _edge_key(from_vertex, to_vertex) + existing = self._lookup_edge_form(edge_id) + if existing is not None and not overwrite: + raise ValueError( + f"Edge '{edge_id}' already exists; pass overwrite=True to replace it." + ) + form = existing.model_copy(deep=True) if existing is not None else InspectApiEdgeForm(fromId=from_vertex, toId=to_vertex) + form.fromId = from_vertex + form.toId = to_vertex + entry = _Staged( + kind=_EDGE, + entity_id=edge_id, + baseline_form=form, + baseline_dump=None if existing is None else existing.model_dump(mode="json"), + intents=dict(edge_fields), + is_new=existing is None, + ) + self._entries[(_EDGE, edge_id)] = entry + + # --- Internal: baselines --- + + def _fetch_baseline(self, kind: str, entity_id: str) -> Any: + if kind == _DEVICE: + return self._lookup_device_form(entity_id, required=True) + if kind == _VERTEX: + return self._lookup_vertex_form(entity_id, required=True) + form = self._lookup_edge_form(entity_id) + if form is None: + raise InspectEntityNotFoundError(entity_id, kind="edge") + return form + + def _lookup_device_form(self, device_id: str, required: bool) -> Optional[InspectApiLookupInspectDeviceFields]: + try: + response = self._api.lookup_inspect_device(device_id) + except Exception as exc: # connector-level miss + if required: + raise InspectEntityNotFoundError(device_id, kind="device") from exc + return None + return response.data.fields + + def _lookup_vertex_form(self, vertex_id: str, required: bool) -> Optional[InspectApiVertexEditForm]: + response = self._api.lookup_vertices([vertex_id]) + item = response.data.get(vertex_id) + if item is None: + if required: + raise InspectEntityNotFoundError(vertex_id, kind="vertex") + return None + return item.fields + + def _lookup_edge_form(self, edge_id: str) -> Optional[InspectApiEdgeForm]: + response = self._api.lookup_edges([edge_id]) + item = response.data.get(edge_id) + return item.edge if item is not None else None + + # --- Internal: conflict check (compare-and-commit, ADR-0009) --- + + def _check_conflicts(self) -> None: + current = self._refetch_baselines() + conflicts: list[InspectConflict] = [] + for entry in self._entries.values(): + if entry.remove or entry.is_new or entry.baseline_dump is None: + continue + key = (entry.kind, entry.entity_id) + server_form = current.get(key) + if server_form is None: + conflicts.append( + InspectConflict(entry.entity_id, entry.kind, {"__exists__": (True, False)}) + ) + continue + server_dump = server_form.model_dump(mode="json") + if server_dump != entry.baseline_dump: + diffs = _field_diffs(entry.baseline_dump, server_dump) + conflicts.append(InspectConflict(entry.entity_id, entry.kind, diffs)) + if conflicts: + raise InspectCommitConflictError(conflicts) + + def _refetch_baselines(self) -> dict[tuple[str, str], Any]: + """Batched re-fetch of every conflict-checkable staged entity's current server form.""" + vertex_ids = [e.entity_id for e in self._entries.values() if e.kind == _VERTEX and _checkable(e)] + edge_ids = [e.entity_id for e in self._entries.values() if e.kind == _EDGE and _checkable(e)] + device_ids = [e.entity_id for e in self._entries.values() if e.kind == _DEVICE and _checkable(e)] + + current: dict[tuple[str, str], Any] = {} + if vertex_ids: + data = self._api.lookup_vertices(vertex_ids).data + for vid in vertex_ids: + item = data.get(vid) + if item is not None: + current[(_VERTEX, vid)] = item.fields + if edge_ids: + data = self._api.lookup_edges(edge_ids).data + for eid in edge_ids: + item = data.get(eid) + if item is not None: + current[(_EDGE, eid)] = item.edge + for did in device_ids: + form = self._lookup_device_form(did, required=False) + if form is not None: + current[(_DEVICE, did)] = form + return current + + # --- Internal: payload build --- + + def _build_delta(self) -> InspectApiUpdateTopologyData: + delta = InspectApiUpdateTopologyData() + for entry in self._entries.values(): + if entry.remove: + delta.remove.append(entry.entity_id) + continue + form = entry.baseline_form.model_copy(deep=True) + _apply_intents(form, entry.intents) + if entry.kind == _DEVICE: + delta.replaceDevices[entry.entity_id] = form + elif entry.kind == _VERTEX: + delta.replaceVertices[entry.entity_id] = form + else: + delta.replaceEdges[entry.entity_id] = form + return delta + + # --- Internal: post-commit targeted refresh (ADR-0010) --- + + def _refresh_snapshot(self) -> None: + if self._snapshot is None: + return + removed_ids: list[str] = [] + device_ids: set[str] = set() + pair_ids: set[str] = set() + for entry in self._entries.values(): + if entry.remove: + removed_ids.append(entry.entity_id) + if entry.kind == _EDGE: + pair_ids.update(_pair_ids_for_edge(entry.entity_id)) + continue + if entry.kind == _DEVICE: + device_ids.add(entry.entity_id) + elif entry.kind == _VERTEX: + device_ids.add(_device_of(entry.entity_id)) + else: + pair_ids.update(_pair_ids_for_edge(entry.entity_id)) + self._snapshot.apply_post_commit( + removed_ids=removed_ids, + device_ids=list(device_ids), + pair_ids=list(pair_ids), + mark_paths_stale=True, + ) + + +def _checkable(entry: _Staged) -> bool: + return not entry.remove and not entry.is_new and entry.baseline_dump is not None + + +def _field_diffs(baseline: dict[str, Any], current: dict[str, Any]) -> dict[str, tuple[object, object]]: + diffs: dict[str, tuple[object, object]] = {} + for key in set(baseline) | set(current): + before = baseline.get(key) + after = current.get(key) + if before != after: + diffs[key] = (before, after) + return diffs + + +def _pair_ids_for_edge(edge_id: str) -> tuple[str, ...]: + """Both possible collector pair-key orderings for an edge (one is a no-op on refresh).""" + if "::" not in edge_id: + return () + from_id, to_id = edge_id.split("::", 1) + dev_a, dev_b = _device_of(from_id), _device_of(to_id) + if dev_a == dev_b: + return (f"{dev_a}::{dev_b}",) + return (f"{dev_a}::{dev_b}", f"{dev_b}::{dev_a}") + + +__all__ = ["InspectTransaction", "CommitResult"] diff --git a/src/videoipath_automation_tool/apps/inspect/domain/device.py b/src/videoipath_automation_tool/apps/inspect/domain/device.py index 3b49ce2..b395829 100644 --- a/src/videoipath_automation_tool/apps/inspect/domain/device.py +++ b/src/videoipath_automation_tool/apps/inspect/domain/device.py @@ -1,58 +1,75 @@ from __future__ import annotations from dataclasses import dataclass +from datetime import datetime from typing import TYPE_CHECKING from videoipath_automation_tool.apps.inspect.model.common import InspectApiStatusSummary -from videoipath_automation_tool.apps.inspect.snapshot import _DeviceRecord if TYPE_CHECKING: from videoipath_automation_tool.apps.inspect.domain.edge import InspectEdge from videoipath_automation_tool.apps.inspect.domain.port import InspectPort from videoipath_automation_tool.apps.inspect.domain.service import InspectService - from videoipath_automation_tool.apps.inspect.snapshot import InspectSnapshot + from videoipath_automation_tool.apps.inspect.snapshot import _DeviceRecord, InspectSnapshot @dataclass(frozen=True, slots=True) class InspectDevice: + """A topology device/node. Skeleton fields (id, label, coordinates, status, sync, tags) are + available immediately; ``ports`` and ``services`` lazily hydrate from the server on first + access ([ADR-0007]). The record is resolved live from the snapshot, so a held reference sees + hydrated/refreshed data transparently.""" + snapshot: InspectSnapshot - record: _DeviceRecord + id: str - @property - def id(self) -> str: - return self.record.device_id + def _record(self) -> "_DeviceRecord": + record = self.snapshot.get_device_record(self.id) + if record is None: + raise KeyError(f"Device '{self.id}' is no longer present in the snapshot.") + return record @property def label(self) -> str | None: - return self.record.label + return self._record().label @property def pid(self) -> str | None: - return self.record.pid + return self._record().pid + + @property + def is_virtual(self) -> bool | None: + meta = self._record().node.meta + return meta.isVirtual if meta is not None else None + + @property + def icon_type(self) -> str | None: + meta = self._record().node.meta + return meta.iconType if meta is not None else None @property def status(self) -> InspectApiStatusSummary | None: - if self.record.node is None: - return None - return self.record.node.status + return self._record().node.status @property def sync_severity(self) -> int | str | None: - if self.record.node is None: - return None - return self.record.node.syncSeverity + return self._record().node.syncSeverity @property def tags(self) -> tuple[str, ...]: - if self.record.node is None: - return () - return tuple(self.record.node.tags) + return tuple(self._record().node.tags) @property def coordinates(self) -> dict[str, float | int | str | None] | None: - if self.record.node is None or self.record.node.meta is None: - return None - return self.record.node.meta.coordinates + return self._record().node.coordinates + + @property + def is_hydrated(self) -> bool: + return self.snapshot.is_device_hydrated(self.id) + + @property + def fetched_at(self) -> datetime | None: + return self.snapshot.fetched_at(self.id) @property def ports(self) -> list[InspectPort]: diff --git a/src/videoipath_automation_tool/apps/inspect/domain/edge.py b/src/videoipath_automation_tool/apps/inspect/domain/edge.py index 60d94cb..3186b37 100644 --- a/src/videoipath_automation_tool/apps/inspect/domain/edge.py +++ b/src/videoipath_automation_tool/apps/inspect/domain/edge.py @@ -60,7 +60,9 @@ def max_bandwidth(self) -> float | int | None: @property def status(self) -> InspectApiExternalEdgeLiveStatus | None: - return self.indexed.edge.status + """Live status for this edge. In the lean skeleton only the pair-level status is present; + the per-edge status (per direction) appears in the full edge shape.""" + return self.indexed.edge.status or self.indexed.pair_status @property def services(self) -> list[InspectService]: diff --git a/src/videoipath_automation_tool/apps/inspect/domain/port.py b/src/videoipath_automation_tool/apps/inspect/domain/port.py index 5120e6a..7260d59 100644 --- a/src/videoipath_automation_tool/apps/inspect/domain/port.py +++ b/src/videoipath_automation_tool/apps/inspect/domain/port.py @@ -27,7 +27,7 @@ def id(self) -> str | None: @property def label(self) -> str | None: - return self.indexed.port.label + return self.indexed.port.effective_label @property def device(self) -> InspectDevice | None: diff --git a/src/videoipath_automation_tool/apps/inspect/errors.py b/src/videoipath_automation_tool/apps/inspect/errors.py new file mode 100644 index 0000000..3ac0682 --- /dev/null +++ b/src/videoipath_automation_tool/apps/inspect/errors.py @@ -0,0 +1,103 @@ +"""Typed exceptions for the Inspect app. + +The Inspect surface has semantics that a raw HTTP envelope cannot express: +commit success is a three-flag check (see [ADR-0006]), concurrent writes are +detected client-side (see [ADR-0009]), and over-long projection URLs are +rejected by the proxy before they reach the server. These exceptions carry the +structured detail callers need to react without parsing raw responses. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from videoipath_automation_tool.apps.inspect.model.update_topology import ( + InspectApiUpdateTopologyResponse, + ) + + +class InspectError(Exception): + """Base class for all Inspect app errors.""" + + +class InspectEntityNotFoundError(InspectError): + """A device, vertex, or edge referenced by a read or write does not exist on the server.""" + + def __init__(self, entity_id: str, kind: str = "entity") -> None: + self.entity_id = entity_id + self.kind = kind + super().__init__(f"Inspect {kind} '{entity_id}' was not found on the server.") + + +class InspectQueryTooLongError(InspectError): + """A scoped collector query URL exceeds the server/proxy URI length limit (HTTP 414). + + The Inspect package trims its skeleton projections to stay within the limit; this is + raised only if a caller-built query is too long. Fall back to ``refresh(load="full")``. + """ + + def __init__(self, length: int, limit: int) -> None: + self.length = length + self.limit = limit + super().__init__( + f"Collector query URL is {length} characters, exceeding the {limit}-character limit. " + f"Use a shorter projection or 'app.inspect.refresh(load=\"full\")'." + ) + + +class InspectCommitError(InspectError): + """An ``updateTopology`` commit failed (validation gate or apply gate). + + HTTP/envelope success is not commit success: the server can return ``header.ok == true`` + while ``data.res.ok`` or ``data.validation.result.ok`` is ``false``. This carries the full + typed response so callers can inspect ``validation.details`` and ``res.msg``. + """ + + def __init__(self, response: "InspectApiUpdateTopologyResponse") -> None: + self.response = response + self.result = response.data.res + self.validation = response.data.validation + messages = list(self.result.msg) + list(self.validation.result.msg) + detail = "; ".join(m for m in messages if m) or "commit rejected by the server" + super().__init__(f"Inspect commit failed: {detail}") + + +class InspectConflict: + """One entity whose server state changed between staging and commit (see [ADR-0009]).""" + + def __init__(self, entity_id: str, kind: str, field_diffs: dict[str, tuple[object, object]]) -> None: + self.entity_id = entity_id + self.kind = kind + # field -> (staged_baseline_value, current_server_value) + self.field_diffs = field_diffs + + def __repr__(self) -> str: + return f"InspectConflict(entity_id={self.entity_id!r}, kind={self.kind!r}, fields={list(self.field_diffs)})" + + +class InspectCommitConflictError(InspectError): + """A concurrent modification was detected before the commit was sent; nothing was written. + + The commit is aborted as a whole (matching the server's all-or-nothing apply). Callers can + inspect ``conflicts``, then either ``transaction.rebase()`` onto fresh state and retry, or + re-commit with ``check_conflicts=False`` to force last-writer-wins. + """ + + def __init__(self, conflicts: list[InspectConflict]) -> None: + self.conflicts = conflicts + ids = ", ".join(c.entity_id for c in conflicts) + super().__init__( + f"Concurrent modification detected for {len(conflicts)} entity(ies) [{ids}]; commit aborted. " + f"Rebase the transaction or commit with check_conflicts=False to override." + ) + + +__all__ = [ + "InspectError", + "InspectEntityNotFoundError", + "InspectQueryTooLongError", + "InspectCommitError", + "InspectConflict", + "InspectCommitConflictError", +] diff --git a/src/videoipath_automation_tool/apps/inspect/inspect_api.py b/src/videoipath_automation_tool/apps/inspect/inspect_api.py new file mode 100644 index 0000000..eafed14 --- /dev/null +++ b/src/videoipath_automation_tool/apps/inspect/inspect_api.py @@ -0,0 +1,166 @@ +"""Raw Inspect API layer: one method per verified endpoint, typed responses, no business logic. + +All reads use the collector namespace only ([ADR-0008]); scoped queries come from +``queries.py`` ([ADR-0007]). Writes go through ``updateTopology`` and the ``addDevices`` / +``syncDevices`` network actions. +""" + +from __future__ import annotations + +import logging +from typing import Any, Optional + +from videoipath_automation_tool.apps.inspect import queries +from videoipath_automation_tool.apps.inspect.model.actions import ( + InspectApiAddDevicesItem, + InspectApiAddDevicesRequest, + InspectApiLookupEdgesRequest, + InspectApiLookupEdgesResponse, + InspectApiLookupInspectDeviceRequest, + InspectApiLookupInspectDeviceResponse, + InspectApiLookupSyncInfoRequest, + InspectApiLookupSyncInfoResponse, + InspectApiLookupVerticesRequest, + InspectApiLookupVerticesResponse, + InspectApiSyncDevicesRequest, + InspectApiSyncDevicesRequestData, +) +from videoipath_automation_tool.apps.inspect.model.collector import ( + InspectApiCollectorResponse, + InspectApiExternalEdgesByDeviceKeyItem, + InspectApiNodeStatusItem, + InspectApiPathItem, +) +from videoipath_automation_tool.apps.inspect.model.common import ( + InspectApiSimpleActionResponse, +) +from videoipath_automation_tool.apps.inspect.model.update_topology import ( + InspectApiUpdateTopologyData, + InspectApiUpdateTopologyRequest, + InspectApiUpdateTopologyResponse, +) +from videoipath_automation_tool.connector.vip_connector import VideoIPathConnector +from videoipath_automation_tool.utils.cross_app_utils import create_fallback_logger + + +def _extract_items(data: dict[str, Any], *path: str) -> list[dict[str, Any]]: + """Walk ``data`` down ``path`` and return the ``_items`` list (empty if any node is absent).""" + node: Any = data + for key in path: + if not isinstance(node, dict): + return [] + node = node.get(key) + if node is None: + return [] + if isinstance(node, dict): + items = node.get("_items", []) + return items if isinstance(items, list) else [] + return [] + + +class InspectAPI: + def __init__(self, vip_connector: VideoIPathConnector, logger: Optional[logging.Logger] = None): + self._logger = logger or create_fallback_logger("videoipath_automation_tool_inspect_api") + self.vip_connector = vip_connector + self._logger.debug("Inspect API initialized.") + + # --- Collector reads (scoped, ADR-0007) --- + + def get_device_skeleton(self) -> list[InspectApiNodeStatusItem]: + """All devices without module/port detail (skeleton load).""" + response = self.vip_connector.rest.get(queries.device_skeleton(), allow_projection=True) + items = _extract_items(response.data, "status", "collector", "inspect", "nodeStatus") + return [InspectApiNodeStatusItem.model_validate(item) for item in items] + + def get_device_detail(self, device_id: str) -> Optional[InspectApiNodeStatusItem]: + """One device's full nodeStatus sub-tree (lazy hydration).""" + response = self.vip_connector.rest.get(queries.device_detail(device_id), allow_projection=True) + items = _extract_items(response.data, "status", "collector", "inspect", "nodeStatus") + if not items: + return None + return InspectApiNodeStatusItem.model_validate(items[0]) + + def get_edge_skeleton(self) -> list[InspectApiExternalEdgesByDeviceKeyItem]: + """All external-edge device pairs, lean projection.""" + response = self.vip_connector.rest.get(queries.edge_skeleton(), allow_projection=True) + items = _extract_items(response.data, "status", "collector", "externalEdgesByDeviceKey") + return [InspectApiExternalEdgesByDeviceKeyItem.model_validate(item) for item in items] + + def get_edge_pair(self, pair_id: str) -> Optional[InspectApiExternalEdgesByDeviceKeyItem]: + """A single external-edge device pair (targeted refresh, ADR-0010).""" + response = self.vip_connector.rest.get(queries.edge_pair(pair_id), allow_projection=True) + items = _extract_items(response.data, "status", "collector", "externalEdgesByDeviceKey") + if not items: + return None + return InspectApiExternalEdgesByDeviceKeyItem.model_validate(items[0]) + + def get_paths_section(self) -> list[InspectApiPathItem]: + """The services/paths section.""" + response = self.vip_connector.rest.get(queries.paths_section(), allow_projection=True) + items = _extract_items(response.data, "status", "collector", "inspect", "paths") + return [InspectApiPathItem.model_validate(item) for item in items] + + def get_collector_full(self) -> InspectApiCollectorResponse: + """The full collector aggregate (eager / fallback mode).""" + response = self.vip_connector.rest.get(queries.collector_full(), allow_projection=True) + return InspectApiCollectorResponse.model_validate({"data": response.data, "header": _header_dict(response)}) + + # --- Lookups (baselines for compare-and-commit, ADR-0009) --- + + def lookup_inspect_device(self, device_id: str) -> InspectApiLookupInspectDeviceResponse: + request = InspectApiLookupInspectDeviceRequest(data=device_id) + response = self.vip_connector.rest.post("/rest/v2/actions/status/collector/lookupInspectDevice", request) + return InspectApiLookupInspectDeviceResponse.model_validate(_post_envelope(response)) + + def lookup_vertices(self, vertex_ids: list[str]) -> InspectApiLookupVerticesResponse: + request = InspectApiLookupVerticesRequest(data=vertex_ids) + response = self.vip_connector.rest.post("/rest/v2/actions/status/collector/lookupInspectVertexByIds", request) + return InspectApiLookupVerticesResponse.model_validate(_post_envelope(response)) + + def lookup_edges(self, edge_ids: list[str]) -> InspectApiLookupEdgesResponse: + request = InspectApiLookupEdgesRequest(data=edge_ids) + response = self.vip_connector.rest.post("/rest/v2/actions/status/collector/lookupInspectEdgesByIds", request) + return InspectApiLookupEdgesResponse.model_validate(_post_envelope(response)) + + def lookup_sync_info(self, device_ids: list[str]) -> InspectApiLookupSyncInfoResponse: + request = InspectApiLookupSyncInfoRequest(data=device_ids) + response = self.vip_connector.rest.post("/rest/v2/actions/status/collector/lookupSyncInfo", request) + return InspectApiLookupSyncInfoResponse.model_validate(_post_envelope(response)) + + # --- Writes --- + + def update_topology(self, delta: InspectApiUpdateTopologyData) -> InspectApiUpdateTopologyResponse: + request = InspectApiUpdateTopologyRequest(data=delta) + response = self.vip_connector.rest.post("/rest/v2/actions/status/collector/updateTopology", request) + return InspectApiUpdateTopologyResponse.model_validate(_post_envelope(response)) + + def add_devices(self, items: list[InspectApiAddDevicesItem]) -> InspectApiSimpleActionResponse: + request = InspectApiAddDevicesRequest(data=items) + response = self.vip_connector.rest.post("/rest/v2/actions/status/network/addDevices", request) + return InspectApiSimpleActionResponse.model_validate(_post_envelope(response)) + + def sync_devices( + self, device_ids: list[str], add_only: bool = True, conflict_strategy: int = 0 + ) -> InspectApiSimpleActionResponse: + request = InspectApiSyncDevicesRequest( + data=InspectApiSyncDevicesRequestData( + ids=device_ids, addOnly=add_only, conflictStrategy=conflict_strategy + ) + ) + response = self.vip_connector.rest.post("/rest/v2/actions/status/network/syncDevices", request) + return InspectApiSimpleActionResponse.model_validate(_post_envelope(response)) + + +def _header_dict(response: Any) -> dict[str, Any]: + header = getattr(response, "header", None) + if header is None: + return {} + return header.model_dump(mode="json") if hasattr(header, "model_dump") else dict(header) + + +def _post_envelope(response: Any) -> dict[str, Any]: + """Reassemble a ``{data, header}`` dict from a ResponseV2Post for DTO validation.""" + return {"data": response.data, "header": _header_dict(response)} + + +__all__ = ["InspectAPI"] diff --git a/src/videoipath_automation_tool/apps/inspect/inspect_app.py b/src/videoipath_automation_tool/apps/inspect/inspect_app.py new file mode 100644 index 0000000..00cc0b6 --- /dev/null +++ b/src/videoipath_automation_tool/apps/inspect/inspect_app.py @@ -0,0 +1,70 @@ +"""InspectApp — the user-facing entry point for the VideoIPath Inspect surface. + +Read-only monitoring plus commit-style topology writes, built entirely on the collector API +([ADR-0008]). Composed from focused mixins, mirroring the Inventory/Topology app layout. +""" + +from __future__ import annotations + +import logging +from typing import Optional + +from videoipath_automation_tool.apps.inspect.app.actions import InspectActionsMixin +from videoipath_automation_tool.apps.inspect.app.read import InspectReadMixin, LoadMode +from videoipath_automation_tool.apps.inspect.app.write import InspectWriteMixin +from videoipath_automation_tool.apps.inspect.inspect_api import InspectAPI +from videoipath_automation_tool.apps.inspect.snapshot import InspectSnapshot +from videoipath_automation_tool.connector.vip_connector import VideoIPathConnector + +# First VideoIPath version the Inspect collector surface was verified against. +_MIN_VERIFIED_VERSION = (2025, 4) + + +class InspectApp(InspectReadMixin, InspectWriteMixin, InspectActionsMixin): + def __init__( + self, + vip_connector: VideoIPathConnector, + logger: Optional[logging.Logger] = None, + load: LoadMode = "skeleton", + ): + """Inspect App: read the topology/status and apply commit-style topology changes. + + The app keeps a single internal topology view that is loaded lazily on the first read and + kept up to date across writes; interact with it entirely through this app (``app.inspect``), + the same way as the other apps. Call :meth:`refresh` to reload it from the server. + + Args: + vip_connector (VideoIPathConnector): connector handling the VideoIPath connection. + logger (Optional[logging.Logger]): logger instance. + load (LoadMode): how the internal view is loaded — ``"skeleton"`` (default; fast, with + lazy per-device detail) or ``"full"`` (eager, point-in-time). + """ + self._logger = logger or logging.getLogger("videoipath_automation_tool_inspect_app") + self._inspect_api = InspectAPI(vip_connector=vip_connector, logger=self._logger) + self._vip_connector = vip_connector + self._load_mode: LoadMode = load + self._snapshot: Optional[InspectSnapshot] = None + self._warn_if_version_unverified() + self._logger.debug("Inspect APP initialized.") + + def _warn_if_version_unverified(self) -> None: + version = self._vip_connector.videoipath_version + parsed = _parse_version(version) + if parsed is not None and parsed < _MIN_VERIFIED_VERSION: + self._logger.warning( + f"Inspect app: VideoIPath version '{version}' predates the first verified Inspect " + f"surface ({_MIN_VERIFIED_VERSION[0]}.{_MIN_VERIFIED_VERSION[1]}). Behaviour is unverified." + ) + + +def _parse_version(version: str) -> Optional[tuple[int, int]]: + parts = version.split(".") + if len(parts) < 2: + return None + try: + return int(parts[0]), int(parts[1]) + except ValueError: + return None + + +__all__ = ["InspectApp"] diff --git a/src/videoipath_automation_tool/apps/inspect/model/actions.py b/src/videoipath_automation_tool/apps/inspect/model/actions.py index e0f329a..5b351be 100644 --- a/src/videoipath_automation_tool/apps/inspect/model/actions.py +++ b/src/videoipath_automation_tool/apps/inspect/model/actions.py @@ -46,6 +46,116 @@ class InspectApiLookupInspectDeviceResponse(InspectApiBaseModel): header: InspectApiRestV2Header +# --- Vertex lookup / edit form (also the replaceVertices write shape, verified 2025.4.9) --- + + +class InspectApiVertexTypeFields(InspectApiBaseModel): + ipAddress: str | None = None + ipNetmask: str | None = None + public: bool | None = None + supportsCpipeCfg: bool | None = None + supportsIgmpCfg: bool | None = None + supportsMacForwardingCfg: bool | None = None + supportsNsoCfg: bool | None = None + supportsOpenflowCfg: bool | None = None + supportsStaticIgmpCfg: bool | None = None + supportsVlanCfg: bool | None = None + supportsVplsCfg: bool | None = None + type: str | None = None + vlanId: str | None = None + vrfId: str | None = None + + +class InspectApiVertexEditForm(InspectApiBaseModel): + """The vertex ``fields`` object returned by ``lookupInspectVertexById`` — this exact shape is + what ``replaceVertices`` accepts (update-only; verified 2025.4.9).""" + + active: bool | None = None + controlProps: Any | None = None + custom: dict[str, Any] = Field(default_factory=dict) + desc: str = "" + destinationMonitorLeader: bool | None = None + extraAlertFilters: list[Any] = Field(default_factory=list) + label: str = "" + localAssignedTags: list[str] = Field(default_factory=list) + queueable: bool | None = None + sipsMode: str | None = None + tags: list[str] = Field(default_factory=list) + typeFields: InspectApiVertexTypeFields | None = None + useAsEndpoint: bool | None = None + + +class InspectApiLookupVertexRequest(InspectApiBaseModel): + header: InspectApiPostRequestHeader = Field(default_factory=InspectApiPostRequestHeader) + data: str + + +class InspectApiLookupVerticesRequest(InspectApiBaseModel): + header: InspectApiPostRequestHeader = Field(default_factory=InspectApiPostRequestHeader) + data: list[str] + + +class InspectApiLookupVertexResponseData(InspectApiBaseModel): + assignedTags: InspectApiAssignedTags | None = None + context: dict[str, Any] | None = None + customSchemas: dict[str, Any] = Field(default_factory=dict) + fields: InspectApiVertexEditForm + id: str + isVirtual: bool | None = None + vertexType: str | None = None + + +class InspectApiLookupVertexResponse(InspectApiBaseModel): + data: InspectApiLookupVertexResponseData + header: InspectApiRestV2Header + + +class InspectApiLookupVerticesResponse(InspectApiBaseModel): + data: dict[str, InspectApiLookupVertexResponseData] + header: InspectApiRestV2Header + + +# --- Edge lookup (also the replaceEdges write shape, verified 2025.4.9) --- + + +class InspectApiEdgeForm(InspectApiBaseModel): + """The persisted edge object returned by ``lookupInspectEdgesByIds`` — this exact shape is what + ``replaceEdges`` accepts (verified 2025.4.9). No ``_id`` / ``_rev`` / ``type`` in the write form.""" + + active: bool = True + bandwidth: float | int = -1.0 + capacity: int = 65535 + conflictPri: int | str = 0 + descriptor: InspectApiDescriptor = Field(default_factory=InspectApiDescriptor) + excludeFormats: list[str] = Field(default_factory=list) + fDescriptor: InspectApiDescriptor = Field(default_factory=InspectApiDescriptor) + fromId: str + includeFormats: list[str] = Field(default_factory=list) + redundancyMode: str = "Any" + tags: list[str] = Field(default_factory=list) + toId: str + weight: int = 1 + weightFactors: dict[str, Any] = Field( + default_factory=lambda: {"bandwidth": {"weight": 0}, "service": {"max": 100, "weight": 0}} + ) + + +class InspectApiLookupEdgesRequest(InspectApiBaseModel): + header: InspectApiPostRequestHeader = Field(default_factory=InspectApiPostRequestHeader) + data: list[str] + + +class InspectApiLookupEdgeResponseItem(InspectApiBaseModel): + edge: InspectApiEdgeForm + fromDevice: str | None = None + toDevice: str | None = None + + +class InspectApiLookupEdgesResponse(InspectApiBaseModel): + data: dict[str, InspectApiLookupEdgeResponseItem] + header: InspectApiRestV2Header + + class InspectApiLookupSyncInfoRequest(InspectApiBaseModel): header: InspectApiPostRequestHeader = Field(default_factory=InspectApiPostRequestHeader) data: list[str] @@ -90,6 +200,10 @@ class InspectApiSyncDevicesRequest(InspectApiBaseModel): "InspectApiAddDevicesItem", "InspectApiAddDevicesRequest", "InspectApiAssignedTags", + "InspectApiEdgeForm", + "InspectApiLookupEdgeResponseItem", + "InspectApiLookupEdgesRequest", + "InspectApiLookupEdgesResponse", "InspectApiLookupInspectDeviceFields", "InspectApiLookupInspectDeviceRequest", "InspectApiLookupInspectDeviceResponse", @@ -97,6 +211,13 @@ class InspectApiSyncDevicesRequest(InspectApiBaseModel): "InspectApiLookupSyncInfoItem", "InspectApiLookupSyncInfoRequest", "InspectApiLookupSyncInfoResponse", + "InspectApiLookupVerticesRequest", + "InspectApiLookupVerticesResponse", + "InspectApiLookupVertexRequest", + "InspectApiLookupVertexResponse", + "InspectApiLookupVertexResponseData", "InspectApiSyncDevicesRequest", "InspectApiSyncDevicesRequestData", + "InspectApiVertexEditForm", + "InspectApiVertexTypeFields", ] diff --git a/src/videoipath_automation_tool/apps/inspect/model/collector.py b/src/videoipath_automation_tool/apps/inspect/model/collector.py index 1939fa7..ea79520 100644 --- a/src/videoipath_automation_tool/apps/inspect/model/collector.py +++ b/src/videoipath_automation_tool/apps/inspect/model/collector.py @@ -10,6 +10,7 @@ InspectApiDescriptor, InspectApiEndpointStatus, InspectApiRestV2Header, + InspectApiStatusContext, InspectServiceStatus, InspectApiStatusSummary, ) @@ -66,6 +67,14 @@ class InspectApiPathItem(InspectApiBaseModel): class InspectApiNodeMeta(InspectApiBaseModel): coordinates: dict[str, float | int | str | None] | None = None + hwPanelType: str | None = None + iconSize: str | None = None + iconType: str | None = None + isCore: bool | None = None + isVirtual: bool | None = None + sdpStrategy: str | None = None + siteId: str | None = None + tags: list[str] = Field(default_factory=list) class InspectApiVertexInfoFields(InspectApiBaseModel): @@ -102,16 +111,27 @@ class InspectApiPathDescriptionItem(InspectApiBaseModel): class InspectPortStatus(InspectApiBaseModel): id: str | None = Field(default=None, alias="_id") vid: str | None = Field(default=None, alias="_vid") + context: InspectApiStatusContext | None = None + descriptor: InspectApiDescriptor | None = None label: str | None = None pid: str | None = None + resourceId: str | None = None status: InspectApiStatusSummary | None = None vertexInfo: InspectApiSingleVertexInfo | InspectApiDoubleVertexInfo | dict[str, Any] | None = None pathDescriptions: dict[str, InspectApiPathDescriptionItem] = Field(default_factory=dict) + @property + def effective_label(self) -> str | None: + if self.descriptor is not None and self.descriptor.label: + return self.descriptor.label + return self.label + class InspectApiModuleStatus(InspectApiBaseModel): id: str | None = Field(default=None, alias="_id") vid: str | None = Field(default=None, alias="_vid") + context: InspectApiStatusContext | None = None + descriptor: InspectApiDescriptor | None = None label: str | None = None pid: str | None = None ports: dict[str, InspectPortStatus] | list[InspectPortStatus] = Field(default_factory=dict) @@ -121,7 +141,10 @@ class InspectApiModuleStatus(InspectApiBaseModel): class InspectApiNodeStatusItem(InspectApiBaseModel): id: str = Field(alias="_id") vid: str | None = Field(default=None, alias="_vid") + context: InspectApiStatusContext | None = None + descriptor: InspectApiDescriptor | None = None deviceId: str | None = None + fDescriptor: InspectApiDescriptor | None = None hasEndpoints: bool | None = None label: str | None = None meta: InspectApiNodeMeta | None = None @@ -129,9 +152,26 @@ class InspectApiNodeStatusItem(InspectApiBaseModel): pathDescriptions: dict[str, InspectApiPathDescriptionItem] = Field(default_factory=dict) pid: str | None = None ptpDeviceStatus: dict[str, Any] | None = None + relatedNodeTags: list[str] = Field(default_factory=list) + resourceId: str | None = None status: InspectApiStatusSummary | None = None syncSeverity: int | str | None = None tags: list[str] = Field(default_factory=list) + tagsInfo: dict[str, Any] | None = None + + @property + def effective_label(self) -> str | None: + """The label the UI shows: user ``descriptor.label``, falling back to the device-reported + ``fDescriptor.label`` (and finally the legacy top-level ``label`` field, if present).""" + if self.descriptor is not None and self.descriptor.label: + return self.descriptor.label + if self.fDescriptor is not None and self.fDescriptor.label: + return self.fDescriptor.label + return self.label + + @property + def coordinates(self) -> dict[str, float | int | str | None] | None: + return self.meta.coordinates if self.meta is not None else None class InspectApiExternalEdgeLiveStatus(InspectApiBaseModel): diff --git a/src/videoipath_automation_tool/apps/inspect/model/update_topology.py b/src/videoipath_automation_tool/apps/inspect/model/update_topology.py index f15af97..f1aa9c9 100644 --- a/src/videoipath_automation_tool/apps/inspect/model/update_topology.py +++ b/src/videoipath_automation_tool/apps/inspect/model/update_topology.py @@ -4,32 +4,39 @@ from pydantic import Field +from videoipath_automation_tool.apps.inspect.model.actions import ( + InspectApiEdgeForm, + InspectApiLookupInspectDeviceFields, + InspectApiVertexEditForm, +) from videoipath_automation_tool.apps.inspect.model.common import ( InspectApiBaseModel, InspectApiPostRequestHeader, InspectApiRestV2Header, ) from videoipath_automation_tool.apps.inspect.model.ngraph import ( - InspectApiBaseDevice, - InspectApiCodecVertex, - InspectApiGenericVertex, - InspectApiIpVertex, InspectApiNGraphResourceTransform, - InspectApiUnidirectionalEdge, ) -InspectApiReplaceVertex = InspectApiIpVertex | InspectApiCodecVertex | InspectApiGenericVertex | dict[str, Any] -InspectApiReplaceDevice = InspectApiBaseDevice | dict[str, Any] +# The verified per-kind write shapes (2025.4.9): +# - replaceDevices takes the device *edit form* (lookupInspectDevice.fields); the raw baseDevice +# element is rejected (coordinates + localAssignedTags are mandatory). +# - replaceVertices takes the vertex *edit form* (lookupInspectVertexById.fields); update-only. +# - replaceEdges takes the raw persisted edge form (lookupInspectEdgesByIds). +# dict fallbacks keep the models permissive for hand-built payloads. +InspectApiReplaceDevice = InspectApiLookupInspectDeviceFields | dict[str, Any] +InspectApiReplaceVertex = InspectApiVertexEditForm | dict[str, Any] +InspectApiReplaceEdge = InspectApiEdgeForm | dict[str, Any] InspectApiReplaceResourceTransform = InspectApiNGraphResourceTransform | dict[str, Any] class InspectApiUpdateTopologyData(InspectApiBaseModel): replaceDevices: dict[str, InspectApiReplaceDevice] = Field(default_factory=dict) replaceVertices: dict[str, InspectApiReplaceVertex] = Field(default_factory=dict) - replaceEdges: dict[str, InspectApiUnidirectionalEdge] = Field(default_factory=dict) + replaceEdges: dict[str, InspectApiReplaceEdge] = Field(default_factory=dict) replaceResourceTransforms: dict[str, InspectApiReplaceResourceTransform] = Field(default_factory=dict) - addExternalEdges: list[InspectApiUnidirectionalEdge] = Field(default_factory=list) + addExternalEdges: list[InspectApiEdgeForm | dict[str, Any]] = Field(default_factory=list) remove: list[str] = Field(default_factory=list) force: bool = False @@ -76,6 +83,7 @@ def committed(self) -> bool: __all__ = [ "InspectApiReplaceDevice", + "InspectApiReplaceEdge", "InspectApiReplaceResourceTransform", "InspectApiReplaceVertex", "InspectApiUpdateTopologyData", diff --git a/src/videoipath_automation_tool/apps/inspect/queries.py b/src/videoipath_automation_tool/apps/inspect/queries.py new file mode 100644 index 0000000..439048a --- /dev/null +++ b/src/videoipath_automation_tool/apps/inspect/queries.py @@ -0,0 +1,131 @@ +"""Scoped collector query catalogue for the Inspect app. + +Every query string here is **verified live against VideoIPath 2025.4.9** and is the +concrete basis for the skeleton-first + lazy-hydration loading model ([ADR-0007]). +The Inspect UI issues the same paths over WebSocket subscriptions; the package +issues them as REST GETs (see docs/inspect-app/endpoints.md). + +Projection grammar used below (verified): +- ``*`` select all items of a collection +- ``"_noId"`` suppress a sub-tree (returns ``{}`` / omits the key) +- ``field/**`` select a field's full sub-tree +- ``a,b`` select several leaf fields at the current level +- ``/.../`` pop one level back up in the projection tree + +The full UI projection is too long for a REST GET (HTTP 414); the skeleton +projections here are deliberately trimmed to stay well within the URI limit. +""" + +from __future__ import annotations + +import urllib.parse + +from videoipath_automation_tool.apps.inspect.errors import InspectQueryTooLongError + +# Base for all collector data reads. +_DATA = "/rest/v2/data" + +# Conservative URI length ceiling (path only). Verified queries are ~200-370 chars; +# the full UI projection (thousands of chars) triggers HTTP 414 behind the proxy. +MAX_QUERY_LENGTH = 4000 + +# Characters that are meaningful in the projection grammar and must survive encoding. +# Everything else (spaces, double quotes, ...) is percent-encoded. +_SAFE = "/*,'=()" + + +# --- Verified query fragments (path relative to /rest/v2/data) --- + +# Device skeleton: identity + descriptor + meta (incl. coordinates) + status + syncSeverity +# + tags, with the module sub-tree suppressed ("_noId"). ~200 char URL, ~30 KB / 30 devices. +_DEVICE_SKELETON = ( + "/status/collector/inspect/nodeStatus/*" + "/deviceId,resourceId,syncSeverity" + '/.../descriptor/**' + "/.../.../meta/**" + "/.../.../status/**" + "/.../.../tags/*" + '/.../.../modules/"_noId"' +) + +# Edge skeleton (lean): device-pair keys, edge ids, endpoint port context+labels, and the +# pair-level status severities. No pathDescriptions, no bandwidth values. ~370 char URL. +_EDGE_LEAN_TAIL = ( + "/primary,secondary/devicePid,label" + "/.../.../status/alarm,bandwidth,maintenance,ptp" + "/.../.../primary/data/*/id" + "/.../fromStatus,toStatus/label" + "/.../context/devicePid,modulePid,portPid" + "/.../.../.../.../.../.../secondary/data/*/id" + "/.../fromStatus,toStatus/label" + "/.../context/devicePid,modulePid,portPid" +) +_EDGE_SKELETON = "/status/collector/externalEdgesByDeviceKey/*" + _EDGE_LEAN_TAIL + +# Services / paths section: serviceFields (endpoints, labels, status) + per-hop path structure. +_PATHS_SECTION = ( + "/status/collector/inspect/paths/*" + "/serviceFields/bid,from,fromLabel,isMain,to,toLabel" + "/.../generic/descriptor/**" + "/.../.../serviceStatus/**" + "/.../.../.../path/*/bid,ipDesc" + "/.../structure/deviceId,deviceLabel,devicePid" + "/.../inputStatus,outputStatus/label,pid" +) + +# Full aggregate (eager / fallback mode). +_COLLECTOR_FULL = "/status/collector/**" + + +def encode(path: str) -> str: + """Percent-encode a collector query path, preserving projection-grammar characters.""" + return urllib.parse.quote(path, safe=_SAFE) + + +def _build(path: str) -> str: + encoded = encode(_DATA + path) + if len(encoded) > MAX_QUERY_LENGTH: + raise InspectQueryTooLongError(len(encoded), MAX_QUERY_LENGTH) + return encoded + + +def device_skeleton() -> str: + """GET path for the device skeleton (all devices, no module/port detail).""" + return _build(_DEVICE_SKELETON) + + +def device_detail(device_id: str) -> str: + """GET path for one device's full nodeStatus sub-tree (modules, ports, vertexInfo, ...).""" + return _build(f"/status/collector/inspect/nodeStatus/{device_id}/**") + + +def edge_skeleton() -> str: + """GET path for the lean edge skeleton (all device pairs, connectivity + status severities).""" + return _build(_EDGE_SKELETON) + + +def edge_pair(pair_id: str) -> str: + """GET path for a single external-edge device pair (targeted refresh, [ADR-0010]).""" + return _build(f"/status/collector/externalEdgesByDeviceKey/{pair_id}" + _EDGE_LEAN_TAIL) + + +def paths_section() -> str: + """GET path for the services/paths section.""" + return _build(_PATHS_SECTION) + + +def collector_full() -> str: + """GET path for the full collector aggregate (eager / fallback mode).""" + return _build(_COLLECTOR_FULL) + + +__all__ = [ + "MAX_QUERY_LENGTH", + "encode", + "device_skeleton", + "device_detail", + "edge_skeleton", + "edge_pair", + "paths_section", + "collector_full", +] diff --git a/src/videoipath_automation_tool/apps/inspect/snapshot.py b/src/videoipath_automation_tool/apps/inspect/snapshot.py index b685d5c..07e78bc 100644 --- a/src/videoipath_automation_tool/apps/inspect/snapshot.py +++ b/src/videoipath_automation_tool/apps/inspect/snapshot.py @@ -1,10 +1,29 @@ +"""InspectSnapshot: skeleton-first, lazily-hydrated, accreting read state ([ADR-0007]). + +A snapshot is built from two scoped skeleton reads (all devices without module/port detail, all +external-edge pairs). Detail is hydrated on demand — the first access to a device's ports fetches +that one device's full nodeStatus sub-tree and merges it into the same internal indexes; services +load once as a section. The snapshot is never a single point in time: each device and section +carries its own fetch timestamp. ``refresh()`` builds a *new* snapshot; state is never reused +across snapshots. + +After a successful commit the change set calls the post-commit hooks here to update only the +touched entities via targeted scoped re-reads ([ADR-0010]) instead of a full reload. +""" + from __future__ import annotations -from dataclasses import dataclass -from typing import TYPE_CHECKING, Any, Iterator +import threading +from concurrent.futures import ThreadPoolExecutor +from dataclasses import dataclass, field +from datetime import datetime, timezone +from enum import Enum +from typing import TYPE_CHECKING, Any, Iterator, Optional from videoipath_automation_tool.apps.inspect.model.collector import ( InspectApiCollectorResponse, + InspectApiExternalEdgeLiveStatus, + InspectApiExternalEdgesByDeviceKeyItem, InspectApiExternalEdgeStatus, InspectApiModuleStatus, InspectApiNodeStatusItem, @@ -17,14 +36,34 @@ from videoipath_automation_tool.apps.inspect.domain.edge import InspectEdge from videoipath_automation_tool.apps.inspect.domain.port import InspectPort from videoipath_automation_tool.apps.inspect.domain.service import InspectService + from videoipath_automation_tool.apps.inspect.inspect_api import InspectAPI +_PRELOAD_WORKERS = 8 -@dataclass(frozen=True, slots=True) + +class HydrationLevel(str, Enum): + SKELETON = "skeleton" + FULL = "full" + + +def _now() -> datetime: + return datetime.now(timezone.utc) + + +@dataclass class _DeviceRecord: device_id: str - label: str | None - pid: str | None - node: InspectApiNodeStatusItem | None + node: InspectApiNodeStatusItem + level: HydrationLevel + fetched_at: datetime = field(default_factory=_now) + + @property + def label(self) -> str | None: + return self.node.effective_label + + @property + def pid(self) -> str | None: + return self.node.pid or self.node.deviceId @dataclass(frozen=True, slots=True) @@ -39,6 +78,7 @@ class _IndexedEdge: edge_id: str pair_id: str edge: InspectApiExternalEdgeStatus + pair_status: InspectApiExternalEdgeLiveStatus | None primary_device_id: str | None secondary_device_id: str | None from_device_id: str | None @@ -48,175 +88,486 @@ class _IndexedEdge: class InspectSnapshot: - def __init__(self, response: InspectApiCollectorResponse) -> None: - self._response = response - collector = response.data.status.collector - - self._node_status_items = collector.inspect.node_status_items - self._path_items = collector.inspect.path_items - self._external_edge_items = collector.external_edges_by_device_key_items - + def __init__( + self, + fetcher: Optional["InspectAPI"] = None, + device_items: Optional[list[InspectApiNodeStatusItem]] = None, + edge_items: Optional[list[InspectApiExternalEdgesByDeviceKeyItem]] = None, + *, + device_level: HydrationLevel = HydrationLevel.SKELETON, + path_items: Optional[list[InspectApiPathItem]] = None, + ) -> None: + self._fetcher = fetcher + self._lock = threading.RLock() + self._created_at = _now() + + # Core indexes self._devices_by_id: dict[str, _DeviceRecord] = {} self._devices_by_label: dict[str, list[str]] = {} - self._paths_by_booking_id: dict[str, InspectApiPathItem] = {} - self._services_by_device_id: dict[str, list[str]] = {} + self._edge_pairs: dict[str, InspectApiExternalEdgesByDeviceKeyItem] = {} + self._edges_by_device_id: dict[str, list[_IndexedEdge]] = {} + self._edge_by_port_key: dict[tuple[str, str], _IndexedEdge] = {} + + # Per-device port indexes (populated on hydration) self._ports_by_device_id: dict[str, list[_IndexedPort]] = {} self._port_by_key: dict[tuple[str, str], _IndexedPort] = {} self._ports_by_pid: dict[str, list[_IndexedPort]] = {} - self._edges_by_device_id: dict[str, list[_IndexedEdge]] = {} - self._edge_by_port_key: dict[tuple[str, str], _IndexedEdge] = {} - self._device_cache: dict[str, InspectDevice] = {} - self._port_cache: dict[tuple[str, str], InspectPort] = {} - self._edge_cache: dict[str, InspectEdge] = {} - self._service_cache: dict[str, InspectService] = {} - self._ports_for_device_cache: dict[str, list[InspectPort]] = {} - self._edges_for_device_cache: dict[str, list[InspectEdge]] = {} - self._services_for_device_cache: dict[str, list[InspectService]] = {} + # Section: services / paths + self._paths_by_booking_id: dict[str, InspectApiPathItem] = {} + self._services_by_device_id: dict[str, list[str]] = {} + self._section_loaded: dict[str, bool] = {"paths": False} + self._section_fetched_at: dict[str, datetime] = {} + + # Domain-object caches + self._device_cache: dict[str, "InspectDevice"] = {} + self._edge_cache: dict[str, "InspectEdge"] = {} + self._service_cache: dict[str, "InspectService"] = {} - self._build_device_indexes() - self._build_path_indexes() - self._build_port_indexes() - self._build_edge_indexes() + for node in device_items or []: + self._index_device(node, device_level) + for pair in edge_items or []: + self._index_edge_pair(pair) + if path_items is not None: + self._index_paths(path_items) + self._section_loaded["paths"] = True + self._section_fetched_at["paths"] = self._created_at - @property - def raw_response(self) -> InspectApiCollectorResponse: - return self._response + # --- Construction --- @classmethod - def from_response(cls, response: InspectApiCollectorResponse) -> InspectSnapshot: - return cls(response) + def from_full_response( + cls, response: InspectApiCollectorResponse, fetcher: Optional["InspectAPI"] = None + ) -> "InspectSnapshot": + """Build a fully-hydrated snapshot from one full collector aggregate (eager / fallback mode).""" + collector = response.data.status.collector + return cls( + fetcher=fetcher, + device_items=collector.inspect.node_status_items, + edge_items=collector.external_edges_by_device_key_items, + device_level=HydrationLevel.FULL, + path_items=collector.inspect.path_items, + ) + + # Backwards-compatible alias for the original draft API. + from_response = from_full_response - def get_device_by_id(self, device_id: str) -> InspectDevice | None: + # --- Freshness / introspection --- + + @property + def created_at(self) -> datetime: + return self._created_at + + def fetched_at(self, device_id: str) -> datetime | None: record = self._devices_by_id.get(device_id) - if record is None: - return None - return self._wrap_device(record) - - def find_devices_by_name(self, label: str) -> list[InspectDevice]: - devices: list[InspectDevice] = [] - for device_id in self._devices_by_label.get(label, []): - device = self.get_device_by_id(device_id) - if device is not None: - devices.append(device) - return devices - - def get_devices(self) -> list[InspectDevice]: - return [self._wrap_device(record) for record in self._devices_by_id.values()] - - def get_service_by_booking_id(self, booking_id: str) -> InspectService | None: - path_item = self._paths_by_booking_id.get(booking_id) - if path_item is None: - return None - return self._wrap_service(path_item) + return record.fetched_at if record else None - def get_services(self) -> list[InspectService]: - return [self._wrap_service(item) for item in self._path_items] + def section_fetched_at(self, section: str = "paths") -> datetime | None: + return self._section_fetched_at.get(section) - def get_port(self, device_id: str, port_id: str) -> InspectPort | None: - indexed = self._port_by_key.get((device_id, port_id)) - if indexed is None: - return None - return self._wrap_port(indexed) + def is_device_hydrated(self, device_id: str) -> bool: + record = self._devices_by_id.get(device_id) + return record is not None and record.level is HydrationLevel.FULL + + # --- Device reads --- - def find_port_by_id(self, port_id: str) -> InspectPort | None: - indexed_ports = self._ports_by_pid.get(port_id) - if not indexed_ports: + def get_device(self, device_id: str) -> Optional["InspectDevice"]: + if device_id not in self._devices_by_id: return None - return self._wrap_port(indexed_ports[0]) + return self._wrap_device(device_id) - def get_ports_for_device(self, device_id: str) -> list[InspectPort]: - cached = self._ports_for_device_cache.get(device_id) - if cached is not None: - return cached - ports = [self._wrap_port(indexed) for indexed in self._ports_by_device_id.get(device_id, [])] - self._ports_for_device_cache[device_id] = ports - return ports + # Backwards-compatible alias. + get_device_by_id = get_device - def get_edge_for_port(self, device_id: str, port_id: str) -> InspectEdge | None: - indexed = self._edge_by_port_key.get((device_id, port_id)) - if indexed is None: - return None - return self._wrap_edge(indexed) + def find_device_by_label(self, label: str) -> Optional["InspectDevice"]: + ids = self._devices_by_label.get(label, []) + return self._wrap_device(ids[0]) if ids else None + + def find_devices_by_label(self, label: str) -> list["InspectDevice"]: + return [self._wrap_device(device_id) for device_id in self._devices_by_label.get(label, [])] + + # Backwards-compatible alias. + find_devices_by_name = find_devices_by_label + + @property + def devices(self) -> list["InspectDevice"]: + return [self._wrap_device(device_id) for device_id in self._devices_by_id] + + def get_devices(self, detail: bool = False) -> list["InspectDevice"]: + if detail: + self.preload() + return self.devices + + def get_device_record(self, device_id: str) -> Optional[_DeviceRecord]: + """Internal: return the (possibly hydrated) record for a device; used by domain objects.""" + return self._devices_by_id.get(device_id) + + # --- Port reads (trigger hydration) --- + + def get_ports_for_device(self, device_id: str) -> list["InspectPort"]: + self._ensure_device_detail(device_id) + return [self._wrap_port(indexed) for indexed in self._ports_by_device_id.get(device_id, [])] + + def get_port(self, device_id: str, port_id: str) -> Optional["InspectPort"]: + self._ensure_device_detail(device_id) + indexed = self._port_by_key.get((device_id, port_id)) + return self._wrap_port(indexed) if indexed else None + + def find_port_by_id(self, port_id: str) -> Optional["InspectPort"]: + indexed = self._ports_by_pid.get(port_id) + return self._wrap_port(indexed[0]) if indexed else None + + # --- Edge reads (no hydration) --- - def get_edges(self) -> list[InspectEdge]: + @property + def edges(self) -> list["InspectEdge"]: seen: set[str] = set() - edges: list[InspectEdge] = [] + result: list["InspectEdge"] = [] for indexed_edges in self._edges_by_device_id.values(): for indexed in indexed_edges: if indexed.edge_id in seen: continue seen.add(indexed.edge_id) - edges.append(self._wrap_edge(indexed)) - return edges + result.append(self._wrap_edge(indexed)) + return result - def get_edges_for_device(self, device_id: str) -> list[InspectEdge]: - cached = self._edges_for_device_cache.get(device_id) - if cached is not None: - return cached + def get_edges(self) -> list["InspectEdge"]: + return self.edges + + def get_edges_for_device(self, device_id: str) -> list["InspectEdge"]: seen: set[str] = set() - edges: list[InspectEdge] = [] + result: list["InspectEdge"] = [] for indexed in self._edges_by_device_id.get(device_id, []): if indexed.edge_id in seen: continue seen.add(indexed.edge_id) - edges.append(self._wrap_edge(indexed)) - self._edges_for_device_cache[device_id] = edges - return edges + result.append(self._wrap_edge(indexed)) + return result - def get_services_for_device(self, device_id: str) -> list[InspectService]: - cached = self._services_for_device_cache.get(device_id) - if cached is not None: - return cached - services: list[InspectService] = [] - for booking_id in self._services_by_device_id.get(device_id, []): - service = self.get_service_by_booking_id(booking_id) - if service is not None: - services.append(service) - self._services_for_device_cache[device_id] = services - return services + def get_edge_for_port(self, device_id: str, port_id: str) -> Optional["InspectEdge"]: + indexed = self._edge_by_port_key.get((device_id, port_id)) + return self._wrap_edge(indexed) if indexed else None - def get_linked_devices(self, device_id: str) -> list[InspectDevice]: + def get_linked_devices(self, device_id: str) -> list["InspectDevice"]: linked: set[str] = set() for indexed in self._edges_by_device_id.get(device_id, []): for candidate in (indexed.from_device_id, indexed.to_device_id): if candidate and candidate != device_id: linked.add(candidate) + return [d for lid in sorted(linked) if (d := self.get_device(lid)) is not None] + + # --- Service reads (section, trigger section load) --- + + @property + def services(self) -> list["InspectService"]: + self._ensure_section_paths() + return [self._wrap_service(item) for item in self._paths_by_booking_id.values()] + + def get_services(self) -> list["InspectService"]: + return self.services + + def get_service_by_booking_id(self, booking_id: str) -> Optional["InspectService"]: + self._ensure_section_paths() + item = self._paths_by_booking_id.get(booking_id) + return self._wrap_service(item) if item else None + + def get_services_for_device(self, device_id: str) -> list["InspectService"]: + self._ensure_section_paths() + result: list["InspectService"] = [] for booking_id in self._services_by_device_id.get(device_id, []): - path_item = self._paths_by_booking_id.get(booking_id) - if path_item is None: + item = self._paths_by_booking_id.get(booking_id) + if item is not None: + result.append(self._wrap_service(item)) + return result + + # --- Bulk preload (ADR-0004) --- + + def preload(self, devices: Optional[list[str]] = None) -> None: + """Hydrate multiple devices in parallel to avoid N+1 when detail is needed for many.""" + target = devices if devices is not None else list(self._devices_by_id) + pending = [d for d in target if not self.is_device_hydrated(d)] + if not pending or self._fetcher is None: + for device_id in pending: + self._ensure_device_detail(device_id) + return + with ThreadPoolExecutor(max_workers=min(_PRELOAD_WORKERS, len(pending))) as pool: + list(pool.map(self._ensure_device_detail, pending)) + + # --- Refresh --- + + def refresh(self) -> "InspectSnapshot": + """Return a *new* snapshot from a fresh skeleton read (never mutates this one).""" + if self._fetcher is None: + raise RuntimeError("This snapshot has no fetcher and cannot be refreshed; build a new snapshot instead.") + return InspectSnapshot( + fetcher=self._fetcher, + device_items=self._fetcher.get_device_skeleton(), + edge_items=self._fetcher.get_edge_skeleton(), + ) + + # --- Post-commit hooks (ADR-0010) --- + + def apply_post_commit( + self, + removed_ids: Optional[list[str]] = None, + device_ids: Optional[list[str]] = None, + pair_ids: Optional[list[str]] = None, + mark_paths_stale: bool = True, + ) -> None: + """Targeted refresh after a successful commit: drop removed entities locally, re-fetch the + affected devices and edge pairs, and mark the services section stale.""" + self._apply_removals(removed_ids or []) + if self._fetcher is not None: + for device_id in device_ids or []: + if device_id in self._devices_by_id: + self._refresh_device(device_id) + for pair_id in pair_ids or []: + self._refresh_edge_pair(pair_id) + if mark_paths_stale: + with self._lock: + self._section_loaded["paths"] = False + self._paths_by_booking_id.clear() + self._services_by_device_id.clear() + + # --- Internal: hydration --- + + def _ensure_device_detail(self, device_id: str) -> None: + record = self._devices_by_id.get(device_id) + if record is None or record.level is HydrationLevel.FULL or self._fetcher is None: + return + # The collector keys nodeStatus by the item's own id (dash form for virtual devices, + # e.g. 'virtual-2'), which differs from the public device id ('virtual.2'). Use it here. + detail = self._fetcher.get_device_detail(record.node.id or device_id) + if detail is None: + # No further detail to load (e.g. virtual devices expose no modules); mark hydrated + # so we honour the at-most-one-fetch contract instead of re-fetching on every access. + with self._lock: + current = self._devices_by_id.get(device_id) + if current is not None and current.level is not HydrationLevel.FULL: + current.level = HydrationLevel.FULL + return + with self._lock: + current = self._devices_by_id.get(device_id) + if current is None or current.level is HydrationLevel.FULL: + return + self._devices_by_id[device_id] = _DeviceRecord( + device_id=device_id, node=detail, level=HydrationLevel.FULL + ) + self._device_cache.pop(device_id, None) + self._rebuild_device_ports(device_id, detail) + + def _refresh_device(self, device_id: str) -> None: + if self._fetcher is None: + return + record = self._devices_by_id.get(device_id) + detail = self._fetcher.get_device_detail(record.node.id if record else device_id) + if detail is None: + return + with self._lock: + self._devices_by_id[device_id] = _DeviceRecord( + device_id=device_id, node=detail, level=HydrationLevel.FULL + ) + self._device_cache.pop(device_id, None) + self._rebuild_device_ports(device_id, detail) + + def _refresh_edge_pair(self, pair_id: str) -> None: + if self._fetcher is None: + return + pair = self._fetcher.get_edge_pair(pair_id) + with self._lock: + self._drop_edge_pair(pair_id) + if pair is not None: + self._index_edge_pair(pair) + + def _ensure_section_paths(self) -> None: + if self._section_loaded.get("paths") or self._fetcher is None: + return + items = self._fetcher.get_paths_section() + with self._lock: + if self._section_loaded.get("paths"): + return + self._index_paths(items) + self._section_loaded["paths"] = True + self._section_fetched_at["paths"] = _now() + self._service_cache.clear() + + # --- Internal: indexing --- + + def _index_device(self, node: InspectApiNodeStatusItem, level: HydrationLevel) -> None: + device_id = node.deviceId or node.id + if not device_id: + return + record = _DeviceRecord(device_id=device_id, node=node, level=level) + self._devices_by_id[device_id] = record + label = record.label + if label: + ids = self._devices_by_label.setdefault(label, []) + if device_id not in ids: + ids.append(device_id) + if level is HydrationLevel.FULL: + self._rebuild_device_ports(device_id, node) + + def _rebuild_device_ports(self, device_id: str, node: InspectApiNodeStatusItem) -> None: + # Drop existing port index entries for this device + old = self._ports_by_device_id.pop(device_id, []) + for indexed in old: + port_id = _port_id_from_status(indexed.port) + if port_id is not None: + self._port_by_key.pop((device_id, port_id), None) + remaining = [p for p in self._ports_by_pid.get(port_id, []) if p.device_id != device_id] + if remaining: + self._ports_by_pid[port_id] = remaining + else: + self._ports_by_pid.pop(port_id, None) + # Rebuild + entries: list[_IndexedPort] = [] + for module in _iter_modules(node.modules): + module_id = module.pid or module.id + for port in _iter_ports(module.ports): + indexed = _IndexedPort(device_id=device_id, module_id=module_id, port=port) + entries.append(indexed) + port_id = _port_id_from_status(port) + if port_id is None: + continue + self._port_by_key[(device_id, port_id)] = indexed + self._ports_by_pid.setdefault(port_id, []).append(indexed) + self._ports_by_device_id[device_id] = entries + + def _resolve_device_id(self, pid: str | None) -> str | None: + """Map an edge ``devicePid`` to the canonical device id. + + For physical devices the pid equals the device id. For virtual devices the collector reports + the pid in dash-encoded form (``virtual-2``) while the device id is dot form (``virtual.2``); + reconcile the two so edges index under the same key the device is stored under. + """ + if not pid or pid in self._devices_by_id: + return pid + dotted = pid.replace("-", ".") + return dotted if dotted in self._devices_by_id else pid + + def _index_edge_pair(self, pair_item: InspectApiExternalEdgesByDeviceKeyItem) -> None: + self._edge_pairs[pair_item.id] = pair_item + primary_device_id = self._resolve_device_id(pair_item.primary.devicePid) + secondary_device_id = self._resolve_device_id(pair_item.secondary.devicePid) + for side, device_id in ( + (pair_item.primary, primary_device_id), + (pair_item.secondary, secondary_device_id), + ): + if not device_id: continue - for segment in path_item.path: + for edge in side.data.values(): + from_device_id = ( + self._resolve_device_id(_device_id_from_context(edge.fromStatus.context if edge.fromStatus else None)) + or primary_device_id + ) + from_port_id = _port_id_from_endpoint(edge.fromStatus) + to_device_id = ( + self._resolve_device_id(_device_id_from_context(edge.toStatus.context if edge.toStatus else None)) + or secondary_device_id + ) + to_port_id = _port_id_from_endpoint(edge.toStatus) + indexed = _IndexedEdge( + edge_id=edge.id, + pair_id=pair_item.id, + edge=edge, + pair_status=pair_item.status, + primary_device_id=primary_device_id, + secondary_device_id=secondary_device_id, + from_device_id=from_device_id, + from_port_id=from_port_id, + to_device_id=to_device_id, + to_port_id=to_port_id, + ) + self._edges_by_device_id.setdefault(device_id, []).append(indexed) + for endpoint_device_id, port_id in ( + (from_device_id, from_port_id), + (to_device_id, to_port_id), + ): + if endpoint_device_id and port_id: + self._edge_by_port_key[(endpoint_device_id, port_id)] = indexed + + def _drop_edge_pair(self, pair_id: str) -> None: + self._edge_pairs.pop(pair_id, None) + for device_id, edges in list(self._edges_by_device_id.items()): + kept = [e for e in edges if e.pair_id != pair_id] + if kept: + self._edges_by_device_id[device_id] = kept + else: + self._edges_by_device_id.pop(device_id, None) + for key, indexed in list(self._edge_by_port_key.items()): + if indexed.pair_id == pair_id: + self._edge_by_port_key.pop(key, None) + for edge_id, edge in list(self._edge_cache.items()): + if edge.pair_id == pair_id: + self._edge_cache.pop(edge_id, None) + + def _index_paths(self, path_items: list[InspectApiPathItem]) -> None: + for item in path_items: + booking_id = item.serviceFields.bid + self._paths_by_booking_id[booking_id] = item + device_ids: set[str] = set() + for segment in item.path: structure = segment.structure - if structure and structure.deviceId and structure.deviceId != device_id: - linked.add(structure.deviceId) - return [device for linked_id in sorted(linked) if (device := self.get_device_by_id(linked_id)) is not None] - - def _wrap_device(self, record: _DeviceRecord) -> InspectDevice: + if structure and structure.deviceId: + device_ids.add(structure.deviceId) + for device_id in device_ids: + ids = self._services_by_device_id.setdefault(device_id, []) + if booking_id not in ids: + ids.append(booking_id) + + def _apply_removals(self, removed_ids: list[str]) -> None: + if not removed_ids: + return + with self._lock: + for removed in removed_ids: + # Device removal + record = self._devices_by_id.pop(removed, None) + if record is not None: + label = record.label + if label and label in self._devices_by_label: + self._devices_by_label[label] = [ + d for d in self._devices_by_label[label] if d != removed + ] + if not self._devices_by_label[label]: + self._devices_by_label.pop(label, None) + self._device_cache.pop(removed, None) + self._ports_by_device_id.pop(removed, None) + self._edges_by_device_id.pop(removed, None) + # Edge removal by edge id or pair id + if "::" in removed: + self._drop_edge_id(removed) + + def _drop_edge_id(self, edge_or_pair_id: str) -> None: + for device_id, edges in list(self._edges_by_device_id.items()): + kept = [e for e in edges if e.edge_id != edge_or_pair_id and e.pair_id != edge_or_pair_id] + if kept != edges: + if kept: + self._edges_by_device_id[device_id] = kept + else: + self._edges_by_device_id.pop(device_id, None) + for key, indexed in list(self._edge_by_port_key.items()): + if indexed.edge_id == edge_or_pair_id or indexed.pair_id == edge_or_pair_id: + self._edge_by_port_key.pop(key, None) + self._edge_cache.pop(edge_or_pair_id, None) + + # --- Internal: domain wrappers (cached) --- + + def _wrap_device(self, device_id: str) -> "InspectDevice": from videoipath_automation_tool.apps.inspect.domain.device import InspectDevice - cached = self._device_cache.get(record.device_id) + cached = self._device_cache.get(device_id) if cached is not None: return cached - device = InspectDevice(snapshot=self, record=record) - self._device_cache[record.device_id] = device + device = InspectDevice(snapshot=self, id=device_id) + self._device_cache[device_id] = device return device - def _wrap_port(self, indexed: _IndexedPort) -> InspectPort: + def _wrap_port(self, indexed: _IndexedPort) -> "InspectPort": from videoipath_automation_tool.apps.inspect.domain.port import InspectPort - port_id = _port_id_from_status(indexed.port) - if port_id is not None: - key = (indexed.device_id, port_id) - cached = self._port_cache.get(key) - if cached is not None: - return cached - port = InspectPort(snapshot=self, indexed=indexed) - self._port_cache[key] = port - return port return InspectPort(snapshot=self, indexed=indexed) - def _wrap_edge(self, indexed: _IndexedEdge) -> InspectEdge: + def _wrap_edge(self, indexed: _IndexedEdge) -> "InspectEdge": from videoipath_automation_tool.apps.inspect.domain.edge import InspectEdge cached = self._edge_cache.get(indexed.edge_id) @@ -226,7 +577,7 @@ def _wrap_edge(self, indexed: _IndexedEdge) -> InspectEdge: self._edge_cache[indexed.edge_id] = edge return edge - def _wrap_service(self, path_item: InspectApiPathItem) -> InspectService: + def _wrap_service(self, path_item: InspectApiPathItem) -> "InspectService": from videoipath_automation_tool.apps.inspect.domain.service import InspectService booking_id = path_item.serviceFields.bid @@ -237,127 +588,8 @@ def _wrap_service(self, path_item: InspectApiPathItem) -> InspectService: self._service_cache[booking_id] = service return service - def _build_device_indexes(self) -> None: - for node in self._node_status_items: - device_id = node.deviceId or node.pid or node.id - if not device_id: - continue - self._upsert_device_record( - _DeviceRecord( - device_id=device_id, - label=node.label, - pid=node.pid, - node=node, - ) - ) - - for path_item in self._path_items: - for segment in path_item.path: - structure = segment.structure - if structure is None or not structure.deviceId: - continue - existing = self._devices_by_id.get(structure.deviceId) - if existing is not None and existing.node is not None: - continue - self._upsert_device_record( - _DeviceRecord( - device_id=structure.deviceId, - label=structure.deviceLabel, - pid=structure.devicePid, - node=existing.node if existing else None, - ) - ) - - def _upsert_device_record(self, record: _DeviceRecord) -> None: - existing = self._devices_by_id.get(record.device_id) - if existing is not None and existing.node is not None and record.node is None: - merged = existing - elif existing is not None and record.node is not None: - merged = _DeviceRecord( - device_id=record.device_id, - label=record.label or existing.label, - pid=record.pid or existing.pid, - node=record.node, - ) - else: - merged = record - - self._devices_by_id[merged.device_id] = merged - if merged.label: - labels = self._devices_by_label.setdefault(merged.label, []) - if merged.device_id not in labels: - labels.append(merged.device_id) - - def _build_path_indexes(self) -> None: - for path_item in self._path_items: - booking_id = path_item.serviceFields.bid - self._paths_by_booking_id[booking_id] = path_item - device_ids: set[str] = set() - for segment in path_item.path: - structure = segment.structure - if structure and structure.deviceId: - device_ids.add(structure.deviceId) - for device_id in device_ids: - booking_ids = self._services_by_device_id.setdefault(device_id, []) - if booking_id not in booking_ids: - booking_ids.append(booking_id) - def _build_port_indexes(self) -> None: - for node in self._node_status_items: - device_id = node.deviceId or node.pid or node.id - if not device_id: - continue - for module in _iter_modules(node.modules): - module_id = module.pid or module.id - for port in _iter_ports(module.ports): - indexed = _IndexedPort(device_id=device_id, module_id=module_id, port=port) - self._ports_by_device_id.setdefault(device_id, []).append(indexed) - port_id = _port_id_from_status(port) - if port_id is None: - continue - key = (device_id, port_id) - self._port_by_key[key] = indexed - self._ports_by_pid.setdefault(port_id, []).append(indexed) - - def _build_edge_indexes(self) -> None: - for pair_item in self._external_edge_items: - primary_device_id = pair_item.primary.devicePid - secondary_device_id = pair_item.secondary.devicePid - for side, device_id in ( - (pair_item.primary, primary_device_id), - (pair_item.secondary, secondary_device_id), - ): - if not device_id: - continue - for edge in side.data.values(): - from_device_id = ( - _device_id_from_context(edge.fromStatus.context if edge.fromStatus else None) - or primary_device_id - ) - from_port_id = _port_id_from_endpoint(edge.fromStatus) - to_device_id = ( - _device_id_from_context(edge.toStatus.context if edge.toStatus else None) - or secondary_device_id - ) - to_port_id = _port_id_from_endpoint(edge.toStatus) - indexed = _IndexedEdge( - edge_id=edge.id, - pair_id=pair_item.id, - edge=edge, - primary_device_id=primary_device_id, - secondary_device_id=secondary_device_id, - from_device_id=from_device_id, - from_port_id=from_port_id, - to_device_id=to_device_id, - to_port_id=to_port_id, - ) - self._edges_by_device_id.setdefault(device_id, []).append(indexed) - for endpoint_device_id, port_id in ( - (from_device_id, from_port_id), - (to_device_id, to_port_id), - ): - if endpoint_device_id and port_id: - self._edge_by_port_key[(endpoint_device_id, port_id)] = indexed +# --- Module-level helpers (kept stable for the domain layer) --- def _device_id_from_context(context: Any) -> str | None: @@ -394,15 +626,26 @@ def _port_id_from_status(port: InspectPortStatus) -> str | None: return port_id if port_id else None -def _iter_modules(modules: dict[str, InspectApiModuleStatus] | list[InspectApiModuleStatus]) -> Iterator[InspectApiModuleStatus]: +def _iter_modules( + modules: dict[str, InspectApiModuleStatus] | list[InspectApiModuleStatus] | None, +) -> Iterator[InspectApiModuleStatus]: + if not modules: + return if isinstance(modules, dict): yield from modules.values() return yield from modules -def _iter_ports(ports: dict[str, InspectPortStatus] | list[InspectPortStatus]) -> Iterator[InspectPortStatus]: +def _iter_ports( + ports: dict[str, InspectPortStatus] | list[InspectPortStatus] | None, +) -> Iterator[InspectPortStatus]: + if not ports: + return if isinstance(ports, dict): yield from ports.values() return yield from ports + + +__all__ = ["InspectSnapshot", "HydrationLevel"] diff --git a/src/videoipath_automation_tool/apps/videoipath_app.py b/src/videoipath_automation_tool/apps/videoipath_app.py index 5081dcf..1a48590 100644 --- a/src/videoipath_automation_tool/apps/videoipath_app.py +++ b/src/videoipath_automation_tool/apps/videoipath_app.py @@ -1,6 +1,7 @@ import logging from typing import Literal, Optional +from videoipath_automation_tool.apps.inspect.inspect_app import InspectApp from videoipath_automation_tool.apps.inventory import InventoryApp from videoipath_automation_tool.apps.inventory.model.drivers import AVAILABLE_SCHEMA_VERSIONS, SELECTED_SCHEMA_VERSION from videoipath_automation_tool.apps.preferences.preferences_app import PreferencesApp @@ -221,6 +222,7 @@ def __init__( self._preferences = None self._profile = None self._security = None + self._inspect = None self._logger.info("VideoIPath Automation Tool initialized.") @@ -230,6 +232,7 @@ def __init__( self._topology_api = self.topology._topology_api self._preferences_api = self.preferences._preferences_api self._profile_api = self.profile._profile_api + self._inspect_api = self.inspect._inspect_api # --- Getters to enable lazy loading --- @property @@ -267,6 +270,13 @@ def security(self): self._security = SecurityApp(vip_connector=self._videoipath_connector, logger=self._logger) return self._security + @property + def inspect(self): + if self._inspect is None: + self._logger.debug("InspectApp first called. Initialize InspectApp.") + self._inspect = InspectApp(vip_connector=self._videoipath_connector, logger=self._logger) + return self._inspect + # --- Basic Methods --- def _determine_fallback_driver_schema_version(self) -> Optional[str]: """ diff --git a/src/videoipath_automation_tool/connector/vip_rest_connector.py b/src/videoipath_automation_tool/connector/vip_rest_connector.py index 0269d7c..fce914d 100644 --- a/src/videoipath_automation_tool/connector/vip_rest_connector.py +++ b/src/videoipath_automation_tool/connector/vip_rest_connector.py @@ -13,7 +13,10 @@ class VideoIPathRestConnector(VideoIPathBaseConnector): }, "PATCH": {"PREFIXES": {"/rest/v2/data/config/"}, "EXACT_MATCHES": set()}, "POST": { - "PREFIXES": {"/rest/v2/actions/status/collector/"}, + "PREFIXES": { + "/rest/v2/actions/status/collector/", + "/rest/v2/actions/status/network/", + }, "EXACT_MATCHES": {"/rest/v2/actions/status/pathman/validateTopologyUpdate"}, }, } @@ -24,6 +27,7 @@ def get( auth_check: bool = True, node_check: bool = True, url_validation: bool = True, + allow_projection: bool = False, version: Literal["v2"] = "v2", ) -> ResponseV2Get: """ @@ -37,6 +41,11 @@ def get( auth_check (bool, optional): If `True`, verifies authentication status in the response (default: `True`). node_check (bool, optional): If `True`, ensures that all expected nodes are present in the response data (default: `True`). url_validation (bool, optional): If `True`, validates the URL path (default: `True`). + allow_projection (bool, optional): If `True`, permits scoped projection paths that contain + `/.../` up-navigation segments (used by the Inspect collector queries). When `True`, + `node_check` is implicitly skipped because projected responses do not contain the full + node structure. Defaults to `False` (the `/...` wildcard is rejected, preserving the + behaviour of all existing callers). version (Literal["v2"], optional): The API version to use (default: "v2"). Returns: @@ -56,10 +65,14 @@ def get( if url_validation: self._validate_url(url_path, "GET") - if "/..." in url_path: + if not allow_projection and "/..." in url_path: error_message = "Wildcard '/...' is not allowed in URL path." raise ValueError(error_message) + if allow_projection: + # Projected responses only carry the selected sub-tree, so the full-node check cannot pass. + node_check = False + response = self._execute_request( method="GET", url=self._build_url(url_path), diff --git a/tests/e2e/__init__.py b/tests/e2e/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/e2e/conftest.py b/tests/e2e/conftest.py new file mode 100644 index 0000000..cb66602 --- /dev/null +++ b/tests/e2e/conftest.py @@ -0,0 +1,39 @@ +"""Gating and shared fixtures for the developer-run live-server E2E suite ([ADR-0005]). + +These tests are excluded by default (``-m "not e2e"`` in ``pyproject.toml``) and only run when: + * you pass ``-m e2e`` on the command line, **and** + * ``VIPAT_E2E_ENABLED=1`` is set (put it in ``tests/.env.test`` next to the connection vars), **and** + * the target server is a verified version (>= 2025.4). + +Everything the suite writes is namespaced (``E2E-`` label prefix + ``vipat-e2e`` tag) so a shared +local instance is safe: the scenario teardown removes only that namespace. +""" + +from __future__ import annotations + +import os + +import pytest + +from videoipath_automation_tool.apps.inspect.inspect_app import _MIN_VERIFIED_VERSION, _parse_version +from videoipath_automation_tool.apps.videoipath_app import VideoIPathApp + + +def _e2e_enabled() -> bool: + return os.environ.get("VIPAT_E2E_ENABLED", "").strip() == "1" + + +@pytest.fixture(scope="session") +def app() -> VideoIPathApp: + """A live ``VideoIPathApp`` built from ``tests/.env.test``; skips unless E2E is enabled + verified.""" + if not _e2e_enabled(): + pytest.skip("E2E disabled (set VIPAT_E2E_ENABLED=1 in tests/.env.test to run).") + application = VideoIPathApp() + version = application._videoipath_connector.videoipath_version + parsed = _parse_version(version) + if parsed is None or parsed < _MIN_VERIFIED_VERSION: + pytest.skip( + f"Server version '{version}' is below the verified Inspect baseline " + f"{_MIN_VERIFIED_VERSION[0]}.{_MIN_VERIFIED_VERSION[1]}." + ) + return application diff --git a/tests/e2e/inspect/__init__.py b/tests/e2e/inspect/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/e2e/inspect/scenario.py b/tests/e2e/inspect/scenario.py new file mode 100644 index 0000000..59706ff --- /dev/null +++ b/tests/e2e/inspect/scenario.py @@ -0,0 +1,215 @@ +"""Fixed leaf-spine scenario for the Inspect E2E suite ([ADR-0005]). + +Declaratively models a small, fully-flexed leaf-spine network (two planes, red/blue), modelled on +the real local rig. Devices and their IP vertices are created through the **topology app** (vertices +cannot be created via ``updateTopology`` — [ADR-0009]); placement, connections, edits and removals +that Inspect owns are then driven through ``app.inspect``. + +Every created element is namespaced so a shared instance is safe: + * device/vertex labels start with ``E2E-``, + * every element carries the ``vipat-e2e`` tag. + +Vertex ids are **discovered** from an Inspect snapshot after creation (each vertex is created with a +unique descriptor label and looked up by ``port.label``), so the scenario does not depend on the +server's vertex-id format. + +NOTE (developer-run): the exact virtual-device build calls exercise the *experimental* topology +virtual-device API; confirm labels/ids on the first live run against your instance. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from videoipath_automation_tool.apps.videoipath_app import VideoIPathApp + +E2E_PREFIX = "E2E-" +E2E_TAG = "vipat-e2e" + +# Grid coordinates per role tier. +_Y_SPINE = 0.0 +_Y_LEAF = 200.0 +_Y_ENDPOINT = 400.0 +_X_STEP = 300.0 + + +@dataclass(frozen=True) +class DeviceSpec: + label: str + icon_type: str + ports: tuple[str, ...] + x: float + y: float + + +@dataclass(frozen=True) +class LinkSpec: + """A bidirectional connection between two ports (built as two directed edges).""" + + from_device: str + from_port: str + to_device: str + to_port: str + + +def _vertex_token(device_label: str, port: str, direction: str) -> str: + """Unique descriptor label used to discover the created vertex on the Inspect surface.""" + return f"{device_label}|{port}|{direction}" + + +def _build_specs() -> tuple[list[DeviceSpec], list[LinkSpec]]: + spines = [ + DeviceSpec("E2E-SPINE-A", "ipSwitchRouter", tuple(f"downlink{i}" for i in range(4)), 0 * _X_STEP, _Y_SPINE), + DeviceSpec("E2E-SPINE-B", "ipSwitchRouter", tuple(f"downlink{i}" for i in range(4)), 3 * _X_STEP, _Y_SPINE), + ] + leaf_ports = ("uplink0", "uplink1", "host0", "host1", "host2", "host3") + leaves = [ + DeviceSpec("E2E-LEAF-A1", "ipSwitchRouter", leaf_ports, 0 * _X_STEP, _Y_LEAF), + DeviceSpec("E2E-LEAF-A2", "ipSwitchRouter", leaf_ports, 1 * _X_STEP, _Y_LEAF), + DeviceSpec("E2E-LEAF-B1", "ipSwitchRouter", leaf_ports, 2 * _X_STEP, _Y_LEAF), + DeviceSpec("E2E-LEAF-B2", "ipSwitchRouter", leaf_ports, 3 * _X_STEP, _Y_LEAF), + ] + endpoint_ports = ("eth-a", "eth-b") + endpoints = [ + DeviceSpec("E2E-ENC-1", "encoder", endpoint_ports, 0 * _X_STEP, _Y_ENDPOINT), + DeviceSpec("E2E-ENC-2", "encoder", endpoint_ports, 1 * _X_STEP, _Y_ENDPOINT), + DeviceSpec("E2E-DEC-1", "decoder", endpoint_ports, 2 * _X_STEP, _Y_ENDPOINT), + DeviceSpec("E2E-DEC-2", "decoder", endpoint_ports, 3 * _X_STEP, _Y_ENDPOINT), + ] + + links: list[LinkSpec] = [ + # Uplinks: each leaf's uplink0 to a downlink on its plane's spine. + LinkSpec("E2E-LEAF-A1", "uplink0", "E2E-SPINE-A", "downlink0"), + LinkSpec("E2E-LEAF-A2", "uplink0", "E2E-SPINE-A", "downlink1"), + LinkSpec("E2E-LEAF-B1", "uplink0", "E2E-SPINE-B", "downlink0"), + LinkSpec("E2E-LEAF-B2", "uplink0", "E2E-SPINE-B", "downlink1"), + # Endpoint red-plane (eth-a) and blue-plane (eth-b) attachments. + LinkSpec("E2E-ENC-1", "eth-a", "E2E-LEAF-A1", "host0"), + LinkSpec("E2E-ENC-1", "eth-b", "E2E-LEAF-B1", "host0"), + LinkSpec("E2E-ENC-2", "eth-a", "E2E-LEAF-A1", "host1"), + LinkSpec("E2E-ENC-2", "eth-b", "E2E-LEAF-B1", "host1"), + LinkSpec("E2E-DEC-1", "eth-a", "E2E-LEAF-A2", "host0"), + LinkSpec("E2E-DEC-1", "eth-b", "E2E-LEAF-B2", "host0"), + LinkSpec("E2E-DEC-2", "eth-a", "E2E-LEAF-A2", "host1"), + LinkSpec("E2E-DEC-2", "eth-b", "E2E-LEAF-B2", "host1"), + ] + return [*spines, *leaves, *endpoints], links + + +class LeafSpineScenario: + """Builds, inspects, and tears down the fixed leaf-spine network on a live instance.""" + + def __init__(self) -> None: + self.devices, self.links = _build_specs() + # Resolved after build(): + self.device_ids: dict[str, str] = {} # label -> device id + self._vertex_ids: dict[str, str] = {} # token -> inspect vertex id + + # --- Introspection helpers --- + + @property + def device_labels(self) -> list[str]: + return [d.label for d in self.devices] + + def device_id(self, label: str) -> str: + return self.device_ids[label] + + def out_vertex(self, label: str, port: str) -> str: + return self._vertex_ids[_vertex_token(label, port, "out")] + + def in_vertex(self, label: str, port: str) -> str: + return self._vertex_ids[_vertex_token(label, port, "in")] + + def expected_edge_count(self) -> int: + # Two directed edges per bidirectional link. + return len(self.links) * 2 + + # --- Build --- + + def build(self, app: "VideoIPathApp") -> None: + for spec in self.devices: + device_id = self._create_device(app, spec) + self.device_ids[spec.label] = device_id + # Place the devices on the grid (they are already graph elements after creation). + with app.inspect.transaction() as tx: + for spec in self.devices: + tx.place_device(self.device_ids[spec.label], spec.x, spec.y) + tx.commit() + self._connect_links(app) + + def _create_device(self, app: "VideoIPathApp", spec: DeviceSpec) -> str: + device = app.topology.create_virtual_device() + device.configuration.base_device.label = spec.label + device.configuration.base_device.tags = [E2E_TAG] + device.add_virtual_module() + for port in spec.ports: + for direction, api_dir in (("out", "Out"), ("in", "In")): + vertex = device.add_virtual_ip_vertex(vertex_direction=api_dir) + vertex.label = _vertex_token(spec.label, port, direction) + vertex.tags = [E2E_TAG] + app.topology.add_device_initially(device) + # add_device_initially assigns the next free virtual id onto the base device; read it + # directly rather than looking up by label (the label index is only eventually consistent). + device_id = device.configuration.base_device.id + if not device_id: + raise RuntimeError(f"Device '{spec.label}' has no id after creation.") + # Virtual-device vertices do not surface as ports in the Inspect nodeStatus, so capture + # their ids straight from the created graph elements (keyed by the descriptor label we set). + for vtx in device.configuration.ip_vertices: + self._vertex_ids[vtx.label] = vtx.id + return device_id + + def _connect_links(self, app: "VideoIPathApp") -> None: + with app.inspect.transaction() as tx: + for link in self.links: + tx.connect( + self.out_vertex(link.from_device, link.from_port), + self.in_vertex(link.to_device, link.to_port), + bidirectional=False, + ) + tx.connect( + self.out_vertex(link.to_device, link.to_port), + self.in_vertex(link.from_device, link.from_port), + bidirectional=False, + ) + tx.commit() + + # --- Teardown --- + + def teardown(self, app: "VideoIPathApp") -> None: + """Remove all scenario edges then all scenario devices. Safe to call repeatedly. + + Discovers the currently-present E2E devices and their edges from a live snapshot (by the + ``E2E-`` label prefix), so it also cleans up orphaned runs and never tries to remove an edge + that is already gone. + """ + app.inspect.refresh() + # Only touch devices that currently exist (discovered by label prefix), so teardown is + # idempotent — a second call finds nothing and is a no-op. + known_ids = {d.id for d in app.inspect.devices if (d.label or "").startswith(E2E_PREFIX)} + if not known_ids: + return + # Edges first (only the ones that actually exist). + edge_ids = [ + edge.id + for edge in app.inspect.edges + if (edge.from_device and edge.from_device.id in known_ids) + or (edge.to_device and edge.to_device.id in known_ids) + ] + if edge_ids: + with app.inspect.transaction() as tx: + for edge_id in edge_ids: + tx.remove(edge_id) + tx.commit(check_conflicts=False) + for device_id in known_ids: + try: + app.topology.remove_device_by_id(device_id, ignore_affected_services=True) + except Exception: # already gone / not an nGraphElement — best-effort cleanup + pass + + def assert_clean(self, app: "VideoIPathApp") -> None: + app.inspect.refresh() + leftover = [d.label for d in app.inspect.devices if (d.label or "").startswith(E2E_PREFIX)] + assert not leftover, f"E2E devices still present after teardown: {leftover}" diff --git a/tests/e2e/inspect/test_e2e_leaf_spine.py b/tests/e2e/inspect/test_e2e_leaf_spine.py new file mode 100644 index 0000000..c39343b --- /dev/null +++ b/tests/e2e/inspect/test_e2e_leaf_spine.py @@ -0,0 +1,189 @@ +"""End-to-end Inspect tests against a live instance using the fixed leaf-spine scenario. + +Developer-run only (see ``tests/e2e/conftest.py``). Ordered by dependency and sharing one +session-scoped scenario that is built once and torn down at the end (an autouse finalizer also +cleans up if a run aborts). All writes stay inside the ``E2E-`` / ``vipat-e2e`` namespace. + +Everything goes through ``app.inspect`` — the app owns its topology view internally; tests call +``app.inspect.refresh()`` to get a fresh read and then use ``app.inspect.devices`` / ``get_device`` / +``edges`` directly. + +Run with:: + + poetry run pytest -m e2e tests/e2e/inspect +""" + +from __future__ import annotations + +import pytest + +from videoipath_automation_tool.apps.inspect.errors import InspectCommitConflictError, InspectCommitError +from videoipath_automation_tool.apps.videoipath_app import VideoIPathApp + +from .scenario import E2E_PREFIX, LeafSpineScenario + +pytestmark = pytest.mark.e2e + + +@pytest.fixture(scope="session") +def scenario(app: VideoIPathApp): + scn = LeafSpineScenario() + scn.teardown(app) # clean slate from any aborted prior run + scn.build(app) + yield scn + scn.teardown(app) + scn.assert_clean(app) + + +class _FetchSpy: + """Wraps get_device_detail to count hydration fetches.""" + + def __init__(self, api): + self._api = api + self._orig = api.get_device_detail + self.count = 0 + + def __enter__(self): + def counting(device_id): + self.count += 1 + return self._orig(device_id) + + self._api.get_device_detail = counting + return self + + def __exit__(self, *exc): + self._api.get_device_detail = self._orig + + +def test_build_and_skeleton_read(app, scenario): + app.inspect.refresh() + with _FetchSpy(app.inspect._inspect_api) as spy: + labels = {d.label for d in app.inspect.devices} + for expected in scenario.device_labels: + assert expected in labels + known = set(scenario.device_ids.values()) + scenario_edges = [ + e + for e in app.inspect.edges + if (e.from_device and e.from_device.id in known) or (e.to_device and e.to_device.id in known) + ] + assert len(scenario_edges) >= scenario.expected_edge_count() + assert spy.count == 0 # skeleton read triggers no hydration + + +def test_lazy_hydration(app, scenario): + # Virtual devices do not expose ports in nodeStatus; this asserts the hydration *mechanic* + # (exactly one detail fetch per device, cached thereafter, hydration state flips). + app.inspect.refresh() + leaf_id = scenario.device_id("E2E-LEAF-A1") + other_id = scenario.device_id("E2E-SPINE-A") + assert not app.inspect.is_device_hydrated(leaf_id) + with _FetchSpy(app.inspect._inspect_api) as spy: + _ = app.inspect.get_device(leaf_id).ports # triggers one detail fetch + assert spy.count == 1 + _ = app.inspect.get_device(leaf_id).ports # cached, no further fetch + assert spy.count == 1 + assert app.inspect.is_device_hydrated(leaf_id) + assert not app.inspect.is_device_hydrated(other_id) + + +def test_connectivity_graph(app, scenario): + app.inspect.refresh() + enc1 = app.inspect.get_device(scenario.device_id("E2E-ENC-1")) + linked = {d.label for d in enc1.linked_devices} + assert linked == {"E2E-LEAF-A1", "E2E-LEAF-B1"} + # Spine adjacency: SPINE-A is linked to both red leaves. + spine_a = app.inspect.get_device(scenario.device_id("E2E-SPINE-A")) + spine_links = {d.label for d in spine_a.linked_devices} + assert {"E2E-LEAF-A1", "E2E-LEAF-A2"} <= spine_links + + +def test_edge_pair_refresh(app, scenario): + app.inspect.refresh() # load the internal view so the write can update it in place + edge_id = f"{scenario.out_vertex('E2E-LEAF-A1', 'uplink0')}::{scenario.in_vertex('E2E-SPINE-A', 'downlink0')}" + result = app.inspect.update_edge(edge_id, weight=7) + assert result.ok + # The edge survives the targeted post-commit refresh without a full reload. + assert any(e.id == edge_id for e in app.inspect.edges) + app.inspect.update_edge(edge_id, weight=1) # revert + + +def test_transaction_atomicity(app, scenario): + edge_id = f"{scenario.out_vertex('E2E-LEAF-A2', 'uplink0')}::{scenario.in_vertex('E2E-SPINE-A', 'downlink1')}" + with pytest.raises(InspectCommitError): + with app.inspect.transaction() as tx: + tx.update_edge(edge_id, weight=13) + tx.remove("does-not-exist::also-not-real") + tx.commit() + # Nothing applied: the edge is still present on the server. + app.inspect.refresh() + assert any(e.id == edge_id for e in app.inspect.edges) + + +def test_conflict_detection(app, scenario): + edge_id = f"{scenario.out_vertex('E2E-LEAF-B1', 'uplink0')}::{scenario.in_vertex('E2E-SPINE-B', 'downlink0')}" + tx = app.inspect.transaction() + tx.update_edge(edge_id, weight=21) + # Out-of-band change via a second app instance. + other = VideoIPathApp() + other.inspect.update_edge(edge_id, weight=9) + with pytest.raises(InspectCommitConflictError) as exc: + tx.commit() + assert any(c.entity_id == edge_id for c in exc.value.conflicts) + tx.rebase() + tx.commit() + app.inspect.update_edge(edge_id, weight=1) # revert + + +def test_device_placement_roundtrip(app, scenario): + device_id = scenario.device_id("E2E-ENC-1") + app.inspect.place_device(device_id, 4200, 4200) + app.inspect.refresh() + coords = app.inspect.get_device(device_id).coordinates + assert coords is not None and coords["x"] == 4200 and coords["y"] == 4200 + # revert to scenario coordinates + spec = next(d for d in scenario.devices if d.label == "E2E-ENC-1") + app.inspect.place_device(device_id, spec.x, spec.y) + + +def test_disconnect_connect_cycle(app, scenario): + a_out = scenario.out_vertex("E2E-DEC-2", "eth-a") + b_in = scenario.in_vertex("E2E-LEAF-A2", "host1") + b_out = scenario.out_vertex("E2E-LEAF-A2", "host1") + a_in = scenario.in_vertex("E2E-DEC-2", "eth-a") + app.inspect.disconnect(a_out, b_in, bidirectional=False) + app.inspect.disconnect(b_out, a_in, bidirectional=False) + app.inspect.refresh() + assert not any(e.id == f"{a_out}::{b_in}" for e in app.inspect.edges) + # reconnect + app.inspect.connect(a_out, b_in, bidirectional=False) + app.inspect.connect(b_out, a_in, bidirectional=False) + app.inspect.refresh() + assert any(e.id == f"{a_out}::{b_in}" for e in app.inspect.edges) + + +def test_full_vs_skeleton_equivalence(app, scenario): + def graph_view() -> dict: + return { + device_id: ( + app.inspect.get_device(device_id).label, + frozenset(d.label for d in app.inspect.get_device(device_id).linked_devices), + ) + for device_id in scenario.device_ids.values() + } + + app.inspect.refresh(load="skeleton") + app.inspect.preload(list(scenario.device_ids.values())) + skeleton = graph_view() + app.inspect.refresh(load="full") + full = graph_view() + assert skeleton == full + app.inspect.refresh(load="skeleton") # restore default load mode for later tests + + +def test_teardown(app, scenario): + # Explicit teardown assertion; the fixture finalizer repeats it for aborted runs. + scenario.teardown(app) + scenario.assert_clean(app) + app.inspect.refresh() + assert not any((d.label or "").startswith(E2E_PREFIX) for d in app.inspect.devices) diff --git a/tests/fixtures/inspect/2025.4.9/update_topology_replace_devices.json b/tests/fixtures/inspect/2025.4.9/update_topology_replace_devices.json new file mode 100644 index 0000000..be47ae5 --- /dev/null +++ b/tests/fixtures/inspect/2025.4.9/update_topology_replace_devices.json @@ -0,0 +1,72 @@ +{ + "request": { + "header": { "id": 0 }, + "data": { + "replaceDevices": { + "device-a": { + "coordinates": { "x": 1600.0, "y": 9050.0 }, + "descriptor": { "desc": "", "label": "" }, + "iconSize": "medium", + "iconType": "default", + "localAssignedTags": [], + "sdpStrategy": "always", + "siteId": null, + "tags": [], + "virtualDeviceFields": null + } + }, + "replaceVertices": {}, + "replaceEdges": {}, + "replaceResourceTransforms": {}, + "addExternalEdges": [], + "remove": [], + "force": false + } + }, + "response": { + "data": { + "items": [ + { + "external": null, + "id": "device-a", + "idx": null, + "res": { "msg": [""], "ok": true } + } + ], + "res": { "msg": [], "ok": true }, + "validation": { + "createIds": [], + "details": {}, + "result": { "msg": [], "ok": true } + } + }, + "header": { "ok": true, "code": "OK" } + }, + "rejected_request_error": { + "note": "Sending the raw persisted baseDevice element (maps[], fDescriptor, type) instead of the edit form is rejected:", + "header": { + "auth": true, + "caption": "Invalid Request", + "code": "INVALID_REQUEST", + "errorDetails": [ + { + "msg": "Mandatory field 'localAssignedTags' not present in input", + "path": ["replaceDevices", "device-a", "localAssignedTags"], + "type": "conversionError" + }, + { + "msg": "Mandatory field 'coordinates' not present in input", + "path": ["replaceDevices", "device-a", "coordinates"], + "type": "conversionError" + } + ], + "id": "0", + "msg": [ + "Mandatory field 'localAssignedTags' not present in input", + "Mandatory field 'coordinates' not present in input" + ], + "ok": false, + "user": "api-user" + } + } +} diff --git a/tests/fixtures/inspect/2025.4.9/update_topology_replace_vertices.json b/tests/fixtures/inspect/2025.4.9/update_topology_replace_vertices.json new file mode 100644 index 0000000..bc6fd19 --- /dev/null +++ b/tests/fixtures/inspect/2025.4.9/update_topology_replace_vertices.json @@ -0,0 +1,80 @@ +{ + "request": { + "header": { "id": 0 }, + "data": { + "replaceDevices": {}, + "replaceVertices": { + "device-a.module-1.port-out-1.out": { + "active": true, + "controlProps": null, + "custom": {}, + "desc": "", + "destinationMonitorLeader": false, + "extraAlertFilters": [], + "label": "", + "localAssignedTags": [], + "queueable": false, + "sipsMode": "NONE", + "tags": [], + "typeFields": { + "ipAddress": null, + "ipNetmask": null, + "public": false, + "supportsCpipeCfg": false, + "supportsIgmpCfg": false, + "supportsMacForwardingCfg": false, + "supportsNsoCfg": false, + "supportsOpenflowCfg": false, + "supportsStaticIgmpCfg": false, + "supportsVlanCfg": false, + "supportsVplsCfg": false, + "type": "ip", + "vlanId": null, + "vrfId": null + }, + "useAsEndpoint": false + } + }, + "replaceEdges": {}, + "replaceResourceTransforms": {}, + "addExternalEdges": [], + "remove": [], + "force": false + } + }, + "response": { + "data": { + "items": [ + { + "external": null, + "id": "device-a.module-1.port-out-1.out", + "idx": null, + "res": { "msg": [""], "ok": true } + } + ], + "res": { "msg": [], "ok": true }, + "validation": { + "createIds": [], + "details": {}, + "result": { "msg": [], "ok": true } + } + }, + "header": { "ok": true, "code": "OK" } + }, + "unknown_id_validation_failure": { + "note": "Committing a vertex id that does not exist in the graph passes schema conversion but fails validation — replaceVertices is update-only:", + "data": { + "items": [], + "res": { "msg": ["Validation failed"], "ok": false }, + "validation": { + "createIds": [], + "details": {}, + "result": { + "msg": ["Vertex with id device-a.module-9.new-port.out was not found in graph"], + "ok": false + } + } + }, + "header": { "ok": true, "code": "OK" } + } +} diff --git a/tests/inspect/__init__.py b/tests/inspect/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/inspect/conftest.py b/tests/inspect/conftest.py new file mode 100644 index 0000000..075cab1 --- /dev/null +++ b/tests/inspect/conftest.py @@ -0,0 +1,23 @@ +"""Shared fixtures for offline Inspect tests.""" + +import json +from pathlib import Path + +import pytest + +FIXTURE_DIR = Path(__file__).parent.parent / "fixtures" / "inspect" / "2025.4.9" + + +def load_fixture(name: str) -> dict: + with open(FIXTURE_DIR / name) as handle: + return json.load(handle) + + +@pytest.fixture +def fixtures_dir() -> Path: + return FIXTURE_DIR + + +@pytest.fixture +def load(): + return load_fixture diff --git a/tests/inspect/test_actions.py b/tests/inspect/test_actions.py new file mode 100644 index 0000000..f9a0da8 --- /dev/null +++ b/tests/inspect/test_actions.py @@ -0,0 +1,68 @@ +"""Network-action mixin tests with a fake connector.""" + +from types import SimpleNamespace + +import pytest + +from videoipath_automation_tool.apps.inspect.app.actions import ConflictStrategy, InspectActionsMixin +from videoipath_automation_tool.apps.inspect.inspect_api import InspectAPI + + +def _ok_header(): + return SimpleNamespace( + model_dump=lambda mode="json": { + "auth": True, "caption": "OK", "code": "OK", "errorCodes": [], "errorDetails": [], + "id": "0", "msg": [], "ok": True, "user": "api-user", + } + ) + + +class FakeRest: + def __init__(self, post_data): + self._post_data = post_data + self.post_calls = [] + + def post(self, url_path, body, **kwargs): + self.post_calls.append((url_path, body.model_dump(mode="json", by_alias=True))) + return SimpleNamespace(data=self._post_data, header=_ok_header()) + + +class _App(InspectActionsMixin): + def __init__(self, post_data): + import logging + + self._logger = logging.getLogger("test") + self._inspect_api = InspectAPI(SimpleNamespace(rest=FakeRest(post_data)), self._logger) + + +def test_conflict_strategy_values(): + assert int(ConflictStrategy.STRICT) == 0 + assert int(ConflictStrategy.INVALIDATE_SERVICES) == 1 + assert int(ConflictStrategy.CANCEL_SERVICES) == 2 + + +def test_add_devices_builds_placement_items(): + app = _App({"msg": [], "ok": True}) + assert app.add_devices_to_topology([("device12", 100, 200), "device13"]) is True + _, payload = app._inspect_api.vip_connector.rest.post_calls[0] + assert payload["data"] == [{"id": "device12", "x": 100, "y": 200}, {"id": "device13", "x": 0, "y": 0}] + + +def test_sync_devices_passes_strategy(): + app = _App({"msg": [], "ok": True}) + app.sync_devices(["device12"], add_only=True, conflict_strategy=ConflictStrategy.CANCEL_SERVICES) + _, payload = app._inspect_api.vip_connector.rest.post_calls[0] + assert payload["data"] == {"ids": ["device12"], "addOnly": True, "conflictStrategy": 2} + + +def test_sync_devices_reports_failure(): + app = _App({"msg": ["No topology reported by the device"], "ok": False}) + assert app.sync_devices(["device12"]) is False + + +def test_empty_lists_rejected(): + app = _App({"msg": [], "ok": True}) + with pytest.raises(ValueError): + app.sync_devices([]) + with pytest.raises(ValueError): + app.get_sync_info([]) diff --git a/tests/inspect/test_api.py b/tests/inspect/test_api.py new file mode 100644 index 0000000..a8458bf --- /dev/null +++ b/tests/inspect/test_api.py @@ -0,0 +1,101 @@ +"""InspectAPI wiring tests with a fake REST connector: verifies each method hits the right +endpoint, passes allow_projection for scoped reads, and parses responses into DTOs.""" + +from types import SimpleNamespace + +from videoipath_automation_tool.apps.inspect.inspect_api import InspectAPI +from videoipath_automation_tool.apps.inspect.model.update_topology import InspectApiUpdateTopologyData + + +class FakeRest: + def __init__(self, get_data=None, post_data=None): + self._get_data = get_data or {} + self._post_data = post_data or {} + self.get_calls = [] + self.post_calls = [] + + def get(self, url_path, allow_projection=False, **kwargs): + self.get_calls.append((url_path, allow_projection)) + return SimpleNamespace(data=self._get_data, header=_ok_header()) + + def post(self, url_path, body, **kwargs): + self.post_calls.append((url_path, body)) + return SimpleNamespace(data=self._post_data, header=_ok_header()) + + +def _ok_header(): + return SimpleNamespace( + model_dump=lambda mode="json": { + "auth": True, "caption": "OK", "code": "OK", "errorCodes": [], "errorDetails": [], + "id": "0", "msg": [], "ok": True, "user": "api-user", + } + ) + + +def _connector(get_data=None, post_data=None): + rest = FakeRest(get_data=get_data, post_data=post_data) + return SimpleNamespace(rest=rest), rest + + +def _collector(node_items=None, edge_items=None, path_items=None): + return { + "status": { + "collector": { + "inspect": { + "nodeStatus": {"_items": node_items or []}, + "paths": {"_items": path_items or []}, + }, + "externalEdgesByDeviceKey": {"_items": edge_items or []}, + } + } + } + + +def test_device_skeleton_uses_projection_and_parses(load): + node_items = load("skeleton_nodestatus_short.json")["data"]["status"]["collector"]["inspect"]["nodeStatus"]["_items"] + conn, rest = _connector(get_data=_collector(node_items=node_items)) + api = InspectAPI(conn) + devices = api.get_device_skeleton() + assert len(devices) == len(node_items) + assert rest.get_calls[0][1] is True # allow_projection + + +def test_edge_skeleton_parses(load): + edge_items = load("edge_skeleton.json")["data"]["status"]["collector"]["externalEdgesByDeviceKey"]["_items"] + conn, rest = _connector(get_data=_collector(edge_items=edge_items)) + api = InspectAPI(conn) + edges = api.get_edge_skeleton() + assert len(edges) == len(edge_items) + + +def test_device_detail_returns_none_when_absent(): + conn, rest = _connector(get_data=_collector(node_items=[])) + api = InspectAPI(conn) + assert api.get_device_detail("deviceX") is None + + +def test_lookup_edges_hits_correct_endpoint(load): + conn, rest = _connector(post_data=load("lookup_inspect_edges_by_ids.json")["data"]) + api = InspectAPI(conn) + resp = api.lookup_edges(["a::b"]) + assert rest.post_calls[0][0].endswith("/lookupInspectEdgesByIds") + assert resp.data + + +def test_update_topology_posts_delta(): + conn, rest = _connector( + post_data={"items": [], "res": {"msg": [], "ok": True}, "validation": {"details": {}, "result": {"msg": [], "ok": True}}} + ) + api = InspectAPI(conn) + resp = api.update_topology(InspectApiUpdateTopologyData()) + assert rest.post_calls[0][0].endswith("/updateTopology") + assert resp.committed is True + + +def test_add_and_sync_devices_endpoints(): + conn, rest = _connector(post_data={"msg": [], "ok": True}) + api = InspectAPI(conn) + api.add_devices([]) + api.sync_devices([], add_only=True, conflict_strategy=0) + assert rest.post_calls[0][0].endswith("/network/addDevices") + assert rest.post_calls[1][0].endswith("/network/syncDevices") diff --git a/tests/inspect/test_changeset.py b/tests/inspect/test_changeset.py new file mode 100644 index 0000000..7b5165e --- /dev/null +++ b/tests/inspect/test_changeset.py @@ -0,0 +1,345 @@ +"""Change-set / transaction unit tests with a fake API (offline). + +Covers staging + intent application, payload-shape assertions against the verified write forms, +the three-flag commit result, conflict detection (compare-and-commit), the ``check_conflicts=False`` +bypass, ``rebase``, and the post-commit targeted-refresh hook derivation. +""" + +from types import SimpleNamespace + +import pytest + +from videoipath_automation_tool.apps.inspect.changeset import InspectTransaction +from videoipath_automation_tool.apps.inspect.errors import ( + InspectCommitConflictError, + InspectCommitError, + InspectEntityNotFoundError, +) +from videoipath_automation_tool.apps.inspect.model.actions import ( + InspectApiLookupEdgeResponseItem, + InspectApiLookupInspectDeviceResponse, + InspectApiLookupVertexResponseData, +) +from videoipath_automation_tool.apps.inspect.model.update_topology import InspectApiUpdateTopologyResponse + +from .conftest import load_fixture + +_OK_HEADER = { + "auth": True, "caption": "OK", "code": "OK", "errorCodes": [], "errorDetails": [], + "id": "0", "msg": [], "ok": True, "user": "api-user", +} + +A_OUT = "device12.1.Ethernet1.out" +B_IN = "device7.0.swp1.in" +EDGE_ID = f"{A_OUT}::{B_IN}" + + +def _device_response(label="Dev A", coordinates=None, icon_type="switch", tags=None): + return InspectApiLookupInspectDeviceResponse.model_validate( + { + "data": { + "assignedTags": {"all": [], "inherited": {}, "inheritedConflict": False, "local": {}}, + "fields": { + "coordinates": coordinates or {"x": 0, "y": 0}, + "descriptor": {"desc": "", "label": label}, + "iconSize": "medium", + "iconType": icon_type, + "localAssignedTags": [], + "sdpStrategy": None, + "siteId": None, + "tags": tags or [], + "virtualDeviceFields": None, + }, + }, + "header": _OK_HEADER, + } + ) + + +def _vertex_data(): + fixture = load_fixture("lookup_inspect_vertex_by_id.json") + return InspectApiLookupVertexResponseData.model_validate(fixture["data"]) + + +def _edge_item(weight=1): + edge = { + "active": True, "bandwidth": -1.0, "capacity": 65535, "conflictPri": 0, + "descriptor": {"desc": "", "label": ""}, "excludeFormats": [], + "fDescriptor": {"desc": "", "label": ""}, "fromId": A_OUT, "includeFormats": [], + "redundancyMode": "Any", "tags": [], "toId": B_IN, "weight": weight, + "weightFactors": {"bandwidth": {"weight": 0}, "service": {"max": 100, "weight": 0}}, + } + return InspectApiLookupEdgeResponseItem.model_validate({"edge": edge, "fromDevice": "device12", "toDevice": "device7"}) + + +class FakeAPI: + def __init__(self): + self.devices = {} + self.vertices = {} + self.edges = {} + self.update_response = InspectApiUpdateTopologyResponse.model_validate(load_fixture("update_topology_success.json")) + self.update_calls = [] + self.lookup_device_calls = [] + self.lookup_vertices_calls = [] + self.lookup_edges_calls = [] + + def lookup_inspect_device(self, device_id): + self.lookup_device_calls.append(device_id) + if device_id not in self.devices: + raise KeyError(device_id) + return self.devices[device_id] + + def lookup_vertices(self, ids): + self.lookup_vertices_calls.append(list(ids)) + return SimpleNamespace(data={i: self.vertices[i] for i in ids if i in self.vertices}) + + def lookup_edges(self, ids): + self.lookup_edges_calls.append(list(ids)) + return SimpleNamespace(data={i: self.edges[i] for i in ids if i in self.edges}) + + def update_topology(self, delta): + self.update_calls.append(delta) + return self.update_response + + +class FakeSnapshot: + def __init__(self): + self.calls = [] + + def apply_post_commit(self, **kwargs): + self.calls.append(kwargs) + + +def _txn(api, snapshot=None): + return InspectTransaction(api, snapshot=snapshot) + + +# --- Staging + payload shape --- + + +def test_connect_builds_edge_payload(): + api = FakeAPI() + with _txn(api) as tx: + tx.connect(A_OUT, B_IN, bidirectional=False, weight=10) + tx.commit() + delta = api.update_calls[0] + assert list(delta.replaceEdges) == [EDGE_ID] + edge = delta.replaceEdges[EDGE_ID] + assert edge.fromId == A_OUT and edge.toId == B_IN + assert edge.weight == 10 and edge.capacity == 65535 and edge.redundancyMode == "Any" + + +def test_connect_bidirectional_stages_reverse_edge(): + api = FakeAPI() + with _txn(api) as tx: + tx.connect(A_OUT, B_IN, bidirectional=True) + tx.commit() + keys = set(api.update_calls[0].replaceEdges) + assert keys == {EDGE_ID, "device7.0.swp1.out::device12.1.Ethernet1.in"} + + +def test_connect_existing_edge_requires_overwrite(): + api = FakeAPI() + api.edges[EDGE_ID] = _edge_item() + with _txn(api) as tx: + with pytest.raises(ValueError, match="already exists"): + tx.connect(A_OUT, B_IN, bidirectional=False) + + +def test_update_edge_applies_intent(): + api = FakeAPI() + api.edges[EDGE_ID] = _edge_item() + with _txn(api) as tx: + tx.update_edge(EDGE_ID, weight=42) + tx.commit() + assert api.update_calls[0].replaceEdges[EDGE_ID].weight == 42 + + +def test_update_edge_missing_raises_not_found(): + api = FakeAPI() + with _txn(api) as tx: + with pytest.raises(InspectEntityNotFoundError): + tx.update_edge(EDGE_ID, weight=42) + + +def test_update_device_only_changes_label_when_set(): + api = FakeAPI() + api.devices["device12"] = _device_response(label="Original", icon_type="switch") + with _txn(api) as tx: + tx.update_device("device12", icon_type="router") + tx.commit() + form = api.update_calls[0].replaceDevices["device12"] + assert form.descriptor.label == "Original" # round-tripped, not cleared (descriptor mandatory) + assert form.iconType == "router" + + +def test_update_device_sets_label(): + api = FakeAPI() + api.devices["device12"] = _device_response(label="Original") + with _txn(api) as tx: + tx.update_device("device12", label="BU-LEAF-A") + tx.commit() + assert api.update_calls[0].replaceDevices["device12"].descriptor.label == "BU-LEAF-A" + + +def test_place_device_sets_coordinates(): + api = FakeAPI() + api.devices["device12"] = _device_response() + with _txn(api) as tx: + tx.place_device("device12", 1600, 9050) + tx.commit() + assert api.update_calls[0].replaceDevices["device12"].coordinates == {"x": 1600, "y": 9050} + + +def test_update_vertex_sets_endpoint(): + api = FakeAPI() + api.vertices[A_OUT] = _vertex_data() + with _txn(api) as tx: + tx.update_vertex(A_OUT, use_as_endpoint=True) + tx.commit() + assert api.update_calls[0].replaceVertices[A_OUT].useAsEndpoint is True + + +def test_remove_appends_to_remove_list(): + api = FakeAPI() + with _txn(api) as tx: + tx.remove(EDGE_ID) + tx.commit() + assert api.update_calls[0].remove == [EDGE_ID] + + +def test_disconnect_removes_both_directions(): + api = FakeAPI() + with _txn(api) as tx: + tx.disconnect(A_OUT, B_IN, bidirectional=True) + tx.commit() + assert set(api.update_calls[0].remove) == {EDGE_ID, "device7.0.swp1.out::device12.1.Ethernet1.in"} + + +# --- Commit result / failure --- + + +def test_commit_success_returns_result(): + api = FakeAPI() + api.edges[EDGE_ID] = _edge_item() + with _txn(api) as tx: + tx.update_edge(EDGE_ID, weight=5) + result = tx.commit() + assert result.ok is True + assert result.applied_ids == [EDGE_ID] + + +def test_commit_failure_raises_commit_error(): + api = FakeAPI() + api.update_response = InspectApiUpdateTopologyResponse.model_validate(load_fixture("update_topology_fail_remove.json")) + with _txn(api) as tx: + tx.remove("nonexistent-edge-id-xyz") + with pytest.raises(InspectCommitError) as exc: + tx.commit() + assert "non-existent" in str(exc.value) + + +def test_empty_commit_rejected(): + api = FakeAPI() + tx = _txn(api) + with pytest.raises(ValueError, match="Nothing staged"): + tx.commit() + + +# --- Conflict detection (ADR-0009) --- + + +def test_conflict_detected_on_baseline_change(): + api = FakeAPI() + api.edges[EDGE_ID] = _edge_item(weight=1) + tx = _txn(api) + tx.update_edge(EDGE_ID, weight=99) + api.edges[EDGE_ID] = _edge_item(weight=5) # concurrent out-of-band change + with pytest.raises(InspectCommitConflictError) as exc: + tx.commit() + conflict = exc.value.conflicts[0] + assert conflict.entity_id == EDGE_ID + assert "weight" in conflict.field_diffs + assert conflict.field_diffs["weight"] == (1, 5) + assert not api.update_calls # nothing sent + + +def test_conflict_check_bypass(): + api = FakeAPI() + api.edges[EDGE_ID] = _edge_item(weight=1) + tx = _txn(api) + tx.update_edge(EDGE_ID, weight=99) + api.edges[EDGE_ID] = _edge_item(weight=5) + tx.commit(check_conflicts=False) + assert api.update_calls # sent despite the change + + +def test_conflict_when_entity_vanishes(): + api = FakeAPI() + api.edges[EDGE_ID] = _edge_item() + tx = _txn(api) + tx.update_edge(EDGE_ID, weight=7) + api.edges.clear() # removed out-of-band + with pytest.raises(InspectCommitConflictError) as exc: + tx.commit() + assert "__exists__" in exc.value.conflicts[0].field_diffs + + +def test_rebase_refetches_baseline(): + api = FakeAPI() + api.edges[EDGE_ID] = _edge_item(weight=1) + tx = _txn(api) + tx.update_edge(EDGE_ID, weight=99) + api.edges[EDGE_ID] = _edge_item(weight=5) + with pytest.raises(InspectCommitConflictError): + tx.commit() + tx.rebase() + tx.commit() # now baseline == server, no conflict + assert api.update_calls[0].replaceEdges[EDGE_ID].weight == 99 + + +# --- Post-commit refresh hook derivation (ADR-0010) --- + + +def test_post_commit_refresh_derives_affected_ids(): + api = FakeAPI() + snapshot = FakeSnapshot() + with _txn(api, snapshot=snapshot) as tx: + tx.connect(A_OUT, B_IN, bidirectional=True) + tx.commit() + call = snapshot.calls[0] + assert set(call["pair_ids"]) == {"device12::device7", "device7::device12"} + assert call["device_ids"] == [] + assert call["mark_paths_stale"] is True + + +def test_post_commit_refresh_vertex_maps_to_device(): + api = FakeAPI() + api.vertices[A_OUT] = _vertex_data() + snapshot = FakeSnapshot() + with _txn(api, snapshot=snapshot) as tx: + tx.update_vertex(A_OUT, use_as_endpoint=True) + tx.commit() + assert snapshot.calls[0]["device_ids"] == ["device12"] + + +# --- Lifecycle --- + + +def test_reuse_after_commit_rejected(): + api = FakeAPI() + api.edges[EDGE_ID] = _edge_item() + tx = _txn(api) + tx.update_edge(EDGE_ID, weight=5) + tx.commit() + with pytest.raises(RuntimeError, match="already committed"): + tx.update_edge(EDGE_ID, weight=6) + + +def test_context_exit_without_commit_discards(caplog): + api = FakeAPI() + api.edges[EDGE_ID] = _edge_item() + with _txn(api) as tx: + tx.update_edge(EDGE_ID, weight=5) + assert not api.update_calls + assert tx._discarded diff --git a/tests/inspect/test_models.py b/tests/inspect/test_models.py new file mode 100644 index 0000000..3d2f25d --- /dev/null +++ b/tests/inspect/test_models.py @@ -0,0 +1,119 @@ +"""Contract tests: every fixture parses into its DTO, and write-payload builders reproduce +the verified request shapes byte-for-byte.""" + +from videoipath_automation_tool.apps.inspect.model.actions import ( + InspectApiLookupEdgesResponse, + InspectApiLookupInspectDeviceResponse, + InspectApiLookupVertexResponse, +) +from videoipath_automation_tool.apps.inspect.model.collector import ( + InspectApiExternalEdgesByDeviceKeyItem, + InspectApiNodeStatusItem, + InspectApiPathItem, +) +from videoipath_automation_tool.apps.inspect.model.update_topology import ( + InspectApiUpdateTopologyData, + InspectApiUpdateTopologyResponse, +) + + +def _node_items(payload): + return payload["data"]["status"]["collector"]["inspect"]["nodeStatus"]["_items"] + + +def test_device_skeleton_items_parse_and_expose_effective_label(load): + items = _node_items(load("skeleton_nodestatus_short.json")) + assert items + for raw in items: + node = InspectApiNodeStatusItem.model_validate(raw) + assert node.id + assert node.effective_label # comes from descriptor.label, not a top-level label + + +def test_device_detail_parses_modules_and_ports(load): + items = _node_items(load("device_hydration_modules_ports.json")) + node = InspectApiNodeStatusItem.model_validate(items[0]) + assert node.modules + module = next(iter(node.modules.values())) + assert module.ports + + +def test_edge_skeleton_items_parse(load): + items = load("edge_skeleton.json")["data"]["status"]["collector"]["externalEdgesByDeviceKey"]["_items"] + assert len(items) > 0 + edge = InspectApiExternalEdgesByDeviceKeyItem.model_validate(items[0]) + assert "::" in edge.id + assert edge.primary is not None + + +def test_paths_fixture_parses(load): + items = load("inspect_paths_limit5.json")["data"]["status"]["collector"]["inspect"]["paths"]["_items"] + for raw in items: + path = InspectApiPathItem.model_validate(raw) + assert path.serviceFields.bid + + +def test_lookup_edges_returns_full_persisted_edge_form(load): + resp = InspectApiLookupEdgesResponse.model_validate(load("lookup_inspect_edges_by_ids.json")) + key = next(iter(resp.data)) + edge = resp.data[key].edge + assert edge.fromId and edge.toId + assert edge.capacity == 65535 + + +def test_lookup_vertex_edit_form(load): + resp = InspectApiLookupVertexResponse.model_validate(load("lookup_inspect_vertex_by_id.json")) + assert resp.data.fields.typeFields is not None + assert resp.data.vertexType in ("In", "Out", "Internal", None) + + +def test_lookup_device_fields(): + resp = InspectApiLookupInspectDeviceResponse.model_validate(_device_lookup_fixture()) + assert resp.data.fields.coordinates is not None + + +def _device_lookup_fixture(): + # lookupInspectDevice example from endpoints.md (anonymized) + return { + "data": { + "assignedTags": {"all": [], "inherited": {}, "inheritedConflict": False, "local": {}}, + "fields": { + "coordinates": {"x": 500, "y": 8150}, + "descriptor": {"desc": "", "label": "Example Device A"}, + "iconSize": "medium", + "iconType": "gateway", + "localAssignedTags": [], + "sdpStrategy": "always", + "siteId": None, + "tags": [], + "virtualDeviceFields": None, + }, + }, + "header": {"auth": True, "caption": "OK", "code": "OK", "id": "0", "msg": [], "ok": True, "user": "api-user"}, + } + + +def test_replace_devices_payload_roundtrips_byte_for_byte(load): + fixture = load("update_topology_replace_devices.json") + data = InspectApiUpdateTopologyData.model_validate(fixture["request"]["data"]) + built = data.model_dump(mode="json", by_alias=True) + assert built["replaceDevices"] == fixture["request"]["data"]["replaceDevices"] + + +def test_replace_vertices_payload_roundtrips_byte_for_byte(load): + fixture = load("update_topology_replace_vertices.json") + data = InspectApiUpdateTopologyData.model_validate(fixture["request"]["data"]) + built = data.model_dump(mode="json", by_alias=True) + assert built["replaceVertices"] == fixture["request"]["data"]["replaceVertices"] + + +def test_commit_success_and_failure_flags(load): + ok = InspectApiUpdateTopologyResponse.model_validate(load("update_topology_success.json")) + assert ok.committed is True + + fail_booking = InspectApiUpdateTopologyResponse.model_validate(load("update_topology_fail_booking.json")) + assert fail_booking.committed is False + assert fail_booking.data.validation.details # per-entity detail present + + fail_remove = InspectApiUpdateTopologyResponse.model_validate(load("update_topology_fail_remove.json")) + assert fail_remove.committed is False diff --git a/tests/inspect/test_queries.py b/tests/inspect/test_queries.py new file mode 100644 index 0000000..b0b3dd2 --- /dev/null +++ b/tests/inspect/test_queries.py @@ -0,0 +1,47 @@ +import urllib.parse + +import pytest + +from videoipath_automation_tool.apps.inspect import queries +from videoipath_automation_tool.apps.inspect.errors import InspectQueryTooLongError + + +def test_all_queries_within_length_limit(): + for build in (queries.device_skeleton, queries.edge_skeleton, queries.paths_section, queries.collector_full): + path = build() + assert len(path) < queries.MAX_QUERY_LENGTH + assert path.startswith("/rest/v2/data/status/collector/") + + +def test_device_skeleton_suppresses_modules_and_selects_skeleton_fields(): + path = queries.device_skeleton() + decoded = urllib.parse.unquote(path) + assert 'modules/"_noId"' in decoded + assert "nodeStatus/*" in decoded + for field in ("descriptor", "meta", "status", "syncSeverity", "tags"): + assert field in decoded + + +def test_device_detail_uses_direct_id_and_full_subtree(): + path = queries.device_detail("device12") + assert path.endswith("/nodeStatus/device12/**") + + +def test_edge_pair_targets_single_pair(): + path = queries.edge_pair("device12::device7") + decoded = urllib.parse.unquote(path) + assert "externalEdgesByDeviceKey/device12::device7" in decoded + + +def test_encode_preserves_grammar_characters_and_encodes_quotes_and_spaces(): + encoded = queries.encode("/a b/*/x,y/'z'/\"q\"") + assert "%20" in encoded # space encoded + assert "%22" in encoded # double-quote encoded + assert "/*/" in encoded # star preserved + assert "'z'" in encoded # single-quote preserved + + +def test_query_too_long_raises(monkeypatch): + monkeypatch.setattr(queries, "MAX_QUERY_LENGTH", 10) + with pytest.raises(InspectQueryTooLongError): + queries.device_skeleton() diff --git a/tests/inspect/test_snapshot.py b/tests/inspect/test_snapshot.py new file mode 100644 index 0000000..d009e5d --- /dev/null +++ b/tests/inspect/test_snapshot.py @@ -0,0 +1,261 @@ +"""Snapshot unit tests with a fake fetcher: skeleton indexes, exactly-one hydration per device, +section laziness, preload fan-out, refresh, and post-commit targeted refresh.""" + +from __future__ import annotations + +import pytest + +from videoipath_automation_tool.apps.inspect.model.collector import ( + InspectApiExternalEdgesByDeviceKeyItem, + InspectApiNodeStatusItem, + InspectApiPathItem, +) +from videoipath_automation_tool.apps.inspect.snapshot import HydrationLevel, InspectSnapshot + + +# --- Test data builders (small synthetic leaf-spine-ish graph) --- + + +def _skeleton_node(device_id: str, label: str, x: float = 0.0, y: float = 0.0, sync: int = 0): + return InspectApiNodeStatusItem.model_validate( + { + "_id": device_id, + "_vid": device_id, + "deviceId": device_id, + "descriptor": {"desc": "", "label": label}, + "meta": {"coordinates": {"x": x, "y": y}, "isVirtual": True, "iconType": "switch"}, + "status": {"sa": 0, "severity": 0}, + "syncSeverity": sync, + "tags": ["#e2e"], + "modules": {}, + } + ) + + +def _detail_node(device_id: str, label: str, port_pids: list[str]): + ports = { + pid: { + "pid": pid, + "descriptor": {"label": pid.split(".")[-1]}, + "status": {"sa": 0, "severity": 0}, + "vertexInfo": {"type": "single", "id": pid.replace(".dev.", "."), "vertexType": "Out"}, + } + for pid in port_pids + } + return InspectApiNodeStatusItem.model_validate( + { + "_id": device_id, + "_vid": device_id, + "deviceId": device_id, + "descriptor": {"desc": "", "label": label}, + "meta": {"coordinates": {"x": 0, "y": 0}}, + "status": {"sa": 0, "severity": 0}, + "syncSeverity": 0, + "modules": {f"{device_id}.dev.0": {"pid": f"{device_id}.dev.0", "ports": ports}}, + } + ) + + +def _edge_pair(dev_a: str, dev_b: str, port_a: str, port_b: str): + edge_id = f"{port_a}::{port_b}" + return InspectApiExternalEdgesByDeviceKeyItem.model_validate( + { + "_id": f"{dev_a}::{dev_b}", + "_vid": f"{dev_a}::{dev_b}", + "primary": { + "devicePid": dev_a, + "label": dev_a, + "data": { + edge_id: { + "id": edge_id, + "fromStatus": {"context": {"devicePid": dev_a, "portPid": port_a}, "label": "out"}, + "toStatus": {"context": {"devicePid": dev_b, "portPid": port_b}, "label": "in"}, + } + }, + }, + "secondary": {"devicePid": dev_b, "label": dev_b, "data": {}}, + "status": {"alarm": 0, "bandwidth": 0, "maintenance": 0, "ptp": 0}, + } + ) + + +def _path_item(booking: str, dev_a: str, dev_b: str): + return InspectApiPathItem.model_validate( + { + "_id": f"{booking}::main", + "_vid": f"_:{booking}::main", + "serviceFields": {"bid": booking, "isMain": True, "fromLabel": "src", "toLabel": "dst"}, + "path": [ + {"bid": booking, "structure": {"deviceId": dev_a, "devicePid": dev_a}}, + {"bid": booking, "structure": {"deviceId": dev_b, "devicePid": dev_b}}, + ], + } + ) + + +class FakeFetcher: + """Records how many detail/section fetches occur so laziness can be asserted.""" + + def __init__(self): + self.device_detail_calls: list[str] = [] + self.edge_pair_calls: list[str] = [] + self.section_calls = 0 + self.skeleton_calls = 0 + self._details = { + "spine-a": _detail_node("spine-a", "SPINE-A", ["spine-a.dev.0.swp1", "spine-a.dev.0.swp2"]), + "leaf-a": _detail_node("leaf-a", "LEAF-A", ["leaf-a.dev.0.up1", "leaf-a.dev.0.host1"]), + } + self._paths = [_path_item("1001", "leaf-a", "spine-a")] + + def get_device_skeleton(self): + self.skeleton_calls += 1 + return [_skeleton_node("spine-a", "SPINE-A"), _skeleton_node("leaf-a", "LEAF-A")] + + def get_edge_skeleton(self): + return [_edge_pair("leaf-a", "spine-a", "leaf-a.dev.0.up1", "spine-a.dev.0.swp1")] + + def get_device_detail(self, device_id): + self.device_detail_calls.append(device_id) + return self._details.get(device_id) + + def get_edge_pair(self, pair_id): + self.edge_pair_calls.append(pair_id) + a, b = pair_id.split("::") + return _edge_pair(a, b, f"{a}.dev.0.up1", f"{b}.dev.0.swp1") + + def get_paths_section(self): + self.section_calls += 1 + return self._paths + + +@pytest.fixture +def snapshot(): + fetcher = FakeFetcher() + snap = InspectSnapshot( + fetcher=fetcher, + device_items=fetcher.get_device_skeleton(), + edge_items=fetcher.get_edge_skeleton(), + ) + fetcher.skeleton_calls = 0 # reset after construction + return snap, fetcher + + +def test_skeleton_indexes_devices_and_edges(snapshot): + snap, fetcher = snapshot + assert {d.id for d in snap.devices} == {"spine-a", "leaf-a"} + leaf = snap.get_device("leaf-a") + assert leaf.label == "LEAF-A" + assert leaf.is_virtual is True + assert leaf.coordinates == {"x": 0.0, "y": 0.0} + assert len(snap.edges) == 1 + assert fetcher.device_detail_calls == [] # nothing hydrated yet + + +def test_find_by_label(snapshot): + snap, _ = snapshot + assert snap.find_device_by_label("SPINE-A").id == "spine-a" + + +def test_ports_trigger_exactly_one_hydration(snapshot): + snap, fetcher = snapshot + leaf = snap.get_device("leaf-a") + assert leaf.is_hydrated is False + ports = leaf.ports + assert {p.id for p in ports} == {"leaf-a.dev.0.up1", "leaf-a.dev.0.host1"} + assert leaf.is_hydrated is True + # Access again → no second fetch + _ = leaf.ports + assert fetcher.device_detail_calls == ["leaf-a"] + # Other device still skeleton + assert snap.get_device("spine-a").is_hydrated is False + + +def test_edges_do_not_trigger_hydration(snapshot): + snap, fetcher = snapshot + edge = snap.edges[0] + assert edge.from_device.id == "leaf-a" + assert edge.to_device.id == "spine-a" + assert edge.status is not None + assert fetcher.device_detail_calls == [] + + +def test_edge_from_port_triggers_owning_device_hydration(snapshot): + snap, fetcher = snapshot + edge = snap.edges[0] + port = edge.from_port + assert port is not None + assert port.id == "leaf-a.dev.0.up1" + assert fetcher.device_detail_calls == ["leaf-a"] + + +def test_services_section_is_lazy(snapshot): + snap, fetcher = snapshot + assert fetcher.section_calls == 0 + services = snap.services + assert {s.booking_id for s in services} == {"1001"} + _ = snap.services # second access + assert fetcher.section_calls == 1 + assert {d.id for d in snap.get_services_for_device("leaf-a")[0].path_devices} == {"leaf-a", "spine-a"} + + +def test_linked_devices_from_edges(snapshot): + snap, _ = snapshot + assert {d.id for d in snap.get_device("leaf-a").linked_devices} == {"spine-a"} + + +def test_preload_hydrates_all(snapshot): + snap, fetcher = snapshot + snap.preload() + assert set(fetcher.device_detail_calls) == {"spine-a", "leaf-a"} + assert snap.get_device("spine-a").is_hydrated + + +def test_refresh_returns_new_snapshot(snapshot): + snap, fetcher = snapshot + new = snap.refresh() + assert new is not snap + assert fetcher.skeleton_calls == 1 + assert {d.id for d in new.devices} == {"spine-a", "leaf-a"} + + +def test_post_commit_removes_locally_and_refreshes_pair(snapshot): + snap, fetcher = snapshot + # remove a device locally + snap.apply_post_commit(removed_ids=["spine-a"], mark_paths_stale=False) + assert snap.get_device("spine-a") is None + assert {d.id for d in snap.devices} == {"leaf-a"} + + +def test_post_commit_refreshes_edge_pair(snapshot): + snap, fetcher = snapshot + snap.apply_post_commit(pair_ids=["leaf-a::spine-a"]) + assert "leaf-a::spine-a" in fetcher.edge_pair_calls + + +def test_post_commit_marks_paths_stale(snapshot): + snap, fetcher = snapshot + _ = snap.services # load section + assert fetcher.section_calls == 1 + snap.apply_post_commit(mark_paths_stale=True) + _ = snap.services # reload + assert fetcher.section_calls == 2 + + +def test_full_snapshot_is_hydrated_without_fetcher(): + fetcher = FakeFetcher() + # Build a full snapshot manually (device_level=FULL, path_items provided) + snap = InspectSnapshot( + fetcher=None, + device_items=[fetcher._details["leaf-a"], fetcher._details["spine-a"]], + edge_items=[_edge_pair("leaf-a", "spine-a", "leaf-a.dev.0.up1", "spine-a.dev.0.swp1")], + device_level=HydrationLevel.FULL, + path_items=fetcher._paths, + ) + leaf = snap.get_device("leaf-a") + assert leaf.is_hydrated + assert len(leaf.ports) == 2 + # sections available without a fetcher + assert {s.booking_id for s in snap.services} == {"1001"} + # refresh without a fetcher raises + with pytest.raises(RuntimeError): + snap.refresh() From 5e51d3c6367bb17f6e530c0ddd74d5afc7f2bf34 Mon Sep 17 00:00:00 2001 From: Jonas Scholl <43819845+JonasScholl@users.noreply.github.com> Date: Thu, 9 Jul 2026 11:03:00 +0200 Subject: [PATCH 05/34] e2e test improvements --- docs/inspect-app/implementation-plan.md | 40 ++- tests/e2e/inspect/leaf_spine_topology.json | 377 +++++++++++++++++++++ tests/e2e/inspect/scenario.py | 299 ++++++++-------- tests/e2e/inspect/test_e2e_leaf_spine.py | 157 +++++---- 4 files changed, 631 insertions(+), 242 deletions(-) create mode 100644 tests/e2e/inspect/leaf_spine_topology.json diff --git a/docs/inspect-app/implementation-plan.md b/docs/inspect-app/implementation-plan.md index d1d5370..edf38a0 100644 --- a/docs/inspect-app/implementation-plan.md +++ b/docs/inspect-app/implementation-plan.md @@ -240,33 +240,31 @@ Add `InspectDevice.is_hydrated` / `snapshot.fetched_at(...)` for introspection. - **Changeset unit tests** (`tests/inspect/test_changeset.py`): staging→baseline capture, intent application, conflict detection (mutated baseline → error with field diffs), `check_conflicts=False` bypass, three-flag result evaluation incl. the captured failure fixtures, affected-set derivation. - **Query tests**: encoding, 414 length guard, projection constants stability. -### 6.2 E2E — live instance, leaf-spine scenario (ADR-0005) +### 6.2 E2E — live instance, topology replica (ADR-0005) -Location `tests/e2e/inspect/`, pytest marker `e2e` (excluded by default via `addopts = -m "not e2e"`), gated on `VIPAT_E2E_ENABLED=1` in `tests/.env.test` **plus** a server-version allowlist check; every created element uses label prefix `E2E-` and tag `#vipat-e2e` so setup/teardown is idempotent and safe on a shared local instance. +Location `tests/e2e/inspect/`, pytest marker `e2e` (excluded by default via `addopts = -m "not e2e"`), gated on `VIPAT_E2E_ENABLED=1` in `tests/.env.test` **plus** a server-version allowlist check. Every created element uses label prefix `E2E-` (in both inventory and inspect) and the `vipat-e2e` tag. -**Scenario: `LeafSpineScenario` (module `tests/e2e/inspect/scenario.py`)** — a fixed dataclass-defined network modelled on the real rig (red/blue planes, leaf-spine, endpoints): +**Namespace & lifecycle:** cleanup runs **at startup** (`scenario.cleanup(app)`), not on teardown — so after a run the built topology **persists** in VideoIPath for manual inspection, and the next run starts from a clean namespace. Mutating tests revert their own changes, so the persisted end state is the complete topology. -| Role | Devices (virtual) | Ports (ip vertices, in/out pairs) | -| ---- | ----------------- | -------------------------------- | -| Spines | `E2E-SPINE-A`, `E2E-SPINE-B` | 4 × downlink (`swp1..4`) | -| Leaves | `E2E-LEAF-A1`, `E2E-LEAF-A2` (red), `E2E-LEAF-B1`, `E2E-LEAF-B2` (blue) | 2 × uplink, 4 × host port | -| Endpoints | `E2E-ENC-1`, `E2E-ENC-2`, `E2E-DEC-1`, `E2E-DEC-2` | `eth-a` (red), `eth-b` (blue) | +**Scenario: `LeafSpineScenario` (module `tests/e2e/inspect/scenario.py`)** — an anonymized replica of the local instance's topology, captured to `leaf_spine_topology.json` (indices/coordinates/link-pairs only; no real names/ids). Built with **virtual (mock-driver) devices only** via the real user flow: -Edge matrix (all bidirectional pairs): each leaf ↔ its plane's spine (uplinks, capacity 65535), each endpoint `eth-a` ↔ a red leaf host port and `eth-b` ↔ a blue leaf host port. Grid coordinates per role (spines y=0, leaves y=200, endpoints y=400). +1. **Inventory** — `app.inventory.create_device(driver="com.nevion.mock-0.1.0")` with a router module sized to the device's degree (`num_router_ports`), then `add_device`. No hardware; the topology app is never used. +2. **Inspect** — `add_devices_to_topology` (placement at the captured coordinates), `update_device(label=…, tags=[…])` to set the E2E display label + tag, then `connect` each link (ports discovered from `device.ports`). -**Build path** (respects verified server semantics): virtual devices + their vertices are created via the *topology app* (`create_virtual_device()` + `update_device` — vertices cannot be created through `updateTopology`, and virtual devices need no hardware); everything Inspect *owns* is then done through `app.inspect`: placement, connect (paired edges), edits, removals. Scenario object exposes `build(app)`, `teardown(app)` (delete by tag/prefix — edges first, then devices), `assert_clean(app)`. +`cleanup` removes edges, then the topology node (`remove_device_from_topology`), then the inventory entry — discovering E2E devices by label in **both** inventory and inspect (catches orphans from an aborted run). -**Test list** (`test_e2e_leaf_spine.py`, ordered by dependency, session-scoped scenario fixture): -1. `test_build_and_skeleton_read` — build; skeleton snapshot sees all 10 devices with labels/coordinates; edge count == matrix size; **no** hydration fetches occurred (spy counter). -2. `test_lazy_hydration` — access `dev.ports` on one leaf → exactly one detail fetch; ports/vertex ids match scenario; other devices still skeleton. -3. `test_connectivity_graph` — `linked_devices` of each endpoint == its two leaves; leaf ↔ spine adjacency matches the matrix; `port.edge.to_device` round-trips. -4. `test_edge_pair_refresh` — `update_edge(weight=…)` on one uplink via direct write; assert commit result + snapshot pair record updated (targeted refresh) without full reload. -5. `test_transaction_atomicity` — tx with one valid edge edit + one `remove` of a non-existent id → `InspectCommitError`, nothing applied (weight unchanged on server). -6. `test_conflict_detection` — stage edit for edge E; mutate E out-of-band (second `VideoIPathApp` instance); `commit()` → `InspectCommitConflictError` listing the field; `rebase()` + commit succeeds. -7. `test_device_placement_roundtrip` — `place_device` new coordinates; re-snapshot skeleton shows them; revert. -8. `test_disconnect_connect_cycle` — disconnect one endpoint pair, assert gone from snapshot + collector; reconnect; assert identical to scenario matrix. -9. `test_full_vs_skeleton_equivalence` — `load="full"` snapshot and skeleton+preload produce identical device/port/edge index contents for scenario entities. -10. `test_teardown` — teardown; snapshot contains no `E2E-` devices/edges; runs also as autouse session-finalizer so aborted runs clean up. +**Test list** (`test_e2e_leaf_spine.py`, session-scoped scenario fixture; all reads/writes via `app.inspect`): +1. `test_all_devices_present` — every scenario device appears with its `E2E-` label. +2. `test_skeleton_read_no_hydration` — scenario edge count == `expected_edge_count()`; **no** hydration fetches during the skeleton read (spy counter). +3. `test_lazy_hydration` — `get_device(id).ports` → exactly one detail fetch, cached thereafter; hydration state flips; mock devices expose router ports. +4. `test_connectivity_graph` — `linked_devices` of the busiest device == its fixture adjacency. +5. `test_edge_pair_refresh` — `update_edge(weight=…)`; edge survives targeted refresh without a full reload; revert. +6. `test_transaction_atomicity` — tx with a valid edge edit + a `remove` of a non-existent id → `InspectCommitError`, nothing applied. +7. `test_conflict_detection` — edit an edge; mutate it out-of-band (second `VideoIPathApp`); `commit()` → `InspectCommitConflictError`; `rebase()` + commit succeeds; revert. +8. `test_device_placement_roundtrip` — `place_device` new coordinates; verify; revert. +9. `test_disconnect_reconnect_cycle` — disconnect a link's directed edges, assert gone, reconnect, assert restored. +10. `test_full_vs_skeleton_equivalence` — `load="full"` and skeleton+preload resolve the same connectivity graph. +11. `test_state_persists` — after the suite the complete topology (all devices + edges) remains. Never touched: non-`E2E-` devices, bookings/services (read-only asserts only), profiles/security sections. diff --git a/tests/e2e/inspect/leaf_spine_topology.json b/tests/e2e/inspect/leaf_spine_topology.json new file mode 100644 index 0000000..a1cf622 --- /dev/null +++ b/tests/e2e/inspect/leaf_spine_topology.json @@ -0,0 +1,377 @@ +{ + "_comment": "A dummy VideoIPath spine-leaf topology (indices/coords/links only). Built with mock (virtual) devices in the E2E suite.", + "devices": [ + { + "name": "E2E-DEV-00", + "x": -2301, + "y": 8141, + "ports": 2 + }, + { + "name": "E2E-DEV-01", + "x": -1496, + "y": 8082, + "ports": 1 + }, + { + "name": "E2E-DEV-02", + "x": 2100, + "y": 8800, + "ports": 11 + }, + { + "name": "E2E-DEV-03", + "x": 3600, + "y": 8800, + "ports": 4 + }, + { + "name": "E2E-DEV-04", + "x": 3000, + "y": 8800, + "ports": 4 + }, + { + "name": "E2E-DEV-05", + "x": 1500, + "y": 8800, + "ports": 9 + }, + { + "name": "E2E-DEV-06", + "x": 0, + "y": 0, + "ports": 1 + }, + { + "name": "E2E-DEV-07", + "x": 1600, + "y": 9300, + "ports": 2 + }, + { + "name": "E2E-DEV-08", + "x": 2000, + "y": 9300, + "ports": 2 + }, + { + "name": "E2E-DEV-09", + "x": 1600, + "y": 9050, + "ports": 1 + }, + { + "name": "E2E-DEV-10", + "x": -2000, + "y": 7800, + "ports": 3 + }, + { + "name": "E2E-DEV-11", + "x": 0, + "y": 0, + "ports": 1 + }, + { + "name": "E2E-DEV-12", + "x": 1600, + "y": 10300, + "ports": 2 + }, + { + "name": "E2E-DEV-13", + "x": 0, + "y": 0, + "ports": 1 + }, + { + "name": "E2E-DEV-14", + "x": 2000, + "y": 9800, + "ports": 2 + }, + { + "name": "E2E-DEV-15", + "x": 0, + "y": 8400, + "ports": 7 + }, + { + "name": "E2E-DEV-16", + "x": 0, + "y": 7600, + "ports": 7 + }, + { + "name": "E2E-DEV-17", + "x": -1800, + "y": 8600, + "ports": 3 + }, + { + "name": "E2E-DEV-18", + "x": 500, + "y": 7850, + "ports": 2 + }, + { + "name": "E2E-DEV-19", + "x": -2800, + "y": 8200, + "ports": 2 + }, + { + "name": "E2E-DEV-20", + "x": 3500, + "y": 9300, + "ports": 2 + }, + { + "name": "E2E-DEV-21", + "x": 767, + "y": 8434, + "ports": 1 + }, + { + "name": "E2E-DEV-22", + "x": 1600, + "y": 9800, + "ports": 2 + }, + { + "name": "E2E-DEV-23", + "x": 2000, + "y": 9550, + "ports": 2 + }, + { + "name": "E2E-DEV-24", + "x": 1600, + "y": 9550, + "ports": 2 + }, + { + "name": "E2E-DEV-25", + "x": 900, + "y": 7850, + "ports": 2 + }, + { + "name": "E2E-DEV-26", + "x": 0, + "y": 0, + "ports": 1 + }, + { + "name": "E2E-DEV-27", + "x": 3500, + "y": 9050, + "ports": 2 + }, + { + "name": "E2E-DEV-28", + "x": 3100, + "y": 9300, + "ports": 2 + }, + { + "name": "E2E-DEV-29", + "x": 1046, + "y": 8159, + "ports": 2 + } + ], + "links": [ + [ + 0, + 10, + 1 + ], + [ + 0, + 17, + 1 + ], + [ + 2, + 7, + 1 + ], + [ + 2, + 8, + 1 + ], + [ + 2, + 9, + 1 + ], + [ + 2, + 12, + 1 + ], + [ + 2, + 14, + 1 + ], + [ + 2, + 15, + 2 + ], + [ + 2, + 21, + 1 + ], + [ + 2, + 22, + 1 + ], + [ + 2, + 23, + 1 + ], + [ + 2, + 24, + 1 + ], + [ + 3, + 15, + 1 + ], + [ + 3, + 20, + 1 + ], + [ + 3, + 27, + 1 + ], + [ + 3, + 28, + 1 + ], + [ + 4, + 16, + 1 + ], + [ + 4, + 20, + 1 + ], + [ + 4, + 27, + 1 + ], + [ + 4, + 28, + 1 + ], + [ + 5, + 7, + 1 + ], + [ + 5, + 8, + 1 + ], + [ + 5, + 12, + 1 + ], + [ + 5, + 14, + 1 + ], + [ + 5, + 16, + 2 + ], + [ + 5, + 22, + 1 + ], + [ + 5, + 23, + 1 + ], + [ + 5, + 24, + 1 + ], + [ + 10, + 16, + 1 + ], + [ + 10, + 19, + 1 + ], + [ + 15, + 17, + 1 + ], + [ + 15, + 18, + 1 + ], + [ + 15, + 25, + 1 + ], + [ + 15, + 29, + 1 + ], + [ + 16, + 18, + 1 + ], + [ + 16, + 25, + 1 + ], + [ + 16, + 29, + 1 + ], + [ + 17, + 19, + 1 + ] + ] +} \ No newline at end of file diff --git a/tests/e2e/inspect/scenario.py b/tests/e2e/inspect/scenario.py index 59706ff..b66ffc2 100644 --- a/tests/e2e/inspect/scenario.py +++ b/tests/e2e/inspect/scenario.py @@ -1,25 +1,27 @@ -"""Fixed leaf-spine scenario for the Inspect E2E suite ([ADR-0005]). +"""Topology scenario for the Inspect E2E suite ([ADR-0005]). -Declaratively models a small, fully-flexed leaf-spine network (two planes, red/blue), modelled on -the real local rig. Devices and their IP vertices are created through the **topology app** (vertices -cannot be created via ``updateTopology`` — [ADR-0009]); placement, connections, edits and removals -that Inspect owns are then driven through ``app.inspect``. +Replicates the structure of the local VideoIPath instance (anonymized to indices/coordinates/links +in ``leaf_spine_topology.json``) using **virtual (mock-driver) devices only**. The build path is the +real user flow: -Every created element is namespaced so a shared instance is safe: - * device/vertex labels start with ``E2E-``, - * every element carries the ``vipat-e2e`` tag. + 1. **Inventory** — create each device with the ``com.nevion.mock`` driver (a simulated device with + configurable router ports) and ``add_device`` it. No real hardware is involved. + 2. **Inspect** — add the devices to the topology graph, set their E2E display label + tag, then + connect their ports. -Vertex ids are **discovered** from an Inspect snapshot after creation (each vertex is created with a -unique descriptor label and looked up by ``port.label``), so the scenario does not depend on the -server's vertex-id format. +The topology app is never used. -NOTE (developer-run): the exact virtual-device build calls exercise the *experimental* topology -virtual-device API; confirm labels/ids on the first live run against your instance. +Namespacing: every device carries the ``E2E-`` label prefix (in both inventory and inspect) and the +``vipat-e2e`` tag. Cleanup runs at **startup** (``cleanup``), not on teardown — so the built +topology persists in VideoIPath after a run for manual inspection, and the next run starts fresh. """ from __future__ import annotations +import json +from collections import defaultdict from dataclasses import dataclass +from pathlib import Path from typing import TYPE_CHECKING if TYPE_CHECKING: @@ -27,189 +29,170 @@ E2E_PREFIX = "E2E-" E2E_TAG = "vipat-e2e" +MOCK_DRIVER = "com.nevion.mock-0.1.0" +TOPOLOGY_FILE = Path(__file__).parent / "leaf_spine_topology.json" -# Grid coordinates per role tier. -_Y_SPINE = 0.0 -_Y_LEAF = 200.0 -_Y_ENDPOINT = 400.0 -_X_STEP = 300.0 +# The fixture coordinates are captured from the live topology, so the replica would sit right on top +# of it. Shift the whole E2E topology into its own region of the map so it never overlaps. +POSITION_OFFSET_X = 0 +POSITION_OFFSET_Y = 3000 @dataclass(frozen=True) class DeviceSpec: - label: str - icon_type: str - ports: tuple[str, ...] - x: float - y: float + name: str + x: int + y: int + ports: int @dataclass(frozen=True) class LinkSpec: - """A bidirectional connection between two ports (built as two directed edges).""" - - from_device: str - from_port: str - to_device: str - to_port: str - - -def _vertex_token(device_label: str, port: str, direction: str) -> str: - """Unique descriptor label used to discover the created vertex on the Inspect surface.""" - return f"{device_label}|{port}|{direction}" - - -def _build_specs() -> tuple[list[DeviceSpec], list[LinkSpec]]: - spines = [ - DeviceSpec("E2E-SPINE-A", "ipSwitchRouter", tuple(f"downlink{i}" for i in range(4)), 0 * _X_STEP, _Y_SPINE), - DeviceSpec("E2E-SPINE-B", "ipSwitchRouter", tuple(f"downlink{i}" for i in range(4)), 3 * _X_STEP, _Y_SPINE), - ] - leaf_ports = ("uplink0", "uplink1", "host0", "host1", "host2", "host3") - leaves = [ - DeviceSpec("E2E-LEAF-A1", "ipSwitchRouter", leaf_ports, 0 * _X_STEP, _Y_LEAF), - DeviceSpec("E2E-LEAF-A2", "ipSwitchRouter", leaf_ports, 1 * _X_STEP, _Y_LEAF), - DeviceSpec("E2E-LEAF-B1", "ipSwitchRouter", leaf_ports, 2 * _X_STEP, _Y_LEAF), - DeviceSpec("E2E-LEAF-B2", "ipSwitchRouter", leaf_ports, 3 * _X_STEP, _Y_LEAF), - ] - endpoint_ports = ("eth-a", "eth-b") - endpoints = [ - DeviceSpec("E2E-ENC-1", "encoder", endpoint_ports, 0 * _X_STEP, _Y_ENDPOINT), - DeviceSpec("E2E-ENC-2", "encoder", endpoint_ports, 1 * _X_STEP, _Y_ENDPOINT), - DeviceSpec("E2E-DEC-1", "decoder", endpoint_ports, 2 * _X_STEP, _Y_ENDPOINT), - DeviceSpec("E2E-DEC-2", "decoder", endpoint_ports, 3 * _X_STEP, _Y_ENDPOINT), - ] - - links: list[LinkSpec] = [ - # Uplinks: each leaf's uplink0 to a downlink on its plane's spine. - LinkSpec("E2E-LEAF-A1", "uplink0", "E2E-SPINE-A", "downlink0"), - LinkSpec("E2E-LEAF-A2", "uplink0", "E2E-SPINE-A", "downlink1"), - LinkSpec("E2E-LEAF-B1", "uplink0", "E2E-SPINE-B", "downlink0"), - LinkSpec("E2E-LEAF-B2", "uplink0", "E2E-SPINE-B", "downlink1"), - # Endpoint red-plane (eth-a) and blue-plane (eth-b) attachments. - LinkSpec("E2E-ENC-1", "eth-a", "E2E-LEAF-A1", "host0"), - LinkSpec("E2E-ENC-1", "eth-b", "E2E-LEAF-B1", "host0"), - LinkSpec("E2E-ENC-2", "eth-a", "E2E-LEAF-A1", "host1"), - LinkSpec("E2E-ENC-2", "eth-b", "E2E-LEAF-B1", "host1"), - LinkSpec("E2E-DEC-1", "eth-a", "E2E-LEAF-A2", "host0"), - LinkSpec("E2E-DEC-1", "eth-b", "E2E-LEAF-B2", "host0"), - LinkSpec("E2E-DEC-2", "eth-a", "E2E-LEAF-A2", "host1"), - LinkSpec("E2E-DEC-2", "eth-b", "E2E-LEAF-B2", "host1"), - ] - return [*spines, *leaves, *endpoints], links + a: int # device index + b: int # device index + count: int # number of parallel bidirectional links -class LeafSpineScenario: - """Builds, inspects, and tears down the fixed leaf-spine network on a live instance.""" +def _vertex_slot(vertex_id: str) -> int: + """Sort key: the trailing integer of a mock router vertex id (``device59.11.7`` -> 7).""" + return int(vertex_id.rsplit(".", 1)[-1]) + +class LeafSpineScenario: def __init__(self) -> None: - self.devices, self.links = _build_specs() - # Resolved after build(): - self.device_ids: dict[str, str] = {} # label -> device id - self._vertex_ids: dict[str, str] = {} # token -> inspect vertex id + data = json.loads(TOPOLOGY_FILE.read_text()) + # Bake the offset into the specs so build placement and the placement-revert test agree. + self.devices = [ + DeviceSpec(d["name"], d["x"] + POSITION_OFFSET_X, d["y"] + POSITION_OFFSET_Y, d["ports"]) + for d in data["devices"] + ] + self.links = [LinkSpec(a, b, n) for a, b, n in data["links"]] + self.device_ids: dict[str, str] = {} # E2E name -> VideoIPath device id + self._out: dict[str, list[str]] = {} # name -> ordered out-vertex ids + self._in: dict[str, list[str]] = {} # name -> ordered in-vertex ids - # --- Introspection helpers --- + # --- Introspection --- @property - def device_labels(self) -> list[str]: - return [d.label for d in self.devices] + def device_names(self) -> list[str]: + return [d.name for d in self.devices] - def device_id(self, label: str) -> str: - return self.device_ids[label] + def device_id(self, name: str) -> str: + return self.device_ids[name] - def out_vertex(self, label: str, port: str) -> str: - return self._vertex_ids[_vertex_token(label, port, "out")] - - def in_vertex(self, label: str, port: str) -> str: - return self._vertex_ids[_vertex_token(label, port, "in")] + def adjacency(self) -> dict[str, set[str]]: + """Undirected neighbour set per device name (from the fixture).""" + adj: dict[str, set[str]] = {d.name: set() for d in self.devices} + for link in self.links: + na, nb = self.devices[link.a].name, self.devices[link.b].name + adj[na].add(nb) + adj[nb].add(na) + return adj def expected_edge_count(self) -> int: # Two directed edges per bidirectional link. - return len(self.links) * 2 + return sum(link.count for link in self.links) * 2 - # --- Build --- + def a_link(self) -> tuple[str, str]: + """A representative connected device pair (names).""" + link = self.links[0] + return self.devices[link.a].name, self.devices[link.b].name - def build(self, app: "VideoIPathApp") -> None: - for spec in self.devices: - device_id = self._create_device(app, spec) - self.device_ids[spec.label] = device_id - # Place the devices on the grid (they are already graph elements after creation). - with app.inspect.transaction() as tx: - for spec in self.devices: - tx.place_device(self.device_ids[spec.label], spec.x, spec.y) - tx.commit() - self._connect_links(app) - - def _create_device(self, app: "VideoIPathApp", spec: DeviceSpec) -> str: - device = app.topology.create_virtual_device() - device.configuration.base_device.label = spec.label - device.configuration.base_device.tags = [E2E_TAG] - device.add_virtual_module() - for port in spec.ports: - for direction, api_dir in (("out", "Out"), ("in", "In")): - vertex = device.add_virtual_ip_vertex(vertex_direction=api_dir) - vertex.label = _vertex_token(spec.label, port, direction) - vertex.tags = [E2E_TAG] - app.topology.add_device_initially(device) - # add_device_initially assigns the next free virtual id onto the base device; read it - # directly rather than looking up by label (the label index is only eventually consistent). - device_id = device.configuration.base_device.id - if not device_id: - raise RuntimeError(f"Device '{spec.label}' has no id after creation.") - # Virtual-device vertices do not surface as ports in the Inspect nodeStatus, so capture - # their ids straight from the created graph elements (keyed by the descriptor label we set). - for vtx in device.configuration.ip_vertices: - self._vertex_ids[vtx.label] = vtx.id - return device_id - - def _connect_links(self, app: "VideoIPathApp") -> None: - with app.inspect.transaction() as tx: - for link in self.links: - tx.connect( - self.out_vertex(link.from_device, link.from_port), - self.in_vertex(link.to_device, link.to_port), - bidirectional=False, - ) - tx.connect( - self.out_vertex(link.to_device, link.to_port), - self.in_vertex(link.from_device, link.from_port), - bidirectional=False, - ) - tx.commit() + # --- Startup cleanup (removes any prior E2E state) --- - # --- Teardown --- + def cleanup(self, app: "VideoIPathApp") -> None: + """Remove every E2E device (and its edges) so a run starts from a clean namespace. - def teardown(self, app: "VideoIPathApp") -> None: - """Remove all scenario edges then all scenario devices. Safe to call repeatedly. - - Discovers the currently-present E2E devices and their edges from a live snapshot (by the - ``E2E-`` label prefix), so it also cleans up orphaned runs and never tries to remove an edge - that is already gone. + A device lives in two places — the inventory and the inspect topology graph — and removing + it from one does not remove it from the other. So we discover E2E devices by their ``E2E-`` + label in **both** (catching orphans from an aborted run) and remove edges, the topology node, + and the inventory entry. """ + inventory_labels = app.inventory._inventory_api.fetch_devices_user_defined_labels_as_dict() + inventory_ids = {i for i, label in inventory_labels.items() if (label or "").startswith(E2E_PREFIX)} app.inspect.refresh() - # Only touch devices that currently exist (discovered by label prefix), so teardown is - # idempotent — a second call finds nothing and is a no-op. - known_ids = {d.id for d in app.inspect.devices if (d.label or "").startswith(E2E_PREFIX)} - if not known_ids: + topology_ids = {d.id for d in app.inspect.devices if (d.label or "").startswith(E2E_PREFIX)} + all_ids = inventory_ids | topology_ids + if not all_ids: return - # Edges first (only the ones that actually exist). + # Edges first (only those that actually exist). edge_ids = [ edge.id for edge in app.inspect.edges - if (edge.from_device and edge.from_device.id in known_ids) - or (edge.to_device and edge.to_device.id in known_ids) + if (edge.from_device and edge.from_device.id in all_ids) + or (edge.to_device and edge.to_device.id in all_ids) ] if edge_ids: with app.inspect.transaction() as tx: for edge_id in edge_ids: tx.remove(edge_id) tx.commit(check_conflicts=False) - for device_id in known_ids: + # Remove the topology node, then the inventory entry. + for device_id in topology_ids: + try: + app.inspect.remove_device_from_topology(device_id) + except Exception: # best-effort cleanup + pass + for device_id in inventory_ids: try: - app.topology.remove_device_by_id(device_id, ignore_affected_services=True) - except Exception: # already gone / not an nGraphElement — best-effort cleanup + app.inventory.remove_device(device_id=device_id, check_remove=False) + except Exception: # best-effort cleanup pass - def assert_clean(self, app: "VideoIPathApp") -> None: + # --- Build --- + + def build(self, app: "VideoIPathApp") -> None: + self._create_inventory_devices(app) + # Add to the inspect topology graph at their coordinates. + app.inspect.add_devices_to_topology([(self.device_ids[s.name], s.x, s.y) for s in self.devices]) + # Configure them in inspect: E2E display label + tag. + with app.inspect.transaction() as tx: + for spec in self.devices: + tx.update_device(self.device_ids[spec.name], label=spec.name, tags=[E2E_TAG]) + tx.commit() + self._discover_ports(app) + self._connect(app) + + def _create_inventory_devices(self, app: "VideoIPathApp") -> None: + for i, spec in enumerate(self.devices): + device = app.inventory.create_device(driver=MOCK_DRIVER) + device.configuration.label = spec.name + device.configuration.address = f"10.99.{i // 256}.{i % 256}" + settings = device.configuration.custom_settings + settings.num_router_modules = 1 + settings.num_router_ports = spec.ports + settings.num_codec_modules = 0 + online = app.inventory.add_device(device=device, address_check=False) + self.device_ids[spec.name] = online.configuration.device_id + + def _discover_ports(self, app: "VideoIPathApp") -> None: app.inspect.refresh() - leftover = [d.label for d in app.inspect.devices if (d.label or "").startswith(E2E_PREFIX)] - assert not leftover, f"E2E devices still present after teardown: {leftover}" + app.inspect.preload(list(self.device_ids.values())) + for spec in self.devices: + device = app.inspect.get_device(self.device_ids[spec.name]) + outs: list[str] = [] + ins: list[str] = [] + for port in device.ports: + label = port.label or "" + if not port.vertex_id: + continue + if "Router Out" in label: + outs.append(port.vertex_id) + elif "Router In" in label: + ins.append(port.vertex_id) + self._out[spec.name] = sorted(outs, key=_vertex_slot) + self._in[spec.name] = sorted(ins, key=_vertex_slot) + + def _connect(self, app: "VideoIPathApp") -> None: + slot: dict[str, int] = defaultdict(int) + with app.inspect.transaction() as tx: + for link in self.links: + na, nb = self.devices[link.a].name, self.devices[link.b].name + for _ in range(link.count): + ka, kb = slot[na], slot[nb] + slot[na] += 1 + slot[nb] += 1 + # Bidirectional link between interface ka on A and kb on B. + tx.connect(self._out[na][ka], self._in[nb][kb], bidirectional=False) + tx.connect(self._out[nb][kb], self._in[na][ka], bidirectional=False) + tx.commit() diff --git a/tests/e2e/inspect/test_e2e_leaf_spine.py b/tests/e2e/inspect/test_e2e_leaf_spine.py index c39343b..7b40431 100644 --- a/tests/e2e/inspect/test_e2e_leaf_spine.py +++ b/tests/e2e/inspect/test_e2e_leaf_spine.py @@ -1,12 +1,13 @@ -"""End-to-end Inspect tests against a live instance using the fixed leaf-spine scenario. +"""End-to-end Inspect tests against a live instance, replicating the local topology. -Developer-run only (see ``tests/e2e/conftest.py``). Ordered by dependency and sharing one -session-scoped scenario that is built once and torn down at the end (an autouse finalizer also -cleans up if a run aborts). All writes stay inside the ``E2E-`` / ``vipat-e2e`` namespace. +Developer-run only (see ``tests/e2e/conftest.py``). A session-scoped fixture cleans up any prior +E2E state **at startup** and then builds the topology with virtual (mock-driver) devices via the +inventory → inspect flow. There is **no teardown**: the built topology persists in VideoIPath after +the run (the next run cleans it up and rebuilds). Mutating tests revert their own changes, so the +persisted end state is the complete topology. -Everything goes through ``app.inspect`` — the app owns its topology view internally; tests call -``app.inspect.refresh()`` to get a fresh read and then use ``app.inspect.devices`` / ``get_device`` / -``edges`` directly. +Everything goes through ``app.inventory`` (device creation) and ``app.inspect`` (topology). The +topology app is never used. Run with:: @@ -20,7 +21,8 @@ from videoipath_automation_tool.apps.inspect.errors import InspectCommitConflictError, InspectCommitError from videoipath_automation_tool.apps.videoipath_app import VideoIPathApp -from .scenario import E2E_PREFIX, LeafSpineScenario +from .scenario import LeafSpineScenario + pytestmark = pytest.mark.e2e @@ -28,11 +30,9 @@ @pytest.fixture(scope="session") def scenario(app: VideoIPathApp): scn = LeafSpineScenario() - scn.teardown(app) # clean slate from any aborted prior run + scn.cleanup(app) # startup cleanup only — no teardown, state persists after the run scn.build(app) - yield scn - scn.teardown(app) - scn.assert_clean(app) + return scn class _FetchSpy: @@ -55,73 +55,98 @@ def __exit__(self, *exc): self._api.get_device_detail = self._orig -def test_build_and_skeleton_read(app, scenario): +def _edges_between(app, id_a: str, id_b: str): + return [ + e + for e in app.inspect.edges + if e.from_device and e.to_device and {e.from_device.id, e.to_device.id} == {id_a, id_b} + ] + + +def _busiest_device(scenario) -> str: + adjacency = scenario.adjacency() + return max(scenario.device_names, key=lambda n: len(adjacency[n])) + + +def test_all_devices_present(app, scenario): + app.inspect.refresh() + labels = {d.label for d in app.inspect.devices} + for name in scenario.device_names: + assert name in labels + + +def test_skeleton_read_no_hydration(app, scenario): app.inspect.refresh() with _FetchSpy(app.inspect._inspect_api) as spy: - labels = {d.label for d in app.inspect.devices} - for expected in scenario.device_labels: - assert expected in labels known = set(scenario.device_ids.values()) scenario_edges = [ e for e in app.inspect.edges if (e.from_device and e.from_device.id in known) or (e.to_device and e.to_device.id in known) ] - assert len(scenario_edges) >= scenario.expected_edge_count() - assert spy.count == 0 # skeleton read triggers no hydration + assert len(scenario_edges) == scenario.expected_edge_count() + assert spy.count == 0 # skeleton read triggers no per-device hydration def test_lazy_hydration(app, scenario): - # Virtual devices do not expose ports in nodeStatus; this asserts the hydration *mechanic* - # (exactly one detail fetch per device, cached thereafter, hydration state flips). app.inspect.refresh() - leaf_id = scenario.device_id("E2E-LEAF-A1") - other_id = scenario.device_id("E2E-SPINE-A") - assert not app.inspect.is_device_hydrated(leaf_id) + name = _busiest_device(scenario) + device_id = scenario.device_id(name) + other_id = scenario.device_id(next(n for n in scenario.device_names if n != name)) + assert not app.inspect.is_device_hydrated(device_id) with _FetchSpy(app.inspect._inspect_api) as spy: - _ = app.inspect.get_device(leaf_id).ports # triggers one detail fetch + ports = app.inspect.get_device(device_id).ports # one detail fetch assert spy.count == 1 - _ = app.inspect.get_device(leaf_id).ports # cached, no further fetch + _ = app.inspect.get_device(device_id).ports # cached assert spy.count == 1 - assert app.inspect.is_device_hydrated(leaf_id) + assert len(ports) > 0 # mock devices expose router ports + assert app.inspect.is_device_hydrated(device_id) assert not app.inspect.is_device_hydrated(other_id) def test_connectivity_graph(app, scenario): app.inspect.refresh() - enc1 = app.inspect.get_device(scenario.device_id("E2E-ENC-1")) - linked = {d.label for d in enc1.linked_devices} - assert linked == {"E2E-LEAF-A1", "E2E-LEAF-B1"} - # Spine adjacency: SPINE-A is linked to both red leaves. - spine_a = app.inspect.get_device(scenario.device_id("E2E-SPINE-A")) - spine_links = {d.label for d in spine_a.linked_devices} - assert {"E2E-LEAF-A1", "E2E-LEAF-A2"} <= spine_links + adjacency = scenario.adjacency() + name = _busiest_device(scenario) + device = app.inspect.get_device(scenario.device_id(name)) + linked = {d.label for d in device.linked_devices} + assert linked == adjacency[name] def test_edge_pair_refresh(app, scenario): - app.inspect.refresh() # load the internal view so the write can update it in place - edge_id = f"{scenario.out_vertex('E2E-LEAF-A1', 'uplink0')}::{scenario.in_vertex('E2E-SPINE-A', 'downlink0')}" + app.inspect.refresh() + name_a, name_b = scenario.a_link() + id_a, id_b = scenario.device_id(name_a), scenario.device_id(name_b) + edges = _edges_between(app, id_a, id_b) + assert edges + edge_id = edges[0].id result = app.inspect.update_edge(edge_id, weight=7) assert result.ok - # The edge survives the targeted post-commit refresh without a full reload. + # Survives the targeted post-commit refresh without a full reload. assert any(e.id == edge_id for e in app.inspect.edges) app.inspect.update_edge(edge_id, weight=1) # revert def test_transaction_atomicity(app, scenario): - edge_id = f"{scenario.out_vertex('E2E-LEAF-A2', 'uplink0')}::{scenario.in_vertex('E2E-SPINE-A', 'downlink1')}" + name_a, name_b = scenario.a_link() + id_a, id_b = scenario.device_id(name_a), scenario.device_id(name_b) + app.inspect.refresh() + edge_id = _edges_between(app, id_a, id_b)[0].id with pytest.raises(InspectCommitError): with app.inspect.transaction() as tx: tx.update_edge(edge_id, weight=13) tx.remove("does-not-exist::also-not-real") tx.commit() - # Nothing applied: the edge is still present on the server. + # Nothing applied: the edge is still present. app.inspect.refresh() assert any(e.id == edge_id for e in app.inspect.edges) def test_conflict_detection(app, scenario): - edge_id = f"{scenario.out_vertex('E2E-LEAF-B1', 'uplink0')}::{scenario.in_vertex('E2E-SPINE-B', 'downlink0')}" + app.inspect.refresh() + name_a, name_b = scenario.a_link() + id_a, id_b = scenario.device_id(name_a), scenario.device_id(name_b) + edge_id = _edges_between(app, id_a, id_b)[0].id tx = app.inspect.transaction() tx.update_edge(edge_id, weight=21) # Out-of-band change via a second app instance. @@ -136,30 +161,30 @@ def test_conflict_detection(app, scenario): def test_device_placement_roundtrip(app, scenario): - device_id = scenario.device_id("E2E-ENC-1") + name = scenario.device_names[0] + spec = next(s for s in scenario.devices if s.name == name) + device_id = scenario.device_id(name) app.inspect.place_device(device_id, 4200, 4200) app.inspect.refresh() coords = app.inspect.get_device(device_id).coordinates assert coords is not None and coords["x"] == 4200 and coords["y"] == 4200 - # revert to scenario coordinates - spec = next(d for d in scenario.devices if d.label == "E2E-ENC-1") - app.inspect.place_device(device_id, spec.x, spec.y) - - -def test_disconnect_connect_cycle(app, scenario): - a_out = scenario.out_vertex("E2E-DEC-2", "eth-a") - b_in = scenario.in_vertex("E2E-LEAF-A2", "host1") - b_out = scenario.out_vertex("E2E-LEAF-A2", "host1") - a_in = scenario.in_vertex("E2E-DEC-2", "eth-a") - app.inspect.disconnect(a_out, b_in, bidirectional=False) - app.inspect.disconnect(b_out, a_in, bidirectional=False) + app.inspect.place_device(device_id, spec.x, spec.y) # revert + + +def test_disconnect_reconnect_cycle(app, scenario): + app.inspect.refresh() + name_a, name_b = scenario.a_link() + id_a, id_b = scenario.device_id(name_a), scenario.device_id(name_b) + directed = [tuple(e.id.split("::", 1)) for e in _edges_between(app, id_a, id_b)] + assert directed + for from_vertex, to_vertex in directed: + app.inspect.disconnect(from_vertex, to_vertex, bidirectional=False) app.inspect.refresh() - assert not any(e.id == f"{a_out}::{b_in}" for e in app.inspect.edges) - # reconnect - app.inspect.connect(a_out, b_in, bidirectional=False) - app.inspect.connect(b_out, a_in, bidirectional=False) + assert not _edges_between(app, id_a, id_b) + for from_vertex, to_vertex in directed: + app.inspect.connect(from_vertex, to_vertex, bidirectional=False) app.inspect.refresh() - assert any(e.id == f"{a_out}::{b_in}" for e in app.inspect.edges) + assert len(_edges_between(app, id_a, id_b)) == len(directed) def test_full_vs_skeleton_equivalence(app, scenario): @@ -178,12 +203,18 @@ def graph_view() -> dict: app.inspect.refresh(load="full") full = graph_view() assert skeleton == full - app.inspect.refresh(load="skeleton") # restore default load mode for later tests + app.inspect.refresh(load="skeleton") # restore default load mode -def test_teardown(app, scenario): - # Explicit teardown assertion; the fixture finalizer repeats it for aborted runs. - scenario.teardown(app) - scenario.assert_clean(app) +def test_state_persists(app, scenario): + # There is no teardown: after the suite the complete topology remains in VideoIPath. app.inspect.refresh() - assert not any((d.label or "").startswith(E2E_PREFIX) for d in app.inspect.devices) + labels = {d.label for d in app.inspect.devices} + assert all(name in labels for name in scenario.device_names) + known = set(scenario.device_ids.values()) + scenario_edges = [ + e + for e in app.inspect.edges + if (e.from_device and e.from_device.id in known) or (e.to_device and e.to_device.id in known) + ] + assert len(scenario_edges) == scenario.expected_edge_count() From 664f979e97e15d107822e829f61a6451fdeb55d8 Mon Sep 17 00:00:00 2001 From: Jonas Scholl <43819845+JonasScholl@users.noreply.github.com> Date: Thu, 9 Jul 2026 11:21:15 +0200 Subject: [PATCH 06/34] add tags e2e test --- docs/getting-started-guide/05_Inspect.md | 6 +- .../apps/inspect/changeset.py | 10 ++- .../apps/inspect/domain/port.py | 6 ++ .../apps/inspect/model/collector.py | 12 ++++ tests/e2e/inspect/scenario.py | 71 ++++++++++++++++++- tests/e2e/inspect/test_e2e_leaf_spine.py | 18 ++++- tests/inspect/test_changeset.py | 12 ++++ tests/inspect/test_models.py | 12 ++++ 8 files changed, 142 insertions(+), 5 deletions(-) diff --git a/docs/getting-started-guide/05_Inspect.md b/docs/getting-started-guide/05_Inspect.md index 94774fa..0f96ca4 100644 --- a/docs/getting-started-guide/05_Inspect.md +++ b/docs/getting-started-guide/05_Inspect.md @@ -47,7 +47,7 @@ serves from local state: ```python for port in dev.ports: # triggers one hydration fetch for this device - print(port.label, port.vertex_id, port.status) + print(port.label, port.vertex_id, port.status, port.tags) edge = port.edge # local edge-skeleton lookup, no I/O if edge: print("connected to", edge.to_device.label) @@ -100,6 +100,10 @@ app.inspect.update_device("device12", label="BU-LEAF-A", icon_type="ipSwitchRout app.inspect.update_vertex("device12.1.Ethernet1.out", use_as_endpoint=True) app.inspect.update_edge(edge_id, weight=10) +# Assign catalog tags to a port (an Inspect-only capability). Tags are referenced by their +# "Category~~name" id; read them back with port.tags. +app.inspect.update_vertex("device12.1.Ethernet1.out", tags=["Video~~1080p50"]) + app.inspect.connect( "device12.1.Ethernet1.out", "device7.0.swp1.in", diff --git a/src/videoipath_automation_tool/apps/inspect/changeset.py b/src/videoipath_automation_tool/apps/inspect/changeset.py index 6e3e7cd..ea8777d 100644 --- a/src/videoipath_automation_tool/apps/inspect/changeset.py +++ b/src/videoipath_automation_tool/apps/inspect/changeset.py @@ -204,14 +204,20 @@ def update_vertex( label: Optional[str] = None, tags: Optional[list[str]] = None, ) -> "InspectTransaction": - """Edit a vertex (``replaceVertices``; update-only — vertices cannot be created here).""" + """Edit a vertex/port (``replaceVertices``; update-only — vertices cannot be created here). + + ``tags`` assigns catalog tags to the port (as ``Category~~name`` ids); this is the + Inspect-only port-tagging capability, written to the vertex ``localAssignedTags``. + """ entry = self._stage_vertex(vertex_id) if use_as_endpoint is not None: entry.intents["useAsEndpoint"] = use_as_endpoint if label is not None: entry.intents["label"] = label if tags is not None: - entry.intents["tags"] = list(tags) + # Port tag assignment is the vertex's localAssignedTags (verified 2025.4.9); the + # separate ``fields.tags`` list does not register as an assigned tag. + entry.intents["localAssignedTags"] = list(tags) return self # --- Staging: edges --- diff --git a/src/videoipath_automation_tool/apps/inspect/domain/port.py b/src/videoipath_automation_tool/apps/inspect/domain/port.py index 7260d59..cb52497 100644 --- a/src/videoipath_automation_tool/apps/inspect/domain/port.py +++ b/src/videoipath_automation_tool/apps/inspect/domain/port.py @@ -41,6 +41,12 @@ def module_id(self) -> str | None: def status(self) -> InspectApiStatusSummary | None: return self.indexed.port.status + @property + def tags(self) -> list[str]: + """Tags assigned to this port (as ``Category~~name`` ids). Assign them with + ``app.inspect.update_vertex(port.vertex_id, tags=[...])``.""" + return self.indexed.port.assigned_tags + @property def vertex_id(self) -> str | None: return self._vertex_id_from(self.indexed.port) diff --git a/src/videoipath_automation_tool/apps/inspect/model/collector.py b/src/videoipath_automation_tool/apps/inspect/model/collector.py index ea79520..5b470b2 100644 --- a/src/videoipath_automation_tool/apps/inspect/model/collector.py +++ b/src/videoipath_automation_tool/apps/inspect/model/collector.py @@ -115,11 +115,23 @@ class InspectPortStatus(InspectApiBaseModel): descriptor: InspectApiDescriptor | None = None label: str | None = None pid: str | None = None + relatedNodeTags: list[str] = Field(default_factory=list) resourceId: str | None = None status: InspectApiStatusSummary | None = None + tagsInfo: dict[str, Any] | None = None vertexInfo: InspectApiSingleVertexInfo | InspectApiDoubleVertexInfo | dict[str, Any] | None = None pathDescriptions: dict[str, InspectApiPathDescriptionItem] = Field(default_factory=dict) + @property + def assigned_tags(self) -> list[str]: + """Effective tags assigned to this port (from ``tagsInfo.assigned.all``).""" + if not self.tagsInfo: + return [] + assigned = self.tagsInfo.get("assigned") + if isinstance(assigned, dict) and isinstance(assigned.get("all"), list): + return list(assigned["all"]) + return [] + @property def effective_label(self) -> str | None: if self.descriptor is not None and self.descriptor.label: diff --git a/tests/e2e/inspect/scenario.py b/tests/e2e/inspect/scenario.py index b66ffc2..c93259e 100644 --- a/tests/e2e/inspect/scenario.py +++ b/tests/e2e/inspect/scenario.py @@ -22,7 +22,9 @@ from collections import defaultdict from dataclasses import dataclass from pathlib import Path -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Any + +import requests if TYPE_CHECKING: from videoipath_automation_tool.apps.videoipath_app import VideoIPathApp @@ -32,6 +34,31 @@ MOCK_DRIVER = "com.nevion.mock-0.1.0" TOPOLOGY_FILE = Path(__file__).parent / "leaf_spine_topology.json" +# A test video tag, created via a simple API request in setup and assigned to a port via the inspect +# app. Tag references are ``Category~~name`` ids; it lives in the existing "Video" format category. +TEST_TAG_CATEGORY = "Video" +TEST_TAG_NAME = "E2E-VIDEO-TAG" +TEST_TAG_ID = f"{TEST_TAG_CATEGORY}~~{TEST_TAG_NAME}" + + +def _raw_request(app: "VideoIPathApp", method: str, path: str, body: dict[str, Any]) -> requests.Response: + """A minimal authenticated REST call (for tag-catalog management, which the package's connector + allow-list intentionally does not cover).""" + rc = app._videoipath_connector.rest + response = getattr(requests, method)( + rc._build_url(path), json=body, auth=(rc._username, rc._password), verify=rc.verify_ssl_cert + ) + response.raise_for_status() + return response + + +def _tag_category(app: "VideoIPathApp", category: str) -> dict[str, Any] | None: + trees = app._videoipath_connector.rest.get("/rest/v2/data/config/tags/tagTrees/**") + for item in trees.data["config"]["tags"]["tagTrees"].get("_items", []): + if item.get("_id") == category: + return item + return None + # The fixture coordinates are captured from the live topology, so the replica would sit right on top # of it. Shift the whole E2E topology into its own region of the map so it never overlaps. POSITION_OFFSET_X = 0 @@ -98,6 +125,45 @@ def a_link(self) -> tuple[str, str]: link = self.links[0] return self.devices[link.a].name, self.devices[link.b].name + # --- Test tag catalog (simple API requests) --- + + def create_test_tag(self, app: "VideoIPathApp") -> None: + """Create the E2E test video tag in the catalog (idempotent).""" + category = _tag_category(app, TEST_TAG_CATEGORY) + if category is None: + raise RuntimeError(f"Tag category '{TEST_TAG_CATEGORY}' not found on the server.") + children = dict(category.get("children") or {}) + children[TEST_TAG_NAME] = {"exclusive": False, "children": {}} + body = { + "actions": [ + { + "_action": "update", + "_id": TEST_TAG_CATEGORY, + "_rev": category["_rev"], + "children": children, + "type": category.get("type", "format"), + "exclusive": category.get("exclusive", False), + "formatTagLinks": category.get("formatTagLinks", {}), + "locationTypes": category.get("locationTypes", []), + } + ] + } + _raw_request(app, "patch", "/rest/v2/data/config/tags/tagTrees", body) + + def test_tag_exists(self, app: "VideoIPathApp") -> bool: + category = _tag_category(app, TEST_TAG_CATEGORY) + return category is not None and TEST_TAG_NAME in (category.get("children") or {}) + + def delete_test_tag(self, app: "VideoIPathApp") -> None: + """Force-delete the test tag (removes it and any port bindings) if it exists.""" + if self.test_tag_exists(app): + _raw_request( + app, + "post", + "/rest/v2/actions/status/tags/forceDeleteTag", + {"header": {"id": 0}, "data": {"tagId": TEST_TAG_ID}}, + ) + # --- Startup cleanup (removes any prior E2E state) --- def cleanup(self, app: "VideoIPathApp") -> None: @@ -108,6 +174,7 @@ def cleanup(self, app: "VideoIPathApp") -> None: label in **both** (catching orphans from an aborted run) and remove edges, the topology node, and the inventory entry. """ + self.delete_test_tag(app) inventory_labels = app.inventory._inventory_api.fetch_devices_user_defined_labels_as_dict() inventory_ids = {i for i, label in inventory_labels.items() if (label or "").startswith(E2E_PREFIX)} app.inspect.refresh() @@ -152,6 +219,8 @@ def build(self, app: "VideoIPathApp") -> None: tx.commit() self._discover_ports(app) self._connect(app) + # Create the test video tag so the port-tagging test can assign it. + self.create_test_tag(app) def _create_inventory_devices(self, app: "VideoIPathApp") -> None: for i, spec in enumerate(self.devices): diff --git a/tests/e2e/inspect/test_e2e_leaf_spine.py b/tests/e2e/inspect/test_e2e_leaf_spine.py index 7b40431..d8bfedd 100644 --- a/tests/e2e/inspect/test_e2e_leaf_spine.py +++ b/tests/e2e/inspect/test_e2e_leaf_spine.py @@ -21,7 +21,7 @@ from videoipath_automation_tool.apps.inspect.errors import InspectCommitConflictError, InspectCommitError from videoipath_automation_tool.apps.videoipath_app import VideoIPathApp -from .scenario import LeafSpineScenario +from .scenario import TEST_TAG_ID, LeafSpineScenario pytestmark = pytest.mark.e2e @@ -160,6 +160,22 @@ def test_conflict_detection(app, scenario): app.inspect.update_edge(edge_id, weight=1) # revert +def test_assign_tag_to_port(app, scenario): + # Inspect-only capability: assign a catalog tag to a port. The tag was created in setup. + assert scenario.test_tag_exists(app) + app.inspect.refresh() + name = scenario.a_link()[0] + device_id = scenario.device_id(name) + vertex_id = next( + p.vertex_id for p in app.inspect.get_device(device_id).ports if p.vertex_id and "Router In" in (p.label or "") + ) + result = app.inspect.update_vertex(vertex_id, tags=[TEST_TAG_ID]) + assert result.ok + app.inspect.refresh() + port = next(p for p in app.inspect.get_device(device_id).ports if p.vertex_id == vertex_id) + assert TEST_TAG_ID in port.tags + + def test_device_placement_roundtrip(app, scenario): name = scenario.device_names[0] spec = next(s for s in scenario.devices if s.name == name) diff --git a/tests/inspect/test_changeset.py b/tests/inspect/test_changeset.py index 7b5165e..e69be74 100644 --- a/tests/inspect/test_changeset.py +++ b/tests/inspect/test_changeset.py @@ -200,6 +200,18 @@ def test_update_vertex_sets_endpoint(): assert api.update_calls[0].replaceVertices[A_OUT].useAsEndpoint is True +def test_update_vertex_assigns_tags_as_local(): + # Port tag assignment goes to localAssignedTags, not the plain fields.tags list. + api = FakeAPI() + api.vertices[A_OUT] = _vertex_data() + with _txn(api) as tx: + tx.update_vertex(A_OUT, tags=["Video~~T"]) + tx.commit() + form = api.update_calls[0].replaceVertices[A_OUT] + assert form.localAssignedTags == ["Video~~T"] + assert form.tags == [] + + def test_remove_appends_to_remove_list(): api = FakeAPI() with _txn(api) as tx: diff --git a/tests/inspect/test_models.py b/tests/inspect/test_models.py index 3d2f25d..47ccf89 100644 --- a/tests/inspect/test_models.py +++ b/tests/inspect/test_models.py @@ -10,6 +10,7 @@ InspectApiExternalEdgesByDeviceKeyItem, InspectApiNodeStatusItem, InspectApiPathItem, + InspectPortStatus, ) from videoipath_automation_tool.apps.inspect.model.update_topology import ( InspectApiUpdateTopologyData, @@ -117,3 +118,14 @@ def test_commit_success_and_failure_flags(load): fail_remove = InspectApiUpdateTopologyResponse.model_validate(load("update_topology_fail_remove.json")) assert fail_remove.committed is False + + +def test_port_assigned_tags_from_tags_info(): + port = InspectPortStatus.model_validate( + { + "_id": "device-a.1.p1", + "tagsInfo": {"assigned": {"all": ["Video~~T"], "inherited": {}, "local": {"Video~~T": {}}}}, + } + ) + assert port.assigned_tags == ["Video~~T"] + assert InspectPortStatus.model_validate({"_id": "device-a.1.p2"}).assigned_tags == [] From 1deafb50d10783617937f8c32bb3a07ec62ba428 Mon Sep 17 00:00:00 2001 From: Jonas Scholl <43819845+JonasScholl@users.noreply.github.com> Date: Thu, 9 Jul 2026 11:49:14 +0200 Subject: [PATCH 07/34] improve agent instructions --- .claude/rules/agent-rules-layout.md | 67 ++++++++++++++++++++++++++++ .claude/rules/python-quality.md | 46 +++++++++++++++++++ .claude/rules/python-style.md | 55 +++++++++++++++++++++++ .claude/rules/python-testing.md | 62 +++++++++++++++++++++++++ .cursor/rules/agent-rules-layout.mdc | 1 + .cursor/rules/python-quality.mdc | 1 + .cursor/rules/python-style.mdc | 1 + .cursor/rules/python-testing.mdc | 1 + AGENTS.md | 11 +++++ CLAUDE.md | 1 + 10 files changed, 246 insertions(+) create mode 100644 .claude/rules/agent-rules-layout.md create mode 100644 .claude/rules/python-quality.md create mode 100644 .claude/rules/python-style.md create mode 100644 .claude/rules/python-testing.md create mode 120000 .cursor/rules/agent-rules-layout.mdc create mode 120000 .cursor/rules/python-quality.mdc create mode 120000 .cursor/rules/python-style.mdc create mode 120000 .cursor/rules/python-testing.mdc create mode 120000 CLAUDE.md diff --git a/.claude/rules/agent-rules-layout.md b/.claude/rules/agent-rules-layout.md new file mode 100644 index 0000000..b246ca6 --- /dev/null +++ b/.claude/rules/agent-rules-layout.md @@ -0,0 +1,67 @@ +--- +description: Agent rules directory layout and symlink conventions +alwaysApply: false +globs: .claude/rules/**,.cursor/rules/** +paths: + - ".claude/rules/**/*.md" + - ".cursor/rules/**/*.mdc" +--- + +# Agent rules layout + +This repo shares agent rules between **Claude Code** and **Cursor** via symlinks. + +## Canonical source + +- **Edit rules here:** `.claude/rules/*.md` +- **Do not** put rule content directly in `.cursor/rules/` — those files are symlinks. + +## Cursor bridge + +Cursor requires `.mdc` files in `.cursor/rules/`. Each `.mdc` is a symlink to the matching `.md`: + +```text +.claude/rules/my-rule.md ← canonical (commit this) +.cursor/rules/my-rule.mdc ← symlink → ../.claude/rules/my-rule.md +``` + +## Dual frontmatter + +Every rule file needs frontmatter for both tools: + +```yaml +--- +description: Short summary for Cursor rule picker +alwaysApply: false +globs: path/to/match/** +paths: + - "path/to/match/**" +--- +``` + +- **Cursor** uses `globs`, `alwaysApply`, and `description`. +- **Claude Code** uses `paths`; omit `paths` for always-on rules. +- Each tool ignores the other's keys. + +## Adding a rule + +1. Create `.claude/rules/.md` with dual frontmatter and content. +2. Add the Cursor symlink: + +```bash +ln -s ../.claude/rules/.md .cursor/rules/.mdc +``` + +3. List the new rule in the **Agent rules** section of `AGENTS.md`. + +## Removing a rule + +1. Delete `.claude/rules/.md`. +2. Delete `.cursor/rules/.mdc` (the symlink, not a separate file). +3. Remove its entry from `AGENTS.md`. + +## Do not + +- Symlink the entire `.cursor/rules` directory to `.claude/rules` (extension mismatch: `.mdc` vs `.md`). +- Duplicate rule content in both directories. +- Commit real files under `.cursor/rules/` — only symlinks belong there. diff --git a/.claude/rules/python-quality.md b/.claude/rules/python-quality.md new file mode 100644 index 0000000..0dddc52 --- /dev/null +++ b/.claude/rules/python-quality.md @@ -0,0 +1,46 @@ +--- +description: Python code quality conventions for src/ and tests/ +alwaysApply: false +globs: src/**/*.py,tests/**/*.py +paths: + - "src/**/*.py" + - "tests/**/*.py" +--- + +# Python code quality + +## Structured data + +- Use **Pydantic `BaseModel`** for API responses, settings, and schemas. +- Use **`@dataclass`** only for simple internal structs that do not need validation. +- Prefer typed models over plain `dict` for structured data. + +## Exceptions + +- Raise **specific, descriptive exceptions** rather than bare `Exception`. +- Follow domain error hierarchies: a base exception class plus typed subclasses with context attributes (see `src/videoipath_automation_tool/apps/inspect/errors.py`). + +## Context managers + +- Use `with` for files, locks, test spies, and connector wrappers. + +## Quality gates + +Before finishing Python changes, run: + +```bash +poetry run ruff check --fix src/ tests/ +poetry run ruff format src/ tests/ +``` + +Pre-commit hooks mirror these commands (see `.pre-commit-config.yaml`). + +## Generated and sensitive code + +- Do **not** hand-edit `src/videoipath_automation_tool/apps/inventory/model/drivers.py` — regenerate with `set-videoipath-version `. +- All committed data must follow the anonymization rules in `AGENTS.md`. + +## Change scope + +- Keep diffs minimal and focused. +- Match surrounding patterns: mixins, module docstrings, `TYPE_CHECKING` imports, and existing naming in the file you edit. diff --git a/.claude/rules/python-style.md b/.claude/rules/python-style.md new file mode 100644 index 0000000..92ac676 --- /dev/null +++ b/.claude/rules/python-style.md @@ -0,0 +1,55 @@ +--- +description: Python coding style for src/ and tests/ +alwaysApply: false +globs: src/**/*.py,tests/**/*.py +paths: + - "src/**/*.py" + - "tests/**/*.py" +--- + +# Python coding style + +## Type hints + +- Annotate all function parameters and return types. +- Use `from __future__ import annotations` in new modules. +- Use `TYPE_CHECKING` blocks for imports needed only for type hints. + +## Formatting and naming + +- Follow PEP 8 with **snake_case** for functions, variables, and modules. +- Max line length is **120** characters (ruff formatter default in this project — not Black's 88). +- Format with **ruff**, not Black: + +```bash +poetry run ruff format src/ tests/ +``` + +## Strings and paths + +- Use **f-strings** for string formatting; avoid `%` formatting and `.format()`. +- Use **`pathlib.Path`** over `os.path` for filesystem operations. + +## Comprehensions and readability + +- Prefer list/dict/set comprehensions over explicit loops when the result stays readable. +- Do not sacrifice clarity for brevity. + +## Layout and whitespace + +- Group **logically related lines** together (e.g. setup, core logic, cleanup). +- Separate groups with a **single blank line**; use an extra blank line between larger sections when it aids scanning. +- Do not sprinkle blank lines randomly, and do not leave long unbroken blocks when a visual break would help. +- Within a function, keep the main path easy to follow: inputs and validation first, then the core work, then return/cleanup. + +## Public before private + +- Place **public** API first so readers see the most relevant surface when scrolling: public classes, methods, functions, and module-level constants. +- Place **private** members after public ones: names prefixed with `_` (attributes, methods, functions, nested helpers) and internal implementation details. +- In classes: public methods first, then `_`-prefixed helpers and internal state accessors. +- In modules: public exports and user-facing functions first; private helpers and module-internal constants at the bottom. +- A short section comment (e.g. `# --- Internal ---`) is fine when a class or module has a large private block. + +## Resources + +- Use **context managers** (`with`) for files, locks, and other resources that need cleanup. diff --git a/.claude/rules/python-testing.md b/.claude/rules/python-testing.md new file mode 100644 index 0000000..873425d --- /dev/null +++ b/.claude/rules/python-testing.md @@ -0,0 +1,62 @@ +--- +description: Python testing conventions for src/ and tests/ +alwaysApply: false +globs: src/**/*.py,tests/**/*.py +paths: + - "src/**/*.py" + - "tests/**/*.py" +--- + +# Python testing + +## Framework and commands + +- Use **pytest** for all tests. +- Default suite (excludes e2e): + +```bash +poetry run pytest +``` + +- Single file: + +```bash +poetry run pytest tests/validators/test_device_id.py +``` + +Default `addopts` in `pyproject.toml` run coverage on `src/` and exclude e2e (`-m "not e2e"`). + +## Assertions + +- Use `pytest.raises(SpecificError)` with the exact exception type. +- Do not catch or assert against bare `Exception` when a domain error exists. + +## Unit and offline tests + +- Mock external I/O with fake connectors and lightweight stand-ins (see `tests/inspect/test_actions.py`). +- Load JSON fixtures from `tests/fixtures/` using `pathlib.Path`. +- Put shared fixtures in `conftest.py` at the appropriate directory level. +- Use session-scoped fixtures only when setup is expensive and reuse is intentional. + +## E2E tests + +- Live-server tests live under `tests/e2e/` only. +- Mark with `@pytest.mark.e2e`; they are excluded from the default suite. +- Require `VIPAT_E2E_ENABLED=1` in `tests/.env.test` and run explicitly: + +```bash +poetry run pytest -m e2e tests/e2e/inspect +``` + +- Namespace all writes with the `E2E-` label prefix and `vipat-e2e` tag. +- Do not add e2e tests to the default CI/offline run. + +## Test data + +- All fixture and test data must follow anonymization rules in `AGENTS.md`. +- Preserve structure and relationships; replace real hostnames, IPs, and customer identifiers with generic placeholders. + +## Coverage + +- Default runs report coverage on `src/`. +- Do not disable coverage flags without a clear reason. diff --git a/.cursor/rules/agent-rules-layout.mdc b/.cursor/rules/agent-rules-layout.mdc new file mode 120000 index 0000000..484590f --- /dev/null +++ b/.cursor/rules/agent-rules-layout.mdc @@ -0,0 +1 @@ +../.claude/rules/agent-rules-layout.md \ No newline at end of file diff --git a/.cursor/rules/python-quality.mdc b/.cursor/rules/python-quality.mdc new file mode 120000 index 0000000..5a47a4e --- /dev/null +++ b/.cursor/rules/python-quality.mdc @@ -0,0 +1 @@ +../.claude/rules/python-quality.md \ No newline at end of file diff --git a/.cursor/rules/python-style.mdc b/.cursor/rules/python-style.mdc new file mode 120000 index 0000000..8598caa --- /dev/null +++ b/.cursor/rules/python-style.mdc @@ -0,0 +1 @@ +../.claude/rules/python-style.md \ No newline at end of file diff --git a/.cursor/rules/python-testing.mdc b/.cursor/rules/python-testing.mdc new file mode 120000 index 0000000..94109c9 --- /dev/null +++ b/.cursor/rules/python-testing.mdc @@ -0,0 +1 @@ +../.claude/rules/python-testing.md \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md index 4556da3..dc0ced0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -55,6 +55,17 @@ get-videoipath-version list-videoipath-versions ``` +## Agent rules + +Global repo guidance lives in this file. Path-scoped Python rules are in `.claude/rules/`: + +- `python-style.md` — type hints, ruff formatting, naming, pathlib +- `python-quality.md` — Pydantic, exceptions, lint gates, change scope +- `python-testing.md` — pytest, fixtures, fakes, e2e gating +- `agent-rules-layout.md` — how to add/remove rules and maintain `.cursor/rules/` symlinks + +Cursor reads the same content via symlinks in `.cursor/rules/*.mdc`. Canonical rule files live in `.claude/rules/`; see `agent-rules-layout.md` when creating or deleting rules. Python rules load when working on files under `src/` or `tests/`. + ## Architecture ### Three-layer design diff --git a/CLAUDE.md b/CLAUDE.md new file mode 120000 index 0000000..47dc3e3 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +AGENTS.md \ No newline at end of file From 65b16e1b3abdc34d9bda778235ac94fc1d195c48 Mon Sep 17 00:00:00 2001 From: Jonas Scholl <43819845+JonasScholl@users.noreply.github.com> Date: Thu, 9 Jul 2026 12:04:31 +0200 Subject: [PATCH 08/34] always use pydantic for consistency, remove dataclass usage --- .claude/rules/python-quality.md | 4 +- .../apps/inspect/app/read.py | 4 +- .../apps/inspect/changeset.py | 26 ++++++------ .../apps/inspect/domain/device.py | 9 ++--- .../apps/inspect/domain/edge.py | 8 ++-- .../apps/inspect/domain/port.py | 9 ++--- .../apps/inspect/domain/service.py | 8 ++-- .../apps/inspect/inspect_api.py | 4 +- .../apps/inspect/model/collector.py | 3 +- .../apps/inspect/model/common.py | 10 +++++ .../apps/inspect/queries.py | 2 +- .../apps/inspect/snapshot.py | 31 ++++++-------- tests/e2e/inspect/scenario.py | 19 +++++---- tests/inspect/test_actions.py | 11 ++++- tests/inspect/test_api.py | 21 ++++++++-- tests/inspect/test_changeset.py | 40 ++++++++++++++----- 16 files changed, 124 insertions(+), 85 deletions(-) diff --git a/.claude/rules/python-quality.md b/.claude/rules/python-quality.md index 0dddc52..de5ec19 100644 --- a/.claude/rules/python-quality.md +++ b/.claude/rules/python-quality.md @@ -11,9 +11,9 @@ paths: ## Structured data -- Use **Pydantic `BaseModel`** for API responses, settings, and schemas. -- Use **`@dataclass`** only for simple internal structs that do not need validation. +- Use **Pydantic `BaseModel`** for all structured data — API responses, settings, internal records, result types, and domain wrappers. Do not use `@dataclass`. - Prefer typed models over plain `dict` for structured data. +- Reuse project base models where they exist (e.g. `InspectApiBaseModel` for API shapes; inspect-internal bases for in-memory models). ## Exceptions diff --git a/src/videoipath_automation_tool/apps/inspect/app/read.py b/src/videoipath_automation_tool/apps/inspect/app/read.py index 00bcdb2..47bdac7 100644 --- a/src/videoipath_automation_tool/apps/inspect/app/read.py +++ b/src/videoipath_automation_tool/apps/inspect/app/read.py @@ -47,9 +47,7 @@ def _get_snapshot(self: _HasInspectState) -> InspectSnapshot: def _load_snapshot(self: _HasInspectState, load: LoadMode) -> InspectSnapshot: if load == "full": self._logger.debug("Loading full (eager) Inspect snapshot.") - return InspectSnapshot.from_full_response( - self._inspect_api.get_collector_full(), fetcher=self._inspect_api - ) + return InspectSnapshot.from_full_response(self._inspect_api.get_collector_full(), fetcher=self._inspect_api) self._logger.debug("Loading skeleton Inspect snapshot (devices + edges in parallel).") with ThreadPoolExecutor(max_workers=2) as pool: devices_future = pool.submit(self._inspect_api.get_device_skeleton) diff --git a/src/videoipath_automation_tool/apps/inspect/changeset.py b/src/videoipath_automation_tool/apps/inspect/changeset.py index ea8777d..6cc52b2 100644 --- a/src/videoipath_automation_tool/apps/inspect/changeset.py +++ b/src/videoipath_automation_tool/apps/inspect/changeset.py @@ -24,9 +24,10 @@ from __future__ import annotations import logging -from dataclasses import dataclass, field from typing import TYPE_CHECKING, Any, Optional +from pydantic import Field + from videoipath_automation_tool.apps.inspect.errors import ( InspectCommitConflictError, InspectCommitError, @@ -38,6 +39,7 @@ InspectApiLookupInspectDeviceFields, InspectApiVertexEditForm, ) +from videoipath_automation_tool.apps.inspect.model.common import InspectFrozenModel, InspectInternalModel from videoipath_automation_tool.apps.inspect.model.update_topology import ( InspectApiUpdateTopologyData, InspectApiUpdateTopologyResponse, @@ -53,8 +55,7 @@ _EDGE = "edge" -@dataclass(frozen=True) -class CommitResult: +class CommitResult(InspectFrozenModel): """Outcome of a successful commit ([ADR-0006]); a failed commit raises ``InspectCommitError``.""" applied_ids: list[str] @@ -70,8 +71,7 @@ def validation(self) -> Any: return self.response.data.validation -@dataclass -class _Staged: +class _Staged(InspectInternalModel): kind: str entity_id: str # The write-shape baseline (edit/edge form) as fetched at stage time; None for a raw remove. @@ -79,7 +79,7 @@ class _Staged: # JSON dump of the baseline at stage time, used for the compare-and-commit conflict check. baseline_dump: dict[str, Any] | None = None # Field-level intents (wire field names; dotted for one level of nesting, e.g. "descriptor.label"). - intents: dict[str, Any] = field(default_factory=dict) + intents: dict[str, Any] = Field(default_factory=dict) remove: bool = False is_new: bool = False @@ -384,10 +384,12 @@ def _stage_new_edge( edge_id = _edge_key(from_vertex, to_vertex) existing = self._lookup_edge_form(edge_id) if existing is not None and not overwrite: - raise ValueError( - f"Edge '{edge_id}' already exists; pass overwrite=True to replace it." - ) - form = existing.model_copy(deep=True) if existing is not None else InspectApiEdgeForm(fromId=from_vertex, toId=to_vertex) + raise ValueError(f"Edge '{edge_id}' already exists; pass overwrite=True to replace it.") + form = ( + existing.model_copy(deep=True) + if existing is not None + else InspectApiEdgeForm(fromId=from_vertex, toId=to_vertex) + ) form.fromId = from_vertex form.toId = to_vertex entry = _Staged( @@ -446,9 +448,7 @@ def _check_conflicts(self) -> None: key = (entry.kind, entry.entity_id) server_form = current.get(key) if server_form is None: - conflicts.append( - InspectConflict(entry.entity_id, entry.kind, {"__exists__": (True, False)}) - ) + conflicts.append(InspectConflict(entry.entity_id, entry.kind, {"__exists__": (True, False)})) continue server_dump = server_form.model_dump(mode="json") if server_dump != entry.baseline_dump: diff --git a/src/videoipath_automation_tool/apps/inspect/domain/device.py b/src/videoipath_automation_tool/apps/inspect/domain/device.py index b395829..f440938 100644 --- a/src/videoipath_automation_tool/apps/inspect/domain/device.py +++ b/src/videoipath_automation_tool/apps/inspect/domain/device.py @@ -1,20 +1,19 @@ from __future__ import annotations -from dataclasses import dataclass from datetime import datetime from typing import TYPE_CHECKING -from videoipath_automation_tool.apps.inspect.model.common import InspectApiStatusSummary +from videoipath_automation_tool.apps.inspect.model.common import InspectApiStatusSummary, InspectFrozenModel +from videoipath_automation_tool.apps.inspect.snapshot import InspectSnapshot if TYPE_CHECKING: from videoipath_automation_tool.apps.inspect.domain.edge import InspectEdge from videoipath_automation_tool.apps.inspect.domain.port import InspectPort from videoipath_automation_tool.apps.inspect.domain.service import InspectService - from videoipath_automation_tool.apps.inspect.snapshot import _DeviceRecord, InspectSnapshot + from videoipath_automation_tool.apps.inspect.snapshot import _DeviceRecord -@dataclass(frozen=True, slots=True) -class InspectDevice: +class InspectDevice(InspectFrozenModel): """A topology device/node. Skeleton fields (id, label, coordinates, status, sync, tags) are available immediately; ``ports`` and ``services`` lazily hydrate from the server on first access ([ADR-0007]). The record is resolved live from the snapshot, so a held reference sees diff --git a/src/videoipath_automation_tool/apps/inspect/domain/edge.py b/src/videoipath_automation_tool/apps/inspect/domain/edge.py index 3186b37..597040d 100644 --- a/src/videoipath_automation_tool/apps/inspect/domain/edge.py +++ b/src/videoipath_automation_tool/apps/inspect/domain/edge.py @@ -1,20 +1,18 @@ from __future__ import annotations -from dataclasses import dataclass from typing import TYPE_CHECKING from videoipath_automation_tool.apps.inspect.model.collector import InspectApiExternalEdgeLiveStatus -from videoipath_automation_tool.apps.inspect.snapshot import _IndexedEdge +from videoipath_automation_tool.apps.inspect.model.common import InspectFrozenModel +from videoipath_automation_tool.apps.inspect.snapshot import InspectSnapshot, _IndexedEdge if TYPE_CHECKING: from videoipath_automation_tool.apps.inspect.domain.device import InspectDevice from videoipath_automation_tool.apps.inspect.domain.port import InspectPort from videoipath_automation_tool.apps.inspect.domain.service import InspectService - from videoipath_automation_tool.apps.inspect.snapshot import InspectSnapshot -@dataclass(frozen=True, slots=True) -class InspectEdge: +class InspectEdge(InspectFrozenModel): snapshot: InspectSnapshot indexed: _IndexedEdge diff --git a/src/videoipath_automation_tool/apps/inspect/domain/port.py b/src/videoipath_automation_tool/apps/inspect/domain/port.py index cb52497..15a22aa 100644 --- a/src/videoipath_automation_tool/apps/inspect/domain/port.py +++ b/src/videoipath_automation_tool/apps/inspect/domain/port.py @@ -1,23 +1,20 @@ from __future__ import annotations -from dataclasses import dataclass from typing import TYPE_CHECKING from videoipath_automation_tool.apps.inspect.model.collector import ( InspectApiSingleVertexInfo, InspectPortStatus, ) -from videoipath_automation_tool.apps.inspect.model.common import InspectApiStatusSummary -from videoipath_automation_tool.apps.inspect.snapshot import _IndexedPort, _port_id_from_status +from videoipath_automation_tool.apps.inspect.model.common import InspectApiStatusSummary, InspectFrozenModel +from videoipath_automation_tool.apps.inspect.snapshot import InspectSnapshot, _IndexedPort, _port_id_from_status if TYPE_CHECKING: from videoipath_automation_tool.apps.inspect.domain.device import InspectDevice from videoipath_automation_tool.apps.inspect.domain.edge import InspectEdge - from videoipath_automation_tool.apps.inspect.snapshot import InspectSnapshot -@dataclass(frozen=True, slots=True) -class InspectPort: +class InspectPort(InspectFrozenModel): snapshot: InspectSnapshot indexed: _IndexedPort diff --git a/src/videoipath_automation_tool/apps/inspect/domain/service.py b/src/videoipath_automation_tool/apps/inspect/domain/service.py index 3263a17..e9342ae 100644 --- a/src/videoipath_automation_tool/apps/inspect/domain/service.py +++ b/src/videoipath_automation_tool/apps/inspect/domain/service.py @@ -1,19 +1,17 @@ from __future__ import annotations -from dataclasses import dataclass from typing import TYPE_CHECKING from videoipath_automation_tool.apps.inspect.model.collector import InspectApiPathItem, InspectServiceStatus -from videoipath_automation_tool.apps.inspect.snapshot import _port_id_from_endpoint +from videoipath_automation_tool.apps.inspect.model.common import InspectFrozenModel +from videoipath_automation_tool.apps.inspect.snapshot import InspectSnapshot, _port_id_from_endpoint if TYPE_CHECKING: from videoipath_automation_tool.apps.inspect.domain.device import InspectDevice from videoipath_automation_tool.apps.inspect.domain.port import InspectPort - from videoipath_automation_tool.apps.inspect.snapshot import InspectSnapshot -@dataclass(frozen=True, slots=True) -class InspectService: +class InspectService(InspectFrozenModel): snapshot: InspectSnapshot path_item: InspectApiPathItem diff --git a/src/videoipath_automation_tool/apps/inspect/inspect_api.py b/src/videoipath_automation_tool/apps/inspect/inspect_api.py index eafed14..93cd286 100644 --- a/src/videoipath_automation_tool/apps/inspect/inspect_api.py +++ b/src/videoipath_automation_tool/apps/inspect/inspect_api.py @@ -143,9 +143,7 @@ def sync_devices( self, device_ids: list[str], add_only: bool = True, conflict_strategy: int = 0 ) -> InspectApiSimpleActionResponse: request = InspectApiSyncDevicesRequest( - data=InspectApiSyncDevicesRequestData( - ids=device_ids, addOnly=add_only, conflictStrategy=conflict_strategy - ) + data=InspectApiSyncDevicesRequestData(ids=device_ids, addOnly=add_only, conflictStrategy=conflict_strategy) ) response = self.vip_connector.rest.post("/rest/v2/actions/status/network/syncDevices", request) return InspectApiSimpleActionResponse.model_validate(_post_envelope(response)) diff --git a/src/videoipath_automation_tool/apps/inspect/model/collector.py b/src/videoipath_automation_tool/apps/inspect/model/collector.py index 5b470b2..3d6370d 100644 --- a/src/videoipath_automation_tool/apps/inspect/model/collector.py +++ b/src/videoipath_automation_tool/apps/inspect/model/collector.py @@ -257,8 +257,7 @@ class InspectApiCollector(InspectApiBaseModel): @property def external_edges_by_device_key_items(self) -> list[InspectApiExternalEdgesByDeviceKeyItem]: return [ - InspectApiExternalEdgesByDeviceKeyItem.model_validate(item) - for item in self.externalEdgesByDeviceKey.items + InspectApiExternalEdgesByDeviceKeyItem.model_validate(item) for item in self.externalEdgesByDeviceKey.items ] @property diff --git a/src/videoipath_automation_tool/apps/inspect/model/common.py b/src/videoipath_automation_tool/apps/inspect/model/common.py index 68e9539..7986fe6 100644 --- a/src/videoipath_automation_tool/apps/inspect/model/common.py +++ b/src/videoipath_automation_tool/apps/inspect/model/common.py @@ -9,6 +9,14 @@ class InspectApiBaseModel(BaseModel): model_config = ConfigDict(populate_by_name=True, validate_assignment=True, extra="allow") +class InspectInternalModel(BaseModel): + model_config = ConfigDict(validate_assignment=True, arbitrary_types_allowed=True) + + +class InspectFrozenModel(InspectInternalModel): + model_config = ConfigDict(frozen=True, slots=True, validate_assignment=True, arbitrary_types_allowed=True) + + class InspectApiDescriptor(InspectApiBaseModel): desc: str = "" label: str = "" @@ -74,6 +82,8 @@ class InspectApiActionValidationErrorResponse(InspectApiBaseModel): __all__ = [ "InspectApiActionValidationErrorResponse", "InspectApiBaseModel", + "InspectFrozenModel", + "InspectInternalModel", "InspectApiCollection", "InspectApiDescriptor", "InspectApiEndpointStatus", diff --git a/src/videoipath_automation_tool/apps/inspect/queries.py b/src/videoipath_automation_tool/apps/inspect/queries.py index 439048a..c095bac 100644 --- a/src/videoipath_automation_tool/apps/inspect/queries.py +++ b/src/videoipath_automation_tool/apps/inspect/queries.py @@ -41,7 +41,7 @@ _DEVICE_SKELETON = ( "/status/collector/inspect/nodeStatus/*" "/deviceId,resourceId,syncSeverity" - '/.../descriptor/**' + "/.../descriptor/**" "/.../.../meta/**" "/.../.../status/**" "/.../.../tags/*" diff --git a/src/videoipath_automation_tool/apps/inspect/snapshot.py b/src/videoipath_automation_tool/apps/inspect/snapshot.py index 07e78bc..8109e87 100644 --- a/src/videoipath_automation_tool/apps/inspect/snapshot.py +++ b/src/videoipath_automation_tool/apps/inspect/snapshot.py @@ -15,11 +15,12 @@ import threading from concurrent.futures import ThreadPoolExecutor -from dataclasses import dataclass, field from datetime import datetime, timezone from enum import Enum from typing import TYPE_CHECKING, Any, Iterator, Optional +from pydantic import Field + from videoipath_automation_tool.apps.inspect.model.collector import ( InspectApiCollectorResponse, InspectApiExternalEdgeLiveStatus, @@ -30,6 +31,7 @@ InspectApiPathItem, InspectPortStatus, ) +from videoipath_automation_tool.apps.inspect.model.common import InspectFrozenModel, InspectInternalModel if TYPE_CHECKING: from videoipath_automation_tool.apps.inspect.domain.device import InspectDevice @@ -50,12 +52,11 @@ def _now() -> datetime: return datetime.now(timezone.utc) -@dataclass -class _DeviceRecord: +class _DeviceRecord(InspectInternalModel): device_id: str node: InspectApiNodeStatusItem level: HydrationLevel - fetched_at: datetime = field(default_factory=_now) + fetched_at: datetime = Field(default_factory=_now) @property def label(self) -> str | None: @@ -66,15 +67,13 @@ def pid(self) -> str | None: return self.node.pid or self.node.deviceId -@dataclass(frozen=True, slots=True) -class _IndexedPort: +class _IndexedPort(InspectFrozenModel): device_id: str module_id: str | None port: InspectPortStatus -@dataclass(frozen=True, slots=True) -class _IndexedEdge: +class _IndexedEdge(InspectFrozenModel): edge_id: str pair_id: str edge: InspectApiExternalEdgeStatus @@ -350,9 +349,7 @@ def _ensure_device_detail(self, device_id: str) -> None: current = self._devices_by_id.get(device_id) if current is None or current.level is HydrationLevel.FULL: return - self._devices_by_id[device_id] = _DeviceRecord( - device_id=device_id, node=detail, level=HydrationLevel.FULL - ) + self._devices_by_id[device_id] = _DeviceRecord(device_id=device_id, node=detail, level=HydrationLevel.FULL) self._device_cache.pop(device_id, None) self._rebuild_device_ports(device_id, detail) @@ -364,9 +361,7 @@ def _refresh_device(self, device_id: str) -> None: if detail is None: return with self._lock: - self._devices_by_id[device_id] = _DeviceRecord( - device_id=device_id, node=detail, level=HydrationLevel.FULL - ) + self._devices_by_id[device_id] = _DeviceRecord(device_id=device_id, node=detail, level=HydrationLevel.FULL) self._device_cache.pop(device_id, None) self._rebuild_device_ports(device_id, detail) @@ -457,7 +452,9 @@ def _index_edge_pair(self, pair_item: InspectApiExternalEdgesByDeviceKeyItem) -> continue for edge in side.data.values(): from_device_id = ( - self._resolve_device_id(_device_id_from_context(edge.fromStatus.context if edge.fromStatus else None)) + self._resolve_device_id( + _device_id_from_context(edge.fromStatus.context if edge.fromStatus else None) + ) or primary_device_id ) from_port_id = _port_id_from_endpoint(edge.fromStatus) @@ -525,9 +522,7 @@ def _apply_removals(self, removed_ids: list[str]) -> None: if record is not None: label = record.label if label and label in self._devices_by_label: - self._devices_by_label[label] = [ - d for d in self._devices_by_label[label] if d != removed - ] + self._devices_by_label[label] = [d for d in self._devices_by_label[label] if d != removed] if not self._devices_by_label[label]: self._devices_by_label.pop(label, None) self._device_cache.pop(removed, None) diff --git a/tests/e2e/inspect/scenario.py b/tests/e2e/inspect/scenario.py index c93259e..c12161d 100644 --- a/tests/e2e/inspect/scenario.py +++ b/tests/e2e/inspect/scenario.py @@ -20,12 +20,13 @@ import json from collections import defaultdict -from dataclasses import dataclass from pathlib import Path from typing import TYPE_CHECKING, Any import requests +from videoipath_automation_tool.apps.inspect.model.common import InspectFrozenModel + if TYPE_CHECKING: from videoipath_automation_tool.apps.videoipath_app import VideoIPathApp @@ -59,22 +60,21 @@ def _tag_category(app: "VideoIPathApp", category: str) -> dict[str, Any] | None: return item return None + # The fixture coordinates are captured from the live topology, so the replica would sit right on top # of it. Shift the whole E2E topology into its own region of the map so it never overlaps. POSITION_OFFSET_X = 0 POSITION_OFFSET_Y = 3000 -@dataclass(frozen=True) -class DeviceSpec: +class DeviceSpec(InspectFrozenModel): name: str x: int y: int ports: int -@dataclass(frozen=True) -class LinkSpec: +class LinkSpec(InspectFrozenModel): a: int # device index b: int # device index count: int # number of parallel bidirectional links @@ -90,10 +90,15 @@ def __init__(self) -> None: data = json.loads(TOPOLOGY_FILE.read_text()) # Bake the offset into the specs so build placement and the placement-revert test agree. self.devices = [ - DeviceSpec(d["name"], d["x"] + POSITION_OFFSET_X, d["y"] + POSITION_OFFSET_Y, d["ports"]) + DeviceSpec( + name=d["name"], + x=d["x"] + POSITION_OFFSET_X, + y=d["y"] + POSITION_OFFSET_Y, + ports=d["ports"], + ) for d in data["devices"] ] - self.links = [LinkSpec(a, b, n) for a, b, n in data["links"]] + self.links = [LinkSpec(a=a, b=b, count=n) for a, b, n in data["links"]] self.device_ids: dict[str, str] = {} # E2E name -> VideoIPath device id self._out: dict[str, list[str]] = {} # name -> ordered out-vertex ids self._in: dict[str, list[str]] = {} # name -> ordered in-vertex ids diff --git a/tests/inspect/test_actions.py b/tests/inspect/test_actions.py index f9a0da8..0f4894d 100644 --- a/tests/inspect/test_actions.py +++ b/tests/inspect/test_actions.py @@ -11,8 +11,15 @@ def _ok_header(): return SimpleNamespace( model_dump=lambda mode="json": { - "auth": True, "caption": "OK", "code": "OK", "errorCodes": [], "errorDetails": [], - "id": "0", "msg": [], "ok": True, "user": "api-user", + "auth": True, + "caption": "OK", + "code": "OK", + "errorCodes": [], + "errorDetails": [], + "id": "0", + "msg": [], + "ok": True, + "user": "api-user", } ) diff --git a/tests/inspect/test_api.py b/tests/inspect/test_api.py index a8458bf..efd4b87 100644 --- a/tests/inspect/test_api.py +++ b/tests/inspect/test_api.py @@ -26,8 +26,15 @@ def post(self, url_path, body, **kwargs): def _ok_header(): return SimpleNamespace( model_dump=lambda mode="json": { - "auth": True, "caption": "OK", "code": "OK", "errorCodes": [], "errorDetails": [], - "id": "0", "msg": [], "ok": True, "user": "api-user", + "auth": True, + "caption": "OK", + "code": "OK", + "errorCodes": [], + "errorDetails": [], + "id": "0", + "msg": [], + "ok": True, + "user": "api-user", } ) @@ -52,7 +59,9 @@ def _collector(node_items=None, edge_items=None, path_items=None): def test_device_skeleton_uses_projection_and_parses(load): - node_items = load("skeleton_nodestatus_short.json")["data"]["status"]["collector"]["inspect"]["nodeStatus"]["_items"] + node_items = load("skeleton_nodestatus_short.json")["data"]["status"]["collector"]["inspect"]["nodeStatus"][ + "_items" + ] conn, rest = _connector(get_data=_collector(node_items=node_items)) api = InspectAPI(conn) devices = api.get_device_skeleton() @@ -84,7 +93,11 @@ def test_lookup_edges_hits_correct_endpoint(load): def test_update_topology_posts_delta(): conn, rest = _connector( - post_data={"items": [], "res": {"msg": [], "ok": True}, "validation": {"details": {}, "result": {"msg": [], "ok": True}}} + post_data={ + "items": [], + "res": {"msg": [], "ok": True}, + "validation": {"details": {}, "result": {"msg": [], "ok": True}}, + } ) api = InspectAPI(conn) resp = api.update_topology(InspectApiUpdateTopologyData()) diff --git a/tests/inspect/test_changeset.py b/tests/inspect/test_changeset.py index e69be74..f2f86d5 100644 --- a/tests/inspect/test_changeset.py +++ b/tests/inspect/test_changeset.py @@ -25,8 +25,15 @@ from .conftest import load_fixture _OK_HEADER = { - "auth": True, "caption": "OK", "code": "OK", "errorCodes": [], "errorDetails": [], - "id": "0", "msg": [], "ok": True, "user": "api-user", + "auth": True, + "caption": "OK", + "code": "OK", + "errorCodes": [], + "errorDetails": [], + "id": "0", + "msg": [], + "ok": True, + "user": "api-user", } A_OUT = "device12.1.Ethernet1.out" @@ -63,13 +70,24 @@ def _vertex_data(): def _edge_item(weight=1): edge = { - "active": True, "bandwidth": -1.0, "capacity": 65535, "conflictPri": 0, - "descriptor": {"desc": "", "label": ""}, "excludeFormats": [], - "fDescriptor": {"desc": "", "label": ""}, "fromId": A_OUT, "includeFormats": [], - "redundancyMode": "Any", "tags": [], "toId": B_IN, "weight": weight, + "active": True, + "bandwidth": -1.0, + "capacity": 65535, + "conflictPri": 0, + "descriptor": {"desc": "", "label": ""}, + "excludeFormats": [], + "fDescriptor": {"desc": "", "label": ""}, + "fromId": A_OUT, + "includeFormats": [], + "redundancyMode": "Any", + "tags": [], + "toId": B_IN, + "weight": weight, "weightFactors": {"bandwidth": {"weight": 0}, "service": {"max": 100, "weight": 0}}, } - return InspectApiLookupEdgeResponseItem.model_validate({"edge": edge, "fromDevice": "device12", "toDevice": "device7"}) + return InspectApiLookupEdgeResponseItem.model_validate( + {"edge": edge, "fromDevice": "device12", "toDevice": "device7"} + ) class FakeAPI: @@ -77,7 +95,9 @@ def __init__(self): self.devices = {} self.vertices = {} self.edges = {} - self.update_response = InspectApiUpdateTopologyResponse.model_validate(load_fixture("update_topology_success.json")) + self.update_response = InspectApiUpdateTopologyResponse.model_validate( + load_fixture("update_topology_success.json") + ) self.update_calls = [] self.lookup_device_calls = [] self.lookup_vertices_calls = [] @@ -243,7 +263,9 @@ def test_commit_success_returns_result(): def test_commit_failure_raises_commit_error(): api = FakeAPI() - api.update_response = InspectApiUpdateTopologyResponse.model_validate(load_fixture("update_topology_fail_remove.json")) + api.update_response = InspectApiUpdateTopologyResponse.model_validate( + load_fixture("update_topology_fail_remove.json") + ) with _txn(api) as tx: tx.remove("nonexistent-edge-id-xyz") with pytest.raises(InspectCommitError) as exc: From 3763af9e6c1aedc0ddf9ab59568be64e04c59b78 Mon Sep 17 00:00:00 2001 From: Jonas Scholl <43819845+JonasScholl@users.noreply.github.com> Date: Thu, 9 Jul 2026 12:30:11 +0200 Subject: [PATCH 09/34] improve test setup --- .claude/rules/python-testing.md | 45 ++++++++++++----- .env.example | 11 ---- .env.template | 13 +++++ .vscode/launch.json | 64 ++++++++++++++++++++++-- .vscode/settings.json | 1 + .vscode/tasks.json | 14 ++++++ AGENTS.md | 20 ++++++-- docs/development-and-release.md | 53 ++++++++++++++++++++ poetry.lock | 16 ------ pyproject.toml | 7 +-- src/vipat_cli_scripts/project_env.py | 36 +++++++++++++ src/vipat_cli_scripts/test_runner.py | 33 ++++++++++++ tests/.env.test | 7 --- tests/conftest.py | 23 +++++++++ tests/e2e/conftest.py | 15 ++++-- tests/e2e/inspect/test_e2e_leaf_spine.py | 2 +- 16 files changed, 297 insertions(+), 63 deletions(-) delete mode 100644 .env.example create mode 100644 .env.template create mode 100644 src/vipat_cli_scripts/project_env.py create mode 100644 src/vipat_cli_scripts/test_runner.py delete mode 100644 tests/.env.test create mode 100644 tests/conftest.py diff --git a/.claude/rules/python-testing.md b/.claude/rules/python-testing.md index 873425d..02bf778 100644 --- a/.claude/rules/python-testing.md +++ b/.claude/rules/python-testing.md @@ -12,19 +12,40 @@ paths: ## Framework and commands - Use **pytest** for all tests. -- Default suite (excludes e2e): +- Prefer the dedicated entry points over bare `pytest`: ```bash -poetry run pytest +poetry run test-unit # offline/unit suite (CI default) +poetry run test-e2e # live-server e2e (loads .env) +poetry run test # unit then e2e sequentially + +# Single file or test (extra args pass through to test-unit / test-e2e) +poetry run test-unit tests/validators/test_device_id.py +poetry run test-e2e tests/e2e/inspect/test_e2e_leaf_spine.py::test_name ``` -- Single file: +- `poetry run pytest` also runs unit tests only (e2e excluded via `addopts` in `pyproject.toml`). -```bash -poetry run pytest tests/validators/test_device_id.py -``` +### VS Code + +Use the launch configs in `.vscode/launch.json`: + +- **Unit Tests** / **Unit Tests (current file)** — offline suite +- **E2E Tests** / **E2E Tests (current file)** — live-server suite + +Or run **Tests** from `.vscode/tasks.json` (`poetry run test`). -Default `addopts` in `pyproject.toml` run coverage on `src/` and exclude e2e (`-m "not e2e"`). +## Unit vs e2e separation + +| Layer | Unit | E2E | +|-------|------|-----| +| Location | `tests/` except `tests/e2e/` | `tests/e2e/` only | +| Marker | unmarked | `@pytest.mark.e2e` | +| Env | `tests/conftest.py` (dummy values) | `.env` (copy from `.env.template`) | +| Run command | `test-unit` / `pytest` | `test-e2e` | +| CI | yes | no | + +Default `addopts` run coverage on `src/` and exclude e2e (`-m "not e2e"`). ## Assertions @@ -34,6 +55,7 @@ Default `addopts` in `pyproject.toml` run coverage on `src/` and exclude e2e (`- ## Unit and offline tests - Mock external I/O with fake connectors and lightweight stand-ins (see `tests/inspect/test_actions.py`). +- Dummy `VIPAT_*` env vars are set in `tests/conftest.py` (autouse fixture; skipped for e2e). - Load JSON fixtures from `tests/fixtures/` using `pathlib.Path`. - Put shared fixtures in `conftest.py` at the appropriate directory level. - Use session-scoped fixtures only when setup is expensive and reuse is intentional. @@ -42,12 +64,9 @@ Default `addopts` in `pyproject.toml` run coverage on `src/` and exclude e2e (`- - Live-server tests live under `tests/e2e/` only. - Mark with `@pytest.mark.e2e`; they are excluded from the default suite. -- Require `VIPAT_E2E_ENABLED=1` in `tests/.env.test` and run explicitly: - -```bash -poetry run pytest -m e2e tests/e2e/inspect -``` - +- E2e entry points (`poetry run test-e2e`, `poetry run test`, VS Code **E2E Tests**) load `.env` and enable the suite automatically. E2e runs use `--no-cov`. +- Copy `.env.template` to `.env` (gitignored), set connection vars. The e2e conftest loads `.env` automatically when present. +- Run with `poetry run test-e2e` (no extra env vars on the command line). - Namespace all writes with the `E2E-` label prefix and `vipat-e2e` tag. - Do not add e2e tests to the default CI/offline run. diff --git a/.env.example b/.env.example deleted file mode 100644 index 4022041..0000000 --- a/.env.example +++ /dev/null @@ -1,11 +0,0 @@ -VIPAT_ENVIRONMENT=DEV -VIPAT_VIDEOIPATH_SERVER_ADDRESS=vip.company.com -VIPAT_VIDEOIPATH_USERNAME=user_with_api_access -VIPAT_VIDEOIPATH_PASSWORD=veryStrongPassword -VIPAT_USE_HTTPS=true -VIPAT_VERIFY_SSL_CERT=false -VIPAT_LOG_LEVEL=INFO -VIPAT_ADVANCED_DRIVER_SCHEMA_CHECK=true -VIPAT_TIMEOUT_HTTP_GET=10 -VIPAT_TIMEOUT_HTTP_PATCH=10 -VIPAT_TIMEOUT_HTTP_POST=10 \ No newline at end of file diff --git a/.env.template b/.env.template new file mode 100644 index 0000000..9179638 --- /dev/null +++ b/.env.template @@ -0,0 +1,13 @@ +# Copy to .env (gitignored) for local development and live-server e2e tests. + +VIPAT_ENVIRONMENT=DEV +VIPAT_VIDEOIPATH_SERVER_ADDRESS=vip-server.example +VIPAT_VIDEOIPATH_USERNAME=test-user +VIPAT_VIDEOIPATH_PASSWORD=test-password +VIPAT_USE_HTTPS=true +VIPAT_VERIFY_SSL_CERT=false +VIPAT_LOG_LEVEL=INFO +VIPAT_ADVANCED_DRIVER_SCHEMA_CHECK=true +VIPAT_TIMEOUT_HTTP_GET=10 +VIPAT_TIMEOUT_HTTP_PATCH=10 +VIPAT_TIMEOUT_HTTP_POST=10 diff --git a/.vscode/launch.json b/.vscode/launch.json index 78c16ab..fe83c16 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -2,14 +2,72 @@ "version": "0.2.0", "configurations": [ { - "name": "Tests", + "name": "Unit Tests", "type": "debugpy", "request": "launch", "module": "pytest", + "python": "${workspaceFolder}/.venv/bin/python", + "cwd": "${workspaceFolder}", "args": [ - "-v" + "-m", + "not e2e", + "--ignore=tests/e2e" ], "console": "integratedTerminal" + }, + { + "name": "E2E Tests", + "type": "debugpy", + "request": "launch", + "module": "pytest", + "python": "${workspaceFolder}/.venv/bin/python", + "cwd": "${workspaceFolder}", + "args": [ + "-m", + "e2e", + "tests/e2e", + "--no-cov" + ], + "env": { + "VIPAT_E2E_ENABLED": "1" + }, + "envFile": "${workspaceFolder}/.env", + "console": "integratedTerminal" + }, + { + "name": "Unit Tests (current file)", + "type": "debugpy", + "request": "launch", + "module": "pytest", + "python": "${workspaceFolder}/.venv/bin/python", + "cwd": "${workspaceFolder}", + "args": [ + "-m", + "not e2e", + "--ignore=tests/e2e", + "${file}" + ], + "console": "integratedTerminal" + }, + { + "name": "E2E Tests (current file)", + "type": "debugpy", + "request": "launch", + "module": "pytest", + "python": "${workspaceFolder}/.venv/bin/python", + "cwd": "${workspaceFolder}", + "args": [ + "-m", + "e2e", + "tests/e2e", + "--no-cov", + "${file}" + ], + "env": { + "VIPAT_E2E_ENABLED": "1" + }, + "envFile": "${workspaceFolder}/.env", + "console": "integratedTerminal" } ] -} \ No newline at end of file +} diff --git a/.vscode/settings.json b/.vscode/settings.json index 5bf2cbe..9b87748 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,4 +1,5 @@ { + "python.defaultInterpreterPath": "${workspaceFolder}/.venv/bin/python", "[python]": { "editor.defaultFormatter": "charliermarsh.ruff", "editor.formatOnType": true diff --git a/.vscode/tasks.json b/.vscode/tasks.json index 55282d3..2c5cd45 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -1,6 +1,20 @@ { "version": "2.0.0", "tasks": [ + { + "label": "Tests", + "type": "shell", + "command": "poetry", + "args": [ + "run", + "test" + ], + "group": { + "kind": "test", + "isDefault": false + }, + "problemMatcher": [] + }, { "label": "Generate Data Model", "problemMatcher": [], diff --git a/AGENTS.md b/AGENTS.md index dc0ced0..dd46a55 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -34,11 +34,21 @@ Inspect fixtures can be anonymized with `scripts/anonymize_inspect_fixtures.py` # Install all dependencies (including dev and test groups) poetry install --with dev,test -# Run all tests -poetry run pytest +# Run unit tests (offline; same as CI default) +poetry run test-unit + +# Run e2e tests against a live server (loads connection vars from .env — see .env.template) +poetry run test-e2e + +# Run unit then e2e sequentially +poetry run test -# Run a single test file -poetry run pytest tests/validators/test_device_id.py +# Single file or test (extra args pass through) +poetry run test-unit tests/validators/test_device_id.py +poetry run test-e2e tests/e2e/inspect/test_e2e_leaf_spine.py::test_name + +# Bare pytest also runs unit tests only (e2e excluded via pyproject addopts) +poetry run pytest # Lint with auto-fix poetry run ruff check --fix src/ tests/ @@ -116,7 +126,7 @@ Driver schemas (Pydantic models for device `custom_settings`) are auto-generated ### Settings and environment variables -All configuration is loaded via `Settings` (`settings.py`, backed by `pydantic-settings`). Variables are prefixed `VIPAT_`. Copy `.env.example` to `.env` for local development. Tests use `tests/.env.test`. +All configuration is loaded via `Settings` (`settings.py`, backed by `pydantic-settings`). Variables are prefixed `VIPAT_`. Copy `.env.template` to `.env` for local development and e2e tests (gitignored). Unit tests set dummy `VIPAT_*` values in `tests/conftest.py`. Key variables: | Variable | Default | Notes | diff --git a/docs/development-and-release.md b/docs/development-and-release.md index 0077964..91d5c22 100644 --- a/docs/development-and-release.md +++ b/docs/development-and-release.md @@ -52,6 +52,59 @@ gitGraph commit ``` +## Testing + +Tests use **pytest**. Install dev and test dependencies first: + +```bash +poetry install --with dev,test +``` + +### Unit and e2e suites + +The suite is split into offline **unit tests** and developer-run **e2e tests** against a live VideoIPath instance: + +| | Unit | E2E | +|---|---|---| +| Location | `tests/` (except `tests/e2e/`) | `tests/e2e/` | +| Marker | _(none)_ | `@pytest.mark.e2e` | +| Environment | Dummy `VIPAT_*` values in `tests/conftest.py` | Project root `.env` (see `.env.template`) | +| CI / default run | yes | no | + +**Commands** (prefer these over bare `pytest`): + +```bash +poetry run test-unit # offline suite; same as CI +poetry run test-e2e # live-server suite +poetry run test # unit, then e2e (stops on first failure) + +# Single file or test — extra args pass through +poetry run test-unit tests/validators/test_device_id.py +poetry run test-e2e tests/e2e/inspect/test_e2e_leaf_spine.py::test_name +``` + +`poetry run pytest` also runs unit tests only (e2e is excluded via `addopts` in `pyproject.toml`). All default runs report coverage on `src/`. + +### E2E setup + +1. Copy `.env.template` to `.env` (gitignored) and set your server connection variables (`VIPAT_VIDEOIPATH_SERVER_ADDRESS`, credentials, etc.). +2. Run `poetry run test-e2e` or `poetry run test` — connection vars are loaded from `.env` automatically; no extra env vars on the command line. + +E2E writes are namespaced (`E2E-` label prefix, `vipat-e2e` tag) so a shared dev instance stays safe. + +### VS Code + +Launch configs in `.vscode/launch.json`: + +- **Unit Tests** / **Unit Tests (current file)** — offline suite +- **E2E Tests** / **E2E Tests (current file)** — live-server suite (loads `.env`, uses the project `.venv`) + +Or run **Tests** from `.vscode/tasks.json` (`poetry run test`). + +### Test data + +Committed fixtures and examples must be anonymized — see `AGENTS.md` for placeholder conventions. + ## Publishing / Releasing new Package Versions In order to publish a new version of the package, update the version in the `pyproject.toml` and create a new GitHub Release associated with a version tag. The release notes should contain all relevant changes, especially breaking changes, features & bug fixes. diff --git a/poetry.lock b/poetry.lock index bae3156..e95452e 100644 --- a/poetry.lock +++ b/poetry.lock @@ -749,22 +749,6 @@ pytest = ">=7" [package.extras] testing = ["process-tests", "pytest-xdist", "virtualenv"] -[[package]] -name = "pytest-dotenv" -version = "0.5.2" -description = "A py.test plugin that parses environment files before running tests" -optional = false -python-versions = "*" -groups = ["test"] -files = [ - {file = "pytest-dotenv-0.5.2.tar.gz", hash = "sha256:2dc6c3ac6d8764c71c6d2804e902d0ff810fa19692e95fe138aefc9b1aa73732"}, - {file = "pytest_dotenv-0.5.2-py3-none-any.whl", hash = "sha256:40a2cece120a213898afaa5407673f6bd924b1fa7eafce6bda0e8abffe2f710f"}, -] - -[package.dependencies] -pytest = ">=5.0.0" -python-dotenv = ">=0.9.1" - [[package]] name = "python-discovery" version = "1.3.1" diff --git a/pyproject.toml b/pyproject.toml index 9b0f3fd..653a403 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -46,12 +46,10 @@ pre-commit = "^4.6.0" [tool.poetry.group.test.dependencies] pytest = "^9.1.1" pytest-cov = "^7.1.0" -pytest-dotenv = "^0.5.2" +python-dotenv = "^1.1.0" [tool.pytest.ini_options] addopts = "-x -p no:warnings --cov-report=term --cov-report=term-missing --no-cov-on-fail --cov=src --ignore=__intern -m \"not e2e\"" -env_override_existing_values = 0 -env_files = ["tests/.env.test"] markers = [ "e2e: developer-run tests against a live VideoIPath instance (gated on VIPAT_E2E_ENABLED=1; excluded by default). Run with '-m e2e'.", ] @@ -63,6 +61,9 @@ in-project = true set-videoipath-version = "vipat_cli_scripts.generate_all:main" get-videoipath-version = "vipat_cli_scripts.version_utils:get_videoipath_version" list-videoipath-versions = "vipat_cli_scripts.version_utils:list_videoipath_versions" +test-unit = "vipat_cli_scripts.test_runner:run_unit" +test-e2e = "vipat_cli_scripts.test_runner:run_e2e" +test = "vipat_cli_scripts.test_runner:run" [tool.ruff] include = ["pyproject.toml", "src/**/*.py", "tests/**/*.py"] diff --git a/src/vipat_cli_scripts/project_env.py b/src/vipat_cli_scripts/project_env.py new file mode 100644 index 0000000..474f0fd --- /dev/null +++ b/src/vipat_cli_scripts/project_env.py @@ -0,0 +1,36 @@ +"""Load project-root ``.env`` for e2e and local development.""" + +from __future__ import annotations + +import os +from pathlib import Path + +from dotenv import load_dotenv + +_PROJECT_ROOT = Path(__file__).resolve().parents[2] +_ENV_FILE = _PROJECT_ROOT / ".env" + +_LEGACY_ENV_ALIASES = { + "VIDEOIPATH_SERVER_ADDRESS": "VIPAT_VIDEOIPATH_SERVER_ADDRESS", + "VIDEOIPATH_USERNAME": "VIPAT_VIDEOIPATH_USERNAME", + "VIDEOIPATH_PASSWORD": "VIPAT_VIDEOIPATH_PASSWORD", + "VIDEOIPATH_USE_HTTPS": "VIPAT_USE_HTTPS", + "VIDEOIPATH_VERIFY_SSL": "VIPAT_VERIFY_SSL_CERT", + "VIDEOIPATH_VERIFY_SSL_CERT": "VIPAT_VERIFY_SSL_CERT", +} + + +def load_project_env() -> None: + """Load ``.env`` from the project root and normalize legacy variable names.""" + if _ENV_FILE.is_file(): + load_dotenv(_ENV_FILE, override=True) + for legacy, canonical in _LEGACY_ENV_ALIASES.items(): + legacy_value = os.environ.get(legacy) + if legacy_value and not os.environ.get(canonical): + os.environ[canonical] = legacy_value + + +def prepare_e2e_env() -> None: + """Load ``.env`` and enable the e2e gate for explicit e2e test runs.""" + load_project_env() + os.environ["VIPAT_E2E_ENABLED"] = "1" diff --git a/src/vipat_cli_scripts/test_runner.py b/src/vipat_cli_scripts/test_runner.py new file mode 100644 index 0000000..9a290da --- /dev/null +++ b/src/vipat_cli_scripts/test_runner.py @@ -0,0 +1,33 @@ +"""Pytest entry points for unit, e2e, and combined test suites.""" + +from __future__ import annotations + +import sys + +import pytest + +from vipat_cli_scripts.project_env import prepare_e2e_env + +_UNIT_ARGS = ["-m", "not e2e", "--ignore=tests/e2e"] +_E2E_ARGS = ["-m", "e2e", "tests/e2e", "--no-cov"] + + +def _run(args: list[str], *, extra: list[str] | None = None) -> int: + return pytest.main([*args, *(extra if extra is not None else sys.argv[1:])]) + + +def run_unit() -> None: + raise SystemExit(_run(_UNIT_ARGS)) + + +def run_e2e() -> None: + prepare_e2e_env() + raise SystemExit(_run(_E2E_ARGS)) + + +def run() -> None: + rc = _run(_UNIT_ARGS, extra=[]) + if rc != 0: + raise SystemExit(rc) + prepare_e2e_env() + raise SystemExit(_run(_E2E_ARGS, extra=[])) diff --git a/tests/.env.test b/tests/.env.test deleted file mode 100644 index 9985962..0000000 --- a/tests/.env.test +++ /dev/null @@ -1,7 +0,0 @@ -VIPAT_ENVIRONMENT=DEV -VIPAT_VIDEOIPATH_SERVER_ADDRESS=vip.company.com -VIPAT_VIDEOIPATH_USERNAME=user_with_api_access -VIPAT_VIDEOIPATH_PASSWORD=veryStrongPassword -VIPAT_USE_HTTPS=true -VIPAT_VERIFY_SSL_CERT=false -VIPAT_LOG_LEVEL=DEBUG \ No newline at end of file diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..a0bcd3c --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,23 @@ +"""Shared pytest configuration for offline unit tests.""" + +from __future__ import annotations + +import pytest + +UNIT_TEST_ENV = { + "VIPAT_ENVIRONMENT": "DEV", + "VIPAT_VIDEOIPATH_SERVER_ADDRESS": "vip-server.example", + "VIPAT_VIDEOIPATH_USERNAME": "test-user", + "VIPAT_VIDEOIPATH_PASSWORD": "test-password", + "VIPAT_USE_HTTPS": "true", + "VIPAT_VERIFY_SSL_CERT": "false", + "VIPAT_LOG_LEVEL": "DEBUG", +} + + +@pytest.fixture(autouse=True) +def _unit_test_env(request: pytest.FixtureRequest, monkeypatch: pytest.MonkeyPatch) -> None: + if request.node.get_closest_marker("e2e"): + return + for key, value in UNIT_TEST_ENV.items(): + monkeypatch.setenv(key, value) diff --git a/tests/e2e/conftest.py b/tests/e2e/conftest.py index cb66602..459a113 100644 --- a/tests/e2e/conftest.py +++ b/tests/e2e/conftest.py @@ -1,10 +1,13 @@ """Gating and shared fixtures for the developer-run live-server E2E suite ([ADR-0005]). These tests are excluded by default (``-m "not e2e"`` in ``pyproject.toml``) and only run when: - * you pass ``-m e2e`` on the command line, **and** - * ``VIPAT_E2E_ENABLED=1`` is set (put it in ``tests/.env.test`` next to the connection vars), **and** + * you invoke an e2e entry point (``poetry run test-e2e``, ``poetry run test``, or VS Code **E2E Tests**), **and** + * connection vars are set in the project root ``.env`` (copy from ``.env.template``), **and** * the target server is a verified version (>= 2025.4). +E2e entry points load ``.env`` and enable the suite automatically. E2e runs never collect coverage +(``--no-cov``). + Everything the suite writes is namespaced (``E2E-`` label prefix + ``vipat-e2e`` tag) so a shared local instance is safe: the scenario teardown removes only that namespace. """ @@ -17,6 +20,9 @@ from videoipath_automation_tool.apps.inspect.inspect_app import _MIN_VERIFIED_VERSION, _parse_version from videoipath_automation_tool.apps.videoipath_app import VideoIPathApp +from vipat_cli_scripts.project_env import load_project_env + +load_project_env() def _e2e_enabled() -> bool: @@ -25,9 +31,10 @@ def _e2e_enabled() -> bool: @pytest.fixture(scope="session") def app() -> VideoIPathApp: - """A live ``VideoIPathApp`` built from ``tests/.env.test``; skips unless E2E is enabled + verified.""" + """A live ``VideoIPathApp`` built from the project ``.env``; skips unless E2E is enabled + verified.""" + load_project_env() if not _e2e_enabled(): - pytest.skip("E2E disabled (set VIPAT_E2E_ENABLED=1 in tests/.env.test to run).") + pytest.skip("E2E disabled (use poetry run test-e2e, poetry run test, or the VS Code E2E launch config).") application = VideoIPathApp() version = application._videoipath_connector.videoipath_version parsed = _parse_version(version) diff --git a/tests/e2e/inspect/test_e2e_leaf_spine.py b/tests/e2e/inspect/test_e2e_leaf_spine.py index d8bfedd..3656a24 100644 --- a/tests/e2e/inspect/test_e2e_leaf_spine.py +++ b/tests/e2e/inspect/test_e2e_leaf_spine.py @@ -11,7 +11,7 @@ Run with:: - poetry run pytest -m e2e tests/e2e/inspect + poetry run test-e2e """ from __future__ import annotations From 7428e875f169f7529f0248319ba91b7121415fbe Mon Sep 17 00:00:00 2001 From: Jonas Scholl <43819845+JonasScholl@users.noreply.github.com> Date: Thu, 9 Jul 2026 13:18:01 +0200 Subject: [PATCH 10/34] e2e test rework --- .claude/rules/python-testing.md | 2 +- AGENTS.md | 2 +- docs/development-and-release.md | 2 +- pyproject.toml | 1 + tests/e2e/conftest.py | 52 ++- tests/e2e/helpers.py | 286 ++++++++++++++++ tests/e2e/inspect/leaf_spine_topology.json | 377 --------------------- tests/e2e/inspect/scenario.py | 272 --------------- tests/e2e/inspect/test_e2e_inspect.py | 167 +++++++++ tests/e2e/inspect/test_e2e_leaf_spine.py | 236 ------------- tests/e2e/test_e2e_workflow.py | 172 ++++++++++ 11 files changed, 680 insertions(+), 889 deletions(-) create mode 100644 tests/e2e/helpers.py delete mode 100644 tests/e2e/inspect/leaf_spine_topology.json delete mode 100644 tests/e2e/inspect/scenario.py create mode 100644 tests/e2e/inspect/test_e2e_inspect.py delete mode 100644 tests/e2e/inspect/test_e2e_leaf_spine.py create mode 100644 tests/e2e/test_e2e_workflow.py diff --git a/.claude/rules/python-testing.md b/.claude/rules/python-testing.md index 02bf778..102df3a 100644 --- a/.claude/rules/python-testing.md +++ b/.claude/rules/python-testing.md @@ -21,7 +21,7 @@ poetry run test # unit then e2e sequentially # Single file or test (extra args pass through to test-unit / test-e2e) poetry run test-unit tests/validators/test_device_id.py -poetry run test-e2e tests/e2e/inspect/test_e2e_leaf_spine.py::test_name +poetry run test-e2e tests/e2e/inspect/test_e2e_inspect.py::test_name ``` - `poetry run pytest` also runs unit tests only (e2e excluded via `addopts` in `pyproject.toml`). diff --git a/AGENTS.md b/AGENTS.md index dd46a55..23efa7a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -45,7 +45,7 @@ poetry run test # Single file or test (extra args pass through) poetry run test-unit tests/validators/test_device_id.py -poetry run test-e2e tests/e2e/inspect/test_e2e_leaf_spine.py::test_name +poetry run test-e2e tests/e2e/inspect/test_e2e_inspect.py::test_name # Bare pytest also runs unit tests only (e2e excluded via pyproject addopts) poetry run pytest diff --git a/docs/development-and-release.md b/docs/development-and-release.md index 91d5c22..c0840ce 100644 --- a/docs/development-and-release.md +++ b/docs/development-and-release.md @@ -80,7 +80,7 @@ poetry run test # unit, then e2e (stops on first failure) # Single file or test — extra args pass through poetry run test-unit tests/validators/test_device_id.py -poetry run test-e2e tests/e2e/inspect/test_e2e_leaf_spine.py::test_name +poetry run test-e2e tests/e2e/inspect/test_e2e_inspect.py::test_name ``` `poetry run pytest` also runs unit tests only (e2e is excluded via `addopts` in `pyproject.toml`). All default runs report coverage on `src/`. diff --git a/pyproject.toml b/pyproject.toml index 653a403..02b627d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -52,6 +52,7 @@ python-dotenv = "^1.1.0" addopts = "-x -p no:warnings --cov-report=term --cov-report=term-missing --no-cov-on-fail --cov=src --ignore=__intern -m \"not e2e\"" markers = [ "e2e: developer-run tests against a live VideoIPath instance (gated on VIPAT_E2E_ENABLED=1; excluded by default). Run with '-m e2e'.", + "incremental: sequential workflow steps; later steps are skipped when an earlier one fails.", ] [virtualenvs] diff --git a/tests/e2e/conftest.py b/tests/e2e/conftest.py index 459a113..24a308c 100644 --- a/tests/e2e/conftest.py +++ b/tests/e2e/conftest.py @@ -9,12 +9,17 @@ (``--no-cov``). Everything the suite writes is namespaced (``E2E-`` label prefix + ``vipat-e2e`` tag) so a shared -local instance is safe: the scenario teardown removes only that namespace. +local instance is safe. Cleanup has two layers: a session-start sweep removes any leftovers from +prior runs, and the per-test ``topology_builder`` fixture removes exactly the devices a test +created (also on failure). The sequential workflow suite intentionally leaves its topology behind +for manual inspection; the next run's sweep removes it. """ from __future__ import annotations import os +from itertools import count +from typing import Iterator import pytest @@ -22,8 +27,12 @@ from videoipath_automation_tool.apps.videoipath_app import VideoIPathApp from vipat_cli_scripts.project_env import load_project_env +from .helpers import TopologyBuilder, remove_devices, sweep_e2e_namespace + load_project_env() +_STEP_FAILED_KEY = pytest.StashKey[str]() + def _e2e_enabled() -> bool: return os.environ.get("VIPAT_E2E_ENABLED", "").strip() == "1" @@ -44,3 +53,44 @@ def app() -> VideoIPathApp: f"{_MIN_VERIFIED_VERSION[0]}.{_MIN_VERIFIED_VERSION[1]}." ) return application + + +@pytest.fixture(scope="session", autouse=True) +def e2e_sweep(app: VideoIPathApp) -> None: + """Session-start sweep: remove every ``E2E-`` device left over from a prior run.""" + sweep_e2e_namespace(app) + + +@pytest.fixture(scope="session") +def e2e_addresses() -> Iterator[str]: + """Session-wide device address allocator (private ``10.99.0.0/16`` range), so addresses never collide.""" + return (f"10.99.{i // 256}.{i % 256}" for i in count(1)) + + +@pytest.fixture +def topology_builder(app: VideoIPathApp, e2e_addresses: Iterator[str]) -> Iterator[TopologyBuilder]: + """A per-test topology factory; teardown removes exactly the devices the test created.""" + builder = TopologyBuilder(app, e2e_addresses) + yield builder + remove_devices(app, set(builder.device_ids)) + + +# --- Sequential workflow support (``@pytest.mark.incremental``) --- +# Later steps of a sequential suite are skipped (not failed) once an earlier step fails. With the +# default ``-x`` in ``addopts`` the run stops at the first failure anyway; these hooks make the +# behavior sensible without it too (e.g. ``--maxfail=0``). + + +def pytest_runtest_makereport(item: pytest.Item, call: pytest.CallInfo) -> None: + if "incremental" not in item.keywords or item.parent is None: + return + if call.excinfo is not None and not call.excinfo.errisinstance(pytest.skip.Exception): + item.parent.stash.setdefault(_STEP_FAILED_KEY, item.name) + + +def pytest_runtest_setup(item: pytest.Item) -> None: + if "incremental" not in item.keywords or item.parent is None: + return + failed = item.parent.stash.get(_STEP_FAILED_KEY, None) + if failed is not None: + pytest.skip(f"previous workflow step failed ({failed})") diff --git a/tests/e2e/helpers.py b/tests/e2e/helpers.py new file mode 100644 index 0000000..0544ef3 --- /dev/null +++ b/tests/e2e/helpers.py @@ -0,0 +1,286 @@ +"""Shared helpers for the developer-run live-server E2E suite ([ADR-0005]). + +Everything the suite writes is namespaced (``E2E-`` label prefix + ``vipat-e2e`` tag) so a shared +local instance stays safe. Two cleanup layers use that namespace: + +* ``sweep_e2e_namespace`` — session-start sweep that removes **every** ``E2E-`` device (and its + edges) left over from a prior run, in both the inventory and the inspect topology graph. +* ``remove_devices`` — targeted removal of exactly the devices a single test created (used by the + per-test ``topology_builder`` teardown). + +Devices are always virtual (mock-driver); no real hardware is involved. The build path is the real +user flow: **inventory** (create + add device) → **inspect** (add to topology graph, label + tag, +connect ports). +""" + +from __future__ import annotations + +from collections import defaultdict +from typing import TYPE_CHECKING, Any, Iterator +from uuid import uuid4 + +import requests + +if TYPE_CHECKING: + from videoipath_automation_tool.apps.inspect.domain.edge import InspectEdge + from videoipath_automation_tool.apps.videoipath_app import VideoIPathApp + +E2E_PREFIX = "E2E-" +E2E_TAG = "vipat-e2e" +MOCK_DRIVER = "com.nevion.mock-0.1.0" + +# A test video tag, created via a simple API request and assigned to a port via the inspect app. +# Tag references are ``Category~~name`` ids; it lives in the existing "Video" format category. +TEST_TAG_CATEGORY = "Video" +TEST_TAG_NAME = "E2E-VIDEO-TAG" +TEST_TAG_ID = f"{TEST_TAG_CATEGORY}~~{TEST_TAG_NAME}" + + +def unique_label(base: str) -> str: + """A per-test unique device label, always under the ``E2E-`` prefix so the sweep catches orphans.""" + return f"{E2E_PREFIX}T-{uuid4().hex[:6].upper()}-{base}" + + +def create_mock_device(app: "VideoIPathApp", *, label: str, address: str, ports: int) -> str: + """Create a virtual (mock-driver) device in the inventory and return its device id.""" + device = app.inventory.create_device(driver=MOCK_DRIVER) + device.configuration.label = label + device.configuration.address = address + settings = device.configuration.custom_settings + settings.num_router_modules = 1 + settings.num_router_ports = ports + settings.num_codec_modules = 0 + online = app.inventory.add_device(device=device, address_check=False) + return online.configuration.device_id + + +def discover_router_vertices(app: "VideoIPathApp", device_ids: list[str]) -> dict[str, tuple[list[str], list[str]]]: + """Per device id: the (out, in) router vertex ids, each sorted by port slot. + + Refreshes the snapshot and preloads the devices in parallel to avoid N+1 detail fetches. + """ + app.inspect.refresh() + app.inspect.preload(device_ids) + vertices: dict[str, tuple[list[str], list[str]]] = {} + for device_id in device_ids: + device = app.inspect.get_device(device_id) + if device is None: + raise LookupError(f"Device '{device_id}' not found in the inspect topology.") + outs: list[str] = [] + ins: list[str] = [] + for port in device.ports: + label = port.label or "" + if not port.vertex_id: + continue + if "Router Out" in label: + outs.append(port.vertex_id) + elif "Router In" in label: + ins.append(port.vertex_id) + vertices[device_id] = (sorted(outs, key=_vertex_slot), sorted(ins, key=_vertex_slot)) + return vertices + + +def edges_between(app: "VideoIPathApp", id_a: str, id_b: str) -> list["InspectEdge"]: + """All directed edges between two devices (either direction).""" + return [ + e + for e in app.inspect.edges + if e.from_device and e.to_device and {e.from_device.id, e.to_device.id} == {id_a, id_b} + ] + + +def remove_devices(app: "VideoIPathApp", device_ids: set[str]) -> None: + """Remove the given devices — edges first, then the topology node, then the inventory entry. + + A device lives in two places — the inventory and the inspect topology graph — and removing it + from one does not remove it from the other. Removal is best-effort so cleanup errors never mask + a test failure. + """ + if not device_ids: + return + app.inspect.refresh() + edge_ids = [ + edge.id + for edge in app.inspect.edges + if (edge.from_device and edge.from_device.id in device_ids) + or (edge.to_device and edge.to_device.id in device_ids) + ] + if edge_ids: + with app.inspect.transaction() as tx: + for edge_id in edge_ids: + tx.remove(edge_id) + tx.commit(check_conflicts=False) + topology_ids = {d.id for d in app.inspect.devices} & device_ids + for device_id in topology_ids: + try: + app.inspect.remove_device_from_topology(device_id) + except Exception: # best-effort cleanup + pass + for device_id in device_ids: + try: + app.inventory.remove_device(device_id=device_id, check_remove=False) + except Exception: # best-effort cleanup + pass + + +def sweep_e2e_namespace(app: "VideoIPathApp") -> None: + """Remove every ``E2E-`` device (and the test tag) so a run starts from a clean namespace. + + Discovers devices by their ``E2E-`` label in **both** the inventory and the topology graph, + catching orphans from an aborted run as well as the intentionally persisted workflow topology. + """ + delete_test_tag(app) + inventory_labels = app.inventory._inventory_api.fetch_devices_user_defined_labels_as_dict() + inventory_ids = {i for i, label in inventory_labels.items() if (label or "").startswith(E2E_PREFIX)} + app.inspect.refresh() + topology_ids = {d.id for d in app.inspect.devices if (d.label or "").startswith(E2E_PREFIX)} + remove_devices(app, inventory_ids | topology_ids) + + +class TopologyBuilder: + """Builds a minimal per-test topology of mock devices and tracks their ids for teardown. + + ``add_devices`` mirrors the real user flow (inventory → topology graph → label + tag); ``link`` + connects the next free port pair of two devices bidirectionally (two directed edges). + """ + + def __init__(self, app: "VideoIPathApp", addresses: Iterator[str], *, x: int = 0, y: int = 4200) -> None: + self._app = app + self._addresses = addresses + self._x = x + self._y = y + self.device_ids: list[str] = [] + self.labels: dict[str, str] = {} # device id -> unique E2E label + self._out: dict[str, list[str]] = {} # device id -> ordered out-vertex ids + self._in: dict[str, list[str]] = {} # device id -> ordered in-vertex ids + self._slot: dict[str, int] = defaultdict(int) # device id -> next free port slot + + def add_devices(self, specs: list[tuple[str, int]]) -> list[str]: + """Create mock devices from ``(base_label, ports)`` specs and add them to the topology graph. + + Labels are made unique per test via :func:`unique_label`; returns the new device ids. + """ + created: list[str] = [] + for base_label, ports in specs: + label = unique_label(base_label) + device_id = create_mock_device(self._app, label=label, address=next(self._addresses), ports=ports) + self.labels[device_id] = label + created.append(device_id) + offset = len(self.device_ids) + self._app.inspect.add_devices_to_topology( + [(device_id, self._x + (offset + i) * 300, self._y) for i, device_id in enumerate(created)] + ) + with self._app.inspect.transaction() as tx: + for device_id in created: + tx.update_device(device_id, label=self.labels[device_id], tags=[E2E_TAG]) + tx.commit() + self.device_ids.extend(created) + return created + + def discover(self) -> None: + """Discover the router port vertices of all tracked devices (needed before ``link``).""" + for device_id, (outs, ins) in discover_router_vertices(self._app, self.device_ids).items(): + self._out[device_id] = outs + self._in[device_id] = ins + + def link(self, id_a: str, id_b: str) -> None: + """Connect the next free port pair of two devices bidirectionally (two directed edges).""" + if id_a not in self._out or id_b not in self._out: + self.discover() + ka, kb = self._slot[id_a], self._slot[id_b] + self._slot[id_a] += 1 + self._slot[id_b] += 1 + with self._app.inspect.transaction() as tx: + tx.connect(self._out[id_a][ka], self._in[id_b][kb], bidirectional=False) + tx.connect(self._out[id_b][kb], self._in[id_a][ka], bidirectional=False) + tx.commit() + + +class FetchSpy: + """Wraps get_device_detail to count hydration fetches.""" + + def __init__(self, api): + self._api = api + self._orig = api.get_device_detail + self.count = 0 + + def __enter__(self): + def counting(device_id): + self.count += 1 + return self._orig(device_id) + + self._api.get_device_detail = counting + return self + + def __exit__(self, *exc): + self._api.get_device_detail = self._orig + + +# --- Test tag catalog (simple API requests) --- + + +def create_test_tag(app: "VideoIPathApp") -> None: + """Create the E2E test video tag in the catalog (idempotent).""" + category = _tag_category(app, TEST_TAG_CATEGORY) + if category is None: + raise RuntimeError(f"Tag category '{TEST_TAG_CATEGORY}' not found on the server.") + children = dict(category.get("children") or {}) + children[TEST_TAG_NAME] = {"exclusive": False, "children": {}} + body = { + "actions": [ + { + "_action": "update", + "_id": TEST_TAG_CATEGORY, + "_rev": category["_rev"], + "children": children, + "type": category.get("type", "format"), + "exclusive": category.get("exclusive", False), + "formatTagLinks": category.get("formatTagLinks", {}), + "locationTypes": category.get("locationTypes", []), + } + ] + } + _raw_request(app, "patch", "/rest/v2/data/config/tags/tagTrees", body) + + +def test_tag_exists(app: "VideoIPathApp") -> bool: + category = _tag_category(app, TEST_TAG_CATEGORY) + return category is not None and TEST_TAG_NAME in (category.get("children") or {}) + + +def delete_test_tag(app: "VideoIPathApp") -> None: + """Force-delete the test tag (removes it and any port bindings) if it exists.""" + if test_tag_exists(app): + _raw_request( + app, + "post", + "/rest/v2/actions/status/tags/forceDeleteTag", + {"header": {"id": 0}, "data": {"tagId": TEST_TAG_ID}}, + ) + + +# --- Internal --- + + +def _vertex_slot(vertex_id: str) -> int: + """Sort key: the trailing integer of a mock router vertex id (``device59.11.7`` -> 7).""" + return int(vertex_id.rsplit(".", 1)[-1]) + + +def _raw_request(app: "VideoIPathApp", method: str, path: str, body: dict[str, Any]) -> requests.Response: + """A minimal authenticated REST call (for tag-catalog management, which the package's connector + allow-list intentionally does not cover).""" + rc = app._videoipath_connector.rest + response = getattr(requests, method)( + rc._build_url(path), json=body, auth=(rc._username, rc._password), verify=rc.verify_ssl_cert + ) + response.raise_for_status() + return response + + +def _tag_category(app: "VideoIPathApp", category: str) -> dict[str, Any] | None: + trees = app._videoipath_connector.rest.get("/rest/v2/data/config/tags/tagTrees/**") + for item in trees.data["config"]["tags"]["tagTrees"].get("_items", []): + if item.get("_id") == category: + return item + return None diff --git a/tests/e2e/inspect/leaf_spine_topology.json b/tests/e2e/inspect/leaf_spine_topology.json deleted file mode 100644 index a1cf622..0000000 --- a/tests/e2e/inspect/leaf_spine_topology.json +++ /dev/null @@ -1,377 +0,0 @@ -{ - "_comment": "A dummy VideoIPath spine-leaf topology (indices/coords/links only). Built with mock (virtual) devices in the E2E suite.", - "devices": [ - { - "name": "E2E-DEV-00", - "x": -2301, - "y": 8141, - "ports": 2 - }, - { - "name": "E2E-DEV-01", - "x": -1496, - "y": 8082, - "ports": 1 - }, - { - "name": "E2E-DEV-02", - "x": 2100, - "y": 8800, - "ports": 11 - }, - { - "name": "E2E-DEV-03", - "x": 3600, - "y": 8800, - "ports": 4 - }, - { - "name": "E2E-DEV-04", - "x": 3000, - "y": 8800, - "ports": 4 - }, - { - "name": "E2E-DEV-05", - "x": 1500, - "y": 8800, - "ports": 9 - }, - { - "name": "E2E-DEV-06", - "x": 0, - "y": 0, - "ports": 1 - }, - { - "name": "E2E-DEV-07", - "x": 1600, - "y": 9300, - "ports": 2 - }, - { - "name": "E2E-DEV-08", - "x": 2000, - "y": 9300, - "ports": 2 - }, - { - "name": "E2E-DEV-09", - "x": 1600, - "y": 9050, - "ports": 1 - }, - { - "name": "E2E-DEV-10", - "x": -2000, - "y": 7800, - "ports": 3 - }, - { - "name": "E2E-DEV-11", - "x": 0, - "y": 0, - "ports": 1 - }, - { - "name": "E2E-DEV-12", - "x": 1600, - "y": 10300, - "ports": 2 - }, - { - "name": "E2E-DEV-13", - "x": 0, - "y": 0, - "ports": 1 - }, - { - "name": "E2E-DEV-14", - "x": 2000, - "y": 9800, - "ports": 2 - }, - { - "name": "E2E-DEV-15", - "x": 0, - "y": 8400, - "ports": 7 - }, - { - "name": "E2E-DEV-16", - "x": 0, - "y": 7600, - "ports": 7 - }, - { - "name": "E2E-DEV-17", - "x": -1800, - "y": 8600, - "ports": 3 - }, - { - "name": "E2E-DEV-18", - "x": 500, - "y": 7850, - "ports": 2 - }, - { - "name": "E2E-DEV-19", - "x": -2800, - "y": 8200, - "ports": 2 - }, - { - "name": "E2E-DEV-20", - "x": 3500, - "y": 9300, - "ports": 2 - }, - { - "name": "E2E-DEV-21", - "x": 767, - "y": 8434, - "ports": 1 - }, - { - "name": "E2E-DEV-22", - "x": 1600, - "y": 9800, - "ports": 2 - }, - { - "name": "E2E-DEV-23", - "x": 2000, - "y": 9550, - "ports": 2 - }, - { - "name": "E2E-DEV-24", - "x": 1600, - "y": 9550, - "ports": 2 - }, - { - "name": "E2E-DEV-25", - "x": 900, - "y": 7850, - "ports": 2 - }, - { - "name": "E2E-DEV-26", - "x": 0, - "y": 0, - "ports": 1 - }, - { - "name": "E2E-DEV-27", - "x": 3500, - "y": 9050, - "ports": 2 - }, - { - "name": "E2E-DEV-28", - "x": 3100, - "y": 9300, - "ports": 2 - }, - { - "name": "E2E-DEV-29", - "x": 1046, - "y": 8159, - "ports": 2 - } - ], - "links": [ - [ - 0, - 10, - 1 - ], - [ - 0, - 17, - 1 - ], - [ - 2, - 7, - 1 - ], - [ - 2, - 8, - 1 - ], - [ - 2, - 9, - 1 - ], - [ - 2, - 12, - 1 - ], - [ - 2, - 14, - 1 - ], - [ - 2, - 15, - 2 - ], - [ - 2, - 21, - 1 - ], - [ - 2, - 22, - 1 - ], - [ - 2, - 23, - 1 - ], - [ - 2, - 24, - 1 - ], - [ - 3, - 15, - 1 - ], - [ - 3, - 20, - 1 - ], - [ - 3, - 27, - 1 - ], - [ - 3, - 28, - 1 - ], - [ - 4, - 16, - 1 - ], - [ - 4, - 20, - 1 - ], - [ - 4, - 27, - 1 - ], - [ - 4, - 28, - 1 - ], - [ - 5, - 7, - 1 - ], - [ - 5, - 8, - 1 - ], - [ - 5, - 12, - 1 - ], - [ - 5, - 14, - 1 - ], - [ - 5, - 16, - 2 - ], - [ - 5, - 22, - 1 - ], - [ - 5, - 23, - 1 - ], - [ - 5, - 24, - 1 - ], - [ - 10, - 16, - 1 - ], - [ - 10, - 19, - 1 - ], - [ - 15, - 17, - 1 - ], - [ - 15, - 18, - 1 - ], - [ - 15, - 25, - 1 - ], - [ - 15, - 29, - 1 - ], - [ - 16, - 18, - 1 - ], - [ - 16, - 25, - 1 - ], - [ - 16, - 29, - 1 - ], - [ - 17, - 19, - 1 - ] - ] -} \ No newline at end of file diff --git a/tests/e2e/inspect/scenario.py b/tests/e2e/inspect/scenario.py deleted file mode 100644 index c12161d..0000000 --- a/tests/e2e/inspect/scenario.py +++ /dev/null @@ -1,272 +0,0 @@ -"""Topology scenario for the Inspect E2E suite ([ADR-0005]). - -Replicates the structure of the local VideoIPath instance (anonymized to indices/coordinates/links -in ``leaf_spine_topology.json``) using **virtual (mock-driver) devices only**. The build path is the -real user flow: - - 1. **Inventory** — create each device with the ``com.nevion.mock`` driver (a simulated device with - configurable router ports) and ``add_device`` it. No real hardware is involved. - 2. **Inspect** — add the devices to the topology graph, set their E2E display label + tag, then - connect their ports. - -The topology app is never used. - -Namespacing: every device carries the ``E2E-`` label prefix (in both inventory and inspect) and the -``vipat-e2e`` tag. Cleanup runs at **startup** (``cleanup``), not on teardown — so the built -topology persists in VideoIPath after a run for manual inspection, and the next run starts fresh. -""" - -from __future__ import annotations - -import json -from collections import defaultdict -from pathlib import Path -from typing import TYPE_CHECKING, Any - -import requests - -from videoipath_automation_tool.apps.inspect.model.common import InspectFrozenModel - -if TYPE_CHECKING: - from videoipath_automation_tool.apps.videoipath_app import VideoIPathApp - -E2E_PREFIX = "E2E-" -E2E_TAG = "vipat-e2e" -MOCK_DRIVER = "com.nevion.mock-0.1.0" -TOPOLOGY_FILE = Path(__file__).parent / "leaf_spine_topology.json" - -# A test video tag, created via a simple API request in setup and assigned to a port via the inspect -# app. Tag references are ``Category~~name`` ids; it lives in the existing "Video" format category. -TEST_TAG_CATEGORY = "Video" -TEST_TAG_NAME = "E2E-VIDEO-TAG" -TEST_TAG_ID = f"{TEST_TAG_CATEGORY}~~{TEST_TAG_NAME}" - - -def _raw_request(app: "VideoIPathApp", method: str, path: str, body: dict[str, Any]) -> requests.Response: - """A minimal authenticated REST call (for tag-catalog management, which the package's connector - allow-list intentionally does not cover).""" - rc = app._videoipath_connector.rest - response = getattr(requests, method)( - rc._build_url(path), json=body, auth=(rc._username, rc._password), verify=rc.verify_ssl_cert - ) - response.raise_for_status() - return response - - -def _tag_category(app: "VideoIPathApp", category: str) -> dict[str, Any] | None: - trees = app._videoipath_connector.rest.get("/rest/v2/data/config/tags/tagTrees/**") - for item in trees.data["config"]["tags"]["tagTrees"].get("_items", []): - if item.get("_id") == category: - return item - return None - - -# The fixture coordinates are captured from the live topology, so the replica would sit right on top -# of it. Shift the whole E2E topology into its own region of the map so it never overlaps. -POSITION_OFFSET_X = 0 -POSITION_OFFSET_Y = 3000 - - -class DeviceSpec(InspectFrozenModel): - name: str - x: int - y: int - ports: int - - -class LinkSpec(InspectFrozenModel): - a: int # device index - b: int # device index - count: int # number of parallel bidirectional links - - -def _vertex_slot(vertex_id: str) -> int: - """Sort key: the trailing integer of a mock router vertex id (``device59.11.7`` -> 7).""" - return int(vertex_id.rsplit(".", 1)[-1]) - - -class LeafSpineScenario: - def __init__(self) -> None: - data = json.loads(TOPOLOGY_FILE.read_text()) - # Bake the offset into the specs so build placement and the placement-revert test agree. - self.devices = [ - DeviceSpec( - name=d["name"], - x=d["x"] + POSITION_OFFSET_X, - y=d["y"] + POSITION_OFFSET_Y, - ports=d["ports"], - ) - for d in data["devices"] - ] - self.links = [LinkSpec(a=a, b=b, count=n) for a, b, n in data["links"]] - self.device_ids: dict[str, str] = {} # E2E name -> VideoIPath device id - self._out: dict[str, list[str]] = {} # name -> ordered out-vertex ids - self._in: dict[str, list[str]] = {} # name -> ordered in-vertex ids - - # --- Introspection --- - - @property - def device_names(self) -> list[str]: - return [d.name for d in self.devices] - - def device_id(self, name: str) -> str: - return self.device_ids[name] - - def adjacency(self) -> dict[str, set[str]]: - """Undirected neighbour set per device name (from the fixture).""" - adj: dict[str, set[str]] = {d.name: set() for d in self.devices} - for link in self.links: - na, nb = self.devices[link.a].name, self.devices[link.b].name - adj[na].add(nb) - adj[nb].add(na) - return adj - - def expected_edge_count(self) -> int: - # Two directed edges per bidirectional link. - return sum(link.count for link in self.links) * 2 - - def a_link(self) -> tuple[str, str]: - """A representative connected device pair (names).""" - link = self.links[0] - return self.devices[link.a].name, self.devices[link.b].name - - # --- Test tag catalog (simple API requests) --- - - def create_test_tag(self, app: "VideoIPathApp") -> None: - """Create the E2E test video tag in the catalog (idempotent).""" - category = _tag_category(app, TEST_TAG_CATEGORY) - if category is None: - raise RuntimeError(f"Tag category '{TEST_TAG_CATEGORY}' not found on the server.") - children = dict(category.get("children") or {}) - children[TEST_TAG_NAME] = {"exclusive": False, "children": {}} - body = { - "actions": [ - { - "_action": "update", - "_id": TEST_TAG_CATEGORY, - "_rev": category["_rev"], - "children": children, - "type": category.get("type", "format"), - "exclusive": category.get("exclusive", False), - "formatTagLinks": category.get("formatTagLinks", {}), - "locationTypes": category.get("locationTypes", []), - } - ] - } - _raw_request(app, "patch", "/rest/v2/data/config/tags/tagTrees", body) - - def test_tag_exists(self, app: "VideoIPathApp") -> bool: - category = _tag_category(app, TEST_TAG_CATEGORY) - return category is not None and TEST_TAG_NAME in (category.get("children") or {}) - - def delete_test_tag(self, app: "VideoIPathApp") -> None: - """Force-delete the test tag (removes it and any port bindings) if it exists.""" - if self.test_tag_exists(app): - _raw_request( - app, - "post", - "/rest/v2/actions/status/tags/forceDeleteTag", - {"header": {"id": 0}, "data": {"tagId": TEST_TAG_ID}}, - ) - - # --- Startup cleanup (removes any prior E2E state) --- - - def cleanup(self, app: "VideoIPathApp") -> None: - """Remove every E2E device (and its edges) so a run starts from a clean namespace. - - A device lives in two places — the inventory and the inspect topology graph — and removing - it from one does not remove it from the other. So we discover E2E devices by their ``E2E-`` - label in **both** (catching orphans from an aborted run) and remove edges, the topology node, - and the inventory entry. - """ - self.delete_test_tag(app) - inventory_labels = app.inventory._inventory_api.fetch_devices_user_defined_labels_as_dict() - inventory_ids = {i for i, label in inventory_labels.items() if (label or "").startswith(E2E_PREFIX)} - app.inspect.refresh() - topology_ids = {d.id for d in app.inspect.devices if (d.label or "").startswith(E2E_PREFIX)} - all_ids = inventory_ids | topology_ids - if not all_ids: - return - # Edges first (only those that actually exist). - edge_ids = [ - edge.id - for edge in app.inspect.edges - if (edge.from_device and edge.from_device.id in all_ids) - or (edge.to_device and edge.to_device.id in all_ids) - ] - if edge_ids: - with app.inspect.transaction() as tx: - for edge_id in edge_ids: - tx.remove(edge_id) - tx.commit(check_conflicts=False) - # Remove the topology node, then the inventory entry. - for device_id in topology_ids: - try: - app.inspect.remove_device_from_topology(device_id) - except Exception: # best-effort cleanup - pass - for device_id in inventory_ids: - try: - app.inventory.remove_device(device_id=device_id, check_remove=False) - except Exception: # best-effort cleanup - pass - - # --- Build --- - - def build(self, app: "VideoIPathApp") -> None: - self._create_inventory_devices(app) - # Add to the inspect topology graph at their coordinates. - app.inspect.add_devices_to_topology([(self.device_ids[s.name], s.x, s.y) for s in self.devices]) - # Configure them in inspect: E2E display label + tag. - with app.inspect.transaction() as tx: - for spec in self.devices: - tx.update_device(self.device_ids[spec.name], label=spec.name, tags=[E2E_TAG]) - tx.commit() - self._discover_ports(app) - self._connect(app) - # Create the test video tag so the port-tagging test can assign it. - self.create_test_tag(app) - - def _create_inventory_devices(self, app: "VideoIPathApp") -> None: - for i, spec in enumerate(self.devices): - device = app.inventory.create_device(driver=MOCK_DRIVER) - device.configuration.label = spec.name - device.configuration.address = f"10.99.{i // 256}.{i % 256}" - settings = device.configuration.custom_settings - settings.num_router_modules = 1 - settings.num_router_ports = spec.ports - settings.num_codec_modules = 0 - online = app.inventory.add_device(device=device, address_check=False) - self.device_ids[spec.name] = online.configuration.device_id - - def _discover_ports(self, app: "VideoIPathApp") -> None: - app.inspect.refresh() - app.inspect.preload(list(self.device_ids.values())) - for spec in self.devices: - device = app.inspect.get_device(self.device_ids[spec.name]) - outs: list[str] = [] - ins: list[str] = [] - for port in device.ports: - label = port.label or "" - if not port.vertex_id: - continue - if "Router Out" in label: - outs.append(port.vertex_id) - elif "Router In" in label: - ins.append(port.vertex_id) - self._out[spec.name] = sorted(outs, key=_vertex_slot) - self._in[spec.name] = sorted(ins, key=_vertex_slot) - - def _connect(self, app: "VideoIPathApp") -> None: - slot: dict[str, int] = defaultdict(int) - with app.inspect.transaction() as tx: - for link in self.links: - na, nb = self.devices[link.a].name, self.devices[link.b].name - for _ in range(link.count): - ka, kb = slot[na], slot[nb] - slot[na] += 1 - slot[nb] += 1 - # Bidirectional link between interface ka on A and kb on B. - tx.connect(self._out[na][ka], self._in[nb][kb], bidirectional=False) - tx.connect(self._out[nb][kb], self._in[na][ka], bidirectional=False) - tx.commit() diff --git a/tests/e2e/inspect/test_e2e_inspect.py b/tests/e2e/inspect/test_e2e_inspect.py new file mode 100644 index 0000000..e77b974 --- /dev/null +++ b/tests/e2e/inspect/test_e2e_inspect.py @@ -0,0 +1,167 @@ +"""Independent Inspect capability tests against a live instance. + +Every test builds its **own** minimal topology (1–3 mock devices) via the ``topology_builder`` +fixture — unique labels/addresses per test, guaranteed teardown (edges → topology node → inventory +entry) even on failure. All assertions are scoped to the test's own device ids, so the persisted +workflow topology or any other state on a shared instance never interferes. + +Developer-run only (see ``tests/e2e/conftest.py``). Run with:: + + poetry run test-e2e +""" + +from __future__ import annotations + +import pytest + +from videoipath_automation_tool.apps.inspect.errors import InspectCommitConflictError, InspectCommitError +from videoipath_automation_tool.apps.videoipath_app import VideoIPathApp + +from ..helpers import TEST_TAG_ID, FetchSpy, TopologyBuilder, create_test_tag, delete_test_tag, edges_between + + +pytestmark = pytest.mark.e2e + + +def test_skeleton_read_no_hydration(app: VideoIPathApp, topology_builder: TopologyBuilder) -> None: + id_a, id_b = topology_builder.add_devices([("SKEL-A", 2), ("SKEL-B", 2)]) + topology_builder.link(id_a, id_b) + app.inspect.refresh() + with FetchSpy(app.inspect._inspect_api) as spy: + assert len(edges_between(app, id_a, id_b)) == 2 + assert spy.count == 0 # skeleton read triggers no per-device hydration + + +def test_lazy_hydration(app: VideoIPathApp, topology_builder: TopologyBuilder) -> None: + id_a, id_b = topology_builder.add_devices([("HYD-A", 2), ("HYD-B", 2)]) + app.inspect.refresh() + assert not app.inspect.is_device_hydrated(id_a) + with FetchSpy(app.inspect._inspect_api) as spy: + ports = app.inspect.get_device(id_a).ports # one detail fetch + assert spy.count == 1 + _ = app.inspect.get_device(id_a).ports # cached + assert spy.count == 1 + assert len(ports) > 0 # mock devices expose router ports + assert app.inspect.is_device_hydrated(id_a) + assert not app.inspect.is_device_hydrated(id_b) + + +def test_connectivity_graph(app: VideoIPathApp, topology_builder: TopologyBuilder) -> None: + hub, id_b, id_c = topology_builder.add_devices([("HUB-A", 2), ("HUB-B", 2), ("HUB-C", 2)]) + topology_builder.link(hub, id_b) + topology_builder.link(hub, id_c) + app.inspect.refresh() + linked = {d.label for d in app.inspect.get_device(hub).linked_devices} + assert linked == {topology_builder.labels[id_b], topology_builder.labels[id_c]} + + +def test_edge_pair_refresh(app: VideoIPathApp, topology_builder: TopologyBuilder) -> None: + id_a, id_b = topology_builder.add_devices([("EDGE-A", 2), ("EDGE-B", 2)]) + topology_builder.link(id_a, id_b) + app.inspect.refresh() + edge_id = edges_between(app, id_a, id_b)[0].id + result = app.inspect.update_edge(edge_id, weight=7) + assert result.ok + # Survives the targeted post-commit refresh without a full reload. + assert any(e.id == edge_id for e in app.inspect.edges) + + +def test_transaction_atomicity(app: VideoIPathApp, topology_builder: TopologyBuilder) -> None: + id_a, id_b = topology_builder.add_devices([("ATOM-A", 2), ("ATOM-B", 2)]) + topology_builder.link(id_a, id_b) + app.inspect.refresh() + edge_id = edges_between(app, id_a, id_b)[0].id + with pytest.raises(InspectCommitError): + with app.inspect.transaction() as tx: + tx.update_edge(edge_id, weight=13) + tx.remove("does-not-exist::also-not-real") + tx.commit() + # Nothing applied: the edge is still present. + app.inspect.refresh() + assert any(e.id == edge_id for e in app.inspect.edges) + + +def test_conflict_detection(app: VideoIPathApp, topology_builder: TopologyBuilder) -> None: + id_a, id_b = topology_builder.add_devices([("CONF-A", 2), ("CONF-B", 2)]) + topology_builder.link(id_a, id_b) + app.inspect.refresh() + edge_id = edges_between(app, id_a, id_b)[0].id + tx = app.inspect.transaction() + tx.update_edge(edge_id, weight=21) + # Out-of-band change via a second app instance. + other = VideoIPathApp() + other.inspect.update_edge(edge_id, weight=9) + with pytest.raises(InspectCommitConflictError) as exc: + tx.commit() + assert any(c.entity_id == edge_id for c in exc.value.conflicts) + tx.rebase() + tx.commit() + + +def test_assign_tag_to_port(app: VideoIPathApp, topology_builder: TopologyBuilder) -> None: + # Inspect-only capability: assign a catalog tag to a port. + (device_id,) = topology_builder.add_devices([("TAG-A", 2)]) + create_test_tag(app) + try: + app.inspect.refresh() + vertex_id = next( + p.vertex_id + for p in app.inspect.get_device(device_id).ports + if p.vertex_id and "Router In" in (p.label or "") + ) + result = app.inspect.update_vertex(vertex_id, tags=[TEST_TAG_ID]) + assert result.ok + app.inspect.refresh() + port = next(p for p in app.inspect.get_device(device_id).ports if p.vertex_id == vertex_id) + assert TEST_TAG_ID in port.tags + finally: + delete_test_tag(app) # also removes the port binding + + +def test_device_placement(app: VideoIPathApp, topology_builder: TopologyBuilder) -> None: + (device_id,) = topology_builder.add_devices([("PLACE-A", 2)]) + app.inspect.place_device(device_id, 4200, 4200) + app.inspect.refresh() + coords = app.inspect.get_device(device_id).coordinates + assert coords is not None and coords["x"] == 4200 and coords["y"] == 4200 + + +def test_disconnect_reconnect_cycle(app: VideoIPathApp, topology_builder: TopologyBuilder) -> None: + id_a, id_b = topology_builder.add_devices([("CYC-A", 2), ("CYC-B", 2)]) + topology_builder.link(id_a, id_b) + app.inspect.refresh() + directed = [tuple(e.id.split("::", 1)) for e in edges_between(app, id_a, id_b)] + assert directed + for from_vertex, to_vertex in directed: + app.inspect.disconnect(from_vertex, to_vertex, bidirectional=False) + app.inspect.refresh() + assert not edges_between(app, id_a, id_b) + for from_vertex, to_vertex in directed: + app.inspect.connect(from_vertex, to_vertex, bidirectional=False) + app.inspect.refresh() + assert len(edges_between(app, id_a, id_b)) == len(directed) + + +def test_full_vs_skeleton_equivalence(app: VideoIPathApp, topology_builder: TopologyBuilder) -> None: + id_a, id_b, id_c = topology_builder.add_devices([("EQ-A", 2), ("EQ-B", 2), ("EQ-C", 2)]) + topology_builder.link(id_a, id_b) + topology_builder.link(id_b, id_c) + + def graph_view() -> dict: + return { + device_id: ( + app.inspect.get_device(device_id).label, + frozenset(d.label for d in app.inspect.get_device(device_id).linked_devices), + ) + for device_id in (id_a, id_b, id_c) + } + + try: + app.inspect.refresh(load="skeleton") + app.inspect.preload([id_a, id_b, id_c]) + skeleton = graph_view() + app.inspect.refresh(load="full") + full = graph_view() + assert skeleton == full + finally: + app.inspect.refresh(load="skeleton") # restore default load mode diff --git a/tests/e2e/inspect/test_e2e_leaf_spine.py b/tests/e2e/inspect/test_e2e_leaf_spine.py deleted file mode 100644 index 3656a24..0000000 --- a/tests/e2e/inspect/test_e2e_leaf_spine.py +++ /dev/null @@ -1,236 +0,0 @@ -"""End-to-end Inspect tests against a live instance, replicating the local topology. - -Developer-run only (see ``tests/e2e/conftest.py``). A session-scoped fixture cleans up any prior -E2E state **at startup** and then builds the topology with virtual (mock-driver) devices via the -inventory → inspect flow. There is **no teardown**: the built topology persists in VideoIPath after -the run (the next run cleans it up and rebuilds). Mutating tests revert their own changes, so the -persisted end state is the complete topology. - -Everything goes through ``app.inventory`` (device creation) and ``app.inspect`` (topology). The -topology app is never used. - -Run with:: - - poetry run test-e2e -""" - -from __future__ import annotations - -import pytest - -from videoipath_automation_tool.apps.inspect.errors import InspectCommitConflictError, InspectCommitError -from videoipath_automation_tool.apps.videoipath_app import VideoIPathApp - -from .scenario import TEST_TAG_ID, LeafSpineScenario - - -pytestmark = pytest.mark.e2e - - -@pytest.fixture(scope="session") -def scenario(app: VideoIPathApp): - scn = LeafSpineScenario() - scn.cleanup(app) # startup cleanup only — no teardown, state persists after the run - scn.build(app) - return scn - - -class _FetchSpy: - """Wraps get_device_detail to count hydration fetches.""" - - def __init__(self, api): - self._api = api - self._orig = api.get_device_detail - self.count = 0 - - def __enter__(self): - def counting(device_id): - self.count += 1 - return self._orig(device_id) - - self._api.get_device_detail = counting - return self - - def __exit__(self, *exc): - self._api.get_device_detail = self._orig - - -def _edges_between(app, id_a: str, id_b: str): - return [ - e - for e in app.inspect.edges - if e.from_device and e.to_device and {e.from_device.id, e.to_device.id} == {id_a, id_b} - ] - - -def _busiest_device(scenario) -> str: - adjacency = scenario.adjacency() - return max(scenario.device_names, key=lambda n: len(adjacency[n])) - - -def test_all_devices_present(app, scenario): - app.inspect.refresh() - labels = {d.label for d in app.inspect.devices} - for name in scenario.device_names: - assert name in labels - - -def test_skeleton_read_no_hydration(app, scenario): - app.inspect.refresh() - with _FetchSpy(app.inspect._inspect_api) as spy: - known = set(scenario.device_ids.values()) - scenario_edges = [ - e - for e in app.inspect.edges - if (e.from_device and e.from_device.id in known) or (e.to_device and e.to_device.id in known) - ] - assert len(scenario_edges) == scenario.expected_edge_count() - assert spy.count == 0 # skeleton read triggers no per-device hydration - - -def test_lazy_hydration(app, scenario): - app.inspect.refresh() - name = _busiest_device(scenario) - device_id = scenario.device_id(name) - other_id = scenario.device_id(next(n for n in scenario.device_names if n != name)) - assert not app.inspect.is_device_hydrated(device_id) - with _FetchSpy(app.inspect._inspect_api) as spy: - ports = app.inspect.get_device(device_id).ports # one detail fetch - assert spy.count == 1 - _ = app.inspect.get_device(device_id).ports # cached - assert spy.count == 1 - assert len(ports) > 0 # mock devices expose router ports - assert app.inspect.is_device_hydrated(device_id) - assert not app.inspect.is_device_hydrated(other_id) - - -def test_connectivity_graph(app, scenario): - app.inspect.refresh() - adjacency = scenario.adjacency() - name = _busiest_device(scenario) - device = app.inspect.get_device(scenario.device_id(name)) - linked = {d.label for d in device.linked_devices} - assert linked == adjacency[name] - - -def test_edge_pair_refresh(app, scenario): - app.inspect.refresh() - name_a, name_b = scenario.a_link() - id_a, id_b = scenario.device_id(name_a), scenario.device_id(name_b) - edges = _edges_between(app, id_a, id_b) - assert edges - edge_id = edges[0].id - result = app.inspect.update_edge(edge_id, weight=7) - assert result.ok - # Survives the targeted post-commit refresh without a full reload. - assert any(e.id == edge_id for e in app.inspect.edges) - app.inspect.update_edge(edge_id, weight=1) # revert - - -def test_transaction_atomicity(app, scenario): - name_a, name_b = scenario.a_link() - id_a, id_b = scenario.device_id(name_a), scenario.device_id(name_b) - app.inspect.refresh() - edge_id = _edges_between(app, id_a, id_b)[0].id - with pytest.raises(InspectCommitError): - with app.inspect.transaction() as tx: - tx.update_edge(edge_id, weight=13) - tx.remove("does-not-exist::also-not-real") - tx.commit() - # Nothing applied: the edge is still present. - app.inspect.refresh() - assert any(e.id == edge_id for e in app.inspect.edges) - - -def test_conflict_detection(app, scenario): - app.inspect.refresh() - name_a, name_b = scenario.a_link() - id_a, id_b = scenario.device_id(name_a), scenario.device_id(name_b) - edge_id = _edges_between(app, id_a, id_b)[0].id - tx = app.inspect.transaction() - tx.update_edge(edge_id, weight=21) - # Out-of-band change via a second app instance. - other = VideoIPathApp() - other.inspect.update_edge(edge_id, weight=9) - with pytest.raises(InspectCommitConflictError) as exc: - tx.commit() - assert any(c.entity_id == edge_id for c in exc.value.conflicts) - tx.rebase() - tx.commit() - app.inspect.update_edge(edge_id, weight=1) # revert - - -def test_assign_tag_to_port(app, scenario): - # Inspect-only capability: assign a catalog tag to a port. The tag was created in setup. - assert scenario.test_tag_exists(app) - app.inspect.refresh() - name = scenario.a_link()[0] - device_id = scenario.device_id(name) - vertex_id = next( - p.vertex_id for p in app.inspect.get_device(device_id).ports if p.vertex_id and "Router In" in (p.label or "") - ) - result = app.inspect.update_vertex(vertex_id, tags=[TEST_TAG_ID]) - assert result.ok - app.inspect.refresh() - port = next(p for p in app.inspect.get_device(device_id).ports if p.vertex_id == vertex_id) - assert TEST_TAG_ID in port.tags - - -def test_device_placement_roundtrip(app, scenario): - name = scenario.device_names[0] - spec = next(s for s in scenario.devices if s.name == name) - device_id = scenario.device_id(name) - app.inspect.place_device(device_id, 4200, 4200) - app.inspect.refresh() - coords = app.inspect.get_device(device_id).coordinates - assert coords is not None and coords["x"] == 4200 and coords["y"] == 4200 - app.inspect.place_device(device_id, spec.x, spec.y) # revert - - -def test_disconnect_reconnect_cycle(app, scenario): - app.inspect.refresh() - name_a, name_b = scenario.a_link() - id_a, id_b = scenario.device_id(name_a), scenario.device_id(name_b) - directed = [tuple(e.id.split("::", 1)) for e in _edges_between(app, id_a, id_b)] - assert directed - for from_vertex, to_vertex in directed: - app.inspect.disconnect(from_vertex, to_vertex, bidirectional=False) - app.inspect.refresh() - assert not _edges_between(app, id_a, id_b) - for from_vertex, to_vertex in directed: - app.inspect.connect(from_vertex, to_vertex, bidirectional=False) - app.inspect.refresh() - assert len(_edges_between(app, id_a, id_b)) == len(directed) - - -def test_full_vs_skeleton_equivalence(app, scenario): - def graph_view() -> dict: - return { - device_id: ( - app.inspect.get_device(device_id).label, - frozenset(d.label for d in app.inspect.get_device(device_id).linked_devices), - ) - for device_id in scenario.device_ids.values() - } - - app.inspect.refresh(load="skeleton") - app.inspect.preload(list(scenario.device_ids.values())) - skeleton = graph_view() - app.inspect.refresh(load="full") - full = graph_view() - assert skeleton == full - app.inspect.refresh(load="skeleton") # restore default load mode - - -def test_state_persists(app, scenario): - # There is no teardown: after the suite the complete topology remains in VideoIPath. - app.inspect.refresh() - labels = {d.label for d in app.inspect.devices} - assert all(name in labels for name in scenario.device_names) - known = set(scenario.device_ids.values()) - scenario_edges = [ - e - for e in app.inspect.edges - if (e.from_device and e.from_device.id in known) or (e.to_device and e.to_device.id in known) - ] - assert len(scenario_edges) == scenario.expected_edge_count() diff --git a/tests/e2e/test_e2e_workflow.py b/tests/e2e/test_e2e_workflow.py new file mode 100644 index 0000000..2b0c40a --- /dev/null +++ b/tests/e2e/test_e2e_workflow.py @@ -0,0 +1,172 @@ +"""Sequential end-to-end workflow: connect → inventory → inspect topology → edges, step by step. + +Each build step of the real user journey is its own test with verification, running in definition +order and sharing state via a class-scoped fixture. Once a step fails, the remaining steps are +skipped (``@pytest.mark.incremental``, see ``conftest.py``). + +The suite builds a small dedicated leaf-spine topology (4 devices, 4 bidirectional links) with +fixed ``E2E-WF-`` labels in its own map region. There is **no teardown**: the built topology +persists in VideoIPath after the run for manual inspection; the next run's session sweep removes +it. + +Run with:: + + poetry run test-e2e +""" + +from __future__ import annotations + +from collections import defaultdict +from typing import Iterator + +import pytest +from pydantic import BaseModel + +from videoipath_automation_tool.apps.inspect.model.common import InspectFrozenModel +from videoipath_automation_tool.apps.videoipath_app import VideoIPathApp + +from .helpers import E2E_TAG, create_mock_device, discover_router_vertices, edges_between + + +pytestmark = pytest.mark.e2e + + +class DeviceSpec(InspectFrozenModel): + name: str + x: int + y: int + ports: int + + +class LinkSpec(InspectFrozenModel): + a: int # device index + b: int # device index + + +# A miniature leaf-spine in its own map region: every leaf connects to every spine. +DEVICES = [ + DeviceSpec(name="E2E-WF-SPINE-01", x=0, y=3000, ports=2), + DeviceSpec(name="E2E-WF-SPINE-02", x=600, y=3000, ports=2), + DeviceSpec(name="E2E-WF-LEAF-01", x=0, y=3400, ports=2), + DeviceSpec(name="E2E-WF-LEAF-02", x=600, y=3400, ports=2), +] +LINKS = [LinkSpec(a=2, b=0), LinkSpec(a=2, b=1), LinkSpec(a=3, b=0), LinkSpec(a=3, b=1)] + + +class WorkflowState(BaseModel): + """Mutable state shared across the sequential steps.""" + + device_ids: dict[str, str] = {} # spec name -> VideoIPath device id + out_vertices: dict[str, list[str]] = {} # device id -> ordered out-vertex ids + in_vertices: dict[str, list[str]] = {} # device id -> ordered in-vertex ids + edge_id: str | None = None # representative edge for the update step + + +@pytest.fixture(scope="class") +def state() -> WorkflowState: + return WorkflowState() + + +def _expected_adjacency() -> dict[str, set[str]]: + """Undirected neighbour set per device name (from the specs).""" + adjacency: dict[str, set[str]] = {d.name: set() for d in DEVICES} + for link in LINKS: + na, nb = DEVICES[link.a].name, DEVICES[link.b].name + adjacency[na].add(nb) + adjacency[nb].add(na) + return adjacency + + +@pytest.mark.incremental +class TestInventoryToInspectWorkflow: + def test_connect(self, app: VideoIPathApp) -> None: + app.check_connection() # raises ConnectionError on failure + assert app.get_server_version() + + def test_create_inventory_devices( + self, app: VideoIPathApp, state: WorkflowState, e2e_addresses: Iterator[str] + ) -> None: + for spec in DEVICES: + state.device_ids[spec.name] = create_mock_device( + app, label=spec.name, address=next(e2e_addresses), ports=spec.ports + ) + assert len(state.device_ids) == len(DEVICES) + + def test_verify_inventory(self, app: VideoIPathApp, state: WorkflowState) -> None: + for spec in DEVICES: + device_id = state.device_ids[spec.name] + assert app.inventory.find_device_id_by_label(spec.name) == device_id + device = app.inventory.get_device(device_id=device_id, config_only=True) + assert device.configuration.label == spec.name + + def test_add_to_topology(self, app: VideoIPathApp, state: WorkflowState) -> None: + app.inspect.add_devices_to_topology([(state.device_ids[s.name], s.x, s.y) for s in DEVICES]) + app.inspect.refresh() + topology_ids = {d.id for d in app.inspect.devices} + assert set(state.device_ids.values()) <= topology_ids + + def test_configure_labels_and_tags(self, app: VideoIPathApp, state: WorkflowState) -> None: + with app.inspect.transaction() as tx: + for spec in DEVICES: + tx.update_device(state.device_ids[spec.name], label=spec.name, tags=[E2E_TAG]) + tx.commit() + app.inspect.refresh() + for spec in DEVICES: + device = app.inspect.get_device(state.device_ids[spec.name]) + assert device is not None + assert device.label == spec.name + assert E2E_TAG in device.tags + + def test_discover_ports(self, app: VideoIPathApp, state: WorkflowState) -> None: + vertices = discover_router_vertices(app, list(state.device_ids.values())) + for spec in DEVICES: + outs, ins = vertices[state.device_ids[spec.name]] + assert len(outs) == spec.ports + assert len(ins) == spec.ports + state.out_vertices[state.device_ids[spec.name]] = outs + state.in_vertices[state.device_ids[spec.name]] = ins + + def test_connect_edges(self, app: VideoIPathApp, state: WorkflowState) -> None: + slot: dict[str, int] = defaultdict(int) + with app.inspect.transaction() as tx: + for link in LINKS: + id_a, id_b = state.device_ids[DEVICES[link.a].name], state.device_ids[DEVICES[link.b].name] + ka, kb = slot[id_a], slot[id_b] + slot[id_a] += 1 + slot[id_b] += 1 + # Bidirectional link between port slot ka on A and kb on B (two directed edges). + tx.connect(state.out_vertices[id_a][ka], state.in_vertices[id_b][kb], bidirectional=False) + tx.connect(state.out_vertices[id_b][kb], state.in_vertices[id_a][ka], bidirectional=False) + tx.commit() + app.inspect.refresh() + for link in LINKS: + id_a, id_b = state.device_ids[DEVICES[link.a].name], state.device_ids[DEVICES[link.b].name] + pair_edges = edges_between(app, id_a, id_b) + assert len(pair_edges) == 2 + state.edge_id = pair_edges[0].id + + def test_update_edge_weight(self, app: VideoIPathApp, state: WorkflowState) -> None: + assert state.edge_id is not None + result = app.inspect.update_edge(state.edge_id, weight=7) + assert result.ok + # Survives the targeted post-commit refresh without a full reload. + assert any(e.id == state.edge_id for e in app.inspect.edges) + + def test_verify_connectivity_and_persistence(self, app: VideoIPathApp, state: WorkflowState) -> None: + # A fresh read of the final state: labels, adjacency, and edge count all match the specs. + # There is no teardown — this persisted topology remains in VideoIPath after the run. + app.inspect.refresh() + labels = {d.label for d in app.inspect.devices} + assert all(spec.name in labels for spec in DEVICES) + adjacency = _expected_adjacency() + for spec in DEVICES: + device = app.inspect.get_device(state.device_ids[spec.name]) + assert device is not None + assert {d.label for d in device.linked_devices} == adjacency[spec.name] + known = set(state.device_ids.values()) + workflow_edges = [ + e + for e in app.inspect.edges + if (e.from_device and e.from_device.id in known) or (e.to_device and e.to_device.id in known) + ] + assert len(workflow_edges) == len(LINKS) * 2 From 6f772e5a4df53366619170976898f0f95d3a5571 Mon Sep 17 00:00:00 2001 From: Jonas Scholl <43819845+JonasScholl@users.noreply.github.com> Date: Thu, 9 Jul 2026 14:02:40 +0200 Subject: [PATCH 11/34] update poetry lock --- poetry.lock | 477 +++++++++++++++++++++++----------------------------- 1 file changed, 213 insertions(+), 264 deletions(-) diff --git a/poetry.lock b/poetry.lock index e95452e..8b2ee44 100644 --- a/poetry.lock +++ b/poetry.lock @@ -14,14 +14,14 @@ files = [ [[package]] name = "certifi" -version = "2026.5.20" +version = "2026.6.17" description = "Python package for providing Mozilla's CA Bundle." optional = false python-versions = ">=3.7" groups = ["main"] files = [ - {file = "certifi-2026.5.20-py3-none-any.whl", hash = "sha256:3c52e209ba0a4ad7aebe60436a4ab349c39e1e602e8c134221e546902ad25897"}, - {file = "certifi-2026.5.20.tar.gz", hash = "sha256:69dea482ab64caa7b9f6aba1c6bf48bb6a5448d1c0f1b17ab42ad8c763a5344d"}, + {file = "certifi-2026.6.17-py3-none-any.whl", hash = "sha256:2227dcbaafe0d2f59279d1762ddddc37783ed4354594f194ffc31d20f41fc3db"}, + {file = "certifi-2026.6.17.tar.gz", hash = "sha256:024c88eeec92ca068db80f02b8b07c9cef7b9fe261d1d535abfd5abd6f6af432"}, ] [[package]] @@ -38,141 +38,105 @@ files = [ [[package]] name = "charset-normalizer" -version = "3.4.7" +version = "3.4.9" description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." optional = false python-versions = ">=3.7" groups = ["main"] files = [ - {file = "charset_normalizer-3.4.7-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:cdd68a1fb318e290a2077696b7eb7a21a49163c455979c639bf5a5dcdc46617d"}, - {file = "charset_normalizer-3.4.7-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e17b8d5d6a8c47c85e68ca8379def1303fd360c3e22093a807cd34a71cd082b8"}, - {file = "charset_normalizer-3.4.7-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:511ef87c8aec0783e08ac18565a16d435372bc1ac25a91e6ac7f5ef2b0bff790"}, - {file = "charset_normalizer-3.4.7-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:007d05ec7321d12a40227aae9e2bc6dca73f3cb21058999a1df9e193555a9dcc"}, - {file = "charset_normalizer-3.4.7-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cf29836da5119f3c8a8a70667b0ef5fdca3bb12f80fd06487cfa575b3909b393"}, - {file = "charset_normalizer-3.4.7-cp310-cp310-manylinux_2_31_armv7l.whl", hash = "sha256:12d8baf840cc7889b37c7c770f478adea7adce3dcb3944d02ec87508e2dcf153"}, - {file = "charset_normalizer-3.4.7-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d560742f3c0d62afaccf9f41fe485ed69bd7661a241f86a3ef0f0fb8b1a397af"}, - {file = "charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:b14b2d9dac08e28bb8046a1a0434b1750eb221c8f5b87a68f4fa11a6f97b5e34"}, - {file = "charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:bc17a677b21b3502a21f66a8cc64f5bfad4df8a0b8434d661666f8ce90ac3af1"}, - {file = "charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:750e02e074872a3fad7f233b47734166440af3cdea0add3e95163110816d6752"}, - {file = "charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:4e5163c14bffd570ef2affbfdd77bba66383890797df43dc8b4cc7d6f500bf53"}, - {file = "charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:6ed74185b2db44f41ef35fd1617c5888e59792da9bbc9190d6c7300617182616"}, - {file = "charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:94e1885b270625a9a828c9793b4d52a64445299baa1fea5a173bf1d3dd9a1a5a"}, - {file = "charset_normalizer-3.4.7-cp310-cp310-win32.whl", hash = "sha256:6785f414ae0f3c733c437e0f3929197934f526d19dfaa75e18fdb4f94c6fb374"}, - {file = "charset_normalizer-3.4.7-cp310-cp310-win_amd64.whl", hash = "sha256:6696b7688f54f5af4462118f0bfa7c1621eeb87154f77fa04b9295ce7a8f2943"}, - {file = "charset_normalizer-3.4.7-cp310-cp310-win_arm64.whl", hash = "sha256:66671f93accb62ed07da56613636f3641f1a12c13046ce91ffc923721f23c008"}, - {file = "charset_normalizer-3.4.7-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7641bb8895e77f921102f72833904dcd9901df5d6d72a2ab8f31d04b7e51e4e7"}, - {file = "charset_normalizer-3.4.7-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:202389074300232baeb53ae2569a60901f7efadd4245cf3a3bf0617d60b439d7"}, - {file = "charset_normalizer-3.4.7-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:30b8d1d8c52a48c2c5690e152c169b673487a2a58de1ec7393196753063fcd5e"}, - {file = "charset_normalizer-3.4.7-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:532bc9bf33a68613fd7d65e4b1c71a6a38d7d42604ecf239c77392e9b4e8998c"}, - {file = "charset_normalizer-3.4.7-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2fe249cb4651fd12605b7288b24751d8bfd46d35f12a20b1ba33dea122e690df"}, - {file = "charset_normalizer-3.4.7-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:65bcd23054beab4d166035cabbc868a09c1a49d1efe458fe8e4361215df40265"}, - {file = "charset_normalizer-3.4.7-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:08e721811161356f97b4059a9ba7bafb23ea5ee2255402c42881c214e173c6b4"}, - {file = "charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:e060d01aec0a910bdccb8be71faf34e7799ce36950f8294c8bf612cba65a2c9e"}, - {file = "charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:38c0109396c4cfc574d502df99742a45c72c08eff0a36158b6f04000043dbf38"}, - {file = "charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:1c2a768fdd44ee4a9339a9b0b130049139b8ce3c01d2ce09f67f5a68048d477c"}, - {file = "charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:1a87ca9d5df6fe460483d9a5bbf2b18f620cbed41b432e2bddb686228282d10b"}, - {file = "charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:d635aab80466bc95771bb78d5370e74d36d1fe31467b6b29b8b57b2a3cd7d22c"}, - {file = "charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ae196f021b5e7c78e918242d217db021ed2a6ace2bc6ae94c0fc596221c7f58d"}, - {file = "charset_normalizer-3.4.7-cp311-cp311-win32.whl", hash = "sha256:adb2597b428735679446b46c8badf467b4ca5f5056aae4d51a19f9570301b1ad"}, - {file = "charset_normalizer-3.4.7-cp311-cp311-win_amd64.whl", hash = "sha256:8e385e4267ab76874ae30db04c627faaaf0b509e1ccc11a95b3fc3e83f855c00"}, - {file = "charset_normalizer-3.4.7-cp311-cp311-win_arm64.whl", hash = "sha256:d4a48e5b3c2a489fae013b7589308a40146ee081f6f509e047e0e096084ceca1"}, - {file = "charset_normalizer-3.4.7-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:eca9705049ad3c7345d574e3510665cb2cf844c2f2dcfe675332677f081cbd46"}, - {file = "charset_normalizer-3.4.7-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6178f72c5508bfc5fd446a5905e698c6212932f25bcdd4b47a757a50605a90e2"}, - {file = "charset_normalizer-3.4.7-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e1421b502d83040e6d7fb2fb18dff63957f720da3d77b2fbd3187ceb63755d7b"}, - {file = "charset_normalizer-3.4.7-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:edac0f1ab77644605be2cbba52e6b7f630731fc42b34cb0f634be1a6eface56a"}, - {file = "charset_normalizer-3.4.7-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5649fd1c7bade02f320a462fdefd0b4bd3ce036065836d4f42e0de958038e116"}, - {file = "charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:203104ed3e428044fd943bc4bf45fa73c0730391f9621e37fe39ecf477b128cb"}, - {file = "charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:298930cec56029e05497a76988377cbd7457ba864beeea92ad7e844fe74cd1f1"}, - {file = "charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:708838739abf24b2ceb208d0e22403dd018faeef86ddac04319a62ae884c4f15"}, - {file = "charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:0f7eb884681e3938906ed0434f20c63046eacd0111c4ba96f27b76084cd679f5"}, - {file = "charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4dc1e73c36828f982bfe79fadf5919923f8a6f4df2860804db9a98c48824ce8d"}, - {file = "charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:aed52fea0513bac0ccde438c188c8a471c4e0f457c2dd20cdbf6ea7a450046c7"}, - {file = "charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:fea24543955a6a729c45a73fe90e08c743f0b3334bbf3201e6c4bc1b0c7fa464"}, - {file = "charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:bb6d88045545b26da47aa879dd4a89a71d1dce0f0e549b1abcb31dfe4a8eac49"}, - {file = "charset_normalizer-3.4.7-cp312-cp312-win32.whl", hash = "sha256:2257141f39fe65a3fdf38aeccae4b953e5f3b3324f4ff0daf9f15b8518666a2c"}, - {file = "charset_normalizer-3.4.7-cp312-cp312-win_amd64.whl", hash = "sha256:5ed6ab538499c8644b8a3e18debabcd7ce684f3fa91cf867521a7a0279cab2d6"}, - {file = "charset_normalizer-3.4.7-cp312-cp312-win_arm64.whl", hash = "sha256:56be790f86bfb2c98fb742ce566dfb4816e5a83384616ab59c49e0604d49c51d"}, - {file = "charset_normalizer-3.4.7-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:f496c9c3cc02230093d8330875c4c3cdfc3b73612a5fd921c65d39cbcef08063"}, - {file = "charset_normalizer-3.4.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ea948db76d31190bf08bd371623927ee1339d5f2a0b4b1b4a4439a65298703c"}, - {file = "charset_normalizer-3.4.7-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a277ab8928b9f299723bc1a2dabb1265911b1a76341f90a510368ca44ad9ab66"}, - {file = "charset_normalizer-3.4.7-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3bec022aec2c514d9cf199522a802bd007cd588ab17ab2525f20f9c34d067c18"}, - {file = "charset_normalizer-3.4.7-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e044c39e41b92c845bc815e5ae4230804e8e7bc29e399b0437d64222d92809dd"}, - {file = "charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:f495a1652cf3fbab2eb0639776dad966c2fb874d79d87ca07f9d5f059b8bd215"}, - {file = "charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e712b419df8ba5e42b226c510472b37bd57b38e897d3eca5e8cfd410a29fa859"}, - {file = "charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7804338df6fcc08105c7745f1502ba68d900f45fd770d5bdd5288ddccb8a42d8"}, - {file = "charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:481551899c856c704d58119b5025793fa6730adda3571971af568f66d2424bb5"}, - {file = "charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:f59099f9b66f0d7145115e6f80dd8b1d847176df89b234a5a6b3f00437aa0832"}, - {file = "charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:f59ad4c0e8f6bba240a9bb85504faa1ab438237199d4cce5f622761507b8f6a6"}, - {file = "charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:3dedcc22d73ec993f42055eff4fcfed9318d1eeb9a6606c55892a26964964e48"}, - {file = "charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:64f02c6841d7d83f832cd97ccf8eb8a906d06eb95d5276069175c696b024b60a"}, - {file = "charset_normalizer-3.4.7-cp313-cp313-win32.whl", hash = "sha256:4042d5c8f957e15221d423ba781e85d553722fc4113f523f2feb7b188cc34c5e"}, - {file = "charset_normalizer-3.4.7-cp313-cp313-win_amd64.whl", hash = "sha256:3946fa46a0cf3e4c8cb1cc52f56bb536310d34f25f01ca9b6c16afa767dab110"}, - {file = "charset_normalizer-3.4.7-cp313-cp313-win_arm64.whl", hash = "sha256:80d04837f55fc81da168b98de4f4b797ef007fc8a79ab71c6ec9bc4dd662b15b"}, - {file = "charset_normalizer-3.4.7-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:c36c333c39be2dbca264d7803333c896ab8fa7d4d6f0ab7edb7dfd7aea6e98c0"}, - {file = "charset_normalizer-3.4.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c2aed2e5e41f24ea8ef1590b8e848a79b56f3a5564a65ceec43c9d692dc7d8a"}, - {file = "charset_normalizer-3.4.7-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:54523e136b8948060c0fa0bc7b1b50c32c186f2fceee897a495406bb6e311d2b"}, - {file = "charset_normalizer-3.4.7-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:715479b9a2802ecac752a3b0efa2b0b60285cf962ee38414211abdfccc233b41"}, - {file = "charset_normalizer-3.4.7-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bd6c2a1c7573c64738d716488d2cdd3c00e340e4835707d8fdb8dc1a66ef164e"}, - {file = "charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:c45e9440fb78f8ddabcf714b68f936737a121355bf59f3907f4e17721b9d1aae"}, - {file = "charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3534e7dcbdcf757da6b85a0bbf5b6868786d5982dd959b065e65481644817a18"}, - {file = "charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e8ac484bf18ce6975760921bb6148041faa8fef0547200386ea0b52b5d27bf7b"}, - {file = "charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:a5fe03b42827c13cdccd08e6c0247b6a6d4b5e3cdc53fd1749f5896adcdc2356"}, - {file = "charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:2d6eb928e13016cea4f1f21d1e10c1cebd5a421bc57ddf5b1142ae3f86824fab"}, - {file = "charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e74327fb75de8986940def6e8dee4f127cc9752bee7355bb323cc5b2659b6d46"}, - {file = "charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d6038d37043bced98a66e68d3aa2b6a35505dc01328cd65217cefe82f25def44"}, - {file = "charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7579e913a5339fb8fa133f6bbcfd8e6749696206cf05acdbdca71a1b436d8e72"}, - {file = "charset_normalizer-3.4.7-cp314-cp314-win32.whl", hash = "sha256:5b77459df20e08151cd6f8b9ef8ef1f961ef73d85c21a555c7eed5b79410ec10"}, - {file = "charset_normalizer-3.4.7-cp314-cp314-win_amd64.whl", hash = "sha256:92a0a01ead5e668468e952e4238cccd7c537364eb7d851ab144ab6627dbbe12f"}, - {file = "charset_normalizer-3.4.7-cp314-cp314-win_arm64.whl", hash = "sha256:67f6279d125ca0046a7fd386d01b311c6363844deac3e5b069b514ba3e63c246"}, - {file = "charset_normalizer-3.4.7-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:effc3f449787117233702311a1b7d8f59cba9ced946ba727bdc329ec69028e24"}, - {file = "charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fbccdc05410c9ee21bbf16a35f4c1d16123dcdeb8a1d38f33654fa21d0234f79"}, - {file = "charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:733784b6d6def852c814bce5f318d25da2ee65dd4839a0718641c696e09a2960"}, - {file = "charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a89c23ef8d2c6b27fd200a42aa4ac72786e7c60d40efdc76e6011260b6e949c4"}, - {file = "charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6c114670c45346afedc0d947faf3c7f701051d2518b943679c8ff88befe14f8e"}, - {file = "charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:a180c5e59792af262bf263b21a3c49353f25945d8d9f70628e73de370d55e1e1"}, - {file = "charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3c9a494bc5ec77d43cea229c4f6db1e4d8fe7e1bbffa8b6f0f0032430ff8ab44"}, - {file = "charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8d828b6667a32a728a1ad1d93957cdf37489c57b97ae6c4de2860fa749b8fc1e"}, - {file = "charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cf1493cd8607bec4d8a7b9b004e699fcf8f9103a9284cc94962cb73d20f9d4a3"}, - {file = "charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0c96c3b819b5c3e9e165495db84d41914d6894d55181d2d108cc1a69bfc9cce0"}, - {file = "charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:752a45dc4a6934060b3b0dab47e04edc3326575f82be64bc4fc293914566503e"}, - {file = "charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:8778f0c7a52e56f75d12dae53ae320fae900a8b9b4164b981b9c5ce059cd1fcb"}, - {file = "charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ce3412fbe1e31eb81ea42f4169ed94861c56e643189e1e75f0041f3fe7020abe"}, - {file = "charset_normalizer-3.4.7-cp314-cp314t-win32.whl", hash = "sha256:c03a41a8784091e67a39648f70c5f97b5b6a37f216896d44d2cdcb82615339a0"}, - {file = "charset_normalizer-3.4.7-cp314-cp314t-win_amd64.whl", hash = "sha256:03853ed82eeebbce3c2abfdbc98c96dc205f32a79627688ac9a27370ea61a49c"}, - {file = "charset_normalizer-3.4.7-cp314-cp314t-win_arm64.whl", hash = "sha256:c35abb8bfff0185efac5878da64c45dafd2b37fb0383add1be155a763c1f083d"}, - {file = "charset_normalizer-3.4.7-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:e5f4d355f0a2b1a31bc3edec6795b46324349c9cb25eed068049e4f472fb4259"}, - {file = "charset_normalizer-3.4.7-cp38-cp38-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:16d971e29578a5e97d7117866d15889a4a07befe0e87e703ed63cd90cb348c01"}, - {file = "charset_normalizer-3.4.7-cp38-cp38-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:dca4bbc466a95ba9c0234ef56d7dd9509f63da22274589ebd4ed7f1f4d4c54e3"}, - {file = "charset_normalizer-3.4.7-cp38-cp38-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e80c8378d8f3d83cd3164da1ad2df9e37a666cdde7b1cb2298ed0b558064be30"}, - {file = "charset_normalizer-3.4.7-cp38-cp38-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:36836d6ff945a00b88ba1e4572d721e60b5b8c98c155d465f56ad19d68f23734"}, - {file = "charset_normalizer-3.4.7-cp38-cp38-manylinux_2_31_armv7l.whl", hash = "sha256:bd9b23791fe793e4968dba0c447e12f78e425c59fc0e3b97f6450f4781f3ee60"}, - {file = "charset_normalizer-3.4.7-cp38-cp38-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:aef65cd602a6d0e0ff6f9930fcb1c8fec60dd2cfcb6facaf4bdb0e5873042db0"}, - {file = "charset_normalizer-3.4.7-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:82b271f5137d07749f7bf32f70b17ab6eaabedd297e75dce75081a24f76eb545"}, - {file = "charset_normalizer-3.4.7-cp38-cp38-musllinux_1_2_armv7l.whl", hash = "sha256:1efde3cae86c8c273f1eb3b287be7d8499420cf2fe7585c41d370d3e790054a5"}, - {file = "charset_normalizer-3.4.7-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:c593052c465475e64bbfe5dbd81680f64a67fdc752c56d7a0ae205dc8aeefe0f"}, - {file = "charset_normalizer-3.4.7-cp38-cp38-musllinux_1_2_riscv64.whl", hash = "sha256:af21eb4409a119e365397b2adbaca4c9ccab56543a65d5dbd9f920d6ac29f686"}, - {file = "charset_normalizer-3.4.7-cp38-cp38-musllinux_1_2_s390x.whl", hash = "sha256:84c018e49c3bf790f9c2771c45e9313a08c2c2a6342b162cd650258b57817706"}, - {file = "charset_normalizer-3.4.7-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:dd915403e231e6b1809fe9b6d9fc55cf8fb5e02765ac625d9cd623342a7905d7"}, - {file = "charset_normalizer-3.4.7-cp38-cp38-win32.whl", hash = "sha256:320ade88cfb846b8cd6b4ddf5ee9e80ee0c1f52401f2456b84ae1ae6a1a5f207"}, - {file = "charset_normalizer-3.4.7-cp38-cp38-win_amd64.whl", hash = "sha256:1dc8b0ea451d6e69735094606991f32867807881400f808a106ee1d963c46a83"}, - {file = "charset_normalizer-3.4.7-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:177a0ba5f0211d488e295aaf82707237e331c24788d8d76c96c5a41594723217"}, - {file = "charset_normalizer-3.4.7-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e0d51f618228538a3e8f46bd246f87a6cd030565e015803691603f55e12afb5"}, - {file = "charset_normalizer-3.4.7-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:14265bfe1f09498b9d8ec91e9ec9fa52775edf90fcbde092b25f4a33d444fea9"}, - {file = "charset_normalizer-3.4.7-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:87fad7d9ba98c86bcb41b2dc8dbb326619be2562af1f8ff50776a39e55721c5a"}, - {file = "charset_normalizer-3.4.7-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f22dec1690b584cea26fade98b2435c132c1b5f68e39f5a0b7627cd7ae31f1dc"}, - {file = "charset_normalizer-3.4.7-cp39-cp39-manylinux_2_31_armv7l.whl", hash = "sha256:d61f00a0869d77422d9b2aba989e2d24afa6ffd552af442e0e58de4f35ea6d00"}, - {file = "charset_normalizer-3.4.7-cp39-cp39-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6370e8686f662e6a3941ee48ed4742317cafbe5707e36406e9df792cdb535776"}, - {file = "charset_normalizer-3.4.7-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:a6c5863edfbe888d9eff9c8b8087354e27618d9da76425c119293f11712a6319"}, - {file = "charset_normalizer-3.4.7-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:ed065083d0898c9d5b4bbec7b026fd755ff7454e6e8b73a67f8c744b13986e24"}, - {file = "charset_normalizer-3.4.7-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:2cd4a60d0e2fb04537162c62bbbb4182f53541fe0ede35cdf270a1c1e723cc42"}, - {file = "charset_normalizer-3.4.7-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:813c0e0132266c08eb87469a642cb30aaff57c5f426255419572aaeceeaa7bf4"}, - {file = "charset_normalizer-3.4.7-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:07d9e39b01743c3717745f4c530a6349eadbfa043c7577eef86c502c15df2c67"}, - {file = "charset_normalizer-3.4.7-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:c0f081d69a6e58272819b70288d3221a6ee64b98df852631c80f293514d3b274"}, - {file = "charset_normalizer-3.4.7-cp39-cp39-win32.whl", hash = "sha256:8751d2787c9131302398b11e6c8068053dcb55d5a8964e114b6e196cf16cb366"}, - {file = "charset_normalizer-3.4.7-cp39-cp39-win_amd64.whl", hash = "sha256:12a6fff75f6bc66711b73a2f0addfc4c8c15a20e805146a02d147a318962c444"}, - {file = "charset_normalizer-3.4.7-cp39-cp39-win_arm64.whl", hash = "sha256:bb8cc7534f51d9a017b93e3e85b260924f909601c3df002bcdb58ddb4dc41a5c"}, - {file = "charset_normalizer-3.4.7-py3-none-any.whl", hash = "sha256:3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d"}, - {file = "charset_normalizer-3.4.7.tar.gz", hash = "sha256:ae89db9e5f98a11a4bf50407d4363e7b09b31e55bc117b4f7d80aab97ba009e5"}, + {file = "charset_normalizer-3.4.9-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:cd6280cf040f233bd7d3407b743b4b4c74f70e8e1c4199cb112a62c941c0772a"}, + {file = "charset_normalizer-3.4.9-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:aa99adc8f081b475a12843953db36831eaf83ec33eb46a90629ca6a5de45a616"}, + {file = "charset_normalizer-3.4.9-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c1225416b463483160e4af85d5fc3a9690ccb53fd4b1865a6437825f5ede3209"}, + {file = "charset_normalizer-3.4.9-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:16d10d789dd9bcca1173c95af82c58433122564b7bc39385124be735a35cbe99"}, + {file = "charset_normalizer-3.4.9-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9bb41182d93ea91f60b4bc8fbf4c820c69ef8a12ab2d917f3f1834f1acad07e8"}, + {file = "charset_normalizer-3.4.9-cp310-cp310-manylinux_2_31_armv7l.whl", hash = "sha256:bcf74c1df76758a395bf0af608c04c82257523f55c9868b334f06270d0f2112b"}, + {file = "charset_normalizer-3.4.9-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b5314963fce9b0b12743891de876e724997864ee22aa496f903f426c7e2fa5b2"}, + {file = "charset_normalizer-3.4.9-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:e9701d0049d92c16703a42771b98d560b95248949f23f8cf7b4eddd201814fb9"}, + {file = "charset_normalizer-3.4.9-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:65a7ff3f705e57d392f7261b6d0550fe137c3019477431f1c355e0db0a7d3e15"}, + {file = "charset_normalizer-3.4.9-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:79580094b00d1789d1f93ea55bc43cb2f611910c72235b7657f3482ddcc1b22d"}, + {file = "charset_normalizer-3.4.9-cp310-cp310-win32.whl", hash = "sha256:432786d3561e69aeeae6c7e8648964ce0ad05736120135601f87ac26b9c83381"}, + {file = "charset_normalizer-3.4.9-cp310-cp310-win_amd64.whl", hash = "sha256:8c041122946b7ba21bb32c45b1aa57b1be35527690aeb3c5c234521085632eee"}, + {file = "charset_normalizer-3.4.9-cp310-cp310-win_arm64.whl", hash = "sha256:375b83ed0aecfce76c16d198fbc21f3b11b337d68662bea0a995046682a11419"}, + {file = "charset_normalizer-3.4.9-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:0e94703ec9684807f20cfb5eed95c70f67f2a8f21ad620146d7b5a13677b93e5"}, + {file = "charset_normalizer-3.4.9-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2a441ea71902098ffe78c5abe6c494f44160b4af614ed16c3d9a3b1d17fd8ee2"}, + {file = "charset_normalizer-3.4.9-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:304b13570067b2547562e308af560b3963857b1fa90bd6afd978130130fe2d6a"}, + {file = "charset_normalizer-3.4.9-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4773092f8019072343a7447203308b176e10199920eb02d6195e81bbb3274c29"}, + {file = "charset_normalizer-3.4.9-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:04ce310cb89c15df659582aee80a0603788732a5e017d5bd5c81158106ce249c"}, + {file = "charset_normalizer-3.4.9-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:c0323c9daef75ef2e5083624b4585018a0c9d5e3b40f607eed81a311270b934b"}, + {file = "charset_normalizer-3.4.9-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:871ff67ea1aad4dfd91736464934d56b32dac49f9fbe16cddba36198a7b3a0db"}, + {file = "charset_normalizer-3.4.9-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:67830fc78e67501f47bb950471b2dcb9b35b140084429318e862895a8e89c993"}, + {file = "charset_normalizer-3.4.9-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:3d92613ec25e43b05f042302531ec0f00b8445190e43325880cbd6ab7c2581da"}, + {file = "charset_normalizer-3.4.9-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:280081916dc341820640489a66e4696049401ef1cf6dd672f672e70ad915aca3"}, + {file = "charset_normalizer-3.4.9-cp311-cp311-win32.whl", hash = "sha256:ac351b3b8014eead140e77e9717e2992c6bbe30b63bc3422422eb84865412e3d"}, + {file = "charset_normalizer-3.4.9-cp311-cp311-win_amd64.whl", hash = "sha256:6366a16e1a25018694d6a5d784d09b046edc9eac40ea2b54065c3052672516a1"}, + {file = "charset_normalizer-3.4.9-cp311-cp311-win_arm64.whl", hash = "sha256:1d22856ffbe153a602df38e4a5464f0b748a54002e0d69ac6d2ad0a197cc99ec"}, + {file = "charset_normalizer-3.4.9-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:45b0cc4e3556cd875e09102988d1ab8356c998b596c9fced84547c8138b487a0"}, + {file = "charset_normalizer-3.4.9-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9b2aff1c7b3884512b9512c3eaadd9bab39fb45042ffaaa1dd08ff2b9f8109d9"}, + {file = "charset_normalizer-3.4.9-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9104ed0bd76a429d46f9ec0dbc9b08ad1d2dcdf2b00a5a0daa1c145329b35b44"}, + {file = "charset_normalizer-3.4.9-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7b86a2b16095d250c6f58b3d9b2eee6f4147754344f3dab0922f7c9bf7d226c9"}, + {file = "charset_normalizer-3.4.9-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5e226f6218febc71f6c1fc2fafb91c226f75bdc1d8fb12d66823716e891608fd"}, + {file = "charset_normalizer-3.4.9-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:90c44bc373b7687f6948b693cceaea1348ae0975d7474746559494468e3c1d84"}, + {file = "charset_normalizer-3.4.9-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9cdef90ae47919cae358d8ab15797a800ed41da7aba5d72419fb510729e2ed4b"}, + {file = "charset_normalizer-3.4.9-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:60f44ade2cf573dad7a277e6f8ca9a51a21dda572b13bd7d8539bb3cd5dbedde"}, + {file = "charset_normalizer-3.4.9-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:a1786910334ed46ab1dd73222f2cd1e05c2c3bb39f6dddb4f8b36fc382058a39"}, + {file = "charset_normalizer-3.4.9-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:03d07803992c6c7bbc976327f34b18b6160327fc81cb82c9d504720ac0be3b62"}, + {file = "charset_normalizer-3.4.9-cp312-cp312-win32.whl", hash = "sha256:78841cccf1af7b40f6f716338d50c0902dbe88d9f800b3c973b7a9a0a693a642"}, + {file = "charset_normalizer-3.4.9-cp312-cp312-win_amd64.whl", hash = "sha256:4b3dac63058cc36820b0dd072f89898604e2d39686fe05321729d00d8ac185a0"}, + {file = "charset_normalizer-3.4.9-cp312-cp312-win_arm64.whl", hash = "sha256:78fa18e436a1a0e58dbd7e02fc4473f3f32cceb12df9dfca542d075961c307d2"}, + {file = "charset_normalizer-3.4.9-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:440eede837960000d74978f0eba527be106b5b9aee0daf779d395276ed0b0614"}, + {file = "charset_normalizer-3.4.9-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:21e764fd1e70b6a3e205a0e46f3051701f98a8cb3fad66eeb80e48bb502f8698"}, + {file = "charset_normalizer-3.4.9-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e4fd89cc178bced6ad29cb3e6dd4aa63fa5017c3524dbd0b25998fb64a87cc8b"}, + {file = "charset_normalizer-3.4.9-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:bd47ba7fc3ca94896759ea0109775132d3e7ab921fbf54038e1bab2e46c313c9"}, + {file = "charset_normalizer-3.4.9-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:84fd18bcc17526fc2b3c1af7d2b9217d32c9c04448c16ec693b9b4f1985c3d33"}, + {file = "charset_normalizer-3.4.9-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:5b10cd92fc5c498b35a8635df6d5a100207f88b63a4dc1de7ef9a548e1e2cd63"}, + {file = "charset_normalizer-3.4.9-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a4fbdde9dd4a9ce5fd52c2b3a347bb50cc89483ef783f1cb00d408c13f7a96c0"}, + {file = "charset_normalizer-3.4.9-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:416c229f77e5ea25b3dfd4b582f8d73d7e43c22320302b9ab128a2d3a0b38efe"}, + {file = "charset_normalizer-3.4.9-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:75286256590a6320cf106a0d28970d3560aad9ee09aa7b34fb40524792436d35"}, + {file = "charset_normalizer-3.4.9-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:69b157c5d3292bcd443faca052f3096f637f1e074b98212a933c074ae23dc3b8"}, + {file = "charset_normalizer-3.4.9-cp313-cp313-win32.whl", hash = "sha256:51307f5c71007673a2bf8232ad973483d281e74cb99c8c5a990af1eefa6277d9"}, + {file = "charset_normalizer-3.4.9-cp313-cp313-win_amd64.whl", hash = "sha256:fe2c7201c642b7c308f1675355ad7ff7b66acfe3541625efe5a3ad38f29d6115"}, + {file = "charset_normalizer-3.4.9-cp313-cp313-win_arm64.whl", hash = "sha256:611057cc5d5c0afc743ba8be6bd828c17e0aaa8643f9d0a9b9bb7dea80eb8012"}, + {file = "charset_normalizer-3.4.9-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:0327fcd59a935777d83410750c50600ee9571af2846f71ce40f25b13da1ef380"}, + {file = "charset_normalizer-3.4.9-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8a79d9f4d8001473a30c163556b3c3bfebec837495a412dde78b51672f6134f9"}, + {file = "charset_normalizer-3.4.9-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:33bdcc2a32c0a0e861f60841a512c8acc658c87c2ac59d89e3a46dacf7d866e4"}, + {file = "charset_normalizer-3.4.9-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f840ed6d8ecba8255df8c42b87fadeda98ddfc6eeec05e2dc66e26d46dd6f58a"}, + {file = "charset_normalizer-3.4.9-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c25fe15c70c59eb7c5ce8c06a1f3fa1da0ecc5ea1e7a5922c40fd2fa9b0d5046"}, + {file = "charset_normalizer-3.4.9-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:f7fb7d750cfa0a070d2c24e831fd3481019a60dd317ea2b39acbcebc08b6ed81"}, + {file = "charset_normalizer-3.4.9-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4d1c96a7a18b9690a4d46df09e3e3382406ae3213727cd1019ebade1c4a81917"}, + {file = "charset_normalizer-3.4.9-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a4cfde78a9f2880208d16a93b795726a3017d5977e08d1e162a7a31322479c41"}, + {file = "charset_normalizer-3.4.9-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:d4d6fcde76f94f5cb9e43e9e9a61f16dacefd228cbbf6f1a09bd9b219a92f1a1"}, + {file = "charset_normalizer-3.4.9-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:898f0e9068ca27d37f8e83a5b962821df851532e6c4a7d615c1c033f9da6eedf"}, + {file = "charset_normalizer-3.4.9-cp314-cp314-win32.whl", hash = "sha256:c1c948747b03be832dceed96ca815cef7360de9aa19d37c730f8e3f6101aca48"}, + {file = "charset_normalizer-3.4.9-cp314-cp314-win_amd64.whl", hash = "sha256:16b65ea0f2465b6fb52aa22de5eca612aa964ddfec00a912e26f4656cbef890b"}, + {file = "charset_normalizer-3.4.9-cp314-cp314-win_arm64.whl", hash = "sha256:40a126142a56b2dfc0aacbad1de8310cbf60da7656db0e6b16eebd48e3e93519"}, + {file = "charset_normalizer-3.4.9-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:609b3ba8fcc0fb5ab7af00719d0fb6ad0cb518e48e7712d12fd68f1327951198"}, + {file = "charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:51447e9aa2684679af07ca5021c3db526e0284347ebf4ffcec1154c3350cfe32"}, + {file = "charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cc1b0fff8ead343dae06305f954eb8468ba0ec1a97881f42489d198e4ce3c632"}, + {file = "charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fa36ec09ef71d158186bc79e359ff5fdd6e7996fe8ab638f00d6b93139ba4fcf"}, + {file = "charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:df115d4d83168fdf2cae48ef1ff6d1cb4c466364e30861b37121de0f3bf1b990"}, + {file = "charset_normalizer-3.4.9-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:f86c6358749bd4fda175388691e3ba8c46e24c5347d0afd20f9b7edfc9faf07d"}, + {file = "charset_normalizer-3.4.9-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:32286a2c8d167e897177b673176c1e3e00d4057caf5d2b64eef9a3666b03018e"}, + {file = "charset_normalizer-3.4.9-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:83aed2c10721ddd90f68140685391b50811a880af20654c59af6b6c66c40513c"}, + {file = "charset_normalizer-3.4.9-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cd6c3d4b783c556fa00bf540854e42f135e2f256abd29669fcd0da0f2dec79c2"}, + {file = "charset_normalizer-3.4.9-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ee2f2a527e3c1a6e6411eb4209642e138b544a2d72fe5d0d76daf77b24063534"}, + {file = "charset_normalizer-3.4.9-cp314-cp314t-win32.whl", hash = "sha256:0d861473f743244d349b50f850d10eb87aeb22bbdcc8e64f79273c94af5a8226"}, + {file = "charset_normalizer-3.4.9-cp314-cp314t-win_amd64.whl", hash = "sha256:9b8e0f3107e2200b76f6054de99016eac3ee6762713587b36baaa7e4bd2ae177"}, + {file = "charset_normalizer-3.4.9-cp314-cp314t-win_arm64.whl", hash = "sha256:19ac87f93086ce37b86e098888555c4b4bc48102279bae3350098c0ed664b501"}, + {file = "charset_normalizer-3.4.9-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:253a4a220747e8b5faf57ec320c4f5efb0cef05f647420bf267143ec15dba10a"}, + {file = "charset_normalizer-3.4.9-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:68ce9f4d6b26d5ccbf7fd4459bf75f74a0a146677ebba80597df60cbdb20e6f4"}, + {file = "charset_normalizer-3.4.9-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:58150c9f9b9a552505912d182ccdf26f6396fb6094816ceebcbb20eecabaed94"}, + {file = "charset_normalizer-3.4.9-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:df7276909358e5635ae203673ab7e509ddd224225a8d6b0790bf13eb2bde1cc5"}, + {file = "charset_normalizer-3.4.9-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3c09a49d6cde137258beb3d551994a2927fd35ad5cf96aed573f61bbd67c5f84"}, + {file = "charset_normalizer-3.4.9-cp39-cp39-manylinux_2_31_armv7l.whl", hash = "sha256:231ddcbb35e2ff8973e1365db41fe0572662893b99a05deb183b68ad4c0c8bd4"}, + {file = "charset_normalizer-3.4.9-cp39-cp39-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:920079c3f7456fa213e0829ed2073aaa727fd39d889ead5b4f35d0de5460d04f"}, + {file = "charset_normalizer-3.4.9-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:0fa1aec2d32bcc03c8fa0f6f1712caad1adc38509f31142112e5c9daf5b9c833"}, + {file = "charset_normalizer-3.4.9-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:ad41ba96094304aa090f5a30cb6e4fb3b3f1c264c523394b4c39bbacc4dc92ba"}, + {file = "charset_normalizer-3.4.9-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:43b9e366a31fdd1c87d0eb08f579b4a82b723ea54338f040d6b4e518a026ea29"}, + {file = "charset_normalizer-3.4.9-cp39-cp39-win32.whl", hash = "sha256:93d59d504b230e83c7a843251681959a0b6a9cd76f6e146ce1b8a80eb8739af9"}, + {file = "charset_normalizer-3.4.9-cp39-cp39-win_amd64.whl", hash = "sha256:ddf4af30b417d9fe16481e9b81c27ab2a7cde1ff7ba3e85653b02db7d145dc7b"}, + {file = "charset_normalizer-3.4.9-cp39-cp39-win_arm64.whl", hash = "sha256:476743fe6dfe14a2da12e3ac79125dc84a3b2cf8094369a47a1529b0cd8549fe"}, + {file = "charset_normalizer-3.4.9-py3-none-any.whl", hash = "sha256:68e5f26a1ad57ded6d1cfb85331d1c1a195314756471d97758c48498bb4dcdf5"}, + {file = "charset_normalizer-3.4.9.tar.gz", hash = "sha256:673611bbd43f0810bec0b0f028ddeaaa501190339cac411f347ac76917c3ae7b"}, ] [[package]] @@ -190,118 +154,103 @@ files = [ [[package]] name = "coverage" -version = "7.14.1" +version = "7.15.0" description = "Code coverage measurement for Python" optional = false python-versions = ">=3.10" groups = ["test"] files = [ - {file = "coverage-7.14.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:3e3680291c4a1d0dadfa84a2c459576a4af5133abb617905714339a0c73138cf"}, - {file = "coverage-7.14.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a5274669f37f2343635a347b91a60777621341ab3378e9c6ac9335eee704bddf"}, - {file = "coverage-7.14.1-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:cfe5a5fec635799ef33428f1e5e61bafa45a92a96190ba731561ba558ccc214d"}, - {file = "coverage-7.14.1-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:62a9f70b52e0b5a95cfef4a5c5641b06983cadc5e538a3feeb5c00211f523ac2"}, - {file = "coverage-7.14.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3c18ebc343e15be53049b3a2dce38fe82d58f37e20ab9094b3a39c0aa4f6bb47"}, - {file = "coverage-7.14.1-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b84ffdf877644e7096aa936991efeed873f7f3df57b9cd001312b7668ab08550"}, - {file = "coverage-7.14.1-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e854312c4103f2ad4c0dc023b69b77ebfd2c89db5f86c4c94dc2353f9a92167e"}, - {file = "coverage-7.14.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:c643734307300234fafa36bf2a040a7235f8f177ea1fd6ec1423aea6fb7b929f"}, - {file = "coverage-7.14.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:84ac9499e48700399a5dd0ea7085b5091961fec52c68d66b4ec0d3cf7f4441b1"}, - {file = "coverage-7.14.1-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:7f02d09f70776579b926d889a4c9c235070a1f47c40458aeaca563fae5acfdb5"}, - {file = "coverage-7.14.1-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:ce66d8e46da2bb5ee313a745cbd2e391d319176c1f7a9451bfcd3a2fb920859b"}, - {file = "coverage-7.14.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:c912c259304cfb5ee584481cfb7ce1ff932b4d61e6c9140b8f19cb7b5ed82332"}, - {file = "coverage-7.14.1-cp310-cp310-win32.whl", hash = "sha256:1238cb94638e610e972c60dac68e813f868dc7d6e982535270558443058d9d59"}, - {file = "coverage-7.14.1-cp310-cp310-win_amd64.whl", hash = "sha256:fc459e5d73be2d6332fcfe8dbf3d8994671fe33c700f4565988ecfa511547253"}, - {file = "coverage-7.14.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:478b5bcd63c2e1357c5c7e16c070690df7b07f676b1c114d7b93e533c664309f"}, - {file = "coverage-7.14.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a24a81f9715ee42ef59a316cc11611c98fe23920f7c81861315c9f3ff4a230f4"}, - {file = "coverage-7.14.1-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:196a13319ad88d6d8ef5ab489ec4f44ddde2143c0c7d5b27786f6c3ffd56a7e1"}, - {file = "coverage-7.14.1-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:3d452fd08b5c72c5167c93e6867b5c08500bd40f2a21e1e854a500550b6cc36f"}, - {file = "coverage-7.14.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:23bf7fa51ac02e07fc7c96849b82946da47ae862dc8f86d183b2a4864fc38129"}, - {file = "coverage-7.14.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:bcaa50684dcaadfa599ac48f81103c756d791cfd85c97203d2217c593d48b860"}, - {file = "coverage-7.14.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4ea1c034f95c9b056e856b794630b17f9fa3d57e4800ff1e503d3be0f9c9078c"}, - {file = "coverage-7.14.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c7e057326434e441306226fbeb5d1aaf14a2637efe97ba668306635835f32ad7"}, - {file = "coverage-7.14.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:59baf88468dbc8d63b1887afd92bda52e40bb1561696e5819670601403810cec"}, - {file = "coverage-7.14.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:d34d75f892b3ab73ba11cab5442cce7b3e168fd64162b16f0e1e0d09c508edef"}, - {file = "coverage-7.14.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:3a56abc20a472baf0304c455721bc601477440d28ecfde8a03dde79ede07e0df"}, - {file = "coverage-7.14.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:6a3cb83d1552c0cd1b4906655b6a33fd4a8473229633a901c6b73bf86914dee9"}, - {file = "coverage-7.14.1-cp311-cp311-win32.whl", hash = "sha256:10274a1fbeb8ec5d72966e17bb198a3104257aca4ac09d98667c5f8aca8c8548"}, - {file = "coverage-7.14.1-cp311-cp311-win_amd64.whl", hash = "sha256:87ebdf787d4888e3f3f2d523eadc6e18c6d18c6d0eb173801a189641627fb37e"}, - {file = "coverage-7.14.1-cp311-cp311-win_arm64.whl", hash = "sha256:dd34767fa19848d35659ffc0a75314f58c7af3f1cd87ec521e8292a1238398a3"}, - {file = "coverage-7.14.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a06c76364a9360e33d6d23769aefdf7f66f38e2ffb60ceb1baaa4989d83b695c"}, - {file = "coverage-7.14.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fad54e871165f6ec2f536063ac74c3104508a12963e64072ba44bd822de52b0c"}, - {file = "coverage-7.14.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:84b535f00655ecafe1d929d1fb00ed5d6fa3051ea643ab2c161a3887b86f294b"}, - {file = "coverage-7.14.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6b6b0853b895fe0e98cbfc580d1ec3393d9302b4b1e96a77b3f5c91fdab899e6"}, - {file = "coverage-7.14.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:442cc9c952b2df400cda54bb04ab87330cf2cd08a8692cbbea36773531eb6f37"}, - {file = "coverage-7.14.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8270544c361ed405a27a060dbc9ed2c124b084d96dfdc2d9a2510482aef981ad"}, - {file = "coverage-7.14.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:48b283b1dd6372e8de2a7a9a4c4d5dc06f4d4fd209b876f3c88a7a205a0c8f84"}, - {file = "coverage-7.14.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5b0c99ba93a07d56f6df340bb79be53202a082b2fdb81bfe6190b741a3470d54"}, - {file = "coverage-7.14.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:e471bc5769ff073b058cfadb0d736b56ce067c8560eabeb0da88462df98c23e7"}, - {file = "coverage-7.14.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:f497a1ea81d4cd7c10ddcaa685135b9aabd291af3d55775a9ddf3cb7a364cdd9"}, - {file = "coverage-7.14.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:2222be86d0b54f5dd5a38f45f17f315f737245e857bf0bdedc70734f84a13c02"}, - {file = "coverage-7.14.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:85e85586565842f6932abebd4c18bcb1074223dc0b3576e7d173ca710622813a"}, - {file = "coverage-7.14.1-cp312-cp312-win32.whl", hash = "sha256:4a28fd227808366b196a75476dced2eb35b351d6766ba9c858dc93319e87f4f1"}, - {file = "coverage-7.14.1-cp312-cp312-win_amd64.whl", hash = "sha256:54acdb6674a4661768d7bf7db32dfb9f46ab1d764f8aba6df75ce1a6a088724e"}, - {file = "coverage-7.14.1-cp312-cp312-win_arm64.whl", hash = "sha256:99cd41ff91afd94896fea3bc002706b6ae4ce95727d06e4a0f39c0a8d8bd8b1a"}, - {file = "coverage-7.14.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:be9f2c802dcfce3f71298303aa5dad0dce440a76c52f2f60dacd8656dab78793"}, - {file = "coverage-7.14.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:6223a72fd0e4c7156353ec0f08a5f93623e1d3034d0e2683b9bb8ea674131b1d"}, - {file = "coverage-7.14.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:7279d2110a28cebc738b6459ecda2771735a4c18465fbbd36b3288fe5ed92247"}, - {file = "coverage-7.14.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9eeb3fcbc13ba40dfbdb22d01d196a28e9cef9ed4c29b60061a1e0e823a9929d"}, - {file = "coverage-7.14.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f0cfc27c539f07cf5c0a4cfe211d0b6cae039f8f40526dbaa71944e64b50a7b"}, - {file = "coverage-7.14.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:221c70f316241a78e77e607c227cefc8808d4e08f28d99c04f35694690e940be"}, - {file = "coverage-7.14.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:da028256b04ec30e5e0114b6f76172938c313991f0a2d3d894271315cf5d5e43"}, - {file = "coverage-7.14.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:76a085d7005236a767e3426148b2c407e53ad61695c562f8a81da2d373324901"}, - {file = "coverage-7.14.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:b553d04b5e778a8e56d57eb134aff42a92718ecba45e79c4764ecfa40efd92ff"}, - {file = "coverage-7.14.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:46f714d2fb8ae2f4f29f23ada7f1e79b759fff5a70f94a1dac23af204c3ec9e4"}, - {file = "coverage-7.14.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:1896f5e19ff3f0431c7ce2172adc54890fd97f86b59ced8ca1649145d9ffe35d"}, - {file = "coverage-7.14.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:62fd185ef9df3c33d1c8178c5af105f762afbad96038de9a4ae100aa6297ca33"}, - {file = "coverage-7.14.1-cp313-cp313-win32.whl", hash = "sha256:ab4af6352741a604c431c6072fce5bee33bf0f20dc7a56618d6bf6bb89e9810c"}, - {file = "coverage-7.14.1-cp313-cp313-win_amd64.whl", hash = "sha256:7af486dabe8954d03b087f0021540897afe084f04e16ff5579e08cc46f871416"}, - {file = "coverage-7.14.1-cp313-cp313-win_arm64.whl", hash = "sha256:2224f89ffd0c5605ccce1ed7a584da162bc7c55f601ab1c946bc9de31a486b42"}, - {file = "coverage-7.14.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:de286598cc65d2b489411174b1faec2f5a7775fb3201fd925db2a76b4030f37d"}, - {file = "coverage-7.14.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:042c46ded7c288aeb07cf14a28b6c1e10b78fcba40171c3fa1e939377eeef0b5"}, - {file = "coverage-7.14.1-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:f4ddbe407477f04c45115d1a4e5bc480f753553b534d338d4c3358b1cdd0ea52"}, - {file = "coverage-7.14.1-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:d13e6725992e2d2fd7d81d4f5241952d13740121dfd501da09201be39b2c003a"}, - {file = "coverage-7.14.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f747dc8edcfe740130f28f32f3995e955494285717e86ee25af51db2219df08a"}, - {file = "coverage-7.14.1-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ced2f09ef276fd58611a1ef502164ad266d2b75174e5a40cabbdb4033f9f6cf2"}, - {file = "coverage-7.14.1-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b84800013769a78ccb9ef4659402e26d06867e337b61ec365f77ad008adea80e"}, - {file = "coverage-7.14.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:ea8cd6ca0ee9f616aaef3afc6882e32c2cbf18b00d96313ffd76af650574034d"}, - {file = "coverage-7.14.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:aa5e304a873fabddc11e484e9b6b738bd38bd7bed17b09aa84eecf5332e8b8bb"}, - {file = "coverage-7.14.1-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:5a1c5215be81035e629d5bc756650634d0bf31991038db7a0eccb90f025ce16d"}, - {file = "coverage-7.14.1-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:79058c47dae6788504b5effb319961bcd72d7240551464b91d474bc0ed186d69"}, - {file = "coverage-7.14.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:370c5afae3fa0658e11694a32b24c2778f6bc2d17718121f94ee185e69f26b54"}, - {file = "coverage-7.14.1-cp313-cp313t-win32.whl", hash = "sha256:3758dd0a7f1fa57365ef2e781df0f0731d38b6e3772259d13dae4bd8a958d4b1"}, - {file = "coverage-7.14.1-cp313-cp313t-win_amd64.whl", hash = "sha256:6ff665fb023a77386fe11685190cee1f60a7d635994a30d9b0a061533d470fce"}, - {file = "coverage-7.14.1-cp313-cp313t-win_arm64.whl", hash = "sha256:17a5a241e5997621a956a7f402a7433ef4221e5152809b785bec79e2323799f1"}, - {file = "coverage-7.14.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d5ed429d0b8edaac649e889b4ffcedb6c80b06629a3f93050e3dddfb99235bee"}, - {file = "coverage-7.14.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:8011224a62280e50dab346960c03cf47aca1a1e09e608c0fb33fd6e0cc8e9500"}, - {file = "coverage-7.14.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:12c42ec1e14f553c4f817e989365982e646e27211f10a0f717855b94a79c8906"}, - {file = "coverage-7.14.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:06144cd511cf2624873a035c5069cf297144f6e77a73ee3d7a55b605ec5efb42"}, - {file = "coverage-7.14.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a311d8e1da24be5c1ccf85cbfb06315dbaa1703d5a1eab3f6432c72b837917c8"}, - {file = "coverage-7.14.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c79cead5b5bc584d9c71451cb984d0e3a84e0c0937379c8efcbf27c8d661b851"}, - {file = "coverage-7.14.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:dcbf65f1f66a26cdd88c35cf68fb4729c5d1cd2e88added72420541dfb212034"}, - {file = "coverage-7.14.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fd86572566fb40189a8260446158235159bc7a82dfbc87a3b39cf4fb57fcec1c"}, - {file = "coverage-7.14.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:7771b601718fdde84832c3a434ca9bbf4ae9adbc49d84198b4110700c3c77c36"}, - {file = "coverage-7.14.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:39b21e212c55af06fa375e3dbf90a8a8e38792f3a910c580066d23563830ddd5"}, - {file = "coverage-7.14.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:f2302660e32562a532b442480121aef8aa61a5bdb20b30bf0adab29f10a5a4b4"}, - {file = "coverage-7.14.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:03a6f93c1ec3b7f2e77b5dbcc5573a2c21f12529a5c6bbe0f16f72303cc2fa4d"}, - {file = "coverage-7.14.1-cp314-cp314-win32.whl", hash = "sha256:8a3ce026d73290f42f08dafecbd82c193a74df280461fbf97300fec51fd133ee"}, - {file = "coverage-7.14.1-cp314-cp314-win_amd64.whl", hash = "sha256:114c95ef29302423b87d159075805f4ab973254a2638a5d7d046c94887cc87d7"}, - {file = "coverage-7.14.1-cp314-cp314-win_arm64.whl", hash = "sha256:a07891c3f4805442b31b71e84ba3cf29ed1aa9a428284e06deeb4b23e5b46343"}, - {file = "coverage-7.14.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:1101a5ebb083aecb625ebb6209d4105b58f647b093cb2dc8122d7b33f743cfe1"}, - {file = "coverage-7.14.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:851b9e1e4e8a4608e77c79714b2e77c0970d2ed7202a05e92ae407817481887b"}, - {file = "coverage-7.14.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d5b89cdfb2ee051b71e8c3c70bd81a9eff81100f736a269136fe1a68efe00474"}, - {file = "coverage-7.14.1-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0177614a0370f227888b4e436a7c55686d6a9f90eb1ade2b624ba685a1686e86"}, - {file = "coverage-7.14.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2d69af5dea2de76fc485a83032a630523f985198b7e25be901ec60181587b01e"}, - {file = "coverage-7.14.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:35ab22d91de736e8966b980dc355cbcdd2c6dbbcfe275f9a2991bc8a91b3df65"}, - {file = "coverage-7.14.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:357d4e32935c36588aaba057d734fa32428c360c9fc2e4442afbf1b646beee6e"}, - {file = "coverage-7.14.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:51bd64741cc6fa065abd300ede1afe5a5291ece9c31da8b24884deda48bcc3f8"}, - {file = "coverage-7.14.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:9132cd363a68a4c3daa7c8704a654b1e39d3360f6f5b8ddd470608a945236c07"}, - {file = "coverage-7.14.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:07c6290b1697b862c0478eab545eec949a0d0e4d6d03497f446d706da3b4f2de"}, - {file = "coverage-7.14.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:5ea0c297e27133853b4d8a3eb799bff5a2dbd9f2f41537a240d337ac9b4df890"}, - {file = "coverage-7.14.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:01b7733daad0237daa01ef80fe2dfceffc911e6a17fa7b55d14aa8214eaaaecd"}, - {file = "coverage-7.14.1-cp314-cp314t-win32.whl", hash = "sha256:6adc5a36984624a70bf11d7184e20fa0a49aa7c47ffab43804106a1a695ea22e"}, - {file = "coverage-7.14.1-cp314-cp314t-win_amd64.whl", hash = "sha256:ddf799247318f34dbcd2efa8c95a8d0642674e926bb1774cf9b63dfd2a389d1c"}, - {file = "coverage-7.14.1-cp314-cp314t-win_arm64.whl", hash = "sha256:145986fe66647eb489f18d9a997567a3fd358584c4b5a808769113abc07466af"}, - {file = "coverage-7.14.1-py3-none-any.whl", hash = "sha256:a252f21c27e38347e60111a3266b03827422a7d5525951aceee313aa68bab1d2"}, - {file = "coverage-7.14.1.tar.gz", hash = "sha256:30c08f7d90415aa98b3c990385dea2939b0da55f38515e5b369b83655f8523be"}, + {file = "coverage-7.15.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:50913d4bf5ddafa6ca3693da5e4dd833dd1b772e0283c99ca7f7d287db67331a"}, + {file = "coverage-7.15.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:359e141ccd33893ce3f1ad5525f8b96083003677c82182e5907d62d4ea5799fc"}, + {file = "coverage-7.15.0-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3200b6204935f928c64b2ca1f923ab8c1acb7c9de45ec61569711b34d25cccaf"}, + {file = "coverage-7.15.0-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:be616bf61346883b2cfdc5178669647e03531d81ab761a7e378558b7e8bcb628"}, + {file = "coverage-7.15.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cc7bafc3fe1059463a8fdd97ca79972d6e2bf819d775c7d54991b5b1971201d6"}, + {file = "coverage-7.15.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b713aa7fcf325a01d4184d848acb46fd84f78fdb0978470c636b23a06a753d91"}, + {file = "coverage-7.15.0-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e38e6fba2d56652fdfaf0231f8f78aeb805234a912de25dc291ee5cce5b8faa4"}, + {file = "coverage-7.15.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:884499f42e382675be80770391983b90e0c0c774d87dbeeebf5f991cf6612b20"}, + {file = "coverage-7.15.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:840481b12e083dbcbafab14794a8781a958edf327c8d3d70b4eee42f9b8253aa"}, + {file = "coverage-7.15.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:276646e9481703d09f854f3b2f018f24e19fd7049ae670a92570043eb97203b1"}, + {file = "coverage-7.15.0-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:4de4b4d3f5545aa6c60dc4efd9c63b5b5dcc3bf00fe83146b2bdfffb8f6613bd"}, + {file = "coverage-7.15.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5c504097b2a89b1e85bc6070d920df77daec701337e3aeef2c17775a5dd0ca90"}, + {file = "coverage-7.15.0-cp310-cp310-win32.whl", hash = "sha256:f6e80ed91f98316e86b9c137206b04b2bcfbffccbdff49bd2eb09dddb1cf14e0"}, + {file = "coverage-7.15.0-cp310-cp310-win_amd64.whl", hash = "sha256:b3b3e22030f3f6f5e01a5ce69936552a5c0f6992b7698777377b99041961031f"}, + {file = "coverage-7.15.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:003fff99412ea848c0aaebcc78ed2b6ce7d8a1227ed17e68470672770b78a02a"}, + {file = "coverage-7.15.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5cbd804bf2784ce7b45114516050f346ecd50f960c4bb630a7ee9e1d78fa2118"}, + {file = "coverage-7.15.0-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:8773e15c23305b58882a4611fb9b2755977eae0dc2e515366a1b6c98866cc4c2"}, + {file = "coverage-7.15.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f50e40081494c1dc4239ebb202014cbcc3306ea96fb6302a34c8cc0967fc5ae8"}, + {file = "coverage-7.15.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:daf96f37f5fc3a7b6c6da862eb4aee61c426bd63da236ed4a73ef0e503b4bca5"}, + {file = "coverage-7.15.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:51aa20f6ae2788fd197747766edf4cd8234fd9423309b934257fa6b21a592723"}, + {file = "coverage-7.15.0-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:03d1f922757662eb7af586e77834792274cff776bc7b1d1a0b66a49ea9d84735"}, + {file = "coverage-7.15.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a6d6acc9a7666245e6133dd15144ca038a85a9cd5026bb06d6bbae9e77440dc9"}, + {file = "coverage-7.15.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:1ac2c4c27c7df851dc9a017c2d7de00b69147e84ba3d96f37a530b0b6fb51035"}, + {file = "coverage-7.15.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:b761a1d504fd4bd1f20f418753964dca9f5862a511fc854dac58296b3b223671"}, + {file = "coverage-7.15.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:e43b045e11c16e897895758ae90e4a90cf99e93d58549e2f90c0e2272e155695"}, + {file = "coverage-7.15.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:589b54513e901739f4b4582c705ce96b80c96f57641b1464607e2367a270e540"}, + {file = "coverage-7.15.0-cp311-cp311-win32.whl", hash = "sha256:106781b8482749162d0b47056937ba0933508e5d9447f65a5e7d5c422f0d6bb4"}, + {file = "coverage-7.15.0-cp311-cp311-win_amd64.whl", hash = "sha256:821e92b3631d762a339695824cadbbc73020354eba2a23a551a99ad34938fbe6"}, + {file = "coverage-7.15.0-cp311-cp311-win_arm64.whl", hash = "sha256:309990eb5fb8014b9f67cb211f7fd41876ec8a88a88d3ae76de0ed1d611e3640"}, + {file = "coverage-7.15.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b75ee5e8cb7575636ac598719b4307ac529ec8fcd79608a35c3cd4d4dada812d"}, + {file = "coverage-7.15.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ffb31267816b93b075302248cc1737506081b4f163df4401e9df1a6424aafabe"}, + {file = "coverage-7.15.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:e4d0bb73455bf97ab243a8f12c37c686ccf1c13bb614b7b85f1d062f06f42b2c"}, + {file = "coverage-7.15.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:20d9ccc4ebd0edc434d86dfd2a1dd2a8efa6b6b3073d0485a394fee86459ebb4"}, + {file = "coverage-7.15.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:20c8a976c365c8cb12f0cbd099508772ea41fb5fa80657a8506df0e11bd278c5"}, + {file = "coverage-7.15.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f948fd5ba1b9cbca91f0ae08b4c1ce2b139509149a435e2585d056d57d70bf01"}, + {file = "coverage-7.15.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f58185f06edf6ad68ec9fb155d63ef650c82f3fbd7e1770e2867751fb13158f4"}, + {file = "coverage-7.15.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:02adc79a920c73c647c5d117f55747df7f2de94571884758ce8bc58e04f0a796"}, + {file = "coverage-7.15.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:6eb7c300fbed667fd6e3588eba71c1904cdb06110ca6fdf908c26bdd88b8e382"}, + {file = "coverage-7.15.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:b5fb23fa2de9dce1f5c36c09066d8fcda16cd96e8e26686caa2d7cb9b567d65c"}, + {file = "coverage-7.15.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:cec79341dbe6281484024979976d0c7f22beae08b4a254655decd25d42cbe766"}, + {file = "coverage-7.15.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6c664c5444b1d970b1b2a450e21fb19ee5c9cfdf151ded2dda37260031cca0da"}, + {file = "coverage-7.15.0-cp312-cp312-win32.whl", hash = "sha256:5f764a3fa339bde6b3aa97657f5a6a3a9451e4a5b4ea98a2892c773a43525f77"}, + {file = "coverage-7.15.0-cp312-cp312-win_amd64.whl", hash = "sha256:52f9a4d2c4c56c8848bc2f524916698354b0211488b38c49ad9ae54f6cafbff6"}, + {file = "coverage-7.15.0-cp312-cp312-win_arm64.whl", hash = "sha256:31e5c3e70c85307ea35a12964e2e40f56ca2ee4b1c8c721ccf4609d17071080b"}, + {file = "coverage-7.15.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5be4caf3b28836f078abe700f8944dac4a65d78f16d6c600c89cb624e5535782"}, + {file = "coverage-7.15.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:dd58ad1404704303ca8d4f4b8a1095e7cbc7040ef17a66df1e6619aa10176430"}, + {file = "coverage-7.15.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:bbcbb317c2e5ded5b21104af81c29f391be2af98d065693ffbe8d23949b948e5"}, + {file = "coverage-7.15.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:27f31ecb458da3f859aab3f15ada871eb7a7768807d88df4a9f186bb17737970"}, + {file = "coverage-7.15.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:13fb759be317fdc62e0f56bffdf61cfcb45c7761ad6b71e3e583e71a67ae753c"}, + {file = "coverage-7.15.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d5cf007add5ab4bb8fa9f4c77e3732127c9e6cad501d7db43355fbfafca0be84"}, + {file = "coverage-7.15.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cc78d9843bd576fbe2118248258d485e968dc535f95ed504a7b0867ba9b51389"}, + {file = "coverage-7.15.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a263060f1de0b4b74b4e089c2a70b8003b3781c733329a9c8fd54995328f9950"}, + {file = "coverage-7.15.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:c48decf16e0dfd5b049c7d5e82200c23c08126719142998d4f172444e3d0529e"}, + {file = "coverage-7.15.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:08fb028000ed0aaa0a4cbdfbb98be7cb42f370db973fbbb469733505ab20e13e"}, + {file = "coverage-7.15.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:fb7dc0c3b7d8a1077abea0b8546ebc5e26d6ef6ecefc2f0f5ad2b8a53bdad837"}, + {file = "coverage-7.15.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6cb3602054ccbe9f0d8c2dc04bbeba90d5719236e2cd06e042ddd6d3fc7b6e37"}, + {file = "coverage-7.15.0-cp313-cp313-win32.whl", hash = "sha256:0bf781da64326b677be344df505171435b6f58716108606621d5d27d964fff8b"}, + {file = "coverage-7.15.0-cp313-cp313-win_amd64.whl", hash = "sha256:2c57a275078ee3fa185f83e400f765bc764a549de66d99b47881645cbd4ea629"}, + {file = "coverage-7.15.0-cp313-cp313-win_arm64.whl", hash = "sha256:3812c61afc6685c7999b39320779ab8f43b7a3081fdb0def39976e56fbdb9a21"}, + {file = "coverage-7.15.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:41cb79af843222e11da87127ad0ecbfa878abadd0f770a4a99391a27d3887324"}, + {file = "coverage-7.15.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:7d2008989ef8fe54188d3f3bfa2e3099b025af11e90a6a1b9e7dc433d04263d8"}, + {file = "coverage-7.15.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:769e8ece11a596315ebf5aa7ec383aeeed016c091d2bf6363ffb996d41529092"}, + {file = "coverage-7.15.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:65a6b6164ee5c39e2f3803f314292d6c61a607ba7fee253d1e03c42dc3903502"}, + {file = "coverage-7.15.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:75128817f95a5c45bb01d65fd2d8b9cb54bbe03d81608fb70e3e14b437ad56c2"}, + {file = "coverage-7.15.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9887bb428fe2d4cd4bee89bac1a6c9932f484afd5b36fbd4ff6ea5f825bb1f5e"}, + {file = "coverage-7.15.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0bfc0be1f702042207a93a00523b1065ee1fe951e96edf311581c0bbc2e34888"}, + {file = "coverage-7.15.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f64627d55def5a43282d70e08396672692f77e4da610a5bb8bb4060b432b6859"}, + {file = "coverage-7.15.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:2c6f0fa473003905c6d5bac328ee4eba9fbea654f15bc24b8a3274b23363fa99"}, + {file = "coverage-7.15.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:2bcf9afaf064172c6ec3c58a325a9957ad1178c05dd934e25f253321776e0676"}, + {file = "coverage-7.15.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:baf06bc987115d6fb938d403f7eab684a057766c490367999a2b71a6883110c6"}, + {file = "coverage-7.15.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f0405f2ff97b1c4c0e782cb32e02f32369bcf2e6b618b591d67e1ea754575dfe"}, + {file = "coverage-7.15.0-cp314-cp314-win32.whl", hash = "sha256:ab282853ed5fbd64bbb162f19cb8fcb7087187508a6374b4f9c34ec1577c4e8f"}, + {file = "coverage-7.15.0-cp314-cp314-win_amd64.whl", hash = "sha256:3bb3040e9f4bbe26fcb0cd7cc85ac63e630d3f3a9c74f027abf4caa27e706663"}, + {file = "coverage-7.15.0-cp314-cp314-win_arm64.whl", hash = "sha256:346771144d34f7fa84ec28386f78e0f31653f33cf35e19d253d5b35f9e8201da"}, + {file = "coverage-7.15.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d34a010905fb6401324ba016b5da03d574967f7b21ce48ea41e66f0f1f95f641"}, + {file = "coverage-7.15.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:bb25d825d885ca8036795dacfc3924d33091fc76d71ebc99420c6b79e77d96fa"}, + {file = "coverage-7.15.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:94c9686bfe8a9a6810297aecbd99beaa3445f9e8dc2f80b1382cca0d86b64461"}, + {file = "coverage-7.15.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9bd671c25f9d85f09d7ec481d0e43d5139f486c06a37139847a7ce569788af72"}, + {file = "coverage-7.15.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:110cbdf8d2e216577312cf06ccf85539c0e5a5420ef747e4a4719b5e483c88cd"}, + {file = "coverage-7.15.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2c5d4619214f1d9993e7b00a8600d14614b7e9d84e89507460b126aa5e6559e5"}, + {file = "coverage-7.15.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:781a704516e2d8346fbbd5be6c6f3412dd824785146528b3a01816f26c081007"}, + {file = "coverage-7.15.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bd4a1b44bcb65ee29e947ac92bbee04956df3a6bfc6143641bb6cae7ede00fc9"}, + {file = "coverage-7.15.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:0e4950c9d6d3e39c64c991814ff315e2d0b9cb8152363594212c9e55208c0a8f"}, + {file = "coverage-7.15.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:fe9c87ff42e5472d80d21704972e1f96e104a0a599d77c5e35db5a3c562e2571"}, + {file = "coverage-7.15.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f00d5ae1dd2fe13fb8186e3e7d37bcbd8b25c0d764ff7d1b32cef9be058510a8"}, + {file = "coverage-7.15.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:363ab38cc78b615f11c9cac3cf1d7eef950c18b9fdedfb9066f59461dcf84d68"}, + {file = "coverage-7.15.0-cp314-cp314t-win32.whl", hash = "sha256:54fd9c53a5fafff509195f1b6a3f9be615d8e8362a3629ff1de23d270c03c86b"}, + {file = "coverage-7.15.0-cp314-cp314t-win_amd64.whl", hash = "sha256:87b47553097ba185ed964866078e7e63adea9f5f51b5f39691c34f30afd21080"}, + {file = "coverage-7.15.0-cp314-cp314t-win_arm64.whl", hash = "sha256:aeefb2dd178fe7eee79f0ad25d75855cb35ee9ed472db2c5ea06f5b4fd00cec5"}, + {file = "coverage-7.15.0-py3-none-any.whl", hash = "sha256:56da6a4cbe8f7e9e80bd072ca9cefe67d7106a440a7ec06519ec6507ac94ad19"}, + {file = "coverage-7.15.0.tar.gz", hash = "sha256:9ac3fe7a1435986463eaa8ee253ae2f2a268709ba4ae5c7dd1f52a05391ad78f"}, ] [package.extras] @@ -333,26 +282,26 @@ test = ["pytest (>=8.3.0,<8.4.0)", "pytest-benchmark (>=5.1.0,<5.2.0)", "pytest- [[package]] name = "distlib" -version = "0.4.0" +version = "0.4.3" description = "Distribution utilities" optional = false python-versions = "*" groups = ["dev"] files = [ - {file = "distlib-0.4.0-py2.py3-none-any.whl", hash = "sha256:9659f7d87e46584a30b5780e43ac7a2143098441670ff0a49d5f9034c54a6c16"}, - {file = "distlib-0.4.0.tar.gz", hash = "sha256:feec40075be03a04501a973d81f633735b4b69f98b05450592310c0f401a4e0d"}, + {file = "distlib-0.4.3-py2.py3-none-any.whl", hash = "sha256:4b0ce306c966eb73bc3a7b6abad017c556dadd92c44701562cd528ac7fde4d5b"}, + {file = "distlib-0.4.3.tar.gz", hash = "sha256:f152097224a0ae24be5a0f6bae1b9359af82133bce63f98a95f86cae1aede9ed"}, ] [[package]] name = "filelock" -version = "3.29.0" +version = "3.29.7" description = "A platform independent file lock." optional = false python-versions = ">=3.10" groups = ["dev"] files = [ - {file = "filelock-3.29.0-py3-none-any.whl", hash = "sha256:96f5f6344709aa1572bbf631c640e4ebeeb519e08da902c39a001882f30ac258"}, - {file = "filelock-3.29.0.tar.gz", hash = "sha256:69974355e960702e789734cb4871f884ea6fe50bd8404051a3530bc07809cf90"}, + {file = "filelock-3.29.7-py3-none-any.whl", hash = "sha256:987db6f789a3a2a59f55081801b2b3697cb97e2a736b5f1a9e99b559285fbc51"}, + {file = "filelock-3.29.7.tar.gz", hash = "sha256:5b481979797ae69e72f0b389d89a80bdd585c260c5b3f1fb9c0a5ba9bb3f195d"}, ] [[package]] @@ -372,14 +321,14 @@ license = ["ukkonen"] [[package]] name = "idna" -version = "3.16" +version = "3.18" description = "Internationalized Domain Names in Applications (IDNA)" optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "idna-3.16-py3-none-any.whl", hash = "sha256:cc246e3a3f89580c3a951b5ad298ca4638078b2cdd4f115654332b5c26daded5"}, - {file = "idna-3.16.tar.gz", hash = "sha256:d7a6da03db833450fca25d2358ac9ff06cd624577a4aea3a596d5c0f77b8e03d"}, + {file = "idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2"}, + {file = "idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848"}, ] [package.extras] @@ -442,14 +391,14 @@ files = [ [[package]] name = "platformdirs" -version = "4.9.6" +version = "4.10.0" description = "A small Python package for determining appropriate platform-specific dirs, e.g. a `user data dir`." optional = false python-versions = ">=3.10" groups = ["dev"] files = [ - {file = "platformdirs-4.9.6-py3-none-any.whl", hash = "sha256:e61adb1d5e5cb3441b4b7710bea7e4c12250ca49439228cc1021c00dcfac0917"}, - {file = "platformdirs-4.9.6.tar.gz", hash = "sha256:3bfa75b0ad0db84096ae777218481852c0ebc6c727b3168c1b9e0118e458cf0a"}, + {file = "platformdirs-4.10.0-py3-none-any.whl", hash = "sha256:fb516cdb12eb0d857d0cd85a7c57cea4d060bee4578d6cf5a14dfdf8cbf8784a"}, + {file = "platformdirs-4.10.0.tar.gz", hash = "sha256:31e761a6a0ca04faf7353ea759bdba55652be214725111e5aac52dfa29d4bef7"}, ] [[package]] @@ -751,14 +700,14 @@ testing = ["process-tests", "pytest-xdist", "virtualenv"] [[package]] name = "python-discovery" -version = "1.3.1" +version = "1.4.4" description = "Python interpreter discovery" optional = false python-versions = ">=3.8" groups = ["dev"] files = [ - {file = "python_discovery-1.3.1-py3-none-any.whl", hash = "sha256:ed188687ebb3b82c01a17cd5ac62fc94d9f6487a7f1a0f9dfe89753fec91039c"}, - {file = "python_discovery-1.3.1.tar.gz", hash = "sha256:62f6db28064c9613e7ca76cb3f00c38c839a07c31c00dfe7ed0986493d2150a6"}, + {file = "python_discovery-1.4.4-py3-none-any.whl", hash = "sha256:abebe9120b43453b68c908acfb1e72a19d1a959ed2cb620ad38fc57d08056dbe"}, + {file = "python_discovery-1.4.4.tar.gz", hash = "sha256:5cad33982d412c1f3ffb8f9ca4ea292c9680bca3942451d30b69c37fce53a4a3"}, ] [package.dependencies] @@ -919,14 +868,14 @@ files = [ [[package]] name = "typing-extensions" -version = "4.15.0" +version = "4.16.0" description = "Backported and Experimental Type Hints for Python 3.9+" optional = false python-versions = ">=3.9" groups = ["main"] files = [ - {file = "typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548"}, - {file = "typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466"}, + {file = "typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8"}, + {file = "typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5"}, ] [[package]] @@ -964,23 +913,23 @@ zstd = ["backports-zstd (>=1.0.0) ; python_version < \"3.14\""] [[package]] name = "virtualenv" -version = "21.3.3" +version = "21.6.0" description = "Virtual Python Environment builder" optional = false -python-versions = ">=3.8" +python-versions = ">=3.9" groups = ["dev"] files = [ - {file = "virtualenv-21.3.3-py3-none-any.whl", hash = "sha256:7d5987d8369e098e41406efb780a3d4ca79280097293899e351a6407ee153ab3"}, - {file = "virtualenv-21.3.3.tar.gz", hash = "sha256:f5bda277e553b1c2b3c1a8debfc30496e1288cc93ce6b7b71b3280047e317328"}, + {file = "virtualenv-21.6.0-py3-none-any.whl", hash = "sha256:bce9d097950fef9d81129b333babfb7767072850c2f1acce0ec536708401bfd1"}, + {file = "virtualenv-21.6.0.tar.gz", hash = "sha256:e18a4d750f2b64dea73e72ffde3922f3c52365fabdc8157ebd3da20d031c4734"}, ] [package.dependencies] distlib = ">=0.3.7,<1" filelock = {version = ">=3.24.2,<4", markers = "python_version >= \"3.10\""} platformdirs = ">=3.9.1,<5" -python-discovery = ">=1.3.1" +python-discovery = ">=1.4.2" [metadata] lock-version = "2.1" python-versions = ">=3.11" -content-hash = "3834715d55e7a30c87eae83f735f51328d3fc86b0324ca934084d37c11d8ab56" +content-hash = "be839f147f93f86d74e84ed19924b23b49d74c0601e5a0dce4d204394326cf57" From 935728f27235e1910208cb9aed77c851cf4a80a4 Mon Sep 17 00:00:00 2001 From: Jonas Scholl <43819845+JonasScholl@users.noreply.github.com> Date: Thu, 9 Jul 2026 14:19:50 +0200 Subject: [PATCH 12/34] test improvements --- .claude/rules/python-testing.md | 2 +- AGENTS.md | 2 +- docs/inspect-app/concepts.md | 2 +- docs/inspect-app/endpoints.md | 10 +- docs/inspect-app/implementation-plan.md | 4 +- tests/e2e/test_e2e_workflow.py | 94 ++++++++++++++++--- tests/inspect/conftest.py | 2 +- .../2025.4.9/action_schema_collector.json | 0 .../device_hydration_modules_ports.json | 0 .../fixtures}/2025.4.9/edge_skeleton.json | 0 .../2025.4.9/inspect_paths_limit5.json | 0 .../2025.4.9/lookup_inspect_edges_by_ids.json | 0 .../2025.4.9/lookup_inspect_vertex_by_id.json | 0 .../2025.4.9/nodestatus_noid_collection.json | 0 .../2025.4.9/skeleton_nodestatus_short.json | 0 .../update_topology_fail_booking.json | 0 .../2025.4.9/update_topology_fail_remove.json | 0 .../update_topology_replace_devices.json | 0 .../update_topology_replace_vertices.json | 0 .../2025.4.9/update_topology_success.json | 0 20 files changed, 94 insertions(+), 22 deletions(-) rename tests/{fixtures/inspect => inspect/fixtures}/2025.4.9/action_schema_collector.json (100%) rename tests/{fixtures/inspect => inspect/fixtures}/2025.4.9/device_hydration_modules_ports.json (100%) rename tests/{fixtures/inspect => inspect/fixtures}/2025.4.9/edge_skeleton.json (100%) rename tests/{fixtures/inspect => inspect/fixtures}/2025.4.9/inspect_paths_limit5.json (100%) rename tests/{fixtures/inspect => inspect/fixtures}/2025.4.9/lookup_inspect_edges_by_ids.json (100%) rename tests/{fixtures/inspect => inspect/fixtures}/2025.4.9/lookup_inspect_vertex_by_id.json (100%) rename tests/{fixtures/inspect => inspect/fixtures}/2025.4.9/nodestatus_noid_collection.json (100%) rename tests/{fixtures/inspect => inspect/fixtures}/2025.4.9/skeleton_nodestatus_short.json (100%) rename tests/{fixtures/inspect => inspect/fixtures}/2025.4.9/update_topology_fail_booking.json (100%) rename tests/{fixtures/inspect => inspect/fixtures}/2025.4.9/update_topology_fail_remove.json (100%) rename tests/{fixtures/inspect => inspect/fixtures}/2025.4.9/update_topology_replace_devices.json (100%) rename tests/{fixtures/inspect => inspect/fixtures}/2025.4.9/update_topology_replace_vertices.json (100%) rename tests/{fixtures/inspect => inspect/fixtures}/2025.4.9/update_topology_success.json (100%) diff --git a/.claude/rules/python-testing.md b/.claude/rules/python-testing.md index 102df3a..cf7f943 100644 --- a/.claude/rules/python-testing.md +++ b/.claude/rules/python-testing.md @@ -56,7 +56,7 @@ Default `addopts` run coverage on `src/` and exclude e2e (`-m "not e2e"`). - Mock external I/O with fake connectors and lightweight stand-ins (see `tests/inspect/test_actions.py`). - Dummy `VIPAT_*` env vars are set in `tests/conftest.py` (autouse fixture; skipped for e2e). -- Load JSON fixtures from `tests/fixtures/` using `pathlib.Path`. +- Load JSON fixtures from `tests//fixtures//` using `pathlib.Path`. - Put shared fixtures in `conftest.py` at the appropriate directory level. - Use session-scoped fixtures only when setup is expensive and reuse is intentional. diff --git a/AGENTS.md b/AGENTS.md index 23efa7a..a4c5a2b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -24,7 +24,7 @@ Rules: 2. **Apply the same rules to docs and inline examples** — a markdown code block with a real hostname is as sensitive as a JSON fixture. 3. **Preserve structure, not identity.** Keep IDs, relationships, and field shapes realistic so tests and docs remain meaningful; only replace identifying values. 4. **Review diffs for leaks.** Scan new files for hostnames, email addresses, customer abbreviations, and internal project codenames. -5. **When in doubt, generalize.** Prefer `device-a` / `port-out-1` style names already used under `tests/fixtures/`. +5. **When in doubt, generalize.** Prefer `device-a` / `port-out-1` style names already used under `tests//fixtures/`. Inspect fixtures can be anonymized with `scripts/anonymize_inspect_fixtures.py` when available. diff --git a/docs/inspect-app/concepts.md b/docs/inspect-app/concepts.md index 80cf72d..f6fb4d5 100644 --- a/docs/inspect-app/concepts.md +++ b/docs/inspect-app/concepts.md @@ -375,7 +375,7 @@ skeleton-first load strategy (see 3. **Diff against known resources.** Compare captured paths to the prefixes the package already allows. New prefixes ⇒ new Inspect resources. 4. **Save representative payloads as fixtures.** Store sanitised JSON under - `tests/fixtures/inspect//…`. These feed the offline tests in + `tests/inspect/fixtures//…`. These feed the offline tests in [ADR-0005](./decisions/0005-e2e-testing.md) and double as living documentation of the wire format. diff --git a/docs/inspect-app/endpoints.md b/docs/inspect-app/endpoints.md index b4aecc6..0d842a7 100644 --- a/docs/inspect-app/endpoints.md +++ b/docs/inspect-app/endpoints.md @@ -319,7 +319,7 @@ module keys map to child objects whose expansion follows the projection depth only after sync completes; the earlier capture was not a projection bug. `GET …/nodeStatus//**` returns the full subtree (~21 KB for one device) — fixture: -`tests/fixtures/inspect/2025.4.9/device_hydration_modules_ports.json`. +`tests/inspect/fixtures/2025.4.9/device_hydration_modules_ports.json`. ### Edge skeleton — `externalEdgesByDeviceKey` lean projection @@ -846,7 +846,7 @@ Response (keyed by requested edge id): } ``` -Fixture: `tests/fixtures/inspect/2025.4.9/lookup_inspect_edges_by_ids.json`. +Fixture: `tests/inspect/fixtures/2025.4.9/lookup_inspect_edges_by_ids.json`. ### `POST /rest/v2/actions/status/collector/lookupInspectVertexById` / `…ByIds` @@ -916,7 +916,7 @@ Response (single form; `…ByIds` nests this per id): } ``` -Fixture: `tests/fixtures/inspect/2025.4.9/lookup_inspect_vertex_by_id.json`. +Fixture: `tests/inspect/fixtures/2025.4.9/lookup_inspect_vertex_by_id.json`. The `fields` object is **exactly the accepted `replaceVertices` payload shape** (verified 2025.4.9 — see @@ -1459,7 +1459,7 @@ Failure modes (verified 2025.4.9): a bumped `_rev`; a parallel UUID-keyed document may also exist for the same edge. - `resolvable: true` has not been observed on 2025.4.9. -- Fixtures: `tests/fixtures/inspect/2025.4.9/update_topology_success.json`, +- Fixtures: `tests/inspect/fixtures/2025.4.9/update_topology_success.json`, `…/update_topology_replace_devices.json` (edit-form request, success response, and the raw-element rejection error), `…/update_topology_replace_vertices.json` (vertex edit-form request, @@ -1595,7 +1595,7 @@ references none of them; `importExport` data namespaces are empty under `status`/`config`/`experimental`). These are unregistered server stubs — no further payload probing is warranted unless a future version registers them (re-check the GET schema after upgrades). Fixture: -`tests/fixtures/inspect/2025.4.9/action_schema_collector.json`. +`tests/inspect/fixtures/2025.4.9/action_schema_collector.json`. Cross-check against the UI bundle (`/assets/index-*.js`, 2025.4.9): it references `updateTopology`, `addDevices`, `syncDevices`, `lookupSyncInfo`, diff --git a/docs/inspect-app/implementation-plan.md b/docs/inspect-app/implementation-plan.md index edf38a0..0562ee4 100644 --- a/docs/inspect-app/implementation-plan.md +++ b/docs/inspect-app/implementation-plan.md @@ -21,7 +21,7 @@ Governing decisions (all Accepted): | --- | --- | | [0001](./decisions/0001-api-paradigm.md)/[0003](./decisions/0003-websocket-subscriptions.md) | Request/response only; no WebSocket API | | [0004](./decisions/0004-async-strategy.md) | Sync public API; internal thread-pool parallelism for multi-request operations | -| [0005](./decisions/0005-e2e-testing.md) | E2E = developer-run live-server tests via `tests/.env.test`; offline fixture tests feed from `tests/fixtures/inspect//` | +| [0005](./decisions/0005-e2e-testing.md) | E2E = developer-run live-server tests via `tests/.env.test`; offline fixture tests feed from `tests//fixtures//` | | [0006](./decisions/0006-commit-write-model.md) | Commit-style writes: hybrid — direct auto-commit methods + explicit transaction context manager; success = `header.ok ∧ data.res.ok ∧ data.validation.result.ok` | | [0007](./decisions/0007-lazy-snapshot-loading.md) | Skeleton-first snapshot (devices + edges scoped queries), transparent per-entity lazy hydration, section-level lazy loads, accreting state, `load="full"` eager mode | | [0008](./decisions/0008-collector-only-endpoints.md) | Collector-only endpoints: never call `nGraphElements` / `edgesByDevice` at runtime | @@ -235,7 +235,7 @@ Add `InspectDevice.is_hydrated` / `snapshot.fetched_at(...)` for introspection. ### 6.1 Offline (runs in CI, every push) -- **Fixture contract tests** (`tests/inspect/test_models.py`): every fixture in `tests/fixtures/inspect/2025.4.9/` parses into its DTO; write-payload builders reproduce the fixture requests byte-for-byte (devices/vertices edit forms, edge form, no-op). +- **Fixture contract tests** (`tests/inspect/test_models.py`): every fixture in `tests/inspect/fixtures/2025.4.9/` parses into its DTO; write-payload builders reproduce the fixture requests byte-for-byte (devices/vertices edit forms, edge form, no-op). - **Snapshot unit tests** (`tests/inspect/test_snapshot.py`): fake fetcher returning fixture payloads — skeleton indexes, exactly-one hydration fetch per device, section laziness, preload fan-out, post-commit hooks (removal drops indexes; refresh replaces records; stale section reloads). - **Changeset unit tests** (`tests/inspect/test_changeset.py`): staging→baseline capture, intent application, conflict detection (mutated baseline → error with field diffs), `check_conflicts=False` bypass, three-flag result evaluation incl. the captured failure fixtures, affected-set derivation. - **Query tests**: encoding, 414 length guard, projection constants stability. diff --git a/tests/e2e/test_e2e_workflow.py b/tests/e2e/test_e2e_workflow.py index 2b0c40a..1b84299 100644 --- a/tests/e2e/test_e2e_workflow.py +++ b/tests/e2e/test_e2e_workflow.py @@ -4,10 +4,10 @@ order and sharing state via a class-scoped fixture. Once a step fails, the remaining steps are skipped (``@pytest.mark.incremental``, see ``conftest.py``). -The suite builds a small dedicated leaf-spine topology (4 devices, 4 bidirectional links) with -fixed ``E2E-WF-`` labels in its own map region. There is **no teardown**: the built topology -persists in VideoIPath after the run for manual inspection; the next run's session sweep removes -it. +The suite builds a full dedicated leaf-spine topology (30 devices, 40 bidirectional links — +including two parallel links) that replicates the local VideoIPath instance, with fixed +``E2E-WF-`` labels in its own map region. There is **no teardown**: the built topology persists in +VideoIPath after the run for manual inspection; the next run's session sweep removes it. Run with:: @@ -43,14 +43,84 @@ class LinkSpec(InspectFrozenModel): b: int # device index -# A miniature leaf-spine in its own map region: every leaf connects to every spine. +# A full leaf-spine in its own map region (all coordinates shifted +3000 in y so the replica never +# overlaps the live topology it mirrors). Port counts equal each device's link degree; devices with +# no links sit at the region origin. Two device pairs — (2, 15) and (5, 16) — carry parallel links, +# represented here as repeated ``LinkSpec`` entries. DEVICES = [ - DeviceSpec(name="E2E-WF-SPINE-01", x=0, y=3000, ports=2), - DeviceSpec(name="E2E-WF-SPINE-02", x=600, y=3000, ports=2), - DeviceSpec(name="E2E-WF-LEAF-01", x=0, y=3400, ports=2), - DeviceSpec(name="E2E-WF-LEAF-02", x=600, y=3400, ports=2), + DeviceSpec(name="E2E-WF-DEV-00", x=-2301, y=11141, ports=2), + DeviceSpec(name="E2E-WF-DEV-01", x=-1496, y=11082, ports=1), + DeviceSpec(name="E2E-WF-DEV-02", x=2100, y=11800, ports=11), + DeviceSpec(name="E2E-WF-DEV-03", x=3600, y=11800, ports=4), + DeviceSpec(name="E2E-WF-DEV-04", x=3000, y=11800, ports=4), + DeviceSpec(name="E2E-WF-DEV-05", x=1500, y=11800, ports=9), + DeviceSpec(name="E2E-WF-DEV-06", x=0, y=3000, ports=1), + DeviceSpec(name="E2E-WF-DEV-07", x=1600, y=12300, ports=2), + DeviceSpec(name="E2E-WF-DEV-08", x=2000, y=12300, ports=2), + DeviceSpec(name="E2E-WF-DEV-09", x=1600, y=12050, ports=1), + DeviceSpec(name="E2E-WF-DEV-10", x=-2000, y=10800, ports=3), + DeviceSpec(name="E2E-WF-DEV-11", x=0, y=3000, ports=1), + DeviceSpec(name="E2E-WF-DEV-12", x=1600, y=13300, ports=2), + DeviceSpec(name="E2E-WF-DEV-13", x=0, y=3000, ports=1), + DeviceSpec(name="E2E-WF-DEV-14", x=2000, y=12800, ports=2), + DeviceSpec(name="E2E-WF-DEV-15", x=0, y=11400, ports=7), + DeviceSpec(name="E2E-WF-DEV-16", x=0, y=10600, ports=7), + DeviceSpec(name="E2E-WF-DEV-17", x=-1800, y=11600, ports=3), + DeviceSpec(name="E2E-WF-DEV-18", x=500, y=10850, ports=2), + DeviceSpec(name="E2E-WF-DEV-19", x=-2800, y=11200, ports=2), + DeviceSpec(name="E2E-WF-DEV-20", x=3500, y=12300, ports=2), + DeviceSpec(name="E2E-WF-DEV-21", x=767, y=11434, ports=1), + DeviceSpec(name="E2E-WF-DEV-22", x=1600, y=12800, ports=2), + DeviceSpec(name="E2E-WF-DEV-23", x=2000, y=12550, ports=2), + DeviceSpec(name="E2E-WF-DEV-24", x=1600, y=12550, ports=2), + DeviceSpec(name="E2E-WF-DEV-25", x=900, y=10850, ports=2), + DeviceSpec(name="E2E-WF-DEV-26", x=0, y=3000, ports=1), + DeviceSpec(name="E2E-WF-DEV-27", x=3500, y=12050, ports=2), + DeviceSpec(name="E2E-WF-DEV-28", x=3100, y=12300, ports=2), + DeviceSpec(name="E2E-WF-DEV-29", x=1046, y=11159, ports=2), +] +LINKS = [ + LinkSpec(a=0, b=10), + LinkSpec(a=0, b=17), + LinkSpec(a=2, b=7), + LinkSpec(a=2, b=8), + LinkSpec(a=2, b=9), + LinkSpec(a=2, b=12), + LinkSpec(a=2, b=14), + LinkSpec(a=2, b=15), + LinkSpec(a=2, b=15), + LinkSpec(a=2, b=21), + LinkSpec(a=2, b=22), + LinkSpec(a=2, b=23), + LinkSpec(a=2, b=24), + LinkSpec(a=3, b=15), + LinkSpec(a=3, b=20), + LinkSpec(a=3, b=27), + LinkSpec(a=3, b=28), + LinkSpec(a=4, b=16), + LinkSpec(a=4, b=20), + LinkSpec(a=4, b=27), + LinkSpec(a=4, b=28), + LinkSpec(a=5, b=7), + LinkSpec(a=5, b=8), + LinkSpec(a=5, b=12), + LinkSpec(a=5, b=14), + LinkSpec(a=5, b=16), + LinkSpec(a=5, b=16), + LinkSpec(a=5, b=22), + LinkSpec(a=5, b=23), + LinkSpec(a=5, b=24), + LinkSpec(a=10, b=16), + LinkSpec(a=10, b=19), + LinkSpec(a=15, b=17), + LinkSpec(a=15, b=18), + LinkSpec(a=15, b=25), + LinkSpec(a=15, b=29), + LinkSpec(a=16, b=18), + LinkSpec(a=16, b=25), + LinkSpec(a=16, b=29), + LinkSpec(a=17, b=19), ] -LINKS = [LinkSpec(a=2, b=0), LinkSpec(a=2, b=1), LinkSpec(a=3, b=0), LinkSpec(a=3, b=1)] class WorkflowState(BaseModel): @@ -142,7 +212,9 @@ def test_connect_edges(self, app: VideoIPathApp, state: WorkflowState) -> None: for link in LINKS: id_a, id_b = state.device_ids[DEVICES[link.a].name], state.device_ids[DEVICES[link.b].name] pair_edges = edges_between(app, id_a, id_b) - assert len(pair_edges) == 2 + # Two directed edges per bidirectional link; a pair with parallel links has more. + parallel = sum(1 for other in LINKS if {other.a, other.b} == {link.a, link.b}) + assert len(pair_edges) == parallel * 2 state.edge_id = pair_edges[0].id def test_update_edge_weight(self, app: VideoIPathApp, state: WorkflowState) -> None: diff --git a/tests/inspect/conftest.py b/tests/inspect/conftest.py index 075cab1..c28dfec 100644 --- a/tests/inspect/conftest.py +++ b/tests/inspect/conftest.py @@ -5,7 +5,7 @@ import pytest -FIXTURE_DIR = Path(__file__).parent.parent / "fixtures" / "inspect" / "2025.4.9" +FIXTURE_DIR = Path(__file__).parent / "fixtures" / "2025.4.9" def load_fixture(name: str) -> dict: diff --git a/tests/fixtures/inspect/2025.4.9/action_schema_collector.json b/tests/inspect/fixtures/2025.4.9/action_schema_collector.json similarity index 100% rename from tests/fixtures/inspect/2025.4.9/action_schema_collector.json rename to tests/inspect/fixtures/2025.4.9/action_schema_collector.json diff --git a/tests/fixtures/inspect/2025.4.9/device_hydration_modules_ports.json b/tests/inspect/fixtures/2025.4.9/device_hydration_modules_ports.json similarity index 100% rename from tests/fixtures/inspect/2025.4.9/device_hydration_modules_ports.json rename to tests/inspect/fixtures/2025.4.9/device_hydration_modules_ports.json diff --git a/tests/fixtures/inspect/2025.4.9/edge_skeleton.json b/tests/inspect/fixtures/2025.4.9/edge_skeleton.json similarity index 100% rename from tests/fixtures/inspect/2025.4.9/edge_skeleton.json rename to tests/inspect/fixtures/2025.4.9/edge_skeleton.json diff --git a/tests/fixtures/inspect/2025.4.9/inspect_paths_limit5.json b/tests/inspect/fixtures/2025.4.9/inspect_paths_limit5.json similarity index 100% rename from tests/fixtures/inspect/2025.4.9/inspect_paths_limit5.json rename to tests/inspect/fixtures/2025.4.9/inspect_paths_limit5.json diff --git a/tests/fixtures/inspect/2025.4.9/lookup_inspect_edges_by_ids.json b/tests/inspect/fixtures/2025.4.9/lookup_inspect_edges_by_ids.json similarity index 100% rename from tests/fixtures/inspect/2025.4.9/lookup_inspect_edges_by_ids.json rename to tests/inspect/fixtures/2025.4.9/lookup_inspect_edges_by_ids.json diff --git a/tests/fixtures/inspect/2025.4.9/lookup_inspect_vertex_by_id.json b/tests/inspect/fixtures/2025.4.9/lookup_inspect_vertex_by_id.json similarity index 100% rename from tests/fixtures/inspect/2025.4.9/lookup_inspect_vertex_by_id.json rename to tests/inspect/fixtures/2025.4.9/lookup_inspect_vertex_by_id.json diff --git a/tests/fixtures/inspect/2025.4.9/nodestatus_noid_collection.json b/tests/inspect/fixtures/2025.4.9/nodestatus_noid_collection.json similarity index 100% rename from tests/fixtures/inspect/2025.4.9/nodestatus_noid_collection.json rename to tests/inspect/fixtures/2025.4.9/nodestatus_noid_collection.json diff --git a/tests/fixtures/inspect/2025.4.9/skeleton_nodestatus_short.json b/tests/inspect/fixtures/2025.4.9/skeleton_nodestatus_short.json similarity index 100% rename from tests/fixtures/inspect/2025.4.9/skeleton_nodestatus_short.json rename to tests/inspect/fixtures/2025.4.9/skeleton_nodestatus_short.json diff --git a/tests/fixtures/inspect/2025.4.9/update_topology_fail_booking.json b/tests/inspect/fixtures/2025.4.9/update_topology_fail_booking.json similarity index 100% rename from tests/fixtures/inspect/2025.4.9/update_topology_fail_booking.json rename to tests/inspect/fixtures/2025.4.9/update_topology_fail_booking.json diff --git a/tests/fixtures/inspect/2025.4.9/update_topology_fail_remove.json b/tests/inspect/fixtures/2025.4.9/update_topology_fail_remove.json similarity index 100% rename from tests/fixtures/inspect/2025.4.9/update_topology_fail_remove.json rename to tests/inspect/fixtures/2025.4.9/update_topology_fail_remove.json diff --git a/tests/fixtures/inspect/2025.4.9/update_topology_replace_devices.json b/tests/inspect/fixtures/2025.4.9/update_topology_replace_devices.json similarity index 100% rename from tests/fixtures/inspect/2025.4.9/update_topology_replace_devices.json rename to tests/inspect/fixtures/2025.4.9/update_topology_replace_devices.json diff --git a/tests/fixtures/inspect/2025.4.9/update_topology_replace_vertices.json b/tests/inspect/fixtures/2025.4.9/update_topology_replace_vertices.json similarity index 100% rename from tests/fixtures/inspect/2025.4.9/update_topology_replace_vertices.json rename to tests/inspect/fixtures/2025.4.9/update_topology_replace_vertices.json diff --git a/tests/fixtures/inspect/2025.4.9/update_topology_success.json b/tests/inspect/fixtures/2025.4.9/update_topology_success.json similarity index 100% rename from tests/fixtures/inspect/2025.4.9/update_topology_success.json rename to tests/inspect/fixtures/2025.4.9/update_topology_success.json From 9337be01799384814fd05dfa704e1b8ba5aedfed Mon Sep 17 00:00:00 2001 From: Jonas Scholl <43819845+JonasScholl@users.noreply.github.com> Date: Thu, 9 Jul 2026 15:09:14 +0200 Subject: [PATCH 13/34] minor docs adjustments --- docs/getting-started-guide/05_Inspect.md | 16 ++++++++-------- tests/e2e/test_e2e_workflow.py | 4 +--- 2 files changed, 9 insertions(+), 11 deletions(-) diff --git a/docs/getting-started-guide/05_Inspect.md b/docs/getting-started-guide/05_Inspect.md index 0f96ca4..fcc511f 100644 --- a/docs/getting-started-guide/05_Inspect.md +++ b/docs/getting-started-guide/05_Inspect.md @@ -33,29 +33,29 @@ Everything is read straight off `app.inspect`. Skeleton fields are available wit I/O: ```python -dev = app.inspect.get_device("device10") -dev = app.inspect.find_device_by_label("BORDERLEAF-26B") +device = app.inspect.get_device("device10") +device = app.inspect.find_device_by_label("BORDERLEAF-26B") -print(dev.label, dev.coordinates, dev.status, dev.sync_severity, dev.tags) +print(device.label, device.coordinates, device.status, device.sync_severity, device.tags) -for dev in app.inspect.devices: # all devices - print(dev.id, dev.label) +for device in app.inspect.devices: # all devices + print(device.id, device.label) ``` The first access to a device's **ports** hydrates that one device (a single scoped read), then serves from local state: ```python -for port in dev.ports: # triggers one hydration fetch for this device +for port in device.ports: # triggers one hydration fetch for this device print(port.label, port.vertex_id, port.status, port.tags) edge = port.edge # local edge-skeleton lookup, no I/O if edge: print("connected to", edge.to_device.label) -for edge in dev.edges: # local, no hydration +for edge in device.edges: # local, no hydration print(edge.from_port, "->", edge.to_port, edge.status) -for other in dev.linked_devices: # local graph walk +for other in device.linked_devices: # local graph walk print(other.label) for edge in app.inspect.edges: # all external edges diff --git a/tests/e2e/test_e2e_workflow.py b/tests/e2e/test_e2e_workflow.py index 1b84299..6709396 100644 --- a/tests/e2e/test_e2e_workflow.py +++ b/tests/e2e/test_e2e_workflow.py @@ -27,7 +27,6 @@ from .helpers import E2E_TAG, create_mock_device, discover_router_vertices, edges_between - pytestmark = pytest.mark.e2e @@ -43,8 +42,7 @@ class LinkSpec(InspectFrozenModel): b: int # device index -# A full leaf-spine in its own map region (all coordinates shifted +3000 in y so the replica never -# overlaps the live topology it mirrors). Port counts equal each device's link degree; devices with +# A full leaf-spine in its own map region (all coordinates shifted +3000 in y so the test data rather not conflict with other data). Port counts equal each device's link degree; devices with # no links sit at the region origin. Two device pairs — (2, 15) and (5, 16) — carry parallel links, # represented here as repeated ``LinkSpec`` entries. DEVICES = [ From d8c351f3979d6234e315b3781e0ed12de4f1ff82 Mon Sep 17 00:00:00 2001 From: Jonas Scholl <43819845+JonasScholl@users.noreply.github.com> Date: Thu, 9 Jul 2026 15:46:56 +0200 Subject: [PATCH 14/34] inspect snapshot update improvements --- .github/workflows/ci.yml | 8 +- .../0010-post-commit-snapshot-refresh.md | 17 ++ docs/inspect-app/implementation-plan.md | 12 +- .../apps/inspect/__init__.py | 6 +- .../apps/inspect/api/__init__.py | 4 + .../apps/inspect/{ => api}/inspect_api.py | 2 +- .../apps/inspect/{ => api}/queries.py | 0 .../apps/inspect/app/__init__.py | 3 + .../apps/inspect/app/actions.py | 25 ++- .../inspect/{inspect_app.py => app/app.py} | 9 +- .../apps/inspect/app/read.py | 2 +- .../apps/inspect/app/write.py | 6 +- .../apps/inspect/snapshot.py | 148 ++++++++++++++++-- .../inspect/{changeset.py => transaction.py} | 4 +- .../apps/videoipath_app.py | 2 +- tests/e2e/conftest.py | 2 +- tests/inspect/test_actions.py | 45 +++++- tests/inspect/test_api.py | 2 +- tests/inspect/test_queries.py | 2 +- tests/inspect/test_snapshot.py | 41 +++++ ...{test_changeset.py => test_transaction.py} | 4 +- 21 files changed, 292 insertions(+), 52 deletions(-) create mode 100644 src/videoipath_automation_tool/apps/inspect/api/__init__.py rename src/videoipath_automation_tool/apps/inspect/{ => api}/inspect_api.py (99%) rename src/videoipath_automation_tool/apps/inspect/{ => api}/queries.py (100%) rename src/videoipath_automation_tool/apps/inspect/{inspect_app.py => app/app.py} (89%) rename src/videoipath_automation_tool/apps/inspect/{changeset.py => transaction.py} (99%) rename tests/inspect/{test_changeset.py => test_transaction.py} (98%) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5a4e1a5..e989636 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,7 +5,7 @@ on: branches: - main tags: - - 'v*' # Trigger on version tags like v1.2.3 + - "v*" # Trigger on version tags like v1.2.3 pull_request_target: branches: - main @@ -20,7 +20,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: ["3.11", "3.12", "3.13"] + python-version: ["3.11", "3.12", "3.13", "3.14"] steps: - name: Checkout repository uses: actions/checkout@v4 @@ -37,7 +37,7 @@ jobs: run: poetry install --with dev,test - name: Run tests - run: poetry run pytest + run: poetry run test-unit # 2️⃣ Release Job (Uses Prebuilt Package) release: @@ -52,7 +52,7 @@ jobs: - name: Set up Python uses: actions/setup-python@v4 with: - python-version: "3.13" + python-version: "3.14" - name: Install Poetry run: pip install poetry diff --git a/docs/inspect-app/decisions/0010-post-commit-snapshot-refresh.md b/docs/inspect-app/decisions/0010-post-commit-snapshot-refresh.md index c970fac..90d65b3 100644 --- a/docs/inspect-app/decisions/0010-post-commit-snapshot-refresh.md +++ b/docs/inspect-app/decisions/0010-post-commit-snapshot-refresh.md @@ -84,6 +84,23 @@ result is success (`res.ok` and `validation.result.ok`): A failed commit changes nothing server-side (reject-before-apply, verified) — the snapshot is left untouched. +### Extensions + +- **Network actions** (`addDevices` / `syncDevices`) get the same automatic + update: on success they call `apply_network_refresh(device_ids)`, which + upserts the named devices (new **or** restructured) and reconciles the edge + pairs touching them. Unlike a commit, these actions do not report the exact + touched entities and can create pairs to previously-unconnected devices, so + edges are reconciled from **one** cheap edge-skeleton read scoped to pairs + touching an affected device (per-device detail stays targeted). As on the + write path, this only runs when a snapshot is already loaded. +- **Refresh is resilient, never all-or-nothing.** A scoped re-fetch that fails + (network blip) marks just that entity stale (`_stale_devices` / + `_stale_pairs`) and logs; it never propagates, so a post-write hook cannot + lose the caller's already-successful result. Stale entities are re-fetched + lazily on the next access that touches them (the ADR-0007 hydration path), + making the snapshot self-healing without a full reload. + ## Consequences - Post-commit cost is proportional to the **change set size**, not the network: diff --git a/docs/inspect-app/implementation-plan.md b/docs/inspect-app/implementation-plan.md index 0562ee4..98d70af 100644 --- a/docs/inspect-app/implementation-plan.md +++ b/docs/inspect-app/implementation-plan.md @@ -64,7 +64,7 @@ snapshot.fetched_at(entity_or_section) # freshness introspection Contract (already documented in models.md): at most one hydration fetch per entity/section; getters can raise connector errors; snapshot is not a single point in time. -### 2.3 Writes (change set, ADR-0006/0009/0010) +### 2.3 Writes (transaction, ADR-0006/0009/0010) ```python # Convenience direct writes (auto-commit, one updateTopology each): @@ -124,7 +124,7 @@ apps/inspect/ │ ├── write.py # direct writes + transaction() │ └── actions.py # add/sync devices, sync info ├── snapshot.py # InspectSnapshot: state, indexes, hydration, refresh hooks -├── changeset.py # InspectTransaction, staging entries, baselines, commit +├── transaction.py # InspectTransaction, staging entries, baselines, commit ├── domain/ # InspectDevice/Port/Edge/Service (extend existing) └── model/ # InspectApi* DTOs (extend existing: collector.py, actions.py, # update_topology.py, ngraph.py, common.py) @@ -185,7 +185,7 @@ State per ADR-0007/0010: - `_ensure_device_detail(device_id)`: no-op if FULL; else `get_device_detail`, re-parse item, replace record, rebuild that device's port index entries, drop that device's cached domain wrappers, stamp `fetched_at`. Same pattern `_ensure_section("paths")`. - **Concurrency**: a `threading.Lock` around merge operations (preload uses a pool); document snapshot as not-thread-safe for general use, but hydration merges are internally consistent. - `preload(devices=None)`: ThreadPoolExecutor (bounded, e.g. 8) over `_ensure_device_detail` (ADR-0004). -- **Post-commit hooks** (called by changeset, ADR-0010): `_apply_removals(ids)`, `_refresh_devices(ids)`, `_refresh_edge_pairs(pair_ids)`, `_mark_section_stale("paths")`. +- **Post-commit hooks** (called by transaction, ADR-0010): `_apply_removals(ids)`, `_refresh_devices(ids)`, `_refresh_edge_pairs(pair_ids)`, `_mark_section_stale("paths")`. - Keep `raw_response` only for `load="full"` snapshots; skeleton snapshots expose `raw_skeleton` parts instead. ### 4.6 Domain objects (`domain/`) @@ -202,7 +202,7 @@ Property → data-source mapping (drives which getter triggers hydration): Add `InspectDevice.is_hydrated` / `snapshot.fetched_at(...)` for introspection. Keep frozen-dataclass wrappers + snapshot back-reference pattern from the draft. -### 4.7 `changeset.py` — transaction, baselines, commit +### 4.7 `transaction.py` — transaction, baselines, commit - `_StagedEntry(kind: DEVICE|VERTEX|EDGE, id, baseline: DTO, intents: dict[field, value] | REMOVE)`. - **Staging**: first touch of an entity fetches baselines via *batched* lookups (`lookup_edges`, `lookup_vertices` accept lists; device lookup is per-id). Staging validates the entity exists (translate lookup miss → `InspectEntityNotFoundError`); vertices/devices cannot be *created* (update-only — enforced client-side with a clear message). @@ -228,7 +228,7 @@ Add `InspectDevice.is_hydrated` / `snapshot.fetched_at(...)` for introspection. 1. **M1 – Connector + queries + API layer (read)**: connector changes, `queries.py`, `inspect_api.py` read methods, DTO gap-fill; offline fixture tests green. 2. **M2 – Snapshot rework + domain (read path complete)**: hydration states, sections, preload, `from_full_response`; `app.inspect.get_snapshot()`; VideoIPathApp property; unit tests with fake fetcher (hydration counts, merge idempotency). 3. **M3 – Lookups + actions**: lookup API methods + DTOs, `get_sync_info`, `add_devices_to_topology`, `sync_devices`. -4. **M4 – Change set + writes**: `changeset.py`, direct write sugar, commit lifecycle incl. conflict check + targeted refresh; unit tests with mocked API (payload-shape assertions against the four updateTopology fixtures; conflict scenarios; refresh-hook calls). +4. **M4 – Transaction + writes**: `transaction.py`, direct write sugar, commit lifecycle incl. conflict check + targeted refresh; unit tests with mocked API (payload-shape assertions against the four updateTopology fixtures; conflict scenarios; refresh-hook calls). 5. **M5 – E2E suite** (below) + getting-started doc page (`docs/getting-started-guide/0X_Inspect.md`) + [models.md](./models.md)/README sync. ## 6. Testing @@ -237,7 +237,7 @@ Add `InspectDevice.is_hydrated` / `snapshot.fetched_at(...)` for introspection. - **Fixture contract tests** (`tests/inspect/test_models.py`): every fixture in `tests/inspect/fixtures/2025.4.9/` parses into its DTO; write-payload builders reproduce the fixture requests byte-for-byte (devices/vertices edit forms, edge form, no-op). - **Snapshot unit tests** (`tests/inspect/test_snapshot.py`): fake fetcher returning fixture payloads — skeleton indexes, exactly-one hydration fetch per device, section laziness, preload fan-out, post-commit hooks (removal drops indexes; refresh replaces records; stale section reloads). -- **Changeset unit tests** (`tests/inspect/test_changeset.py`): staging→baseline capture, intent application, conflict detection (mutated baseline → error with field diffs), `check_conflicts=False` bypass, three-flag result evaluation incl. the captured failure fixtures, affected-set derivation. +- **Transaction unit tests** (`tests/inspect/test_transaction.py`): staging→baseline capture, intent application, conflict detection (mutated baseline → error with field diffs), `check_conflicts=False` bypass, three-flag result evaluation incl. the captured failure fixtures, affected-set derivation. - **Query tests**: encoding, 414 length guard, projection constants stability. ### 6.2 E2E — live instance, topology replica (ADR-0005) diff --git a/src/videoipath_automation_tool/apps/inspect/__init__.py b/src/videoipath_automation_tool/apps/inspect/__init__.py index 57ca1c8..1969abb 100644 --- a/src/videoipath_automation_tool/apps/inspect/__init__.py +++ b/src/videoipath_automation_tool/apps/inspect/__init__.py @@ -1,9 +1,9 @@ from videoipath_automation_tool.apps.inspect.app.actions import ConflictStrategy as ConflictStrategy -from videoipath_automation_tool.apps.inspect.changeset import CommitResult as CommitResult -from videoipath_automation_tool.apps.inspect.changeset import InspectTransaction as InspectTransaction +from videoipath_automation_tool.apps.inspect.transaction import CommitResult as CommitResult +from videoipath_automation_tool.apps.inspect.transaction import InspectTransaction as InspectTransaction from videoipath_automation_tool.apps.inspect.domain import * from videoipath_automation_tool.apps.inspect.errors import * -from videoipath_automation_tool.apps.inspect.inspect_app import InspectApp as InspectApp +from videoipath_automation_tool.apps.inspect.app import InspectApp as InspectApp from videoipath_automation_tool.apps.inspect.model import * # InspectSnapshot is an internal implementation detail of InspectApp ([ADR-0007]); it is not part of diff --git a/src/videoipath_automation_tool/apps/inspect/api/__init__.py b/src/videoipath_automation_tool/apps/inspect/api/__init__.py new file mode 100644 index 0000000..2ee039a --- /dev/null +++ b/src/videoipath_automation_tool/apps/inspect/api/__init__.py @@ -0,0 +1,4 @@ +from . import queries +from .inspect_api import InspectAPI + +__all__ = ["InspectAPI", "queries"] diff --git a/src/videoipath_automation_tool/apps/inspect/inspect_api.py b/src/videoipath_automation_tool/apps/inspect/api/inspect_api.py similarity index 99% rename from src/videoipath_automation_tool/apps/inspect/inspect_api.py rename to src/videoipath_automation_tool/apps/inspect/api/inspect_api.py index 93cd286..b238f4c 100644 --- a/src/videoipath_automation_tool/apps/inspect/inspect_api.py +++ b/src/videoipath_automation_tool/apps/inspect/api/inspect_api.py @@ -10,7 +10,7 @@ import logging from typing import Any, Optional -from videoipath_automation_tool.apps.inspect import queries +from . import queries from videoipath_automation_tool.apps.inspect.model.actions import ( InspectApiAddDevicesItem, InspectApiAddDevicesRequest, diff --git a/src/videoipath_automation_tool/apps/inspect/queries.py b/src/videoipath_automation_tool/apps/inspect/api/queries.py similarity index 100% rename from src/videoipath_automation_tool/apps/inspect/queries.py rename to src/videoipath_automation_tool/apps/inspect/api/queries.py diff --git a/src/videoipath_automation_tool/apps/inspect/app/__init__.py b/src/videoipath_automation_tool/apps/inspect/app/__init__.py index e69de29..7adf215 100644 --- a/src/videoipath_automation_tool/apps/inspect/app/__init__.py +++ b/src/videoipath_automation_tool/apps/inspect/app/__init__.py @@ -0,0 +1,3 @@ +from videoipath_automation_tool.apps.inspect.app.app import InspectApp + +__all__ = ["InspectApp"] diff --git a/src/videoipath_automation_tool/apps/inspect/app/actions.py b/src/videoipath_automation_tool/apps/inspect/app/actions.py index 7464ba4..32b8bf9 100644 --- a/src/videoipath_automation_tool/apps/inspect/app/actions.py +++ b/src/videoipath_automation_tool/apps/inspect/app/actions.py @@ -2,7 +2,7 @@ These wrap the ``actions/status/network/*`` and ``lookupSyncInfo`` endpoints used by the Inspect device-onboarding-into-topology workflows. Placement and connections themselves go through the -change set ([ADR-0006]); these actions handle bringing a device's driver-reported topology into +transaction ([ADR-0006]); these actions handle bringing a device's driver-reported topology into the graph and keeping it in sync. """ @@ -10,13 +10,14 @@ import logging from enum import IntEnum -from typing import Iterable, Protocol, Union +from typing import Iterable, Optional, Protocol, Union -from videoipath_automation_tool.apps.inspect.inspect_api import InspectAPI +from videoipath_automation_tool.apps.inspect.api import InspectAPI from videoipath_automation_tool.apps.inspect.model.actions import ( InspectApiAddDevicesItem, InspectApiLookupSyncInfoItem, ) +from videoipath_automation_tool.apps.inspect.snapshot import InspectSnapshot class ConflictStrategy(IntEnum): @@ -34,11 +35,13 @@ class ConflictStrategy(IntEnum): class _HasInspectApi(Protocol): _inspect_api: InspectAPI _logger: logging.Logger + _snapshot: Optional[InspectSnapshot] class InspectActionsMixin: _inspect_api: InspectAPI _logger: logging.Logger + _snapshot: Optional[InspectSnapshot] def get_sync_info(self: _HasInspectApi, device_ids: list[str]) -> dict[str, InspectApiLookupSyncInfoItem]: """Per-device sync differences (what would be added/removed/updated on the next sync).""" @@ -59,7 +62,9 @@ def add_devices_to_topology(self: _HasInspectApi, devices: Iterable[AddDeviceSpe response = self._inspect_api.add_devices(items) if not response.data.ok: self._logger.warning(f"addDevices reported failure: {response.data.msg}") - return response.data.ok + return False + self._refresh_after_network_action([item.id for item in items]) + return True def sync_devices( self: _HasInspectApi, @@ -84,7 +89,17 @@ def sync_devices( ) if not response.data.ok: self._logger.warning(f"syncDevices reported failure: {response.data.msg}") - return response.data.ok + return False + self._refresh_after_network_action(device_ids) + return True + + def _refresh_after_network_action(self: _HasInspectApi, device_ids: list[str]) -> None: + """Update the internal snapshot for the affected devices after a successful network action. + + Only refreshes when a snapshot is already loaded, so a pure-action workflow never triggers + an unnecessary topology read (mirrors the write path; [ADR-0010]).""" + if self._snapshot is not None: + self._snapshot.apply_network_refresh(device_ids) def _to_add_item(spec: AddDeviceSpec) -> InspectApiAddDevicesItem: diff --git a/src/videoipath_automation_tool/apps/inspect/inspect_app.py b/src/videoipath_automation_tool/apps/inspect/app/app.py similarity index 89% rename from src/videoipath_automation_tool/apps/inspect/inspect_app.py rename to src/videoipath_automation_tool/apps/inspect/app/app.py index 00cc0b6..4eee4f9 100644 --- a/src/videoipath_automation_tool/apps/inspect/inspect_app.py +++ b/src/videoipath_automation_tool/apps/inspect/app/app.py @@ -9,13 +9,14 @@ import logging from typing import Optional -from videoipath_automation_tool.apps.inspect.app.actions import InspectActionsMixin -from videoipath_automation_tool.apps.inspect.app.read import InspectReadMixin, LoadMode -from videoipath_automation_tool.apps.inspect.app.write import InspectWriteMixin -from videoipath_automation_tool.apps.inspect.inspect_api import InspectAPI +from videoipath_automation_tool.apps.inspect.api import InspectAPI from videoipath_automation_tool.apps.inspect.snapshot import InspectSnapshot from videoipath_automation_tool.connector.vip_connector import VideoIPathConnector +from .actions import InspectActionsMixin +from .read import InspectReadMixin, LoadMode +from .write import InspectWriteMixin + # First VideoIPath version the Inspect collector surface was verified against. _MIN_VERIFIED_VERSION = (2025, 4) diff --git a/src/videoipath_automation_tool/apps/inspect/app/read.py b/src/videoipath_automation_tool/apps/inspect/app/read.py index 47bdac7..3cd0be2 100644 --- a/src/videoipath_automation_tool/apps/inspect/app/read.py +++ b/src/videoipath_automation_tool/apps/inspect/app/read.py @@ -12,7 +12,7 @@ from datetime import datetime from typing import TYPE_CHECKING, Literal, Optional, Protocol -from videoipath_automation_tool.apps.inspect.inspect_api import InspectAPI +from videoipath_automation_tool.apps.inspect.api import InspectAPI from videoipath_automation_tool.apps.inspect.snapshot import InspectSnapshot if TYPE_CHECKING: diff --git a/src/videoipath_automation_tool/apps/inspect/app/write.py b/src/videoipath_automation_tool/apps/inspect/app/write.py index cc39602..2cc4d4c 100644 --- a/src/videoipath_automation_tool/apps/inspect/app/write.py +++ b/src/videoipath_automation_tool/apps/inspect/app/write.py @@ -14,8 +14,8 @@ import logging from typing import Any, Optional, Protocol -from videoipath_automation_tool.apps.inspect.changeset import CommitResult, InspectTransaction -from videoipath_automation_tool.apps.inspect.inspect_api import InspectAPI +from videoipath_automation_tool.apps.inspect.transaction import CommitResult, InspectTransaction +from videoipath_automation_tool.apps.inspect.api import InspectAPI from videoipath_automation_tool.apps.inspect.snapshot import InspectSnapshot @@ -31,7 +31,7 @@ class InspectWriteMixin: _snapshot: Optional[InspectSnapshot] def transaction(self: _HasInspectState) -> InspectTransaction: - """Open a batched, atomic change set bound to the app's internal snapshot.""" + """Open a batched, atomic transaction bound to the app's internal snapshot.""" return InspectTransaction(self._inspect_api, snapshot=self._snapshot, logger=self._logger) def place_device(self: _HasInspectState, device_id: str, x: float, y: float) -> CommitResult: diff --git a/src/videoipath_automation_tool/apps/inspect/snapshot.py b/src/videoipath_automation_tool/apps/inspect/snapshot.py index 8109e87..d336fb5 100644 --- a/src/videoipath_automation_tool/apps/inspect/snapshot.py +++ b/src/videoipath_automation_tool/apps/inspect/snapshot.py @@ -7,12 +7,13 @@ carries its own fetch timestamp. ``refresh()`` builds a *new* snapshot; state is never reused across snapshots. -After a successful commit the change set calls the post-commit hooks here to update only the +After a successful commit the transaction calls the post-commit hooks here to update only the touched entities via targeted scoped re-reads ([ADR-0010]) instead of a full reload. """ from __future__ import annotations +import logging import threading from concurrent.futures import ThreadPoolExecutor from datetime import datetime, timezone @@ -38,10 +39,12 @@ from videoipath_automation_tool.apps.inspect.domain.edge import InspectEdge from videoipath_automation_tool.apps.inspect.domain.port import InspectPort from videoipath_automation_tool.apps.inspect.domain.service import InspectService - from videoipath_automation_tool.apps.inspect.inspect_api import InspectAPI + from videoipath_automation_tool.apps.inspect.api import InspectAPI _PRELOAD_WORKERS = 8 +_logger = logging.getLogger("videoipath_automation_tool_inspect_snapshot") + class HydrationLevel(str, Enum): SKELETON = "skeleton" @@ -123,6 +126,10 @@ def __init__( self._edge_cache: dict[str, "InspectEdge"] = {} self._service_cache: dict[str, "InspectService"] = {} + # Entities whose post-write re-fetch failed; re-fetched lazily on next access ([ADR-0010]). + self._stale_devices: set[str] = set() + self._stale_pairs: set[str] = set() + for node in device_items or []: self._index_device(node, device_level) for pair in edge_items or []: @@ -171,6 +178,7 @@ def is_device_hydrated(self, device_id: str) -> bool: # --- Device reads --- def get_device(self, device_id: str) -> Optional["InspectDevice"]: + self._reconcile_stale_device(device_id) if device_id not in self._devices_by_id: return None return self._wrap_device(device_id) @@ -199,6 +207,7 @@ def get_devices(self, detail: bool = False) -> list["InspectDevice"]: def get_device_record(self, device_id: str) -> Optional[_DeviceRecord]: """Internal: return the (possibly hydrated) record for a device; used by domain objects.""" + self._reconcile_stale_device(device_id) return self._devices_by_id.get(device_id) # --- Port reads (trigger hydration) --- @@ -220,6 +229,7 @@ def find_port_by_id(self, port_id: str) -> Optional["InspectPort"]: @property def edges(self) -> list["InspectEdge"]: + self._reconcile_stale_pairs() seen: set[str] = set() result: list["InspectEdge"] = [] for indexed_edges in self._edges_by_device_id.values(): @@ -234,6 +244,7 @@ def get_edges(self) -> list["InspectEdge"]: return self.edges def get_edges_for_device(self, device_id: str) -> list["InspectEdge"]: + self._reconcile_stale_pairs() seen: set[str] = set() result: list["InspectEdge"] = [] for indexed in self._edges_by_device_id.get(device_id, []): @@ -244,10 +255,12 @@ def get_edges_for_device(self, device_id: str) -> list["InspectEdge"]: return result def get_edge_for_port(self, device_id: str, port_id: str) -> Optional["InspectEdge"]: + self._reconcile_stale_pairs() indexed = self._edge_by_port_key.get((device_id, port_id)) return self._wrap_edge(indexed) if indexed else None def get_linked_devices(self, device_id: str) -> list["InspectDevice"]: + self._reconcile_stale_pairs() linked: set[str] = set() for indexed in self._edges_by_device_id.get(device_id, []): for candidate in (indexed.from_device_id, indexed.to_device_id): @@ -314,23 +327,41 @@ def apply_post_commit( mark_paths_stale: bool = True, ) -> None: """Targeted refresh after a successful commit: drop removed entities locally, re-fetch the - affected devices and edge pairs, and mark the services section stale.""" + affected devices and edge pairs, and mark the services section stale. + + Never raises: a failed re-fetch marks the entity stale (re-fetched lazily on next access) + and logs, so a post-commit hook cannot lose the caller's already-successful commit result. + """ self._apply_removals(removed_ids or []) if self._fetcher is not None: for device_id in device_ids or []: if device_id in self._devices_by_id: - self._refresh_device(device_id) + self._try_refresh_device(device_id) for pair_id in pair_ids or []: - self._refresh_edge_pair(pair_id) + self._try_refresh_edge_pair(pair_id) if mark_paths_stale: - with self._lock: - self._section_loaded["paths"] = False - self._paths_by_booking_id.clear() - self._services_by_device_id.clear() + self._mark_paths_stale() + + def apply_network_refresh(self, device_ids: list[str]) -> None: + """Targeted refresh after a network action (addDevices / syncDevices): upsert the named + devices (new or restructured) and reconcile the edge pairs touching them, then mark the + services section stale. Never raises (same contract as :meth:`apply_post_commit`). + + Unlike a commit, a network action does not report the exact touched entities and can create + pairs to previously-unconnected devices, so edges are reconciled from one cheap edge-skeleton + read scoped to pairs touching an affected device (per-device detail stays targeted).""" + if self._fetcher is None or not device_ids: + return + affected = set(device_ids) + for device_id in device_ids: + self._try_refresh_device(device_id) + self._reconcile_pairs_for_devices(affected) + self._mark_paths_stale() # --- Internal: hydration --- def _ensure_device_detail(self, device_id: str) -> None: + self._reconcile_stale_device(device_id) record = self._devices_by_id.get(device_id) if record is None or record.level is HydrationLevel.FULL or self._fetcher is None: return @@ -349,23 +380,23 @@ def _ensure_device_detail(self, device_id: str) -> None: current = self._devices_by_id.get(device_id) if current is None or current.level is HydrationLevel.FULL: return - self._devices_by_id[device_id] = _DeviceRecord(device_id=device_id, node=detail, level=HydrationLevel.FULL) - self._device_cache.pop(device_id, None) - self._rebuild_device_ports(device_id, detail) + self._upsert_device(device_id, detail) def _refresh_device(self, device_id: str) -> None: + """Re-fetch one device's full detail and upsert it (adds it if newly present). May raise.""" if self._fetcher is None: return record = self._devices_by_id.get(device_id) detail = self._fetcher.get_device_detail(record.node.id if record else device_id) if detail is None: + # Keep any existing record untouched (e.g. detail-less virtual devices); just clear stale. + self._stale_devices.discard(device_id) return with self._lock: - self._devices_by_id[device_id] = _DeviceRecord(device_id=device_id, node=detail, level=HydrationLevel.FULL) - self._device_cache.pop(device_id, None) - self._rebuild_device_ports(device_id, detail) + self._upsert_device(device_id, detail) def _refresh_edge_pair(self, pair_id: str) -> None: + """Re-fetch and re-index one external-edge device pair. May raise.""" if self._fetcher is None: return pair = self._fetcher.get_edge_pair(pair_id) @@ -373,6 +404,93 @@ def _refresh_edge_pair(self, pair_id: str) -> None: self._drop_edge_pair(pair_id) if pair is not None: self._index_edge_pair(pair) + self._stale_pairs.discard(pair_id) + + # --- Internal: resilient refresh + lazy-stale self-heal (ADR-0010) --- + + def _try_refresh_device(self, device_id: str) -> None: + """Re-fetch a device; on failure mark it stale (lazy self-heal on next access) and log.""" + try: + self._refresh_device(device_id) + except Exception as exc: + self._stale_devices.add(device_id) + _logger.warning("Inspect snapshot: post-write re-fetch of device '%s' failed: %s", device_id, exc) + + def _try_refresh_edge_pair(self, pair_id: str) -> None: + """Re-fetch an edge pair; on failure mark it stale (lazy self-heal on next access) and log.""" + try: + self._refresh_edge_pair(pair_id) + except Exception as exc: + self._stale_pairs.add(pair_id) + _logger.warning("Inspect snapshot: post-write re-fetch of edge pair '%s' failed: %s", pair_id, exc) + + def _reconcile_stale_device(self, device_id: str) -> None: + if device_id not in self._stale_devices: + return + try: + self._refresh_device(device_id) + self._stale_devices.discard(device_id) + except Exception as exc: + _logger.warning("Inspect snapshot: lazy re-fetch of stale device '%s' failed: %s", device_id, exc) + + def _reconcile_stale_pairs(self) -> None: + for pair_id in list(self._stale_pairs): + try: + self._refresh_edge_pair(pair_id) + except Exception as exc: + _logger.warning("Inspect snapshot: lazy re-fetch of stale edge pair '%s' failed: %s", pair_id, exc) + + def _reconcile_pairs_for_devices(self, device_ids: set[str]) -> None: + """Reconcile every edge pair touching an affected device from one fresh edge-skeleton read.""" + if self._fetcher is None or not device_ids: + return + try: + pairs = self._fetcher.get_edge_skeleton() + except Exception as exc: + self._stale_pairs.update( + indexed.pair_id for d in device_ids for indexed in self._edges_by_device_id.get(d, []) + ) + _logger.warning("Inspect snapshot: edge reconcile after network action failed: %s", exc) + return + with self._lock: + for pair_id in {indexed.pair_id for d in device_ids for indexed in self._edges_by_device_id.get(d, [])}: + self._drop_edge_pair(pair_id) + for pair in pairs: + if self._pair_touches(pair, device_ids): + self._drop_edge_pair(pair.id) + self._index_edge_pair(pair) + + def _pair_touches(self, pair: InspectApiExternalEdgesByDeviceKeyItem, device_ids: set[str]) -> bool: + primary = self._resolve_device_id(pair.primary.devicePid) + secondary = self._resolve_device_id(pair.secondary.devicePid) + return primary in device_ids or secondary in device_ids + + def _mark_paths_stale(self) -> None: + with self._lock: + self._section_loaded["paths"] = False + self._paths_by_booking_id.clear() + self._services_by_device_id.clear() + + def _upsert_device(self, device_id: str, detail: InspectApiNodeStatusItem) -> None: + """Insert or replace a device record (FULL), keeping the label index and caches consistent.""" + old = self._devices_by_id.get(device_id) + old_label = old.label if old is not None else None + record = _DeviceRecord(device_id=device_id, node=detail, level=HydrationLevel.FULL) + new_label = record.label + if old_label and old_label != new_label: + remaining = [d for d in self._devices_by_label.get(old_label, []) if d != device_id] + if remaining: + self._devices_by_label[old_label] = remaining + else: + self._devices_by_label.pop(old_label, None) + self._devices_by_id[device_id] = record + if new_label: + ids = self._devices_by_label.setdefault(new_label, []) + if device_id not in ids: + ids.append(device_id) + self._device_cache.pop(device_id, None) + self._rebuild_device_ports(device_id, detail) + self._stale_devices.discard(device_id) def _ensure_section_paths(self) -> None: if self._section_loaded.get("paths") or self._fetcher is None: diff --git a/src/videoipath_automation_tool/apps/inspect/changeset.py b/src/videoipath_automation_tool/apps/inspect/transaction.py similarity index 99% rename from src/videoipath_automation_tool/apps/inspect/changeset.py rename to src/videoipath_automation_tool/apps/inspect/transaction.py index 6cc52b2..ea6f9ad 100644 --- a/src/videoipath_automation_tool/apps/inspect/changeset.py +++ b/src/videoipath_automation_tool/apps/inspect/transaction.py @@ -1,4 +1,4 @@ -"""Change set / transaction for Inspect topology writes ([ADR-0006]/[ADR-0009]/[ADR-0010]). +"""Transaction for Inspect topology writes ([ADR-0006]/[ADR-0009]/[ADR-0010]). A transaction stages topology changes, captures a per-entity baseline via the lookup endpoints (the lookup forms *are* the ``updateTopology`` write shapes — [ADR-0009]), and applies them @@ -46,7 +46,7 @@ ) if TYPE_CHECKING: - from videoipath_automation_tool.apps.inspect.inspect_api import InspectAPI + from videoipath_automation_tool.apps.inspect.api import InspectAPI from videoipath_automation_tool.apps.inspect.snapshot import InspectSnapshot # Staged-entry kinds. diff --git a/src/videoipath_automation_tool/apps/videoipath_app.py b/src/videoipath_automation_tool/apps/videoipath_app.py index 1a48590..6c13121 100644 --- a/src/videoipath_automation_tool/apps/videoipath_app.py +++ b/src/videoipath_automation_tool/apps/videoipath_app.py @@ -1,7 +1,7 @@ import logging from typing import Literal, Optional -from videoipath_automation_tool.apps.inspect.inspect_app import InspectApp +from videoipath_automation_tool.apps.inspect.app import InspectApp from videoipath_automation_tool.apps.inventory import InventoryApp from videoipath_automation_tool.apps.inventory.model.drivers import AVAILABLE_SCHEMA_VERSIONS, SELECTED_SCHEMA_VERSION from videoipath_automation_tool.apps.preferences.preferences_app import PreferencesApp diff --git a/tests/e2e/conftest.py b/tests/e2e/conftest.py index 24a308c..7d94fdc 100644 --- a/tests/e2e/conftest.py +++ b/tests/e2e/conftest.py @@ -23,7 +23,7 @@ import pytest -from videoipath_automation_tool.apps.inspect.inspect_app import _MIN_VERIFIED_VERSION, _parse_version +from videoipath_automation_tool.apps.inspect.app.app import _MIN_VERIFIED_VERSION, _parse_version from videoipath_automation_tool.apps.videoipath_app import VideoIPathApp from vipat_cli_scripts.project_env import load_project_env diff --git a/tests/inspect/test_actions.py b/tests/inspect/test_actions.py index 0f4894d..ada7c58 100644 --- a/tests/inspect/test_actions.py +++ b/tests/inspect/test_actions.py @@ -5,7 +5,7 @@ import pytest from videoipath_automation_tool.apps.inspect.app.actions import ConflictStrategy, InspectActionsMixin -from videoipath_automation_tool.apps.inspect.inspect_api import InspectAPI +from videoipath_automation_tool.apps.inspect.api import InspectAPI def _ok_header(): @@ -34,12 +34,23 @@ def post(self, url_path, body, **kwargs): return SimpleNamespace(data=self._post_data, header=_ok_header()) +class _RecordingSnapshot: + """Stand-in that records apply_network_refresh calls (see snapshot.apply_network_refresh).""" + + def __init__(self): + self.network_refresh_calls = [] + + def apply_network_refresh(self, device_ids): + self.network_refresh_calls.append(list(device_ids)) + + class _App(InspectActionsMixin): - def __init__(self, post_data): + def __init__(self, post_data, snapshot=None): import logging self._logger = logging.getLogger("test") self._inspect_api = InspectAPI(SimpleNamespace(rest=FakeRest(post_data)), self._logger) + self._snapshot = snapshot def test_conflict_strategy_values(): @@ -73,3 +84,33 @@ def test_empty_lists_rejected(): app.sync_devices([]) with pytest.raises(ValueError): app.get_sync_info([]) + + +# --- Post-action snapshot refresh (ADR-0010) --- + + +def test_add_devices_refreshes_snapshot_when_loaded(): + snap = _RecordingSnapshot() + app = _App({"msg": [], "ok": True}, snapshot=snap) + assert app.add_devices_to_topology([("device12", 1, 2), "device13"]) is True + assert snap.network_refresh_calls == [["device12", "device13"]] + + +def test_sync_devices_refreshes_snapshot_when_loaded(): + snap = _RecordingSnapshot() + app = _App({"msg": [], "ok": True}, snapshot=snap) + assert app.sync_devices(["device12", "device13"]) is True + assert snap.network_refresh_calls == [["device12", "device13"]] + + +def test_network_action_without_snapshot_does_not_build_one(): + # _snapshot is None: a pure-action workflow must not trigger a topology read. + app = _App({"msg": [], "ok": True}) + assert app.add_devices_to_topology(["device12"]) is True + + +def test_failed_action_does_not_refresh(): + snap = _RecordingSnapshot() + app = _App({"msg": ["nope"], "ok": False}, snapshot=snap) + assert app.sync_devices(["device12"]) is False + assert snap.network_refresh_calls == [] diff --git a/tests/inspect/test_api.py b/tests/inspect/test_api.py index efd4b87..b3412d3 100644 --- a/tests/inspect/test_api.py +++ b/tests/inspect/test_api.py @@ -3,7 +3,7 @@ from types import SimpleNamespace -from videoipath_automation_tool.apps.inspect.inspect_api import InspectAPI +from videoipath_automation_tool.apps.inspect.api import InspectAPI from videoipath_automation_tool.apps.inspect.model.update_topology import InspectApiUpdateTopologyData diff --git a/tests/inspect/test_queries.py b/tests/inspect/test_queries.py index b0b3dd2..c21a211 100644 --- a/tests/inspect/test_queries.py +++ b/tests/inspect/test_queries.py @@ -2,7 +2,7 @@ import pytest -from videoipath_automation_tool.apps.inspect import queries +from videoipath_automation_tool.apps.inspect.api import queries from videoipath_automation_tool.apps.inspect.errors import InspectQueryTooLongError diff --git a/tests/inspect/test_snapshot.py b/tests/inspect/test_snapshot.py index d009e5d..e1e23ec 100644 --- a/tests/inspect/test_snapshot.py +++ b/tests/inspect/test_snapshot.py @@ -241,6 +241,47 @@ def test_post_commit_marks_paths_stale(snapshot): assert fetcher.section_calls == 2 +def test_post_commit_reindexes_changed_label(snapshot): + # Latent-bug guard: a committed label change must re-point the label index (ADR-0010). + snap, fetcher = snapshot + fetcher._details["leaf-a"] = _detail_node("leaf-a", "LEAF-A-RENAMED", ["leaf-a.dev.0.up1"]) + snap.apply_post_commit(device_ids=["leaf-a"], mark_paths_stale=False) + assert snap.find_device_by_label("LEAF-A-RENAMED").id == "leaf-a" + assert snap.find_device_by_label("LEAF-A") is None + + +def test_post_commit_refetch_failure_does_not_raise_and_self_heals(snapshot): + snap, fetcher = snapshot + calls = {"n": 0} + real = fetcher.get_device_detail + + def flaky(device_id): + calls["n"] += 1 + if calls["n"] == 1: + raise RuntimeError("network blip") + return real(device_id) + + fetcher.get_device_detail = flaky + # The refresh failure is swallowed (the commit result must survive); the device is marked stale. + snap.apply_post_commit(device_ids=["leaf-a"], mark_paths_stale=False) + # Next access re-fetches (self-heal) and succeeds. + assert snap.get_device("leaf-a").label == "LEAF-A" + assert calls["n"] == 2 + + +def test_apply_network_refresh_adds_new_device_and_edge(snapshot): + snap, fetcher = snapshot + fetcher._details["leaf-b"] = _detail_node("leaf-b", "LEAF-B", ["leaf-b.dev.0.up1"]) + fetcher.get_edge_skeleton = lambda: [ + _edge_pair("leaf-a", "spine-a", "leaf-a.dev.0.up1", "spine-a.dev.0.swp1"), + _edge_pair("leaf-b", "spine-a", "leaf-b.dev.0.up1", "spine-a.dev.0.swp2"), + ] + snap.apply_network_refresh(["leaf-b"]) + assert snap.get_device("leaf-b") is not None + assert snap.find_device_by_label("LEAF-B").id == "leaf-b" + assert {e.pair_id for e in snap.get_edges_for_device("leaf-b")} == {"leaf-b::spine-a"} + + def test_full_snapshot_is_hydrated_without_fetcher(): fetcher = FakeFetcher() # Build a full snapshot manually (device_level=FULL, path_items provided) diff --git a/tests/inspect/test_changeset.py b/tests/inspect/test_transaction.py similarity index 98% rename from tests/inspect/test_changeset.py rename to tests/inspect/test_transaction.py index f2f86d5..fca4e31 100644 --- a/tests/inspect/test_changeset.py +++ b/tests/inspect/test_transaction.py @@ -1,4 +1,4 @@ -"""Change-set / transaction unit tests with a fake API (offline). +"""Transaction unit tests with a fake API (offline). Covers staging + intent application, payload-shape assertions against the verified write forms, the three-flag commit result, conflict detection (compare-and-commit), the ``check_conflicts=False`` @@ -9,7 +9,7 @@ import pytest -from videoipath_automation_tool.apps.inspect.changeset import InspectTransaction +from videoipath_automation_tool.apps.inspect.transaction import InspectTransaction from videoipath_automation_tool.apps.inspect.errors import ( InspectCommitConflictError, InspectCommitError, From b81386ff0a6f63ac854f1b47f436fe7c5c5753f7 Mon Sep 17 00:00:00 2001 From: Jonas Scholl <43819845+JonasScholl@users.noreply.github.com> Date: Thu, 9 Jul 2026 16:07:49 +0200 Subject: [PATCH 15/34] code style improvements --- .../apps/inspect/__init__.py | 2 + .../apps/inspect/api/__init__.py | 2 + .../apps/inspect/api/inspect_api.py | 35 +- .../apps/inspect/api/queries.py | 76 ++--- .../apps/inspect/app/__init__.py | 2 + .../apps/inspect/app/actions.py | 6 +- .../apps/inspect/app/app.py | 15 +- .../apps/inspect/app/read.py | 40 +-- .../apps/inspect/app/write.py | 6 +- .../apps/inspect/domain/__init__.py | 2 + .../apps/inspect/domain/device.py | 12 +- .../apps/inspect/model/__init__.py | 2 + .../apps/inspect/snapshot.py | 87 ++--- .../apps/inspect/transaction.py | 93 +++--- tests/e2e/inspect/test_e2e_inspect.py | 2 +- tests/e2e/test_e2e_workflow.py | 3 +- tests/inspect/conftest.py | 8 +- tests/inspect/test_actions.py | 103 +++--- tests/inspect/test_api.py | 110 ++++--- tests/inspect/test_models.py | 80 ++--- tests/inspect/test_queries.py | 14 +- tests/inspect/test_snapshot.py | 298 +++++++++--------- tests/inspect/test_transaction.py | 243 +++++++------- 23 files changed, 665 insertions(+), 576 deletions(-) diff --git a/src/videoipath_automation_tool/apps/inspect/__init__.py b/src/videoipath_automation_tool/apps/inspect/__init__.py index 1969abb..d547716 100644 --- a/src/videoipath_automation_tool/apps/inspect/__init__.py +++ b/src/videoipath_automation_tool/apps/inspect/__init__.py @@ -1,3 +1,5 @@ +from __future__ import annotations + from videoipath_automation_tool.apps.inspect.app.actions import ConflictStrategy as ConflictStrategy from videoipath_automation_tool.apps.inspect.transaction import CommitResult as CommitResult from videoipath_automation_tool.apps.inspect.transaction import InspectTransaction as InspectTransaction diff --git a/src/videoipath_automation_tool/apps/inspect/api/__init__.py b/src/videoipath_automation_tool/apps/inspect/api/__init__.py index 2ee039a..7d5e871 100644 --- a/src/videoipath_automation_tool/apps/inspect/api/__init__.py +++ b/src/videoipath_automation_tool/apps/inspect/api/__init__.py @@ -1,3 +1,5 @@ +from __future__ import annotations + from . import queries from .inspect_api import InspectAPI diff --git a/src/videoipath_automation_tool/apps/inspect/api/inspect_api.py b/src/videoipath_automation_tool/apps/inspect/api/inspect_api.py index b238f4c..519decc 100644 --- a/src/videoipath_automation_tool/apps/inspect/api/inspect_api.py +++ b/src/videoipath_automation_tool/apps/inspect/api/inspect_api.py @@ -43,23 +43,8 @@ from videoipath_automation_tool.utils.cross_app_utils import create_fallback_logger -def _extract_items(data: dict[str, Any], *path: str) -> list[dict[str, Any]]: - """Walk ``data`` down ``path`` and return the ``_items`` list (empty if any node is absent).""" - node: Any = data - for key in path: - if not isinstance(node, dict): - return [] - node = node.get(key) - if node is None: - return [] - if isinstance(node, dict): - items = node.get("_items", []) - return items if isinstance(items, list) else [] - return [] - - class InspectAPI: - def __init__(self, vip_connector: VideoIPathConnector, logger: Optional[logging.Logger] = None): + def __init__(self, vip_connector: VideoIPathConnector, logger: Optional[logging.Logger] = None) -> None: self._logger = logger or create_fallback_logger("videoipath_automation_tool_inspect_api") self.vip_connector = vip_connector self._logger.debug("Inspect API initialized.") @@ -149,6 +134,24 @@ def sync_devices( return InspectApiSimpleActionResponse.model_validate(_post_envelope(response)) +# --- Internal --- + + +def _extract_items(data: dict[str, Any], *path: str) -> list[dict[str, Any]]: + """Walk ``data`` down ``path`` and return the ``_items`` list (empty if any node is absent).""" + node: Any = data + for key in path: + if not isinstance(node, dict): + return [] + node = node.get(key) + if node is None: + return [] + if isinstance(node, dict): + items = node.get("_items", []) + return items if isinstance(items, list) else [] + return [] + + def _header_dict(response: Any) -> dict[str, Any]: header = getattr(response, "header", None) if header is None: diff --git a/src/videoipath_automation_tool/apps/inspect/api/queries.py b/src/videoipath_automation_tool/apps/inspect/api/queries.py index c095bac..8fb8205 100644 --- a/src/videoipath_automation_tool/apps/inspect/api/queries.py +++ b/src/videoipath_automation_tool/apps/inspect/api/queries.py @@ -29,13 +29,48 @@ # the full UI projection (thousands of chars) triggers HTTP 414 behind the proxy. MAX_QUERY_LENGTH = 4000 + +def encode(path: str) -> str: + """Percent-encode a collector query path, preserving projection-grammar characters.""" + return urllib.parse.quote(path, safe=_SAFE) + + +def device_skeleton() -> str: + """GET path for the device skeleton (all devices, no module/port detail).""" + return _build(_DEVICE_SKELETON) + + +def device_detail(device_id: str) -> str: + """GET path for one device's full nodeStatus sub-tree (modules, ports, vertexInfo, ...).""" + return _build(f"/status/collector/inspect/nodeStatus/{device_id}/**") + + +def edge_skeleton() -> str: + """GET path for the lean edge skeleton (all device pairs, connectivity + status severities).""" + return _build(_EDGE_SKELETON) + + +def edge_pair(pair_id: str) -> str: + """GET path for a single external-edge device pair (targeted refresh, [ADR-0010]).""" + return _build(f"/status/collector/externalEdgesByDeviceKey/{pair_id}" + _EDGE_LEAN_TAIL) + + +def paths_section() -> str: + """GET path for the services/paths section.""" + return _build(_PATHS_SECTION) + + +def collector_full() -> str: + """GET path for the full collector aggregate (eager / fallback mode).""" + return _build(_COLLECTOR_FULL) + + +# --- Internal --- + # Characters that are meaningful in the projection grammar and must survive encoding. # Everything else (spaces, double quotes, ...) is percent-encoded. _SAFE = "/*,'=()" - -# --- Verified query fragments (path relative to /rest/v2/data) --- - # Device skeleton: identity + descriptor + meta (incl. coordinates) + status + syncSeverity # + tags, with the module sub-tree suppressed ("_noId"). ~200 char URL, ~30 KB / 30 devices. _DEVICE_SKELETON = ( @@ -77,11 +112,6 @@ _COLLECTOR_FULL = "/status/collector/**" -def encode(path: str) -> str: - """Percent-encode a collector query path, preserving projection-grammar characters.""" - return urllib.parse.quote(path, safe=_SAFE) - - def _build(path: str) -> str: encoded = encode(_DATA + path) if len(encoded) > MAX_QUERY_LENGTH: @@ -89,36 +119,6 @@ def _build(path: str) -> str: return encoded -def device_skeleton() -> str: - """GET path for the device skeleton (all devices, no module/port detail).""" - return _build(_DEVICE_SKELETON) - - -def device_detail(device_id: str) -> str: - """GET path for one device's full nodeStatus sub-tree (modules, ports, vertexInfo, ...).""" - return _build(f"/status/collector/inspect/nodeStatus/{device_id}/**") - - -def edge_skeleton() -> str: - """GET path for the lean edge skeleton (all device pairs, connectivity + status severities).""" - return _build(_EDGE_SKELETON) - - -def edge_pair(pair_id: str) -> str: - """GET path for a single external-edge device pair (targeted refresh, [ADR-0010]).""" - return _build(f"/status/collector/externalEdgesByDeviceKey/{pair_id}" + _EDGE_LEAN_TAIL) - - -def paths_section() -> str: - """GET path for the services/paths section.""" - return _build(_PATHS_SECTION) - - -def collector_full() -> str: - """GET path for the full collector aggregate (eager / fallback mode).""" - return _build(_COLLECTOR_FULL) - - __all__ = [ "MAX_QUERY_LENGTH", "encode", diff --git a/src/videoipath_automation_tool/apps/inspect/app/__init__.py b/src/videoipath_automation_tool/apps/inspect/app/__init__.py index 7adf215..250e9fb 100644 --- a/src/videoipath_automation_tool/apps/inspect/app/__init__.py +++ b/src/videoipath_automation_tool/apps/inspect/app/__init__.py @@ -1,3 +1,5 @@ +from __future__ import annotations + from videoipath_automation_tool.apps.inspect.app.app import InspectApp __all__ = ["InspectApp"] diff --git a/src/videoipath_automation_tool/apps/inspect/app/actions.py b/src/videoipath_automation_tool/apps/inspect/app/actions.py index 32b8bf9..230db07 100644 --- a/src/videoipath_automation_tool/apps/inspect/app/actions.py +++ b/src/videoipath_automation_tool/apps/inspect/app/actions.py @@ -10,14 +10,16 @@ import logging from enum import IntEnum -from typing import Iterable, Optional, Protocol, Union +from typing import TYPE_CHECKING, Iterable, Optional, Protocol, Union from videoipath_automation_tool.apps.inspect.api import InspectAPI from videoipath_automation_tool.apps.inspect.model.actions import ( InspectApiAddDevicesItem, InspectApiLookupSyncInfoItem, ) -from videoipath_automation_tool.apps.inspect.snapshot import InspectSnapshot + +if TYPE_CHECKING: + from videoipath_automation_tool.apps.inspect.snapshot import InspectSnapshot class ConflictStrategy(IntEnum): diff --git a/src/videoipath_automation_tool/apps/inspect/app/app.py b/src/videoipath_automation_tool/apps/inspect/app/app.py index 4eee4f9..cf4d487 100644 --- a/src/videoipath_automation_tool/apps/inspect/app/app.py +++ b/src/videoipath_automation_tool/apps/inspect/app/app.py @@ -7,19 +7,18 @@ from __future__ import annotations import logging -from typing import Optional +from typing import TYPE_CHECKING, Optional from videoipath_automation_tool.apps.inspect.api import InspectAPI -from videoipath_automation_tool.apps.inspect.snapshot import InspectSnapshot from videoipath_automation_tool.connector.vip_connector import VideoIPathConnector +if TYPE_CHECKING: + from videoipath_automation_tool.apps.inspect.snapshot import InspectSnapshot + from .actions import InspectActionsMixin from .read import InspectReadMixin, LoadMode from .write import InspectWriteMixin -# First VideoIPath version the Inspect collector surface was verified against. -_MIN_VERIFIED_VERSION = (2025, 4) - class InspectApp(InspectReadMixin, InspectWriteMixin, InspectActionsMixin): def __init__( @@ -27,7 +26,7 @@ def __init__( vip_connector: VideoIPathConnector, logger: Optional[logging.Logger] = None, load: LoadMode = "skeleton", - ): + ) -> None: """Inspect App: read the topology/status and apply commit-style topology changes. The app keeps a single internal topology view that is loaded lazily on the first read and @@ -58,6 +57,10 @@ def _warn_if_version_unverified(self) -> None: ) +# First VideoIPath version the Inspect collector surface was verified against. +_MIN_VERIFIED_VERSION = (2025, 4) + + def _parse_version(version: str) -> Optional[tuple[int, int]]: parts = version.split(".") if len(parts) < 2: diff --git a/src/videoipath_automation_tool/apps/inspect/app/read.py b/src/videoipath_automation_tool/apps/inspect/app/read.py index 3cd0be2..58f3bb5 100644 --- a/src/videoipath_automation_tool/apps/inspect/app/read.py +++ b/src/videoipath_automation_tool/apps/inspect/app/read.py @@ -36,26 +36,6 @@ class InspectReadMixin: _snapshot: Optional[InspectSnapshot] _load_mode: LoadMode - # --- Internal snapshot lifecycle --- - - def _get_snapshot(self: _HasInspectState) -> InspectSnapshot: - """Return the internal snapshot, building it on first access ([ADR-0007]).""" - if self._snapshot is None: - self._snapshot = self._load_snapshot(self._load_mode) - return self._snapshot - - def _load_snapshot(self: _HasInspectState, load: LoadMode) -> InspectSnapshot: - if load == "full": - self._logger.debug("Loading full (eager) Inspect snapshot.") - return InspectSnapshot.from_full_response(self._inspect_api.get_collector_full(), fetcher=self._inspect_api) - self._logger.debug("Loading skeleton Inspect snapshot (devices + edges in parallel).") - with ThreadPoolExecutor(max_workers=2) as pool: - devices_future = pool.submit(self._inspect_api.get_device_skeleton) - edges_future = pool.submit(self._inspect_api.get_edge_skeleton) - devices = devices_future.result() - edges = edges_future.result() - return InspectSnapshot(fetcher=self._inspect_api, device_items=devices, edge_items=edges) - def refresh(self: _HasInspectState, load: Optional[LoadMode] = None) -> None: """Reload the topology from the server, discarding the current internal view. @@ -128,5 +108,25 @@ def get_services_for_device(self: _HasInspectState, device_id: str) -> list["Ins """All services whose path traverses the given device.""" return self._get_snapshot().get_services_for_device(device_id) + # --- Internal snapshot lifecycle --- + + def _get_snapshot(self: _HasInspectState) -> InspectSnapshot: + """Return the internal snapshot, building it on first access ([ADR-0007]).""" + if self._snapshot is None: + self._snapshot = self._load_snapshot(self._load_mode) + return self._snapshot + + def _load_snapshot(self: _HasInspectState, load: LoadMode) -> InspectSnapshot: + if load == "full": + self._logger.debug("Loading full (eager) Inspect snapshot.") + return InspectSnapshot.from_full_response(self._inspect_api.get_collector_full(), fetcher=self._inspect_api) + self._logger.debug("Loading skeleton Inspect snapshot (devices + edges in parallel).") + with ThreadPoolExecutor(max_workers=2) as pool: + devices_future = pool.submit(self._inspect_api.get_device_skeleton) + edges_future = pool.submit(self._inspect_api.get_edge_skeleton) + devices = devices_future.result() + edges = edges_future.result() + return InspectSnapshot(fetcher=self._inspect_api, device_items=devices, edge_items=edges) + __all__ = ["InspectReadMixin", "LoadMode"] diff --git a/src/videoipath_automation_tool/apps/inspect/app/write.py b/src/videoipath_automation_tool/apps/inspect/app/write.py index 2cc4d4c..054984b 100644 --- a/src/videoipath_automation_tool/apps/inspect/app/write.py +++ b/src/videoipath_automation_tool/apps/inspect/app/write.py @@ -12,11 +12,13 @@ from __future__ import annotations import logging -from typing import Any, Optional, Protocol +from typing import TYPE_CHECKING, Any, Optional, Protocol from videoipath_automation_tool.apps.inspect.transaction import CommitResult, InspectTransaction from videoipath_automation_tool.apps.inspect.api import InspectAPI -from videoipath_automation_tool.apps.inspect.snapshot import InspectSnapshot + +if TYPE_CHECKING: + from videoipath_automation_tool.apps.inspect.snapshot import InspectSnapshot class _HasInspectState(Protocol): diff --git a/src/videoipath_automation_tool/apps/inspect/domain/__init__.py b/src/videoipath_automation_tool/apps/inspect/domain/__init__.py index b65cbc5..589625e 100644 --- a/src/videoipath_automation_tool/apps/inspect/domain/__init__.py +++ b/src/videoipath_automation_tool/apps/inspect/domain/__init__.py @@ -1,3 +1,5 @@ +from __future__ import annotations + from videoipath_automation_tool.apps.inspect.domain.device import InspectDevice from videoipath_automation_tool.apps.inspect.domain.edge import InspectEdge from videoipath_automation_tool.apps.inspect.domain.port import InspectPort diff --git a/src/videoipath_automation_tool/apps/inspect/domain/device.py b/src/videoipath_automation_tool/apps/inspect/domain/device.py index f440938..450e4ad 100644 --- a/src/videoipath_automation_tool/apps/inspect/domain/device.py +++ b/src/videoipath_automation_tool/apps/inspect/domain/device.py @@ -22,12 +22,6 @@ class InspectDevice(InspectFrozenModel): snapshot: InspectSnapshot id: str - def _record(self) -> "_DeviceRecord": - record = self.snapshot.get_device_record(self.id) - if record is None: - raise KeyError(f"Device '{self.id}' is no longer present in the snapshot.") - return record - @property def label(self) -> str | None: return self._record().label @@ -85,3 +79,9 @@ def services(self) -> list[InspectService]: @property def linked_devices(self) -> list[InspectDevice]: return self.snapshot.get_linked_devices(self.id) + + def _record(self) -> "_DeviceRecord": + record = self.snapshot.get_device_record(self.id) + if record is None: + raise KeyError(f"Device '{self.id}' is no longer present in the snapshot.") + return record diff --git a/src/videoipath_automation_tool/apps/inspect/model/__init__.py b/src/videoipath_automation_tool/apps/inspect/model/__init__.py index 8b773e0..cf78aaf 100644 --- a/src/videoipath_automation_tool/apps/inspect/model/__init__.py +++ b/src/videoipath_automation_tool/apps/inspect/model/__init__.py @@ -1,3 +1,5 @@ +from __future__ import annotations + from videoipath_automation_tool.apps.inspect.model.actions import * from videoipath_automation_tool.apps.inspect.model.collector import * from videoipath_automation_tool.apps.inspect.model.common import * diff --git a/src/videoipath_automation_tool/apps/inspect/snapshot.py b/src/videoipath_automation_tool/apps/inspect/snapshot.py index d336fb5..2021117 100644 --- a/src/videoipath_automation_tool/apps/inspect/snapshot.py +++ b/src/videoipath_automation_tool/apps/inspect/snapshot.py @@ -41,54 +41,12 @@ from videoipath_automation_tool.apps.inspect.domain.service import InspectService from videoipath_automation_tool.apps.inspect.api import InspectAPI -_PRELOAD_WORKERS = 8 - -_logger = logging.getLogger("videoipath_automation_tool_inspect_snapshot") - class HydrationLevel(str, Enum): SKELETON = "skeleton" FULL = "full" -def _now() -> datetime: - return datetime.now(timezone.utc) - - -class _DeviceRecord(InspectInternalModel): - device_id: str - node: InspectApiNodeStatusItem - level: HydrationLevel - fetched_at: datetime = Field(default_factory=_now) - - @property - def label(self) -> str | None: - return self.node.effective_label - - @property - def pid(self) -> str | None: - return self.node.pid or self.node.deviceId - - -class _IndexedPort(InspectFrozenModel): - device_id: str - module_id: str | None - port: InspectPortStatus - - -class _IndexedEdge(InspectFrozenModel): - edge_id: str - pair_id: str - edge: InspectApiExternalEdgeStatus - pair_status: InspectApiExternalEdgeLiveStatus | None - primary_device_id: str | None - secondary_device_id: str | None - from_device_id: str | None - from_port_id: str | None - to_device_id: str | None - to_port_id: str | None - - class InspectSnapshot: def __init__( self, @@ -702,6 +660,51 @@ def _wrap_service(self, path_item: InspectApiPathItem) -> "InspectService": return service +# --- Internal --- + +_PRELOAD_WORKERS = 8 + +_logger = logging.getLogger("videoipath_automation_tool_inspect_snapshot") + + +def _now() -> datetime: + return datetime.now(timezone.utc) + + +class _DeviceRecord(InspectInternalModel): + device_id: str + node: InspectApiNodeStatusItem + level: HydrationLevel + fetched_at: datetime = Field(default_factory=_now) + + @property + def label(self) -> str | None: + return self.node.effective_label + + @property + def pid(self) -> str | None: + return self.node.pid or self.node.deviceId + + +class _IndexedPort(InspectFrozenModel): + device_id: str + module_id: str | None + port: InspectPortStatus + + +class _IndexedEdge(InspectFrozenModel): + edge_id: str + pair_id: str + edge: InspectApiExternalEdgeStatus + pair_status: InspectApiExternalEdgeLiveStatus | None + primary_device_id: str | None + secondary_device_id: str | None + from_device_id: str | None + from_port_id: str | None + to_device_id: str | None + to_port_id: str | None + + # --- Module-level helpers (kept stable for the domain layer) --- diff --git a/src/videoipath_automation_tool/apps/inspect/transaction.py b/src/videoipath_automation_tool/apps/inspect/transaction.py index ea6f9ad..953ea69 100644 --- a/src/videoipath_automation_tool/apps/inspect/transaction.py +++ b/src/videoipath_automation_tool/apps/inspect/transaction.py @@ -49,11 +49,6 @@ from videoipath_automation_tool.apps.inspect.api import InspectAPI from videoipath_automation_tool.apps.inspect.snapshot import InspectSnapshot -# Staged-entry kinds. -_DEVICE = "device" -_VERTEX = "vertex" -_EDGE = "edge" - class CommitResult(InspectFrozenModel): """Outcome of a successful commit ([ADR-0006]); a failed commit raises ``InspectCommitError``.""" @@ -71,46 +66,6 @@ def validation(self) -> Any: return self.response.data.validation -class _Staged(InspectInternalModel): - kind: str - entity_id: str - # The write-shape baseline (edit/edge form) as fetched at stage time; None for a raw remove. - baseline_form: Any | None = None - # JSON dump of the baseline at stage time, used for the compare-and-commit conflict check. - baseline_dump: dict[str, Any] | None = None - # Field-level intents (wire field names; dotted for one level of nesting, e.g. "descriptor.label"). - intents: dict[str, Any] = Field(default_factory=dict) - remove: bool = False - is_new: bool = False - - -def _device_of(entity_id: str) -> str: - """Owning device id of a vertex/port id (``device12.1.Ethernet1.out`` -> ``device12``).""" - return entity_id.split(".", 1)[0] - - -def _reverse_vertex(vertex_id: str) -> str: - """Flip the trailing direction of an IP vertex id (``.out`` <-> ``.in``).""" - if vertex_id.endswith(".out"): - return vertex_id[: -len(".out")] + ".in" - if vertex_id.endswith(".in"): - return vertex_id[: -len(".in")] + ".out" - return vertex_id - - -def _edge_key(from_id: str, to_id: str) -> str: - return f"{from_id}::{to_id}" - - -def _apply_intents(form: Any, intents: dict[str, Any]) -> None: - for key, value in intents.items(): - if "." in key: - head, tail = key.split(".", 1) - setattr(getattr(form, head), tail, value) - else: - setattr(form, key, value) - - class InspectTransaction: """Single-use, atomic batch of Inspect topology changes. @@ -528,6 +483,54 @@ def _refresh_snapshot(self) -> None: ) +# --- Internal --- + +# Staged-entry kinds. +_DEVICE = "device" +_VERTEX = "vertex" +_EDGE = "edge" + + +class _Staged(InspectInternalModel): + kind: str + entity_id: str + # The write-shape baseline (edit/edge form) as fetched at stage time; None for a raw remove. + baseline_form: Any | None = None + # JSON dump of the baseline at stage time, used for the compare-and-commit conflict check. + baseline_dump: dict[str, Any] | None = None + # Field-level intents (wire field names; dotted for one level of nesting, e.g. "descriptor.label"). + intents: dict[str, Any] = Field(default_factory=dict) + remove: bool = False + is_new: bool = False + + +def _device_of(entity_id: str) -> str: + """Owning device id of a vertex/port id (``device12.1.Ethernet1.out`` -> ``device12``).""" + return entity_id.split(".", 1)[0] + + +def _reverse_vertex(vertex_id: str) -> str: + """Flip the trailing direction of an IP vertex id (``.out`` <-> ``.in``).""" + if vertex_id.endswith(".out"): + return vertex_id[: -len(".out")] + ".in" + if vertex_id.endswith(".in"): + return vertex_id[: -len(".in")] + ".out" + return vertex_id + + +def _edge_key(from_id: str, to_id: str) -> str: + return f"{from_id}::{to_id}" + + +def _apply_intents(form: Any, intents: dict[str, Any]) -> None: + for key, value in intents.items(): + if "." in key: + head, tail = key.split(".", 1) + setattr(getattr(form, head), tail, value) + else: + setattr(form, key, value) + + def _checkable(entry: _Staged) -> bool: return not entry.remove and not entry.is_new and entry.baseline_dump is not None diff --git a/tests/e2e/inspect/test_e2e_inspect.py b/tests/e2e/inspect/test_e2e_inspect.py index e77b974..d299592 100644 --- a/tests/e2e/inspect/test_e2e_inspect.py +++ b/tests/e2e/inspect/test_e2e_inspect.py @@ -147,7 +147,7 @@ def test_full_vs_skeleton_equivalence(app: VideoIPathApp, topology_builder: Topo topology_builder.link(id_a, id_b) topology_builder.link(id_b, id_c) - def graph_view() -> dict: + def graph_view() -> dict[str, tuple[str | None, frozenset[str | None]]]: return { device_id: ( app.inspect.get_device(device_id).label, diff --git a/tests/e2e/test_e2e_workflow.py b/tests/e2e/test_e2e_workflow.py index 6709396..e463e3e 100644 --- a/tests/e2e/test_e2e_workflow.py +++ b/tests/e2e/test_e2e_workflow.py @@ -42,7 +42,8 @@ class LinkSpec(InspectFrozenModel): b: int # device index -# A full leaf-spine in its own map region (all coordinates shifted +3000 in y so the test data rather not conflict with other data). Port counts equal each device's link degree; devices with +# A full leaf-spine in its own map region (all coordinates shifted +3000 in y so the test data +# rather not conflict with other data). Port counts equal each device's link degree; devices with # no links sit at the region origin. Two device pairs — (2, 15) and (5, 16) — carry parallel links, # represented here as repeated ``LinkSpec`` entries. DEVICES = [ diff --git a/tests/inspect/conftest.py b/tests/inspect/conftest.py index c28dfec..5cfd95b 100644 --- a/tests/inspect/conftest.py +++ b/tests/inspect/conftest.py @@ -1,14 +1,18 @@ """Shared fixtures for offline Inspect tests.""" +from __future__ import annotations + import json +from collections.abc import Callable from pathlib import Path +from typing import Any import pytest FIXTURE_DIR = Path(__file__).parent / "fixtures" / "2025.4.9" -def load_fixture(name: str) -> dict: +def load_fixture(name: str) -> dict[str, Any]: with open(FIXTURE_DIR / name) as handle: return json.load(handle) @@ -19,5 +23,5 @@ def fixtures_dir() -> Path: @pytest.fixture -def load(): +def load() -> Callable[[str], dict[str, Any]]: return load_fixture diff --git a/tests/inspect/test_actions.py b/tests/inspect/test_actions.py index ada7c58..15cb6cb 100644 --- a/tests/inspect/test_actions.py +++ b/tests/inspect/test_actions.py @@ -1,6 +1,10 @@ """Network-action mixin tests with a fake connector.""" +from __future__ import annotations + +import logging from types import SimpleNamespace +from typing import Any import pytest @@ -8,77 +12,42 @@ from videoipath_automation_tool.apps.inspect.api import InspectAPI -def _ok_header(): - return SimpleNamespace( - model_dump=lambda mode="json": { - "auth": True, - "caption": "OK", - "code": "OK", - "errorCodes": [], - "errorDetails": [], - "id": "0", - "msg": [], - "ok": True, - "user": "api-user", - } - ) - - class FakeRest: - def __init__(self, post_data): + def __init__(self, post_data: dict[str, Any]) -> None: self._post_data = post_data - self.post_calls = [] + self.post_calls: list[tuple[str, dict[str, Any]]] = [] - def post(self, url_path, body, **kwargs): + def post(self, url_path: str, body: Any, **kwargs: Any) -> SimpleNamespace: self.post_calls.append((url_path, body.model_dump(mode="json", by_alias=True))) return SimpleNamespace(data=self._post_data, header=_ok_header()) -class _RecordingSnapshot: - """Stand-in that records apply_network_refresh calls (see snapshot.apply_network_refresh).""" - - def __init__(self): - self.network_refresh_calls = [] - - def apply_network_refresh(self, device_ids): - self.network_refresh_calls.append(list(device_ids)) - - -class _App(InspectActionsMixin): - def __init__(self, post_data, snapshot=None): - import logging - - self._logger = logging.getLogger("test") - self._inspect_api = InspectAPI(SimpleNamespace(rest=FakeRest(post_data)), self._logger) - self._snapshot = snapshot - - -def test_conflict_strategy_values(): +def test_conflict_strategy_values() -> None: assert int(ConflictStrategy.STRICT) == 0 assert int(ConflictStrategy.INVALIDATE_SERVICES) == 1 assert int(ConflictStrategy.CANCEL_SERVICES) == 2 -def test_add_devices_builds_placement_items(): +def test_add_devices_builds_placement_items() -> None: app = _App({"msg": [], "ok": True}) assert app.add_devices_to_topology([("device12", 100, 200), "device13"]) is True _, payload = app._inspect_api.vip_connector.rest.post_calls[0] assert payload["data"] == [{"id": "device12", "x": 100, "y": 200}, {"id": "device13", "x": 0, "y": 0}] -def test_sync_devices_passes_strategy(): +def test_sync_devices_passes_strategy() -> None: app = _App({"msg": [], "ok": True}) app.sync_devices(["device12"], add_only=True, conflict_strategy=ConflictStrategy.CANCEL_SERVICES) _, payload = app._inspect_api.vip_connector.rest.post_calls[0] assert payload["data"] == {"ids": ["device12"], "addOnly": True, "conflictStrategy": 2} -def test_sync_devices_reports_failure(): +def test_sync_devices_reports_failure() -> None: app = _App({"msg": ["No topology reported by the device"], "ok": False}) assert app.sync_devices(["device12"]) is False -def test_empty_lists_rejected(): +def test_empty_lists_rejected() -> None: app = _App({"msg": [], "ok": True}) with pytest.raises(ValueError): app.sync_devices([]) @@ -89,28 +58,68 @@ def test_empty_lists_rejected(): # --- Post-action snapshot refresh (ADR-0010) --- -def test_add_devices_refreshes_snapshot_when_loaded(): +def test_add_devices_refreshes_snapshot_when_loaded() -> None: snap = _RecordingSnapshot() app = _App({"msg": [], "ok": True}, snapshot=snap) assert app.add_devices_to_topology([("device12", 1, 2), "device13"]) is True assert snap.network_refresh_calls == [["device12", "device13"]] -def test_sync_devices_refreshes_snapshot_when_loaded(): +def test_sync_devices_refreshes_snapshot_when_loaded() -> None: snap = _RecordingSnapshot() app = _App({"msg": [], "ok": True}, snapshot=snap) assert app.sync_devices(["device12", "device13"]) is True assert snap.network_refresh_calls == [["device12", "device13"]] -def test_network_action_without_snapshot_does_not_build_one(): +def test_network_action_without_snapshot_does_not_build_one() -> None: # _snapshot is None: a pure-action workflow must not trigger a topology read. app = _App({"msg": [], "ok": True}) assert app.add_devices_to_topology(["device12"]) is True -def test_failed_action_does_not_refresh(): +def test_failed_action_does_not_refresh() -> None: snap = _RecordingSnapshot() app = _App({"msg": ["nope"], "ok": False}, snapshot=snap) assert app.sync_devices(["device12"]) is False assert snap.network_refresh_calls == [] + + +# --- Internal --- + + +def _ok_header() -> SimpleNamespace: + return SimpleNamespace( + model_dump=lambda mode="json": { + "auth": True, + "caption": "OK", + "code": "OK", + "errorCodes": [], + "errorDetails": [], + "id": "0", + "msg": [], + "ok": True, + "user": "api-user", + } + ) + + +class _RecordingSnapshot: + """Stand-in that records apply_network_refresh calls (see snapshot.apply_network_refresh).""" + + def __init__(self) -> None: + self.network_refresh_calls: list[list[str]] = [] + + def apply_network_refresh(self, device_ids: list[str]) -> None: + self.network_refresh_calls.append(list(device_ids)) + + +class _App(InspectActionsMixin): + def __init__( + self, + post_data: dict[str, Any], + snapshot: _RecordingSnapshot | None = None, + ) -> None: + self._logger = logging.getLogger("test") + self._inspect_api = InspectAPI(SimpleNamespace(rest=FakeRest(post_data)), self._logger) + self._snapshot = snapshot diff --git a/tests/inspect/test_api.py b/tests/inspect/test_api.py index b3412d3..87d971a 100644 --- a/tests/inspect/test_api.py +++ b/tests/inspect/test_api.py @@ -1,64 +1,37 @@ """InspectAPI wiring tests with a fake REST connector: verifies each method hits the right endpoint, passes allow_projection for scoped reads, and parses responses into DTOs.""" +from __future__ import annotations + +from collections.abc import Callable from types import SimpleNamespace +from typing import Any from videoipath_automation_tool.apps.inspect.api import InspectAPI from videoipath_automation_tool.apps.inspect.model.update_topology import InspectApiUpdateTopologyData class FakeRest: - def __init__(self, get_data=None, post_data=None): + def __init__( + self, + get_data: dict[str, Any] | None = None, + post_data: dict[str, Any] | None = None, + ) -> None: self._get_data = get_data or {} self._post_data = post_data or {} - self.get_calls = [] - self.post_calls = [] + self.get_calls: list[tuple[str, bool]] = [] + self.post_calls: list[tuple[str, Any]] = [] - def get(self, url_path, allow_projection=False, **kwargs): + def get(self, url_path: str, allow_projection: bool = False, **kwargs: Any) -> SimpleNamespace: self.get_calls.append((url_path, allow_projection)) return SimpleNamespace(data=self._get_data, header=_ok_header()) - def post(self, url_path, body, **kwargs): + def post(self, url_path: str, body: Any, **kwargs: Any) -> SimpleNamespace: self.post_calls.append((url_path, body)) return SimpleNamespace(data=self._post_data, header=_ok_header()) -def _ok_header(): - return SimpleNamespace( - model_dump=lambda mode="json": { - "auth": True, - "caption": "OK", - "code": "OK", - "errorCodes": [], - "errorDetails": [], - "id": "0", - "msg": [], - "ok": True, - "user": "api-user", - } - ) - - -def _connector(get_data=None, post_data=None): - rest = FakeRest(get_data=get_data, post_data=post_data) - return SimpleNamespace(rest=rest), rest - - -def _collector(node_items=None, edge_items=None, path_items=None): - return { - "status": { - "collector": { - "inspect": { - "nodeStatus": {"_items": node_items or []}, - "paths": {"_items": path_items or []}, - }, - "externalEdgesByDeviceKey": {"_items": edge_items or []}, - } - } - } - - -def test_device_skeleton_uses_projection_and_parses(load): +def test_device_skeleton_uses_projection_and_parses(load: Callable[[str], dict[str, Any]]) -> None: node_items = load("skeleton_nodestatus_short.json")["data"]["status"]["collector"]["inspect"]["nodeStatus"][ "_items" ] @@ -69,7 +42,7 @@ def test_device_skeleton_uses_projection_and_parses(load): assert rest.get_calls[0][1] is True # allow_projection -def test_edge_skeleton_parses(load): +def test_edge_skeleton_parses(load: Callable[[str], dict[str, Any]]) -> None: edge_items = load("edge_skeleton.json")["data"]["status"]["collector"]["externalEdgesByDeviceKey"]["_items"] conn, rest = _connector(get_data=_collector(edge_items=edge_items)) api = InspectAPI(conn) @@ -77,13 +50,13 @@ def test_edge_skeleton_parses(load): assert len(edges) == len(edge_items) -def test_device_detail_returns_none_when_absent(): +def test_device_detail_returns_none_when_absent() -> None: conn, rest = _connector(get_data=_collector(node_items=[])) api = InspectAPI(conn) assert api.get_device_detail("deviceX") is None -def test_lookup_edges_hits_correct_endpoint(load): +def test_lookup_edges_hits_correct_endpoint(load: Callable[[str], dict[str, Any]]) -> None: conn, rest = _connector(post_data=load("lookup_inspect_edges_by_ids.json")["data"]) api = InspectAPI(conn) resp = api.lookup_edges(["a::b"]) @@ -91,7 +64,7 @@ def test_lookup_edges_hits_correct_endpoint(load): assert resp.data -def test_update_topology_posts_delta(): +def test_update_topology_posts_delta() -> None: conn, rest = _connector( post_data={ "items": [], @@ -105,10 +78,55 @@ def test_update_topology_posts_delta(): assert resp.committed is True -def test_add_and_sync_devices_endpoints(): +def test_add_and_sync_devices_endpoints() -> None: conn, rest = _connector(post_data={"msg": [], "ok": True}) api = InspectAPI(conn) api.add_devices([]) api.sync_devices([], add_only=True, conflict_strategy=0) assert rest.post_calls[0][0].endswith("/network/addDevices") assert rest.post_calls[1][0].endswith("/network/syncDevices") + + +# --- Internal --- + + +def _ok_header() -> SimpleNamespace: + return SimpleNamespace( + model_dump=lambda mode="json": { + "auth": True, + "caption": "OK", + "code": "OK", + "errorCodes": [], + "errorDetails": [], + "id": "0", + "msg": [], + "ok": True, + "user": "api-user", + } + ) + + +def _connector( + get_data: dict[str, Any] | None = None, + post_data: dict[str, Any] | None = None, +) -> tuple[SimpleNamespace, FakeRest]: + rest = FakeRest(get_data=get_data, post_data=post_data) + return SimpleNamespace(rest=rest), rest + + +def _collector( + node_items: list[dict[str, Any]] | None = None, + edge_items: list[dict[str, Any]] | None = None, + path_items: list[dict[str, Any]] | None = None, +) -> dict[str, Any]: + return { + "status": { + "collector": { + "inspect": { + "nodeStatus": {"_items": node_items or []}, + "paths": {"_items": path_items or []}, + }, + "externalEdgesByDeviceKey": {"_items": edge_items or []}, + } + } + } diff --git a/tests/inspect/test_models.py b/tests/inspect/test_models.py index 47ccf89..0134e52 100644 --- a/tests/inspect/test_models.py +++ b/tests/inspect/test_models.py @@ -1,6 +1,11 @@ """Contract tests: every fixture parses into its DTO, and write-payload builders reproduce the verified request shapes byte-for-byte.""" +from __future__ import annotations + +from collections.abc import Callable +from typing import Any + from videoipath_automation_tool.apps.inspect.model.actions import ( InspectApiLookupEdgesResponse, InspectApiLookupInspectDeviceResponse, @@ -18,11 +23,7 @@ ) -def _node_items(payload): - return payload["data"]["status"]["collector"]["inspect"]["nodeStatus"]["_items"] - - -def test_device_skeleton_items_parse_and_expose_effective_label(load): +def test_device_skeleton_items_parse_and_expose_effective_label(load: Callable[[str], dict[str, Any]]) -> None: items = _node_items(load("skeleton_nodestatus_short.json")) assert items for raw in items: @@ -31,7 +32,7 @@ def test_device_skeleton_items_parse_and_expose_effective_label(load): assert node.effective_label # comes from descriptor.label, not a top-level label -def test_device_detail_parses_modules_and_ports(load): +def test_device_detail_parses_modules_and_ports(load: Callable[[str], dict[str, Any]]) -> None: items = _node_items(load("device_hydration_modules_ports.json")) node = InspectApiNodeStatusItem.model_validate(items[0]) assert node.modules @@ -39,7 +40,7 @@ def test_device_detail_parses_modules_and_ports(load): assert module.ports -def test_edge_skeleton_items_parse(load): +def test_edge_skeleton_items_parse(load: Callable[[str], dict[str, Any]]) -> None: items = load("edge_skeleton.json")["data"]["status"]["collector"]["externalEdgesByDeviceKey"]["_items"] assert len(items) > 0 edge = InspectApiExternalEdgesByDeviceKeyItem.model_validate(items[0]) @@ -47,14 +48,14 @@ def test_edge_skeleton_items_parse(load): assert edge.primary is not None -def test_paths_fixture_parses(load): +def test_paths_fixture_parses(load: Callable[[str], dict[str, Any]]) -> None: items = load("inspect_paths_limit5.json")["data"]["status"]["collector"]["inspect"]["paths"]["_items"] for raw in items: path = InspectApiPathItem.model_validate(raw) assert path.serviceFields.bid -def test_lookup_edges_returns_full_persisted_edge_form(load): +def test_lookup_edges_returns_full_persisted_edge_form(load: Callable[[str], dict[str, Any]]) -> None: resp = InspectApiLookupEdgesResponse.model_validate(load("lookup_inspect_edges_by_ids.json")) key = next(iter(resp.data)) edge = resp.data[key].edge @@ -62,53 +63,32 @@ def test_lookup_edges_returns_full_persisted_edge_form(load): assert edge.capacity == 65535 -def test_lookup_vertex_edit_form(load): +def test_lookup_vertex_edit_form(load: Callable[[str], dict[str, Any]]) -> None: resp = InspectApiLookupVertexResponse.model_validate(load("lookup_inspect_vertex_by_id.json")) assert resp.data.fields.typeFields is not None assert resp.data.vertexType in ("In", "Out", "Internal", None) -def test_lookup_device_fields(): +def test_lookup_device_fields() -> None: resp = InspectApiLookupInspectDeviceResponse.model_validate(_device_lookup_fixture()) assert resp.data.fields.coordinates is not None -def _device_lookup_fixture(): - # lookupInspectDevice example from endpoints.md (anonymized) - return { - "data": { - "assignedTags": {"all": [], "inherited": {}, "inheritedConflict": False, "local": {}}, - "fields": { - "coordinates": {"x": 500, "y": 8150}, - "descriptor": {"desc": "", "label": "Example Device A"}, - "iconSize": "medium", - "iconType": "gateway", - "localAssignedTags": [], - "sdpStrategy": "always", - "siteId": None, - "tags": [], - "virtualDeviceFields": None, - }, - }, - "header": {"auth": True, "caption": "OK", "code": "OK", "id": "0", "msg": [], "ok": True, "user": "api-user"}, - } - - -def test_replace_devices_payload_roundtrips_byte_for_byte(load): +def test_replace_devices_payload_roundtrips_byte_for_byte(load: Callable[[str], dict[str, Any]]) -> None: fixture = load("update_topology_replace_devices.json") data = InspectApiUpdateTopologyData.model_validate(fixture["request"]["data"]) built = data.model_dump(mode="json", by_alias=True) assert built["replaceDevices"] == fixture["request"]["data"]["replaceDevices"] -def test_replace_vertices_payload_roundtrips_byte_for_byte(load): +def test_replace_vertices_payload_roundtrips_byte_for_byte(load: Callable[[str], dict[str, Any]]) -> None: fixture = load("update_topology_replace_vertices.json") data = InspectApiUpdateTopologyData.model_validate(fixture["request"]["data"]) built = data.model_dump(mode="json", by_alias=True) assert built["replaceVertices"] == fixture["request"]["data"]["replaceVertices"] -def test_commit_success_and_failure_flags(load): +def test_commit_success_and_failure_flags(load: Callable[[str], dict[str, Any]]) -> None: ok = InspectApiUpdateTopologyResponse.model_validate(load("update_topology_success.json")) assert ok.committed is True @@ -120,7 +100,7 @@ def test_commit_success_and_failure_flags(load): assert fail_remove.committed is False -def test_port_assigned_tags_from_tags_info(): +def test_port_assigned_tags_from_tags_info() -> None: port = InspectPortStatus.model_validate( { "_id": "device-a.1.p1", @@ -129,3 +109,31 @@ def test_port_assigned_tags_from_tags_info(): ) assert port.assigned_tags == ["Video~~T"] assert InspectPortStatus.model_validate({"_id": "device-a.1.p2"}).assigned_tags == [] + + +# --- Internal --- + + +def _node_items(payload: dict[str, Any]) -> list[dict[str, Any]]: + return payload["data"]["status"]["collector"]["inspect"]["nodeStatus"]["_items"] + + +def _device_lookup_fixture() -> dict[str, Any]: + # lookupInspectDevice example from endpoints.md (anonymized) + return { + "data": { + "assignedTags": {"all": [], "inherited": {}, "inheritedConflict": False, "local": {}}, + "fields": { + "coordinates": {"x": 500, "y": 8150}, + "descriptor": {"desc": "", "label": "Example Device A"}, + "iconSize": "medium", + "iconType": "gateway", + "localAssignedTags": [], + "sdpStrategy": "always", + "siteId": None, + "tags": [], + "virtualDeviceFields": None, + }, + }, + "header": {"auth": True, "caption": "OK", "code": "OK", "id": "0", "msg": [], "ok": True, "user": "api-user"}, + } diff --git a/tests/inspect/test_queries.py b/tests/inspect/test_queries.py index c21a211..a1fd31a 100644 --- a/tests/inspect/test_queries.py +++ b/tests/inspect/test_queries.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import urllib.parse import pytest @@ -6,14 +8,14 @@ from videoipath_automation_tool.apps.inspect.errors import InspectQueryTooLongError -def test_all_queries_within_length_limit(): +def test_all_queries_within_length_limit() -> None: for build in (queries.device_skeleton, queries.edge_skeleton, queries.paths_section, queries.collector_full): path = build() assert len(path) < queries.MAX_QUERY_LENGTH assert path.startswith("/rest/v2/data/status/collector/") -def test_device_skeleton_suppresses_modules_and_selects_skeleton_fields(): +def test_device_skeleton_suppresses_modules_and_selects_skeleton_fields() -> None: path = queries.device_skeleton() decoded = urllib.parse.unquote(path) assert 'modules/"_noId"' in decoded @@ -22,18 +24,18 @@ def test_device_skeleton_suppresses_modules_and_selects_skeleton_fields(): assert field in decoded -def test_device_detail_uses_direct_id_and_full_subtree(): +def test_device_detail_uses_direct_id_and_full_subtree() -> None: path = queries.device_detail("device12") assert path.endswith("/nodeStatus/device12/**") -def test_edge_pair_targets_single_pair(): +def test_edge_pair_targets_single_pair() -> None: path = queries.edge_pair("device12::device7") decoded = urllib.parse.unquote(path) assert "externalEdgesByDeviceKey/device12::device7" in decoded -def test_encode_preserves_grammar_characters_and_encodes_quotes_and_spaces(): +def test_encode_preserves_grammar_characters_and_encodes_quotes_and_spaces() -> None: encoded = queries.encode("/a b/*/x,y/'z'/\"q\"") assert "%20" in encoded # space encoded assert "%22" in encoded # double-quote encoded @@ -41,7 +43,7 @@ def test_encode_preserves_grammar_characters_and_encodes_quotes_and_spaces(): assert "'z'" in encoded # single-quote preserved -def test_query_too_long_raises(monkeypatch): +def test_query_too_long_raises(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr(queries, "MAX_QUERY_LENGTH", 10) with pytest.raises(InspectQueryTooLongError): queries.device_skeleton() diff --git a/tests/inspect/test_snapshot.py b/tests/inspect/test_snapshot.py index e1e23ec..8aaf207 100644 --- a/tests/inspect/test_snapshot.py +++ b/tests/inspect/test_snapshot.py @@ -3,6 +3,8 @@ from __future__ import annotations +from collections.abc import Iterator + import pytest from videoipath_automation_tool.apps.inspect.model.collector import ( @@ -13,134 +15,7 @@ from videoipath_automation_tool.apps.inspect.snapshot import HydrationLevel, InspectSnapshot -# --- Test data builders (small synthetic leaf-spine-ish graph) --- - - -def _skeleton_node(device_id: str, label: str, x: float = 0.0, y: float = 0.0, sync: int = 0): - return InspectApiNodeStatusItem.model_validate( - { - "_id": device_id, - "_vid": device_id, - "deviceId": device_id, - "descriptor": {"desc": "", "label": label}, - "meta": {"coordinates": {"x": x, "y": y}, "isVirtual": True, "iconType": "switch"}, - "status": {"sa": 0, "severity": 0}, - "syncSeverity": sync, - "tags": ["#e2e"], - "modules": {}, - } - ) - - -def _detail_node(device_id: str, label: str, port_pids: list[str]): - ports = { - pid: { - "pid": pid, - "descriptor": {"label": pid.split(".")[-1]}, - "status": {"sa": 0, "severity": 0}, - "vertexInfo": {"type": "single", "id": pid.replace(".dev.", "."), "vertexType": "Out"}, - } - for pid in port_pids - } - return InspectApiNodeStatusItem.model_validate( - { - "_id": device_id, - "_vid": device_id, - "deviceId": device_id, - "descriptor": {"desc": "", "label": label}, - "meta": {"coordinates": {"x": 0, "y": 0}}, - "status": {"sa": 0, "severity": 0}, - "syncSeverity": 0, - "modules": {f"{device_id}.dev.0": {"pid": f"{device_id}.dev.0", "ports": ports}}, - } - ) - - -def _edge_pair(dev_a: str, dev_b: str, port_a: str, port_b: str): - edge_id = f"{port_a}::{port_b}" - return InspectApiExternalEdgesByDeviceKeyItem.model_validate( - { - "_id": f"{dev_a}::{dev_b}", - "_vid": f"{dev_a}::{dev_b}", - "primary": { - "devicePid": dev_a, - "label": dev_a, - "data": { - edge_id: { - "id": edge_id, - "fromStatus": {"context": {"devicePid": dev_a, "portPid": port_a}, "label": "out"}, - "toStatus": {"context": {"devicePid": dev_b, "portPid": port_b}, "label": "in"}, - } - }, - }, - "secondary": {"devicePid": dev_b, "label": dev_b, "data": {}}, - "status": {"alarm": 0, "bandwidth": 0, "maintenance": 0, "ptp": 0}, - } - ) - - -def _path_item(booking: str, dev_a: str, dev_b: str): - return InspectApiPathItem.model_validate( - { - "_id": f"{booking}::main", - "_vid": f"_:{booking}::main", - "serviceFields": {"bid": booking, "isMain": True, "fromLabel": "src", "toLabel": "dst"}, - "path": [ - {"bid": booking, "structure": {"deviceId": dev_a, "devicePid": dev_a}}, - {"bid": booking, "structure": {"deviceId": dev_b, "devicePid": dev_b}}, - ], - } - ) - - -class FakeFetcher: - """Records how many detail/section fetches occur so laziness can be asserted.""" - - def __init__(self): - self.device_detail_calls: list[str] = [] - self.edge_pair_calls: list[str] = [] - self.section_calls = 0 - self.skeleton_calls = 0 - self._details = { - "spine-a": _detail_node("spine-a", "SPINE-A", ["spine-a.dev.0.swp1", "spine-a.dev.0.swp2"]), - "leaf-a": _detail_node("leaf-a", "LEAF-A", ["leaf-a.dev.0.up1", "leaf-a.dev.0.host1"]), - } - self._paths = [_path_item("1001", "leaf-a", "spine-a")] - - def get_device_skeleton(self): - self.skeleton_calls += 1 - return [_skeleton_node("spine-a", "SPINE-A"), _skeleton_node("leaf-a", "LEAF-A")] - - def get_edge_skeleton(self): - return [_edge_pair("leaf-a", "spine-a", "leaf-a.dev.0.up1", "spine-a.dev.0.swp1")] - - def get_device_detail(self, device_id): - self.device_detail_calls.append(device_id) - return self._details.get(device_id) - - def get_edge_pair(self, pair_id): - self.edge_pair_calls.append(pair_id) - a, b = pair_id.split("::") - return _edge_pair(a, b, f"{a}.dev.0.up1", f"{b}.dev.0.swp1") - - def get_paths_section(self): - self.section_calls += 1 - return self._paths - - -@pytest.fixture -def snapshot(): - fetcher = FakeFetcher() - snap = InspectSnapshot( - fetcher=fetcher, - device_items=fetcher.get_device_skeleton(), - edge_items=fetcher.get_edge_skeleton(), - ) - fetcher.skeleton_calls = 0 # reset after construction - return snap, fetcher - - -def test_skeleton_indexes_devices_and_edges(snapshot): +def test_skeleton_indexes_devices_and_edges(snapshot: tuple[InspectSnapshot, FakeFetcher]) -> None: snap, fetcher = snapshot assert {d.id for d in snap.devices} == {"spine-a", "leaf-a"} leaf = snap.get_device("leaf-a") @@ -151,12 +26,12 @@ def test_skeleton_indexes_devices_and_edges(snapshot): assert fetcher.device_detail_calls == [] # nothing hydrated yet -def test_find_by_label(snapshot): +def test_find_by_label(snapshot: tuple[InspectSnapshot, FakeFetcher]) -> None: snap, _ = snapshot assert snap.find_device_by_label("SPINE-A").id == "spine-a" -def test_ports_trigger_exactly_one_hydration(snapshot): +def test_ports_trigger_exactly_one_hydration(snapshot: tuple[InspectSnapshot, FakeFetcher]) -> None: snap, fetcher = snapshot leaf = snap.get_device("leaf-a") assert leaf.is_hydrated is False @@ -170,7 +45,7 @@ def test_ports_trigger_exactly_one_hydration(snapshot): assert snap.get_device("spine-a").is_hydrated is False -def test_edges_do_not_trigger_hydration(snapshot): +def test_edges_do_not_trigger_hydration(snapshot: tuple[InspectSnapshot, FakeFetcher]) -> None: snap, fetcher = snapshot edge = snap.edges[0] assert edge.from_device.id == "leaf-a" @@ -179,7 +54,7 @@ def test_edges_do_not_trigger_hydration(snapshot): assert fetcher.device_detail_calls == [] -def test_edge_from_port_triggers_owning_device_hydration(snapshot): +def test_edge_from_port_triggers_owning_device_hydration(snapshot: tuple[InspectSnapshot, FakeFetcher]) -> None: snap, fetcher = snapshot edge = snap.edges[0] port = edge.from_port @@ -188,7 +63,7 @@ def test_edge_from_port_triggers_owning_device_hydration(snapshot): assert fetcher.device_detail_calls == ["leaf-a"] -def test_services_section_is_lazy(snapshot): +def test_services_section_is_lazy(snapshot: tuple[InspectSnapshot, FakeFetcher]) -> None: snap, fetcher = snapshot assert fetcher.section_calls == 0 services = snap.services @@ -198,19 +73,19 @@ def test_services_section_is_lazy(snapshot): assert {d.id for d in snap.get_services_for_device("leaf-a")[0].path_devices} == {"leaf-a", "spine-a"} -def test_linked_devices_from_edges(snapshot): +def test_linked_devices_from_edges(snapshot: tuple[InspectSnapshot, FakeFetcher]) -> None: snap, _ = snapshot assert {d.id for d in snap.get_device("leaf-a").linked_devices} == {"spine-a"} -def test_preload_hydrates_all(snapshot): +def test_preload_hydrates_all(snapshot: tuple[InspectSnapshot, FakeFetcher]) -> None: snap, fetcher = snapshot snap.preload() assert set(fetcher.device_detail_calls) == {"spine-a", "leaf-a"} assert snap.get_device("spine-a").is_hydrated -def test_refresh_returns_new_snapshot(snapshot): +def test_refresh_returns_new_snapshot(snapshot: tuple[InspectSnapshot, FakeFetcher]) -> None: snap, fetcher = snapshot new = snap.refresh() assert new is not snap @@ -218,7 +93,7 @@ def test_refresh_returns_new_snapshot(snapshot): assert {d.id for d in new.devices} == {"spine-a", "leaf-a"} -def test_post_commit_removes_locally_and_refreshes_pair(snapshot): +def test_post_commit_removes_locally_and_refreshes_pair(snapshot: tuple[InspectSnapshot, FakeFetcher]) -> None: snap, fetcher = snapshot # remove a device locally snap.apply_post_commit(removed_ids=["spine-a"], mark_paths_stale=False) @@ -226,13 +101,13 @@ def test_post_commit_removes_locally_and_refreshes_pair(snapshot): assert {d.id for d in snap.devices} == {"leaf-a"} -def test_post_commit_refreshes_edge_pair(snapshot): +def test_post_commit_refreshes_edge_pair(snapshot: tuple[InspectSnapshot, FakeFetcher]) -> None: snap, fetcher = snapshot snap.apply_post_commit(pair_ids=["leaf-a::spine-a"]) assert "leaf-a::spine-a" in fetcher.edge_pair_calls -def test_post_commit_marks_paths_stale(snapshot): +def test_post_commit_marks_paths_stale(snapshot: tuple[InspectSnapshot, FakeFetcher]) -> None: snap, fetcher = snapshot _ = snap.services # load section assert fetcher.section_calls == 1 @@ -241,7 +116,7 @@ def test_post_commit_marks_paths_stale(snapshot): assert fetcher.section_calls == 2 -def test_post_commit_reindexes_changed_label(snapshot): +def test_post_commit_reindexes_changed_label(snapshot: tuple[InspectSnapshot, FakeFetcher]) -> None: # Latent-bug guard: a committed label change must re-point the label index (ADR-0010). snap, fetcher = snapshot fetcher._details["leaf-a"] = _detail_node("leaf-a", "LEAF-A-RENAMED", ["leaf-a.dev.0.up1"]) @@ -250,12 +125,14 @@ def test_post_commit_reindexes_changed_label(snapshot): assert snap.find_device_by_label("LEAF-A") is None -def test_post_commit_refetch_failure_does_not_raise_and_self_heals(snapshot): +def test_post_commit_refetch_failure_does_not_raise_and_self_heals( + snapshot: tuple[InspectSnapshot, FakeFetcher], +) -> None: snap, fetcher = snapshot calls = {"n": 0} real = fetcher.get_device_detail - def flaky(device_id): + def flaky(device_id: str) -> InspectApiNodeStatusItem | None: calls["n"] += 1 if calls["n"] == 1: raise RuntimeError("network blip") @@ -269,7 +146,7 @@ def flaky(device_id): assert calls["n"] == 2 -def test_apply_network_refresh_adds_new_device_and_edge(snapshot): +def test_apply_network_refresh_adds_new_device_and_edge(snapshot: tuple[InspectSnapshot, FakeFetcher]) -> None: snap, fetcher = snapshot fetcher._details["leaf-b"] = _detail_node("leaf-b", "LEAF-B", ["leaf-b.dev.0.up1"]) fetcher.get_edge_skeleton = lambda: [ @@ -282,7 +159,7 @@ def test_apply_network_refresh_adds_new_device_and_edge(snapshot): assert {e.pair_id for e in snap.get_edges_for_device("leaf-b")} == {"leaf-b::spine-a"} -def test_full_snapshot_is_hydrated_without_fetcher(): +def test_full_snapshot_is_hydrated_without_fetcher() -> None: fetcher = FakeFetcher() # Build a full snapshot manually (device_level=FULL, path_items provided) snap = InspectSnapshot( @@ -300,3 +177,136 @@ def test_full_snapshot_is_hydrated_without_fetcher(): # refresh without a fetcher raises with pytest.raises(RuntimeError): snap.refresh() + + +# --- Internal --- + + +def _skeleton_node( + device_id: str, + label: str, + x: float = 0.0, + y: float = 0.0, + sync: int = 0, +) -> InspectApiNodeStatusItem: + return InspectApiNodeStatusItem.model_validate( + { + "_id": device_id, + "_vid": device_id, + "deviceId": device_id, + "descriptor": {"desc": "", "label": label}, + "meta": {"coordinates": {"x": x, "y": y}, "isVirtual": True, "iconType": "switch"}, + "status": {"sa": 0, "severity": 0}, + "syncSeverity": sync, + "tags": ["#e2e"], + "modules": {}, + } + ) + + +def _detail_node(device_id: str, label: str, port_pids: list[str]) -> InspectApiNodeStatusItem: + ports = { + pid: { + "pid": pid, + "descriptor": {"label": pid.split(".")[-1]}, + "status": {"sa": 0, "severity": 0}, + "vertexInfo": {"type": "single", "id": pid.replace(".dev.", "."), "vertexType": "Out"}, + } + for pid in port_pids + } + return InspectApiNodeStatusItem.model_validate( + { + "_id": device_id, + "_vid": device_id, + "deviceId": device_id, + "descriptor": {"desc": "", "label": label}, + "meta": {"coordinates": {"x": 0, "y": 0}}, + "status": {"sa": 0, "severity": 0}, + "syncSeverity": 0, + "modules": {f"{device_id}.dev.0": {"pid": f"{device_id}.dev.0", "ports": ports}}, + } + ) + + +def _edge_pair(dev_a: str, dev_b: str, port_a: str, port_b: str) -> InspectApiExternalEdgesByDeviceKeyItem: + edge_id = f"{port_a}::{port_b}" + return InspectApiExternalEdgesByDeviceKeyItem.model_validate( + { + "_id": f"{dev_a}::{dev_b}", + "_vid": f"{dev_a}::{dev_b}", + "primary": { + "devicePid": dev_a, + "label": dev_a, + "data": { + edge_id: { + "id": edge_id, + "fromStatus": {"context": {"devicePid": dev_a, "portPid": port_a}, "label": "out"}, + "toStatus": {"context": {"devicePid": dev_b, "portPid": port_b}, "label": "in"}, + } + }, + }, + "secondary": {"devicePid": dev_b, "label": dev_b, "data": {}}, + "status": {"alarm": 0, "bandwidth": 0, "maintenance": 0, "ptp": 0}, + } + ) + + +def _path_item(booking: str, dev_a: str, dev_b: str) -> InspectApiPathItem: + return InspectApiPathItem.model_validate( + { + "_id": f"{booking}::main", + "_vid": f"_:{booking}::main", + "serviceFields": {"bid": booking, "isMain": True, "fromLabel": "src", "toLabel": "dst"}, + "path": [ + {"bid": booking, "structure": {"deviceId": dev_a, "devicePid": dev_a}}, + {"bid": booking, "structure": {"deviceId": dev_b, "devicePid": dev_b}}, + ], + } + ) + + +class FakeFetcher: + """Records how many detail/section fetches occur so laziness can be asserted.""" + + def __init__(self) -> None: + self.device_detail_calls: list[str] = [] + self.edge_pair_calls: list[str] = [] + self.section_calls = 0 + self.skeleton_calls = 0 + self._details = { + "spine-a": _detail_node("spine-a", "SPINE-A", ["spine-a.dev.0.swp1", "spine-a.dev.0.swp2"]), + "leaf-a": _detail_node("leaf-a", "LEAF-A", ["leaf-a.dev.0.up1", "leaf-a.dev.0.host1"]), + } + self._paths = [_path_item("1001", "leaf-a", "spine-a")] + + def get_device_skeleton(self) -> list[InspectApiNodeStatusItem]: + self.skeleton_calls += 1 + return [_skeleton_node("spine-a", "SPINE-A"), _skeleton_node("leaf-a", "LEAF-A")] + + def get_edge_skeleton(self) -> list[InspectApiExternalEdgesByDeviceKeyItem]: + return [_edge_pair("leaf-a", "spine-a", "leaf-a.dev.0.up1", "spine-a.dev.0.swp1")] + + def get_device_detail(self, device_id: str) -> InspectApiNodeStatusItem | None: + self.device_detail_calls.append(device_id) + return self._details.get(device_id) + + def get_edge_pair(self, pair_id: str) -> InspectApiExternalEdgesByDeviceKeyItem: + self.edge_pair_calls.append(pair_id) + a, b = pair_id.split("::") + return _edge_pair(a, b, f"{a}.dev.0.up1", f"{b}.dev.0.swp1") + + def get_paths_section(self) -> list[InspectApiPathItem]: + self.section_calls += 1 + return self._paths + + +@pytest.fixture +def snapshot() -> Iterator[tuple[InspectSnapshot, FakeFetcher]]: + fetcher = FakeFetcher() + snap = InspectSnapshot( + fetcher=fetcher, + device_items=fetcher.get_device_skeleton(), + edge_items=fetcher.get_edge_skeleton(), + ) + fetcher.skeleton_calls = 0 # reset after construction + yield snap, fetcher diff --git a/tests/inspect/test_transaction.py b/tests/inspect/test_transaction.py index fca4e31..87790a9 100644 --- a/tests/inspect/test_transaction.py +++ b/tests/inspect/test_transaction.py @@ -5,7 +5,10 @@ bypass, ``rebase``, and the post-commit targeted-refresh hook derivation. """ +from __future__ import annotations + from types import SimpleNamespace +from typing import Any import pytest @@ -41,103 +44,10 @@ EDGE_ID = f"{A_OUT}::{B_IN}" -def _device_response(label="Dev A", coordinates=None, icon_type="switch", tags=None): - return InspectApiLookupInspectDeviceResponse.model_validate( - { - "data": { - "assignedTags": {"all": [], "inherited": {}, "inheritedConflict": False, "local": {}}, - "fields": { - "coordinates": coordinates or {"x": 0, "y": 0}, - "descriptor": {"desc": "", "label": label}, - "iconSize": "medium", - "iconType": icon_type, - "localAssignedTags": [], - "sdpStrategy": None, - "siteId": None, - "tags": tags or [], - "virtualDeviceFields": None, - }, - }, - "header": _OK_HEADER, - } - ) - - -def _vertex_data(): - fixture = load_fixture("lookup_inspect_vertex_by_id.json") - return InspectApiLookupVertexResponseData.model_validate(fixture["data"]) - - -def _edge_item(weight=1): - edge = { - "active": True, - "bandwidth": -1.0, - "capacity": 65535, - "conflictPri": 0, - "descriptor": {"desc": "", "label": ""}, - "excludeFormats": [], - "fDescriptor": {"desc": "", "label": ""}, - "fromId": A_OUT, - "includeFormats": [], - "redundancyMode": "Any", - "tags": [], - "toId": B_IN, - "weight": weight, - "weightFactors": {"bandwidth": {"weight": 0}, "service": {"max": 100, "weight": 0}}, - } - return InspectApiLookupEdgeResponseItem.model_validate( - {"edge": edge, "fromDevice": "device12", "toDevice": "device7"} - ) - - -class FakeAPI: - def __init__(self): - self.devices = {} - self.vertices = {} - self.edges = {} - self.update_response = InspectApiUpdateTopologyResponse.model_validate( - load_fixture("update_topology_success.json") - ) - self.update_calls = [] - self.lookup_device_calls = [] - self.lookup_vertices_calls = [] - self.lookup_edges_calls = [] - - def lookup_inspect_device(self, device_id): - self.lookup_device_calls.append(device_id) - if device_id not in self.devices: - raise KeyError(device_id) - return self.devices[device_id] - - def lookup_vertices(self, ids): - self.lookup_vertices_calls.append(list(ids)) - return SimpleNamespace(data={i: self.vertices[i] for i in ids if i in self.vertices}) - - def lookup_edges(self, ids): - self.lookup_edges_calls.append(list(ids)) - return SimpleNamespace(data={i: self.edges[i] for i in ids if i in self.edges}) - - def update_topology(self, delta): - self.update_calls.append(delta) - return self.update_response - - -class FakeSnapshot: - def __init__(self): - self.calls = [] - - def apply_post_commit(self, **kwargs): - self.calls.append(kwargs) - - -def _txn(api, snapshot=None): - return InspectTransaction(api, snapshot=snapshot) - - # --- Staging + payload shape --- -def test_connect_builds_edge_payload(): +def test_connect_builds_edge_payload() -> None: api = FakeAPI() with _txn(api) as tx: tx.connect(A_OUT, B_IN, bidirectional=False, weight=10) @@ -149,7 +59,7 @@ def test_connect_builds_edge_payload(): assert edge.weight == 10 and edge.capacity == 65535 and edge.redundancyMode == "Any" -def test_connect_bidirectional_stages_reverse_edge(): +def test_connect_bidirectional_stages_reverse_edge() -> None: api = FakeAPI() with _txn(api) as tx: tx.connect(A_OUT, B_IN, bidirectional=True) @@ -158,7 +68,7 @@ def test_connect_bidirectional_stages_reverse_edge(): assert keys == {EDGE_ID, "device7.0.swp1.out::device12.1.Ethernet1.in"} -def test_connect_existing_edge_requires_overwrite(): +def test_connect_existing_edge_requires_overwrite() -> None: api = FakeAPI() api.edges[EDGE_ID] = _edge_item() with _txn(api) as tx: @@ -166,7 +76,7 @@ def test_connect_existing_edge_requires_overwrite(): tx.connect(A_OUT, B_IN, bidirectional=False) -def test_update_edge_applies_intent(): +def test_update_edge_applies_intent() -> None: api = FakeAPI() api.edges[EDGE_ID] = _edge_item() with _txn(api) as tx: @@ -175,14 +85,14 @@ def test_update_edge_applies_intent(): assert api.update_calls[0].replaceEdges[EDGE_ID].weight == 42 -def test_update_edge_missing_raises_not_found(): +def test_update_edge_missing_raises_not_found() -> None: api = FakeAPI() with _txn(api) as tx: with pytest.raises(InspectEntityNotFoundError): tx.update_edge(EDGE_ID, weight=42) -def test_update_device_only_changes_label_when_set(): +def test_update_device_only_changes_label_when_set() -> None: api = FakeAPI() api.devices["device12"] = _device_response(label="Original", icon_type="switch") with _txn(api) as tx: @@ -193,7 +103,7 @@ def test_update_device_only_changes_label_when_set(): assert form.iconType == "router" -def test_update_device_sets_label(): +def test_update_device_sets_label() -> None: api = FakeAPI() api.devices["device12"] = _device_response(label="Original") with _txn(api) as tx: @@ -202,7 +112,7 @@ def test_update_device_sets_label(): assert api.update_calls[0].replaceDevices["device12"].descriptor.label == "BU-LEAF-A" -def test_place_device_sets_coordinates(): +def test_place_device_sets_coordinates() -> None: api = FakeAPI() api.devices["device12"] = _device_response() with _txn(api) as tx: @@ -211,7 +121,7 @@ def test_place_device_sets_coordinates(): assert api.update_calls[0].replaceDevices["device12"].coordinates == {"x": 1600, "y": 9050} -def test_update_vertex_sets_endpoint(): +def test_update_vertex_sets_endpoint() -> None: api = FakeAPI() api.vertices[A_OUT] = _vertex_data() with _txn(api) as tx: @@ -220,7 +130,7 @@ def test_update_vertex_sets_endpoint(): assert api.update_calls[0].replaceVertices[A_OUT].useAsEndpoint is True -def test_update_vertex_assigns_tags_as_local(): +def test_update_vertex_assigns_tags_as_local() -> None: # Port tag assignment goes to localAssignedTags, not the plain fields.tags list. api = FakeAPI() api.vertices[A_OUT] = _vertex_data() @@ -232,7 +142,7 @@ def test_update_vertex_assigns_tags_as_local(): assert form.tags == [] -def test_remove_appends_to_remove_list(): +def test_remove_appends_to_remove_list() -> None: api = FakeAPI() with _txn(api) as tx: tx.remove(EDGE_ID) @@ -240,7 +150,7 @@ def test_remove_appends_to_remove_list(): assert api.update_calls[0].remove == [EDGE_ID] -def test_disconnect_removes_both_directions(): +def test_disconnect_removes_both_directions() -> None: api = FakeAPI() with _txn(api) as tx: tx.disconnect(A_OUT, B_IN, bidirectional=True) @@ -251,7 +161,7 @@ def test_disconnect_removes_both_directions(): # --- Commit result / failure --- -def test_commit_success_returns_result(): +def test_commit_success_returns_result() -> None: api = FakeAPI() api.edges[EDGE_ID] = _edge_item() with _txn(api) as tx: @@ -261,7 +171,7 @@ def test_commit_success_returns_result(): assert result.applied_ids == [EDGE_ID] -def test_commit_failure_raises_commit_error(): +def test_commit_failure_raises_commit_error() -> None: api = FakeAPI() api.update_response = InspectApiUpdateTopologyResponse.model_validate( load_fixture("update_topology_fail_remove.json") @@ -273,7 +183,7 @@ def test_commit_failure_raises_commit_error(): assert "non-existent" in str(exc.value) -def test_empty_commit_rejected(): +def test_empty_commit_rejected() -> None: api = FakeAPI() tx = _txn(api) with pytest.raises(ValueError, match="Nothing staged"): @@ -283,7 +193,7 @@ def test_empty_commit_rejected(): # --- Conflict detection (ADR-0009) --- -def test_conflict_detected_on_baseline_change(): +def test_conflict_detected_on_baseline_change() -> None: api = FakeAPI() api.edges[EDGE_ID] = _edge_item(weight=1) tx = _txn(api) @@ -298,7 +208,7 @@ def test_conflict_detected_on_baseline_change(): assert not api.update_calls # nothing sent -def test_conflict_check_bypass(): +def test_conflict_check_bypass() -> None: api = FakeAPI() api.edges[EDGE_ID] = _edge_item(weight=1) tx = _txn(api) @@ -308,7 +218,7 @@ def test_conflict_check_bypass(): assert api.update_calls # sent despite the change -def test_conflict_when_entity_vanishes(): +def test_conflict_when_entity_vanishes() -> None: api = FakeAPI() api.edges[EDGE_ID] = _edge_item() tx = _txn(api) @@ -319,7 +229,7 @@ def test_conflict_when_entity_vanishes(): assert "__exists__" in exc.value.conflicts[0].field_diffs -def test_rebase_refetches_baseline(): +def test_rebase_refetches_baseline() -> None: api = FakeAPI() api.edges[EDGE_ID] = _edge_item(weight=1) tx = _txn(api) @@ -335,7 +245,7 @@ def test_rebase_refetches_baseline(): # --- Post-commit refresh hook derivation (ADR-0010) --- -def test_post_commit_refresh_derives_affected_ids(): +def test_post_commit_refresh_derives_affected_ids() -> None: api = FakeAPI() snapshot = FakeSnapshot() with _txn(api, snapshot=snapshot) as tx: @@ -347,7 +257,7 @@ def test_post_commit_refresh_derives_affected_ids(): assert call["mark_paths_stale"] is True -def test_post_commit_refresh_vertex_maps_to_device(): +def test_post_commit_refresh_vertex_maps_to_device() -> None: api = FakeAPI() api.vertices[A_OUT] = _vertex_data() snapshot = FakeSnapshot() @@ -360,7 +270,7 @@ def test_post_commit_refresh_vertex_maps_to_device(): # --- Lifecycle --- -def test_reuse_after_commit_rejected(): +def test_reuse_after_commit_rejected() -> None: api = FakeAPI() api.edges[EDGE_ID] = _edge_item() tx = _txn(api) @@ -370,10 +280,111 @@ def test_reuse_after_commit_rejected(): tx.update_edge(EDGE_ID, weight=6) -def test_context_exit_without_commit_discards(caplog): +def test_context_exit_without_commit_discards(caplog: pytest.LogCaptureFixture) -> None: api = FakeAPI() api.edges[EDGE_ID] = _edge_item() with _txn(api) as tx: tx.update_edge(EDGE_ID, weight=5) assert not api.update_calls assert tx._discarded + + +# --- Internal --- + + +def _device_response( + label: str = "Dev A", + coordinates: dict[str, int] | None = None, + icon_type: str = "switch", + tags: list[str] | None = None, +) -> InspectApiLookupInspectDeviceResponse: + return InspectApiLookupInspectDeviceResponse.model_validate( + { + "data": { + "assignedTags": {"all": [], "inherited": {}, "inheritedConflict": False, "local": {}}, + "fields": { + "coordinates": coordinates or {"x": 0, "y": 0}, + "descriptor": {"desc": "", "label": label}, + "iconSize": "medium", + "iconType": icon_type, + "localAssignedTags": [], + "sdpStrategy": None, + "siteId": None, + "tags": tags or [], + "virtualDeviceFields": None, + }, + }, + "header": _OK_HEADER, + } + ) + + +def _vertex_data() -> InspectApiLookupVertexResponseData: + fixture = load_fixture("lookup_inspect_vertex_by_id.json") + return InspectApiLookupVertexResponseData.model_validate(fixture["data"]) + + +def _edge_item(weight: int = 1) -> InspectApiLookupEdgeResponseItem: + edge = { + "active": True, + "bandwidth": -1.0, + "capacity": 65535, + "conflictPri": 0, + "descriptor": {"desc": "", "label": ""}, + "excludeFormats": [], + "fDescriptor": {"desc": "", "label": ""}, + "fromId": A_OUT, + "includeFormats": [], + "redundancyMode": "Any", + "tags": [], + "toId": B_IN, + "weight": weight, + "weightFactors": {"bandwidth": {"weight": 0}, "service": {"max": 100, "weight": 0}}, + } + return InspectApiLookupEdgeResponseItem.model_validate( + {"edge": edge, "fromDevice": "device12", "toDevice": "device7"} + ) + + +class FakeAPI: + def __init__(self) -> None: + self.devices: dict[str, InspectApiLookupInspectDeviceResponse] = {} + self.vertices: dict[str, InspectApiLookupVertexResponseData] = {} + self.edges: dict[str, InspectApiLookupEdgeResponseItem] = {} + self.update_response = InspectApiUpdateTopologyResponse.model_validate( + load_fixture("update_topology_success.json") + ) + self.update_calls: list[Any] = [] + self.lookup_device_calls: list[str] = [] + self.lookup_vertices_calls: list[list[str]] = [] + self.lookup_edges_calls: list[list[str]] = [] + + def lookup_inspect_device(self, device_id: str) -> InspectApiLookupInspectDeviceResponse: + self.lookup_device_calls.append(device_id) + if device_id not in self.devices: + raise KeyError(device_id) + return self.devices[device_id] + + def lookup_vertices(self, ids: list[str]) -> SimpleNamespace: + self.lookup_vertices_calls.append(list(ids)) + return SimpleNamespace(data={i: self.vertices[i] for i in ids if i in self.vertices}) + + def lookup_edges(self, ids: list[str]) -> SimpleNamespace: + self.lookup_edges_calls.append(list(ids)) + return SimpleNamespace(data={i: self.edges[i] for i in ids if i in self.edges}) + + def update_topology(self, delta: Any) -> InspectApiUpdateTopologyResponse: + self.update_calls.append(delta) + return self.update_response + + +class FakeSnapshot: + def __init__(self) -> None: + self.calls: list[dict[str, Any]] = [] + + def apply_post_commit(self, **kwargs: Any) -> None: + self.calls.append(kwargs) + + +def _txn(api: FakeAPI, snapshot: FakeSnapshot | None = None) -> InspectTransaction: + return InspectTransaction(api, snapshot=snapshot) From 309cd2565b3c5cb1b97f1b5d0ab7757e5c67db10 Mon Sep 17 00:00:00 2001 From: Jonas Scholl <43819845+JonasScholl@users.noreply.github.com> Date: Thu, 16 Jul 2026 16:20:15 +0200 Subject: [PATCH 16/34] apply feedback --- .../apps/inspect/__init__.py | 31 ++- .../apps/inspect/app/write.py | 3 +- .../apps/inspect/domain/device.py | 51 ++++- .../apps/inspect/domain/port.py | 123 ++++++++--- .../apps/inspect/model/__init__.py | 9 + .../apps/inspect/model/actions.py | 10 +- .../apps/inspect/model/collector.py | 34 ++++ .../apps/inspect/model/common.py | 37 +++- .../apps/inspect/snapshot.py | 39 +++- .../apps/inspect/transaction.py | 8 +- .../device_hydration_modules_ports.json | 47 ++++- .../lookup_inspect_vertices_by_ids.json | 65 ++++++ .../2025.4.9/skeleton_nodestatus_short.json | 1 + tests/inspect/test_exports.py | 37 ++++ tests/inspect/test_models.py | 81 ++++++++ tests/inspect/test_snapshot.py | 191 +++++++++++++++++- 16 files changed, 730 insertions(+), 37 deletions(-) create mode 100644 tests/inspect/fixtures/2025.4.9/lookup_inspect_vertices_by_ids.json create mode 100644 tests/inspect/test_exports.py diff --git a/src/videoipath_automation_tool/apps/inspect/__init__.py b/src/videoipath_automation_tool/apps/inspect/__init__.py index d547716..2e64f2d 100644 --- a/src/videoipath_automation_tool/apps/inspect/__init__.py +++ b/src/videoipath_automation_tool/apps/inspect/__init__.py @@ -1,12 +1,39 @@ from __future__ import annotations +from videoipath_automation_tool.apps.inspect import model as _model from videoipath_automation_tool.apps.inspect.app.actions import ConflictStrategy as ConflictStrategy from videoipath_automation_tool.apps.inspect.transaction import CommitResult as CommitResult from videoipath_automation_tool.apps.inspect.transaction import InspectTransaction as InspectTransaction -from videoipath_automation_tool.apps.inspect.domain import * -from videoipath_automation_tool.apps.inspect.errors import * +from videoipath_automation_tool.apps.inspect.domain import InspectDevice as InspectDevice +from videoipath_automation_tool.apps.inspect.domain import InspectEdge as InspectEdge +from videoipath_automation_tool.apps.inspect.domain import InspectPort as InspectPort +from videoipath_automation_tool.apps.inspect.domain import InspectService as InspectService +from videoipath_automation_tool.apps.inspect.errors import InspectCommitConflictError as InspectCommitConflictError +from videoipath_automation_tool.apps.inspect.errors import InspectCommitError as InspectCommitError +from videoipath_automation_tool.apps.inspect.errors import InspectConflict as InspectConflict +from videoipath_automation_tool.apps.inspect.errors import InspectEntityNotFoundError as InspectEntityNotFoundError +from videoipath_automation_tool.apps.inspect.errors import InspectError as InspectError +from videoipath_automation_tool.apps.inspect.errors import InspectQueryTooLongError as InspectQueryTooLongError from videoipath_automation_tool.apps.inspect.app import InspectApp as InspectApp from videoipath_automation_tool.apps.inspect.model import * # InspectSnapshot is an internal implementation detail of InspectApp ([ADR-0007]); it is not part of # the public API. Interact with the topology entirely through ``app.inspect``. + +__all__ = [ + "CommitResult", + "ConflictStrategy", + "InspectApp", + "InspectCommitConflictError", + "InspectCommitError", + "InspectConflict", + "InspectDevice", + "InspectEdge", + "InspectEntityNotFoundError", + "InspectError", + "InspectPort", + "InspectQueryTooLongError", + "InspectService", + "InspectTransaction", + *_model.__all__, +] diff --git a/src/videoipath_automation_tool/apps/inspect/app/write.py b/src/videoipath_automation_tool/apps/inspect/app/write.py index 054984b..652e417 100644 --- a/src/videoipath_automation_tool/apps/inspect/app/write.py +++ b/src/videoipath_automation_tool/apps/inspect/app/write.py @@ -16,6 +16,7 @@ from videoipath_automation_tool.apps.inspect.transaction import CommitResult, InspectTransaction from videoipath_automation_tool.apps.inspect.api import InspectAPI +from videoipath_automation_tool.apps.inspect.model.common import InspectIconType if TYPE_CHECKING: from videoipath_automation_tool.apps.inspect.snapshot import InspectSnapshot @@ -47,7 +48,7 @@ def update_device( device_id: str, *, label: Optional[str] = None, - icon_type: Optional[str] = None, + icon_type: Optional[InspectIconType | str] = None, sdp_strategy: Optional[str] = None, tags: Optional[list[str]] = None, coordinates: Optional[dict[str, float]] = None, diff --git a/src/videoipath_automation_tool/apps/inspect/domain/device.py b/src/videoipath_automation_tool/apps/inspect/domain/device.py index 450e4ad..82e6752 100644 --- a/src/videoipath_automation_tool/apps/inspect/domain/device.py +++ b/src/videoipath_automation_tool/apps/inspect/domain/device.py @@ -3,7 +3,13 @@ from datetime import datetime from typing import TYPE_CHECKING -from videoipath_automation_tool.apps.inspect.model.common import InspectApiStatusSummary, InspectFrozenModel +from videoipath_automation_tool.apps.inspect.model.common import ( + InspectApiStatusSummary, + InspectFrozenModel, + InspectIconType, + InspectVertexKind, + InspectVertexType, +) from videoipath_automation_tool.apps.inspect.snapshot import InspectSnapshot if TYPE_CHECKING: @@ -26,6 +32,10 @@ class InspectDevice(InspectFrozenModel): def label(self) -> str | None: return self._record().label + @property + def description(self) -> str | None: + return self._record().node.effective_description + @property def pid(self) -> str | None: return self._record().pid @@ -36,7 +46,7 @@ def is_virtual(self) -> bool | None: return meta.isVirtual if meta is not None else None @property - def icon_type(self) -> str | None: + def icon_type(self) -> InspectIconType | str | None: meta = self._record().node.meta return meta.iconType if meta is not None else None @@ -68,6 +78,43 @@ def fetched_at(self) -> datetime | None: def ports(self) -> list[InspectPort]: return self.snapshot.get_ports_for_device(self.id) + def filter_ports( + self, + *, + module_id: str | None = None, + vertex_type: InspectVertexType | str | None = None, + kind: InspectVertexKind | str | None = None, + active: bool | None = None, + controlled: bool | None = None, + endpoint: bool | None = None, + ) -> list[InspectPort]: + """Filter this device's ports/vertices. + + All filters except ``kind`` are evaluated from already-hydrated data (at most the one lazy + device-detail fetch that ``ports`` itself triggers). ``kind`` (``"generic"`` / ``"ip"`` / + ``"codec"`` / ``"router"``) requires the vertex edit form: uncached vertices are resolved + with a single batched ``lookupInspectVertexByIds`` call. A port whose value for an explicit + filter is unknown never matches. + """ + result: list[InspectPort] = [] + for port in self.ports: + if module_id is not None and port.module_id != module_id: + continue + if vertex_type is not None and port.vertex_type != vertex_type: + continue + if active is not None and port.is_active is not active: + continue + if controlled is not None and port.is_controlled is not controlled: + continue + if endpoint is not None and port.is_endpoint is not endpoint: + continue + result.append(port) + + if kind is not None: + self.snapshot.get_vertex_details_many([p.vertex_id for p in result if p.vertex_id]) + result = [p for p in result if p.vertex_kind == kind] + return result + @property def edges(self) -> list[InspectEdge]: return self.snapshot.get_edges_for_device(self.id) diff --git a/src/videoipath_automation_tool/apps/inspect/domain/port.py b/src/videoipath_automation_tool/apps/inspect/domain/port.py index 15a22aa..bc6265f 100644 --- a/src/videoipath_automation_tool/apps/inspect/domain/port.py +++ b/src/videoipath_automation_tool/apps/inspect/domain/port.py @@ -3,15 +3,26 @@ from typing import TYPE_CHECKING from videoipath_automation_tool.apps.inspect.model.collector import ( + InspectApiDoubleVertexInfo, InspectApiSingleVertexInfo, - InspectPortStatus, ) -from videoipath_automation_tool.apps.inspect.model.common import InspectApiStatusSummary, InspectFrozenModel -from videoipath_automation_tool.apps.inspect.snapshot import InspectSnapshot, _IndexedPort, _port_id_from_status +from videoipath_automation_tool.apps.inspect.model.common import ( + InspectApiStatusSummary, + InspectFrozenModel, + InspectVertexKind, + InspectVertexType, +) +from videoipath_automation_tool.apps.inspect.snapshot import ( + InspectSnapshot, + _IndexedPort, + _port_id_from_status, + _vertex_ids_from_status, +) if TYPE_CHECKING: from videoipath_automation_tool.apps.inspect.domain.device import InspectDevice from videoipath_automation_tool.apps.inspect.domain.edge import InspectEdge + from videoipath_automation_tool.apps.inspect.model.actions import InspectApiLookupVertexResponseData class InspectPort(InspectFrozenModel): @@ -24,8 +35,19 @@ def id(self) -> str | None: @property def label(self) -> str | None: + """The label the UI shows: the manual override (``descriptor.label``), falling back to the + device-reported factory label.""" return self.indexed.port.effective_label + @property + def factory_label(self) -> str | None: + """The device-reported (factory) port label, even when a manual override is set.""" + return self.indexed.port.label + + @property + def description(self) -> str | None: + return self.indexed.port.effective_description + @property def device(self) -> InspectDevice | None: return self.snapshot.get_device_by_id(self.indexed.device_id) @@ -44,9 +66,69 @@ def tags(self) -> list[str]: ``app.inspect.update_vertex(port.vertex_id, tags=[...])``.""" return self.indexed.port.assigned_tags + @property + def vertex_info(self) -> InspectApiSingleVertexInfo | InspectApiDoubleVertexInfo | None: + """The port's raw (typed) ``vertexInfo`` from the collector.""" + return self.indexed.port.parsed_vertex_info + + @property + def vertex_type(self) -> InspectVertexType | str | None: + """Vertex direction: ``"In"`` / ``"Out"`` / ``"Internal"`` / …; ``"BiDirectional"`` when the + port carries a double (in+out) vertexInfo.""" + info = self.vertex_info + if info is None: + return None + if isinstance(info, InspectApiDoubleVertexInfo): + return "BiDirectional" + return info.vertexType + + @property + def is_bidirectional(self) -> bool | None: + info = self.vertex_info + return isinstance(info, InspectApiDoubleVertexInfo) if info is not None else None + + @property + def is_active(self) -> bool | None: + return self._vertex_flag("isActive") + + @property + def is_controlled(self) -> bool | None: + return self._vertex_flag("isControlled") + + @property + def is_endpoint(self) -> bool | None: + """Whether the vertex is usable as a service endpoint ("use as endpoint" in the UI).""" + return self._vertex_flag("isEndpoint") + @property def vertex_id(self) -> str | None: - return self._vertex_id_from(self.indexed.port) + vertex_ids = self.vertex_ids + return vertex_ids[0] if vertex_ids else None + + @property + def vertex_ids(self) -> tuple[str, ...]: + """All vertex ids of this port: one for a single vertex, (out, in) for a bidirectional.""" + return _vertex_ids_from_status(self.indexed.port) + + @property + def vertex_details(self) -> InspectApiLookupVertexResponseData | None: + """Full vertex edit-form data (``lookupInspectVertexByIds``): active, useAsEndpoint, + sipsMode, controlProps, typeFields (ip address, vlan, …) and more. First access per vertex + triggers one server lookup; results are cached on the snapshot and invalidated when the + owning device is refreshed after a commit.""" + vertex_id = self.vertex_id + if not vertex_id: + return None + return self.snapshot.get_vertex_details(vertex_id) + + @property + def vertex_kind(self) -> InspectVertexKind | str | None: + """Vertex kind: ``"generic"`` / ``"ip"`` / ``"codec"`` / ``"router"`` — from the vertex edit + form's ``typeFields.type`` (triggers the lazy ``vertex_details`` lookup on first access).""" + details = self.vertex_details + if details is None or details.fields.typeFields is None: + return None + return details.fields.typeFields.type @property def edge(self) -> InspectEdge | None: @@ -55,25 +137,18 @@ def edge(self) -> InspectEdge | None: return None return self.snapshot.get_edge_for_port(self.indexed.device_id, port_id) - @staticmethod - def _vertex_id_from(port: InspectPortStatus) -> str | None: - vertex_info = port.vertexInfo - if vertex_info is None: + def _vertex_flag(self, name: str) -> bool | None: + """Resolve a ``vertexInfo.fields`` flag; for double vertices: True if either side is True, + False only if all sides are known False, else None.""" + info = self.vertex_info + if info is None: return None - if isinstance(vertex_info, InspectApiSingleVertexInfo): - return vertex_info.id - if isinstance(vertex_info, dict): - vertex_type = vertex_info.get("type") - if vertex_type == "single": - single = vertex_info.get("id") - return single if isinstance(single, str) else None - if vertex_type == "double": - for key in ("in", "out"): - side = vertex_info.get(key) - if isinstance(side, dict) and isinstance(side.get("id"), str): - return side["id"] - else: - for candidate in (getattr(vertex_info, "out", None), getattr(vertex_info, "in_", None)): - if candidate is not None and candidate.id: - return candidate.id + sides = (info,) if isinstance(info, InspectApiSingleVertexInfo) else (info.out, info.in_) + values = [ + getattr(side.fields, name) if side is not None and side.fields is not None else None for side in sides + ] + if any(value is True for value in values): + return True + if values and all(value is False for value in values): + return False return None diff --git a/src/videoipath_automation_tool/apps/inspect/model/__init__.py b/src/videoipath_automation_tool/apps/inspect/model/__init__.py index cf78aaf..adf27b2 100644 --- a/src/videoipath_automation_tool/apps/inspect/model/__init__.py +++ b/src/videoipath_automation_tool/apps/inspect/model/__init__.py @@ -1,7 +1,16 @@ from __future__ import annotations +from videoipath_automation_tool.apps.inspect.model import actions, collector, common, ngraph, update_topology from videoipath_automation_tool.apps.inspect.model.actions import * from videoipath_automation_tool.apps.inspect.model.collector import * from videoipath_automation_tool.apps.inspect.model.common import * from videoipath_automation_tool.apps.inspect.model.ngraph import * from videoipath_automation_tool.apps.inspect.model.update_topology import * + +__all__ = [ + *actions.__all__, + *collector.__all__, + *common.__all__, + *ngraph.__all__, + *update_topology.__all__, +] diff --git a/src/videoipath_automation_tool/apps/inspect/model/actions.py b/src/videoipath_automation_tool/apps/inspect/model/actions.py index 5b351be..0994764 100644 --- a/src/videoipath_automation_tool/apps/inspect/model/actions.py +++ b/src/videoipath_automation_tool/apps/inspect/model/actions.py @@ -66,12 +66,19 @@ class InspectApiVertexTypeFields(InspectApiBaseModel): vrfId: str | None = None +class InspectApiVertexControlProps(InspectApiBaseModel): + """Control properties of a controlled vertex (verified against a live 2025.4.9 server).""" + + configPriority: str | None = None + onlyInitial: bool | None = None + + class InspectApiVertexEditForm(InspectApiBaseModel): """The vertex ``fields`` object returned by ``lookupInspectVertexById`` — this exact shape is what ``replaceVertices`` accepts (update-only; verified 2025.4.9).""" active: bool | None = None - controlProps: Any | None = None + controlProps: InspectApiVertexControlProps | None = None custom: dict[str, Any] = Field(default_factory=dict) desc: str = "" destinationMonitorLeader: bool | None = None @@ -218,6 +225,7 @@ class InspectApiSyncDevicesRequest(InspectApiBaseModel): "InspectApiLookupVertexResponseData", "InspectApiSyncDevicesRequest", "InspectApiSyncDevicesRequestData", + "InspectApiVertexControlProps", "InspectApiVertexEditForm", "InspectApiVertexTypeFields", ] diff --git a/src/videoipath_automation_tool/apps/inspect/model/collector.py b/src/videoipath_automation_tool/apps/inspect/model/collector.py index 3d6370d..9d61710 100644 --- a/src/videoipath_automation_tool/apps/inspect/model/collector.py +++ b/src/videoipath_automation_tool/apps/inspect/model/collector.py @@ -138,6 +138,30 @@ def effective_label(self) -> str | None: return self.descriptor.label return self.label + @property + def effective_description(self) -> str | None: + """The user-set port description (``descriptor.desc``), if any.""" + if self.descriptor is not None and self.descriptor.desc: + return self.descriptor.desc + return None + + @property + def parsed_vertex_info(self) -> InspectApiSingleVertexInfo | InspectApiDoubleVertexInfo | None: + """``vertexInfo`` coerced to its typed form (handles the raw-dict fallback branch).""" + vertex_info = self.vertexInfo + if isinstance(vertex_info, (InspectApiSingleVertexInfo, InspectApiDoubleVertexInfo)): + return vertex_info + if isinstance(vertex_info, dict): + model = {"single": InspectApiSingleVertexInfo, "double": InspectApiDoubleVertexInfo}.get( + vertex_info.get("type", "") + ) + if model is not None: + try: + return model.model_validate(vertex_info) + except ValueError: + return None + return None + class InspectApiModuleStatus(InspectApiBaseModel): id: str | None = Field(default=None, alias="_id") @@ -181,6 +205,16 @@ def effective_label(self) -> str | None: return self.fDescriptor.label return self.label + @property + def effective_description(self) -> str | None: + """The description the UI shows: user ``descriptor.desc``, falling back to the + device-reported ``fDescriptor.desc``.""" + if self.descriptor is not None and self.descriptor.desc: + return self.descriptor.desc + if self.fDescriptor is not None and self.fDescriptor.desc: + return self.fDescriptor.desc + return None + @property def coordinates(self) -> dict[str, float | int | str | None] | None: return self.meta.coordinates if self.meta is not None else None diff --git a/src/videoipath_automation_tool/apps/inspect/model/common.py b/src/videoipath_automation_tool/apps/inspect/model/common.py index 7986fe6..375f151 100644 --- a/src/videoipath_automation_tool/apps/inspect/model/common.py +++ b/src/videoipath_automation_tool/apps/inspect/model/common.py @@ -1,9 +1,41 @@ from __future__ import annotations -from typing import Any +from typing import Any, Literal from pydantic import BaseModel, ConfigDict, Field +# The icon types selectable in the VideoIPath UI (mirrors the topology app's ``IconType``; live data +# may contain further values, so read/write surfaces use the permissive ``InspectIconType | str``). +InspectIconType = Literal[ + "default", + "none", + "device", + "camera", + "monitor", + "encoder", + "decoder", + "audioMixer", + "videoMixer", + "processingDevice", + "transportStreamProcessor", + "mediaDevice", + "server", + "gateway", + "ipSwitchRouter", + "vlanCloud", + "videoAudioRouterMatrix", + "encoderDecoder", +] + +# Vertex direction as reported in ``vertexInfo.vertexType`` (a "double" vertexInfo is the +# bidirectional case and is surfaced as "BiDirectional"). +InspectVertexType = Literal["BiDirectional", "In", "Internal", "Out", "Undecided"] + +# Vertex kind from the vertex edit form's ``typeFields.type``. "ip", "codec" and "router" are +# verified against a live 2025.4.9 server; "generic" is inferred from the nGraph element types. +# Read surfaces use the permissive ``InspectVertexKind | str`` for unknown future kinds. +InspectVertexKind = Literal["generic", "ip", "codec", "router"] + class InspectApiBaseModel(BaseModel): model_config = ConfigDict(populate_by_name=True, validate_assignment=True, extra="allow") @@ -94,4 +126,7 @@ class InspectApiActionValidationErrorResponse(InspectApiBaseModel): "InspectApiSimpleActionResult", "InspectApiStatusContext", "InspectApiStatusSummary", + "InspectIconType", + "InspectVertexKind", + "InspectVertexType", ] diff --git a/src/videoipath_automation_tool/apps/inspect/snapshot.py b/src/videoipath_automation_tool/apps/inspect/snapshot.py index 2021117..9b581cd 100644 --- a/src/videoipath_automation_tool/apps/inspect/snapshot.py +++ b/src/videoipath_automation_tool/apps/inspect/snapshot.py @@ -30,6 +30,7 @@ InspectApiModuleStatus, InspectApiNodeStatusItem, InspectApiPathItem, + InspectApiSingleVertexInfo, InspectPortStatus, ) from videoipath_automation_tool.apps.inspect.model.common import InspectFrozenModel, InspectInternalModel @@ -40,6 +41,7 @@ from videoipath_automation_tool.apps.inspect.domain.port import InspectPort from videoipath_automation_tool.apps.inspect.domain.service import InspectService from videoipath_automation_tool.apps.inspect.api import InspectAPI + from videoipath_automation_tool.apps.inspect.model.actions import InspectApiLookupVertexResponseData class HydrationLevel(str, Enum): @@ -73,6 +75,10 @@ def __init__( self._port_by_key: dict[tuple[str, str], _IndexedPort] = {} self._ports_by_pid: dict[str, list[_IndexedPort]] = {} + # Vertex edit-form details, fetched lazily per vertex ([ADR-0007]) and invalidated when the + # owning device's ports are rebuilt after a refresh/commit. + self._vertex_details: dict[str, "InspectApiLookupVertexResponseData"] = {} + # Section: services / paths self._paths_by_booking_id: dict[str, InspectApiPathItem] = {} self._services_by_device_id: dict[str, list[str]] = {} @@ -183,6 +189,23 @@ def find_port_by_id(self, port_id: str) -> Optional["InspectPort"]: indexed = self._ports_by_pid.get(port_id) return self._wrap_port(indexed[0]) if indexed else None + # --- Vertex detail reads (trigger lookup) --- + + def get_vertex_details(self, vertex_id: str) -> Optional["InspectApiLookupVertexResponseData"]: + return self.get_vertex_details_many([vertex_id]).get(vertex_id) + + def get_vertex_details_many(self, vertex_ids: list[str]) -> dict[str, "InspectApiLookupVertexResponseData"]: + """Batched, cached vertex edit-form lookup (``lookupInspectVertexByIds``). Only uncached ids + are fetched, in a single call; without a fetcher only cached entries are returned.""" + missing = [vertex_id for vertex_id in vertex_ids if vertex_id not in self._vertex_details] + if missing and self._fetcher is not None: + response = self._fetcher.lookup_vertices(missing) + with self._lock: + self._vertex_details.update(response.data) + return { + vertex_id: detail for vertex_id in vertex_ids if (detail := self._vertex_details.get(vertex_id)) is not None + } + # --- Edge reads (no hydration) --- @property @@ -482,6 +505,8 @@ def _rebuild_device_ports(self, device_id: str, node: InspectApiNodeStatusItem) # Drop existing port index entries for this device old = self._ports_by_device_id.pop(device_id, []) for indexed in old: + for vertex_id in _vertex_ids_from_status(indexed.port): + self._vertex_details.pop(vertex_id, None) port_id = _port_id_from_status(indexed.port) if port_id is not None: self._port_by_key.pop((device_id, port_id), None) @@ -602,7 +627,9 @@ def _apply_removals(self, removed_ids: list[str]) -> None: if not self._devices_by_label[label]: self._devices_by_label.pop(label, None) self._device_cache.pop(removed, None) - self._ports_by_device_id.pop(removed, None) + for indexed in self._ports_by_device_id.pop(removed, []): + for vertex_id in _vertex_ids_from_status(indexed.port): + self._vertex_details.pop(vertex_id, None) self._edges_by_device_id.pop(removed, None) # Edge removal by edge id or pair id if "::" in removed: @@ -742,6 +769,16 @@ def _port_id_from_status(port: InspectPortStatus) -> str | None: return port_id if port_id else None +def _vertex_ids_from_status(port: InspectPortStatus) -> tuple[str, ...]: + """All vertex ids carried by a port's ``vertexInfo`` (one for single, out+in for double).""" + info = port.parsed_vertex_info + if info is None: + return () + if isinstance(info, InspectApiSingleVertexInfo): + return (info.id,) if info.id else () + return tuple(side.id for side in (info.out, info.in_) if side is not None and side.id) + + def _iter_modules( modules: dict[str, InspectApiModuleStatus] | list[InspectApiModuleStatus] | None, ) -> Iterator[InspectApiModuleStatus]: diff --git a/src/videoipath_automation_tool/apps/inspect/transaction.py b/src/videoipath_automation_tool/apps/inspect/transaction.py index 953ea69..3410ec7 100644 --- a/src/videoipath_automation_tool/apps/inspect/transaction.py +++ b/src/videoipath_automation_tool/apps/inspect/transaction.py @@ -39,7 +39,11 @@ InspectApiLookupInspectDeviceFields, InspectApiVertexEditForm, ) -from videoipath_automation_tool.apps.inspect.model.common import InspectFrozenModel, InspectInternalModel +from videoipath_automation_tool.apps.inspect.model.common import ( + InspectFrozenModel, + InspectIconType, + InspectInternalModel, +) from videoipath_automation_tool.apps.inspect.model.update_topology import ( InspectApiUpdateTopologyData, InspectApiUpdateTopologyResponse, @@ -126,7 +130,7 @@ def update_device( device_id: str, *, label: Optional[str] = None, - icon_type: Optional[str] = None, + icon_type: Optional[InspectIconType | str] = None, sdp_strategy: Optional[str] = None, tags: Optional[list[str]] = None, coordinates: Optional[dict[str, float]] = None, diff --git a/tests/inspect/fixtures/2025.4.9/device_hydration_modules_ports.json b/tests/inspect/fixtures/2025.4.9/device_hydration_modules_ports.json index 2aa9e50..08de981 100644 --- a/tests/inspect/fixtures/2025.4.9/device_hydration_modules_ports.json +++ b/tests/inspect/fixtures/2025.4.9/device_hydration_modules_ports.json @@ -13,9 +13,54 @@ "ports": { "device-a.dev.module-1.port-out-1": { "descriptor": { + "desc": "Example port description", "label": "port-out-1 (out)" }, - "pid": "device-a.dev.module-1.port-out-1" + "label": "port-out-1", + "pid": "device-a.dev.module-1.port-out-1", + "vertexInfo": { + "type": "single", + "id": "device-a.module-1.port-out-1.out", + "label": "port-out-1 (out)", + "vertexType": "Out", + "fields": { + "isActive": true, + "isControlled": true, + "isEndpoint": false + } + } + }, + "device-a.dev.module-1.port-bidi-1": { + "descriptor": { + "label": "port-bidi-1" + }, + "label": "port-bidi-1", + "pid": "device-a.dev.module-1.port-bidi-1", + "vertexInfo": { + "type": "double", + "in": { + "type": "single", + "id": "device-a.module-1.port-bidi-1.in", + "label": "port-bidi-1 (in)", + "vertexType": "In", + "fields": { + "isActive": true, + "isControlled": false, + "isEndpoint": true + } + }, + "out": { + "type": "single", + "id": "device-a.module-1.port-bidi-1.out", + "label": "port-bidi-1 (out)", + "vertexType": "Out", + "fields": { + "isActive": true, + "isControlled": false, + "isEndpoint": true + } + } + } } } }, diff --git a/tests/inspect/fixtures/2025.4.9/lookup_inspect_vertices_by_ids.json b/tests/inspect/fixtures/2025.4.9/lookup_inspect_vertices_by_ids.json new file mode 100644 index 0000000..b4c285a --- /dev/null +++ b/tests/inspect/fixtures/2025.4.9/lookup_inspect_vertices_by_ids.json @@ -0,0 +1,65 @@ +{ + "data": { + "device-a.module-1.port-out-1.out": { + "assignedTags": { + "all": [], + "inherited": {}, + "inheritedConflict": false, + "local": {} + }, + "context": { + "devicePid": "device-a", + "modulePid": "device-a.dev.module-1", + "portPid": "device-a.dev.module-1.port-out-1" + }, + "customSchemas": {}, + "fields": { + "active": true, + "controlProps": { + "configPriority": "off", + "onlyInitial": false + }, + "custom": {}, + "desc": "", + "destinationMonitorLeader": false, + "extraAlertFilters": [], + "label": "Port A (out)", + "localAssignedTags": [], + "queueable": false, + "sipsMode": "NONE", + "tags": [], + "typeFields": { + "ipAddress": null, + "ipNetmask": null, + "public": false, + "supportsCpipeCfg": false, + "supportsIgmpCfg": false, + "supportsMacForwardingCfg": false, + "supportsNsoCfg": false, + "supportsOpenflowCfg": false, + "supportsStaticIgmpCfg": false, + "supportsVlanCfg": false, + "supportsVplsCfg": false, + "type": "ip", + "vlanId": null, + "vrfId": null + }, + "useAsEndpoint": false + }, + "id": "device-a.module-1.port-out-1.out", + "isVirtual": false, + "vertexType": "Out" + } + }, + "header": { + "auth": true, + "caption": "Operation Successful", + "code": "OK", + "errorCodes": [], + "errorDetails": [], + "id": "0", + "msg": [], + "ok": true, + "user": "api-user" + } +} diff --git a/tests/inspect/fixtures/2025.4.9/skeleton_nodestatus_short.json b/tests/inspect/fixtures/2025.4.9/skeleton_nodestatus_short.json index 323db04..eed824f 100644 --- a/tests/inspect/fixtures/2025.4.9/skeleton_nodestatus_short.json +++ b/tests/inspect/fixtures/2025.4.9/skeleton_nodestatus_short.json @@ -9,6 +9,7 @@ "_id": "device-h", "_vid": "device-h", "descriptor": { + "desc": "Example device description", "label": "Example Device A" }, "deviceId": "device-h", diff --git a/tests/inspect/test_exports.py b/tests/inspect/test_exports.py new file mode 100644 index 0000000..3763b91 --- /dev/null +++ b/tests/inspect/test_exports.py @@ -0,0 +1,37 @@ +"""Export contract tests: the inspect package and its model package define a complete ``__all__`` +so star imports stay well-defined (no namespace pollution).""" + +from __future__ import annotations + +import videoipath_automation_tool.apps +from videoipath_automation_tool.apps import inspect +from videoipath_automation_tool.apps.inspect import model +from videoipath_automation_tool.apps.inspect.model import actions, collector, common, ngraph, update_topology + + +def test_model_package_all_aggregates_submodules() -> None: + expected = { + *actions.__all__, + *collector.__all__, + *common.__all__, + *ngraph.__all__, + *update_topology.__all__, + } + assert set(model.__all__) == expected + assert len(model.__all__) == len(set(model.__all__)) # no duplicates across submodules + + +def test_model_package_all_names_resolve() -> None: + for name in model.__all__: + assert hasattr(model, name), f"model.__all__ contains unresolvable name: {name}" + + +def test_inspect_package_all_names_resolve() -> None: + assert {"InspectApp", "InspectDevice", "InspectError"} <= set(inspect.__all__) + for name in inspect.__all__: + assert hasattr(inspect, name), f"inspect.__all__ contains unresolvable name: {name}" + + +def test_apps_star_import_is_clean() -> None: + assert videoipath_automation_tool.apps.inspect is inspect + assert hasattr(inspect, "__all__") diff --git a/tests/inspect/test_models.py b/tests/inspect/test_models.py index 0134e52..5c4cfa8 100644 --- a/tests/inspect/test_models.py +++ b/tests/inspect/test_models.py @@ -10,11 +10,14 @@ InspectApiLookupEdgesResponse, InspectApiLookupInspectDeviceResponse, InspectApiLookupVertexResponse, + InspectApiLookupVerticesResponse, ) from videoipath_automation_tool.apps.inspect.model.collector import ( + InspectApiDoubleVertexInfo, InspectApiExternalEdgesByDeviceKeyItem, InspectApiNodeStatusItem, InspectApiPathItem, + InspectApiSingleVertexInfo, InspectPortStatus, ) from videoipath_automation_tool.apps.inspect.model.update_topology import ( @@ -32,6 +35,26 @@ def test_device_skeleton_items_parse_and_expose_effective_label(load: Callable[[ assert node.effective_label # comes from descriptor.label, not a top-level label +def test_device_skeleton_exposes_description(load: Callable[[str], dict[str, Any]]) -> None: + items = _node_items(load("skeleton_nodestatus_short.json")) + node = InspectApiNodeStatusItem.model_validate(items[0]) + assert node.effective_description == "Example device description" + + +def test_node_effective_description_prefers_descriptor_then_fdescriptor() -> None: + both = InspectApiNodeStatusItem.model_validate( + {"_id": "device-a", "descriptor": {"desc": "user desc"}, "fDescriptor": {"desc": "factory desc"}} + ) + assert both.effective_description == "user desc" + + factory_only = InspectApiNodeStatusItem.model_validate( + {"_id": "device-a", "descriptor": {"desc": ""}, "fDescriptor": {"desc": "factory desc"}} + ) + assert factory_only.effective_description == "factory desc" + + assert InspectApiNodeStatusItem.model_validate({"_id": "device-a"}).effective_description is None + + def test_device_detail_parses_modules_and_ports(load: Callable[[str], dict[str, Any]]) -> None: items = _node_items(load("device_hydration_modules_ports.json")) node = InspectApiNodeStatusItem.model_validate(items[0]) @@ -40,6 +63,54 @@ def test_device_detail_parses_modules_and_ports(load: Callable[[str], dict[str, assert module.ports +def test_device_detail_ports_expose_factory_label_and_override(load: Callable[[str], dict[str, Any]]) -> None: + items = _node_items(load("device_hydration_modules_ports.json")) + node = InspectApiNodeStatusItem.model_validate(items[0]) + ports = next(iter(node.modules.values())).ports + port = ports["device-a.dev.module-1.port-out-1"] + assert port.label == "port-out-1" # factory label survives the override + assert port.effective_label == "port-out-1 (out)" + assert port.effective_description == "Example port description" + + +def test_port_vertex_info_parses_single_and_double(load: Callable[[str], dict[str, Any]]) -> None: + items = _node_items(load("device_hydration_modules_ports.json")) + node = InspectApiNodeStatusItem.model_validate(items[0]) + ports = next(iter(node.modules.values())).ports + + single = ports["device-a.dev.module-1.port-out-1"].parsed_vertex_info + assert isinstance(single, InspectApiSingleVertexInfo) + assert single.vertexType == "Out" + assert single.fields is not None and single.fields.isActive is True and single.fields.isControlled is True + + double = ports["device-a.dev.module-1.port-bidi-1"].parsed_vertex_info + assert isinstance(double, InspectApiDoubleVertexInfo) + assert double.in_ is not None and double.in_.vertexType == "In" + assert double.out is not None and double.out.vertexType == "Out" + + +def test_port_parsed_vertex_info_coerces_raw_dict() -> None: + port = InspectPortStatus.model_construct( + vertexInfo={"type": "single", "id": "device-a.1.p1.out", "vertexType": "Out"} + ) + info = port.parsed_vertex_info + assert isinstance(info, InspectApiSingleVertexInfo) + assert info.id == "device-a.1.p1.out" + + assert InspectPortStatus.model_construct(vertexInfo={"type": "unknown"}).parsed_vertex_info is None + assert InspectPortStatus.model_validate({"_id": "device-a.1.p1"}).parsed_vertex_info is None + + +def test_lookup_vertices_batch_response_parses(load: Callable[[str], dict[str, Any]]) -> None: + resp = InspectApiLookupVerticesResponse.model_validate(load("lookup_inspect_vertices_by_ids.json")) + details = resp.data["device-a.module-1.port-out-1.out"] + assert details.vertexType == "Out" + assert details.fields.typeFields is not None and details.fields.typeFields.type == "ip" + assert details.fields.controlProps is not None + assert details.fields.controlProps.configPriority == "off" + assert details.fields.controlProps.onlyInitial is False + + def test_edge_skeleton_items_parse(load: Callable[[str], dict[str, Any]]) -> None: items = load("edge_skeleton.json")["data"]["status"]["collector"]["externalEdgesByDeviceKey"]["_items"] assert len(items) > 0 @@ -100,6 +171,16 @@ def test_commit_success_and_failure_flags(load: Callable[[str], dict[str, Any]]) assert fail_remove.committed is False +def test_inspect_icon_type_matches_topology_icon_type() -> None: + """Drift guard: the inspect-local Literal must stay in sync with the topology app's IconType.""" + from typing import get_args + + from videoipath_automation_tool.apps.inspect.model.common import InspectIconType + from videoipath_automation_tool.apps.topology.model.n_graph_elements.topology_n_graph_element import IconType + + assert set(get_args(InspectIconType)) == set(get_args(IconType)) + + def test_port_assigned_tags_from_tags_info() -> None: port = InspectPortStatus.model_validate( { diff --git a/tests/inspect/test_snapshot.py b/tests/inspect/test_snapshot.py index 8aaf207..b95fc2a 100644 --- a/tests/inspect/test_snapshot.py +++ b/tests/inspect/test_snapshot.py @@ -7,6 +7,7 @@ import pytest +from videoipath_automation_tool.apps.inspect.model.actions import InspectApiLookupVerticesResponse from videoipath_automation_tool.apps.inspect.model.collector import ( InspectApiExternalEdgesByDeviceKeyItem, InspectApiNodeStatusItem, @@ -31,6 +32,11 @@ def test_find_by_label(snapshot: tuple[InspectSnapshot, FakeFetcher]) -> None: assert snap.find_device_by_label("SPINE-A").id == "spine-a" +def test_device_description_from_descriptor(snapshot: tuple[InspectSnapshot, FakeFetcher]) -> None: + snap, _ = snapshot + assert snap.get_device("leaf-a").description == "LEAF-A description" + + def test_ports_trigger_exactly_one_hydration(snapshot: tuple[InspectSnapshot, FakeFetcher]) -> None: snap, fetcher = snapshot leaf = snap.get_device("leaf-a") @@ -179,6 +185,89 @@ def test_full_snapshot_is_hydrated_without_fetcher() -> None: snap.refresh() +def test_port_exposes_direction_flags_and_factory_label(snapshot: tuple[InspectSnapshot, FakeFetcher]) -> None: + snap, fetcher = snapshot + port = snap.get_port("leaf-a", "leaf-a.dev.0.up1") + assert port.label == "up1" # descriptor override + assert port.factory_label == "up1-factory" + assert port.vertex_type == "Out" + assert port.is_bidirectional is False + assert port.vertex_ids == ("leaf-a.0.up1",) + assert port.is_active is True + assert port.is_controlled is True + assert port.is_endpoint is False + assert fetcher.vertex_lookup_calls == [] # all of the above is offline + + +def test_port_vertex_details_fetches_once_and_caches(snapshot: tuple[InspectSnapshot, FakeFetcher]) -> None: + snap, fetcher = snapshot + port = snap.get_port("leaf-a", "leaf-a.dev.0.up1") + details = port.vertex_details + assert details is not None + assert details.fields.typeFields is not None and details.fields.typeFields.type == "ip" + assert port.vertex_kind == "ip" + _ = port.vertex_details # second access → cached + assert fetcher.vertex_lookup_calls == [["leaf-a.0.up1"]] + + +def test_vertex_details_invalidated_by_post_commit_device_refresh( + snapshot: tuple[InspectSnapshot, FakeFetcher], +) -> None: + snap, fetcher = snapshot + _ = snap.get_port("leaf-a", "leaf-a.dev.0.up1").vertex_details + assert len(fetcher.vertex_lookup_calls) == 1 + snap.apply_post_commit(device_ids=["leaf-a"], mark_paths_stale=False) + _ = snap.get_port("leaf-a", "leaf-a.dev.0.up1").vertex_details + assert len(fetcher.vertex_lookup_calls) == 2 # cache was invalidated by the refresh + + +def test_vertex_details_without_fetcher_returns_none() -> None: + fetcher = FakeFetcher() + snap = InspectSnapshot( + fetcher=None, + device_items=[fetcher._details["leaf-a"]], + device_level=HydrationLevel.FULL, + ) + port = snap.get_port("leaf-a", "leaf-a.dev.0.up1") + assert port.vertex_details is None + assert port.vertex_kind is None + + +def test_filter_ports_by_module_and_direction(snapshot: tuple[InspectSnapshot, FakeFetcher]) -> None: + snap, fetcher = snapshot + fetcher._details["leaf-a"] = _filter_detail_node("leaf-a", "LEAF-A") + leaf = snap.get_device("leaf-a") + assert {p.label for p in leaf.filter_ports(module_id="leaf-a.dev.0")} == {"up1", "host1"} + assert {p.label for p in leaf.filter_ports(vertex_type="Out")} == {"up1"} + assert {p.label for p in leaf.filter_ports(vertex_type="BiDirectional")} == {"bidi1"} + assert {p.label for p in leaf.filter_ports(module_id="leaf-a.dev.1", vertex_type="BiDirectional")} == {"bidi1"} + assert fetcher.vertex_lookup_calls == [] + + +def test_filter_ports_by_flags(snapshot: tuple[InspectSnapshot, FakeFetcher]) -> None: + snap, fetcher = snapshot + fetcher._details["leaf-a"] = _filter_detail_node("leaf-a", "LEAF-A") + leaf = snap.get_device("leaf-a") + assert {p.label for p in leaf.filter_ports(active=True)} == {"up1", "bidi1"} + assert {p.label for p in leaf.filter_ports(active=False)} == {"host1"} # mgmt1 (unknown) never matches + assert {p.label for p in leaf.filter_ports(endpoint=True)} == {"host1", "bidi1"} + assert {p.label for p in leaf.filter_ports(controlled=True)} == {"up1"} + assert {p.label for p in leaf.filter_ports(active=True, endpoint=True)} == {"bidi1"} + assert fetcher.vertex_lookup_calls == [] + + +def test_filter_ports_by_kind_uses_one_batched_lookup(snapshot: tuple[InspectSnapshot, FakeFetcher]) -> None: + snap, fetcher = snapshot + fetcher._details["leaf-a"] = _filter_detail_node("leaf-a", "LEAF-A") + leaf = snap.get_device("leaf-a") + ports = leaf.filter_ports(kind="ip") + assert {p.label for p in ports} == {"up1", "host1", "bidi1"} # mgmt1 has no vertex → excluded + assert len(fetcher.vertex_lookup_calls) == 1 # one batched call for all uncached vertices + assert set(fetcher.vertex_lookup_calls[0]) == {"leaf-a.0.up1", "leaf-a.0.host1", "leaf-a.1.bidi1.out"} + _ = leaf.filter_ports(kind="ip") # cached → no further lookups + assert len(fetcher.vertex_lookup_calls) == 1 + + # --- Internal --- @@ -194,7 +283,7 @@ def _skeleton_node( "_id": device_id, "_vid": device_id, "deviceId": device_id, - "descriptor": {"desc": "", "label": label}, + "descriptor": {"desc": f"{label} description", "label": label}, "meta": {"coordinates": {"x": x, "y": y}, "isVirtual": True, "iconType": "switch"}, "status": {"sa": 0, "severity": 0}, "syncSeverity": sync, @@ -209,8 +298,11 @@ def _detail_node(device_id: str, label: str, port_pids: list[str]) -> InspectApi pid: { "pid": pid, "descriptor": {"label": pid.split(".")[-1]}, + "label": f"{pid.split('.')[-1]}-factory", "status": {"sa": 0, "severity": 0}, - "vertexInfo": {"type": "single", "id": pid.replace(".dev.", "."), "vertexType": "Out"}, + "vertexInfo": _single_vertex_info( + pid.replace(".dev.", "."), "Out", active=True, controlled=True, endpoint=False + ), } for pid in port_pids } @@ -228,6 +320,72 @@ def _detail_node(device_id: str, label: str, port_pids: list[str]) -> InspectApi ) +def _single_vertex_info(vertex_id: str, vertex_type: str, *, active: bool, controlled: bool, endpoint: bool) -> dict: + return { + "type": "single", + "id": vertex_id, + "vertexType": vertex_type, + "fields": {"isActive": active, "isControlled": controlled, "isEndpoint": endpoint}, + } + + +def _filter_detail_node(device_id: str, label: str) -> InspectApiNodeStatusItem: + """A hydrated device with port variety for filter tests: two single vertices (Out active + controlled / In inactive endpoint), one double (bidirectional endpoint), one without vertexInfo.""" + + def port(module: str, name: str, vertex_info: dict | None) -> dict: + entry: dict = {"pid": f"{device_id}.dev.{module}.{name}", "descriptor": {"label": name}} + if vertex_info is not None: + entry["vertexInfo"] = vertex_info + return entry + + modules = { + f"{device_id}.dev.0": { + "pid": f"{device_id}.dev.0", + "ports": { + f"{device_id}.dev.0.up1": port( + "0", + "up1", + _single_vertex_info(f"{device_id}.0.up1", "Out", active=True, controlled=True, endpoint=False), + ), + f"{device_id}.dev.0.host1": port( + "0", + "host1", + _single_vertex_info(f"{device_id}.0.host1", "In", active=False, controlled=False, endpoint=True), + ), + }, + }, + f"{device_id}.dev.1": { + "pid": f"{device_id}.dev.1", + "ports": { + f"{device_id}.dev.1.bidi1": port( + "1", + "bidi1", + { + "type": "double", + "in": _single_vertex_info( + f"{device_id}.1.bidi1.in", "In", active=True, controlled=False, endpoint=True + ), + "out": _single_vertex_info( + f"{device_id}.1.bidi1.out", "Out", active=True, controlled=False, endpoint=True + ), + }, + ), + f"{device_id}.dev.1.mgmt1": port("1", "mgmt1", None), + }, + }, + } + return InspectApiNodeStatusItem.model_validate( + { + "_id": device_id, + "_vid": device_id, + "deviceId": device_id, + "descriptor": {"desc": "", "label": label}, + "modules": modules, + } + ) + + def _edge_pair(dev_a: str, dev_b: str, port_a: str, port_b: str) -> InspectApiExternalEdgesByDeviceKeyItem: edge_id = f"{port_a}::{port_b}" return InspectApiExternalEdgesByDeviceKeyItem.model_validate( @@ -271,6 +429,7 @@ class FakeFetcher: def __init__(self) -> None: self.device_detail_calls: list[str] = [] self.edge_pair_calls: list[str] = [] + self.vertex_lookup_calls: list[list[str]] = [] self.section_calls = 0 self.skeleton_calls = 0 self._details = { @@ -299,6 +458,34 @@ def get_paths_section(self) -> list[InspectApiPathItem]: self.section_calls += 1 return self._paths + def lookup_vertices(self, vertex_ids: list[str]) -> InspectApiLookupVerticesResponse: + self.vertex_lookup_calls.append(list(vertex_ids)) + data = { + vertex_id: { + "id": vertex_id, + "isVirtual": False, + "vertexType": "Out", + "fields": {"label": vertex_id, "active": True, "useAsEndpoint": False, "typeFields": {"type": "ip"}}, + } + for vertex_id in vertex_ids + } + return InspectApiLookupVerticesResponse.model_validate( + { + "data": data, + "header": { + "auth": True, + "caption": "Operation Successful", + "code": "OK", + "errorCodes": [], + "errorDetails": [], + "id": "0", + "msg": [], + "ok": True, + "user": "api-user", + }, + } + ) + @pytest.fixture def snapshot() -> Iterator[tuple[InspectSnapshot, FakeFetcher]]: From f7abeaddc3a375f87a278e0f51a34eb8a330b906 Mon Sep 17 00:00:00 2001 From: Jonas Scholl <43819845+JonasScholl@users.noreply.github.com> Date: Thu, 16 Jul 2026 16:22:06 +0200 Subject: [PATCH 17/34] Potential fix for pull request finding 'Module is imported with 'import' and 'import from'' Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com> --- tests/inspect/test_exports.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/inspect/test_exports.py b/tests/inspect/test_exports.py index 3763b91..ce8b31b 100644 --- a/tests/inspect/test_exports.py +++ b/tests/inspect/test_exports.py @@ -3,7 +3,7 @@ from __future__ import annotations -import videoipath_automation_tool.apps +from videoipath_automation_tool import apps from videoipath_automation_tool.apps import inspect from videoipath_automation_tool.apps.inspect import model from videoipath_automation_tool.apps.inspect.model import actions, collector, common, ngraph, update_topology @@ -33,5 +33,5 @@ def test_inspect_package_all_names_resolve() -> None: def test_apps_star_import_is_clean() -> None: - assert videoipath_automation_tool.apps.inspect is inspect + assert apps.inspect is inspect assert hasattr(inspect, "__all__") From 1ad728da2241e27f1a9cf19e3e20faaed2a1aaba Mon Sep 17 00:00:00 2001 From: Jonas Scholl <43819845+JonasScholl@users.noreply.github.com> Date: Fri, 17 Jul 2026 02:07:46 +0200 Subject: [PATCH 18/34] improve port domain dto --- .../apps/inspect/app/write.py | 21 ++++- .../apps/inspect/domain/port.py | 92 ++++++++++++++----- .../apps/inspect/model/actions.py | 1 + .../apps/inspect/transaction.py | 25 +++++ tests/e2e/inspect/test_e2e_inspect.py | 39 ++++++++ .../lookup_inspect_router_vertex_by_id.json | 48 ++++++++++ .../2025.4.9/lookup_inspect_vertex_by_id.json | 1 + .../lookup_inspect_vertices_by_ids.json | 1 + .../update_topology_replace_vertices.json | 1 + tests/inspect/test_models.py | 7 ++ tests/inspect/test_snapshot.py | 47 +++++++--- tests/inspect/test_transaction.py | 63 +++++++++++++ 12 files changed, 311 insertions(+), 35 deletions(-) create mode 100644 tests/inspect/fixtures/2025.4.9/lookup_inspect_router_vertex_by_id.json diff --git a/src/videoipath_automation_tool/apps/inspect/app/write.py b/src/videoipath_automation_tool/apps/inspect/app/write.py index 652e417..49d682a 100644 --- a/src/videoipath_automation_tool/apps/inspect/app/write.py +++ b/src/videoipath_automation_tool/apps/inspect/app/write.py @@ -72,10 +72,29 @@ def update_vertex( use_as_endpoint: Optional[bool] = None, label: Optional[str] = None, tags: Optional[list[str]] = None, + description: Optional[str] = None, + active: Optional[bool] = None, + sips_mode: Optional[str] = None, + control_props: Optional[Any] = None, + extra_alert_filters: Optional[list[Any]] = None, + custom: Optional[dict[str, Any]] = None, + park_port: Optional[int] = None, ) -> CommitResult: """Edit a vertex (single auto-committed change; update-only).""" with self.transaction() as tx: - tx.update_vertex(vertex_id, use_as_endpoint=use_as_endpoint, label=label, tags=tags) + tx.update_vertex( + vertex_id, + use_as_endpoint=use_as_endpoint, + label=label, + tags=tags, + description=description, + active=active, + sips_mode=sips_mode, + control_props=control_props, + extra_alert_filters=extra_alert_filters, + custom=custom, + park_port=park_port, + ) return tx.commit() def update_edge( diff --git a/src/videoipath_automation_tool/apps/inspect/domain/port.py b/src/videoipath_automation_tool/apps/inspect/domain/port.py index bc6265f..cf43661 100644 --- a/src/videoipath_automation_tool/apps/inspect/domain/port.py +++ b/src/videoipath_automation_tool/apps/inspect/domain/port.py @@ -1,6 +1,6 @@ from __future__ import annotations -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Any from videoipath_automation_tool.apps.inspect.model.collector import ( InspectApiDoubleVertexInfo, @@ -12,6 +12,16 @@ InspectVertexKind, InspectVertexType, ) + +if TYPE_CHECKING: + from videoipath_automation_tool.apps.inspect.domain.device import InspectDevice + from videoipath_automation_tool.apps.inspect.domain.edge import InspectEdge + from videoipath_automation_tool.apps.inspect.model.actions import ( + InspectApiLookupVertexResponseData, + InspectApiVertexControlProps, + InspectApiVertexEditForm, + InspectApiVertexTypeFields, + ) from videoipath_automation_tool.apps.inspect.snapshot import ( InspectSnapshot, _IndexedPort, @@ -19,11 +29,6 @@ _vertex_ids_from_status, ) -if TYPE_CHECKING: - from videoipath_automation_tool.apps.inspect.domain.device import InspectDevice - from videoipath_automation_tool.apps.inspect.domain.edge import InspectEdge - from videoipath_automation_tool.apps.inspect.model.actions import InspectApiLookupVertexResponseData - class InspectPort(InspectFrozenModel): snapshot: InspectSnapshot @@ -110,25 +115,58 @@ def vertex_ids(self) -> tuple[str, ...]: """All vertex ids of this port: one for a single vertex, (out, in) for a bidirectional.""" return _vertex_ids_from_status(self.indexed.port) - @property - def vertex_details(self) -> InspectApiLookupVertexResponseData | None: - """Full vertex edit-form data (``lookupInspectVertexByIds``): active, useAsEndpoint, - sipsMode, controlProps, typeFields (ip address, vlan, …) and more. First access per vertex - triggers one server lookup; results are cached on the snapshot and invalidated when the - owning device is refreshed after a commit.""" - vertex_id = self.vertex_id - if not vertex_id: - return None - return self.snapshot.get_vertex_details(vertex_id) - @property def vertex_kind(self) -> InspectVertexKind | str | None: """Vertex kind: ``"generic"`` / ``"ip"`` / ``"codec"`` / ``"router"`` — from the vertex edit - form's ``typeFields.type`` (triggers the lazy ``vertex_details`` lookup on first access).""" - details = self.vertex_details - if details is None or details.fields.typeFields is None: - return None - return details.fields.typeFields.type + form's ``typeFields.type`` (triggers a lazy vertex lookup on first access).""" + type_fields = self.type_fields + return type_fields.type if type_fields is not None else None + + @property + def type_fields(self) -> InspectApiVertexTypeFields | None: + form = self._edit_form() + return form.typeFields if form else None + + @property + def sips_mode(self) -> str | None: + form = self._edit_form() + return form.sipsMode if form else None + + @property + def control_props(self) -> InspectApiVertexControlProps | None: + form = self._edit_form() + return form.controlProps if form else None + + @property + def extra_alert_filters(self) -> list[Any]: + form = self._edit_form() + return list(form.extraAlertFilters) if form else [] + + @property + def custom(self) -> dict[str, Any]: + form = self._edit_form() + return dict(form.custom) if form else {} + + @property + def custom_schemas(self) -> dict[str, Any]: + lookup = self._vertex_lookup() + return dict(lookup.customSchemas) if lookup else {} + + @property + def queueable(self) -> bool | None: + form = self._edit_form() + return form.queueable if form else None + + @property + def destination_monitor_leader(self) -> bool | None: + form = self._edit_form() + return form.destinationMonitorLeader if form else None + + @property + def park_port(self) -> int | None: + """Park port (router vertices): ``typeFields.parkPort`` from the vertex edit form.""" + type_fields = self.type_fields + return type_fields.parkPort if type_fields is not None else None @property def edge(self) -> InspectEdge | None: @@ -137,6 +175,16 @@ def edge(self) -> InspectEdge | None: return None return self.snapshot.get_edge_for_port(self.indexed.device_id, port_id) + def _vertex_lookup(self) -> InspectApiLookupVertexResponseData | None: + vertex_id = self.vertex_id + if not vertex_id: + return None + return self.snapshot.get_vertex_details(vertex_id) + + def _edit_form(self) -> InspectApiVertexEditForm | None: + lookup = self._vertex_lookup() + return lookup.fields if lookup else None + def _vertex_flag(self, name: str) -> bool | None: """Resolve a ``vertexInfo.fields`` flag; for double vertices: True if either side is True, False only if all sides are known False, else None.""" diff --git a/src/videoipath_automation_tool/apps/inspect/model/actions.py b/src/videoipath_automation_tool/apps/inspect/model/actions.py index 0994764..fd89754 100644 --- a/src/videoipath_automation_tool/apps/inspect/model/actions.py +++ b/src/videoipath_automation_tool/apps/inspect/model/actions.py @@ -52,6 +52,7 @@ class InspectApiLookupInspectDeviceResponse(InspectApiBaseModel): class InspectApiVertexTypeFields(InspectApiBaseModel): ipAddress: str | None = None ipNetmask: str | None = None + parkPort: int | None = None public: bool | None = None supportsCpipeCfg: bool | None = None supportsIgmpCfg: bool | None = None diff --git a/src/videoipath_automation_tool/apps/inspect/transaction.py b/src/videoipath_automation_tool/apps/inspect/transaction.py index 3410ec7..38e1273 100644 --- a/src/videoipath_automation_tool/apps/inspect/transaction.py +++ b/src/videoipath_automation_tool/apps/inspect/transaction.py @@ -37,6 +37,7 @@ from videoipath_automation_tool.apps.inspect.model.actions import ( InspectApiEdgeForm, InspectApiLookupInspectDeviceFields, + InspectApiVertexControlProps, InspectApiVertexEditForm, ) from videoipath_automation_tool.apps.inspect.model.common import ( @@ -162,6 +163,13 @@ def update_vertex( use_as_endpoint: Optional[bool] = None, label: Optional[str] = None, tags: Optional[list[str]] = None, + description: Optional[str] = None, + active: Optional[bool] = None, + sips_mode: Optional[str] = None, + control_props: Optional[Any] = None, + extra_alert_filters: Optional[list[Any]] = None, + custom: Optional[dict[str, Any]] = None, + park_port: Optional[int] = None, ) -> "InspectTransaction": """Edit a vertex/port (``replaceVertices``; update-only — vertices cannot be created here). @@ -177,6 +185,23 @@ def update_vertex( # Port tag assignment is the vertex's localAssignedTags (verified 2025.4.9); the # separate ``fields.tags`` list does not register as an assigned tag. entry.intents["localAssignedTags"] = list(tags) + if description is not None: + entry.intents["desc"] = description + if active is not None: + entry.intents["active"] = active + if sips_mode is not None: + entry.intents["sipsMode"] = sips_mode + if control_props is not None: + if isinstance(control_props, dict): + entry.intents["controlProps"] = InspectApiVertexControlProps.model_validate(control_props) + else: + entry.intents["controlProps"] = control_props + if extra_alert_filters is not None: + entry.intents["extraAlertFilters"] = list(extra_alert_filters) + if custom is not None: + entry.intents["custom"] = dict(custom) + if park_port is not None: + entry.intents["typeFields.parkPort"] = park_port return self # --- Staging: edges --- diff --git a/tests/e2e/inspect/test_e2e_inspect.py b/tests/e2e/inspect/test_e2e_inspect.py index d299592..2b543c9 100644 --- a/tests/e2e/inspect/test_e2e_inspect.py +++ b/tests/e2e/inspect/test_e2e_inspect.py @@ -118,6 +118,45 @@ def test_assign_tag_to_port(app: VideoIPathApp, topology_builder: TopologyBuilde delete_test_tag(app) # also removes the port binding +def test_update_vertex_fields(app: VideoIPathApp, topology_builder: TopologyBuilder) -> None: + """Write every editable vertex UI field and read it back via InspectPort attributes.""" + (device_id,) = topology_builder.add_devices([("VTX-A", 2)]) + app.inspect.refresh() + vertex_id = next( + p.vertex_id for p in app.inspect.get_device(device_id).ports if p.vertex_id and "Router In" in (p.label or "") + ) + alert_filter = "0:*:e2e-alarm:*" + result = app.inspect.update_vertex( + vertex_id, + label="E2E Router In", + description="E2E vertex description", + use_as_endpoint=True, + active=True, + sips_mode="SIPSAuto", + control_props={"configPriority": "high", "onlyInitial": True}, + extra_alert_filters=[alert_filter], + custom={"e2e-param": "e2e-value"}, + park_port=7, + ) + assert result.ok + + app.inspect.refresh() + port = next(p for p in app.inspect.get_device(device_id).ports if p.vertex_id == vertex_id) + assert port.label == "E2E Router In" + assert port.description == "E2E vertex description" + assert port.is_endpoint is True + assert port.is_active is True + assert port.sips_mode == "SIPSAuto" + assert port.control_props is not None + assert port.control_props.configPriority == "high" + assert port.control_props.onlyInitial is True + assert alert_filter in port.extra_alert_filters + assert port.custom.get("e2e-param") == "e2e-value" + assert port.park_port == 7 + assert port.vertex_kind == "router" + assert port.type_fields is not None and port.type_fields.type == "router" + + def test_device_placement(app: VideoIPathApp, topology_builder: TopologyBuilder) -> None: (device_id,) = topology_builder.add_devices([("PLACE-A", 2)]) app.inspect.place_device(device_id, 4200, 4200) diff --git a/tests/inspect/fixtures/2025.4.9/lookup_inspect_router_vertex_by_id.json b/tests/inspect/fixtures/2025.4.9/lookup_inspect_router_vertex_by_id.json new file mode 100644 index 0000000..b678595 --- /dev/null +++ b/tests/inspect/fixtures/2025.4.9/lookup_inspect_router_vertex_by_id.json @@ -0,0 +1,48 @@ +{ + "data": { + "assignedTags": { + "all": [], + "inherited": {}, + "inheritedConflict": false, + "local": {} + }, + "context": { + "devicePid": "device-a", + "modulePid": "device-a.dev.module-1", + "portPid": "device-a.dev.module-1.port-in-1" + }, + "customSchemas": {}, + "fields": { + "active": true, + "controlProps": null, + "custom": {}, + "desc": "", + "destinationMonitorLeader": false, + "extraAlertFilters": [], + "label": "Router In 11.1", + "localAssignedTags": [], + "queueable": false, + "sipsMode": "NONE", + "tags": [], + "typeFields": { + "parkPort": 42, + "type": "router" + }, + "useAsEndpoint": false + }, + "id": "device-a.module-1.port-in-1.in", + "isVirtual": false, + "vertexType": "In" + }, + "header": { + "auth": true, + "caption": "Operation Successful", + "code": "OK", + "errorCodes": [], + "errorDetails": [], + "id": "0", + "msg": [], + "ok": true, + "user": "api-user" + } +} diff --git a/tests/inspect/fixtures/2025.4.9/lookup_inspect_vertex_by_id.json b/tests/inspect/fixtures/2025.4.9/lookup_inspect_vertex_by_id.json index 60aa756..5064343 100644 --- a/tests/inspect/fixtures/2025.4.9/lookup_inspect_vertex_by_id.json +++ b/tests/inspect/fixtures/2025.4.9/lookup_inspect_vertex_by_id.json @@ -27,6 +27,7 @@ "typeFields": { "ipAddress": null, "ipNetmask": null, + "parkPort": null, "public": false, "supportsCpipeCfg": false, "supportsIgmpCfg": false, diff --git a/tests/inspect/fixtures/2025.4.9/lookup_inspect_vertices_by_ids.json b/tests/inspect/fixtures/2025.4.9/lookup_inspect_vertices_by_ids.json index b4c285a..47d1746 100644 --- a/tests/inspect/fixtures/2025.4.9/lookup_inspect_vertices_by_ids.json +++ b/tests/inspect/fixtures/2025.4.9/lookup_inspect_vertices_by_ids.json @@ -31,6 +31,7 @@ "typeFields": { "ipAddress": null, "ipNetmask": null, + "parkPort": null, "public": false, "supportsCpipeCfg": false, "supportsIgmpCfg": false, diff --git a/tests/inspect/fixtures/2025.4.9/update_topology_replace_vertices.json b/tests/inspect/fixtures/2025.4.9/update_topology_replace_vertices.json index bc6fd19..bda0a35 100644 --- a/tests/inspect/fixtures/2025.4.9/update_topology_replace_vertices.json +++ b/tests/inspect/fixtures/2025.4.9/update_topology_replace_vertices.json @@ -19,6 +19,7 @@ "typeFields": { "ipAddress": null, "ipNetmask": null, + "parkPort": null, "public": false, "supportsCpipeCfg": false, "supportsIgmpCfg": false, diff --git a/tests/inspect/test_models.py b/tests/inspect/test_models.py index 5c4cfa8..cb060b8 100644 --- a/tests/inspect/test_models.py +++ b/tests/inspect/test_models.py @@ -140,6 +140,13 @@ def test_lookup_vertex_edit_form(load: Callable[[str], dict[str, Any]]) -> None: assert resp.data.vertexType in ("In", "Out", "Internal", None) +def test_lookup_router_vertex_exposes_park_port(load: Callable[[str], dict[str, Any]]) -> None: + resp = InspectApiLookupVertexResponse.model_validate(load("lookup_inspect_router_vertex_by_id.json")) + assert resp.data.fields.typeFields is not None + assert resp.data.fields.typeFields.type == "router" + assert resp.data.fields.typeFields.parkPort == 42 + + def test_lookup_device_fields() -> None: resp = InspectApiLookupInspectDeviceResponse.model_validate(_device_lookup_fixture()) assert resp.data.fields.coordinates is not None diff --git a/tests/inspect/test_snapshot.py b/tests/inspect/test_snapshot.py index b95fc2a..159137a 100644 --- a/tests/inspect/test_snapshot.py +++ b/tests/inspect/test_snapshot.py @@ -199,29 +199,35 @@ def test_port_exposes_direction_flags_and_factory_label(snapshot: tuple[InspectS assert fetcher.vertex_lookup_calls == [] # all of the above is offline -def test_port_vertex_details_fetches_once_and_caches(snapshot: tuple[InspectSnapshot, FakeFetcher]) -> None: +def test_port_vertex_attrs_fetches_once_and_caches(snapshot: tuple[InspectSnapshot, FakeFetcher]) -> None: snap, fetcher = snapshot port = snap.get_port("leaf-a", "leaf-a.dev.0.up1") - details = port.vertex_details - assert details is not None - assert details.fields.typeFields is not None and details.fields.typeFields.type == "ip" + assert port.type_fields is not None and port.type_fields.type == "ip" assert port.vertex_kind == "ip" - _ = port.vertex_details # second access → cached + assert port.sips_mode == "NONE" + assert port.control_props is not None and port.control_props.configPriority == "off" + assert port.extra_alert_filters == [] + assert port.custom == {} + assert port.custom_schemas == {} + assert port.queueable is False + assert port.destination_monitor_leader is False + assert port.park_port is None + _ = port.vertex_kind # second access → cached assert fetcher.vertex_lookup_calls == [["leaf-a.0.up1"]] -def test_vertex_details_invalidated_by_post_commit_device_refresh( +def test_vertex_lookup_invalidated_by_post_commit_device_refresh( snapshot: tuple[InspectSnapshot, FakeFetcher], ) -> None: snap, fetcher = snapshot - _ = snap.get_port("leaf-a", "leaf-a.dev.0.up1").vertex_details + _ = snap.get_port("leaf-a", "leaf-a.dev.0.up1").vertex_kind assert len(fetcher.vertex_lookup_calls) == 1 snap.apply_post_commit(device_ids=["leaf-a"], mark_paths_stale=False) - _ = snap.get_port("leaf-a", "leaf-a.dev.0.up1").vertex_details - assert len(fetcher.vertex_lookup_calls) == 2 # cache was invalidated by the refresh + _ = snap.get_port("leaf-a", "leaf-a.dev.0.up1").vertex_kind + assert len(fetcher.vertex_lookup_calls) == 2 -def test_vertex_details_without_fetcher_returns_none() -> None: +def test_vertex_attrs_without_fetcher_returns_none() -> None: fetcher = FakeFetcher() snap = InspectSnapshot( fetcher=None, @@ -229,8 +235,10 @@ def test_vertex_details_without_fetcher_returns_none() -> None: device_level=HydrationLevel.FULL, ) port = snap.get_port("leaf-a", "leaf-a.dev.0.up1") - assert port.vertex_details is None assert port.vertex_kind is None + assert port.sips_mode is None + assert port.control_props is None + assert port.park_port is None def test_filter_ports_by_module_and_direction(snapshot: tuple[InspectSnapshot, FakeFetcher]) -> None: @@ -465,7 +473,22 @@ def lookup_vertices(self, vertex_ids: list[str]) -> InspectApiLookupVerticesResp "id": vertex_id, "isVirtual": False, "vertexType": "Out", - "fields": {"label": vertex_id, "active": True, "useAsEndpoint": False, "typeFields": {"type": "ip"}}, + "customSchemas": {}, + "fields": { + "active": True, + "controlProps": {"configPriority": "off", "onlyInitial": False}, + "custom": {}, + "desc": "", + "destinationMonitorLeader": False, + "extraAlertFilters": [], + "label": vertex_id, + "localAssignedTags": [], + "queueable": False, + "sipsMode": "NONE", + "tags": [], + "typeFields": {"type": "ip"}, + "useAsEndpoint": False, + }, } for vertex_id in vertex_ids } diff --git a/tests/inspect/test_transaction.py b/tests/inspect/test_transaction.py index 87790a9..511fc5b 100644 --- a/tests/inspect/test_transaction.py +++ b/tests/inspect/test_transaction.py @@ -142,6 +142,64 @@ def test_update_vertex_assigns_tags_as_local() -> None: assert form.tags == [] +def test_update_vertex_sets_description() -> None: + api = FakeAPI() + api.vertices[A_OUT] = _vertex_data() + with _txn(api) as tx: + tx.update_vertex(A_OUT, description="Router input") + tx.commit() + assert api.update_calls[0].replaceVertices[A_OUT].desc == "Router input" + + +def test_update_vertex_sets_active_and_sips_mode() -> None: + api = FakeAPI() + api.vertices[A_OUT] = _vertex_data() + with _txn(api) as tx: + tx.update_vertex(A_OUT, active=False, sips_mode="SIPSAuto") + tx.commit() + form = api.update_calls[0].replaceVertices[A_OUT] + assert form.active is False + assert form.sipsMode == "SIPSAuto" + + +def test_update_vertex_sets_control_props_from_dict() -> None: + api = FakeAPI() + api.vertices[A_OUT] = _vertex_data() + with _txn(api) as tx: + tx.update_vertex(A_OUT, control_props={"configPriority": "high", "onlyInitial": True}) + tx.commit() + form = api.update_calls[0].replaceVertices[A_OUT] + assert form.controlProps is not None + assert form.controlProps.configPriority == "high" + assert form.controlProps.onlyInitial is True + + +def test_update_vertex_sets_extra_alert_filters_and_custom() -> None: + api = FakeAPI() + api.vertices[A_OUT] = _vertex_data() + with _txn(api) as tx: + tx.update_vertex( + A_OUT, + extra_alert_filters=["alarm-point-a"], + custom={"param-a": "value-a"}, + ) + tx.commit() + form = api.update_calls[0].replaceVertices[A_OUT] + assert form.extraAlertFilters == ["alarm-point-a"] + assert form.custom == {"param-a": "value-a"} + + +def test_update_vertex_sets_park_port() -> None: + api = FakeAPI() + api.vertices[A_OUT] = _router_vertex_data() + with _txn(api) as tx: + tx.update_vertex(A_OUT, park_port=99) + tx.commit() + form = api.update_calls[0].replaceVertices[A_OUT] + assert form.typeFields is not None + assert form.typeFields.parkPort == 99 + + def test_remove_appends_to_remove_list() -> None: api = FakeAPI() with _txn(api) as tx: @@ -324,6 +382,11 @@ def _vertex_data() -> InspectApiLookupVertexResponseData: return InspectApiLookupVertexResponseData.model_validate(fixture["data"]) +def _router_vertex_data() -> InspectApiLookupVertexResponseData: + fixture = load_fixture("lookup_inspect_router_vertex_by_id.json") + return InspectApiLookupVertexResponseData.model_validate(fixture["data"]) + + def _edge_item(weight: int = 1) -> InspectApiLookupEdgeResponseItem: edge = { "active": True, From 6288f771573778616ef264e2922e81f8bb62b1c1 Mon Sep 17 00:00:00 2001 From: Jonas Scholl <43819845+JonasScholl@users.noreply.github.com> Date: Thu, 23 Jul 2026 01:21:43 +0200 Subject: [PATCH 19/34] project all inspect data and configurations from VideoIPath UI --- docs/inspect-app/concepts.md | 8 +- docs/inspect-app/endpoints.md | 217 ++++++++- .../apps/inspect/__init__.py | 10 + .../apps/inspect/api/inspect_api.py | 49 +- .../apps/inspect/api/queries.py | 16 + .../apps/inspect/app/actions.py | 190 +++++++- .../apps/inspect/app/write.py | 72 ++- .../apps/inspect/domain/__init__.py | 22 +- .../apps/inspect/domain/device.py | 153 ++++++- .../apps/inspect/domain/edge.py | 95 +++- .../apps/inspect/domain/module.py | 95 ++++ .../apps/inspect/domain/port.py | 270 ++++++----- .../apps/inspect/domain/vertex.py | 345 ++++++++++++++ .../apps/inspect/model/__init__.py | 4 +- .../apps/inspect/model/actions.py | 34 +- .../apps/inspect/model/collector.py | 24 + .../apps/inspect/model/common.py | 21 + .../apps/inspect/model/virtual.py | 167 +++++++ .../apps/inspect/snapshot.py | 140 +++++- .../apps/inspect/transaction.py | 203 +++++++-- .../validators/virtual_device_id.py | 15 +- tests/e2e/helpers.py | 7 +- tests/e2e/inspect/test_e2e_inspect.py | 48 +- .../lookup_inspect_codec_vertex_by_id.json | 66 +++ .../lookup_inspect_virtual_device.json | 48 ++ .../update_virtual_instances_create.json | 30 ++ .../fixtures/2025.4.9/virtual_devices.json | 43 ++ .../fixtures/2025.4.9/virtual_templates.json | 72 +++ tests/inspect/test_exports.py | 3 +- tests/inspect/test_models.py | 10 + tests/inspect/test_queries.py | 21 +- tests/inspect/test_snapshot.py | 261 ++++++++--- tests/inspect/test_transaction.py | 90 ++++ tests/inspect/test_virtual_devices.py | 423 ++++++++++++++++++ tests/validators/test_virtual_device_id.py | 12 +- 35 files changed, 2983 insertions(+), 301 deletions(-) create mode 100644 src/videoipath_automation_tool/apps/inspect/domain/module.py create mode 100644 src/videoipath_automation_tool/apps/inspect/domain/vertex.py create mode 100644 src/videoipath_automation_tool/apps/inspect/model/virtual.py create mode 100644 tests/inspect/fixtures/2025.4.9/lookup_inspect_codec_vertex_by_id.json create mode 100644 tests/inspect/fixtures/2025.4.9/lookup_inspect_virtual_device.json create mode 100644 tests/inspect/fixtures/2025.4.9/update_virtual_instances_create.json create mode 100644 tests/inspect/fixtures/2025.4.9/virtual_devices.json create mode 100644 tests/inspect/fixtures/2025.4.9/virtual_templates.json create mode 100644 tests/inspect/test_virtual_devices.py diff --git a/docs/inspect-app/concepts.md b/docs/inspect-app/concepts.md index f6fb4d5..be2cf39 100644 --- a/docs/inspect-app/concepts.md +++ b/docs/inspect-app/concepts.md @@ -221,7 +221,7 @@ workflows: | Config | `app.topology`, `app.inventory`, **Inspect (effective store)** | `GET …/data/config/…` | `PATCH …/data/config/…` (revisioned) or RPC | | Status | Inventory status reads, Inspect status reads | `GET …/data/status/…` | — | | Collector (facade) | `app.inspect` | Scoped queries on `…/data/status/collector/…` (skeleton + hydration, ADR-0007); `GET …/**` as eager/fallback | `POST …/actions/status/collector/updateTopology` → `nGraphElements` | -| Network actions | `app.inspect` device topology workflows | — | `POST …/actions/status/network/addDevices`, `POST …/actions/status/network/syncDevices` | +| Network actions | `app.inspect` device topology workflows | `GET …/data/status/network/virtualDevices/**`, `…/virtualTemplates/**` | `POST …/actions/status/network/addDevices`, `…/syncDevices`, `…/updateVirtualInstances` (**create** virtual devices), `…/updateVirtualTemplates`, `…/addVirtualTopology`. After create, virtual devices use the same `updateTopology` / write methods as physical devices (`InspectDevice.is_virtual`). | So Inspect's read aggregate is status-namespace and net-new in shape, but its writes are commit-time-validated bulk actions that land in the revisioned @@ -231,7 +231,8 @@ id for the verified `updateTopology` flow. **Endpoint policy** ([ADR-0008](./decisions/0008-collector-only-endpoints.md)): the Inspect package calls **only** the Inspect surface — collector data reads, -collector actions, and the `addDevices`/`syncDevices` network actions. The +collector actions, and the network actions (`addDevices`, `syncDevices`, +virtual-device / port-template actions). The config-plane row above is context, not a call path: the package never issues `GET`/`PATCH …/config/network/nGraphElements` (that stays `app.topology`'s surface). Consequence: no `_rev` is available to Inspect, and since @@ -402,7 +403,8 @@ REST: - [x] Import / Export (preview): **unregistered** on 2025.4.9 — empty data namespaces; GET action schema empty; POST → `No action node in request` - [x] Write/action endpoint: `/rest/v2/actions/status/collector/updateTopology` - [x] Collector action payloads captured and modelled: `/rest/v2/actions/status/collector/lookupInspectDevice`, `/rest/v2/actions/status/collector/lookupSyncInfo` -- [x] Network action request shapes, normal action responses, and validation-error responses captured: `/rest/v2/actions/status/network/addDevices`, `/rest/v2/actions/status/network/syncDevices` +- [x] Network action request shapes, normal action responses, and validation-error responses captured: `/rest/v2/actions/status/network/addDevices`, `/rest/v2/actions/status/network/syncDevices`, `/rest/v2/actions/status/network/updateVirtualInstances`, `/rest/v2/actions/status/network/updateVirtualTemplates`, `/rest/v2/actions/status/network/addVirtualTopology` +- [x] Virtual device / port-template status reads: `GET …/status/network/virtualDevices/**`, `GET …/status/network/virtualTemplates/**`; `lookupInspectDevice.fields.virtualDeviceFields` typed as `{dynamic, manual}` module lists - [x] Config store / write target: `updateTopology` lands in `GET /rest/v2/data/config/network/nGraphElements/**` (`_items[]`, `_rev`, `type` ∈ `baseDevice` / `codecVertex` / `ipVertex` / `unidirectionalEdge`); model with standalone `InspectApi*` DTOs (§3.3) - [x] `_rev` handling on commit: last-writer-wins; `_rev` in `replaceEdges` payload is ignored - [x] Version gating: collector read/write and `updateTopology` verified on **2025.4.9** diff --git a/docs/inspect-app/endpoints.md b/docs/inspect-app/endpoints.md index 0d842a7..06fc6da 100644 --- a/docs/inspect-app/endpoints.md +++ b/docs/inspect-app/endpoints.md @@ -1325,8 +1325,219 @@ Non-empty request against a disposable, non-driver device ID returned: } ``` +## Virtual devices and port templates (verified 2025.4.9) + +Topology virtual devices (`virtual.N`) behave like normal Inspect devices after +creation: placement, labels, tags, icons, edges, vertex edits, and removal all +use the same `updateTopology` / write-mixin / transaction path as physical +devices. `InspectDevice.is_virtual` distinguishes them. **Create** is the only +dedicated public op (`create_virtual_device(s)` → `updateVirtualInstances` add); +port-template management and adding ports from templates are the other build +helpers. + +Verified 2025.4.9: `updateTopology` with `remove: ["virtual.N"]` fully removes +the node and the `virtualDevices` definition (no special delete routing). + +UI naming uses **port templates**; the API uses `virtualTemplates` / +`templateId`. + +### `GET /rest/v2/data/status/network/virtualTemplates/**` + +Purpose: list port templates available in the Create Virtual Devices dialog. + +Response example (anonymized): + +```json +{ + "data": { + "status": { + "network": { + "virtualTemplates": { + "_items": [ + { + "_id": "generic_bidir", + "_vid": "generic_bidir", + "label": "Generic bidir", + "vertex": { + "type": "genericVertex", + "vertexType": "BiDirectional", + "isVirtual": true + } + } + ] + } + } + } + }, + "header": { "ok": true, "code": "OK" } +} +``` + +Fixture: `tests/inspect/fixtures/2025.4.9/virtual_templates.json`. + +### `GET /rest/v2/data/status/network/virtualDevices/**` + +Purpose: list virtual device module/port definitions (wire/status). The Inspect +package does not wrap this as a domain list — after create, use +``InspectDevice`` (and ``lookupInspectDevice.fields.virtualDeviceFields`` when +needed). Available on ``InspectAPI.get_virtual_devices`` for low-level access. + +Response example (anonymized): + +```json +{ + "data": { + "status": { + "network": { + "virtualDevices": { + "_items": [ + { + "_id": "virtual.1", + "_vid": "virtual.1", + "modules": [ + { + "moduleNumber": 0, + "vertices": [ + { "count": 1, "templateId": "ip_in" }, + { "count": 1, "templateId": "ip_out" } + ] + } + ] + } + ] + } + } + } + }, + "header": { "ok": true, "code": "OK" } +} +``` + +Fixture: `tests/inspect/fixtures/2025.4.9/virtual_devices.json`. + +### `POST /rest/v2/actions/status/network/updateVirtualInstances` + +Purpose: create (and, at the wire level, update/remove) virtual device +definitions (UI: Create Virtual Devices). The Inspect package's public surface +uses this action for **create** only; metadata edits and removal go through +`updateTopology` like any other device. Server allocates `virtual.N` ids and +labels. + +Request: + +```json +{ + "header": { "id": 0 }, + "data": { + "add": [ + { + "modules": [ + { + "moduleNumber": null, + "vertices": [{ "templateId": "generic_bidir", "count": 1 }] + } + ] + } + ], + "update": {}, + "remove": [], + "force": false + } +} +``` + +Success response: + +```json +{ + "data": { + "addedDeviceLabels": { "virtual.1": "Virtual Device 1" }, + "res": { "msg": [], "ok": true }, + "validation": { + "createIds": [], + "details": {}, + "result": { "msg": [], "ok": true } + } + }, + "header": { "ok": true, "code": "OK" } +} +``` + +Update uses `update: { "virtual.1": { "modules": [...] } }`. Remove uses +`remove: ["virtual.1"]` (typically with `force: true`). The Inspect package +exposes **create** via this action only; metadata edits and device removal use +`updateTopology`. Created devices start with `coordinates: null` until placed +via `place_device` / `update_device`. + +Fixture: `tests/inspect/fixtures/2025.4.9/update_virtual_instances_create.json`. + +`lookupInspectDevice` for a virtual device exposes the same module list under +`fields.virtualDeviceFields`: + +```json +{ + "virtualDeviceFields": { + "dynamic": [ + { + "moduleNumber": 0, + "vertices": [{ "count": 1, "templateId": "generic_bidir" }] + } + ], + "manual": [] + } +} +``` + +Fixture: `tests/inspect/fixtures/2025.4.9/lookup_inspect_virtual_device.json`. + +### `POST /rest/v2/actions/status/network/updateVirtualTemplates` + +Purpose: add or remove port templates (UI: Manage port templates). Template ids +are client-chosen map keys. + +Request: + +```json +{ + "header": { "id": 0 }, + "data": { + "add": { + "example_tpl": { + "label": "Example template", + "vertex": { "type": "genericVertex", "vertexType": "BiDirectional" } + } + }, + "remove": [], + "force": false + } +} +``` + +Response uses the simple action shape (`data.ok` / `data.msg`). + +### `POST /rest/v2/actions/status/network/addVirtualTopology` + +Purpose: add ports from templates onto an existing virtual-device module +(UI: Add ports on a virtual device). + +Request: + +```json +{ + "header": { "id": 0 }, + "data": { + "deviceId": "virtual.1", + "moduleId": 0, + "countByVertexTemplate": { "ip_out": 1 } + } +} +``` + +Response uses the simple action shape (`data.ok` / `data.msg`). + ## `POST /rest/v2/actions/status/collector/updateTopology` + Purpose: Inspect commit endpoint. The client sends a full change set; empty maps/lists mean no changes in that category. Staging is client-side until this POST. @@ -1600,5 +1811,7 @@ further payload probing is warranted unless a future version registers them Cross-check against the UI bundle (`/assets/index-*.js`, 2025.4.9): it references `updateTopology`, `addDevices`, `syncDevices`, `lookupSyncInfo`, `lookupInspectEdgesByIds`, `lookupInspectVertexByIds`, `lookupEdgeInfo`, -`lookupNodeInfo`, and `lookupConfigDesc` — and contains **zero references to -`nGraphElements`** ([ADR-0008](./decisions/0008-collector-only-endpoints.md)). +`lookupNodeInfo`, `lookupConfigDesc`, `updateVirtualInstances`, +`updateVirtualTemplates`, and `addVirtualTopology` — and contains **zero +references to `nGraphElements`** +([ADR-0008](./decisions/0008-collector-only-endpoints.md)). diff --git a/src/videoipath_automation_tool/apps/inspect/__init__.py b/src/videoipath_automation_tool/apps/inspect/__init__.py index 2e64f2d..a002dcb 100644 --- a/src/videoipath_automation_tool/apps/inspect/__init__.py +++ b/src/videoipath_automation_tool/apps/inspect/__init__.py @@ -6,8 +6,13 @@ from videoipath_automation_tool.apps.inspect.transaction import InspectTransaction as InspectTransaction from videoipath_automation_tool.apps.inspect.domain import InspectDevice as InspectDevice from videoipath_automation_tool.apps.inspect.domain import InspectEdge as InspectEdge +from videoipath_automation_tool.apps.inspect.domain import InspectModule as InspectModule from videoipath_automation_tool.apps.inspect.domain import InspectPort as InspectPort +from videoipath_automation_tool.apps.inspect.domain import InspectPortTemplate as InspectPortTemplate from videoipath_automation_tool.apps.inspect.domain import InspectService as InspectService +from videoipath_automation_tool.apps.inspect.domain import PortFromTemplate as PortFromTemplate +from videoipath_automation_tool.apps.inspect.domain import VirtualDeviceSpec as VirtualDeviceSpec +from videoipath_automation_tool.apps.inspect.domain import VirtualModuleSpec as VirtualModuleSpec from videoipath_automation_tool.apps.inspect.errors import InspectCommitConflictError as InspectCommitConflictError from videoipath_automation_tool.apps.inspect.errors import InspectCommitError as InspectCommitError from videoipath_automation_tool.apps.inspect.errors import InspectConflict as InspectConflict @@ -31,9 +36,14 @@ "InspectEdge", "InspectEntityNotFoundError", "InspectError", + "InspectModule", "InspectPort", + "InspectPortTemplate", "InspectQueryTooLongError", "InspectService", "InspectTransaction", + "PortFromTemplate", + "VirtualDeviceSpec", + "VirtualModuleSpec", *_model.__all__, ] diff --git a/src/videoipath_automation_tool/apps/inspect/api/inspect_api.py b/src/videoipath_automation_tool/apps/inspect/api/inspect_api.py index 519decc..cd37920 100644 --- a/src/videoipath_automation_tool/apps/inspect/api/inspect_api.py +++ b/src/videoipath_automation_tool/apps/inspect/api/inspect_api.py @@ -1,8 +1,8 @@ """Raw Inspect API layer: one method per verified endpoint, typed responses, no business logic. All reads use the collector namespace only ([ADR-0008]); scoped queries come from -``queries.py`` ([ADR-0007]). Writes go through ``updateTopology`` and the ``addDevices`` / -``syncDevices`` network actions. +``queries.py`` ([ADR-0007]). Writes go through ``updateTopology`` and the network +actions (``addDevices``, ``syncDevices``, virtual-device actions). """ from __future__ import annotations @@ -39,6 +39,17 @@ InspectApiUpdateTopologyRequest, InspectApiUpdateTopologyResponse, ) +from videoipath_automation_tool.apps.inspect.model.virtual import ( + InspectApiAddVirtualTopologyData, + InspectApiAddVirtualTopologyRequest, + InspectApiUpdateVirtualInstancesData, + InspectApiUpdateVirtualInstancesRequest, + InspectApiUpdateVirtualInstancesResponse, + InspectApiUpdateVirtualTemplatesData, + InspectApiUpdateVirtualTemplatesRequest, + InspectApiVirtualDeviceInstance, + InspectApiVirtualTemplateItem, +) from videoipath_automation_tool.connector.vip_connector import VideoIPathConnector from videoipath_automation_tool.utils.cross_app_utils import create_fallback_logger @@ -90,6 +101,20 @@ def get_collector_full(self) -> InspectApiCollectorResponse: response = self.vip_connector.rest.get(queries.collector_full(), allow_projection=True) return InspectApiCollectorResponse.model_validate({"data": response.data, "header": _header_dict(response)}) + # --- Virtual device / port-template reads --- + + def get_virtual_templates(self) -> list[InspectApiVirtualTemplateItem]: + """All port templates (UI: Manage port templates).""" + response = self.vip_connector.rest.get(queries.virtual_templates(), allow_projection=True) + items = _extract_items(response.data, "status", "network", "virtualTemplates") + return [InspectApiVirtualTemplateItem.model_validate(item) for item in items] + + def get_virtual_devices(self) -> list[InspectApiVirtualDeviceInstance]: + """All virtual device module/port definitions.""" + response = self.vip_connector.rest.get(queries.virtual_devices(), allow_projection=True) + items = _extract_items(response.data, "status", "network", "virtualDevices") + return [InspectApiVirtualDeviceInstance.model_validate(item) for item in items] + # --- Lookups (baselines for compare-and-commit, ADR-0009) --- def lookup_inspect_device(self, device_id: str) -> InspectApiLookupInspectDeviceResponse: @@ -133,6 +158,26 @@ def sync_devices( response = self.vip_connector.rest.post("/rest/v2/actions/status/network/syncDevices", request) return InspectApiSimpleActionResponse.model_validate(_post_envelope(response)) + def update_virtual_instances( + self, data: InspectApiUpdateVirtualInstancesData + ) -> InspectApiUpdateVirtualInstancesResponse: + """Create virtual devices (UI: Create virtual devices); wire also supports update/remove.""" + request = InspectApiUpdateVirtualInstancesRequest(data=data) + response = self.vip_connector.rest.post("/rest/v2/actions/status/network/updateVirtualInstances", request) + return InspectApiUpdateVirtualInstancesResponse.model_validate(_post_envelope(response)) + + def update_virtual_templates(self, data: InspectApiUpdateVirtualTemplatesData) -> InspectApiSimpleActionResponse: + """Add or remove port templates (UI: Manage port templates).""" + request = InspectApiUpdateVirtualTemplatesRequest(data=data) + response = self.vip_connector.rest.post("/rest/v2/actions/status/network/updateVirtualTemplates", request) + return InspectApiSimpleActionResponse.model_validate(_post_envelope(response)) + + def add_virtual_topology(self, data: InspectApiAddVirtualTopologyData) -> InspectApiSimpleActionResponse: + """Add ports from templates to an existing virtual-device module.""" + request = InspectApiAddVirtualTopologyRequest(data=data) + response = self.vip_connector.rest.post("/rest/v2/actions/status/network/addVirtualTopology", request) + return InspectApiSimpleActionResponse.model_validate(_post_envelope(response)) + # --- Internal --- diff --git a/src/videoipath_automation_tool/apps/inspect/api/queries.py b/src/videoipath_automation_tool/apps/inspect/api/queries.py index 8fb8205..6fa0fc9 100644 --- a/src/videoipath_automation_tool/apps/inspect/api/queries.py +++ b/src/videoipath_automation_tool/apps/inspect/api/queries.py @@ -65,6 +65,16 @@ def collector_full() -> str: return _build(_COLLECTOR_FULL) +def virtual_templates() -> str: + """GET path for all port templates (``status/network/virtualTemplates``).""" + return _build(_VIRTUAL_TEMPLATES) + + +def virtual_devices() -> str: + """GET path for all virtual device definitions (``status/network/virtualDevices``).""" + return _build(_VIRTUAL_DEVICES) + + # --- Internal --- # Characters that are meaningful in the projection grammar and must survive encoding. @@ -111,6 +121,10 @@ def collector_full() -> str: # Full aggregate (eager / fallback mode). _COLLECTOR_FULL = "/status/collector/**" +# Virtual device / port-template definitions (network status, not collector). +_VIRTUAL_TEMPLATES = "/status/network/virtualTemplates/**" +_VIRTUAL_DEVICES = "/status/network/virtualDevices/**" + def _build(path: str) -> str: encoded = encode(_DATA + path) @@ -128,4 +142,6 @@ def _build(path: str) -> str: "edge_pair", "paths_section", "collector_full", + "virtual_templates", + "virtual_devices", ] diff --git a/src/videoipath_automation_tool/apps/inspect/app/actions.py b/src/videoipath_automation_tool/apps/inspect/app/actions.py index 230db07..e289b17 100644 --- a/src/videoipath_automation_tool/apps/inspect/app/actions.py +++ b/src/videoipath_automation_tool/apps/inspect/app/actions.py @@ -1,24 +1,42 @@ -"""Topology device/sync network actions (addDevices, syncDevices, lookupSyncInfo). +"""Topology device/sync network actions (addDevices, syncDevices, lookupSyncInfo) and +virtual-device create / port-template helpers (updateVirtualInstances, +updateVirtualTemplates, addVirtualTopology). These wrap the ``actions/status/network/*`` and ``lookupSyncInfo`` endpoints used by the Inspect -device-onboarding-into-topology workflows. Placement and connections themselves go through the -transaction ([ADR-0006]); these actions handle bringing a device's driver-reported topology into -the graph and keeping it in sync. +device-onboarding-into-topology workflows. Placement, metadata edits, connections, and removal of +virtual devices go through the same write/transaction path as physical devices ([ADR-0006]); only +creating a virtual device (and managing port templates / adding ports from templates) uses these +dedicated network actions. """ from __future__ import annotations import logging from enum import IntEnum -from typing import TYPE_CHECKING, Iterable, Optional, Protocol, Union +from typing import TYPE_CHECKING, Iterable, Mapping, Optional, Protocol, Union from videoipath_automation_tool.apps.inspect.api import InspectAPI +from videoipath_automation_tool.apps.inspect.domain.device import VirtualDeviceSpec +from videoipath_automation_tool.apps.inspect.domain.port import ( + InspectPortTemplate, + PortFromTemplate, + _ports_to_count_by_template, +) +from videoipath_automation_tool.apps.inspect.errors import InspectError from videoipath_automation_tool.apps.inspect.model.actions import ( InspectApiAddDevicesItem, InspectApiLookupSyncInfoItem, ) +from videoipath_automation_tool.apps.inspect.model.virtual import ( + InspectApiAddVirtualTopologyData, + InspectApiUpdateVirtualInstancesData, + InspectApiUpdateVirtualTemplatesData, + InspectApiVirtualTemplateWriteBody, +) +from videoipath_automation_tool.validators.virtual_device_id import validate_virtual_device_id if TYPE_CHECKING: + from videoipath_automation_tool.apps.inspect.domain.device import InspectDevice from videoipath_automation_tool.apps.inspect.snapshot import InspectSnapshot @@ -95,6 +113,168 @@ def sync_devices( self._refresh_after_network_action(device_ids) return True + # --- Virtual devices / port templates (UI: Create virtual devices) --- + + def list_port_templates(self: _HasInspectApi) -> list[InspectPortTemplate]: + """List port templates available when building virtual devices.""" + return [InspectPortTemplate.from_wire(item) for item in self._inspect_api.get_virtual_templates()] + + def create_port_template( + self: _HasInspectApi, + template_id: str, + label: str, + vertex: dict, + *, + force: bool = False, + ) -> bool: + """Create or replace a port template (UI: Manage port templates). + + Args: + template_id: client-chosen template id (non-empty). + label: display label in the Add port dropdown. + vertex: vertex configuration payload (same shape as template ``vertex`` on read). + force: pass-through to the network action. + """ + if not template_id: + raise ValueError("template_id must not be empty.") + if not label: + raise ValueError("label must not be empty.") + response = self._inspect_api.update_virtual_templates( + InspectApiUpdateVirtualTemplatesData( + add={template_id: InspectApiVirtualTemplateWriteBody(label=label, vertex=vertex)}, + remove=[], + force=force, + ) + ) + if not response.data.ok: + self._logger.warning(f"updateVirtualTemplates reported failure: {response.data.msg}") + return False + return True + + def delete_port_templates(self: _HasInspectApi, template_ids: Iterable[str], *, force: bool = False) -> bool: + """Remove port templates by id.""" + ids = list(template_ids) + if not ids: + raise ValueError("template_ids must not be empty.") + if any(not template_id for template_id in ids): + raise ValueError("template_ids must not contain empty ids.") + response = self._inspect_api.update_virtual_templates( + InspectApiUpdateVirtualTemplatesData(add={}, remove=ids, force=force) + ) + if not response.data.ok: + self._logger.warning(f"updateVirtualTemplates reported failure: {response.data.msg}") + return False + return True + + def create_virtual_device(self: _HasInspectApi, spec: VirtualDeviceSpec) -> "InspectDevice": + """Create one virtual device from a module/port-template spec. + + The device is created unplaced (``coordinates`` null); use ``place_device`` / + ``update_device`` / ``remove_device_from_topology`` afterwards — the same methods as for + physical devices. ``InspectDevice.is_virtual`` is ``True`` on the returned object. + """ + return self.create_virtual_devices(spec, copies=1)[0] + + def create_virtual_devices( + self: _HasInspectApi, + spec: VirtualDeviceSpec, + *, + copies: int = 1, + ) -> list["InspectDevice"]: + """Create one or more virtual devices from a module/port-template spec. + + Devices are created unplaced (``coordinates`` null); use ``place_device`` to position them + and the normal device write methods for metadata edits / removal. + + Args: + spec: module/port definition (UI: Virtual Devices dialog). + copies: number of identical devices to create (UI: Number of copies). + + Returns: + Created :class:`InspectDevice` objects (server-assigned ``virtual.N`` ids). + + Raises: + InspectError: the network action failed, or created devices are not yet in the snapshot. + """ + if copies < 1: + raise ValueError("copies must be at least 1.") + body = spec.to_wire() + response = self._inspect_api.update_virtual_instances( + InspectApiUpdateVirtualInstancesData( + add=[body for _ in range(copies)], + update={}, + remove=[], + force=False, + ) + ) + if not (response.header.ok and response.data.res.ok and response.data.validation.result.ok): + msgs = response.data.res.msg or response.data.validation.result.msg + detail = "; ".join(m for m in msgs if m) or "updateVirtualInstances reported failure" + raise InspectError(f"create_virtual_devices failed: {detail}") + created_ids = list(response.data.addedDeviceLabels) + snapshot = self._ensure_snapshot() + snapshot.upsert_devices_from_skeleton(created_ids) + devices: list[InspectDevice] = [] + for device_id in created_ids: + device = snapshot.get_device(device_id) + if device is None: + self._logger.warning( + "Virtual device '%s' was created but is not yet visible in the Inspect snapshot.", + device_id, + ) + continue + devices.append(device) + if not devices: + raise InspectError( + f"Virtual device(s) created ({', '.join(created_ids) or 'none'}) " + "but not visible in the Inspect snapshot." + ) + return devices + + def add_virtual_ports( + self: _HasInspectApi, + device_id: str, + module_number: int, + ports: Mapping[str, int] | list[PortFromTemplate], + ) -> bool: + """Add ports from templates to an existing virtual-device module. + + Args: + device_id: virtual device id (``virtual.N``). + module_number: target module index (UI module number). + ports: ``{template_id: count}`` or a list of :class:`PortFromTemplate`. + """ + validate_virtual_device_id(device_id) + if module_number < 0: + raise ValueError("module_number must be non-negative.") + count_by_template = _ports_to_count_by_template(ports) + if not count_by_template: + raise ValueError("ports must not be empty.") + response = self._inspect_api.add_virtual_topology( + InspectApiAddVirtualTopologyData( + deviceId=device_id, + moduleId=module_number, + countByVertexTemplate=count_by_template, + ) + ) + if not response.data.ok: + self._logger.warning(f"addVirtualTopology reported failure: {response.data.msg}") + return False + self._refresh_after_network_action([device_id]) + return True + + def _ensure_snapshot(self: _HasInspectApi) -> "InspectSnapshot": + """Return the app snapshot, building it lazily when the read mixin is available.""" + get_snapshot = getattr(self, "_get_snapshot", None) + if callable(get_snapshot): + return get_snapshot() + if self._snapshot is not None: + return self._snapshot + raise InspectError( + "Inspect snapshot is not available; load the topology (e.g. access app.inspect.devices) " + "before creating virtual devices, or use InspectApp rather than a bare actions mixin." + ) + def _refresh_after_network_action(self: _HasInspectApi, device_ids: list[str]) -> None: """Update the internal snapshot for the affected devices after a successful network action. diff --git a/src/videoipath_automation_tool/apps/inspect/app/write.py b/src/videoipath_automation_tool/apps/inspect/app/write.py index 49d682a..77e9e73 100644 --- a/src/videoipath_automation_tool/apps/inspect/app/write.py +++ b/src/videoipath_automation_tool/apps/inspect/app/write.py @@ -16,7 +16,13 @@ from videoipath_automation_tool.apps.inspect.transaction import CommitResult, InspectTransaction from videoipath_automation_tool.apps.inspect.api import InspectAPI -from videoipath_automation_tool.apps.inspect.model.common import InspectIconType +from videoipath_automation_tool.apps.inspect.model.common import ( + InspectConfigPriority, + InspectIconSize, + InspectIconType, + InspectRedundancyMode, + InspectSdpStrategy, +) if TYPE_CHECKING: from videoipath_automation_tool.apps.inspect.snapshot import InspectSnapshot @@ -48,18 +54,24 @@ def update_device( device_id: str, *, label: Optional[str] = None, + description: Optional[str] = None, icon_type: Optional[InspectIconType | str] = None, - sdp_strategy: Optional[str] = None, + icon_size: Optional[InspectIconSize | str] = None, + sdp_strategy: Optional[InspectSdpStrategy | str] = None, + site_id: Optional[str] = None, tags: Optional[list[str]] = None, coordinates: Optional[dict[str, float]] = None, ) -> CommitResult: - """Edit a device's placement/appearance fields (single auto-committed change).""" + """Edit a device's "Edit Device" dialog fields (single auto-committed change).""" with self.transaction() as tx: tx.update_device( device_id, label=label, + description=description, icon_type=icon_type, + icon_size=icon_size, sdp_strategy=sdp_strategy, + site_id=site_id, tags=tags, coordinates=coordinates, ) @@ -79,6 +91,19 @@ def update_vertex( extra_alert_filters: Optional[list[Any]] = None, custom: Optional[dict[str, Any]] = None, park_port: Optional[int] = None, + ip_address: Optional[str] = None, + ip_netmask: Optional[str] = None, + public: Optional[bool] = None, + vlan_id: Optional[str] = None, + vrf_id: Optional[str] = None, + supports_cpipe: Optional[bool] = None, + supports_igmp: Optional[bool] = None, + supports_mac_forwarding: Optional[bool] = None, + supports_nso: Optional[bool] = None, + supports_openflow: Optional[bool] = None, + supports_static_igmp: Optional[bool] = None, + supports_vlan: Optional[bool] = None, + supports_vpls: Optional[bool] = None, ) -> CommitResult: """Edit a vertex (single auto-committed change; update-only).""" with self.transaction() as tx: @@ -94,6 +119,19 @@ def update_vertex( extra_alert_filters=extra_alert_filters, custom=custom, park_port=park_port, + ip_address=ip_address, + ip_netmask=ip_netmask, + public=public, + vlan_id=vlan_id, + vrf_id=vrf_id, + supports_cpipe=supports_cpipe, + supports_igmp=supports_igmp, + supports_mac_forwarding=supports_mac_forwarding, + supports_nso=supports_nso, + supports_openflow=supports_openflow, + supports_static_igmp=supports_static_igmp, + supports_vlan=supports_vlan, + supports_vpls=supports_vpls, ) return tx.commit() @@ -101,23 +139,42 @@ def update_edge( self: _HasInspectState, edge_id: str, *, + label: Optional[str] = None, + description: Optional[str] = None, weight: Optional[int] = None, capacity: Optional[int] = None, bandwidth: Optional[float] = None, - redundancy_mode: Optional[str] = None, + redundancy_mode: Optional[InspectRedundancyMode | str] = None, + conflict_priority: Optional[InspectConfigPriority | int | str] = None, + include_formats: Optional[list[str]] = None, + exclude_formats: Optional[list[str]] = None, + bandwidth_weight_factor: Optional[int] = None, + weight_per_service: Optional[int] = None, active: Optional[bool] = None, tags: Optional[list[str]] = None, + also_opposite: bool = False, ) -> CommitResult: - """Edit an existing edge (single auto-committed change).""" + """Edit an existing edge's "Edit Edge" dialog fields (single auto-committed change). + + With ``also_opposite`` the same changes are applied to the opposite directed edge too. + """ with self.transaction() as tx: tx.update_edge( edge_id, + label=label, + description=description, weight=weight, capacity=capacity, bandwidth=bandwidth, redundancy_mode=redundancy_mode, + conflict_priority=conflict_priority, + include_formats=include_formats, + exclude_formats=exclude_formats, + bandwidth_weight_factor=bandwidth_weight_factor, + weight_per_service=weight_per_service, active=active, tags=tags, + also_opposite=also_opposite, ) return tx.commit() @@ -148,7 +205,10 @@ def disconnect( return tx.commit() def remove_device_from_topology(self: _HasInspectState, device_id: str) -> CommitResult: - """Remove a device (its baseDevice element) from the topology graph.""" + """Remove a device (its baseDevice element) from the topology graph. + + Works for both physical and virtual (``virtual.N``) devices via ``updateTopology``. + """ with self.transaction() as tx: tx.remove_device(device_id) return tx.commit() diff --git a/src/videoipath_automation_tool/apps/inspect/domain/__init__.py b/src/videoipath_automation_tool/apps/inspect/domain/__init__.py index 589625e..a08a91e 100644 --- a/src/videoipath_automation_tool/apps/inspect/domain/__init__.py +++ b/src/videoipath_automation_tool/apps/inspect/domain/__init__.py @@ -1,13 +1,31 @@ from __future__ import annotations -from videoipath_automation_tool.apps.inspect.domain.device import InspectDevice +from videoipath_automation_tool.apps.inspect.domain.device import InspectDevice, VirtualDeviceSpec from videoipath_automation_tool.apps.inspect.domain.edge import InspectEdge -from videoipath_automation_tool.apps.inspect.domain.port import InspectPort +from videoipath_automation_tool.apps.inspect.domain.module import InspectModule, VirtualModuleSpec +from videoipath_automation_tool.apps.inspect.domain.port import InspectPort, InspectPortTemplate, PortFromTemplate from videoipath_automation_tool.apps.inspect.domain.service import InspectService +from videoipath_automation_tool.apps.inspect.domain.vertex import ( + InspectCodecVertex, + InspectGenericVertex, + InspectIpVertex, + InspectResourceTransformVertex, + InspectVertex, +) __all__ = [ + "InspectCodecVertex", "InspectDevice", "InspectEdge", + "InspectGenericVertex", + "InspectIpVertex", + "InspectModule", "InspectPort", + "InspectPortTemplate", + "InspectResourceTransformVertex", "InspectService", + "InspectVertex", + "PortFromTemplate", + "VirtualDeviceSpec", + "VirtualModuleSpec", ] diff --git a/src/videoipath_automation_tool/apps/inspect/domain/device.py b/src/videoipath_automation_tool/apps/inspect/domain/device.py index 82e6752..ad0b6f7 100644 --- a/src/videoipath_automation_tool/apps/inspect/domain/device.py +++ b/src/videoipath_automation_tool/apps/inspect/domain/device.py @@ -1,24 +1,81 @@ from __future__ import annotations from datetime import datetime -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Self +from pydantic import Field + +from videoipath_automation_tool.apps.inspect.domain.module import VirtualModuleSpec +from videoipath_automation_tool.apps.inspect.domain.port import PortFromTemplate from videoipath_automation_tool.apps.inspect.model.common import ( InspectApiStatusSummary, InspectFrozenModel, + InspectIconSize, InspectIconType, + InspectInternalModel, + InspectSdpStrategy, InspectVertexKind, InspectVertexType, ) +from videoipath_automation_tool.apps.inspect.model.virtual import InspectApiVirtualDeviceWriteBody from videoipath_automation_tool.apps.inspect.snapshot import InspectSnapshot +from videoipath_automation_tool.validators.virtual_device_id import is_virtual_device_id if TYPE_CHECKING: from videoipath_automation_tool.apps.inspect.domain.edge import InspectEdge + from videoipath_automation_tool.apps.inspect.domain.module import InspectModule from videoipath_automation_tool.apps.inspect.domain.port import InspectPort from videoipath_automation_tool.apps.inspect.domain.service import InspectService + from videoipath_automation_tool.apps.inspect.domain.vertex import InspectVertex from videoipath_automation_tool.apps.inspect.snapshot import _DeviceRecord +class VirtualDeviceSpec(InspectInternalModel): + """Declarative definition of a virtual device, matching the Create Virtual Devices dialog. + + Mutable so fluent helpers (``add_module`` / ``add_port``) can mirror the UI workflow. + """ + + modules: list[VirtualModuleSpec] = Field(default_factory=lambda: [VirtualModuleSpec()]) + + @classmethod + def empty(cls) -> VirtualDeviceSpec: + """One empty module, as in the UI default.""" + return cls(modules=[VirtualModuleSpec()]) + + def add_module(self) -> Self: + """Append an empty module (UI: + Add module).""" + self.modules.append(VirtualModuleSpec()) + return self + + def add_port(self, template_id: str, count: int = 1, *, module_index: int = -1) -> Self: + """Add a port from a template to a module (UI: Add port).""" + if not self.modules: + self.modules.append(VirtualModuleSpec()) + idx = module_index if module_index >= 0 else len(self.modules) + module_index + if idx < 0 or idx >= len(self.modules): + raise IndexError(f"module_index {module_index} is out of range for {len(self.modules)} module(s).") + self.modules[idx].ports.append(PortFromTemplate(template_id=template_id, count=count)) + return self + + def to_wire(self) -> InspectApiVirtualDeviceWriteBody: + return InspectApiVirtualDeviceWriteBody(modules=[module.to_wire() for module in self.modules]) + + @classmethod + def from_ports(cls, *ports: PortFromTemplate | tuple[str, int] | str) -> VirtualDeviceSpec: + """Build a single-module device from port specs.""" + resolved: list[PortFromTemplate] = [] + for port in ports: + if isinstance(port, PortFromTemplate): + resolved.append(port) + elif isinstance(port, str): + resolved.append(PortFromTemplate(template_id=port)) + else: + template_id, count = port + resolved.append(PortFromTemplate(template_id=template_id, count=count)) + return cls(modules=[VirtualModuleSpec(ports=resolved)]) + + class InspectDevice(InspectFrozenModel): """A topology device/node. Skeleton fields (id, label, coordinates, status, sync, tags) are available immediately; ``ports`` and ``services`` lazily hydrate from the server on first @@ -42,6 +99,13 @@ def pid(self) -> str | None: @property def is_virtual(self) -> bool | None: + """Whether this is a topology virtual device (``virtual.N``). + + ``virtual.N`` ids are always virtual; otherwise the collector ``meta.isVirtual`` flag is + used when present. + """ + if is_virtual_device_id(self.id): + return True meta = self._record().node.meta return meta.isVirtual if meta is not None else None @@ -50,6 +114,26 @@ def icon_type(self) -> InspectIconType | str | None: meta = self._record().node.meta return meta.iconType if meta is not None else None + @property + def icon_size(self) -> InspectIconSize | str | None: + """Device icon size ("Device icon size" in the UI): ``"auto"`` / ``"large"`` / ``"medium"`` / + ``"small"``.""" + meta = self._record().node.meta + return meta.iconSize if meta is not None else None + + @property + def sdp_strategy(self) -> InspectSdpStrategy | str | None: + """SDP polling strategy ("SDP polling strategy" in the UI): ``"always"`` (Continuous) / + ``"once"`` (Fetch and Confirm) / ``"video"`` (Continuous Video, Confirm Others).""" + meta = self._record().node.meta + return meta.sdpStrategy if meta is not None else None + + @property + def site_id(self) -> str | None: + """The id of the site this device is located at ("Site ID" in the UI).""" + meta = self._record().node.meta + return meta.siteId if meta is not None else None + @property def status(self) -> InspectApiStatusSummary | None: return self._record().node.status @@ -74,8 +158,18 @@ def is_hydrated(self) -> bool: def fetched_at(self) -> datetime | None: return self.snapshot.fetched_at(self.id) + @property + def modules(self) -> list[InspectModule]: + """The device's modules / slots, each owning many ports/vertices.""" + return self.snapshot.get_modules_for_device(self.id) + + def get_module(self, module_id: str) -> InspectModule | None: + return self.snapshot.get_module(self.id, module_id) + @property def ports(self) -> list[InspectPort]: + """All port rows across the device's modules (flattened). Use :attr:`modules` for the + module → port grouping.""" return self.snapshot.get_ports_for_device(self.id) def filter_ports( @@ -88,31 +182,41 @@ def filter_ports( controlled: bool | None = None, endpoint: bool | None = None, ) -> list[InspectPort]: - """Filter this device's ports/vertices. + """Filter this device's ports by their vertices' attributes. - All filters except ``kind`` are evaluated from already-hydrated data (at most the one lazy - device-detail fetch that ``ports`` itself triggers). ``kind`` (``"generic"`` / ``"ip"`` / - ``"codec"`` / ``"router"``) requires the vertex edit form: uncached vertices are resolved + ``vertex_type`` is the port-level direction (``"BiDirectional"`` for a two-vertex port, else + the single vertex's direction); ``active`` / ``controlled`` / ``endpoint`` aggregate the + port's vertices (True if any vertex is True, False only if all are known False). These are + evaluated offline from the already-hydrated ``vertexInfo``. ``kind`` (``"generic"`` / ``"ip"`` + / ``"codec"`` / ``"router"``) requires the vertex edit form: uncached vertices are resolved with a single batched ``lookupInspectVertexByIds`` call. A port whose value for an explicit filter is unknown never matches. """ result: list[InspectPort] = [] for port in self.ports: - if module_id is not None and port.module_id != module_id: + if module_id is not None and port.indexed.module_id != module_id: continue - if vertex_type is not None and port.vertex_type != vertex_type: + vertices = port._offline_vertices() + if vertex_type is not None and _port_direction(vertices) != vertex_type: continue - if active is not None and port.is_active is not active: + if active is not None and _aggregate_flag(vertices, "is_active") is not active: continue - if controlled is not None and port.is_controlled is not controlled: + if controlled is not None and _aggregate_flag(vertices, "is_controlled") is not controlled: continue - if endpoint is not None and port.is_endpoint is not endpoint: + if endpoint is not None and _aggregate_flag(vertices, "is_endpoint") is not endpoint: continue result.append(port) if kind is not None: - self.snapshot.get_vertex_details_many([p.vertex_id for p in result if p.vertex_id]) - result = [p for p in result if p.vertex_kind == kind] + first_sides = [sides[0] for p in result if (sides := p._vertex_sides())] + self.snapshot.get_vertex_details_many([vid for vid, _ in first_sides]) + result = [ + p + for p in result + if (sides := p._vertex_sides()) + and (v := self.snapshot.get_vertex(sides[0][0], vertex_info=sides[0][1])) is not None + and v.vertex_kind == kind + ] return result @property @@ -132,3 +236,28 @@ def _record(self) -> "_DeviceRecord": if record is None: raise KeyError(f"Device '{self.id}' is no longer present in the snapshot.") return record + + +# --- Internal --- + + +def _port_direction(vertices: list[InspectVertex]) -> str | None: + """Port-level direction: ``"BiDirectional"`` for a two-vertex port, else the single vertex's + direction (None for a port without vertices).""" + if len(vertices) > 1: + return "BiDirectional" + return vertices[0].vertex_type if vertices else None + + +def _aggregate_flag(vertices: list[InspectVertex], attr: str) -> bool | None: + """Aggregate a vertex boolean flag across a port: True if any vertex is True, False only if all + are known False, else None (unknown).""" + values = [getattr(vertex, attr) for vertex in vertices] + if any(value is True for value in values): + return True + if values and all(value is False for value in values): + return False + return None + + +__all__ = ["InspectDevice", "VirtualDeviceSpec"] diff --git a/src/videoipath_automation_tool/apps/inspect/domain/edge.py b/src/videoipath_automation_tool/apps/inspect/domain/edge.py index 597040d..3a45f3e 100644 --- a/src/videoipath_automation_tool/apps/inspect/domain/edge.py +++ b/src/videoipath_automation_tool/apps/inspect/domain/edge.py @@ -3,13 +3,19 @@ from typing import TYPE_CHECKING from videoipath_automation_tool.apps.inspect.model.collector import InspectApiExternalEdgeLiveStatus -from videoipath_automation_tool.apps.inspect.model.common import InspectFrozenModel +from videoipath_automation_tool.apps.inspect.model.common import ( + CONFLICT_PRIORITY_BY_INT, + InspectConfigPriority, + InspectFrozenModel, + InspectRedundancyMode, +) from videoipath_automation_tool.apps.inspect.snapshot import InspectSnapshot, _IndexedEdge if TYPE_CHECKING: from videoipath_automation_tool.apps.inspect.domain.device import InspectDevice from videoipath_automation_tool.apps.inspect.domain.port import InspectPort from videoipath_automation_tool.apps.inspect.domain.service import InspectService + from videoipath_automation_tool.apps.inspect.model.actions import InspectApiEdgeForm class InspectEdge(InspectFrozenModel): @@ -75,3 +81,90 @@ def services(self) -> list[InspectService]: seen_booking_ids.add(service.booking_id) services.append(service) return services + + # --- Config (the "Edit Edge" dialog fields; lazily fetched via lookupInspectEdgesByIds) --- + + @property + def label(self) -> str | None: + """Manual edge label ("Label" in the Edit Edge dialog).""" + form = self._edit_form() + return form.descriptor.label if form else None + + @property + def description(self) -> str | None: + """Edge description ("Description" in the Edit Edge dialog).""" + form = self._edit_form() + return form.descriptor.desc if form else None + + @property + def tags(self) -> list[str]: + form = self._edit_form() + return list(form.tags) if form else [] + + @property + def active(self) -> bool | None: + form = self._edit_form() + return form.active if form else None + + @property + def include_formats(self) -> list[str]: + form = self._edit_form() + return list(form.includeFormats) if form else [] + + @property + def exclude_formats(self) -> list[str]: + form = self._edit_form() + return list(form.excludeFormats) if form else [] + + @property + def conflict_priority(self) -> InspectConfigPriority | int | str | None: + """Conflict priority ("Conflict priority" in the UI): ``"off"`` / ``"high"`` / ``"normal"`` / + ``"low"`` (mapped from the on-wire int), or the raw value if unrecognized.""" + form = self._edit_form() + if form is None: + return None + raw = form.conflictPri + return CONFLICT_PRIORITY_BY_INT.get(raw, raw) if isinstance(raw, int) else raw + + @property + def redundancy_mode(self) -> InspectRedundancyMode | str | None: + form = self._edit_form() + return form.redundancyMode if form else None + + @property + def fixed_weight(self) -> int | None: + """Fixed routing weight/cost ("Fixed weight" in the UI).""" + form = self._edit_form() + return form.weight if form else None + + @property + def bandwidth_capacity(self) -> float | int | None: + """Configured max bandwidth in Mbit/s ("Bandwidth capacity" in the UI); ``-1.0`` = disabled. + Distinct from :attr:`bandwidth`, which is the live status value.""" + form = self._edit_form() + return form.bandwidth if form else None + + @property + def services_capacity(self) -> int | None: + """Max number of simultaneous services ("Services capacity" in the UI); ``65535`` = unlimited.""" + form = self._edit_form() + return form.capacity if form else None + + @property + def bandwidth_weight_factor(self) -> int | None: + """Bandwidth-based weight factor ("Bandwidth weight factor" in the UI).""" + form = self._edit_form() + if form is None: + return None + return (form.weightFactors.get("bandwidth") or {}).get("weight") + + @property + def weight_per_service(self) -> int | None: + """Service-based weight factor ("Weight per service" in the UI).""" + form = self._edit_form() + if form is None: + return None + return (form.weightFactors.get("service") or {}).get("weight") + + def _edit_form(self) -> InspectApiEdgeForm | None: + return self.snapshot.get_edge_details(self.id) diff --git a/src/videoipath_automation_tool/apps/inspect/domain/module.py b/src/videoipath_automation_tool/apps/inspect/domain/module.py new file mode 100644 index 0000000..e5cb22b --- /dev/null +++ b/src/videoipath_automation_tool/apps/inspect/domain/module.py @@ -0,0 +1,95 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +from pydantic import Field + +from videoipath_automation_tool.apps.inspect.domain.port import PortFromTemplate +from videoipath_automation_tool.apps.inspect.model.common import ( + InspectApiStatusSummary, + InspectFrozenModel, + InspectInternalModel, +) +from videoipath_automation_tool.apps.inspect.model.virtual import InspectApiVirtualModule +from videoipath_automation_tool.apps.inspect.snapshot import InspectSnapshot + +if TYPE_CHECKING: + from videoipath_automation_tool.apps.inspect.domain.device import InspectDevice + from videoipath_automation_tool.apps.inspect.domain.port import InspectPort + from videoipath_automation_tool.apps.inspect.domain.vertex import InspectVertex + from videoipath_automation_tool.apps.inspect.model.collector import InspectApiModuleStatus + + +class VirtualModuleSpec(InspectInternalModel): + """One module on a virtual device (UI: Module 1, Module 2, …). Mutable for fluent building.""" + + ports: list[PortFromTemplate] = Field(default_factory=list) + module_number: int | None = None + + def to_wire(self) -> InspectApiVirtualModule: + return InspectApiVirtualModule( + moduleNumber=self.module_number, + vertices=[port.to_wire() for port in self.ports], + ) + + +class InspectModule(InspectFrozenModel): + """A device module / slot. A module owns many ports, each of which carries one or more vertices, so a module + holds many vertices in total. The module status is resolved live from the snapshot, so a held + reference sees hydrated/refreshed data transparently. + + Prefer :attr:`id` and :attr:`device` (``module.device.id``) over the constructor fields + ``module_id`` / ``device_id``. + """ + + snapshot: InspectSnapshot + device_id: str + module_id: str + + @property + def id(self) -> str: + return self.module_id + + @property + def label(self) -> str | None: + """The module label.""" + status = self._status() + return status.effective_label if status is not None else None + + @property + def description(self) -> str | None: + status = self._status() + return status.effective_description if status is not None else None + + @property + def status(self) -> InspectApiStatusSummary | None: + status = self._status() + return status.status if status is not None else None + + @property + def tags(self) -> list[str]: + """Tags assigned to this module (from ``tagsInfo.assigned.all``).""" + status = self._status() + return status.assigned_tags if status is not None else [] + + @property + def device(self) -> InspectDevice | None: + """The owning device; use ``module.device.id`` for the device id.""" + return self.snapshot.get_device_by_id(self.device_id) + + @property + def ports(self) -> list[InspectPort]: + """The port rows in this module (e.g. "Router In 11.1", "Router Out 11.1", …).""" + return self.snapshot.get_ports_for_module(self.device_id, self.module_id) + + @property + def vertices(self) -> list[InspectVertex]: + """Every vertex across the module's ports (triggers a lazy vertex lookup per port for the + typed kind).""" + return [vertex for port in self.ports for vertex in port._vertices()] + + def _status(self) -> InspectApiModuleStatus | None: + return self.snapshot.get_module_status(self.device_id, self.module_id) + + +__all__ = ["InspectModule", "VirtualModuleSpec"] diff --git a/src/videoipath_automation_tool/apps/inspect/domain/port.py b/src/videoipath_automation_tool/apps/inspect/domain/port.py index cf43661..79e38ab 100644 --- a/src/videoipath_automation_tool/apps/inspect/domain/port.py +++ b/src/videoipath_automation_tool/apps/inspect/domain/port.py @@ -1,36 +1,83 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, Mapping, Self + +from pydantic import Field, model_validator from videoipath_automation_tool.apps.inspect.model.collector import ( InspectApiDoubleVertexInfo, InspectApiSingleVertexInfo, ) -from videoipath_automation_tool.apps.inspect.model.common import ( - InspectApiStatusSummary, - InspectFrozenModel, - InspectVertexKind, - InspectVertexType, +from videoipath_automation_tool.apps.inspect.model.common import InspectFrozenModel +from videoipath_automation_tool.apps.inspect.model.virtual import ( + InspectApiVirtualPortFromTemplate, + InspectApiVirtualTemplateItem, ) - -if TYPE_CHECKING: - from videoipath_automation_tool.apps.inspect.domain.device import InspectDevice - from videoipath_automation_tool.apps.inspect.domain.edge import InspectEdge - from videoipath_automation_tool.apps.inspect.model.actions import ( - InspectApiLookupVertexResponseData, - InspectApiVertexControlProps, - InspectApiVertexEditForm, - InspectApiVertexTypeFields, - ) from videoipath_automation_tool.apps.inspect.snapshot import ( InspectSnapshot, _IndexedPort, _port_id_from_status, - _vertex_ids_from_status, ) +if TYPE_CHECKING: + from videoipath_automation_tool.apps.inspect.domain.device import InspectDevice + from videoipath_automation_tool.apps.inspect.domain.edge import InspectEdge + from videoipath_automation_tool.apps.inspect.domain.module import InspectModule + from videoipath_automation_tool.apps.inspect.domain.vertex import InspectVertex + from videoipath_automation_tool.apps.inspect.model.common import InspectApiStatusSummary + + +class PortFromTemplate(InspectFrozenModel): + """One port (or set of ports) instantiated from a port template (virtual-device create).""" + + template_id: str + count: int = 1 + + @model_validator(mode="after") + def _validate_count(self) -> Self: + if not self.template_id: + raise ValueError("template_id must not be empty.") + if self.count < 1: + raise ValueError("count must be at least 1.") + return self + + def to_wire(self) -> InspectApiVirtualPortFromTemplate: + return InspectApiVirtualPortFromTemplate(templateId=self.template_id, count=self.count) + + +class InspectPortTemplate(InspectFrozenModel): + """A port template (UI term; API: virtual template).""" + + id: str + label: str + kind: str | None = None + direction: str | None = None + codec_format: str | None = None + vertex: dict[str, Any] = Field(default_factory=dict) + + @classmethod + def from_wire(cls, wire: InspectApiVirtualTemplateItem) -> InspectPortTemplate: + vertex = wire.vertex.model_dump(mode="json", exclude_none=False) + return cls( + id=wire.id, + label=wire.label, + kind=wire.vertex.type, + direction=wire.vertex.vertexType, + codec_format=wire.vertex.codecFormat, + vertex=vertex, + ) + class InspectPort(InspectFrozenModel): + """A port (the Inspect UI's "Module" edit modal): a lean container that owns one or more vertices. + + Its own editable attributes are ``label``, ``description`` and ``tags``; ``id``, ``status`` and + ``factory_label`` are read-only. Navigate to the owning module via :attr:`module` (use + ``port.module.id``). All vertex-specific configuration (direction, active/controlled/endpoint, + IP/codec fields, SIPS, control, …) lives on the vertices reachable via :attr:`vertex_out` / + :attr:`vertex_in`. + """ + snapshot: InspectSnapshot indexed: _IndexedPort @@ -53,14 +100,6 @@ def factory_label(self) -> str | None: def description(self) -> str | None: return self.indexed.port.effective_description - @property - def device(self) -> InspectDevice | None: - return self.snapshot.get_device_by_id(self.indexed.device_id) - - @property - def module_id(self) -> str | None: - return self.indexed.module_id - @property def status(self) -> InspectApiStatusSummary | None: return self.indexed.port.status @@ -68,135 +107,92 @@ def status(self) -> InspectApiStatusSummary | None: @property def tags(self) -> list[str]: """Tags assigned to this port (as ``Category~~name`` ids). Assign them with - ``app.inspect.update_vertex(port.vertex_id, tags=[...])``.""" + ``app.inspect.update_vertex(v.id, tags=[...])`` for a vertex ``v`` from + :attr:`vertex_out` / :attr:`vertex_in`.""" return self.indexed.port.assigned_tags @property - def vertex_info(self) -> InspectApiSingleVertexInfo | InspectApiDoubleVertexInfo | None: - """The port's raw (typed) ``vertexInfo`` from the collector.""" - return self.indexed.port.parsed_vertex_info + def device(self) -> InspectDevice | None: + return self.snapshot.get_device_by_id(self.indexed.device_id) @property - def vertex_type(self) -> InspectVertexType | str | None: - """Vertex direction: ``"In"`` / ``"Out"`` / ``"Internal"`` / …; ``"BiDirectional"`` when the - port carries a double (in+out) vertexInfo.""" - info = self.vertex_info - if info is None: + def module(self) -> InspectModule | None: + """The module / slot this port belongs to.""" + module_id = self.indexed.module_id + if module_id is None: return None - if isinstance(info, InspectApiDoubleVertexInfo): - return "BiDirectional" - return info.vertexType - - @property - def is_bidirectional(self) -> bool | None: - info = self.vertex_info - return isinstance(info, InspectApiDoubleVertexInfo) if info is not None else None - - @property - def is_active(self) -> bool | None: - return self._vertex_flag("isActive") - - @property - def is_controlled(self) -> bool | None: - return self._vertex_flag("isControlled") - - @property - def is_endpoint(self) -> bool | None: - """Whether the vertex is usable as a service endpoint ("use as endpoint" in the UI).""" - return self._vertex_flag("isEndpoint") - - @property - def vertex_id(self) -> str | None: - vertex_ids = self.vertex_ids - return vertex_ids[0] if vertex_ids else None - - @property - def vertex_ids(self) -> tuple[str, ...]: - """All vertex ids of this port: one for a single vertex, (out, in) for a bidirectional.""" - return _vertex_ids_from_status(self.indexed.port) - - @property - def vertex_kind(self) -> InspectVertexKind | str | None: - """Vertex kind: ``"generic"`` / ``"ip"`` / ``"codec"`` / ``"router"`` — from the vertex edit - form's ``typeFields.type`` (triggers a lazy vertex lookup on first access).""" - type_fields = self.type_fields - return type_fields.type if type_fields is not None else None - - @property - def type_fields(self) -> InspectApiVertexTypeFields | None: - form = self._edit_form() - return form.typeFields if form else None + return self.snapshot.get_module(self.indexed.device_id, module_id) @property - def sips_mode(self) -> str | None: - form = self._edit_form() - return form.sipsMode if form else None + def is_bidirectional(self) -> bool: + """True when this port carries a ``double`` ``vertexInfo`` (separate out and in vertices).""" + return isinstance(self.indexed.port.parsed_vertex_info, InspectApiDoubleVertexInfo) @property - def control_props(self) -> InspectApiVertexControlProps | None: - form = self._edit_form() - return form.controlProps if form else None + def vertex_out(self) -> InspectVertex | None: + """The ``Out`` vertex, if this port has one; ``None`` otherwise.""" + return self._vertex_by_type("Out") @property - def extra_alert_filters(self) -> list[Any]: - form = self._edit_form() - return list(form.extraAlertFilters) if form else [] + def vertex_in(self) -> InspectVertex | None: + """The ``In`` vertex, if this port has one; ``None`` otherwise.""" + return self._vertex_by_type("In") @property - def custom(self) -> dict[str, Any]: - form = self._edit_form() - return dict(form.custom) if form else {} - - @property - def custom_schemas(self) -> dict[str, Any]: - lookup = self._vertex_lookup() - return dict(lookup.customSchemas) if lookup else {} - - @property - def queueable(self) -> bool | None: - form = self._edit_form() - return form.queueable if form else None - - @property - def destination_monitor_leader(self) -> bool | None: - form = self._edit_form() - return form.destinationMonitorLeader if form else None - - @property - def park_port(self) -> int | None: - """Park port (router vertices): ``typeFields.parkPort`` from the vertex edit form.""" - type_fields = self.type_fields - return type_fields.parkPort if type_fields is not None else None - - @property - def edge(self) -> InspectEdge | None: + def edges(self) -> list[InspectEdge]: + """The edges incident on this port. The Inspect read view (``externalEdgesByDeviceKey``) keys + edge endpoints by *port* — with a UUID edge id and only a direction hint in the endpoint label + — so edges are exposed at the port level here, not per vertex.""" port_id = self.id if not port_id: - return None - return self.snapshot.get_edge_for_port(self.indexed.device_id, port_id) + return [] + return self.snapshot.get_edges_for_port(self.indexed.device_id, port_id) - def _vertex_lookup(self) -> InspectApiLookupVertexResponseData | None: - vertex_id = self.vertex_id - if not vertex_id: - return None - return self.snapshot.get_vertex_details(vertex_id) + def _vertex_by_type(self, vertex_type: str) -> InspectVertex | None: + for vid, side in self._vertex_sides(): + if side.vertexType == vertex_type: + return self.snapshot.get_vertex(vid, vertex_info=side) + return None - def _edit_form(self) -> InspectApiVertexEditForm | None: - lookup = self._vertex_lookup() - return lookup.fields if lookup else None + def _vertices(self) -> list[InspectVertex]: + """All typed vertex views this port carries (including Internal/Undecided). Prefer + :attr:`vertex_out` / :attr:`vertex_in` for the public API.""" + return [self.snapshot.get_vertex(vid, vertex_info=side) for vid, side in self._vertex_sides()] - def _vertex_flag(self, name: str) -> bool | None: - """Resolve a ``vertexInfo.fields`` flag; for double vertices: True if either side is True, - False only if all sides are known False, else None.""" - info = self.vertex_info + def _vertex_sides(self) -> list[tuple[str, InspectApiSingleVertexInfo]]: + """(vertex id, its offline ``vertexInfo`` side) for each vertex the port carries.""" + info = self.indexed.port.parsed_vertex_info if info is None: - return None - sides = (info,) if isinstance(info, InspectApiSingleVertexInfo) else (info.out, info.in_) - values = [ - getattr(side.fields, name) if side is not None and side.fields is not None else None for side in sides - ] - if any(value is True for value in values): - return True - if values and all(value is False for value in values): - return False - return None + return [] + if isinstance(info, InspectApiSingleVertexInfo): + return [(info.id, info)] if info.id else [] + return [(side.id, side) for side in (info.out, info.in_) if side is not None and side.id] + + def _offline_vertices(self) -> list[InspectVertex]: + """Base vertex views built purely from the offline ``vertexInfo`` (no lookup). Used for + offline filtering; direction/active/controlled/endpoint resolve without a fetch.""" + from videoipath_automation_tool.apps.inspect.domain.vertex import InspectVertex + + return [InspectVertex(snapshot=self.snapshot, id=vid, vertex_info=side) for vid, side in self._vertex_sides()] + + +def _ports_to_count_by_template( + ports: Mapping[str, int] | list[PortFromTemplate], +) -> dict[str, int]: + """Normalize port specs to the ``countByVertexTemplate`` wire map.""" + result: dict[str, int] + if isinstance(ports, Mapping): + result = {template_id: count for template_id, count in ports.items()} + else: + result = {} + for port in ports: + result[port.template_id] = result.get(port.template_id, 0) + port.count + for template_id, count in result.items(): + if not template_id: + raise ValueError("template_id must not be empty.") + if count < 1: + raise ValueError(f"count for template {template_id!r} must be at least 1.") + return result + + +__all__ = ["InspectPort", "InspectPortTemplate", "PortFromTemplate"] diff --git a/src/videoipath_automation_tool/apps/inspect/domain/vertex.py b/src/videoipath_automation_tool/apps/inspect/domain/vertex.py new file mode 100644 index 0000000..a33475a --- /dev/null +++ b/src/videoipath_automation_tool/apps/inspect/domain/vertex.py @@ -0,0 +1,345 @@ +"""Typed vertex domain views ([ADR-0007]). + +A vertex is one directed endpoint of a port (a port carries one vertex, or an ``out``/``in`` pair). +``InspectVertex`` is the base read view over the vertex edit form (``lookupInspectVertexById``); +concrete kinds subclass it to expose their kind-specific attributes: + +- ``InspectIpVertex`` — IP config (address, netmask, VLAN, VRF, config-support flags). +- ``InspectCodecVertex`` — codec config (``typeFields.generic`` / ``typeFields.specific``). +- ``InspectGenericVertex`` — base fields only. +- ``InspectResourceTransformVertex`` — base fields only (kind-specific fields await a live sample). + +Router vertices are surfaced as the base ``InspectVertex`` (their ``park_port`` lives on the base). +Instances are built by :meth:`InspectSnapshot.get_vertex`, which picks the subclass from the edit +form's ``typeFields.type``. Edits go through ``app.inspect.update_vertex(vertex.id, ...)``. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +from videoipath_automation_tool.apps.inspect.model.collector import InspectApiSingleVertexInfo +from videoipath_automation_tool.apps.inspect.model.common import ( + InspectFrozenModel, + InspectVertexKind, + InspectVertexType, +) +from videoipath_automation_tool.apps.inspect.snapshot import InspectSnapshot + +if TYPE_CHECKING: + from videoipath_automation_tool.apps.inspect.model.actions import ( + InspectApiCodecGeneric, + InspectApiCodecSpecific, + InspectApiLookupVertexResponseData, + InspectApiVertexControlProps, + InspectApiVertexEditForm, + InspectApiVertexTypeFields, + ) + + +class InspectVertex(InspectFrozenModel): + """Base read view of a single vertex. + + Direction and the ``isActive`` / ``isControlled`` / ``isEndpoint`` status flags resolve offline + from the owning port's ``vertexInfo`` (``vertex_info``, set when built via a port); the config + fields (label, tags, SIPS, control, IP/codec, …) resolve live from the snapshot's cached edit + form (``lookupInspectVertexById``).""" + + snapshot: InspectSnapshot + id: str + vertex_info: InspectApiSingleVertexInfo | None = None + + @property + def vertex_type(self) -> InspectVertexType | str | None: + """Vertex direction: ``"In"`` / ``"Out"`` / ``"Internal"`` / …. Offline from the port's + ``vertexInfo``; falls back to the lookup response when built without a port.""" + if self.vertex_info is not None: + return self.vertex_info.vertexType + lookup = self._lookup() + return lookup.vertexType if lookup else None + + @property + def is_active(self) -> bool | None: + return self._info_flag("isActive") + + @property + def is_controlled(self) -> bool | None: + return self._info_flag("isControlled") + + @property + def is_endpoint(self) -> bool | None: + """Whether the vertex is usable as a service endpoint ("Use as endpoint" in the UI).""" + return self._info_flag("isEndpoint") + + @property + def label(self) -> str | None: + form = self._form() + return form.label if form else None + + @property + def description(self) -> str | None: + form = self._form() + return form.desc if form else None + + @property + def tags(self) -> list[str]: + """Tags assigned to this vertex (``localAssignedTags``). Assign with + ``app.inspect.update_vertex(vertex.id, tags=[...])``.""" + form = self._form() + return list(form.localAssignedTags) if form else [] + + @property + def active(self) -> bool | None: + form = self._form() + return form.active if form else None + + @property + def use_as_endpoint(self) -> bool | None: + """Whether the vertex is usable as a service endpoint ("Use as endpoint" in the UI).""" + form = self._form() + return form.useAsEndpoint if form else None + + @property + def sips_mode(self) -> str | None: + form = self._form() + return form.sipsMode if form else None + + @property + def control_props(self) -> InspectApiVertexControlProps | None: + form = self._form() + return form.controlProps if form else None + + @property + def extra_alert_filters(self) -> list[Any]: + form = self._form() + return list(form.extraAlertFilters) if form else [] + + @property + def custom(self) -> dict[str, Any]: + form = self._form() + return dict(form.custom) if form else {} + + @property + def queueable(self) -> bool | None: + form = self._form() + return form.queueable if form else None + + @property + def destination_monitor_leader(self) -> bool | None: + form = self._form() + return form.destinationMonitorLeader if form else None + + @property + def vertex_kind(self) -> InspectVertexKind | str | None: + """Vertex kind from ``typeFields.type``: ``"generic"`` / ``"ip"`` / ``"codec"`` / ``"router"``.""" + type_fields = self.type_fields + return type_fields.type if type_fields is not None else None + + @property + def custom_schemas(self) -> dict[str, Any]: + lookup = self._lookup() + return dict(lookup.customSchemas) if lookup else {} + + @property + def is_virtual(self) -> bool | None: + lookup = self._lookup() + return lookup.isVirtual if lookup else None + + @property + def park_port(self) -> int | None: + """Park port (router vertices): ``typeFields.parkPort``.""" + type_fields = self.type_fields + return type_fields.parkPort if type_fields is not None else None + + @property + def type_fields(self) -> InspectApiVertexTypeFields | None: + form = self._form() + return form.typeFields if form else None + + def _info_flag(self, name: str) -> bool | None: + info = self.vertex_info + if info is None or info.fields is None: + return None + return getattr(info.fields, name, None) + + def _lookup(self) -> InspectApiLookupVertexResponseData | None: + return self.snapshot.get_vertex_details(self.id) + + def _form(self) -> InspectApiVertexEditForm | None: + lookup = self._lookup() + return lookup.fields if lookup else None + + +class InspectGenericVertex(InspectVertex): + """A generic vertex (``typeFields.type == "generic"``); exposes the base fields only.""" + + +class InspectIpVertex(InspectVertex): + """An IP vertex (``typeFields.type == "ip"``): IP addressing and config-support flags.""" + + @property + def ip_address(self) -> str | None: + return self._tf("ipAddress") + + @property + def ip_netmask(self) -> str | None: + return self._tf("ipNetmask") + + @property + def public(self) -> bool | None: + return self._tf("public") + + @property + def vlan_id(self) -> str | None: + return self._tf("vlanId") + + @property + def vrf_id(self) -> str | None: + return self._tf("vrfId") + + @property + def supports_cpipe(self) -> bool | None: + return self._tf("supportsCpipeCfg") + + @property + def supports_igmp(self) -> bool | None: + return self._tf("supportsIgmpCfg") + + @property + def supports_mac_forwarding(self) -> bool | None: + return self._tf("supportsMacForwardingCfg") + + @property + def supports_nso(self) -> bool | None: + return self._tf("supportsNsoCfg") + + @property + def supports_openflow(self) -> bool | None: + return self._tf("supportsOpenflowCfg") + + @property + def supports_static_igmp(self) -> bool | None: + return self._tf("supportsStaticIgmpCfg") + + @property + def supports_vlan(self) -> bool | None: + return self._tf("supportsVlanCfg") + + @property + def supports_vpls(self) -> bool | None: + return self._tf("supportsVplsCfg") + + def _tf(self, name: str) -> Any: + type_fields = self.type_fields + return getattr(type_fields, name, None) if type_fields is not None else None + + +class InspectCodecVertex(InspectVertex): + """A codec vertex (``typeFields.type == "codec"``): codec format and source/destination config, + read from the ``typeFields.generic`` / ``typeFields.specific`` blocks (verified 2025.4.9).""" + + @property + def codec_format(self) -> str | None: + generic = self.generic + return generic.codecFormat if generic else None + + @property + def public(self) -> bool | None: + generic = self.generic + return generic.public if generic else None + + @property + def multiplicity(self) -> int | None: + generic = self.generic + return generic.multiplicity if generic else None + + @property + def extra_formats(self) -> list[Any]: + generic = self.generic + return list(generic.extraFormats) if generic else [] + + @property + def main_src_info(self) -> dict[str, Any] | None: + generic = self.generic + return generic.mainSrcInfo if generic else None + + @property + def main_dst_info(self) -> dict[str, Any] | None: + generic = self.generic + return generic.mainDstInfo if generic else None + + @property + def spare_src_info(self) -> dict[str, Any] | None: + generic = self.generic + return generic.spareSrcInfo if generic else None + + @property + def spare_dst_info(self) -> dict[str, Any] | None: + generic = self.generic + return generic.spareDstInfo if generic else None + + @property + def is_igmp_source(self) -> bool | None: + specific = self.specific + return specific.isIgmpSource if specific else None + + @property + def sdp_support(self) -> bool | None: + specific = self.specific + return specific.sdpSupport if specific else None + + @property + def generic(self) -> InspectApiCodecGeneric | None: + """The typed ``typeFields.generic`` block (parsed from the lossless-preserved extra).""" + from videoipath_automation_tool.apps.inspect.model.actions import InspectApiCodecGeneric + + raw = self._type_fields_extra("generic") + return InspectApiCodecGeneric.model_validate(raw) if isinstance(raw, dict) else None + + @property + def specific(self) -> InspectApiCodecSpecific | None: + """The typed ``typeFields.specific`` block (parsed from the lossless-preserved extra).""" + from videoipath_automation_tool.apps.inspect.model.actions import InspectApiCodecSpecific + + raw = self._type_fields_extra("specific") + return InspectApiCodecSpecific.model_validate(raw) if isinstance(raw, dict) else None + + def _type_fields_extra(self, name: str) -> Any: + type_fields = self.type_fields + return getattr(type_fields, name, None) if type_fields is not None else None + + +class InspectResourceTransformVertex(InspectVertex): + """A resource-transform vertex. Exposes the base fields; kind-specific attributes await a live + sample (none present on the verified 2025.4.9 server).""" + + +def build_vertex( + snapshot: InspectSnapshot, + vertex_id: str, + kind: InspectVertexKind | str | None, + vertex_info: InspectApiSingleVertexInfo | None = None, +) -> InspectVertex: + """Construct the typed vertex view for ``vertex_id`` from its ``typeFields.type`` kind (falls + back to the base :class:`InspectVertex` when the kind is unknown, e.g. no fetcher).""" + cls = _VERTEX_CLASS_BY_KIND.get(kind, InspectVertex) + return cls(snapshot=snapshot, id=vertex_id, vertex_info=vertex_info) + + +_VERTEX_CLASS_BY_KIND: dict[str | None, type[InspectVertex]] = { + "ip": InspectIpVertex, + "codec": InspectCodecVertex, + "generic": InspectGenericVertex, + "resourceTransform": InspectResourceTransformVertex, + "nGraphResourceTransform": InspectResourceTransformVertex, +} + + +__all__ = [ + "InspectVertex", + "InspectGenericVertex", + "InspectIpVertex", + "InspectCodecVertex", + "InspectResourceTransformVertex", + "build_vertex", +] diff --git a/src/videoipath_automation_tool/apps/inspect/model/__init__.py b/src/videoipath_automation_tool/apps/inspect/model/__init__.py index adf27b2..68f4b1f 100644 --- a/src/videoipath_automation_tool/apps/inspect/model/__init__.py +++ b/src/videoipath_automation_tool/apps/inspect/model/__init__.py @@ -1,11 +1,12 @@ from __future__ import annotations -from videoipath_automation_tool.apps.inspect.model import actions, collector, common, ngraph, update_topology +from videoipath_automation_tool.apps.inspect.model import actions, collector, common, ngraph, update_topology, virtual from videoipath_automation_tool.apps.inspect.model.actions import * from videoipath_automation_tool.apps.inspect.model.collector import * from videoipath_automation_tool.apps.inspect.model.common import * from videoipath_automation_tool.apps.inspect.model.ngraph import * from videoipath_automation_tool.apps.inspect.model.update_topology import * +from videoipath_automation_tool.apps.inspect.model.virtual import * __all__ = [ *actions.__all__, @@ -13,4 +14,5 @@ *common.__all__, *ngraph.__all__, *update_topology.__all__, + *virtual.__all__, ] diff --git a/src/videoipath_automation_tool/apps/inspect/model/actions.py b/src/videoipath_automation_tool/apps/inspect/model/actions.py index fd89754..ca26566 100644 --- a/src/videoipath_automation_tool/apps/inspect/model/actions.py +++ b/src/videoipath_automation_tool/apps/inspect/model/actions.py @@ -10,6 +10,7 @@ InspectApiPostRequestHeader, InspectApiRestV2Header, ) +from videoipath_automation_tool.apps.inspect.model.virtual import InspectApiVirtualDeviceFields class InspectApiLookupInspectDeviceRequest(InspectApiBaseModel): @@ -33,7 +34,7 @@ class InspectApiLookupInspectDeviceFields(InspectApiBaseModel): sdpStrategy: str | None = None siteId: str | None = None tags: list[str] = Field(default_factory=list) - virtualDeviceFields: dict[str, Any] | None = None + virtualDeviceFields: InspectApiVirtualDeviceFields | None = None class InspectApiLookupInspectDeviceResponseData(InspectApiBaseModel): @@ -49,6 +50,31 @@ class InspectApiLookupInspectDeviceResponse(InspectApiBaseModel): # --- Vertex lookup / edit form (also the replaceVertices write shape, verified 2025.4.9) --- +class InspectApiCodecGeneric(InspectApiBaseModel): + """The ``typeFields.generic`` block of a codec vertex edit form (verified 2025.4.9). Nested + endpoint blocks are kept as dicts for lossless round-tripping (``extra="allow"`` on the base).""" + + bidirPartnerId: str | None = None + codecFormat: str | None = None + extraFormats: list[Any] = Field(default_factory=list) + mainDstInfo: dict[str, Any] | None = None + mainSrcInfo: dict[str, Any] | None = None + multiplicity: int | None = None + partnerConfig: Any | None = None + public: bool | None = None + serviceId: Any | None = None + spareDstInfo: dict[str, Any] | None = None + spareSrcInfo: dict[str, Any] | None = None + + +class InspectApiCodecSpecific(InspectApiBaseModel): + """The ``typeFields.specific`` block of a codec vertex edit form (verified 2025.4.9).""" + + isIgmpSource: bool | None = None + sdpSupport: bool | None = None + type: str | None = None + + class InspectApiVertexTypeFields(InspectApiBaseModel): ipAddress: str | None = None ipNetmask: str | None = None @@ -65,6 +91,10 @@ class InspectApiVertexTypeFields(InspectApiBaseModel): type: str | None = None vlanId: str | None = None vrfId: str | None = None + # Codec vertices additionally carry ``generic`` / ``specific`` blocks here; they are preserved + # losslessly by ``extra="allow"`` (declaring them would emit ``null`` on non-codec vertices and + # break the byte-for-byte ``replaceVertices`` round-trip) and read back typed via + # ``InspectApiCodecGeneric`` / ``InspectApiCodecSpecific`` in the codec vertex view. class InspectApiVertexControlProps(InspectApiBaseModel): @@ -208,6 +238,8 @@ class InspectApiSyncDevicesRequest(InspectApiBaseModel): "InspectApiAddDevicesItem", "InspectApiAddDevicesRequest", "InspectApiAssignedTags", + "InspectApiCodecGeneric", + "InspectApiCodecSpecific", "InspectApiEdgeForm", "InspectApiLookupEdgeResponseItem", "InspectApiLookupEdgesRequest", diff --git a/src/videoipath_automation_tool/apps/inspect/model/collector.py b/src/videoipath_automation_tool/apps/inspect/model/collector.py index 9d61710..5d448d7 100644 --- a/src/videoipath_automation_tool/apps/inspect/model/collector.py +++ b/src/videoipath_automation_tool/apps/inspect/model/collector.py @@ -172,6 +172,30 @@ class InspectApiModuleStatus(InspectApiBaseModel): pid: str | None = None ports: dict[str, InspectPortStatus] | list[InspectPortStatus] = Field(default_factory=dict) status: InspectApiStatusSummary | None = None + tagsInfo: dict[str, Any] | None = None + + @property + def assigned_tags(self) -> list[str]: + """Effective tags assigned to this module (from ``tagsInfo.assigned.all``).""" + if not self.tagsInfo: + return [] + assigned = self.tagsInfo.get("assigned") + if isinstance(assigned, dict) and isinstance(assigned.get("all"), list): + return list(assigned["all"]) + return [] + + @property + def effective_label(self) -> str | None: + if self.descriptor is not None and self.descriptor.label: + return self.descriptor.label + return self.label + + @property + def effective_description(self) -> str | None: + """The user-set module description (``descriptor.desc``), if any.""" + if self.descriptor is not None and self.descriptor.desc: + return self.descriptor.desc + return None class InspectApiNodeStatusItem(InspectApiBaseModel): diff --git a/src/videoipath_automation_tool/apps/inspect/model/common.py b/src/videoipath_automation_tool/apps/inspect/model/common.py index 375f151..f288e4f 100644 --- a/src/videoipath_automation_tool/apps/inspect/model/common.py +++ b/src/videoipath_automation_tool/apps/inspect/model/common.py @@ -27,6 +27,13 @@ "encoderDecoder", ] +# Device icon size selectable in the UI (mirrors the topology app's ``IconSize``). +InspectIconSize = Literal["auto", "large", "medium", "small"] + +# SDP polling strategy: "always" (Continuous), "once" (Fetch and Confirm), "video" (Continuous +# Video, Confirm Others). Mirrors the topology app's ``SdpStrategy``. +InspectSdpStrategy = Literal["always", "once", "video"] + # Vertex direction as reported in ``vertexInfo.vertexType`` (a "double" vertexInfo is the # bidirectional case and is surfaced as "BiDirectional"). InspectVertexType = Literal["BiDirectional", "In", "Internal", "Out", "Undecided"] @@ -36,6 +43,16 @@ # Read surfaces use the permissive ``InspectVertexKind | str`` for unknown future kinds. InspectVertexKind = Literal["generic", "ip", "codec", "router"] +# Edge redundancy mode (mirrors the topology app's ``RedundancyMode``). +InspectRedundancyMode = Literal["Any", "OnlyMain", "OnlySpare"] + +# Conflict priority for edges (``conflictPri``) and vertex control (``controlProps.configPriority``). +# The UI labels are off/high/normal/low; the edge form carries the priority as an int on the wire +# (verified 2025.4.9), so read/write surfaces convert with the mappings below. +InspectConfigPriority = Literal["off", "high", "normal", "low"] +CONFLICT_PRIORITY_TO_INT: dict[str, int] = {"off": 0, "high": 1, "normal": 2, "low": 3} +CONFLICT_PRIORITY_BY_INT: dict[int, str] = {value: name for name, value in CONFLICT_PRIORITY_TO_INT.items()} + class InspectApiBaseModel(BaseModel): model_config = ConfigDict(populate_by_name=True, validate_assignment=True, extra="allow") @@ -126,7 +143,11 @@ class InspectApiActionValidationErrorResponse(InspectApiBaseModel): "InspectApiSimpleActionResult", "InspectApiStatusContext", "InspectApiStatusSummary", + "InspectConfigPriority", + "InspectIconSize", "InspectIconType", + "InspectRedundancyMode", + "InspectSdpStrategy", "InspectVertexKind", "InspectVertexType", ] diff --git a/src/videoipath_automation_tool/apps/inspect/model/virtual.py b/src/videoipath_automation_tool/apps/inspect/model/virtual.py new file mode 100644 index 0000000..ee22055 --- /dev/null +++ b/src/videoipath_automation_tool/apps/inspect/model/virtual.py @@ -0,0 +1,167 @@ +"""Wire models for Inspect virtual devices and port templates (verified 2025.4.9). + +The VideoIPath UI creates topology virtual devices via network actions +(``updateVirtualInstances``, ``updateVirtualTemplates``, ``addVirtualTopology``). +After create, placement / metadata / edges / removal use the same collector +``updateTopology`` path as physical devices. Status reads use +``status/network/virtualDevices`` and ``status/network/virtualTemplates``. +""" + +from __future__ import annotations + +from typing import Any + +from pydantic import Field + +from videoipath_automation_tool.apps.inspect.model.common import ( + InspectApiBaseModel, + InspectApiPostRequestHeader, + InspectApiRestV2Header, + InspectApiSimpleActionResult, +) + + +class InspectApiVirtualPortFromTemplate(InspectApiBaseModel): + """One port instantiation from a port template (wire: ``vertices[]`` entry).""" + + templateId: str + count: int = 1 + + +class InspectApiVirtualModule(InspectApiBaseModel): + """A module on a virtual device definition (wire shape shared by reads and writes).""" + + moduleNumber: int | None = None + vertices: list[InspectApiVirtualPortFromTemplate] = Field(default_factory=list) + + +class InspectApiVirtualDeviceFields(InspectApiBaseModel): + """``lookupInspectDevice.fields.virtualDeviceFields`` (verified 2025.4.9).""" + + dynamic: list[InspectApiVirtualModule] = Field(default_factory=list) + manual: list[InspectApiVirtualModule] = Field(default_factory=list) + + +class InspectApiVirtualDeviceInstance(InspectApiBaseModel): + """One entry from ``status/network/virtualDevices`` (create/update body omits ``_id``).""" + + id: str | None = Field(default=None, alias="_id") + vid: str | None = Field(default=None, alias="_vid") + modules: list[InspectApiVirtualModule] = Field(default_factory=list) + + +class InspectApiVirtualDeviceWriteBody(InspectApiBaseModel): + """Body for one virtual device in ``updateVirtualInstances`` add/update.""" + + modules: list[InspectApiVirtualModule] = Field(default_factory=list) + + +class InspectApiUpdateVirtualInstancesData(InspectApiBaseModel): + add: list[InspectApiVirtualDeviceWriteBody] = Field(default_factory=list) + update: dict[str, InspectApiVirtualDeviceWriteBody] = Field(default_factory=dict) + remove: list[str] = Field(default_factory=list) + force: bool = False + + +class InspectApiUpdateVirtualInstancesRequest(InspectApiBaseModel): + header: InspectApiPostRequestHeader = Field(default_factory=InspectApiPostRequestHeader) + data: InspectApiUpdateVirtualInstancesData + + +class InspectApiUpdateVirtualInstancesValidation(InspectApiBaseModel): + createIds: list[Any] = Field(default_factory=list) + details: dict[str, Any] = Field(default_factory=dict) + result: InspectApiSimpleActionResult + + +class InspectApiUpdateVirtualInstancesResponseData(InspectApiBaseModel): + addedDeviceLabels: dict[str, str] = Field(default_factory=dict) + res: InspectApiSimpleActionResult + validation: InspectApiUpdateVirtualInstancesValidation + + +class InspectApiUpdateVirtualInstancesResponse(InspectApiBaseModel): + data: InspectApiUpdateVirtualInstancesResponseData + header: InspectApiRestV2Header + + +class InspectApiVirtualTemplateVertex(InspectApiBaseModel): + """Vertex config embedded in a port template (lossless; ``extra="allow"``).""" + + type: str | None = None + vertexType: str | None = None + codecFormat: str | None = None + isVirtual: bool | None = None + active: bool | None = None + control: str | None = None + configPriority: str | None = None + useAsEndpoint: bool | None = None + deviceId: str | None = None + descriptor: dict[str, Any] | None = None + fDescriptor: dict[str, Any] | None = None + custom: dict[str, Any] = Field(default_factory=dict) + tags: list[str] = Field(default_factory=list) + maps: list[Any] = Field(default_factory=list) + sipsMode: str | None = None + imgUrl: str | None = None + extraAlertFilters: list[Any] = Field(default_factory=list) + gpid: dict[str, Any] | None = None + + +class InspectApiVirtualTemplateItem(InspectApiBaseModel): + """One entry from ``status/network/virtualTemplates``.""" + + id: str = Field(alias="_id") + vid: str | None = Field(default=None, alias="_vid") + label: str + vertex: InspectApiVirtualTemplateVertex + + +class InspectApiVirtualTemplateWriteBody(InspectApiBaseModel): + """Body for one port template in ``updateVirtualTemplates.add``.""" + + label: str + vertex: dict[str, Any] + + +class InspectApiUpdateVirtualTemplatesData(InspectApiBaseModel): + add: dict[str, InspectApiVirtualTemplateWriteBody] = Field(default_factory=dict) + remove: list[str] = Field(default_factory=list) + force: bool = False + + +class InspectApiUpdateVirtualTemplatesRequest(InspectApiBaseModel): + header: InspectApiPostRequestHeader = Field(default_factory=InspectApiPostRequestHeader) + data: InspectApiUpdateVirtualTemplatesData + + +class InspectApiAddVirtualTopologyData(InspectApiBaseModel): + deviceId: str + moduleId: int + countByVertexTemplate: dict[str, int] = Field(default_factory=dict) + + +class InspectApiAddVirtualTopologyRequest(InspectApiBaseModel): + header: InspectApiPostRequestHeader = Field(default_factory=InspectApiPostRequestHeader) + data: InspectApiAddVirtualTopologyData + + +__all__ = [ + "InspectApiAddVirtualTopologyData", + "InspectApiAddVirtualTopologyRequest", + "InspectApiUpdateVirtualInstancesData", + "InspectApiUpdateVirtualInstancesRequest", + "InspectApiUpdateVirtualInstancesResponse", + "InspectApiUpdateVirtualInstancesResponseData", + "InspectApiUpdateVirtualInstancesValidation", + "InspectApiUpdateVirtualTemplatesData", + "InspectApiUpdateVirtualTemplatesRequest", + "InspectApiVirtualDeviceFields", + "InspectApiVirtualDeviceInstance", + "InspectApiVirtualDeviceWriteBody", + "InspectApiVirtualModule", + "InspectApiVirtualPortFromTemplate", + "InspectApiVirtualTemplateItem", + "InspectApiVirtualTemplateVertex", + "InspectApiVirtualTemplateWriteBody", +] diff --git a/src/videoipath_automation_tool/apps/inspect/snapshot.py b/src/videoipath_automation_tool/apps/inspect/snapshot.py index 9b581cd..852068b 100644 --- a/src/videoipath_automation_tool/apps/inspect/snapshot.py +++ b/src/videoipath_automation_tool/apps/inspect/snapshot.py @@ -38,10 +38,15 @@ if TYPE_CHECKING: from videoipath_automation_tool.apps.inspect.domain.device import InspectDevice from videoipath_automation_tool.apps.inspect.domain.edge import InspectEdge + from videoipath_automation_tool.apps.inspect.domain.module import InspectModule from videoipath_automation_tool.apps.inspect.domain.port import InspectPort from videoipath_automation_tool.apps.inspect.domain.service import InspectService + from videoipath_automation_tool.apps.inspect.domain.vertex import InspectVertex from videoipath_automation_tool.apps.inspect.api import InspectAPI - from videoipath_automation_tool.apps.inspect.model.actions import InspectApiLookupVertexResponseData + from videoipath_automation_tool.apps.inspect.model.actions import ( + InspectApiEdgeForm, + InspectApiLookupVertexResponseData, + ) class HydrationLevel(str, Enum): @@ -70,15 +75,20 @@ def __init__( self._edges_by_device_id: dict[str, list[_IndexedEdge]] = {} self._edge_by_port_key: dict[tuple[str, str], _IndexedEdge] = {} - # Per-device port indexes (populated on hydration) + # Per-device port + module indexes (populated on hydration) self._ports_by_device_id: dict[str, list[_IndexedPort]] = {} self._port_by_key: dict[tuple[str, str], _IndexedPort] = {} self._ports_by_pid: dict[str, list[_IndexedPort]] = {} + self._modules_by_device_id: dict[str, dict[str, InspectApiModuleStatus]] = {} # Vertex edit-form details, fetched lazily per vertex ([ADR-0007]) and invalidated when the # owning device's ports are rebuilt after a refresh/commit. self._vertex_details: dict[str, "InspectApiLookupVertexResponseData"] = {} + # Edge edit-form details, fetched lazily per edge ([ADR-0007]) and invalidated when the + # owning edge pair is dropped/re-indexed after a refresh/commit. + self._edge_details: dict[str, "InspectApiEdgeForm"] = {} + # Section: services / paths self._paths_by_booking_id: dict[str, InspectApiPathItem] = {} self._services_by_device_id: dict[str, list[str]] = {} @@ -87,6 +97,7 @@ def __init__( # Domain-object caches self._device_cache: dict[str, "InspectDevice"] = {} + self._module_cache: dict[tuple[str, str], "InspectModule"] = {} self._edge_cache: dict[str, "InspectEdge"] = {} self._service_cache: dict[str, "InspectService"] = {} @@ -174,12 +185,34 @@ def get_device_record(self, device_id: str) -> Optional[_DeviceRecord]: self._reconcile_stale_device(device_id) return self._devices_by_id.get(device_id) - # --- Port reads (trigger hydration) --- + # --- Module + port reads (trigger hydration) --- + + def get_modules_for_device(self, device_id: str) -> list["InspectModule"]: + self._ensure_device_detail(device_id) + return [self._wrap_module(device_id, module_id) for module_id in self._modules_by_device_id.get(device_id, {})] + + def get_module(self, device_id: str, module_id: str) -> Optional["InspectModule"]: + self._ensure_device_detail(device_id) + if module_id not in self._modules_by_device_id.get(device_id, {}): + return None + return self._wrap_module(device_id, module_id) + + def get_module_status(self, device_id: str, module_id: str) -> Optional[InspectApiModuleStatus]: + """The raw module status (for domain objects to resolve live); None if the module is gone.""" + return self._modules_by_device_id.get(device_id, {}).get(module_id) def get_ports_for_device(self, device_id: str) -> list["InspectPort"]: self._ensure_device_detail(device_id) return [self._wrap_port(indexed) for indexed in self._ports_by_device_id.get(device_id, [])] + def get_ports_for_module(self, device_id: str, module_id: str) -> list["InspectPort"]: + self._ensure_device_detail(device_id) + return [ + self._wrap_port(indexed) + for indexed in self._ports_by_device_id.get(device_id, []) + if indexed.module_id == module_id + ] + def get_port(self, device_id: str, port_id: str) -> Optional["InspectPort"]: self._ensure_device_detail(device_id) indexed = self._port_by_key.get((device_id, port_id)) @@ -206,6 +239,23 @@ def get_vertex_details_many(self, vertex_ids: list[str]) -> dict[str, "InspectAp vertex_id: detail for vertex_id in vertex_ids if (detail := self._vertex_details.get(vertex_id)) is not None } + def get_vertex( + self, vertex_id: str, vertex_info: Optional["InspectApiSingleVertexInfo"] = None + ) -> Optional["InspectVertex"]: + """Typed vertex view for ``vertex_id`` (triggers a cached ``lookupInspectVertexById``); the + concrete subclass is chosen from the edit form's ``typeFields.type``. ``vertex_info`` (the + owning port's offline ``vertexInfo`` side) supplies the direction/status flags: when it is + given a base vertex is still returned even if the edit form is unavailable; without it, an + unknown vertex returns None.""" + lookup = self.get_vertex_details(vertex_id) + if lookup is None and vertex_info is None: + return None + type_fields = lookup.fields.typeFields if lookup is not None else None + kind = type_fields.type if type_fields is not None else None + from videoipath_automation_tool.apps.inspect.domain.vertex import build_vertex + + return build_vertex(self, vertex_id, kind, vertex_info) + # --- Edge reads (no hydration) --- @property @@ -235,11 +285,41 @@ def get_edges_for_device(self, device_id: str) -> list["InspectEdge"]: result.append(self._wrap_edge(indexed)) return result + def get_edges_for_port(self, device_id: str, port_id: str) -> list["InspectEdge"]: + """All edges incident on a port. The read view (``externalEdgesByDeviceKey``) keys edge + endpoints by *port* (not vertex), so edges are grouped at the port level.""" + self._reconcile_stale_pairs() + seen: set[str] = set() + result: list["InspectEdge"] = [] + for indexed in self._edges_by_device_id.get(device_id, []): + on_port = (indexed.from_device_id == device_id and indexed.from_port_id == port_id) or ( + indexed.to_device_id == device_id and indexed.to_port_id == port_id + ) + if not on_port or indexed.edge_id in seen: + continue + seen.add(indexed.edge_id) + result.append(self._wrap_edge(indexed)) + return result + def get_edge_for_port(self, device_id: str, port_id: str) -> Optional["InspectEdge"]: self._reconcile_stale_pairs() indexed = self._edge_by_port_key.get((device_id, port_id)) return self._wrap_edge(indexed) if indexed else None + def get_edge_details(self, edge_id: str) -> Optional["InspectApiEdgeForm"]: + return self.get_edge_details_many([edge_id]).get(edge_id) + + def get_edge_details_many(self, edge_ids: list[str]) -> dict[str, "InspectApiEdgeForm"]: + """Batched, cached edge edit-form lookup (``lookupInspectEdgesByIds``). Only uncached ids are + fetched, in a single call; without a fetcher only cached entries are returned.""" + missing = [edge_id for edge_id in edge_ids if edge_id not in self._edge_details] + if missing and self._fetcher is not None: + response = self._fetcher.lookup_edges(missing) + with self._lock: + for edge_id, item in response.data.items(): + self._edge_details[edge_id] = item.edge + return {edge_id: detail for edge_id in edge_ids if (detail := self._edge_details.get(edge_id)) is not None} + def get_linked_devices(self, device_id: str) -> list["InspectDevice"]: self._reconcile_stale_pairs() linked: set[str] = set() @@ -339,6 +419,36 @@ def apply_network_refresh(self, device_ids: list[str]) -> None: self._reconcile_pairs_for_devices(affected) self._mark_paths_stale() + def upsert_devices_from_skeleton(self, device_ids: list[str]) -> None: + """Insert or refresh named devices from one device-skeleton read. + + Used after creating virtual devices: per-device detail fetches often miss brand-new + ``virtual.N`` nodes (detail-less / dash-vs-dot id form), while the skeleton indexes them + under the public ``deviceId`` (``virtual.N``) that ``addedDeviceLabels`` returns. + Never raises (same contract as :meth:`apply_network_refresh`). + """ + if self._fetcher is None or not device_ids: + return + wanted = set(device_ids) + try: + nodes = self._fetcher.get_device_skeleton() + except Exception as exc: + for device_id in device_ids: + self._stale_devices.add(device_id) + _logger.warning( + "Inspect snapshot: skeleton upsert for %s failed: %s", + sorted(wanted), + exc, + ) + return + with self._lock: + for node in nodes: + device_id = node.deviceId or node.id + if device_id not in wanted: + continue + self._index_device(node, HydrationLevel.SKELETON) + self._stale_devices.discard(device_id) + # --- Internal: hydration --- def _ensure_device_detail(self, device_id: str) -> None: @@ -515,10 +625,16 @@ def _rebuild_device_ports(self, device_id: str, node: InspectApiNodeStatusItem) self._ports_by_pid[port_id] = remaining else: self._ports_by_pid.pop(port_id, None) + # Drop the device's module index + wrappers + for module_id in self._modules_by_device_id.pop(device_id, {}): + self._module_cache.pop((device_id, module_id), None) # Rebuild entries: list[_IndexedPort] = [] + modules: dict[str, InspectApiModuleStatus] = {} for module in _iter_modules(node.modules): module_id = module.pid or module.id + if module_id is not None: + modules[module_id] = module for port in _iter_ports(module.ports): indexed = _IndexedPort(device_id=device_id, module_id=module_id, port=port) entries.append(indexed) @@ -528,6 +644,7 @@ def _rebuild_device_ports(self, device_id: str, node: InspectApiNodeStatusItem) self._port_by_key[(device_id, port_id)] = indexed self._ports_by_pid.setdefault(port_id, []).append(indexed) self._ports_by_device_id[device_id] = entries + self._modules_by_device_id[device_id] = modules def _resolve_device_id(self, pid: str | None) -> str | None: """Map an edge ``devicePid`` to the canonical device id. @@ -586,6 +703,10 @@ def _index_edge_pair(self, pair_item: InspectApiExternalEdgesByDeviceKeyItem) -> def _drop_edge_pair(self, pair_id: str) -> None: self._edge_pairs.pop(pair_id, None) + for edges in self._edges_by_device_id.values(): + for edge in edges: + if edge.pair_id == pair_id: + self._edge_details.pop(edge.edge_id, None) for device_id, edges in list(self._edges_by_device_id.items()): kept = [e for e in edges if e.pair_id != pair_id] if kept: @@ -630,6 +751,8 @@ def _apply_removals(self, removed_ids: list[str]) -> None: for indexed in self._ports_by_device_id.pop(removed, []): for vertex_id in _vertex_ids_from_status(indexed.port): self._vertex_details.pop(vertex_id, None) + for module_id in self._modules_by_device_id.pop(removed, {}): + self._module_cache.pop((removed, module_id), None) self._edges_by_device_id.pop(removed, None) # Edge removal by edge id or pair id if "::" in removed: @@ -647,6 +770,7 @@ def _drop_edge_id(self, edge_or_pair_id: str) -> None: if indexed.edge_id == edge_or_pair_id or indexed.pair_id == edge_or_pair_id: self._edge_by_port_key.pop(key, None) self._edge_cache.pop(edge_or_pair_id, None) + self._edge_details.pop(edge_or_pair_id, None) # --- Internal: domain wrappers (cached) --- @@ -660,6 +784,16 @@ def _wrap_device(self, device_id: str) -> "InspectDevice": self._device_cache[device_id] = device return device + def _wrap_module(self, device_id: str, module_id: str) -> "InspectModule": + from videoipath_automation_tool.apps.inspect.domain.module import InspectModule + + cached = self._module_cache.get((device_id, module_id)) + if cached is not None: + return cached + module = InspectModule(snapshot=self, device_id=device_id, module_id=module_id) + self._module_cache[(device_id, module_id)] = module + return module + def _wrap_port(self, indexed: _IndexedPort) -> "InspectPort": from videoipath_automation_tool.apps.inspect.domain.port import InspectPort diff --git a/src/videoipath_automation_tool/apps/inspect/transaction.py b/src/videoipath_automation_tool/apps/inspect/transaction.py index 38e1273..bad3e7f 100644 --- a/src/videoipath_automation_tool/apps/inspect/transaction.py +++ b/src/videoipath_automation_tool/apps/inspect/transaction.py @@ -23,6 +23,7 @@ from __future__ import annotations +import copy import logging from typing import TYPE_CHECKING, Any, Optional @@ -41,9 +42,14 @@ InspectApiVertexEditForm, ) from videoipath_automation_tool.apps.inspect.model.common import ( + CONFLICT_PRIORITY_TO_INT, + InspectConfigPriority, InspectFrozenModel, + InspectIconSize, InspectIconType, InspectInternalModel, + InspectRedundancyMode, + InspectSdpStrategy, ) from videoipath_automation_tool.apps.inspect.model.update_topology import ( InspectApiUpdateTopologyData, @@ -131,23 +137,34 @@ def update_device( device_id: str, *, label: Optional[str] = None, + description: Optional[str] = None, icon_type: Optional[InspectIconType | str] = None, - sdp_strategy: Optional[str] = None, + icon_size: Optional[InspectIconSize | str] = None, + sdp_strategy: Optional[InspectSdpStrategy | str] = None, + site_id: Optional[str] = None, tags: Optional[list[str]] = None, coordinates: Optional[dict[str, float]] = None, ) -> "InspectTransaction": - """Edit a device's placement/appearance fields (``replaceDevices``). + """Edit a device's edit-dialog fields (``replaceDevices``). - ``descriptor`` is always round-tripped from the baseline (it is mandatory server-side); - ``label`` is applied to ``descriptor.label`` only when set here. + Covers the Inspect UI "Edit Device" dialog: ``label`` / ``description`` (the persisted + ``descriptor``), ``tags``, ``site_id``, ``icon_size``, ``icon_type`` and ``sdp_strategy``, + plus ``coordinates`` for placement. ``descriptor`` is always round-tripped from the baseline + (it is mandatory server-side); ``label`` / ``description`` are applied to it only when set here. """ entry = self._stage_device(device_id) if label is not None: entry.intents["descriptor.label"] = label + if description is not None: + entry.intents["descriptor.desc"] = description if icon_type is not None: entry.intents["iconType"] = icon_type + if icon_size is not None: + entry.intents["iconSize"] = icon_size if sdp_strategy is not None: entry.intents["sdpStrategy"] = sdp_strategy + if site_id is not None: + entry.intents["siteId"] = site_id if tags is not None: entry.intents["tags"] = list(tags) if coordinates is not None: @@ -170,11 +187,27 @@ def update_vertex( extra_alert_filters: Optional[list[Any]] = None, custom: Optional[dict[str, Any]] = None, park_port: Optional[int] = None, + # IP-vertex ``typeFields`` (only meaningful on IP vertices): + ip_address: Optional[str] = None, + ip_netmask: Optional[str] = None, + public: Optional[bool] = None, + vlan_id: Optional[str] = None, + vrf_id: Optional[str] = None, + supports_cpipe: Optional[bool] = None, + supports_igmp: Optional[bool] = None, + supports_mac_forwarding: Optional[bool] = None, + supports_nso: Optional[bool] = None, + supports_openflow: Optional[bool] = None, + supports_static_igmp: Optional[bool] = None, + supports_vlan: Optional[bool] = None, + supports_vpls: Optional[bool] = None, ) -> "InspectTransaction": """Edit a vertex/port (``replaceVertices``; update-only — vertices cannot be created here). - ``tags`` assigns catalog tags to the port (as ``Category~~name`` ids); this is the - Inspect-only port-tagging capability, written to the vertex ``localAssignedTags``. + Covers the "Edit vertex" / bulk-edit dialogs: base fields plus the IP-vertex ``typeFields`` + (address, netmask, VLAN, VRF, public, and the "Config supports" flags). ``tags`` assigns + catalog tags to the port (as ``Category~~name`` ids), written to the vertex + ``localAssignedTags``. """ entry = self._stage_vertex(vertex_id) if use_as_endpoint is not None: @@ -200,8 +233,27 @@ def update_vertex( entry.intents["extraAlertFilters"] = list(extra_alert_filters) if custom is not None: entry.intents["custom"] = dict(custom) - if park_port is not None: - entry.intents["typeFields.parkPort"] = park_port + + # IP-vertex typeFields, applied as one-level dotted intents (require a populated typeFields + # baseline, which IP/router/codec vertices have from the lookup edit form). + for value, wire_field in ( + (park_port, "parkPort"), + (ip_address, "ipAddress"), + (ip_netmask, "ipNetmask"), + (public, "public"), + (vlan_id, "vlanId"), + (vrf_id, "vrfId"), + (supports_cpipe, "supportsCpipeCfg"), + (supports_igmp, "supportsIgmpCfg"), + (supports_mac_forwarding, "supportsMacForwardingCfg"), + (supports_nso, "supportsNsoCfg"), + (supports_openflow, "supportsOpenflowCfg"), + (supports_static_igmp, "supportsStaticIgmpCfg"), + (supports_vlan, "supportsVlanCfg"), + (supports_vpls, "supportsVplsCfg"), + ): + if value is not None: + entry.intents[f"typeFields.{wire_field}"] = value return self # --- Staging: edges --- @@ -210,29 +262,81 @@ def update_edge( self, edge_id: str, *, + label: Optional[str] = None, + description: Optional[str] = None, weight: Optional[int] = None, capacity: Optional[int] = None, bandwidth: Optional[float] = None, - redundancy_mode: Optional[str] = None, + redundancy_mode: Optional[InspectRedundancyMode | str] = None, + conflict_priority: Optional[InspectConfigPriority | int | str] = None, + include_formats: Optional[list[str]] = None, + exclude_formats: Optional[list[str]] = None, + bandwidth_weight_factor: Optional[int] = None, + weight_per_service: Optional[int] = None, active: Optional[bool] = None, tags: Optional[list[str]] = None, + also_opposite: bool = False, ) -> "InspectTransaction": - """Edit an existing edge (``replaceEdges``).""" - entry = self._stage_edge(edge_id) - if weight is not None: - entry.intents["weight"] = weight - if capacity is not None: - entry.intents["capacity"] = capacity - if bandwidth is not None: - entry.intents["bandwidth"] = bandwidth - if redundancy_mode is not None: - entry.intents["redundancyMode"] = redundancy_mode - if active is not None: - entry.intents["active"] = active - if tags is not None: - entry.intents["tags"] = list(tags) + """Edit an existing edge's "Edit Edge" dialog fields (``replaceEdges``). + + With ``also_opposite`` the same changes are staged on the opposite directed edge (the reverse + ``.out`` <-> ``.in`` edge, as the Inspect UI's "apply changes to opposite directed edge" + option does); raises ``InspectEntityNotFoundError`` if that opposite edge does not exist. + """ + fields = dict( + label=label, + description=description, + weight=weight, + capacity=capacity, + bandwidth=bandwidth, + redundancy_mode=redundancy_mode, + conflict_priority=conflict_priority, + include_formats=include_formats, + exclude_formats=exclude_formats, + bandwidth_weight_factor=bandwidth_weight_factor, + weight_per_service=weight_per_service, + active=active, + tags=tags, + ) + self._apply_edge_update(edge_id, fields) + if also_opposite: + opposite_id = _opposite_edge_id(edge_id) + if self._lookup_edge_form(opposite_id) is None: + raise InspectEntityNotFoundError(opposite_id, kind="edge") + self._apply_edge_update(opposite_id, fields) return self + def _apply_edge_update(self, edge_id: str, fields: dict[str, Any]) -> None: + entry = self._stage_edge(edge_id) + if fields["label"] is not None: + entry.intents["descriptor.label"] = fields["label"] + if fields["description"] is not None: + entry.intents["descriptor.desc"] = fields["description"] + if fields["weight"] is not None: + entry.intents["weight"] = fields["weight"] + if fields["capacity"] is not None: + entry.intents["capacity"] = fields["capacity"] + if fields["bandwidth"] is not None: + entry.intents["bandwidth"] = fields["bandwidth"] + if fields["redundancy_mode"] is not None: + entry.intents["redundancyMode"] = fields["redundancy_mode"] + if fields["conflict_priority"] is not None: + entry.intents["conflictPri"] = _conflict_priority_to_wire(fields["conflict_priority"]) + if fields["include_formats"] is not None: + entry.intents["includeFormats"] = list(fields["include_formats"]) + if fields["exclude_formats"] is not None: + entry.intents["excludeFormats"] = list(fields["exclude_formats"]) + if fields["active"] is not None: + entry.intents["active"] = fields["active"] + if fields["tags"] is not None: + entry.intents["tags"] = list(fields["tags"]) + if fields["bandwidth_weight_factor"] is not None or fields["weight_per_service"] is not None: + entry.intents["weightFactors"] = _merged_weight_factors( + entry.baseline_form.weightFactors, + fields["bandwidth_weight_factor"], + fields["weight_per_service"], + ) + def connect( self, from_vertex: str, @@ -267,7 +371,7 @@ def disconnect(self, from_vertex: str, to_vertex: str, *, bidirectional: bool = def remove(self, entity_id: str) -> "InspectTransaction": """Remove any entity by id (device / vertex / edge key). Last-writer-wins (no conflict check).""" self._ensure_open() - kind = _EDGE if "::" in entity_id else (_VERTEX if "." in entity_id else _DEVICE) + kind = _entity_kind(entity_id) self._entries[(kind, entity_id)] = _Staged(kind=kind, entity_id=entity_id, remove=True) return self @@ -534,10 +638,29 @@ class _Staged(InspectInternalModel): def _device_of(entity_id: str) -> str: - """Owning device id of a vertex/port id (``device12.1.Ethernet1.out`` -> ``device12``).""" + """Owning device id of a vertex/port id (``device12.1.Ethernet1.out`` -> ``device12``, + ``virtual.2.0.1`` -> ``virtual.2``).""" + if entity_id.startswith("virtual."): + parts = entity_id.split(".") + if len(parts) >= 2: + return f"{parts[0]}.{parts[1]}" return entity_id.split(".", 1)[0] +def _entity_kind(entity_id: str) -> str: + """Classify an entity id as device, vertex, or edge for staging.""" + if "::" in entity_id: + return _EDGE + if "." not in entity_id: + return _DEVICE + # ``virtual.N`` is a device id (dotted); ``virtual.N.module.vertex`` is a vertex. + if entity_id.startswith("virtual."): + parts = entity_id.split(".") + if len(parts) == 2 and parts[1].isdigit(): + return _DEVICE + return _VERTEX + + def _reverse_vertex(vertex_id: str) -> str: """Flip the trailing direction of an IP vertex id (``.out`` <-> ``.in``).""" if vertex_id.endswith(".out"): @@ -551,6 +674,36 @@ def _edge_key(from_id: str, to_id: str) -> str: return f"{from_id}::{to_id}" +def _opposite_edge_id(edge_id: str) -> str: + """The opposite directed edge of ``fromId::toId`` — ``reverse(toId)::reverse(fromId)`` with the + trailing ``.out`` <-> ``.in`` direction flipped on each vertex.""" + from_id, to_id = edge_id.split("::", 1) + return _edge_key(_reverse_vertex(to_id), _reverse_vertex(from_id)) + + +def _conflict_priority_to_wire(value: InspectConfigPriority | int | str) -> int | str: + """Map a friendly conflict-priority name (off/high/normal/low) to the on-wire int; pass ints and + unknown values through unchanged.""" + if isinstance(value, str): + return CONFLICT_PRIORITY_TO_INT.get(value, value) + return value + + +def _merged_weight_factors( + baseline: Any, bandwidth_weight_factor: Optional[int], weight_per_service: Optional[int] +) -> dict[str, Any]: + """Merge the requested weight-factor changes onto a deep copy of the baseline ``weightFactors`` + (a nested dict), preserving untouched sub-values (e.g. ``service.max``).""" + merged: dict[str, Any] = copy.deepcopy(baseline) if isinstance(baseline, dict) else {} + merged.setdefault("bandwidth", {}) + merged.setdefault("service", {}) + if bandwidth_weight_factor is not None: + merged["bandwidth"]["weight"] = bandwidth_weight_factor + if weight_per_service is not None: + merged["service"]["weight"] = weight_per_service + return merged + + def _apply_intents(form: Any, intents: dict[str, Any]) -> None: for key, value in intents.items(): if "." in key: diff --git a/src/videoipath_automation_tool/validators/virtual_device_id.py b/src/videoipath_automation_tool/validators/virtual_device_id.py index 64fba13..92702b4 100644 --- a/src/videoipath_automation_tool/validators/virtual_device_id.py +++ b/src/videoipath_automation_tool/validators/virtual_device_id.py @@ -1,5 +1,12 @@ import re +_VIRTUAL_DEVICE_ID_PATTERN = re.compile(r"virtual\.(0|[1-9]\d*)") + + +def is_virtual_device_id(device_id: str) -> bool: + """Return whether ``device_id`` matches the ``virtual.`` topology id form.""" + return isinstance(device_id, str) and _VIRTUAL_DEVICE_ID_PATTERN.fullmatch(device_id) is not None + def validate_virtual_device_id(virtual_device_id: str) -> str: """ @@ -17,13 +24,7 @@ def validate_virtual_device_id(virtual_device_id: str) -> str: if not isinstance(virtual_device_id, str): raise ValueError(f"Each virtual device ID must be a string. Invalid virtual device ID: {virtual_device_id}") - pattern = r"virtual\.(0|[1-9]\d*)" - # Regular expression pattern explanation: - # virtual\.(0|[1-9]\d*) - The virtual device ID starts with the word 'virtual.' followed by a number: - # - '0' or - # - a positive integer (1-9) followed by zero or more digits between 0 and 9. - - if not re.fullmatch(pattern, virtual_device_id): + if not is_virtual_device_id(virtual_device_id): raise ValueError( f"Invalid virtual device ID syntax: '{virtual_device_id}'. The expected format is: virtual.." ) diff --git a/tests/e2e/helpers.py b/tests/e2e/helpers.py index 0544ef3..e7a8eca 100644 --- a/tests/e2e/helpers.py +++ b/tests/e2e/helpers.py @@ -70,12 +70,13 @@ def discover_router_vertices(app: "VideoIPathApp", device_ids: list[str]) -> dic ins: list[str] = [] for port in device.ports: label = port.label or "" - if not port.vertex_id: + vertex = port.vertex_out or port.vertex_in + if vertex is None: continue if "Router Out" in label: - outs.append(port.vertex_id) + outs.append(vertex.id) elif "Router In" in label: - ins.append(port.vertex_id) + ins.append(vertex.id) vertices[device_id] = (sorted(outs, key=_vertex_slot), sorted(ins, key=_vertex_slot)) return vertices diff --git a/tests/e2e/inspect/test_e2e_inspect.py b/tests/e2e/inspect/test_e2e_inspect.py index 2b543c9..2c7cf75 100644 --- a/tests/e2e/inspect/test_e2e_inspect.py +++ b/tests/e2e/inspect/test_e2e_inspect.py @@ -105,25 +105,31 @@ def test_assign_tag_to_port(app: VideoIPathApp, topology_builder: TopologyBuilde try: app.inspect.refresh() vertex_id = next( - p.vertex_id + p.vertex_in.id for p in app.inspect.get_device(device_id).ports - if p.vertex_id and "Router In" in (p.label or "") + if p.vertex_in is not None and "Router In" in (p.label or "") ) result = app.inspect.update_vertex(vertex_id, tags=[TEST_TAG_ID]) assert result.ok app.inspect.refresh() - port = next(p for p in app.inspect.get_device(device_id).ports if p.vertex_id == vertex_id) + port = next( + p + for p in app.inspect.get_device(device_id).ports + if p.vertex_in is not None and p.vertex_in.id == vertex_id + ) assert TEST_TAG_ID in port.tags finally: delete_test_tag(app) # also removes the port binding def test_update_vertex_fields(app: VideoIPathApp, topology_builder: TopologyBuilder) -> None: - """Write every editable vertex UI field and read it back via InspectPort attributes.""" + """Write every editable vertex UI field and read it back via the typed InspectVertex.""" (device_id,) = topology_builder.add_devices([("VTX-A", 2)]) app.inspect.refresh() vertex_id = next( - p.vertex_id for p in app.inspect.get_device(device_id).ports if p.vertex_id and "Router In" in (p.label or "") + p.vertex_in.id + for p in app.inspect.get_device(device_id).ports + if p.vertex_in is not None and "Router In" in (p.label or "") ) alert_filter = "0:*:e2e-alarm:*" result = app.inspect.update_vertex( @@ -141,20 +147,24 @@ def test_update_vertex_fields(app: VideoIPathApp, topology_builder: TopologyBuil assert result.ok app.inspect.refresh() - port = next(p for p in app.inspect.get_device(device_id).ports if p.vertex_id == vertex_id) - assert port.label == "E2E Router In" - assert port.description == "E2E vertex description" - assert port.is_endpoint is True - assert port.is_active is True - assert port.sips_mode == "SIPSAuto" - assert port.control_props is not None - assert port.control_props.configPriority == "high" - assert port.control_props.onlyInitial is True - assert alert_filter in port.extra_alert_filters - assert port.custom.get("e2e-param") == "e2e-value" - assert port.park_port == 7 - assert port.vertex_kind == "router" - assert port.type_fields is not None and port.type_fields.type == "router" + port = next( + p for p in app.inspect.get_device(device_id).ports if p.vertex_in is not None and p.vertex_in.id == vertex_id + ) + vertex = port.vertex_in + assert vertex is not None + assert vertex.label == "E2E Router In" + assert vertex.description == "E2E vertex description" + assert vertex.use_as_endpoint is True + assert vertex.active is True + assert vertex.sips_mode == "SIPSAuto" + assert vertex.control_props is not None + assert vertex.control_props.configPriority == "high" + assert vertex.control_props.onlyInitial is True + assert alert_filter in vertex.extra_alert_filters + assert vertex.custom.get("e2e-param") == "e2e-value" + assert vertex.park_port == 7 + assert vertex.vertex_kind == "router" + assert vertex.type_fields is not None and vertex.type_fields.type == "router" def test_device_placement(app: VideoIPathApp, topology_builder: TopologyBuilder) -> None: diff --git a/tests/inspect/fixtures/2025.4.9/lookup_inspect_codec_vertex_by_id.json b/tests/inspect/fixtures/2025.4.9/lookup_inspect_codec_vertex_by_id.json new file mode 100644 index 0000000..bbaacb6 --- /dev/null +++ b/tests/inspect/fixtures/2025.4.9/lookup_inspect_codec_vertex_by_id.json @@ -0,0 +1,66 @@ +{ + "data": { + "id": "device-a.module-1.port-out-1.out", + "isVirtual": false, + "vertexType": "In", + "customSchemas": {}, + "assignedTags": { "all": [], "inherited": {}, "inheritedConflict": false, "local": {} }, + "context": {}, + "fields": { + "active": true, + "controlProps": { "configPriority": "off", "onlyInitial": false }, + "custom": {}, + "desc": "", + "destinationMonitorLeader": false, + "extraAlertFilters": [], + "label": "codec-out-1", + "localAssignedTags": [], + "queueable": false, + "sipsMode": "NONE", + "tags": [], + "typeFields": { + "ipAddress": null, + "ipNetmask": null, + "parkPort": null, + "public": null, + "supportsCpipeCfg": null, + "supportsIgmpCfg": null, + "supportsMacForwardingCfg": null, + "supportsNsoCfg": null, + "supportsOpenflowCfg": null, + "supportsStaticIgmpCfg": null, + "supportsVlanCfg": null, + "supportsVplsCfg": null, + "type": "codec", + "vlanId": null, + "vrfId": null, + "generic": { + "bidirPartnerId": null, + "codecFormat": "Video", + "extraFormats": [], + "mainDstInfo": { "ip": "10.0.0.1", "mac": null, "port": 5000, "vlan": null }, + "mainSrcInfo": { "gateway": null, "ip": "10.0.0.2", "mac": null, "netmask": "255.255.255.0" }, + "multiplicity": 1, + "partnerConfig": null, + "public": false, + "serviceId": null, + "spareDstInfo": { "ip": null, "mac": null, "port": null, "vlan": null }, + "spareSrcInfo": { "gateway": null, "ip": null, "mac": null, "netmask": null } + }, + "specific": { "isIgmpSource": false, "sdpSupport": true, "type": "video" } + }, + "useAsEndpoint": false + } + }, + "header": { + "auth": true, + "caption": "Operation Successful", + "code": "OK", + "errorCodes": [], + "errorDetails": [], + "id": "0", + "msg": [], + "ok": true, + "user": "api-user" + } +} diff --git a/tests/inspect/fixtures/2025.4.9/lookup_inspect_virtual_device.json b/tests/inspect/fixtures/2025.4.9/lookup_inspect_virtual_device.json new file mode 100644 index 0000000..18a23da --- /dev/null +++ b/tests/inspect/fixtures/2025.4.9/lookup_inspect_virtual_device.json @@ -0,0 +1,48 @@ +{ + "data": { + "assignedTags": { + "all": [], + "inherited": {}, + "inheritedConflict": false, + "local": {} + }, + "fields": { + "coordinates": null, + "descriptor": { + "desc": "", + "label": "Virtual Device 1" + }, + "iconSize": "medium", + "iconType": "default", + "localAssignedTags": [], + "sdpStrategy": "always", + "siteId": null, + "tags": [], + "virtualDeviceFields": { + "dynamic": [ + { + "moduleNumber": 0, + "vertices": [ + { + "count": 1, + "templateId": "generic_bidir" + } + ] + } + ], + "manual": [] + } + } + }, + "header": { + "auth": true, + "caption": "Operation Successful", + "code": "OK", + "errorCodes": [], + "errorDetails": [], + "id": "0", + "msg": [], + "ok": true, + "user": "api-user" + } +} diff --git a/tests/inspect/fixtures/2025.4.9/update_virtual_instances_create.json b/tests/inspect/fixtures/2025.4.9/update_virtual_instances_create.json new file mode 100644 index 0000000..45e8725 --- /dev/null +++ b/tests/inspect/fixtures/2025.4.9/update_virtual_instances_create.json @@ -0,0 +1,30 @@ +{ + "data": { + "addedDeviceLabels": { + "virtual.1": "Virtual Device 1" + }, + "res": { + "msg": [], + "ok": true + }, + "validation": { + "createIds": [], + "details": {}, + "result": { + "msg": [], + "ok": true + } + } + }, + "header": { + "auth": true, + "caption": "Operation Successful", + "code": "OK", + "errorCodes": [], + "errorDetails": [], + "id": "0", + "msg": [], + "ok": true, + "user": "api-user" + } +} diff --git a/tests/inspect/fixtures/2025.4.9/virtual_devices.json b/tests/inspect/fixtures/2025.4.9/virtual_devices.json new file mode 100644 index 0000000..61d5277 --- /dev/null +++ b/tests/inspect/fixtures/2025.4.9/virtual_devices.json @@ -0,0 +1,43 @@ +{ + "data": { + "status": { + "network": { + "virtualDevices": { + "_items": [ + { + "_id": "virtual.1", + "_vid": "virtual.1", + "modules": [ + { + "moduleNumber": 0, + "vertices": [ + { "count": 1, "templateId": "ip_in" }, + { "count": 1, "templateId": "ip_out" }, + { "count": 5, "templateId": "video_in" }, + { "count": 5, "templateId": "video_out" } + ] + } + ] + }, + { + "_id": "virtual.2", + "_vid": "virtual.2", + "modules": [] + } + ] + } + } + } + }, + "header": { + "auth": true, + "caption": "Operation Successful", + "code": "OK", + "errorCodes": [], + "errorDetails": [], + "id": "", + "msg": [], + "ok": true, + "user": "api-user" + } +} diff --git a/tests/inspect/fixtures/2025.4.9/virtual_templates.json b/tests/inspect/fixtures/2025.4.9/virtual_templates.json new file mode 100644 index 0000000..be20f1b --- /dev/null +++ b/tests/inspect/fixtures/2025.4.9/virtual_templates.json @@ -0,0 +1,72 @@ +{ + "data": { + "status": { + "network": { + "virtualTemplates": { + "_items": [ + { + "_id": "generic_bidir", + "_vid": "generic_bidir", + "label": "Generic bidir", + "vertex": { + "active": true, + "configPriority": "off", + "control": "off", + "custom": {}, + "descriptor": { "desc": "", "label": "" }, + "deviceId": "", + "extraAlertFilters": [], + "fDescriptor": { "desc": "", "label": "Virtual Vertex Template" }, + "gpid": { "component": 1, "pointId": [] }, + "imgUrl": "", + "isVirtual": true, + "maps": [], + "sipsMode": "NONE", + "tags": [], + "type": "genericVertex", + "useAsEndpoint": false, + "vertexType": "BiDirectional" + } + }, + { + "_id": "video_in", + "_vid": "video_in", + "label": "Video in", + "vertex": { + "active": true, + "codecFormat": "Video", + "configPriority": "off", + "control": "off", + "custom": {}, + "descriptor": { "desc": "", "label": "" }, + "deviceId": "", + "extraAlertFilters": [], + "fDescriptor": { "desc": "", "label": "Virtual Vertex Template" }, + "gpid": { "component": 1, "pointId": [] }, + "imgUrl": "", + "isVirtual": true, + "maps": [], + "sipsMode": "NONE", + "tags": [], + "type": "codecVertex", + "useAsEndpoint": false, + "vertexType": "In" + } + } + ] + } + } + } + }, + "header": { + "auth": true, + "caption": "Operation Successful", + "code": "OK", + "errorCodes": [], + "errorDetails": [], + "id": "", + "msg": [], + "ok": true, + "user": "api-user" + } +} diff --git a/tests/inspect/test_exports.py b/tests/inspect/test_exports.py index ce8b31b..42cf9b6 100644 --- a/tests/inspect/test_exports.py +++ b/tests/inspect/test_exports.py @@ -6,7 +6,7 @@ from videoipath_automation_tool import apps from videoipath_automation_tool.apps import inspect from videoipath_automation_tool.apps.inspect import model -from videoipath_automation_tool.apps.inspect.model import actions, collector, common, ngraph, update_topology +from videoipath_automation_tool.apps.inspect.model import actions, collector, common, ngraph, update_topology, virtual def test_model_package_all_aggregates_submodules() -> None: @@ -16,6 +16,7 @@ def test_model_package_all_aggregates_submodules() -> None: *common.__all__, *ngraph.__all__, *update_topology.__all__, + *virtual.__all__, } assert set(model.__all__) == expected assert len(model.__all__) == len(set(model.__all__)) # no duplicates across submodules diff --git a/tests/inspect/test_models.py b/tests/inspect/test_models.py index cb060b8..57d9399 100644 --- a/tests/inspect/test_models.py +++ b/tests/inspect/test_models.py @@ -147,6 +147,16 @@ def test_lookup_router_vertex_exposes_park_port(load: Callable[[str], dict[str, assert resp.data.fields.typeFields.parkPort == 42 +def test_lookup_codec_vertex_preserves_generic_specific(load: Callable[[str], dict[str, Any]]) -> None: + fixture = load("lookup_inspect_codec_vertex_by_id.json") + resp = InspectApiLookupVertexResponse.model_validate(fixture) + type_fields = resp.data.fields.typeFields + assert type_fields is not None and type_fields.type == "codec" + # generic/specific are preserved losslessly via extra="allow" and re-serialize byte-for-byte. + built = resp.data.fields.model_dump(mode="json", by_alias=True) + assert built["typeFields"] == fixture["data"]["fields"]["typeFields"] + + def test_lookup_device_fields() -> None: resp = InspectApiLookupInspectDeviceResponse.model_validate(_device_lookup_fixture()) assert resp.data.fields.coordinates is not None diff --git a/tests/inspect/test_queries.py b/tests/inspect/test_queries.py index a1fd31a..b5e26f9 100644 --- a/tests/inspect/test_queries.py +++ b/tests/inspect/test_queries.py @@ -9,10 +9,27 @@ def test_all_queries_within_length_limit() -> None: - for build in (queries.device_skeleton, queries.edge_skeleton, queries.paths_section, queries.collector_full): + for build in ( + queries.device_skeleton, + queries.edge_skeleton, + queries.paths_section, + queries.collector_full, + queries.virtual_templates, + queries.virtual_devices, + ): path = build() assert len(path) < queries.MAX_QUERY_LENGTH - assert path.startswith("/rest/v2/data/status/collector/") + assert path.startswith("/rest/v2/data/status/") + + +def test_collector_queries_use_collector_namespace() -> None: + for build in (queries.device_skeleton, queries.edge_skeleton, queries.paths_section, queries.collector_full): + assert build().startswith("/rest/v2/data/status/collector/") + + +def test_virtual_queries_use_network_namespace() -> None: + assert "/status/network/virtualTemplates/**" in queries.virtual_templates() + assert "/status/network/virtualDevices/**" in queries.virtual_devices() def test_device_skeleton_suppresses_modules_and_selects_skeleton_fields() -> None: diff --git a/tests/inspect/test_snapshot.py b/tests/inspect/test_snapshot.py index 159137a..4ac676e 100644 --- a/tests/inspect/test_snapshot.py +++ b/tests/inspect/test_snapshot.py @@ -7,7 +7,10 @@ import pytest -from videoipath_automation_tool.apps.inspect.model.actions import InspectApiLookupVerticesResponse +from videoipath_automation_tool.apps.inspect.model.actions import ( + InspectApiLookupEdgesResponse, + InspectApiLookupVerticesResponse, +) from videoipath_automation_tool.apps.inspect.model.collector import ( InspectApiExternalEdgesByDeviceKeyItem, InspectApiNodeStatusItem, @@ -15,6 +18,8 @@ ) from videoipath_automation_tool.apps.inspect.snapshot import HydrationLevel, InspectSnapshot +from .conftest import load_fixture + def test_skeleton_indexes_devices_and_edges(snapshot: tuple[InspectSnapshot, FakeFetcher]) -> None: snap, fetcher = snapshot @@ -185,34 +190,51 @@ def test_full_snapshot_is_hydrated_without_fetcher() -> None: snap.refresh() -def test_port_exposes_direction_flags_and_factory_label(snapshot: tuple[InspectSnapshot, FakeFetcher]) -> None: - snap, fetcher = snapshot +def test_port_is_lean_and_vertex_carries_flags(snapshot: tuple[InspectSnapshot, FakeFetcher]) -> None: + snap, _ = snapshot port = snap.get_port("leaf-a", "leaf-a.dev.0.up1") + # Port keeps only the module-level attributes. assert port.label == "up1" # descriptor override assert port.factory_label == "up1-factory" - assert port.vertex_type == "Out" + assert not hasattr(port, "is_active") # vertex-specific fields live on the vertex now + assert not hasattr(port, "edge") # edges are accessed via the collection now + # The direction/status flags live on the vertex. assert port.is_bidirectional is False - assert port.vertex_ids == ("leaf-a.0.up1",) - assert port.is_active is True - assert port.is_controlled is True - assert port.is_endpoint is False - assert fetcher.vertex_lookup_calls == [] # all of the above is offline + assert port.vertex_out is not None + assert port.vertex_out.id == "leaf-a.0.up1" + assert port.vertex_out.vertex_type == "Out" + assert port.vertex_out.is_active is True + assert port.vertex_out.is_controlled is True + assert port.vertex_out.is_endpoint is False + assert port.vertex_in is None -def test_port_vertex_attrs_fetches_once_and_caches(snapshot: tuple[InspectSnapshot, FakeFetcher]) -> None: +def test_bidirectional_port_vertex_accessors(snapshot: tuple[InspectSnapshot, FakeFetcher]) -> None: snap, fetcher = snapshot - port = snap.get_port("leaf-a", "leaf-a.dev.0.up1") - assert port.type_fields is not None and port.type_fields.type == "ip" - assert port.vertex_kind == "ip" - assert port.sips_mode == "NONE" - assert port.control_props is not None and port.control_props.configPriority == "off" - assert port.extra_alert_filters == [] - assert port.custom == {} - assert port.custom_schemas == {} - assert port.queueable is False - assert port.destination_monitor_leader is False - assert port.park_port is None - _ = port.vertex_kind # second access → cached + fetcher._details["leaf-a"] = _filter_detail_node("leaf-a", "LEAF-A") + port = snap.get_port("leaf-a", "leaf-a.dev.1.bidi1") + assert port.is_bidirectional is True + assert port.vertex_out is not None and port.vertex_out.vertex_type == "Out" + assert port.vertex_in is not None and port.vertex_in.vertex_type == "In" + assert port.vertex_out.id == "leaf-a.1.bidi1.out" + assert port.vertex_in.id == "leaf-a.1.bidi1.in" + + +def test_vertex_config_attrs_fetch_once_and_cache(snapshot: tuple[InspectSnapshot, FakeFetcher]) -> None: + snap, fetcher = snapshot + vertex = snap.get_port("leaf-a", "leaf-a.dev.0.up1").vertex_out + assert vertex is not None + assert vertex.type_fields is not None and vertex.type_fields.type == "ip" + assert vertex.vertex_kind == "ip" + assert vertex.sips_mode == "NONE" + assert vertex.control_props is not None and vertex.control_props.configPriority == "off" + assert vertex.extra_alert_filters == [] + assert vertex.custom == {} + assert vertex.custom_schemas == {} + assert vertex.queueable is False + assert vertex.destination_monitor_leader is False + assert vertex.park_port is None + _ = vertex.vertex_kind # second access → cached assert fetcher.vertex_lookup_calls == [["leaf-a.0.up1"]] @@ -220,25 +242,28 @@ def test_vertex_lookup_invalidated_by_post_commit_device_refresh( snapshot: tuple[InspectSnapshot, FakeFetcher], ) -> None: snap, fetcher = snapshot - _ = snap.get_port("leaf-a", "leaf-a.dev.0.up1").vertex_kind + _ = snap.get_port("leaf-a", "leaf-a.dev.0.up1").vertex_out.vertex_kind assert len(fetcher.vertex_lookup_calls) == 1 snap.apply_post_commit(device_ids=["leaf-a"], mark_paths_stale=False) - _ = snap.get_port("leaf-a", "leaf-a.dev.0.up1").vertex_kind + _ = snap.get_port("leaf-a", "leaf-a.dev.0.up1").vertex_out.vertex_kind assert len(fetcher.vertex_lookup_calls) == 2 -def test_vertex_attrs_without_fetcher_returns_none() -> None: +def test_vertex_flags_offline_but_config_none_without_fetcher() -> None: fetcher = FakeFetcher() snap = InspectSnapshot( fetcher=None, device_items=[fetcher._details["leaf-a"]], device_level=HydrationLevel.FULL, ) - port = snap.get_port("leaf-a", "leaf-a.dev.0.up1") - assert port.vertex_kind is None - assert port.sips_mode is None - assert port.control_props is None - assert port.park_port is None + vertex = snap.get_port("leaf-a", "leaf-a.dev.0.up1").vertex_out + assert vertex is not None + assert vertex.vertex_type == "Out" # offline from the port's vertexInfo + assert vertex.is_active is True # offline + assert vertex.vertex_kind is None # needs a lookup; no fetcher + assert vertex.sips_mode is None + assert vertex.control_props is None + assert vertex.park_port is None def test_filter_ports_by_module_and_direction(snapshot: tuple[InspectSnapshot, FakeFetcher]) -> None: @@ -323,7 +348,13 @@ def _detail_node(device_id: str, label: str, port_pids: list[str]) -> InspectApi "meta": {"coordinates": {"x": 0, "y": 0}}, "status": {"sa": 0, "severity": 0}, "syncSeverity": 0, - "modules": {f"{device_id}.dev.0": {"pid": f"{device_id}.dev.0", "ports": ports}}, + "modules": { + f"{device_id}.dev.0": { + "pid": f"{device_id}.dev.0", + "descriptor": {"label": "Slot 0"}, + "ports": ports, + } + }, } ) @@ -350,6 +381,7 @@ def port(module: str, name: str, vertex_info: dict | None) -> dict: modules = { f"{device_id}.dev.0": { "pid": f"{device_id}.dev.0", + "descriptor": {"label": "Slot 0"}, "ports": { f"{device_id}.dev.0.up1": port( "0", @@ -365,6 +397,7 @@ def port(module: str, name: str, vertex_info: dict | None) -> dict: }, f"{device_id}.dev.1": { "pid": f"{device_id}.dev.1", + "descriptor": {"label": "Slot 1"}, "ports": { f"{device_id}.dev.1.bidi1": port( "1", @@ -438,6 +471,7 @@ def __init__(self) -> None: self.device_detail_calls: list[str] = [] self.edge_pair_calls: list[str] = [] self.vertex_lookup_calls: list[list[str]] = [] + self.edge_lookup_calls: list[list[str]] = [] self.section_calls = 0 self.skeleton_calls = 0 self._details = { @@ -468,46 +502,137 @@ def get_paths_section(self) -> list[InspectApiPathItem]: def lookup_vertices(self, vertex_ids: list[str]) -> InspectApiLookupVerticesResponse: self.vertex_lookup_calls.append(list(vertex_ids)) + data = {vertex_id: _vertex_lookup_item(vertex_id) for vertex_id in vertex_ids} + return InspectApiLookupVerticesResponse.model_validate({"data": data, "header": _fetcher_ok_header()}) + + def lookup_edges(self, edge_ids: list[str]) -> InspectApiLookupEdgesResponse: + self.edge_lookup_calls.append(list(edge_ids)) data = { - vertex_id: { - "id": vertex_id, - "isVirtual": False, - "vertexType": "Out", - "customSchemas": {}, - "fields": { - "active": True, - "controlProps": {"configPriority": "off", "onlyInitial": False}, - "custom": {}, - "desc": "", - "destinationMonitorLeader": False, - "extraAlertFilters": [], - "label": vertex_id, - "localAssignedTags": [], - "queueable": False, - "sipsMode": "NONE", - "tags": [], - "typeFields": {"type": "ip"}, - "useAsEndpoint": False, - }, + edge_id: { + "edge": {"fromId": edge_id.split("::")[0], "toId": edge_id.split("::")[-1]}, + "fromDevice": None, + "toDevice": None, } - for vertex_id in vertex_ids + for edge_id in edge_ids } - return InspectApiLookupVerticesResponse.model_validate( - { - "data": data, - "header": { - "auth": True, - "caption": "Operation Successful", - "code": "OK", - "errorCodes": [], - "errorDetails": [], - "id": "0", - "msg": [], - "ok": True, - "user": "api-user", - }, - } - ) + return InspectApiLookupEdgesResponse.model_validate({"data": data, "header": _fetcher_ok_header()}) + + +def _fetcher_ok_header() -> dict[str, object]: + return { + "auth": True, + "caption": "Operation Successful", + "code": "OK", + "errorCodes": [], + "errorDetails": [], + "id": "0", + "msg": [], + "ok": True, + "user": "api-user", + } + + +def _vertex_lookup_item(vertex_id: str) -> dict[str, object]: + """An IP-vertex lookup item; codec ids (containing 'codec') return the codec fixture's data.""" + if "codec" in vertex_id: + item = dict(load_fixture("lookup_inspect_codec_vertex_by_id.json")["data"]) + item["id"] = vertex_id + return item + return { + "id": vertex_id, + "isVirtual": False, + "vertexType": "Out", + "customSchemas": {}, + "fields": { + "active": True, + "controlProps": {"configPriority": "off", "onlyInitial": False}, + "custom": {}, + "desc": "", + "destinationMonitorLeader": False, + "extraAlertFilters": [], + "label": vertex_id, + "localAssignedTags": [], + "queueable": False, + "sipsMode": "NONE", + "tags": [], + "typeFields": {"type": "ip", "ipAddress": "10.0.0.1", "vlanId": "100"}, + "useAsEndpoint": False, + }, + } + + +# --- Typed vertices + edge config (new field coverage) --- + + +def test_get_vertex_returns_typed_ip_vertex(snapshot: tuple[InspectSnapshot, FakeFetcher]) -> None: + from videoipath_automation_tool.apps.inspect.domain.vertex import InspectIpVertex + + snap, _ = snapshot + vertex = snap.get_vertex("leaf-a.0.up1") + assert isinstance(vertex, InspectIpVertex) + assert vertex.vertex_kind == "ip" + assert vertex.vertex_type == "Out" + assert vertex.ip_address == "10.0.0.1" + assert vertex.vlan_id == "100" + + +def test_get_vertex_returns_typed_codec_vertex(snapshot: tuple[InspectSnapshot, FakeFetcher]) -> None: + from videoipath_automation_tool.apps.inspect.domain.vertex import InspectCodecVertex + + snap, _ = snapshot + vertex = snap.get_vertex("device-a.1.codec-out-1.out") + assert isinstance(vertex, InspectCodecVertex) + assert vertex.vertex_kind == "codec" + assert vertex.codec_format == "Video" + assert vertex.is_igmp_source is False + assert vertex.sdp_support is True + assert vertex.main_dst_info == {"ip": "10.0.0.1", "mac": None, "port": 5000, "vlan": None} + + +def test_edge_config_props_and_detail_cache(snapshot: tuple[InspectSnapshot, FakeFetcher]) -> None: + snap, fetcher = snapshot + edge = snap.edges[0] + assert edge.redundancy_mode == "Any" + assert edge.fixed_weight == 1 + assert edge.conflict_priority == "off" # on-wire 0 -> "off" + assert edge.services_capacity == 65535 + assert edge.bandwidth_weight_factor == 0 + _ = edge.label # further access is served from the cache + assert fetcher.edge_lookup_calls == [[edge.id]] + + +# --- Modules --- + + +def test_device_modules_group_ports(snapshot: tuple[InspectSnapshot, FakeFetcher]) -> None: + snap, _ = snapshot + leaf = snap.get_device("leaf-a") + modules = leaf.modules + assert len(modules) == 1 + module = modules[0] + assert module.id == "leaf-a.dev.0" + assert module.label == "Slot 0" + assert {p.label for p in module.ports} == {"up1", "host1"} + # module.ports == device.ports grouped by module, and every port links back to its module + assert {p.id for p in module.ports} == { + p.id for p in leaf.ports if p.module is not None and p.module.id == "leaf-a.dev.0" + } + assert all(p.module is not None for p in leaf.ports) + assert all(p.module is not None and p.module.id == module.id for p in module.ports) + # flattened convenience: one vertex per (single-vertex) port + assert len(module.vertices) == 2 + assert leaf.get_module("leaf-a.dev.0").id == "leaf-a.dev.0" + assert leaf.get_module("no-such-module") is None + + +def test_device_exposes_multiple_modules(snapshot: tuple[InspectSnapshot, FakeFetcher]) -> None: + snap, fetcher = snapshot + fetcher._details["leaf-a"] = _filter_detail_node("leaf-a", "LEAF-A") + leaf = snap.get_device("leaf-a") + modules = {m.label: m for m in leaf.modules} + assert set(modules) == {"Slot 0", "Slot 1"} + assert {p.label for p in modules["Slot 0"].ports} == {"up1", "host1"} + assert {p.label for p in modules["Slot 1"].ports} == {"bidi1", "mgmt1"} @pytest.fixture diff --git a/tests/inspect/test_transaction.py b/tests/inspect/test_transaction.py index 511fc5b..f1b05c6 100644 --- a/tests/inspect/test_transaction.py +++ b/tests/inspect/test_transaction.py @@ -92,6 +92,51 @@ def test_update_edge_missing_raises_not_found() -> None: tx.update_edge(EDGE_ID, weight=42) +def test_update_edge_sets_all_config_fields() -> None: + api = FakeAPI() + api.edges[EDGE_ID] = _edge_item() + with _txn(api) as tx: + tx.update_edge( + EDGE_ID, + label="A -> B", + description="link", + include_formats=["fmt-a"], + exclude_formats=["fmt-b"], + conflict_priority="high", + bandwidth_weight_factor=5, + weight_per_service=3, + ) + tx.commit() + form = api.update_calls[0].replaceEdges[EDGE_ID] + assert form.descriptor.label == "A -> B" and form.descriptor.desc == "link" + assert form.includeFormats == ["fmt-a"] and form.excludeFormats == ["fmt-b"] + assert form.conflictPri == 1 # "high" mapped to the on-wire int + assert form.weightFactors["bandwidth"]["weight"] == 5 + assert form.weightFactors["service"]["weight"] == 3 + assert form.weightFactors["service"]["max"] == 100 # untouched sub-value preserved + + +def test_update_edge_also_opposite_stages_reverse() -> None: + opposite_id = "device7.0.swp1.out::device12.1.Ethernet1.in" + api = FakeAPI() + api.edges[EDGE_ID] = _edge_item() + api.edges[opposite_id] = _edge_item() + with _txn(api) as tx: + tx.update_edge(EDGE_ID, weight=7, also_opposite=True) + tx.commit() + replaced = api.update_calls[0].replaceEdges + assert set(replaced) == {EDGE_ID, opposite_id} + assert replaced[EDGE_ID].weight == 7 and replaced[opposite_id].weight == 7 + + +def test_update_edge_also_opposite_missing_raises() -> None: + api = FakeAPI() + api.edges[EDGE_ID] = _edge_item() + with _txn(api) as tx: + with pytest.raises(InspectEntityNotFoundError): + tx.update_edge(EDGE_ID, weight=7, also_opposite=True) + + def test_update_device_only_changes_label_when_set() -> None: api = FakeAPI() api.devices["device12"] = _device_response(label="Original", icon_type="switch") @@ -112,6 +157,19 @@ def test_update_device_sets_label() -> None: assert api.update_calls[0].replaceDevices["device12"].descriptor.label == "BU-LEAF-A" +def test_update_device_sets_all_edit_fields() -> None: + api = FakeAPI() + api.devices["device12"] = _device_response(label="Original") + with _txn(api) as tx: + tx.update_device("device12", description="Spine A", site_id="site-a", icon_size="large") + tx.commit() + form = api.update_calls[0].replaceDevices["device12"] + assert form.descriptor.desc == "Spine A" + assert form.siteId == "site-a" + assert form.iconSize == "large" + assert form.descriptor.label == "Original" # untouched + + def test_place_device_sets_coordinates() -> None: api = FakeAPI() api.devices["device12"] = _device_response() @@ -200,6 +258,20 @@ def test_update_vertex_sets_park_port() -> None: assert form.typeFields.parkPort == 99 +def test_update_vertex_sets_ip_typefields() -> None: + api = FakeAPI() + api.vertices[A_OUT] = _vertex_data() # the IP vertex fixture (typeFields populated) + with _txn(api) as tx: + tx.update_vertex(A_OUT, ip_address="10.0.0.1", vlan_id="100", public=True, supports_igmp=False) + tx.commit() + form = api.update_calls[0].replaceVertices[A_OUT] + assert form.typeFields is not None + assert form.typeFields.ipAddress == "10.0.0.1" + assert form.typeFields.vlanId == "100" + assert form.typeFields.public is True + assert form.typeFields.supportsIgmpCfg is False + + def test_remove_appends_to_remove_list() -> None: api = FakeAPI() with _txn(api) as tx: @@ -208,6 +280,24 @@ def test_remove_appends_to_remove_list() -> None: assert api.update_calls[0].remove == [EDGE_ID] +def test_remove_virtual_device_id_classified_as_device() -> None: + from videoipath_automation_tool.apps.inspect.transaction import _DEVICE, _EDGE, _VERTEX, _device_of, _entity_kind + + assert _entity_kind("virtual.2") == _DEVICE + assert _entity_kind("virtual.2.0.1") == _VERTEX + assert _entity_kind("device12") == _DEVICE + assert _entity_kind("device12.1.Ethernet1.out") == _VERTEX + assert _entity_kind(EDGE_ID) == _EDGE + assert _device_of("virtual.2.0.1") == "virtual.2" + assert _device_of("device12.1.Ethernet1.out") == "device12" + + api = FakeAPI() + with _txn(api) as tx: + tx.remove("virtual.2") + tx.commit() + assert api.update_calls[0].remove == ["virtual.2"] + + def test_disconnect_removes_both_directions() -> None: api = FakeAPI() with _txn(api) as tx: diff --git a/tests/inspect/test_virtual_devices.py b/tests/inspect/test_virtual_devices.py new file mode 100644 index 0000000..228e4ca --- /dev/null +++ b/tests/inspect/test_virtual_devices.py @@ -0,0 +1,423 @@ +"""Virtual device / port-template domain, wire, and actions tests.""" + +from __future__ import annotations + +import logging +from collections.abc import Callable +from types import SimpleNamespace +from typing import Any + +import pytest + +from videoipath_automation_tool.apps.inspect.app.actions import InspectActionsMixin +from videoipath_automation_tool.apps.inspect.app.write import InspectWriteMixin +from videoipath_automation_tool.apps.inspect.api import InspectAPI +from videoipath_automation_tool.apps.inspect.domain.device import InspectDevice, VirtualDeviceSpec +from videoipath_automation_tool.apps.inspect.domain.port import ( + InspectPortTemplate, + PortFromTemplate, + _ports_to_count_by_template, +) +from videoipath_automation_tool.apps.inspect.errors import InspectError +from videoipath_automation_tool.apps.inspect.model.actions import ( + InspectApiLookupInspectDeviceFields, + InspectApiLookupInspectDeviceResponse, +) +from videoipath_automation_tool.apps.inspect.model.collector import InspectApiNodeStatusItem +from videoipath_automation_tool.apps.inspect.model.update_topology import InspectApiUpdateTopologyResponse +from videoipath_automation_tool.apps.inspect.model.virtual import ( + InspectApiAddVirtualTopologyData, + InspectApiUpdateVirtualInstancesData, + InspectApiUpdateVirtualInstancesResponse, + InspectApiUpdateVirtualTemplatesData, + InspectApiVirtualDeviceInstance, + InspectApiVirtualTemplateItem, +) +from videoipath_automation_tool.apps.inspect.snapshot import InspectSnapshot + + +def test_virtual_templates_fixture_parses(load: Callable[[str], dict[str, Any]]) -> None: + items = load("virtual_templates.json")["data"]["status"]["network"]["virtualTemplates"]["_items"] + templates = [InspectApiVirtualTemplateItem.model_validate(item) for item in items] + assert templates[0].id == "generic_bidir" + assert templates[0].vertex.type == "genericVertex" + assert templates[1].id == "video_in" + assert templates[1].vertex.codecFormat == "Video" + + +def test_virtual_devices_fixture_parses(load: Callable[[str], dict[str, Any]]) -> None: + items = load("virtual_devices.json")["data"]["status"]["network"]["virtualDevices"]["_items"] + devices = [InspectApiVirtualDeviceInstance.model_validate(item) for item in items] + assert devices[0].id == "virtual.1" + assert devices[0].modules[0].vertices[0].templateId == "ip_in" + assert devices[1].modules == [] + + +def test_update_virtual_instances_create_response_parses(load: Callable[[str], dict[str, Any]]) -> None: + resp = InspectApiUpdateVirtualInstancesResponse.model_validate(load("update_virtual_instances_create.json")) + assert resp.data.addedDeviceLabels == {"virtual.1": "Virtual Device 1"} + assert resp.data.res.ok is True + assert resp.data.validation.result.ok is True + + +def test_lookup_virtual_device_fields_typed(load: Callable[[str], dict[str, Any]]) -> None: + resp = InspectApiLookupInspectDeviceResponse.model_validate(load("lookup_inspect_virtual_device.json")) + fields = resp.data.fields.virtualDeviceFields + assert fields is not None + assert fields.dynamic[0].moduleNumber == 0 + assert fields.dynamic[0].vertices[0].templateId == "generic_bidir" + assert fields.manual == [] + assert resp.data.fields.coordinates is None + + +def test_virtual_device_edit_form_round_trips_virtual_device_fields( + load: Callable[[str], dict[str, Any]], +) -> None: + """replaceDevices can round-trip a virtual device's lookup form including virtualDeviceFields.""" + raw_fields = load("lookup_inspect_virtual_device.json")["data"]["fields"] + fields = InspectApiLookupInspectDeviceFields.model_validate(raw_fields) + dumped = fields.model_dump(mode="json", by_alias=True) + assert dumped["virtualDeviceFields"] == raw_fields["virtualDeviceFields"] + assert dumped["descriptor"]["label"] == "Virtual Device 1" + + +def test_spec_to_wire_matches_ui_payload() -> None: + spec = ( + VirtualDeviceSpec.empty() + .add_port("video_in", count=2) + .add_port("video_out", count=2) + .add_module() + .add_port("ip_in") + ) + wire = spec.to_wire() + payload = wire.model_dump(mode="json", by_alias=True) + assert payload == { + "modules": [ + { + "moduleNumber": None, + "vertices": [ + {"templateId": "video_in", "count": 2}, + {"templateId": "video_out", "count": 2}, + ], + }, + { + "moduleNumber": None, + "vertices": [{"templateId": "ip_in", "count": 1}], + }, + ] + } + + +def test_spec_from_ports() -> None: + spec = VirtualDeviceSpec.from_ports("ip_in", ("ip_out", 2), PortFromTemplate(template_id="video_in", count=1)) + assert [p.template_id for p in spec.modules[0].ports] == ["ip_in", "ip_out", "video_in"] + assert spec.modules[0].ports[1].count == 2 + + +def test_port_template_domain_summary(load: Callable[[str], dict[str, Any]]) -> None: + items = load("virtual_templates.json")["data"]["status"]["network"]["virtualTemplates"]["_items"] + template = InspectPortTemplate.from_wire(InspectApiVirtualTemplateItem.model_validate(items[1])) + assert template.id == "video_in" + assert template.label == "Video in" + assert template.kind == "codecVertex" + assert template.direction == "In" + assert template.codec_format == "Video" + + +def test_ports_to_count_by_template_merges_lists() -> None: + assert _ports_to_count_by_template( + [PortFromTemplate(template_id="video_in", count=2), PortFromTemplate(template_id="video_in", count=3)] + ) == {"video_in": 5} + assert _ports_to_count_by_template({"ip_out": 1}) == {"ip_out": 1} + + +def test_create_virtual_devices_returns_inspect_devices(load: Callable[[str], dict[str, Any]]) -> None: + create = load("update_virtual_instances_create.json")["data"] + app = _App(post_data=create, skeleton_nodes=[_virtual_skeleton_node("virtual.1", "Virtual Device 1")]) + devices = app.create_virtual_devices(VirtualDeviceSpec.from_ports("generic_bidir"), copies=2) + assert len(devices) == 1 + assert isinstance(devices[0], InspectDevice) + assert devices[0].id == "virtual.1" + assert devices[0].is_virtual is True + assert devices[0].label == "Virtual Device 1" + url, payload = app._inspect_api.vip_connector.rest.post_calls[0] + assert url.endswith("/network/updateVirtualInstances") + assert len(payload["data"]["add"]) == 2 + assert payload["data"]["add"][0]["modules"][0]["vertices"] == [{"templateId": "generic_bidir", "count": 1}] + assert app._snapshot.upsert_calls == [["virtual.1"]] + + +def test_create_virtual_device_singular(load: Callable[[str], dict[str, Any]]) -> None: + app = _App( + post_data=load("update_virtual_instances_create.json")["data"], + skeleton_nodes=[_virtual_skeleton_node("virtual.1", "Virtual Device 1")], + ) + device = app.create_virtual_device(VirtualDeviceSpec.empty()) + assert device.id == "virtual.1" + assert device.is_virtual is True + + +def test_create_virtual_device_raises_on_failure() -> None: + app = _App( + post_data={ + "addedDeviceLabels": {}, + "res": {"msg": ["boom"], "ok": False}, + "validation": {"createIds": [], "details": {}, "result": {"msg": [], "ok": True}}, + }, + skeleton_nodes=[], + ) + with pytest.raises(InspectError): + app.create_virtual_device(VirtualDeviceSpec.empty()) + + +def test_remove_device_from_topology_uses_update_topology_for_virtual( + load: Callable[[str], dict[str, Any]], +) -> None: + """Virtual device removal uses the normal updateTopology path (verified 2025.4.9).""" + api = _FakeWriteAPI(load("update_topology_success.json")) + app = _WriteApp(api) + result = app.remove_device_from_topology("virtual.1") + assert result.ok is True + assert len(api.update_calls) == 1 + assert api.update_calls[0].remove == ["virtual.1"] + assert api.virtual_instance_calls == [] + + +def test_transaction_remove_virtual_and_physical_uses_update_topology( + load: Callable[[str], dict[str, Any]], +) -> None: + api = _FakeWriteAPI(load("update_topology_success.json")) + app = _WriteApp(api) + with app.transaction() as tx: + tx.remove_device("virtual.1") + tx.remove_device("device5") + result = tx.commit(check_conflicts=False) + assert result.ok is True + assert len(api.update_calls) == 1 + assert set(api.update_calls[0].remove) == {"virtual.1", "device5"} + assert api.virtual_instance_calls == [] + + +def test_add_virtual_ports() -> None: + app = _App(post_data={"msg": [], "ok": True}, skeleton_nodes=[]) + assert app.add_virtual_ports("virtual.1", 0, {"ip_out": 1}) is True + url, payload = app._inspect_api.vip_connector.rest.post_calls[0] + assert url.endswith("/network/addVirtualTopology") + assert payload["data"] == { + "deviceId": "virtual.1", + "moduleId": 0, + "countByVertexTemplate": {"ip_out": 1}, + } + + +def test_port_template_crud_payloads() -> None: + app = _App(post_data={"msg": [], "ok": True}, skeleton_nodes=[]) + assert app.create_port_template("example_tpl", "Example", {"type": "genericVertex", "vertexType": "In"}) is True + assert app.delete_port_templates(["example_tpl"]) is True + add_payload = app._inspect_api.vip_connector.rest.post_calls[0][1]["data"] + assert add_payload["add"]["example_tpl"]["label"] == "Example" + remove_payload = app._inspect_api.vip_connector.rest.post_calls[1][1]["data"] + assert remove_payload["remove"] == ["example_tpl"] + + +def test_list_port_templates(load: Callable[[str], dict[str, Any]]) -> None: + app = _App( + get_data=load("virtual_templates.json")["data"], + post_data={"msg": [], "ok": True}, + skeleton_nodes=[], + ) + templates = app.list_port_templates() + assert [t.id for t in templates] == ["generic_bidir", "video_in"] + + +def test_invalid_inputs_rejected() -> None: + app = _App(post_data={"msg": [], "ok": True}, skeleton_nodes=[]) + with pytest.raises(ValueError): + app.create_virtual_devices(VirtualDeviceSpec.empty(), copies=0) + with pytest.raises(ValueError): + app.create_port_template("", "x", {}) + with pytest.raises(ValueError): + PortFromTemplate(template_id="x", count=0) + + +def test_api_virtual_reads_and_writes(load: Callable[[str], dict[str, Any]]) -> None: + templates_data = load("virtual_templates.json")["data"] + rest = _FakeRest(get_data=templates_data, post_data=load("update_virtual_instances_create.json")["data"]) + api = InspectAPI(SimpleNamespace(rest=rest)) + templates = api.get_virtual_templates() + assert templates[0].id == "generic_bidir" + assert rest.get_calls[0][0].endswith("/virtualTemplates/**") + assert rest.get_calls[0][1] is True + + rest._get_data = load("virtual_devices.json")["data"] + devices = api.get_virtual_devices() + assert devices[0].id == "virtual.1" + assert rest.get_calls[1][0].endswith("/virtualDevices/**") + + resp = api.update_virtual_instances( + InspectApiUpdateVirtualInstancesData(add=[VirtualDeviceSpec.from_ports("generic_bidir").to_wire()]) + ) + assert resp.data.addedDeviceLabels["virtual.1"] == "Virtual Device 1" + assert rest.post_calls[0][0].endswith("/updateVirtualInstances") + + rest._post_data = {"msg": [], "ok": True} + api.update_virtual_templates(InspectApiUpdateVirtualTemplatesData()) + api.add_virtual_topology( + InspectApiAddVirtualTopologyData(deviceId="virtual.1", moduleId=0, countByVertexTemplate={"ip_out": 1}) + ) + assert rest.post_calls[1][0].endswith("/updateVirtualTemplates") + assert rest.post_calls[2][0].endswith("/addVirtualTopology") + + +def test_upsert_devices_from_skeleton_indexes_virtual_nodes() -> None: + nodes = [_virtual_skeleton_node("virtual.1", "Virtual Device 1")] + api = InspectAPI(SimpleNamespace(rest=_FakeRest(get_data={}, post_data={}))) + api.get_device_skeleton = lambda: list(nodes) # type: ignore[method-assign] + snap = InspectSnapshot(fetcher=api, device_items=[], edge_items=[]) + snap.upsert_devices_from_skeleton(["virtual.1"]) + device = snap.get_device("virtual.1") + assert device is not None + assert device.is_virtual is True + assert device.label == "Virtual Device 1" + + +# --- Internal --- + + +def _virtual_skeleton_node(device_id: str, label: str) -> InspectApiNodeStatusItem: + dash = device_id.replace(".", "-") + return InspectApiNodeStatusItem.model_validate( + { + "_id": dash, + "_vid": dash, + "descriptor": {"label": label}, + "deviceId": device_id, + "resourceId": f"device:{dash}", + } + ) + + +class _FakeRest: + def __init__( + self, + get_data: dict[str, Any] | None = None, + post_data: dict[str, Any] | None = None, + skeleton_nodes: list[InspectApiNodeStatusItem] | None = None, + ) -> None: + self._get_data = get_data or {} + self._post_data = post_data or {} + self._skeleton_nodes = skeleton_nodes or [] + self.get_calls: list[tuple[str, bool]] = [] + self.post_calls: list[tuple[str, dict[str, Any]]] = [] + + def get(self, url_path: str, allow_projection: bool = False, **kwargs: Any) -> SimpleNamespace: + self.get_calls.append((url_path, allow_projection)) + return SimpleNamespace(data=self._get_data, header=_ok_header()) + + def post(self, url_path: str, body: Any, **kwargs: Any) -> SimpleNamespace: + payload = body.model_dump(mode="json", by_alias=True) + self.post_calls.append((url_path, payload)) + return SimpleNamespace(data=self._post_data, header=_ok_header()) + + +class _RecordingSnapshot: + def __init__(self, devices: dict[str, InspectDevice] | None = None) -> None: + self.network_refresh_calls: list[list[str]] = [] + self.upsert_calls: list[list[str]] = [] + self._devices = devices or {} + + def apply_network_refresh(self, device_ids: list[str]) -> None: + self.network_refresh_calls.append(list(device_ids)) + + def upsert_devices_from_skeleton(self, device_ids: list[str]) -> None: + self.upsert_calls.append(list(device_ids)) + + def get_device(self, device_id: str) -> InspectDevice | None: + return self._devices.get(device_id) + + def apply_post_commit(self, **kwargs: Any) -> None: + return None + + +class _App(InspectActionsMixin): + def __init__( + self, + post_data: dict[str, Any], + get_data: dict[str, Any] | None = None, + skeleton_nodes: list[InspectApiNodeStatusItem] | None = None, + snapshot: _RecordingSnapshot | None = None, + ) -> None: + self._logger = logging.getLogger("test") + rest = _FakeRest(get_data=get_data, post_data=post_data, skeleton_nodes=skeleton_nodes) + self._inspect_api = InspectAPI(SimpleNamespace(rest=rest)) + # Bind skeleton fetch onto the API for recording-snapshot tests that call real upsert. + self._inspect_api.get_device_skeleton = lambda: list(skeleton_nodes or []) # type: ignore[method-assign] + if snapshot is not None: + self._snapshot = snapshot + else: + devices: dict[str, InspectDevice] = {} + recording = _RecordingSnapshot(devices=devices) + # Populate devices after upsert by wrapping upsert to create InspectDevice stubs. + real_snap = InspectSnapshot(fetcher=self._inspect_api, device_items=[], edge_items=[]) + + def _upsert(device_ids: list[str]) -> None: + recording.upsert_calls.append(list(device_ids)) + real_snap.upsert_devices_from_skeleton(device_ids) + for device_id in device_ids: + device = real_snap.get_device(device_id) + if device is not None: + devices[device_id] = device + + recording.upsert_devices_from_skeleton = _upsert # type: ignore[method-assign] + recording.get_device = devices.get # type: ignore[method-assign] + self._snapshot = recording + + def _get_snapshot(self) -> Any: + return self._snapshot + + +class _FakeWriteAPI: + def __init__(self, topology_response: dict[str, Any]) -> None: + self.update_response = InspectApiUpdateTopologyResponse.model_validate(topology_response) + self.update_calls: list[Any] = [] + self.virtual_instance_calls: list[Any] = [] + + def update_topology(self, delta: Any) -> InspectApiUpdateTopologyResponse: + self.update_calls.append(delta) + return self.update_response + + def update_virtual_instances(self, data: Any) -> Any: + self.virtual_instance_calls.append(data) + raise AssertionError("updateVirtualInstances must not be used for virtual device removal") + + +class _WriteApp(InspectWriteMixin): + def __init__(self, api: _FakeWriteAPI) -> None: + self._logger = logging.getLogger("test") + self._inspect_api = api # type: ignore[assignment] + self._snapshot = _RecordingSnapshot() + + +def _ok_header() -> SimpleNamespace: + return SimpleNamespace( + model_dump=lambda mode="json": { + "auth": True, + "caption": "OK", + "code": "OK", + "errorCodes": [], + "errorDetails": [], + "id": "0", + "msg": [], + "ok": True, + "user": "api-user", + }, + auth=True, + caption="OK", + code="OK", + errorCodes=[], + errorDetails=[], + id="0", + msg=[], + ok=True, + user="api-user", + ) diff --git a/tests/validators/test_virtual_device_id.py b/tests/validators/test_virtual_device_id.py index 8918aec..b7760a5 100644 --- a/tests/validators/test_virtual_device_id.py +++ b/tests/validators/test_virtual_device_id.py @@ -1,6 +1,16 @@ import pytest -from videoipath_automation_tool.validators.virtual_device_id import validate_virtual_device_id +from videoipath_automation_tool.validators.virtual_device_id import is_virtual_device_id, validate_virtual_device_id + + +class TestIsVirtualDeviceId: + @pytest.mark.parametrize("device_id", ["virtual.0", "virtual.1", "virtual.123"]) + def test_true_for_virtual_ids(self, device_id: str): + assert is_virtual_device_id(device_id) is True + + @pytest.mark.parametrize("device_id", ["device5", "virtual.1.0.out", "virtual-1", "", None, 1]) + def test_false_for_non_virtual_ids(self, device_id: str): + assert is_virtual_device_id(device_id) is False class TestValidateVirtualDeviceId: From 2d7a093ab045e615ea1f1852363af7dfb6dc088f Mon Sep 17 00:00:00 2001 From: Jonas Scholl <43819845+JonasScholl@users.noreply.github.com> Date: Thu, 23 Jul 2026 02:18:17 +0200 Subject: [PATCH 20/34] update model improvements --- README.md | 3 +- .../future/unified-domain-architecture.md} | 0 docs/{ => architecture}/inspect-app/README.md | 8 +- .../inspect-app/concepts.md | 28 +- .../decisions/0001-api-paradigm.md | 0 .../decisions/0002-loading-and-state.md | 0 .../decisions/0003-websocket-subscriptions.md | 0 .../decisions/0004-async-strategy.md | 0 .../inspect-app/decisions/0005-e2e-testing.md | 0 .../decisions/0006-commit-write-model.md | 0 .../decisions/0007-lazy-snapshot-loading.md | 0 .../0008-collector-only-endpoints.md | 0 .../decisions/0009-write-consistency.md | 0 .../0010-post-commit-snapshot-refresh.md | 0 .../inspect-app/decisions/README.md | 0 .../inspect-app/endpoints.md | 35 ++ docs/{ => architecture}/inspect-app/models.md | 0 .../python-module-architecture.md | 2 +- docs/getting-started-guide/05_Inspect.md | 8 +- docs/inspect-app/implementation-plan.md | 284 ----------- .../apps/inspect/api/inspect_api.py | 27 ++ .../apps/inspect/api/queries.py | 2 +- .../apps/inspect/app/write.py | 190 +++++++- .../apps/inspect/domain/device.py | 263 ++++++++--- .../apps/inspect/domain/edge.py | 174 +++++-- .../apps/inspect/domain/module.py | 30 +- .../apps/inspect/domain/port.py | 7 +- .../apps/inspect/domain/vertex.py | 444 +++++++++++++++--- .../apps/inspect/model/__init__.py | 12 +- .../apps/inspect/model/collector.py | 13 + .../apps/inspect/model/common.py | 56 ++- .../apps/inspect/model/tags.py | 38 ++ .../apps/inspect/snapshot.py | 80 +++- .../apps/inspect/transaction.py | 269 ++++++++++- .../apps/topology/errors.py | 7 + .../apps/topology/topology_app.py | 36 ++ .../connector/models/response_rest_v2.py | 7 +- .../connector/vip_rest_connector.py | 1 + tests/e2e/helpers.py | 88 +++- tests/e2e/inspect/test_e2e_inspect.py | 45 +- tests/inspect/test_domain_writes.py | 422 +++++++++++++++++ tests/topology/__init__.py | 0 tests/topology/test_version_compat.py | 44 ++ 43 files changed, 2080 insertions(+), 543 deletions(-) rename docs/{future/domain-architecture.md => architecture/future/unified-domain-architecture.md} (100%) rename docs/{ => architecture}/inspect-app/README.md (93%) rename docs/{ => architecture}/inspect-app/concepts.md (95%) rename docs/{ => architecture}/inspect-app/decisions/0001-api-paradigm.md (100%) rename docs/{ => architecture}/inspect-app/decisions/0002-loading-and-state.md (100%) rename docs/{ => architecture}/inspect-app/decisions/0003-websocket-subscriptions.md (100%) rename docs/{ => architecture}/inspect-app/decisions/0004-async-strategy.md (100%) rename docs/{ => architecture}/inspect-app/decisions/0005-e2e-testing.md (100%) rename docs/{ => architecture}/inspect-app/decisions/0006-commit-write-model.md (100%) rename docs/{ => architecture}/inspect-app/decisions/0007-lazy-snapshot-loading.md (100%) rename docs/{ => architecture}/inspect-app/decisions/0008-collector-only-endpoints.md (100%) rename docs/{ => architecture}/inspect-app/decisions/0009-write-consistency.md (100%) rename docs/{ => architecture}/inspect-app/decisions/0010-post-commit-snapshot-refresh.md (100%) rename docs/{ => architecture}/inspect-app/decisions/README.md (100%) rename docs/{ => architecture}/inspect-app/endpoints.md (97%) rename docs/{ => architecture}/inspect-app/models.md (100%) rename docs/{ => architecture}/python-module-architecture.md (96%) delete mode 100644 docs/inspect-app/implementation-plan.md create mode 100644 src/videoipath_automation_tool/apps/inspect/model/tags.py create mode 100644 src/videoipath_automation_tool/apps/topology/errors.py create mode 100644 tests/inspect/test_domain_writes.py create mode 100644 tests/topology/__init__.py create mode 100644 tests/topology/test_version_compat.py diff --git a/README.md b/README.md index 1bcc230..055a49d 100644 --- a/README.md +++ b/README.md @@ -79,10 +79,9 @@ except Exception as e: ## Documentation - [Getting Started Guide](https://github.com/SWR-MoIP/VideoIPath-Automation-Tool/blob/main/docs/getting-started-guide/README.md) -- [Python Module Architecture](https://github.com/SWR-MoIP/VideoIPath-Automation-Tool/blob/main/docs/python-module-architecture.md) - [Driver Versioning](https://github.com/SWR-MoIP/VideoIPath-Automation-Tool/blob/main/docs/driver-versioning.md) +- [Python Module Architecture](https://github.com/SWR-MoIP/VideoIPath-Automation-Tool/blob/main/docs/architecture/python-module-architecture.md) - [Development and Release](https://github.com/SWR-MoIP/VideoIPath-Automation-Tool/blob/main/docs/development-and-release.md) -- [Inspect App — Architecture & Concept Docs](https://github.com/SWR-MoIP/VideoIPath-Automation-Tool/blob/main/docs/inspect-app/README.md) (planning) ## Feedback & Contributions diff --git a/docs/future/domain-architecture.md b/docs/architecture/future/unified-domain-architecture.md similarity index 100% rename from docs/future/domain-architecture.md rename to docs/architecture/future/unified-domain-architecture.md diff --git a/docs/inspect-app/README.md b/docs/architecture/inspect-app/README.md similarity index 93% rename from docs/inspect-app/README.md rename to docs/architecture/inspect-app/README.md index 1a12658..9f2ac79 100644 --- a/docs/inspect-app/README.md +++ b/docs/architecture/inspect-app/README.md @@ -8,9 +8,9 @@ replace **Inventory**: devices are still onboarded in Inventory first, then plac and connected in Inspect. Inspect also uses a **commit-style** write model, where create/edit/delete actions are gathered into a change set and committed together. -In **this package**, `app.inspect` is **purely additive**: it is added alongside -the existing apps. `app.topology` and `app.inventory` keep working **unchanged** — -there is **no deprecation and no migration** planned. +In **this package**, `app.inspect` is the replacement for `app.topology`: +`TopologyApp` emits a deprecation warning on VideoIPath 2025.x and raises on +2026.x+. `app.inventory` remains unchanged and required for device onboarding. These docs are **living documents** — meant to be edited and refined as the design firms up and as the real Inspect API is reverse-engineered. The official @@ -21,7 +21,7 @@ reference is now a primary source. > (`src/videoipath_automation_tool/apps/inspect/`), with offline unit tests under > `tests/inspect/` and a developer-run live E2E suite under `tests/e2e/inspect/`. > All ten ADRs are Accepted. For usage, see the -> [Inspect getting-started page](../getting-started-guide/05_Inspect.md). +> [Inspect getting-started page](../../getting-started-guide/05_Inspect.md). ## Reading order diff --git a/docs/inspect-app/concepts.md b/docs/architecture/inspect-app/concepts.md similarity index 95% rename from docs/inspect-app/concepts.md rename to docs/architecture/inspect-app/concepts.md index be2cf39..65eefe2 100644 --- a/docs/inspect-app/concepts.md +++ b/docs/architecture/inspect-app/concepts.md @@ -31,8 +31,9 @@ Inspect does **not** replace the **Inventory** app. Devices are still onboarded in Inventory first; only then can they be placed and connected in Inspect. So Inventory remains a separate, required prerequisite. -In **this package**, `app.inspect` is added **additively** — `app.topology` and -`app.inventory` keep working unchanged, with no deprecation planned. +In **this package**, `app.inspect` replaces `app.topology`: `TopologyApp` is +deprecated on VideoIPath 2025.x and unsupported on 2026.x+. `app.inventory` +remains unchanged and required for device onboarding. ## 2. Architecture: the `collector` facade @@ -292,22 +293,24 @@ the package keeps a separate Inspect model namespace (`InspectApiBaseDevice`, model classes in Inspect DTOs. `updateTopology` is **last-writer-wins** — a stale `_rev` in the payload is ignored ([endpoints.md](./endpoints.md#post-restv2actionsstatuscollectorupdatetopology)). -### 3.4 Tagging — device vs. vertex (Inspect vs. Topology) +### 3.4 Tagging — device vs. vertex vs. module (Inspect vs. Topology) -Inspect distinguishes two tag scopes. This is a **key difference from +Inspect distinguishes three tag scopes. This is a **key difference from `app.topology`**, which only models tags on topology **devices** (`baseDevice` entries in `nGraphElements`). -| Scope | What is tagged | Topology / `nGraphElements` | Inspect read surface | Server storage | -| ----- | -------------- | --------------------------- | -------------------- | -------------- | -| **Device** | Topology node (`baseDevice`) | `tags` on the `baseDevice` item | `nodeStatus` `tags` / `meta.tags` / `tagsInfo`; `lookupInspectDevice` | `ngraph` / `nGraphElements` | -| **Vertex** | Individual port vertex (`ipVertex`, `codecVertex`, …) | **Not present** — no vertex-tag field on persisted graph elements | Hydrated port `tagsInfo`; editable form in `lookupInspectVertexByIds` | `videoipath_docs.device_tags` (separate from `ngraph`) | +| Scope | What is tagged | Topology / `nGraphElements` | Inspect read surface | Write path | +| ----- | -------------- | --------------------------- | -------------------- | ---------- | +| **Device** | Topology node (`baseDevice`) | `tags` on the `baseDevice` item | `nodeStatus` `tags` / `meta.tags` / `tagsInfo`; `lookupInspectDevice` | `updateTopology` `replaceDevices` | +| **Vertex** | Individual port vertex (`ipVertex`, `codecVertex`, …) | **Not present** — no vertex-tag field on persisted graph elements | Hydrated port `tagsInfo`; editable form in `lookupInspectVertexByIds` | `updateTopology` `replaceVertices` (`localAssignedTags`) | +| **Module** | Device module / slot | **Not present** | Hydrated module `tagsInfo` on `nodeStatus` | `assignTag` / `unassignTag` with `elementIds: ["device:{modulePid}"]` | **Implications for the package:** - `app.topology` reads/writes device tags via `nGraphElements` only. It has no API for binding tags to a vertex id such as - `device-a.module-1.port-out-1.out`. + `device-a.module-1.port-out-1.out`, nor for module resource ids such as + `device:device-a.dev.0`. - `app.inspect` must treat vertex tags as a **separate concern** from the persisted graph element shape. Do not assume a vertex's `tags` array in an `nGraphElements` `ipVertex` / `codecVertex` item (if present at all) is the @@ -316,6 +319,12 @@ entries in `nGraphElements`). - Stage-time baselines and compare-and-commit for vertex edits must use `lookupInspectVertexByIds` for tag fields ([ADR-0009](./decisions/0009-write-consistency.md)), not `nGraphElements` or the collector skeleton. +- Module tags are **not** written via `updateTopology`. The Inspect UI uses + `POST …/actions/status/tags/assignTag` and `…/unassignTag` with a single + `tagId` plus `elementIds`. The package diffs the desired local tag list + against `tagsInfo.assigned.local` and issues one call per added/removed tag. + These RPCs are separate from the topology commit (not one atomic server + transaction). - Collector `tagInfo` provides tag → profile metadata for the aggregate; it does not replace per-vertex `assignedTags` on the lookup response. @@ -414,6 +423,7 @@ Write consistency & post-commit refresh ([ADR-0009](./decisions/0009-write-consi - [x] Persisted-element lookups: `lookupInspectEdgesByIds` returns the **full persisted edge form** (batched); `lookupInspectVertexById`/`…ByIds` and `lookupInspectDevice` return editable forms; **no `_rev` in any lookup response**; `lookupGraphElement` does **not** exist; `lookupNodeInfo`/`lookupEdgeInfo`/`lookupDeviceVertices` are display-oriented ([endpoints.md](./endpoints.md#post-restv2actionsstatuscollectorlookupinspectedgesbyids)) - [x] Vertex tag bindings: separate from `nGraphElements` — stored server-side in `videoipath_docs.device_tags`; surfaced on `lookupInspectVertexByIds` (`assignedTags`, `fields.tags`, `fields.localAssignedTags`) and hydrated port `tagsInfo` in `nodeStatus`; not modelled by `app.topology` (§3.4) +- [x] Module tag bindings: read from hydrated module `tagsInfo`; write via `assignTag` / `unassignTag` with `device:{modulePid}` element ids (not `updateTopology`) (§3.4) - [x] UI edit/commit flows avoid `nGraphElements`: the Inspect UI bundle contains zero references; the edge edit flow calls `lookupInspectEdgesByIds` with both pair directions ([ADR-0008](./decisions/0008-collector-only-endpoints.md)) - [x] Batched lookups for compare-and-commit baselines: `…ByIds` actions take id lists natively - [x] Direct edge-pair addressing for targeted refresh: `externalEdgesByDeviceKey//` returns the single pair item diff --git a/docs/inspect-app/decisions/0001-api-paradigm.md b/docs/architecture/inspect-app/decisions/0001-api-paradigm.md similarity index 100% rename from docs/inspect-app/decisions/0001-api-paradigm.md rename to docs/architecture/inspect-app/decisions/0001-api-paradigm.md diff --git a/docs/inspect-app/decisions/0002-loading-and-state.md b/docs/architecture/inspect-app/decisions/0002-loading-and-state.md similarity index 100% rename from docs/inspect-app/decisions/0002-loading-and-state.md rename to docs/architecture/inspect-app/decisions/0002-loading-and-state.md diff --git a/docs/inspect-app/decisions/0003-websocket-subscriptions.md b/docs/architecture/inspect-app/decisions/0003-websocket-subscriptions.md similarity index 100% rename from docs/inspect-app/decisions/0003-websocket-subscriptions.md rename to docs/architecture/inspect-app/decisions/0003-websocket-subscriptions.md diff --git a/docs/inspect-app/decisions/0004-async-strategy.md b/docs/architecture/inspect-app/decisions/0004-async-strategy.md similarity index 100% rename from docs/inspect-app/decisions/0004-async-strategy.md rename to docs/architecture/inspect-app/decisions/0004-async-strategy.md diff --git a/docs/inspect-app/decisions/0005-e2e-testing.md b/docs/architecture/inspect-app/decisions/0005-e2e-testing.md similarity index 100% rename from docs/inspect-app/decisions/0005-e2e-testing.md rename to docs/architecture/inspect-app/decisions/0005-e2e-testing.md diff --git a/docs/inspect-app/decisions/0006-commit-write-model.md b/docs/architecture/inspect-app/decisions/0006-commit-write-model.md similarity index 100% rename from docs/inspect-app/decisions/0006-commit-write-model.md rename to docs/architecture/inspect-app/decisions/0006-commit-write-model.md diff --git a/docs/inspect-app/decisions/0007-lazy-snapshot-loading.md b/docs/architecture/inspect-app/decisions/0007-lazy-snapshot-loading.md similarity index 100% rename from docs/inspect-app/decisions/0007-lazy-snapshot-loading.md rename to docs/architecture/inspect-app/decisions/0007-lazy-snapshot-loading.md diff --git a/docs/inspect-app/decisions/0008-collector-only-endpoints.md b/docs/architecture/inspect-app/decisions/0008-collector-only-endpoints.md similarity index 100% rename from docs/inspect-app/decisions/0008-collector-only-endpoints.md rename to docs/architecture/inspect-app/decisions/0008-collector-only-endpoints.md diff --git a/docs/inspect-app/decisions/0009-write-consistency.md b/docs/architecture/inspect-app/decisions/0009-write-consistency.md similarity index 100% rename from docs/inspect-app/decisions/0009-write-consistency.md rename to docs/architecture/inspect-app/decisions/0009-write-consistency.md diff --git a/docs/inspect-app/decisions/0010-post-commit-snapshot-refresh.md b/docs/architecture/inspect-app/decisions/0010-post-commit-snapshot-refresh.md similarity index 100% rename from docs/inspect-app/decisions/0010-post-commit-snapshot-refresh.md rename to docs/architecture/inspect-app/decisions/0010-post-commit-snapshot-refresh.md diff --git a/docs/inspect-app/decisions/README.md b/docs/architecture/inspect-app/decisions/README.md similarity index 100% rename from docs/inspect-app/decisions/README.md rename to docs/architecture/inspect-app/decisions/README.md diff --git a/docs/inspect-app/endpoints.md b/docs/architecture/inspect-app/endpoints.md similarity index 97% rename from docs/inspect-app/endpoints.md rename to docs/architecture/inspect-app/endpoints.md index 06fc6da..977825f 100644 --- a/docs/inspect-app/endpoints.md +++ b/docs/architecture/inspect-app/endpoints.md @@ -1760,6 +1760,41 @@ Earlier anonymized failure shape (browser capture, 2026-06-18): } ``` +## `POST /rest/v2/actions/status/tags/assignTag` / `…/unassignTag` + +Module (and other resource) tag bindings that are **not** carried on +`updateTopology` edit forms. Captured from the Inspect UI (2025.4.x). + +Both actions share the same request body shape: + +```http +POST /rest/v2/actions/status/tags/assignTag +POST /rest/v2/actions/status/tags/unassignTag +``` + +```json +{ + "header": { "id": 0 }, + "data": { + "tagId": "Format~~V_720p60", + "elementIds": ["device:device-a.dev.0"] + } +} +``` + +| Field | Notes | +| ----- | ----- | +| `tagId` | Catalog tag id (`Category~~name`) | +| `elementIds` | Resource ids — for modules, `device:{modulePid}` (e.g. `device:device-a.dev.0`) | + +One tag per call; the package batches multiple modules that share the same +`tagId` into a single `elementIds` list. Assign adds a local binding; unassign +removes it. These calls are **not** part of the `updateTopology` atomic apply +— a mixed topology + module-tag commit runs topology first, then tag RPCs. + +Success responses often return ``data: null`` with ``header.ok == true`` (verified +live); the package treats a successful header as enough when ``data`` is absent. + ## Action registration discovery `GET /rest/v2/actions/status/collector/` returns whether an action diff --git a/docs/inspect-app/models.md b/docs/architecture/inspect-app/models.md similarity index 100% rename from docs/inspect-app/models.md rename to docs/architecture/inspect-app/models.md diff --git a/docs/python-module-architecture.md b/docs/architecture/python-module-architecture.md similarity index 96% rename from docs/python-module-architecture.md rename to docs/architecture/python-module-architecture.md index b09c591..cdff12d 100644 --- a/docs/python-module-architecture.md +++ b/docs/architecture/python-module-architecture.md @@ -6,7 +6,7 @@ The architecture of this Python module is designed for **maintainability** and * The module consists of multiple layers, as visualized in the diagram below: -![Module Architecture](images/module-architecture.svg) +![Module Architecture](../images/module-architecture.svg) ### Business Logic Layer diff --git a/docs/getting-started-guide/05_Inspect.md b/docs/getting-started-guide/05_Inspect.md index fcc511f..beef826 100644 --- a/docs/getting-started-guide/05_Inspect.md +++ b/docs/getting-started-guide/05_Inspect.md @@ -104,6 +104,12 @@ app.inspect.update_edge(edge_id, weight=10) # "Category~~name" id; read them back with port.tags. app.inspect.update_vertex("device12.1.Ethernet1.out", tags=["Video~~1080p50"]) +# Module tags use the same setter / update() pattern (backed by assignTag / unassignTag). +module = device.get_module("device12.dev.0") +module.tags = ["Format~~V_720p60"] +app.inspect.update(module) +# or: app.inspect.update_module("device12.dev.0", tags=["Format~~V_720p60"]) + app.inspect.connect( "device12.1.Ethernet1.out", "device7.0.swp1.in", @@ -171,4 +177,4 @@ app.inspect.sync_devices(["device12"], add_only=True, conflict_strategy=Conflict triggers a read. Reads and hydration are internally consistent under concurrent access, but a single `VideoIPathApp` is otherwise intended for single-owner use. - For the design rationale, see the architecture docs under - [`docs/inspect-app/`](../inspect-app/README.md). + [`docs/architecture/inspect-app/`](../architecture/inspect-app/README.md). diff --git a/docs/inspect-app/implementation-plan.md b/docs/inspect-app/implementation-plan.md deleted file mode 100644 index 98d70af..0000000 --- a/docs/inspect-app/implementation-plan.md +++ /dev/null @@ -1,284 +0,0 @@ -# Inspect App — Implementation Plan - -> Status: **Implemented** · Last updated: 2026-07-09 -> -> Turns the concept phase ([concepts.md](./concepts.md), [endpoints.md](./endpoints.md), -> [ADRs 0001–0010](./decisions/README.md)) into the shipped `app.inspect` surface. -> All wire facts referenced below are live-verified against VideoIPath 2025.4.9. -> -> **Shipped:** code under `src/videoipath_automation_tool/apps/inspect/`; offline tests under -> `tests/inspect/`; developer-run live E2E suite (`-m e2e`) under `tests/e2e/inspect/`; user guide -> at [getting-started 05_Inspect](../getting-started-guide/05_Inspect.md). Milestones M1–M5 below -> are complete. - -## 1. Context - -The inspect-app concept phase is complete: all wire formats are captured and live-verified against VideoIPath 2025.4.9 ([endpoints.md](./endpoints.md)), and ten ADRs pin the architecture. This plan turns the concepts into the shipped `app.inspect` package surface. The existing code under `src/videoipath_automation_tool/apps/inspect/` (snapshot.py, domain/, model/) is a **draft** — it predates ADR-0007/0009/0010 (it parses one full collector response, no lazy hydration, no writes) and will be reworked in place. - -Governing decisions (all Accepted): - -| ADR | Decision (implementation-relevant core) | -| --- | --- | -| [0001](./decisions/0001-api-paradigm.md)/[0003](./decisions/0003-websocket-subscriptions.md) | Request/response only; no WebSocket API | -| [0004](./decisions/0004-async-strategy.md) | Sync public API; internal thread-pool parallelism for multi-request operations | -| [0005](./decisions/0005-e2e-testing.md) | E2E = developer-run live-server tests via `tests/.env.test`; offline fixture tests feed from `tests//fixtures//` | -| [0006](./decisions/0006-commit-write-model.md) | Commit-style writes: hybrid — direct auto-commit methods + explicit transaction context manager; success = `header.ok ∧ data.res.ok ∧ data.validation.result.ok` | -| [0007](./decisions/0007-lazy-snapshot-loading.md) | Skeleton-first snapshot (devices + edges scoped queries), transparent per-entity lazy hydration, section-level lazy loads, accreting state, `load="full"` eager mode | -| [0008](./decisions/0008-collector-only-endpoints.md) | Collector-only endpoints: never call `nGraphElements` / `edgesByDevice` at runtime | -| [0009](./decisions/0009-write-consistency.md) | Client-side compare-and-commit; baselines from `lookupInspectDevice` / `lookupInspectVertexByIds` / `lookupInspectEdgesByIds` (lookup forms **are** the write shapes); typed conflict error; explicit override | -| [0010](./decisions/0010-post-commit-snapshot-refresh.md) | Post-commit targeted refresh: local removes, per-device/per-pair scoped re-reads (~25 ms propagation, no retry loop), sections marked stale | - -Verified server facts the code must encode: `replaceDevices`/`replaceVertices` take **edit forms** (`coordinates`+`localAssignedTags` mandatory for devices), `replaceVertices` is **update-only**, `replaceEdges` takes the raw persisted edge form, last-writer-wins (no `_rev` anywhere on the Inspect surface), reject-before-apply atomicity, HTTP 414 for over-long projections, `"_noId"` suppresses expansion, effective-label merging in lookups/collector. - -## 2. Public API (the user-facing contract — design this first, code to it) - -### 2.1 Entry point - -```python -app = VideoIPathApp(...) -snapshot = app.inspect.get_snapshot() # skeleton: all devices + edges, 2 parallel GETs -snapshot = app.inspect.get_snapshot(load="full") # eager /** fallback (point-in-time) -``` - -### 2.2 Reads (snapshot + domain objects, ADR-0007) - -```python -dev = snapshot.get_device("device12") # skeleton-backed, no I/O -dev = snapshot.find_device_by_label("BU-LEAF-A") # exact; find_devices_by_label for lists -devs = snapshot.devices # list[InspectDevice] -edges = snapshot.edges # list[InspectEdge] (pair-level) -svcs = snapshot.services # lazy: loads inspect/paths section on first touch - -dev.label, dev.id, dev.coordinates, dev.status, dev.sync_severity, dev.tags # skeleton fields -dev.ports # first access → one GET nodeStatus//, then local -dev.edges # local (edge skeleton) -dev.services # triggers paths section load once -dev.linked_devices # local graph walk -port.vertex_id, port.status, port.edge, port.device -edge.from_device, edge.to_port, edge.status, edge.pair_id - -snapshot.preload(devices=[...]) # batch hydration, thread pool (ADR-0004) -snapshot.refresh() # returns a NEW snapshot (never mutates) -snapshot.fetched_at(entity_or_section) # freshness introspection -``` - -Contract (already documented in models.md): at most one hydration fetch per entity/section; getters can raise connector errors; snapshot is not a single point in time. - -### 2.3 Writes (transaction, ADR-0006/0009/0010) - -```python -# Convenience direct writes (auto-commit, one updateTopology each): -app.inspect.place_device("device12", x=1600, y=9050) -app.inspect.update_device("device12", label="BU-LEAF-A", icon_type="switch") -app.inspect.update_vertex("device12.1.Ethernet1.out", use_as_endpoint=True) -app.inspect.update_edge(edge_id, weight=10) -app.inspect.connect("device12.1.Ethernet1.out", "device7.0.swp1.in", - bidirectional=True, capacity=65535) # paired edges -app.inspect.disconnect(edge_or_pair_id) -app.inspect.remove_device_from_topology("device12") # remove baseDevice element - -# Batched, atomic (primary path for pipelines): -with app.inspect.transaction() as tx: - tx.place_device("device12", x=100, y=200) - tx.connect(a_out, b_in, bidirectional=True) - tx.remove(edge_id) - result = tx.commit() # conflict check → POST → targeted snapshot refresh -# exit without commit → discard + warning log; exception → discard - -result.ok, result.applied_ids, result.validation # typed CommitResult - -# Conflict handling (ADR-0009): -try: - tx.commit() -except InspectCommitConflictError as e: - e.conflicts # [{entity_id, kind, field_diffs}] - tx.rebase() # re-fetch baselines, re-apply staged intents; then commit again -tx.commit(check_conflicts=False) # explicit last-writer-wins -``` - -Semantics: staging an entity fetches its baseline via the lookups (batched); caller mutations are field-level *intents* applied onto the baseline at commit-build time; `remove` baselines assert existence. Direct writes are sugar: open implicit tx → stage → commit. - -### 2.4 Topology device/sync actions (network actions) - -```python -app.inspect.add_devices_to_topology([("device12", 100, 200), ...]) # addDevices action -app.inspect.sync_devices(["device12"], add_only=True, conflict_strategy=ConflictStrategy.STRICT) -info = app.inspect.get_sync_info(["device12"]) # lookupSyncInfo -``` - -### 2.5 Errors (new `apps/inspect/errors.py`) - -`InspectError` (base) → `InspectCommitError` (carries `CommitResult` with `res.msg` + `validation` details), `InspectCommitConflictError`, `InspectEntityNotFoundError`, `InspectQueryTooLongError` (HTTP 414 → message points at `load="full"`). Connector-level errors pass through unchanged. - -## 3. Module layout (target) - -``` -apps/inspect/ -├── __init__.py # public re-exports -├── inspect_app.py # InspectApp: composes read + write + actions mixins -├── inspect_api.py # raw API layer: all HTTP calls, typed responses -├── queries.py # scoped-query/projection builder + frozen projection constants -├── errors.py -├── app/ # user-facing method mixins (inventory-app pattern) -│ ├── read.py # get_snapshot, find_* helpers -│ ├── write.py # direct writes + transaction() -│ └── actions.py # add/sync devices, sync info -├── snapshot.py # InspectSnapshot: state, indexes, hydration, refresh hooks -├── transaction.py # InspectTransaction, staging entries, baselines, commit -├── domain/ # InspectDevice/Port/Edge/Service (extend existing) -└── model/ # InspectApi* DTOs (extend existing: collector.py, actions.py, - # update_topology.py, ngraph.py, common.py) -``` - -Follows the established package idioms: `*App` composed from mixins (like `InventoryApp`), `*API` raw layer, Pydantic DTOs under `model/`, snake_case fields with wire aliases. - -## 4. Implementation detail per component - -### 4.1 Connector changes (small, prerequisite) - -`connector/vip_rest_connector.py`: -- **POST allow-list**: add prefix `/rest/v2/actions/status/network/` (for `addDevices`/`syncDevices`). GET already covers `/rest/v2/data/status/`; POST already covers `…/actions/status/collector/`. -- **`/...` wildcard block**: GET currently raises on any `"/..."` in the path — scoped projections *require* `/.../` segments. Move this check behind the existing `node_check`/`url_validation` flags or add `allow_projection: bool = False` param that InspectAPI sets. Do not weaken behavior for existing callers. -- **`node_check=False`** for scoped reads (partial trees don't contain all envelope nodes). -- URL-encode query paths (space `%20`, `"` `%22`, parens, `=`) in `queries.py`, not in the connector. - -### 4.2 `queries.py` — the projection catalogue - -Single home for every verified query string (they are contract, not ad-hoc): -- `DEVICE_SKELETON` — trimmed msg-13 projection with `modules/"_noId"` (the full UI projection 414s; the trimmed variant is verified at ~8.6 KB/27 devices). -- `DEVICE_DETAIL(device_id)` — direct-id form `nodeStatus//…` with `modules/*` detail projection. -- `EDGE_SKELETON` — msg-14 lean projection; `EDGE_PAIR(pair_id)` — direct pair-key form. -- `PATHS_SECTION` — msg-12 serviceFields+path projection. -- Encoding helper `encode_query(path) -> str` + length guard raising `InspectQueryTooLongError` before sending (proxy limit). -Unit-test each constant against the fixtures (round-trip: query → fixture response parses into DTOs). - -### 4.3 `inspect_api.py` — raw layer (one method per endpoint) - -| Method | Endpoint | -| ------ | -------- | -| `get_collector_full()` | `GET …/status/collector/**` | -| `get_device_skeleton()` / `get_device_detail(id)` | scoped nodeStatus queries | -| `get_edge_skeleton()` / `get_edge_pair(pair_id)` | scoped externalEdgesByDeviceKey | -| `get_paths_section()` | scoped inspect/paths | -| `lookup_inspect_device(id)` | POST lookupInspectDevice | -| `lookup_vertices(ids)` | POST lookupInspectVertexByIds | -| `lookup_edges(ids)` | POST lookupInspectEdgesByIds | -| `lookup_sync_info(ids)` | POST lookupSyncInfo | -| `update_topology(delta)` | POST updateTopology | -| `add_devices(items)` / `sync_devices(req)` | POST network actions | -| `get_registered_actions()` | GET actions schema (version gating) | - -Each returns typed DTOs; each logs at DEBUG. No business logic here. - -### 4.4 DTO additions (`model/`) - -- `collector.py`: already covers nodeStatus/paths/edges — verify field coverage against the skeleton/detail fixtures; add missing fields (`relatedNodeTags`, `ptpDeviceStatus`, `tagsInfo`, `meta` extras) as **optional** so skeleton and full payloads parse with one model set (skeleton items simply have `modules={}` / fields absent). -- `actions.py`: add `InspectApiLookupEdgesByIdsRequest/Response(+Item)`, `InspectApiLookupVertexByIdRequest/Response`, `InspectApiVertexEditForm` (the `fields` object incl. `typeFields`), reuse existing lookup DTOs. -- `update_topology.py`: encode the **verified per-kind shapes** — `InspectApiDeviceEditForm` (== lookupInspectDevice.fields; used in `replaceDevices`), `InspectApiVertexEditForm` (`replaceVertices`), persisted edge model (`replaceEdges`, reuse/align with `ngraph.py` edge). `CommitResponse` with `items[]`, `res`, `validation{createIds, details, result}`. -- All DTOs: `model_config = ConfigDict(extra="allow")` on read models (unknown-field tolerance across server versions), strict on write payload models. - -### 4.5 `snapshot.py` rework (keep public getters, change internals) - -State per ADR-0007/0010: -- `_records`: skeleton-indexed devices/edges as today, plus `_hydration: dict[str, _EntityState]` where `_EntityState = (level: SKELETON|FULL, fetched_at: datetime)`; `_sections: dict[str, _SectionState]` (paths, maintenanceBookings…). -- Constructor takes `fetcher: InspectAPI | None` + parsed skeleton payloads. `from_full_response(response)` classmethod → everything marked FULL, fetcher optional (fixtures/offline: lazy loading inert; raise a clear error if an unloaded section is touched with no fetcher). -- `_ensure_device_detail(device_id)`: no-op if FULL; else `get_device_detail`, re-parse item, replace record, rebuild that device's port index entries, drop that device's cached domain wrappers, stamp `fetched_at`. Same pattern `_ensure_section("paths")`. -- **Concurrency**: a `threading.Lock` around merge operations (preload uses a pool); document snapshot as not-thread-safe for general use, but hydration merges are internally consistent. -- `preload(devices=None)`: ThreadPoolExecutor (bounded, e.g. 8) over `_ensure_device_detail` (ADR-0004). -- **Post-commit hooks** (called by transaction, ADR-0010): `_apply_removals(ids)`, `_refresh_devices(ids)`, `_refresh_edge_pairs(pair_ids)`, `_mark_section_stale("paths")`. -- Keep `raw_response` only for `load="full"` snapshots; skeleton snapshots expose `raw_skeleton` parts instead. - -### 4.6 Domain objects (`domain/`) - -Property → data-source mapping (drives which getter triggers hydration): - -| Property | Source | Hydrates? | -| -------- | ------ | --------- | -| `InspectDevice.id/label/pid/coordinates/status/sync_severity/tags/icon` | skeleton | no | -| `InspectDevice.ports`, `InspectPort.*` | device detail | yes (device) | -| `InspectDevice.edges`, `InspectEdge.*` | edge skeleton | no | -| `InspectDevice.services`, `InspectService.*`, `InspectEdge.services` | paths section | yes (section) | -| `InspectPort.edge` | edge skeleton index | no | - -Add `InspectDevice.is_hydrated` / `snapshot.fetched_at(...)` for introspection. Keep frozen-dataclass wrappers + snapshot back-reference pattern from the draft. - -### 4.7 `transaction.py` — transaction, baselines, commit - -- `_StagedEntry(kind: DEVICE|VERTEX|EDGE, id, baseline: DTO, intents: dict[field, value] | REMOVE)`. -- **Staging**: first touch of an entity fetches baselines via *batched* lookups (`lookup_edges`, `lookup_vertices` accept lists; device lookup is per-id). Staging validates the entity exists (translate lookup miss → `InspectEntityNotFoundError`); vertices/devices cannot be *created* (update-only — enforced client-side with a clear message). -- `connect(a_vertex, b_vertex, bidirectional=True, **edge_fields)`: builds one/two `replaceEdges` entries keyed `a::b` (and `b::a`) from a default edge template (capacity 65535, redundancyMode "Any", weightFactors default — the verified persisted-edge defaults); no baseline needed for *new* edge keys, but stage a lookup to detect "already exists" and require explicit `overwrite=True`. -- **Payload build**: intents applied onto baselines → per-kind write DTOs (devices/vertices: edit forms verbatim — **no** field mapping; edges: persisted form). Label/desc caveat: `descriptor` fields are only included in the diff-intents if the caller set them (never round-trip the effective label silently). -- **Commit sequence** (one method, well-logged): - 1. re-fetch baselines (batched), deep-compare vs staged baselines over editable fields (exclude volatile: statuses, `assignedTags.inherited`); mismatch → `InspectCommitConflictError` (all conflicts collected, nothing sent); - 2. POST `update_topology`; - 3. evaluate three-flag success; failure → `InspectCommitError` with typed validation details; - 4. targeted snapshot refresh (if the tx was created from a snapshot): removes applied locally, affected devices/pairs re-fetched, paths section marked stale. Affected-set derivation from staged keys + `items[]` using the id conventions (vertex id → owning device; edge key → pair id). -- `rebase()`: refresh baselines, keep intents, drop conflicts resolution to caller when intent-field itself conflicts. -- Transaction is single-use; `discard()` clears; context-manager exit without commit logs a warning (explicitly decided: **no auto-commit on exit** — commit must be visible in user code). - -### 4.8 `InspectApp` + package integration - -- `inspect_app.py`: composes mixins; ctor `(vip_connector, logger)` like the other apps. -- `videoipath_app.py`: add lazy `inspect` property + `self._inspect_api = self.inspect._inspect_api` in the DEV block; placeholder `self._inspect = None`. -- **Version gating**: on `InspectApp` init, check `get_server_version()` ≥ 2025.4 (first verified: 2025.4.9); below → log warning "Inspect API surface unverified for this server version". Optionally probe `get_registered_actions()` at DEBUG. -- `apps/inspect/__init__.py`: export `InspectApp`, domain classes, `InspectSnapshot`, errors, `ConflictStrategy`. - -## 5. Milestones (PR-sized, each independently shippable) - -1. **M1 – Connector + queries + API layer (read)**: connector changes, `queries.py`, `inspect_api.py` read methods, DTO gap-fill; offline fixture tests green. -2. **M2 – Snapshot rework + domain (read path complete)**: hydration states, sections, preload, `from_full_response`; `app.inspect.get_snapshot()`; VideoIPathApp property; unit tests with fake fetcher (hydration counts, merge idempotency). -3. **M3 – Lookups + actions**: lookup API methods + DTOs, `get_sync_info`, `add_devices_to_topology`, `sync_devices`. -4. **M4 – Transaction + writes**: `transaction.py`, direct write sugar, commit lifecycle incl. conflict check + targeted refresh; unit tests with mocked API (payload-shape assertions against the four updateTopology fixtures; conflict scenarios; refresh-hook calls). -5. **M5 – E2E suite** (below) + getting-started doc page (`docs/getting-started-guide/0X_Inspect.md`) + [models.md](./models.md)/README sync. - -## 6. Testing - -### 6.1 Offline (runs in CI, every push) - -- **Fixture contract tests** (`tests/inspect/test_models.py`): every fixture in `tests/inspect/fixtures/2025.4.9/` parses into its DTO; write-payload builders reproduce the fixture requests byte-for-byte (devices/vertices edit forms, edge form, no-op). -- **Snapshot unit tests** (`tests/inspect/test_snapshot.py`): fake fetcher returning fixture payloads — skeleton indexes, exactly-one hydration fetch per device, section laziness, preload fan-out, post-commit hooks (removal drops indexes; refresh replaces records; stale section reloads). -- **Transaction unit tests** (`tests/inspect/test_transaction.py`): staging→baseline capture, intent application, conflict detection (mutated baseline → error with field diffs), `check_conflicts=False` bypass, three-flag result evaluation incl. the captured failure fixtures, affected-set derivation. -- **Query tests**: encoding, 414 length guard, projection constants stability. - -### 6.2 E2E — live instance, topology replica (ADR-0005) - -Location `tests/e2e/inspect/`, pytest marker `e2e` (excluded by default via `addopts = -m "not e2e"`), gated on `VIPAT_E2E_ENABLED=1` in `tests/.env.test` **plus** a server-version allowlist check. Every created element uses label prefix `E2E-` (in both inventory and inspect) and the `vipat-e2e` tag. - -**Namespace & lifecycle:** cleanup runs **at startup** (`scenario.cleanup(app)`), not on teardown — so after a run the built topology **persists** in VideoIPath for manual inspection, and the next run starts from a clean namespace. Mutating tests revert their own changes, so the persisted end state is the complete topology. - -**Scenario: `LeafSpineScenario` (module `tests/e2e/inspect/scenario.py`)** — an anonymized replica of the local instance's topology, captured to `leaf_spine_topology.json` (indices/coordinates/link-pairs only; no real names/ids). Built with **virtual (mock-driver) devices only** via the real user flow: - -1. **Inventory** — `app.inventory.create_device(driver="com.nevion.mock-0.1.0")` with a router module sized to the device's degree (`num_router_ports`), then `add_device`. No hardware; the topology app is never used. -2. **Inspect** — `add_devices_to_topology` (placement at the captured coordinates), `update_device(label=…, tags=[…])` to set the E2E display label + tag, then `connect` each link (ports discovered from `device.ports`). - -`cleanup` removes edges, then the topology node (`remove_device_from_topology`), then the inventory entry — discovering E2E devices by label in **both** inventory and inspect (catches orphans from an aborted run). - -**Test list** (`test_e2e_leaf_spine.py`, session-scoped scenario fixture; all reads/writes via `app.inspect`): -1. `test_all_devices_present` — every scenario device appears with its `E2E-` label. -2. `test_skeleton_read_no_hydration` — scenario edge count == `expected_edge_count()`; **no** hydration fetches during the skeleton read (spy counter). -3. `test_lazy_hydration` — `get_device(id).ports` → exactly one detail fetch, cached thereafter; hydration state flips; mock devices expose router ports. -4. `test_connectivity_graph` — `linked_devices` of the busiest device == its fixture adjacency. -5. `test_edge_pair_refresh` — `update_edge(weight=…)`; edge survives targeted refresh without a full reload; revert. -6. `test_transaction_atomicity` — tx with a valid edge edit + a `remove` of a non-existent id → `InspectCommitError`, nothing applied. -7. `test_conflict_detection` — edit an edge; mutate it out-of-band (second `VideoIPathApp`); `commit()` → `InspectCommitConflictError`; `rebase()` + commit succeeds; revert. -8. `test_device_placement_roundtrip` — `place_device` new coordinates; verify; revert. -9. `test_disconnect_reconnect_cycle` — disconnect a link's directed edges, assert gone, reconnect, assert restored. -10. `test_full_vs_skeleton_equivalence` — `load="full"` and skeleton+preload resolve the same connectivity graph. -11. `test_state_persists` — after the suite the complete topology (all devices + edges) remains. - -Never touched: non-`E2E-` devices, bookings/services (read-only asserts only), profiles/security sections. - -## 7. Risks & mitigations - -- **Connector `/...`-block relaxation** could affect existing callers → new opt-in parameter only; existing default behavior unchanged; unit test both. -- **Skeleton projection length vs. proxies** (414 verified for full UI projection) → pre-flight length guard + documented `load="full"` fallback. -- **Version drift** (all verification on 2025.4.9) → version gate + `get_registered_actions()` probe; concepts.md upgrade checklist already mandates re-verification; read DTOs `extra="allow"`. -- **Draft-code compatibility**: `InspectSnapshot.from_response()` exists in the draft — keep as alias of `from_full_response` (nothing published depends on it yet; package unreleased for inspect, so breaking the draft internals is acceptable). -- **Shared-instance E2E safety** → tag/prefix discipline, env gating, teardown-finalizer, no writes outside `E2E-` namespace (test 4/6 operate on scenario-owned edges only). - -## 8. Verification of the implementation - -- `poetry run pytest` (offline suite) green in CI. -- `poetry run pytest -m e2e tests/e2e/inspect` against the local 2025.4.9 instance (`tests/.env.test`) — full scenario build → tests → teardown leaves the instance in its pre-run state (asserted by test 10). -- `poetry run ruff check src/ tests/` clean. -- Manual smoke in DEV env: `app._inspect_api` exposure, DEBUG logs show skeleton (2 GETs) then exactly one hydration GET on first `ports` access. diff --git a/src/videoipath_automation_tool/apps/inspect/api/inspect_api.py b/src/videoipath_automation_tool/apps/inspect/api/inspect_api.py index cd37920..97fe783 100644 --- a/src/videoipath_automation_tool/apps/inspect/api/inspect_api.py +++ b/src/videoipath_automation_tool/apps/inspect/api/inspect_api.py @@ -34,6 +34,10 @@ from videoipath_automation_tool.apps.inspect.model.common import ( InspectApiSimpleActionResponse, ) +from videoipath_automation_tool.apps.inspect.model.tags import ( + InspectApiAssignTagData, + InspectApiAssignTagRequest, +) from videoipath_automation_tool.apps.inspect.model.update_topology import ( InspectApiUpdateTopologyData, InspectApiUpdateTopologyRequest, @@ -144,6 +148,18 @@ def update_topology(self, delta: InspectApiUpdateTopologyData) -> InspectApiUpda response = self.vip_connector.rest.post("/rest/v2/actions/status/collector/updateTopology", request) return InspectApiUpdateTopologyResponse.model_validate(_post_envelope(response)) + def assign_tag(self, tag_id: str, element_ids: list[str]) -> InspectApiSimpleActionResponse: + """Bind ``tag_id`` to one or more resource ids (e.g. ``device:{modulePid}``).""" + request = InspectApiAssignTagRequest(data=InspectApiAssignTagData(tagId=tag_id, elementIds=element_ids)) + response = self.vip_connector.rest.post("/rest/v2/actions/status/tags/assignTag", request) + return _tag_action_response(response) + + def unassign_tag(self, tag_id: str, element_ids: list[str]) -> InspectApiSimpleActionResponse: + """Remove ``tag_id`` from one or more resource ids (e.g. ``device:{modulePid}``).""" + request = InspectApiAssignTagRequest(data=InspectApiAssignTagData(tagId=tag_id, elementIds=element_ids)) + response = self.vip_connector.rest.post("/rest/v2/actions/status/tags/unassignTag", request) + return _tag_action_response(response) + def add_devices(self, items: list[InspectApiAddDevicesItem]) -> InspectApiSimpleActionResponse: request = InspectApiAddDevicesRequest(data=items) response = self.vip_connector.rest.post("/rest/v2/actions/status/network/addDevices", request) @@ -209,4 +225,15 @@ def _post_envelope(response: Any) -> dict[str, Any]: return {"data": response.data, "header": _header_dict(response)} +def _tag_action_response(response: Any) -> InspectApiSimpleActionResponse: + """Normalize assignTag / unassignTag responses (server often returns ``data: null``).""" + header = _header_dict(response) + data = response.data + if not isinstance(data, dict): + data = {"ok": bool(header.get("ok")), "msg": list(header.get("msg") or [])} + elif "ok" not in data: + data = {**data, "ok": bool(header.get("ok")), "msg": list(data.get("msg") or header.get("msg") or [])} + return InspectApiSimpleActionResponse.model_validate({"data": data, "header": header}) + + __all__ = ["InspectAPI"] diff --git a/src/videoipath_automation_tool/apps/inspect/api/queries.py b/src/videoipath_automation_tool/apps/inspect/api/queries.py index 6fa0fc9..757d1a4 100644 --- a/src/videoipath_automation_tool/apps/inspect/api/queries.py +++ b/src/videoipath_automation_tool/apps/inspect/api/queries.py @@ -3,7 +3,7 @@ Every query string here is **verified live against VideoIPath 2025.4.9** and is the concrete basis for the skeleton-first + lazy-hydration loading model ([ADR-0007]). The Inspect UI issues the same paths over WebSocket subscriptions; the package -issues them as REST GETs (see docs/inspect-app/endpoints.md). +issues them as REST GETs (see docs/architecture/inspect-app/endpoints.md). Projection grammar used below (verified): - ``*`` select all items of a collection diff --git a/src/videoipath_automation_tool/apps/inspect/app/write.py b/src/videoipath_automation_tool/apps/inspect/app/write.py index 77e9e73..c724b2f 100644 --- a/src/videoipath_automation_tool/apps/inspect/app/write.py +++ b/src/videoipath_automation_tool/apps/inspect/app/write.py @@ -4,6 +4,10 @@ transaction and commit it immediately. For batched, atomic changes use ``transaction()`` as a context manager and call ``commit()`` explicitly. +Domain objects also support a unit-of-work pattern: mutate attributes via setters (pending edits +stage on the snapshot), then call ``update(device)`` / ``update(vertex)`` / ``update(edge)`` to +flush them through a transaction (optionally appending to an existing ``tx``). + Every write is bound to the app's internal snapshot: on a successful commit the touched entities are refreshed in place ([ADR-0010]) — but only if the snapshot has already been loaded, so a pure-write workflow never triggers an unnecessary topology read. @@ -12,7 +16,7 @@ from __future__ import annotations import logging -from typing import TYPE_CHECKING, Any, Optional, Protocol +from typing import TYPE_CHECKING, Any, Optional, Protocol, Sequence, Union from videoipath_automation_tool.apps.inspect.transaction import CommitResult, InspectTransaction from videoipath_automation_tool.apps.inspect.api import InspectAPI @@ -25,8 +29,14 @@ ) if TYPE_CHECKING: + from videoipath_automation_tool.apps.inspect.domain.device import InspectDevice + from videoipath_automation_tool.apps.inspect.domain.edge import InspectEdge + from videoipath_automation_tool.apps.inspect.domain.module import InspectModule + from videoipath_automation_tool.apps.inspect.domain.vertex import InspectVertex from videoipath_automation_tool.apps.inspect.snapshot import InspectSnapshot +Editable = Union["InspectDevice", "InspectVertex", "InspectEdge", "InspectModule"] + class _HasInspectState(Protocol): _inspect_api: InspectAPI @@ -43,6 +53,56 @@ def transaction(self: _HasInspectState) -> InspectTransaction: """Open a batched, atomic transaction bound to the app's internal snapshot.""" return InspectTransaction(self._inspect_api, snapshot=self._snapshot, logger=self._logger) + def update( + self: _HasInspectState, + obj: Editable | Sequence[Editable], + tx: Optional[InspectTransaction] = None, + ) -> CommitResult | InspectTransaction: + """Flush pending domain-object edits through a transaction. + + Accepts an :class:`InspectDevice`, :class:`InspectVertex`, :class:`InspectEdge`, + :class:`InspectModule`, or a sequence of them. For a device, also cascades every dirty + vertex/edge/module whose id belongs to that device (unit of work). + + Args: + obj: Domain object(s) with pending setter edits. + tx: Optional open transaction to append into. When ``None``, a new transaction is opened + and committed; the return value is a :class:`CommitResult`. When given, edits are + staged into ``tx`` and ``tx`` is returned (caller commits). + + Returns: + ``CommitResult`` when ``tx is None``; otherwise the supplied ``tx``. + """ + objects = list(obj) if isinstance(obj, Sequence) and not _is_single_editable(obj) else [obj] # type: ignore[list-item] + if not objects: + raise ValueError("Nothing to update.") + if self._snapshot is None: + raise RuntimeError( + "Inspect snapshot is not available; load the topology (e.g. access app.inspect.devices) " + "before updating domain objects." + ) + + owns_tx = tx is None + txn = self.transaction() if owns_tx else tx + assert txn is not None + + flushed_keys: list[tuple[str, str]] = [] + for item in objects: + flushed_keys.extend(_stage_editable(txn, self._snapshot, item)) + + if owns_tx: + if not flushed_keys and len(txn) == 0: + raise ValueError("No pending edits to flush.") + result = txn.commit() + for kind, entity_id in flushed_keys: + self._snapshot.clear_staged(kind=kind, entity_id=entity_id) + return result + + # When appending to a caller-owned transaction, no-op if there was nothing staged. + for kind, entity_id in flushed_keys: + self._snapshot.clear_staged(kind=kind, entity_id=entity_id) + return txn + def place_device(self: _HasInspectState, device_id: str, x: float, y: float) -> CommitResult: """Move a device to grid coordinates (single auto-committed change).""" with self.transaction() as tx: @@ -60,6 +120,7 @@ def update_device( sdp_strategy: Optional[InspectSdpStrategy | str] = None, site_id: Optional[str] = None, tags: Optional[list[str]] = None, + local_assigned_tags: Optional[list[str]] = None, coordinates: Optional[dict[str, float]] = None, ) -> CommitResult: """Edit a device's "Edit Device" dialog fields (single auto-committed change).""" @@ -73,10 +134,31 @@ def update_device( sdp_strategy=sdp_strategy, site_id=site_id, tags=tags, + local_assigned_tags=local_assigned_tags, coordinates=coordinates, ) return tx.commit() + def update_module( + self: _HasInspectState, + module_id: str, + *, + tags: Optional[list[str]] = None, + ) -> CommitResult: + """Edit a module's locally assigned tags (single auto-committed change). + + Diffs the desired list against the current local tags and calls ``assignTag`` / + ``unassignTag``. Requires a loaded inspect snapshot (module detail is hydrated on demand). + """ + if self._snapshot is None: + raise RuntimeError( + "Inspect snapshot is not available; load the topology (e.g. access app.inspect.devices) " + "before updating modules." + ) + with self.transaction() as tx: + tx.update_module(module_id, tags=tags) + return tx.commit() + def update_vertex( self: _HasInspectState, vertex_id: str, @@ -84,12 +166,16 @@ def update_vertex( use_as_endpoint: Optional[bool] = None, label: Optional[str] = None, tags: Optional[list[str]] = None, + form_tags: Optional[list[str]] = None, description: Optional[str] = None, active: Optional[bool] = None, sips_mode: Optional[str] = None, + control: Optional[str] = None, control_props: Optional[Any] = None, extra_alert_filters: Optional[list[Any]] = None, custom: Optional[dict[str, Any]] = None, + queueable: Optional[bool] = None, + destination_monitor_leader: Optional[bool] = None, park_port: Optional[int] = None, ip_address: Optional[str] = None, ip_netmask: Optional[str] = None, @@ -104,6 +190,22 @@ def update_vertex( supports_static_igmp: Optional[bool] = None, supports_vlan: Optional[bool] = None, supports_vpls: Optional[bool] = None, + sdp_support: Optional[bool] = None, + is_igmp_source: Optional[bool] = None, + specific_type: Optional[str] = None, + codec_format: Optional[str] = None, + multiplicity: Optional[int] = None, + codec_public: Optional[bool] = None, + extra_formats: Optional[list[Any]] = None, + bidir_partner_id: Optional[str] = None, + partner_config: Optional[Any] = None, + service_id: Optional[Any] = None, + main_src_info: Optional[dict[str, Any]] = None, + main_dst_info: Optional[dict[str, Any]] = None, + spare_src_info: Optional[dict[str, Any]] = None, + spare_dst_info: Optional[dict[str, Any]] = None, + main_destination_port: Optional[int] = None, + spare_destination_port: Optional[int] = None, ) -> CommitResult: """Edit a vertex (single auto-committed change; update-only).""" with self.transaction() as tx: @@ -112,12 +214,16 @@ def update_vertex( use_as_endpoint=use_as_endpoint, label=label, tags=tags, + form_tags=form_tags, description=description, active=active, sips_mode=sips_mode, + control=control, control_props=control_props, extra_alert_filters=extra_alert_filters, custom=custom, + queueable=queueable, + destination_monitor_leader=destination_monitor_leader, park_port=park_port, ip_address=ip_address, ip_netmask=ip_netmask, @@ -132,6 +238,22 @@ def update_vertex( supports_static_igmp=supports_static_igmp, supports_vlan=supports_vlan, supports_vpls=supports_vpls, + sdp_support=sdp_support, + is_igmp_source=is_igmp_source, + specific_type=specific_type, + codec_format=codec_format, + multiplicity=multiplicity, + codec_public=codec_public, + extra_formats=extra_formats, + bidir_partner_id=bidir_partner_id, + partner_config=partner_config, + service_id=service_id, + main_src_info=main_src_info, + main_dst_info=main_dst_info, + spare_src_info=spare_src_info, + spare_dst_info=spare_dst_info, + main_destination_port=main_destination_port, + spare_destination_port=spare_destination_port, ) return tx.commit() @@ -214,4 +336,70 @@ def remove_device_from_topology(self: _HasInspectState, device_id: str) -> Commi return tx.commit() +def _is_single_editable(obj: Any) -> bool: + from videoipath_automation_tool.apps.inspect.domain.device import InspectDevice + from videoipath_automation_tool.apps.inspect.domain.edge import InspectEdge + from videoipath_automation_tool.apps.inspect.domain.module import InspectModule + from videoipath_automation_tool.apps.inspect.domain.vertex import InspectVertex + + return isinstance(obj, (InspectDevice, InspectVertex, InspectEdge, InspectModule)) + + +def _stage_editable( + tx: InspectTransaction, + snapshot: "InspectSnapshot", + obj: Editable, +) -> list[tuple[str, str]]: + """Stage pending edits for ``obj`` (and cascade children for a device). Returns flushed keys.""" + from videoipath_automation_tool.apps.inspect.domain.device import InspectDevice + from videoipath_automation_tool.apps.inspect.domain.edge import InspectEdge + from videoipath_automation_tool.apps.inspect.domain.module import InspectModule + from videoipath_automation_tool.apps.inspect.domain.vertex import InspectVertex + from videoipath_automation_tool.apps.inspect.transaction import _device_of + + flushed: list[tuple[str, str]] = [] + + if isinstance(obj, InspectDevice): + device_edits = snapshot.get_staged_edits("device", obj.id) + if device_edits: + tx.update_device(obj.id, intents=device_edits) + flushed.append(("device", obj.id)) + for kind, entity_id, intents in snapshot.iter_staged_edits(): + if kind == "vertex" and _device_of(entity_id) == obj.id and intents: + tx.update_vertex(entity_id, intents=intents) + flushed.append((kind, entity_id)) + elif kind == "module" and _device_of(entity_id) == obj.id and intents: + tx.update_module(entity_id, intents=intents) + flushed.append((kind, entity_id)) + elif kind == "edge" and intents: + from_id, _, to_id = entity_id.partition("::") + if _device_of(from_id) == obj.id or _device_of(to_id) == obj.id: + tx.update_edge(entity_id, intents=intents) + flushed.append((kind, entity_id)) + return flushed + + if isinstance(obj, InspectVertex): + edits = snapshot.get_staged_edits("vertex", obj.id) + if edits: + tx.update_vertex(obj.id, intents=edits) + flushed.append(("vertex", obj.id)) + return flushed + + if isinstance(obj, InspectEdge): + edits = snapshot.get_staged_edits("edge", obj.id) + if edits: + tx.update_edge(obj.id, intents=edits) + flushed.append(("edge", obj.id)) + return flushed + + if isinstance(obj, InspectModule): + edits = snapshot.get_staged_edits("module", obj.id) + if edits: + tx.update_module(obj.id, intents=edits) + flushed.append(("module", obj.id)) + return flushed + + raise TypeError(f"Unsupported update target: {type(obj)!r}") + + __all__ = ["InspectWriteMixin"] diff --git a/src/videoipath_automation_tool/apps/inspect/domain/device.py b/src/videoipath_automation_tool/apps/inspect/domain/device.py index ad0b6f7..8a89401 100644 --- a/src/videoipath_automation_tool/apps/inspect/domain/device.py +++ b/src/videoipath_automation_tool/apps/inspect/domain/device.py @@ -1,7 +1,7 @@ from __future__ import annotations from datetime import datetime -from typing import TYPE_CHECKING, Self +from typing import TYPE_CHECKING, Any, Literal, Self from pydantic import Field @@ -9,7 +9,7 @@ from videoipath_automation_tool.apps.inspect.domain.port import PortFromTemplate from videoipath_automation_tool.apps.inspect.model.common import ( InspectApiStatusSummary, - InspectFrozenModel, + InspectEditableModel, InspectIconSize, InspectIconType, InspectInternalModel, @@ -30,68 +30,47 @@ from videoipath_automation_tool.apps.inspect.snapshot import _DeviceRecord -class VirtualDeviceSpec(InspectInternalModel): - """Declarative definition of a virtual device, matching the Create Virtual Devices dialog. - - Mutable so fluent helpers (``add_module`` / ``add_port``) can mirror the UI workflow. - """ - - modules: list[VirtualModuleSpec] = Field(default_factory=lambda: [VirtualModuleSpec()]) - - @classmethod - def empty(cls) -> VirtualDeviceSpec: - """One empty module, as in the UI default.""" - return cls(modules=[VirtualModuleSpec()]) - - def add_module(self) -> Self: - """Append an empty module (UI: + Add module).""" - self.modules.append(VirtualModuleSpec()) - return self - - def add_port(self, template_id: str, count: int = 1, *, module_index: int = -1) -> Self: - """Add a port from a template to a module (UI: Add port).""" - if not self.modules: - self.modules.append(VirtualModuleSpec()) - idx = module_index if module_index >= 0 else len(self.modules) + module_index - if idx < 0 or idx >= len(self.modules): - raise IndexError(f"module_index {module_index} is out of range for {len(self.modules)} module(s).") - self.modules[idx].ports.append(PortFromTemplate(template_id=template_id, count=count)) - return self - - def to_wire(self) -> InspectApiVirtualDeviceWriteBody: - return InspectApiVirtualDeviceWriteBody(modules=[module.to_wire() for module in self.modules]) - - @classmethod - def from_ports(cls, *ports: PortFromTemplate | tuple[str, int] | str) -> VirtualDeviceSpec: - """Build a single-module device from port specs.""" - resolved: list[PortFromTemplate] = [] - for port in ports: - if isinstance(port, PortFromTemplate): - resolved.append(port) - elif isinstance(port, str): - resolved.append(PortFromTemplate(template_id=port)) - else: - template_id, count = port - resolved.append(PortFromTemplate(template_id=template_id, count=count)) - return cls(modules=[VirtualModuleSpec(ports=resolved)]) - - -class InspectDevice(InspectFrozenModel): +class InspectDevice(InspectEditableModel): """A topology device/node. Skeleton fields (id, label, coordinates, status, sync, tags) are available immediately; ``ports`` and ``services`` lazily hydrate from the server on first access ([ADR-0007]). The record is resolved live from the snapshot, so a held reference sees - hydrated/refreshed data transparently.""" + hydrated/refreshed data transparently. + + Editable attributes use property setters that stage pending intents on the snapshot + (read-your-writes). Flush with ``app.inspect.update(device)``. + """ snapshot: InspectSnapshot id: str + @property + def _edit_kind(self) -> Literal["device"]: + return "device" + @property def label(self) -> str | None: - return self._record().label + return self._staged_or("descriptor.label", lambda: self._record().label) + + @label.setter + def label(self, value: str) -> None: + self._stage("descriptor.label", value) @property def description(self) -> str | None: - return self._record().node.effective_description + return self._staged_or( + "descriptor.desc", + lambda: self._record().node.effective_description, + ) + + @description.setter + def description(self, value: str) -> None: + self._stage("descriptor.desc", value) + + @property + def factory_label(self) -> str | None: + """Device-reported factory label (``fDescriptor.label`` / collector ``label``).""" + node = self._record().node + return node.label @property def pid(self) -> str | None: @@ -111,28 +90,40 @@ def is_virtual(self) -> bool | None: @property def icon_type(self) -> InspectIconType | str | None: - meta = self._record().node.meta - return meta.iconType if meta is not None else None + return self._staged_or("iconType", lambda: self._meta_get("iconType")) + + @icon_type.setter + def icon_type(self, value: InspectIconType | str) -> None: + self._stage("iconType", value) @property def icon_size(self) -> InspectIconSize | str | None: """Device icon size ("Device icon size" in the UI): ``"auto"`` / ``"large"`` / ``"medium"`` / ``"small"``.""" - meta = self._record().node.meta - return meta.iconSize if meta is not None else None + return self._staged_or("iconSize", lambda: self._meta_get("iconSize")) + + @icon_size.setter + def icon_size(self, value: InspectIconSize | str) -> None: + self._stage("iconSize", value) @property def sdp_strategy(self) -> InspectSdpStrategy | str | None: """SDP polling strategy ("SDP polling strategy" in the UI): ``"always"`` (Continuous) / ``"once"`` (Fetch and Confirm) / ``"video"`` (Continuous Video, Confirm Others).""" - meta = self._record().node.meta - return meta.sdpStrategy if meta is not None else None + return self._staged_or("sdpStrategy", lambda: self._meta_get("sdpStrategy")) + + @sdp_strategy.setter + def sdp_strategy(self, value: InspectSdpStrategy | str) -> None: + self._stage("sdpStrategy", value) @property def site_id(self) -> str | None: """The id of the site this device is located at ("Site ID" in the UI).""" - meta = self._record().node.meta - return meta.siteId if meta is not None else None + return self._staged_or("siteId", lambda: self._meta_get("siteId")) + + @site_id.setter + def site_id(self, value: str) -> None: + self._stage("siteId", value) @property def status(self) -> InspectApiStatusSummary | None: @@ -143,12 +134,33 @@ def sync_severity(self) -> int | str | None: return self._record().node.syncSeverity @property - def tags(self) -> tuple[str, ...]: - return tuple(self._record().node.tags) + def tags(self) -> list[str]: + return self._staged_or("tags", lambda: list(self._record().node.tags), adapt=list) + + @tags.setter + def tags(self, value: list[str] | tuple[str, ...]) -> None: + self._stage("tags", list(value)) + + @property + def local_assigned_tags(self) -> list[str]: + """Device ``localAssignedTags`` (distinct from collector ``tags`` when both are present).""" + return self._staged_or("localAssignedTags", lambda: [], adapt=list) + + @local_assigned_tags.setter + def local_assigned_tags(self, value: list[str]) -> None: + self._stage("localAssignedTags", list(value)) @property def coordinates(self) -> dict[str, float | int | str | None] | None: - return self._record().node.coordinates + return self._staged_or( + "coordinates", + lambda: self._record().node.coordinates, + adapt=lambda value: dict(value) if value is not None else None, + ) + + @coordinates.setter + def coordinates(self, value: dict[str, float | int] | None) -> None: + self._stage("coordinates", dict(value) if value is not None else None) @property def is_hydrated(self) -> bool: @@ -172,6 +184,70 @@ def ports(self) -> list[InspectPort]: module → port grouping.""" return self.snapshot.get_ports_for_device(self.id) + @property + def codec_vertices(self) -> list[InspectVertex]: + """All codec vertices across this device's ports (hydrates + batches vertex lookups).""" + return self._vertices_of_kind("codec") + + @property + def ip_vertices(self) -> list[InspectVertex]: + """All IP vertices across this device's ports (hydrates + batches vertex lookups).""" + return self._vertices_of_kind("ip") + + @property + def generic_vertices(self) -> list[InspectVertex]: + """All generic vertices across this device's ports (hydrates + batches vertex lookups).""" + return self._vertices_of_kind("generic") + + def find_vertex_by_factory_label( + self, + label: str, + *, + kind: InspectVertexKind | str | None = None, + vertex_type: InspectVertexType | str | None = None, + ) -> InspectVertex | None: + """First vertex whose owning port's factory label equals ``label`` (optionally filtered).""" + for vertex in self._all_vertices(): + if vertex.factory_label != label: + continue + if kind is not None and vertex.vertex_kind != kind: + continue + if vertex_type is not None and vertex.vertex_type != vertex_type: + continue + return vertex + return None + + def get_vertices_by_module_label( + self, + module_label: str, + *, + kind: InspectVertexKind | str | None = None, + ) -> list[InspectVertex]: + """Vertices belonging to modules whose label equals ``module_label`` (e.g. ``Slot 3``).""" + module_ids = {m.id for m in self.modules if m.label == module_label} + if not module_ids: + return [] + result: list[InspectVertex] = [] + for port in self.ports: + if port.indexed.module_id not in module_ids: + continue + for vertex in port._vertices(): + if kind is not None and vertex.vertex_kind != kind: + continue + result.append(vertex) + return result + + def get_vertex_by_id(self, vertex_id: str) -> InspectVertex | None: + """A vertex of this device by id, or ``None`` if it is not on any port.""" + for vertex in self._all_vertices(): + if vertex.id == vertex_id: + return vertex + if vertex_id.startswith(self.id + ".") or ( + self.id.startswith("virtual.") and vertex_id.startswith(self.id + ".") + ): + return self.snapshot.get_vertex(vertex_id) + return None + def filter_ports( self, *, @@ -237,6 +313,67 @@ def _record(self) -> "_DeviceRecord": raise KeyError(f"Device '{self.id}' is no longer present in the snapshot.") return record + def _meta_get(self, attr: str, default: Any = None) -> Any: + meta = self._record().node.meta + return getattr(meta, attr, default) if meta is not None else default + + def _all_vertices(self) -> list[InspectVertex]: + sides = [side for port in self.ports for side in port._vertex_sides()] + self.snapshot.get_vertex_details_many([vid for vid, _ in sides]) + result: list[InspectVertex] = [] + for port in self.ports: + result.extend(port._vertices()) + return result + + def _vertices_of_kind(self, kind: str) -> list[InspectVertex]: + return [v for v in self._all_vertices() if v.vertex_kind == kind] + + +class VirtualDeviceSpec(InspectInternalModel): + """Declarative definition of a virtual device, matching the Create Virtual Devices dialog. + + Mutable so fluent helpers (``add_module`` / ``add_port``) can mirror the UI workflow. + """ + + modules: list[VirtualModuleSpec] = Field(default_factory=lambda: [VirtualModuleSpec()]) + + @classmethod + def empty(cls) -> VirtualDeviceSpec: + """One empty module, as in the UI default.""" + return cls(modules=[VirtualModuleSpec()]) + + def add_module(self) -> Self: + """Append an empty module (UI: + Add module).""" + self.modules.append(VirtualModuleSpec()) + return self + + def add_port(self, template_id: str, count: int = 1, *, module_index: int = -1) -> Self: + """Add a port from a template to a module (UI: Add port).""" + if not self.modules: + self.modules.append(VirtualModuleSpec()) + idx = module_index if module_index >= 0 else len(self.modules) + module_index + if idx < 0 or idx >= len(self.modules): + raise IndexError(f"module_index {module_index} is out of range for {len(self.modules)} module(s).") + self.modules[idx].ports.append(PortFromTemplate(template_id=template_id, count=count)) + return self + + def to_wire(self) -> InspectApiVirtualDeviceWriteBody: + return InspectApiVirtualDeviceWriteBody(modules=[module.to_wire() for module in self.modules]) + + @classmethod + def from_ports(cls, *ports: PortFromTemplate | tuple[str, int] | str) -> VirtualDeviceSpec: + """Build a single-module device from port specs.""" + resolved: list[PortFromTemplate] = [] + for port in ports: + if isinstance(port, PortFromTemplate): + resolved.append(port) + elif isinstance(port, str): + resolved.append(PortFromTemplate(template_id=port)) + else: + template_id, count = port + resolved.append(PortFromTemplate(template_id=template_id, count=count)) + return cls(modules=[VirtualModuleSpec(ports=resolved)]) + # --- Internal --- diff --git a/src/videoipath_automation_tool/apps/inspect/domain/edge.py b/src/videoipath_automation_tool/apps/inspect/domain/edge.py index 3a45f3e..4295992 100644 --- a/src/videoipath_automation_tool/apps/inspect/domain/edge.py +++ b/src/videoipath_automation_tool/apps/inspect/domain/edge.py @@ -1,12 +1,13 @@ from __future__ import annotations -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Any, Literal from videoipath_automation_tool.apps.inspect.model.collector import InspectApiExternalEdgeLiveStatus from videoipath_automation_tool.apps.inspect.model.common import ( CONFLICT_PRIORITY_BY_INT, + CONFLICT_PRIORITY_TO_INT, InspectConfigPriority, - InspectFrozenModel, + InspectEditableModel, InspectRedundancyMode, ) from videoipath_automation_tool.apps.inspect.snapshot import InspectSnapshot, _IndexedEdge @@ -18,10 +19,18 @@ from videoipath_automation_tool.apps.inspect.model.actions import InspectApiEdgeForm -class InspectEdge(InspectFrozenModel): +class InspectEdge(InspectEditableModel): + """A directed external edge. Live status fields come from the collector skeleton; Edit Edge + dialog fields resolve from the lazily-fetched edit form, with pending setter edits taking + precedence (read-your-writes). Flush with ``app.inspect.update(edge)``.""" + snapshot: InspectSnapshot indexed: _IndexedEdge + @property + def _edit_kind(self) -> Literal["edge"]: + return "edge" + @property def id(self) -> str: return self.indexed.edge_id @@ -56,6 +65,7 @@ def to_port(self) -> InspectPort | None: @property def bandwidth(self) -> float | int | None: + """Live bandwidth status. For the configured capacity use :attr:`bandwidth_capacity`.""" return self.indexed.edge.bandwidth @property @@ -87,84 +97,176 @@ def services(self) -> list[InspectService]: @property def label(self) -> str | None: """Manual edge label ("Label" in the Edit Edge dialog).""" - form = self._edit_form() - return form.descriptor.label if form else None + return self._staged_or( + "descriptor.label", + lambda: f.descriptor.label if (f := self._edit_form()) else None, + ) + + @label.setter + def label(self, value: str) -> None: + self._stage("descriptor.label", value) @property def description(self) -> str | None: """Edge description ("Description" in the Edit Edge dialog).""" - form = self._edit_form() - return form.descriptor.desc if form else None + return self._staged_or( + "descriptor.desc", + lambda: f.descriptor.desc if (f := self._edit_form()) else None, + ) + + @description.setter + def description(self, value: str) -> None: + self._stage("descriptor.desc", value) @property def tags(self) -> list[str]: - form = self._edit_form() - return list(form.tags) if form else [] + return self._staged_or("tags", lambda: list(self._form_get("tags") or []), adapt=list) + + @tags.setter + def tags(self, value: list[str]) -> None: + self._stage("tags", list(value)) @property def active(self) -> bool | None: - form = self._edit_form() - return form.active if form else None + return self._staged_or("active", lambda: self._form_get("active")) + + @active.setter + def active(self, value: bool) -> None: + self._stage("active", value) @property def include_formats(self) -> list[str]: - form = self._edit_form() - return list(form.includeFormats) if form else [] + return self._staged_or( + "includeFormats", + lambda: list(self._form_get("includeFormats") or []), + adapt=list, + ) + + @include_formats.setter + def include_formats(self, value: list[str]) -> None: + self._stage("includeFormats", list(value)) @property def exclude_formats(self) -> list[str]: - form = self._edit_form() - return list(form.excludeFormats) if form else [] + return self._staged_or( + "excludeFormats", + lambda: list(self._form_get("excludeFormats") or []), + adapt=list, + ) + + @exclude_formats.setter + def exclude_formats(self, value: list[str]) -> None: + self._stage("excludeFormats", list(value)) @property def conflict_priority(self) -> InspectConfigPriority | int | str | None: """Conflict priority ("Conflict priority" in the UI): ``"off"`` / ``"high"`` / ``"normal"`` / ``"low"`` (mapped from the on-wire int), or the raw value if unrecognized.""" - form = self._edit_form() - if form is None: - return None - raw = form.conflictPri - return CONFLICT_PRIORITY_BY_INT.get(raw, raw) if isinstance(raw, int) else raw + return self._staged_or( + "conflictPri", + lambda: self._map_conflict_priority(self._form_get("conflictPri")), + adapt=self._map_conflict_priority, + ) + + @conflict_priority.setter + def conflict_priority(self, value: InspectConfigPriority | int | str) -> None: + wire = CONFLICT_PRIORITY_TO_INT.get(value, value) if isinstance(value, str) else value + self._stage("conflictPri", wire) @property def redundancy_mode(self) -> InspectRedundancyMode | str | None: - form = self._edit_form() - return form.redundancyMode if form else None + return self._staged_or("redundancyMode", lambda: self._form_get("redundancyMode")) + + @redundancy_mode.setter + def redundancy_mode(self, value: InspectRedundancyMode | str) -> None: + self._stage("redundancyMode", value) @property def fixed_weight(self) -> int | None: """Fixed routing weight/cost ("Fixed weight" in the UI).""" - form = self._edit_form() - return form.weight if form else None + return self._staged_or("weight", lambda: self._form_get("weight")) + + @fixed_weight.setter + def fixed_weight(self, value: int) -> None: + self._stage("weight", value) + + @property + def weight(self) -> int | None: + return self.fixed_weight + + @weight.setter + def weight(self, value: int) -> None: + self.fixed_weight = value @property def bandwidth_capacity(self) -> float | int | None: """Configured max bandwidth in Mbit/s ("Bandwidth capacity" in the UI); ``-1.0`` = disabled. - Distinct from :attr:`bandwidth`, which is the live status value.""" - form = self._edit_form() - return form.bandwidth if form else None + Distinct from the live status bandwidth when no edit is staged.""" + return self._staged_or("bandwidth", lambda: self._form_get("bandwidth")) + + @bandwidth_capacity.setter + def bandwidth_capacity(self, value: float | int) -> None: + self._stage("bandwidth", value) @property def services_capacity(self) -> int | None: """Max number of simultaneous services ("Services capacity" in the UI); ``65535`` = unlimited.""" - form = self._edit_form() - return form.capacity if form else None + return self._staged_or("capacity", lambda: self._form_get("capacity")) + + @services_capacity.setter + def services_capacity(self, value: int) -> None: + self._stage("capacity", value) + + @property + def capacity(self) -> int | None: + return self.services_capacity + + @capacity.setter + def capacity(self, value: int) -> None: + self.services_capacity = value @property def bandwidth_weight_factor(self) -> int | None: """Bandwidth-based weight factor ("Bandwidth weight factor" in the UI).""" - form = self._edit_form() - if form is None: - return None - return (form.weightFactors.get("bandwidth") or {}).get("weight") + return self._staged_or( + "weightFactors.bandwidth.weight", + lambda: self._weight_factor("bandwidth"), + ) + + @bandwidth_weight_factor.setter + def bandwidth_weight_factor(self, value: int) -> None: + self._stage("weightFactors.bandwidth.weight", value) @property def weight_per_service(self) -> int | None: """Service-based weight factor ("Weight per service" in the UI).""" + return self._staged_or( + "weightFactors.service.weight", + lambda: self._weight_factor("service"), + ) + + @weight_per_service.setter + def weight_per_service(self, value: int) -> None: + self._stage("weightFactors.service.weight", value) + + def _edit_form(self) -> InspectApiEdgeForm | None: + return self.snapshot.get_edge_details(self.id) + + def _form_get(self, attr: str, default: Any = None) -> Any: + form = self._edit_form() + return getattr(form, attr, default) if form is not None else default + + def _weight_factor(self, key: str) -> int | None: form = self._edit_form() if form is None: return None - return (form.weightFactors.get("service") or {}).get("weight") + return (form.weightFactors.get(key) or {}).get("weight") - def _edit_form(self) -> InspectApiEdgeForm | None: - return self.snapshot.get_edge_details(self.id) + @staticmethod + def _map_conflict_priority(raw: Any) -> InspectConfigPriority | int | str | None: + if raw is None: + return None + return CONFLICT_PRIORITY_BY_INT.get(raw, raw) if isinstance(raw, int) else raw + + +__all__ = ["InspectEdge"] diff --git a/src/videoipath_automation_tool/apps/inspect/domain/module.py b/src/videoipath_automation_tool/apps/inspect/domain/module.py index e5cb22b..2282fef 100644 --- a/src/videoipath_automation_tool/apps/inspect/domain/module.py +++ b/src/videoipath_automation_tool/apps/inspect/domain/module.py @@ -1,17 +1,17 @@ from __future__ import annotations -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Literal from pydantic import Field from videoipath_automation_tool.apps.inspect.domain.port import PortFromTemplate from videoipath_automation_tool.apps.inspect.model.common import ( InspectApiStatusSummary, - InspectFrozenModel, + InspectEditableModel, InspectInternalModel, ) from videoipath_automation_tool.apps.inspect.model.virtual import InspectApiVirtualModule -from videoipath_automation_tool.apps.inspect.snapshot import InspectSnapshot +from videoipath_automation_tool.apps.inspect.snapshot import InspectSnapshot, _STAGED_MISSING if TYPE_CHECKING: from videoipath_automation_tool.apps.inspect.domain.device import InspectDevice @@ -33,19 +33,27 @@ def to_wire(self) -> InspectApiVirtualModule: ) -class InspectModule(InspectFrozenModel): +class InspectModule(InspectEditableModel): """A device module / slot. A module owns many ports, each of which carries one or more vertices, so a module holds many vertices in total. The module status is resolved live from the snapshot, so a held reference sees hydrated/refreshed data transparently. Prefer :attr:`id` and :attr:`device` (``module.device.id``) over the constructor fields ``module_id`` / ``device_id``. + + Editable attributes use property setters that stage pending intents on the snapshot + (read-your-writes). Flush with ``app.inspect.update(module)`` or ``app.inspect.update(device)``. + Module tags are committed via ``assignTag`` / ``unassignTag`` (not ``updateTopology``). """ snapshot: InspectSnapshot device_id: str module_id: str + @property + def _edit_kind(self) -> Literal["module"]: + return "module" + @property def id(self) -> str: return self.module_id @@ -68,9 +76,19 @@ def status(self) -> InspectApiStatusSummary | None: @property def tags(self) -> list[str]: - """Tags assigned to this module (from ``tagsInfo.assigned.all``).""" + """Locally assigned module tags (``tagsInfo.assigned.local``; writable via assign/unassign).""" + staged = self._staged("tags") + if staged is not _STAGED_MISSING: + return list(staged) status = self._status() - return status.assigned_tags if status is not None else [] + if status is None: + return [] + local = status.local_assigned_tags + return list(local) if local else list(status.assigned_tags) + + @tags.setter + def tags(self, value: list[str]) -> None: + self._stage("tags", list(value)) @property def device(self) -> InspectDevice | None: diff --git a/src/videoipath_automation_tool/apps/inspect/domain/port.py b/src/videoipath_automation_tool/apps/inspect/domain/port.py index 79e38ab..d4b4f56 100644 --- a/src/videoipath_automation_tool/apps/inspect/domain/port.py +++ b/src/videoipath_automation_tool/apps/inspect/domain/port.py @@ -151,13 +151,16 @@ def edges(self) -> list[InspectEdge]: def _vertex_by_type(self, vertex_type: str) -> InspectVertex | None: for vid, side in self._vertex_sides(): if side.vertexType == vertex_type: - return self.snapshot.get_vertex(vid, vertex_info=side) + return self.snapshot.get_vertex(vid, vertex_info=side, port_factory_label=self.factory_label) return None def _vertices(self) -> list[InspectVertex]: """All typed vertex views this port carries (including Internal/Undecided). Prefer :attr:`vertex_out` / :attr:`vertex_in` for the public API.""" - return [self.snapshot.get_vertex(vid, vertex_info=side) for vid, side in self._vertex_sides()] + return [ + self.snapshot.get_vertex(vid, vertex_info=side, port_factory_label=self.factory_label) + for vid, side in self._vertex_sides() + ] def _vertex_sides(self) -> list[tuple[str, InspectApiSingleVertexInfo]]: """(vertex id, its offline ``vertexInfo`` side) for each vertex the port carries.""" diff --git a/src/videoipath_automation_tool/apps/inspect/domain/vertex.py b/src/videoipath_automation_tool/apps/inspect/domain/vertex.py index a33475a..a2d5251 100644 --- a/src/videoipath_automation_tool/apps/inspect/domain/vertex.py +++ b/src/videoipath_automation_tool/apps/inspect/domain/vertex.py @@ -1,7 +1,7 @@ """Typed vertex domain views ([ADR-0007]). A vertex is one directed endpoint of a port (a port carries one vertex, or an ``out``/``in`` pair). -``InspectVertex`` is the base read view over the vertex edit form (``lookupInspectVertexById``); +``InspectVertex`` is the base read/write view over the vertex edit form (``lookupInspectVertexById``); concrete kinds subclass it to expose their kind-specific attributes: - ``InspectIpVertex`` — IP config (address, netmask, VLAN, VRF, config-support flags). @@ -11,18 +11,23 @@ Router vertices are surfaced as the base ``InspectVertex`` (their ``park_port`` lives on the base). Instances are built by :meth:`InspectSnapshot.get_vertex`, which picks the subclass from the edit -form's ``typeFields.type``. Edits go through ``app.inspect.update_vertex(vertex.id, ...)``. +form's ``typeFields.type``. + +Editable attributes use property setters that stage pending wire-field intents on the snapshot +(read-your-writes). Flush with ``app.inspect.update(vertex)`` or ``app.inspect.update(device)``. """ from __future__ import annotations -from typing import TYPE_CHECKING, Any +import warnings +from typing import TYPE_CHECKING, Any, Literal from videoipath_automation_tool.apps.inspect.model.collector import InspectApiSingleVertexInfo from videoipath_automation_tool.apps.inspect.model.common import ( - InspectFrozenModel, + InspectEditableModel, InspectVertexKind, InspectVertexType, + _STAGED_MISSING, ) from videoipath_automation_tool.apps.inspect.snapshot import InspectSnapshot @@ -37,17 +42,24 @@ ) -class InspectVertex(InspectFrozenModel): - """Base read view of a single vertex. +class InspectVertex(InspectEditableModel): + """Base read/write view of a single vertex. Direction and the ``isActive`` / ``isControlled`` / ``isEndpoint`` status flags resolve offline from the owning port's ``vertexInfo`` (``vertex_info``, set when built via a port); the config - fields (label, tags, SIPS, control, IP/codec, …) resolve live from the snapshot's cached edit - form (``lookupInspectVertexById``).""" + fields resolve live from the snapshot's cached edit form, with pending setter edits taking + precedence (read-your-writes).""" snapshot: InspectSnapshot id: str vertex_info: InspectApiSingleVertexInfo | None = None + port_factory_label: str | None = None + + @property + def _edit_kind(self) -> Literal["vertex"]: + return "vertex" + + # --- Offline / identity --- @property def vertex_type(self) -> InspectVertexType | str | None: @@ -58,6 +70,11 @@ def vertex_type(self) -> InspectVertexType | str | None: lookup = self._lookup() return lookup.vertexType if lookup else None + @property + def factory_label(self) -> str | None: + """Device-reported factory label of the owning port (set when built via a port).""" + return self.port_factory_label + @property def is_active(self) -> bool | None: return self._info_flag("isActive") @@ -71,63 +88,137 @@ def is_endpoint(self) -> bool | None: """Whether the vertex is usable as a service endpoint ("Use as endpoint" in the UI).""" return self._info_flag("isEndpoint") + # --- Base edit-form fields --- + @property def label(self) -> str | None: - form = self._form() - return form.label if form else None + return self._staged_or("label", lambda: self._form_get("label")) + + @label.setter + def label(self, value: str) -> None: + self._stage("label", value) @property def description(self) -> str | None: - form = self._form() - return form.desc if form else None + return self._staged_or("desc", lambda: self._form_get("desc")) + + @description.setter + def description(self, value: str) -> None: + self._stage("desc", value) @property def tags(self) -> list[str]: - """Tags assigned to this vertex (``localAssignedTags``). Assign with - ``app.inspect.update_vertex(vertex.id, tags=[...])``.""" - form = self._form() - return list(form.localAssignedTags) if form else [] + """Tags assigned to this vertex (``localAssignedTags``).""" + return self._staged_or( + "localAssignedTags", + lambda: list(self._form_get("localAssignedTags") or []), + adapt=list, + ) + + @tags.setter + def tags(self, value: list[str]) -> None: + self._stage("localAssignedTags", list(value)) + + @property + def form_tags(self) -> list[str]: + """The vertex form's ``tags`` list (distinct from :attr:`tags` / ``localAssignedTags``).""" + return self._staged_or("tags", lambda: list(self._form_get("tags") or []), adapt=list) + + @form_tags.setter + def form_tags(self, value: list[str]) -> None: + self._stage("tags", list(value)) @property def active(self) -> bool | None: - form = self._form() - return form.active if form else None + return self._staged_or("active", lambda: self._form_get("active")) + + @active.setter + def active(self, value: bool) -> None: + self._stage("active", value) @property def use_as_endpoint(self) -> bool | None: """Whether the vertex is usable as a service endpoint ("Use as endpoint" in the UI).""" - form = self._form() - return form.useAsEndpoint if form else None + return self._staged_or("useAsEndpoint", lambda: self._form_get("useAsEndpoint")) + + @use_as_endpoint.setter + def use_as_endpoint(self, value: bool) -> None: + self._stage("useAsEndpoint", value) @property def sips_mode(self) -> str | None: - form = self._form() - return form.sipsMode if form else None + return self._staged_or("sipsMode", lambda: self._form_get("sipsMode")) + + @sips_mode.setter + def sips_mode(self, value: str) -> None: + self._stage("sipsMode", value) @property def control_props(self) -> InspectApiVertexControlProps | None: - form = self._form() - return form.controlProps if form else None + return self._staged_or("controlProps", lambda: self._form_get("controlProps"), adapt=self._as_control_props) + + @control_props.setter + def control_props(self, value: Any) -> None: + self._stage("controlProps", value) + + @property + def control(self) -> str | None: + """Best-effort ``control`` scalar (``"full"`` / ``"off"`` / ``"semi"``). + + The verified 2025.4.9 vertex edit form exposes ``controlProps`` rather than a ``control`` + scalar; this setter stages a top-level ``control`` intent (allowed by ``extra="allow"``) + for servers that accept it. Prefer :attr:`control_props` when targeting the verified form. + """ + return self._staged_or("control", lambda: self._form_get("control")) + + @control.setter + def control(self, value: str) -> None: + warnings.warn( + "InspectVertex.control stages a best-effort top-level 'control' field; the verified " + "2025.4.9 edit form exposes controlProps instead. Prefer control_props when possible.", + UserWarning, + stacklevel=2, + ) + self._stage("control", value) @property def extra_alert_filters(self) -> list[Any]: - form = self._form() - return list(form.extraAlertFilters) if form else [] + return self._staged_or( + "extraAlertFilters", + lambda: list(self._form_get("extraAlertFilters") or []), + adapt=list, + ) + + @extra_alert_filters.setter + def extra_alert_filters(self, value: list[Any]) -> None: + self._stage("extraAlertFilters", list(value)) @property def custom(self) -> dict[str, Any]: - form = self._form() - return dict(form.custom) if form else {} + return self._staged_or("custom", lambda: dict(self._form_get("custom") or {}), adapt=dict) + + @custom.setter + def custom(self, value: dict[str, Any]) -> None: + self._stage("custom", dict(value)) @property def queueable(self) -> bool | None: - form = self._form() - return form.queueable if form else None + return self._staged_or("queueable", lambda: self._form_get("queueable")) + + @queueable.setter + def queueable(self, value: bool) -> None: + self._stage("queueable", value) @property def destination_monitor_leader(self) -> bool | None: - form = self._form() - return form.destinationMonitorLeader if form else None + return self._staged_or( + "destinationMonitorLeader", + lambda: self._form_get("destinationMonitorLeader"), + ) + + @destination_monitor_leader.setter + def destination_monitor_leader(self, value: bool) -> None: + self._stage("destinationMonitorLeader", value) @property def vertex_kind(self) -> InspectVertexKind | str | None: @@ -148,13 +239,20 @@ def is_virtual(self) -> bool | None: @property def park_port(self) -> int | None: """Park port (router vertices): ``typeFields.parkPort``.""" - type_fields = self.type_fields - return type_fields.parkPort if type_fields is not None else None + return self._staged_or( + "typeFields.parkPort", + lambda: self.type_fields.parkPort if self.type_fields is not None else None, + ) + + @park_port.setter + def park_port(self, value: int) -> None: + self._stage("typeFields.parkPort", value) @property def type_fields(self) -> InspectApiVertexTypeFields | None: - form = self._form() - return form.typeFields if form else None + return self._form_get("typeFields") + + # --- Internal --- def _info_flag(self, name: str) -> bool | None: info = self.vertex_info @@ -169,6 +267,18 @@ def _form(self) -> InspectApiVertexEditForm | None: lookup = self._lookup() return lookup.fields if lookup else None + def _form_get(self, attr: str, default: Any = None) -> Any: + form = self._form() + return getattr(form, attr, default) if form is not None else default + + @staticmethod + def _as_control_props(value: Any) -> InspectApiVertexControlProps | None: + from videoipath_automation_tool.apps.inspect.model.actions import InspectApiVertexControlProps + + if value is None or isinstance(value, InspectApiVertexControlProps): + return value + return InspectApiVertexControlProps.model_validate(value) + class InspectGenericVertex(InspectVertex): """A generic vertex (``typeFields.type == "generic"``); exposes the base fields only.""" @@ -179,59 +289,122 @@ class InspectIpVertex(InspectVertex): @property def ip_address(self) -> str | None: - return self._tf("ipAddress") + return self._tf("ipAddress", "typeFields.ipAddress") + + @ip_address.setter + def ip_address(self, value: str) -> None: + self._stage("typeFields.ipAddress", value) @property def ip_netmask(self) -> str | None: - return self._tf("ipNetmask") + return self._tf("ipNetmask", "typeFields.ipNetmask") + + @ip_netmask.setter + def ip_netmask(self, value: str) -> None: + self._stage("typeFields.ipNetmask", value) @property def public(self) -> bool | None: - return self._tf("public") + return self._tf("public", "typeFields.public") + + @public.setter + def public(self, value: bool) -> None: + self._stage("typeFields.public", value) @property def vlan_id(self) -> str | None: - return self._tf("vlanId") + return self._tf("vlanId", "typeFields.vlanId") + + @vlan_id.setter + def vlan_id(self, value: str) -> None: + self._stage("typeFields.vlanId", value) @property def vrf_id(self) -> str | None: - return self._tf("vrfId") + return self._tf("vrfId", "typeFields.vrfId") + + @vrf_id.setter + def vrf_id(self, value: str) -> None: + self._stage("typeFields.vrfId", value) @property def supports_cpipe(self) -> bool | None: - return self._tf("supportsCpipeCfg") + return self._tf("supportsCpipeCfg", "typeFields.supportsCpipeCfg") + + @supports_cpipe.setter + def supports_cpipe(self, value: bool) -> None: + self._stage("typeFields.supportsCpipeCfg", value) @property def supports_igmp(self) -> bool | None: - return self._tf("supportsIgmpCfg") + return self._tf("supportsIgmpCfg", "typeFields.supportsIgmpCfg") + + @supports_igmp.setter + def supports_igmp(self, value: bool) -> None: + self._stage("typeFields.supportsIgmpCfg", value) @property def supports_mac_forwarding(self) -> bool | None: - return self._tf("supportsMacForwardingCfg") + return self._tf("supportsMacForwardingCfg", "typeFields.supportsMacForwardingCfg") + + @supports_mac_forwarding.setter + def supports_mac_forwarding(self, value: bool) -> None: + self._stage("typeFields.supportsMacForwardingCfg", value) @property def supports_nso(self) -> bool | None: - return self._tf("supportsNsoCfg") + return self._tf("supportsNsoCfg", "typeFields.supportsNsoCfg") + + @supports_nso.setter + def supports_nso(self, value: bool) -> None: + self._stage("typeFields.supportsNsoCfg", value) @property def supports_openflow(self) -> bool | None: - return self._tf("supportsOpenflowCfg") + return self._tf("supportsOpenflowCfg", "typeFields.supportsOpenflowCfg") + + @supports_openflow.setter + def supports_openflow(self, value: bool) -> None: + self._stage("typeFields.supportsOpenflowCfg", value) @property def supports_static_igmp(self) -> bool | None: - return self._tf("supportsStaticIgmpCfg") + return self._tf("supportsStaticIgmpCfg", "typeFields.supportsStaticIgmpCfg") + + @supports_static_igmp.setter + def supports_static_igmp(self, value: bool) -> None: + self._stage("typeFields.supportsStaticIgmpCfg", value) + + # Alias matching the topology app's naming used by vertex processors. + @property + def supports_static_igmp_config(self) -> bool | None: + return self.supports_static_igmp + + @supports_static_igmp_config.setter + def supports_static_igmp_config(self, value: bool) -> None: + self.supports_static_igmp = value @property def supports_vlan(self) -> bool | None: - return self._tf("supportsVlanCfg") + return self._tf("supportsVlanCfg", "typeFields.supportsVlanCfg") + + @supports_vlan.setter + def supports_vlan(self, value: bool) -> None: + self._stage("typeFields.supportsVlanCfg", value) @property def supports_vpls(self) -> bool | None: - return self._tf("supportsVplsCfg") + return self._tf("supportsVplsCfg", "typeFields.supportsVplsCfg") - def _tf(self, name: str) -> Any: - type_fields = self.type_fields - return getattr(type_fields, name, None) if type_fields is not None else None + @supports_vpls.setter + def supports_vpls(self, value: bool) -> None: + self._stage("typeFields.supportsVplsCfg", value) + + def _tf(self, name: str, staged_field: str) -> Any: + return self._staged_or( + staged_field, + lambda: getattr(self.type_fields, name, None) if self.type_fields is not None else None, + ) class InspectCodecVertex(InspectVertex): @@ -240,53 +413,141 @@ class InspectCodecVertex(InspectVertex): @property def codec_format(self) -> str | None: - generic = self.generic - return generic.codecFormat if generic else None + return self._generic_get("codecFormat", "typeFields.generic.codecFormat") + + @codec_format.setter + def codec_format(self, value: str) -> None: + self._stage("typeFields.generic.codecFormat", value) @property def public(self) -> bool | None: - generic = self.generic - return generic.public if generic else None + return self._generic_get("public", "typeFields.generic.public") + + @public.setter + def public(self, value: bool) -> None: + self._stage("typeFields.generic.public", value) @property def multiplicity(self) -> int | None: - generic = self.generic - return generic.multiplicity if generic else None + return self._generic_get("multiplicity", "typeFields.generic.multiplicity") + + @multiplicity.setter + def multiplicity(self, value: int) -> None: + self._stage("typeFields.generic.multiplicity", value) @property def extra_formats(self) -> list[Any]: - generic = self.generic - return list(generic.extraFormats) if generic else [] + return self._staged_or( + "typeFields.generic.extraFormats", + lambda: list(g.extraFormats) if (g := self.generic) else [], + adapt=list, + ) + + @extra_formats.setter + def extra_formats(self, value: list[Any]) -> None: + self._stage("typeFields.generic.extraFormats", list(value)) + + @property + def bidir_partner_id(self) -> str | None: + return self._generic_get("bidirPartnerId", "typeFields.generic.bidirPartnerId") + + @bidir_partner_id.setter + def bidir_partner_id(self, value: str | None) -> None: + self._stage("typeFields.generic.bidirPartnerId", value) + + @property + def partner_config(self) -> Any: + return self._generic_get("partnerConfig", "typeFields.generic.partnerConfig") + + @partner_config.setter + def partner_config(self, value: Any) -> None: + self._stage("typeFields.generic.partnerConfig", value) + + @property + def service_id(self) -> Any: + return self._generic_get("serviceId", "typeFields.generic.serviceId") + + @service_id.setter + def service_id(self, value: Any) -> None: + self._stage("typeFields.generic.serviceId", value) @property def main_src_info(self) -> dict[str, Any] | None: - generic = self.generic - return generic.mainSrcInfo if generic else None + return self._endpoint_info("mainSrcInfo") + + @main_src_info.setter + def main_src_info(self, value: dict[str, Any] | None) -> None: + self._stage("typeFields.generic.mainSrcInfo", value) @property def main_dst_info(self) -> dict[str, Any] | None: - generic = self.generic - return generic.mainDstInfo if generic else None + return self._endpoint_info("mainDstInfo") + + @main_dst_info.setter + def main_dst_info(self, value: dict[str, Any] | None) -> None: + self._stage("typeFields.generic.mainDstInfo", value) @property def spare_src_info(self) -> dict[str, Any] | None: - generic = self.generic - return generic.spareSrcInfo if generic else None + return self._endpoint_info("spareSrcInfo") + + @spare_src_info.setter + def spare_src_info(self, value: dict[str, Any] | None) -> None: + self._stage("typeFields.generic.spareSrcInfo", value) @property def spare_dst_info(self) -> dict[str, Any] | None: - generic = self.generic - return generic.spareDstInfo if generic else None + return self._endpoint_info("spareDstInfo") + + @spare_dst_info.setter + def spare_dst_info(self, value: dict[str, Any] | None) -> None: + self._stage("typeFields.generic.spareDstInfo", value) + + @property + def main_destination_port(self) -> int | None: + return self._staged_or( + "typeFields.generic.mainDstInfo.port", + lambda: self._port_from_endpoint_info(self.main_dst_info), + ) + + @main_destination_port.setter + def main_destination_port(self, value: int) -> None: + self._stage("typeFields.generic.mainDstInfo.port", value) + + @property + def spare_destination_port(self) -> int | None: + return self._staged_or( + "typeFields.generic.spareDstInfo.port", + lambda: self._port_from_endpoint_info(self.spare_dst_info), + ) + + @spare_destination_port.setter + def spare_destination_port(self, value: int) -> None: + self._stage("typeFields.generic.spareDstInfo.port", value) @property def is_igmp_source(self) -> bool | None: - specific = self.specific - return specific.isIgmpSource if specific else None + return self._specific_get("isIgmpSource", "typeFields.specific.isIgmpSource") + + @is_igmp_source.setter + def is_igmp_source(self, value: bool) -> None: + self._stage("typeFields.specific.isIgmpSource", value) @property def sdp_support(self) -> bool | None: - specific = self.specific - return specific.sdpSupport if specific else None + return self._specific_get("sdpSupport", "typeFields.specific.sdpSupport") + + @sdp_support.setter + def sdp_support(self, value: bool) -> None: + self._stage("typeFields.specific.sdpSupport", value) + + @property + def specific_type(self) -> str | None: + return self._specific_get("type", "typeFields.specific.type") + + @specific_type.setter + def specific_type(self, value: str) -> None: + self._stage("typeFields.specific.type", value) @property def generic(self) -> InspectApiCodecGeneric | None: @@ -304,6 +565,37 @@ def specific(self) -> InspectApiCodecSpecific | None: raw = self._type_fields_extra("specific") return InspectApiCodecSpecific.model_validate(raw) if isinstance(raw, dict) else None + def _generic_get(self, attr: str, staged_field: str) -> Any: + return self._staged_or( + staged_field, + lambda: getattr(g, attr, None) if (g := self.generic) else None, + ) + + def _specific_get(self, attr: str, staged_field: str) -> Any: + return self._staged_or( + staged_field, + lambda: getattr(s, attr, None) if (s := self.specific) else None, + ) + + @staticmethod + def _port_from_endpoint_info(info: dict[str, Any] | None) -> int | None: + port = info.get("port") if info else None + return int(port) if isinstance(port, (int, float)) else port + + def _endpoint_info(self, name: str) -> dict[str, Any] | None: + staged = self._staged(f"typeFields.generic.{name}") + if staged is not _STAGED_MISSING: + return dict(staged) if isinstance(staged, dict) else staged + # Merge leaf-level staged ports into the baseline block for read-your-writes. + generic = self.generic + baseline = getattr(generic, name, None) if generic else None + merged: dict[str, Any] = dict(baseline) if isinstance(baseline, dict) else {} + for leaf in ("ip", "mac", "port", "vlan", "gateway", "netmask"): + leaf_staged = self._staged(f"typeFields.generic.{name}.{leaf}") + if leaf_staged is not _STAGED_MISSING: + merged[leaf] = leaf_staged + return merged or (baseline if isinstance(baseline, dict) else None) + def _type_fields_extra(self, name: str) -> Any: type_fields = self.type_fields return getattr(type_fields, name, None) if type_fields is not None else None @@ -319,11 +611,17 @@ def build_vertex( vertex_id: str, kind: InspectVertexKind | str | None, vertex_info: InspectApiSingleVertexInfo | None = None, + port_factory_label: str | None = None, ) -> InspectVertex: """Construct the typed vertex view for ``vertex_id`` from its ``typeFields.type`` kind (falls back to the base :class:`InspectVertex` when the kind is unknown, e.g. no fetcher).""" cls = _VERTEX_CLASS_BY_KIND.get(kind, InspectVertex) - return cls(snapshot=snapshot, id=vertex_id, vertex_info=vertex_info) + return cls( + snapshot=snapshot, + id=vertex_id, + vertex_info=vertex_info, + port_factory_label=port_factory_label, + ) _VERTEX_CLASS_BY_KIND: dict[str | None, type[InspectVertex]] = { diff --git a/src/videoipath_automation_tool/apps/inspect/model/__init__.py b/src/videoipath_automation_tool/apps/inspect/model/__init__.py index 68f4b1f..f747815 100644 --- a/src/videoipath_automation_tool/apps/inspect/model/__init__.py +++ b/src/videoipath_automation_tool/apps/inspect/model/__init__.py @@ -1,10 +1,19 @@ from __future__ import annotations -from videoipath_automation_tool.apps.inspect.model import actions, collector, common, ngraph, update_topology, virtual +from videoipath_automation_tool.apps.inspect.model import ( + actions, + collector, + common, + ngraph, + tags, + update_topology, + virtual, +) from videoipath_automation_tool.apps.inspect.model.actions import * from videoipath_automation_tool.apps.inspect.model.collector import * from videoipath_automation_tool.apps.inspect.model.common import * from videoipath_automation_tool.apps.inspect.model.ngraph import * +from videoipath_automation_tool.apps.inspect.model.tags import * from videoipath_automation_tool.apps.inspect.model.update_topology import * from videoipath_automation_tool.apps.inspect.model.virtual import * @@ -13,6 +22,7 @@ *collector.__all__, *common.__all__, *ngraph.__all__, + *tags.__all__, *update_topology.__all__, *virtual.__all__, ] diff --git a/src/videoipath_automation_tool/apps/inspect/model/collector.py b/src/videoipath_automation_tool/apps/inspect/model/collector.py index 5d448d7..0d39a37 100644 --- a/src/videoipath_automation_tool/apps/inspect/model/collector.py +++ b/src/videoipath_automation_tool/apps/inspect/model/collector.py @@ -184,6 +184,19 @@ def assigned_tags(self) -> list[str]: return list(assigned["all"]) return [] + @property + def local_assigned_tags(self) -> list[str]: + """Locally bound tags on this module (keys of ``tagsInfo.assigned.local``).""" + if not self.tagsInfo: + return [] + assigned = self.tagsInfo.get("assigned") + if not isinstance(assigned, dict): + return [] + local = assigned.get("local") + if isinstance(local, dict): + return list(local.keys()) + return [] + @property def effective_label(self) -> str | None: if self.descriptor is not None and self.descriptor.label: diff --git a/src/videoipath_automation_tool/apps/inspect/model/common.py b/src/videoipath_automation_tool/apps/inspect/model/common.py index f288e4f..b76b8c1 100644 --- a/src/videoipath_automation_tool/apps/inspect/model/common.py +++ b/src/videoipath_automation_tool/apps/inspect/model/common.py @@ -1,9 +1,20 @@ from __future__ import annotations -from typing import Any, Literal +from abc import ABC, abstractmethod +from collections.abc import Callable +from typing import TYPE_CHECKING, Any, Literal, TypeVar from pydantic import BaseModel, ConfigDict, Field +if TYPE_CHECKING: + from videoipath_automation_tool.apps.inspect.snapshot import InspectSnapshot + +# Sentinel returned by :meth:`InspectSnapshot.get_staged_value` / :meth:`InspectEditableModel._staged` +# when no pending edit exists for a field. +_STAGED_MISSING: Any = object() + +_T = TypeVar("_T") + # The icon types selectable in the VideoIPath UI (mirrors the topology app's ``IconType``; live data # may contain further values, so read/write surfaces use the permissive ``InspectIconType | str``). InspectIconType = Literal[ @@ -66,6 +77,47 @@ class InspectFrozenModel(InspectInternalModel): model_config = ConfigDict(frozen=True, slots=True, validate_assignment=True, arbitrary_types_allowed=True) +class InspectEditableModel(InspectInternalModel, ABC): + """Mutable domain view: identity fields are set at construction; editable attributes are + exposed as property setters that stage pending edits on the snapshot (read-your-writes). + + Subclasses must provide ``snapshot``, ``id``, and :attr:`_edit_kind`. Staging helpers + (:meth:`_stage` / :meth:`_staged`) are shared here. + """ + + model_config = ConfigDict(validate_assignment=True, arbitrary_types_allowed=True) + + snapshot: InspectSnapshot + + @property + @abstractmethod + def _edit_kind(self) -> Literal["device", "vertex", "edge", "module"]: + """Snapshot staging namespace for this entity (``device`` / ``vertex`` / ``edge`` / ``module``).""" + + def _stage(self, field: str, value: Any) -> None: + self.snapshot.stage_edit(self._edit_kind, self.id, field, value) + + def _staged(self, field: str) -> Any: + return self.snapshot.get_staged_value(self._edit_kind, self.id, field) + + def _staged_or( + self, + field: str, + fallback: Callable[[], _T], + *, + adapt: Callable[[Any], _T] | None = None, + ) -> _T: + """Return the staged value for ``field``, else ``fallback()``. + + When a staged value exists and ``adapt`` is given, ``adapt(staged)`` is returned (e.g. + ``list`` / ``dict`` for defensive copies). + """ + staged = self._staged(field) + if staged is not _STAGED_MISSING: + return adapt(staged) if adapt is not None else staged + return fallback() + + class InspectApiDescriptor(InspectApiBaseModel): desc: str = "" label: str = "" @@ -131,6 +183,7 @@ class InspectApiActionValidationErrorResponse(InspectApiBaseModel): __all__ = [ "InspectApiActionValidationErrorResponse", "InspectApiBaseModel", + "InspectEditableModel", "InspectFrozenModel", "InspectInternalModel", "InspectApiCollection", @@ -150,4 +203,5 @@ class InspectApiActionValidationErrorResponse(InspectApiBaseModel): "InspectSdpStrategy", "InspectVertexKind", "InspectVertexType", + "_STAGED_MISSING", ] diff --git a/src/videoipath_automation_tool/apps/inspect/model/tags.py b/src/videoipath_automation_tool/apps/inspect/model/tags.py new file mode 100644 index 0000000..fc9b565 --- /dev/null +++ b/src/videoipath_automation_tool/apps/inspect/model/tags.py @@ -0,0 +1,38 @@ +"""Tag assign / unassign action envelopes (``/rest/v2/actions/status/tags/*``). + +Used for module (and potentially other resource) tag bindings that are not written via +``updateTopology``. +""" + +from __future__ import annotations + +from pydantic import Field + +from videoipath_automation_tool.apps.inspect.model.common import ( + InspectApiBaseModel, + InspectApiPostRequestHeader, +) + + +def module_resource_id(module_pid: str) -> str: + """Collector resource id for a module pid (``device57.dev.0`` → ``device:device57.dev.0``).""" + if module_pid.startswith("device:"): + return module_pid + return f"device:{module_pid}" + + +class InspectApiAssignTagData(InspectApiBaseModel): + tagId: str + elementIds: list[str] = Field(default_factory=list) + + +class InspectApiAssignTagRequest(InspectApiBaseModel): + header: InspectApiPostRequestHeader = Field(default_factory=InspectApiPostRequestHeader) + data: InspectApiAssignTagData + + +__all__ = [ + "InspectApiAssignTagData", + "InspectApiAssignTagRequest", + "module_resource_id", +] diff --git a/src/videoipath_automation_tool/apps/inspect/snapshot.py b/src/videoipath_automation_tool/apps/inspect/snapshot.py index 852068b..05c1924 100644 --- a/src/videoipath_automation_tool/apps/inspect/snapshot.py +++ b/src/videoipath_automation_tool/apps/inspect/snapshot.py @@ -33,7 +33,11 @@ InspectApiSingleVertexInfo, InspectPortStatus, ) -from videoipath_automation_tool.apps.inspect.model.common import InspectFrozenModel, InspectInternalModel +from videoipath_automation_tool.apps.inspect.model.common import ( + InspectFrozenModel, + InspectInternalModel, + _STAGED_MISSING, +) if TYPE_CHECKING: from videoipath_automation_tool.apps.inspect.domain.device import InspectDevice @@ -105,6 +109,10 @@ def __init__( self._stale_devices: set[str] = set() self._stale_pairs: set[str] = set() + # Pending domain-object edits (wire-field intents) staged by setters before update()/commit. + # Keyed by (kind, entity_id) where kind is "device" / "vertex" / "edge". + self._pending_edits: dict[tuple[str, str], dict[str, Any]] = {} + for node in device_items or []: self._index_device(node, device_level) for pair in edge_items or []: @@ -240,13 +248,18 @@ def get_vertex_details_many(self, vertex_ids: list[str]) -> dict[str, "InspectAp } def get_vertex( - self, vertex_id: str, vertex_info: Optional["InspectApiSingleVertexInfo"] = None + self, + vertex_id: str, + vertex_info: Optional["InspectApiSingleVertexInfo"] = None, + *, + port_factory_label: str | None = None, ) -> Optional["InspectVertex"]: """Typed vertex view for ``vertex_id`` (triggers a cached ``lookupInspectVertexById``); the concrete subclass is chosen from the edit form's ``typeFields.type``. ``vertex_info`` (the owning port's offline ``vertexInfo`` side) supplies the direction/status flags: when it is given a base vertex is still returned even if the edit form is unavailable; without it, an - unknown vertex returns None.""" + unknown vertex returns None. ``port_factory_label`` (when built via a port) is exposed as + :attr:`InspectVertex.factory_label`.""" lookup = self.get_vertex_details(vertex_id) if lookup is None and vertex_info is None: return None @@ -254,7 +267,7 @@ def get_vertex( kind = type_fields.type if type_fields is not None else None from videoipath_automation_tool.apps.inspect.domain.vertex import build_vertex - return build_vertex(self, vertex_id, kind, vertex_info) + return build_vertex(self, vertex_id, kind, vertex_info, port_factory_label=port_factory_label) # --- Edge reads (no hydration) --- @@ -366,6 +379,63 @@ def preload(self, devices: Optional[list[str]] = None) -> None: with ThreadPoolExecutor(max_workers=min(_PRELOAD_WORKERS, len(pending))) as pool: list(pool.map(self._ensure_device_detail, pending)) + # --- Pending domain edits (setters → update()) --- + + def stage_edit(self, kind: str, entity_id: str, field: str, value: Any) -> None: + """Record a pending wire-field intent for ``entity_id`` (``kind``: device/vertex/edge/module).""" + with self._lock: + self._pending_edits.setdefault((kind, entity_id), {})[field] = value + + def get_staged_edits(self, kind: str, entity_id: str) -> dict[str, Any]: + """Return a copy of the pending intents for ``entity_id``, or an empty dict.""" + with self._lock: + return dict(self._pending_edits.get((kind, entity_id), {})) + + def get_staged_value(self, kind: str, entity_id: str, field: str) -> Any: + """Return the staged value for ``field``, or ``_STAGED_MISSING`` if none.""" + with self._lock: + edits = self._pending_edits.get((kind, entity_id)) + if edits is None or field not in edits: + return _STAGED_MISSING + return edits[field] + + def iter_staged_edits(self, kind: str | None = None) -> list[tuple[str, str, dict[str, Any]]]: + """All pending edits as ``(kind, entity_id, intents)``. Optionally filter by ``kind``.""" + with self._lock: + return [ + (k, eid, dict(intents)) + for (k, eid), intents in self._pending_edits.items() + if kind is None or k == kind + ] + + def clear_staged( + self, + *, + kind: str | None = None, + entity_id: str | None = None, + entity_ids: Optional[list[str]] = None, + ) -> None: + """Clear pending edits. With ``entity_id``/``entity_ids``, clear those keys (optionally + scoped by ``kind``); with only ``kind``, clear every entity of that kind; with neither, + clear all.""" + with self._lock: + if entity_id is not None: + ids = [entity_id] + elif entity_ids is not None: + ids = list(entity_ids) + else: + ids = None + if ids is None and kind is None: + self._pending_edits.clear() + return + for key in list(self._pending_edits): + k, eid = key + if kind is not None and k != kind: + continue + if ids is not None and eid not in ids: + continue + self._pending_edits.pop(key, None) + # --- Refresh --- def refresh(self) -> "InspectSnapshot": @@ -935,4 +1005,4 @@ def _iter_ports( yield from ports -__all__ = ["InspectSnapshot", "HydrationLevel"] +__all__ = ["InspectSnapshot", "HydrationLevel", "_STAGED_MISSING"] diff --git a/src/videoipath_automation_tool/apps/inspect/transaction.py b/src/videoipath_automation_tool/apps/inspect/transaction.py index bad3e7f..334c133 100644 --- a/src/videoipath_automation_tool/apps/inspect/transaction.py +++ b/src/videoipath_automation_tool/apps/inspect/transaction.py @@ -34,6 +34,7 @@ InspectCommitError, InspectConflict, InspectEntityNotFoundError, + InspectError, ) from videoipath_automation_tool.apps.inspect.model.actions import ( InspectApiEdgeForm, @@ -43,6 +44,7 @@ ) from videoipath_automation_tool.apps.inspect.model.common import ( CONFLICT_PRIORITY_TO_INT, + InspectApiSimpleActionResponse, InspectConfigPriority, InspectFrozenModel, InspectIconSize, @@ -51,6 +53,7 @@ InspectRedundancyMode, InspectSdpStrategy, ) +from videoipath_automation_tool.apps.inspect.model.tags import module_resource_id from videoipath_automation_tool.apps.inspect.model.update_topology import ( InspectApiUpdateTopologyData, InspectApiUpdateTopologyResponse, @@ -62,11 +65,15 @@ class CommitResult(InspectFrozenModel): - """Outcome of a successful commit ([ADR-0006]); a failed commit raises ``InspectCommitError``.""" + """Outcome of a successful commit ([ADR-0006]); a failed commit raises ``InspectCommitError``. + + ``response`` is ``None`` when the commit only applied module tag assign/unassign ops (no + ``updateTopology`` call). + """ applied_ids: list[str] created_ids: list[str] - response: InspectApiUpdateTopologyResponse + response: InspectApiUpdateTopologyResponse | None = None @property def ok(self) -> bool: @@ -74,7 +81,7 @@ def ok(self) -> bool: @property def validation(self) -> Any: - return self.response.data.validation + return self.response.data.validation if self.response is not None else None class InspectTransaction: @@ -143,7 +150,9 @@ def update_device( sdp_strategy: Optional[InspectSdpStrategy | str] = None, site_id: Optional[str] = None, tags: Optional[list[str]] = None, + local_assigned_tags: Optional[list[str]] = None, coordinates: Optional[dict[str, float]] = None, + intents: Optional[dict[str, Any]] = None, ) -> "InspectTransaction": """Edit a device's edit-dialog fields (``replaceDevices``). @@ -153,6 +162,8 @@ def update_device( (it is mandatory server-side); ``label`` / ``description`` are applied to it only when set here. """ entry = self._stage_device(device_id) + if intents: + entry.intents.update(intents) if label is not None: entry.intents["descriptor.label"] = label if description is not None: @@ -167,6 +178,8 @@ def update_device( entry.intents["siteId"] = site_id if tags is not None: entry.intents["tags"] = list(tags) + if local_assigned_tags is not None: + entry.intents["localAssignedTags"] = list(local_assigned_tags) if coordinates is not None: entry.intents["coordinates"] = dict(coordinates) return self @@ -180,12 +193,16 @@ def update_vertex( use_as_endpoint: Optional[bool] = None, label: Optional[str] = None, tags: Optional[list[str]] = None, + form_tags: Optional[list[str]] = None, description: Optional[str] = None, active: Optional[bool] = None, sips_mode: Optional[str] = None, + control: Optional[str] = None, control_props: Optional[Any] = None, extra_alert_filters: Optional[list[Any]] = None, custom: Optional[dict[str, Any]] = None, + queueable: Optional[bool] = None, + destination_monitor_leader: Optional[bool] = None, park_port: Optional[int] = None, # IP-vertex ``typeFields`` (only meaningful on IP vertices): ip_address: Optional[str] = None, @@ -201,15 +218,35 @@ def update_vertex( supports_static_igmp: Optional[bool] = None, supports_vlan: Optional[bool] = None, supports_vpls: Optional[bool] = None, + # Codec-vertex ``typeFields.specific`` / ``typeFields.generic``: + sdp_support: Optional[bool] = None, + is_igmp_source: Optional[bool] = None, + specific_type: Optional[str] = None, + codec_format: Optional[str] = None, + multiplicity: Optional[int] = None, + codec_public: Optional[bool] = None, + extra_formats: Optional[list[Any]] = None, + bidir_partner_id: Optional[str] = None, + partner_config: Optional[Any] = None, + service_id: Optional[Any] = None, + main_src_info: Optional[dict[str, Any]] = None, + main_dst_info: Optional[dict[str, Any]] = None, + spare_src_info: Optional[dict[str, Any]] = None, + spare_dst_info: Optional[dict[str, Any]] = None, + main_destination_port: Optional[int] = None, + spare_destination_port: Optional[int] = None, + # Raw wire-field intents (used by domain-object flush / update()): + intents: Optional[dict[str, Any]] = None, ) -> "InspectTransaction": """Edit a vertex/port (``replaceVertices``; update-only — vertices cannot be created here). - Covers the "Edit vertex" / bulk-edit dialogs: base fields plus the IP-vertex ``typeFields`` - (address, netmask, VLAN, VRF, public, and the "Config supports" flags). ``tags`` assigns - catalog tags to the port (as ``Category~~name`` ids), written to the vertex - ``localAssignedTags``. + Covers the "Edit vertex" / bulk-edit dialogs: base fields, IP-vertex ``typeFields``, and + codec ``typeFields.generic`` / ``typeFields.specific``. ``tags`` assigns catalog tags + (``localAssignedTags``); ``form_tags`` sets the form's distinct ``tags`` list. """ entry = self._stage_vertex(vertex_id) + if intents: + entry.intents.update(intents) if use_as_endpoint is not None: entry.intents["useAsEndpoint"] = use_as_endpoint if label is not None: @@ -218,12 +255,18 @@ def update_vertex( # Port tag assignment is the vertex's localAssignedTags (verified 2025.4.9); the # separate ``fields.tags`` list does not register as an assigned tag. entry.intents["localAssignedTags"] = list(tags) + if form_tags is not None: + entry.intents["tags"] = list(form_tags) if description is not None: entry.intents["desc"] = description if active is not None: entry.intents["active"] = active if sips_mode is not None: entry.intents["sipsMode"] = sips_mode + if control is not None: + # Best-effort: verified 2025.4.9 form has controlProps, not control; extra="allow" + # preserves a top-level control field if the server accepts it. + entry.intents["control"] = control if control_props is not None: if isinstance(control_props, dict): entry.intents["controlProps"] = InspectApiVertexControlProps.model_validate(control_props) @@ -233,9 +276,11 @@ def update_vertex( entry.intents["extraAlertFilters"] = list(extra_alert_filters) if custom is not None: entry.intents["custom"] = dict(custom) + if queueable is not None: + entry.intents["queueable"] = queueable + if destination_monitor_leader is not None: + entry.intents["destinationMonitorLeader"] = destination_monitor_leader - # IP-vertex typeFields, applied as one-level dotted intents (require a populated typeFields - # baseline, which IP/router/codec vertices have from the lookup edit form). for value, wire_field in ( (park_port, "parkPort"), (ip_address, "ipAddress"), @@ -254,6 +299,27 @@ def update_vertex( ): if value is not None: entry.intents[f"typeFields.{wire_field}"] = value + + for value, wire_path in ( + (sdp_support, "typeFields.specific.sdpSupport"), + (is_igmp_source, "typeFields.specific.isIgmpSource"), + (specific_type, "typeFields.specific.type"), + (codec_format, "typeFields.generic.codecFormat"), + (multiplicity, "typeFields.generic.multiplicity"), + (codec_public, "typeFields.generic.public"), + (extra_formats, "typeFields.generic.extraFormats"), + (bidir_partner_id, "typeFields.generic.bidirPartnerId"), + (partner_config, "typeFields.generic.partnerConfig"), + (service_id, "typeFields.generic.serviceId"), + (main_src_info, "typeFields.generic.mainSrcInfo"), + (main_dst_info, "typeFields.generic.mainDstInfo"), + (spare_src_info, "typeFields.generic.spareSrcInfo"), + (spare_dst_info, "typeFields.generic.spareDstInfo"), + (main_destination_port, "typeFields.generic.mainDstInfo.port"), + (spare_destination_port, "typeFields.generic.spareDstInfo.port"), + ): + if value is not None: + entry.intents[wire_path] = list(value) if wire_path.endswith("extraFormats") else value return self # --- Staging: edges --- @@ -276,6 +342,7 @@ def update_edge( active: Optional[bool] = None, tags: Optional[list[str]] = None, also_opposite: bool = False, + intents: Optional[dict[str, Any]] = None, ) -> "InspectTransaction": """Edit an existing edge's "Edit Edge" dialog fields (``replaceEdges``). @@ -297,6 +364,7 @@ def update_edge( weight_per_service=weight_per_service, active=active, tags=tags, + intents=intents, ) self._apply_edge_update(edge_id, fields) if also_opposite: @@ -308,6 +376,8 @@ def update_edge( def _apply_edge_update(self, edge_id: str, fields: dict[str, Any]) -> None: entry = self._stage_edge(edge_id) + if fields.get("intents"): + entry.intents.update(fields["intents"]) if fields["label"] is not None: entry.intents["descriptor.label"] = fields["label"] if fields["description"] is not None: @@ -379,32 +449,67 @@ def remove_device(self, device_id: str) -> "InspectTransaction": """Remove a device (its baseDevice element) from the topology graph.""" return self.remove(device_id) + # --- Staging: modules (tag assign/unassign; not updateTopology) --- + + def update_module( + self, + module_id: str, + *, + tags: Optional[list[str]] = None, + intents: Optional[dict[str, Any]] = None, + ) -> "InspectTransaction": + """Edit a module's locally assigned tags (``assignTag`` / ``unassignTag`` on commit). + + Requires a bound snapshot so the current local tag set can be read for the diff. Tag ops + are separate RPCs from ``updateTopology`` and are not atomic with topology changes. + """ + entry = self._stage_module(module_id) + if intents: + entry.intents.update(intents) + if tags is not None: + entry.intents["tags"] = list(tags) + return self + # --- Commit lifecycle --- def commit(self, check_conflicts: bool = True) -> CommitResult: """Validate, send, and (on success) refresh. Raises on conflict or server rejection. + Topology entries go through ``updateTopology`` ([ADR-0006]). Module tag intents are applied + afterward via ``assignTag`` / ``unassignTag`` (not one atomic server transaction). + Raises: InspectCommitConflictError: a staged entity changed on the server since staging. - InspectCommitError: the server rejected the commit (validation or apply gate). + InspectCommitError: the server rejected the topology commit (validation or apply gate). + InspectError: a module tag assign/unassign action failed. """ self._ensure_open() if not self._entries: raise ValueError("Nothing staged; commit aborted.") - if check_conflicts: + topology_entries = [e for e in self._entries.values() if e.kind != _MODULE] + module_entries = [e for e in self._entries.values() if e.kind == _MODULE] + + if check_conflicts and topology_entries: self._check_conflicts() - delta = self._build_delta() - response = self._api.update_topology(delta) - if not response.committed: - raise InspectCommitError(response) + response: InspectApiUpdateTopologyResponse | None = None + created_ids: list[str] = [] + if topology_entries: + delta = self._build_delta() + response = self._api.update_topology(delta) + if not response.committed: + raise InspectCommitError(response) + created_ids = list(response.data.validation.createIds) + + if module_entries: + self._commit_module_tags(module_entries) self._committed = True applied_ids = [e.entity_id for e in self._entries.values()] result = CommitResult( applied_ids=applied_ids, - created_ids=list(response.data.validation.createIds), + created_ids=created_ids, response=response, ) self._refresh_snapshot() @@ -416,12 +521,19 @@ def rebase(self) -> "InspectTransaction": Use after ``InspectCommitConflictError`` to move the staged changes onto current server state; then ``commit()`` again. Intents that themselves target a concurrently-changed field - will overwrite that change (last-writer-wins for the intent). + will overwrite that change (last-writer-wins for the intent). Module tag baselines are + refreshed from the bound snapshot when present. """ self._ensure_open() for entry in self._entries.values(): if entry.remove or entry.is_new or entry.baseline_form is None: continue + if entry.kind == _MODULE: + if self._snapshot is not None: + tags = self._module_local_tags(entry.entity_id) + entry.baseline_form = list(tags) + entry.baseline_dump = {"tags": list(tags)} + continue fresh = self._fetch_baseline(entry.kind, entry.entity_id) entry.baseline_form = fresh entry.baseline_dump = fresh.model_dump(mode="json") @@ -449,6 +561,30 @@ def _stage_vertex(self, vertex_id: str) -> _Staged: def _stage_edge(self, edge_id: str) -> _Staged: return self._stage(_EDGE, edge_id) + def _stage_module(self, module_id: str) -> _Staged: + """Stage a module tag edit; baseline is the current local tag list from the snapshot.""" + self._ensure_open() + key = (_MODULE, module_id) + existing = self._entries.get(key) + if existing is not None and not existing.remove: + return existing + if self._snapshot is None: + raise RuntimeError( + "Inspect snapshot is required to stage module tag edits (load the topology before updating modules)." + ) + device_id = _device_of(module_id) + if self._snapshot.get_module(device_id, module_id) is None: + raise InspectEntityNotFoundError(module_id, kind="module") + baseline_tags = self._module_local_tags(module_id) + entry = _Staged( + kind=_MODULE, + entity_id=module_id, + baseline_form=list(baseline_tags), + baseline_dump={"tags": list(baseline_tags)}, + ) + self._entries[key] = entry + return entry + def _stage(self, kind: str, entity_id: str) -> _Staged: self._ensure_open() key = (kind, entity_id) @@ -531,7 +667,7 @@ def _check_conflicts(self) -> None: current = self._refetch_baselines() conflicts: list[InspectConflict] = [] for entry in self._entries.values(): - if entry.remove or entry.is_new or entry.baseline_dump is None: + if entry.kind == _MODULE or entry.remove or entry.is_new or entry.baseline_dump is None: continue key = (entry.kind, entry.entity_id) server_form = current.get(key) @@ -575,6 +711,8 @@ def _refetch_baselines(self) -> dict[tuple[str, str], Any]: def _build_delta(self) -> InspectApiUpdateTopologyData: delta = InspectApiUpdateTopologyData() for entry in self._entries.values(): + if entry.kind == _MODULE: + continue if entry.remove: delta.remove.append(entry.entity_id) continue @@ -588,6 +726,37 @@ def _build_delta(self) -> InspectApiUpdateTopologyData: delta.replaceEdges[entry.entity_id] = form return delta + def _commit_module_tags(self, entries: list[_Staged]) -> None: + """Diff desired vs current local tags and call assignTag / unassignTag (batched by tag).""" + to_assign: dict[str, list[str]] = {} + to_unassign: dict[str, list[str]] = {} + for entry in entries: + desired = entry.intents.get("tags") + if desired is None: + continue + desired_set = set(desired) + current_set = set(self._module_local_tags(entry.entity_id)) + element_id = module_resource_id(entry.entity_id) + for tag_id in desired_set - current_set: + to_assign.setdefault(tag_id, []).append(element_id) + for tag_id in current_set - desired_set: + to_unassign.setdefault(tag_id, []).append(element_id) + + for tag_id, element_ids in to_assign.items(): + _raise_if_tag_action_failed("assignTag", tag_id, self._api.assign_tag(tag_id, element_ids)) + for tag_id, element_ids in to_unassign.items(): + _raise_if_tag_action_failed("unassignTag", tag_id, self._api.unassign_tag(tag_id, element_ids)) + + def _module_local_tags(self, module_id: str) -> list[str]: + """Current local (or effective) tags for ``module_id`` from the bound snapshot.""" + assert self._snapshot is not None + device_id = _device_of(module_id) + status = self._snapshot.get_module_status(device_id, module_id) + if status is None: + return [] + local = status.local_assigned_tags + return list(local) if local else list(status.assigned_tags) + # --- Internal: post-commit targeted refresh (ADR-0010) --- def _refresh_snapshot(self) -> None: @@ -604,9 +773,9 @@ def _refresh_snapshot(self) -> None: continue if entry.kind == _DEVICE: device_ids.add(entry.entity_id) - elif entry.kind == _VERTEX: + elif entry.kind in (_VERTEX, _MODULE): device_ids.add(_device_of(entry.entity_id)) - else: + elif entry.kind == _EDGE: pair_ids.update(_pair_ids_for_edge(entry.entity_id)) self._snapshot.apply_post_commit( removed_ids=removed_ids, @@ -622,6 +791,7 @@ def _refresh_snapshot(self) -> None: _DEVICE = "device" _VERTEX = "vertex" _EDGE = "edge" +_MODULE = "module" class _Staged(InspectInternalModel): @@ -647,6 +817,14 @@ def _device_of(entity_id: str) -> str: return entity_id.split(".", 1)[0] +def _raise_if_tag_action_failed(action: str, tag_id: str, response: InspectApiSimpleActionResponse) -> None: + if response.header.ok and response.data.ok: + return + messages = list(response.data.msg) + list(response.header.msg) + detail = "; ".join(m for m in messages if m) or "tag action rejected by the server" + raise InspectError(f"Inspect {action} failed for tag '{tag_id}': {detail}") + + def _entity_kind(entity_id: str) -> str: """Classify an entity id as device, vertex, or edge for staging.""" if "::" in entity_id: @@ -705,12 +883,55 @@ def _merged_weight_factors( def _apply_intents(form: Any, intents: dict[str, Any]) -> None: + """Apply wire-field intents onto a baseline form. Supports arbitrary dotted paths and deep-merges + when the terminal parent is a ``dict`` (e.g. codec ``mainDstInfo.port``, edge ``weightFactors``).""" for key, value in intents.items(): - if "." in key: - head, tail = key.split(".", 1) - setattr(getattr(form, head), tail, value) - else: + if "." not in key: setattr(form, key, value) + continue + parts = key.split(".") + target: Any = form + for index, part in enumerate(parts[:-1]): + next_target = _get_path_child(target, part) + if next_target is None: + # Intermediate containers are dicts (codec generic/specific, weightFactors, …). + next_target = {} + _set_path_child(target, part, next_target) + # Re-fetch in case the parent model replaced the assigned value. + next_target = _get_path_child(target, part) + if next_target is None: + raise ValueError(f"Cannot create intermediate path '{'.'.join(parts[: index + 1])}' on form.") + target = next_target + leaf = parts[-1] + if isinstance(target, dict): + existing = target.get(leaf) + if isinstance(existing, dict) and isinstance(value, dict): + merged = copy.deepcopy(existing) + merged.update(value) + target[leaf] = merged + else: + target[leaf] = value + else: + existing = getattr(target, leaf, None) + if isinstance(existing, dict) and isinstance(value, dict): + merged = copy.deepcopy(existing) + merged.update(value) + setattr(target, leaf, merged) + else: + setattr(target, leaf, value) + + +def _get_path_child(obj: Any, name: str) -> Any: + if isinstance(obj, dict): + return obj.get(name) + return getattr(obj, name, None) + + +def _set_path_child(obj: Any, name: str, value: Any) -> None: + if isinstance(obj, dict): + obj[name] = value + else: + setattr(obj, name, value) def _checkable(entry: _Staged) -> bool: diff --git a/src/videoipath_automation_tool/apps/topology/errors.py b/src/videoipath_automation_tool/apps/topology/errors.py new file mode 100644 index 0000000..286f0a9 --- /dev/null +++ b/src/videoipath_automation_tool/apps/topology/errors.py @@ -0,0 +1,7 @@ +"""Typed exceptions for the Topology app.""" + +from __future__ import annotations + + +class TopologyUnsupportedError(Exception): + """Raised when TopologyApp is used against an unsupported VideoIPath version.""" diff --git a/src/videoipath_automation_tool/apps/topology/topology_app.py b/src/videoipath_automation_tool/apps/topology/topology_app.py index b590dd9..32d123d 100644 --- a/src/videoipath_automation_tool/apps/topology/topology_app.py +++ b/src/videoipath_automation_tool/apps/topology/topology_app.py @@ -1,8 +1,10 @@ import logging +import warnings from typing import List, Literal, Optional from typing_extensions import deprecated +from videoipath_automation_tool.apps.topology.errors import TopologyUnsupportedError from videoipath_automation_tool.apps.topology.helper.placement import TopologyPlacement from videoipath_automation_tool.apps.topology.model.n_graph_elements.topology_base_device import BaseDevice from videoipath_automation_tool.apps.topology.model.n_graph_elements.topology_codec_vertex import CodecVertex @@ -20,6 +22,9 @@ from videoipath_automation_tool.utils.cross_app_utils import create_fallback_logger from videoipath_automation_tool.validators.device_id_including_virtual import validate_device_id_including_virtual +_DEPRECATION_MAJOR = 2025 +_UNSUPPORTED_MAJOR = 2026 + class TopologyApp: def __init__(self, vip_connector: VideoIPathConnector, logger: Optional[logging.Logger] = None): @@ -32,6 +37,8 @@ def __init__(self, vip_connector: VideoIPathConnector, logger: Optional[logging. # --- Setup Logging --- self._logger = logger or create_fallback_logger("videoipath_automation_tool_topology_app") + self._check_version_compatibility(vip_connector) + # --- Setup Topology API --- self._topology_api = TopologyAPI(vip_connector=vip_connector, logger=self._logger) @@ -46,6 +53,25 @@ def __init__(self, vip_connector: VideoIPathConnector, logger: Optional[logging. self._logger.debug("Topology APP initialized.") + def _check_version_compatibility(self, vip_connector: VideoIPathConnector) -> None: + version = vip_connector.videoipath_version + parsed = _parse_version(version) + if parsed is None: + return + + major, _minor = parsed + if major >= _UNSUPPORTED_MAJOR: + raise TopologyUnsupportedError( + f"TopologyApp is not supported on VideoIPath {version}. Use InspectApp (app.inspect) instead." + ) + if major == _DEPRECATION_MAJOR: + message = ( + "TopologyApp is deprecated on VideoIPath 2025.x and will not be supported on 2026.x. " + "Migrate to InspectApp (app.inspect)." + ) + warnings.warn(message, DeprecationWarning, stacklevel=3) + self._logger.warning(message) + # --- Topology Device CRUD Operations --- def get_device(self, device_id: str) -> TopologyDevice: @@ -411,6 +437,16 @@ def create_edges( ) +def _parse_version(version: str) -> Optional[tuple[int, int]]: + parts = version.split(".") + if len(parts) < 2: + return None + try: + return int(parts[0]), int(parts[1]) + except ValueError: + return None + + class TopologyExperimental: def __init__(self, topology_api: TopologyAPI, logger: logging.Logger, get_device_method): """Experimental layer for the TopologyApp.""" diff --git a/src/videoipath_automation_tool/connector/models/response_rest_v2.py b/src/videoipath_automation_tool/connector/models/response_rest_v2.py index 3a1dbf2..54b14a3 100644 --- a/src/videoipath_automation_tool/connector/models/response_rest_v2.py +++ b/src/videoipath_automation_tool/connector/models/response_rest_v2.py @@ -69,6 +69,9 @@ class ResponseV2Patch(ResponseV2): # --- POST --- class ResponseV2Post(ResponseV2): - """REST API v2 POST Response""" + """REST API v2 POST Response. - data: dict + ``data`` may be ``null`` for some actions (e.g. ``assignTag`` / ``unassignTag``). + """ + + data: dict | None = None diff --git a/src/videoipath_automation_tool/connector/vip_rest_connector.py b/src/videoipath_automation_tool/connector/vip_rest_connector.py index fce914d..52a1905 100644 --- a/src/videoipath_automation_tool/connector/vip_rest_connector.py +++ b/src/videoipath_automation_tool/connector/vip_rest_connector.py @@ -16,6 +16,7 @@ class VideoIPathRestConnector(VideoIPathBaseConnector): "PREFIXES": { "/rest/v2/actions/status/collector/", "/rest/v2/actions/status/network/", + "/rest/v2/actions/status/tags/", }, "EXACT_MATCHES": {"/rest/v2/actions/status/pathman/validateTopologyUpdate"}, }, diff --git a/tests/e2e/helpers.py b/tests/e2e/helpers.py index e7a8eca..725a725 100644 --- a/tests/e2e/helpers.py +++ b/tests/e2e/helpers.py @@ -29,12 +29,16 @@ E2E_TAG = "vipat-e2e" MOCK_DRIVER = "com.nevion.mock-0.1.0" -# A test video tag, created via a simple API request and assigned to a port via the inspect app. -# Tag references are ``Category~~name`` ids; it lives in the existing "Video" format category. +# Catalog tags created via simple API requests for Inspect e2e. Tag references are +# ``Category~~name`` ids; they live in the existing "Video" format category. TEST_TAG_CATEGORY = "Video" TEST_TAG_NAME = "E2E-VIDEO-TAG" TEST_TAG_ID = f"{TEST_TAG_CATEGORY}~~{TEST_TAG_NAME}" +MODULE_TEST_TAG_CATEGORY = "Video" +MODULE_TEST_TAG_NAME = "E2E-MODULE-TAG" +MODULE_TEST_TAG_ID = f"{MODULE_TEST_TAG_CATEGORY}~~{MODULE_TEST_TAG_NAME}" + def unique_label(base: str) -> str: """A per-test unique device label, always under the ``E2E-`` prefix so the sweep catches orphans.""" @@ -125,12 +129,13 @@ def remove_devices(app: "VideoIPathApp", device_ids: set[str]) -> None: def sweep_e2e_namespace(app: "VideoIPathApp") -> None: - """Remove every ``E2E-`` device (and the test tag) so a run starts from a clean namespace. + """Remove every ``E2E-`` device (and e2e catalog tags) so a run starts from a clean namespace. Discovers devices by their ``E2E-`` label in **both** the inventory and the topology graph, catching orphans from an aborted run as well as the intentionally persisted workflow topology. """ delete_test_tag(app) + delete_module_test_tag(app) inventory_labels = app.inventory._inventory_api.fetch_devices_user_defined_labels_as_dict() inventory_ids = {i for i, label in inventory_labels.items() if (label or "").startswith(E2E_PREFIX)} app.inspect.refresh() @@ -220,44 +225,75 @@ def __exit__(self, *exc): # --- Test tag catalog (simple API requests) --- -def create_test_tag(app: "VideoIPathApp") -> None: - """Create the E2E test video tag in the catalog (idempotent).""" - category = _tag_category(app, TEST_TAG_CATEGORY) - if category is None: - raise RuntimeError(f"Tag category '{TEST_TAG_CATEGORY}' not found on the server.") - children = dict(category.get("children") or {}) - children[TEST_TAG_NAME] = {"exclusive": False, "children": {}} +def create_catalog_tag(app: "VideoIPathApp", *, category: str, name: str) -> str: + """Create a catalog tag under ``category`` (idempotent). Returns the ``Category~~name`` id.""" + cat = _tag_category(app, category) + if cat is None: + raise RuntimeError(f"Tag category '{category}' not found on the server.") + children = dict(cat.get("children") or {}) + children[name] = {"exclusive": False, "children": {}} body = { "actions": [ { "_action": "update", - "_id": TEST_TAG_CATEGORY, - "_rev": category["_rev"], + "_id": category, + "_rev": cat["_rev"], "children": children, - "type": category.get("type", "format"), - "exclusive": category.get("exclusive", False), - "formatTagLinks": category.get("formatTagLinks", {}), - "locationTypes": category.get("locationTypes", []), + "type": cat.get("type", "format"), + "exclusive": cat.get("exclusive", False), + "formatTagLinks": cat.get("formatTagLinks", {}), + "locationTypes": cat.get("locationTypes", []), } ] } _raw_request(app, "patch", "/rest/v2/data/config/tags/tagTrees", body) + return f"{category}~~{name}" + + +def catalog_tag_exists(app: "VideoIPathApp", *, category: str, name: str) -> bool: + cat = _tag_category(app, category) + return cat is not None and name in (cat.get("children") or {}) + + +def delete_catalog_tag(app: "VideoIPathApp", tag_id: str) -> None: + """Force-delete a catalog tag (removes it and any resource bindings) if it exists.""" + category, _, name = tag_id.partition("~~") + if not category or not name or not catalog_tag_exists(app, category=category, name=name): + return + _raw_request( + app, + "post", + "/rest/v2/actions/status/tags/forceDeleteTag", + {"header": {"id": 0}, "data": {"tagId": tag_id}}, + ) + + +def create_test_tag(app: "VideoIPathApp") -> None: + """Create the E2E port/vertex test video tag in the catalog (idempotent).""" + create_catalog_tag(app, category=TEST_TAG_CATEGORY, name=TEST_TAG_NAME) def test_tag_exists(app: "VideoIPathApp") -> bool: - category = _tag_category(app, TEST_TAG_CATEGORY) - return category is not None and TEST_TAG_NAME in (category.get("children") or {}) + return catalog_tag_exists(app, category=TEST_TAG_CATEGORY, name=TEST_TAG_NAME) def delete_test_tag(app: "VideoIPathApp") -> None: - """Force-delete the test tag (removes it and any port bindings) if it exists.""" - if test_tag_exists(app): - _raw_request( - app, - "post", - "/rest/v2/actions/status/tags/forceDeleteTag", - {"header": {"id": 0}, "data": {"tagId": TEST_TAG_ID}}, - ) + """Force-delete the port/vertex test tag (removes it and any port bindings) if it exists.""" + delete_catalog_tag(app, TEST_TAG_ID) + + +def create_module_test_tag(app: "VideoIPathApp") -> None: + """Create the E2E module test tag in the catalog (idempotent).""" + create_catalog_tag(app, category=MODULE_TEST_TAG_CATEGORY, name=MODULE_TEST_TAG_NAME) + + +def module_test_tag_exists(app: "VideoIPathApp") -> bool: + return catalog_tag_exists(app, category=MODULE_TEST_TAG_CATEGORY, name=MODULE_TEST_TAG_NAME) + + +def delete_module_test_tag(app: "VideoIPathApp") -> None: + """Force-delete the module test tag (removes it and any module bindings) if it exists.""" + delete_catalog_tag(app, MODULE_TEST_TAG_ID) # --- Internal --- diff --git a/tests/e2e/inspect/test_e2e_inspect.py b/tests/e2e/inspect/test_e2e_inspect.py index 2c7cf75..87c8264 100644 --- a/tests/e2e/inspect/test_e2e_inspect.py +++ b/tests/e2e/inspect/test_e2e_inspect.py @@ -17,7 +17,17 @@ from videoipath_automation_tool.apps.inspect.errors import InspectCommitConflictError, InspectCommitError from videoipath_automation_tool.apps.videoipath_app import VideoIPathApp -from ..helpers import TEST_TAG_ID, FetchSpy, TopologyBuilder, create_test_tag, delete_test_tag, edges_between +from ..helpers import ( + MODULE_TEST_TAG_ID, + TEST_TAG_ID, + FetchSpy, + TopologyBuilder, + create_module_test_tag, + create_test_tag, + delete_module_test_tag, + delete_test_tag, + edges_between, +) pytestmark = pytest.mark.e2e @@ -122,6 +132,39 @@ def test_assign_tag_to_port(app: VideoIPathApp, topology_builder: TopologyBuilde delete_test_tag(app) # also removes the port binding +def test_assign_and_unassign_module_tag(app: VideoIPathApp, topology_builder: TopologyBuilder) -> None: + """Create a dedicated catalog tag, assign it to a module, then unassign it.""" + (device_id,) = topology_builder.add_devices([("MOD-TAG-A", 2)]) + create_module_test_tag(app) + try: + app.inspect.refresh() + device = app.inspect.get_device(device_id) + assert device is not None + assert device.modules, "mock device should expose at least one module" + module = device.modules[0] + module_id = module.id + + module.tags = [MODULE_TEST_TAG_ID] + result = app.inspect.update(module) + assert result.ok + + app.inspect.refresh() + module = app.inspect.get_device(device_id).get_module(module_id) + assert module is not None + assert MODULE_TEST_TAG_ID in module.tags + + module.tags = [] + result = app.inspect.update(module) + assert result.ok + + app.inspect.refresh() + module = app.inspect.get_device(device_id).get_module(module_id) + assert module is not None + assert MODULE_TEST_TAG_ID not in module.tags + finally: + delete_module_test_tag(app) + + def test_update_vertex_fields(app: VideoIPathApp, topology_builder: TopologyBuilder) -> None: """Write every editable vertex UI field and read it back via the typed InspectVertex.""" (device_id,) = topology_builder.add_devices([("VTX-A", 2)]) diff --git a/tests/inspect/test_domain_writes.py b/tests/inspect/test_domain_writes.py new file mode 100644 index 0000000..2ccd344 --- /dev/null +++ b/tests/inspect/test_domain_writes.py @@ -0,0 +1,422 @@ +"""Writable domain objects + app.inspect.update() cascade (offline).""" + +from __future__ import annotations + +from types import SimpleNamespace +from typing import Any + +import pytest + +from videoipath_automation_tool.apps.inspect.app.write import InspectWriteMixin +from videoipath_automation_tool.apps.inspect.domain.edge import InspectEdge +from videoipath_automation_tool.apps.inspect.domain.vertex import InspectCodecVertex, InspectIpVertex, build_vertex +from videoipath_automation_tool.apps.inspect.model.actions import ( + InspectApiLookupEdgeResponseItem, + InspectApiLookupInspectDeviceResponse, + InspectApiLookupVertexResponseData, +) +from videoipath_automation_tool.apps.inspect.model.update_topology import InspectApiUpdateTopologyResponse +from videoipath_automation_tool.apps.inspect.snapshot import InspectSnapshot, _IndexedEdge, _STAGED_MISSING +from videoipath_automation_tool.apps.inspect.transaction import InspectTransaction + +from .conftest import load_fixture + +_OK_HEADER = { + "auth": True, + "caption": "OK", + "code": "OK", + "errorCodes": [], + "errorDetails": [], + "id": "0", + "msg": [], + "ok": True, + "user": "api-user", +} + +CODEC_ID = "device-a.module-1.port-out-1.out" +DEVICE_ID = "device-a" +EDGE_ID = "device-a.1.p1.out::device-b.1.p1.in" + + +def test_vertex_setter_stages_and_reads_own_writes() -> None: + snapshot = InspectSnapshot() + vertex = build_vertex(snapshot, "device-a.1.v1", kind="generic", port_factory_label="Port A") + assert vertex.factory_label == "Port A" + assert vertex.label is None + + vertex.label = "new-label" + vertex.use_as_endpoint = True + vertex.tags = ["Video~~T"] + + assert vertex.label == "new-label" + assert vertex.use_as_endpoint is True + assert vertex.tags == ["Video~~T"] + staged = snapshot.get_staged_edits("vertex", "device-a.1.v1") + assert staged["label"] == "new-label" + assert staged["useAsEndpoint"] is True + assert staged["localAssignedTags"] == ["Video~~T"] + + +def test_codec_vertex_nested_setters() -> None: + snapshot = InspectSnapshot() + fixture = load_fixture("lookup_inspect_codec_vertex_by_id.json") + snapshot._vertex_details[CODEC_ID] = InspectApiLookupVertexResponseData.model_validate(fixture["data"]) + vertex = build_vertex(snapshot, CODEC_ID, kind="codec", port_factory_label="TX #1") + assert isinstance(vertex, InspectCodecVertex) + + assert vertex.sdp_support is True + vertex.sdp_support = False + vertex.main_destination_port = 50311 + assert vertex.sdp_support is False + assert vertex.main_destination_port == 50311 + + staged = snapshot.get_staged_edits("vertex", CODEC_ID) + assert staged["typeFields.specific.sdpSupport"] is False + assert staged["typeFields.generic.mainDstInfo.port"] == 50311 + + +def test_ip_vertex_supports_static_igmp_alias() -> None: + snapshot = InspectSnapshot() + vertex = build_vertex(snapshot, "device-a.SwitchingCore", kind="ip") + assert isinstance(vertex, InspectIpVertex) + vertex.supports_static_igmp_config = True + assert vertex.supports_static_igmp is True + assert snapshot.get_staged_value("vertex", "device-a.SwitchingCore", "typeFields.supportsStaticIgmpCfg") is True + + +def test_control_setter_warns() -> None: + snapshot = InspectSnapshot() + vertex = build_vertex(snapshot, "device-a.1.v1", kind="codec") + with pytest.warns(UserWarning, match="best-effort"): + vertex.control = "full" + assert snapshot.get_staged_value("vertex", "device-a.1.v1", "control") == "full" + + +def test_device_setters_stage_descriptor_and_icon() -> None: + snapshot = _skeleton_snapshot_with_device(DEVICE_ID, label="Old") + device = snapshot.get_device(DEVICE_ID) + assert device is not None + device.label = "New Label" + device.icon_type = "gateway" + device.tags = ["#SITE"] + device.coordinates = {"x": 10.0, "y": 20.0} + + assert device.label == "New Label" + assert device.icon_type == "gateway" + assert device.tags == ["#SITE"] + assert device.coordinates == {"x": 10.0, "y": 20.0} + staged = snapshot.get_staged_edits("device", DEVICE_ID) + assert staged["descriptor.label"] == "New Label" + assert staged["iconType"] == "gateway" + assert staged["coordinates"] == {"x": 10.0, "y": 20.0} + + +def test_update_device_cascades_vertex_edits() -> None: + api = FakeAPI() + api.devices[DEVICE_ID] = _device_response() + codec_fixture = load_fixture("lookup_inspect_codec_vertex_by_id.json") + api.vertices[CODEC_ID] = InspectApiLookupVertexResponseData.model_validate(codec_fixture["data"]) + + snapshot = _skeleton_snapshot_with_device(DEVICE_ID) + snapshot._vertex_details[CODEC_ID] = api.vertices[CODEC_ID] + snapshot._fetcher = api # type: ignore[assignment] + + device = snapshot.get_device(DEVICE_ID) + assert device is not None + device.label = "Synced" + vertex = build_vertex(snapshot, CODEC_ID, kind="codec") + assert isinstance(vertex, InspectCodecVertex) + vertex.sdp_support = True + vertex.main_destination_port = 50311 + + app = _WriteApp(api, snapshot) + result = app.update(device) + assert result.ok + assert len(api.update_calls) == 1 + delta = api.update_calls[0] + assert DEVICE_ID in delta.replaceDevices + assert delta.replaceDevices[DEVICE_ID].descriptor.label == "Synced" + assert CODEC_ID in delta.replaceVertices + form = delta.replaceVertices[CODEC_ID] + assert ( + form.typeFields.specific["sdpSupport"] is True + or getattr(form.typeFields, "specific", {}).get("sdpSupport") is True + or _nested_get(form, "typeFields.specific.sdpSupport") is True + ) + # Pending edits cleared after commit. + assert snapshot.get_staged_edits("device", DEVICE_ID) == {} + assert snapshot.get_staged_edits("vertex", CODEC_ID) == {} + + +def test_update_appends_to_existing_transaction() -> None: + api = FakeAPI() + api.devices[DEVICE_ID] = _device_response() + snapshot = _skeleton_snapshot_with_device(DEVICE_ID) + device = snapshot.get_device(DEVICE_ID) + assert device is not None + device.label = "Via Tx" + + app = _WriteApp(api, snapshot) + with app.transaction() as tx: + returned = app.update(device, tx=tx) + assert returned is tx + tx.commit() + assert api.update_calls[0].replaceDevices[DEVICE_ID].descriptor.label == "Via Tx" + + +def test_transaction_codec_nested_intents_round_trip() -> None: + api = FakeAPI() + codec_fixture = load_fixture("lookup_inspect_codec_vertex_by_id.json") + api.vertices[CODEC_ID] = InspectApiLookupVertexResponseData.model_validate(codec_fixture["data"]) + with InspectTransaction(api) as tx: + tx.update_vertex(CODEC_ID, sdp_support=False, main_destination_port=50311) + tx.commit() + form = api.update_calls[0].replaceVertices[CODEC_ID] + specific = getattr(form.typeFields, "specific") + generic = getattr(form.typeFields, "generic") + if isinstance(specific, dict): + assert specific["sdpSupport"] is False + else: + assert specific.sdpSupport is False + if isinstance(generic, dict): + assert generic["mainDstInfo"]["port"] == 50311 + assert generic["mainDstInfo"]["ip"] == "10.0.0.1" # untouched leaf preserved + else: + assert generic.mainDstInfo["port"] == 50311 + + +def test_edge_setters_stage_weight_factors() -> None: + from videoipath_automation_tool.apps.inspect.model.collector import InspectApiExternalEdgeStatus + + snapshot = InspectSnapshot() + indexed = _IndexedEdge( + edge_id=EDGE_ID, + pair_id="device-a::device-b", + edge=InspectApiExternalEdgeStatus(id=EDGE_ID), + pair_status=None, + primary_device_id="device-a", + secondary_device_id="device-b", + from_device_id="device-a", + from_port_id="p1", + to_device_id="device-b", + to_port_id="p1", + ) + edge = InspectEdge(snapshot=snapshot, indexed=indexed) + edge.weight = 42 + edge.bandwidth_weight_factor = 3 + assert edge.weight == 42 + assert edge.bandwidth_weight_factor == 3 + staged = snapshot.get_staged_edits("edge", EDGE_ID) + assert staged["weight"] == 42 + assert staged["weightFactors.bandwidth.weight"] == 3 + + +MODULE_ID = "device-a.dev.0" + + +def test_module_tags_setter_stages_and_reads_own_writes() -> None: + snapshot = _snapshot_with_module(DEVICE_ID, MODULE_ID, local_tags=["Format~~A"]) + module = snapshot.get_module(DEVICE_ID, MODULE_ID) + assert module is not None + assert module.tags == ["Format~~A"] + + module.tags = ["Format~~B", "Format~~C"] + assert module.tags == ["Format~~B", "Format~~C"] + assert snapshot.get_staged_edits("module", MODULE_ID)["tags"] == ["Format~~B", "Format~~C"] + + +def test_update_module_diffs_assign_and_unassign() -> None: + api = FakeAPI() + snapshot = _snapshot_with_module(DEVICE_ID, MODULE_ID, local_tags=["Format~~Keep", "Format~~Old"]) + app = _WriteApp(api, snapshot) + + result = app.update_module(MODULE_ID, tags=["Format~~Keep", "Format~~New"]) + assert result.ok + assert result.response is None + assert api.update_calls == [] + assert api.assign_calls == [("Format~~New", ["device:device-a.dev.0"])] + assert api.unassign_calls == [("Format~~Old", ["device:device-a.dev.0"])] + + +def test_update_module_noop_when_tags_unchanged() -> None: + api = FakeAPI() + snapshot = _snapshot_with_module(DEVICE_ID, MODULE_ID, local_tags=["Format~~A"]) + app = _WriteApp(api, snapshot) + + result = app.update_module(MODULE_ID, tags=["Format~~A"]) + assert result.ok + assert api.assign_calls == [] + assert api.unassign_calls == [] + + +def test_update_module_via_domain_object() -> None: + api = FakeAPI() + snapshot = _snapshot_with_module(DEVICE_ID, MODULE_ID, local_tags=[]) + module = snapshot.get_module(DEVICE_ID, MODULE_ID) + assert module is not None + module.tags = ["Format~~V_720p60"] + + app = _WriteApp(api, snapshot) + result = app.update(module) + assert result.ok + assert api.assign_calls == [("Format~~V_720p60", ["device:device-a.dev.0"])] + assert snapshot.get_staged_edits("module", MODULE_ID) == {} + + +def test_update_device_cascades_module_tag_edits() -> None: + api = FakeAPI() + api.devices[DEVICE_ID] = _device_response() + snapshot = _snapshot_with_module(DEVICE_ID, MODULE_ID, local_tags=["Format~~Old"]) + device = snapshot.get_device(DEVICE_ID) + assert device is not None + device.label = "Synced" + module = snapshot.get_module(DEVICE_ID, MODULE_ID) + assert module is not None + module.tags = ["Format~~New"] + + app = _WriteApp(api, snapshot) + result = app.update(device) + assert result.ok + assert len(api.update_calls) == 1 + assert api.update_calls[0].replaceDevices[DEVICE_ID].descriptor.label == "Synced" + assert api.assign_calls == [("Format~~New", ["device:device-a.dev.0"])] + assert api.unassign_calls == [("Format~~Old", ["device:device-a.dev.0"])] + assert snapshot.get_staged_edits("module", MODULE_ID) == {} + + +# --- Internal --- + + +def _nested_get(obj: Any, path: str) -> Any: + cur = obj + for part in path.split("."): + if cur is None: + return _STAGED_MISSING + if isinstance(cur, dict): + cur = cur.get(part) + else: + cur = getattr(cur, part, None) + return cur + + +def _skeleton_snapshot_with_device(device_id: str, label: str = "Device A") -> InspectSnapshot: + from videoipath_automation_tool.apps.inspect.model.collector import InspectApiNodeStatusItem + + node = InspectApiNodeStatusItem.model_validate( + { + "_id": device_id, + "deviceId": device_id, + "label": label, + "desc": "", + "tags": [], + "meta": {"iconType": "default", "iconSize": "medium", "coordinates": {"x": 0, "y": 0}}, + "status": {"sa": 0, "severity": 0}, + } + ) + return InspectSnapshot(device_items=[node]) + + +def _snapshot_with_module( + device_id: str, + module_id: str, + *, + local_tags: list[str] | None = None, + all_tags: list[str] | None = None, +) -> InspectSnapshot: + from videoipath_automation_tool.apps.inspect.model.collector import InspectApiModuleStatus + from videoipath_automation_tool.apps.inspect.snapshot import HydrationLevel + + snapshot = _skeleton_snapshot_with_device(device_id) + local_tags = list(local_tags or []) + all_tags = list(all_tags) if all_tags is not None else list(local_tags) + status = InspectApiModuleStatus.model_validate( + { + "_id": module_id, + "pid": module_id, + "label": "Module 0", + "tagsInfo": { + "assigned": { + "all": all_tags, + "inherited": {}, + "inheritedConflict": False, + "local": {tag: {"label": tag, "path": []} for tag in local_tags}, + } + }, + } + ) + snapshot._modules_by_device_id[device_id] = {module_id: status} + record = snapshot._devices_by_id[device_id] + record.level = HydrationLevel.FULL + return snapshot + + +def _device_response(label: str = "Device A") -> InspectApiLookupInspectDeviceResponse: + return InspectApiLookupInspectDeviceResponse.model_validate( + { + "data": { + "assignedTags": {"all": [], "inherited": {}, "inheritedConflict": False, "local": {}}, + "fields": { + "coordinates": {"x": 0, "y": 0}, + "descriptor": {"desc": "", "label": label}, + "iconSize": "medium", + "iconType": "default", + "localAssignedTags": [], + "sdpStrategy": None, + "siteId": None, + "tags": [], + "virtualDeviceFields": None, + }, + }, + "header": _OK_HEADER, + } + ) + + +class FakeAPI: + def __init__(self) -> None: + self.devices: dict[str, InspectApiLookupInspectDeviceResponse] = {} + self.vertices: dict[str, InspectApiLookupVertexResponseData] = {} + self.edges: dict[str, InspectApiLookupEdgeResponseItem] = {} + self.update_response = InspectApiUpdateTopologyResponse.model_validate( + load_fixture("update_topology_success.json") + ) + self.update_calls: list[Any] = [] + self.assign_calls: list[tuple[str, list[str]]] = [] + self.unassign_calls: list[tuple[str, list[str]]] = [] + + def lookup_inspect_device(self, device_id: str) -> InspectApiLookupInspectDeviceResponse: + if device_id not in self.devices: + raise KeyError(device_id) + return self.devices[device_id] + + def lookup_vertices(self, ids: list[str]) -> SimpleNamespace: + return SimpleNamespace(data={i: self.vertices[i] for i in ids if i in self.vertices}) + + def lookup_edges(self, ids: list[str]) -> SimpleNamespace: + return SimpleNamespace(data={i: self.edges[i] for i in ids if i in self.edges}) + + def update_topology(self, delta: Any) -> InspectApiUpdateTopologyResponse: + self.update_calls.append(delta) + return self.update_response + + def assign_tag(self, tag_id: str, element_ids: list[str]) -> SimpleNamespace: + self.assign_calls.append((tag_id, list(element_ids))) + return _ok_simple_action() + + def unassign_tag(self, tag_id: str, element_ids: list[str]) -> SimpleNamespace: + self.unassign_calls.append((tag_id, list(element_ids))) + return _ok_simple_action() + + +def _ok_simple_action() -> SimpleNamespace: + return SimpleNamespace( + header=SimpleNamespace(ok=True, msg=[]), + data=SimpleNamespace(ok=True, msg=[]), + ) + + +class _WriteApp(InspectWriteMixin): + def __init__(self, api: FakeAPI, snapshot: InspectSnapshot) -> None: + self._inspect_api = api # type: ignore[assignment] + self._logger = __import__("logging").getLogger("test") + self._snapshot = snapshot diff --git a/tests/topology/__init__.py b/tests/topology/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/topology/test_version_compat.py b/tests/topology/test_version_compat.py new file mode 100644 index 0000000..4abb7df --- /dev/null +++ b/tests/topology/test_version_compat.py @@ -0,0 +1,44 @@ +"""TopologyApp VideoIPath version compatibility gate.""" + +from __future__ import annotations + +import warnings +from types import SimpleNamespace + +import pytest + +from videoipath_automation_tool.apps.topology.errors import TopologyUnsupportedError +from videoipath_automation_tool.apps.topology.topology_app import TopologyApp + + +def _fake_connector(version: str) -> SimpleNamespace: + return SimpleNamespace(videoipath_version=version) + + +def test_topology_app_supported_on_2024() -> None: + with warnings.catch_warnings(record=True) as record: + warnings.simplefilter("always", DeprecationWarning) + app = TopologyApp(vip_connector=_fake_connector("2024.4.30")) # type: ignore[arg-type] + assert app is not None + assert not any(issubclass(w.category, DeprecationWarning) for w in record) + + +def test_topology_app_deprecated_on_2025() -> None: + with pytest.warns(DeprecationWarning, match="deprecated on VideoIPath 2025") as record: + app = TopologyApp(vip_connector=_fake_connector("2025.4.9")) # type: ignore[arg-type] + assert app is not None + assert len(record) == 1 + assert "InspectApp" in str(record[0].message) + + +def test_topology_app_unsupported_on_2026() -> None: + with pytest.raises(TopologyUnsupportedError, match="not supported on VideoIPath 2026.1.0"): + TopologyApp(vip_connector=_fake_connector("2026.1.0")) # type: ignore[arg-type] + + +def test_topology_app_skips_gate_for_unparseable_version() -> None: + with warnings.catch_warnings(record=True) as record: + warnings.simplefilter("always", DeprecationWarning) + app = TopologyApp(vip_connector=_fake_connector("unknown")) # type: ignore[arg-type] + assert app is not None + assert not any(issubclass(w.category, DeprecationWarning) for w in record) From 9d9110b365fa56cd92d7d96b4806542cb054c473 Mon Sep 17 00:00:00 2001 From: Jonas Scholl <43819845+JonasScholl@users.noreply.github.com> Date: Thu, 23 Jul 2026 02:52:17 +0200 Subject: [PATCH 21/34] add examples and improve the documentation --- README.md | 1 + docs/architecture/inspect-app/README.md | 2 +- .../examples/01_setup/01_connect_and_check.py | 69 +++++++ .../02_inventory/01_create_and_add_device.py | 63 +++++++ .../02_get_update_and_diff_device.py | 77 ++++++++ .../02_inventory/03_discovery_onboarding.py | 67 +++++++ .../02_inventory/04_backup_restore_clone.py | 74 ++++++++ .../05_driver_settings_and_queries.py | 64 +++++++ .../01_device_lifecycle_inspect.py | 82 +++++++++ .../01_device_lifecycle_topology.py | 71 ++++++++ .../02_configure_vertices_inspect.py | 73 ++++++++ .../02_configure_vertices_topology.py | 65 +++++++ .../03_connect_devices_with_edges_inspect.py | 85 +++++++++ .../03_connect_devices_with_edges_topology.py | 68 +++++++ .../04_grid_placement_inspect.py | 64 +++++++ .../04_grid_placement_topology.py | 67 +++++++ .../05_virtual_device_inspect.py | 60 +++++++ .../05_virtual_device_topology.py | 53 ++++++ .../01_explore_topology_read_only.py | 73 ++++++++ .../02_transactions_and_conflicts.py | 87 +++++++++ .../04_inspect/03_services_and_paths.py | 69 +++++++ .../01_security_domains_and_memberships.py | 85 +++++++++ .../examples/05_administration/02_profiles.py | 58 ++++++ .../05_administration/03_multicast_pools.py | 63 +++++++ .../01_full_onboarding_pipeline.py | 168 ++++++++++++++++++ .../06_workflows/02_network_audit_report.py | 81 +++++++++ .../06_workflows/03_bulk_retag_and_relabel.py | 80 +++++++++ docs/examples/README.md | 96 ++++++++++ .../{03_Topology.md => 03_A_Topology.md} | 115 ++++++++---- .../{05_Inspect.md => 03_B_Inspect.md} | 96 ++++++---- docs/getting-started-guide/README.md | 26 ++- pyproject.toml | 2 +- .../apps/inspect/app/actions.py | 29 ++- tests/inspect/test_actions.py | 110 ++++++++++-- 34 files changed, 2259 insertions(+), 84 deletions(-) create mode 100644 docs/examples/01_setup/01_connect_and_check.py create mode 100644 docs/examples/02_inventory/01_create_and_add_device.py create mode 100644 docs/examples/02_inventory/02_get_update_and_diff_device.py create mode 100644 docs/examples/02_inventory/03_discovery_onboarding.py create mode 100644 docs/examples/02_inventory/04_backup_restore_clone.py create mode 100644 docs/examples/02_inventory/05_driver_settings_and_queries.py create mode 100644 docs/examples/03_topology_and_inspect/01_device_lifecycle_inspect.py create mode 100644 docs/examples/03_topology_and_inspect/01_device_lifecycle_topology.py create mode 100644 docs/examples/03_topology_and_inspect/02_configure_vertices_inspect.py create mode 100644 docs/examples/03_topology_and_inspect/02_configure_vertices_topology.py create mode 100644 docs/examples/03_topology_and_inspect/03_connect_devices_with_edges_inspect.py create mode 100644 docs/examples/03_topology_and_inspect/03_connect_devices_with_edges_topology.py create mode 100644 docs/examples/03_topology_and_inspect/04_grid_placement_inspect.py create mode 100644 docs/examples/03_topology_and_inspect/04_grid_placement_topology.py create mode 100644 docs/examples/03_topology_and_inspect/05_virtual_device_inspect.py create mode 100644 docs/examples/03_topology_and_inspect/05_virtual_device_topology.py create mode 100644 docs/examples/04_inspect/01_explore_topology_read_only.py create mode 100644 docs/examples/04_inspect/02_transactions_and_conflicts.py create mode 100644 docs/examples/04_inspect/03_services_and_paths.py create mode 100644 docs/examples/05_administration/01_security_domains_and_memberships.py create mode 100644 docs/examples/05_administration/02_profiles.py create mode 100644 docs/examples/05_administration/03_multicast_pools.py create mode 100644 docs/examples/06_workflows/01_full_onboarding_pipeline.py create mode 100644 docs/examples/06_workflows/02_network_audit_report.py create mode 100644 docs/examples/06_workflows/03_bulk_retag_and_relabel.py create mode 100644 docs/examples/README.md rename docs/getting-started-guide/{03_Topology.md => 03_A_Topology.md} (61%) rename docs/getting-started-guide/{05_Inspect.md => 03_B_Inspect.md} (63%) diff --git a/README.md b/README.md index 055a49d..a1abc21 100644 --- a/README.md +++ b/README.md @@ -79,6 +79,7 @@ except Exception as e: ## Documentation - [Getting Started Guide](https://github.com/SWR-MoIP/VideoIPath-Automation-Tool/blob/main/docs/getting-started-guide/README.md) +- [Examples](https://github.com/SWR-MoIP/VideoIPath-Automation-Tool/blob/main/docs/examples/README.md) - [Driver Versioning](https://github.com/SWR-MoIP/VideoIPath-Automation-Tool/blob/main/docs/driver-versioning.md) - [Python Module Architecture](https://github.com/SWR-MoIP/VideoIPath-Automation-Tool/blob/main/docs/architecture/python-module-architecture.md) - [Development and Release](https://github.com/SWR-MoIP/VideoIPath-Automation-Tool/blob/main/docs/development-and-release.md) diff --git a/docs/architecture/inspect-app/README.md b/docs/architecture/inspect-app/README.md index 9f2ac79..06b9b15 100644 --- a/docs/architecture/inspect-app/README.md +++ b/docs/architecture/inspect-app/README.md @@ -21,7 +21,7 @@ reference is now a primary source. > (`src/videoipath_automation_tool/apps/inspect/`), with offline unit tests under > `tests/inspect/` and a developer-run live E2E suite under `tests/e2e/inspect/`. > All ten ADRs are Accepted. For usage, see the -> [Inspect getting-started page](../../getting-started-guide/05_Inspect.md). +> [Inspect getting-started page](../../getting-started-guide/03_B_Inspect.md). ## Reading order diff --git a/docs/examples/01_setup/01_connect_and_check.py b/docs/examples/01_setup/01_connect_and_check.py new file mode 100644 index 0000000..8153afa --- /dev/null +++ b/docs/examples/01_setup/01_connect_and_check.py @@ -0,0 +1,69 @@ +"""Connect to a VideoIPath server and verify the connection. + +Description +----------- +The entry point to the whole package is a single ``VideoIPathApp`` object. This example shows the two +ways to construct it (explicit arguments vs. ``VIPAT_*`` environment variables / a ``.env`` file), +how to verify connectivity, read the server version, and how the sub-apps +(``inventory``, ``topology``, ``inspect``, ``preferences``, ``profile``, ``security``) hang off it. + +Every other example in this folder assumes this connection boilerplate and jumps straight into the +relevant sub-app. + +Prerequisites +------------- +- A reachable VideoIPath server and valid credentials. +- videoipath-automation-tool installed (``pip install videoipath-automation-tool``). + +Related examples +---------------- +- 02_inventory/01_create_and_add_device.py +- 04_inspect/01_explore_topology_read_only.py +""" + +from __future__ import annotations + +from videoipath_automation_tool import VideoIPathApp + +# Placeholder values — replace with your environment's data. +SERVER_ADDRESS = "" +USERNAME = "" +PASSWORD = "" + + +def main() -> None: + # --- 1. Connect with explicit arguments ----------------------------------- + # `use_https`/`verify_ssl_cert` default to True; they are relaxed here for a lab server. + app = VideoIPathApp( + server_address=SERVER_ADDRESS, + username=USERNAME, + password=PASSWORD, + use_https=False, + verify_ssl_cert=False, + ) + + # --- 2. Alternative: connect from environment / .env ---------------------- + # With VIPAT_VIDEOIPATH_SERVER_ADDRESS, VIPAT_VIDEOIPATH_USERNAME, VIPAT_VIDEOIPATH_PASSWORD + # (and optional VIPAT_USE_HTTPS / VIPAT_VERIFY_SSL_CERT) set, no arguments are needed: + # + # app = VideoIPathApp() + + # --- 3. Verify the connection --------------------------------------------- + app.check_connection() # raises ConnectionError if the server is unreachable / credentials fail + print("Connected to", SERVER_ADDRESS) + # > Connected to 192.0.2.10 + + print("Server version:", app.get_server_version()) + # > Server version: 2025.4.9 + + # --- 4. Sub-apps are lazily initialized on first access ------------------- + # Each sub-app is the namespace for one area of the API. + print("Inventory devices:", len(app.inventory.list_device_ids_by_driver("com.nevion.NMOS_multidevice-0.1.0"))) + # > Inventory devices: 3 + + print("Topology devices in Inspect:", len(app.inspect.devices)) + # > Topology devices in Inspect: 12 + + +if __name__ == "__main__": + main() diff --git a/docs/examples/02_inventory/01_create_and_add_device.py b/docs/examples/02_inventory/01_create_and_add_device.py new file mode 100644 index 0000000..144cfab --- /dev/null +++ b/docs/examples/02_inventory/01_create_and_add_device.py @@ -0,0 +1,63 @@ +"""Create a device and add it to the inventory. + +Description +----------- +Onboarding a device into VideoIPath starts in the inventory: you create a staged device from a +driver id, fill in its address, label, and driver-specific ``custom_settings``, then add it. The +server assigns the device id, which is only known after ``add_device`` returns. + +The ``custom_settings`` object is a typed model chosen by the driver id, so your editor offers +completion for exactly the fields that driver supports. + +Prerequisites +------------- +- A reachable VideoIPath server and credentials with inventory write access. + +Related examples +---------------- +- 02_inventory/02_get_update_and_diff_device.py +- 02_inventory/05_driver_settings_and_queries.py +""" + +from __future__ import annotations + +from videoipath_automation_tool import VideoIPathApp + +SERVER_ADDRESS = "" +USERNAME = "" +PASSWORD = "" + +DRIVER = "com.nevion.NMOS_multidevice-0.1.0" + + +def main() -> None: + # --- 1. Connect ----------------------------------------------------------- + app = VideoIPathApp(server_address=SERVER_ADDRESS, username=USERNAME, password=PASSWORD, use_https=False) + + # --- 2. Create a staged device from a driver ------------------------------ + device = app.inventory.create_device(driver=DRIVER) + device.configuration.label = "device-a" + device.configuration.address = "192.0.2.20" + device.configuration.description = "Example media node" + + # Driver-specific settings are typed for the chosen driver (IntelliSense-friendly). + device.configuration.custom_settings.port = 8080 + device.configuration.custom_settings.indices_in_ids = False + + # --- 3. Add it to the inventory ------------------------------------------- + # `label_check`/`address_check` (both True by default) raise if a duplicate already exists. + online_device = app.inventory.add_device(device=device) + + print(f"Added '{online_device.configuration.label}' as {online_device.configuration.device_id}") + # > Added 'device-a' as device34 + + # --- 4. Re-adding the same label raises ----------------------------------- + try: + app.inventory.add_device(device=device) + except ValueError as error: + print("Rejected:", error) + # > Rejected: Device with label 'device-a' already exists in Inventory: ['device34'] + + +if __name__ == "__main__": + main() diff --git a/docs/examples/02_inventory/02_get_update_and_diff_device.py b/docs/examples/02_inventory/02_get_update_and_diff_device.py new file mode 100644 index 0000000..9242f47 --- /dev/null +++ b/docs/examples/02_inventory/02_get_update_and_diff_device.py @@ -0,0 +1,77 @@ +"""Fetch, diff, and update a device (idempotent writes). + +Description +----------- +This example shows the read-modify-write cycle for an inventory device and the "diff before write" +pattern that makes automation idempotent: stage the desired configuration, compare it against what is +already on the server, and only call ``update_device`` when something actually changed. Re-running the +same script is then a no-op. + +It also covers fetching a device by id, label, or address, and refreshing its live status. + +Prerequisites +------------- +- A reachable VideoIPath server; a device named ``device-a`` already in the inventory + (see 02_inventory/01_create_and_add_device.py). + +Related examples +---------------- +- 02_inventory/01_create_and_add_device.py +- 06_workflows/01_full_onboarding_pipeline.py +""" + +from __future__ import annotations + +from videoipath_automation_tool import VideoIPathApp + +SERVER_ADDRESS = "" +USERNAME = "" +PASSWORD = "" + +DRIVER = "com.nevion.NMOS_multidevice-0.1.0" + + +def main() -> None: + # --- 1. Connect ----------------------------------------------------------- + app = VideoIPathApp(server_address=SERVER_ADDRESS, username=USERNAME, password=PASSWORD, use_https=False) + + # --- 2. Fetch a device (by label, id, or address) ------------------------- + # Passing `custom_settings_type` gives typed access to the driver settings. + device = app.inventory.get_device( + label="device-a", + label_search_mode="user_defined_label_only", + custom_settings_type=DRIVER, + ) + print(device.configuration.device_id, device.configuration.address) + # > device34 192.0.2.20 + + # Equivalent lookups: + # app.inventory.get_device(device_id="device34") + # app.inventory.get_device(address="192.0.2.20") + + # --- 3. Stage the desired configuration ----------------------------------- + reference = app.inventory.get_device(device_id=device.configuration.device_id) + device.configuration.description = "Primary media node" + device.configuration.custom_settings.port = 8080 + + # --- 4. Diff, then write only when there is a change ----------------------- + diff = app.inventory.diff_device_configuration(reference_device=reference, staged_device=device) + changes = diff.configuration_diff + if changes.added or changes.changed or changes.removed: + app.inventory.update_device(device=device) + print("Updated device (changes applied).") + # > Updated device (changes applied). + else: + print("No changes — nothing to write.") + # > No changes — nothing to write. + + # --- 5. Refresh the live status ------------------------------------------- + # Device status is not updated automatically; refresh it explicitly. + app.inventory.refresh_device_status(device=device) + if device.status: + print("Reachable:", device.status.reachable) + # > Reachable: True + + +if __name__ == "__main__": + main() diff --git a/docs/examples/02_inventory/03_discovery_onboarding.py b/docs/examples/02_inventory/03_discovery_onboarding.py new file mode 100644 index 0000000..8d2e2a2 --- /dev/null +++ b/docs/examples/02_inventory/03_discovery_onboarding.py @@ -0,0 +1,67 @@ +"""Onboard auto-discovered devices. + +Description +----------- +VideoIPath discovers devices on the network before they are added to the inventory. This example +lists the discovered devices, builds an inventory device from a discovery entry's suggested +configuration, adds it, and shows how to enable/disable a device afterward. + +Prerequisites +------------- +- A reachable VideoIPath server with at least one discovered (but not yet onboarded) device. + +Related examples +---------------- +- 02_inventory/01_create_and_add_device.py +""" + +from __future__ import annotations + +from videoipath_automation_tool import VideoIPathApp + +SERVER_ADDRESS = "" +USERNAME = "" +PASSWORD = "" + + +def main() -> None: + # --- 1. Connect ----------------------------------------------------------- + app = VideoIPathApp(server_address=SERVER_ADDRESS, username=USERNAME, password=PASSWORD, use_https=False) + + # --- 2. List discovered devices ------------------------------------------- + discovered = app.inventory.get_discovered_devices() + for entry in discovered: + suggested_driver = entry.suggestedConfigs[0].driver if entry.suggestedConfigs else None + print(f"{entry.id} | already onboarded as: {entry.exists} | driver: {suggested_driver}") + # > OF-00:00:00:00:00:01 | already onboarded as: [] | driver: name='openflow' organization='com.nevion' ... + + # --- 3. Onboard the first not-yet-existing discovered device -------------- + candidate = next((entry for entry in discovered if not entry.exists), None) + if candidate is None: + print("Nothing new to onboard.") + # > Nothing new to onboard. + return + + device = app.inventory.create_device_from_discovered_device( + discovered_device_id=candidate.id, + driver="com.nevion.openflow-0.0.1", + ) + device.configuration.label = "device-b" + + online_device = app.inventory.add_device(device) + device_id = online_device.configuration.device_id + print(f"Onboarded {candidate.id} as {device_id}") + # > Onboarded OF-00:00:00:00:00:01 as device35 + + # --- 4. Enable / disable a device ----------------------------------------- + disabled = app.inventory.disable_device(device_id) + print("Active after disable:", disabled.configuration.active) + # > Active after disable: False + + enabled = app.inventory.enable_device(device_id) + print("Active after enable:", enabled.configuration.active) + # > Active after enable: True + + +if __name__ == "__main__": + main() diff --git a/docs/examples/02_inventory/04_backup_restore_clone.py b/docs/examples/02_inventory/04_backup_restore_clone.py new file mode 100644 index 0000000..8ffd8e8 --- /dev/null +++ b/docs/examples/02_inventory/04_backup_restore_clone.py @@ -0,0 +1,74 @@ +"""Back up, restore, and clone device configurations. + +Description +----------- +A device configuration can be dumped to a plain dict (easily serialized to JSON) for backup, then +parsed back into a device object to restore or compare it. The same mechanism clones a device: strip +its device id and add it again — either on the same server (a copy) or on a second server (migration). + +Prerequisites +------------- +- A reachable VideoIPath server; a device named ``device-a`` in the inventory. +- For the cross-server clone: a second reachable server. + +Related examples +---------------- +- 02_inventory/02_get_update_and_diff_device.py +""" + +from __future__ import annotations + +import json +from pathlib import Path + +from videoipath_automation_tool import VideoIPathApp + +SERVER_ADDRESS = "" +USERNAME = "" +PASSWORD = "" + +DRIVER = "com.nevion.NMOS_multidevice-0.1.0" +BACKUP_FILE = Path("device-a.backup.json") + + +def main() -> None: + # --- 1. Connect ----------------------------------------------------------- + app = VideoIPathApp(server_address=SERVER_ADDRESS, username=USERNAME, password=PASSWORD, use_https=False) + + device = app.inventory.get_device( + label="device-a", label_search_mode="user_defined_label_only", custom_settings_type=DRIVER + ) + + # --- 2. Back up the configuration to a JSON file -------------------------- + config_dump = app.inventory.dump_configuration(device) + BACKUP_FILE.write_text(json.dumps(config_dump, indent=4)) + print("Backed up to", BACKUP_FILE) + # > Backed up to device-a.backup.json + + # --- 3. Restore: load the backup and diff against the live config --------- + restored = app.inventory.parse_configuration(json.loads(BACKUP_FILE.read_text())) + live = app.inventory.get_device(device_id=restored.configuration.device_id) + diff = app.inventory.diff_device_configuration(reference_device=restored, staged_device=live) + if diff.configuration_diff.changed: + print("Live config drifted from the backup; run update_device(restored) to restore it.") + # > Live config drifted from the backup; run update_device(restored) to restore it. + + # --- 4. Clone on the same server ------------------------------------------ + # Removing the device id turns the object into a fresh "staged" device. + clone = app.inventory.parse_configuration(config_dump) + clone.configuration.label = "device-a-clone" + clone.configuration.address = "192.0.2.21" + clone.remove_device_id() + cloned_device = app.inventory.add_device(clone) + print("Cloned as", cloned_device.configuration.device_id) + # > Cloned as device36 + + # --- 5. Clone onto a second server (migration) ---------------------------- + # migration = app.inventory.parse_configuration(config_dump) + # migration.remove_device_id() + # prod_app = VideoIPathApp(server_address="198.51.100.10", username=USERNAME, password=PASSWORD, use_https=False) + # prod_app.inventory.add_device(migration) + + +if __name__ == "__main__": + main() diff --git a/docs/examples/02_inventory/05_driver_settings_and_queries.py b/docs/examples/02_inventory/05_driver_settings_and_queries.py new file mode 100644 index 0000000..9b1b131 --- /dev/null +++ b/docs/examples/02_inventory/05_driver_settings_and_queries.py @@ -0,0 +1,64 @@ +"""Driver-specific settings and inventory queries. + +Description +----------- +Every driver exposes its own typed ``custom_settings`` model, so the fields you can set depend on the +driver id you pass to ``create_device``. This example inspects and edits those settings, then shows +the common inventory lookup helpers (all device ids for a driver, id-by-label) and a short global SNMP +configuration section. + +Prerequisites +------------- +- A reachable VideoIPath server with devices in the inventory. + +Related examples +---------------- +- 02_inventory/01_create_and_add_device.py +""" + +from __future__ import annotations + +from videoipath_automation_tool import VideoIPathApp + +SERVER_ADDRESS = "" +USERNAME = "" +PASSWORD = "" + +DRIVER = "com.nevion.NMOS_multidevice-0.1.0" + + +def main() -> None: + # --- 1. Connect ----------------------------------------------------------- + app = VideoIPathApp(server_address=SERVER_ADDRESS, username=USERNAME, password=PASSWORD, use_https=False) + + # --- 2. Inspect and edit typed driver settings ---------------------------- + device = app.inventory.create_device(driver=DRIVER) + settings = device.configuration.custom_settings + + # The available fields are specific to this driver; read defaults, then change them. + print("Default NMOS port:", settings.port) + # > Default NMOS port: 8080 + settings.port = 8000 + settings.indices_in_ids = True + + # --- 3. Query the inventory ----------------------------------------------- + device_ids = app.inventory.list_device_ids_by_driver(DRIVER) + print(f"{len(device_ids)} device(s) use this driver: {device_ids}") + # > 2 device(s) use this driver: ['device34', 'device36'] + + device_id = app.inventory.find_device_id_by_label("device-a", label_search_mode="user_defined_label_only") + print("device-a ->", device_id) + # > device-a -> device34 + + # --- 4. Global SNMP configurations ---------------------------------------- + snmp_configs = app.inventory.get_all_global_snmp_config_ids() + print("Global SNMP configs:", snmp_configs) + # > Global SNMP configs: {'default': 'default'} + + default_snmp = app.inventory.get_global_snmp_config("default") + print("Default SNMP read community:", default_snmp.security.read.community) + # > Default SNMP read community: public + + +if __name__ == "__main__": + main() diff --git a/docs/examples/03_topology_and_inspect/01_device_lifecycle_inspect.py b/docs/examples/03_topology_and_inspect/01_device_lifecycle_inspect.py new file mode 100644 index 0000000..bbc336d --- /dev/null +++ b/docs/examples/03_topology_and_inspect/01_device_lifecycle_inspect.py @@ -0,0 +1,82 @@ +"""Device lifecycle in the topology (Inspect app): add, configure, remove. + +Description +----------- +Once a device exists in the inventory it can be placed into the topology graph, given display metadata +(label, description, tags, icon, coordinates), and later removed. This is the Inspect-app variant; the +paired ``01_device_lifecycle_topology.py`` implements the same scenario with the classic Topology app. + +The recommended write style is: edit the domain object's properties and flush them with +``app.inspect.update(obj)`` (a unit of work that commits all pending edits). The keyword-argument +methods (``app.inspect.update_device(...)``) do the same thing in one call and are shown at the end as +an alternative. + +Prerequisites +------------- +- A reachable VideoIPath server, VideoIPath >= 2025.4. +- A device named ``device-a`` already in the inventory (see 02_inventory/01_create_and_add_device.py). + +Related examples +---------------- +- 03_topology_and_inspect/01_device_lifecycle_topology.py +- 04_inspect/01_explore_topology_read_only.py +""" + +from __future__ import annotations + +from videoipath_automation_tool import VideoIPathApp + +SERVER_ADDRESS = "" +USERNAME = "" +PASSWORD = "" + + +def main() -> None: + # --- 1. Connect ----------------------------------------------------------- + app = VideoIPathApp(server_address=SERVER_ADDRESS, username=USERNAME, password=PASSWORD, use_https=False) + + device_id = app.inventory.find_device_id_by_label("device-a", label_search_mode="user_defined_label_only") + assert isinstance(device_id, str) + + # --- 2. Add the device to the topology graph ------------------------------ + # Places at (x, y) and syncs driver-reported ports/vertices (sync=True by default). + app.inspect.add_devices_to_topology([(device_id, 1000, 500)]) + + # --- 3. Apply base configuration via property setters (recommended) ------- + device = app.inspect.get_device(device_id) + assert device is not None + device.label = "leaf-1" + device.description = "Top-of-rack leaf switch" + device.tags = ["site-a", "leaf"] + device.icon_type = "ipSwitchRouter" + device.coordinates = {"x": 1000, "y": 500} + app.inspect.update(device) # commits every pending edit on the device in one transaction + print("Configured", device.label) + # > Configured leaf-1 + + # --- 4. Remove the device from the topology (guarded) --------------------- + # Refuse to remove a device that still carries booked services. + affected = app.inspect.get_services_for_device(device_id) + if affected: + print(f"Skipping removal: {len(affected)} service(s) still use this device.") + # > Skipping removal: 2 service(s) still use this device. + else: + app.inspect.remove_device_from_topology(device_id) + print("Removed from topology.") + # > Removed from topology. + + # --- 5. Alternative: keyword-style update in a single call ---------------- + # Equivalent to the setter block in step 3, without holding a domain object: + # + # app.inspect.update_device( + # device_id, + # label="leaf-1", + # description="Top-of-rack leaf switch", + # tags=["site-a", "leaf"], + # icon_type="ipSwitchRouter", + # coordinates={"x": 1000, "y": 500}, + # ) + + +if __name__ == "__main__": + main() diff --git a/docs/examples/03_topology_and_inspect/01_device_lifecycle_topology.py b/docs/examples/03_topology_and_inspect/01_device_lifecycle_topology.py new file mode 100644 index 0000000..e4095ff --- /dev/null +++ b/docs/examples/03_topology_and_inspect/01_device_lifecycle_topology.py @@ -0,0 +1,71 @@ +"""Device lifecycle in the topology (Topology app): add, configure, remove. + +Description +----------- +Once a device exists in the inventory it can be placed into the topology graph, given display metadata +(label, description, tags, icon, coordinates), and later removed. This is the classic Topology-app +variant; the paired ``01_device_lifecycle_inspect.py`` implements the same scenario with the +forward-looking Inspect app. + +With the Topology app you fetch a ``TopologyDevice``, mutate its nested ``configuration`` object, and +push the whole device with ``update_device`` (which diffs and writes only the changed graph elements). + +Prerequisites +------------- +- A reachable VideoIPath server; a device named ``device-a`` in the inventory. +- NOTE: the Topology app is deprecated on VideoIPath 2025.x and unsupported on 2026.x, where its + constructor raises ``TopologyUnsupportedError``. On modern servers prefer the paired + ``01_device_lifecycle_inspect.py``. + +Related examples +---------------- +- 03_topology_and_inspect/01_device_lifecycle_inspect.py +""" + +from __future__ import annotations + +from videoipath_automation_tool import VideoIPathApp + +SERVER_ADDRESS = "" +USERNAME = "" +PASSWORD = "" + + +def main() -> None: + # --- 1. Connect ----------------------------------------------------------- + app = VideoIPathApp(server_address=SERVER_ADDRESS, username=USERNAME, password=PASSWORD, use_https=False) + + device_id = app.inventory.find_device_id_by_label("device-a", label_search_mode="user_defined_label_only") + assert isinstance(device_id, str) + + # --- 2. Fetch the device's driver-generated graph ------------------------- + # get_device synthesizes the device from the driver if it is not yet placed in the topology. + device = app.topology.get_device(device_id=device_id) + + # --- 3. Apply base configuration via the configuration object ------------- + config = device.configuration + config.label = "leaf-1" + config.description = "Top-of-rack leaf switch" + config.tags = ["site-a", "leaf"] + config.icon_type = "ipSwitchRouter" + config.position_x = 1000 + config.position_y = 500 + + # update_device places the device if new, and writes only the changed graph elements. + updated = app.topology.update_device(device) + print("Configured", updated.configuration.label) + # > Configured leaf-1 + + # --- 4. Remove the device from the topology (guarded) --------------------- + affected = app.topology.list_services_affected_by_device_remove(device) + if affected: + print(f"Skipping removal: {len(affected)} service(s) still use this device.") + # > Skipping removal: 2 service(s) still use this device. + else: + app.topology.remove_device_by_id(device_id=device_id) + print("Removed from topology.") + # > Removed from topology. + + +if __name__ == "__main__": + main() diff --git a/docs/examples/03_topology_and_inspect/02_configure_vertices_inspect.py b/docs/examples/03_topology_and_inspect/02_configure_vertices_inspect.py new file mode 100644 index 0000000..4a03d07 --- /dev/null +++ b/docs/examples/03_topology_and_inspect/02_configure_vertices_inspect.py @@ -0,0 +1,73 @@ +"""Configure device vertices (Inspect app): endpoints, SIPS, media tags. + +Description +----------- +A device's vertices describe its media inputs/outputs (codec vertices) and network interfaces (IP +vertices). This example marks the codec vertices as usable endpoints, sets their SIPS mode, and tags +them with media profiles pulled from the profile app — the same pattern a vendor "vertex processor" +uses. The paired ``02_configure_vertices_topology.py`` does the same with the Topology app. + +The recommended write style edits each vertex's properties, collects the dirty objects, and flushes +them together with a single ``app.inspect.update([...])`` call. + +Prerequisites +------------- +- A reachable VideoIPath server, VideoIPath >= 2025.4. +- A device named ``leaf-1`` in the topology whose ports/vertices are synced. + +Related examples +---------------- +- 03_topology_and_inspect/02_configure_vertices_topology.py +- 05_administration/02_profiles.py +""" + +from __future__ import annotations + +from videoipath_automation_tool import VideoIPathApp + +SERVER_ADDRESS = "" +USERNAME = "" +PASSWORD = "" + + +def main() -> None: + # --- 1. Connect ----------------------------------------------------------- + app = VideoIPathApp(server_address=SERVER_ADDRESS, username=USERNAME, password=PASSWORD, use_https=False) + + device = app.inspect.find_device_by_label("leaf-1") + assert device is not None + + # Media profile tags to assign to endpoints (falls back to a fixed list if none are defined). + profile_tags = app.profile.list_profile_names() or ["V_1080i50", "V_1080p50"] + + # --- 2. Configure every codec vertex via property setters ----------------- + edited = [] + for vertex in device.codec_vertices: + vertex.use_as_endpoint = True + vertex.sips_mode = "SIPSAuto" + vertex.active = True + vertex.tags = profile_tags + edited.append(vertex) + + # --- 3. Flush all edits in a single unit of work -------------------------- + if edited: + app.inspect.update(edited) + print(f"Configured {len(edited)} codec vertices.") + # > Configured 8 codec vertices. + + # --- 4. Target a single vertex by its factory label ----------------------- + uplink = device.find_vertex_by_factory_label("port-out-1") + if uplink is not None: + uplink.label = "Uplink to spine-1" + app.inspect.update(uplink) + print("Labeled uplink vertex", uplink.id) + # > Labeled uplink vertex device34.1.3000000 + + # --- 5. Alternative: keyword-style update --------------------------------- + # A single vertex can also be edited without holding the object: + # + # app.inspect.update_vertex(uplink.id, use_as_endpoint=True, sips_mode="SIPSAuto", tags=profile_tags) + + +if __name__ == "__main__": + main() diff --git a/docs/examples/03_topology_and_inspect/02_configure_vertices_topology.py b/docs/examples/03_topology_and_inspect/02_configure_vertices_topology.py new file mode 100644 index 0000000..d4985e0 --- /dev/null +++ b/docs/examples/03_topology_and_inspect/02_configure_vertices_topology.py @@ -0,0 +1,65 @@ +"""Configure device vertices (Topology app): endpoints, SIPS, media tags. + +Description +----------- +A device's vertices describe its media inputs/outputs (codec vertices) and network interfaces (IP +vertices). This example marks the codec vertices as usable endpoints, sets their SIPS mode, and tags +them with media profiles pulled from the profile app. The paired ``02_configure_vertices_inspect.py`` +does the same with the Inspect app. + +With the Topology app you mutate the vertex objects held inside ``device.configuration`` and then push +the whole device once with ``update_device`` — a single write covers all the vertex changes. + +Prerequisites +------------- +- A reachable VideoIPath server; a device named ``leaf-1`` in the topology. +- NOTE: the Topology app is deprecated on VideoIPath 2025.x and unsupported on 2026.x (its + constructor raises ``TopologyUnsupportedError``). On modern servers prefer the paired + ``02_configure_vertices_inspect.py``. + +Related examples +---------------- +- 03_topology_and_inspect/02_configure_vertices_inspect.py +- 05_administration/02_profiles.py +""" + +from __future__ import annotations + +from videoipath_automation_tool import VideoIPathApp + +SERVER_ADDRESS = "" +USERNAME = "" +PASSWORD = "" + + +def main() -> None: + # --- 1. Connect ----------------------------------------------------------- + app = VideoIPathApp(server_address=SERVER_ADDRESS, username=USERNAME, password=PASSWORD, use_https=False) + + device_id = app.topology.find_device_id_by_label("leaf-1", label_search_mode="user_defined_label_only") + assert isinstance(device_id, str) + device = app.topology.get_device(device_id=device_id) + + profile_tags = app.profile.list_profile_names() or ["V_1080i50", "V_1080p50"] + + # --- 2. Configure every codec vertex in the configuration object ---------- + for vertex in device.configuration.codec_vertices: + vertex.use_as_endpoint = True + vertex.sips_mode = "SIPSAuto" + vertex.sdp_support = True + vertex.tags = profile_tags + + # --- 3. Target a single vertex by its factory label ----------------------- + uplink = device.configuration.get_vertex_by_label("port-out-1", label_type="factory") + if uplink is not None: + uplink.label = "Uplink to spine-1" + + # --- 4. Push the whole device once ---------------------------------------- + # update_device diffs against the server and writes only the changed graph elements. + app.topology.update_device(device=device) + print(f"Configured vertices on {device.configuration.label}.") + # > Configured vertices on leaf-1. + + +if __name__ == "__main__": + main() diff --git a/docs/examples/03_topology_and_inspect/03_connect_devices_with_edges_inspect.py b/docs/examples/03_topology_and_inspect/03_connect_devices_with_edges_inspect.py new file mode 100644 index 0000000..be4b861 --- /dev/null +++ b/docs/examples/03_topology_and_inspect/03_connect_devices_with_edges_inspect.py @@ -0,0 +1,85 @@ +"""Connect two devices with edges (Inspect app). + +Description +----------- +A physical link between two devices is modeled as a pair of directed edges (one per direction) between +an out-vertex on one device and an in-vertex on the other. This example connects ``leaf-1`` to +``spine-1`` bidirectionally, then tunes the resulting edge's routing weight. The paired +``03_connect_devices_with_edges_topology.py`` does the same with the Topology app. + +Edge creation has no property-setter form, so ``connect`` is used directly; editing an existing edge +uses the recommended setter + ``app.inspect.update(edge)`` style. + +Prerequisites +------------- +- A reachable VideoIPath server, VideoIPath >= 2025.4. +- Devices ``leaf-1`` and ``spine-1`` in the topology with synced ports. + +Related examples +---------------- +- 03_topology_and_inspect/03_connect_devices_with_edges_topology.py +- 04_inspect/02_transactions_and_conflicts.py +""" + +from __future__ import annotations + +from videoipath_automation_tool import VideoIPathApp +from videoipath_automation_tool.apps.inspect import InspectDevice +from videoipath_automation_tool.apps.inspect.domain import InspectVertex + +SERVER_ADDRESS = "" +USERNAME = "" +PASSWORD = "" + + +def first_free_port_vertices(device: InspectDevice) -> tuple[InspectVertex, InspectVertex]: + """Return the (out, in) vertices of the device's first usable port.""" + for port in device.ports: + if port.vertex_out is not None and port.vertex_in is not None: + return port.vertex_out, port.vertex_in + raise LookupError(f"No connectable port found on {device.label}.") + + +def main() -> None: + # --- 1. Connect ----------------------------------------------------------- + app = VideoIPathApp(server_address=SERVER_ADDRESS, username=USERNAME, password=PASSWORD, use_https=False) + + leaf = app.inspect.find_device_by_label("leaf-1") + spine = app.inspect.find_device_by_label("spine-1") + assert leaf is not None and spine is not None + + leaf_out, leaf_in = first_free_port_vertices(leaf) + spine_out, spine_in = first_free_port_vertices(spine) + + # --- 2. Create the bidirectional link ------------------------------------- + # bidirectional=True also stages the reverse edge. Extra edge fields (bandwidth, weight, + # redundancy) are passed straight through. Bandwidth here reserves 10% headroom (90% of 10G). + app.inspect.connect( + leaf_out.id, + spine_in.id, + bidirectional=True, + bandwidth=int(10_000 * 0.9), + redundancy_mode="OnlyMain", + ) + print(f"Connected {leaf.label} <-> {spine.label}") + # > Connected leaf-1 <-> spine-1 + + # --- 3. Verify connectivity ----------------------------------------------- + neighbours = {d.label for d in leaf.linked_devices} + print("leaf-1 neighbours:", neighbours) + # > leaf-1 neighbours: {'spine-1'} + + # --- 4. Tune an existing edge via setter (recommended) -------------------- + edge = next((e for e in leaf.edges if e.to_device and e.to_device.label == "spine-1"), None) + if edge is not None: + edge.weight = 7 + app.inspect.update(edge) + print("Set edge weight to", edge.weight) + # > Set edge weight to 7 + + # --- 5. Remove the link ---------------------------------------------------- + # app.inspect.disconnect(leaf_out.id, spine_in.id, bidirectional=True) + + +if __name__ == "__main__": + main() diff --git a/docs/examples/03_topology_and_inspect/03_connect_devices_with_edges_topology.py b/docs/examples/03_topology_and_inspect/03_connect_devices_with_edges_topology.py new file mode 100644 index 0000000..c295ebf --- /dev/null +++ b/docs/examples/03_topology_and_inspect/03_connect_devices_with_edges_topology.py @@ -0,0 +1,68 @@ +"""Connect two devices with edges (Topology app). + +Description +----------- +A physical link between two devices is modeled as directed edges between their vertices. This example +connects ``leaf-1`` to ``spine-1`` using ``create_edges`` (which resolves the correct vertex pairing +from factory labels), attaches the edges to the device, and pushes it. The paired +``03_connect_devices_with_edges_inspect.py`` does the same with the Inspect app. + +Prerequisites +------------- +- A reachable VideoIPath server; devices ``leaf-1`` and ``spine-1`` in the topology. +- NOTE: the Topology app is deprecated on VideoIPath 2025.x and unsupported on 2026.x (its + constructor raises ``TopologyUnsupportedError``). On modern servers prefer the paired + ``03_connect_devices_with_edges_inspect.py``. + +Related examples +---------------- +- 03_topology_and_inspect/03_connect_devices_with_edges_inspect.py +""" + +from __future__ import annotations + +from videoipath_automation_tool import VideoIPathApp + +SERVER_ADDRESS = "" +USERNAME = "" +PASSWORD = "" + + +def main() -> None: + # --- 1. Connect ----------------------------------------------------------- + app = VideoIPathApp(server_address=SERVER_ADDRESS, username=USERNAME, password=PASSWORD, use_https=False) + + leaf_id = app.topology.find_device_id_by_label("leaf-1", label_search_mode="user_defined_label_only") + spine_id = app.topology.find_device_id_by_label("spine-1", label_search_mode="user_defined_label_only") + assert isinstance(leaf_id, str) and isinstance(spine_id, str) + + # --- 2. Build the directed edges between two ports ------------------------ + # create_edges pairs the out/in vertices behind the given factory labels. `bandwidth_factor` + # reserves headroom (0.9 -> use 90% of the nominal bandwidth); redundancy_mode tags the link. + edges = app.topology.create_edges( + device_1_id=leaf_id, + device_1_vertex_factory_label="port-out-1", + device_2_id=spine_id, + device_2_vertex_factory_label="port-in-1", + bandwidth=10000, + bandwidth_factor=0.9, + redundancy_mode="OnlyMain", + ) + + # --- 3. Attach the edges to the device and push --------------------------- + leaf = app.topology.get_device(device_id=leaf_id) + leaf.configuration.external_edges.extend(edges) + app.topology.update_device(device=leaf) + print(f"Connected leaf-1 <-> spine-1 with {len(edges)} directed edge(s).") + # > Connected leaf-1 <-> spine-1 with 2 directed edge(s). + + # --- 4. Tune an existing edge --------------------------------------------- + edge = edges[0] + edge.weight = 7 + app.topology.update_element(edge) + print("Set edge weight to", edge.weight) + # > Set edge weight to 7 + + +if __name__ == "__main__": + main() diff --git a/docs/examples/03_topology_and_inspect/04_grid_placement_inspect.py b/docs/examples/03_topology_and_inspect/04_grid_placement_inspect.py new file mode 100644 index 0000000..44c12d9 --- /dev/null +++ b/docs/examples/03_topology_and_inspect/04_grid_placement_inspect.py @@ -0,0 +1,64 @@ +"""Lay out devices in a grid (Inspect app). + +Description +----------- +Automated topology builds usually arrange devices on the map programmatically. This example computes a +tidy grid for a set of leaf/spine devices and moves them there — but only writes the positions that +actually changed, so re-running it is a no-op. The paired ``04_grid_placement_topology.py`` does the +same with the Topology app. + +Prerequisites +------------- +- A reachable VideoIPath server, VideoIPath >= 2025.4. +- The devices below present in the topology. + +Related examples +---------------- +- 03_topology_and_inspect/04_grid_placement_topology.py +""" + +from __future__ import annotations + +from videoipath_automation_tool import VideoIPathApp + +SERVER_ADDRESS = "" +USERNAME = "" +PASSWORD = "" + +DEVICE_LABELS = ["leaf-1", "leaf-2", "leaf-3", "leaf-4", "spine-1", "spine-2"] + +ORIGIN_X, ORIGIN_Y = 1000, 500 +COLUMNS = 3 +SPACING = 300 + + +def main() -> None: + # --- 1. Connect ----------------------------------------------------------- + app = VideoIPathApp(server_address=SERVER_ADDRESS, username=USERNAME, password=PASSWORD, use_https=False) + + # --- 2. Compute the target grid coordinates ------------------------------- + targets: dict[str, tuple[int, int]] = {} + for index, label in enumerate(DEVICE_LABELS): + row, col = divmod(index, COLUMNS) + targets[label] = (ORIGIN_X + col * SPACING, ORIGIN_Y + row * SPACING) + + # --- 3. Move only the devices whose position changed ---------------------- + moved = 0 + with app.inspect.transaction() as tx: + for label, (x, y) in targets.items(): + device = app.inspect.find_device_by_label(label) + if device is None: + continue + current = device.coordinates or {} + if (current.get("x"), current.get("y")) == (x, y): + continue # already in place — skip the write + tx.place_device(device.id, x, y) + moved += 1 + tx.commit() + + print(f"Repositioned {moved} device(s).") + # > Repositioned 6 device(s). + + +if __name__ == "__main__": + main() diff --git a/docs/examples/03_topology_and_inspect/04_grid_placement_topology.py b/docs/examples/03_topology_and_inspect/04_grid_placement_topology.py new file mode 100644 index 0000000..ec764bf --- /dev/null +++ b/docs/examples/03_topology_and_inspect/04_grid_placement_topology.py @@ -0,0 +1,67 @@ +"""Lay out devices in a grid (Topology app). + +Description +----------- +Automated topology builds usually arrange devices on the map programmatically. This example reads the +current positions with ``placement.get_all_device_positions``, computes a tidy grid, and moves only the +devices whose position changed. The paired ``04_grid_placement_inspect.py`` does the same with the +Inspect app. + +Prerequisites +------------- +- A reachable VideoIPath server with the devices below present in the topology. +- NOTE: the Topology app is deprecated on VideoIPath 2025.x and unsupported on 2026.x (its + constructor raises ``TopologyUnsupportedError``). On modern servers prefer the paired + ``04_grid_placement_inspect.py``. + +Related examples +---------------- +- 03_topology_and_inspect/04_grid_placement_inspect.py +""" + +from __future__ import annotations + +from videoipath_automation_tool import VideoIPathApp + +SERVER_ADDRESS = "" +USERNAME = "" +PASSWORD = "" + +DEVICE_LABELS = ["leaf-1", "leaf-2", "leaf-3", "leaf-4", "spine-1", "spine-2"] + +ORIGIN_X, ORIGIN_Y = 1000, 500 +COLUMNS = 3 +SPACING = 300 + + +def main() -> None: + # --- 1. Connect ----------------------------------------------------------- + app = VideoIPathApp(server_address=SERVER_ADDRESS, username=USERNAME, password=PASSWORD, use_https=False) + + # --- 2. Read the current positions of all devices ------------------------- + current_positions = app.topology.placement.get_all_device_positions() + + # --- 3. Move only the devices whose position changed ---------------------- + moved = 0 + for index, label in enumerate(DEVICE_LABELS): + device_id = app.topology.find_device_id_by_label(label, label_search_mode="user_defined_label_only") + if not isinstance(device_id, str): + continue + + row, col = divmod(index, COLUMNS) + x, y = ORIGIN_X + col * SPACING, ORIGIN_Y + row * SPACING + + current = current_positions.get(device_id, {}) + if (current.get("x"), current.get("y")) == (x, y): + continue # already in place — skip the write + + # fetch_device=False keeps bulk repositioning fast (no re-fetch after each move). + app.topology.placement.set_device_position(device_id, x=x, y=y, fetch_device=False) + moved += 1 + + print(f"Repositioned {moved} device(s).") + # > Repositioned 6 device(s). + + +if __name__ == "__main__": + main() diff --git a/docs/examples/03_topology_and_inspect/05_virtual_device_inspect.py b/docs/examples/03_topology_and_inspect/05_virtual_device_inspect.py new file mode 100644 index 0000000..e888ee7 --- /dev/null +++ b/docs/examples/03_topology_and_inspect/05_virtual_device_inspect.py @@ -0,0 +1,60 @@ +"""Create a virtual (driverless) device (Inspect app). + +Description +----------- +Virtual devices model endpoints that have no real driver — patch panels, tie-line groups, or external +switches. This example builds one from a port-template spec and places it on the map. The paired +``05_virtual_device_topology.py`` does the same with the Topology app. + +A virtual device is created unplaced; afterward it behaves like any other device (``place_device``, +metadata edits via setters, ``remove_device_from_topology``). + +Prerequisites +------------- +- A reachable VideoIPath server, VideoIPath >= 2025.4. + +Related examples +---------------- +- 03_topology_and_inspect/05_virtual_device_topology.py +""" + +from __future__ import annotations + +from videoipath_automation_tool import VideoIPathApp +from videoipath_automation_tool.apps.inspect import VirtualDeviceSpec + +SERVER_ADDRESS = "" +USERNAME = "" +PASSWORD = "" + + +def main() -> None: + # --- 1. Connect ----------------------------------------------------------- + app = VideoIPathApp(server_address=SERVER_ADDRESS, username=USERNAME, password=PASSWORD, use_https=False) + + # --- 2. Inspect the available port templates ------------------------------ + templates = app.inspect.list_port_templates() + for template in templates: + print(f"{template.id}: {template.label} ({template.direction})") + # > ip_in: IP input (In) + # > ip_out: IP output (Out) + + # --- 3. Build a virtual-device spec --------------------------------------- + # from_ports takes template ids (optionally as (template_id, count) tuples). + spec = VirtualDeviceSpec.from_ports(("ip_in", 2), ("ip_out", 2)) + + # --- 4. Create and place the device --------------------------------------- + device = app.inspect.create_virtual_device(spec) + print("Created virtual device", device.id) + # > Created virtual device virtual.1 + + device.label = "tieline-a" + device.tags = ["virtual", "tieline"] + app.inspect.update(device) + app.inspect.place_device(device.id, x=1500, y=900) + print("Placed", device.label) + # > Placed tieline-a + + +if __name__ == "__main__": + main() diff --git a/docs/examples/03_topology_and_inspect/05_virtual_device_topology.py b/docs/examples/03_topology_and_inspect/05_virtual_device_topology.py new file mode 100644 index 0000000..eddc72e --- /dev/null +++ b/docs/examples/03_topology_and_inspect/05_virtual_device_topology.py @@ -0,0 +1,53 @@ +"""Create a virtual (driverless) device (Topology app). + +Description +----------- +Virtual devices model endpoints that have no real driver — patch panels, tie-line groups, or external +switches. This example builds one by adding a virtual switching-core module and codec vertices, then +adds it to the topology (which assigns the next free ``virtual.N`` id). The paired +``05_virtual_device_inspect.py`` does the same with the Inspect app. + +Prerequisites +------------- +- A reachable VideoIPath server. +- NOTE: the Topology app is deprecated on VideoIPath 2025.x and unsupported on 2026.x (its + constructor raises ``TopologyUnsupportedError``). Virtual-device editing here is experimental. On + modern servers prefer the paired ``05_virtual_device_inspect.py``. + +Related examples +---------------- +- 03_topology_and_inspect/05_virtual_device_inspect.py +""" + +from __future__ import annotations + +from videoipath_automation_tool import VideoIPathApp + +SERVER_ADDRESS = "" +USERNAME = "" +PASSWORD = "" + + +def main() -> None: + # --- 1. Connect ----------------------------------------------------------- + app = VideoIPathApp(server_address=SERVER_ADDRESS, username=USERNAME, password=PASSWORD, use_https=False) + + # --- 2. Build the virtual device ------------------------------------------ + device = app.topology.create_virtual_device() + device.configuration.label = "tieline-a" + device.configuration.tags = ["virtual", "tieline"] + + # Add a switching core, then attach codec vertices to it. + device.add_virtual_module() + device.add_virtual_codec_vertex(vertex_direction="In", codec_format="Video", module_number=0) + device.add_virtual_codec_vertex(vertex_direction="Out", codec_format="Video", module_number=0) + + # --- 3. Add it to the topology -------------------------------------------- + # add_device_initially assigns the next free virtual.N id automatically. + app.topology.add_device_initially(device) + print("Created virtual device", device.configuration.base_device.id) + # > Created virtual device virtual.1 + + +if __name__ == "__main__": + main() diff --git a/docs/examples/04_inspect/01_explore_topology_read_only.py b/docs/examples/04_inspect/01_explore_topology_read_only.py new file mode 100644 index 0000000..adba296 --- /dev/null +++ b/docs/examples/04_inspect/01_explore_topology_read_only.py @@ -0,0 +1,73 @@ +"""Explore the network topology (read-only). + +Description +----------- +A safe, read-only tour of the Inspect app: list devices, edges, and services, walk from a device to +its ports, vertices, and neighbours, and look devices up by label. It also explains skeleton vs. full +loading and the ``preload`` call that avoids N+1 fetches when you need detail for many devices. + +Nothing here writes to the server, so it is a good first script to run against any environment. + +Prerequisites +------------- +- A reachable VideoIPath server, VideoIPath >= 2025.4. + +Related examples +---------------- +- 04_inspect/03_services_and_paths.py +- 06_workflows/02_network_audit_report.py +""" + +from __future__ import annotations + +from videoipath_automation_tool import VideoIPathApp + +SERVER_ADDRESS = "" +USERNAME = "" +PASSWORD = "" + + +def main() -> None: + # --- 1. Connect ----------------------------------------------------------- + app = VideoIPathApp(server_address=SERVER_ADDRESS, username=USERNAME, password=PASSWORD, use_https=False) + + # --- 2. Skeleton reads (no per-device detail I/O) ------------------------- + # The first read builds a fast "skeleton" view: all devices and edges, no port detail. + print(f"{len(app.inspect.devices)} devices, {len(app.inspect.edges)} edges") + # > 12 devices, 20 edges + + for device in app.inspect.devices: + print(device.id, device.label, device.status) + # > device34 leaf-1 + + # --- 3. Look up a device and walk its detail ------------------------------ + device = app.inspect.find_device_by_label("leaf-1") + assert device is not None + + # The first access to .ports hydrates this one device (a single scoped fetch), then caches it. + for port in device.ports: + out_vertex = port.vertex_out.id if port.vertex_out else "-" + in_vertex = port.vertex_in.id if port.vertex_in else "-" + print(f"{port.label}: out={out_vertex} in={in_vertex}") + # > Router Out 1: out=device34.1.0 in=- + + for neighbour in device.linked_devices: # local graph walk, no I/O + print("linked to", neighbour.label) + # > linked to spine-1 + + # --- 4. Preload many devices in parallel ---------------------------------- + # Hydrate everything up front to avoid one fetch per device in a loop. + app.inspect.preload() + hydrated = sum(1 for d in app.inspect.devices if app.inspect.is_device_hydrated(d.id)) + print(f"{hydrated} device(s) hydrated.") + # > 12 device(s) hydrated. + + # --- 5. Skeleton vs. full loading ----------------------------------------- + # "full" loads the entire topology eagerly in one request (a point-in-time snapshot). + app.inspect.refresh(load="full") + print("Reloaded eagerly.") + # > Reloaded eagerly. + + +if __name__ == "__main__": + main() diff --git a/docs/examples/04_inspect/02_transactions_and_conflicts.py b/docs/examples/04_inspect/02_transactions_and_conflicts.py new file mode 100644 index 0000000..d2fb96e --- /dev/null +++ b/docs/examples/04_inspect/02_transactions_and_conflicts.py @@ -0,0 +1,87 @@ +"""Batch changes with transactions and handle concurrent edits. + +Description +----------- +A transaction stages several changes and commits them atomically — either all apply or none do. This +example batches a device edit, a vertex edit, and a new connection into one commit, then shows how to +handle a concurrent modification: the commit detects it, raises ``InspectCommitConflictError``, and you +``rebase`` onto fresh server state and retry. + +The recommended way to stage domain-object edits into a transaction is +``app.inspect.update(objects, tx=tx)``; the keyword-style ``tx.update_device(...)`` is shown as an +alternative. + +Prerequisites +------------- +- A reachable VideoIPath server, VideoIPath >= 2025.4. +- Devices ``leaf-1`` and ``spine-1`` in the topology with synced ports. + +Related examples +---------------- +- 03_topology_and_inspect/03_connect_devices_with_edges_inspect.py +""" + +from __future__ import annotations + +from videoipath_automation_tool import VideoIPathApp +from videoipath_automation_tool.apps.inspect import InspectCommitConflictError, InspectCommitError + +SERVER_ADDRESS = "" +USERNAME = "" +PASSWORD = "" + +MAX_RETRIES = 3 + + +def main() -> None: + # --- 1. Connect ----------------------------------------------------------- + app = VideoIPathApp(server_address=SERVER_ADDRESS, username=USERNAME, password=PASSWORD, use_https=False) + + leaf = app.inspect.find_device_by_label("leaf-1") + spine = app.inspect.find_device_by_label("spine-1") + assert leaf is not None and spine is not None + + leaf_out = next((p.vertex_out for p in leaf.ports if p.vertex_out), None) + spine_in = next((p.vertex_in for p in spine.ports if p.vertex_in), None) + assert leaf_out is not None and spine_in is not None + + # --- 2. Stage several changes and commit them atomically ------------------ + leaf.description = "Rack A leaf" + leaf_out.use_as_endpoint = True + try: + with app.inspect.transaction() as tx: + app.inspect.update([leaf, leaf_out], tx=tx) # stage setter edits into the transaction + tx.connect(leaf_out.id, spine_in.id, bidirectional=True) # edge creation (no setter form) + result = tx.commit() + print("Committed:", result.applied_ids) + # > Committed: ['device34', 'device34.1.0', ...] + except InspectCommitError as error: + # The server rejected the commit (validation / apply gate) — nothing was written. + print("Server rejected the commit:", error) + return + + # Alternative keyword style for the same device edit: + # tx.update_device(leaf.id, description="Rack A leaf") + + # --- 3. Handle a concurrent modification with rebase + retry -------------- + # Stage the edit once, then retry the commit; rebase re-fetches baselines and keeps our intent. + leaf.description = "Rack A leaf (updated)" + tx = app.inspect.transaction() + app.inspect.update(leaf, tx=tx) + for attempt in range(1, MAX_RETRIES + 1): + try: + tx.commit() + print("Committed on attempt", attempt) + # > Committed on attempt 1 + break + except InspectCommitConflictError as conflict: + # Someone changed leaf-1 since we staged it. + print(f"Conflict on {[c.entity_id for c in conflict.conflicts]}; rebasing.") + tx.rebase() + else: + print("Gave up after", MAX_RETRIES, "attempts.") + tx.discard() + + +if __name__ == "__main__": + main() diff --git a/docs/examples/04_inspect/03_services_and_paths.py b/docs/examples/04_inspect/03_services_and_paths.py new file mode 100644 index 0000000..30a0109 --- /dev/null +++ b/docs/examples/04_inspect/03_services_and_paths.py @@ -0,0 +1,69 @@ +"""Inspect services and their paths. + +Description +----------- +Services are the booked media connections routed across the topology. This example lists them, looks +one up by booking id, prints its source, destination, and the devices its path traverses, and shows +the "service-impact guard": before changing or removing a device, check which services still depend on +it. + +This is read-only except for the illustrative guard at the end. + +Prerequisites +------------- +- A reachable VideoIPath server, VideoIPath >= 2025.4, with at least one booked service. + +Related examples +---------------- +- 04_inspect/01_explore_topology_read_only.py +- 03_topology_and_inspect/01_device_lifecycle_inspect.py +""" + +from __future__ import annotations + +from videoipath_automation_tool import VideoIPathApp + +SERVER_ADDRESS = "" +USERNAME = "" +PASSWORD = "" + + +def main() -> None: + # --- 1. Connect ----------------------------------------------------------- + app = VideoIPathApp(server_address=SERVER_ADDRESS, username=USERNAME, password=PASSWORD, use_https=False) + + # --- 2. List services ----------------------------------------------------- + services = app.inspect.services + print(f"{len(services)} service(s) booked.") + # > 4 service(s) booked. + + for service in services: + print(f"{service.booking_id}: {service.source} -> {service.destination} [{service.status}]") + # > booking-1: leaf-1 -> spine-1 [] + + if not services: + return + + # --- 3. Resolve one service and print its path ---------------------------- + service = app.inspect.get_service_by_booking_id(services[0].booking_id) + assert service is not None + print("Source device:", service.source_device.label if service.source_device else "-") + # > Source device: leaf-1 + path = " -> ".join(device.label or device.id for device in service.path_devices) + print("Path:", path) + # > Path: leaf-1 -> spine-1 -> leaf-2 + + # --- 4. Service-impact guard before touching a device --------------------- + device = app.inspect.find_device_by_label("leaf-1") + if device is not None: + affected = app.inspect.get_services_for_device(device.id) + if affected: + print(f"leaf-1 carries {len(affected)} service(s); change it with care.") + # > leaf-1 carries 2 service(s); change it with care. + else: + print("leaf-1 carries no services; safe to change.") + # > leaf-1 carries no services; safe to change. + + +if __name__ == "__main__": + main() diff --git a/docs/examples/05_administration/01_security_domains_and_memberships.py b/docs/examples/05_administration/01_security_domains_and_memberships.py new file mode 100644 index 0000000..114f2c6 --- /dev/null +++ b/docs/examples/05_administration/01_security_domains_and_memberships.py @@ -0,0 +1,85 @@ +"""Sync security domains and device memberships. + +Description +----------- +Security domains partition resources (devices, profiles) for access control. This example reconciles a +desired set of domains against the server — creating what is missing, updating descriptions, and +removing what is stale — while always protecting the built-in ``Default`` domain. It then reconciles a +single device's domain memberships with a read → compare → write-only-on-change pattern. + +This mirrors how an external source of truth (e.g. site/tenant data) drives domain management. + +Prerequisites +------------- +- A reachable VideoIPath server and credentials with security-administration access. +- A device named ``device-a`` in the inventory. + +Related examples +---------------- +- 06_workflows/01_full_onboarding_pipeline.py +""" + +from __future__ import annotations + +from videoipath_automation_tool import VideoIPathApp + +SERVER_ADDRESS = "" +USERNAME = "" +PASSWORD = "" + +PROTECTED_DOMAIN = "Default" +DESIRED_DOMAINS = { + "site-a": "Devices at site A", + "site-b": "Devices at site B", + "site-c": "Devices at site C", +} + + +def main() -> None: + # --- 1. Connect ----------------------------------------------------------- + app = VideoIPathApp(server_address=SERVER_ADDRESS, username=USERNAME, password=PASSWORD, use_https=False) + + # --- 2. Reconcile the set of domains -------------------------------------- + existing = set(app.security.domains.list_domain_names()) + + # Create missing domains. + for name, description in DESIRED_DOMAINS.items(): + if name not in existing: + app.security.domains.create_domain(name=name, description=description) + print("Created domain", name) + # > Created domain site-a + + # Update descriptions that drifted. + for name, description in DESIRED_DOMAINS.items(): + if name in existing: + domain = app.security.domains.get_domain_by_name(domain_name=name) + if domain.description != description: + domain.description = description + app.security.domains.update_domain(domain) + print("Updated domain", name) + + # Remove stale domains (never the protected one). + for name in existing - set(DESIRED_DOMAINS) - {PROTECTED_DOMAIN}: + app.security.domains.remove_domain(app.security.domains.get_domain_by_name(domain_name=name)) + print("Removed domain", name) + + # --- 3. Reconcile one device's domain memberships ------------------------- + device_id = app.inventory.find_device_id_by_label("device-a", label_search_mode="user_defined_label_only") + assert isinstance(device_id, str) + + memberships = app.security.resources.get_device_memberships(device_id=device_id) + current = set(app.security.resources.convert_domain_ids_to_names(memberships.domains)) + desired = {"site-a"} + + if current != desired: + memberships.domains = app.security.resources.convert_domain_names_to_ids(sorted(desired)) + app.security.resources.update_memberships(memberships=memberships) + print(f"Set {device_id} memberships to {sorted(desired)}") + # > Set device34 memberships to ['site-a'] + else: + print("Memberships already correct.") + # > Memberships already correct. + + +if __name__ == "__main__": + main() diff --git a/docs/examples/05_administration/02_profiles.py b/docs/examples/05_administration/02_profiles.py new file mode 100644 index 0000000..6d012dc --- /dev/null +++ b/docs/examples/05_administration/02_profiles.py @@ -0,0 +1,58 @@ +"""Manage profiles. + +Description +----------- +Profiles describe media formats and are referenced as vertex tags when configuring endpoints. This +example lists profile names, fetches one, creates a new profile, clones an existing one as a template, +and cleans up the clone. + +Prerequisites +------------- +- A reachable VideoIPath server and credentials with profile-management access. + +Related examples +---------------- +- 03_topology_and_inspect/02_configure_vertices_inspect.py +""" + +from __future__ import annotations + +from videoipath_automation_tool import VideoIPathApp + +SERVER_ADDRESS = "" +USERNAME = "" +PASSWORD = "" + + +def main() -> None: + # --- 1. Connect ----------------------------------------------------------- + app = VideoIPathApp(server_address=SERVER_ADDRESS, username=USERNAME, password=PASSWORD, use_https=False) + + # --- 2. List existing profiles -------------------------------------------- + names = app.profile.list_profile_names() or [] + print(f"{len(names)} profile(s): {names[:5]}") + # > 12 profile(s): ['V_1080i50', 'V_1080p50', 'A_2CH_LR', ...] + + # --- 3. Create a new profile ---------------------------------------------- + profile = app.profile.create_profile(name="profile-a") + app.profile.add_profile(profile) + print("Created profile-a") + # > Created profile-a + + # --- 4. Clone an existing profile as a template --------------------------- + if names: + source = app.profile.get_profile_by_name(names[0]) + if source is not None and not isinstance(source, list): + clone = app.profile.clone_profile(source) + app.profile.add_profile(clone) + print("Cloned", names[0], "->", clone.name) + # > Cloned V_1080i50 -> V_1080i50 (clone) + + # --- 5. Clean up the clone -------------------------------------------- + app.profile.remove_profile(profile=clone) + print("Removed the clone.") + # > Removed the clone. + + +if __name__ == "__main__": + main() diff --git a/docs/examples/05_administration/03_multicast_pools.py b/docs/examples/05_administration/03_multicast_pools.py new file mode 100644 index 0000000..a95c461 --- /dev/null +++ b/docs/examples/05_administration/03_multicast_pools.py @@ -0,0 +1,63 @@ +"""Configure multicast allocation pools. + +Description +----------- +Multicast allocation pools are the IP ranges VideoIPath draws from when assigning multicast addresses +to services. This example reads the existing pools and their utilization, creates a new pool, extends +it with an extra range, and removes it again. + +Prerequisites +------------- +- A reachable VideoIPath server and credentials with system-configuration access. + +Related examples +---------------- +- 04_inspect/01_explore_topology_read_only.py +""" + +from __future__ import annotations + +from videoipath_automation_tool import VideoIPathApp + +SERVER_ADDRESS = "" +USERNAME = "" +PASSWORD = "" + +POOL_NAME = "pool-a" + + +def main() -> None: + # --- 1. Connect ----------------------------------------------------------- + app = VideoIPathApp(server_address=SERVER_ADDRESS, username=USERNAME, password=PASSWORD, use_https=False) + allocation_pools = app.preferences.system_configuration.allocation_pools + + # --- 2. Read existing pools and utilization ------------------------------- + ranges = allocation_pools.get_multicast_ranges() + print("Pools:", ranges.available_ranges) + # > Pools: ['default'] + for name in ranges.available_ranges: + pool = allocation_pools.get_multicast_range_by_name(name) + print(f" {name}: {pool.utilization.percentage}% used") + # > default: 0% used + + # --- 3. Create a new pool ------------------------------------------------- + staged = allocation_pools.create_multicast_range(name=POOL_NAME, start_ip="239.0.10.0", end_ip="239.0.10.255") + allocation_pools.add_multicast_range(staged) + print("Created", POOL_NAME) + # > Created pool-a + + # --- 4. Extend the pool with another range -------------------------------- + pool = allocation_pools.get_multicast_range_by_name(POOL_NAME) + pool.add_ip_range(start_ip="239.0.11.0", end_ip="239.0.11.255") + allocation_pools.update_multicast_range(pool) + print(f"{POOL_NAME} now has {len(pool.ranges)} range(s).") + # > pool-a now has 2 range(s). + + # --- 5. Remove the pool --------------------------------------------------- + allocation_pools.remove_multicast_range(POOL_NAME) + print("Removed", POOL_NAME) + # > Removed pool-a + + +if __name__ == "__main__": + main() diff --git a/docs/examples/06_workflows/01_full_onboarding_pipeline.py b/docs/examples/06_workflows/01_full_onboarding_pipeline.py new file mode 100644 index 0000000..21a4ed2 --- /dev/null +++ b/docs/examples/06_workflows/01_full_onboarding_pipeline.py @@ -0,0 +1,168 @@ +"""Full onboarding pipeline: external source of truth to a running topology. + +Description +----------- +This is the flagship example — it stitches the individual operations into one end-to-end pipeline, +modeled on a real "sync from an external source of truth" workflow (e.g. an IPAM/DCIM system). Given a +small inline description of the desired network, it: + +1. creates or updates each device in the inventory (diff before write), +2. waits until the devices are reachable, +3. adds them to the topology at computed grid coordinates, +4. applies base configuration and endpoint settings in one transaction, +5. connects the devices per the cabling data (reserving bandwidth headroom), +6. assigns each device to its site's security domain. + +A ``DRY_RUN`` flag runs the whole thing without writing, and because every step is diff-based, running +it twice is a no-op. + +Prerequisites +------------- +- A reachable VideoIPath server, VideoIPath >= 2025.4, with inventory + security write access. + +Related examples +---------------- +- 02_inventory/02_get_update_and_diff_device.py +- 03_topology_and_inspect/03_connect_devices_with_edges_inspect.py +- 05_administration/01_security_domains_and_memberships.py +""" + +from __future__ import annotations + +import time + +from videoipath_automation_tool import VideoIPathApp + +SERVER_ADDRESS = "" +USERNAME = "" +PASSWORD = "" + +DRIVER = "com.nevion.NMOS_multidevice-0.1.0" +DRY_RUN = True # set to False to actually write to the server +BANDWIDTH_HEADROOM = 0.9 # use 90% of the nominal link bandwidth + +# The desired network, as it might come from an external source of truth. +DESIRED_DEVICES = [ + {"label": "leaf-1", "address": "192.0.2.21", "site": "site-a"}, + {"label": "leaf-2", "address": "192.0.2.22", "site": "site-a"}, + {"label": "spine-1", "address": "192.0.2.31", "site": "site-a"}, +] +DESIRED_LINKS = [ + {"a": "leaf-1", "b": "spine-1", "bandwidth": 10000}, + {"a": "leaf-2", "b": "spine-1", "bandwidth": 10000}, +] + + +def main() -> None: + # --- 1. Connect ----------------------------------------------------------- + app = VideoIPathApp(server_address=SERVER_ADDRESS, username=USERNAME, password=PASSWORD, use_https=False) + print(f"{'DRY RUN — ' if DRY_RUN else ''}syncing {len(DESIRED_DEVICES)} device(s)") + + # --- 2. Inventory: create or update each device (diff before write) ------- + device_ids: dict[str, str] = {} + for spec in DESIRED_DEVICES: + device_ids[spec["label"]] = sync_inventory_device(app, spec) + + if DRY_RUN: + print("Dry run complete — no changes written.") + return + + # --- 3. Wait until the devices are reachable ------------------------------ + for label, device_id in device_ids.items(): + if wait_until_reachable(app, device_id): + print(f"{label} is reachable.") + + # --- 4. Topology: add devices at a grid position -------------------------- + # add_devices_to_topology syncs driver-reported ports/vertices by default. + app.inspect.add_devices_to_topology( + [(device_id, 1000 + i * 300, 500) for i, device_id in enumerate(device_ids.values())] + ) + + # --- 5. Base configuration + endpoints in one transaction ----------------- + with app.inspect.transaction() as tx: + for spec in DESIRED_DEVICES: + device = app.inspect.get_device(device_ids[spec["label"]]) + if device is None: + continue + device.label = spec["label"] + device.tags = [spec["site"]] + for vertex in device.codec_vertices: + vertex.use_as_endpoint = True + vertex.sips_mode = "SIPSAuto" + app.inspect.update(device, tx=tx) # cascades the device + its dirty vertices + tx.commit() + + # --- 6. Connect devices per the cabling data ------------------------------ + for link in DESIRED_LINKS: + connect_devices(app, device_ids[link["a"]], device_ids[link["b"]], link["bandwidth"]) + + # --- 7. Assign each device to its site's security domain ------------------ + domain_names = set(app.security.domains.list_domain_names()) + for spec in DESIRED_DEVICES: + if spec["site"] not in domain_names: + app.security.domains.create_domain(name=spec["site"], description=f"Devices at {spec['site']}") + domain_names.add(spec["site"]) + assign_domain(app, device_ids[spec["label"]], spec["site"]) + + print("Onboarding complete.") + + +def sync_inventory_device(app: VideoIPathApp, spec: dict[str, str]) -> str: + """Create the device, or update it when its configuration drifted. Returns the device id.""" + existing_id = app.inventory.find_device_id_by_label(spec["label"], label_search_mode="user_defined_label_only") + + if not isinstance(existing_id, str): + staged = app.inventory.create_device(driver=DRIVER) + staged.configuration.label = spec["label"] + staged.configuration.address = spec["address"] + print(f" + create {spec['label']} ({spec['address']})") + if DRY_RUN: + return "" + return app.inventory.add_device(staged).configuration.device_id + + reference = app.inventory.get_device(device_id=existing_id, custom_settings_type=DRIVER) + staged = app.inventory.get_device(device_id=existing_id, custom_settings_type=DRIVER) + staged.configuration.address = spec["address"] + + diff = app.inventory.diff_device_configuration(reference_device=reference, staged_device=staged) + if diff.configuration_diff.changed: + print(f" ~ update {spec['label']}") + if not DRY_RUN: + app.inventory.update_device(device=staged) + return existing_id + + +def wait_until_reachable(app: VideoIPathApp, device_id: str, *, attempts: int = 10, delay: int = 3) -> bool: + """Poll the device status until it reports reachable (bounded retry).""" + device = app.inventory.get_device(device_id=device_id) + for _ in range(attempts): + app.inventory.refresh_device_status(device=device) + if device.status and device.status.reachable: + return True + time.sleep(delay) + return False + + +def connect_devices(app: VideoIPathApp, id_a: str, id_b: str, bandwidth: int) -> None: + """Connect the first free port of each device bidirectionally.""" + device_a, device_b = app.inspect.get_device(id_a), app.inspect.get_device(id_b) + if device_a is None or device_b is None: + return + out_a = next((p.vertex_out for p in device_a.ports if p.vertex_out), None) + in_b = next((p.vertex_in for p in device_b.ports if p.vertex_in), None) + if out_a is None or in_b is None: + return + app.inspect.connect(out_a.id, in_b.id, bidirectional=True, bandwidth=int(bandwidth * BANDWIDTH_HEADROOM)) + + +def assign_domain(app: VideoIPathApp, device_id: str, domain_name: str) -> None: + """Ensure the device belongs to exactly the given domain (write only on change).""" + memberships = app.security.resources.get_device_memberships(device_id=device_id) + current = set(app.security.resources.convert_domain_ids_to_names(memberships.domains)) + if current != {domain_name}: + memberships.domains = app.security.resources.convert_domain_names_to_ids([domain_name]) + app.security.resources.update_memberships(memberships=memberships) + + +if __name__ == "__main__": + main() diff --git a/docs/examples/06_workflows/02_network_audit_report.py b/docs/examples/06_workflows/02_network_audit_report.py new file mode 100644 index 0000000..5768fa1 --- /dev/null +++ b/docs/examples/06_workflows/02_network_audit_report.py @@ -0,0 +1,81 @@ +"""Read-only network audit report. + +Description +----------- +A strictly read-only, cross-app audit you can run against production safely. It reports devices in the +inventory that are missing from the topology, unreachable devices, endpoint vertices without media +tags, edges left at the default weight, services per device, and multicast pool utilization. + +Because it never writes, this is a good first script to point at any environment. + +Prerequisites +------------- +- A reachable VideoIPath server, VideoIPath >= 2025.4. + +Related examples +---------------- +- 04_inspect/01_explore_topology_read_only.py +- 06_workflows/03_bulk_retag_and_relabel.py +""" + +from __future__ import annotations + +from videoipath_automation_tool import VideoIPathApp + +SERVER_ADDRESS = "" +USERNAME = "" +PASSWORD = "" + +DRIVER = "com.nevion.NMOS_multidevice-0.1.0" + + +def main() -> None: + # --- 1. Connect ----------------------------------------------------------- + app = VideoIPathApp(server_address=SERVER_ADDRESS, username=USERNAME, password=PASSWORD, use_https=False) + print(f"Audit of {SERVER_ADDRESS} (VideoIPath {app.get_server_version()})") + + # --- 2. Inventory vs. topology coverage ----------------------------------- + topology_ids = {device.id for device in app.inspect.devices} + inventory_ids = app.inventory.list_device_ids_by_driver(DRIVER) + missing = [device_id for device_id in inventory_ids if device_id not in topology_ids] + print(f"\nInventory devices not in the topology: {len(missing)}") + for device_id in missing: + print(" -", device_id) + + # --- 3. Unreachable devices ----------------------------------------------- + print("\nUnreachable devices:") + for device_id in inventory_ids: + device = app.inventory.get_device(device_id=device_id) + app.inventory.refresh_device_status(device=device) + if device.status and not device.status.reachable: + print(" -", device.configuration.label) + + # --- 4. Endpoints without media tags -------------------------------------- + app.inspect.preload() + print("\nEndpoint vertices without tags:") + for device in app.inspect.devices: + untagged = [v for v in device.codec_vertices if v.is_endpoint and not v.tags] + if untagged: + print(f" - {device.label}: {len(untagged)} untagged endpoint(s)") + + # --- 5. Edges left at the default weight ---------------------------------- + default_weight_edges = [edge for edge in app.inspect.edges if not edge.weight] + print(f"\nEdges at default weight: {len(default_weight_edges)}") + + # --- 6. Services per device ----------------------------------------------- + print("\nServices per device:") + for device in app.inspect.devices: + services = app.inspect.get_services_for_device(device.id) + if services: + print(f" - {device.label}: {len(services)} service(s)") + + # --- 7. Multicast pool utilization ---------------------------------------- + allocation_pools = app.preferences.system_configuration.allocation_pools + print("\nMulticast pool utilization:") + for name in allocation_pools.get_multicast_ranges().available_ranges: + pool = allocation_pools.get_multicast_range_by_name(name) + print(f" - {name}: {pool.utilization.percentage}%") + + +if __name__ == "__main__": + main() diff --git a/docs/examples/06_workflows/03_bulk_retag_and_relabel.py b/docs/examples/06_workflows/03_bulk_retag_and_relabel.py new file mode 100644 index 0000000..c4f79dc --- /dev/null +++ b/docs/examples/06_workflows/03_bulk_retag_and_relabel.py @@ -0,0 +1,80 @@ +"""Bulk re-labeling and re-tagging. + +Description +----------- +Applies a naming/tagging policy across a fleet of devices: select devices by a label prefix, compute +the change set, print a dry-run report, then apply every change in a single Inspect transaction. The +matching inventory labels are updated too, so both views stay consistent. + +This demonstrates the change-set + dry-run + batch-commit pattern on a realistic bulk edit. + +Prerequisites +------------- +- A reachable VideoIPath server, VideoIPath >= 2025.4. + +Related examples +---------------- +- 06_workflows/02_network_audit_report.py +- 03_topology_and_inspect/02_configure_vertices_inspect.py +""" + +from __future__ import annotations + +from videoipath_automation_tool import VideoIPathApp + +SERVER_ADDRESS = "" +USERNAME = "" +PASSWORD = "" + +DRY_RUN = True # set to False to apply the changes +OLD_PREFIX = "cam-" +NEW_PREFIX = "camera-" +ADD_TAG = "camera" + + +def main() -> None: + # --- 1. Connect ----------------------------------------------------------- + app = VideoIPathApp(server_address=SERVER_ADDRESS, username=USERNAME, password=PASSWORD, use_https=False) + + # --- 2. Compute the change set -------------------------------------------- + changes: list[tuple[str, str, str]] = [] # (device_id, old_label, new_label) + for device in app.inspect.devices: + label = device.label or "" + if not label.startswith(OLD_PREFIX): + continue + new_label = NEW_PREFIX + label[len(OLD_PREFIX) :] + if new_label != label or ADD_TAG not in device.tags: + changes.append((device.id, label, new_label)) + + # --- 3. Dry-run report ---------------------------------------------------- + print(f"{len(changes)} device(s) to update:") + for _, old_label, new_label in changes: + print(f" {old_label} -> {new_label} (+tag '{ADD_TAG}')") + # > cam-1 -> camera-1 (+tag 'camera') + + if DRY_RUN: + print("Dry run — no changes written. Set DRY_RUN = False to apply.") + return + + # --- 4. Apply topology changes in one transaction ------------------------- + with app.inspect.transaction() as tx: + for device_id, _, new_label in changes: + device = app.inspect.get_device(device_id) + if device is None: + continue + device.label = new_label + device.tags = sorted(set(device.tags) | {ADD_TAG}) + app.inspect.update(device, tx=tx) + tx.commit() + + # --- 5. Keep the inventory labels in sync --------------------------------- + for device_id, _, new_label in changes: + inventory_device = app.inventory.get_device(device_id=device_id) + inventory_device.configuration.label = new_label + app.inventory.update_device(device=inventory_device) + + print(f"Updated {len(changes)} device(s).") + + +if __name__ == "__main__": + main() diff --git a/docs/examples/README.md b/docs/examples/README.md new file mode 100644 index 0000000..b9abe67 --- /dev/null +++ b/docs/examples/README.md @@ -0,0 +1,96 @@ +# Examples + +Runnable, task-oriented example scripts for the VideoIPath Automation Tool. Each file is a +self-contained Python script with a module docstring (title, description, prerequisites, related +examples) and numbered sections, showing how to solve one realistic automation scenario. + +Where the [Getting Started Guide](../getting-started-guide/README.md) explains concepts, these +examples show complete workflows you can copy and adapt. + +## Running an example + +```bash +pip install videoipath-automation-tool +python docs/examples/01_setup/01_connect_and_check.py +``` + +Each script has a small block of placeholder constants near the top (`SERVER_ADDRESS`, `USERNAME`, +`PASSWORD`, device labels, …) — edit these for your environment. Alternatively, set the `VIPAT_*` +environment variables (or a `.env` file) and connect with a bare `VideoIPathApp()`; see +[01_connect_and_check.py](01_setup/01_connect_and_check.py). + +All identifiers in these examples are anonymized placeholders (`device-a`, `leaf-1`, `192.0.2.x`, +`site-a`, …). **The examples write to the server — run them against a test system first.** The +read-only scripts ([04_inspect/01](04_inspect/01_explore_topology_read_only.py), +[06_workflows/02](06_workflows/02_network_audit_report.py)) are safe starting points. + +## Compatibility and conventions + +- **Inspect app** (`app.inspect`) is the forward-looking read/write interface and requires + **VideoIPath 2025.4 or newer**. +- **Topology app** (`app.topology`) is **deprecated on VideoIPath 2025.x** and **unsupported on + 2026.x**, where its constructor raises `TopologyUnsupportedError`. Prefer the Inspect variant of a + scenario on modern servers. +- **Recommended write style (Inspect):** edit a domain object's properties and flush with + `app.inspect.update(obj)`. The keyword-argument methods (`app.inspect.update_device(...)`, + `update_vertex(...)`, …) do the same in one call and are shown as an alternative where relevant. + +## Contents + +### 01 — Setup + +- [01_connect_and_check.py](01_setup/01_connect_and_check.py) — connect (constructor args or `VIPAT_*` + env vars), verify the connection, read the server version. + +### 02 — Inventory + +- [01_create_and_add_device.py](02_inventory/01_create_and_add_device.py) — create a device from a + driver, set typed `custom_settings`, add it. +- [02_get_update_and_diff_device.py](02_inventory/02_get_update_and_diff_device.py) — fetch, diff, and + update a device; idempotent "write only on change". +- [03_discovery_onboarding.py](02_inventory/03_discovery_onboarding.py) — onboard auto-discovered + devices; enable/disable. +- [04_backup_restore_clone.py](02_inventory/04_backup_restore_clone.py) — dump/parse a configuration to + back up, restore, or clone a device (same or second server). +- [05_driver_settings_and_queries.py](02_inventory/05_driver_settings_and_queries.py) — typed driver + settings, inventory queries, global SNMP configuration. + +### 03 — Topology and Inspect (paired examples) + +Each scenario is implemented twice — once with the modern **Inspect** app, once with the classic +**Topology** app — using identical data, so you can compare the two approaches side by side. + +| Scenario | Inspect | Topology | +|---|---|---| +| Device lifecycle: add, configure, remove | [01_device_lifecycle_inspect.py](03_topology_and_inspect/01_device_lifecycle_inspect.py) | [01_device_lifecycle_topology.py](03_topology_and_inspect/01_device_lifecycle_topology.py) | +| Configure vertices (endpoints, SIPS, tags) | [02_configure_vertices_inspect.py](03_topology_and_inspect/02_configure_vertices_inspect.py) | [02_configure_vertices_topology.py](03_topology_and_inspect/02_configure_vertices_topology.py) | +| Connect two devices with edges | [03_connect_devices_with_edges_inspect.py](03_topology_and_inspect/03_connect_devices_with_edges_inspect.py) | [03_connect_devices_with_edges_topology.py](03_topology_and_inspect/03_connect_devices_with_edges_topology.py) | +| Lay out devices in a grid | [04_grid_placement_inspect.py](03_topology_and_inspect/04_grid_placement_inspect.py) | [04_grid_placement_topology.py](03_topology_and_inspect/04_grid_placement_topology.py) | +| Create a virtual (driverless) device | [05_virtual_device_inspect.py](03_topology_and_inspect/05_virtual_device_inspect.py) | [05_virtual_device_topology.py](03_topology_and_inspect/05_virtual_device_topology.py) | + +### 04 — Inspect (app-specific strengths) + +- [01_explore_topology_read_only.py](04_inspect/01_explore_topology_read_only.py) — read-only tour: + devices, edges, ports, neighbours; skeleton vs. full loading, `preload`. +- [02_transactions_and_conflicts.py](04_inspect/02_transactions_and_conflicts.py) — atomic batched + changes; detect concurrent edits and `rebase` + retry. +- [03_services_and_paths.py](04_inspect/03_services_and_paths.py) — inspect services, their paths, and + the service-impact guard. + +### 05 — Administration + +- [01_security_domains_and_memberships.py](05_administration/01_security_domains_and_memberships.py) — + reconcile security domains and a device's domain memberships. +- [02_profiles.py](05_administration/02_profiles.py) — list, create, clone, and remove profiles. +- [03_multicast_pools.py](05_administration/03_multicast_pools.py) — read, create, extend, and remove + multicast allocation pools. + +### 06 — Workflows (composite, real-world) + +- [01_full_onboarding_pipeline.py](06_workflows/01_full_onboarding_pipeline.py) — end-to-end sync from + an external source of truth: inventory → reachability → topology → edges → domains, with a dry-run + flag and diff-before-write. +- [02_network_audit_report.py](06_workflows/02_network_audit_report.py) — read-only cross-app audit + report (safe to run against production). +- [03_bulk_retag_and_relabel.py](06_workflows/03_bulk_retag_and_relabel.py) — apply a naming/tagging + policy across a fleet with a dry-run report and a single batched commit. diff --git a/docs/getting-started-guide/03_Topology.md b/docs/getting-started-guide/03_A_Topology.md similarity index 61% rename from docs/getting-started-guide/03_Topology.md rename to docs/getting-started-guide/03_A_Topology.md index 244ee1a..2f631c5 100644 --- a/docs/getting-started-guide/03_Topology.md +++ b/docs/getting-started-guide/03_A_Topology.md @@ -1,29 +1,54 @@ -# Topology App +# 03-A — Topology App -## 1. Introduction +> **Paired stage:** this page and [03-B Inspect](03_B_Inspect.md) cover the same +> topology workflows with different implementations. Prefer **03-B** on VideoIPath +> version >= 2025.x; use this page when you still target the classic Topology API. + +### Compatibility & deprecation -The Topology App focuses on configuring devices, defining their capabilities, and establishing links between them. For all these purposes, instances of "Topology Device" are used. +**⚠️ CAUTION ⚠️**: The Topology App is **not supported** for VideoIPath version **2026.x or above**! Use the Inspect App for these versions (see [03-B Inspect](03_B_Inspect.md)). -A **Topology Device** represents a network entity within the topology, containing configuration details. It is composed of multiple elements, each serving a specific role: +| Server version | Status | +| ----------------------- | ------------------------------------------------------------------------- | +| VideoIPath **≤ 2024.x** | Supported | +| VideoIPath **2025.x** | **Deprecated** — constructor emits `DeprecationWarning` and a log warning | +| VideoIPath **≥ 2026.x** | **Unsupported** — constructor raises `TopologyUnsupportedError` | -- **Base Device (BaseDevice)**: Stores fundamental properties such as labels, descriptions, and appearance settings. -- **Vertices**: - - **Generic Vertices (GenericVertex)**: Primarily represent switching cores. - - **IP Vertices (IpVertex)**: Represent network interfaces. - - **Codec Vertices (CodecVertex)**: Represent media-specific inputs and outputs. -- **Edges**: - - **Internal Edges**: Connect vertices within the same device. - - **External Edges**: Link the device to other devices in the topology. +## 1. Introduction -All device properties are stored within the configuration attribute of a **Topology Device**. -While the **Base Device** exists as a single instance and can be accessed directly, all **Vertices** and **Edges** are stored in lists within configuration, categorized by their type. -The following examples illustrate how to retrieve, modify and update specific properties of a **Topology Device**. +The Topology App (`app.topology`) focuses on configuring devices, defining their +capabilities, and establishing links between them. For all these purposes, +instances of "Topology Device" are used. + +A **Topology Device** represents a network entity within the topology, containing +configuration details. It is composed of multiple elements, each serving a +specific role: + +- **Base Device (BaseDevice)**: Stores fundamental properties such as labels, +descriptions, and appearance settings. +- **Vertices**: + - **Generic Vertices (GenericVertex)**: Primarily represent switching cores. + - **IP Vertices (IpVertex)**: Represent network interfaces. + - **Codec Vertices (CodecVertex)**: Represent media-specific inputs and outputs. +- **Edges**: + - **Internal Edges**: Connect vertices within the same device. + - **External Edges**: Link the device to other devices in the topology. + +All device properties are stored within the configuration attribute of a +**Topology Device**. While the **Base Device** exists as a single instance and +can be accessed directly, all **Vertices** and **Edges** are stored in lists +within configuration, categorized by their type. The following examples +illustrate how to retrieve, modify and update specific properties of a +**Topology Device**. ## 2. Basic Usage + + ### 2.1. Retrieving the Configuration of a Device in the Topology -The configuration of a device that has already been added to the topology or is ready for synchronization can be retrieved using its unique device ID. +The configuration of a device that has already been added to the topology or is +ready for synchronization can be retrieved using its unique device ID. ```python device = app.topology.get_device(device_id="device10") @@ -31,14 +56,20 @@ print(device.configuration.factory_label) # > BORDERLEAF-26B [10.0.1.26][Arista Networks EOS] ``` -Alternatively, a device can be identified by determining its device ID based on its label. +Alternatively, a device can be identified by determining its device ID based on +its label. -Similar to the Inventory app, multiple label_search_mode options are available, with canonical_label set as the default. -In this mode, devices that have not yet been added but are ready for synchronization are matched using the factory label. -For devices already configured in the topology, the displayed label is used—either the user-defined label, if set, or otherwise the factory label. +Similar to the Inventory app, multiple `label_search_mode` options are available, +with `canonical_label` set as the default. In this mode, devices that have not +yet been added but are ready for synchronization are matched using the factory +label. For devices already configured in the topology, the displayed label is +used—either the user-defined label, if set, or otherwise the factory label. ```python -device_id = app.topology.find_device_id_by_label("BORDERLEAF-26B [10.0.1.26][Arista Networks EOS]", label_search_mode="canonical_label") +device_id = app.topology.find_device_id_by_label( + "BORDERLEAF-26B [10.0.1.26][Arista Networks EOS]", + label_search_mode="canonical_label", +) if device_id is None: raise ValueError("Device not found") @@ -50,9 +81,12 @@ print(device_id) # > device10 ``` + + ### 2.2. Updating a Device in the Topology -The configuration of a device in the topology can be updated. If the device does not exist, it is automatically added to the topology. +The configuration of a device in the topology can be updated. If the device does +not exist, it is automatically added to the topology. ```python device.configuration.label = "New Label" @@ -66,7 +100,10 @@ print(updated_device.configuration.label) # > New Label ``` -The method evaluates which `nGraphElements` have changed compared to the server state and updates only those elements. Additionally, by default, it checks whether any services are affected. This behavior can be bypassed by setting `ignore_affected_services` to `True`. +The method evaluates which `nGraphElements` have changed compared to the server +state and updates only those elements. Additionally, by default, it checks +whether any services are affected. This behavior can be bypassed by setting +`ignore_affected_services` to `True`. ### 2.3. Remove a Device from the Topology @@ -76,14 +113,17 @@ A device can be removed from the topology using its device ID. app.topology.remove_device_by_id(device_id="device10") ``` + + ## 3. Working with Vertices and Edges -It is possible to either iterate through all vertices/edges of a category, which is often useful for bulk operations, or access individual vertices/edges directly by their ID or label. +It is possible to either iterate through all vertices/edges of a category, which +is often useful for bulk operations, or access individual vertices/edges +directly by their ID or label. ### 3.1 Iterate through all Edges/Vertices of a Category ```python - device = app.topology.get_device(device_id="device80") codec_vertices = device.configuration.codec_vertices @@ -99,6 +139,8 @@ for codec_vertex in codec_vertices: # ... ``` + + ### 3.2 Access a Edge/Vertex by its ID ```python @@ -111,6 +153,8 @@ print(vertice_video_ip_in_1_1.factory_label) # > Video-IP In 1.1 ``` + + ### 3.3 Access a Vertex by its Label ```python @@ -123,8 +167,12 @@ print(vertice_video_ip_in_1_1.id) # > device80.1.3000000 ``` + + ### 3.4. Example: Configure existing Edges/Vertices + + #### 3.4.1. Configure Codec Vertices based on information from factory labels ```python @@ -164,12 +212,12 @@ for vertex in codec_vertices: vertex.spare_destination_address_pool = "SPARE_POOL" print( - f"Vertex with id '{vertex.id}‘ and label '{vertex.factory_label}' configured for {codec_format} ({direction}). Tags: {', '.join(vertex.tags)}" + f"Vertex with id '{vertex.id}' and label '{vertex.factory_label}' configured for {codec_format} ({direction}). Tags: {', '.join(vertex.tags)}" ) - # > Vertex with id 'device80.1.3000000‘ and label 'Video-IP In 1.1' configured for Video (RX). Tags: V_1080i25, V_1080p50, V_2160p50 - # Vertex with id 'device80.1.3000001‘ and label 'Video-IP In 1.2' configured for Video (RX). Tags: V_1080i25, V_1080p50, V_2160p50 + # > Vertex with id 'device80.1.3000000' and label 'Video-IP In 1.1' configured for Video (RX). Tags: V_1080i25, V_1080p50, V_2160p50 + # Vertex with id 'device80.1.3000001' and label 'Video-IP In 1.2' configured for Video (RX). Tags: V_1080i25, V_1080p50, V_2160p50 # ... - + app.topology.update_device(device=device) ``` @@ -177,6 +225,8 @@ More examples will be added soon! --- + + ### 3.5. Creating external Edges between devices ```python @@ -218,4 +268,9 @@ virtuoso_topology.configuration.external_edges.extend(edges_blue_slot_1) app.topology.update_device(device=virtuoso_topology) ``` -> **Note:** The documentation is currently being expanded. Upcoming sections will include details on device positioning, virtual device management, and device comparison, as well as synchronization status and various helper functions. +> **Note:** The documentation is currently being expanded. Upcoming sections will +> include details on device positioning, virtual device management, and device +> comparison, as well as synchronization status and various helper functions. +> Runnable paired scripts for these workflows live under +> `[docs/examples/03_topology_and_inspect/](../examples/03_topology_and_inspect/)`. + diff --git a/docs/getting-started-guide/05_Inspect.md b/docs/getting-started-guide/03_B_Inspect.md similarity index 63% rename from docs/getting-started-guide/05_Inspect.md rename to docs/getting-started-guide/03_B_Inspect.md index beef826..4846996 100644 --- a/docs/getting-started-guide/05_Inspect.md +++ b/docs/getting-started-guide/03_B_Inspect.md @@ -1,36 +1,46 @@ -# Inspect App +# 03-B — Inspect App -## 1. Introduction +> **Paired stage:** this page and [03-A Topology](03_A_Topology.md) cover the same +> topology workflows with different implementations. This page is the recommended +> path on VideoIPath **2025.4+**. -The **Inspect App** (`app.inspect`) is the read/write interface to VideoIPath's newer *Inspect* -surface: it builds a live view of the topology (devices, ports, edges, and services) and applies -topology changes with a **commit-style** write model. +### Compatibility & deprecation -It is **purely additive** — `app.topology` and `app.inventory` keep working unchanged. Devices are -still onboarded in Inventory first; Inspect then places, connects, and monitors them. +| Server version | Status | +|---|---| +| VideoIPath **≥ 2025.4** | Supported and recommended (verified) | +| VideoIPath **< 2025.4** | Unverified — the app logs a warning; behaviour is not guaranteed | +| Relation to Topology | Inspect **replaces** `app.topology` going forward | -Two ideas shape the API: +## 1. Introduction -- **Skeleton-first snapshots** — a snapshot loads only the minimal topology (all devices and edges, - without per-port detail) up front, then *lazily hydrates* detail the first time you touch it. This - keeps the initial read fast even in large environments. A snapshot is never a single point in - time; each device and section carries its own fetch timestamp. -- **Commit-style writes** — changes are staged and applied atomically. Before sending, the change - set re-checks that nobody else modified the affected entities (compare-and-commit); after a - successful commit it refreshes only the touched entities. +The **Inspect App** (`app.inspect`) is the read/write interface to VideoIPath's +newer *Inspect* surface: it builds a live view of the topology (devices, ports, +edges, and services) and applies topology changes with a **commit-style** write +model. -> Inspect is verified against VideoIPath **2025.4** and newer. Against older servers the app logs a -> warning; behaviour is unverified. +Two ideas shape the API: -The app keeps a single topology view internally — you never handle a "snapshot" object. It loads on -your first read and stays current across writes; call `app.inspect.refresh()` to reload it. +- **Skeleton-first snapshots** — a snapshot loads only the minimal topology (all + devices and edges, without per-port detail) up front, then *lazily hydrates* + detail the first time you touch it. This keeps the initial read fast even in + large environments. A snapshot is never a single point in time; each device and + section carries its own fetch timestamp. +- **Commit-style writes** — changes are staged and applied atomically. Before + sending, the change set re-checks that nobody else modified the affected + entities (compare-and-commit); after a successful commit it refreshes only the + touched entities. + +The app keeps a single topology view internally — you never handle a "snapshot" +object. It loads on your first read and stays current across writes; call +`app.inspect.refresh()` to reload it. ## 2. Reading the topology ### 2.1. Devices, ports, and edges -Everything is read straight off `app.inspect`. Skeleton fields are available without any per-device -I/O: +Everything is read straight off `app.inspect`. Skeleton fields are available +without any per-device I/O: ```python device = app.inspect.get_device("device10") @@ -42,8 +52,8 @@ for device in app.inspect.devices: # all devices print(device.id, device.label) ``` -The first access to a device's **ports** hydrates that one device (a single scoped read), then -serves from local state: +The first access to a device's **ports** hydrates that one device (a single +scoped read), then serves from local state: ```python for port in device.ports: # triggers one hydration fetch for this device @@ -80,7 +90,11 @@ for service in app.inspect.services: # loads the paths section on fi ### 2.3. Refreshing -The view updates itself after your own writes. To pick up external changes, reload it: +The view updates itself after your own writes and network actions (targeted +scoped re-fetch of touched devices/edges) — you do **not** need to call +`refresh()` after `connect`, `update`, `add_devices_to_topology`, and similar. +Use `refresh()` only to pick up **external** changes (another user/session, or +server-side work outside this app): ```python app.inspect.refresh() # reload (skeleton; lazy detail) @@ -91,8 +105,9 @@ app.inspect.refresh(load="full") # reload eagerly in one request ### 3.1. Direct writes (auto-commit) -Each direct method opens a one-change transaction and commits it immediately. If the internal view -is already loaded, the change is reflected into it via targeted refresh: +Each direct method opens a one-change transaction and commits it immediately. If +the internal view is already loaded, the change is reflected into it via targeted +refresh: ```python app.inspect.place_device("device12", x=1600, y=9050) @@ -134,7 +149,8 @@ with app.inspect.transaction() as tx: print(result.ok, result.applied_ids) ``` -Exiting the `with` block **without** committing discards the staged changes (and logs a warning). +Exiting the `with` block **without** committing discards the staged changes (and +logs a warning). ### 3.3. Handling concurrent changes @@ -156,25 +172,37 @@ except InspectCommitConflictError as exc: tx.commit(check_conflicts=False) ``` -A server-rejected commit (validation or apply gate) raises `InspectCommitError`, which carries the -typed `validation` details. +A server-rejected commit (validation or apply gate) raises `InspectCommitError`, +which carries the typed `validation` details. ## 4. Onboarding devices into the topology +`add_devices_to_topology` places devices and, by default, syncs their +driver-reported ports/vertices in one call: + ```python from videoipath_automation_tool.apps.inspect import ConflictStrategy app.inspect.add_devices_to_topology([("device12", 100, 200), "device13"]) +# Pass sync=False to place only; or override sync options: +# app.inspect.add_devices_to_topology( +# ["device12"], sync=True, add_only=True, conflict_strategy=ConflictStrategy.STRICT +# ) + +# Preview what a later re-sync would change, then re-sync existing devices: info = app.inspect.get_sync_info(["device12"]) app.inspect.sync_devices(["device12"], add_only=True, conflict_strategy=ConflictStrategy.STRICT) ``` ## 5. Notes -- Inspect uses **only** the collector API surface at runtime; it never calls the legacy - `nGraphElements` / `edgesByDevice` endpoints. -- The topology view is loaded lazily and kept internal to `app.inspect`; a pure-write workflow never - triggers a read. Reads and hydration are internally consistent under concurrent access, but a - single `VideoIPathApp` is otherwise intended for single-owner use. +- Inspect uses **only** the collector API surface at runtime; it never calls the + legacy `nGraphElements` / `edgesByDevice` endpoints. +- The topology view is loaded lazily and kept internal to `app.inspect`; a + pure-write workflow never triggers a read. Reads and hydration are internally + consistent under concurrent access, but a single `VideoIPathApp` is otherwise + intended for single-owner use. +- Runnable paired scripts (Inspect vs Topology) live under + [`docs/examples/03_topology_and_inspect/`](../examples/03_topology_and_inspect/). - For the design rationale, see the architecture docs under [`docs/architecture/inspect-app/`](../architecture/inspect-app/README.md). diff --git a/docs/getting-started-guide/README.md b/docs/getting-started-guide/README.md index 955b7eb..8b26c12 100644 --- a/docs/getting-started-guide/README.md +++ b/docs/getting-started-guide/README.md @@ -1,11 +1,31 @@ # Getting Started Guide -This guide will help you get started with the VideoIPath Automation Tool. It will show you how to establish a connection to the VideoIPath server and how to manage devices in the inventory and topology. Also, it demonstrates how to configure multicast pools and read/write the topology through the Inspect app. +This guide will help you get started with the VideoIPath Automation Tool. It will +show you how to establish a connection to the VideoIPath server and how to manage +devices in the inventory and topology. Stage 3 covers topology work twice — once +with the classic Topology app and once with the modern Inspect app — so you can +follow the same workflows with either implementation. It also demonstrates how to +configure multicast pools. + +## Compatibility (Topology vs Inspect) + +| App | API | Compatibility | +|---|---|---| +| **Inspect** (`app.inspect`) | Forward-looking read/write topology interface | Requires **VideoIPath 2025.4 or newer** (recommended) | +| **Topology** (`app.topology`) | Classic topology interface | **Deprecated on 2025.x**; **unsupported on 2026.x** (`TopologyUnsupportedError`) | + +Prefer the Inspect variant ([03-B](03_B_Inspect.md)) on modern servers. Inventory +onboarding is unchanged and remains a prerequisite for both. ## Table of Contents 1. [Establishing a Connection to the VideoIPath Server](01_Setup_and_connect_to_Server.md) 2. [Managing Devices in the Inventory](02_Inventory.md) -3. [Managing Devices in the Topology](03_Topology.md) +3. Managing Devices in the Topology — choose an implementation: + - A. [Topology App](03_A_Topology.md) (classic / legacy) + - B. [Inspect App](03_B_Inspect.md) (recommended on 2025.4+) 4. [Configuring Multicast Pools](04_Multicast_Pools.md) -5. [Inspecting and Editing the Topology (Inspect App)](05_Inspect.md) + +For runnable, task-oriented scripts covering realistic automation scenarios +(including paired Topology/Inspect examples), see the +[Examples](../examples/README.md). diff --git a/pyproject.toml b/pyproject.toml index 02b627d..c4f295b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -67,7 +67,7 @@ test-e2e = "vipat_cli_scripts.test_runner:run_e2e" test = "vipat_cli_scripts.test_runner:run" [tool.ruff] -include = ["pyproject.toml", "src/**/*.py", "tests/**/*.py"] +include = ["pyproject.toml", "src/**/*.py", "tests/**/*.py", "docs/examples/**/*.py"] # Formatter config line-length = 120 diff --git a/src/videoipath_automation_tool/apps/inspect/app/actions.py b/src/videoipath_automation_tool/apps/inspect/app/actions.py index e289b17..f696b05 100644 --- a/src/videoipath_automation_tool/apps/inspect/app/actions.py +++ b/src/videoipath_automation_tool/apps/inspect/app/actions.py @@ -69,21 +69,44 @@ def get_sync_info(self: _HasInspectApi, device_ids: list[str]) -> dict[str, Insp raise ValueError("device_ids must not be empty.") return self._inspect_api.lookup_sync_info(device_ids).data - def add_devices_to_topology(self: _HasInspectApi, devices: Iterable[AddDeviceSpec]) -> bool: + def add_devices_to_topology( + self: _HasInspectApi, + devices: Iterable[AddDeviceSpec], + *, + sync: bool = True, + add_only: bool = True, + conflict_strategy: ConflictStrategy = ConflictStrategy.STRICT, + ) -> bool: """Add onboarded devices to the topology graph (``addDevices`` network action). + By default also runs ``syncDevices`` so driver-reported ports/vertices appear in the + graph — the usual onboarding sequence in one call. Pass ``sync=False`` to place only. + Args: devices: device ids, or ``(device_id, x, y)`` tuples to place them. + sync: when ``True`` (default), synchronize driver-reported topology after adding. + add_only: forwarded to ``syncDevices`` (only add new elements; do not remove/update). + conflict_strategy: forwarded to ``syncDevices`` for conflicts with active services. Returns: - bool: whether the action reported success (``data.ok``). + bool: whether every action that ran reported success (``data.ok``). On sync failure + after a successful add, the snapshot is still refreshed for the added devices. """ items = [_to_add_item(spec) for spec in devices] + device_ids = [item.id for item in items] response = self._inspect_api.add_devices(items) if not response.data.ok: self._logger.warning(f"addDevices reported failure: {response.data.msg}") return False - self._refresh_after_network_action([item.id for item in items]) + if sync and device_ids: + sync_response = self._inspect_api.sync_devices( + device_ids, add_only=add_only, conflict_strategy=int(conflict_strategy) + ) + if not sync_response.data.ok: + self._logger.warning(f"syncDevices reported failure: {sync_response.data.msg}") + self._refresh_after_network_action(device_ids) + return False + self._refresh_after_network_action(device_ids) return True def sync_devices( diff --git a/tests/inspect/test_actions.py b/tests/inspect/test_actions.py index 15cb6cb..971ec84 100644 --- a/tests/inspect/test_actions.py +++ b/tests/inspect/test_actions.py @@ -11,15 +11,25 @@ from videoipath_automation_tool.apps.inspect.app.actions import ConflictStrategy, InspectActionsMixin from videoipath_automation_tool.apps.inspect.api import InspectAPI +_ADD_DEVICES = "/rest/v2/actions/status/network/addDevices" +_SYNC_DEVICES = "/rest/v2/actions/status/network/syncDevices" + class FakeRest: - def __init__(self, post_data: dict[str, Any]) -> None: - self._post_data = post_data + def __init__( + self, + post_data: dict[str, Any] | None = None, + *, + by_path: dict[str, dict[str, Any]] | None = None, + ) -> None: + self._post_data = post_data if post_data is not None else {"msg": [], "ok": True} + self._by_path = by_path or {} self.post_calls: list[tuple[str, dict[str, Any]]] = [] def post(self, url_path: str, body: Any, **kwargs: Any) -> SimpleNamespace: self.post_calls.append((url_path, body.model_dump(mode="json", by_alias=True))) - return SimpleNamespace(data=self._post_data, header=_ok_header()) + data = self._by_path.get(url_path, self._post_data) + return SimpleNamespace(data=data, header=_ok_header()) def test_conflict_strategy_values() -> None: @@ -29,14 +39,58 @@ def test_conflict_strategy_values() -> None: def test_add_devices_builds_placement_items() -> None: - app = _App({"msg": [], "ok": True}) - assert app.add_devices_to_topology([("device12", 100, 200), "device13"]) is True + app = _App() + assert app.add_devices_to_topology([("device12", 100, 200), "device13"], sync=False) is True + paths = [path for path, _ in app._inspect_api.vip_connector.rest.post_calls] + assert paths == [_ADD_DEVICES] _, payload = app._inspect_api.vip_connector.rest.post_calls[0] assert payload["data"] == [{"id": "device12", "x": 100, "y": 200}, {"id": "device13", "x": 0, "y": 0}] +def test_add_devices_syncs_by_default() -> None: + app = _App() + assert app.add_devices_to_topology([("device12", 100, 200), "device13"]) is True + rest = app._inspect_api.vip_connector.rest + assert [path for path, _ in rest.post_calls] == [_ADD_DEVICES, _SYNC_DEVICES] + assert rest.post_calls[0][1]["data"] == [ + {"id": "device12", "x": 100, "y": 200}, + {"id": "device13", "x": 0, "y": 0}, + ] + assert rest.post_calls[1][1]["data"] == { + "ids": ["device12", "device13"], + "addOnly": True, + "conflictStrategy": 0, + } + + +def test_add_devices_passes_sync_options() -> None: + app = _App() + assert ( + app.add_devices_to_topology( + ["device12"], + sync=True, + add_only=False, + conflict_strategy=ConflictStrategy.CANCEL_SERVICES, + ) + is True + ) + _, sync_payload = app._inspect_api.vip_connector.rest.post_calls[1] + assert sync_payload["data"] == { + "ids": ["device12"], + "addOnly": False, + "conflictStrategy": 2, + } + + +def test_add_devices_sync_false_skips_sync() -> None: + app = _App() + assert app.add_devices_to_topology(["device12"], sync=False) is True + paths = [path for path, _ in app._inspect_api.vip_connector.rest.post_calls] + assert paths == [_ADD_DEVICES] + + def test_sync_devices_passes_strategy() -> None: - app = _App({"msg": [], "ok": True}) + app = _App() app.sync_devices(["device12"], add_only=True, conflict_strategy=ConflictStrategy.CANCEL_SERVICES) _, payload = app._inspect_api.vip_connector.rest.post_calls[0] assert payload["data"] == {"ids": ["device12"], "addOnly": True, "conflictStrategy": 2} @@ -48,7 +102,7 @@ def test_sync_devices_reports_failure() -> None: def test_empty_lists_rejected() -> None: - app = _App({"msg": [], "ok": True}) + app = _App() with pytest.raises(ValueError): app.sync_devices([]) with pytest.raises(ValueError): @@ -60,21 +114,21 @@ def test_empty_lists_rejected() -> None: def test_add_devices_refreshes_snapshot_when_loaded() -> None: snap = _RecordingSnapshot() - app = _App({"msg": [], "ok": True}, snapshot=snap) + app = _App(snapshot=snap) assert app.add_devices_to_topology([("device12", 1, 2), "device13"]) is True assert snap.network_refresh_calls == [["device12", "device13"]] def test_sync_devices_refreshes_snapshot_when_loaded() -> None: snap = _RecordingSnapshot() - app = _App({"msg": [], "ok": True}, snapshot=snap) + app = _App(snapshot=snap) assert app.sync_devices(["device12", "device13"]) is True assert snap.network_refresh_calls == [["device12", "device13"]] def test_network_action_without_snapshot_does_not_build_one() -> None: # _snapshot is None: a pure-action workflow must not trigger a topology read. - app = _App({"msg": [], "ok": True}) + app = _App() assert app.add_devices_to_topology(["device12"]) is True @@ -85,6 +139,33 @@ def test_failed_action_does_not_refresh() -> None: assert snap.network_refresh_calls == [] +def test_add_devices_failure_skips_sync_and_refresh() -> None: + snap = _RecordingSnapshot() + app = _App( + by_path={_ADD_DEVICES: {"msg": ["add failed"], "ok": False}}, + snapshot=snap, + ) + assert app.add_devices_to_topology(["device12"]) is False + paths = [path for path, _ in app._inspect_api.vip_connector.rest.post_calls] + assert paths == [_ADD_DEVICES] + assert snap.network_refresh_calls == [] + + +def test_add_devices_sync_failure_refreshes_and_returns_false() -> None: + snap = _RecordingSnapshot() + app = _App( + by_path={ + _ADD_DEVICES: {"msg": [], "ok": True}, + _SYNC_DEVICES: {"msg": ["sync failed"], "ok": False}, + }, + snapshot=snap, + ) + assert app.add_devices_to_topology(["device12"]) is False + paths = [path for path, _ in app._inspect_api.vip_connector.rest.post_calls] + assert paths == [_ADD_DEVICES, _SYNC_DEVICES] + assert snap.network_refresh_calls == [["device12"]] + + # --- Internal --- @@ -117,9 +198,14 @@ def apply_network_refresh(self, device_ids: list[str]) -> None: class _App(InspectActionsMixin): def __init__( self, - post_data: dict[str, Any], + post_data: dict[str, Any] | None = None, snapshot: _RecordingSnapshot | None = None, + *, + by_path: dict[str, dict[str, Any]] | None = None, ) -> None: self._logger = logging.getLogger("test") - self._inspect_api = InspectAPI(SimpleNamespace(rest=FakeRest(post_data)), self._logger) + self._inspect_api = InspectAPI( + SimpleNamespace(rest=FakeRest(post_data, by_path=by_path)), + self._logger, + ) self._snapshot = snapshot From b130d600de02173abc796b285b120308fc7d8644 Mon Sep 17 00:00:00 2001 From: Jonas Scholl <43819845+JonasScholl@users.noreply.github.com> Date: Thu, 23 Jul 2026 03:40:17 +0200 Subject: [PATCH 22/34] refactor, improve and add new e2e tests --- tests/e2e/{inspect => apps}/__init__.py | 0 .../test_inspect.py} | 173 ++++----- tests/e2e/apps/test_inventory.py | 95 +++++ tests/e2e/apps/test_preferences.py | 46 +++ tests/e2e/apps/test_profile.py | 40 +++ tests/e2e/apps/test_security.py | 42 +++ tests/e2e/apps/test_topology.py | 65 ++++ tests/e2e/conftest.py | 47 ++- tests/e2e/helpers.py | 331 ++++++++++++------ tests/e2e/networks.py | 91 +++++ tests/e2e/test_e2e_workflow.py | 243 ------------- tests/e2e/workflows/__init__.py | 0 tests/e2e/workflows/test_build_networks.py | 233 ++++++++++++ .../e2e/workflows/test_onboarding_pipeline.py | 122 +++++++ tests/inspect/test_exports.py | 11 +- 15 files changed, 1097 insertions(+), 442 deletions(-) rename tests/e2e/{inspect => apps}/__init__.py (100%) rename tests/e2e/{inspect/test_e2e_inspect.py => apps/test_inspect.py} (62%) create mode 100644 tests/e2e/apps/test_inventory.py create mode 100644 tests/e2e/apps/test_preferences.py create mode 100644 tests/e2e/apps/test_profile.py create mode 100644 tests/e2e/apps/test_security.py create mode 100644 tests/e2e/apps/test_topology.py create mode 100644 tests/e2e/networks.py delete mode 100644 tests/e2e/test_e2e_workflow.py create mode 100644 tests/e2e/workflows/__init__.py create mode 100644 tests/e2e/workflows/test_build_networks.py create mode 100644 tests/e2e/workflows/test_onboarding_pipeline.py diff --git a/tests/e2e/inspect/__init__.py b/tests/e2e/apps/__init__.py similarity index 100% rename from tests/e2e/inspect/__init__.py rename to tests/e2e/apps/__init__.py diff --git a/tests/e2e/inspect/test_e2e_inspect.py b/tests/e2e/apps/test_inspect.py similarity index 62% rename from tests/e2e/inspect/test_e2e_inspect.py rename to tests/e2e/apps/test_inspect.py index 87c8264..410cb56 100644 --- a/tests/e2e/inspect/test_e2e_inspect.py +++ b/tests/e2e/apps/test_inspect.py @@ -1,11 +1,11 @@ -"""Independent Inspect capability tests against a live instance. +"""Focused Inspect app suite: read/write capabilities against a live instance. Every test builds its **own** minimal topology (1–3 mock devices) via the ``topology_builder`` -fixture — unique labels/addresses per test, guaranteed teardown (edges → topology node → inventory -entry) even on failure. All assertions are scoped to the test's own device ids, so the persisted -workflow topology or any other state on a shared instance never interferes. +fixture — unique labels/addresses per test. Assertions are scoped to the test's own device ids so +other suites on a shared instance never interfere. ``E2E-`` artifacts persist until the next e2e +session-start sweep. -Developer-run only (see ``tests/e2e/conftest.py``). Run with:: +Run with:: poetry run test-e2e """ @@ -14,22 +14,23 @@ import pytest +from videoipath_automation_tool.apps.inspect import VirtualDeviceSpec from videoipath_automation_tool.apps.inspect.errors import InspectCommitConflictError, InspectCommitError from videoipath_automation_tool.apps.videoipath_app import VideoIPathApp from ..helpers import ( + E2E_TAG, MODULE_TEST_TAG_ID, TEST_TAG_ID, FetchSpy, TopologyBuilder, create_module_test_tag, create_test_tag, - delete_module_test_tag, - delete_test_tag, edges_between, + router_ports, + unique_label, ) - pytestmark = pytest.mark.e2e @@ -39,7 +40,7 @@ def test_skeleton_read_no_hydration(app: VideoIPathApp, topology_builder: Topolo app.inspect.refresh() with FetchSpy(app.inspect._inspect_api) as spy: assert len(edges_between(app, id_a, id_b)) == 2 - assert spy.count == 0 # skeleton read triggers no per-device hydration + assert spy.count == 0 def test_lazy_hydration(app: VideoIPathApp, topology_builder: TopologyBuilder) -> None: @@ -47,11 +48,11 @@ def test_lazy_hydration(app: VideoIPathApp, topology_builder: TopologyBuilder) - app.inspect.refresh() assert not app.inspect.is_device_hydrated(id_a) with FetchSpy(app.inspect._inspect_api) as spy: - ports = app.inspect.get_device(id_a).ports # one detail fetch + ports = app.inspect.get_device(id_a).ports assert spy.count == 1 - _ = app.inspect.get_device(id_a).ports # cached + _ = app.inspect.get_device(id_a).ports assert spy.count == 1 - assert len(ports) > 0 # mock devices expose router ports + assert len(ports) > 0 assert app.inspect.is_device_hydrated(id_a) assert not app.inspect.is_device_hydrated(id_b) @@ -61,7 +62,7 @@ def test_connectivity_graph(app: VideoIPathApp, topology_builder: TopologyBuilde topology_builder.link(hub, id_b) topology_builder.link(hub, id_c) app.inspect.refresh() - linked = {d.label for d in app.inspect.get_device(hub).linked_devices} + linked = {device.label for device in app.inspect.get_device(hub).linked_devices} assert linked == {topology_builder.labels[id_b], topology_builder.labels[id_c]} @@ -72,8 +73,7 @@ def test_edge_pair_refresh(app: VideoIPathApp, topology_builder: TopologyBuilder edge_id = edges_between(app, id_a, id_b)[0].id result = app.inspect.update_edge(edge_id, weight=7) assert result.ok - # Survives the targeted post-commit refresh without a full reload. - assert any(e.id == edge_id for e in app.inspect.edges) + assert any(edge.id == edge_id for edge in app.inspect.edges) def test_transaction_atomicity(app: VideoIPathApp, topology_builder: TopologyBuilder) -> None: @@ -86,9 +86,8 @@ def test_transaction_atomicity(app: VideoIPathApp, topology_builder: TopologyBui tx.update_edge(edge_id, weight=13) tx.remove("does-not-exist::also-not-real") tx.commit() - # Nothing applied: the edge is still present. app.inspect.refresh() - assert any(e.id == edge_id for e in app.inspect.edges) + assert any(edge.id == edge_id for edge in app.inspect.edges) def test_conflict_detection(app: VideoIPathApp, topology_builder: TopologyBuilder) -> None: @@ -98,82 +97,68 @@ def test_conflict_detection(app: VideoIPathApp, topology_builder: TopologyBuilde edge_id = edges_between(app, id_a, id_b)[0].id tx = app.inspect.transaction() tx.update_edge(edge_id, weight=21) - # Out-of-band change via a second app instance. other = VideoIPathApp() other.inspect.update_edge(edge_id, weight=9) with pytest.raises(InspectCommitConflictError) as exc: tx.commit() - assert any(c.entity_id == edge_id for c in exc.value.conflicts) + assert any(conflict.entity_id == edge_id for conflict in exc.value.conflicts) tx.rebase() tx.commit() def test_assign_tag_to_port(app: VideoIPathApp, topology_builder: TopologyBuilder) -> None: - # Inspect-only capability: assign a catalog tag to a port. (device_id,) = topology_builder.add_devices([("TAG-A", 2)]) create_test_tag(app) - try: - app.inspect.refresh() - vertex_id = next( - p.vertex_in.id - for p in app.inspect.get_device(device_id).ports - if p.vertex_in is not None and "Router In" in (p.label or "") - ) - result = app.inspect.update_vertex(vertex_id, tags=[TEST_TAG_ID]) - assert result.ok - app.inspect.refresh() - port = next( - p - for p in app.inspect.get_device(device_id).ports - if p.vertex_in is not None and p.vertex_in.id == vertex_id - ) - assert TEST_TAG_ID in port.tags - finally: - delete_test_tag(app) # also removes the port binding + app.inspect.refresh() + device = app.inspect.get_device(device_id) + assert device is not None + _out, in_vertex = router_ports(device)[0] + result = app.inspect.update_vertex(in_vertex, tags=[TEST_TAG_ID]) + assert result.ok + app.inspect.refresh() + port = next( + port + for port in app.inspect.get_device(device_id).ports + if port.vertex_in is not None and port.vertex_in.id == in_vertex + ) + assert TEST_TAG_ID in port.tags def test_assign_and_unassign_module_tag(app: VideoIPathApp, topology_builder: TopologyBuilder) -> None: - """Create a dedicated catalog tag, assign it to a module, then unassign it.""" (device_id,) = topology_builder.add_devices([("MOD-TAG-A", 2)]) create_module_test_tag(app) - try: - app.inspect.refresh() - device = app.inspect.get_device(device_id) - assert device is not None - assert device.modules, "mock device should expose at least one module" - module = device.modules[0] - module_id = module.id - - module.tags = [MODULE_TEST_TAG_ID] - result = app.inspect.update(module) - assert result.ok - - app.inspect.refresh() - module = app.inspect.get_device(device_id).get_module(module_id) - assert module is not None - assert MODULE_TEST_TAG_ID in module.tags - - module.tags = [] - result = app.inspect.update(module) - assert result.ok - - app.inspect.refresh() - module = app.inspect.get_device(device_id).get_module(module_id) - assert module is not None - assert MODULE_TEST_TAG_ID not in module.tags - finally: - delete_module_test_tag(app) + app.inspect.refresh() + device = app.inspect.get_device(device_id) + assert device is not None + assert device.modules + module = device.modules[0] + module_id = module.id + + module.tags = [MODULE_TEST_TAG_ID] + result = app.inspect.update(module) + assert result.ok + + app.inspect.refresh() + module = app.inspect.get_device(device_id).get_module(module_id) + assert module is not None + assert MODULE_TEST_TAG_ID in module.tags + + module.tags = [] + result = app.inspect.update(module) + assert result.ok + + app.inspect.refresh() + module = app.inspect.get_device(device_id).get_module(module_id) + assert module is not None + assert MODULE_TEST_TAG_ID not in module.tags def test_update_vertex_fields(app: VideoIPathApp, topology_builder: TopologyBuilder) -> None: - """Write every editable vertex UI field and read it back via the typed InspectVertex.""" (device_id,) = topology_builder.add_devices([("VTX-A", 2)]) app.inspect.refresh() - vertex_id = next( - p.vertex_in.id - for p in app.inspect.get_device(device_id).ports - if p.vertex_in is not None and "Router In" in (p.label or "") - ) + device = app.inspect.get_device(device_id) + assert device is not None + _out, vertex_id = router_ports(device)[0] alert_filter = "0:*:e2e-alarm:*" result = app.inspect.update_vertex( vertex_id, @@ -191,7 +176,9 @@ def test_update_vertex_fields(app: VideoIPathApp, topology_builder: TopologyBuil app.inspect.refresh() port = next( - p for p in app.inspect.get_device(device_id).ports if p.vertex_in is not None and p.vertex_in.id == vertex_id + port + for port in app.inspect.get_device(device_id).ports + if port.vertex_in is not None and port.vertex_in.id == vertex_id ) vertex = port.vertex_in assert vertex is not None @@ -212,17 +199,18 @@ def test_update_vertex_fields(app: VideoIPathApp, topology_builder: TopologyBuil def test_device_placement(app: VideoIPathApp, topology_builder: TopologyBuilder) -> None: (device_id,) = topology_builder.add_devices([("PLACE-A", 2)]) - app.inspect.place_device(device_id, 4200, 4200) + x, y = topology_builder.origin[0] + 150, topology_builder.origin[1] + 150 + app.inspect.place_device(device_id, x, y) app.inspect.refresh() coords = app.inspect.get_device(device_id).coordinates - assert coords is not None and coords["x"] == 4200 and coords["y"] == 4200 + assert coords is not None and coords["x"] == x and coords["y"] == y def test_disconnect_reconnect_cycle(app: VideoIPathApp, topology_builder: TopologyBuilder) -> None: id_a, id_b = topology_builder.add_devices([("CYC-A", 2), ("CYC-B", 2)]) topology_builder.link(id_a, id_b) app.inspect.refresh() - directed = [tuple(e.id.split("::", 1)) for e in edges_between(app, id_a, id_b)] + directed = [tuple(edge.id.split("::", 1)) for edge in edges_between(app, id_a, id_b)] assert directed for from_vertex, to_vertex in directed: app.inspect.disconnect(from_vertex, to_vertex, bidirectional=False) @@ -243,7 +231,7 @@ def graph_view() -> dict[str, tuple[str | None, frozenset[str | None]]]: return { device_id: ( app.inspect.get_device(device_id).label, - frozenset(d.label for d in app.inspect.get_device(device_id).linked_devices), + frozenset(device.label for device in app.inspect.get_device(device_id).linked_devices), ) for device_id in (id_a, id_b, id_c) } @@ -256,4 +244,33 @@ def graph_view() -> dict[str, tuple[str | None, frozenset[str | None]]]: full = graph_view() assert skeleton == full finally: - app.inspect.refresh(load="skeleton") # restore default load mode + app.inspect.refresh(load="skeleton") + + +def test_create_virtual_device(app: VideoIPathApp, e2e_map_origins) -> None: + templates = app.inspect.list_port_templates() + if not templates: + pytest.skip("No port templates available on this server.") + by_id = {template.id: template for template in templates} + # Prefer a known pair from the docs example; otherwise pick any two templates. + if "ip_in" in by_id and "ip_out" in by_id: + spec = VirtualDeviceSpec.from_ports(("ip_in", 1), ("ip_out", 1)) + else: + first = templates[0] + second = templates[1] if len(templates) > 1 else templates[0] + spec = VirtualDeviceSpec.from_ports((first.id, 1), (second.id, 1)) + + device = app.inspect.create_virtual_device(spec) + assert device.is_virtual is True + label = unique_label("VIRTUAL") + device.label = label + device.tags = [E2E_TAG, "virtual"] + app.inspect.update(device) + x, y = next(e2e_map_origins) + app.inspect.place_device(device.id, x=x, y=y) + app.inspect.refresh() + loaded = app.inspect.get_device(device.id) + assert loaded is not None + assert loaded.label == label + assert loaded.coordinates is not None + assert loaded.coordinates["x"] == x and loaded.coordinates["y"] == y diff --git a/tests/e2e/apps/test_inventory.py b/tests/e2e/apps/test_inventory.py new file mode 100644 index 0000000..163f16c --- /dev/null +++ b/tests/e2e/apps/test_inventory.py @@ -0,0 +1,95 @@ +"""Focused Inventory app suite: create → get → diff → update → clone → enable/disable. + +Ordered ``@pytest.mark.incremental`` steps mirror ``docs/examples/02_inventory/``. Devices use the +mock driver and the ``E2E-`` namespace; they persist until the next e2e session-start sweep. + +Run with:: + + poetry run test-e2e +""" + +from __future__ import annotations + +from typing import Iterator + +import pytest +from pydantic import BaseModel + +from videoipath_automation_tool.apps.videoipath_app import VideoIPathApp + +from ..helpers import MOCK_DRIVER, create_mock_device, unique_label + +pytestmark = pytest.mark.e2e + + +class InventoryState(BaseModel): + device_ids: list[str] = [] + label: str | None = None + address: str | None = None + + +@pytest.fixture(scope="class") +def state() -> InventoryState: + return InventoryState() + + +@pytest.mark.incremental +class TestInventoryLifecycle: + def test_create_and_add(self, app: VideoIPathApp, state: InventoryState, e2e_addresses: Iterator[str]) -> None: + state.label = unique_label("INV") + state.address = next(e2e_addresses) + device_id = create_mock_device(app, label=state.label, address=state.address, ports=2) + state.device_ids.append(device_id) + assert device_id + + def test_get_by_label_and_id(self, app: VideoIPathApp, state: InventoryState) -> None: + assert state.label is not None + device_id = state.device_ids[0] + found = app.inventory.find_device_id_by_label(state.label, label_search_mode="user_defined_label_only") + assert found == device_id + by_id = app.inventory.get_device(device_id=device_id, config_only=True, custom_settings_type=MOCK_DRIVER) + by_label = app.inventory.get_device( + label=state.label, label_search_mode="user_defined_label_only", config_only=True + ) + assert by_id.configuration.label == state.label + assert by_label.configuration.device_id == device_id + + def test_diff_unchanged(self, app: VideoIPathApp, state: InventoryState) -> None: + device_id = state.device_ids[0] + reference = app.inventory.get_device(device_id=device_id, config_only=True, custom_settings_type=MOCK_DRIVER) + staged = app.inventory.get_device(device_id=device_id, config_only=True, custom_settings_type=MOCK_DRIVER) + diff = app.inventory.diff_device_configuration(reference_device=reference, staged_device=staged) + assert not diff.configuration_diff.added + assert not diff.configuration_diff.changed + assert not diff.configuration_diff.removed + + def test_update_description(self, app: VideoIPathApp, state: InventoryState) -> None: + device_id = state.device_ids[0] + reference = app.inventory.get_device(device_id=device_id, config_only=True, custom_settings_type=MOCK_DRIVER) + staged = app.inventory.get_device(device_id=device_id, config_only=True, custom_settings_type=MOCK_DRIVER) + staged.configuration.description = "E2E inventory lifecycle device" + diff = app.inventory.diff_device_configuration(reference_device=reference, staged_device=staged) + assert diff.configuration_diff.changed + app.inventory.update_device(device=staged) + updated = app.inventory.get_device(device_id=device_id, config_only=True) + assert updated.configuration.description == "E2E inventory lifecycle device" + + def test_dump_parse_clone(self, app: VideoIPathApp, state: InventoryState, e2e_addresses: Iterator[str]) -> None: + device_id = state.device_ids[0] + device = app.inventory.get_device(device_id=device_id, config_only=True, custom_settings_type=MOCK_DRIVER) + dump = app.inventory.dump_configuration(device) + clone = app.inventory.parse_configuration(dump) + clone.configuration.label = unique_label("INV-CLONE") + clone.configuration.address = next(e2e_addresses) + clone.remove_device_id() + cloned = app.inventory.add_device(device=clone, address_check=False) + state.device_ids.append(cloned.configuration.device_id) + assert cloned.configuration.device_id != device_id + assert cloned.configuration.label == clone.configuration.label + + def test_disable_and_enable(self, app: VideoIPathApp, state: InventoryState) -> None: + device_id = state.device_ids[0] + disabled = app.inventory.disable_device(device_id) + assert disabled.configuration.active is False + enabled = app.inventory.enable_device(device_id) + assert enabled.configuration.active is True diff --git a/tests/e2e/apps/test_preferences.py b/tests/e2e/apps/test_preferences.py new file mode 100644 index 0000000..edf1cf4 --- /dev/null +++ b/tests/e2e/apps/test_preferences.py @@ -0,0 +1,46 @@ +"""Focused Preferences app suite: system info reads + multicast pool lifecycle. + +Mirrors ``docs/examples/05_administration/03_multicast_pools.py``. The multicast pool uses a unique +``E2E-`` name and is left in place; the next e2e session-start sweep removes it. + +Run with:: + + poetry run test-e2e +""" + +from __future__ import annotations + +import pytest + +from videoipath_automation_tool.apps.videoipath_app import VideoIPathApp + +from ..helpers import unique_name + +pytestmark = pytest.mark.e2e + + +def test_read_system_network_info(app: VideoIPathApp) -> None: + network = app.preferences.system_configuration.network + hostname = network.get_hostname() + assert isinstance(hostname, str) and hostname + interfaces = network.get_all_interfaces() + assert isinstance(interfaces, list) + dns_servers = network.get_dns_servers() + assert isinstance(dns_servers, list) + + +def test_multicast_pool_lifecycle(app: VideoIPathApp) -> None: + pools = app.preferences.system_configuration.allocation_pools + existing = pools.get_multicast_ranges() + assert isinstance(existing.available_ranges, list) + + pool_name = unique_name("pool") + staged = pools.create_multicast_range(name=pool_name, start_ip="239.99.0.0", end_ip="239.99.0.255") + pools.add_multicast_range(staged) + assert pool_name in pools.get_multicast_ranges().available_ranges + + pool = pools.get_multicast_range_by_name(pool_name) + pool.add_ip_range(start_ip="239.99.1.0", end_ip="239.99.1.255") + pools.update_multicast_range(pool) + refreshed = pools.get_multicast_range_by_name(pool_name) + assert len(refreshed.ranges) == 2 diff --git a/tests/e2e/apps/test_profile.py b/tests/e2e/apps/test_profile.py new file mode 100644 index 0000000..9efc32d --- /dev/null +++ b/tests/e2e/apps/test_profile.py @@ -0,0 +1,40 @@ +"""Focused Profile app suite: create and clone an ``E2E-`` profile. + +Mirrors ``docs/examples/05_administration/02_profiles.py``. Profiles are left in place; the next e2e +session-start sweep removes ``E2E-`` profiles. + +Run with:: + + poetry run test-e2e +""" + +from __future__ import annotations + +import pytest + +from videoipath_automation_tool.apps.videoipath_app import VideoIPathApp + +from ..helpers import unique_name + +pytestmark = pytest.mark.e2e + + +def test_profile_create_and_clone(app: VideoIPathApp) -> None: + names_before = app.profile.list_profile_names() or [] + assert isinstance(names_before, list) + + profile_name = unique_name("profile") + created = app.profile.create_profile(name=profile_name) + created = app.profile.add_profile(created) + names = app.profile.list_profile_names() or [] + assert profile_name in names + + fetched = app.profile.get_profile_by_name(profile_name) + assert fetched is not None + source = fetched[0] if isinstance(fetched, list) else fetched + + clone = app.profile.clone_profile(source) + clone = app.profile.add_profile(clone) + assert clone.name.endswith("(clone)") + clone_names = app.profile.list_profile_names() or [] + assert clone.name in clone_names diff --git a/tests/e2e/apps/test_security.py b/tests/e2e/apps/test_security.py new file mode 100644 index 0000000..2991540 --- /dev/null +++ b/tests/e2e/apps/test_security.py @@ -0,0 +1,42 @@ +"""Focused Security app suite: domain create + device membership assign/clear. + +Mirrors ``docs/examples/05_administration/01_security_domains_and_memberships.py``. Uses a +``topology_builder`` device so memberships attach to a real inventory id. ``E2E-`` domains and +devices persist until the next e2e session-start sweep. + +Run with:: + + poetry run test-e2e +""" + +from __future__ import annotations + +import pytest + +from videoipath_automation_tool.apps.videoipath_app import VideoIPathApp + +from ..helpers import TopologyBuilder, unique_name + +pytestmark = pytest.mark.e2e + + +def test_domain_and_membership_lifecycle(app: VideoIPathApp, topology_builder: TopologyBuilder) -> None: + (device_id,) = topology_builder.add_devices([("SEC-A", 2)]) + domain_name = unique_name("domain") + domain = app.security.domains.create_domain(name=domain_name, description="E2E security domain") + assert domain.name == domain_name + assert domain_name in app.security.domains.list_domain_names() + + memberships = app.security.resources.get_device_memberships(device_id=device_id) + memberships.domains = app.security.resources.convert_domain_names_to_ids([domain_name]) + app.security.resources.update_memberships(memberships=memberships) + + memberships = app.security.resources.get_device_memberships(device_id=device_id) + names = set(app.security.resources.convert_domain_ids_to_names(memberships.domains)) + assert domain_name in names + + memberships = app.security.resources.get_device_memberships(device_id=device_id) + memberships.domains = [] + app.security.resources.update_memberships(memberships=memberships) + memberships = app.security.resources.get_device_memberships(device_id=device_id) + assert memberships.domains == [] diff --git a/tests/e2e/apps/test_topology.py b/tests/e2e/apps/test_topology.py new file mode 100644 index 0000000..40c3d0d --- /dev/null +++ b/tests/e2e/apps/test_topology.py @@ -0,0 +1,65 @@ +"""Focused Topology app suite (legacy path), skipped on VideoIPath 2026.x+. + +Builds a device via Inspect (``topology_builder``), then exercises the classic Topology API: +``get_device``, ``find_device_id_by_label``, and a label / position round-trip. TopologyApp is +deprecated on 2025.x and raises ``TopologyUnsupportedError`` on 2026.x — prefer Inspect elsewhere. + +Run with:: + + poetry run test-e2e +""" + +from __future__ import annotations + +import pytest + +from videoipath_automation_tool.apps.topology.errors import TopologyUnsupportedError +from videoipath_automation_tool.apps.topology.topology_app import TopologyApp +from videoipath_automation_tool.apps.videoipath_app import VideoIPathApp + +from ..helpers import TopologyBuilder + +pytestmark = pytest.mark.e2e + + +@pytest.fixture +def topology(app: VideoIPathApp) -> TopologyApp: + try: + return app.topology + except TopologyUnsupportedError as exc: + pytest.skip(str(exc)) + + +def test_get_device_and_find_by_label( + app: VideoIPathApp, topology: TopologyApp, topology_builder: TopologyBuilder +) -> None: + (device_id,) = topology_builder.add_devices([("TOPO-A", 2)]) + label = topology_builder.labels[device_id] + device = topology.get_device(device_id) + assert device.configuration.base_device.id == device_id + found = topology.find_device_id_by_label(label, label_search_mode="user_defined_label_only") + assert found == device_id + + +def test_update_label_and_position( + app: VideoIPathApp, topology: TopologyApp, topology_builder: TopologyBuilder +) -> None: + (device_id,) = topology_builder.add_devices([("TOPO-B", 2)]) + device = topology.get_device(device_id) + new_label = topology_builder.labels[device_id] + "-MOVED" + x, y = topology_builder.origin[0] + 150, topology_builder.origin[1] + 150 + device.configuration.label = new_label + device.configuration.position_x = x + device.configuration.position_y = y + updated = topology.update_device(device) + assert updated.configuration.label == new_label + assert updated.configuration.position_x == x + assert updated.configuration.position_y == y + + app.inspect.refresh() + inspect_device = app.inspect.get_device(device_id) + assert inspect_device is not None + assert inspect_device.label == new_label + assert inspect_device.coordinates is not None + assert inspect_device.coordinates["x"] == x + assert inspect_device.coordinates["y"] == y diff --git a/tests/e2e/conftest.py b/tests/e2e/conftest.py index 7d94fdc..794edc3 100644 --- a/tests/e2e/conftest.py +++ b/tests/e2e/conftest.py @@ -8,11 +8,15 @@ E2e entry points load ``.env`` and enable the suite automatically. E2e runs never collect coverage (``--no-cov``). +Layout of the suite: + * ``workflows/`` — general, ordered "build the scenario step by step" suites: the generic + network-builder (one suite per :mod:`networks` architecture) and the cross-app onboarding pipeline. + * ``apps/`` — focused per-app suites (inventory, inspect, topology, preferences, profile, security). + Everything the suite writes is namespaced (``E2E-`` label prefix + ``vipat-e2e`` tag) so a shared -local instance is safe. Cleanup has two layers: a session-start sweep removes any leftovers from -prior runs, and the per-test ``topology_builder`` fixture removes exactly the devices a test -created (also on failure). The sequential workflow suite intentionally leaves its topology behind -for manual inspection; the next run's sweep removes it. +local instance is safe. Cleanup is a single session-start sweep that removes every ``E2E-`` +artifact left from a prior run; suites intentionally leave their topologies (including the +network-builder architectures) in VideoIPath for manual inspection after the run. """ from __future__ import annotations @@ -27,7 +31,7 @@ from videoipath_automation_tool.apps.videoipath_app import VideoIPathApp from vipat_cli_scripts.project_env import load_project_env -from .helpers import TopologyBuilder, remove_devices, sweep_e2e_namespace +from .helpers import TopologyBuilder, sweep_e2e_namespace load_project_env() @@ -57,7 +61,7 @@ def app() -> VideoIPathApp: @pytest.fixture(scope="session", autouse=True) def e2e_sweep(app: VideoIPathApp) -> None: - """Session-start sweep: remove every ``E2E-`` device left over from a prior run.""" + """Session-start sweep: remove every ``E2E-`` artifact left over from a prior run.""" sweep_e2e_namespace(app) @@ -67,18 +71,31 @@ def e2e_addresses() -> Iterator[str]: return (f"10.99.{i // 256}.{i % 256}" for i in count(1)) +@pytest.fixture(scope="session") +def e2e_map_origins() -> Iterator[tuple[int, int]]: + """Session-wide map-origin allocator so per-test TopologyBuilder instances never stack. + + Laid out in a grid well clear of the workflow network-builder region (y >= 6000). + Each slot is large enough for a few devices spaced 300 apart horizontally. + """ + cols, slot_w, slot_h, base_x, base_y = 8, 1200, 800, 0, 2000 + return ((base_x + (i % cols) * slot_w, base_y + (i // cols) * slot_h) for i in count()) + + @pytest.fixture -def topology_builder(app: VideoIPathApp, e2e_addresses: Iterator[str]) -> Iterator[TopologyBuilder]: - """A per-test topology factory; teardown removes exactly the devices the test created.""" - builder = TopologyBuilder(app, e2e_addresses) - yield builder - remove_devices(app, set(builder.device_ids)) +def topology_builder( + app: VideoIPathApp, e2e_addresses: Iterator[str], e2e_map_origins: Iterator[tuple[int, int]] +) -> TopologyBuilder: + """A per-test topology factory with a unique map origin (session sweep cleans ``E2E-`` artifacts).""" + x, y = next(e2e_map_origins) + return TopologyBuilder(app, e2e_addresses, x=x, y=y) -# --- Sequential workflow support (``@pytest.mark.incremental``) --- -# Later steps of a sequential suite are skipped (not failed) once an earlier step fails. With the +# --- Sequential suite support (``@pytest.mark.incremental``) --- +# Later steps of an ordered suite are skipped (not failed) once an earlier step fails. With the # default ``-x`` in ``addopts`` the run stops at the first failure anyway; these hooks make the -# behavior sensible without it too (e.g. ``--maxfail=0``). +# behavior sensible without it too (e.g. ``--maxfail=0``). Each test class has its own failure stash, +# so ordered suites (including the per-network builder subclasses) are isolated from one another. def pytest_runtest_makereport(item: pytest.Item, call: pytest.CallInfo) -> None: @@ -93,4 +110,4 @@ def pytest_runtest_setup(item: pytest.Item) -> None: return failed = item.parent.stash.get(_STEP_FAILED_KEY, None) if failed is not None: - pytest.skip(f"previous workflow step failed ({failed})") + pytest.skip(f"previous step in this suite failed ({failed})") diff --git a/tests/e2e/helpers.py b/tests/e2e/helpers.py index 725a725..9720a93 100644 --- a/tests/e2e/helpers.py +++ b/tests/e2e/helpers.py @@ -1,12 +1,11 @@ """Shared helpers for the developer-run live-server E2E suite ([ADR-0005]). Everything the suite writes is namespaced (``E2E-`` label prefix + ``vipat-e2e`` tag) so a shared -local instance stays safe. Two cleanup layers use that namespace: - -* ``sweep_e2e_namespace`` — session-start sweep that removes **every** ``E2E-`` device (and its - edges) left over from a prior run, in both the inventory and the inspect topology graph. -* ``remove_devices`` — targeted removal of exactly the devices a single test created (used by the - per-test ``topology_builder`` teardown). +local instance stays safe. Cleanup is a single session-start :func:`sweep_e2e_namespace` that +removes **every** ``E2E-`` artifact left from a prior run (devices and their edges in both the +inventory and the Inspect graph, plus ``E2E-`` profiles, security domains, multicast pools, and e2e +catalog tags). Suites leave their topologies in place — including the network-builder architectures — +for manual inspection after the run. Devices are always virtual (mock-driver); no real hardware is involved. The build path is the real user flow: **inventory** (create + add device) → **inspect** (add to topology graph, label + tag, @@ -15,21 +14,23 @@ from __future__ import annotations -from collections import defaultdict -from typing import TYPE_CHECKING, Any, Iterator +from typing import TYPE_CHECKING, Any, Iterable, Iterator from uuid import uuid4 import requests if TYPE_CHECKING: + from videoipath_automation_tool.apps.inspect.domain.device import InspectDevice from videoipath_automation_tool.apps.inspect.domain.edge import InspectEdge from videoipath_automation_tool.apps.videoipath_app import VideoIPathApp + from .networks import Network + E2E_PREFIX = "E2E-" E2E_TAG = "vipat-e2e" MOCK_DRIVER = "com.nevion.mock-0.1.0" -# Catalog tags created via simple API requests for Inspect e2e. Tag references are +# Catalog tags created via simple API requests for the Inspect tag tests. Tag references are # ``Category~~name`` ids; they live in the existing "Video" format category. TEST_TAG_CATEGORY = "Video" TEST_TAG_NAME = "E2E-VIDEO-TAG" @@ -45,8 +46,20 @@ def unique_label(base: str) -> str: return f"{E2E_PREFIX}T-{uuid4().hex[:6].upper()}-{base}" +def unique_name(base: str) -> str: + """A per-test unique ``E2E-`` name for profiles / domains / pools so the sweep catches orphans.""" + return f"{E2E_PREFIX}{base}-{uuid4().hex[:6].upper()}" + + +# --- Device build path (inventory -> inspect) ------------------------------------------------------ + + def create_mock_device(app: "VideoIPathApp", *, label: str, address: str, ports: int) -> str: - """Create a virtual (mock-driver) device in the inventory and return its device id.""" + """Create a virtual (mock-driver) device in the inventory and return its device id. + + Mirrors the inventory example: create a device from a driver, set its typed ``custom_settings``, + and add it. The mock driver exposes one router module with ``ports`` router ports. + """ device = app.inventory.create_device(driver=MOCK_DRIVER) device.configuration.label = label device.configuration.address = address @@ -58,48 +71,188 @@ def create_mock_device(app: "VideoIPathApp", *, label: str, address: str, ports: return online.configuration.device_id -def discover_router_vertices(app: "VideoIPathApp", device_ids: list[str]) -> dict[str, tuple[list[str], list[str]]]: - """Per device id: the (out, in) router vertex ids, each sorted by port slot. +def router_ports(device: "InspectDevice") -> list[tuple[str, str]]: + """Ordered ``(out-vertex-id, in-vertex-id)`` pairs for a mock router device, one per port slot. - Refreshes the snapshot and preloads the devices in parallel to avoid N+1 detail fetches. + A mock router device exposes separate "Router Out N" and "Router In N" ports; this pairs them by + slot so a caller can wire the out side of one device to the in side of another. """ + outs: list[str] = [] + ins: list[str] = [] + for port in device.ports: + label = port.label or "" + vertex = port.vertex_out or port.vertex_in + if vertex is None: + continue + if "Router Out" in label: + outs.append(vertex.id) + elif "Router In" in label: + ins.append(vertex.id) + outs.sort(key=_vertex_slot) + ins.sort(key=_vertex_slot) + return list(zip(outs, ins)) + + +class PortCursor: + """Hands out a device's router ports one slot at a time (the next free out/in vertex pair).""" + + def __init__(self, ports: list[tuple[str, str]]) -> None: + self._ports = ports + self._next = 0 + + def next_port(self) -> tuple[str, str]: + """Return the next unused ``(out-vertex-id, in-vertex-id)`` pair.""" + if self._next >= len(self._ports): + raise LookupError("No free router port left on this device.") + pair = self._ports[self._next] + self._next += 1 + return pair + + +def discover_port_cursors(app: "VideoIPathApp", device_ids: Iterable[str]) -> dict[str, PortCursor]: + """A next-free-port cursor per device: refresh the view, hydrate in one batch, read the ports.""" + ids = list(device_ids) app.inspect.refresh() - app.inspect.preload(device_ids) - vertices: dict[str, tuple[list[str], list[str]]] = {} - for device_id in device_ids: + app.inspect.preload(ids) + cursors: dict[str, PortCursor] = {} + for device_id in ids: device = app.inspect.get_device(device_id) if device is None: raise LookupError(f"Device '{device_id}' not found in the inspect topology.") - outs: list[str] = [] - ins: list[str] = [] - for port in device.ports: - label = port.label or "" - vertex = port.vertex_out or port.vertex_in - if vertex is None: - continue - if "Router Out" in label: - outs.append(vertex.id) - elif "Router In" in label: - ins.append(vertex.id) - vertices[device_id] = (sorted(outs, key=_vertex_slot), sorted(ins, key=_vertex_slot)) - return vertices + cursors[device_id] = PortCursor(router_ports(device)) + return cursors def edges_between(app: "VideoIPathApp", id_a: str, id_b: str) -> list["InspectEdge"]: """All directed edges between two devices (either direction).""" return [ - e - for e in app.inspect.edges - if e.from_device and e.to_device and {e.from_device.id, e.to_device.id} == {id_a, id_b} + edge + for edge in app.inspect.edges + if edge.from_device and edge.to_device and {edge.from_device.id, edge.to_device.id} == {id_a, id_b} ] +class NetworkBuild: + """Builds a :class:`Network` into a live topology step by step (the engine behind the builder suite). + + Each phase of the real user journey is one readable call, and a little state is kept between + phases: inventory create → onboard into the Inspect topology (at ``base position + offset``) → + label & tag → connect the links. Device labels are ``E2E--`` so they are unique + per network and caught by the session sweep. + """ + + def __init__( + self, + app: "VideoIPathApp", + network: "Network", + offset: tuple[int, int], + addresses: Iterator[str], + ) -> None: + self._app = app + self.network = network + self._offset = offset + self._addresses = addresses + self.device_ids: dict[str, str] = {} # spec name -> VideoIPath device id + + def label_of(self, name: str) -> str: + return f"{E2E_PREFIX}{self.network.name}-{name}" + + def create_devices(self) -> None: + for spec in self.network.devices: + self.device_ids[spec.name] = create_mock_device( + self._app, label=self.label_of(spec.name), address=next(self._addresses), ports=spec.ports + ) + + def add_to_topology(self) -> None: + dx, dy = self._offset + placements = [(self.device_ids[s.name], s.x + dx, s.y + dy) for s in self.network.devices] + self._app.inspect.add_devices_to_topology(placements) + + def label_and_tag(self) -> None: + with self._app.inspect.transaction() as tx: + for spec in self.network.devices: + tx.update_device(self.device_ids[spec.name], label=self.label_of(spec.name), tags=[E2E_TAG]) + tx.commit() + + def connect_links(self) -> None: + cursors = discover_port_cursors(self._app, self.device_ids.values()) + with self._app.inspect.transaction() as tx: + for link in self.network.links: + id_a = self.device_ids[self.network.devices[link.a].name] + id_b = self.device_ids[self.network.devices[link.b].name] + a_out, a_in = cursors[id_a].next_port() + b_out, b_in = cursors[id_b].next_port() + # A physical link is two directed edges: out of A -> in of B, and out of B -> in of A. + tx.connect(a_out, b_in, bidirectional=False) + tx.connect(b_out, a_in, bidirectional=False) + tx.commit() + + +class TopologyBuilder: + """Per-test factory for a small mock topology; mirrors the user flow and tracks created ids. + + ``add_devices`` onboards mock devices (inventory → Inspect topology → label + e2e tag); ``link`` + connects the next free router port of two devices bidirectionally (two directed edges). Devices + persist until the next e2e session-start sweep. + + Pass a unique ``(x, y)`` origin per test (via the session ``e2e_map_origins`` fixture) so + builders never stack on the same map coordinates. + """ + + def __init__(self, app: "VideoIPathApp", addresses: Iterator[str], *, x: int, y: int) -> None: + self._app = app + self._addresses = addresses + self.origin = (x, y) + self._x = x + self._y = y + self.device_ids: list[str] = [] + self.labels: dict[str, str] = {} # device id -> unique E2E label + self._cursors: dict[str, PortCursor] = {} + + def add_devices(self, specs: list[tuple[str, int]]) -> list[str]: + """Create mock devices from ``(base_label, ports)`` specs and add them to the topology graph. + + Labels are made unique per test via :func:`unique_label`; returns the new device ids. + """ + created: list[str] = [] + for base_label, ports in specs: + label = unique_label(base_label) + device_id = create_mock_device(self._app, label=label, address=next(self._addresses), ports=ports) + self.labels[device_id] = label + created.append(device_id) + offset = len(self.device_ids) + self._app.inspect.add_devices_to_topology( + [(device_id, self._x + (offset + i) * 300, self._y) for i, device_id in enumerate(created)] + ) + with self._app.inspect.transaction() as tx: + for device_id in created: + tx.update_device(device_id, label=self.labels[device_id], tags=[E2E_TAG]) + tx.commit() + self.device_ids.extend(created) + self._cursors.clear() # ports changed; rediscover before the next link + return created + + def link(self, id_a: str, id_b: str) -> None: + """Connect the next free port pair of two devices bidirectionally (two directed edges).""" + if not self._cursors: + self._cursors = discover_port_cursors(self._app, self.device_ids) + a_out, a_in = self._cursors[id_a].next_port() + b_out, b_in = self._cursors[id_b].next_port() + with self._app.inspect.transaction() as tx: + tx.connect(a_out, b_in, bidirectional=False) + tx.connect(b_out, a_in, bidirectional=False) + tx.commit() + + +# --- Cleanup --------------------------------------------------------------------------------------- + + def remove_devices(app: "VideoIPathApp", device_ids: set[str]) -> None: """Remove the given devices — edges first, then the topology node, then the inventory entry. - A device lives in two places — the inventory and the inspect topology graph — and removing it - from one does not remove it from the other. Removal is best-effort so cleanup errors never mask - a test failure. + A device lives in two places — the inventory and the Inspect topology graph — and removing it + from one does not remove it from the other. Used by the session-start sweep; best-effort so + cleanup errors never abort the suite. """ if not device_ids: return @@ -129,100 +282,76 @@ def remove_devices(app: "VideoIPathApp", device_ids: set[str]) -> None: def sweep_e2e_namespace(app: "VideoIPathApp") -> None: - """Remove every ``E2E-`` device (and e2e catalog tags) so a run starts from a clean namespace. + """Remove every ``E2E-`` artifact so a run starts from a clean namespace (best-effort). - Discovers devices by their ``E2E-`` label in **both** the inventory and the topology graph, - catching orphans from an aborted run as well as the intentionally persisted workflow topology. + Covers devices (in both the inventory and the topology graph, catching orphans from an aborted + run as well as an intentionally persisted build), plus ``E2E-`` profiles, security domains, + multicast pools, and the e2e catalog tags. """ delete_test_tag(app) delete_module_test_tag(app) + inventory_labels = app.inventory._inventory_api.fetch_devices_user_defined_labels_as_dict() inventory_ids = {i for i, label in inventory_labels.items() if (label or "").startswith(E2E_PREFIX)} app.inspect.refresh() topology_ids = {d.id for d in app.inspect.devices if (d.label or "").startswith(E2E_PREFIX)} remove_devices(app, inventory_ids | topology_ids) + _sweep_profiles(app) + _sweep_domains(app) + _sweep_multicast_pools(app) -class TopologyBuilder: - """Builds a minimal per-test topology of mock devices and tracks their ids for teardown. - ``add_devices`` mirrors the real user flow (inventory → topology graph → label + tag); ``link`` - connects the next free port pair of two devices bidirectionally (two directed edges). - """ +def _sweep_profiles(app: "VideoIPathApp") -> None: + try: + profiles = app.profile.get_profiles() or [] + for profile in profiles: + if (profile.name or "").startswith(E2E_PREFIX): + app.profile.remove_profile(profile=profile) + except Exception: # best-effort cleanup + pass - def __init__(self, app: "VideoIPathApp", addresses: Iterator[str], *, x: int = 0, y: int = 4200) -> None: - self._app = app - self._addresses = addresses - self._x = x - self._y = y - self.device_ids: list[str] = [] - self.labels: dict[str, str] = {} # device id -> unique E2E label - self._out: dict[str, list[str]] = {} # device id -> ordered out-vertex ids - self._in: dict[str, list[str]] = {} # device id -> ordered in-vertex ids - self._slot: dict[str, int] = defaultdict(int) # device id -> next free port slot - def add_devices(self, specs: list[tuple[str, int]]) -> list[str]: - """Create mock devices from ``(base_label, ports)`` specs and add them to the topology graph. +def _sweep_domains(app: "VideoIPathApp") -> None: + try: + for domain in app.security.domains.get_all_domains(): + if (domain.name or "").startswith(E2E_PREFIX): + app.security.domains.remove_domain(domain) + except Exception: # best-effort cleanup + pass - Labels are made unique per test via :func:`unique_label`; returns the new device ids. - """ - created: list[str] = [] - for base_label, ports in specs: - label = unique_label(base_label) - device_id = create_mock_device(self._app, label=label, address=next(self._addresses), ports=ports) - self.labels[device_id] = label - created.append(device_id) - offset = len(self.device_ids) - self._app.inspect.add_devices_to_topology( - [(device_id, self._x + (offset + i) * 300, self._y) for i, device_id in enumerate(created)] - ) - with self._app.inspect.transaction() as tx: - for device_id in created: - tx.update_device(device_id, label=self.labels[device_id], tags=[E2E_TAG]) - tx.commit() - self.device_ids.extend(created) - return created - def discover(self) -> None: - """Discover the router port vertices of all tracked devices (needed before ``link``).""" - for device_id, (outs, ins) in discover_router_vertices(self._app, self.device_ids).items(): - self._out[device_id] = outs - self._in[device_id] = ins - - def link(self, id_a: str, id_b: str) -> None: - """Connect the next free port pair of two devices bidirectionally (two directed edges).""" - if id_a not in self._out or id_b not in self._out: - self.discover() - ka, kb = self._slot[id_a], self._slot[id_b] - self._slot[id_a] += 1 - self._slot[id_b] += 1 - with self._app.inspect.transaction() as tx: - tx.connect(self._out[id_a][ka], self._in[id_b][kb], bidirectional=False) - tx.connect(self._out[id_b][kb], self._in[id_a][ka], bidirectional=False) - tx.commit() +def _sweep_multicast_pools(app: "VideoIPathApp") -> None: + try: + pools = app.preferences.system_configuration.allocation_pools.get_multicast_ranges() + e2e_pools = [name for name in pools.available_ranges if name.startswith(E2E_PREFIX)] + if e2e_pools: + app.preferences.system_configuration.allocation_pools.remove_multicast_range(e2e_pools) + except Exception: # best-effort cleanup + pass class FetchSpy: - """Wraps get_device_detail to count hydration fetches.""" + """Wraps ``get_device_detail`` to count per-device hydration fetches.""" - def __init__(self, api): + def __init__(self, api: Any) -> None: self._api = api self._orig = api.get_device_detail self.count = 0 - def __enter__(self): - def counting(device_id): + def __enter__(self) -> "FetchSpy": + def counting(device_id: str) -> Any: self.count += 1 return self._orig(device_id) self._api.get_device_detail = counting return self - def __exit__(self, *exc): + def __exit__(self, *exc: object) -> None: self._api.get_device_detail = self._orig -# --- Test tag catalog (simple API requests) --- +# --- Test tag catalog (simple API requests) -------------------------------------------------------- def create_catalog_tag(app: "VideoIPathApp", *, category: str, name: str) -> str: @@ -273,10 +402,6 @@ def create_test_tag(app: "VideoIPathApp") -> None: create_catalog_tag(app, category=TEST_TAG_CATEGORY, name=TEST_TAG_NAME) -def test_tag_exists(app: "VideoIPathApp") -> bool: - return catalog_tag_exists(app, category=TEST_TAG_CATEGORY, name=TEST_TAG_NAME) - - def delete_test_tag(app: "VideoIPathApp") -> None: """Force-delete the port/vertex test tag (removes it and any port bindings) if it exists.""" delete_catalog_tag(app, TEST_TAG_ID) @@ -287,16 +412,12 @@ def create_module_test_tag(app: "VideoIPathApp") -> None: create_catalog_tag(app, category=MODULE_TEST_TAG_CATEGORY, name=MODULE_TEST_TAG_NAME) -def module_test_tag_exists(app: "VideoIPathApp") -> bool: - return catalog_tag_exists(app, category=MODULE_TEST_TAG_CATEGORY, name=MODULE_TEST_TAG_NAME) - - def delete_module_test_tag(app: "VideoIPathApp") -> None: """Force-delete the module test tag (removes it and any module bindings) if it exists.""" delete_catalog_tag(app, MODULE_TEST_TAG_ID) -# --- Internal --- +# --- Internal -------------------------------------------------------------------------------------- def _vertex_slot(vertex_id: str) -> int: diff --git a/tests/e2e/networks.py b/tests/e2e/networks.py new file mode 100644 index 0000000..f263ad8 --- /dev/null +++ b/tests/e2e/networks.py @@ -0,0 +1,91 @@ +"""Declarative network definitions for the generic e2e network-builder suite. + +A :class:`Network` is a small, readable description of an architecture — named devices with a grid +position, plus the links between them. The builder suite in ``workflows/test_build_networks.py`` +turns any network into a live VideoIPath topology: it creates the devices in the inventory, adds +them to the Inspect topology at ``base position + offset``, labels and tags them, then connects the +links. + +Define a new architecture by adding a ``Network`` here (via :func:`build_network`) and a three-line +``Test*`` subclass in the builder suite — each network then builds as its own ordered test suite at +its own map offset, so several networks can coexist on a shared instance without colliding. + +Built networks stay in VideoIPath after the run. The next e2e session starts with a sweep that +removes every ``E2E-`` artifact before rebuilding. +""" + +from __future__ import annotations + +from collections import defaultdict +from dataclasses import dataclass + + +@dataclass(frozen=True) +class DeviceSpec: + """A device in a network: a unique name, its router-port count, and a relative grid position.""" + + name: str + ports: int + x: int + y: int + + +@dataclass(frozen=True) +class LinkSpec: + """A bidirectional link between two devices, referenced by their index in ``Network.devices``. + + Repeat a pair to model parallel links between the same two devices. + """ + + a: int + b: int + + +@dataclass(frozen=True) +class Network: + """A named architecture: the devices to build and the links to connect between them.""" + + name: str + devices: list[DeviceSpec] + links: list[LinkSpec] + + def index_of(self, name: str) -> int: + for i, device in enumerate(self.devices): + if device.name == name: + return i + raise KeyError(f"Device '{name}' is not part of network '{self.name}'.") + + def neighbours(self) -> dict[str, set[str]]: + """Undirected neighbour-name set per device (derived from the links).""" + adjacency: dict[str, set[str]] = {device.name: set() for device in self.devices} + for link in self.links: + a, b = self.devices[link.a].name, self.devices[link.b].name + adjacency[a].add(b) + adjacency[b].add(a) + return adjacency + + def parallel_count(self, link: LinkSpec) -> int: + """How many links connect the same device pair as ``link`` (1 unless there are parallels).""" + return sum(1 for other in self.links if {other.a, other.b} == {link.a, link.b}) + + +def build_network( + name: str, + *, + devices: list[tuple[str, int, int]], + links: list[tuple[str, str]], +) -> Network: + """Build a :class:`Network` from readable ``(name, x, y)`` devices and ``(name_a, name_b)`` links. + + Each device's router-port count is sized automatically to its link degree (repeat a link pair to + add a parallel link, which also grows the port count), so definitions stay declarative and + self-consistent. + """ + index = {device_name: i for i, (device_name, _, _) in enumerate(devices)} + degree: dict[str, int] = defaultdict(int) + for a, b in links: + degree[a] += 1 + degree[b] += 1 + device_specs = [DeviceSpec(name=n, ports=max(1, degree[n]), x=x, y=y) for n, x, y in devices] + link_specs = [LinkSpec(a=index[a], b=index[b]) for a, b in links] + return Network(name=name, devices=device_specs, links=link_specs) diff --git a/tests/e2e/test_e2e_workflow.py b/tests/e2e/test_e2e_workflow.py deleted file mode 100644 index e463e3e..0000000 --- a/tests/e2e/test_e2e_workflow.py +++ /dev/null @@ -1,243 +0,0 @@ -"""Sequential end-to-end workflow: connect → inventory → inspect topology → edges, step by step. - -Each build step of the real user journey is its own test with verification, running in definition -order and sharing state via a class-scoped fixture. Once a step fails, the remaining steps are -skipped (``@pytest.mark.incremental``, see ``conftest.py``). - -The suite builds a full dedicated leaf-spine topology (30 devices, 40 bidirectional links — -including two parallel links) that replicates the local VideoIPath instance, with fixed -``E2E-WF-`` labels in its own map region. There is **no teardown**: the built topology persists in -VideoIPath after the run for manual inspection; the next run's session sweep removes it. - -Run with:: - - poetry run test-e2e -""" - -from __future__ import annotations - -from collections import defaultdict -from typing import Iterator - -import pytest -from pydantic import BaseModel - -from videoipath_automation_tool.apps.inspect.model.common import InspectFrozenModel -from videoipath_automation_tool.apps.videoipath_app import VideoIPathApp - -from .helpers import E2E_TAG, create_mock_device, discover_router_vertices, edges_between - -pytestmark = pytest.mark.e2e - - -class DeviceSpec(InspectFrozenModel): - name: str - x: int - y: int - ports: int - - -class LinkSpec(InspectFrozenModel): - a: int # device index - b: int # device index - - -# A full leaf-spine in its own map region (all coordinates shifted +3000 in y so the test data -# rather not conflict with other data). Port counts equal each device's link degree; devices with -# no links sit at the region origin. Two device pairs — (2, 15) and (5, 16) — carry parallel links, -# represented here as repeated ``LinkSpec`` entries. -DEVICES = [ - DeviceSpec(name="E2E-WF-DEV-00", x=-2301, y=11141, ports=2), - DeviceSpec(name="E2E-WF-DEV-01", x=-1496, y=11082, ports=1), - DeviceSpec(name="E2E-WF-DEV-02", x=2100, y=11800, ports=11), - DeviceSpec(name="E2E-WF-DEV-03", x=3600, y=11800, ports=4), - DeviceSpec(name="E2E-WF-DEV-04", x=3000, y=11800, ports=4), - DeviceSpec(name="E2E-WF-DEV-05", x=1500, y=11800, ports=9), - DeviceSpec(name="E2E-WF-DEV-06", x=0, y=3000, ports=1), - DeviceSpec(name="E2E-WF-DEV-07", x=1600, y=12300, ports=2), - DeviceSpec(name="E2E-WF-DEV-08", x=2000, y=12300, ports=2), - DeviceSpec(name="E2E-WF-DEV-09", x=1600, y=12050, ports=1), - DeviceSpec(name="E2E-WF-DEV-10", x=-2000, y=10800, ports=3), - DeviceSpec(name="E2E-WF-DEV-11", x=0, y=3000, ports=1), - DeviceSpec(name="E2E-WF-DEV-12", x=1600, y=13300, ports=2), - DeviceSpec(name="E2E-WF-DEV-13", x=0, y=3000, ports=1), - DeviceSpec(name="E2E-WF-DEV-14", x=2000, y=12800, ports=2), - DeviceSpec(name="E2E-WF-DEV-15", x=0, y=11400, ports=7), - DeviceSpec(name="E2E-WF-DEV-16", x=0, y=10600, ports=7), - DeviceSpec(name="E2E-WF-DEV-17", x=-1800, y=11600, ports=3), - DeviceSpec(name="E2E-WF-DEV-18", x=500, y=10850, ports=2), - DeviceSpec(name="E2E-WF-DEV-19", x=-2800, y=11200, ports=2), - DeviceSpec(name="E2E-WF-DEV-20", x=3500, y=12300, ports=2), - DeviceSpec(name="E2E-WF-DEV-21", x=767, y=11434, ports=1), - DeviceSpec(name="E2E-WF-DEV-22", x=1600, y=12800, ports=2), - DeviceSpec(name="E2E-WF-DEV-23", x=2000, y=12550, ports=2), - DeviceSpec(name="E2E-WF-DEV-24", x=1600, y=12550, ports=2), - DeviceSpec(name="E2E-WF-DEV-25", x=900, y=10850, ports=2), - DeviceSpec(name="E2E-WF-DEV-26", x=0, y=3000, ports=1), - DeviceSpec(name="E2E-WF-DEV-27", x=3500, y=12050, ports=2), - DeviceSpec(name="E2E-WF-DEV-28", x=3100, y=12300, ports=2), - DeviceSpec(name="E2E-WF-DEV-29", x=1046, y=11159, ports=2), -] -LINKS = [ - LinkSpec(a=0, b=10), - LinkSpec(a=0, b=17), - LinkSpec(a=2, b=7), - LinkSpec(a=2, b=8), - LinkSpec(a=2, b=9), - LinkSpec(a=2, b=12), - LinkSpec(a=2, b=14), - LinkSpec(a=2, b=15), - LinkSpec(a=2, b=15), - LinkSpec(a=2, b=21), - LinkSpec(a=2, b=22), - LinkSpec(a=2, b=23), - LinkSpec(a=2, b=24), - LinkSpec(a=3, b=15), - LinkSpec(a=3, b=20), - LinkSpec(a=3, b=27), - LinkSpec(a=3, b=28), - LinkSpec(a=4, b=16), - LinkSpec(a=4, b=20), - LinkSpec(a=4, b=27), - LinkSpec(a=4, b=28), - LinkSpec(a=5, b=7), - LinkSpec(a=5, b=8), - LinkSpec(a=5, b=12), - LinkSpec(a=5, b=14), - LinkSpec(a=5, b=16), - LinkSpec(a=5, b=16), - LinkSpec(a=5, b=22), - LinkSpec(a=5, b=23), - LinkSpec(a=5, b=24), - LinkSpec(a=10, b=16), - LinkSpec(a=10, b=19), - LinkSpec(a=15, b=17), - LinkSpec(a=15, b=18), - LinkSpec(a=15, b=25), - LinkSpec(a=15, b=29), - LinkSpec(a=16, b=18), - LinkSpec(a=16, b=25), - LinkSpec(a=16, b=29), - LinkSpec(a=17, b=19), -] - - -class WorkflowState(BaseModel): - """Mutable state shared across the sequential steps.""" - - device_ids: dict[str, str] = {} # spec name -> VideoIPath device id - out_vertices: dict[str, list[str]] = {} # device id -> ordered out-vertex ids - in_vertices: dict[str, list[str]] = {} # device id -> ordered in-vertex ids - edge_id: str | None = None # representative edge for the update step - - -@pytest.fixture(scope="class") -def state() -> WorkflowState: - return WorkflowState() - - -def _expected_adjacency() -> dict[str, set[str]]: - """Undirected neighbour set per device name (from the specs).""" - adjacency: dict[str, set[str]] = {d.name: set() for d in DEVICES} - for link in LINKS: - na, nb = DEVICES[link.a].name, DEVICES[link.b].name - adjacency[na].add(nb) - adjacency[nb].add(na) - return adjacency - - -@pytest.mark.incremental -class TestInventoryToInspectWorkflow: - def test_connect(self, app: VideoIPathApp) -> None: - app.check_connection() # raises ConnectionError on failure - assert app.get_server_version() - - def test_create_inventory_devices( - self, app: VideoIPathApp, state: WorkflowState, e2e_addresses: Iterator[str] - ) -> None: - for spec in DEVICES: - state.device_ids[spec.name] = create_mock_device( - app, label=spec.name, address=next(e2e_addresses), ports=spec.ports - ) - assert len(state.device_ids) == len(DEVICES) - - def test_verify_inventory(self, app: VideoIPathApp, state: WorkflowState) -> None: - for spec in DEVICES: - device_id = state.device_ids[spec.name] - assert app.inventory.find_device_id_by_label(spec.name) == device_id - device = app.inventory.get_device(device_id=device_id, config_only=True) - assert device.configuration.label == spec.name - - def test_add_to_topology(self, app: VideoIPathApp, state: WorkflowState) -> None: - app.inspect.add_devices_to_topology([(state.device_ids[s.name], s.x, s.y) for s in DEVICES]) - app.inspect.refresh() - topology_ids = {d.id for d in app.inspect.devices} - assert set(state.device_ids.values()) <= topology_ids - - def test_configure_labels_and_tags(self, app: VideoIPathApp, state: WorkflowState) -> None: - with app.inspect.transaction() as tx: - for spec in DEVICES: - tx.update_device(state.device_ids[spec.name], label=spec.name, tags=[E2E_TAG]) - tx.commit() - app.inspect.refresh() - for spec in DEVICES: - device = app.inspect.get_device(state.device_ids[spec.name]) - assert device is not None - assert device.label == spec.name - assert E2E_TAG in device.tags - - def test_discover_ports(self, app: VideoIPathApp, state: WorkflowState) -> None: - vertices = discover_router_vertices(app, list(state.device_ids.values())) - for spec in DEVICES: - outs, ins = vertices[state.device_ids[spec.name]] - assert len(outs) == spec.ports - assert len(ins) == spec.ports - state.out_vertices[state.device_ids[spec.name]] = outs - state.in_vertices[state.device_ids[spec.name]] = ins - - def test_connect_edges(self, app: VideoIPathApp, state: WorkflowState) -> None: - slot: dict[str, int] = defaultdict(int) - with app.inspect.transaction() as tx: - for link in LINKS: - id_a, id_b = state.device_ids[DEVICES[link.a].name], state.device_ids[DEVICES[link.b].name] - ka, kb = slot[id_a], slot[id_b] - slot[id_a] += 1 - slot[id_b] += 1 - # Bidirectional link between port slot ka on A and kb on B (two directed edges). - tx.connect(state.out_vertices[id_a][ka], state.in_vertices[id_b][kb], bidirectional=False) - tx.connect(state.out_vertices[id_b][kb], state.in_vertices[id_a][ka], bidirectional=False) - tx.commit() - app.inspect.refresh() - for link in LINKS: - id_a, id_b = state.device_ids[DEVICES[link.a].name], state.device_ids[DEVICES[link.b].name] - pair_edges = edges_between(app, id_a, id_b) - # Two directed edges per bidirectional link; a pair with parallel links has more. - parallel = sum(1 for other in LINKS if {other.a, other.b} == {link.a, link.b}) - assert len(pair_edges) == parallel * 2 - state.edge_id = pair_edges[0].id - - def test_update_edge_weight(self, app: VideoIPathApp, state: WorkflowState) -> None: - assert state.edge_id is not None - result = app.inspect.update_edge(state.edge_id, weight=7) - assert result.ok - # Survives the targeted post-commit refresh without a full reload. - assert any(e.id == state.edge_id for e in app.inspect.edges) - - def test_verify_connectivity_and_persistence(self, app: VideoIPathApp, state: WorkflowState) -> None: - # A fresh read of the final state: labels, adjacency, and edge count all match the specs. - # There is no teardown — this persisted topology remains in VideoIPath after the run. - app.inspect.refresh() - labels = {d.label for d in app.inspect.devices} - assert all(spec.name in labels for spec in DEVICES) - adjacency = _expected_adjacency() - for spec in DEVICES: - device = app.inspect.get_device(state.device_ids[spec.name]) - assert device is not None - assert {d.label for d in device.linked_devices} == adjacency[spec.name] - known = set(state.device_ids.values()) - workflow_edges = [ - e - for e in app.inspect.edges - if (e.from_device and e.from_device.id in known) or (e.to_device and e.to_device.id in known) - ] - assert len(workflow_edges) == len(LINKS) * 2 diff --git a/tests/e2e/workflows/__init__.py b/tests/e2e/workflows/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/e2e/workflows/test_build_networks.py b/tests/e2e/workflows/test_build_networks.py new file mode 100644 index 0000000..199f575 --- /dev/null +++ b/tests/e2e/workflows/test_build_networks.py @@ -0,0 +1,233 @@ +"""Generic ordered network-builder suite: one Test* subclass per architecture. + +Each subclass pairs a :class:`~tests.e2e.networks.Network` with a map ``offset``. The base +:class:`NetworkBuildSuite` turns that into a live VideoIPath topology step by step: + + connect → create inventory devices → add to topology → label & tag → connect links → verify + +Define a new architecture by adding a ``Network`` in ``tests/e2e/networks.py`` and a three-line +``Test*`` subclass here. Each network builds as its own independent suite at its own map region. + +Built networks are left in VideoIPath for manual inspection. The next e2e run's session-start sweep +removes every ``E2E-`` artifact before rebuilding. + +Run with:: + + poetry run test-e2e +""" + +from __future__ import annotations + +from typing import Iterator + +import pytest + +from videoipath_automation_tool.apps.videoipath_app import VideoIPathApp + +from ..helpers import E2E_TAG, NetworkBuild, edges_between +from ..networks import Network, build_network + +pytestmark = pytest.mark.e2e + + +# --- Concrete networks ----------------------------------------------------------------------------- +# Each is intentionally small and easy to picture; every ``Test*`` subclass in the builder suite +# pairs one of these with a distinct map offset. + +LINE = build_network( + "line", + devices=[("line-a", 0, 0), ("line-b", 300, 0), ("line-c", 600, 0)], + links=[("line-a", "line-b"), ("line-b", "line-c")], +) + +RING = build_network( + "ring", + devices=[("ring-1", 0, 0), ("ring-2", 300, 0), ("ring-3", 300, 300), ("ring-4", 0, 300)], + links=[("ring-1", "ring-2"), ("ring-2", "ring-3"), ("ring-3", "ring-4"), ("ring-4", "ring-1")], +) + +STAR = build_network( + "star", + devices=[("hub", 300, 300), ("spoke-1", 0, 0), ("spoke-2", 600, 0), ("spoke-3", 0, 600), ("spoke-4", 600, 600)], + links=[("hub", "spoke-1"), ("hub", "spoke-2"), ("hub", "spoke-3"), ("hub", "spoke-4")], +) + +# 2-tier spine-leaf (Clos): every leaf meshed to every spine; endpoints attach to leaf pairs +# (dual-homed on the first two pairs, single-homed on the third). +SPINE_LEAF_2_TIER = build_network( + "spine-leaf-2tier", + devices=[ + ("spine-1", 500, 0), + ("spine-2", 1100, 0), + ("leaf-1", 0, 400), + ("leaf-2", 300, 400), + ("leaf-3", 600, 400), + ("leaf-4", 900, 400), + ("leaf-5", 1200, 400), + ("leaf-6", 1500, 400), + ("endpoint-1", 150, 800), + ("endpoint-2", 750, 800), + ("endpoint-3", 1200, 800), + ("endpoint-4", 1500, 800), + ], + links=[ + # Full mesh: leaf ↔ spine + ("leaf-1", "spine-1"), + ("leaf-1", "spine-2"), + ("leaf-2", "spine-1"), + ("leaf-2", "spine-2"), + ("leaf-3", "spine-1"), + ("leaf-3", "spine-2"), + ("leaf-4", "spine-1"), + ("leaf-4", "spine-2"), + ("leaf-5", "spine-1"), + ("leaf-5", "spine-2"), + ("leaf-6", "spine-1"), + ("leaf-6", "spine-2"), + # Endpoints + ("endpoint-1", "leaf-1"), + ("endpoint-1", "leaf-2"), + ("endpoint-2", "leaf-3"), + ("endpoint-2", "leaf-4"), + ("endpoint-3", "leaf-5"), + ("endpoint-4", "leaf-6"), + ], +) + +# Traditional 3-tier: core ↔ aggregation (full mesh) + overlapping aggregation↔access pairs + dual-homed endpoints. +SPINE_LEAF_3_TIER = build_network( + "spine-leaf-3tier", + devices=[ + ("core-1", 450, 0), + ("core-2", 1050, 0), + ("agg-1", 0, 400), + ("agg-2", 500, 400), + ("agg-3", 1000, 400), + ("agg-4", 1500, 400), + ("access-1", 0, 800), + ("access-2", 300, 800), + ("access-3", 600, 800), + ("access-4", 900, 800), + ("access-5", 1200, 800), + ("access-6", 1500, 800), + ("endpoint-1", 150, 1200), + ("endpoint-2", 750, 1200), + ("endpoint-3", 1350, 1200), + ], + links=[ + ("core-1", "core-2"), + # Full mesh: core ↔ aggregation + ("core-1", "agg-1"), + ("core-1", "agg-2"), + ("core-1", "agg-3"), + ("core-1", "agg-4"), + ("core-2", "agg-1"), + ("core-2", "agg-2"), + ("core-2", "agg-3"), + ("core-2", "agg-4"), + # Overlapping access pairs → aggregation pairs + ("access-1", "agg-1"), + ("access-1", "agg-2"), + ("access-2", "agg-1"), + ("access-2", "agg-2"), + ("access-3", "agg-2"), + ("access-3", "agg-3"), + ("access-4", "agg-2"), + ("access-4", "agg-3"), + ("access-5", "agg-3"), + ("access-5", "agg-4"), + ("access-6", "agg-3"), + ("access-6", "agg-4"), + # Dual-homed endpoints + ("endpoint-1", "access-1"), + ("endpoint-1", "access-2"), + ("endpoint-2", "access-3"), + ("endpoint-2", "access-4"), + ("endpoint-3", "access-5"), + ("endpoint-3", "access-6"), + ], +) + + +@pytest.fixture(scope="class") +def build(request: pytest.FixtureRequest, app: VideoIPathApp, e2e_addresses: Iterator[str]) -> Iterator[NetworkBuild]: + """Drive a :class:`NetworkBuild` for the requesting suite (no teardown — networks persist).""" + network: Network = request.cls.network + offset: tuple[int, int] = request.cls.offset + yield NetworkBuild(app, network, offset, e2e_addresses) + + +@pytest.mark.incremental +class NetworkBuildSuite: + """Base suite — not collected (no ``Test`` prefix). Subclasses set ``network`` and ``offset``.""" + + network: Network + offset: tuple[int, int] + + def test_connect(self, app: VideoIPathApp) -> None: + app.check_connection() + assert app.get_server_version() + + def test_create_inventory_devices(self, build: NetworkBuild) -> None: + build.create_devices() + assert len(build.device_ids) == len(build.network.devices) + + def test_add_to_topology(self, app: VideoIPathApp, build: NetworkBuild) -> None: + build.add_to_topology() + topology_ids = {device.id for device in app.inspect.devices} + assert set(build.device_ids.values()) <= topology_ids + + def test_label_and_tag(self, app: VideoIPathApp, build: NetworkBuild) -> None: + build.label_and_tag() + for spec in build.network.devices: + device = app.inspect.get_device(build.device_ids[spec.name]) + assert device is not None + assert device.label == build.label_of(spec.name) + assert E2E_TAG in device.tags + + def test_connect_links(self, app: VideoIPathApp, build: NetworkBuild) -> None: + build.connect_links() + for link in build.network.links: + id_a = build.device_ids[build.network.devices[link.a].name] + id_b = build.device_ids[build.network.devices[link.b].name] + pair_edges = edges_between(app, id_a, id_b) + assert len(pair_edges) == build.network.parallel_count(link) * 2 + + def test_verify_connectivity(self, app: VideoIPathApp, build: NetworkBuild) -> None: + app.inspect.refresh() + adjacency = build.network.neighbours() + for spec in build.network.devices: + device_id = build.device_ids[spec.name] + label = build.label_of(spec.name) + assert ( + app.inventory.find_device_id_by_label(label, label_search_mode="user_defined_label_only") == device_id + ) + device = app.inspect.get_device(device_id) + assert device is not None + expected = {build.label_of(neighbour) for neighbour in adjacency[spec.name]} + assert {linked.label for linked in device.linked_devices} == expected + + +class TestLineNetwork(NetworkBuildSuite): + network = LINE + offset = (0, 6000) + + +class TestRingNetwork(NetworkBuildSuite): + network = RING + offset = (2000, 6000) + + +class TestStarNetwork(NetworkBuildSuite): + network = STAR + offset = (0, 7000) + + +class TestSpineLeaf2TierNetwork(NetworkBuildSuite): + network = SPINE_LEAF_2_TIER + offset = (0, 8000) + + +class TestSpineLeaf3TierNetwork(NetworkBuildSuite): + network = SPINE_LEAF_3_TIER + offset = (2500, 8000) diff --git a/tests/e2e/workflows/test_onboarding_pipeline.py b/tests/e2e/workflows/test_onboarding_pipeline.py new file mode 100644 index 0000000..7f61419 --- /dev/null +++ b/tests/e2e/workflows/test_onboarding_pipeline.py @@ -0,0 +1,122 @@ +"""Cross-app onboarding pipeline: inventory → Inspect topology → edges → security domains. + +Mirrors ``docs/examples/06_workflows/01_full_onboarding_pipeline.py`` on a small 2-leaf / 1-spine +network, as an ordered ``@pytest.mark.incremental`` suite. Reachability polling is omitted because +mock devices are not reachable. The built topology is left in VideoIPath; the next e2e session's +sweep removes ``E2E-`` artifacts. + +Run with:: + + poetry run test-e2e +""" + +from __future__ import annotations + +from typing import Iterator + +import pytest +from pydantic import BaseModel + +from videoipath_automation_tool.apps.videoipath_app import VideoIPathApp + +from ..helpers import E2E_TAG, create_mock_device, discover_port_cursors, unique_name +from ..networks import build_network + +pytestmark = pytest.mark.e2e + +PIPELINE = build_network( + "onboard", + devices=[("leaf-1", 0, 0), ("leaf-2", 600, 0), ("spine-1", 300, 400)], + links=[("leaf-1", "spine-1"), ("leaf-2", "spine-1")], +) +# Below the network-builder map regions (spine-leaf 3-tier reaches y≈9200 at offset (2500, 8000)). +OFFSET = (0, 10000) + +SITE_TAG = "site-a" + + +class PipelineState(BaseModel): + device_ids: dict[str, str] = {} + domain_name: str | None = None + + +@pytest.fixture(scope="class") +def state() -> PipelineState: + return PipelineState() + + +@pytest.mark.incremental +class TestOnboardingPipeline: + def test_connect(self, app: VideoIPathApp) -> None: + app.check_connection() + assert app.get_server_version() + + def test_create_inventory_devices( + self, app: VideoIPathApp, state: PipelineState, e2e_addresses: Iterator[str] + ) -> None: + for spec in PIPELINE.devices: + label = f"E2E-{PIPELINE.name}-{spec.name}" + state.device_ids[spec.name] = create_mock_device( + app, label=label, address=next(e2e_addresses), ports=spec.ports + ) + assert len(state.device_ids) == len(PIPELINE.devices) + + def test_add_to_topology(self, app: VideoIPathApp, state: PipelineState) -> None: + dx, dy = OFFSET + app.inspect.add_devices_to_topology( + [(state.device_ids[spec.name], spec.x + dx, spec.y + dy) for spec in PIPELINE.devices] + ) + topology_ids = {device.id for device in app.inspect.devices} + assert set(state.device_ids.values()) <= topology_ids + + def test_configure_devices(self, app: VideoIPathApp, state: PipelineState) -> None: + with app.inspect.transaction() as tx: + for spec in PIPELINE.devices: + device = app.inspect.get_device(state.device_ids[spec.name]) + assert device is not None + device.label = f"E2E-{PIPELINE.name}-{spec.name}" + device.description = f"Onboarding pipeline {spec.name}" + device.tags = [E2E_TAG, SITE_TAG] + app.inspect.update(device, tx=tx) + tx.commit() + for spec in PIPELINE.devices: + device = app.inspect.get_device(state.device_ids[spec.name]) + assert device is not None + assert device.label == f"E2E-{PIPELINE.name}-{spec.name}" + assert SITE_TAG in device.tags + + def test_connect_links(self, app: VideoIPathApp, state: PipelineState) -> None: + cursors = discover_port_cursors(app, state.device_ids.values()) + with app.inspect.transaction() as tx: + for link in PIPELINE.links: + id_a = state.device_ids[PIPELINE.devices[link.a].name] + id_b = state.device_ids[PIPELINE.devices[link.b].name] + a_out, a_in = cursors[id_a].next_port() + b_out, b_in = cursors[id_b].next_port() + tx.connect(a_out, b_in, bidirectional=False) + tx.connect(b_out, a_in, bidirectional=False) + tx.commit() + + def test_assign_security_domains(self, app: VideoIPathApp, state: PipelineState) -> None: + state.domain_name = unique_name("domain") + app.security.domains.create_domain(name=state.domain_name, description="E2E onboarding pipeline domain") + for device_id in state.device_ids.values(): + memberships = app.security.resources.get_device_memberships(device_id=device_id) + memberships.domains = app.security.resources.convert_domain_names_to_ids([state.domain_name]) + app.security.resources.update_memberships(memberships=memberships) + + def test_verify_pipeline(self, app: VideoIPathApp, state: PipelineState) -> None: + app.inspect.refresh() + adjacency = PIPELINE.neighbours() + assert state.domain_name is not None + for spec in PIPELINE.devices: + device_id = state.device_ids[spec.name] + label = f"E2E-{PIPELINE.name}-{spec.name}" + device = app.inspect.get_device(device_id) + assert device is not None + assert device.label == label + expected = {f"E2E-{PIPELINE.name}-{neighbour}" for neighbour in adjacency[spec.name]} + assert {linked.label for linked in device.linked_devices} == expected + memberships = app.security.resources.get_device_memberships(device_id=device_id) + names = set(app.security.resources.convert_domain_ids_to_names(memberships.domains)) + assert state.domain_name in names diff --git a/tests/inspect/test_exports.py b/tests/inspect/test_exports.py index 42cf9b6..8c791c1 100644 --- a/tests/inspect/test_exports.py +++ b/tests/inspect/test_exports.py @@ -6,7 +6,15 @@ from videoipath_automation_tool import apps from videoipath_automation_tool.apps import inspect from videoipath_automation_tool.apps.inspect import model -from videoipath_automation_tool.apps.inspect.model import actions, collector, common, ngraph, update_topology, virtual +from videoipath_automation_tool.apps.inspect.model import ( + actions, + collector, + common, + ngraph, + tags, + update_topology, + virtual, +) def test_model_package_all_aggregates_submodules() -> None: @@ -15,6 +23,7 @@ def test_model_package_all_aggregates_submodules() -> None: *collector.__all__, *common.__all__, *ngraph.__all__, + *tags.__all__, *update_topology.__all__, *virtual.__all__, } From f292caedddc58939b8a956505900efc5c325b0eb Mon Sep 17 00:00:00 2001 From: Jonas Scholl <43819845+JonasScholl@users.noreply.github.com> Date: Thu, 23 Jul 2026 03:47:00 +0200 Subject: [PATCH 23/34] resolve findings --- src/videoipath_automation_tool/apps/inspect/app/write.py | 6 ++++-- src/videoipath_automation_tool/apps/inspect/model/common.py | 2 ++ tests/e2e/apps/test_profile.py | 1 + 3 files changed, 7 insertions(+), 2 deletions(-) diff --git a/src/videoipath_automation_tool/apps/inspect/app/write.py b/src/videoipath_automation_tool/apps/inspect/app/write.py index c724b2f..471dbfe 100644 --- a/src/videoipath_automation_tool/apps/inspect/app/write.py +++ b/src/videoipath_automation_tool/apps/inspect/app/write.py @@ -16,7 +16,7 @@ from __future__ import annotations import logging -from typing import TYPE_CHECKING, Any, Optional, Protocol, Sequence, Union +from typing import TYPE_CHECKING, Any, Optional, Protocol, Sequence from videoipath_automation_tool.apps.inspect.transaction import CommitResult, InspectTransaction from videoipath_automation_tool.apps.inspect.api import InspectAPI @@ -35,7 +35,9 @@ from videoipath_automation_tool.apps.inspect.domain.vertex import InspectVertex from videoipath_automation_tool.apps.inspect.snapshot import InspectSnapshot -Editable = Union["InspectDevice", "InspectVertex", "InspectEdge", "InspectModule"] + Editable = InspectDevice | InspectVertex | InspectEdge | InspectModule +else: + Editable = Any class _HasInspectState(Protocol): diff --git a/src/videoipath_automation_tool/apps/inspect/model/common.py b/src/videoipath_automation_tool/apps/inspect/model/common.py index b76b8c1..99d56b3 100644 --- a/src/videoipath_automation_tool/apps/inspect/model/common.py +++ b/src/videoipath_automation_tool/apps/inspect/model/common.py @@ -196,6 +196,8 @@ class InspectApiActionValidationErrorResponse(InspectApiBaseModel): "InspectApiSimpleActionResult", "InspectApiStatusContext", "InspectApiStatusSummary", + "CONFLICT_PRIORITY_BY_INT", + "CONFLICT_PRIORITY_TO_INT", "InspectConfigPriority", "InspectIconSize", "InspectIconType", diff --git a/tests/e2e/apps/test_profile.py b/tests/e2e/apps/test_profile.py index 9efc32d..36b9b87 100644 --- a/tests/e2e/apps/test_profile.py +++ b/tests/e2e/apps/test_profile.py @@ -26,6 +26,7 @@ def test_profile_create_and_clone(app: VideoIPathApp) -> None: profile_name = unique_name("profile") created = app.profile.create_profile(name=profile_name) created = app.profile.add_profile(created) + assert created.name == profile_name names = app.profile.list_profile_names() or [] assert profile_name in names From f60b076e0a1b7fafbf68eee942e296e1c6ddd708 Mon Sep 17 00:00:00 2001 From: Jonas Scholl <43819845+JonasScholl@users.noreply.github.com> Date: Thu, 23 Jul 2026 03:55:40 +0200 Subject: [PATCH 24/34] refactor agent rules setup --- .agents/rules/agent-rules-layout.md | 70 ++++++++++++++++++++++++ .agents/rules/python-quality.md | 46 ++++++++++++++++ .agents/rules/python-style.md | 55 +++++++++++++++++++ .agents/rules/python-testing.md | 81 +++++++++++++++++++++++++++ .claude/rules/agent-rules-layout.md | 68 +---------------------- .claude/rules/python-quality.md | 47 +--------------- .claude/rules/python-style.md | 56 +------------------ .claude/rules/python-testing.md | 82 +--------------------------- .cursor/rules/agent-rules-layout.mdc | 2 +- .cursor/rules/python-quality.mdc | 2 +- .cursor/rules/python-style.mdc | 2 +- .cursor/rules/python-testing.mdc | 2 +- AGENTS.md | 6 +- 13 files changed, 263 insertions(+), 256 deletions(-) create mode 100644 .agents/rules/agent-rules-layout.md create mode 100644 .agents/rules/python-quality.md create mode 100644 .agents/rules/python-style.md create mode 100644 .agents/rules/python-testing.md mode change 100644 => 120000 .claude/rules/agent-rules-layout.md mode change 100644 => 120000 .claude/rules/python-quality.md mode change 100644 => 120000 .claude/rules/python-style.md mode change 100644 => 120000 .claude/rules/python-testing.md diff --git a/.agents/rules/agent-rules-layout.md b/.agents/rules/agent-rules-layout.md new file mode 100644 index 0000000..b43956a --- /dev/null +++ b/.agents/rules/agent-rules-layout.md @@ -0,0 +1,70 @@ +--- +description: Agent rules directory layout and symlink conventions +alwaysApply: false +globs: .agents/rules/**,.claude/rules/**,.cursor/rules/** +paths: + - ".agents/rules/**/*.md" + - ".claude/rules/**/*.md" + - ".cursor/rules/**/*.mdc" +--- + +# Agent rules layout + +This repo stores path-scoped agent rules in a tool-agnostic location and bridges them into Claude Code and Cursor via symlinks. + +## Canonical source + +- **Edit rules here:** `.agents/rules/*.md` +- **Do not** put rule content directly in `.claude/rules/` or `.cursor/rules/` — those files are symlinks. + +## Tool bridges + +Claude Code reads `.claude/rules/*.md`. Cursor requires `.cursor/rules/*.mdc`. Each bridge entry is a symlink to the matching canonical `.md`: + +```text +.agents/rules/my-rule.md ← canonical (commit this) +.claude/rules/my-rule.md ← symlink → ../../.agents/rules/my-rule.md +.cursor/rules/my-rule.mdc ← symlink → ../../.agents/rules/my-rule.md +``` + +## Dual frontmatter + +Every rule file needs frontmatter for both tools: + +```yaml +--- +description: Short summary for Cursor rule picker +alwaysApply: false +globs: path/to/match/** +paths: + - "path/to/match/**" +--- +``` + +- **Cursor** uses `globs`, `alwaysApply`, and `description`. +- **Claude Code** uses `paths`; omit `paths` for always-on rules. +- Each tool ignores the other's keys. + +## Adding a rule + +1. Create `.agents/rules/.md` with dual frontmatter and content. +2. Add the Claude and Cursor symlinks: + +```bash +ln -s ../../.agents/rules/.md .claude/rules/.md +ln -s ../../.agents/rules/.md .cursor/rules/.mdc +``` + +3. List the new rule in the **Agent rules** section of `AGENTS.md`. + +## Removing a rule + +1. Delete `.agents/rules/.md`. +2. Delete `.claude/rules/.md` and `.cursor/rules/.mdc` (the symlinks, not separate copies). +3. Remove its entry from `AGENTS.md`. + +## Do not + +- Symlink an entire bridge directory to another (extension mismatch: `.mdc` vs `.md`). +- Duplicate rule content across directories. +- Commit real files under `.claude/rules/` or `.cursor/rules/` — only symlinks belong there. diff --git a/.agents/rules/python-quality.md b/.agents/rules/python-quality.md new file mode 100644 index 0000000..de5ec19 --- /dev/null +++ b/.agents/rules/python-quality.md @@ -0,0 +1,46 @@ +--- +description: Python code quality conventions for src/ and tests/ +alwaysApply: false +globs: src/**/*.py,tests/**/*.py +paths: + - "src/**/*.py" + - "tests/**/*.py" +--- + +# Python code quality + +## Structured data + +- Use **Pydantic `BaseModel`** for all structured data — API responses, settings, internal records, result types, and domain wrappers. Do not use `@dataclass`. +- Prefer typed models over plain `dict` for structured data. +- Reuse project base models where they exist (e.g. `InspectApiBaseModel` for API shapes; inspect-internal bases for in-memory models). + +## Exceptions + +- Raise **specific, descriptive exceptions** rather than bare `Exception`. +- Follow domain error hierarchies: a base exception class plus typed subclasses with context attributes (see `src/videoipath_automation_tool/apps/inspect/errors.py`). + +## Context managers + +- Use `with` for files, locks, test spies, and connector wrappers. + +## Quality gates + +Before finishing Python changes, run: + +```bash +poetry run ruff check --fix src/ tests/ +poetry run ruff format src/ tests/ +``` + +Pre-commit hooks mirror these commands (see `.pre-commit-config.yaml`). + +## Generated and sensitive code + +- Do **not** hand-edit `src/videoipath_automation_tool/apps/inventory/model/drivers.py` — regenerate with `set-videoipath-version `. +- All committed data must follow the anonymization rules in `AGENTS.md`. + +## Change scope + +- Keep diffs minimal and focused. +- Match surrounding patterns: mixins, module docstrings, `TYPE_CHECKING` imports, and existing naming in the file you edit. diff --git a/.agents/rules/python-style.md b/.agents/rules/python-style.md new file mode 100644 index 0000000..92ac676 --- /dev/null +++ b/.agents/rules/python-style.md @@ -0,0 +1,55 @@ +--- +description: Python coding style for src/ and tests/ +alwaysApply: false +globs: src/**/*.py,tests/**/*.py +paths: + - "src/**/*.py" + - "tests/**/*.py" +--- + +# Python coding style + +## Type hints + +- Annotate all function parameters and return types. +- Use `from __future__ import annotations` in new modules. +- Use `TYPE_CHECKING` blocks for imports needed only for type hints. + +## Formatting and naming + +- Follow PEP 8 with **snake_case** for functions, variables, and modules. +- Max line length is **120** characters (ruff formatter default in this project — not Black's 88). +- Format with **ruff**, not Black: + +```bash +poetry run ruff format src/ tests/ +``` + +## Strings and paths + +- Use **f-strings** for string formatting; avoid `%` formatting and `.format()`. +- Use **`pathlib.Path`** over `os.path` for filesystem operations. + +## Comprehensions and readability + +- Prefer list/dict/set comprehensions over explicit loops when the result stays readable. +- Do not sacrifice clarity for brevity. + +## Layout and whitespace + +- Group **logically related lines** together (e.g. setup, core logic, cleanup). +- Separate groups with a **single blank line**; use an extra blank line between larger sections when it aids scanning. +- Do not sprinkle blank lines randomly, and do not leave long unbroken blocks when a visual break would help. +- Within a function, keep the main path easy to follow: inputs and validation first, then the core work, then return/cleanup. + +## Public before private + +- Place **public** API first so readers see the most relevant surface when scrolling: public classes, methods, functions, and module-level constants. +- Place **private** members after public ones: names prefixed with `_` (attributes, methods, functions, nested helpers) and internal implementation details. +- In classes: public methods first, then `_`-prefixed helpers and internal state accessors. +- In modules: public exports and user-facing functions first; private helpers and module-internal constants at the bottom. +- A short section comment (e.g. `# --- Internal ---`) is fine when a class or module has a large private block. + +## Resources + +- Use **context managers** (`with`) for files, locks, and other resources that need cleanup. diff --git a/.agents/rules/python-testing.md b/.agents/rules/python-testing.md new file mode 100644 index 0000000..cf7f943 --- /dev/null +++ b/.agents/rules/python-testing.md @@ -0,0 +1,81 @@ +--- +description: Python testing conventions for src/ and tests/ +alwaysApply: false +globs: src/**/*.py,tests/**/*.py +paths: + - "src/**/*.py" + - "tests/**/*.py" +--- + +# Python testing + +## Framework and commands + +- Use **pytest** for all tests. +- Prefer the dedicated entry points over bare `pytest`: + +```bash +poetry run test-unit # offline/unit suite (CI default) +poetry run test-e2e # live-server e2e (loads .env) +poetry run test # unit then e2e sequentially + +# Single file or test (extra args pass through to test-unit / test-e2e) +poetry run test-unit tests/validators/test_device_id.py +poetry run test-e2e tests/e2e/inspect/test_e2e_inspect.py::test_name +``` + +- `poetry run pytest` also runs unit tests only (e2e excluded via `addopts` in `pyproject.toml`). + +### VS Code + +Use the launch configs in `.vscode/launch.json`: + +- **Unit Tests** / **Unit Tests (current file)** — offline suite +- **E2E Tests** / **E2E Tests (current file)** — live-server suite + +Or run **Tests** from `.vscode/tasks.json` (`poetry run test`). + +## Unit vs e2e separation + +| Layer | Unit | E2E | +|-------|------|-----| +| Location | `tests/` except `tests/e2e/` | `tests/e2e/` only | +| Marker | unmarked | `@pytest.mark.e2e` | +| Env | `tests/conftest.py` (dummy values) | `.env` (copy from `.env.template`) | +| Run command | `test-unit` / `pytest` | `test-e2e` | +| CI | yes | no | + +Default `addopts` run coverage on `src/` and exclude e2e (`-m "not e2e"`). + +## Assertions + +- Use `pytest.raises(SpecificError)` with the exact exception type. +- Do not catch or assert against bare `Exception` when a domain error exists. + +## Unit and offline tests + +- Mock external I/O with fake connectors and lightweight stand-ins (see `tests/inspect/test_actions.py`). +- Dummy `VIPAT_*` env vars are set in `tests/conftest.py` (autouse fixture; skipped for e2e). +- Load JSON fixtures from `tests//fixtures//` using `pathlib.Path`. +- Put shared fixtures in `conftest.py` at the appropriate directory level. +- Use session-scoped fixtures only when setup is expensive and reuse is intentional. + +## E2E tests + +- Live-server tests live under `tests/e2e/` only. +- Mark with `@pytest.mark.e2e`; they are excluded from the default suite. +- E2e entry points (`poetry run test-e2e`, `poetry run test`, VS Code **E2E Tests**) load `.env` and enable the suite automatically. E2e runs use `--no-cov`. +- Copy `.env.template` to `.env` (gitignored), set connection vars. The e2e conftest loads `.env` automatically when present. +- Run with `poetry run test-e2e` (no extra env vars on the command line). +- Namespace all writes with the `E2E-` label prefix and `vipat-e2e` tag. +- Do not add e2e tests to the default CI/offline run. + +## Test data + +- All fixture and test data must follow anonymization rules in `AGENTS.md`. +- Preserve structure and relationships; replace real hostnames, IPs, and customer identifiers with generic placeholders. + +## Coverage + +- Default runs report coverage on `src/`. +- Do not disable coverage flags without a clear reason. diff --git a/.claude/rules/agent-rules-layout.md b/.claude/rules/agent-rules-layout.md deleted file mode 100644 index b246ca6..0000000 --- a/.claude/rules/agent-rules-layout.md +++ /dev/null @@ -1,67 +0,0 @@ ---- -description: Agent rules directory layout and symlink conventions -alwaysApply: false -globs: .claude/rules/**,.cursor/rules/** -paths: - - ".claude/rules/**/*.md" - - ".cursor/rules/**/*.mdc" ---- - -# Agent rules layout - -This repo shares agent rules between **Claude Code** and **Cursor** via symlinks. - -## Canonical source - -- **Edit rules here:** `.claude/rules/*.md` -- **Do not** put rule content directly in `.cursor/rules/` — those files are symlinks. - -## Cursor bridge - -Cursor requires `.mdc` files in `.cursor/rules/`. Each `.mdc` is a symlink to the matching `.md`: - -```text -.claude/rules/my-rule.md ← canonical (commit this) -.cursor/rules/my-rule.mdc ← symlink → ../.claude/rules/my-rule.md -``` - -## Dual frontmatter - -Every rule file needs frontmatter for both tools: - -```yaml ---- -description: Short summary for Cursor rule picker -alwaysApply: false -globs: path/to/match/** -paths: - - "path/to/match/**" ---- -``` - -- **Cursor** uses `globs`, `alwaysApply`, and `description`. -- **Claude Code** uses `paths`; omit `paths` for always-on rules. -- Each tool ignores the other's keys. - -## Adding a rule - -1. Create `.claude/rules/.md` with dual frontmatter and content. -2. Add the Cursor symlink: - -```bash -ln -s ../.claude/rules/.md .cursor/rules/.mdc -``` - -3. List the new rule in the **Agent rules** section of `AGENTS.md`. - -## Removing a rule - -1. Delete `.claude/rules/.md`. -2. Delete `.cursor/rules/.mdc` (the symlink, not a separate file). -3. Remove its entry from `AGENTS.md`. - -## Do not - -- Symlink the entire `.cursor/rules` directory to `.claude/rules` (extension mismatch: `.mdc` vs `.md`). -- Duplicate rule content in both directories. -- Commit real files under `.cursor/rules/` — only symlinks belong there. diff --git a/.claude/rules/agent-rules-layout.md b/.claude/rules/agent-rules-layout.md new file mode 120000 index 0000000..4f04229 --- /dev/null +++ b/.claude/rules/agent-rules-layout.md @@ -0,0 +1 @@ +../../.agents/rules/agent-rules-layout.md \ No newline at end of file diff --git a/.claude/rules/python-quality.md b/.claude/rules/python-quality.md deleted file mode 100644 index de5ec19..0000000 --- a/.claude/rules/python-quality.md +++ /dev/null @@ -1,46 +0,0 @@ ---- -description: Python code quality conventions for src/ and tests/ -alwaysApply: false -globs: src/**/*.py,tests/**/*.py -paths: - - "src/**/*.py" - - "tests/**/*.py" ---- - -# Python code quality - -## Structured data - -- Use **Pydantic `BaseModel`** for all structured data — API responses, settings, internal records, result types, and domain wrappers. Do not use `@dataclass`. -- Prefer typed models over plain `dict` for structured data. -- Reuse project base models where they exist (e.g. `InspectApiBaseModel` for API shapes; inspect-internal bases for in-memory models). - -## Exceptions - -- Raise **specific, descriptive exceptions** rather than bare `Exception`. -- Follow domain error hierarchies: a base exception class plus typed subclasses with context attributes (see `src/videoipath_automation_tool/apps/inspect/errors.py`). - -## Context managers - -- Use `with` for files, locks, test spies, and connector wrappers. - -## Quality gates - -Before finishing Python changes, run: - -```bash -poetry run ruff check --fix src/ tests/ -poetry run ruff format src/ tests/ -``` - -Pre-commit hooks mirror these commands (see `.pre-commit-config.yaml`). - -## Generated and sensitive code - -- Do **not** hand-edit `src/videoipath_automation_tool/apps/inventory/model/drivers.py` — regenerate with `set-videoipath-version `. -- All committed data must follow the anonymization rules in `AGENTS.md`. - -## Change scope - -- Keep diffs minimal and focused. -- Match surrounding patterns: mixins, module docstrings, `TYPE_CHECKING` imports, and existing naming in the file you edit. diff --git a/.claude/rules/python-quality.md b/.claude/rules/python-quality.md new file mode 120000 index 0000000..4a795e4 --- /dev/null +++ b/.claude/rules/python-quality.md @@ -0,0 +1 @@ +../../.agents/rules/python-quality.md \ No newline at end of file diff --git a/.claude/rules/python-style.md b/.claude/rules/python-style.md deleted file mode 100644 index 92ac676..0000000 --- a/.claude/rules/python-style.md +++ /dev/null @@ -1,55 +0,0 @@ ---- -description: Python coding style for src/ and tests/ -alwaysApply: false -globs: src/**/*.py,tests/**/*.py -paths: - - "src/**/*.py" - - "tests/**/*.py" ---- - -# Python coding style - -## Type hints - -- Annotate all function parameters and return types. -- Use `from __future__ import annotations` in new modules. -- Use `TYPE_CHECKING` blocks for imports needed only for type hints. - -## Formatting and naming - -- Follow PEP 8 with **snake_case** for functions, variables, and modules. -- Max line length is **120** characters (ruff formatter default in this project — not Black's 88). -- Format with **ruff**, not Black: - -```bash -poetry run ruff format src/ tests/ -``` - -## Strings and paths - -- Use **f-strings** for string formatting; avoid `%` formatting and `.format()`. -- Use **`pathlib.Path`** over `os.path` for filesystem operations. - -## Comprehensions and readability - -- Prefer list/dict/set comprehensions over explicit loops when the result stays readable. -- Do not sacrifice clarity for brevity. - -## Layout and whitespace - -- Group **logically related lines** together (e.g. setup, core logic, cleanup). -- Separate groups with a **single blank line**; use an extra blank line between larger sections when it aids scanning. -- Do not sprinkle blank lines randomly, and do not leave long unbroken blocks when a visual break would help. -- Within a function, keep the main path easy to follow: inputs and validation first, then the core work, then return/cleanup. - -## Public before private - -- Place **public** API first so readers see the most relevant surface when scrolling: public classes, methods, functions, and module-level constants. -- Place **private** members after public ones: names prefixed with `_` (attributes, methods, functions, nested helpers) and internal implementation details. -- In classes: public methods first, then `_`-prefixed helpers and internal state accessors. -- In modules: public exports and user-facing functions first; private helpers and module-internal constants at the bottom. -- A short section comment (e.g. `# --- Internal ---`) is fine when a class or module has a large private block. - -## Resources - -- Use **context managers** (`with`) for files, locks, and other resources that need cleanup. diff --git a/.claude/rules/python-style.md b/.claude/rules/python-style.md new file mode 120000 index 0000000..e7ec4f8 --- /dev/null +++ b/.claude/rules/python-style.md @@ -0,0 +1 @@ +../../.agents/rules/python-style.md \ No newline at end of file diff --git a/.claude/rules/python-testing.md b/.claude/rules/python-testing.md deleted file mode 100644 index cf7f943..0000000 --- a/.claude/rules/python-testing.md +++ /dev/null @@ -1,81 +0,0 @@ ---- -description: Python testing conventions for src/ and tests/ -alwaysApply: false -globs: src/**/*.py,tests/**/*.py -paths: - - "src/**/*.py" - - "tests/**/*.py" ---- - -# Python testing - -## Framework and commands - -- Use **pytest** for all tests. -- Prefer the dedicated entry points over bare `pytest`: - -```bash -poetry run test-unit # offline/unit suite (CI default) -poetry run test-e2e # live-server e2e (loads .env) -poetry run test # unit then e2e sequentially - -# Single file or test (extra args pass through to test-unit / test-e2e) -poetry run test-unit tests/validators/test_device_id.py -poetry run test-e2e tests/e2e/inspect/test_e2e_inspect.py::test_name -``` - -- `poetry run pytest` also runs unit tests only (e2e excluded via `addopts` in `pyproject.toml`). - -### VS Code - -Use the launch configs in `.vscode/launch.json`: - -- **Unit Tests** / **Unit Tests (current file)** — offline suite -- **E2E Tests** / **E2E Tests (current file)** — live-server suite - -Or run **Tests** from `.vscode/tasks.json` (`poetry run test`). - -## Unit vs e2e separation - -| Layer | Unit | E2E | -|-------|------|-----| -| Location | `tests/` except `tests/e2e/` | `tests/e2e/` only | -| Marker | unmarked | `@pytest.mark.e2e` | -| Env | `tests/conftest.py` (dummy values) | `.env` (copy from `.env.template`) | -| Run command | `test-unit` / `pytest` | `test-e2e` | -| CI | yes | no | - -Default `addopts` run coverage on `src/` and exclude e2e (`-m "not e2e"`). - -## Assertions - -- Use `pytest.raises(SpecificError)` with the exact exception type. -- Do not catch or assert against bare `Exception` when a domain error exists. - -## Unit and offline tests - -- Mock external I/O with fake connectors and lightweight stand-ins (see `tests/inspect/test_actions.py`). -- Dummy `VIPAT_*` env vars are set in `tests/conftest.py` (autouse fixture; skipped for e2e). -- Load JSON fixtures from `tests//fixtures//` using `pathlib.Path`. -- Put shared fixtures in `conftest.py` at the appropriate directory level. -- Use session-scoped fixtures only when setup is expensive and reuse is intentional. - -## E2E tests - -- Live-server tests live under `tests/e2e/` only. -- Mark with `@pytest.mark.e2e`; they are excluded from the default suite. -- E2e entry points (`poetry run test-e2e`, `poetry run test`, VS Code **E2E Tests**) load `.env` and enable the suite automatically. E2e runs use `--no-cov`. -- Copy `.env.template` to `.env` (gitignored), set connection vars. The e2e conftest loads `.env` automatically when present. -- Run with `poetry run test-e2e` (no extra env vars on the command line). -- Namespace all writes with the `E2E-` label prefix and `vipat-e2e` tag. -- Do not add e2e tests to the default CI/offline run. - -## Test data - -- All fixture and test data must follow anonymization rules in `AGENTS.md`. -- Preserve structure and relationships; replace real hostnames, IPs, and customer identifiers with generic placeholders. - -## Coverage - -- Default runs report coverage on `src/`. -- Do not disable coverage flags without a clear reason. diff --git a/.claude/rules/python-testing.md b/.claude/rules/python-testing.md new file mode 120000 index 0000000..0a6692e --- /dev/null +++ b/.claude/rules/python-testing.md @@ -0,0 +1 @@ +../../.agents/rules/python-testing.md \ No newline at end of file diff --git a/.cursor/rules/agent-rules-layout.mdc b/.cursor/rules/agent-rules-layout.mdc index 484590f..4f04229 120000 --- a/.cursor/rules/agent-rules-layout.mdc +++ b/.cursor/rules/agent-rules-layout.mdc @@ -1 +1 @@ -../.claude/rules/agent-rules-layout.md \ No newline at end of file +../../.agents/rules/agent-rules-layout.md \ No newline at end of file diff --git a/.cursor/rules/python-quality.mdc b/.cursor/rules/python-quality.mdc index 5a47a4e..4a795e4 120000 --- a/.cursor/rules/python-quality.mdc +++ b/.cursor/rules/python-quality.mdc @@ -1 +1 @@ -../.claude/rules/python-quality.md \ No newline at end of file +../../.agents/rules/python-quality.md \ No newline at end of file diff --git a/.cursor/rules/python-style.mdc b/.cursor/rules/python-style.mdc index 8598caa..e7ec4f8 120000 --- a/.cursor/rules/python-style.mdc +++ b/.cursor/rules/python-style.mdc @@ -1 +1 @@ -../.claude/rules/python-style.md \ No newline at end of file +../../.agents/rules/python-style.md \ No newline at end of file diff --git a/.cursor/rules/python-testing.mdc b/.cursor/rules/python-testing.mdc index 94109c9..0a6692e 120000 --- a/.cursor/rules/python-testing.mdc +++ b/.cursor/rules/python-testing.mdc @@ -1 +1 @@ -../.claude/rules/python-testing.md \ No newline at end of file +../../.agents/rules/python-testing.md \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md index a4c5a2b..af93963 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -67,14 +67,14 @@ list-videoipath-versions ## Agent rules -Global repo guidance lives in this file. Path-scoped Python rules are in `.claude/rules/`: +Global repo guidance lives in this file. Path-scoped Python rules are in `.agents/rules/`: - `python-style.md` — type hints, ruff formatting, naming, pathlib - `python-quality.md` — Pydantic, exceptions, lint gates, change scope - `python-testing.md` — pytest, fixtures, fakes, e2e gating -- `agent-rules-layout.md` — how to add/remove rules and maintain `.cursor/rules/` symlinks +- `agent-rules-layout.md` — how to add/remove rules and maintain tool symlinks -Cursor reads the same content via symlinks in `.cursor/rules/*.mdc`. Canonical rule files live in `.claude/rules/`; see `agent-rules-layout.md` when creating or deleting rules. Python rules load when working on files under `src/` or `tests/`. +Claude Code and Cursor read the same content via symlinks in `.claude/rules/*.md` and `.cursor/rules/*.mdc`. Canonical rule files live in `.agents/rules/`; see `agent-rules-layout.md` when creating or deleting rules. Python rules load when working on files under `src/` or `tests/`. ## Architecture From ded36d45b7b2216bcb2b561cf48678823bb54430 Mon Sep 17 00:00:00 2001 From: Jonas Scholl <43819845+JonasScholl@users.noreply.github.com> Date: Thu, 23 Jul 2026 04:15:29 +0200 Subject: [PATCH 25/34] add update method to transaction instance --- .../02_transactions_and_conflicts.py | 9 +- .../01_full_onboarding_pipeline.py | 2 +- .../06_workflows/03_bulk_retag_and_relabel.py | 2 +- docs/getting-started-guide/03_B_Inspect.md | 2 + .../apps/inspect/app/write.py | 121 +++--------------- .../apps/inspect/domain/device.py | 3 +- .../apps/inspect/domain/edge.py | 3 +- .../apps/inspect/domain/module.py | 5 +- .../apps/inspect/domain/vertex.py | 3 +- .../apps/inspect/transaction.py | 112 +++++++++++++++- .../e2e/workflows/test_onboarding_pipeline.py | 2 +- tests/inspect/test_domain_writes.py | 4 +- 12 files changed, 145 insertions(+), 123 deletions(-) diff --git a/docs/examples/04_inspect/02_transactions_and_conflicts.py b/docs/examples/04_inspect/02_transactions_and_conflicts.py index d2fb96e..bfce8d0 100644 --- a/docs/examples/04_inspect/02_transactions_and_conflicts.py +++ b/docs/examples/04_inspect/02_transactions_and_conflicts.py @@ -7,9 +7,8 @@ handle a concurrent modification: the commit detects it, raises ``InspectCommitConflictError``, and you ``rebase`` onto fresh server state and retry. -The recommended way to stage domain-object edits into a transaction is -``app.inspect.update(objects, tx=tx)``; the keyword-style ``tx.update_device(...)`` is shown as an -alternative. +The recommended way to stage domain-object edits into a transaction is ``tx.update(objects)``; +the keyword-style ``tx.update_device(...)`` is shown as an alternative. Prerequisites ------------- @@ -50,7 +49,7 @@ def main() -> None: leaf_out.use_as_endpoint = True try: with app.inspect.transaction() as tx: - app.inspect.update([leaf, leaf_out], tx=tx) # stage setter edits into the transaction + tx.update([leaf, leaf_out]) # stage setter edits into the transaction tx.connect(leaf_out.id, spine_in.id, bidirectional=True) # edge creation (no setter form) result = tx.commit() print("Committed:", result.applied_ids) @@ -67,7 +66,7 @@ def main() -> None: # Stage the edit once, then retry the commit; rebase re-fetches baselines and keeps our intent. leaf.description = "Rack A leaf (updated)" tx = app.inspect.transaction() - app.inspect.update(leaf, tx=tx) + tx.update(leaf) for attempt in range(1, MAX_RETRIES + 1): try: tx.commit() diff --git a/docs/examples/06_workflows/01_full_onboarding_pipeline.py b/docs/examples/06_workflows/01_full_onboarding_pipeline.py index 21a4ed2..36652f3 100644 --- a/docs/examples/06_workflows/01_full_onboarding_pipeline.py +++ b/docs/examples/06_workflows/01_full_onboarding_pipeline.py @@ -89,7 +89,7 @@ def main() -> None: for vertex in device.codec_vertices: vertex.use_as_endpoint = True vertex.sips_mode = "SIPSAuto" - app.inspect.update(device, tx=tx) # cascades the device + its dirty vertices + tx.update(device) # cascades the device + its dirty vertices tx.commit() # --- 6. Connect devices per the cabling data ------------------------------ diff --git a/docs/examples/06_workflows/03_bulk_retag_and_relabel.py b/docs/examples/06_workflows/03_bulk_retag_and_relabel.py index c4f79dc..9897525 100644 --- a/docs/examples/06_workflows/03_bulk_retag_and_relabel.py +++ b/docs/examples/06_workflows/03_bulk_retag_and_relabel.py @@ -64,7 +64,7 @@ def main() -> None: continue device.label = new_label device.tags = sorted(set(device.tags) | {ADD_TAG}) - app.inspect.update(device, tx=tx) + tx.update(device) tx.commit() # --- 5. Keep the inventory labels in sync --------------------------------- diff --git a/docs/getting-started-guide/03_B_Inspect.md b/docs/getting-started-guide/03_B_Inspect.md index 4846996..faa24b2 100644 --- a/docs/getting-started-guide/03_B_Inspect.md +++ b/docs/getting-started-guide/03_B_Inspect.md @@ -141,6 +141,8 @@ Use a transaction to stage several changes and commit them together: ```python with app.inspect.transaction() as tx: + device.description = "Rack A leaf" + tx.update(device) # stage setter edits into the transaction tx.place_device("device12", x=100, y=200) tx.connect(a_out, b_in, bidirectional=True) tx.remove(edge_id) diff --git a/src/videoipath_automation_tool/apps/inspect/app/write.py b/src/videoipath_automation_tool/apps/inspect/app/write.py index 471dbfe..c25752e 100644 --- a/src/videoipath_automation_tool/apps/inspect/app/write.py +++ b/src/videoipath_automation_tool/apps/inspect/app/write.py @@ -6,7 +6,7 @@ Domain objects also support a unit-of-work pattern: mutate attributes via setters (pending edits stage on the snapshot), then call ``update(device)`` / ``update(vertex)`` / ``update(edge)`` to -flush them through a transaction (optionally appending to an existing ``tx``). +auto-commit, or ``tx.update(...)`` to stage into an open transaction. Every write is bound to the app's internal snapshot: on a successful commit the touched entities are refreshed in place ([ADR-0010]) — but only if the snapshot has already been loaded, so a pure-write @@ -18,7 +18,6 @@ import logging from typing import TYPE_CHECKING, Any, Optional, Protocol, Sequence -from videoipath_automation_tool.apps.inspect.transaction import CommitResult, InspectTransaction from videoipath_automation_tool.apps.inspect.api import InspectAPI from videoipath_automation_tool.apps.inspect.model.common import ( InspectConfigPriority, @@ -27,18 +26,17 @@ InspectRedundancyMode, InspectSdpStrategy, ) +from videoipath_automation_tool.apps.inspect.transaction import ( + CommitResult, + Editable, + InspectTransaction, + _is_single_editable, + _stage_editable, +) if TYPE_CHECKING: - from videoipath_automation_tool.apps.inspect.domain.device import InspectDevice - from videoipath_automation_tool.apps.inspect.domain.edge import InspectEdge - from videoipath_automation_tool.apps.inspect.domain.module import InspectModule - from videoipath_automation_tool.apps.inspect.domain.vertex import InspectVertex from videoipath_automation_tool.apps.inspect.snapshot import InspectSnapshot - Editable = InspectDevice | InspectVertex | InspectEdge | InspectModule -else: - Editable = Any - class _HasInspectState(Protocol): _inspect_api: InspectAPI @@ -55,25 +53,15 @@ def transaction(self: _HasInspectState) -> InspectTransaction: """Open a batched, atomic transaction bound to the app's internal snapshot.""" return InspectTransaction(self._inspect_api, snapshot=self._snapshot, logger=self._logger) - def update( - self: _HasInspectState, - obj: Editable | Sequence[Editable], - tx: Optional[InspectTransaction] = None, - ) -> CommitResult | InspectTransaction: - """Flush pending domain-object edits through a transaction. + def update(self: _HasInspectState, obj: Editable | Sequence[Editable]) -> CommitResult: + """Flush pending domain-object edits through a new auto-committed transaction. Accepts an :class:`InspectDevice`, :class:`InspectVertex`, :class:`InspectEdge`, :class:`InspectModule`, or a sequence of them. For a device, also cascades every dirty vertex/edge/module whose id belongs to that device (unit of work). - Args: - obj: Domain object(s) with pending setter edits. - tx: Optional open transaction to append into. When ``None``, a new transaction is opened - and committed; the return value is a :class:`CommitResult`. When given, edits are - staged into ``tx`` and ``tx`` is returned (caller commits). - - Returns: - ``CommitResult`` when ``tx is None``; otherwise the supplied ``tx``. + For batched changes, mutate domain objects then call ``tx.update(obj)`` on an open + :meth:`transaction` and ``commit()`` yourself. """ objects = list(obj) if isinstance(obj, Sequence) and not _is_single_editable(obj) else [obj] # type: ignore[list-item] if not objects: @@ -84,26 +72,17 @@ def update( "before updating domain objects." ) - owns_tx = tx is None - txn = self.transaction() if owns_tx else tx - assert txn is not None - + txn = self.transaction() flushed_keys: list[tuple[str, str]] = [] for item in objects: flushed_keys.extend(_stage_editable(txn, self._snapshot, item)) - if owns_tx: - if not flushed_keys and len(txn) == 0: - raise ValueError("No pending edits to flush.") - result = txn.commit() - for kind, entity_id in flushed_keys: - self._snapshot.clear_staged(kind=kind, entity_id=entity_id) - return result - - # When appending to a caller-owned transaction, no-op if there was nothing staged. + if not flushed_keys and len(txn) == 0: + raise ValueError("No pending edits to flush.") + result = txn.commit() for kind, entity_id in flushed_keys: self._snapshot.clear_staged(kind=kind, entity_id=entity_id) - return txn + return result def place_device(self: _HasInspectState, device_id: str, x: float, y: float) -> CommitResult: """Move a device to grid coordinates (single auto-committed change).""" @@ -338,70 +317,4 @@ def remove_device_from_topology(self: _HasInspectState, device_id: str) -> Commi return tx.commit() -def _is_single_editable(obj: Any) -> bool: - from videoipath_automation_tool.apps.inspect.domain.device import InspectDevice - from videoipath_automation_tool.apps.inspect.domain.edge import InspectEdge - from videoipath_automation_tool.apps.inspect.domain.module import InspectModule - from videoipath_automation_tool.apps.inspect.domain.vertex import InspectVertex - - return isinstance(obj, (InspectDevice, InspectVertex, InspectEdge, InspectModule)) - - -def _stage_editable( - tx: InspectTransaction, - snapshot: "InspectSnapshot", - obj: Editable, -) -> list[tuple[str, str]]: - """Stage pending edits for ``obj`` (and cascade children for a device). Returns flushed keys.""" - from videoipath_automation_tool.apps.inspect.domain.device import InspectDevice - from videoipath_automation_tool.apps.inspect.domain.edge import InspectEdge - from videoipath_automation_tool.apps.inspect.domain.module import InspectModule - from videoipath_automation_tool.apps.inspect.domain.vertex import InspectVertex - from videoipath_automation_tool.apps.inspect.transaction import _device_of - - flushed: list[tuple[str, str]] = [] - - if isinstance(obj, InspectDevice): - device_edits = snapshot.get_staged_edits("device", obj.id) - if device_edits: - tx.update_device(obj.id, intents=device_edits) - flushed.append(("device", obj.id)) - for kind, entity_id, intents in snapshot.iter_staged_edits(): - if kind == "vertex" and _device_of(entity_id) == obj.id and intents: - tx.update_vertex(entity_id, intents=intents) - flushed.append((kind, entity_id)) - elif kind == "module" and _device_of(entity_id) == obj.id and intents: - tx.update_module(entity_id, intents=intents) - flushed.append((kind, entity_id)) - elif kind == "edge" and intents: - from_id, _, to_id = entity_id.partition("::") - if _device_of(from_id) == obj.id or _device_of(to_id) == obj.id: - tx.update_edge(entity_id, intents=intents) - flushed.append((kind, entity_id)) - return flushed - - if isinstance(obj, InspectVertex): - edits = snapshot.get_staged_edits("vertex", obj.id) - if edits: - tx.update_vertex(obj.id, intents=edits) - flushed.append(("vertex", obj.id)) - return flushed - - if isinstance(obj, InspectEdge): - edits = snapshot.get_staged_edits("edge", obj.id) - if edits: - tx.update_edge(obj.id, intents=edits) - flushed.append(("edge", obj.id)) - return flushed - - if isinstance(obj, InspectModule): - edits = snapshot.get_staged_edits("module", obj.id) - if edits: - tx.update_module(obj.id, intents=edits) - flushed.append(("module", obj.id)) - return flushed - - raise TypeError(f"Unsupported update target: {type(obj)!r}") - - __all__ = ["InspectWriteMixin"] diff --git a/src/videoipath_automation_tool/apps/inspect/domain/device.py b/src/videoipath_automation_tool/apps/inspect/domain/device.py index 8a89401..ba0aea1 100644 --- a/src/videoipath_automation_tool/apps/inspect/domain/device.py +++ b/src/videoipath_automation_tool/apps/inspect/domain/device.py @@ -37,7 +37,8 @@ class InspectDevice(InspectEditableModel): hydrated/refreshed data transparently. Editable attributes use property setters that stage pending intents on the snapshot - (read-your-writes). Flush with ``app.inspect.update(device)``. + (read-your-writes). Flush with ``app.inspect.update(device)`` or ``tx.update(device)`` + inside a transaction. """ snapshot: InspectSnapshot diff --git a/src/videoipath_automation_tool/apps/inspect/domain/edge.py b/src/videoipath_automation_tool/apps/inspect/domain/edge.py index 4295992..d38f28d 100644 --- a/src/videoipath_automation_tool/apps/inspect/domain/edge.py +++ b/src/videoipath_automation_tool/apps/inspect/domain/edge.py @@ -22,7 +22,8 @@ class InspectEdge(InspectEditableModel): """A directed external edge. Live status fields come from the collector skeleton; Edit Edge dialog fields resolve from the lazily-fetched edit form, with pending setter edits taking - precedence (read-your-writes). Flush with ``app.inspect.update(edge)``.""" + precedence (read-your-writes). Flush with ``app.inspect.update(edge)`` or ``tx.update(edge)`` + inside a transaction.""" snapshot: InspectSnapshot indexed: _IndexedEdge diff --git a/src/videoipath_automation_tool/apps/inspect/domain/module.py b/src/videoipath_automation_tool/apps/inspect/domain/module.py index 2282fef..dafea86 100644 --- a/src/videoipath_automation_tool/apps/inspect/domain/module.py +++ b/src/videoipath_automation_tool/apps/inspect/domain/module.py @@ -42,8 +42,9 @@ class InspectModule(InspectEditableModel): ``module_id`` / ``device_id``. Editable attributes use property setters that stage pending intents on the snapshot - (read-your-writes). Flush with ``app.inspect.update(module)`` or ``app.inspect.update(device)``. - Module tags are committed via ``assignTag`` / ``unassignTag`` (not ``updateTopology``). + (read-your-writes). Flush with ``app.inspect.update(module)``, ``app.inspect.update(device)``, + or ``tx.update(...)`` inside a transaction. Module tags are committed via ``assignTag`` / + ``unassignTag`` (not ``updateTopology``). """ snapshot: InspectSnapshot diff --git a/src/videoipath_automation_tool/apps/inspect/domain/vertex.py b/src/videoipath_automation_tool/apps/inspect/domain/vertex.py index a2d5251..c11595b 100644 --- a/src/videoipath_automation_tool/apps/inspect/domain/vertex.py +++ b/src/videoipath_automation_tool/apps/inspect/domain/vertex.py @@ -14,7 +14,8 @@ form's ``typeFields.type``. Editable attributes use property setters that stage pending wire-field intents on the snapshot -(read-your-writes). Flush with ``app.inspect.update(vertex)`` or ``app.inspect.update(device)``. +(read-your-writes). Flush with ``app.inspect.update(vertex)``, ``app.inspect.update(device)``, +or ``tx.update(...)`` inside a transaction. """ from __future__ import annotations diff --git a/src/videoipath_automation_tool/apps/inspect/transaction.py b/src/videoipath_automation_tool/apps/inspect/transaction.py index 334c133..3a4a3fb 100644 --- a/src/videoipath_automation_tool/apps/inspect/transaction.py +++ b/src/videoipath_automation_tool/apps/inspect/transaction.py @@ -25,7 +25,7 @@ import copy import logging -from typing import TYPE_CHECKING, Any, Optional +from typing import TYPE_CHECKING, Any, Optional, Sequence from pydantic import Field @@ -61,8 +61,16 @@ if TYPE_CHECKING: from videoipath_automation_tool.apps.inspect.api import InspectAPI + from videoipath_automation_tool.apps.inspect.domain.device import InspectDevice + from videoipath_automation_tool.apps.inspect.domain.edge import InspectEdge + from videoipath_automation_tool.apps.inspect.domain.module import InspectModule + from videoipath_automation_tool.apps.inspect.domain.vertex import InspectVertex from videoipath_automation_tool.apps.inspect.snapshot import InspectSnapshot + Editable = InspectDevice | InspectVertex | InspectEdge | InspectModule +else: + Editable = Any + class CommitResult(InspectFrozenModel): """Outcome of a successful commit ([ADR-0006]); a failed commit raises ``InspectCommitError``. @@ -87,9 +95,10 @@ def validation(self) -> Any: class InspectTransaction: """Single-use, atomic batch of Inspect topology changes. - Stage changes with the ``place_device`` / ``update_*`` / ``connect`` / ``disconnect`` / ``remove`` - methods, then call ``commit()``. The transaction cannot be reused after commit or discard; use - it as a context manager to guarantee cleanup (exit without commit discards and logs a warning). + Stage changes with ``update`` (domain-object setter flush), ``place_device`` / ``update_*`` / + ``connect`` / ``disconnect`` / ``remove``, then call ``commit()``. The transaction cannot be + reused after commit or discard; use it as a context manager to guarantee cleanup (exit without + commit discards and logs a warning). """ def __init__( @@ -131,6 +140,36 @@ def staged(self) -> list[tuple[str, str]]: def __len__(self) -> int: return len(self._entries) + # --- Staging: domain objects --- + + def update(self, obj: Editable | Sequence[Editable]) -> "InspectTransaction": + """Flush pending domain-object setter edits into this transaction. + + Accepts an :class:`InspectDevice`, :class:`InspectVertex`, :class:`InspectEdge`, + :class:`InspectModule`, or a sequence of them. For a device, also cascades every dirty + vertex/edge/module whose id belongs to that device (unit of work). Clears the snapshot's + pending edits after staging. Returns ``self`` for chaining. + + Prefer this over keyword ``update_device`` / ``update_vertex`` / … when edits were made via + domain-object setters. Requires a snapshot-bound transaction (from ``app.inspect.transaction()``). + """ + if self._snapshot is None: + raise RuntimeError( + "Inspect snapshot is not available; load the topology (e.g. access app.inspect.devices) " + "before updating domain objects." + ) + objects = list(obj) if isinstance(obj, Sequence) and not _is_single_editable(obj) else [obj] # type: ignore[list-item] + if not objects: + raise ValueError("Nothing to update.") + + flushed_keys: list[tuple[str, str]] = [] + for item in objects: + flushed_keys.extend(_stage_editable(self, self._snapshot, item)) + + for kind, entity_id in flushed_keys: + self._snapshot.clear_staged(kind=kind, entity_id=entity_id) + return self + # --- Staging: devices --- def place_device(self, device_id: str, x: float, y: float) -> "InspectTransaction": @@ -817,6 +856,71 @@ def _device_of(entity_id: str) -> str: return entity_id.split(".", 1)[0] +def _is_single_editable(obj: Any) -> bool: + from videoipath_automation_tool.apps.inspect.domain.device import InspectDevice + from videoipath_automation_tool.apps.inspect.domain.edge import InspectEdge + from videoipath_automation_tool.apps.inspect.domain.module import InspectModule + from videoipath_automation_tool.apps.inspect.domain.vertex import InspectVertex + + return isinstance(obj, (InspectDevice, InspectVertex, InspectEdge, InspectModule)) + + +def _stage_editable( + tx: InspectTransaction, + snapshot: "InspectSnapshot", + obj: Editable, +) -> list[tuple[str, str]]: + """Stage pending edits for ``obj`` (and cascade children for a device). Returns flushed keys.""" + from videoipath_automation_tool.apps.inspect.domain.device import InspectDevice + from videoipath_automation_tool.apps.inspect.domain.edge import InspectEdge + from videoipath_automation_tool.apps.inspect.domain.module import InspectModule + from videoipath_automation_tool.apps.inspect.domain.vertex import InspectVertex + + flushed: list[tuple[str, str]] = [] + + if isinstance(obj, InspectDevice): + device_edits = snapshot.get_staged_edits("device", obj.id) + if device_edits: + tx.update_device(obj.id, intents=device_edits) + flushed.append(("device", obj.id)) + for kind, entity_id, intents in snapshot.iter_staged_edits(): + if kind == "vertex" and _device_of(entity_id) == obj.id and intents: + tx.update_vertex(entity_id, intents=intents) + flushed.append((kind, entity_id)) + elif kind == "module" and _device_of(entity_id) == obj.id and intents: + tx.update_module(entity_id, intents=intents) + flushed.append((kind, entity_id)) + elif kind == "edge" and intents: + from_id, _, to_id = entity_id.partition("::") + if _device_of(from_id) == obj.id or _device_of(to_id) == obj.id: + tx.update_edge(entity_id, intents=intents) + flushed.append((kind, entity_id)) + return flushed + + if isinstance(obj, InspectVertex): + edits = snapshot.get_staged_edits("vertex", obj.id) + if edits: + tx.update_vertex(obj.id, intents=edits) + flushed.append(("vertex", obj.id)) + return flushed + + if isinstance(obj, InspectEdge): + edits = snapshot.get_staged_edits("edge", obj.id) + if edits: + tx.update_edge(obj.id, intents=edits) + flushed.append(("edge", obj.id)) + return flushed + + if isinstance(obj, InspectModule): + edits = snapshot.get_staged_edits("module", obj.id) + if edits: + tx.update_module(obj.id, intents=edits) + flushed.append(("module", obj.id)) + return flushed + + raise TypeError(f"Unsupported update target: {type(obj)!r}") + + def _raise_if_tag_action_failed(action: str, tag_id: str, response: InspectApiSimpleActionResponse) -> None: if response.header.ok and response.data.ok: return diff --git a/tests/e2e/workflows/test_onboarding_pipeline.py b/tests/e2e/workflows/test_onboarding_pipeline.py index 7f61419..6035823 100644 --- a/tests/e2e/workflows/test_onboarding_pipeline.py +++ b/tests/e2e/workflows/test_onboarding_pipeline.py @@ -77,7 +77,7 @@ def test_configure_devices(self, app: VideoIPathApp, state: PipelineState) -> No device.label = f"E2E-{PIPELINE.name}-{spec.name}" device.description = f"Onboarding pipeline {spec.name}" device.tags = [E2E_TAG, SITE_TAG] - app.inspect.update(device, tx=tx) + tx.update(device) tx.commit() for spec in PIPELINE.devices: device = app.inspect.get_device(state.device_ids[spec.name]) diff --git a/tests/inspect/test_domain_writes.py b/tests/inspect/test_domain_writes.py index 2ccd344..a909a8c 100644 --- a/tests/inspect/test_domain_writes.py +++ b/tests/inspect/test_domain_writes.py @@ -148,7 +148,7 @@ def test_update_device_cascades_vertex_edits() -> None: assert snapshot.get_staged_edits("vertex", CODEC_ID) == {} -def test_update_appends_to_existing_transaction() -> None: +def test_transaction_update_stages_domain_edits() -> None: api = FakeAPI() api.devices[DEVICE_ID] = _device_response() snapshot = _skeleton_snapshot_with_device(DEVICE_ID) @@ -158,7 +158,7 @@ def test_update_appends_to_existing_transaction() -> None: app = _WriteApp(api, snapshot) with app.transaction() as tx: - returned = app.update(device, tx=tx) + returned = tx.update(device) assert returned is tx tx.commit() assert api.update_calls[0].replaceDevices[DEVICE_ID].descriptor.label == "Via Tx" From 076a2c2f4bb70edb9acf835a7cfc2a1b32747073 Mon Sep 17 00:00:00 2001 From: Paul Winterstein Date: Thu, 23 Jul 2026 18:24:19 +0200 Subject: [PATCH 26/34] add new schema version 2026.2.0 --- .../model/driver_schema/2026.2.0.json | 21469 ++++++++++++++++ .../apps/inventory/model/drivers.py | 1 + .../2026.2.0_nGraphSchemaFlat.json | 4011 +++ 3 files changed, 25481 insertions(+) create mode 100644 src/videoipath_automation_tool/apps/inventory/model/driver_schema/2026.2.0.json create mode 100644 src/videoipath_automation_tool/apps/topology/model/n_graph_elements/n_graph_schema/2026.2.0_nGraphSchemaFlat.json diff --git a/src/videoipath_automation_tool/apps/inventory/model/driver_schema/2026.2.0.json b/src/videoipath_automation_tool/apps/inventory/model/driver_schema/2026.2.0.json new file mode 100644 index 0000000..039087d --- /dev/null +++ b/src/videoipath_automation_tool/apps/inventory/model/driver_schema/2026.2.0.json @@ -0,0 +1,21469 @@ +{ + "data": { + "status": { + "system": { + "drivers": { + "_items": [ + { + "_id": "com.dante.DDM-0.1.0", + "_vid": "com.dante.DDM-0.1.0", + "attachments": [ + { + "description": "Default", + "name": "DDM" + } + ], + "configurableDevice": false, + "configurableModule": [], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "Custom setting fields for DDM driver", + "label": "DanteDomainManager" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map", + "values": { + "com.dante.DDM.api_token": { + "_schema": { + "default": "", + "descriptor": { + "desc": "Needs to be provided", + "label": "API Token" + }, + "encoding": "UTF-8", + "isNullable": false, + "lengthRanges": [], + "options": [], + "status": "Current", + "type": "string" + } + } + } + } + }, + "customSettingsSeed": {}, + "description": "Driver for Dante Domain Manager", + "deviceType": "driver", + "discoveredBy": null, + "exists": "No", + "iconType": "gateway", + "ipAddress": null, + "label": "Dante Domain Manager", + "modules": [], + "name": "DDM", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.dante", + "products": [], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.2.1.1.2.0" + ], + "snmpSysObjectIdValue": null, + "status": "", + "supportedApis": [ + "MatrixControlLike", + "CoreLike", + "DeviceLike", + "StatusLike" + ], + "supportsExternal": true, + "supportsParameterApi": false, + "uniqueMetricDefs": [], + "updateableDevice": false, + "updateableModule": {}, + "version": "0.1.0" + }, + { + "_id": "com.nevion.NMOS-0.1.0", + "_vid": "com.nevion.NMOS-0.1.0", + "attachments": [ + { + "description": "Default", + "name": "NMOS" + } + ], + "configurableDevice": false, + "configurableModule": [], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "Custom setting fields for NMOS Nodes", + "label": "NMOS" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map", + "values": { + "com.nevion.NMOS.always_enable_rtp": { + "_schema": { + "default": false, + "descriptor": { + "desc": "The \"rtp_enabled\" field in \"transport_params\" will always be set to true", + "label": "Always enable RTP" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "bool" + } + }, + "com.nevion.NMOS.disable_rx_sdp": { + "_schema": { + "default": false, + "descriptor": { + "desc": "Configure this unit's receivers with regular transport parameters only", + "label": "Disable Rx SDP" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "bool" + } + }, + "com.nevion.NMOS.disable_rx_sdp_with_null": { + "_schema": { + "default": true, + "descriptor": { + "desc": "Configures how RX SDPs are disabled. If unchecked, an empty string is used", + "label": "Disable Rx SDP with null" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "bool" + } + }, + "com.nevion.NMOS.enable_bulk_config": { + "_schema": { + "default": false, + "descriptor": { + "desc": "Configure this unit using bulk API", + "label": "Enable bulk config" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "bool" + } + }, + "com.nevion.NMOS.enable_experimental_alarm": { + "_schema": { + "default": false, + "descriptor": { + "desc": "Enables experimental alarms over websockets using IS-07 on certain Vizrt devices. Disables alarms completely if disabled", + "label": "Enable experimental alarms using IS-07" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "bool" + } + }, + "com.nevion.NMOS.experimental_alarm_port": { + "_schema": { + "default": 0, + "descriptor": { + "desc": "HTTP port for location of experimental IS-07 alarm websocket. If empty or 0 it uses Port field instead", + "label": "Experimental alarm port" + }, + "isNullable": true, + "options": [], + "ranges": [ + [ + 1, + 65535, + 1 + ] + ], + "status": "Current", + "type": "number", + "units": "" + } + }, + "com.nevion.NMOS.is05_api_version": { + "_schema": { + "default": false, + "descriptor": { + "desc": "Configure IS05 API version to use max", + "label": "Enable Max IS05 API version" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "bool" + } + }, + "com.nevion.NMOS.port": { + "_schema": { + "default": 80, + "descriptor": { + "desc": "The HTTP port used to reach the Node directly", + "label": "Port" + }, + "isNullable": false, + "options": [], + "ranges": [ + [ + 1, + 65535, + 1 + ] + ], + "status": "Current", + "type": "number", + "units": "" + } + } + } + } + }, + "customSettingsSeed": {}, + "description": "A driver for NMOS capable devices tailored for single devices", + "deviceType": "nmos", + "discoveredBy": null, + "exists": "No", + "iconType": "none", + "ipAddress": null, + "label": "NMOS (Single-device)", + "modules": [], + "name": "NMOS", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.nevion", + "products": [ + { + "name": "NMOS", + "swBuildTime": null, + "swVersion": null + } + ], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.2.1.1.2.0" + ], + "snmpSysObjectIdValue": null, + "status": "", + "supportedApis": [ + "MatrixControlLike", + "DeviceLike", + "PortLike", + "CoreLike", + "StatusLike" + ], + "supportsExternal": true, + "supportsParameterApi": false, + "uniqueMetricDefs": [], + "updateableDevice": false, + "updateableModule": {}, + "version": "0.1.0" + }, + { + "_id": "com.nevion.NMOS_multidevice-0.1.0", + "_vid": "com.nevion.NMOS_multidevice-0.1.0", + "attachments": [ + { + "description": "Default", + "name": "NMOS_multidevice" + } + ], + "configurableDevice": false, + "configurableModule": [], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "Custom setting fields for NMOS Multidevice Nodes", + "label": "NMOS Multidevice" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map", + "values": { + "com.nevion.NMOS_multidevice.always_enable_rtp": { + "_schema": { + "default": false, + "descriptor": { + "desc": "The \"rtp_enabled\" field in \"transport_params\" will always be set to true", + "label": "Always enable RTP" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "bool" + } + }, + "com.nevion.NMOS_multidevice.disable_rx_sdp": { + "_schema": { + "default": false, + "descriptor": { + "desc": "Configure this unit's receivers with regular transport parameters only", + "label": "Disable Rx SDP" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "bool" + } + }, + "com.nevion.NMOS_multidevice.disable_rx_sdp_with_null": { + "_schema": { + "default": true, + "descriptor": { + "desc": "Configures how RX SDPs are disabled. If unchecked, an empty string is used", + "label": "Disable Rx SDP with null" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "bool" + } + }, + "com.nevion.NMOS_multidevice.enable_bulk_config": { + "_schema": { + "default": false, + "descriptor": { + "desc": "Configure this unit using bulk API", + "label": "Enable bulk config" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "bool" + } + }, + "com.nevion.NMOS_multidevice.enable_experimental_alarm": { + "_schema": { + "default": false, + "descriptor": { + "desc": "Enables experimental alarms over websockets using IS-07 on certain Vizrt devices. Disables alarms completely if disabled", + "label": "Enable experimental alarms using IS-07" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "bool" + } + }, + "com.nevion.NMOS_multidevice.experimental_alarm_port": { + "_schema": { + "default": 0, + "descriptor": { + "desc": "HTTP port for location of experimental IS-07 alarm websocket. If empty or 0 it uses Port field instead", + "label": "Experimental alarm port" + }, + "isNullable": true, + "options": [], + "ranges": [ + [ + 1, + 65535, + 1 + ] + ], + "status": "Current", + "type": "number", + "units": "" + } + }, + "com.nevion.NMOS_multidevice.indices_in_ids": { + "_schema": { + "default": true, + "descriptor": { + "desc": "Enable if device reports static streams to get sortable ids", + "label": "Use indices in IDs" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "bool" + } + }, + "com.nevion.NMOS_multidevice.is05_api_version": { + "_schema": { + "default": false, + "descriptor": { + "desc": "Configure IS05 API version to use max", + "label": "Enable Max IS05 API version" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "bool" + } + }, + "com.nevion.NMOS_multidevice.port": { + "_schema": { + "default": 80, + "descriptor": { + "desc": "The HTTP port used to reach the Node directly", + "label": "Port" + }, + "isNullable": false, + "options": [], + "ranges": [ + [ + 1, + 65535, + 1 + ] + ], + "status": "Current", + "type": "number", + "units": "" + } + } + } + } + }, + "customSettingsSeed": {}, + "description": "A driver for NMOS capable devices tailored for single devices", + "deviceType": "nmos", + "discoveredBy": null, + "exists": "No", + "iconType": "none", + "ipAddress": null, + "label": "NMOS (Multi-device)", + "modules": [], + "name": "NMOS_multidevice", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.nevion", + "products": [ + { + "name": "NMOS", + "swBuildTime": null, + "swVersion": null + } + ], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.2.1.1.2.0" + ], + "snmpSysObjectIdValue": null, + "status": "", + "supportedApis": [ + "MatrixControlLike", + "DeviceLike", + "PortLike", + "CoreLike", + "StatusLike" + ], + "supportsExternal": true, + "supportsParameterApi": false, + "uniqueMetricDefs": [], + "updateableDevice": false, + "updateableModule": {}, + "version": "0.1.0" + }, + { + "_id": "com.nevion.abb_dpa_upscale_st-0.1.0", + "_vid": "com.nevion.abb_dpa_upscale_st-0.1.0", + "attachments": [ + { + "description": "Default", + "name": "abb_dpa_upscale_st" + } + ], + "configurableDevice": false, + "configurableModule": [], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "customSettingsSeed": {}, + "description": "Driver for ABB DPA UPScale ST UPS system", + "deviceType": "driver", + "discoveredBy": null, + "exists": "No", + "iconType": "device", + "ipAddress": null, + "label": "ABB DPA UPScale ST UPS", + "modules": [ + "System" + ], + "name": "abb_dpa_upscale_st", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.nevion", + "products": [ + { + "name": "DPA UPScale ST 40", + "swBuildTime": null, + "swVersion": null + }, + { + "name": "DPA UPScale ST 80", + "swBuildTime": null, + "swVersion": null + } + ], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.2.1.1.2.0", + "1.3.6.1.2.1.33.1.1.1.0", + "1.3.6.1.2.1.33.1.1.2.0" + ], + "snmpSysObjectIdValue": "1.3.6.1.2.1.33", + "status": "", + "supportedApis": [ + "ParameterControlLike", + "CoreLike", + "DeviceLike", + "StatusLike" + ], + "supportsExternal": true, + "supportsParameterApi": true, + "uniqueMetricDefs": [], + "updateableDevice": false, + "updateableModule": {}, + "version": "0.1.0" + }, + { + "_id": "com.nevion.adva_fsp150-0.1.0", + "_vid": "com.nevion.adva_fsp150-0.1.0", + "attachments": [ + { + "description": "Default", + "name": "adva_fsp150" + } + ], + "configurableDevice": false, + "configurableModule": [], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "customSettingsSeed": {}, + "description": "Driver for ADVA FSP 150", + "deviceType": "switch", + "discoveredBy": null, + "exists": "No", + "iconType": "ipSwitchRouter", + "ipAddress": null, + "label": "ADVA FSP 150", + "modules": [], + "name": "adva_fsp150", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.nevion", + "products": [], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.2.1.1.2.0" + ], + "snmpSysObjectIdValue": "1.3.6.1.4.1.2544.1.12.1.1.11", + "status": "", + "supportedApis": [ + "ParameterControlLike", + "DeviceLike", + "TestLike", + "CoreLike", + "StatusLike" + ], + "supportsExternal": true, + "supportsParameterApi": true, + "uniqueMetricDefs": [], + "updateableDevice": false, + "updateableModule": {}, + "version": "0.1.0" + }, + { + "_id": "com.nevion.adva_fsp150_xg400_series-0.1.0", + "_vid": "com.nevion.adva_fsp150_xg400_series-0.1.0", + "attachments": [ + { + "description": "Default", + "name": "adva_fsp150_xg400_series" + } + ], + "configurableDevice": false, + "configurableModule": [], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "customSettingsSeed": {}, + "description": "Driver for ADVA FSP 150-XG400 Series", + "deviceType": "switch", + "discoveredBy": null, + "exists": "No", + "iconType": "ipSwitchRouter", + "ipAddress": null, + "label": "ADVA FSP 150-XG400", + "modules": [], + "name": "adva_fsp150_xg400_series", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.nevion", + "products": [], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.2.1.1.2.0" + ], + "snmpSysObjectIdValue": "1.3.6.1.4.1.2544.1.20.2.2", + "status": "", + "supportedApis": [ + "ParameterControlLike", + "DeviceLike", + "TestLike", + "CoreLike", + "StatusLike" + ], + "supportsExternal": true, + "supportsParameterApi": true, + "uniqueMetricDefs": [], + "updateableDevice": false, + "updateableModule": {}, + "version": "0.1.0" + }, + { + "_id": "com.nevion.agama_analyzer-0.1.0", + "_vid": "com.nevion.agama_analyzer-0.1.0", + "attachments": [ + { + "description": "Default", + "name": "agama_analyzer" + } + ], + "configurableDevice": false, + "configurableModule": [], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "customSettingsSeed": {}, + "description": "Driver for Agama Analyzer devices", + "deviceType": "driver", + "discoveredBy": null, + "exists": "No", + "iconType": "default", + "ipAddress": null, + "label": "Sky Agama Analyzer", + "modules": [], + "name": "agama_analyzer", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.nevion", + "products": [], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.2.1.1.2.0" + ], + "snmpSysObjectIdValue": null, + "status": "", + "supportedApis": [ + "CoreLike", + "DeviceLike", + "PortLike" + ], + "supportsExternal": true, + "supportsParameterApi": false, + "uniqueMetricDefs": [], + "updateableDevice": false, + "updateableModule": {}, + "version": "0.1.0" + }, + { + "_id": "com.nevion.altum_xavic_decoder-0.1.0", + "_vid": "com.nevion.altum_xavic_decoder-0.1.0", + "attachments": [ + { + "description": "Default", + "name": "altum_xavic_decoder" + } + ], + "configurableDevice": false, + "configurableModule": [], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "customSettingsSeed": {}, + "description": "Driver for Altum XVE Decoder", + "deviceType": "driver", + "discoveredBy": null, + "exists": "No", + "iconType": "decoder", + "ipAddress": null, + "label": "SAPEC Altum II Decoder", + "modules": [], + "name": "altum_xavic_decoder", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.nevion", + "products": [ + { + "name": "AHE 3", + "swBuildTime": null, + "swVersion": null + } + ], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.2.1.1.2.0", + "1.3.6.1.4.1.21664.101.1.1.5.0" + ], + "snmpSysObjectIdValue": "1.3.6.1.4.1.21664.101", + "status": "", + "supportedApis": [ + "ParameterControlLike", + "DeviceLike", + "DynamicLike", + "PortLike", + "CoreLike", + "StatusLike" + ], + "supportsExternal": true, + "supportsParameterApi": true, + "uniqueMetricDefs": [], + "updateableDevice": false, + "updateableModule": {}, + "version": "0.1.0" + }, + { + "_id": "com.nevion.altum_xavic_encoder-0.1.0", + "_vid": "com.nevion.altum_xavic_encoder-0.1.0", + "attachments": [ + { + "description": "Default", + "name": "altum_xavic_encoder" + } + ], + "configurableDevice": false, + "configurableModule": [], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "customSettingsSeed": {}, + "description": "Driver for Altum XVE Encoder", + "deviceType": "driver", + "discoveredBy": null, + "exists": "No", + "iconType": "encoder", + "ipAddress": null, + "label": "SAPEC Altum II Encoder", + "modules": [], + "name": "altum_xavic_encoder", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.nevion", + "products": [ + { + "name": "AHE 3", + "swBuildTime": null, + "swVersion": null + } + ], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.2.1.1.2.0", + "1.3.6.1.4.1.21664.101.1.1.5.0" + ], + "snmpSysObjectIdValue": "1.3.6.1.4.1.21664.101", + "status": "", + "supportedApis": [ + "ParameterControlLike", + "DeviceLike", + "DynamicLike", + "PortLike", + "CoreLike", + "StatusLike" + ], + "supportsExternal": true, + "supportsParameterApi": true, + "uniqueMetricDefs": [], + "updateableDevice": false, + "updateableModule": {}, + "version": "0.1.0" + }, + { + "_id": "com.nevion.amagi_cloudport-0.1.0", + "_vid": "com.nevion.amagi_cloudport-0.1.0", + "attachments": [ + { + "description": "Default", + "name": "amagi_cloudport" + } + ], + "configurableDevice": false, + "configurableModule": [], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "Custom setting fields for Amagi Cloudport", + "label": "Amagi Cloudport" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map", + "values": { + "com.nevion.amagi_cloudport.port": { + "_schema": { + "default": 4999, + "descriptor": { + "desc": "Port", + "label": "Port" + }, + "isNullable": false, + "options": [], + "ranges": [ + [ + 0, + 65535, + 1 + ] + ], + "status": "Current", + "type": "number", + "units": "" + } + } + } + } + }, + "customSettingsSeed": {}, + "description": "Driver for Amagi Cloudport", + "deviceType": "driver", + "discoveredBy": null, + "exists": "No", + "iconType": "none", + "ipAddress": null, + "label": "Amagi Cloudport", + "modules": [], + "name": "amagi_cloudport", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.nevion", + "products": [], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.2.1.1.2.0" + ], + "snmpSysObjectIdValue": null, + "status": "", + "supportedApis": [ + "CoreLike", + "DeviceLike", + "PortLike" + ], + "supportsExternal": true, + "supportsParameterApi": false, + "uniqueMetricDefs": [], + "updateableDevice": false, + "updateableModule": {}, + "version": "0.1.0" + }, + { + "_id": "com.nevion.amethyst3-0.1.0", + "_vid": "com.nevion.amethyst3-0.1.0", + "attachments": [ + { + "description": "Default", + "name": "amethyst3" + } + ], + "configurableDevice": false, + "configurableModule": [], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "customSettingsSeed": {}, + "description": "Redundancy Switch", + "deviceType": "driver", + "discoveredBy": null, + "exists": "No", + "iconType": "ipSwitchRouter", + "ipAddress": null, + "label": "Thomson AMETHYST III", + "modules": [], + "name": "amethyst3", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.nevion", + "products": [ + { + "name": "AMETHYST III", + "swBuildTime": null, + "swVersion": null + } + ], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.2.1.1.2.0" + ], + "snmpSysObjectIdValue": "1.3.6.1.4.1.4947.2.13.11", + "status": "", + "supportedApis": [ + "ParameterControlLike", + "CoreLike", + "DeviceLike", + "StatusLike" + ], + "supportsExternal": true, + "supportsParameterApi": true, + "uniqueMetricDefs": [], + "updateableDevice": false, + "updateableModule": {}, + "version": "0.1.0" + }, + { + "_id": "com.nevion.anubis-0.1.0", + "_vid": "com.nevion.anubis-0.1.0", + "attachments": [ + { + "description": "Default", + "name": "anubis" + } + ], + "configurableDevice": false, + "configurableModule": [], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "customSettingsSeed": {}, + "description": "", + "deviceType": "driver", + "discoveredBy": null, + "exists": "No", + "iconType": "none", + "ipAddress": null, + "label": "Merging Anubis", + "modules": [], + "name": "anubis", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.nevion", + "products": [], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.2.1.1.2.0" + ], + "snmpSysObjectIdValue": null, + "status": "", + "supportedApis": [ + "CoreLike", + "DeviceLike", + "PortLike" + ], + "supportsExternal": true, + "supportsParameterApi": false, + "uniqueMetricDefs": [], + "updateableDevice": false, + "updateableModule": {}, + "version": "0.1.0" + }, + { + "_id": "com.nevion.appeartv_x_platform-0.2.0", + "_vid": "com.nevion.appeartv_x_platform-0.2.0", + "attachments": [ + { + "description": "Default", + "name": "appeartv_x_platform" + } + ], + "configurableDevice": false, + "configurableModule": [], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "Custom setting fields for AppearTV X-Platform devices", + "label": "AppearTV X-Platform" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map", + "values": { + "com.nevion.appeartv_x_platform.coder_ip_mapping": { + "_schema": { + "default": "", + "descriptor": { + "desc": "Coder module - IP module association map", + "label": "Coder-IP mapping" + }, + "encoding": "UTF-8", + "isNullable": false, + "lengthRanges": [], + "options": [], + "status": "Current", + "type": "string" + } + }, + "com.nevion.appeartv_x_platform.lan_wan_mapping": { + "_schema": { + "default": "", + "descriptor": { + "desc": "LAN/WAN module association map", + "label": "LAN-WAN mapping" + }, + "encoding": "UTF-8", + "isNullable": false, + "lengthRanges": [], + "options": [], + "status": "Current", + "type": "string" + } + } + } + } + }, + "customSettingsSeed": {}, + "description": "Driver for AppearTV X-Platform devices with dynamic topology", + "deviceType": "driver", + "discoveredBy": null, + "exists": "No", + "iconType": "none", + "ipAddress": null, + "label": "AppearTV X-Platform (Dynamic)", + "modules": [], + "name": "appeartv_x_platform", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.nevion", + "products": [], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.2.1.1.2.0" + ], + "snmpSysObjectIdValue": null, + "status": "", + "supportedApis": [ + "ParameterControlLike", + "DeviceLike", + "PortLike", + "CoreLike", + "StatusLike" + ], + "supportsExternal": true, + "supportsParameterApi": true, + "uniqueMetricDefs": [], + "updateableDevice": false, + "updateableModule": {}, + "version": "0.2.0" + }, + { + "_id": "com.nevion.appeartv_x_platform_static-0.1.0", + "_vid": "com.nevion.appeartv_x_platform_static-0.1.0", + "attachments": [ + { + "description": "Default", + "name": "appeartv_x_platform_static" + } + ], + "configurableDevice": false, + "configurableModule": [], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "Custom setting fields for AppearTV X-Platform devices", + "label": "AppearTV X-Platform (Static)" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map", + "values": { + "com.nevion.appeartv_x_platform_static.implicit_interface_selection": { + "_schema": { + "default": false, + "descriptor": { + "desc": "Select vlan subinterfaces based on vlan in port configuration.", + "label": "Implicit Interface Selection" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "bool" + } + } + } + } + }, + "customSettingsSeed": {}, + "description": "Driver for AppearTV X-Platform devices with static topology", + "deviceType": "driver", + "discoveredBy": null, + "exists": "No", + "iconType": "none", + "ipAddress": null, + "label": "AppearTV X-Platform (Static)", + "modules": [], + "name": "appeartv_x_platform_static", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.nevion", + "products": [], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.2.1.1.2.0" + ], + "snmpSysObjectIdValue": null, + "status": "", + "supportedApis": [ + "ParameterControlLike", + "DeviceLike", + "PortLike", + "CoreLike", + "StatusLike" + ], + "supportsExternal": true, + "supportsParameterApi": true, + "uniqueMetricDefs": [], + "updateableDevice": false, + "updateableModule": {}, + "version": "0.1.0" + }, + { + "_id": "com.nevion.archwave_unet-0.1.0", + "_vid": "com.nevion.archwave_unet-0.1.0", + "attachments": [ + { + "description": "Default", + "name": "archwave_unet" + } + ], + "configurableDevice": false, + "configurableModule": [], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "Custom settings field for ArchwaveUnet drivers", + "label": "ArchwaveUnet" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map", + "values": { + "com.nevion.archwave_unet.channel_mode": { + "_schema": { + "default": "Stereo", + "descriptor": { + "desc": "In Stereo mode the driver will only report one stream consumer (output) to the topology. The driver will automatically configure the second stream consumer based on the received SDP to the former consumer stream\nIn Dual Mono mode both stream consumers will be reported to the topology and handled as individual streams", + "label": "Stream consumer channel mode" + }, + "encoding": "UTF-8", + "gui": { + "tags": [], + "widget": "Dropdown" + }, + "isNullable": false, + "lengthRanges": [], + "options": [ + { + "descriptor": { + "desc": "", + "label": "Dual Mono" + }, + "value": "Dual Mono" + }, + { + "descriptor": { + "desc": "", + "label": "Stereo" + }, + "value": "Stereo" + } + ], + "status": "Current", + "type": "string" + } + } + } + } + }, + "customSettingsSeed": {}, + "description": "Driver for Archwave AudioLan/uNet modules", + "deviceType": "driver", + "discoveredBy": null, + "exists": "No", + "iconType": "gateway", + "ipAddress": null, + "label": "Archwave AudioLan/uNet", + "modules": [], + "name": "archwave_unet", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.nevion", + "products": [], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.2.1.1.2.0" + ], + "snmpSysObjectIdValue": null, + "status": "", + "supportedApis": [ + "CoreLike", + "DeviceLike", + "PortLike" + ], + "supportsExternal": true, + "supportsParameterApi": false, + "uniqueMetricDefs": [], + "updateableDevice": false, + "updateableModule": {}, + "version": "0.1.0" + }, + { + "_id": "com.nevion.arista-0.1.0", + "_vid": "com.nevion.arista-0.1.0", + "attachments": [ + { + "description": "Global ACL rules for Arista, used unless specific is specified.", + "name": "arista_static_acl_global" + }, + { + "description": "Specific ACL rules for Arista, overrides global if defined.", + "name": "arista_static_acl_specific" + } + ], + "configurableDevice": false, + "configurableModule": [], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "Custom setting fields for Arista", + "label": "Arista" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map", + "values": { + "com.nevion.arista.enable_cache": { + "_schema": { + "default": true, + "descriptor": { + "desc": "", + "label": "Enable config related cache" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "bool" + } + }, + "com.nevion.arista.multicast_route_ignore": { + "_schema": { + "default": "", + "descriptor": { + "desc": "", + "label": "Multicast routes ignore list, comma separated" + }, + "encoding": "UTF-8", + "isNullable": false, + "lengthRanges": [], + "options": [], + "status": "Current", + "type": "string" + } + }, + "com.nevion.arista.use_multi_vrf": { + "_schema": { + "default": false, + "descriptor": { + "desc": "", + "label": "Enable multi-VRF functionality" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "bool" + } + }, + "com.nevion.arista.use_tls": { + "_schema": { + "default": true, + "descriptor": { + "desc": "", + "label": "Use TLS (no certificate checks)" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "bool" + } + }, + "com.nevion.arista.use_twice_nat": { + "_schema": { + "default": false, + "descriptor": { + "desc": "", + "label": "Enable twice NAT functionality" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "bool" + } + } + } + } + }, + "customSettingsSeed": {}, + "description": "Driver for Arista Switch series", + "deviceType": "switch", + "discoveredBy": null, + "exists": "No", + "iconType": "ipSwitchRouter", + "ipAddress": null, + "label": "Arista Switch Series", + "modules": [], + "name": "arista", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.nevion", + "products": [], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.2.1.1.2.0", + "1.3.6.1.2.1.1.1.0" + ], + "snmpSysObjectIdValue": null, + "status": "", + "supportedApis": [ + "CoreLike", + "DeviceLike", + "StatusLike", + "PortLike" + ], + "supportsExternal": true, + "supportsParameterApi": false, + "uniqueMetricDefs": [], + "updateableDevice": false, + "updateableModule": {}, + "version": "0.1.0" + }, + { + "_id": "com.nevion.ateme_cm4101-0.1.0", + "_vid": "com.nevion.ateme_cm4101-0.1.0", + "attachments": [ + { + "description": "Encoder configuration", + "name": "ateme_cm4101" + } + ], + "configurableDevice": false, + "configurableModule": [], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "customSettingsSeed": {}, + "description": "A driver for the Ateme CM4101 encoder", + "deviceType": "driver", + "discoveredBy": null, + "exists": "No", + "iconType": "encoder", + "ipAddress": null, + "label": "Ateme CM4101", + "modules": [ + "System", + "Encoder 1-N" + ], + "name": "ateme_cm4101", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.nevion", + "products": [ + { + "name": "Ateme CM4101", + "swBuildTime": null, + "swVersion": null + } + ], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.2.1.1.1.0" + ], + "snmpSysObjectIdValue": null, + "status": "", + "supportedApis": [ + "ParameterControlLike", + "DeviceLike", + "DynamicLike", + "PortLike", + "CoreLike", + "StatusLike" + ], + "supportsExternal": true, + "supportsParameterApi": true, + "uniqueMetricDefs": [], + "updateableDevice": false, + "updateableModule": {}, + "version": "0.1.0" + }, + { + "_id": "com.nevion.ateme_cm5000-0.1.0", + "_vid": "com.nevion.ateme_cm5000-0.1.0", + "attachments": [ + { + "description": "Encoder configuration", + "name": "ateme_cm5000.enc" + }, + { + "description": "Ethernet configuration", + "name": "ateme_cm5000.ip" + } + ], + "configurableDevice": false, + "configurableModule": [], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "customSettingsSeed": {}, + "description": "A driver for the Ateme Kyrion CM5000 encoder", + "deviceType": "driver", + "discoveredBy": null, + "exists": "No", + "iconType": "encoder", + "ipAddress": null, + "label": "Ateme Kyrion CM5000", + "modules": [ + "System", + "Encoder 1-N" + ], + "name": "ateme_cm5000", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.nevion", + "products": [ + { + "name": "Ateme Kyrion CM5000", + "swBuildTime": null, + "swVersion": null + } + ], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.4.1.27338.4.2.2.0" + ], + "snmpSysObjectIdValue": null, + "status": "", + "supportedApis": [ + "ParameterControlLike", + "DeviceLike", + "DynamicLike", + "PortLike", + "CoreLike", + "StatusLike" + ], + "supportsExternal": true, + "supportsParameterApi": true, + "uniqueMetricDefs": [], + "updateableDevice": false, + "updateableModule": {}, + "version": "0.1.0" + }, + { + "_id": "com.nevion.ateme_dr5000-0.1.0", + "_vid": "com.nevion.ateme_dr5000-0.1.0", + "attachments": [ + { + "description": "Decoder configuration", + "name": "ateme_dr5000.dec" + }, + { + "description": "Ethernet configuration", + "name": "ateme_dr5000.ip" + } + ], + "configurableDevice": false, + "configurableModule": [], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "customSettingsSeed": {}, + "description": "A driver for the Ateme DR5000 decoder", + "deviceType": "driver", + "discoveredBy": null, + "exists": "No", + "iconType": "decoder", + "ipAddress": null, + "label": "Ateme DR5000", + "modules": [ + "System", + "Decoder 1-N" + ], + "name": "ateme_dr5000", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.nevion", + "products": [ + { + "name": "DR5000", + "swBuildTime": null, + "swVersion": null + } + ], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.4.1.27338.5.2.2.0" + ], + "snmpSysObjectIdValue": null, + "status": "", + "supportedApis": [ + "ParameterControlLike", + "DeviceLike", + "DynamicLike", + "NetworkServiceLike", + "PortLike", + "CoreLike", + "StatusLike" + ], + "supportsExternal": true, + "supportsParameterApi": true, + "uniqueMetricDefs": [], + "updateableDevice": false, + "updateableModule": {}, + "version": "0.1.0" + }, + { + "_id": "com.nevion.ateme_dr8400-0.1.0", + "_vid": "com.nevion.ateme_dr8400-0.1.0", + "attachments": [ + { + "description": "Decoder configuration", + "name": "ateme_dr8400" + } + ], + "configurableDevice": false, + "configurableModule": [], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "customSettingsSeed": {}, + "description": "A driver for the Ateme DR8400 decoder", + "deviceType": "driver", + "discoveredBy": null, + "exists": "No", + "iconType": "decoder", + "ipAddress": null, + "label": "Ateme DR8400", + "modules": [ + "System", + "Decoder 1-N" + ], + "name": "ateme_dr8400", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.nevion", + "products": [ + { + "name": "Ateme DR8400", + "swBuildTime": null, + "swVersion": null + } + ], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.2.1.1.1.0" + ], + "snmpSysObjectIdValue": null, + "status": "", + "supportedApis": [ + "ParameterControlLike", + "DeviceLike", + "DynamicLike", + "NetworkServiceLike", + "PortLike", + "CoreLike", + "StatusLike" + ], + "supportsExternal": true, + "supportsParameterApi": true, + "uniqueMetricDefs": [], + "updateableDevice": false, + "updateableModule": {}, + "version": "0.1.0" + }, + { + "_id": "com.nevion.avnpxh12-0.1.0", + "_vid": "com.nevion.avnpxh12-0.1.0", + "attachments": [ + { + "description": "Default", + "name": "avnpxh12" + } + ], + "configurableDevice": false, + "configurableModule": [], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "Custom setting fields for avnpxh12", + "label": "avnpxh12" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map", + "values": { + "com.nevion.emberplus.keepalives": { + "_schema": { + "default": true, + "descriptor": { + "desc": "If selected, keep-alives will be used to determine reachability", + "label": "Send keep-alives" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "bool" + } + }, + "com.nevion.emberplus.port": { + "_schema": { + "default": 9000, + "descriptor": { + "desc": "Port", + "label": "Port" + }, + "isNullable": false, + "options": [], + "ranges": [ + [ + 0, + 65535, + 1 + ] + ], + "status": "Current", + "type": "number", + "units": "" + } + }, + "com.nevion.emberplus.queue": { + "_schema": { + "default": true, + "descriptor": { + "desc": "", + "label": "Request queueing" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "bool" + } + }, + "com.nevion.emberplus.suppress_illegal": { + "_schema": { + "default": false, + "descriptor": { + "desc": "", + "label": "Suppress illegal update warnings" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "bool" + } + }, + "com.nevion.emberplus.trace": { + "_schema": { + "default": false, + "descriptor": { + "desc": "", + "label": "Tracing (logging intensive)" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "bool" + } + } + } + } + }, + "customSettingsSeed": {}, + "description": "", + "deviceType": "driver", + "discoveredBy": null, + "exists": "No", + "iconType": "none", + "ipAddress": null, + "label": "Sonifex AVNPXH12", + "modules": [], + "name": "avnpxh12", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.nevion", + "products": [], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.2.1.1.2.0" + ], + "snmpSysObjectIdValue": null, + "status": "", + "supportedApis": [ + "ParameterControlLike", + "CoreLike", + "DeviceLike", + "PortLike" + ], + "supportsExternal": true, + "supportsParameterApi": true, + "uniqueMetricDefs": [], + "updateableDevice": false, + "updateableModule": {}, + "version": "0.1.0" + }, + { + "_id": "com.nevion.aws_media-0.1.0", + "_vid": "com.nevion.aws_media-0.1.0", + "attachments": [ + { + "description": "Default", + "name": "aws_media" + } + ], + "configurableDevice": false, + "configurableModule": [], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "Custom setting fields for aws_media", + "label": "aws_media" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map", + "values": { + "com.nevion.aws_media.n_flows": { + "_schema": { + "default": 10, + "descriptor": { + "desc": "Number of MediaConnect flows", + "label": "Max #Flows" + }, + "isNullable": false, + "options": [], + "ranges": [ + [ + 0, + 1000, + 1 + ] + ], + "status": "Current", + "type": "number", + "units": "" + } + }, + "com.nevion.aws_media.n_outputs_per_fow": { + "_schema": { + "default": 2, + "descriptor": { + "desc": "Number of outputs per MediaConnect flow", + "label": "Max #Outputs/Flow" + }, + "isNullable": false, + "options": [], + "ranges": [ + [ + 0, + 50, + 1 + ] + ], + "status": "Current", + "type": "number", + "units": "" + } + } + } + } + }, + "customSettingsSeed": {}, + "description": "Generic driver for aws services", + "deviceType": "driver", + "discoveredBy": null, + "exists": "No", + "iconType": "vlanCloud", + "ipAddress": null, + "label": "AWS Media", + "modules": [], + "name": "aws_media", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.nevion", + "products": [], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.2.1.1.2.0" + ], + "snmpSysObjectIdValue": null, + "status": "", + "supportedApis": [ + "MatrixControlLike", + "CoreLike", + "DeviceLike", + "PortLike" + ], + "supportsExternal": true, + "supportsParameterApi": false, + "uniqueMetricDefs": [], + "updateableDevice": false, + "updateableModule": {}, + "version": "0.1.0" + }, + { + "_id": "com.nevion.blade_runner-0.1.0", + "_vid": "com.nevion.blade_runner-0.1.0", + "attachments": [ + { + "description": "Default", + "name": "blade_runner" + } + ], + "configurableDevice": false, + "configurableModule": [], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "Custom driver settings for Arkona Blade Runner", + "label": "Arkona Blade Runner" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map", + "values": { + "com.nevion.blade_runner.matrix": { + "_schema": { + "default": "", + "descriptor": { + "desc": "Example: {\n \"audioShuffler\": {\n \"0\": {\n \"label\": \"Shuffler 1\",\n \"maxOutputChannels\": 16,\n \"rtpInputs\": {\n \"0\": [\n 1,\n 2\n ]\n }\n }\n }\n}", + "label": "AudioShuffler Matrix JSON" + }, + "encoding": "UTF-8", + "isNullable": false, + "lengthRanges": [], + "options": [], + "status": "Current", + "type": "string" + } + }, + "com.nevion.blade_runner.rtp_receivers": { + "_schema": { + "default": "", + "descriptor": { + "desc": "Example:{\n \"rtpInput\": {\n \"0\": {\n \"label\": \"Input 1\",\n \"maxChannel\": 16,\n \"channelLabel\": [\n \"ex1\",\n \"ex2\"\n ]\n }\n }\n}", + "label": "RTP Receivers JSON" + }, + "encoding": "UTF-8", + "isNullable": false, + "lengthRanges": [], + "options": [], + "status": "Current", + "type": "string" + } + } + } + } + }, + "customSettingsSeed": {}, + "description": "Software-defined IP-Routing, Processing & Multi-Viewing Platform", + "deviceType": "driver", + "discoveredBy": null, + "exists": "No", + "iconType": "none", + "ipAddress": null, + "label": "Arkona Blade Runner", + "modules": [], + "name": "blade_runner", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.nevion", + "products": [], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.2.1.1.2.0" + ], + "snmpSysObjectIdValue": null, + "status": "", + "supportedApis": [ + "DeviceLike", + "PortLike", + "CoreLike", + "StatusLike", + "MatrixControlLike", + "ParameterControlLike" + ], + "supportsExternal": true, + "supportsParameterApi": true, + "uniqueMetricDefs": [], + "updateableDevice": false, + "updateableModule": {}, + "version": "0.1.0" + }, + { + "_id": "com.nevion.cisco_7600_series-0.1.0", + "_vid": "com.nevion.cisco_7600_series-0.1.0", + "attachments": [ + { + "description": "Default", + "name": "cisco_7600_series" + } + ], + "configurableDevice": false, + "configurableModule": [], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "customSettingsSeed": {}, + "description": "Driver for Cisco 7600 series routers", + "deviceType": "switch", + "discoveredBy": null, + "exists": "No", + "iconType": "none", + "ipAddress": null, + "label": "Cisco 7600 Series", + "modules": [], + "name": "cisco_7600_series", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.nevion", + "products": [], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.2.1.1.2.0" + ], + "snmpSysObjectIdValue": null, + "status": "", + "supportedApis": [ + "ParameterControlLike", + "CoreLike", + "DeviceLike", + "StatusLike" + ], + "supportsExternal": true, + "supportsParameterApi": true, + "uniqueMetricDefs": [], + "updateableDevice": false, + "updateableModule": {}, + "version": "0.1.0" + }, + { + "_id": "com.nevion.cisco_asr-0.1.0", + "_vid": "com.nevion.cisco_asr-0.1.0", + "attachments": [ + { + "description": "Default", + "name": "cisco_asr" + } + ], + "configurableDevice": false, + "configurableModule": [], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "customSettingsSeed": {}, + "description": "Driver for Cisco ASR (Aggregation Services Routers) 9904/9901/920/1002 Routers", + "deviceType": "switch", + "discoveredBy": null, + "exists": "No", + "iconType": "none", + "ipAddress": null, + "label": "Cisco ASR", + "modules": [], + "name": "cisco_asr", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.nevion", + "products": [], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.2.1.1.2.0" + ], + "snmpSysObjectIdValue": null, + "status": "", + "supportedApis": [ + "ParameterControlLike", + "CoreLike", + "DeviceLike", + "StatusLike" + ], + "supportsExternal": true, + "supportsParameterApi": true, + "uniqueMetricDefs": [], + "updateableDevice": false, + "updateableModule": {}, + "version": "0.1.0" + }, + { + "_id": "com.nevion.cisco_catalyst_3850-0.1.0", + "_vid": "com.nevion.cisco_catalyst_3850-0.1.0", + "attachments": [ + { + "description": "Default", + "name": "cisco_catalyst_3850" + } + ], + "configurableDevice": false, + "configurableModule": [], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "Custom setting fields for Catalyst 3850", + "label": "Catalyst 3850" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map", + "values": { + "com.nevion.api.sample_flows_interval": { + "_schema": { + "default": 0, + "descriptor": { + "desc": "Interval at which to poll flow stats. 0 to disable.", + "label": "Flow stats interval [s]" + }, + "isNullable": false, + "options": [], + "ranges": [ + [ + 0, + 0, + 1 + ], + [ + 2, + 3600, + 1 + ] + ], + "status": "Current", + "type": "number", + "units": "" + } + } + } + } + }, + "customSettingsSeed": {}, + "description": "Driver for Cisco Catalyst 3850 devices", + "deviceType": "switch", + "discoveredBy": null, + "exists": "No", + "iconType": "ipSwitchRouter", + "ipAddress": null, + "label": "Cisco Catalyst 3850", + "modules": [], + "name": "cisco_catalyst_3850", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.nevion", + "products": [], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "netconf": 830, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.2.1.1.2.0" + ], + "snmpSysObjectIdValue": null, + "status": "", + "supportedApis": [ + "ParameterControlLike", + "DeviceLike", + "PortLike", + "CoreLike", + "StatusLike" + ], + "supportsExternal": true, + "supportsParameterApi": true, + "uniqueMetricDefs": [], + "updateableDevice": false, + "updateableModule": {}, + "version": "0.1.0" + }, + { + "_id": "com.nevion.cisco_me-0.1.0", + "_vid": "com.nevion.cisco_me-0.1.0", + "attachments": [ + { + "description": "Default", + "name": "cisco_me" + } + ], + "configurableDevice": false, + "configurableModule": [], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "customSettingsSeed": {}, + "description": "Driver for Cisco ME3800X/ME3600X Ethernet Switches", + "deviceType": "switch", + "discoveredBy": null, + "exists": "No", + "iconType": "none", + "ipAddress": null, + "label": "Cisco ME", + "modules": [], + "name": "cisco_me", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.nevion", + "products": [], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.2.1.1.2.0" + ], + "snmpSysObjectIdValue": null, + "status": "", + "supportedApis": [ + "ParameterControlLike", + "CoreLike", + "DeviceLike", + "StatusLike" + ], + "supportsExternal": true, + "supportsParameterApi": true, + "uniqueMetricDefs": [], + "updateableDevice": false, + "updateableModule": {}, + "version": "0.1.0" + }, + { + "_id": "com.nevion.cisco_ncs540-0.1.0", + "_vid": "com.nevion.cisco_ncs540-0.1.0", + "attachments": [ + { + "description": "Default", + "name": "cisco_ncs540" + } + ], + "configurableDevice": false, + "configurableModule": [], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "customSettingsSeed": {}, + "description": "Driver for a Cisco NCS540 SNMP device", + "deviceType": "driver", + "discoveredBy": null, + "exists": "No", + "iconType": "none", + "ipAddress": null, + "label": "Cisco NCS540 SNMP driver", + "modules": [], + "name": "cisco_ncs540", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.nevion", + "products": [], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.2.1.1.2.0" + ], + "snmpSysObjectIdValue": null, + "status": "", + "supportedApis": [ + "ParameterControlLike", + "CoreLike", + "DeviceLike", + "StatusLike" + ], + "supportsExternal": true, + "supportsParameterApi": true, + "uniqueMetricDefs": [], + "updateableDevice": false, + "updateableModule": {}, + "version": "0.1.0" + }, + { + "_id": "com.nevion.cisco_nexus-0.1.0", + "_vid": "com.nevion.cisco_nexus-0.1.0", + "attachments": [ + { + "description": "Default", + "name": "cisco_nexus" + } + ], + "configurableDevice": false, + "configurableModule": [], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "Custom setting fields for Nexus", + "label": "Nexus" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map", + "values": { + "com.nevion.nexus.controlled_vrfs": { + "_schema": { + "default": "", + "descriptor": { + "desc": "Comma-separated lists of VRFs to control. Empty list = all VRFs.", + "label": "Controlled VRFs" + }, + "encoding": "UTF-8", + "isNullable": false, + "lengthRanges": [], + "options": [], + "status": "Current", + "type": "string" + } + }, + "com.nevion.nexus.full_vrf_control": { + "_schema": { + "default": false, + "descriptor": { + "desc": "True = configure RPF for all/specified VRFs. False = only configure RPF for known source IP adresses.", + "label": "Full VRF Control" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "bool" + } + }, + "com.nevion.nexus.layer2_netmask_mode": { + "_schema": { + "default": false, + "descriptor": { + "desc": "Use /31 mroute source address netmask for layer 2 mroutes, i.e. when source address and next-hop are identical.", + "label": "Use /31 mroute netmask for layer 2" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "bool" + } + }, + "com.nevion.nexus.periodic_netconf_restart": { + "_schema": { + "default": 0, + "descriptor": { + "desc": "Interval in seconds for periodic netconf connection restart. If 0, no restart is performed.", + "label": "Restart netconf every (s)" + }, + "isNullable": false, + "options": [], + "ranges": [ + [ + 0, + 2147483647, + 1 + ] + ], + "status": "Current", + "type": "number", + "units": "" + } + } + } + } + }, + "customSettingsSeed": {}, + "description": "Driver for Cisco Nexus devices", + "deviceType": "switch", + "discoveredBy": null, + "exists": "No", + "iconType": "ipSwitchRouter", + "ipAddress": null, + "label": "Cisco Nexus (NX-OS)", + "modules": [], + "name": "cisco_nexus", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.nevion", + "products": [], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.2.1.1.2.0" + ], + "snmpSysObjectIdValue": null, + "status": "", + "supportedApis": [ + "ParameterControlLike", + "DeviceLike", + "DynamicLike", + "PortLike", + "CoreLike", + "StatusLike" + ], + "supportsExternal": true, + "supportsParameterApi": true, + "uniqueMetricDefs": [], + "updateableDevice": false, + "updateableModule": {}, + "version": "0.1.0" + }, + { + "_id": "com.nevion.cisco_nexus_nbm-0.1.0", + "_vid": "com.nevion.cisco_nexus_nbm-0.1.0", + "attachments": [ + { + "description": "Default", + "name": "cisco_nexus_nbm" + } + ], + "configurableDevice": false, + "configurableModule": [], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "Custom setting fields for Cisco Nexus NBM", + "label": "Cisco Nexus NBM" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map", + "values": { + "com.nevion.api.sample_flows_interval": { + "_schema": { + "default": 0, + "descriptor": { + "desc": "Interval at which to poll flow stats. 0 to disable.", + "label": "Flow stats interval [s]" + }, + "isNullable": false, + "options": [], + "ranges": [ + [ + 0, + 0, + 1 + ], + [ + 2, + 3600, + 1 + ] + ], + "status": "Current", + "type": "number", + "units": "" + } + }, + "com.nevion.cisco_nexus_nbm.use_nat": { + "_schema": { + "default": false, + "descriptor": { + "desc": "", + "label": "Enable NAT functionality" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "bool" + } + } + } + } + }, + "customSettingsSeed": {}, + "description": "Driver for Cisco Nexus devices with non-blocking multicast (NBM) process", + "deviceType": "switch", + "discoveredBy": null, + "exists": "No", + "iconType": "none", + "ipAddress": null, + "label": "Cisco Nexus (NBM)", + "modules": [], + "name": "cisco_nexus_nbm", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.nevion", + "products": [], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.2.1.1.2.0" + ], + "snmpSysObjectIdValue": null, + "status": "", + "supportedApis": [ + "ParameterControlLike", + "DeviceLike", + "PortLike", + "CoreLike", + "StatusLike" + ], + "supportsExternal": true, + "supportsParameterApi": true, + "uniqueMetricDefs": [], + "updateableDevice": false, + "updateableModule": {}, + "version": "0.1.0" + }, + { + "_id": "com.nevion.comprimato-0.1.0", + "_vid": "com.nevion.comprimato-0.1.0", + "attachments": [ + { + "description": "Default", + "name": "comprimato" + } + ], + "configurableDevice": false, + "configurableModule": [], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "customSettingsSeed": {}, + "description": "A driver for Comprimato", + "deviceType": "driver", + "discoveredBy": null, + "exists": "No", + "iconType": "none", + "ipAddress": null, + "label": "Comprimato Driver", + "modules": [], + "name": "comprimato", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.nevion", + "products": [], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.2.1.1.2.0" + ], + "snmpSysObjectIdValue": null, + "status": "", + "supportedApis": [ + "CoreLike", + "DeviceLike", + "StatusLike", + "PortLike" + ], + "supportsExternal": true, + "supportsParameterApi": false, + "uniqueMetricDefs": [], + "updateableDevice": false, + "updateableModule": {}, + "version": "0.1.0" + }, + { + "_id": "com.nevion.cp330-0.1.0", + "_vid": "com.nevion.cp330-0.1.0", + "attachments": [ + { + "description": "Default", + "name": "cp330" + } + ], + "configurableDevice": true, + "configurableModule": [], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "customSettingsSeed": {}, + "description": "Driver for the Nevion CP330 T2-Bridge", + "deviceType": "driver", + "discoveredBy": null, + "exists": "No", + "iconType": "transportStreamProcessor", + "ipAddress": null, + "label": "Nevion CP330", + "modules": [], + "name": "cp330", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.nevion", + "products": [ + { + "name": "CP330", + "swBuildTime": null, + "swVersion": null + } + ], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.2.1.1.2.0" + ], + "snmpSysObjectIdValue": "1.3.6.1.4.1.22909.3.28", + "status": "", + "supportedApis": [ + "ParameterControlLike", + "DeviceLike", + "DynamicLike", + "MaintenanceLike", + "CoreLike", + "StatusLike" + ], + "supportsExternal": true, + "supportsParameterApi": true, + "uniqueMetricDefs": [], + "updateableDevice": true, + "updateableModule": {}, + "version": "0.1.0" + }, + { + "_id": "com.nevion.cp4400-0.1.0", + "_vid": "com.nevion.cp4400-0.1.0", + "attachments": [ + { + "description": "Default", + "name": "cp4400" + } + ], + "configurableDevice": true, + "configurableModule": [], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "Custom setting fields for Platform4000", + "label": "Platform4000" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map", + "values": { + "com.nevion.null.reuse_ts_element": { + "_schema": { + "default": false, + "descriptor": { + "desc": "", + "label": "Enable to activate logic to join existing TS input element for ASI outputs when setting up multicast with identical settings" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "bool" + } + } + } + } + }, + "customSettingsSeed": {}, + "description": "Driver for the Nevion CP4400", + "deviceType": "driver", + "discoveredBy": null, + "exists": "No", + "iconType": "transportStreamProcessor", + "ipAddress": null, + "label": "Nevion CP4400", + "modules": [], + "name": "cp4400", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.nevion", + "products": [ + { + "name": "CP4400", + "swBuildTime": null, + "swVersion": null + } + ], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.2.1.1.2.0" + ], + "snmpSysObjectIdValue": "1.3.6.1.4.1.8768.10.38", + "status": "", + "supportedApis": [ + "ParameterControlLike", + "DeviceLike", + "DynamicLike", + "PortLike", + "CoreLike", + "StatusLike", + "MaintenanceLike", + "NetworkServiceLike" + ], + "supportsExternal": true, + "supportsParameterApi": true, + "uniqueMetricDefs": [ + { + "descriptor": { + "desc": "Last bitrate measured.", + "label": "Ts Pid Bitrate" + }, + "id": "ts.pid.bit.rate", + "isAlarmTrigger": false, + "isCounter": false, + "unit": "" + }, + { + "descriptor": { + "desc": "Last average bitrate measured.", + "label": "Ts Pid Average Bitrate" + }, + "id": "ts.pid.average.bit.rate", + "isAlarmTrigger": false, + "isCounter": false, + "unit": "" + }, + { + "descriptor": { + "desc": "Continuity error counter.", + "label": "Ts Pid Continuity Error Counter" + }, + "id": "ts.pid.continuity.error.counter", + "isAlarmTrigger": false, + "isCounter": false, + "unit": "" + }, + { + "descriptor": { + "desc": "Last bitrate sampled for program.", + "label": "Ts Service Bitrate" + }, + "id": "ts.service.bit.rate", + "isAlarmTrigger": false, + "isCounter": false, + "unit": "" + }, + { + "descriptor": { + "desc": "Effective bitrate, i.e., bitrate without null packets.", + "label": "Ts Effective Bitrate" + }, + "id": "ts.effective.bit.rate", + "isAlarmTrigger": false, + "isCounter": false, + "unit": "" + }, + { + "descriptor": { + "desc": "Total bitrate.", + "label": "Ts Total Bitrate" + }, + "id": "ts.total.bit.rate", + "isAlarmTrigger": false, + "isCounter": false, + "unit": "" + }, + { + "descriptor": { + "desc": "Inter arrival time, i.e., the time between arrivals into the system.", + "label": "Tsoip Rx Sips Iat" + }, + "id": "tsoip.rx.sips.iat", + "isAlarmTrigger": false, + "isCounter": false, + "unit": "" + }, + { + "descriptor": { + "desc": "Packet delay factor.", + "label": "Tsoip Rx Sips Pdv" + }, + "id": "tsoip.rx.sips.pdv", + "isAlarmTrigger": false, + "isCounter": false, + "unit": "" + }, + { + "descriptor": { + "desc": "Current buffer.", + "label": "Tsoip Rx Buff Lat" + }, + "id": "tsoip.rx.buff.lat", + "isAlarmTrigger": false, + "isCounter": false, + "unit": "" + }, + { + "descriptor": { + "desc": "Current utilization fraction of buffer, with the signal currently received and configured parameters.", + "label": "Tsoip Rx Buff Util" + }, + "id": "tsoip.rx.buff.util", + "isAlarmTrigger": false, + "isCounter": false, + "unit": "" + }, + { + "descriptor": { + "desc": "SFP Channel internal bias current", + "label": "Sfp Internal Current" + }, + "id": "sfp.internal.current", + "isAlarmTrigger": false, + "isCounter": false, + "unit": "mA" + }, + { + "descriptor": { + "desc": "SFP Channel Internal Vcc", + "label": "Sfp Internal Voltage" + }, + "id": "sfp.internal.voltage", + "isAlarmTrigger": false, + "isCounter": false, + "unit": "V" + }, + { + "descriptor": { + "desc": "SFP Channel internal temperature", + "label": "Sfp Internal Temp" + }, + "id": "sfp.internal.temp", + "isAlarmTrigger": false, + "isCounter": false, + "unit": "C" + }, + { + "descriptor": { + "desc": "SFP Channel RX input power", + "label": "Sfp Rx Effect" + }, + "id": "sfp.rx.effect", + "isAlarmTrigger": false, + "isCounter": false, + "unit": "mW" + }, + { + "descriptor": { + "desc": "SFP Channel RX input power", + "label": "Sfp Rx Effect Log" + }, + "id": "sfp.rx.effect.log", + "isAlarmTrigger": false, + "isCounter": false, + "unit": "dBm" + }, + { + "descriptor": { + "desc": "SFP Channel TX output power", + "label": "Sfp Tx Effect" + }, + "id": "sfp.tx.effect", + "isAlarmTrigger": false, + "isCounter": false, + "unit": "mW" + }, + { + "descriptor": { + "desc": "SFP Channel TX output power", + "label": "Sfp Tx Effect Log" + }, + "id": "sfp.tx.effect.log", + "isAlarmTrigger": false, + "isCounter": false, + "unit": "dBm" + } + ], + "updateableDevice": false, + "updateableModule": { + "ASI-Input/Output-Board": { + "availableBanks": [], + "rebootOption": 0 + }, + "DVB-T/T2-Demodulator": { + "availableBanks": [], + "rebootOption": 0 + }, + "GNSS-clock-reference-board": { + "availableBanks": [], + "rebootOption": 0 + }, + "Main-Board": { + "availableBanks": [], + "rebootOption": 0 + } + }, + "version": "0.1.0" + }, + { + "_id": "com.nevion.cp505-0.1.0", + "_vid": "com.nevion.cp505-0.1.0", + "attachments": [ + { + "description": "Default", + "name": "cp505" + } + ], + "configurableDevice": true, + "configurableModule": [], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "customSettingsSeed": {}, + "description": "Driver for the Nevion CP505 ATSC Processor", + "deviceType": "driver", + "discoveredBy": null, + "exists": "No", + "iconType": "transportStreamProcessor", + "ipAddress": null, + "label": "Nevion CP505", + "modules": [ + "System", + "Network", + "ASI Inputs", + "ASI Outputs", + "Switch Inputs", + "TS Out", + "IP Inputs" + ], + "name": "cp505", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.nevion", + "products": [ + { + "name": "CP505", + "swBuildTime": null, + "swVersion": null + } + ], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.2.1.1.2.0" + ], + "snmpSysObjectIdValue": "1.3.6.1.4.1.22909.3.20", + "status": "", + "supportedApis": [ + "ParameterControlLike", + "DeviceLike", + "DynamicLike", + "MaintenanceLike", + "CoreLike", + "StatusLike" + ], + "supportsExternal": true, + "supportsParameterApi": true, + "uniqueMetricDefs": [], + "updateableDevice": true, + "updateableModule": {}, + "version": "0.1.0" + }, + { + "_id": "com.nevion.cp511-0.1.0", + "_vid": "com.nevion.cp511-0.1.0", + "attachments": [ + { + "description": "Default", + "name": "cp511" + } + ], + "configurableDevice": true, + "configurableModule": [], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "customSettingsSeed": {}, + "description": "Driver for the Nevion CP511 SFN Adapter", + "deviceType": "driver", + "discoveredBy": null, + "exists": "No", + "iconType": "transportStreamProcessor", + "ipAddress": null, + "label": "Nevion CP511", + "modules": [], + "name": "cp511", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.nevion", + "products": [ + { + "name": "CP511", + "swBuildTime": null, + "swVersion": null + } + ], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.2.1.1.2.0" + ], + "snmpSysObjectIdValue": "1.3.6.1.4.1.22909.3.15", + "status": "", + "supportedApis": [ + "ParameterControlLike", + "DeviceLike", + "DynamicLike", + "MaintenanceLike", + "CoreLike", + "StatusLike" + ], + "supportsExternal": true, + "supportsParameterApi": true, + "uniqueMetricDefs": [], + "updateableDevice": true, + "updateableModule": {}, + "version": "0.1.0" + }, + { + "_id": "com.nevion.cp515-0.1.0", + "_vid": "com.nevion.cp515-0.1.0", + "attachments": [ + { + "description": "Default", + "name": "cp515" + } + ], + "configurableDevice": true, + "configurableModule": [], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "customSettingsSeed": {}, + "description": "Driver for the Nevion CP515 SI Manager", + "deviceType": "driver", + "discoveredBy": null, + "exists": "No", + "iconType": "transportStreamProcessor", + "ipAddress": null, + "label": "Nevion CP515", + "modules": [], + "name": "cp515", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.nevion", + "products": [ + { + "name": "CP515", + "swBuildTime": null, + "swVersion": null + } + ], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.2.1.1.2.0" + ], + "snmpSysObjectIdValue": "1.3.6.1.4.1.22909.3.9", + "status": "", + "supportedApis": [ + "ParameterControlLike", + "DeviceLike", + "DynamicLike", + "MaintenanceLike", + "PortLike", + "CoreLike", + "StatusLike" + ], + "supportsExternal": true, + "supportsParameterApi": true, + "uniqueMetricDefs": [], + "updateableDevice": true, + "updateableModule": {}, + "version": "0.1.0" + }, + { + "_id": "com.nevion.cp524-0.1.0", + "_vid": "com.nevion.cp524-0.1.0", + "attachments": [ + { + "description": "Default", + "name": "cp524" + } + ], + "configurableDevice": true, + "configurableModule": [], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "customSettingsSeed": {}, + "description": "Driver for the Nevion CP524 TS Adapter", + "deviceType": "driver", + "discoveredBy": null, + "exists": "No", + "iconType": "transportStreamProcessor", + "ipAddress": null, + "label": "Nevion CP524", + "modules": [ + "System", + "Network", + "Clock Regulator", + "ASI Inputs", + "ASI Outputs", + "Switches", + "TS Out", + "IP Inputs", + "IP Outputs" + ], + "name": "cp524", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.nevion", + "products": [ + { + "name": "CP524", + "swBuildTime": null, + "swVersion": null + } + ], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.2.1.1.2.0" + ], + "snmpSysObjectIdValue": "1.3.6.1.4.1.22909.3.32", + "status": "", + "supportedApis": [ + "ParameterControlLike", + "DeviceLike", + "DynamicLike", + "MaintenanceLike", + "PortLike", + "CoreLike", + "StatusLike" + ], + "supportsExternal": true, + "supportsParameterApi": true, + "uniqueMetricDefs": [], + "updateableDevice": true, + "updateableModule": {}, + "version": "0.1.0" + }, + { + "_id": "com.nevion.cp525-0.1.0", + "_vid": "com.nevion.cp525-0.1.0", + "attachments": [ + { + "description": "Default", + "name": "cp525" + } + ], + "configurableDevice": true, + "configurableModule": [], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "customSettingsSeed": {}, + "description": "Driver for the Nevion CP525 cMUX Remultiplexer", + "deviceType": "driver", + "discoveredBy": null, + "exists": "No", + "iconType": "transportStreamProcessor", + "ipAddress": null, + "label": "Nevion CP525", + "modules": [ + "System", + "Network", + "Clock Regulator", + "ASI Inputs", + "ASI Outputs", + "TS Out", + "IP Inputs" + ], + "name": "cp525", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.nevion", + "products": [ + { + "name": "CP525", + "swBuildTime": null, + "swVersion": null + } + ], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.2.1.1.2.0" + ], + "snmpSysObjectIdValue": "1.3.6.1.4.1.22909.3.5", + "status": "", + "supportedApis": [ + "ParameterControlLike", + "DeviceLike", + "DynamicLike", + "MaintenanceLike", + "CoreLike", + "StatusLike" + ], + "supportsExternal": true, + "supportsParameterApi": true, + "uniqueMetricDefs": [], + "updateableDevice": true, + "updateableModule": {}, + "version": "0.1.0" + }, + { + "_id": "com.nevion.cp540-0.1.0", + "_vid": "com.nevion.cp540-0.1.0", + "attachments": [ + { + "description": "Default", + "name": "cp540" + } + ], + "configurableDevice": true, + "configurableModule": [], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "customSettingsSeed": {}, + "description": "Driver for the Nevion CP540 TS Monitoring Switch", + "deviceType": "driver", + "discoveredBy": null, + "exists": "No", + "iconType": "transportStreamProcessor", + "ipAddress": null, + "label": "Nevion CP540", + "modules": [ + "System", + "Network", + "Clock Regulator", + "ASI Inputs" + ], + "name": "cp540", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.nevion", + "products": [ + { + "name": "CP540", + "swBuildTime": null, + "swVersion": null + } + ], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.2.1.1.2.0" + ], + "snmpSysObjectIdValue": "1.3.6.1.4.1.22909.3.6", + "status": "", + "supportedApis": [ + "ParameterControlLike", + "DeviceLike", + "DynamicLike", + "MaintenanceLike", + "CoreLike", + "StatusLike" + ], + "supportsExternal": true, + "supportsParameterApi": true, + "uniqueMetricDefs": [], + "updateableDevice": true, + "updateableModule": {}, + "version": "0.1.0" + }, + { + "_id": "com.nevion.cp560-0.1.0", + "_vid": "com.nevion.cp560-0.1.0", + "attachments": [ + { + "description": "Default", + "name": "cp560" + } + ], + "configurableDevice": true, + "configurableModule": [], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "customSettingsSeed": {}, + "description": "Driver for the Nevion CP560 DVB-T2 Gateway", + "deviceType": "driver", + "discoveredBy": null, + "exists": "No", + "iconType": "transportStreamProcessor", + "ipAddress": null, + "label": "Nevion CP560", + "modules": [ + "System", + "Network", + "Clock Regulator", + "ASI Inputs", + "ASI Outputs", + "Switch Inputs", + "T2 Outputs", + "IP Inputs" + ], + "name": "cp560", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.nevion", + "products": [ + { + "name": "CP560", + "swBuildTime": null, + "swVersion": null + } + ], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.2.1.1.2.0" + ], + "snmpSysObjectIdValue": "1.3.6.1.4.1.22909.3.14", + "status": "", + "supportedApis": [ + "ParameterControlLike", + "DeviceLike", + "DynamicLike", + "MaintenanceLike", + "CoreLike", + "StatusLike" + ], + "supportsExternal": true, + "supportsParameterApi": true, + "uniqueMetricDefs": [], + "updateableDevice": true, + "updateableModule": {}, + "version": "0.1.0" + }, + { + "_id": "com.nevion.demo-tns-0.1.0", + "_vid": "com.nevion.demo-tns-0.1.0", + "attachments": [ + { + "description": "Default", + "name": "demo-tns" + } + ], + "configurableDevice": false, + "configurableModule": [], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "customSettingsSeed": {}, + "description": "A demo driver that fakes access towards a Nevion TNS device for monitoring", + "deviceType": "driver", + "discoveredBy": null, + "exists": "No", + "iconType": "monitor", + "ipAddress": null, + "label": "Demo TNS", + "modules": [], + "name": "demo-tns", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.nevion", + "products": [ + { + "name": "Dummy TNS4200", + "swBuildTime": null, + "swVersion": null + } + ], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.2.1.1.2.0" + ], + "snmpSysObjectIdValue": null, + "status": "", + "supportedApis": [ + "ParameterControlLike", + "DeviceLike", + "NetworkServiceLike", + "PortLike", + "CoreLike", + "StatusLike" + ], + "supportsExternal": true, + "supportsParameterApi": true, + "uniqueMetricDefs": [], + "updateableDevice": false, + "updateableModule": {}, + "version": "0.1.0" + }, + { + "_id": "com.nevion.device_up_driver-0.1.0", + "_vid": "com.nevion.device_up_driver-0.1.0", + "attachments": [ + { + "description": "Default", + "name": "device_up_driver" + } + ], + "configurableDevice": false, + "configurableModule": [], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "Custom setting fields for DeviceUpDriver family", + "label": "DeviceUpDriver family" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map", + "values": { + "com.nevion.device_up_driver.retries": { + "_schema": { + "default": 1, + "descriptor": { + "desc": "The number of times the device will check reachability.", + "label": "Number of retries" + }, + "isNullable": false, + "options": [], + "ranges": [ + [ + 1, + 20, + 1 + ] + ], + "status": "Current", + "type": "number", + "units": "" + } + }, + "com.nevion.device_up_driver.timeout": { + "_schema": { + "default": 5, + "descriptor": { + "desc": "Timeout in seconds. Upon reaching the timeout, the cache is considered stale and will be invalidated.", + "label": "Timeout [s]" + }, + "isNullable": false, + "options": [], + "ranges": [ + [ + 0, + 20, + 1 + ] + ], + "status": "Current", + "type": "number", + "units": "" + } + } + } + } + }, + "customSettingsSeed": {}, + "description": "", + "deviceType": "driver", + "discoveredBy": null, + "exists": "No", + "iconType": "none", + "ipAddress": null, + "label": "Device Up Driver (Ping)", + "modules": [], + "name": "device_up_driver", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.nevion", + "products": [], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.2.1.1.2.0" + ], + "snmpSysObjectIdValue": null, + "status": "", + "supportedApis": [ + "CoreLike", + "DeviceLike" + ], + "supportsExternal": false, + "supportsParameterApi": false, + "uniqueMetricDefs": [], + "updateableDevice": false, + "updateableModule": {}, + "version": "0.1.0" + }, + { + "_id": "com.nevion.dhd_series52-0.1.0", + "_vid": "com.nevion.dhd_series52-0.1.0", + "attachments": [ + { + "description": "Default", + "name": "dhd_series52" + } + ], + "configurableDevice": false, + "configurableModule": [], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "Custom setting fields for dhd_series52", + "label": "dhd_series52" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map", + "values": { + "com.nevion.emberplus.keepalives": { + "_schema": { + "default": true, + "descriptor": { + "desc": "If selected, keep-alives will be used to determine reachability", + "label": "Send keep-alives" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "bool" + } + }, + "com.nevion.emberplus.port": { + "_schema": { + "default": 9000, + "descriptor": { + "desc": "Port", + "label": "Port" + }, + "isNullable": false, + "options": [], + "ranges": [ + [ + 0, + 65535, + 1 + ] + ], + "status": "Current", + "type": "number", + "units": "" + } + }, + "com.nevion.emberplus.queue": { + "_schema": { + "default": true, + "descriptor": { + "desc": "", + "label": "Request queueing" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "bool" + } + }, + "com.nevion.emberplus.suppress_illegal": { + "_schema": { + "default": false, + "descriptor": { + "desc": "", + "label": "Suppress illegal update warnings" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "bool" + } + }, + "com.nevion.emberplus.trace": { + "_schema": { + "default": false, + "descriptor": { + "desc": "", + "label": "Tracing (logging intensive)" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "bool" + } + } + } + } + }, + "customSettingsSeed": {}, + "description": "Driver for DHD.audio", + "deviceType": "driver", + "discoveredBy": null, + "exists": "No", + "iconType": "none", + "ipAddress": null, + "label": "DBD Series 52", + "modules": [], + "name": "dhd_series52", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.nevion", + "products": [], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.2.1.1.2.0" + ], + "snmpSysObjectIdValue": null, + "status": "", + "supportedApis": [ + "ParameterControlLike", + "DeviceLike", + "PortLike", + "CoreLike", + "StatusLike" + ], + "supportsExternal": true, + "supportsParameterApi": true, + "uniqueMetricDefs": [], + "updateableDevice": false, + "updateableModule": {}, + "version": "0.1.0" + }, + { + "_id": "com.nevion.dhd_xc3core-0.1.0", + "_vid": "com.nevion.dhd_xc3core-0.1.0", + "attachments": [ + { + "description": "Default", + "name": "dhd_xc3core" + } + ], + "configurableDevice": false, + "configurableModule": [], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "Custom setting fields for DHD Audio XC3 Core", + "label": "DHD Audio XC3 Core" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map", + "values": { + "com.nevion.dhd_xc3core.gpi_range": { + "_schema": { + "default": "", + "descriptor": { + "desc": "GPI as ranges or values, e.g., start-end,single,start-end", + "label": "GPI Range" + }, + "encoding": "UTF-8", + "isNullable": false, + "lengthRanges": [], + "options": [], + "status": "Current", + "type": "string" + } + }, + "com.nevion.dhd_xc3core.gpo_range": { + "_schema": { + "default": "", + "descriptor": { + "desc": "GPO as ranges or values, e.g., start-end,single,start-end", + "label": "GPO Range" + }, + "encoding": "UTF-8", + "isNullable": false, + "lengthRanges": [], + "options": [], + "status": "Current", + "type": "string" + } + }, + "com.nevion.dhd_xc3core.token": { + "_schema": { + "default": "", + "descriptor": { + "desc": "DHD Token", + "label": "Token" + }, + "encoding": "UTF-8", + "isNullable": false, + "lengthRanges": [], + "options": [], + "status": "Current", + "type": "string" + } + }, + "com.nevion.dhd_xc3core.user": { + "_schema": { + "default": "BCS", + "descriptor": { + "desc": "DHD User", + "label": "User" + }, + "encoding": "UTF-8", + "isNullable": false, + "lengthRanges": [], + "options": [], + "status": "Current", + "type": "string" + } + } + } + } + }, + "customSettingsSeed": {}, + "description": "Driver for DHD Audio XC3 Core", + "deviceType": "driver", + "discoveredBy": null, + "exists": "No", + "iconType": "none", + "ipAddress": null, + "label": "DHD Audio XC3 Core", + "modules": [], + "name": "dhd_xc3core", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.nevion", + "products": [ + { + "name": "DHD Audio", + "swBuildTime": null, + "swVersion": null + } + ], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.2.1.1.2.0" + ], + "snmpSysObjectIdValue": null, + "status": "", + "supportedApis": [ + "TallyLike", + "GPIOLike", + "CoreLike", + "DeviceLike" + ], + "supportsExternal": true, + "supportsParameterApi": false, + "uniqueMetricDefs": [], + "updateableDevice": false, + "updateableModule": {}, + "version": "0.1.0" + }, + { + "_id": "com.nevion.directout_prodigy_gpio-0.1.0", + "_vid": "com.nevion.directout_prodigy_gpio-0.1.0", + "attachments": [ + { + "description": "Default", + "name": "directout_prodigy_gpio" + } + ], + "configurableDevice": false, + "configurableModule": [], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "customSettingsSeed": {}, + "description": "Multiformat audio matrix", + "deviceType": "driver", + "discoveredBy": null, + "exists": "No", + "iconType": "none", + "ipAddress": null, + "label": "PRODIGY-MX-GPIO", + "modules": [], + "name": "directout_prodigy_gpio", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.nevion", + "products": [], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.2.1.1.2.0" + ], + "snmpSysObjectIdValue": null, + "status": "", + "supportedApis": [ + "ParameterControlLike", + "GPIOLike", + "CoreLike", + "DeviceLike" + ], + "supportsExternal": true, + "supportsParameterApi": true, + "uniqueMetricDefs": [], + "updateableDevice": false, + "updateableModule": {}, + "version": "0.1.0" + }, + { + "_id": "com.nevion.dse892-0.1.0", + "_vid": "com.nevion.dse892-0.1.0", + "attachments": [ + { + "description": "Default", + "name": "dse892" + } + ], + "configurableDevice": false, + "configurableModule": [], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "customSettingsSeed": {}, + "description": "Simple Network Management Protocol (SNMP) Gateway", + "deviceType": "driver", + "discoveredBy": null, + "exists": "No", + "iconType": "device", + "ipAddress": null, + "label": "Kohler DSE892", + "modules": [], + "name": "dse892", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.nevion", + "products": [ + { + "name": "DSE892", + "swBuildTime": null, + "swVersion": null + } + ], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.2.1.1.2.0" + ], + "snmpSysObjectIdValue": "1.3.6.1.4.1.41385.1", + "status": "", + "supportedApis": [ + "ParameterControlLike", + "CoreLike", + "DeviceLike", + "StatusLike" + ], + "supportsExternal": true, + "supportsParameterApi": true, + "uniqueMetricDefs": [], + "updateableDevice": false, + "updateableModule": {}, + "version": "0.1.0" + }, + { + "_id": "com.nevion.dyvi-0.1.0", + "_vid": "com.nevion.dyvi-0.1.0", + "attachments": [ + { + "description": "Default", + "name": "dyvi" + } + ], + "configurableDevice": false, + "configurableModule": [], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "customSettingsSeed": {}, + "description": "", + "deviceType": "driver", + "discoveredBy": null, + "exists": "No", + "iconType": "none", + "ipAddress": null, + "label": "EVS Dyvi", + "modules": [], + "name": "dyvi", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.nevion", + "products": [], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.2.1.1.2.0" + ], + "snmpSysObjectIdValue": null, + "status": "", + "supportedApis": [ + "ParameterControlLike", + "CoreLike", + "DeviceLike", + "PortLike" + ], + "supportsExternal": true, + "supportsParameterApi": true, + "uniqueMetricDefs": [], + "updateableDevice": false, + "updateableModule": {}, + "version": "0.1.0" + }, + { + "_id": "com.nevion.electra-0.1.0", + "_vid": "com.nevion.electra-0.1.0", + "attachments": [ + { + "description": "Default", + "name": "electra" + } + ], + "configurableDevice": false, + "configurableModule": [], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "customSettingsSeed": {}, + "description": "Universal Multi-Service Encoder", + "deviceType": "driver", + "discoveredBy": null, + "exists": "No", + "iconType": "gateway", + "ipAddress": null, + "label": "Electra", + "modules": [], + "name": "electra", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.nevion", + "products": [], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.2.1.1.2.0" + ], + "snmpSysObjectIdValue": "0.0", + "status": "", + "supportedApis": [ + "ParameterControlLike", + "CoreLike", + "DeviceLike", + "StatusLike" + ], + "supportsExternal": true, + "supportsParameterApi": true, + "uniqueMetricDefs": [], + "updateableDevice": false, + "updateableModule": {}, + "version": "0.1.0" + }, + { + "_id": "com.nevion.elemental_live-01.01.01", + "_vid": "com.nevion.elemental_live-01.01.01", + "attachments": [ + { + "description": "Default", + "name": "elemental_live" + } + ], + "configurableDevice": false, + "configurableModule": [], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "customSettingsSeed": {}, + "description": "Broadcast and live streaming encoder", + "deviceType": "driver", + "discoveredBy": null, + "exists": "No", + "iconType": "encoder", + "ipAddress": null, + "label": "Elemental Live", + "modules": [], + "name": "elemental_live", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.nevion", + "products": [ + { + "name": "Live", + "swBuildTime": null, + "swVersion": null + } + ], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.2.1.1.2.0" + ], + "snmpSysObjectIdValue": null, + "status": "", + "supportedApis": [ + "ParameterControlLike", + "DeviceLike", + "PortLike", + "CoreLike", + "StatusLike" + ], + "supportsExternal": true, + "supportsParameterApi": true, + "uniqueMetricDefs": [], + "updateableDevice": false, + "updateableModule": {}, + "version": "01.01.01" + }, + { + "_id": "com.nevion.embrionix_sfp-0.1.0", + "_vid": "com.nevion.embrionix_sfp-0.1.0", + "attachments": [ + { + "description": "Default", + "name": "embrionix_sfp" + } + ], + "configurableDevice": false, + "configurableModule": [], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "customSettingsSeed": {}, + "description": "Driver for Embrionix SFPs", + "deviceType": "driver", + "discoveredBy": null, + "exists": "No", + "iconType": "gateway", + "ipAddress": null, + "label": "Embrionix SFP", + "modules": [], + "name": "embrionix_sfp", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.nevion", + "products": [], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.2.1.1.2.0" + ], + "snmpSysObjectIdValue": null, + "status": "", + "supportedApis": [ + "CoreLike", + "DeviceLike", + "StatusLike", + "PortLike" + ], + "supportsExternal": true, + "supportsParameterApi": false, + "uniqueMetricDefs": [], + "updateableDevice": false, + "updateableModule": {}, + "version": "0.1.0" + }, + { + "_id": "com.nevion.emerge_enterprise-0.0.1", + "_vid": "com.nevion.emerge_enterprise-0.0.1", + "attachments": [ + { + "description": "Default", + "name": "emerge_enterprise" + } + ], + "configurableDevice": false, + "configurableModule": [], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "customSettingsSeed": {}, + "description": "A driver for eMerge Enterprise devices (via an SNMP interface)", + "deviceType": "switch", + "discoveredBy": null, + "exists": "No", + "iconType": "none", + "ipAddress": null, + "label": "Nevion eMerge (SNMP monitoring)", + "modules": [], + "name": "emerge_enterprise", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.nevion", + "products": [ + { + "name": "eMerge Enterprise", + "swBuildTime": null, + "swVersion": null + } + ], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.2.1.1.2.0" + ], + "snmpSysObjectIdValue": null, + "status": "", + "supportedApis": [ + "ParameterControlLike", + "CoreLike", + "DeviceLike", + "StatusLike" + ], + "supportsExternal": true, + "supportsParameterApi": true, + "uniqueMetricDefs": [], + "updateableDevice": false, + "updateableModule": {}, + "version": "0.0.1" + }, + { + "_id": "com.nevion.emerge_openflow-0.0.1", + "_vid": "com.nevion.emerge_openflow-0.0.1", + "attachments": [ + { + "description": "Default", + "name": "emerge_openflow" + } + ], + "configurableDevice": false, + "configurableModule": [], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "Custom setting fields for Openflow drivers", + "label": "Openflow" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map", + "values": { + "com.nevion.api.sample_flows_interval": { + "_schema": { + "default": 0, + "descriptor": { + "desc": "Interval at which to poll flow stats. 0 to disable.", + "label": "Flow stats interval [s]" + }, + "isNullable": false, + "options": [], + "ranges": [ + [ + 0, + 0, + 1 + ], + [ + 2, + 3600, + 1 + ] + ], + "status": "Current", + "type": "number", + "units": "" + } + }, + "com.nevion.emerge_openflow.ipv4address": { + "_schema": { + "default": "", + "descriptor": { + "desc": "Required when using DPID as main address instead of IPv4 (cluster)", + "label": "IPv4 address" + }, + "encoding": "UTF-8", + "isNullable": false, + "lengthRanges": [], + "options": [], + "status": "Current", + "type": "string" + } + }, + "com.nevion.openflow_allow_groups": { + "_schema": { + "default": true, + "descriptor": { + "desc": "Allow use of group actions in flows", + "label": "Allow groups" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "bool" + } + }, + "com.nevion.openflow_flow_priority": { + "_schema": { + "default": 60000, + "descriptor": { + "desc": "Flow priority used by videoipath", + "label": "Flow Priority" + }, + "isNullable": false, + "options": [], + "ranges": [ + [ + 2, + 65535, + 1 + ] + ], + "status": "Current", + "type": "number", + "units": "" + } + }, + "com.nevion.openflow_interface_shutdown_alarms": { + "_schema": { + "default": false, + "descriptor": { + "desc": "Allow service correlated alarms when admin shuts down an interface", + "label": "Interface shutdown alarms" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "bool" + } + }, + "com.nevion.openflow_max_buckets": { + "_schema": { + "default": 65535, + "descriptor": { + "desc": "Max number of buckets in an openflow group", + "label": "Max buckets" + }, + "isNullable": false, + "options": [], + "ranges": [ + [ + 2, + 65535, + 1 + ] + ], + "status": "Current", + "type": "number", + "units": "" + } + }, + "com.nevion.openflow_max_groups": { + "_schema": { + "default": 65535, + "descriptor": { + "desc": "Max number of groups on the switch", + "label": "Max groups" + }, + "isNullable": false, + "options": [], + "ranges": [ + [ + 1, + 65535, + 1 + ] + ], + "status": "Current", + "type": "number", + "units": "" + } + }, + "com.nevion.openflow_max_meters": { + "_schema": { + "default": 65535, + "descriptor": { + "desc": "Max number of meters on the switch", + "label": "Max meters" + }, + "isNullable": false, + "options": [], + "ranges": [ + [ + 2, + 65535, + 1 + ] + ], + "status": "Current", + "type": "number", + "units": "" + } + }, + "com.nevion.openflow_table_id": { + "_schema": { + "default": 0, + "descriptor": { + "desc": "Table ID to use for videoipath flows", + "label": "Table ID" + }, + "isNullable": false, + "options": [], + "ranges": [ + [ + 0, + 255, + 1 + ] + ], + "status": "Current", + "type": "number", + "units": "" + } + } + } + } + }, + "customSettingsSeed": {}, + "description": "A driver for eMerge Openflow devices (via an Openflow controller)", + "deviceType": "switch", + "discoveredBy": null, + "exists": "No", + "iconType": "ipSwitchRouter", + "ipAddress": null, + "label": "Nevion eMerge (Openflow + SNMP)", + "modules": [], + "name": "emerge_openflow", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.nevion", + "products": [ + { + "name": "eMerge Openflow", + "swBuildTime": null, + "swVersion": null + } + ], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [], + "snmpSysObjectIdValue": "1.3.6.1.4.1.27975.99.0", + "status": "", + "supportedApis": [ + "ParameterControlLike", + "DeviceLike", + "PortLike", + "CoreLike", + "StatusLike" + ], + "supportsExternal": true, + "supportsParameterApi": true, + "uniqueMetricDefs": [], + "updateableDevice": false, + "updateableModule": {}, + "version": "0.0.1" + }, + { + "_id": "com.nevion.ericsson_avp2000-0.1.0", + "_vid": "com.nevion.ericsson_avp2000-0.1.0", + "attachments": [ + { + "description": "Map from vbi line number to value", + "name": "ericsson_avp_teletext" + }, + { + "description": "Info about which alarms to mask", + "name": "ericsson_avp_alarm_masking" + } + ], + "configurableDevice": true, + "configurableModule": [], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "Custom setting fields for Ericsson devices", + "label": "Ericsson" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map", + "values": { + "com.nevion.ericsson.use_alarm_map": { + "_schema": { + "default": true, + "descriptor": { + "desc": "If enabled, only relevant alerts will be raised.", + "label": "Map alarms" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "bool" + } + } + } + } + }, + "customSettingsSeed": {}, + "description": "Driver for the Ericsson AVP 2000 and 4000", + "deviceType": "switch", + "discoveredBy": null, + "exists": "No", + "iconType": "encoder", + "ipAddress": null, + "label": "Ericsson AVP", + "modules": [], + "name": "ericsson_avp2000", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.nevion", + "products": [ + { + "name": "AVP", + "swBuildTime": null, + "swVersion": null + } + ], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.2.1.1.2.0", + "1.3.6.1.4.1.1773.1.1.1.7.0" + ], + "snmpSysObjectIdValue": "1.3.6.1.4.1.1773", + "status": "", + "supportedApis": [ + "ParameterControlLike", + "DeviceLike", + "DynamicLike", + "MaintenanceLike", + "PortLike", + "CoreLike", + "StatusLike" + ], + "supportsExternal": true, + "supportsParameterApi": true, + "uniqueMetricDefs": [], + "updateableDevice": false, + "updateableModule": {}, + "version": "0.1.0" + }, + { + "_id": "com.nevion.ericsson_ce-0.1.0", + "_vid": "com.nevion.ericsson_ce-0.1.0", + "attachments": [ + { + "description": "Map from vbi line number to value", + "name": "ericsson_avp_teletext" + }, + { + "description": "Info about which alarms to mask", + "name": "ericsson_avp_alarm_masking" + } + ], + "configurableDevice": true, + "configurableModule": [], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "Custom setting fields for Ericsson devices", + "label": "Ericsson" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map", + "values": { + "com.nevion.ericsson.use_alarm_map": { + "_schema": { + "default": true, + "descriptor": { + "desc": "If enabled, only relevant alerts will be raised.", + "label": "Map alarms" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "bool" + } + } + } + } + }, + "customSettingsSeed": {}, + "description": "Driver for the Ericsson CE", + "deviceType": "driver", + "discoveredBy": null, + "exists": "No", + "iconType": "encoder", + "ipAddress": null, + "label": "Ericsson CE", + "modules": [], + "name": "ericsson_ce", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.nevion", + "products": [ + { + "name": "CE", + "swBuildTime": null, + "swVersion": null + } + ], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.2.1.1.2.0", + "1.3.6.1.4.1.1773.1.1.1.7.0" + ], + "snmpSysObjectIdValue": "1.3.6.1.4.1.1773", + "status": "", + "supportedApis": [ + "ParameterControlLike", + "DeviceLike", + "DynamicLike", + "MaintenanceLike", + "PortLike", + "CoreLike", + "StatusLike" + ], + "supportsExternal": true, + "supportsParameterApi": true, + "uniqueMetricDefs": [], + "updateableDevice": false, + "updateableModule": {}, + "version": "0.1.0" + }, + { + "_id": "com.nevion.ericsson_rx8200-0.1.0", + "_vid": "com.nevion.ericsson_rx8200-0.1.0", + "attachments": [ + { + "description": "Default", + "name": "ericsson_rx8200" + } + ], + "configurableDevice": false, + "configurableModule": [], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "Custom setting fields for Ericsson devices", + "label": "Ericsson" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map", + "values": { + "com.nevion.ericsson.use_alarm_map": { + "_schema": { + "default": true, + "descriptor": { + "desc": "If enabled, only relevant alerts will be raised.", + "label": "Map alarms" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "bool" + } + } + } + } + }, + "customSettingsSeed": {}, + "description": "Driver for the Ericsson RX8200 Advanced Modular Receiver", + "deviceType": "driver", + "discoveredBy": null, + "exists": "No", + "iconType": "decoder", + "ipAddress": null, + "label": "Ericsson RX8200", + "modules": [ + "RX8000", + "IP Out", + "Audio 1", + "HD Output", + "Multi Standard Decoder", + "CA Lite", + "Control Interface" + ], + "name": "ericsson_rx8200", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.nevion", + "products": [ + { + "name": "RX8200", + "swBuildTime": null, + "swVersion": null + } + ], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.2.1.1.2.0", + "1.3.6.1.4.1.1773.1.1.1.7.0" + ], + "snmpSysObjectIdValue": "1.3.6.1.4.1.1773", + "status": "", + "supportedApis": [ + "ParameterControlLike", + "DeviceLike", + "DynamicLike", + "PortLike", + "CoreLike", + "StatusLike" + ], + "supportsExternal": true, + "supportsParameterApi": true, + "uniqueMetricDefs": [], + "updateableDevice": false, + "updateableModule": {}, + "version": "0.1.0" + }, + { + "_id": "com.nevion.evertz_500fc-0.1.0", + "_vid": "com.nevion.evertz_500fc-0.1.0", + "attachments": [ + { + "description": "Default", + "name": "evertz_500fc" + } + ], + "configurableDevice": false, + "configurableModule": [], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "customSettingsSeed": {}, + "description": "", + "deviceType": "driver", + "discoveredBy": null, + "exists": "No", + "iconType": "none", + "ipAddress": null, + "label": "Evertz 500FC", + "modules": [], + "name": "evertz_500fc", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.nevion", + "products": [], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.2.1.1.2.0" + ], + "snmpSysObjectIdValue": "1.3.6.1.4.1.6827", + "status": "", + "supportedApis": [ + "ParameterControlLike", + "DeviceLike", + "DynamicLike", + "CoreLike", + "StatusLike" + ], + "supportsExternal": true, + "supportsParameterApi": true, + "uniqueMetricDefs": [], + "updateableDevice": false, + "updateableModule": {}, + "version": "0.1.0" + }, + { + "_id": "com.nevion.evertz_570fc-0.1.0", + "_vid": "com.nevion.evertz_570fc-0.1.0", + "attachments": [ + { + "description": "Default", + "name": "evertz_570fc" + } + ], + "configurableDevice": false, + "configurableModule": [], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "customSettingsSeed": {}, + "description": "Driver for Evertz 570 Frame Controller", + "deviceType": "driver", + "discoveredBy": null, + "exists": "No", + "iconType": "default", + "ipAddress": null, + "label": "Evertz 570 FC", + "modules": [], + "name": "evertz_570fc", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.nevion", + "products": [], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.2.1.1.2.0" + ], + "snmpSysObjectIdValue": null, + "status": "", + "supportedApis": [ + "CoreLike", + "DeviceLike", + "StatusLike" + ], + "supportsExternal": true, + "supportsParameterApi": false, + "uniqueMetricDefs": [], + "updateableDevice": false, + "updateableModule": {}, + "version": "0.1.0" + }, + { + "_id": "com.nevion.evertz_570itxe_hw_p60_udc-0.1.0", + "_vid": "com.nevion.evertz_570itxe_hw_p60_udc-0.1.0", + "attachments": [ + { + "description": "Default", + "name": "evertz_570itxe_hw_p60_udc" + } + ], + "configurableDevice": false, + "configurableModule": [], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "customSettingsSeed": {}, + "description": "Driver for Evertz 570ITXE-HW-P60 Multi-Channel J2K Encoder/Decoder", + "deviceType": "driver", + "discoveredBy": null, + "exists": "No", + "iconType": "default", + "ipAddress": null, + "label": "Evertz 570ITXE-HW-P60", + "modules": [], + "name": "evertz_570itxe_hw_p60_udc", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.nevion", + "products": [], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.2.1.1.2.0" + ], + "snmpSysObjectIdValue": null, + "status": "", + "supportedApis": [ + "CoreLike", + "DeviceLike", + "StatusLike", + "PortLike" + ], + "supportsExternal": true, + "supportsParameterApi": false, + "uniqueMetricDefs": [], + "updateableDevice": false, + "updateableModule": {}, + "version": "0.1.0" + }, + { + "_id": "com.nevion.evertz_570j2k_x19_12e-0.1.0", + "_vid": "com.nevion.evertz_570j2k_x19_12e-0.1.0", + "attachments": [ + { + "description": "Default", + "name": "evertz_570j2k_x19_12e" + } + ], + "configurableDevice": false, + "configurableModule": [], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "customSettingsSeed": {}, + "description": "Driver for Evertz 570J2K-HW-X19 Multi-Channel J2K Encoder/Decoder, app mode 12E", + "deviceType": "driver", + "discoveredBy": null, + "exists": "No", + "iconType": "encoderDecoder", + "ipAddress": null, + "label": "Evertz 570J2K-HW-X19 (12E)", + "modules": [], + "name": "evertz_570j2k_x19_12e", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.nevion", + "products": [], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.2.1.1.2.0" + ], + "snmpSysObjectIdValue": null, + "status": "", + "supportedApis": [ + "CoreLike", + "DeviceLike", + "StatusLike", + "PortLike" + ], + "supportsExternal": true, + "supportsParameterApi": false, + "uniqueMetricDefs": [], + "updateableDevice": false, + "updateableModule": {}, + "version": "0.1.0" + }, + { + "_id": "com.nevion.evertz_570j2k_x19_6e6d-0.1.0", + "_vid": "com.nevion.evertz_570j2k_x19_6e6d-0.1.0", + "attachments": [ + { + "description": "Default", + "name": "evertz_570j2k_x19_6e6d" + } + ], + "configurableDevice": false, + "configurableModule": [], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "customSettingsSeed": {}, + "description": "Driver for Evertz 570J2K-HW-X19 Multi-Channel J2K Encoder/Decoder, app mode 6E6D", + "deviceType": "driver", + "discoveredBy": null, + "exists": "No", + "iconType": "encoderDecoder", + "ipAddress": null, + "label": "Evertz 570J2K-HW-X19 (6E6D)", + "modules": [], + "name": "evertz_570j2k_x19_6e6d", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.nevion", + "products": [], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.2.1.1.2.0" + ], + "snmpSysObjectIdValue": null, + "status": "", + "supportedApis": [ + "CoreLike", + "DeviceLike", + "StatusLike", + "PortLike" + ], + "supportsExternal": true, + "supportsParameterApi": false, + "uniqueMetricDefs": [], + "updateableDevice": false, + "updateableModule": {}, + "version": "0.1.0" + }, + { + "_id": "com.nevion.evertz_570j2k_x19_u9d-0.1.0", + "_vid": "com.nevion.evertz_570j2k_x19_u9d-0.1.0", + "attachments": [ + { + "description": "Default", + "name": "evertz_570j2k_x19_u9d" + } + ], + "configurableDevice": false, + "configurableModule": [], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "customSettingsSeed": {}, + "description": "Driver for Evertz 570J2K-HW-X19 Multi-Channel J2K Encoder/Decoder, app mode U9D", + "deviceType": "driver", + "discoveredBy": null, + "exists": "No", + "iconType": "encoderDecoder", + "ipAddress": null, + "label": "Evertz 570J2K-HW-X19 (U9D)", + "modules": [], + "name": "evertz_570j2k_x19_u9d", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.nevion", + "products": [], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.2.1.1.2.0" + ], + "snmpSysObjectIdValue": null, + "status": "", + "supportedApis": [ + "CoreLike", + "DeviceLike", + "StatusLike", + "PortLike" + ], + "supportsExternal": true, + "supportsParameterApi": false, + "uniqueMetricDefs": [], + "updateableDevice": false, + "updateableModule": {}, + "version": "0.1.0" + }, + { + "_id": "com.nevion.evertz_570j2k_x19_u9e-0.1.0", + "_vid": "com.nevion.evertz_570j2k_x19_u9e-0.1.0", + "attachments": [ + { + "description": "Default", + "name": "evertz_570j2k_x19_u9e" + } + ], + "configurableDevice": false, + "configurableModule": [], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "customSettingsSeed": {}, + "description": "Driver for Evertz 570J2K-HW-X19 Multi-Channel J2K Encoder/Decoder, app mode U9E", + "deviceType": "driver", + "discoveredBy": null, + "exists": "No", + "iconType": "encoderDecoder", + "ipAddress": null, + "label": "Evertz 570J2K-HW-X19 (U9E)", + "modules": [], + "name": "evertz_570j2k_x19_u9e", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.nevion", + "products": [], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.2.1.1.2.0" + ], + "snmpSysObjectIdValue": null, + "status": "", + "supportedApis": [ + "CoreLike", + "DeviceLike", + "StatusLike", + "PortLike" + ], + "supportsExternal": true, + "supportsParameterApi": false, + "uniqueMetricDefs": [], + "updateableDevice": false, + "updateableModule": {}, + "version": "0.1.0" + }, + { + "_id": "com.nevion.evertz_5782dec-0.1.0", + "_vid": "com.nevion.evertz_5782dec-0.1.0", + "attachments": [ + { + "description": "Default", + "name": "evertz_5782dec" + } + ], + "configurableDevice": false, + "configurableModule": [], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "Custom setting fields for Evertz drivers", + "label": "Evertz" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map", + "values": { + "com.nevion.evertz.enable_frame_controller": { + "_schema": { + "default": false, + "descriptor": { + "desc": "Control card through Frame Controller", + "label": "Enable Frame Controller" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "bool" + } + }, + "com.nevion.evertz.frame_controller_slot": { + "_schema": { + "default": 1, + "descriptor": { + "desc": "Defines which slot will be used for communication", + "label": "Frame Controller Slot" + }, + "isNullable": false, + "options": [], + "ranges": [ + [ + 1, + 15, + 1 + ] + ], + "status": "Current", + "type": "number", + "units": "" + } + } + } + } + }, + "customSettingsSeed": {}, + "description": "Driver for Evertz 5782 decoder", + "deviceType": "driver", + "discoveredBy": null, + "exists": "No", + "iconType": "decoder", + "ipAddress": null, + "label": "Evertz 5782 Decoder", + "modules": [], + "name": "evertz_5782dec", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.nevion", + "products": [], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.2.1.1.2.0" + ], + "snmpSysObjectIdValue": null, + "status": "", + "supportedApis": [ + "CoreLike", + "DeviceLike", + "StatusLike", + "PortLike" + ], + "supportsExternal": true, + "supportsParameterApi": false, + "uniqueMetricDefs": [], + "updateableDevice": false, + "updateableModule": {}, + "version": "0.1.0" + }, + { + "_id": "com.nevion.evertz_5782enc-0.1.0", + "_vid": "com.nevion.evertz_5782enc-0.1.0", + "attachments": [ + { + "description": "Default", + "name": "evertz_5782enc" + } + ], + "configurableDevice": false, + "configurableModule": [], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "Custom setting fields for Evertz drivers", + "label": "Evertz" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map", + "values": { + "com.nevion.evertz.enable_frame_controller": { + "_schema": { + "default": false, + "descriptor": { + "desc": "Control card through Frame Controller", + "label": "Enable Frame Controller" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "bool" + } + }, + "com.nevion.evertz.frame_controller_slot": { + "_schema": { + "default": 1, + "descriptor": { + "desc": "Defines which slot will be used for communication", + "label": "Frame Controller Slot" + }, + "isNullable": false, + "options": [], + "ranges": [ + [ + 1, + 15, + 1 + ] + ], + "status": "Current", + "type": "number", + "units": "" + } + } + } + } + }, + "customSettingsSeed": {}, + "description": "Driver for Evertz 5782 encoder", + "deviceType": "driver", + "discoveredBy": null, + "exists": "No", + "iconType": "encoder", + "ipAddress": null, + "label": "Evertz 5782 Encoder", + "modules": [], + "name": "evertz_5782enc", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.nevion", + "products": [], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.2.1.1.2.0" + ], + "snmpSysObjectIdValue": null, + "status": "", + "supportedApis": [ + "CoreLike", + "DeviceLike", + "StatusLike", + "PortLike" + ], + "supportsExternal": true, + "supportsParameterApi": false, + "uniqueMetricDefs": [], + "updateableDevice": false, + "updateableModule": {}, + "version": "0.1.0" + }, + { + "_id": "com.nevion.evertz_7800fc-0.1.0", + "_vid": "com.nevion.evertz_7800fc-0.1.0", + "attachments": [ + { + "description": "Default", + "name": "evertz_7800fc" + } + ], + "configurableDevice": false, + "configurableModule": [], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "customSettingsSeed": {}, + "description": "Driver for Evertz 7800 Frame Controller", + "deviceType": "driver", + "discoveredBy": null, + "exists": "No", + "iconType": "default", + "ipAddress": null, + "label": "Evertz 7800 FC", + "modules": [], + "name": "evertz_7800fc", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.nevion", + "products": [], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.2.1.1.2.0" + ], + "snmpSysObjectIdValue": null, + "status": "", + "supportedApis": [ + "CoreLike", + "DeviceLike", + "StatusLike" + ], + "supportsExternal": true, + "supportsParameterApi": false, + "uniqueMetricDefs": [], + "updateableDevice": false, + "updateableModule": {}, + "version": "0.1.0" + }, + { + "_id": "com.nevion.evertz_7880ipg8_10ge2-0.1.0", + "_vid": "com.nevion.evertz_7880ipg8_10ge2-0.1.0", + "attachments": [ + { + "description": "Default", + "name": "evertz_7880ipg8_10ge2" + } + ], + "configurableDevice": false, + "configurableModule": [], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "customSettingsSeed": {}, + "description": "Media gateway", + "deviceType": "driver", + "discoveredBy": null, + "exists": "No", + "iconType": "none", + "ipAddress": null, + "label": "Evertz 7880IPG8-10GE2", + "modules": [], + "name": "evertz_7880ipg8_10ge2", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.nevion", + "products": [], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.2.1.1.2.0" + ], + "snmpSysObjectIdValue": null, + "status": "", + "supportedApis": [ + "ParameterControlLike", + "CoreLike", + "DeviceLike", + "PortLike" + ], + "supportsExternal": true, + "supportsParameterApi": true, + "uniqueMetricDefs": [], + "updateableDevice": false, + "updateableModule": {}, + "version": "0.1.0" + }, + { + "_id": "com.nevion.evertz_7882dec-0.1.0", + "_vid": "com.nevion.evertz_7882dec-0.1.0", + "attachments": [ + { + "description": "Default", + "name": "evertz_7882dec" + } + ], + "configurableDevice": false, + "configurableModule": [], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "Custom setting fields for Evertz drivers", + "label": "Evertz" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map", + "values": { + "com.nevion.evertz.enable_frame_controller": { + "_schema": { + "default": false, + "descriptor": { + "desc": "Control card through Frame Controller", + "label": "Enable Frame Controller" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "bool" + } + }, + "com.nevion.evertz.frame_controller_slot": { + "_schema": { + "default": 1, + "descriptor": { + "desc": "Defines which slot will be used for communication", + "label": "Frame Controller Slot" + }, + "isNullable": false, + "options": [], + "ranges": [ + [ + 1, + 15, + 1 + ] + ], + "status": "Current", + "type": "number", + "units": "" + } + } + } + } + }, + "customSettingsSeed": {}, + "description": "Driver for Evertz 7882 decoder", + "deviceType": "driver", + "discoveredBy": null, + "exists": "No", + "iconType": "decoder", + "ipAddress": null, + "label": "Evertz 7882 Decoder", + "modules": [], + "name": "evertz_7882dec", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.nevion", + "products": [], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.2.1.1.2.0" + ], + "snmpSysObjectIdValue": null, + "status": "", + "supportedApis": [ + "CoreLike", + "DeviceLike", + "StatusLike", + "PortLike" + ], + "supportsExternal": true, + "supportsParameterApi": false, + "uniqueMetricDefs": [], + "updateableDevice": false, + "updateableModule": {}, + "version": "0.1.0" + }, + { + "_id": "com.nevion.evertz_7882enc-0.1.0", + "_vid": "com.nevion.evertz_7882enc-0.1.0", + "attachments": [ + { + "description": "Default", + "name": "evertz_7882enc" + } + ], + "configurableDevice": false, + "configurableModule": [], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "Custom setting fields for Evertz drivers", + "label": "Evertz" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map", + "values": { + "com.nevion.evertz.enable_frame_controller": { + "_schema": { + "default": false, + "descriptor": { + "desc": "Control card through Frame Controller", + "label": "Enable Frame Controller" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "bool" + } + }, + "com.nevion.evertz.frame_controller_slot": { + "_schema": { + "default": 1, + "descriptor": { + "desc": "Defines which slot will be used for communication", + "label": "Frame Controller Slot" + }, + "isNullable": false, + "options": [], + "ranges": [ + [ + 1, + 15, + 1 + ] + ], + "status": "Current", + "type": "number", + "units": "" + } + } + } + } + }, + "customSettingsSeed": {}, + "description": "Driver for Evertz 7882 encoder", + "deviceType": "driver", + "discoveredBy": null, + "exists": "No", + "iconType": "encoder", + "ipAddress": null, + "label": "Evertz 7882 Encoder", + "modules": [], + "name": "evertz_7882enc", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.nevion", + "products": [], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.2.1.1.2.0" + ], + "snmpSysObjectIdValue": null, + "status": "", + "supportedApis": [ + "CoreLike", + "DeviceLike", + "StatusLike", + "PortLike" + ], + "supportsExternal": true, + "supportsParameterApi": false, + "uniqueMetricDefs": [], + "updateableDevice": false, + "updateableModule": {}, + "version": "0.1.0" + }, + { + "_id": "com.nevion.evertz_quartz-0.1.0", + "_vid": "com.nevion.evertz_quartz-0.1.0", + "attachments": [ + { + "description": "Default", + "name": "evertz_quartz" + } + ], + "configurableDevice": false, + "configurableModule": [], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "Custom setting fields for Evertz Quartz", + "label": "Evertz Quartz" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map", + "values": { + "com.nevion.evertz_quartz.destination": { + "_schema": { + "default": 32, + "descriptor": { + "desc": "Max number of destinations", + "label": "Destinations" + }, + "isNullable": false, + "options": [], + "ranges": [ + [ + 0, + 32, + 1 + ] + ], + "status": "Current", + "type": "number", + "units": "" + } + }, + "com.nevion.evertz_quartz.port": { + "_schema": { + "default": 10023, + "descriptor": { + "desc": "Port", + "label": "Port" + }, + "isNullable": false, + "options": [], + "ranges": [ + [ + 0, + 65535, + 1 + ] + ], + "status": "Current", + "type": "number", + "units": "" + } + }, + "com.nevion.evertz_quartz.sources": { + "_schema": { + "default": 32, + "descriptor": { + "desc": "Max number of sources", + "label": "Sources" + }, + "isNullable": false, + "options": [], + "ranges": [ + [ + 0, + 32, + 1 + ] + ], + "status": "Current", + "type": "number", + "units": "" + } + } + } + } + }, + "customSettingsSeed": {}, + "description": "Driver for Evertz Quartz", + "deviceType": "switch", + "discoveredBy": null, + "exists": "No", + "iconType": "none", + "ipAddress": null, + "label": "Evertz Quartz", + "modules": [], + "name": "evertz_quartz", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.nevion", + "products": [], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.2.1.1.2.0" + ], + "snmpSysObjectIdValue": null, + "status": "", + "supportedApis": [ + "CoreLike", + "DeviceLike", + "PortLike" + ], + "supportsExternal": true, + "supportsParameterApi": false, + "uniqueMetricDefs": [], + "updateableDevice": false, + "updateableModule": {}, + "version": "0.1.0" + }, + { + "_id": "com.nevion.flexAI-0.1.0", + "_vid": "com.nevion.flexAI-0.1.0", + "attachments": [ + { + "description": "Default", + "name": "flexAI" + } + ], + "configurableDevice": false, + "configurableModule": [], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "Custom setting fields for flexAI", + "label": "flexAI" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map", + "values": { + "com.nevion.emberplus.keepalives": { + "_schema": { + "default": true, + "descriptor": { + "desc": "If selected, keep-alives will be used to determine reachability", + "label": "Send keep-alives" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "bool" + } + }, + "com.nevion.emberplus.port": { + "_schema": { + "default": 9000, + "descriptor": { + "desc": "Port", + "label": "Port" + }, + "isNullable": false, + "options": [], + "ranges": [ + [ + 0, + 65535, + 1 + ] + ], + "status": "Current", + "type": "number", + "units": "" + } + }, + "com.nevion.emberplus.queue": { + "_schema": { + "default": true, + "descriptor": { + "desc": "", + "label": "Request queueing" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "bool" + } + }, + "com.nevion.emberplus.suppress_illegal": { + "_schema": { + "default": false, + "descriptor": { + "desc": "", + "label": "Suppress illegal update warnings" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "bool" + } + }, + "com.nevion.emberplus.trace": { + "_schema": { + "default": false, + "descriptor": { + "desc": "", + "label": "Tracing (logging intensive)" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "bool" + } + } + } + } + }, + "customSettingsSeed": {}, + "description": "A driver for the Flexible Audio Infrastructure (FlexAI) made by Jünger", + "deviceType": "driver", + "discoveredBy": null, + "exists": "No", + "iconType": "none", + "ipAddress": null, + "label": "Jünger FlexAI", + "modules": [], + "name": "flexAI", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.nevion", + "products": [], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.2.1.1.2.0" + ], + "snmpSysObjectIdValue": null, + "status": "", + "supportedApis": [ + "ParameterControlLike", + "CoreLike", + "DeviceLike", + "PortLike" + ], + "supportsExternal": true, + "supportsParameterApi": true, + "uniqueMetricDefs": [], + "updateableDevice": false, + "updateableModule": {}, + "version": "0.1.0" + }, + { + "_id": "com.nevion.generic_emberplus-0.1.0", + "_vid": "com.nevion.generic_emberplus-0.1.0", + "attachments": [ + { + "description": "Default", + "name": "generic_emberplus" + } + ], + "configurableDevice": false, + "configurableModule": [], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "Custom setting fields for generic_emberplus", + "label": "generic_emberplus" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map", + "values": { + "com.nevion.emberplus.keepalives": { + "_schema": { + "default": true, + "descriptor": { + "desc": "If selected, keep-alives will be used to determine reachability", + "label": "Send keep-alives" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "bool" + } + }, + "com.nevion.emberplus.port": { + "_schema": { + "default": 9000, + "descriptor": { + "desc": "Port", + "label": "Port" + }, + "isNullable": false, + "options": [], + "ranges": [ + [ + 0, + 65535, + 1 + ] + ], + "status": "Current", + "type": "number", + "units": "" + } + }, + "com.nevion.emberplus.queue": { + "_schema": { + "default": true, + "descriptor": { + "desc": "", + "label": "Request queueing" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "bool" + } + }, + "com.nevion.emberplus.suppress_illegal": { + "_schema": { + "default": false, + "descriptor": { + "desc": "", + "label": "Suppress illegal update warnings" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "bool" + } + }, + "com.nevion.emberplus.trace": { + "_schema": { + "default": false, + "descriptor": { + "desc": "", + "label": "Tracing (logging intensive)" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "bool" + } + } + } + } + }, + "customSettingsSeed": {}, + "description": "Driver for a generic Ember+ device", + "deviceType": "generic", + "discoveredBy": null, + "exists": "No", + "iconType": "none", + "ipAddress": null, + "label": "Generic Ember+ driver", + "modules": [], + "name": "generic_emberplus", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.nevion", + "products": [], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.2.1.1.2.0" + ], + "snmpSysObjectIdValue": null, + "status": "", + "supportedApis": [ + "ParameterControlLike", + "CoreLike", + "DeviceLike" + ], + "supportsExternal": true, + "supportsParameterApi": true, + "uniqueMetricDefs": [], + "updateableDevice": false, + "updateableModule": {}, + "version": "0.1.0" + }, + { + "_id": "com.nevion.generic_snmp-0.1.0", + "_vid": "com.nevion.generic_snmp-0.1.0", + "attachments": [ + { + "description": "Default", + "name": "generic_snmp" + } + ], + "configurableDevice": false, + "configurableModule": [], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "customSettingsSeed": {}, + "description": "Driver for a generic SNMP device", + "deviceType": "generic", + "discoveredBy": null, + "exists": "No", + "iconType": "none", + "ipAddress": null, + "label": "Generic SNMP driver", + "modules": [], + "name": "generic_snmp", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.nevion", + "products": [], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.2.1.1.2.0" + ], + "snmpSysObjectIdValue": null, + "status": "", + "supportedApis": [ + "ParameterControlLike", + "CoreLike", + "DeviceLike" + ], + "supportsExternal": true, + "supportsParameterApi": true, + "uniqueMetricDefs": [], + "updateableDevice": false, + "updateableModule": {}, + "version": "0.1.0" + }, + { + "_id": "com.nevion.gigacaster2-0.1.0", + "_vid": "com.nevion.gigacaster2-0.1.0", + "attachments": [ + { + "description": "Default", + "name": "gigacaster2" + } + ], + "configurableDevice": false, + "configurableModule": [], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "customSettingsSeed": {}, + "description": "Driver for Enensys GigaCaster II", + "deviceType": "driver", + "discoveredBy": null, + "exists": "No", + "iconType": "gateway", + "ipAddress": null, + "label": "GigaCaster II", + "modules": [], + "name": "gigacaster2", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.nevion", + "products": [], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.2.1.1.2.0", + "1.3.6.1.2.1.1.1.0" + ], + "snmpSysObjectIdValue": null, + "status": "", + "supportedApis": [ + "ParameterControlLike", + "DeviceLike", + "DynamicLike", + "PortLike", + "CoreLike", + "StatusLike" + ], + "supportsExternal": true, + "supportsParameterApi": true, + "uniqueMetricDefs": [], + "updateableDevice": false, + "updateableModule": {}, + "version": "0.1.0" + }, + { + "_id": "com.nevion.gredos-02.22.01", + "_vid": "com.nevion.gredos-02.22.01", + "attachments": [ + { + "description": "Default", + "name": "gredos" + } + ], + "configurableDevice": false, + "configurableModule": [], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "customSettingsSeed": {}, + "description": "Broadcast Decoder", + "deviceType": "driver", + "discoveredBy": null, + "exists": "No", + "iconType": "decoder", + "ipAddress": null, + "label": "SAPEC Gredos", + "modules": [], + "name": "gredos", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.nevion", + "products": [ + { + "name": "GREDOS", + "swBuildTime": null, + "swVersion": null + } + ], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.2.1.1.2.0", + "1.3.6.1.4.1.21664.201.1.1.5.1.2.98.114.97.110.100.73.100" + ], + "snmpSysObjectIdValue": "1.3.6.1.4.1.21664.201.10.10", + "status": "", + "supportedApis": [ + "ParameterControlLike", + "DeviceLike", + "DynamicLike", + "PortLike", + "CoreLike", + "StatusLike" + ], + "supportsExternal": true, + "supportsParameterApi": true, + "uniqueMetricDefs": [], + "updateableDevice": false, + "updateableModule": {}, + "version": "02.22.01" + }, + { + "_id": "com.nevion.gv_kahuna-0.1.0", + "_vid": "com.nevion.gv_kahuna-0.1.0", + "attachments": [ + { + "description": "Default", + "name": "gv_kahuna" + } + ], + "configurableDevice": false, + "configurableModule": [], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "Custom setting fields for Grass Valley Kahuna", + "label": "Grass Valley Kahuna" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map", + "values": { + "com.nevion.gv_kahuna.port": { + "_schema": { + "default": 2022, + "descriptor": { + "desc": "Port", + "label": "Port" + }, + "isNullable": false, + "options": [], + "ranges": [ + [ + 0, + 65535, + 1 + ] + ], + "status": "Current", + "type": "number", + "units": "" + } + } + } + } + }, + "customSettingsSeed": {}, + "description": "Driver for Grass Valley Kahuna Vision Mixers", + "deviceType": "driver", + "discoveredBy": null, + "exists": "No", + "iconType": "none", + "ipAddress": null, + "label": "GV Kahuna", + "modules": [], + "name": "gv_kahuna", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.nevion", + "products": [], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.2.1.1.2.0" + ], + "snmpSysObjectIdValue": null, + "status": "", + "supportedApis": [ + "CoreLike", + "DeviceLike", + "PortLike" + ], + "supportsExternal": true, + "supportsParameterApi": false, + "uniqueMetricDefs": [], + "updateableDevice": false, + "updateableModule": {}, + "version": "0.1.0" + }, + { + "_id": "com.nevion.haivision-0.0.1", + "_vid": "com.nevion.haivision-0.0.1", + "attachments": [ + { + "description": "Default", + "name": "haivision" + } + ], + "configurableDevice": false, + "configurableModule": [], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "customSettingsSeed": {}, + "description": "A driver towards HaiVision codecs, using HaiVision MIB-files", + "deviceType": "driver", + "discoveredBy": null, + "exists": "No", + "iconType": "encoderDecoder", + "ipAddress": null, + "label": "HaiVision Codec", + "modules": [], + "name": "haivision", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.nevion", + "products": [ + { + "name": "HaiVision Makito2", + "swBuildTime": null, + "swVersion": null + } + ], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.2.1.1.2.0" + ], + "snmpSysObjectIdValue": null, + "status": "", + "supportedApis": [ + "ParameterControlLike", + "DeviceLike", + "DynamicLike", + "PortLike", + "CoreLike", + "StatusLike" + ], + "supportsExternal": true, + "supportsParameterApi": true, + "uniqueMetricDefs": [], + "updateableDevice": false, + "updateableModule": {}, + "version": "0.0.1" + }, + { + "_id": "com.nevion.huawei_ce8800_6800-0.1.0", + "_vid": "com.nevion.huawei_ce8800_6800-0.1.0", + "attachments": [ + { + "description": "Default", + "name": "huawei_ce8800_6800" + } + ], + "configurableDevice": false, + "configurableModule": [], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "Custom setting fields for Huawei Cloud Engine", + "label": "huawei_ce8800_6800" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map", + "values": { + "com.nevion.huawei_ce8800_6800.is_upstream": { + "_schema": { + "default": false, + "descriptor": { + "desc": "PIM Upstream is supported only on CE6885 CE8855 with V300R0024 software", + "label": "Enable PIM Upstream" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "bool" + } + } + } + } + }, + "customSettingsSeed": {}, + "description": "Driver for devices in the Huawei CloudEngine 6800/8800 product families", + "deviceType": "switch", + "discoveredBy": null, + "exists": "No", + "iconType": "ipSwitchRouter", + "ipAddress": null, + "label": "Huawei CloudEngine 68XX/88XX", + "modules": [], + "name": "huawei_ce8800_6800", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.nevion", + "products": [], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "netconf": 830, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.2.1.1.2.0" + ], + "snmpSysObjectIdValue": null, + "status": "", + "supportedApis": [ + "CoreLike", + "DeviceLike", + "StatusLike", + "PortLike" + ], + "supportsExternal": true, + "supportsParameterApi": false, + "uniqueMetricDefs": [], + "updateableDevice": false, + "updateableModule": {}, + "version": "0.1.0" + }, + { + "_id": "com.nevion.huawei_cloudengine-0.1.0", + "_vid": "com.nevion.huawei_cloudengine-0.1.0", + "attachments": [ + { + "description": "Default", + "name": "huawei_cloudengine" + } + ], + "configurableDevice": false, + "configurableModule": [], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "customSettingsSeed": {}, + "description": "Driver for Huawei CloudEngine devices", + "deviceType": "switch", + "discoveredBy": null, + "exists": "No", + "iconType": "ipSwitchRouter", + "ipAddress": null, + "label": "Huawei CloudEngine", + "modules": [], + "name": "huawei_cloudengine", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.nevion", + "products": [], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "netconf": 830, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.2.1.1.2.0" + ], + "snmpSysObjectIdValue": null, + "status": "", + "supportedApis": [ + "ParameterControlLike", + "DeviceLike", + "PortLike", + "CoreLike", + "StatusLike" + ], + "supportsExternal": true, + "supportsParameterApi": true, + "uniqueMetricDefs": [], + "updateableDevice": false, + "updateableModule": {}, + "version": "0.1.0" + }, + { + "_id": "com.nevion.huawei_netengine-0.1.0", + "_vid": "com.nevion.huawei_netengine-0.1.0", + "attachments": [ + { + "description": "Default", + "name": "huawei_netengine" + } + ], + "configurableDevice": false, + "configurableModule": [], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "customSettingsSeed": {}, + "description": "Driver for Huawei NetEngine devices", + "deviceType": "switch", + "discoveredBy": null, + "exists": "No", + "iconType": "ipSwitchRouter", + "ipAddress": null, + "label": "Huawei NetEngine", + "modules": [], + "name": "huawei_netengine", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.nevion", + "products": [], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "netconf": 830, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.2.1.1.2.0" + ], + "snmpSysObjectIdValue": null, + "status": "", + "supportedApis": [ + "ParameterControlLike", + "DeviceLike", + "PortLike", + "CoreLike", + "StatusLike" + ], + "supportsExternal": true, + "supportsParameterApi": true, + "uniqueMetricDefs": [], + "updateableDevice": false, + "updateableModule": {}, + "version": "0.1.0" + }, + { + "_id": "com.nevion.iothink-0.1.0", + "_vid": "com.nevion.iothink-0.1.0", + "attachments": [ + { + "description": "Default", + "name": "iothink" + } + ], + "configurableDevice": false, + "configurableModule": [], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "customSettingsSeed": {}, + "description": "Driver for Moxa ioThink 4510", + "deviceType": "driver", + "discoveredBy": null, + "exists": "No", + "iconType": "gateway", + "ipAddress": null, + "label": "ioThink 4510", + "modules": [], + "name": "iothink", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.nevion", + "products": [], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.2.1.1.2.0" + ], + "snmpSysObjectIdValue": null, + "status": "", + "supportedApis": [ + "ParameterControlLike", + "GPIOLike", + "CoreLike", + "DeviceLike" + ], + "supportsExternal": true, + "supportsParameterApi": true, + "uniqueMetricDefs": [], + "updateableDevice": false, + "updateableModule": {}, + "version": "0.1.0" + }, + { + "_id": "com.nevion.iqoyalink_ic-0.1.0", + "_vid": "com.nevion.iqoyalink_ic-0.1.0", + "attachments": [ + { + "description": "Default", + "name": "iqoyalink_ic" + } + ], + "configurableDevice": false, + "configurableModule": [], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "customSettingsSeed": {}, + "description": "Driver for Digigram IQOYA *LINK/IC", + "deviceType": "driver", + "discoveredBy": null, + "exists": "No", + "iconType": "encoderDecoder", + "ipAddress": null, + "label": "IQOYA *LINK/IC", + "modules": [], + "name": "iqoyalink_ic", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.nevion", + "products": [], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.2.1.1.2.0", + "1.3.6.1.4.1.23901.1.3.1.0" + ], + "snmpSysObjectIdValue": "1.3.6.1.4.1.8072.3.2.10", + "status": "", + "supportedApis": [ + "ParameterControlLike", + "DeviceLike", + "PortLike", + "CoreLike", + "StatusLike" + ], + "supportsExternal": true, + "supportsParameterApi": true, + "uniqueMetricDefs": [], + "updateableDevice": false, + "updateableModule": {}, + "version": "0.1.0" + }, + { + "_id": "com.nevion.iqoyalink_le-0.1.0", + "_vid": "com.nevion.iqoyalink_le-0.1.0", + "attachments": [ + { + "description": "Default", + "name": "iqoyalink_le" + } + ], + "configurableDevice": false, + "configurableModule": [], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "customSettingsSeed": {}, + "description": "Driver for Digigram IQOYA *LINK/LE", + "deviceType": "driver", + "discoveredBy": null, + "exists": "No", + "iconType": "encoderDecoder", + "ipAddress": null, + "label": "IQOYA *LINK/LE", + "modules": [], + "name": "iqoyalink_le", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.nevion", + "products": [], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.2.1.1.2.0", + "1.3.6.1.4.1.23901.1.3.1.0" + ], + "snmpSysObjectIdValue": "1.3.6.1.4.1.8072.3.2.10", + "status": "", + "supportedApis": [ + "ParameterControlLike", + "DeviceLike", + "PortLike", + "CoreLike", + "StatusLike" + ], + "supportsExternal": true, + "supportsParameterApi": true, + "uniqueMetricDefs": [], + "updateableDevice": false, + "updateableModule": {}, + "version": "0.1.0" + }, + { + "_id": "com.nevion.juniper_ex-0.1.0", + "_vid": "com.nevion.juniper_ex-0.1.0", + "attachments": [ + { + "description": "Default", + "name": "juniper_ex" + } + ], + "configurableDevice": false, + "configurableModule": [], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "customSettingsSeed": {}, + "description": "Driver for Juniper EX devices", + "deviceType": "switch", + "discoveredBy": null, + "exists": "No", + "iconType": "ipSwitchRouter", + "ipAddress": null, + "label": "Juniper EX", + "modules": [], + "name": "juniper_ex", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.nevion", + "products": [], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.2.1.1.2.0" + ], + "snmpSysObjectIdValue": null, + "status": "", + "supportedApis": [ + "ParameterControlLike", + "CoreLike", + "DeviceLike", + "StatusLike" + ], + "supportsExternal": true, + "supportsParameterApi": true, + "uniqueMetricDefs": [], + "updateableDevice": false, + "updateableModule": {}, + "version": "0.1.0" + }, + { + "_id": "com.nevion.laguna-0.1.0", + "_vid": "com.nevion.laguna-0.1.0", + "attachments": [ + { + "description": "Default", + "name": "laguna" + } + ], + "configurableDevice": false, + "configurableModule": [], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "customSettingsSeed": {}, + "description": "Media processor", + "deviceType": "driver", + "discoveredBy": null, + "exists": "No", + "iconType": "none", + "ipAddress": null, + "label": "Sapec Laguna", + "modules": [], + "name": "laguna", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.nevion", + "products": [ + { + "name": "LAGUNA", + "swBuildTime": null, + "swVersion": null + } + ], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.2.1.1.2.0" + ], + "snmpSysObjectIdValue": null, + "status": "", + "supportedApis": [ + "CoreLike", + "DeviceLike", + "StatusLike", + "PortLike" + ], + "supportsExternal": true, + "supportsParameterApi": false, + "uniqueMetricDefs": [], + "updateableDevice": false, + "updateableModule": {}, + "version": "0.1.0" + }, + { + "_id": "com.nevion.lawo_ravenna-0.1.0", + "_vid": "com.nevion.lawo_ravenna-0.1.0", + "attachments": [ + { + "description": "Default", + "name": "lawo_ravenna" + } + ], + "configurableDevice": false, + "configurableModule": [], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "Custom setting fields for lawo_ravenna", + "label": "lawo_ravenna" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map", + "values": { + "com.nevion.emberplus.keepalives": { + "_schema": { + "default": true, + "descriptor": { + "desc": "If selected, keep-alives will be used to determine reachability", + "label": "Send keep-alives" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "bool" + } + }, + "com.nevion.emberplus.port": { + "_schema": { + "default": 9000, + "descriptor": { + "desc": "Port", + "label": "Port" + }, + "isNullable": false, + "options": [], + "ranges": [ + [ + 0, + 65535, + 1 + ] + ], + "status": "Current", + "type": "number", + "units": "" + } + }, + "com.nevion.emberplus.queue": { + "_schema": { + "default": true, + "descriptor": { + "desc": "", + "label": "Request queueing" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "bool" + } + }, + "com.nevion.emberplus.request_separation": { + "_schema": { + "default": 0, + "descriptor": { + "desc": "Set to zero to disable.", + "label": "Request Separation [ms]" + }, + "isNullable": false, + "options": [], + "ranges": [ + [ + 0, + 250, + 1 + ] + ], + "status": "Current", + "type": "number", + "units": "" + } + }, + "com.nevion.emberplus.suppress_illegal": { + "_schema": { + "default": true, + "descriptor": { + "desc": "", + "label": "Suppress illegal update warnings" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "bool" + } + }, + "com.nevion.emberplus.trace": { + "_schema": { + "default": false, + "descriptor": { + "desc": "", + "label": "Tracing (logging intensive)" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "bool" + } + }, + "com.nevion.lawo_ravenna.ctrl_local_addr": { + "_schema": { + "default": false, + "descriptor": { + "desc": "", + "label": "Control Local Addresses" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "bool" + } + } + } + } + }, + "customSettingsSeed": {}, + "description": "", + "deviceType": "driver", + "discoveredBy": null, + "exists": "No", + "iconType": "none", + "ipAddress": null, + "label": "Lawo Ravenna", + "modules": [], + "name": "lawo_ravenna", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.nevion", + "products": [], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.2.1.1.2.0" + ], + "snmpSysObjectIdValue": null, + "status": "", + "supportedApis": [ + "ParameterControlLike", + "DeviceLike", + "PortLike", + "CoreLike", + "StatusLike" + ], + "supportsExternal": true, + "supportsParameterApi": true, + "uniqueMetricDefs": [], + "updateableDevice": false, + "updateableModule": {}, + "version": "0.1.0" + }, + { + "_id": "com.nevion.liebert_nx-0.1.0", + "_vid": "com.nevion.liebert_nx-0.1.0", + "attachments": [ + { + "description": "Default", + "name": "liebert_nx" + } + ], + "configurableDevice": false, + "configurableModule": [], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "customSettingsSeed": {}, + "description": "A/V Content Monitoring & Multiviewer", + "deviceType": "driver", + "discoveredBy": null, + "exists": "No", + "iconType": "device", + "ipAddress": null, + "label": "Liebert NX", + "modules": [], + "name": "liebert_nx", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.nevion", + "products": [], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.2.1.1.2.0" + ], + "snmpSysObjectIdValue": "1.3.6.1.4.1.2021.250.10", + "status": "", + "supportedApis": [ + "ParameterControlLike", + "CoreLike", + "DeviceLike", + "StatusLike" + ], + "supportsExternal": true, + "supportsParameterApi": true, + "uniqueMetricDefs": [], + "updateableDevice": false, + "updateableModule": {}, + "version": "0.1.0" + }, + { + "_id": "com.nevion.lvb440-1.0.0", + "_vid": "com.nevion.lvb440-1.0.0", + "attachments": [ + { + "description": "Default", + "name": "lvb440" + } + ], + "configurableDevice": true, + "configurableModule": [], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "customSettingsSeed": {}, + "description": "A Driver for the monitoring device Leader LVB440", + "deviceType": "driver", + "discoveredBy": null, + "exists": "No", + "iconType": "monitor", + "ipAddress": null, + "label": "Leader LVB440", + "modules": [], + "name": "lvb440", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.nevion", + "products": [ + { + "name": "LVB440", + "swBuildTime": null, + "swVersion": null + } + ], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.2.1.1.2.0" + ], + "snmpSysObjectIdValue": null, + "status": "", + "supportedApis": [ + "MatrixControlLike", + "DeviceLike", + "MaintenanceLike", + "PortLike", + "CoreLike", + "StatusLike" + ], + "supportsExternal": true, + "supportsParameterApi": false, + "uniqueMetricDefs": [], + "updateableDevice": false, + "updateableModule": {}, + "version": "1.0.0" + }, + { + "_id": "com.nevion.m2lx-1.0.0", + "_vid": "com.nevion.m2lx-1.0.0", + "attachments": [ + { + "description": "Default", + "name": "m2lx" + } + ], + "configurableDevice": false, + "configurableModule": [], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "Custom setting fields for m2lx", + "label": "m2lx" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map", + "values": { + "com.nevion.m2lx.auto_start": { + "_schema": { + "default": true, + "descriptor": { + "desc": "", + "label": "Automatically start event" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "bool" + } + }, + "com.nevion.m2lx.event_name": { + "_schema": { + "default": "", + "descriptor": { + "desc": "", + "label": "Event Name" + }, + "encoding": "UTF-8", + "isNullable": false, + "lengthRanges": [], + "options": [], + "status": "Current", + "type": "string" + } + } + } + } + }, + "customSettingsSeed": {}, + "description": "A Driver for the Sony M2LX", + "deviceType": "driver", + "discoveredBy": null, + "exists": "No", + "iconType": "videoMixer", + "ipAddress": null, + "label": "Sony M2LX", + "modules": [], + "name": "m2lx", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.nevion", + "products": [ + { + "name": "M2LX", + "swBuildTime": null, + "swVersion": null + } + ], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.2.1.1.2.0" + ], + "snmpSysObjectIdValue": null, + "status": "", + "supportedApis": [ + "CoreLike", + "DeviceLike", + "PortLike" + ], + "supportsExternal": true, + "supportsParameterApi": false, + "uniqueMetricDefs": [], + "updateableDevice": false, + "updateableModule": {}, + "version": "1.0.0" + }, + { + "_id": "com.nevion.maxiva-0.1.0", + "_vid": "com.nevion.maxiva-0.1.0", + "attachments": [ + { + "description": "Default", + "name": "maxiva" + } + ], + "configurableDevice": false, + "configurableModule": [], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "customSettingsSeed": {}, + "description": " A/V Content Monitoring & Multiviewer", + "deviceType": "driver", + "discoveredBy": null, + "exists": "No", + "iconType": "device", + "ipAddress": null, + "label": "Maxiva", + "modules": [], + "name": "maxiva", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.nevion", + "products": [ + { + "name": "MSC2 LITE 1+1", + "swBuildTime": null, + "swVersion": null + }, + { + "name": "UAX-250T2", + "swBuildTime": null, + "swVersion": null + }, + { + "name": "UAX-500T2", + "swBuildTime": null, + "swVersion": null + } + ], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.2.1.1.2.0" + ], + "snmpSysObjectIdValue": "1.3.6.1.4.1.290.9.2.1.1", + "status": "", + "supportedApis": [ + "ParameterControlLike", + "CoreLike", + "DeviceLike", + "StatusLike" + ], + "supportsExternal": true, + "supportsParameterApi": true, + "uniqueMetricDefs": [], + "updateableDevice": false, + "updateableModule": {}, + "version": "0.1.0" + }, + { + "_id": "com.nevion.maxiva_uaxop4p6e-0.1.0", + "_vid": "com.nevion.maxiva_uaxop4p6e-0.1.0", + "attachments": [ + { + "description": "Default", + "name": "maxiva_uaxop4p6e" + } + ], + "configurableDevice": false, + "configurableModule": [], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "customSettingsSeed": {}, + "description": "Simple Network Management Protocol (SNMP) Gateway", + "deviceType": "driver", + "discoveredBy": null, + "exists": "No", + "iconType": "device", + "ipAddress": null, + "label": "Maxiva UAX-OP-4P6E", + "modules": [], + "name": "maxiva_uaxop4p6e", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.nevion", + "products": [ + { + "name": "Maxiva SNMP", + "swBuildTime": null, + "swVersion": null + } + ], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.4.1.43768.3.1.1.9.2.3.4.0" + ], + "snmpSysObjectIdValue": "1.3.6.1.4.1.100000", + "status": "", + "supportedApis": [ + "ParameterControlLike", + "CoreLike", + "DeviceLike", + "StatusLike" + ], + "supportsExternal": true, + "supportsParameterApi": true, + "uniqueMetricDefs": [], + "updateableDevice": false, + "updateableModule": {}, + "version": "0.1.0" + }, + { + "_id": "com.nevion.maxiva_uaxt30uc-0.1.0", + "_vid": "com.nevion.maxiva_uaxt30uc-0.1.0", + "attachments": [ + { + "description": "Default", + "name": "maxiva_uaxt30uc" + } + ], + "configurableDevice": false, + "configurableModule": [], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "customSettingsSeed": {}, + "description": "Simple Network Management Protocol (SNMP) Gateway", + "deviceType": "driver", + "discoveredBy": null, + "exists": "No", + "iconType": "device", + "ipAddress": null, + "label": "Maxiva UAXT-30-UC", + "modules": [], + "name": "maxiva_uaxt30uc", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.nevion", + "products": [ + { + "name": "Maxiva SNMP", + "swBuildTime": null, + "swVersion": null + } + ], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.4.1.43768.3.1.1.8.2.3.4.0" + ], + "snmpSysObjectIdValue": "1.3.6.1.4.1.100000", + "status": "", + "supportedApis": [ + "ParameterControlLike", + "CoreLike", + "DeviceLike", + "StatusLike" + ], + "supportsExternal": true, + "supportsParameterApi": true, + "uniqueMetricDefs": [], + "updateableDevice": false, + "updateableModule": {}, + "version": "0.1.0" + }, + { + "_id": "com.nevion.md8000-0.1.0", + "_vid": "com.nevion.md8000-0.1.0", + "attachments": [ + { + "description": "Default", + "name": "md8000" + } + ], + "configurableDevice": false, + "configurableModule": [], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "Custom setting fields for MD8000 family", + "label": "MD8000 family" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map", + "values": { + "com.nevion.md8000.mac_table_cache_timeout": { + "_schema": { + "default": 10, + "descriptor": { + "desc": "Timeout in seconds. Upon reaching the timeout, the cache is considered stale and will be invalidated", + "label": "MAC table cache timeout" + }, + "isNullable": false, + "options": [], + "ranges": [ + [ + 0, + 300, + 1 + ] + ], + "status": "Current", + "type": "number", + "units": "" + } + }, + "com.nevion.md8000.report_alerts": { + "_schema": { + "default": "yes", + "descriptor": { + "desc": "Toggles whether or not the driver reports alerts", + "label": "Report alerts" + }, + "encoding": "UTF-8", + "isNullable": false, + "lengthRanges": [], + "options": [ + { + "descriptor": { + "desc": "The driver should not report any alerts", + "label": "No" + }, + "value": "no" + }, + { + "descriptor": { + "desc": "The driver should report alerts", + "label": "Yes" + }, + "value": "yes" + } + ], + "status": "Current", + "type": "string" + } + } + } + } + }, + "customSettingsSeed": {}, + "description": "Driver for Media Links MD8000", + "deviceType": "driver", + "discoveredBy": null, + "exists": "No", + "iconType": "encoderDecoder", + "ipAddress": null, + "label": "Media Links MD8000", + "modules": [], + "name": "md8000", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.nevion", + "products": [], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.2.1.1.2.0" + ], + "snmpSysObjectIdValue": "1.3.6.1.4.1.17186.1.10", + "status": "", + "supportedApis": [ + "ParameterControlLike", + "DeviceLike", + "PortLike", + "CoreLike", + "StatusLike" + ], + "supportsExternal": true, + "supportsParameterApi": true, + "uniqueMetricDefs": [], + "updateableDevice": false, + "updateableModule": {}, + "version": "0.1.0" + }, + { + "_id": "com.nevion.mediakind_ce1-0.1.0", + "_vid": "com.nevion.mediakind_ce1-0.1.0", + "attachments": [ + { + "description": "Default", + "name": "mediakind_ce1" + } + ], + "configurableDevice": false, + "configurableModule": [], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "customSettingsSeed": {}, + "description": "Contribution Encoder", + "deviceType": "driver", + "discoveredBy": null, + "exists": "No", + "iconType": "none", + "ipAddress": null, + "label": "MediaKind CE1", + "modules": [], + "name": "mediakind_ce1", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.nevion", + "products": [], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.2.1.1.2.0" + ], + "snmpSysObjectIdValue": null, + "status": "", + "supportedApis": [ + "CoreLike", + "DeviceLike", + "PortLike" + ], + "supportsExternal": true, + "supportsParameterApi": false, + "uniqueMetricDefs": [], + "updateableDevice": false, + "updateableModule": {}, + "version": "0.1.0" + }, + { + "_id": "com.nevion.mediakind_rx1-0.1.0", + "_vid": "com.nevion.mediakind_rx1-0.1.0", + "attachments": [ + { + "description": "Default", + "name": "mediakind_rx1" + } + ], + "configurableDevice": false, + "configurableModule": [], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "customSettingsSeed": {}, + "description": "Contribution Decoder", + "deviceType": "driver", + "discoveredBy": null, + "exists": "No", + "iconType": "none", + "ipAddress": null, + "label": "MediaKind RX1", + "modules": [], + "name": "mediakind_rx1", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.nevion", + "products": [], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.2.1.1.2.0" + ], + "snmpSysObjectIdValue": null, + "status": "", + "supportedApis": [ + "CoreLike", + "DeviceLike", + "PortLike" + ], + "supportsExternal": true, + "supportsParameterApi": false, + "uniqueMetricDefs": [], + "updateableDevice": false, + "updateableModule": {}, + "version": "0.1.0" + }, + { + "_id": "com.nevion.mock-0.1.0", + "_vid": "com.nevion.mock-0.1.0", + "attachments": [ + { + "description": "Default", + "name": "mock" + } + ], + "configurableDevice": false, + "configurableModule": [], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "Custom setting fields for mock", + "label": "mock" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map", + "values": { + "com.nevion.api.sample_flows_interval": { + "_schema": { + "default": 0, + "descriptor": { + "desc": "Interval at which to poll flow stats. 0 to disable.", + "label": "Flow stats interval [s]" + }, + "isNullable": false, + "options": [], + "ranges": [ + [ + 0, + 0, + 1 + ], + [ + 2, + 3600, + 1 + ] + ], + "status": "Current", + "type": "number", + "units": "" + } + }, + "com.nevion.mock.always_compute_rx_sdp": { + "_schema": { + "default": false, + "descriptor": { + "desc": "If enabled, VIP will generate a SDP for a receiver even if the sender does not publish a SDP itself", + "label": "Always compute Rx SDP" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "bool" + } + }, + "com.nevion.mock.always_different": { + "_schema": { + "default": true, + "descriptor": { + "desc": "Skip config apply checks (always different)", + "label": "Skip config apply checks" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "bool" + } + }, + "com.nevion.mock.bulk": { + "_schema": { + "default": true, + "descriptor": { + "desc": "Bulk config", + "label": "Bulk config" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "bool" + } + }, + "com.nevion.mock.codecFormat": { + "_schema": { + "default": "Video", + "descriptor": { + "desc": "", + "label": "Codec format type" + }, + "encoding": "UTF-8", + "isNullable": false, + "lengthRanges": [], + "options": [ + { + "descriptor": { + "desc": "", + "label": "ASI" + }, + "value": "ASI" + }, + { + "descriptor": { + "desc": "", + "label": "Ancillary" + }, + "value": "Ancillary" + }, + { + "descriptor": { + "desc": "", + "label": "Audio" + }, + "value": "Audio" + }, + { + "descriptor": { + "desc": "", + "label": "Video" + }, + "value": "Video" + } + ], + "status": "Current", + "type": "string" + } + }, + "com.nevion.mock.delay": { + "_schema": { + "default": 0, + "descriptor": { + "desc": "Delay", + "label": "Delay" + }, + "isNullable": false, + "options": [], + "ranges": [ + [ + 0, + 10000, + 10 + ] + ], + "status": "Current", + "type": "number", + "units": "" + } + }, + "com.nevion.mock.enableIntercom": { + "_schema": { + "default": false, + "descriptor": { + "desc": "Generate sample intercom data", + "label": "Enable intercom" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "bool" + } + }, + "com.nevion.mock.matrix_type": { + "_schema": { + "default": "1:N", + "descriptor": { + "desc": "", + "label": "Matrix Type" + }, + "encoding": "UTF-8", + "isNullable": false, + "lengthRanges": [], + "options": [ + { + "descriptor": { + "desc": "", + "label": "N:N" + }, + "value": "N:N" + }, + { + "descriptor": { + "desc": "", + "label": "1:N" + }, + "value": "1:N" + }, + { + "descriptor": { + "desc": "", + "label": "1:1" + }, + "value": "1:1" + } + ], + "status": "Current", + "type": "string" + } + }, + "com.nevion.mock.nmetrics": { + "_schema": { + "default": 0, + "descriptor": { + "desc": "Number of metrics per device", + "label": "Number of ports for metrics (nPorts * 12)" + }, + "isNullable": false, + "options": [], + "ranges": [], + "status": "Current", + "type": "number", + "units": "" + } + }, + "com.nevion.mock.num_codec_modules": { + "_schema": { + "default": 2, + "descriptor": { + "desc": "Number of codec modules", + "label": "#Codecs" + }, + "isNullable": false, + "options": [], + "ranges": [ + [ + 0, + 10, + 1 + ] + ], + "status": "Current", + "type": "number", + "units": "" + } + }, + "com.nevion.mock.num_dynamic_resource_modules": { + "_schema": { + "default": 0, + "descriptor": { + "desc": "Number of dynamic resource modules", + "label": "#DynamicResourceMods" + }, + "isNullable": false, + "options": [], + "ranges": [ + [ + 0, + 10, + 1 + ] + ], + "status": "Current", + "type": "number", + "units": "" + } + }, + "com.nevion.mock.num_gpis": { + "_schema": { + "default": 0, + "descriptor": { + "desc": "Number of GPIs. Automatically flips every 2.", + "label": "#GPIs" + }, + "isNullable": false, + "options": [], + "ranges": [ + [ + 0, + 10000, + 1 + ] + ], + "status": "Current", + "type": "number", + "units": "" + } + }, + "com.nevion.mock.num_gpos": { + "_schema": { + "default": 0, + "descriptor": { + "desc": "Number of GPOs", + "label": "#GPOs" + }, + "isNullable": false, + "options": [], + "ranges": [ + [ + 0, + 10000, + 1 + ] + ], + "status": "Current", + "type": "number", + "units": "" + } + }, + "com.nevion.mock.num_hevc_modules": { + "_schema": { + "default": 0, + "descriptor": { + "desc": "Number of HEVC modules", + "label": "#HEVCMods" + }, + "isNullable": false, + "options": [], + "ranges": [ + [ + 0, + 10, + 1 + ] + ], + "status": "Current", + "type": "number", + "units": "" + } + }, + "com.nevion.mock.num_resource_modules": { + "_schema": { + "default": 0, + "descriptor": { + "desc": "Number of resource modules", + "label": "#ResourceMods" + }, + "isNullable": false, + "options": [], + "ranges": [ + [ + 0, + 10, + 1 + ] + ], + "status": "Current", + "type": "number", + "units": "" + } + }, + "com.nevion.mock.num_router_modules": { + "_schema": { + "default": 0, + "descriptor": { + "desc": "Number of router modules", + "label": "#VRouters" + }, + "isNullable": false, + "options": [], + "ranges": [ + [ + 0, + 10, + 1 + ] + ], + "status": "Current", + "type": "number", + "units": "" + } + }, + "com.nevion.mock.num_router_ports": { + "_schema": { + "default": 32, + "descriptor": { + "desc": "Number of in/out ports per router module", + "label": "#VRouterPorts" + }, + "isNullable": false, + "options": [], + "ranges": [ + [ + 0, + 10000, + 1 + ] + ], + "status": "Current", + "type": "number", + "units": "" + } + }, + "com.nevion.mock.num_switch_modules": { + "_schema": { + "default": 0, + "descriptor": { + "desc": "Number of switch modules", + "label": "#Switches" + }, + "isNullable": false, + "options": [], + "ranges": [ + [ + 0, + 10, + 1 + ] + ], + "status": "Current", + "type": "number", + "units": "" + } + }, + "com.nevion.mock.persist": { + "_schema": { + "default": true, + "descriptor": { + "desc": "If enabled configs, source ips etc. will be persisted to disk", + "label": "Persist data" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "bool" + } + }, + "com.nevion.mock.populate_router_matrix": { + "_schema": { + "default": false, + "descriptor": { + "desc": "Populate default router matrix crosspoints", + "label": "Populate router matrix" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "bool" + } + }, + "com.nevion.mock.ptpClockType": { + "_schema": { + "default": 0, + "descriptor": { + "desc": "0: Ordinary, 1: Transparent, 2: Boundary, 3: Grandmaster", + "label": "PTP clock type" + }, + "isNullable": false, + "options": [], + "ranges": [], + "status": "Current", + "type": "number", + "units": "" + } + }, + "com.nevion.mock.tally_ids": { + "_schema": { + "default": "", + "descriptor": { + "desc": "Comma separated list of tally ids", + "label": "Tally ids" + }, + "encoding": "UTF-8", + "isNullable": false, + "lengthRanges": [], + "options": [], + "status": "Current", + "type": "string" + } + }, + "com.nevion.mock.tally_master": { + "_schema": { + "default": "", + "descriptor": { + "desc": "Comma separated list of 'domain/group/color' triples", + "label": "Tally Master data" + }, + "encoding": "UTF-8", + "isNullable": false, + "lengthRanges": [], + "options": [], + "status": "Current", + "type": "string" + } + }, + "matrixId": { + "_schema": { + "default": "", + "descriptor": { + "desc": "", + "label": "Custom matrix ID" + }, + "encoding": "UTF-8", + "isNullable": false, + "lengthRanges": [], + "options": [], + "status": "Current", + "type": "string" + } + } + } + } + }, + "customSettingsSeed": {}, + "description": "A mock driver to fake active drivers without using simulators.", + "deviceType": "driver", + "discoveredBy": null, + "exists": "No", + "iconType": "none", + "ipAddress": null, + "label": "Mock Driver", + "modules": [], + "name": "mock", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.nevion", + "products": [], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.2.1.1.2.0" + ], + "snmpSysObjectIdValue": null, + "status": "", + "supportedApis": [ + "DeviceLike", + "DynamicLike", + "PortLike", + "HwPanelLike", + "GPIOLike", + "TestLike", + "TallyLike", + "StatusLike", + "MatrixControlLike", + "ParameterControlLike", + "CoreLike", + "IntercomLike" + ], + "supportsExternal": true, + "supportsParameterApi": true, + "uniqueMetricDefs": [], + "updateableDevice": false, + "updateableModule": {}, + "version": "0.1.0" + }, + { + "_id": "com.nevion.mock_cloud-0.1.0", + "_vid": "com.nevion.mock_cloud-0.1.0", + "attachments": [ + { + "description": "Default", + "name": "mock_cloud" + } + ], + "configurableDevice": false, + "configurableModule": [], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "customSettingsSeed": {}, + "description": "A mock cloud driver to fake active drivers without using simulators.", + "deviceType": "driver", + "discoveredBy": null, + "exists": "No", + "iconType": "none", + "ipAddress": null, + "label": "Mock Cloud Driver", + "modules": [], + "name": "mock_cloud", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.nevion", + "products": [], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.2.1.1.2.0" + ], + "snmpSysObjectIdValue": null, + "status": "", + "supportedApis": [ + "DeviceLike", + "DynamicLike", + "PortLike", + "HwPanelLike", + "GPIOLike", + "TestLike", + "TallyLike", + "StatusLike", + "MatrixControlLike", + "ParameterControlLike", + "CoreLike", + "IntercomLike" + ], + "supportsExternal": true, + "supportsParameterApi": true, + "uniqueMetricDefs": [], + "updateableDevice": false, + "updateableModule": {}, + "version": "0.1.0" + }, + { + "_id": "com.nevion.montone42-0.1.0", + "_vid": "com.nevion.montone42-0.1.0", + "attachments": [ + { + "description": "Default", + "name": "montone42" + } + ], + "configurableDevice": false, + "configurableModule": [], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "customSettingsSeed": {}, + "description": "Driver for DirectOut Technologies Montone.42", + "deviceType": "driver", + "discoveredBy": null, + "exists": "No", + "iconType": "gateway", + "ipAddress": null, + "label": "Montone.42", + "modules": [], + "name": "montone42", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.nevion", + "products": [], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.2.1.1.2.0" + ], + "snmpSysObjectIdValue": null, + "status": "", + "supportedApis": [ + "CoreLike", + "DeviceLike", + "StatusLike", + "PortLike" + ], + "supportsExternal": true, + "supportsParameterApi": false, + "uniqueMetricDefs": [], + "updateableDevice": false, + "updateableModule": {}, + "version": "0.1.0" + }, + { + "_id": "com.nevion.multicon-0.1.0", + "_vid": "com.nevion.multicon-0.1.0", + "attachments": [ + { + "description": "Default", + "name": "multicon" + } + ], + "configurableDevice": false, + "configurableModule": [], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "customSettingsSeed": {}, + "description": "Driver for Multicon element manager", + "deviceType": "switch", + "discoveredBy": null, + "exists": "No", + "iconType": "processingDevice", + "ipAddress": null, + "label": "Nevion Multicon", + "modules": [], + "name": "multicon", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.nevion", + "products": [ + { + "name": "MULTICON", + "swBuildTime": null, + "swVersion": null + } + ], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.2.1.1.1.0", + "1.3.6.1.2.1.1.2.0", + "1.3.6.1.4.1.2021.100.6.0", + "1.3.6.1.2.1.2.2.1.6.3", + "1.3.6.1.2.1.2.2.1.6.3" + ], + "snmpSysObjectIdValue": "1.3.6.1.4.1.8072.3.2.10", + "status": "", + "supportedApis": [ + "ParameterControlLike", + "DeviceLike", + "DynamicLike", + "PortLike", + "CoreLike", + "StatusLike" + ], + "supportsExternal": true, + "supportsParameterApi": true, + "uniqueMetricDefs": [], + "updateableDevice": false, + "updateableModule": {}, + "version": "0.1.0" + }, + { + "_id": "com.nevion.mwedge-0.1.0", + "_vid": "com.nevion.mwedge-0.1.0", + "attachments": [ + { + "description": "Default", + "name": "mwedge" + } + ], + "configurableDevice": false, + "configurableModule": [], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "customSettingsSeed": {}, + "description": "TS protection and monitoring gateway", + "deviceType": "driver", + "discoveredBy": null, + "exists": "No", + "iconType": "none", + "ipAddress": null, + "label": "Techex MWEDGE", + "modules": [], + "name": "mwedge", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.nevion", + "products": [], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.2.1.1.2.0" + ], + "snmpSysObjectIdValue": null, + "status": "", + "supportedApis": [ + "CoreLike", + "DeviceLike", + "PortLike" + ], + "supportsExternal": true, + "supportsParameterApi": false, + "uniqueMetricDefs": [], + "updateableDevice": false, + "updateableModule": {}, + "version": "0.1.0" + }, + { + "_id": "com.nevion.ndi-0.1.0", + "_vid": "com.nevion.ndi-0.1.0", + "attachments": [ + { + "description": "Default", + "name": "ndi" + } + ], + "configurableDevice": false, + "configurableModule": [], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "Custom setting fields for Ndi Router", + "label": "Ndi Router" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map", + "values": { + "com.nevion.ndi.num_virtual_routing_instances": { + "_schema": { + "default": 10, + "descriptor": { + "desc": "The number of Virtual Routing instances (destinations) to create", + "label": "Virtual Routing instances" + }, + "isNullable": false, + "options": [], + "ranges": [ + [ + 0, + 65535, + 1 + ] + ], + "status": "Current", + "type": "number", + "units": "" + } + }, + "com.nevion.ndi.port": { + "_schema": { + "default": 8765, + "descriptor": { + "desc": "Port used to connect to the NDI router", + "label": "Port" + }, + "isNullable": false, + "options": [], + "ranges": [ + [ + 0, + 65535, + 1 + ] + ], + "status": "Current", + "type": "number", + "units": "" + } + } + } + } + }, + "customSettingsSeed": {}, + "description": "Specialized driver for Controlling NDI Virtual Routing Instances", + "deviceType": "driver", + "discoveredBy": null, + "exists": "No", + "iconType": "none", + "ipAddress": null, + "label": "NDI Matrix driver", + "modules": [], + "name": "ndi", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.nevion", + "products": [], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.2.1.1.2.0" + ], + "snmpSysObjectIdValue": null, + "status": "", + "supportedApis": [ + "MatrixControlLike", + "CoreLike" + ], + "supportsExternal": true, + "supportsParameterApi": false, + "uniqueMetricDefs": [], + "updateableDevice": false, + "updateableModule": {}, + "version": "0.1.0" + }, + { + "_id": "com.nevion.nec_dtl_30-0.1.0", + "_vid": "com.nevion.nec_dtl_30-0.1.0", + "attachments": [ + { + "description": "Default", + "name": "nec_dtl_30" + } + ], + "configurableDevice": false, + "configurableModule": [], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "customSettingsSeed": {}, + "description": "Driver for DTL-30 system", + "deviceType": "driver", + "discoveredBy": null, + "exists": "No", + "iconType": "device", + "ipAddress": null, + "label": "NEC DTL-30", + "modules": [], + "name": "nec_dtl_30", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.nevion", + "products": [ + { + "name": "DTL-30", + "swBuildTime": null, + "swVersion": null + } + ], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.2.1.1.2.0" + ], + "snmpSysObjectIdValue": "1.3.6.1.4.1.119.2.3.96.29", + "status": "", + "supportedApis": [ + "ParameterControlLike", + "CoreLike", + "DeviceLike", + "StatusLike" + ], + "supportsExternal": true, + "supportsParameterApi": true, + "uniqueMetricDefs": [], + "updateableDevice": false, + "updateableModule": {}, + "version": "0.1.0" + }, + { + "_id": "com.nevion.nec_dtu_70d-0.1.0", + "_vid": "com.nevion.nec_dtu_70d-0.1.0", + "attachments": [ + { + "description": "Default", + "name": "nec_dtu_70d" + } + ], + "configurableDevice": false, + "configurableModule": [], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "customSettingsSeed": {}, + "description": "Driver for DTU-70D system", + "deviceType": "driver", + "discoveredBy": null, + "exists": "No", + "iconType": "device", + "ipAddress": null, + "label": "NEC DTU-70D", + "modules": [], + "name": "nec_dtu_70d", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.nevion", + "products": [ + { + "name": "DTU-70D", + "swBuildTime": null, + "swVersion": null + } + ], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.2.1.1.2.0" + ], + "snmpSysObjectIdValue": "1.3.6.1.4.1.119.2.3.96.28", + "status": "", + "supportedApis": [ + "ParameterControlLike", + "CoreLike", + "DeviceLike", + "StatusLike" + ], + "supportsExternal": true, + "supportsParameterApi": true, + "uniqueMetricDefs": [], + "updateableDevice": false, + "updateableModule": {}, + "version": "0.1.0" + }, + { + "_id": "com.nevion.nec_dtu_l10-0.1.0", + "_vid": "com.nevion.nec_dtu_l10-0.1.0", + "attachments": [ + { + "description": "Default", + "name": "nec_dtu_l10" + } + ], + "configurableDevice": false, + "configurableModule": [], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "customSettingsSeed": {}, + "description": "Driver for DTU-L10 system", + "deviceType": "driver", + "discoveredBy": null, + "exists": "No", + "iconType": "device", + "ipAddress": null, + "label": "NEC DTU-L10", + "modules": [], + "name": "nec_dtu_l10", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.nevion", + "products": [ + { + "name": "DTU-L10", + "swBuildTime": null, + "swVersion": null + } + ], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.2.1.1.2.0" + ], + "snmpSysObjectIdValue": "1.3.6.1.4.1.119.2.3.96.71", + "status": "", + "supportedApis": [ + "ParameterControlLike", + "CoreLike", + "DeviceLike", + "StatusLike" + ], + "supportsExternal": true, + "supportsParameterApi": true, + "uniqueMetricDefs": [], + "updateableDevice": false, + "updateableModule": {}, + "version": "0.1.0" + }, + { + "_id": "com.nevion.net_vision-0.1.0", + "_vid": "com.nevion.net_vision-0.1.0", + "attachments": [ + { + "description": "Default", + "name": "net_vision" + } + ], + "configurableDevice": false, + "configurableModule": [], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "customSettingsSeed": {}, + "description": "UPS WEB/SNMP Card", + "deviceType": "driver", + "discoveredBy": null, + "exists": "No", + "iconType": "device", + "ipAddress": null, + "label": "Socomec NET VISION", + "modules": [], + "name": "net_vision", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.nevion", + "products": [ + { + "name": "NET VISION", + "swBuildTime": null, + "swVersion": null + } + ], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.2.1.1.2.0" + ], + "snmpSysObjectIdValue": "1.3.6.1.4.1.4555.1.1.1", + "status": "", + "supportedApis": [ + "ParameterControlLike", + "CoreLike", + "DeviceLike", + "StatusLike" + ], + "supportsExternal": true, + "supportsParameterApi": true, + "uniqueMetricDefs": [], + "updateableDevice": false, + "updateableModule": {}, + "version": "0.1.0" + }, + { + "_id": "com.nevion.nodectrl-0.1.0", + "_vid": "com.nevion.nodectrl-0.1.0", + "attachments": [ + { + "description": "Default", + "name": "nodectrl" + } + ], + "configurableDevice": false, + "configurableModule": [], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "Custom setting fields for nodectrl", + "label": "nodectrl" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map", + "values": { + "com.nevion.emberplus.keepalives": { + "_schema": { + "default": true, + "descriptor": { + "desc": "If selected, keep-alives will be used to determine reachability", + "label": "Send keep-alives" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "bool" + } + }, + "com.nevion.emberplus.port": { + "_schema": { + "default": 9000, + "descriptor": { + "desc": "Port", + "label": "Port" + }, + "isNullable": false, + "options": [], + "ranges": [ + [ + 0, + 65535, + 1 + ] + ], + "status": "Current", + "type": "number", + "units": "" + } + }, + "com.nevion.emberplus.queue": { + "_schema": { + "default": true, + "descriptor": { + "desc": "", + "label": "Request queueing" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "bool" + } + }, + "com.nevion.emberplus.suppress_illegal": { + "_schema": { + "default": false, + "descriptor": { + "desc": "", + "label": "Suppress illegal update warnings" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "bool" + } + }, + "com.nevion.emberplus.trace": { + "_schema": { + "default": false, + "descriptor": { + "desc": "", + "label": "Tracing (logging intensive)" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "bool" + } + } + } + } + }, + "customSettingsSeed": {}, + "description": "A driver for devices using NodeCtrl standard of Ember+ API", + "deviceType": "driver", + "discoveredBy": null, + "exists": "No", + "iconType": "none", + "ipAddress": null, + "label": "NodeCtrl", + "modules": [], + "name": "nodectrl", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.nevion", + "products": [], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.2.1.1.2.0" + ], + "snmpSysObjectIdValue": null, + "status": "", + "supportedApis": [ + "ParameterControlLike", + "CoreLike", + "DeviceLike", + "PortLike" + ], + "supportsExternal": true, + "supportsParameterApi": true, + "uniqueMetricDefs": [], + "updateableDevice": false, + "updateableModule": {}, + "version": "0.1.0" + }, + { + "_id": "com.nevion.nokia7210-0.1.0", + "_vid": "com.nevion.nokia7210-0.1.0", + "attachments": [ + { + "description": "Default", + "name": "nokia7210" + } + ], + "configurableDevice": false, + "configurableModule": [], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "customSettingsSeed": {}, + "description": "Driver for Nokia 7210 switches", + "deviceType": "switch", + "discoveredBy": null, + "exists": "No", + "iconType": "device", + "ipAddress": null, + "label": "Nokia 7210", + "modules": [], + "name": "nokia7210", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.nevion", + "products": [], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.2.1.1.2.0" + ], + "snmpSysObjectIdValue": null, + "status": "", + "supportedApis": [ + "ParameterControlLike", + "DeviceLike", + "PortLike", + "CoreLike", + "StatusLike" + ], + "supportsExternal": true, + "supportsParameterApi": true, + "uniqueMetricDefs": [], + "updateableDevice": false, + "updateableModule": {}, + "version": "0.1.0" + }, + { + "_id": "com.nevion.nokia7705-0.1.0", + "_vid": "com.nevion.nokia7705-0.1.0", + "attachments": [ + { + "description": "Default", + "name": "nokia7705" + } + ], + "configurableDevice": false, + "configurableModule": [], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "customSettingsSeed": {}, + "description": "Driver for Nokia 7705 service aggregation router", + "deviceType": "switch", + "discoveredBy": null, + "exists": "No", + "iconType": "device", + "ipAddress": null, + "label": "Nokia 7705", + "modules": [], + "name": "nokia7705", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.nevion", + "products": [], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.2.1.1.2.0" + ], + "snmpSysObjectIdValue": null, + "status": "", + "supportedApis": [ + "ParameterControlLike", + "DeviceLike", + "PortLike", + "CoreLike", + "StatusLike" + ], + "supportsExternal": true, + "supportsParameterApi": true, + "uniqueMetricDefs": [], + "updateableDevice": false, + "updateableModule": {}, + "version": "0.1.0" + }, + { + "_id": "com.nevion.nso-0.1.0", + "_vid": "com.nevion.nso-0.1.0", + "attachments": [ + { + "description": "Default", + "name": "nso" + } + ], + "configurableDevice": false, + "configurableModule": [], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "customSettingsSeed": {}, + "description": "A driver for Cisco Network Services Orchestration (NSO)", + "deviceType": "switch", + "discoveredBy": null, + "exists": "No", + "iconType": "none", + "ipAddress": null, + "label": "Cisco NSO", + "modules": [], + "name": "nso", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.nevion", + "products": [ + { + "name": "NSO", + "swBuildTime": null, + "swVersion": null + } + ], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.2.1.1.2.0" + ], + "snmpSysObjectIdValue": null, + "status": "", + "supportedApis": [ + "CoreLike", + "DeviceLike", + "PortLike" + ], + "supportsExternal": true, + "supportsParameterApi": false, + "uniqueMetricDefs": [], + "updateableDevice": false, + "updateableModule": {}, + "version": "0.1.0" + }, + { + "_id": "com.nevion.nx4600-0.1.0", + "_vid": "com.nevion.nx4600-0.1.0", + "attachments": [ + { + "description": "Base configuration (partial or full)", + "name": "null.base" + } + ], + "configurableDevice": true, + "configurableModule": [], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "Custom setting fields for Platform4000", + "label": "Platform4000" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map", + "values": { + "com.nevion.null.reuse_ts_element": { + "_schema": { + "default": false, + "descriptor": { + "desc": "", + "label": "Enable to activate logic to join existing TS input element for ASI outputs when setting up multicast with identical settings" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "bool" + } + } + } + } + }, + "customSettingsSeed": {}, + "description": "Driver for the Nevion NX4600 Media Gateway", + "deviceType": "driver", + "discoveredBy": null, + "exists": "No", + "iconType": "gateway", + "ipAddress": null, + "label": "Nevion NX4600", + "modules": [ + "Slot [0-4]" + ], + "name": "nx4600", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.nevion", + "products": [ + { + "name": "NX4600", + "swBuildTime": null, + "swVersion": "1.4" + } + ], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.2.1.1.2.0" + ], + "snmpSysObjectIdValue": "1.3.6.1.4.1.8768.10.37", + "status": "", + "supportedApis": [ + "ParameterControlLike", + "DeviceLike", + "DynamicLike", + "PortLike", + "CoreLike", + "StatusLike", + "MaintenanceLike", + "NetworkServiceLike" + ], + "supportsExternal": true, + "supportsParameterApi": true, + "uniqueMetricDefs": [ + { + "descriptor": { + "desc": "Last bitrate measured.", + "label": "Ts Pid Bitrate" + }, + "id": "ts.pid.bit.rate", + "isAlarmTrigger": false, + "isCounter": false, + "unit": "" + }, + { + "descriptor": { + "desc": "Last average bitrate measured.", + "label": "Ts Pid Average Bitrate" + }, + "id": "ts.pid.average.bit.rate", + "isAlarmTrigger": false, + "isCounter": false, + "unit": "" + }, + { + "descriptor": { + "desc": "Continuity error counter.", + "label": "Ts Pid Continuity Error Counter" + }, + "id": "ts.pid.continuity.error.counter", + "isAlarmTrigger": false, + "isCounter": false, + "unit": "" + }, + { + "descriptor": { + "desc": "Last bitrate sampled for program.", + "label": "Ts Service Bitrate" + }, + "id": "ts.service.bit.rate", + "isAlarmTrigger": false, + "isCounter": false, + "unit": "" + }, + { + "descriptor": { + "desc": "Effective bitrate, i.e., bitrate without null packets.", + "label": "Ts Effective Bitrate" + }, + "id": "ts.effective.bit.rate", + "isAlarmTrigger": false, + "isCounter": false, + "unit": "" + }, + { + "descriptor": { + "desc": "Total bitrate.", + "label": "Ts Total Bitrate" + }, + "id": "ts.total.bit.rate", + "isAlarmTrigger": false, + "isCounter": false, + "unit": "" + }, + { + "descriptor": { + "desc": "Inter arrival time, i.e., the time between arrivals into the system.", + "label": "Tsoip Rx Sips Iat" + }, + "id": "tsoip.rx.sips.iat", + "isAlarmTrigger": false, + "isCounter": false, + "unit": "" + }, + { + "descriptor": { + "desc": "Packet delay factor.", + "label": "Tsoip Rx Sips Pdv" + }, + "id": "tsoip.rx.sips.pdv", + "isAlarmTrigger": false, + "isCounter": false, + "unit": "" + }, + { + "descriptor": { + "desc": "Current buffer.", + "label": "Tsoip Rx Buff Lat" + }, + "id": "tsoip.rx.buff.lat", + "isAlarmTrigger": false, + "isCounter": false, + "unit": "" + }, + { + "descriptor": { + "desc": "Current utilization fraction of buffer, with the signal currently received and configured parameters.", + "label": "Tsoip Rx Buff Util" + }, + "id": "tsoip.rx.buff.util", + "isAlarmTrigger": false, + "isCounter": false, + "unit": "" + }, + { + "descriptor": { + "desc": "SFP Channel internal bias current", + "label": "Sfp Internal Current" + }, + "id": "sfp.internal.current", + "isAlarmTrigger": false, + "isCounter": false, + "unit": "mA" + }, + { + "descriptor": { + "desc": "SFP Channel Internal Vcc", + "label": "Sfp Internal Voltage" + }, + "id": "sfp.internal.voltage", + "isAlarmTrigger": false, + "isCounter": false, + "unit": "V" + }, + { + "descriptor": { + "desc": "SFP Channel internal temperature", + "label": "Sfp Internal Temp" + }, + "id": "sfp.internal.temp", + "isAlarmTrigger": false, + "isCounter": false, + "unit": "C" + }, + { + "descriptor": { + "desc": "SFP Channel RX input power", + "label": "Sfp Rx Effect" + }, + "id": "sfp.rx.effect", + "isAlarmTrigger": false, + "isCounter": false, + "unit": "mW" + }, + { + "descriptor": { + "desc": "SFP Channel RX input power", + "label": "Sfp Rx Effect Log" + }, + "id": "sfp.rx.effect.log", + "isAlarmTrigger": false, + "isCounter": false, + "unit": "dBm" + }, + { + "descriptor": { + "desc": "SFP Channel TX output power", + "label": "Sfp Tx Effect" + }, + "id": "sfp.tx.effect", + "isAlarmTrigger": false, + "isCounter": false, + "unit": "mW" + }, + { + "descriptor": { + "desc": "SFP Channel TX output power", + "label": "Sfp Tx Effect Log" + }, + "id": "sfp.tx.effect.log", + "isAlarmTrigger": false, + "isCounter": false, + "unit": "dBm" + } + ], + "updateableDevice": false, + "updateableModule": { + "ASI-Input/Output-Board": { + "availableBanks": [], + "rebootOption": 0 + }, + "DVB-T/T2-Demodulator": { + "availableBanks": [], + "rebootOption": 0 + }, + "GNSS-clock-reference-board": { + "availableBanks": [], + "rebootOption": 0 + }, + "H-264-Encoder/Decoder": { + "availableBanks": [], + "rebootOption": 0 + }, + "High-Bit-Rate-Accelerator": { + "availableBanks": [], + "rebootOption": 0 + }, + "Main-Board": { + "availableBanks": [], + "rebootOption": 0 + }, + "Multi-codec": { + "availableBanks": [], + "rebootOption": 0 + } + }, + "version": "0.1.0" + }, + { + "_id": "com.nevion.nxl_me80-1.0.0", + "_vid": "com.nevion.nxl_me80-1.0.0", + "attachments": [ + { + "description": "Default", + "name": "nxl_me80" + } + ], + "configurableDevice": true, + "configurableModule": [], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "Custom setting fields for NXL-ME80", + "label": "NXL-ME80" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map", + "values": { + "com.nevion.nxl_me80.always_enable_rtp": { + "_schema": { + "default": false, + "descriptor": { + "desc": "The \"rtp_enabled\" field in \"transport_params\" will always be set to true", + "label": "Always enable RTP" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "bool" + } + }, + "com.nevion.nxl_me80.auth_client_id": { + "_schema": { + "default": "", + "descriptor": { + "desc": "Client ID from registered ME80 Authorization Code", + "label": "NXL-ME80 Authorization Code Client ID" + }, + "encoding": "UTF-8", + "isNullable": false, + "lengthRanges": [], + "options": [], + "status": "Current", + "type": "string" + } + }, + "com.nevion.nxl_me80.cc_client_id": { + "_schema": { + "default": "", + "descriptor": { + "desc": "Client ID from registered ME80 Client Credential", + "label": "NXL-ME80 Client Credential Client ID" + }, + "encoding": "UTF-8", + "isNullable": false, + "lengthRanges": [], + "options": [], + "status": "Current", + "type": "string" + } + }, + "com.nevion.nxl_me80.client_secret": { + "_schema": { + "default": "", + "descriptor": { + "desc": "Client Secret from registered ME80 Client Credential", + "label": "NXL-ME80 Client Credential Client Secret" + }, + "encoding": "UTF-8", + "isNullable": false, + "lengthRanges": [], + "options": [], + "status": "Current", + "type": "string" + } + }, + "com.nevion.nxl_me80.disable_rx_sdp": { + "_schema": { + "default": false, + "descriptor": { + "desc": "Configure this unit's receivers with regular transport parameters only", + "label": "Disable Rx SDP" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "bool" + } + }, + "com.nevion.nxl_me80.disable_rx_sdp_with_null": { + "_schema": { + "default": true, + "descriptor": { + "desc": "Configures how RX SDPs are disabled. If unchecked, an empty string is used", + "label": "Disable Rx SDP with null" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "bool" + } + }, + "com.nevion.nxl_me80.enable_bulk_config": { + "_schema": { + "default": false, + "descriptor": { + "desc": "Configure this unit using bulk API", + "label": "Enable bulk config" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "bool" + } + }, + "com.nevion.nxl_me80.enable_experimental_alarm": { + "_schema": { + "default": false, + "descriptor": { + "desc": "Enables experimental alarms over websockets using IS-07 on certain Vizrt devices. Disables alarms completely if disabled", + "label": "Enable experimental alarms using IS-07" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "bool" + } + }, + "com.nevion.nxl_me80.experimental_alarm_port": { + "_schema": { + "default": 0, + "descriptor": { + "desc": "HTTP port for location of experimental IS-07 alarm websocket. If empty or 0 it uses Port field instead", + "label": "Experimental alarm port" + }, + "isNullable": true, + "options": [], + "ranges": [ + [ + 1, + 65535, + 1 + ] + ], + "status": "Current", + "type": "number", + "units": "" + } + }, + "com.nevion.nxl_me80.is05_api_version": { + "_schema": { + "default": false, + "descriptor": { + "desc": "Configure IS05 API version to use max", + "label": "Enable Max IS05 API version" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "bool" + } + }, + "com.nevion.nxl_me80.me80_port": { + "_schema": { + "default": 443, + "descriptor": { + "desc": "NXL-ME80 port setting used for CTRL", + "label": "NXL-ME80 Port" + }, + "isNullable": false, + "options": [], + "ranges": [ + [ + 0, + 65535, + 1 + ] + ], + "status": "Current", + "type": "number", + "units": "" + } + }, + "com.nevion.nxl_me80.port": { + "_schema": { + "default": 80, + "descriptor": { + "desc": "The HTTP port used to reach the Node directly", + "label": "Port" + }, + "isNullable": false, + "options": [], + "ranges": [ + [ + 1, + 65535, + 1 + ] + ], + "status": "Current", + "type": "number", + "units": "" + } + } + } + } + }, + "customSettingsSeed": {}, + "description": "A Driver for the Sony Media Edge Processor NXL-ME80", + "deviceType": "driver", + "discoveredBy": null, + "exists": "No", + "iconType": "gateway", + "ipAddress": null, + "label": "Sony NXL-ME80", + "modules": [], + "name": "nxl_me80", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.nevion", + "products": [ + { + "name": "NXL-ME80", + "swBuildTime": null, + "swVersion": null + } + ], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.2.1.1.2.0" + ], + "snmpSysObjectIdValue": "1.3.6.1.2.1.1.2.0", + "status": "", + "supportedApis": [ + "MatrixControlLike", + "DeviceLike", + "MaintenanceLike", + "PortLike", + "CoreLike", + "StatusLike" + ], + "supportsExternal": true, + "supportsParameterApi": false, + "uniqueMetricDefs": [], + "updateableDevice": false, + "updateableModule": {}, + "version": "1.0.0" + }, + { + "_id": "com.nevion.openflow-0.0.1", + "_vid": "com.nevion.openflow-0.0.1", + "attachments": [ + { + "description": "Default", + "name": "openflow" + } + ], + "configurableDevice": false, + "configurableModule": [], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "Custom setting fields for Openflow drivers", + "label": "Openflow" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map", + "values": { + "com.nevion.api.sample_flows_interval": { + "_schema": { + "default": 0, + "descriptor": { + "desc": "Interval at which to poll flow stats. 0 to disable.", + "label": "Flow stats interval [s]" + }, + "isNullable": false, + "options": [], + "ranges": [ + [ + 0, + 0, + 1 + ], + [ + 2, + 3600, + 1 + ] + ], + "status": "Current", + "type": "number", + "units": "" + } + }, + "com.nevion.openflow_allow_groups": { + "_schema": { + "default": true, + "descriptor": { + "desc": "Allow use of group actions in flows", + "label": "Allow groups" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "bool" + } + }, + "com.nevion.openflow_flow_priority": { + "_schema": { + "default": 60000, + "descriptor": { + "desc": "Flow priority used by videoipath", + "label": "Flow Priority" + }, + "isNullable": false, + "options": [], + "ranges": [ + [ + 2, + 65535, + 1 + ] + ], + "status": "Current", + "type": "number", + "units": "" + } + }, + "com.nevion.openflow_interface_shutdown_alarms": { + "_schema": { + "default": false, + "descriptor": { + "desc": "Allow service correlated alarms when admin shuts down an interface", + "label": "Interface shutdown alarms" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "bool" + } + }, + "com.nevion.openflow_max_buckets": { + "_schema": { + "default": 65535, + "descriptor": { + "desc": "Max number of buckets in an openflow group", + "label": "Max buckets" + }, + "isNullable": false, + "options": [], + "ranges": [ + [ + 2, + 65535, + 1 + ] + ], + "status": "Current", + "type": "number", + "units": "" + } + }, + "com.nevion.openflow_max_groups": { + "_schema": { + "default": 65535, + "descriptor": { + "desc": "Max number of groups on the switch", + "label": "Max groups" + }, + "isNullable": false, + "options": [], + "ranges": [ + [ + 1, + 65535, + 1 + ] + ], + "status": "Current", + "type": "number", + "units": "" + } + }, + "com.nevion.openflow_max_meters": { + "_schema": { + "default": 65535, + "descriptor": { + "desc": "Max number of meters on the switch", + "label": "Max meters" + }, + "isNullable": false, + "options": [], + "ranges": [ + [ + 2, + 65535, + 1 + ] + ], + "status": "Current", + "type": "number", + "units": "" + } + }, + "com.nevion.openflow_table_id": { + "_schema": { + "default": 0, + "descriptor": { + "desc": "Table ID to use for videoipath flows", + "label": "Table ID" + }, + "isNullable": false, + "options": [], + "ranges": [ + [ + 0, + 255, + 1 + ] + ], + "status": "Current", + "type": "number", + "units": "" + } + } + } + } + }, + "customSettingsSeed": {}, + "description": "A generic driver for Openflow devices (via an Openflow controller)", + "deviceType": "switch", + "discoveredBy": null, + "exists": "No", + "iconType": "ipSwitchRouter", + "ipAddress": null, + "label": "Openflow", + "modules": [], + "name": "openflow", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.nevion", + "products": [ + { + "name": "Openflow", + "swBuildTime": null, + "swVersion": null + } + ], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [], + "snmpSysObjectIdValue": null, + "status": "", + "supportedApis": [ + "ParameterControlLike", + "DeviceLike", + "PortLike", + "CoreLike", + "StatusLike" + ], + "supportsExternal": true, + "supportsParameterApi": true, + "uniqueMetricDefs": [], + "updateableDevice": false, + "updateableModule": {}, + "version": "0.0.1" + }, + { + "_id": "com.nevion.powercore-0.1.0", + "_vid": "com.nevion.powercore-0.1.0", + "attachments": [ + { + "description": "Default", + "name": "powercore" + } + ], + "configurableDevice": false, + "configurableModule": [], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "Custom settings field for PowerCore driver", + "label": "PowerCore" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map", + "values": { + "com.nevion.emberplus.keepalives": { + "_schema": { + "default": true, + "descriptor": { + "desc": "If selected, keep-alives will be used to determine reachability", + "label": "Send keep-alives" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "bool" + } + }, + "com.nevion.emberplus.port": { + "_schema": { + "default": 9000, + "descriptor": { + "desc": "Port", + "label": "Port" + }, + "isNullable": false, + "options": [], + "ranges": [ + [ + 0, + 65535, + 1 + ] + ], + "status": "Current", + "type": "number", + "units": "" + } + }, + "com.nevion.emberplus.queue": { + "_schema": { + "default": true, + "descriptor": { + "desc": "", + "label": "Request queueing" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "bool" + } + }, + "com.nevion.emberplus.request_separation": { + "_schema": { + "default": 0, + "descriptor": { + "desc": "Set to zero to disable.", + "label": "Request Separation [ms]" + }, + "isNullable": false, + "options": [], + "ranges": [ + [ + 0, + 250, + 1 + ] + ], + "status": "Current", + "type": "number", + "units": "" + } + }, + "com.nevion.emberplus.suppress_illegal": { + "_schema": { + "default": false, + "descriptor": { + "desc": "", + "label": "Suppress illegal update warnings" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "bool" + } + }, + "com.nevion.emberplus.trace": { + "_schema": { + "default": false, + "descriptor": { + "desc": "", + "label": "Tracing (logging intensive)" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "bool" + } + }, + "com.nevion.powercore.bulk_config": { + "_schema": { + "default": "Set single configs in parallel", + "descriptor": { + "desc": "Bulk config mode: None = default set single configs in parallel", + "label": "Bulk config setting mode" + }, + "encoding": "UTF-8", + "isNullable": false, + "lengthRanges": [], + "options": [ + { + "descriptor": { + "desc": "", + "label": "Aggregate configs in bigger requests" + }, + "value": "Aggregate configs in bigger requests" + }, + { + "descriptor": { + "desc": "", + "label": "One by one" + }, + "value": "One by one" + }, + { + "descriptor": { + "desc": "", + "label": "Set single configs in parallel" + }, + "value": "Set single configs in parallel" + } + ], + "status": "Current", + "type": "string" + } + }, + "com.nevion.powercore.env_alarms": { + "_schema": { + "default": false, + "descriptor": { + "desc": "", + "label": "Enable environmental alarm reporting" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "bool" + } + }, + "com.nevion.powercore.keep_alive_period": { + "_schema": { + "default": 2000, + "descriptor": { + "desc": "", + "label": "Send KeepAlive request period in millis" + }, + "isNullable": false, + "options": [], + "ranges": [ + [ + 100, + 60000, + 100 + ] + ], + "status": "Current", + "type": "number", + "units": "" + } + }, + "com.nevion.powercore.max_bulk_transactions": { + "_schema": { + "default": 1000, + "descriptor": { + "desc": "", + "label": "Max number of bulk transactions" + }, + "isNullable": false, + "options": [], + "ranges": [ + [ + 1, + 1000, + 1 + ] + ], + "status": "Current", + "type": "number", + "units": "" + } + }, + "com.nevion.powercore.stream_alerts": { + "_schema": { + "default": false, + "descriptor": { + "desc": "", + "label": "Enable Output(RX) flag notifications" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "bool" + } + } + } + } + }, + "customSettingsSeed": {}, + "description": "", + "deviceType": "driver", + "discoveredBy": null, + "exists": "No", + "iconType": "none", + "ipAddress": null, + "label": "Lawo PowerCore", + "modules": [], + "name": "powercore", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.nevion", + "products": [], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.2.1.1.2.0" + ], + "snmpSysObjectIdValue": null, + "status": "", + "supportedApis": [ + "ParameterControlLike", + "DeviceLike", + "PortLike", + "CoreLike", + "StatusLike" + ], + "supportsExternal": true, + "supportsParameterApi": true, + "uniqueMetricDefs": [], + "updateableDevice": false, + "updateableModule": {}, + "version": "0.1.0" + }, + { + "_id": "com.nevion.prismon-1.0.0", + "_vid": "com.nevion.prismon-1.0.0", + "attachments": [ + { + "description": "Default", + "name": "prismon" + } + ], + "configurableDevice": false, + "configurableModule": [], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "Custom setting fields for R&S PRISMON", + "label": "R&S PRISMON" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map", + "values": { + "com.nevion.prismon.always_enable_rtp": { + "_schema": { + "default": false, + "descriptor": { + "desc": "The \"rtp_enabled\" field in \"transport_params\" will always be set to true", + "label": "Always enable RTP" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "bool" + } + }, + "com.nevion.prismon.disable_rx_sdp": { + "_schema": { + "default": false, + "descriptor": { + "desc": "Configure this unit's receivers with regular transport parameters only", + "label": "Disable Rx SDP" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "bool" + } + }, + "com.nevion.prismon.disable_rx_sdp_with_null": { + "_schema": { + "default": true, + "descriptor": { + "desc": "Configures how RX SDPs are disabled. If unchecked, an empty string is used", + "label": "Disable Rx SDP with null" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "bool" + } + }, + "com.nevion.prismon.enable_bulk_config": { + "_schema": { + "default": false, + "descriptor": { + "desc": "Configure this unit using bulk API", + "label": "Enable bulk config" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "bool" + } + }, + "com.nevion.prismon.enable_experimental_alarm": { + "_schema": { + "default": false, + "descriptor": { + "desc": "Enables experimental alarms over websockets using IS-07 on certain Vizrt devices. Disables alarms completely if disabled", + "label": "Enable experimental alarms using IS-07" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "bool" + } + }, + "com.nevion.prismon.experimental_alarm_port": { + "_schema": { + "default": 0, + "descriptor": { + "desc": "HTTP port for location of experimental IS-07 alarm websocket. If empty or 0 it uses Port field instead", + "label": "Experimental alarm port" + }, + "isNullable": true, + "options": [], + "ranges": [ + [ + 1, + 65535, + 1 + ] + ], + "status": "Current", + "type": "number", + "units": "" + } + }, + "com.nevion.prismon.is05_api_version": { + "_schema": { + "default": false, + "descriptor": { + "desc": "Configure IS05 API version to use max", + "label": "Enable Max IS05 API version" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "bool" + } + }, + "com.nevion.prismon.port": { + "_schema": { + "default": 80, + "descriptor": { + "desc": "The HTTP port used to reach the Node directly", + "label": "Port" + }, + "isNullable": false, + "options": [], + "ranges": [ + [ + 1, + 65535, + 1 + ] + ], + "status": "Current", + "type": "number", + "units": "" + } + } + } + } + }, + "customSettingsSeed": {}, + "description": "A/V Content Monitoring & Multiviewer", + "deviceType": "driver", + "discoveredBy": null, + "exists": "No", + "iconType": "device", + "ipAddress": null, + "label": "R&S PRISMON", + "modules": [], + "name": "prismon", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.nevion", + "products": [], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.2.1.1.2.0" + ], + "snmpSysObjectIdValue": "1.3.6.1.4.1.2566.127.1.2.216", + "status": "", + "supportedApis": [ + "DeviceLike", + "PortLike", + "CoreLike", + "StatusLike", + "MatrixControlLike", + "ParameterControlLike" + ], + "supportsExternal": true, + "supportsParameterApi": true, + "uniqueMetricDefs": [], + "updateableDevice": false, + "updateableModule": {}, + "version": "1.0.0" + }, + { + "_id": "com.nevion.probel_sw_p_08-0.1.0", + "_vid": "com.nevion.probel_sw_p_08-0.1.0", + "attachments": [ + { + "description": "Default", + "name": "probel_sw_p_08" + } + ], + "configurableDevice": false, + "configurableModule": [], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "Custom setting fields for probel_sw_p_08", + "label": "probel_sw_p_08" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map", + "values": { + "com.nevion.probel_sw_p_08.disconnect_source_address": { + "_schema": { + "default": 1023, + "descriptor": { + "desc": "Must match disconnect source address in custom matrix", + "label": "Disconnect Source Address" + }, + "isNullable": false, + "options": [], + "ranges": [ + [ + 0, + 1023, + 1 + ] + ], + "status": "Current", + "type": "number", + "units": "" + } + }, + "com.nevion.probel_sw_p_08.matrix_module_index": { + "_schema": { + "default": 0, + "descriptor": { + "desc": "This must be one higher than level in custom matrix", + "label": "Matrix Level" + }, + "isNullable": false, + "options": [], + "ranges": [ + [ + 0, + 16, + 1 + ] + ], + "status": "Current", + "type": "number", + "units": "" + } + }, + "com.nevion.probel_sw_p_08.name_length": { + "_schema": { + "default": 32, + "descriptor": { + "desc": "Must be in range [0,2,4,8,16,32]", + "label": "Length of labels" + }, + "isNullable": false, + "options": [], + "ranges": [ + [ + 0, + 32, + 2 + ] + ], + "status": "Current", + "type": "number", + "units": "" + } + }, + "com.nevion.probel_sw_p_08.num_router_levels": { + "_schema": { + "default": 0, + "descriptor": { + "desc": "Support up to 16", + "label": "SWP08 Level" + }, + "isNullable": false, + "options": [], + "ranges": [ + [ + 0, + 16, + 1 + ] + ], + "status": "Current", + "type": "number", + "units": "" + } + }, + "com.nevion.probel_sw_p_08.num_router_modules": { + "_schema": { + "default": 1, + "descriptor": { + "desc": "The number of matrices", + "label": "Number of matrices" + }, + "isNullable": false, + "options": [], + "ranges": [ + [ + 0, + 15, + 1 + ] + ], + "status": "Current", + "type": "number", + "units": "" + } + }, + "com.nevion.probel_sw_p_08.num_router_ports": { + "_schema": { + "default": 32, + "descriptor": { + "desc": "This must be the same number of ports as on the device", + "label": "Number of router ports" + }, + "isNullable": false, + "options": [], + "ranges": [ + [ + 0, + 1023, + 1 + ] + ], + "status": "Current", + "type": "number", + "units": "" + } + }, + "com.nevion.probel_sw_p_08.park_port": { + "_schema": { + "default": 0, + "descriptor": { + "desc": "Must match park port in topology", + "label": "Custom park port" + }, + "isNullable": false, + "options": [], + "ranges": [ + [ + 0, + 1023, + 1 + ] + ], + "status": "Current", + "type": "number", + "units": "" + } + }, + "com.nevion.probel_sw_p_08.port": { + "_schema": { + "default": 8910, + "descriptor": { + "desc": "Port", + "label": "Port" + }, + "isNullable": false, + "options": [], + "ranges": [ + [ + 0, + 65535, + 1 + ] + ], + "status": "Current", + "type": "number", + "units": "" + } + } + } + } + }, + "customSettingsSeed": {}, + "description": "Driver for Pro-bel-SW-P-08", + "deviceType": "switch", + "discoveredBy": null, + "exists": "No", + "iconType": "videoAudioRouterMatrix", + "ipAddress": null, + "label": "Pro-bel-SW-P-08 Driver", + "modules": [], + "name": "probel_sw_p_08", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.nevion", + "products": [], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.2.1.1.2.0" + ], + "snmpSysObjectIdValue": null, + "status": "", + "supportedApis": [ + "MatrixControlLike", + "CoreLike", + "DeviceLike", + "PortLike" + ], + "supportsExternal": true, + "supportsParameterApi": false, + "uniqueMetricDefs": [], + "updateableDevice": false, + "updateableModule": {}, + "version": "0.1.0" + }, + { + "_id": "com.nevion.r3lay-0.1.0", + "_vid": "com.nevion.r3lay-0.1.0", + "attachments": [ + { + "description": "Default", + "name": "r3lay" + } + ], + "configurableDevice": false, + "configurableModule": [], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "Custom setting fields for Lawo R3lay", + "label": "Lawo R3lay" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map", + "values": { + "com.nevion.r3lay.port": { + "_schema": { + "default": 9998, + "descriptor": { + "desc": "Port", + "label": "Port" + }, + "isNullable": false, + "options": [], + "ranges": [ + [ + 0, + 65535, + 1 + ] + ], + "status": "Current", + "type": "number", + "units": "" + } + } + } + } + }, + "customSettingsSeed": {}, + "description": "", + "deviceType": "driver", + "discoveredBy": null, + "exists": "No", + "iconType": "none", + "ipAddress": null, + "label": "Lawo R3LAY", + "modules": [], + "name": "r3lay", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.nevion", + "products": [], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.2.1.1.2.0" + ], + "snmpSysObjectIdValue": null, + "status": "", + "supportedApis": [ + "ParameterControlLike", + "DeviceLike", + "PortLike", + "TestLike", + "CoreLike", + "StatusLike" + ], + "supportsExternal": true, + "supportsParameterApi": true, + "uniqueMetricDefs": [], + "updateableDevice": false, + "updateableModule": {}, + "version": "0.1.0" + }, + { + "_id": "com.nevion.riedel_rrcs-0.1.0", + "_vid": "com.nevion.riedel_rrcs-0.1.0", + "attachments": [ + { + "description": "Default", + "name": "riedel_rrcs" + } + ], + "configurableDevice": false, + "configurableModule": [], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "customSettingsSeed": {}, + "description": "Riedel RRCS Intercom", + "deviceType": "driver", + "discoveredBy": null, + "exists": "No", + "iconType": "device", + "ipAddress": null, + "label": "Riedel RRCS", + "modules": [], + "name": "riedel_rrcs", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.nevion", + "products": [ + { + "name": "Riedel Artist", + "swBuildTime": null, + "swVersion": null + } + ], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.2.1.1.2.0" + ], + "snmpSysObjectIdValue": null, + "status": "", + "supportedApis": [ + "MatrixControlLike", + "CoreLike", + "DeviceLike", + "IntercomLike" + ], + "supportsExternal": true, + "supportsParameterApi": false, + "uniqueMetricDefs": [], + "updateableDevice": false, + "updateableModule": {}, + "version": "0.1.0" + }, + { + "_id": "com.nevion.selenio_13p-0.1.0", + "_vid": "com.nevion.selenio_13p-0.1.0", + "attachments": [ + { + "description": "Default", + "name": "selenio_13p" + } + ], + "configurableDevice": false, + "configurableModule": [], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "Custom setting fields for Selenio drivers", + "label": "Selenio" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map", + "values": { + "com.nevion.selenio_13p.assume_success_after": { + "_schema": { + "default": 0, + "descriptor": { + "desc": "Assume a configuration was successfully applied after time given in milliseconds, only use if slow response time from Selenio is a problem. Use with care.", + "label": "Assume successful response after [ms]" + }, + "isNullable": false, + "options": [], + "ranges": [], + "status": "Current", + "type": "number", + "units": "" + } + }, + "com.nevion.selenio_13p.cache_alarm_config_timeout": { + "_schema": { + "default": 1800, + "descriptor": { + "desc": "Alarm config cache timeout in seconds. The alarm config is used to fetch severity level for each alarm", + "label": "Alarm config cache timeout [s]" + }, + "isNullable": false, + "options": [], + "ranges": [ + [ + 0, + 252635728, + 1 + ] + ], + "status": "Current", + "type": "number", + "units": "" + } + }, + "com.nevion.selenio_13p.cache_timeout": { + "_schema": { + "default": 60, + "descriptor": { + "desc": "Driver cache timeout in seconds", + "label": "Cache timeout [s]" + }, + "isNullable": false, + "options": [], + "ranges": [ + [ + 0, + 600, + 1 + ] + ], + "status": "Current", + "type": "number", + "units": "" + } + }, + "com.nevion.selenio_13p.manager_ip": { + "_schema": { + "default": "", + "descriptor": { + "desc": "Network address of the manager controlling this element", + "label": "Manager Address" + }, + "encoding": "UTF-8", + "isNullable": false, + "lengthRanges": [], + "options": [], + "status": "Current", + "type": "string" + } + }, + "com.nevion.selenio_13p.nmos_port": { + "_schema": { + "default": 8100, + "descriptor": { + "desc": "The HTTP port used to reach the Node directly", + "label": "Port" + }, + "isNullable": false, + "options": [], + "ranges": [ + [ + 1, + 65535, + 1 + ] + ], + "status": "Current", + "type": "number", + "units": "" + } + } + } + } + }, + "customSettingsSeed": {}, + "description": "Driver for a Selenio Network Processor", + "deviceType": "driver", + "discoveredBy": null, + "exists": "No", + "iconType": "gateway", + "ipAddress": null, + "label": "Imagine SNP", + "modules": [], + "name": "selenio_13p", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.nevion", + "products": [], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.2.1.1.2.0" + ], + "snmpSysObjectIdValue": null, + "status": "", + "supportedApis": [ + "ParameterControlLike", + "DeviceLike", + "PortLike", + "CoreLike", + "StatusLike" + ], + "supportsExternal": true, + "supportsParameterApi": true, + "uniqueMetricDefs": [], + "updateableDevice": false, + "updateableModule": {}, + "version": "0.1.0" + }, + { + "_id": "com.nevion.sencore_dmg-0.1.0", + "_vid": "com.nevion.sencore_dmg-0.1.0", + "attachments": [ + { + "description": "Default", + "name": "sencore_dmg" + } + ], + "configurableDevice": false, + "configurableModule": [], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "Custom setting fields for Sencore DMG devices", + "label": "Sencore DMG" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map", + "values": { + "com.nevion.sencore_dmg.coder_ip_mapping": { + "_schema": { + "default": "", + "descriptor": { + "desc": "Coder module - IP module association map", + "label": "Coder-IP mapping" + }, + "encoding": "UTF-8", + "isNullable": false, + "lengthRanges": [], + "options": [], + "status": "Current", + "type": "string" + } + }, + "com.nevion.sencore_dmg.lan_wan_mapping": { + "_schema": { + "default": "", + "descriptor": { + "desc": "LAN/WAN module association map", + "label": "LAN-WAN mapping" + }, + "encoding": "UTF-8", + "isNullable": false, + "lengthRanges": [], + "options": [], + "status": "Current", + "type": "string" + } + } + } + } + }, + "customSettingsSeed": {}, + "description": "Driver for Sencore DMG devices with dynamic topology", + "deviceType": "driver", + "discoveredBy": null, + "exists": "No", + "iconType": "none", + "ipAddress": null, + "label": "Sencore DMG", + "modules": [], + "name": "sencore_dmg", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.nevion", + "products": [], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.2.1.1.2.0" + ], + "snmpSysObjectIdValue": null, + "status": "", + "supportedApis": [ + "ParameterControlLike", + "DeviceLike", + "PortLike", + "CoreLike", + "StatusLike" + ], + "supportsExternal": true, + "supportsParameterApi": true, + "uniqueMetricDefs": [], + "updateableDevice": false, + "updateableModule": {}, + "version": "0.1.0" + }, + { + "_id": "com.nevion.snell_probelrouter-0.0.1", + "_vid": "com.nevion.snell_probelrouter-0.0.1", + "attachments": [ + { + "description": "Default", + "name": "snell_probelrouter" + } + ], + "configurableDevice": false, + "configurableModule": [], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "customSettingsSeed": {}, + "description": "A driver for Snell Pro-Bel router, using standard PROBEL-ROUTER Mib-file", + "deviceType": "switch", + "discoveredBy": null, + "exists": "No", + "iconType": "videoAudioRouterMatrix", + "ipAddress": null, + "label": "Snell Pro-Bel Router", + "modules": [], + "name": "snell_probelrouter", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.nevion", + "products": [ + { + "name": "Snell Pro-Bel", + "swBuildTime": null, + "swVersion": null + } + ], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.2.1.1.2.0" + ], + "snmpSysObjectIdValue": null, + "status": "", + "supportedApis": [ + "ParameterControlLike", + "DeviceLike", + "PortLike", + "CoreLike", + "StatusLike" + ], + "supportsExternal": true, + "supportsParameterApi": true, + "uniqueMetricDefs": [], + "updateableDevice": false, + "updateableModule": {}, + "version": "0.0.1" + }, + { + "_id": "com.nevion.sonifex_gpio-0.1.0", + "_vid": "com.nevion.sonifex_gpio-0.1.0", + "attachments": [ + { + "description": "Default", + "name": "sonifex_gpio" + } + ], + "configurableDevice": false, + "configurableModule": [], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "customSettingsSeed": {}, + "description": "Transport stream monitor", + "deviceType": "driver", + "discoveredBy": null, + "exists": "No", + "iconType": "none", + "ipAddress": null, + "label": "SONIFEX-GPIO", + "modules": [], + "name": "sonifex_gpio", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.nevion", + "products": [], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.2.1.1.2.0" + ], + "snmpSysObjectIdValue": null, + "status": "", + "supportedApis": [ + "ParameterControlLike", + "GPIOLike", + "CoreLike", + "DeviceLike" + ], + "supportsExternal": true, + "supportsParameterApi": true, + "uniqueMetricDefs": [], + "updateableDevice": false, + "updateableModule": {}, + "version": "0.1.0" + }, + { + "_id": "com.nevion.sony_nxlk-ip50y-0.1.0", + "_vid": "com.nevion.sony_nxlk-ip50y-0.1.0", + "attachments": [ + { + "description": "Default", + "name": "sony_nxlk-ip50y" + } + ], + "configurableDevice": true, + "configurableModule": [], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "Custom setting fields for NDCP drivers", + "label": "NDCP" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map", + "values": { + "com.nevion.ndcp.deviceId": { + "_schema": { + "default": "", + "descriptor": { + "desc": "Device id usually auto-populated by device discovery", + "label": "NDCP device id" + }, + "encoding": "UTF-8", + "isNullable": false, + "lengthRanges": [], + "options": [], + "status": "Current", + "type": "string" + } + }, + "com.nevion.sony_nxlk-ip50y.always_enable_rtp": { + "_schema": { + "default": false, + "descriptor": { + "desc": "The \"rtp_enabled\" field in \"transport_params\" will always be set to true", + "label": "Always enable RTP" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "bool" + } + }, + "com.nevion.sony_nxlk-ip50y.disable_rx_sdp": { + "_schema": { + "default": false, + "descriptor": { + "desc": "Configure this unit's receivers with regular transport parameters only", + "label": "Disable Rx SDP" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "bool" + } + }, + "com.nevion.sony_nxlk-ip50y.disable_rx_sdp_with_null": { + "_schema": { + "default": true, + "descriptor": { + "desc": "Configures how RX SDPs are disabled. If unchecked, an empty string is used", + "label": "Disable Rx SDP with null" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "bool" + } + }, + "com.nevion.sony_nxlk-ip50y.enable_bulk_config": { + "_schema": { + "default": false, + "descriptor": { + "desc": "Configure this unit using bulk API", + "label": "Enable bulk config" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "bool" + } + }, + "com.nevion.sony_nxlk-ip50y.enable_experimental_alarm": { + "_schema": { + "default": false, + "descriptor": { + "desc": "Enables experimental alarms over websockets using IS-07 on certain Vizrt devices. Disables alarms completely if disabled", + "label": "Enable experimental alarms using IS-07" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "bool" + } + }, + "com.nevion.sony_nxlk-ip50y.experimental_alarm_port": { + "_schema": { + "default": 0, + "descriptor": { + "desc": "HTTP port for location of experimental IS-07 alarm websocket. If empty or 0 it uses Port field instead", + "label": "Experimental alarm port" + }, + "isNullable": true, + "options": [], + "ranges": [ + [ + 1, + 65535, + 1 + ] + ], + "status": "Current", + "type": "number", + "units": "" + } + }, + "com.nevion.sony_nxlk-ip50y.is05_api_version": { + "_schema": { + "default": false, + "descriptor": { + "desc": "Configure IS05 API version to use max", + "label": "Enable Max IS05 API version" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "bool" + } + }, + "com.nevion.sony_nxlk-ip50y.port": { + "_schema": { + "default": 80, + "descriptor": { + "desc": "The HTTP port used to reach the Node directly", + "label": "Port" + }, + "isNullable": false, + "options": [], + "ranges": [ + [ + 1, + 65535, + 1 + ] + ], + "status": "Current", + "type": "number", + "units": "" + } + } + } + } + }, + "customSettingsSeed": {}, + "description": "Driver for NXLK_IP50Y Devices", + "deviceType": "driver", + "discoveredBy": null, + "exists": "No", + "iconType": "none", + "ipAddress": null, + "label": "Sony NXLK-IP50Y", + "modules": [], + "name": "sony_nxlk-ip50y", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.nevion", + "products": [ + { + "name": "NDCP", + "swBuildTime": null, + "swVersion": null + }, + { + "name": "NMOS", + "swBuildTime": null, + "swVersion": null + } + ], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.2.1.1.2.0" + ], + "snmpSysObjectIdValue": null, + "status": "", + "supportedApis": [ + "MatrixControlLike", + "DeviceLike", + "DynamicLike", + "MaintenanceLike", + "PortLike", + "CoreLike", + "StatusLike" + ], + "supportsExternal": true, + "supportsParameterApi": false, + "uniqueMetricDefs": [], + "updateableDevice": false, + "updateableModule": {}, + "version": "0.1.0" + }, + { + "_id": "com.nevion.sony_nxlk-ip51y-0.1.0", + "_vid": "com.nevion.sony_nxlk-ip51y-0.1.0", + "attachments": [ + { + "description": "Default", + "name": "sony_nxlk-ip51y" + } + ], + "configurableDevice": true, + "configurableModule": [], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "Custom setting fields for NDCP drivers", + "label": "NDCP" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map", + "values": { + "com.nevion.ndcp.deviceId": { + "_schema": { + "default": "", + "descriptor": { + "desc": "Device id usually auto-populated by device discovery", + "label": "NDCP device id" + }, + "encoding": "UTF-8", + "isNullable": false, + "lengthRanges": [], + "options": [], + "status": "Current", + "type": "string" + } + }, + "com.nevion.sony_nxlk-ip51y.always_enable_rtp": { + "_schema": { + "default": false, + "descriptor": { + "desc": "The \"rtp_enabled\" field in \"transport_params\" will always be set to true", + "label": "Always enable RTP" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "bool" + } + }, + "com.nevion.sony_nxlk-ip51y.disable_rx_sdp": { + "_schema": { + "default": false, + "descriptor": { + "desc": "Configure this unit's receivers with regular transport parameters only", + "label": "Disable Rx SDP" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "bool" + } + }, + "com.nevion.sony_nxlk-ip51y.disable_rx_sdp_with_null": { + "_schema": { + "default": true, + "descriptor": { + "desc": "Configures how RX SDPs are disabled. If unchecked, an empty string is used", + "label": "Disable Rx SDP with null" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "bool" + } + }, + "com.nevion.sony_nxlk-ip51y.enable_bulk_config": { + "_schema": { + "default": false, + "descriptor": { + "desc": "Configure this unit using bulk API", + "label": "Enable bulk config" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "bool" + } + }, + "com.nevion.sony_nxlk-ip51y.enable_experimental_alarm": { + "_schema": { + "default": false, + "descriptor": { + "desc": "Enables experimental alarms over websockets using IS-07 on certain Vizrt devices. Disables alarms completely if disabled", + "label": "Enable experimental alarms using IS-07" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "bool" + } + }, + "com.nevion.sony_nxlk-ip51y.experimental_alarm_port": { + "_schema": { + "default": 0, + "descriptor": { + "desc": "HTTP port for location of experimental IS-07 alarm websocket. If empty or 0 it uses Port field instead", + "label": "Experimental alarm port" + }, + "isNullable": true, + "options": [], + "ranges": [ + [ + 1, + 65535, + 1 + ] + ], + "status": "Current", + "type": "number", + "units": "" + } + }, + "com.nevion.sony_nxlk-ip51y.is05_api_version": { + "_schema": { + "default": false, + "descriptor": { + "desc": "Configure IS05 API version to use max", + "label": "Enable Max IS05 API version" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "bool" + } + }, + "com.nevion.sony_nxlk-ip51y.port": { + "_schema": { + "default": 80, + "descriptor": { + "desc": "The HTTP port used to reach the Node directly", + "label": "Port" + }, + "isNullable": false, + "options": [], + "ranges": [ + [ + 1, + 65535, + 1 + ] + ], + "status": "Current", + "type": "number", + "units": "" + } + } + } + } + }, + "customSettingsSeed": {}, + "description": "Driver for NXLK_IP51Y Devices", + "deviceType": "driver", + "discoveredBy": null, + "exists": "No", + "iconType": "none", + "ipAddress": null, + "label": "Sony NXLK-IP51Y", + "modules": [], + "name": "sony_nxlk-ip51y", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.nevion", + "products": [ + { + "name": "NDCP", + "swBuildTime": null, + "swVersion": null + }, + { + "name": "NMOS", + "swBuildTime": null, + "swVersion": null + } + ], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.2.1.1.2.0" + ], + "snmpSysObjectIdValue": null, + "status": "", + "supportedApis": [ + "MatrixControlLike", + "DeviceLike", + "DynamicLike", + "MaintenanceLike", + "PortLike", + "CoreLike", + "StatusLike" + ], + "supportsExternal": true, + "supportsParameterApi": false, + "uniqueMetricDefs": [], + "updateableDevice": false, + "updateableModule": {}, + "version": "0.1.0" + }, + { + "_id": "com.nevion.spg9000-0.1.0", + "_vid": "com.nevion.spg9000-0.1.0", + "attachments": [ + { + "description": "Default", + "name": "spg9000" + } + ], + "configurableDevice": true, + "configurableModule": [], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "Custom settings for SPG9000", + "label": "SPG9000" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map", + "values": { + "com.nevion.spg9000.x_api_key": { + "_schema": { + "default": "apikey", + "descriptor": { + "desc": "x-api-key (configurable in SPG9000's System tab)", + "label": "x-api-key" + }, + "encoding": "UTF-8", + "isNullable": false, + "lengthRanges": [], + "options": [], + "status": "Current", + "type": "string" + } + } + } + } + }, + "customSettingsSeed": {}, + "description": "Driver for Telestream SPG9000: Timing and Reference System", + "deviceType": "driver", + "discoveredBy": null, + "exists": "No", + "iconType": "none", + "ipAddress": null, + "label": "SPG9000", + "modules": [], + "name": "spg9000", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.nevion", + "products": [], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.2.1.1.2.0" + ], + "snmpSysObjectIdValue": null, + "status": "", + "supportedApis": [ + "ParameterControlLike", + "MaintenanceLike", + "CoreLike", + "DeviceLike" + ], + "supportsExternal": true, + "supportsParameterApi": true, + "uniqueMetricDefs": [], + "updateableDevice": false, + "updateableModule": {}, + "version": "0.1.0" + }, + { + "_id": "com.nevion.ssl_system_t-0.1.0", + "_vid": "com.nevion.ssl_system_t-0.1.0", + "attachments": [ + { + "description": "Default", + "name": "ssl_system_t" + } + ], + "configurableDevice": false, + "configurableModule": [], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "customSettingsSeed": {}, + "description": "", + "deviceType": "driver", + "discoveredBy": null, + "exists": "No", + "iconType": "none", + "ipAddress": null, + "label": "SSL System T", + "modules": [], + "name": "ssl_system_t", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.nevion", + "products": [], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.2.1.1.2.0" + ], + "snmpSysObjectIdValue": null, + "status": "", + "supportedApis": [ + "ParameterControlLike", + "GPIOLike", + "CoreLike", + "DeviceLike" + ], + "supportsExternal": true, + "supportsParameterApi": true, + "uniqueMetricDefs": [], + "updateableDevice": false, + "updateableModule": {}, + "version": "0.1.0" + }, + { + "_id": "com.nevion.starfish_splicer-0.1.0", + "_vid": "com.nevion.starfish_splicer-0.1.0", + "attachments": [ + { + "description": "Default", + "name": "starfish_splicer" + } + ], + "configurableDevice": false, + "configurableModule": [], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "Custom setting fields for Starfish TS Splicer devices", + "label": "starfish_splicer" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map", + "values": { + "com.nevion.starfish_splicer.api_port": { + "_schema": { + "default": 8080, + "descriptor": { + "desc": "The HTTP port used to reach the API of the device directly", + "label": "API Port" + }, + "isNullable": false, + "options": [], + "ranges": [ + [ + 1, + 65535, + 1 + ] + ], + "status": "Current", + "type": "number", + "units": "" + } + } + } + } + }, + "customSettingsSeed": {}, + "description": "Software based transport stream splicing", + "deviceType": "driver", + "discoveredBy": null, + "exists": "No", + "iconType": "none", + "ipAddress": null, + "label": "Starfish Splicer", + "modules": [], + "name": "starfish_splicer", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.nevion", + "products": [], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.2.1.1.2.0" + ], + "snmpSysObjectIdValue": null, + "status": "", + "supportedApis": [ + "CoreLike", + "DeviceLike", + "PortLike" + ], + "supportsExternal": true, + "supportsParameterApi": false, + "uniqueMetricDefs": [], + "updateableDevice": false, + "updateableModule": {}, + "version": "0.1.0" + }, + { + "_id": "com.nevion.streamdeck_studio-0.1.0", + "_vid": "com.nevion.streamdeck_studio-0.1.0", + "attachments": [ + { + "description": "Default", + "name": "streamdeck_studio" + } + ], + "configurableDevice": false, + "configurableModule": [], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "customSettingsSeed": {}, + "description": "GPIO Driver for StreamDeck Studio", + "deviceType": "driver", + "discoveredBy": null, + "exists": "No", + "iconType": "device", + "ipAddress": null, + "label": "StreamDeck Studio", + "modules": [], + "name": "streamdeck_studio", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.nevion", + "products": [], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.2.1.1.2.0" + ], + "snmpSysObjectIdValue": null, + "status": "", + "supportedApis": [ + "CoreLike", + "DeviceLike", + "HwPanelLike" + ], + "supportsExternal": true, + "supportsParameterApi": false, + "uniqueMetricDefs": [], + "updateableDevice": false, + "updateableModule": {}, + "version": "0.1.0" + }, + { + "_id": "com.nevion.sublime-0.1.0", + "_vid": "com.nevion.sublime-0.1.0", + "attachments": [ + { + "description": "Default", + "name": "sublime" + } + ], + "configurableDevice": false, + "configurableModule": [], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "customSettingsSeed": {}, + "description": "Driver for Sublime Routers", + "deviceType": "switch", + "discoveredBy": null, + "exists": "No", + "iconType": "videoAudioRouterMatrix", + "ipAddress": null, + "label": "Nevion Sublime", + "modules": [], + "name": "sublime", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.nevion", + "products": [ + { + "name": "SUBLIME", + "swBuildTime": null, + "swVersion": null + } + ], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.2.1.1.2.0" + ], + "snmpSysObjectIdValue": null, + "status": "", + "supportedApis": [ + "DeviceLike", + "PortLike", + "CoreLike", + "StatusLike", + "MatrixControlLike", + "ParameterControlLike" + ], + "supportsExternal": true, + "supportsParameterApi": true, + "uniqueMetricDefs": [], + "updateableDevice": false, + "updateableModule": {}, + "version": "0.1.0" + }, + { + "_id": "com.nevion.tag_mcm9000-0.1.0", + "_vid": "com.nevion.tag_mcm9000-0.1.0", + "attachments": [ + { + "description": "Default", + "name": "tag_mcm9000" + } + ], + "configurableDevice": false, + "configurableModule": [], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "Custom setting fields for TAG MCM 9000 Nodes", + "label": "tag_mcm9000" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map", + "values": { + "com.nevion.tag_mcm9000.enable_bulk_config": { + "_schema": { + "default": false, + "descriptor": { + "desc": "Configure this unit using bulk API", + "label": "Enable bulk config" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "bool" + } + }, + "com.nevion.tag_mcm9000.enable_legacy_uuid_api": { + "_schema": { + "default": false, + "descriptor": { + "desc": "Uses legacy uppercase UUIDs in API to match previously synced topologies", + "label": "Enable 4.1 API (legacy UUIDs)" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "bool" + } + } + } + } + }, + "customSettingsSeed": {}, + "description": "", + "deviceType": "driver", + "discoveredBy": null, + "exists": "No", + "iconType": "none", + "ipAddress": null, + "label": "TAG MCM9000", + "modules": [], + "name": "tag_mcm9000", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.nevion", + "products": [], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.2.1.1.2.0" + ], + "snmpSysObjectIdValue": null, + "status": "", + "supportedApis": [ + "CoreLike", + "DeviceLike", + "PortLike" + ], + "supportsExternal": true, + "supportsParameterApi": false, + "uniqueMetricDefs": [], + "updateableDevice": false, + "updateableModule": {}, + "version": "0.1.0" + }, + { + "_id": "com.nevion.tag_mcs-0.1.0", + "_vid": "com.nevion.tag_mcs-0.1.0", + "attachments": [ + { + "description": "Default", + "name": "tag_mcs" + } + ], + "configurableDevice": false, + "configurableModule": [], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "Custom setting fields for TAG MCS Nodes", + "label": "tag_mcs" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map", + "values": { + "com.nevion.tag_mcs.enable_bulk_config": { + "_schema": { + "default": true, + "descriptor": { + "desc": "Configure this unit using bulk API", + "label": "Enable bulk config" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "bool" + } + }, + "com.nevion.tag_mcs.update_extra_address_timer": { + "_schema": { + "default": 5, + "descriptor": { + "desc": "Configure timer for checking for changed managed addresses for SNMP traps in minutes", + "label": "Update SNMP trap extra address timer" + }, + "isNullable": false, + "options": [], + "ranges": [ + [ + 1, + 2147483647, + 1 + ] + ], + "status": "Current", + "type": "number", + "units": "minute(s)" + } + } + } + } + }, + "customSettingsSeed": {}, + "description": "", + "deviceType": "driver", + "discoveredBy": null, + "exists": "No", + "iconType": "none", + "ipAddress": null, + "label": "TAG MCS", + "modules": [], + "name": "tag_mcs", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.nevion", + "products": [ + { + "name": "TAG MCS", + "swBuildTime": null, + "swVersion": null + } + ], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.2.1.1.2.0" + ], + "snmpSysObjectIdValue": null, + "status": "", + "supportedApis": [ + "DeviceLike", + "PortLike", + "CoreLike", + "StatusLike", + "MatrixControlLike", + "ParameterControlLike" + ], + "supportsExternal": true, + "supportsParameterApi": true, + "uniqueMetricDefs": [], + "updateableDevice": false, + "updateableModule": {}, + "version": "0.1.0" + }, + { + "_id": "com.nevion.tally-0.1.0", + "_vid": "com.nevion.tally-0.1.0", + "attachments": [ + { + "description": "Default", + "name": "tally" + } + ], + "configurableDevice": false, + "configurableModule": [], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "Custom setting fields for Tally devices", + "label": "Tally" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map", + "values": { + "com.nevion.tally.primary_port": { + "_schema": { + "default": 8900, + "descriptor": { + "desc": "Primary Port", + "label": "Primary Port" + }, + "isNullable": false, + "options": [], + "ranges": [ + [ + 1, + 65535, + 1 + ] + ], + "status": "Current", + "type": "number", + "units": "" + } + }, + "com.nevion.tally.screen_id": { + "_schema": { + "default": 0, + "descriptor": { + "desc": "Screen ID", + "label": "Static Screen ID" + }, + "isNullable": false, + "options": [], + "ranges": [ + [ + 0, + 65535, + 1 + ] + ], + "status": "Current", + "type": "number", + "units": "" + } + }, + "com.nevion.tally.secondary_port": { + "_schema": { + "default": 8900, + "descriptor": { + "desc": "Secondary Port", + "label": "Secondary Port" + }, + "isNullable": false, + "options": [], + "ranges": [ + [ + 1, + 65535, + 1 + ] + ], + "status": "Current", + "type": "number", + "units": "" + } + }, + "com.nevion.tally.tally_brightness": { + "_schema": { + "default": 3, + "descriptor": { + "desc": "Tally Brightness", + "label": "Static Tally Brightness" + }, + "isNullable": false, + "options": [ + { + "descriptor": { + "desc": "", + "label": "Full" + }, + "value": 3 + }, + { + "descriptor": { + "desc": "", + "label": "Half" + }, + "value": 2 + }, + { + "descriptor": { + "desc": "", + "label": "1/7th" + }, + "value": 1 + }, + { + "descriptor": { + "desc": "", + "label": "Zero" + }, + "value": 0 + } + ], + "ranges": [], + "status": "Current", + "type": "number", + "units": "" + } + }, + "com.nevion.tally.tally_character_set": { + "_schema": { + "default": 1, + "descriptor": { + "desc": "Character Set", + "label": "Tally Character Set" + }, + "isNullable": false, + "options": [ + { + "descriptor": { + "desc": "", + "label": "ASCII" + }, + "value": 0 + }, + { + "descriptor": { + "desc": "", + "label": "UTF-16LE" + }, + "value": 1 + } + ], + "ranges": [], + "status": "Current", + "type": "number", + "units": "" + } + }, + "com.nevion.tally.x_number_of_umd": { + "_schema": { + "default": 32, + "descriptor": { + "desc": "Number of UMDs", + "label": "Number of UMDs" + }, + "isNullable": false, + "options": [], + "ranges": [ + [ + 1, + 256, + 1 + ] + ], + "status": "Current", + "type": "number", + "units": "" + } + } + } + } + }, + "customSettingsSeed": {}, + "description": "Driver for Tally devices", + "deviceType": "panel", + "discoveredBy": null, + "exists": "No", + "iconType": "none", + "ipAddress": null, + "label": "Tally", + "modules": [], + "name": "tally", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.nevion", + "products": [], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.2.1.1.2.0" + ], + "snmpSysObjectIdValue": null, + "status": "", + "supportedApis": [ + "TallyLike", + "CoreLike", + "DeviceLike" + ], + "supportsExternal": false, + "supportsParameterApi": false, + "uniqueMetricDefs": [], + "updateableDevice": false, + "updateableModule": {}, + "version": "0.1.0" + }, + { + "_id": "com.nevion.telestream_surveyor-0.1.0", + "_vid": "com.nevion.telestream_surveyor-0.1.0", + "attachments": [ + { + "description": "Default", + "name": "telestream_surveyor" + } + ], + "configurableDevice": false, + "configurableModule": [], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "customSettingsSeed": {}, + "description": "Driver for a Telestream Surveyor device", + "deviceType": "driver", + "discoveredBy": null, + "exists": "No", + "iconType": "none", + "ipAddress": null, + "label": "Telestream Surveyor driver", + "modules": [], + "name": "telestream_surveyor", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.nevion", + "products": [], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.2.1.1.2.0" + ], + "snmpSysObjectIdValue": null, + "status": "", + "supportedApis": [ + "CoreLike", + "DeviceLike", + "StatusLike", + "PortLike" + ], + "supportsExternal": true, + "supportsParameterApi": false, + "uniqueMetricDefs": [], + "updateableDevice": false, + "updateableModule": {}, + "version": "0.1.0" + }, + { + "_id": "com.nevion.thomson_mxs-0.1.0", + "_vid": "com.nevion.thomson_mxs-0.1.0", + "attachments": [ + { + "description": "Default", + "name": "thomson_mxs" + } + ], + "configurableDevice": false, + "configurableModule": [], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "customSettingsSeed": {}, + "description": "Unified Management System", + "deviceType": "driver", + "discoveredBy": null, + "exists": "No", + "iconType": "device", + "ipAddress": null, + "label": "Thomson MXS", + "modules": [], + "name": "thomson_mxs", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.nevion", + "products": [], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.2.1.1.2.0" + ], + "snmpSysObjectIdValue": null, + "status": "", + "supportedApis": [ + "ParameterControlLike", + "CoreLike", + "DeviceLike", + "StatusLike" + ], + "supportsExternal": true, + "supportsParameterApi": true, + "uniqueMetricDefs": [], + "updateableDevice": false, + "updateableModule": {}, + "version": "0.1.0" + }, + { + "_id": "com.nevion.thomson_vibe-0.1.0", + "_vid": "com.nevion.thomson_vibe-0.1.0", + "attachments": [ + { + "description": "Encoder configuration", + "name": "thomson_vibe.enc" + }, + { + "description": "Decoder configuration", + "name": "thomson_vibe.dec" + }, + { + "description": "IP configuration", + "name": "thomson_vibe.ip" + } + ], + "configurableDevice": false, + "configurableModule": [], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "customSettingsSeed": {}, + "description": "Driver for Thomson ViBE codecs", + "deviceType": "driver", + "discoveredBy": null, + "exists": "No", + "iconType": "encoderDecoder", + "ipAddress": null, + "label": "Thomson ViBE Codec", + "modules": [ + "Controller", + "Encoder|Decoder|FE-100BT" + ], + "name": "thomson_vibe", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.nevion", + "products": [ + { + "name": "Thomson VIBE", + "swBuildTime": null, + "swVersion": null + } + ], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.2.1.1.2.0" + ], + "snmpSysObjectIdValue": "1.3.6.1.4.1.4947.2.11.2", + "status": "", + "supportedApis": [ + "ParameterControlLike", + "DeviceLike", + "DynamicLike", + "PortLike", + "CoreLike", + "StatusLike" + ], + "supportsExternal": true, + "supportsParameterApi": true, + "uniqueMetricDefs": [], + "updateableDevice": false, + "updateableModule": {}, + "version": "0.1.0" + }, + { + "_id": "com.nevion.tns4200-0.1.0", + "_vid": "com.nevion.tns4200-0.1.0", + "attachments": [ + { + "description": "Default", + "name": "tns4200" + } + ], + "configurableDevice": true, + "configurableModule": [], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "Custom setting fields for Platform4000", + "label": "Platform4000" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map", + "values": { + "com.nevion.null.reuse_ts_element": { + "_schema": { + "default": false, + "descriptor": { + "desc": "", + "label": "Enable to activate logic to join existing TS input element for ASI outputs when setting up multicast with identical settings" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "bool" + } + } + } + } + }, + "customSettingsSeed": {}, + "description": "Driver for the Nevion TNS4200 Monitoring Probe", + "deviceType": "driver", + "discoveredBy": null, + "exists": "No", + "iconType": "monitor", + "ipAddress": null, + "label": "Nevion TNS4200", + "modules": [ + "System", + "Slot [0-4]" + ], + "name": "tns4200", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.nevion", + "products": [ + { + "name": "TNS4200", + "swBuildTime": null, + "swVersion": "1.2.2" + } + ], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.2.1.1.2.0" + ], + "snmpSysObjectIdValue": "1.3.6.1.4.1.8768.10.35", + "status": "", + "supportedApis": [ + "ParameterControlLike", + "DeviceLike", + "DynamicLike", + "PortLike", + "CoreLike", + "StatusLike", + "MaintenanceLike", + "NetworkServiceLike" + ], + "supportsExternal": true, + "supportsParameterApi": true, + "uniqueMetricDefs": [ + { + "descriptor": { + "desc": "Last bitrate measured.", + "label": "Ts Pid Bitrate" + }, + "id": "ts.pid.bit.rate", + "isAlarmTrigger": false, + "isCounter": false, + "unit": "" + }, + { + "descriptor": { + "desc": "Last average bitrate measured.", + "label": "Ts Pid Average Bitrate" + }, + "id": "ts.pid.average.bit.rate", + "isAlarmTrigger": false, + "isCounter": false, + "unit": "" + }, + { + "descriptor": { + "desc": "Continuity error counter.", + "label": "Ts Pid Continuity Error Counter" + }, + "id": "ts.pid.continuity.error.counter", + "isAlarmTrigger": false, + "isCounter": false, + "unit": "" + }, + { + "descriptor": { + "desc": "Last bitrate sampled for program.", + "label": "Ts Service Bitrate" + }, + "id": "ts.service.bit.rate", + "isAlarmTrigger": false, + "isCounter": false, + "unit": "" + }, + { + "descriptor": { + "desc": "Effective bitrate, i.e., bitrate without null packets.", + "label": "Ts Effective Bitrate" + }, + "id": "ts.effective.bit.rate", + "isAlarmTrigger": false, + "isCounter": false, + "unit": "" + }, + { + "descriptor": { + "desc": "Total bitrate.", + "label": "Ts Total Bitrate" + }, + "id": "ts.total.bit.rate", + "isAlarmTrigger": false, + "isCounter": false, + "unit": "" + }, + { + "descriptor": { + "desc": "Inter arrival time, i.e., the time between arrivals into the system.", + "label": "Tsoip Rx Sips Iat" + }, + "id": "tsoip.rx.sips.iat", + "isAlarmTrigger": false, + "isCounter": false, + "unit": "" + }, + { + "descriptor": { + "desc": "Packet delay factor.", + "label": "Tsoip Rx Sips Pdv" + }, + "id": "tsoip.rx.sips.pdv", + "isAlarmTrigger": false, + "isCounter": false, + "unit": "" + }, + { + "descriptor": { + "desc": "Current buffer.", + "label": "Tsoip Rx Buff Lat" + }, + "id": "tsoip.rx.buff.lat", + "isAlarmTrigger": false, + "isCounter": false, + "unit": "" + }, + { + "descriptor": { + "desc": "Current utilization fraction of buffer, with the signal currently received and configured parameters.", + "label": "Tsoip Rx Buff Util" + }, + "id": "tsoip.rx.buff.util", + "isAlarmTrigger": false, + "isCounter": false, + "unit": "" + }, + { + "descriptor": { + "desc": "SFP Channel internal bias current", + "label": "Sfp Internal Current" + }, + "id": "sfp.internal.current", + "isAlarmTrigger": false, + "isCounter": false, + "unit": "mA" + }, + { + "descriptor": { + "desc": "SFP Channel Internal Vcc", + "label": "Sfp Internal Voltage" + }, + "id": "sfp.internal.voltage", + "isAlarmTrigger": false, + "isCounter": false, + "unit": "V" + }, + { + "descriptor": { + "desc": "SFP Channel internal temperature", + "label": "Sfp Internal Temp" + }, + "id": "sfp.internal.temp", + "isAlarmTrigger": false, + "isCounter": false, + "unit": "C" + }, + { + "descriptor": { + "desc": "SFP Channel RX input power", + "label": "Sfp Rx Effect" + }, + "id": "sfp.rx.effect", + "isAlarmTrigger": false, + "isCounter": false, + "unit": "mW" + }, + { + "descriptor": { + "desc": "SFP Channel RX input power", + "label": "Sfp Rx Effect Log" + }, + "id": "sfp.rx.effect.log", + "isAlarmTrigger": false, + "isCounter": false, + "unit": "dBm" + }, + { + "descriptor": { + "desc": "SFP Channel TX output power", + "label": "Sfp Tx Effect" + }, + "id": "sfp.tx.effect", + "isAlarmTrigger": false, + "isCounter": false, + "unit": "mW" + }, + { + "descriptor": { + "desc": "SFP Channel TX output power", + "label": "Sfp Tx Effect Log" + }, + "id": "sfp.tx.effect.log", + "isAlarmTrigger": false, + "isCounter": false, + "unit": "dBm" + } + ], + "updateableDevice": false, + "updateableModule": { + "ASI-Input/Output-Board": { + "availableBanks": [], + "rebootOption": 0 + }, + "DVB-T/T2-Demodulator": { + "availableBanks": [], + "rebootOption": 0 + }, + "GNSS-clock-reference-board": { + "availableBanks": [], + "rebootOption": 0 + }, + "Main-Board": { + "availableBanks": [], + "rebootOption": 0 + } + }, + "version": "0.1.0" + }, + { + "_id": "com.nevion.tns460-0.1.0", + "_vid": "com.nevion.tns460-0.1.0", + "attachments": [ + { + "description": "Default", + "name": "tns460" + } + ], + "configurableDevice": true, + "configurableModule": [], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "customSettingsSeed": {}, + "description": "Driver for the Nevion TNS460 HD/SD-SDI Monitor", + "deviceType": "driver", + "discoveredBy": null, + "exists": "No", + "iconType": "monitor", + "ipAddress": null, + "label": "Nevion TNS460", + "modules": [ + "System", + "Network", + "Clock Regulator", + "SDI Inputs", + "Monitors" + ], + "name": "tns460", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.nevion", + "products": [ + { + "name": "TNS460", + "swBuildTime": null, + "swVersion": null + } + ], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.2.1.1.2.0" + ], + "snmpSysObjectIdValue": "1.3.6.1.4.1.22909.3.30", + "status": "", + "supportedApis": [ + "ParameterControlLike", + "DeviceLike", + "DynamicLike", + "CoreLike", + "StatusLike", + "MaintenanceLike", + "NetworkServiceLike" + ], + "supportsExternal": true, + "supportsParameterApi": true, + "uniqueMetricDefs": [], + "updateableDevice": true, + "updateableModule": {}, + "version": "0.1.0" + }, + { + "_id": "com.nevion.tns541-0.1.0", + "_vid": "com.nevion.tns541-0.1.0", + "attachments": [ + { + "description": "Default", + "name": "tns541" + } + ], + "configurableDevice": true, + "configurableModule": [], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "customSettingsSeed": {}, + "description": "Driver for the Nevion TNS541 Seamless TS Monitoring Switch", + "deviceType": "driver", + "discoveredBy": null, + "exists": "No", + "iconType": "monitor", + "ipAddress": null, + "label": "Nevion TNS541", + "modules": [ + "System", + "Network", + "Clock Regulator", + "Relay [N+1]" + ], + "name": "tns541", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.nevion", + "products": [ + { + "name": "TNS541", + "swBuildTime": null, + "swVersion": null + } + ], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.2.1.1.2.0" + ], + "snmpSysObjectIdValue": "1.3.6.1.4.1.22909.3.11", + "status": "", + "supportedApis": [ + "ParameterControlLike", + "DeviceLike", + "DynamicLike", + "MaintenanceLike", + "CoreLike", + "StatusLike" + ], + "supportsExternal": true, + "supportsParameterApi": true, + "uniqueMetricDefs": [], + "updateableDevice": true, + "updateableModule": {}, + "version": "0.1.0" + }, + { + "_id": "com.nevion.tns544-0.1.0", + "_vid": "com.nevion.tns544-0.1.0", + "attachments": [ + { + "description": "Default", + "name": "tns544" + } + ], + "configurableDevice": true, + "configurableModule": [], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "customSettingsSeed": {}, + "description": "Driver for the Nevion TNS544 TSoIP Switch", + "deviceType": "driver", + "discoveredBy": null, + "exists": "No", + "iconType": "monitor", + "ipAddress": null, + "label": "Nevion TNS544", + "modules": [ + "System", + "Network", + "Switch Inputs", + "IP Inputs" + ], + "name": "tns544", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.nevion", + "products": [ + { + "name": "TNS544", + "swBuildTime": null, + "swVersion": null + } + ], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.2.1.1.2.0" + ], + "snmpSysObjectIdValue": "1.3.6.1.4.1.22909.3.21", + "status": "", + "supportedApis": [ + "ParameterControlLike", + "DeviceLike", + "DynamicLike", + "MaintenanceLike", + "CoreLike", + "StatusLike" + ], + "supportsExternal": true, + "supportsParameterApi": true, + "uniqueMetricDefs": [], + "updateableDevice": true, + "updateableModule": {}, + "version": "0.1.0" + }, + { + "_id": "com.nevion.tns546-0.1.0", + "_vid": "com.nevion.tns546-0.1.0", + "attachments": [ + { + "description": "Default", + "name": "tns546" + } + ], + "configurableDevice": true, + "configurableModule": [], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "customSettingsSeed": {}, + "description": "Driver for the Nevion TNS546 TS Monitor", + "deviceType": "driver", + "discoveredBy": null, + "exists": "No", + "iconType": "monitor", + "ipAddress": null, + "label": "Nevion TNS546", + "modules": [ + "System", + "Network", + "ASI Inputs", + "ASI Outputs", + "IP Inputs" + ], + "name": "tns546", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.nevion", + "products": [ + { + "name": "TNS546", + "swBuildTime": null, + "swVersion": null + } + ], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.2.1.1.2.0" + ], + "snmpSysObjectIdValue": "1.3.6.1.4.1.22909.3.16", + "status": "", + "supportedApis": [ + "ParameterControlLike", + "DeviceLike", + "DynamicLike", + "MaintenanceLike", + "CoreLike", + "StatusLike" + ], + "supportsExternal": true, + "supportsParameterApi": true, + "uniqueMetricDefs": [], + "updateableDevice": true, + "updateableModule": {}, + "version": "0.1.0" + }, + { + "_id": "com.nevion.tns547-0.1.0", + "_vid": "com.nevion.tns547-0.1.0", + "attachments": [ + { + "description": "Default", + "name": "tns547" + } + ], + "configurableDevice": true, + "configurableModule": [], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "customSettingsSeed": {}, + "description": "Driver for the Nevion TNS547 DTT Monitor", + "deviceType": "driver", + "discoveredBy": null, + "exists": "No", + "iconType": "monitor", + "ipAddress": null, + "label": "Nevion TNS547", + "modules": [], + "name": "tns547", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.nevion", + "products": [ + { + "name": "TNS547", + "swBuildTime": null, + "swVersion": null + } + ], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.2.1.1.2.0" + ], + "snmpSysObjectIdValue": "1.3.6.1.4.1.22909.3.27", + "status": "", + "supportedApis": [ + "ParameterControlLike", + "DeviceLike", + "DynamicLike", + "MaintenanceLike", + "CoreLike", + "StatusLike" + ], + "supportsExternal": true, + "supportsParameterApi": true, + "uniqueMetricDefs": [], + "updateableDevice": true, + "updateableModule": {}, + "version": "0.1.0" + }, + { + "_id": "com.nevion.tvg420-0.1.0", + "_vid": "com.nevion.tvg420-0.1.0", + "attachments": [ + { + "description": "Default", + "name": "tvg420" + } + ], + "configurableDevice": true, + "configurableModule": [], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "customSettingsSeed": {}, + "description": "Driver for the Nevion TVG420 ASI to IP Video Gateway", + "deviceType": "driver", + "discoveredBy": null, + "exists": "No", + "iconType": "gateway", + "ipAddress": null, + "label": "Nevion TVG420", + "modules": [ + "System", + "Network", + "Clock Regulator", + "ASI Inputs", + "ASI Outputs", + "IP Inputs", + "IP Outputs" + ], + "name": "tvg420", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.nevion", + "products": [ + { + "name": "TVG420", + "swBuildTime": null, + "swVersion": null + } + ], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.2.1.1.2.0" + ], + "snmpSysObjectIdValue": "1.3.6.1.4.1.22909.3.1", + "status": "", + "supportedApis": [ + "ParameterControlLike", + "DeviceLike", + "DynamicLike", + "MaintenanceLike", + "PortLike", + "CoreLike", + "StatusLike" + ], + "supportsExternal": true, + "supportsParameterApi": true, + "uniqueMetricDefs": [], + "updateableDevice": true, + "updateableModule": {}, + "version": "0.1.0" + }, + { + "_id": "com.nevion.tvg425-0.1.0", + "_vid": "com.nevion.tvg425-0.1.0", + "attachments": [ + { + "description": "Default", + "name": "tvg425" + } + ], + "configurableDevice": true, + "configurableModule": [], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "customSettingsSeed": {}, + "description": "Driver for the Nevion TVG425 ASI to IP Video Gateway", + "deviceType": "driver", + "discoveredBy": null, + "exists": "No", + "iconType": "gateway", + "ipAddress": null, + "label": "Nevion TVG425", + "modules": [ + "System", + "Network", + "Clock Regulator", + "ASI Inputs", + "ASI Outputs", + "Switch Inputs", + "Streams", + "IP Inputs" + ], + "name": "tvg425", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.nevion", + "products": [ + { + "name": "TVG425", + "swBuildTime": null, + "swVersion": null + } + ], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.2.1.1.2.0" + ], + "snmpSysObjectIdValue": "1.3.6.1.4.1.22909.3.18", + "status": "", + "supportedApis": [ + "ParameterControlLike", + "DeviceLike", + "DynamicLike", + "MaintenanceLike", + "PortLike", + "CoreLike", + "StatusLike" + ], + "supportsExternal": true, + "supportsParameterApi": true, + "uniqueMetricDefs": [], + "updateableDevice": true, + "updateableModule": {}, + "version": "0.1.0" + }, + { + "_id": "com.nevion.tvg430-0.1.0", + "_vid": "com.nevion.tvg430-0.1.0", + "attachments": [ + { + "description": "Default", + "name": "tvg430" + } + ], + "configurableDevice": false, + "configurableModule": [], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "customSettingsSeed": {}, + "description": "Driver for Nevion TVG430/TVG415 HD JPEG2000 gateways", + "deviceType": "driver", + "discoveredBy": null, + "exists": "No", + "iconType": "gateway", + "ipAddress": null, + "label": "Nevion TVG430/TVG415", + "modules": [], + "name": "tvg430", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.nevion", + "products": [ + { + "name": "TVG415", + "swBuildTime": null, + "swVersion": null + }, + { + "name": "TVG430", + "swBuildTime": null, + "swVersion": null + } + ], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.2.1.1.2.0" + ], + "snmpSysObjectIdValue": "1.3.6.1.4.1.22909.3.3", + "status": "", + "supportedApis": [ + "ParameterControlLike", + "DeviceLike", + "DynamicLike", + "PortLike", + "CoreLike", + "StatusLike" + ], + "supportsExternal": true, + "supportsParameterApi": true, + "uniqueMetricDefs": [], + "updateableDevice": false, + "updateableModule": {}, + "version": "0.1.0" + }, + { + "_id": "com.nevion.tvg450-0.1.0", + "_vid": "com.nevion.tvg450-0.1.0", + "attachments": [ + { + "description": "Base configuration (partial or full)", + "name": "tvg450.base" + } + ], + "configurableDevice": true, + "configurableModule": [], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "customSettingsSeed": {}, + "description": "Driver for the Nevion TVG450 JPEG2000 Gateway", + "deviceType": "driver", + "discoveredBy": null, + "exists": "No", + "iconType": "gateway", + "ipAddress": null, + "label": "Nevion TVG450", + "modules": [ + "System", + "Network", + "Encoder 1-4", + "Decoder 1-4", + "IP Inputs", + "IP Outputs", + "SDI Inputs", + "SDI Outputs" + ], + "name": "tvg450", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.nevion", + "products": [ + { + "name": "TVG450", + "swBuildTime": null, + "swVersion": null + } + ], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.2.1.1.2.0" + ], + "snmpSysObjectIdValue": "1.3.6.1.4.1.22909.3.10", + "status": "", + "supportedApis": [ + "ParameterControlLike", + "DeviceLike", + "DynamicLike", + "MaintenanceLike", + "PortLike", + "CoreLike", + "StatusLike" + ], + "supportsExternal": true, + "supportsParameterApi": true, + "uniqueMetricDefs": [], + "updateableDevice": true, + "updateableModule": {}, + "version": "0.1.0" + }, + { + "_id": "com.nevion.tvg480-0.1.0", + "_vid": "com.nevion.tvg480-0.1.0", + "attachments": [ + { + "description": "Base configuration (partial or full)", + "name": "tvg480.base" + } + ], + "configurableDevice": true, + "configurableModule": [], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "Custom setting fields for TVG480", + "label": "TVG480" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map", + "values": { + "com.nevion.tvg480.control_mode": { + "_schema": { + "default": "full_control", + "descriptor": { + "desc": "Which control mode has Videoipath over the device.", + "label": "Control Mode" + }, + "encoding": "UTF-8", + "isNullable": false, + "lengthRanges": [], + "options": [ + { + "descriptor": { + "desc": "Standard control mode where Videoipath assumes it is the only master of a resource and takes full control over it.", + "label": "Full control" + }, + "value": "full_control" + }, + { + "descriptor": { + "desc": "Special control mode where Videoipath shares a resource with another external system. Videoipath assumes no control over the resource unless a connection is active. In addition, before establishing a connection the configuration is backed up on the resource and reloaded when the connection is ended.", + "label": "Partial control with config restore" + }, + "value": "partial_control_with_config_restore" + } + ], + "status": "Current", + "type": "string" + } + }, + "com.nevion.tvg480.partial_control_config_slot": { + "_schema": { + "default": 0, + "descriptor": { + "desc": "Config slot to use when partial control with config restore is used.", + "label": "Partial control config slot" + }, + "isNullable": false, + "options": [], + "ranges": [ + [ + 0, + 7, + 1 + ] + ], + "status": "Current", + "type": "number", + "units": "" + } + } + } + } + }, + "customSettingsSeed": {}, + "description": "Driver for the Nevion TVG480 Post Production Gateway", + "deviceType": "driver", + "discoveredBy": null, + "exists": "No", + "iconType": "gateway", + "ipAddress": null, + "label": "Nevion TVG480", + "modules": [ + "System", + "Network", + "Encoder 1-4", + "Decoder 1-4", + "IP Inputs", + "IP Outputs", + "SDI Inputs", + "SDI Outputs" + ], + "name": "tvg480", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "Operational mode setting fields for TVG480", + "label": "TVG480" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map", + "values": { + "enc": { + "_schema": { + "default": "Decode", + "descriptor": { + "desc": "enc", + "label": "Encoder/Decoder" + }, + "encoding": "UTF-8", + "isNullable": false, + "lengthRanges": [], + "options": [ + { + "descriptor": { + "desc": "Both modules are configured as decoders.", + "label": "Decode only" + }, + "value": "Decode" + }, + { + "descriptor": { + "desc": "Both modules are configured as encoders.", + "label": "Encode only" + }, + "value": "Encode" + }, + { + "descriptor": { + "desc": "The first module is set as encoder while the second is set as decoder.", + "label": "Encode/Decode" + }, + "value": "EncodeDecode" + } + ], + "status": "Current", + "type": "string" + } + }, + "eth": { + "_schema": { + "default": "1000Base-T", + "descriptor": { + "desc": "eth", + "label": "Ethernet interface" + }, + "encoding": "UTF-8", + "isNullable": false, + "lengthRanges": [], + "options": [ + { + "descriptor": { + "desc": "", + "label": "1000Base-T" + }, + "value": "1000Base-T" + }, + { + "descriptor": { + "desc": "", + "label": "SFP" + }, + "value": "SFP" + } + ], + "status": "Current", + "type": "string" + } + }, + "fieldrate": { + "_schema": { + "default": "50 Hz/24 Hz", + "descriptor": { + "desc": "fieldrate", + "label": "Video field rate" + }, + "encoding": "UTF-8", + "isNullable": false, + "lengthRanges": [], + "options": [ + { + "descriptor": { + "desc": "", + "label": "50 Hz/24 Hz" + }, + "value": "50 Hz/24 Hz" + }, + { + "descriptor": { + "desc": "", + "label": "59.94 Hz/23.98 Hz" + }, + "value": "59.94 Hz/23.98 Hz" + }, + { + "descriptor": { + "desc": "", + "label": "60 Hz" + }, + "value": "60 Hz" + } + ], + "status": "Current", + "type": "string" + } + } + } + } + }, + "organization": "com.nevion", + "products": [ + { + "name": "TVG480", + "swBuildTime": null, + "swVersion": null + } + ], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.2.1.1.2.0" + ], + "snmpSysObjectIdValue": "1.3.6.1.4.1.22909.3.17", + "status": "", + "supportedApis": [ + "ParameterControlLike", + "DeviceLike", + "DynamicLike", + "MaintenanceLike", + "PortLike", + "TestLike", + "CoreLike", + "StatusLike" + ], + "supportsExternal": true, + "supportsParameterApi": true, + "uniqueMetricDefs": [ + { + "descriptor": { + "desc": "", + "label": "Ethernet Rtp Rx Sequence Errors Count" + }, + "id": "ethernet.rtp.rx.sequence.errors.count", + "isAlarmTrigger": false, + "isCounter": false, + "unit": "" + }, + { + "descriptor": { + "desc": "", + "label": "Ethernet Rtp Rx Sequence Errors Diff" + }, + "id": "ethernet.rtp.rx.sequence.errors.diff", + "isAlarmTrigger": false, + "isCounter": false, + "unit": "" + } + ], + "updateableDevice": true, + "updateableModule": {}, + "version": "0.1.0" + }, + { + "_id": "com.nevion.tx9-0.1.0", + "_vid": "com.nevion.tx9-0.1.0", + "attachments": [ + { + "description": "Default", + "name": "tx9" + } + ], + "configurableDevice": false, + "configurableModule": [], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "customSettingsSeed": {}, + "description": "Terrestrial Transmitter", + "deviceType": "driver", + "discoveredBy": null, + "exists": "No", + "iconType": "device", + "ipAddress": null, + "label": "R&S Tx9", + "modules": [], + "name": "tx9", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.nevion", + "products": [], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.2.1.1.2.0" + ], + "snmpSysObjectIdValue": "1.3.6.1.4.1.2566.127.1.2.216", + "status": "", + "supportedApis": [ + "ParameterControlLike", + "CoreLike", + "DeviceLike", + "StatusLike" + ], + "supportsExternal": true, + "supportsParameterApi": true, + "uniqueMetricDefs": [], + "updateableDevice": false, + "updateableModule": {}, + "version": "0.1.0" + }, + { + "_id": "com.nevion.txdarwin_dynamic-0.1.0", + "_vid": "com.nevion.txdarwin_dynamic-0.1.0", + "attachments": [ + { + "description": "Default", + "name": "txdarwin_dynamic" + } + ], + "configurableDevice": false, + "configurableModule": [], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "Custom setting fields for Tx Darwin", + "label": "TxDarwin" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map", + "values": { + "com.nevion.txdarwin_dynamic.port": { + "_schema": { + "default": 9000, + "descriptor": { + "desc": "The HTTP port used to reach the GraphQL API", + "label": "GraphQL port" + }, + "isNullable": false, + "options": [], + "ranges": [ + [ + 1, + 65535, + 1 + ] + ], + "status": "Current", + "type": "number", + "units": "" + } + } + } + } + }, + "customSettingsSeed": {}, + "description": "Techex TX Darwin for controlling a generic device", + "deviceType": "driver", + "discoveredBy": null, + "exists": "No", + "iconType": "gateway", + "ipAddress": null, + "label": "Techex tx darwin (Dynamic)", + "modules": [], + "name": "txdarwin_dynamic", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.nevion", + "products": [], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.2.1.1.2.0" + ], + "snmpSysObjectIdValue": null, + "status": "", + "supportedApis": [ + "CoreLike", + "DeviceLike", + "PortLike", + "TestLike" + ], + "supportsExternal": true, + "supportsParameterApi": false, + "uniqueMetricDefs": [], + "updateableDevice": false, + "updateableModule": {}, + "version": "0.1.0" + }, + { + "_id": "com.nevion.txdarwin_static-0.1.0", + "_vid": "com.nevion.txdarwin_static-0.1.0", + "attachments": [ + { + "description": "Default", + "name": "txdarwin_static" + } + ], + "configurableDevice": false, + "configurableModule": [], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "Custom setting fields for Tx Darwin", + "label": "TxDarwin" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map", + "values": { + "com.nevion.txdarwin_static.port": { + "_schema": { + "default": 9000, + "descriptor": { + "desc": "The HTTP port used to reach the GraphQL API", + "label": "GraphQL port" + }, + "isNullable": false, + "options": [], + "ranges": [ + [ + 1, + 65535, + 1 + ] + ], + "status": "Current", + "type": "number", + "units": "" + } + } + } + } + }, + "customSettingsSeed": {}, + "description": "Techex TX Darwin for controlling an pre-setup device", + "deviceType": "driver", + "discoveredBy": null, + "exists": "No", + "iconType": "gateway", + "ipAddress": null, + "label": "Techex tx darwin (Static)", + "modules": [], + "name": "txdarwin_static", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.nevion", + "products": [], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.2.1.1.2.0" + ], + "snmpSysObjectIdValue": null, + "status": "", + "supportedApis": [ + "CoreLike", + "DeviceLike", + "PortLike", + "TestLike" + ], + "supportsExternal": true, + "supportsParameterApi": false, + "uniqueMetricDefs": [], + "updateableDevice": false, + "updateableModule": {}, + "version": "0.1.0" + }, + { + "_id": "com.nevion.txedge-0.1.0", + "_vid": "com.nevion.txedge-0.1.0", + "attachments": [ + { + "description": "Default", + "name": "txedge" + } + ], + "configurableDevice": false, + "configurableModule": [], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "Custom Data Fields for Techex tx edge", + "label": "Techex tx edge" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map", + "values": { + "com.nevion.txedge.selected_edge": { + "_schema": { + "default": "", + "descriptor": { + "desc": "Write down the name of the edge you want to use", + "label": "Choose tx edge" + }, + "encoding": "UTF-8", + "isNullable": false, + "lengthRanges": [], + "options": [], + "status": "Current", + "type": "string" + } + } + } + } + }, + "customSettingsSeed": {}, + "description": "TS protection and monitoring gateway", + "deviceType": "driver", + "discoveredBy": null, + "exists": "No", + "iconType": "gateway", + "ipAddress": null, + "label": "Techex tx edge", + "modules": [], + "name": "txedge", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.nevion", + "products": [], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.2.1.1.2.0" + ], + "snmpSysObjectIdValue": null, + "status": "", + "supportedApis": [ + "CoreLike", + "DeviceLike", + "StatusLike", + "PortLike" + ], + "supportsExternal": true, + "supportsParameterApi": false, + "uniqueMetricDefs": [], + "updateableDevice": false, + "updateableModule": {}, + "version": "0.1.0" + }, + { + "_id": "com.nevion.v__matrix-0.1.0", + "_vid": "com.nevion.v__matrix-0.1.0", + "attachments": [ + { + "description": "Default", + "name": "v__matrix" + } + ], + "configurableDevice": false, + "configurableModule": [], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "customSettingsSeed": {}, + "description": "Software-defined IP-Routing, Processing & Multi-Viewing Platform", + "deviceType": "driver", + "discoveredBy": null, + "exists": "No", + "iconType": "none", + "ipAddress": null, + "label": "Lawo V__matrix", + "modules": [], + "name": "v__matrix", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.nevion", + "products": [], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.2.1.1.2.0" + ], + "snmpSysObjectIdValue": null, + "status": "", + "supportedApis": [ + "DeviceLike", + "PortLike", + "CoreLike", + "StatusLike", + "MatrixControlLike", + "ParameterControlLike" + ], + "supportsExternal": true, + "supportsParameterApi": true, + "uniqueMetricDefs": [], + "updateableDevice": false, + "updateableModule": {}, + "version": "0.1.0" + }, + { + "_id": "com.nevion.v__matrix_smv-0.1.0", + "_vid": "com.nevion.v__matrix_smv-0.1.0", + "attachments": [ + { + "description": "Default", + "name": "v__matrix_smv" + } + ], + "configurableDevice": false, + "configurableModule": [], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "customSettingsSeed": {}, + "description": "V_matrix Standalone Multiviewer", + "deviceType": "driver", + "discoveredBy": null, + "exists": "No", + "iconType": "none", + "ipAddress": null, + "label": "Lawo V__matrix (SMV)", + "modules": [], + "name": "v__matrix_smv", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.nevion", + "products": [], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.2.1.1.2.0" + ], + "snmpSysObjectIdValue": null, + "status": "", + "supportedApis": [ + "DeviceLike", + "PortLike", + "CoreLike", + "StatusLike", + "MatrixControlLike", + "ParameterControlLike" + ], + "supportsExternal": true, + "supportsParameterApi": true, + "uniqueMetricDefs": [], + "updateableDevice": false, + "updateableModule": {}, + "version": "0.1.0" + }, + { + "_id": "com.nevion.ventura-0.1.0", + "_vid": "com.nevion.ventura-0.1.0", + "attachments": [ + { + "description": "System configuration", + "name": "ventura.vs906-da.config" + }, + { + "description": "System configuration", + "name": "ventura.vs906-da.input" + }, + { + "description": "System configuration", + "name": "ventura.vs906-da.output" + }, + { + "description": "System configuration", + "name": "ventura.vs902-j2ke3g.config" + }, + { + "description": "System configuration", + "name": "ventura.vs902-j2ke3g.input" + }, + { + "description": "System configuration", + "name": "ventura.vs902-aed.config" + }, + { + "description": "System configuration", + "name": "ventura.vs902-aed.input" + }, + { + "description": "System configuration", + "name": "ventura.vs902-aed.output" + }, + { + "description": "System configuration", + "name": "ventura.vs906-aa.config" + }, + { + "description": "System configuration", + "name": "ventura.vs906-aa.input" + }, + { + "description": "System configuration", + "name": "ventura.vs906-aa.output" + }, + { + "description": "System configuration", + "name": "ventura.vs902-j2k-fs-codec.config" + }, + { + "description": "System configuration", + "name": "ventura.vs902-j2k-fs-codec.input" + }, + { + "description": "System configuration", + "name": "ventura.vs902-j2k-fs-codec.output" + }, + { + "description": "System configuration", + "name": "ventura.vs902-j2kd3g.config" + }, + { + "description": "System configuration", + "name": "ventura.vs902-j2kd3g.output" + }, + { + "description": "System configuration", + "name": "ventura.vs902-j2k-fs-dec.config" + }, + { + "description": "System configuration", + "name": "ventura.vs902-j2k-fs-dec.output" + }, + { + "description": "System configuration", + "name": "ventura.vs902-j2kc.config" + }, + { + "description": "System configuration", + "name": "ventura.vs902-j2kc.input" + }, + { + "description": "System configuration", + "name": "ventura.vs902-j2kc.output" + }, + { + "description": "System configuration", + "name": "ventura.vs902-j2ketr01.config" + }, + { + "description": "System configuration", + "name": "ventura.vs902-j2ketr01.input" + }, + { + "description": "System configuration", + "name": "ventura.vs906-e1.config" + }, + { + "description": "System configuration", + "name": "ventura.vs906-e1.input" + }, + { + "description": "System configuration", + "name": "ventura.vs906-e1.output" + }, + { + "description": "System configuration", + "name": "ventura.vs908-demux.config" + }, + { + "description": "System configuration", + "name": "ventura.vs908-demux.output" + }, + { + "description": "System configuration", + "name": "ventura.vs902-j2kd.config" + }, + { + "description": "System configuration", + "name": "ventura.vs902-j2kd.output" + }, + { + "description": "System configuration", + "name": "ventura.vs909.config" + }, + { + "description": "System configuration", + "name": "ventura.vs909.input" + }, + { + "description": "System configuration", + "name": "ventura.vs909.output" + }, + { + "description": "System configuration", + "name": "ventura.vs908-mux.config" + }, + { + "description": "System configuration", + "name": "ventura.vs908-mux.input" + }, + { + "description": "System configuration", + "name": "ventura.vs902-j2ke10ge.config" + }, + { + "description": "System configuration", + "name": "ventura.vs902-j2ke10ge.input" + }, + { + "description": "System configuration", + "name": "ventura.vs902-j2ke.config" + }, + { + "description": "System configuration", + "name": "ventura.vs902-j2ke.input" + }, + { + "description": "System configuration", + "name": "ventura.vs902-j2kd10ge.config" + }, + { + "description": "System configuration", + "name": "ventura.vs902-j2kd10ge.output" + }, + { + "description": "System configuration", + "name": "ventura.vs902-ma.config" + }, + { + "description": "System configuration", + "name": "ventura.vs902-ma.input" + }, + { + "description": "System configuration", + "name": "ventura.vs902-ma.output" + }, + { + "description": "System configuration", + "name": "ventura.vs902-lc.config" + }, + { + "description": "System configuration", + "name": "ventura.vs902-lc.input" + }, + { + "description": "System configuration", + "name": "ventura.vs902-lc.output" + }, + { + "description": "System configuration", + "name": "ventura.vs902-j2k-fs-enc.config" + }, + { + "description": "System configuration", + "name": "ventura.vs902-j2k-fs-enc.input" + }, + { + "description": "System configuration", + "name": "ventura.vs902-j2kdtr01.config" + }, + { + "description": "System configuration", + "name": "ventura.vs902-j2kdtr01.output" + } + ], + "configurableDevice": false, + "configurableModule": [ + "VS901ASIEncoder", + "VS901ASIDecoder", + "VS901VOIPEncoder", + "VS901VOIPDecoder", + "VS902", + "VS902J2KC", + "VS902J2KE", + "VS902J2KD", + "VS902J2KFSCodec", + "VS902J2KFSEnc", + "VS902J2KFSDec", + "VS902J2KE3G", + "VS902J2KD3G", + "VS902J2KE10GE", + "VS902J2KD10GE", + "VS902J2KETR01", + "VS902J2KDTR01", + "VS902AED", + "VS902LC", + "VS902MA", + "VS904AID", + "VS904AIE2AES", + "VS904AIE4AES", + "VS906", + "VS906AA", + "VS906DA", + "VS906E1", + "VS908Mux", + "VS908DeMux", + "VS909", + "VS811Mux", + "VS811DeMux", + "VS861Mux", + "VS861DeMux" + ], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "customSettingsSeed": {}, + "description": "A driver for the Ventura family products", + "deviceType": "driver", + "discoveredBy": null, + "exists": "No", + "iconType": "gateway", + "ipAddress": null, + "label": "Nevion Ventura", + "modules": [], + "name": "ventura", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.nevion", + "products": [ + { + "name": "VS101 Chassis", + "swBuildTime": null, + "swVersion": null + }, + { + "name": "VS103 Chassis", + "swBuildTime": null, + "swVersion": null + } + ], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.2.1.1.2.0" + ], + "snmpSysObjectIdValue": "1.3.6.1.4.1.13130.4", + "status": "", + "supportedApis": [ + "ParameterControlLike", + "DeviceLike", + "DynamicLike", + "MaintenanceLike", + "PortLike", + "CoreLike", + "StatusLike" + ], + "supportsExternal": true, + "supportsParameterApi": true, + "uniqueMetricDefs": [], + "updateableDevice": false, + "updateableModule": { + "AEMS": { + "availableBanks": [], + "rebootOption": 1 + }, + "VS902": { + "availableBanks": [ + "1", + "2", + "3", + "4", + "5", + "6", + "7", + "8", + "9" + ], + "rebootOption": 0 + }, + "VS902AED": { + "availableBanks": [ + "1", + "2", + "3", + "4", + "5", + "6", + "7", + "8", + "9" + ], + "rebootOption": 0 + }, + "VS902J2KC": { + "availableBanks": [ + "1", + "2", + "3", + "4", + "5", + "6", + "7", + "8", + "9" + ], + "rebootOption": 0 + }, + "VS902J2KD": { + "availableBanks": [ + "1", + "2", + "3", + "4", + "5", + "6", + "7", + "8", + "9" + ], + "rebootOption": 0 + }, + "VS902J2KD10GE": { + "availableBanks": [ + "1", + "2", + "3", + "4", + "5", + "6", + "7", + "8", + "9" + ], + "rebootOption": 0 + }, + "VS902J2KD3G": { + "availableBanks": [ + "1", + "2", + "3", + "4", + "5", + "6", + "7", + "8", + "9" + ], + "rebootOption": 0 + }, + "VS902J2KDTR01": { + "availableBanks": [ + "1", + "2", + "3", + "4", + "5", + "6", + "7", + "8", + "9" + ], + "rebootOption": 0 + }, + "VS902J2KE": { + "availableBanks": [ + "1", + "2", + "3", + "4", + "5", + "6", + "7", + "8", + "9" + ], + "rebootOption": 0 + }, + "VS902J2KE10GE": { + "availableBanks": [ + "1", + "2", + "3", + "4", + "5", + "6", + "7", + "8", + "9" + ], + "rebootOption": 0 + }, + "VS902J2KE3G": { + "availableBanks": [ + "1", + "2", + "3", + "4", + "5", + "6", + "7", + "8", + "9" + ], + "rebootOption": 0 + }, + "VS902J2KETR01": { + "availableBanks": [ + "1", + "2", + "3", + "4", + "5", + "6", + "7", + "8", + "9" + ], + "rebootOption": 0 + }, + "VS902J2KFSCodec": { + "availableBanks": [ + "1", + "2", + "3", + "4", + "5", + "6", + "7", + "8", + "9" + ], + "rebootOption": 0 + }, + "VS902J2KFSDec": { + "availableBanks": [ + "1", + "2", + "3", + "4", + "5", + "6", + "7", + "8", + "9" + ], + "rebootOption": 0 + }, + "VS902J2KFSEnc": { + "availableBanks": [ + "1", + "2", + "3", + "4", + "5", + "6", + "7", + "8", + "9" + ], + "rebootOption": 0 + }, + "VS902LC": { + "availableBanks": [ + "1", + "2", + "3", + "4", + "5", + "6", + "7", + "8", + "9" + ], + "rebootOption": 0 + }, + "VS902MA": { + "availableBanks": [ + "1", + "2", + "3", + "4", + "5", + "6", + "7", + "8", + "9" + ], + "rebootOption": 0 + }, + "VS906": { + "availableBanks": [ + "1", + "2", + "3", + "4", + "5", + "6" + ], + "rebootOption": 0 + }, + "VS906AA": { + "availableBanks": [ + "1", + "2", + "3", + "4", + "5", + "6" + ], + "rebootOption": 0 + }, + "VS906DA": { + "availableBanks": [ + "1", + "2", + "3", + "4", + "5", + "6" + ], + "rebootOption": 0 + }, + "VS906E1": { + "availableBanks": [ + "1", + "2", + "3", + "4", + "5", + "6" + ], + "rebootOption": 0 + } + }, + "version": "0.1.0" + }, + { + "_id": "com.nevion.virtuoso-0.1.0", + "_vid": "com.nevion.virtuoso-0.1.0", + "attachments": [ + { + "description": "Base configuration (partial or full)", + "name": "null.base" + } + ], + "configurableDevice": true, + "configurableModule": [], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "Custom setting fields for Platform4000", + "label": "Platform4000" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map", + "values": { + "com.nevion.null.reuse_ts_element": { + "_schema": { + "default": false, + "descriptor": { + "desc": "", + "label": "Enable to activate logic to join existing TS input element for ASI outputs when setting up multicast with identical settings" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "bool" + } + } + } + } + }, + "customSettingsSeed": {}, + "description": "Driver for the Virtuoso Media Gateway", + "deviceType": "driver", + "discoveredBy": null, + "exists": "No", + "iconType": "gateway", + "ipAddress": null, + "label": "Nevion Virtuoso FA", + "modules": [ + "Slot [0-4]" + ], + "name": "virtuoso", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.nevion", + "products": [ + { + "name": "Virtuoso", + "swBuildTime": null, + "swVersion": null + } + ], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.2.1.1.2.0" + ], + "snmpSysObjectIdValue": "1.3.6.1.4.1.8768.10.39", + "status": "", + "supportedApis": [ + "ParameterControlLike", + "DeviceLike", + "DynamicLike", + "PortLike", + "CoreLike", + "StatusLike", + "MaintenanceLike", + "NetworkServiceLike" + ], + "supportsExternal": true, + "supportsParameterApi": true, + "uniqueMetricDefs": [ + { + "descriptor": { + "desc": "Last bitrate measured.", + "label": "Ts Pid Bitrate" + }, + "id": "ts.pid.bit.rate", + "isAlarmTrigger": false, + "isCounter": false, + "unit": "" + }, + { + "descriptor": { + "desc": "Last average bitrate measured.", + "label": "Ts Pid Average Bitrate" + }, + "id": "ts.pid.average.bit.rate", + "isAlarmTrigger": false, + "isCounter": false, + "unit": "" + }, + { + "descriptor": { + "desc": "Continuity error counter.", + "label": "Ts Pid Continuity Error Counter" + }, + "id": "ts.pid.continuity.error.counter", + "isAlarmTrigger": false, + "isCounter": false, + "unit": "" + }, + { + "descriptor": { + "desc": "Last bitrate sampled for program.", + "label": "Ts Service Bitrate" + }, + "id": "ts.service.bit.rate", + "isAlarmTrigger": false, + "isCounter": false, + "unit": "" + }, + { + "descriptor": { + "desc": "Effective bitrate, i.e., bitrate without null packets.", + "label": "Ts Effective Bitrate" + }, + "id": "ts.effective.bit.rate", + "isAlarmTrigger": false, + "isCounter": false, + "unit": "" + }, + { + "descriptor": { + "desc": "Total bitrate.", + "label": "Ts Total Bitrate" + }, + "id": "ts.total.bit.rate", + "isAlarmTrigger": false, + "isCounter": false, + "unit": "" + }, + { + "descriptor": { + "desc": "Inter arrival time, i.e., the time between arrivals into the system.", + "label": "Tsoip Rx Sips Iat" + }, + "id": "tsoip.rx.sips.iat", + "isAlarmTrigger": false, + "isCounter": false, + "unit": "" + }, + { + "descriptor": { + "desc": "Packet delay factor.", + "label": "Tsoip Rx Sips Pdv" + }, + "id": "tsoip.rx.sips.pdv", + "isAlarmTrigger": false, + "isCounter": false, + "unit": "" + }, + { + "descriptor": { + "desc": "Current buffer.", + "label": "Tsoip Rx Buff Lat" + }, + "id": "tsoip.rx.buff.lat", + "isAlarmTrigger": false, + "isCounter": false, + "unit": "" + }, + { + "descriptor": { + "desc": "Current utilization fraction of buffer, with the signal currently received and configured parameters.", + "label": "Tsoip Rx Buff Util" + }, + "id": "tsoip.rx.buff.util", + "isAlarmTrigger": false, + "isCounter": false, + "unit": "" + }, + { + "descriptor": { + "desc": "SFP Channel internal bias current", + "label": "Sfp Internal Current" + }, + "id": "sfp.internal.current", + "isAlarmTrigger": false, + "isCounter": false, + "unit": "mA" + }, + { + "descriptor": { + "desc": "SFP Channel Internal Vcc", + "label": "Sfp Internal Voltage" + }, + "id": "sfp.internal.voltage", + "isAlarmTrigger": false, + "isCounter": false, + "unit": "V" + }, + { + "descriptor": { + "desc": "SFP Channel internal temperature", + "label": "Sfp Internal Temp" + }, + "id": "sfp.internal.temp", + "isAlarmTrigger": false, + "isCounter": false, + "unit": "C" + }, + { + "descriptor": { + "desc": "SFP Channel RX input power", + "label": "Sfp Rx Effect" + }, + "id": "sfp.rx.effect", + "isAlarmTrigger": false, + "isCounter": false, + "unit": "mW" + }, + { + "descriptor": { + "desc": "SFP Channel RX input power", + "label": "Sfp Rx Effect Log" + }, + "id": "sfp.rx.effect.log", + "isAlarmTrigger": false, + "isCounter": false, + "unit": "dBm" + }, + { + "descriptor": { + "desc": "SFP Channel TX output power", + "label": "Sfp Tx Effect" + }, + "id": "sfp.tx.effect", + "isAlarmTrigger": false, + "isCounter": false, + "unit": "mW" + }, + { + "descriptor": { + "desc": "SFP Channel TX output power", + "label": "Sfp Tx Effect Log" + }, + "id": "sfp.tx.effect.log", + "isAlarmTrigger": false, + "isCounter": false, + "unit": "dBm" + } + ], + "updateableDevice": false, + "updateableModule": { + "ASI-Input/Output-Board": { + "availableBanks": [], + "rebootOption": 0 + }, + "DVB-T/T2-Demodulator": { + "availableBanks": [], + "rebootOption": 0 + }, + "GNSS-clock-reference-board": { + "availableBanks": [], + "rebootOption": 0 + }, + "H-264-Encoder/Decoder": { + "availableBanks": [], + "rebootOption": 0 + }, + "High-Bit-Rate-Accelerator": { + "availableBanks": [], + "rebootOption": 0 + }, + "Main-Board": { + "availableBanks": [], + "rebootOption": 0 + }, + "Multi-codec": { + "availableBanks": [], + "rebootOption": 0 + } + }, + "version": "0.1.0" + }, + { + "_id": "com.nevion.virtuoso_fa-0.1.0", + "_vid": "com.nevion.virtuoso_fa-0.1.0", + "attachments": [ + { + "description": "Default", + "name": "virtuoso_fa" + } + ], + "configurableDevice": false, + "configurableModule": [ + "AUD-AES3", + "TICO-UHD-E1D1", + "TICO-UHD-E2", + "TICO-UHD-D2", + "TICO-UHD-E2-12G", + "TICO-UHD-D2-IP-25G", + "TICO-UHD-E2-IP-25G", + "SDI-IP-2022", + "SDI-IP-2110", + "HW-H264-X1", + "MADI", + "AUD-PROC-MADI-IP", + "XS-ENC", + "XS-DEC", + "TXS-HD-E3", + "TXS-HD-D3", + "IPME-RTP", + "JPEG2000-ENC", + "JPEG2000-DEC", + "J2K-HD-E2D2", + "J2K-HD-D4", + "J2K-HD-E4", + "UPLINK-10G", + "UPLINK-25G", + "VID-PROC-UHD-12G" + ], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "Custom Data Fields for Nevion Virtuoso FA", + "label": "Nevion Virtuoso FA" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map", + "values": { + "com.nevion.virtuoso_fa.enable_hibernation": { + "_schema": { + "default": false, + "descriptor": { + "desc": "Automatically put modules not involved in any connection into hibernation. Automatically wake up hibernating modules when setting up a connection involving them.", + "label": "Enable hibernation & wake up(supported for v.3.2.14 and above)" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "bool" + } + } + } + } + }, + "customSettingsSeed": {}, + "description": "Driver for Virtuoso device", + "deviceType": "driver", + "discoveredBy": null, + "exists": "No", + "iconType": "gateway", + "ipAddress": null, + "label": "Virtuoso FA", + "modules": [], + "name": "virtuoso_fa", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.nevion", + "products": [ + { + "name": "Virtuoso", + "swBuildTime": null, + "swVersion": null + } + ], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.2.1.1.2.0" + ], + "snmpSysObjectIdValue": null, + "status": "", + "supportedApis": [ + "ParameterControlLike", + "DeviceLike", + "DynamicLike", + "PortLike", + "CoreLike", + "StatusLike", + "MaintenanceLike", + "NetworkServiceLike" + ], + "supportsExternal": true, + "supportsParameterApi": true, + "uniqueMetricDefs": [], + "updateableDevice": false, + "updateableModule": { + "ASI-Input/Output-Board": { + "availableBanks": [], + "rebootOption": 0 + }, + "AUD-PROC-MADI-IP": { + "availableBanks": [], + "rebootOption": 0 + }, + "DVB-T/T2-Demodulator": { + "availableBanks": [], + "rebootOption": 0 + }, + "GNSS-clock-reference-board": { + "availableBanks": [], + "rebootOption": 0 + }, + "HW-H264-X1": { + "availableBanks": [], + "rebootOption": 0 + }, + "High-Bit-Rate-Accelerator": { + "availableBanks": [], + "rebootOption": 0 + }, + "J2K-HD-D4": { + "availableBanks": [], + "rebootOption": 0 + }, + "J2K-HD-E2D2": { + "availableBanks": [], + "rebootOption": 0 + }, + "J2K-HD-E4": { + "availableBanks": [], + "rebootOption": 0 + }, + "JPEG2000-DEC": { + "availableBanks": [], + "rebootOption": 0 + }, + "JPEG2000-ENC": { + "availableBanks": [], + "rebootOption": 0 + }, + "Main-Board": { + "availableBanks": [], + "rebootOption": 0 + }, + "Multi-codec": { + "availableBanks": [], + "rebootOption": 0 + }, + "SDI-IP-2022": { + "availableBanks": [], + "rebootOption": 0 + }, + "SDI-IP-2110": { + "availableBanks": [], + "rebootOption": 0 + } + }, + "version": "0.1.0" + }, + { + "_id": "com.nevion.virtuoso_mi-0.1.0", + "_vid": "com.nevion.virtuoso_mi-0.1.0", + "attachments": [ + { + "description": "Default", + "name": "virtuoso_mi" + } + ], + "configurableDevice": false, + "configurableModule": [ + "AUD-AES3", + "TICO-UHD-E1D1", + "TICO-UHD-E2", + "TICO-UHD-D2", + "TICO-UHD-E2-12G", + "TICO-UHD-D2-IP-25G", + "TICO-UHD-E2-IP-25G", + "SDI-IP-2022", + "SDI-IP-2110", + "SDI-IP-H25", + "MADI", + "AUD-PROC-MADI-IP", + "XS-ENC", + "XS-DEC", + "TXS-HD-E3", + "TXS-HD-D3", + "IPME-RTP", + "JPEG2000-ENC", + "JPEG2000-DEC", + "JXS-D3", + "JXS-E3", + "JXS-TS-E4", + "JXS-TS-D4", + "JXS-D4-H25", + "JXS-E4-H25", + "JXS-TS-D3-H25", + "JXS-TS-E3-H25", + "J2K-HD-E2D2", + "J2K-HD-D4", + "J2K-HD-E4", + "UDC-IP-H25", + "UPLINK-10G", + "UPLINK-25G", + "VID-PROC-UHD-12G", + "HEVC-ENC", + "HEVC-DEC" + ], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "Custom Data Fields for Nevion Virtuoso MI", + "label": "Nevion Virtuoso MI" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map", + "values": { + "com.nevion.virtuoso_mi.AdvancedReachabilityCheck": { + "_schema": { + "default": true, + "descriptor": { + "desc": "Use a more thorough communication check, this will report an IP address as down if all HBR cards have a status of 'Booting' ", + "label": "Enable advanced communication check" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "bool" + } + }, + "com.nevion.virtuoso_mi.enable_bulk_config": { + "_schema": { + "default": false, + "descriptor": { + "desc": "Configure this unit's audio elements using bulk API", + "label": "Enable bulk config" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "bool" + } + }, + "com.nevion.virtuoso_mi.enable_hibernation": { + "_schema": { + "default": false, + "descriptor": { + "desc": "Automatically put modules not involved in any connection into hibernation. Automatically wake up hibernating modules when setting up a connection involving them.", + "label": "Enable hibernation & wake up(supported for v.1.8.8 and above)" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "bool" + } + }, + "com.nevion.virtuoso_mi.linear_uplink_support": { + "_schema": { + "default": false, + "descriptor": { + "desc": "Support backplane routing to Uplink cards for Linear cards", + "label": "Support uplink routing for Linear cards" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "bool" + } + }, + "com.nevion.virtuoso_mi.madi_uplink_support": { + "_schema": { + "default": false, + "descriptor": { + "desc": "Support backplane routing to Uplink cards for MADI cards", + "label": "Support uplink routing for MADI cards" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "bool" + } + } + } + } + }, + "customSettingsSeed": {}, + "description": "Driver for Virtuoso MI device", + "deviceType": "driver", + "discoveredBy": null, + "exists": "No", + "iconType": "device", + "ipAddress": null, + "label": "Nevion Virtuoso MI", + "modules": [], + "name": "virtuoso_mi", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.nevion", + "products": [], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.2.1.1.2.0" + ], + "snmpSysObjectIdValue": "1.3.6.1.4.1.8768.10.40", + "status": "", + "supportedApis": [ + "DeviceLike", + "DynamicLike", + "PortLike", + "CoreLike", + "StatusLike", + "MatrixControlLike", + "ParameterControlLike", + "MaintenanceLike", + "NetworkServiceLike" + ], + "supportsExternal": true, + "supportsParameterApi": true, + "uniqueMetricDefs": [], + "updateableDevice": false, + "updateableModule": { + "AUD-AES3": { + "availableBanks": [], + "rebootOption": 0 + }, + "AUD-PROC-MADI-IP": { + "availableBanks": [], + "rebootOption": 0 + }, + "HEVC-DEC": { + "availableBanks": [], + "rebootOption": 0 + }, + "HEVC-ENC": { + "availableBanks": [], + "rebootOption": 0 + }, + "IPME-RTP": { + "availableBanks": [], + "rebootOption": 0 + }, + "J2K-HD-D4": { + "availableBanks": [], + "rebootOption": 0 + }, + "J2K-HD-E2D2": { + "availableBanks": [], + "rebootOption": 0 + }, + "J2K-HD-E4": { + "availableBanks": [], + "rebootOption": 0 + }, + "JPEG2000-DEC": { + "availableBanks": [], + "rebootOption": 0 + }, + "JPEG2000-ENC": { + "availableBanks": [], + "rebootOption": 0 + }, + "JXS-D3": { + "availableBanks": [], + "rebootOption": 0 + }, + "JXS-D4-H25": { + "availableBanks": [], + "rebootOption": 0 + }, + "JXS-E3": { + "availableBanks": [], + "rebootOption": 0 + }, + "JXS-E4-H25": { + "availableBanks": [], + "rebootOption": 0 + }, + "JXS-TS-D3-H25": { + "availableBanks": [], + "rebootOption": 0 + }, + "JXS-TS-D4": { + "availableBanks": [], + "rebootOption": 0 + }, + "JXS-TS-E3-H25": { + "availableBanks": [], + "rebootOption": 0 + }, + "JXS-TS-E4": { + "availableBanks": [], + "rebootOption": 0 + }, + "MADI": { + "availableBanks": [], + "rebootOption": 0 + }, + "SDI-IP-2022": { + "availableBanks": [], + "rebootOption": 0 + }, + "SDI-IP-2110": { + "availableBanks": [], + "rebootOption": 0 + }, + "SDI-IP-H25": { + "availableBanks": [], + "rebootOption": 0 + }, + "TICO-UHD-D2": { + "availableBanks": [], + "rebootOption": 0 + }, + "TICO-UHD-D2-IP-25G": { + "availableBanks": [], + "rebootOption": 0 + }, + "TICO-UHD-E1D1": { + "availableBanks": [], + "rebootOption": 0 + }, + "TICO-UHD-E2": { + "availableBanks": [], + "rebootOption": 0 + }, + "TICO-UHD-E2-12G": { + "availableBanks": [], + "rebootOption": 0 + }, + "TICO-UHD-E2-IP-25G": { + "availableBanks": [], + "rebootOption": 0 + }, + "TXS-HD-D3": { + "availableBanks": [], + "rebootOption": 0 + }, + "TXS-HD-E3": { + "availableBanks": [], + "rebootOption": 0 + }, + "UDC-IP-H25": { + "availableBanks": [], + "rebootOption": 0 + }, + "UPLINK-10G": { + "availableBanks": [], + "rebootOption": 0 + }, + "UPLINK-25G": { + "availableBanks": [], + "rebootOption": 0 + }, + "VID-PROC-UHD-12G": { + "availableBanks": [], + "rebootOption": 0 + }, + "Virtuoso-MI": { + "availableBanks": [], + "rebootOption": 0 + }, + "XS-DEC": { + "availableBanks": [], + "rebootOption": 0 + }, + "XS-ENC": { + "availableBanks": [], + "rebootOption": 0 + } + }, + "version": "0.1.0" + }, + { + "_id": "com.nevion.virtuoso_re-0.1.0", + "_vid": "com.nevion.virtuoso_re-0.1.0", + "attachments": [ + { + "description": "Default", + "name": "virtuoso_re" + } + ], + "configurableDevice": false, + "configurableModule": [ + "ASI", + "AUD-AES3", + "TICO-UHD-E1D1", + "TICO-UHD-E2", + "TICO-UHD-D2", + "TICO-UHD-E2-12G", + "TICO-UHD-D2-IP-25G", + "TICO-UHD-E2-IP-25G", + "SDI-IP-2022", + "SDI-IP-2110", + "SDI-IP-H25", + "MADI", + "AUD-PROC-MADI-IP", + "XS-ENC", + "XS-DEC", + "TXS-HD-E3", + "TXS-HD-D3", + "IPME-RTP", + "JPEG2000-ENC", + "JPEG2000-DEC", + "JXS-D3", + "JXS-E3", + "JXS-TS-E4", + "JXS-TS-D4", + "JXS-D4-H25", + "JXS-E4-H25", + "JXS-TS-D3-H25", + "JXS-TS-E3-H25", + "J2K-HD-E2D2", + "J2K-HD-D4", + "J2K-HD-E4", + "UDC-IP-H25", + "UPLINK-10G", + "UPLINK-25G", + "VID-PROC-UHD-12G", + "HEVC-ENC", + "HEVC-DEC" + ], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "Custom Data Fields for Nevion Virtuoso RE", + "label": "Nevion Virtuoso RE" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map", + "values": { + "com.nevion.virtuoso_re.AdvancedReachabilityCheck": { + "_schema": { + "default": true, + "descriptor": { + "desc": "Use a more thorough communication check, this will report an IP address as down if all HBR cards have a status of 'Booting' ", + "label": "Enable advanced communication check" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "bool" + } + }, + "com.nevion.virtuoso_re.enable_bulk_config": { + "_schema": { + "default": false, + "descriptor": { + "desc": "Configure this unit's audio elements using bulk API", + "label": "Enable bulk config" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "bool" + } + }, + "com.nevion.virtuoso_re.linear_uplink_support": { + "_schema": { + "default": false, + "descriptor": { + "desc": "Support backplane routing to Uplink cards for Linear cards", + "label": "Support uplink routing for Linear cards" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "bool" + } + }, + "com.nevion.virtuoso_re.madi_uplink_support": { + "_schema": { + "default": false, + "descriptor": { + "desc": "Support backplane routing to Uplink cards for MADI cards", + "label": "Support uplink routing for MADI cards" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "bool" + } + } + } + } + }, + "customSettingsSeed": {}, + "description": "Driver for Virtuoso RE device", + "deviceType": "driver", + "discoveredBy": null, + "exists": "No", + "iconType": "device", + "ipAddress": null, + "label": "Nevion Virtuoso RE", + "modules": [], + "name": "virtuoso_re", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.nevion", + "products": [], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.2.1.1.2.0" + ], + "snmpSysObjectIdValue": "1.3.6.1.4.1.8768.10.41", + "status": "", + "supportedApis": [ + "ParameterControlLike", + "DeviceLike", + "MaintenanceLike", + "PortLike", + "CoreLike", + "StatusLike" + ], + "supportsExternal": true, + "supportsParameterApi": true, + "uniqueMetricDefs": [], + "updateableDevice": false, + "updateableModule": { + "ASI": { + "availableBanks": [], + "rebootOption": 0 + }, + "AUD-AES3": { + "availableBanks": [], + "rebootOption": 0 + }, + "AUD-PROC-MADI-IP": { + "availableBanks": [], + "rebootOption": 0 + }, + "HEVC-DEC": { + "availableBanks": [], + "rebootOption": 0 + }, + "HEVC-ENC": { + "availableBanks": [], + "rebootOption": 0 + }, + "IPME-RTP": { + "availableBanks": [], + "rebootOption": 0 + }, + "J2K-HD-D4": { + "availableBanks": [], + "rebootOption": 0 + }, + "J2K-HD-E2D2": { + "availableBanks": [], + "rebootOption": 0 + }, + "J2K-HD-E4": { + "availableBanks": [], + "rebootOption": 0 + }, + "JPEG2000-DEC": { + "availableBanks": [], + "rebootOption": 0 + }, + "JPEG2000-ENC": { + "availableBanks": [], + "rebootOption": 0 + }, + "JXS-D3": { + "availableBanks": [], + "rebootOption": 0 + }, + "JXS-D4-H25": { + "availableBanks": [], + "rebootOption": 0 + }, + "JXS-E3": { + "availableBanks": [], + "rebootOption": 0 + }, + "JXS-E4-H25": { + "availableBanks": [], + "rebootOption": 0 + }, + "JXS-TS-D3-H25": { + "availableBanks": [], + "rebootOption": 0 + }, + "JXS-TS-D4": { + "availableBanks": [], + "rebootOption": 0 + }, + "JXS-TS-E3-H25": { + "availableBanks": [], + "rebootOption": 0 + }, + "JXS-TS-E4": { + "availableBanks": [], + "rebootOption": 0 + }, + "MADI": { + "availableBanks": [], + "rebootOption": 0 + }, + "SDI-IP-2022": { + "availableBanks": [], + "rebootOption": 0 + }, + "SDI-IP-2110": { + "availableBanks": [], + "rebootOption": 0 + }, + "SDI-IP-H25": { + "availableBanks": [], + "rebootOption": 0 + }, + "TICO-UHD-D2": { + "availableBanks": [], + "rebootOption": 0 + }, + "TICO-UHD-D2-IP-25G": { + "availableBanks": [], + "rebootOption": 0 + }, + "TICO-UHD-E1D1": { + "availableBanks": [], + "rebootOption": 0 + }, + "TICO-UHD-E2": { + "availableBanks": [], + "rebootOption": 0 + }, + "TICO-UHD-E2-12G": { + "availableBanks": [], + "rebootOption": 0 + }, + "TICO-UHD-E2-IP-25G": { + "availableBanks": [], + "rebootOption": 0 + }, + "TXS-HD-D3": { + "availableBanks": [], + "rebootOption": 0 + }, + "TXS-HD-E3": { + "availableBanks": [], + "rebootOption": 0 + }, + "UDC-IP-H25": { + "availableBanks": [], + "rebootOption": 0 + }, + "UPLINK-10G": { + "availableBanks": [], + "rebootOption": 0 + }, + "UPLINK-25G": { + "availableBanks": [], + "rebootOption": 0 + }, + "VID-PROC-UHD-12G": { + "availableBanks": [], + "rebootOption": 0 + }, + "Virtuoso-RE": { + "availableBanks": [], + "rebootOption": 0 + }, + "XS-DEC": { + "availableBanks": [], + "rebootOption": 0 + }, + "XS-ENC": { + "availableBanks": [], + "rebootOption": 0 + } + }, + "version": "0.1.0" + }, + { + "_id": "com.nevion.vizrt_vizengine-0.1.0", + "_vid": "com.nevion.vizrt_vizengine-0.1.0", + "attachments": [ + { + "description": "Default", + "name": "vizrt_vizengine" + } + ], + "configurableDevice": false, + "configurableModule": [], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "Custom setting fields for Viz Engine", + "label": "Viz Engine" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map", + "values": { + "com.nevion.vizrt_vizengine.port": { + "_schema": { + "default": 6100, + "descriptor": { + "desc": "Port", + "label": "Port" + }, + "isNullable": false, + "options": [], + "ranges": [ + [ + 0, + 65535, + 1 + ] + ], + "status": "Current", + "type": "number", + "units": "" + } + } + } + } + }, + "customSettingsSeed": {}, + "description": "Driver for Viz Engine", + "deviceType": "driver", + "discoveredBy": null, + "exists": "No", + "iconType": "gateway", + "ipAddress": null, + "label": "Vizrt Viz Engine", + "modules": [], + "name": "vizrt_vizengine", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.nevion", + "products": [], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.2.1.1.2.0" + ], + "snmpSysObjectIdValue": null, + "status": "", + "supportedApis": [ + "CoreLike", + "DeviceLike", + "PortLike" + ], + "supportsExternal": true, + "supportsParameterApi": false, + "uniqueMetricDefs": [], + "updateableDevice": false, + "updateableModule": {}, + "version": "0.1.0" + }, + { + "_id": "com.nevion.zman-0.1.0", + "_vid": "com.nevion.zman-0.1.0", + "attachments": [ + { + "description": "Default", + "name": "zman" + } + ], + "configurableDevice": false, + "configurableModule": [], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "customSettingsSeed": {}, + "description": "", + "deviceType": "driver", + "discoveredBy": null, + "exists": "No", + "iconType": "none", + "ipAddress": null, + "label": "Zman", + "modules": [], + "name": "zman", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.nevion", + "products": [], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.2.1.1.2.0" + ], + "snmpSysObjectIdValue": null, + "status": "", + "supportedApis": [ + "CoreLike", + "DeviceLike", + "PortLike" + ], + "supportsExternal": true, + "supportsParameterApi": false, + "uniqueMetricDefs": [], + "updateableDevice": false, + "updateableModule": {}, + "version": "0.1.0" + }, + { + "_id": "com.sony.MLS-Manager-1.0", + "_vid": "com.sony.MLS-Manager-1.0", + "attachments": [ + { + "description": "Default", + "name": "MLS-Manager" + } + ], + "configurableDevice": false, + "configurableModule": [], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "customSettingsSeed": {}, + "description": "MLS Manager parameter control", + "deviceType": "driver", + "discoveredBy": null, + "exists": "No", + "iconType": "none", + "ipAddress": null, + "label": "MLS-Manager", + "modules": [], + "name": "MLS-Manager", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.sony", + "products": [], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "mlsm": 31042, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.2.1.1.2.0" + ], + "snmpSysObjectIdValue": null, + "status": "", + "supportedApis": [ + "ParameterControlLike", + "CoreLike", + "DeviceLike" + ], + "supportsExternal": true, + "supportsParameterApi": true, + "uniqueMetricDefs": [], + "updateableDevice": false, + "updateableModule": {}, + "version": "1.0" + }, + { + "_id": "com.sony.MLS-X1-1.0", + "_vid": "com.sony.MLS-X1-1.0", + "attachments": [ + { + "description": "Default", + "name": "MLS-X1" + } + ], + "configurableDevice": false, + "configurableModule": [], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "Custom setting fields for NS-BUS MLS-X1 driver", + "label": "MLS-X1" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map", + "values": { + "com.nevion.nsbus.deviceId": { + "_schema": { + "default": "", + "descriptor": { + "desc": "Device ID for primary management address usually auto-populated by device discovery", + "label": "NS-BUS Device ID" + }, + "encoding": "UTF-8", + "isNullable": false, + "lengthRanges": [], + "options": [], + "status": "Current", + "type": "string" + } + }, + "com.nevion.nsbus.router.force_tcp": { + "_schema": { + "default": false, + "descriptor": { + "desc": "Don't use TLS on outgoing connection. Note: Depends on support from device, e.g. SC1 may not support this.", + "label": "NS-BUS Router Matrix Protocol: Force TCP" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "bool" + } + }, + "com.nevion.nsbus.tallyType": { + "_schema": { + "default": "NOT_USE_TALLY", + "descriptor": { + "desc": "Tally type usually auto-populated by device discovery", + "label": "NS-BUS Tally Type" + }, + "encoding": "UTF-8", + "isNullable": false, + "lengthRanges": [], + "options": [ + { + "descriptor": { + "desc": "", + "label": "No Tally" + }, + "value": "NOT_USE_TALLY" + }, + { + "descriptor": { + "desc": "", + "label": "Tally Master Device" + }, + "value": "TALLY_MASTER_DEVICE" + }, + { + "descriptor": { + "desc": "", + "label": "Tally Display Device" + }, + "value": "TALLY_DISPLAY_DEVICE" + }, + { + "descriptor": { + "desc": "", + "label": "Tally Master and Display Device" + }, + "value": "MASTER_AND_DISPLAY_DEVICE" + } + ], + "status": "Current", + "type": "string" + } + }, + "matrixId": { + "_schema": { + "default": "", + "descriptor": { + "desc": "", + "label": "Custom matrix ID" + }, + "encoding": "UTF-8", + "isNullable": false, + "lengthRanges": [], + "options": [], + "status": "Current", + "type": "string" + } + } + } + } + }, + "customSettingsSeed": {}, + "description": "Driver for NS-BUS MLS-X1 Device", + "deviceType": "driver", + "discoveredBy": null, + "exists": "No", + "iconType": "device", + "ipAddress": null, + "label": "MLS-X1", + "modules": [], + "name": "MLS-X1", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.sony", + "products": [], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.2.1.1.2.0" + ], + "snmpSysObjectIdValue": null, + "status": "", + "supportedApis": [ + "MatrixControlLike", + "CoreLike", + "DeviceLike" + ], + "supportsExternal": false, + "supportsParameterApi": false, + "uniqueMetricDefs": [], + "updateableDevice": false, + "updateableModule": {}, + "version": "1.0" + }, + { + "_id": "com.sony.MLS-X1_API-1.0", + "_vid": "com.sony.MLS-X1_API-1.0", + "attachments": [ + { + "description": "Default", + "name": "MLS-X1_API" + } + ], + "configurableDevice": false, + "configurableModule": [], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "customSettingsSeed": {}, + "description": "Driver for controlling settings on MLS-X1 units using the gRPC control API", + "deviceType": "panel", + "discoveredBy": null, + "exists": "No", + "iconType": "none", + "ipAddress": null, + "label": "MLS-X1 Control API", + "modules": [], + "name": "MLS-X1_API", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.sony", + "products": [], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "molisx": 9181, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.2.1.1.2.0" + ], + "snmpSysObjectIdValue": null, + "status": "", + "supportedApis": [ + "ParameterControlLike", + "CoreLike", + "DeviceLike" + ], + "supportsExternal": true, + "supportsParameterApi": true, + "uniqueMetricDefs": [], + "updateableDevice": false, + "updateableModule": {}, + "version": "1.0" + }, + { + "_id": "com.sony.Panel-1.0", + "_vid": "com.sony.Panel-1.0", + "attachments": [ + { + "description": "Default", + "name": "Panel" + } + ], + "configurableDevice": true, + "configurableModule": [], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "Custom setting fields for NS-BUS Panel drivers", + "label": "NS-BUS Panel" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map", + "values": { + "com.nevion.nsbus.config.force_tcp": { + "_schema": { + "default": false, + "descriptor": { + "desc": "Don't use TLS, useful for debugging.", + "label": "NS-BUS Configuration Protocol: Force TCP" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "bool" + } + }, + "com.nevion.nsbus.deviceId": { + "_schema": { + "default": "", + "descriptor": { + "desc": "Device ID for primary management address usually auto-populated by device discovery", + "label": "NS-BUS Device ID" + }, + "encoding": "UTF-8", + "isNullable": false, + "lengthRanges": [], + "options": [], + "status": "Current", + "type": "string" + } + }, + "com.nevion.nsbus.tallyType": { + "_schema": { + "default": "NOT_USE_TALLY", + "descriptor": { + "desc": "Tally type usually auto-populated by device discovery", + "label": "NS-BUS Tally Type" + }, + "encoding": "UTF-8", + "isNullable": false, + "lengthRanges": [], + "options": [ + { + "descriptor": { + "desc": "", + "label": "No Tally" + }, + "value": "NOT_USE_TALLY" + }, + { + "descriptor": { + "desc": "", + "label": "Tally Master Device" + }, + "value": "TALLY_MASTER_DEVICE" + }, + { + "descriptor": { + "desc": "", + "label": "Tally Display Device" + }, + "value": "TALLY_DISPLAY_DEVICE" + }, + { + "descriptor": { + "desc": "", + "label": "Tally Master and Display Device" + }, + "value": "MASTER_AND_DISPLAY_DEVICE" + } + ], + "status": "Current", + "type": "string" + } + }, + "matrixId": { + "_schema": { + "default": "", + "descriptor": { + "desc": "", + "label": "Custom matrix ID" + }, + "encoding": "UTF-8", + "isNullable": false, + "lengthRanges": [], + "options": [], + "status": "Current", + "type": "string" + } + } + } + } + }, + "customSettingsSeed": {}, + "description": "Driver for NS-BUS Panel Driver", + "deviceType": "panel", + "discoveredBy": null, + "exists": "No", + "iconType": "device", + "ipAddress": null, + "label": "NS-BUS Panel", + "modules": [], + "name": "Panel", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.sony", + "products": [], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.2.1.1.2.0" + ], + "snmpSysObjectIdValue": null, + "status": "", + "supportedApis": [ + "MaintenanceLike", + "CoreLike", + "DeviceLike", + "DynamicLike" + ], + "supportsExternal": false, + "supportsParameterApi": false, + "uniqueMetricDefs": [], + "updateableDevice": false, + "updateableModule": {}, + "version": "1.0" + }, + { + "_id": "com.sony.SC1-1.0", + "_vid": "com.sony.SC1-1.0", + "attachments": [ + { + "description": "Default", + "name": "SC1" + } + ], + "configurableDevice": false, + "configurableModule": [], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "Custom setting fields for NS-BUS PWS-110SC1 drivers", + "label": "SC1" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map", + "values": { + "com.nevion.nsbus.deviceId": { + "_schema": { + "default": "", + "descriptor": { + "desc": "Device ID for primary management address usually auto-populated by device discovery", + "label": "NS-BUS Device ID" + }, + "encoding": "UTF-8", + "isNullable": false, + "lengthRanges": [], + "options": [], + "status": "Current", + "type": "string" + } + }, + "com.nevion.nsbus.router.force_tcp": { + "_schema": { + "default": false, + "descriptor": { + "desc": "Don't use TLS on outgoing connection. Note: Depends on support from device, e.g. SC1 may not support this.", + "label": "NS-BUS Router Matrix Protocol: Force TCP" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "bool" + } + }, + "com.nevion.nsbus.tallyType": { + "_schema": { + "default": "NOT_USE_TALLY", + "descriptor": { + "desc": "Tally type usually auto-populated by device discovery", + "label": "NS-BUS Tally Type" + }, + "encoding": "UTF-8", + "isNullable": false, + "lengthRanges": [], + "options": [ + { + "descriptor": { + "desc": "", + "label": "No Tally" + }, + "value": "NOT_USE_TALLY" + }, + { + "descriptor": { + "desc": "", + "label": "Tally Master Device" + }, + "value": "TALLY_MASTER_DEVICE" + }, + { + "descriptor": { + "desc": "", + "label": "Tally Display Device" + }, + "value": "TALLY_DISPLAY_DEVICE" + }, + { + "descriptor": { + "desc": "", + "label": "Tally Master and Display Device" + }, + "value": "MASTER_AND_DISPLAY_DEVICE" + } + ], + "status": "Current", + "type": "string" + } + }, + "matrixId": { + "_schema": { + "default": "", + "descriptor": { + "desc": "", + "label": "Custom matrix ID" + }, + "encoding": "UTF-8", + "isNullable": false, + "lengthRanges": [], + "options": [], + "status": "Current", + "type": "string" + } + } + } + } + }, + "customSettingsSeed": {}, + "description": "Driver for NS-BUS PWS-110SC1 Driver", + "deviceType": "driver", + "discoveredBy": null, + "exists": "No", + "iconType": "device", + "ipAddress": null, + "label": "Sony SC1", + "modules": [], + "name": "SC1", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.sony", + "products": [], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.2.1.1.2.0" + ], + "snmpSysObjectIdValue": null, + "status": "", + "supportedApis": [ + "MatrixControlLike", + "CoreLike", + "DeviceLike" + ], + "supportsExternal": false, + "supportsParameterApi": false, + "uniqueMetricDefs": [], + "updateableDevice": false, + "updateableModule": {}, + "version": "1.0" + }, + { + "_id": "com.sony.XVS-G1-1.0", + "_vid": "com.sony.XVS-G1-1.0", + "attachments": [ + { + "description": "Default", + "name": "XVS-G1" + } + ], + "configurableDevice": false, + "configurableModule": [], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "Custom setting fields for NS-BUS XVS-G1 driver", + "label": "XVS-G1" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map", + "values": { + "com.nevion.nsbus.deviceId": { + "_schema": { + "default": "", + "descriptor": { + "desc": "Device ID for primary management address usually auto-populated by device discovery", + "label": "NS-BUS Device ID" + }, + "encoding": "UTF-8", + "isNullable": false, + "lengthRanges": [], + "options": [], + "status": "Current", + "type": "string" + } + }, + "com.nevion.nsbus.router.force_tcp": { + "_schema": { + "default": false, + "descriptor": { + "desc": "Don't use TLS on outgoing connection. Note: Depends on support from device, e.g. SC1 may not support this.", + "label": "NS-BUS Router Matrix Protocol: Force TCP" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "bool" + } + }, + "com.nevion.nsbus.tallyType": { + "_schema": { + "default": "NOT_USE_TALLY", + "descriptor": { + "desc": "Tally type usually auto-populated by device discovery", + "label": "NS-BUS Tally Type" + }, + "encoding": "UTF-8", + "isNullable": false, + "lengthRanges": [], + "options": [ + { + "descriptor": { + "desc": "", + "label": "No Tally" + }, + "value": "NOT_USE_TALLY" + }, + { + "descriptor": { + "desc": "", + "label": "Tally Master Device" + }, + "value": "TALLY_MASTER_DEVICE" + }, + { + "descriptor": { + "desc": "", + "label": "Tally Display Device" + }, + "value": "TALLY_DISPLAY_DEVICE" + }, + { + "descriptor": { + "desc": "", + "label": "Tally Master and Display Device" + }, + "value": "MASTER_AND_DISPLAY_DEVICE" + } + ], + "status": "Current", + "type": "string" + } + }, + "matrixId": { + "_schema": { + "default": "", + "descriptor": { + "desc": "", + "label": "Custom matrix ID" + }, + "encoding": "UTF-8", + "isNullable": false, + "lengthRanges": [], + "options": [], + "status": "Current", + "type": "string" + } + } + } + } + }, + "customSettingsSeed": {}, + "description": "Driver for NS-BUS XVS-G1 Device", + "deviceType": "driver", + "discoveredBy": null, + "exists": "No", + "iconType": "device", + "ipAddress": null, + "label": "XVS-G1", + "modules": [], + "name": "XVS-G1", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.sony", + "products": [], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.2.1.1.2.0" + ], + "snmpSysObjectIdValue": null, + "status": "", + "supportedApis": [ + "MatrixControlLike", + "CoreLike", + "DeviceLike" + ], + "supportsExternal": true, + "supportsParameterApi": false, + "uniqueMetricDefs": [], + "updateableDevice": false, + "updateableModule": {}, + "version": "1.0" + }, + { + "_id": "com.sony.cna2-0.1.0", + "_vid": "com.sony.cna2-0.1.0", + "attachments": [ + { + "description": "Default", + "name": "cna2" + } + ], + "configurableDevice": true, + "configurableModule": [], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "Custom setting fields for CNA-2 driver", + "label": "CNA-2" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map", + "values": { + "com.sony.cna2.domain_number": { + "_schema": { + "default": 0, + "descriptor": { + "desc": "", + "label": "Domain Number" + }, + "isNullable": false, + "options": [], + "ranges": [], + "status": "Current", + "type": "number", + "units": "" + } + }, + "com.sony.cna2.matrix_type": { + "_schema": { + "default": "1:1", + "descriptor": { + "desc": "", + "label": "MatrixType" + }, + "encoding": "UTF-8", + "isNullable": false, + "lengthRanges": [], + "options": [], + "status": "Current", + "type": "string" + } + }, + "com.sony.cna2.total_cameras": { + "_schema": { + "default": 96, + "descriptor": { + "desc": "", + "label": "Total Number of System Cameras" + }, + "isNullable": false, + "options": [], + "ranges": [ + [ + 1, + 96, + 1 + ] + ], + "status": "Current", + "type": "number", + "units": "" + } + }, + "com.sony.cna2.webhook_url": { + "_schema": { + "default": "", + "descriptor": { + "desc": "Typically http://[VIP address]/api", + "label": "Webhook URL" + }, + "encoding": "UTF-8", + "isNullable": false, + "lengthRanges": [], + "options": [], + "status": "Current", + "type": "string" + } + } + } + } + }, + "customSettingsSeed": {}, + "description": "Driver for CNA-2 device", + "deviceType": "driver", + "discoveredBy": null, + "exists": "No", + "iconType": "none", + "ipAddress": null, + "label": "CNA-2", + "modules": [], + "name": "cna2", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.sony", + "products": [ + { + "name": "CNA-2", + "swBuildTime": null, + "swVersion": null + } + ], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.2.1.1.2.0" + ], + "snmpSysObjectIdValue": null, + "status": "", + "supportedApis": [ + "DeviceLike", + "CoreLike", + "MatrixControlLike", + "ParameterControlLike", + "MaintenanceLike", + "WebhooksLike" + ], + "supportsExternal": true, + "supportsParameterApi": true, + "uniqueMetricDefs": [], + "updateableDevice": false, + "updateableModule": {}, + "version": "0.1.0" + }, + { + "_id": "com.sony.generic_ccm-0.1.0", + "_vid": "com.sony.generic_ccm-0.1.0", + "attachments": [ + { + "description": "Default", + "name": "generic_ccm" + } + ], + "configurableDevice": false, + "configurableModule": [], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "customSettingsSeed": {}, + "description": "", + "deviceType": "driver", + "discoveredBy": null, + "exists": "No", + "iconType": "none", + "ipAddress": null, + "label": "Generic CCM", + "modules": [], + "name": "generic_ccm", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.sony", + "products": [ + { + "name": "CBK-RPU7", + "swBuildTime": null, + "swVersion": null + }, + { + "name": "NXL-ME80", + "swBuildTime": null, + "swVersion": null + } + ], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.2.1.1.2.0" + ], + "snmpSysObjectIdValue": null, + "status": "", + "supportedApis": [ + "CoreLike", + "DeviceLike", + "StatusLike" + ], + "supportsExternal": true, + "supportsParameterApi": false, + "uniqueMetricDefs": [], + "updateableDevice": false, + "updateableModule": {}, + "version": "0.1.0" + }, + { + "_id": "com.sony.generic_external_control-1.0", + "_vid": "com.sony.generic_external_control-1.0", + "attachments": [ + { + "description": "Default", + "name": "generic_external_control" + } + ], + "configurableDevice": false, + "configurableModule": [], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "Custom setting fields for NS-BUS Generic External Control drivers", + "label": "NS-BUS Generic" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map", + "values": { + "com.nevion.nsbus.deviceId": { + "_schema": { + "default": "", + "descriptor": { + "desc": "Device ID for primary management address usually auto-populated by device discovery", + "label": "NS-BUS Device ID" + }, + "encoding": "UTF-8", + "isNullable": false, + "lengthRanges": [], + "options": [], + "status": "Current", + "type": "string" + } + }, + "com.nevion.nsbus.tallyType": { + "_schema": { + "default": "NOT_USE_TALLY", + "descriptor": { + "desc": "Tally type usually auto-populated by device discovery", + "label": "NS-BUS Tally Type" + }, + "encoding": "UTF-8", + "isNullable": false, + "lengthRanges": [], + "options": [ + { + "descriptor": { + "desc": "", + "label": "No Tally" + }, + "value": "NOT_USE_TALLY" + }, + { + "descriptor": { + "desc": "", + "label": "Tally Master Device" + }, + "value": "TALLY_MASTER_DEVICE" + }, + { + "descriptor": { + "desc": "", + "label": "Tally Display Device" + }, + "value": "TALLY_DISPLAY_DEVICE" + }, + { + "descriptor": { + "desc": "", + "label": "Tally Master and Display Device" + }, + "value": "MASTER_AND_DISPLAY_DEVICE" + } + ], + "status": "Current", + "type": "string" + } + }, + "matrixId": { + "_schema": { + "default": "", + "descriptor": { + "desc": "", + "label": "Custom matrix ID" + }, + "encoding": "UTF-8", + "isNullable": false, + "lengthRanges": [], + "options": [], + "status": "Current", + "type": "string" + } + } + } + } + }, + "customSettingsSeed": {}, + "description": "Driver for NS-BUS Generic External Control Driver", + "deviceType": "driver", + "discoveredBy": null, + "exists": "No", + "iconType": "device", + "ipAddress": null, + "label": "NS-BUS External Control", + "modules": [], + "name": "generic_external_control", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.sony", + "products": [], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.2.1.1.2.0" + ], + "snmpSysObjectIdValue": null, + "status": "", + "supportedApis": [ + "CoreLike" + ], + "supportsExternal": false, + "supportsParameterApi": false, + "uniqueMetricDefs": [], + "updateableDevice": false, + "updateableModule": {}, + "version": "1.0" + }, + { + "_id": "com.sony.mks-1.0.0", + "_vid": "com.sony.mks-1.0.0", + "attachments": [ + { + "description": "Default", + "name": "mks" + } + ], + "configurableDevice": false, + "configurableModule": [], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "Custom setting fields for mks", + "label": "mks" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map", + "values": { + "com.sony.mks.uuid": { + "_schema": { + "default": "", + "descriptor": { + "desc": "", + "label": "MKS Panel UUID" + }, + "encoding": "UTF-8", + "isNullable": false, + "lengthRanges": [], + "options": [], + "status": "Current", + "type": "string" + } + } + } + } + }, + "customSettingsSeed": {}, + "description": "A Driver for Sony MKS panels (Leo mode)", + "deviceType": "driver", + "discoveredBy": null, + "exists": "No", + "iconType": "none", + "ipAddress": null, + "label": "Sony MKS Panel", + "modules": [], + "name": "mks", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.sony", + "products": [ + { + "name": "MKS", + "swBuildTime": null, + "swVersion": null + } + ], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.2.1.1.2.0" + ], + "snmpSysObjectIdValue": null, + "status": "", + "supportedApis": [ + "CoreLike", + "DeviceLike", + "HwPanelLike" + ], + "supportsExternal": true, + "supportsParameterApi": false, + "uniqueMetricDefs": [], + "updateableDevice": false, + "updateableModule": {}, + "version": "1.0.0" + }, + { + "_id": "com.sony.nsbus_generic_router-1.0", + "_vid": "com.sony.nsbus_generic_router-1.0", + "attachments": [ + { + "description": "Default", + "name": "nsbus_generic_router" + } + ], + "configurableDevice": false, + "configurableModule": [], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "Custom setting fields for Generic NS-BUS Router drivers", + "label": "Generic NS-BUS Router" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map", + "values": { + "com.nevion.nsbus.deviceId": { + "_schema": { + "default": "", + "descriptor": { + "desc": "Device ID for primary management address usually auto-populated by device discovery", + "label": "NS-BUS Device ID" + }, + "encoding": "UTF-8", + "isNullable": false, + "lengthRanges": [], + "options": [], + "status": "Current", + "type": "string" + } + }, + "com.nevion.nsbus.router.force_tcp": { + "_schema": { + "default": false, + "descriptor": { + "desc": "Don't use TLS on outgoing connection. Note: Depends on support from device, e.g. SC1 may not support this.", + "label": "NS-BUS Router Matrix Protocol: Force TCP" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "bool" + } + }, + "com.nevion.nsbus.tallyType": { + "_schema": { + "default": "NOT_USE_TALLY", + "descriptor": { + "desc": "Tally type usually auto-populated by device discovery", + "label": "NS-BUS Tally Type" + }, + "encoding": "UTF-8", + "isNullable": false, + "lengthRanges": [], + "options": [ + { + "descriptor": { + "desc": "", + "label": "No Tally" + }, + "value": "NOT_USE_TALLY" + }, + { + "descriptor": { + "desc": "", + "label": "Tally Master Device" + }, + "value": "TALLY_MASTER_DEVICE" + }, + { + "descriptor": { + "desc": "", + "label": "Tally Display Device" + }, + "value": "TALLY_DISPLAY_DEVICE" + }, + { + "descriptor": { + "desc": "", + "label": "Tally Master and Display Device" + }, + "value": "MASTER_AND_DISPLAY_DEVICE" + } + ], + "status": "Current", + "type": "string" + } + }, + "matrixId": { + "_schema": { + "default": "", + "descriptor": { + "desc": "", + "label": "Custom matrix ID" + }, + "encoding": "UTF-8", + "isNullable": false, + "lengthRanges": [], + "options": [], + "status": "Current", + "type": "string" + } + } + } + } + }, + "customSettingsSeed": {}, + "description": "Driver for NS-BUS Generic Router Driver", + "deviceType": "driver", + "discoveredBy": null, + "exists": "No", + "iconType": "device", + "ipAddress": null, + "label": "NS-BUS Router", + "modules": [], + "name": "nsbus_generic_router", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.sony", + "products": [], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.2.1.1.2.0" + ], + "snmpSysObjectIdValue": null, + "status": "", + "supportedApis": [ + "MatrixControlLike", + "CoreLike", + "DeviceLike" + ], + "supportsExternal": false, + "supportsParameterApi": false, + "uniqueMetricDefs": [], + "updateableDevice": false, + "updateableModule": {}, + "version": "1.0" + }, + { + "_id": "com.sony.rcp3500-0.1.0", + "_vid": "com.sony.rcp3500-0.1.0", + "attachments": [ + { + "description": "Default", + "name": "rcp3500" + } + ], + "configurableDevice": false, + "configurableModule": [], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "Custom setting fields for rcp3500", + "label": "rcp3500" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map", + "values": { + "com.nevion.emberplus.keepalives": { + "_schema": { + "default": true, + "descriptor": { + "desc": "If selected, keep-alives will be used to determine reachability", + "label": "Send keep-alives" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "bool" + } + }, + "com.nevion.emberplus.port": { + "_schema": { + "default": 9000, + "descriptor": { + "desc": "Port", + "label": "Port" + }, + "isNullable": false, + "options": [], + "ranges": [ + [ + 0, + 65535, + 1 + ] + ], + "status": "Current", + "type": "number", + "units": "" + } + }, + "com.nevion.emberplus.queue": { + "_schema": { + "default": true, + "descriptor": { + "desc": "", + "label": "Request queueing" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "bool" + } + }, + "com.nevion.emberplus.suppress_illegal": { + "_schema": { + "default": false, + "descriptor": { + "desc": "", + "label": "Suppress illegal update warnings" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "bool" + } + }, + "com.nevion.emberplus.trace": { + "_schema": { + "default": false, + "descriptor": { + "desc": "", + "label": "Tracing (logging intensive)" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "bool" + } + } + } + } + }, + "customSettingsSeed": {}, + "description": "", + "deviceType": "driver", + "discoveredBy": null, + "exists": "No", + "iconType": "none", + "ipAddress": null, + "label": "RCP 3500", + "modules": [], + "name": "rcp3500", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.sony", + "products": [], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.2.1.1.2.0" + ], + "snmpSysObjectIdValue": null, + "status": "", + "supportedApis": [ + "ParameterControlLike", + "GPIOLike", + "CoreLike", + "DeviceLike" + ], + "supportsExternal": true, + "supportsParameterApi": true, + "uniqueMetricDefs": [], + "updateableDevice": false, + "updateableModule": {}, + "version": "0.1.0" + }, + { + "_id": "com.sony.sony_sdcp_group_all-0.1.0", + "_vid": "com.sony.sony_sdcp_group_all-0.1.0", + "attachments": [ + { + "description": "Default", + "name": "sony_sdcp_group_all" + } + ], + "configurableDevice": false, + "configurableModule": [], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "Custom setting fields for sony_sdcp_group_all", + "label": "sony_sdcp_group_all" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map", + "values": { + "com.nevion.sdcp.community": { + "_schema": { + "default": "SONY", + "descriptor": { + "desc": "Enter custom community string default ('SONY')", + "label": "Community" + }, + "encoding": "UTF-8", + "isNullable": false, + "lengthRanges": [], + "options": [], + "status": "Current", + "type": "string" + } + }, + "com.nevion.sdcp.groupId": { + "_schema": { + "default": 0, + "descriptor": { + "desc": "GroupId for monitor", + "label": "GroupId" + }, + "isNullable": false, + "options": [], + "ranges": [], + "status": "Current", + "type": "number", + "units": "" + } + }, + "com.nevion.sdcp.port": { + "_schema": { + "default": 53484, + "descriptor": { + "desc": "Port", + "label": "Port" + }, + "isNullable": false, + "options": [], + "ranges": [ + [ + 0, + 65535, + 1 + ] + ], + "status": "Current", + "type": "number", + "units": "" + } + } + } + } + }, + "customSettingsSeed": {}, + "description": "Driver for Sony Monitor Control Unit", + "deviceType": "panel", + "discoveredBy": null, + "exists": "No", + "iconType": "monitor", + "ipAddress": null, + "label": "Sony Monitor Control Unit Driver", + "modules": [], + "name": "sony_sdcp_group_all", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.sony", + "products": [], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.2.1.1.2.0" + ], + "snmpSysObjectIdValue": null, + "status": "", + "supportedApis": [ + "ParameterControlLike", + "CoreLike", + "DeviceLike" + ], + "supportsExternal": true, + "supportsParameterApi": true, + "uniqueMetricDefs": [], + "updateableDevice": false, + "updateableModule": {}, + "version": "0.1.0" + }, + { + "_id": "com.sony.sony_sdcp_single-0.1.0", + "_vid": "com.sony.sony_sdcp_single-0.1.0", + "attachments": [ + { + "description": "Default", + "name": "sony_sdcp_single" + } + ], + "configurableDevice": false, + "configurableModule": [], + "configurablePort": {}, + "customSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "Custom setting fields for sony_sdcp_single", + "label": "sony_sdcp_single" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map", + "values": { + "com.nevion.sdcp.community": { + "_schema": { + "default": "SONY", + "descriptor": { + "desc": "Enter custom community string default ('SONY')", + "label": "Community" + }, + "encoding": "UTF-8", + "isNullable": false, + "lengthRanges": [], + "options": [], + "status": "Current", + "type": "string" + } + }, + "com.nevion.sdcp.port": { + "_schema": { + "default": 53484, + "descriptor": { + "desc": "Port", + "label": "Port" + }, + "isNullable": false, + "options": [], + "ranges": [ + [ + 0, + 65535, + 1 + ] + ], + "status": "Current", + "type": "number", + "units": "" + } + }, + "com.nevion.sdcp.unitId": { + "_schema": { + "default": 1, + "descriptor": { + "desc": "UnitId for monitor", + "label": "UnitId" + }, + "isNullable": false, + "options": [], + "ranges": [], + "status": "Current", + "type": "number", + "units": "" + } + } + } + } + }, + "customSettingsSeed": {}, + "description": "Driver for Sony Monitor Control Unit", + "deviceType": "panel", + "discoveredBy": null, + "exists": "No", + "iconType": "monitor", + "ipAddress": null, + "label": "Sony Monitor Control Unit Driver", + "modules": [], + "name": "sony_sdcp_single", + "operationalModeSettings": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map" + } + }, + "organization": "com.sony", + "products": [], + "protocolId": null, + "protocols": { + "gnmi": 6030, + "http": 80, + "https": 443, + "snmp": 161, + "ssh": 22, + "telnet": 23, + "ws": 80, + "wss": 443, + "xap": 80, + "xap21": 80 + }, + "snmpDiscoveryOIDs": [ + "1.3.6.1.2.1.1.2.0" + ], + "snmpSysObjectIdValue": null, + "status": "", + "supportedApis": [ + "ParameterControlLike", + "CoreLike", + "DeviceLike" + ], + "supportsExternal": true, + "supportsParameterApi": true, + "uniqueMetricDefs": [], + "updateableDevice": false, + "updateableModule": {}, + "version": "0.1.0" + } + ] + } + } + } + } +} \ No newline at end of file diff --git a/src/videoipath_automation_tool/apps/inventory/model/drivers.py b/src/videoipath_automation_tool/apps/inventory/model/drivers.py index f99475d..303feb2 100644 --- a/src/videoipath_automation_tool/apps/inventory/model/drivers.py +++ b/src/videoipath_automation_tool/apps/inventory/model/drivers.py @@ -28,6 +28,7 @@ "2025.2.0", "2025.3.2", "2025.4.3", + "2026.2.0", ] diff --git a/src/videoipath_automation_tool/apps/topology/model/n_graph_elements/n_graph_schema/2026.2.0_nGraphSchemaFlat.json b/src/videoipath_automation_tool/apps/topology/model/n_graph_elements/n_graph_schema/2026.2.0_nGraphSchemaFlat.json new file mode 100644 index 0000000..4662e07 --- /dev/null +++ b/src/videoipath_automation_tool/apps/topology/model/n_graph_elements/n_graph_schema/2026.2.0_nGraphSchemaFlat.json @@ -0,0 +1,4011 @@ +{ + "data": { + "status": { + "network": { + "nGraphSchemaFlat": { + "_items": [ + { + "_id": "baseDevice", + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "BaseDevice" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map", + "values": { + "descriptor.desc": { + "_schema": { + "default": "", + "descriptor": { + "desc": "A more elaborate description of the entity", + "label": "Description" + }, + "encoding": "UTF-8", + "isNullable": false, + "lengthRanges": [], + "options": [], + "status": "Current", + "type": "string" + } + }, + "descriptor.label": { + "_schema": { + "default": "", + "descriptor": { + "desc": "A label for the described entity", + "label": "Label" + }, + "encoding": "UTF-8", + "isNullable": false, + "lengthRanges": [], + "options": [], + "status": "Current", + "type": "string" + } + }, + "fDescriptor.desc": { + "_schema": { + "default": "", + "descriptor": { + "desc": "A more elaborate description of the entity", + "label": "Description" + }, + "encoding": "UTF-8", + "isNullable": false, + "lengthRanges": [], + "options": [], + "status": "Current", + "type": "string" + } + }, + "fDescriptor.label": { + "_schema": { + "default": "", + "descriptor": { + "desc": "A label for the described entity", + "label": "Label" + }, + "encoding": "UTF-8", + "isNullable": false, + "lengthRanges": [], + "options": [], + "status": "Current", + "type": "string" + } + }, + "iconSize": { + "_schema": { + "default": "", + "descriptor": { + "desc": "", + "label": "Device icon size" + }, + "encoding": "UTF-8", + "gui": { + "tags": [], + "widget": "Dropdown" + }, + "isNullable": false, + "lengthRanges": [], + "options": [ + { + "descriptor": { + "desc": "", + "label": "Auto" + }, + "value": "auto" + }, + { + "descriptor": { + "desc": "", + "label": "Large" + }, + "value": "large" + }, + { + "descriptor": { + "desc": "", + "label": "Medium" + }, + "value": "medium" + }, + { + "descriptor": { + "desc": "", + "label": "Small" + }, + "value": "small" + } + ], + "status": "Current", + "type": "string" + } + }, + "iconType": { + "_schema": { + "default": "", + "descriptor": { + "desc": "", + "label": "Driver icon type" + }, + "encoding": "UTF-8", + "gui": { + "tags": [], + "widget": "Dropdown" + }, + "isNullable": false, + "lengthRanges": [], + "options": [ + { + "descriptor": { + "desc": "", + "label": "Audio Mixer" + }, + "value": "audioMixer" + }, + { + "descriptor": { + "desc": "", + "label": "Camera" + }, + "value": "camera" + }, + { + "descriptor": { + "desc": "", + "label": "Decoder" + }, + "value": "decoder" + }, + { + "descriptor": { + "desc": "", + "label": "Default Driver-Specified Icon" + }, + "value": "default" + }, + { + "descriptor": { + "desc": "", + "label": "Device" + }, + "value": "device" + }, + { + "descriptor": { + "desc": "", + "label": "Encoder" + }, + "value": "encoder" + }, + { + "descriptor": { + "desc": "", + "label": "Encoder/Decoder" + }, + "value": "encoderDecoder" + }, + { + "descriptor": { + "desc": "", + "label": "Gateway" + }, + "value": "gateway" + }, + { + "descriptor": { + "desc": "", + "label": "IP Switch/Router" + }, + "value": "ipSwitchRouter" + }, + { + "descriptor": { + "desc": "", + "label": "Media device" + }, + "value": "mediaDevice" + }, + { + "descriptor": { + "desc": "", + "label": "Monitor" + }, + "value": "monitor" + }, + { + "descriptor": { + "desc": "", + "label": "Unspecified" + }, + "value": "none" + }, + { + "descriptor": { + "desc": "", + "label": "Processing Device" + }, + "value": "processingDevice" + }, + { + "descriptor": { + "desc": "", + "label": "Server" + }, + "value": "server" + }, + { + "descriptor": { + "desc": "", + "label": "Transport Stream Processor" + }, + "value": "transportStreamProcessor" + }, + { + "descriptor": { + "desc": "", + "label": "VLAN Cloud virtual device" + }, + "value": "vlanCloud" + }, + { + "descriptor": { + "desc": "", + "label": "Video/Audio Router (Matrix)" + }, + "value": "videoAudioRouterMatrix" + }, + { + "descriptor": { + "desc": "", + "label": "Video Mixer" + }, + "value": "videoMixer" + } + ], + "status": "Current", + "type": "string" + } + }, + "isVirtual": { + "_schema": { + "default": true, + "descriptor": { + "desc": "True, if device does not correspond to physical device/driver.", + "label": "Virtual" + }, + "gui": { + "tags": [], + "widget": "CheckBox" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "bool" + } + }, + "maps": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "unknown" + } + }, + "sdpStrategy": { + "_schema": { + "default": "", + "descriptor": { + "desc": "", + "label": "Sdp Polling Strategy" + }, + "encoding": "UTF-8", + "gui": { + "tags": [], + "widget": "Dropdown" + }, + "isNullable": false, + "lengthRanges": [], + "options": [ + { + "descriptor": { + "desc": "", + "label": "Continuous" + }, + "value": "always" + }, + { + "descriptor": { + "desc": "", + "label": "Fetch and Confirm" + }, + "value": "once" + }, + { + "descriptor": { + "desc": "", + "label": "Continuous Video, Confirm Others" + }, + "value": "video" + } + ], + "status": "Current", + "type": "string" + } + }, + "siteId": { + "_schema": { + "default": "", + "descriptor": { + "desc": "The ID of the site this device is located at.", + "label": "Site ID" + }, + "encoding": "UTF-8", + "isNullable": true, + "lengthRanges": [], + "options": [], + "status": "Current", + "type": "string" + } + }, + "tags": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "User defined tags", + "label": "Tags" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "unknown" + } + } + } + }, + "_vid": "baseDevice" + }, + { + "_id": "codecVertex", + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "CodecVertex" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map", + "values": { + "active": { + "_schema": { + "default": true, + "descriptor": { + "desc": "True, if the vertex can be used in service routing.", + "label": "Active" + }, + "gui": { + "tags": [], + "widget": "CheckBox" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "bool" + } + }, + "bidirPartnerId": { + "_schema": { + "default": "", + "descriptor": { + "desc": "", + "label": "Bidir partner id" + }, + "encoding": "UTF-8", + "isNullable": true, + "lengthRanges": [], + "options": [], + "status": "Current", + "type": "string" + } + }, + "codecFormat": { + "_schema": { + "default": "", + "descriptor": { + "desc": "", + "label": "Codec format type" + }, + "encoding": "UTF-8", + "gui": { + "tags": [], + "widget": "Dropdown" + }, + "isNullable": false, + "lengthRanges": [], + "options": [ + { + "descriptor": { + "desc": "", + "label": "ASI" + }, + "value": "ASI" + }, + { + "descriptor": { + "desc": "", + "label": "Ancillary" + }, + "value": "Ancillary" + }, + { + "descriptor": { + "desc": "", + "label": "Audio" + }, + "value": "Audio" + }, + { + "descriptor": { + "desc": "", + "label": "Video" + }, + "value": "Video" + } + ], + "status": "Current", + "type": "string" + } + }, + "configPriority": { + "_schema": { + "default": "", + "descriptor": { + "desc": "Priority level of the endpoint during configuration", + "label": "Config priority level" + }, + "encoding": "UTF-8", + "gui": { + "tags": [], + "widget": "Dropdown" + }, + "isNullable": false, + "lengthRanges": [], + "options": [ + { + "descriptor": { + "desc": "", + "label": "High" + }, + "value": "high" + }, + { + "descriptor": { + "desc": "", + "label": "Low" + }, + "value": "low" + }, + { + "descriptor": { + "desc": "", + "label": "Off" + }, + "value": "off" + } + ], + "status": "Current", + "type": "string" + } + }, + "control": { + "_schema": { + "default": "", + "descriptor": { + "desc": "Determines how the vertex is configured, e.g. if it should be auto-reapplied (full).", + "label": "Control level" + }, + "encoding": "UTF-8", + "gui": { + "tags": [], + "widget": "Dropdown" + }, + "isNullable": false, + "lengthRanges": [], + "options": [ + { + "descriptor": { + "desc": "", + "label": "Full" + }, + "value": "full" + }, + { + "descriptor": { + "desc": "", + "label": "Off" + }, + "value": "off" + }, + { + "descriptor": { + "desc": "", + "label": "Initial" + }, + "value": "semi" + } + ], + "status": "Current", + "type": "string" + } + }, + "custom": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "A map structure containing String-DtValue pairs", + "label": "DtMap" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "unknown" + } + }, + "descriptor.desc": { + "_schema": { + "default": "", + "descriptor": { + "desc": "A more elaborate description of the entity", + "label": "Description" + }, + "encoding": "UTF-8", + "isNullable": false, + "lengthRanges": [], + "options": [], + "status": "Current", + "type": "string" + } + }, + "descriptor.label": { + "_schema": { + "default": "", + "descriptor": { + "desc": "A label for the described entity", + "label": "Label" + }, + "encoding": "UTF-8", + "isNullable": false, + "lengthRanges": [], + "options": [], + "status": "Current", + "type": "string" + } + }, + "deviceId": { + "_schema": { + "default": "", + "descriptor": { + "desc": "", + "label": "Device Id" + }, + "encoding": "UTF-8", + "isNullable": false, + "lengthRanges": [], + "options": [], + "status": "Current", + "type": "string" + } + }, + "exclusive": { + "_schema": { + "default": true, + "descriptor": { + "desc": "A connected source cannot have other receivers (even for multicast).", + "label": "Exclusive" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "bool" + } + }, + "extraAlertFilters": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "Extra Alert Filters" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "unknown" + } + }, + "extraFormats": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "Formats added when used as endpoint", + "label": "Extra Formats" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "unknown" + } + }, + "fDescriptor.desc": { + "_schema": { + "default": "", + "descriptor": { + "desc": "A more elaborate description of the entity", + "label": "Description" + }, + "encoding": "UTF-8", + "isNullable": false, + "lengthRanges": [], + "options": [], + "status": "Current", + "type": "string" + } + }, + "fDescriptor.label": { + "_schema": { + "default": "", + "descriptor": { + "desc": "A label for the described entity", + "label": "Label" + }, + "encoding": "UTF-8", + "isNullable": false, + "lengthRanges": [], + "options": [], + "status": "Current", + "type": "string" + } + }, + "gpid.component": { + "_schema": { + "default": 0, + "descriptor": { + "desc": "", + "label": "Source component" + }, + "gui": { + "tags": [], + "widget": "Dropdown" + }, + "isNullable": false, + "options": [ + { + "descriptor": { + "desc": "", + "label": "Alert Manager" + }, + "value": 5 + }, + { + "descriptor": { + "desc": "", + "label": "Booking Manager" + }, + "value": 8 + }, + { + "descriptor": { + "desc": "", + "label": "Config" + }, + "value": 101 + }, + { + "descriptor": { + "desc": "", + "label": "Configuration Manager" + }, + "value": 7 + }, + { + "descriptor": { + "desc": "", + "label": "Device" + }, + "value": 1 + }, + { + "descriptor": { + "desc": "", + "label": "Device Manager" + }, + "value": 6 + }, + { + "descriptor": { + "desc": "", + "label": "Domain" + }, + "value": 13 + }, + { + "descriptor": { + "desc": "", + "label": "External Manager" + }, + "value": 16 + }, + { + "descriptor": { + "desc": "", + "label": "GPIO Manager" + }, + "value": 19 + }, + { + "descriptor": { + "desc": "", + "label": "Generic" + }, + "value": 0 + }, + { + "descriptor": { + "desc": "", + "label": "Licensing" + }, + "value": 10 + }, + { + "descriptor": { + "desc": "", + "label": "Matrix Manager" + }, + "value": 18 + }, + { + "descriptor": { + "desc": "", + "label": "Metrics" + }, + "value": 102 + }, + { + "descriptor": { + "desc": "", + "label": "NDCP Manager" + }, + "value": 17 + }, + { + "descriptor": { + "desc": "", + "label": "NSO Manager" + }, + "value": 22 + }, + { + "descriptor": { + "desc": "", + "label": "Reachability" + }, + "value": 14 + }, + { + "descriptor": { + "desc": "", + "label": "Redundancy Controller" + }, + "value": 12 + }, + { + "descriptor": { + "desc": "", + "label": "Resource" + }, + "value": 99 + }, + { + "descriptor": { + "desc": "", + "label": "REST" + }, + "value": 11 + }, + { + "descriptor": { + "desc": "", + "label": "Service" + }, + "value": 100 + }, + { + "descriptor": { + "desc": "", + "label": "System" + }, + "value": 2 + }, + { + "descriptor": { + "desc": "", + "label": "System Topology" + }, + "value": 9 + }, + { + "descriptor": { + "desc": "", + "label": "Tally Manager" + }, + "value": 20 + }, + { + "descriptor": { + "desc": "", + "label": "Topology Manager" + }, + "value": 15 + }, + { + "descriptor": { + "desc": "", + "label": "Unknown" + }, + "value": 999 + }, + { + "descriptor": { + "desc": "", + "label": "User" + }, + "value": 4 + }, + { + "descriptor": { + "desc": "", + "label": "Virtual Router Manager" + }, + "value": 21 + } + ], + "ranges": [], + "status": "Current", + "type": "number", + "units": "" + } + }, + "gpid.pointId": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "A hierarchical ID with dot separated levels", + "label": "Point ID" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "unknown" + } + }, + "imgUrl": { + "_schema": { + "default": "", + "descriptor": { + "desc": "", + "label": "Image URL" + }, + "encoding": "UTF-8", + "isNullable": false, + "lengthRanges": [], + "options": [], + "status": "Current", + "type": "string" + } + }, + "isIgmpSource": { + "_schema": { + "default": true, + "descriptor": { + "desc": "Vertex can function as last hop in IGMP config.", + "label": "Igmp Source" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "bool" + } + }, + "isVirtual": { + "_schema": { + "default": true, + "descriptor": { + "desc": "True, if vertex does not correspond to physical resource/driver.", + "label": "Virtual" + }, + "gui": { + "tags": [], + "widget": "CheckBox" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "bool" + } + }, + "mainDstIp": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "Main Destination Ip Address" + }, + "isNullable": true, + "options": [], + "status": "Current", + "type": "unknown" + } + }, + "mainDstMac": { + "_schema": { + "default": "", + "descriptor": { + "desc": "", + "label": "Main Destination Mac Address" + }, + "encoding": "UTF-8", + "isNullable": true, + "lengthRanges": [], + "options": [], + "status": "Current", + "type": "string" + } + }, + "mainDstPort": { + "_schema": { + "default": 0, + "descriptor": { + "desc": "", + "label": "Main Destination TCP/UDP Port" + }, + "isNullable": true, + "options": [], + "ranges": [ + [ + 1, + 65535, + 1 + ] + ], + "status": "Current", + "type": "number", + "units": "" + } + }, + "mainDstVlan": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "Main Destination Vlan" + }, + "isNullable": true, + "options": [], + "status": "Current", + "type": "unknown" + } + }, + "mainSrcGateway": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "Main Source Ip Gateway" + }, + "isNullable": true, + "options": [], + "status": "Current", + "type": "unknown" + } + }, + "mainSrcIp": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "Main Source Ip Address" + }, + "isNullable": true, + "options": [], + "status": "Current", + "type": "unknown" + } + }, + "mainSrcMac": { + "_schema": { + "default": "", + "descriptor": { + "desc": "", + "label": "Main Source Mac Address" + }, + "encoding": "UTF-8", + "isNullable": true, + "lengthRanges": [], + "options": [], + "status": "Current", + "type": "string" + } + }, + "mainSrcNetmask": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "Main Source Ip Netmask" + }, + "isNullable": true, + "options": [], + "status": "Current", + "type": "unknown" + } + }, + "maps": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "Collection of object coordinates for various maps/contexts.", + "label": "Maps" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "unknown" + } + }, + "multiplicity": { + "_schema": { + "default": 1, + "descriptor": { + "desc": "How many physical ip streams it can produce", + "label": "Multiplicity" + }, + "isNullable": false, + "options": [], + "ranges": [], + "status": "Current", + "type": "number", + "units": "" + } + }, + "partnerConfig": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "Vertex Partner Config" + }, + "isNullable": true, + "options": [], + "status": "Current", + "type": "unknown" + } + }, + "public": { + "_schema": { + "default": true, + "descriptor": { + "desc": "Share via distributed systems", + "label": "Public" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "bool" + } + }, + "sdpSupport": { + "_schema": { + "default": true, + "descriptor": { + "desc": "The vertex publishes an SDP", + "label": "SDP support" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "bool" + } + }, + "serviceId": { + "_schema": { + "default": 0, + "descriptor": { + "desc": "", + "label": "Service Id" + }, + "isNullable": true, + "options": [], + "ranges": [], + "status": "Current", + "type": "number", + "units": "" + } + }, + "sipsMode": { + "_schema": { + "default": "", + "descriptor": { + "desc": "", + "label": "SIPS mode type" + }, + "encoding": "UTF-8", + "gui": { + "tags": [], + "widget": "Dropdown" + }, + "isNullable": false, + "lengthRanges": [], + "options": [ + { + "descriptor": { + "desc": "", + "label": "NONE" + }, + "value": "NONE" + }, + { + "descriptor": { + "desc": "", + "label": "SIPS Auto" + }, + "value": "SIPSAuto" + }, + { + "descriptor": { + "desc": "", + "label": "SIPS Duplicate" + }, + "value": "SIPSDuplicate" + }, + { + "descriptor": { + "desc": "", + "label": "SIPS Merge" + }, + "value": "SIPSMerge" + }, + { + "descriptor": { + "desc": "", + "label": "SIPS Split" + }, + "value": "SIPSSplit" + } + ], + "status": "Current", + "type": "string" + } + }, + "spareDstIp": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "Spare Destination Ip Address" + }, + "isNullable": true, + "options": [], + "status": "Current", + "type": "unknown" + } + }, + "spareDstMac": { + "_schema": { + "default": "", + "descriptor": { + "desc": "", + "label": "Spare Destination Mac Address" + }, + "encoding": "UTF-8", + "isNullable": true, + "lengthRanges": [], + "options": [], + "status": "Current", + "type": "string" + } + }, + "spareDstPort": { + "_schema": { + "default": 0, + "descriptor": { + "desc": "", + "label": "Spare Destination TCP/UDP Port" + }, + "isNullable": true, + "options": [], + "ranges": [ + [ + 1, + 65535, + 1 + ] + ], + "status": "Current", + "type": "number", + "units": "" + } + }, + "spareDstVlan": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "Spare Destination Vlan" + }, + "isNullable": true, + "options": [], + "status": "Current", + "type": "unknown" + } + }, + "spareSrcGateway": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "Spare Source Ip Gateway" + }, + "isNullable": true, + "options": [], + "status": "Current", + "type": "unknown" + } + }, + "spareSrcIp": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "Spare Source Ip Address" + }, + "isNullable": true, + "options": [], + "status": "Current", + "type": "unknown" + } + }, + "spareSrcMac": { + "_schema": { + "default": "", + "descriptor": { + "desc": "", + "label": "Spare Source Mac Address" + }, + "encoding": "UTF-8", + "isNullable": true, + "lengthRanges": [], + "options": [], + "status": "Current", + "type": "string" + } + }, + "spareSrcNetmask": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "Spare Source Ip Netmask" + }, + "isNullable": true, + "options": [], + "status": "Current", + "type": "unknown" + } + }, + "tags": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "User defined tags", + "label": "Tags" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "unknown" + } + }, + "useAsEndpoint": { + "_schema": { + "default": true, + "descriptor": { + "desc": "", + "label": "Use as Endpoint" + }, + "gui": { + "tags": [], + "widget": "CheckBox" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "bool" + } + }, + "vertexType": { + "_schema": { + "default": "", + "descriptor": { + "desc": "", + "label": "Vertex type" + }, + "encoding": "UTF-8", + "gui": { + "tags": [], + "widget": "Dropdown" + }, + "isNullable": false, + "lengthRanges": [], + "options": [ + { + "descriptor": { + "desc": "", + "label": "BiDirectional" + }, + "value": "BiDirectional" + }, + { + "descriptor": { + "desc": "", + "label": "Input" + }, + "value": "In" + }, + { + "descriptor": { + "desc": "", + "label": "Internal" + }, + "value": "Internal" + }, + { + "descriptor": { + "desc": "", + "label": "Output" + }, + "value": "Out" + }, + { + "descriptor": { + "desc": "", + "label": "Undecided" + }, + "value": "Undecided" + } + ], + "status": "Current", + "type": "string" + } + } + } + }, + "_vid": "codecVertex" + }, + { + "_id": "genericVertex", + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "GenericVertex" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map", + "values": { + "active": { + "_schema": { + "default": true, + "descriptor": { + "desc": "True, if the vertex can be used in service routing.", + "label": "Active" + }, + "gui": { + "tags": [], + "widget": "CheckBox" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "bool" + } + }, + "configPriority": { + "_schema": { + "default": "", + "descriptor": { + "desc": "Priority level of the endpoint during configuration", + "label": "Config priority level" + }, + "encoding": "UTF-8", + "gui": { + "tags": [], + "widget": "Dropdown" + }, + "isNullable": false, + "lengthRanges": [], + "options": [ + { + "descriptor": { + "desc": "", + "label": "High" + }, + "value": "high" + }, + { + "descriptor": { + "desc": "", + "label": "Low" + }, + "value": "low" + }, + { + "descriptor": { + "desc": "", + "label": "Off" + }, + "value": "off" + } + ], + "status": "Current", + "type": "string" + } + }, + "control": { + "_schema": { + "default": "", + "descriptor": { + "desc": "Determines how the vertex is configured, e.g. if it should be auto-reapplied (full).", + "label": "Control level" + }, + "encoding": "UTF-8", + "gui": { + "tags": [], + "widget": "Dropdown" + }, + "isNullable": false, + "lengthRanges": [], + "options": [ + { + "descriptor": { + "desc": "", + "label": "Full" + }, + "value": "full" + }, + { + "descriptor": { + "desc": "", + "label": "Off" + }, + "value": "off" + }, + { + "descriptor": { + "desc": "", + "label": "Initial" + }, + "value": "semi" + } + ], + "status": "Current", + "type": "string" + } + }, + "custom": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "A map structure containing String-DtValue pairs", + "label": "DtMap" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "unknown" + } + }, + "descriptor.desc": { + "_schema": { + "default": "", + "descriptor": { + "desc": "A more elaborate description of the entity", + "label": "Description" + }, + "encoding": "UTF-8", + "isNullable": false, + "lengthRanges": [], + "options": [], + "status": "Current", + "type": "string" + } + }, + "descriptor.label": { + "_schema": { + "default": "", + "descriptor": { + "desc": "A label for the described entity", + "label": "Label" + }, + "encoding": "UTF-8", + "isNullable": false, + "lengthRanges": [], + "options": [], + "status": "Current", + "type": "string" + } + }, + "deviceId": { + "_schema": { + "default": "", + "descriptor": { + "desc": "", + "label": "Device Id" + }, + "encoding": "UTF-8", + "isNullable": false, + "lengthRanges": [], + "options": [], + "status": "Current", + "type": "string" + } + }, + "extraAlertFilters": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "Extra Alert Filters" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "unknown" + } + }, + "fDescriptor.desc": { + "_schema": { + "default": "", + "descriptor": { + "desc": "A more elaborate description of the entity", + "label": "Description" + }, + "encoding": "UTF-8", + "isNullable": false, + "lengthRanges": [], + "options": [], + "status": "Current", + "type": "string" + } + }, + "fDescriptor.label": { + "_schema": { + "default": "", + "descriptor": { + "desc": "A label for the described entity", + "label": "Label" + }, + "encoding": "UTF-8", + "isNullable": false, + "lengthRanges": [], + "options": [], + "status": "Current", + "type": "string" + } + }, + "gpid.component": { + "_schema": { + "default": 0, + "descriptor": { + "desc": "", + "label": "Source component" + }, + "gui": { + "tags": [], + "widget": "Dropdown" + }, + "isNullable": false, + "options": [ + { + "descriptor": { + "desc": "", + "label": "Alert Manager" + }, + "value": 5 + }, + { + "descriptor": { + "desc": "", + "label": "Booking Manager" + }, + "value": 8 + }, + { + "descriptor": { + "desc": "", + "label": "Config" + }, + "value": 101 + }, + { + "descriptor": { + "desc": "", + "label": "Configuration Manager" + }, + "value": 7 + }, + { + "descriptor": { + "desc": "", + "label": "Device" + }, + "value": 1 + }, + { + "descriptor": { + "desc": "", + "label": "Device Manager" + }, + "value": 6 + }, + { + "descriptor": { + "desc": "", + "label": "Domain" + }, + "value": 13 + }, + { + "descriptor": { + "desc": "", + "label": "External Manager" + }, + "value": 16 + }, + { + "descriptor": { + "desc": "", + "label": "GPIO Manager" + }, + "value": 19 + }, + { + "descriptor": { + "desc": "", + "label": "Generic" + }, + "value": 0 + }, + { + "descriptor": { + "desc": "", + "label": "Licensing" + }, + "value": 10 + }, + { + "descriptor": { + "desc": "", + "label": "Matrix Manager" + }, + "value": 18 + }, + { + "descriptor": { + "desc": "", + "label": "Metrics" + }, + "value": 102 + }, + { + "descriptor": { + "desc": "", + "label": "NDCP Manager" + }, + "value": 17 + }, + { + "descriptor": { + "desc": "", + "label": "NSO Manager" + }, + "value": 22 + }, + { + "descriptor": { + "desc": "", + "label": "Reachability" + }, + "value": 14 + }, + { + "descriptor": { + "desc": "", + "label": "Redundancy Controller" + }, + "value": 12 + }, + { + "descriptor": { + "desc": "", + "label": "Resource" + }, + "value": 99 + }, + { + "descriptor": { + "desc": "", + "label": "REST" + }, + "value": 11 + }, + { + "descriptor": { + "desc": "", + "label": "Service" + }, + "value": 100 + }, + { + "descriptor": { + "desc": "", + "label": "System" + }, + "value": 2 + }, + { + "descriptor": { + "desc": "", + "label": "System Topology" + }, + "value": 9 + }, + { + "descriptor": { + "desc": "", + "label": "Tally Manager" + }, + "value": 20 + }, + { + "descriptor": { + "desc": "", + "label": "Topology Manager" + }, + "value": 15 + }, + { + "descriptor": { + "desc": "", + "label": "Unknown" + }, + "value": 999 + }, + { + "descriptor": { + "desc": "", + "label": "User" + }, + "value": 4 + }, + { + "descriptor": { + "desc": "", + "label": "Virtual Router Manager" + }, + "value": 21 + } + ], + "ranges": [], + "status": "Current", + "type": "number", + "units": "" + } + }, + "gpid.pointId": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "A hierarchical ID with dot separated levels", + "label": "Point ID" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "unknown" + } + }, + "imgUrl": { + "_schema": { + "default": "", + "descriptor": { + "desc": "", + "label": "Image URL" + }, + "encoding": "UTF-8", + "isNullable": false, + "lengthRanges": [], + "options": [], + "status": "Current", + "type": "string" + } + }, + "isVirtual": { + "_schema": { + "default": true, + "descriptor": { + "desc": "True, if vertex does not correspond to physical resource/driver.", + "label": "Virtual" + }, + "gui": { + "tags": [], + "widget": "CheckBox" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "bool" + } + }, + "maps": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "Collection of object coordinates for various maps/contexts.", + "label": "Maps" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "unknown" + } + }, + "sipsMode": { + "_schema": { + "default": "", + "descriptor": { + "desc": "", + "label": "SIPS mode type" + }, + "encoding": "UTF-8", + "gui": { + "tags": [], + "widget": "Dropdown" + }, + "isNullable": false, + "lengthRanges": [], + "options": [ + { + "descriptor": { + "desc": "", + "label": "NONE" + }, + "value": "NONE" + }, + { + "descriptor": { + "desc": "", + "label": "SIPS Auto" + }, + "value": "SIPSAuto" + }, + { + "descriptor": { + "desc": "", + "label": "SIPS Duplicate" + }, + "value": "SIPSDuplicate" + }, + { + "descriptor": { + "desc": "", + "label": "SIPS Merge" + }, + "value": "SIPSMerge" + }, + { + "descriptor": { + "desc": "", + "label": "SIPS Split" + }, + "value": "SIPSSplit" + } + ], + "status": "Current", + "type": "string" + } + }, + "tags": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "User defined tags", + "label": "Tags" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "unknown" + } + }, + "useAsEndpoint": { + "_schema": { + "default": true, + "descriptor": { + "desc": "", + "label": "Use as Endpoint" + }, + "gui": { + "tags": [], + "widget": "CheckBox" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "bool" + } + }, + "vertexType": { + "_schema": { + "default": "", + "descriptor": { + "desc": "", + "label": "Vertex type" + }, + "encoding": "UTF-8", + "gui": { + "tags": [], + "widget": "Dropdown" + }, + "isNullable": false, + "lengthRanges": [], + "options": [ + { + "descriptor": { + "desc": "", + "label": "BiDirectional" + }, + "value": "BiDirectional" + }, + { + "descriptor": { + "desc": "", + "label": "Input" + }, + "value": "In" + }, + { + "descriptor": { + "desc": "", + "label": "Internal" + }, + "value": "Internal" + }, + { + "descriptor": { + "desc": "", + "label": "Output" + }, + "value": "Out" + }, + { + "descriptor": { + "desc": "", + "label": "Undecided" + }, + "value": "Undecided" + } + ], + "status": "Current", + "type": "string" + } + } + } + }, + "_vid": "genericVertex" + }, + { + "_id": "ipVertex", + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "IpVertex" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map", + "values": { + "active": { + "_schema": { + "default": true, + "descriptor": { + "desc": "True, if the vertex can be used in service routing.", + "label": "Active" + }, + "gui": { + "tags": [], + "widget": "CheckBox" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "bool" + } + }, + "configPriority": { + "_schema": { + "default": "", + "descriptor": { + "desc": "Priority level of the endpoint during configuration", + "label": "Config priority level" + }, + "encoding": "UTF-8", + "gui": { + "tags": [], + "widget": "Dropdown" + }, + "isNullable": false, + "lengthRanges": [], + "options": [ + { + "descriptor": { + "desc": "", + "label": "High" + }, + "value": "high" + }, + { + "descriptor": { + "desc": "", + "label": "Low" + }, + "value": "low" + }, + { + "descriptor": { + "desc": "", + "label": "Off" + }, + "value": "off" + } + ], + "status": "Current", + "type": "string" + } + }, + "control": { + "_schema": { + "default": "", + "descriptor": { + "desc": "Determines how the vertex is configured, e.g. if it should be auto-reapplied (full).", + "label": "Control level" + }, + "encoding": "UTF-8", + "gui": { + "tags": [], + "widget": "Dropdown" + }, + "isNullable": false, + "lengthRanges": [], + "options": [ + { + "descriptor": { + "desc": "", + "label": "Full" + }, + "value": "full" + }, + { + "descriptor": { + "desc": "", + "label": "Off" + }, + "value": "off" + }, + { + "descriptor": { + "desc": "", + "label": "Initial" + }, + "value": "semi" + } + ], + "status": "Current", + "type": "string" + } + }, + "custom": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "A map structure containing String-DtValue pairs", + "label": "DtMap" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "unknown" + } + }, + "descriptor.desc": { + "_schema": { + "default": "", + "descriptor": { + "desc": "A more elaborate description of the entity", + "label": "Description" + }, + "encoding": "UTF-8", + "isNullable": false, + "lengthRanges": [], + "options": [], + "status": "Current", + "type": "string" + } + }, + "descriptor.label": { + "_schema": { + "default": "", + "descriptor": { + "desc": "A label for the described entity", + "label": "Label" + }, + "encoding": "UTF-8", + "isNullable": false, + "lengthRanges": [], + "options": [], + "status": "Current", + "type": "string" + } + }, + "deviceId": { + "_schema": { + "default": "", + "descriptor": { + "desc": "", + "label": "Device Id" + }, + "encoding": "UTF-8", + "isNullable": false, + "lengthRanges": [], + "options": [], + "status": "Current", + "type": "string" + } + }, + "extraAlertFilters": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "Extra Alert Filters" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "unknown" + } + }, + "fDescriptor.desc": { + "_schema": { + "default": "", + "descriptor": { + "desc": "A more elaborate description of the entity", + "label": "Description" + }, + "encoding": "UTF-8", + "isNullable": false, + "lengthRanges": [], + "options": [], + "status": "Current", + "type": "string" + } + }, + "fDescriptor.label": { + "_schema": { + "default": "", + "descriptor": { + "desc": "A label for the described entity", + "label": "Label" + }, + "encoding": "UTF-8", + "isNullable": false, + "lengthRanges": [], + "options": [], + "status": "Current", + "type": "string" + } + }, + "gpid.component": { + "_schema": { + "default": 0, + "descriptor": { + "desc": "", + "label": "Source component" + }, + "gui": { + "tags": [], + "widget": "Dropdown" + }, + "isNullable": false, + "options": [ + { + "descriptor": { + "desc": "", + "label": "Alert Manager" + }, + "value": 5 + }, + { + "descriptor": { + "desc": "", + "label": "Booking Manager" + }, + "value": 8 + }, + { + "descriptor": { + "desc": "", + "label": "Config" + }, + "value": 101 + }, + { + "descriptor": { + "desc": "", + "label": "Configuration Manager" + }, + "value": 7 + }, + { + "descriptor": { + "desc": "", + "label": "Device" + }, + "value": 1 + }, + { + "descriptor": { + "desc": "", + "label": "Device Manager" + }, + "value": 6 + }, + { + "descriptor": { + "desc": "", + "label": "Domain" + }, + "value": 13 + }, + { + "descriptor": { + "desc": "", + "label": "External Manager" + }, + "value": 16 + }, + { + "descriptor": { + "desc": "", + "label": "GPIO Manager" + }, + "value": 19 + }, + { + "descriptor": { + "desc": "", + "label": "Generic" + }, + "value": 0 + }, + { + "descriptor": { + "desc": "", + "label": "Licensing" + }, + "value": 10 + }, + { + "descriptor": { + "desc": "", + "label": "Matrix Manager" + }, + "value": 18 + }, + { + "descriptor": { + "desc": "", + "label": "Metrics" + }, + "value": 102 + }, + { + "descriptor": { + "desc": "", + "label": "NDCP Manager" + }, + "value": 17 + }, + { + "descriptor": { + "desc": "", + "label": "NSO Manager" + }, + "value": 22 + }, + { + "descriptor": { + "desc": "", + "label": "Reachability" + }, + "value": 14 + }, + { + "descriptor": { + "desc": "", + "label": "Redundancy Controller" + }, + "value": 12 + }, + { + "descriptor": { + "desc": "", + "label": "Resource" + }, + "value": 99 + }, + { + "descriptor": { + "desc": "", + "label": "REST" + }, + "value": 11 + }, + { + "descriptor": { + "desc": "", + "label": "Service" + }, + "value": 100 + }, + { + "descriptor": { + "desc": "", + "label": "System" + }, + "value": 2 + }, + { + "descriptor": { + "desc": "", + "label": "System Topology" + }, + "value": 9 + }, + { + "descriptor": { + "desc": "", + "label": "Tally Manager" + }, + "value": 20 + }, + { + "descriptor": { + "desc": "", + "label": "Topology Manager" + }, + "value": 15 + }, + { + "descriptor": { + "desc": "", + "label": "Unknown" + }, + "value": 999 + }, + { + "descriptor": { + "desc": "", + "label": "User" + }, + "value": 4 + }, + { + "descriptor": { + "desc": "", + "label": "Virtual Router Manager" + }, + "value": 21 + } + ], + "ranges": [], + "status": "Current", + "type": "number", + "units": "" + } + }, + "gpid.pointId": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "A hierarchical ID with dot separated levels", + "label": "Point ID" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "unknown" + } + }, + "imgUrl": { + "_schema": { + "default": "", + "descriptor": { + "desc": "", + "label": "Image URL" + }, + "encoding": "UTF-8", + "isNullable": false, + "lengthRanges": [], + "options": [], + "status": "Current", + "type": "string" + } + }, + "ipAddress": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "IP Address" + }, + "isNullable": true, + "options": [], + "status": "Current", + "type": "unknown" + } + }, + "ipNetmask": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "IP Netmask" + }, + "isNullable": true, + "options": [], + "status": "Current", + "type": "unknown" + } + }, + "isVirtual": { + "_schema": { + "default": true, + "descriptor": { + "desc": "True, if vertex does not correspond to physical resource/driver.", + "label": "Virtual" + }, + "gui": { + "tags": [], + "widget": "CheckBox" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "bool" + } + }, + "maps": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "Collection of object coordinates for various maps/contexts.", + "label": "Maps" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "unknown" + } + }, + "public": { + "_schema": { + "default": true, + "descriptor": { + "desc": "Share via distributed systems", + "label": "Public" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "bool" + } + }, + "sipsMode": { + "_schema": { + "default": "", + "descriptor": { + "desc": "", + "label": "SIPS mode type" + }, + "encoding": "UTF-8", + "gui": { + "tags": [], + "widget": "Dropdown" + }, + "isNullable": false, + "lengthRanges": [], + "options": [ + { + "descriptor": { + "desc": "", + "label": "NONE" + }, + "value": "NONE" + }, + { + "descriptor": { + "desc": "", + "label": "SIPS Auto" + }, + "value": "SIPSAuto" + }, + { + "descriptor": { + "desc": "", + "label": "SIPS Duplicate" + }, + "value": "SIPSDuplicate" + }, + { + "descriptor": { + "desc": "", + "label": "SIPS Merge" + }, + "value": "SIPSMerge" + }, + { + "descriptor": { + "desc": "", + "label": "SIPS Split" + }, + "value": "SIPSSplit" + } + ], + "status": "Current", + "type": "string" + } + }, + "supportsCpipeCfg": { + "_schema": { + "default": true, + "descriptor": { + "desc": "", + "label": "Supports C-Pipe Config" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "bool" + } + }, + "supportsIgmpCfg": { + "_schema": { + "default": true, + "descriptor": { + "desc": "", + "label": "Supports Igmp Config" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "bool" + } + }, + "supportsMacForwardingCfg": { + "_schema": { + "default": true, + "descriptor": { + "desc": "", + "label": "Supports Mac Forwarding Config" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "bool" + } + }, + "supportsNsoCfg": { + "_schema": { + "default": true, + "descriptor": { + "desc": "", + "label": "Supports Nso Config" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "bool" + } + }, + "supportsOpenflowCfg": { + "_schema": { + "default": true, + "descriptor": { + "desc": "", + "label": "Supports Openflow Config" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "bool" + } + }, + "supportsStaticIgmpCfg": { + "_schema": { + "default": true, + "descriptor": { + "desc": "", + "label": "Supports Static Igmp Config" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "bool" + } + }, + "supportsVlanCfg": { + "_schema": { + "default": true, + "descriptor": { + "desc": "", + "label": "Supports Vlan Config" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "bool" + } + }, + "supportsVplsCfg": { + "_schema": { + "default": true, + "descriptor": { + "desc": "", + "label": "Supports VPLS Config" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "bool" + } + }, + "supportsVxlanCfg": { + "_schema": { + "default": true, + "descriptor": { + "desc": "", + "label": "Supports Vxlan Config" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "bool" + } + }, + "tags": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "User defined tags", + "label": "Tags" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "unknown" + } + }, + "useAsEndpoint": { + "_schema": { + "default": true, + "descriptor": { + "desc": "", + "label": "Use as Endpoint" + }, + "gui": { + "tags": [], + "widget": "CheckBox" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "bool" + } + }, + "vertexType": { + "_schema": { + "default": "", + "descriptor": { + "desc": "", + "label": "Vertex type" + }, + "encoding": "UTF-8", + "gui": { + "tags": [], + "widget": "Dropdown" + }, + "isNullable": false, + "lengthRanges": [], + "options": [ + { + "descriptor": { + "desc": "", + "label": "BiDirectional" + }, + "value": "BiDirectional" + }, + { + "descriptor": { + "desc": "", + "label": "Input" + }, + "value": "In" + }, + { + "descriptor": { + "desc": "", + "label": "Internal" + }, + "value": "Internal" + }, + { + "descriptor": { + "desc": "", + "label": "Output" + }, + "value": "Out" + }, + { + "descriptor": { + "desc": "", + "label": "Undecided" + }, + "value": "Undecided" + } + ], + "status": "Current", + "type": "string" + } + }, + "vlanId": { + "_schema": { + "default": "", + "descriptor": { + "desc": "", + "label": "Vlan Id" + }, + "encoding": "UTF-8", + "isNullable": true, + "lengthRanges": [], + "options": [], + "status": "Current", + "type": "string" + } + }, + "vrfId": { + "_schema": { + "default": "", + "descriptor": { + "desc": "", + "label": "VRF Id" + }, + "encoding": "UTF-8", + "isNullable": true, + "lengthRanges": [], + "options": [], + "status": "Current", + "type": "string" + } + } + } + }, + "_vid": "ipVertex" + }, + { + "_id": "nGraphResourceTransform", + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "ResourceTransform" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map", + "values": { + "active": { + "_schema": { + "default": true, + "descriptor": { + "desc": "True, if the edge is active and used in service routing.", + "label": "Active" + }, + "gui": { + "tags": [], + "widget": "CheckBox" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "bool" + } + }, + "descriptor.desc": { + "_schema": { + "default": "", + "descriptor": { + "desc": "A more elaborate description of the entity", + "label": "Description" + }, + "encoding": "UTF-8", + "isNullable": false, + "lengthRanges": [], + "options": [], + "status": "Current", + "type": "string" + } + }, + "descriptor.label": { + "_schema": { + "default": "", + "descriptor": { + "desc": "A label for the described entity", + "label": "Label" + }, + "encoding": "UTF-8", + "isNullable": false, + "lengthRanges": [], + "options": [], + "status": "Current", + "type": "string" + } + }, + "fDescriptor.desc": { + "_schema": { + "default": "", + "descriptor": { + "desc": "A more elaborate description of the entity", + "label": "Description" + }, + "encoding": "UTF-8", + "isNullable": false, + "lengthRanges": [], + "options": [], + "status": "Current", + "type": "string" + } + }, + "fDescriptor.label": { + "_schema": { + "default": "", + "descriptor": { + "desc": "A label for the described entity", + "label": "Label" + }, + "encoding": "UTF-8", + "isNullable": false, + "lengthRanges": [], + "options": [], + "status": "Current", + "type": "string" + } + }, + "fResourceIds": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "Factory resource ids", + "label": "fResourceIds" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "unknown" + } + }, + "fromId": { + "_schema": { + "default": "", + "descriptor": { + "desc": "ID of the from-vertex", + "label": "fromId" + }, + "encoding": "UTF-8", + "isNullable": false, + "lengthRanges": [], + "options": [], + "status": "Current", + "type": "string" + } + }, + "resourceIds": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "User resource ids", + "label": "resourceIds" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "unknown" + } + }, + "tags": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "Tags" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "unknown" + } + }, + "toId": { + "_schema": { + "default": "", + "descriptor": { + "desc": "ID of the to-vertex", + "label": "toId" + }, + "encoding": "UTF-8", + "isNullable": false, + "lengthRanges": [], + "options": [], + "status": "Current", + "type": "string" + } + } + } + }, + "_vid": "nGraphResourceTransform" + }, + { + "_id": "routerVertex", + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "RouterVertex" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map", + "values": { + "active": { + "_schema": { + "default": true, + "descriptor": { + "desc": "True, if the vertex can be used in service routing.", + "label": "Active" + }, + "gui": { + "tags": [], + "widget": "CheckBox" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "bool" + } + }, + "configPriority": { + "_schema": { + "default": "", + "descriptor": { + "desc": "Priority level of the endpoint during configuration", + "label": "Config priority level" + }, + "encoding": "UTF-8", + "gui": { + "tags": [], + "widget": "Dropdown" + }, + "isNullable": false, + "lengthRanges": [], + "options": [ + { + "descriptor": { + "desc": "", + "label": "High" + }, + "value": "high" + }, + { + "descriptor": { + "desc": "", + "label": "Low" + }, + "value": "low" + }, + { + "descriptor": { + "desc": "", + "label": "Off" + }, + "value": "off" + } + ], + "status": "Current", + "type": "string" + } + }, + "control": { + "_schema": { + "default": "", + "descriptor": { + "desc": "Determines how the vertex is configured, e.g. if it should be auto-reapplied (full).", + "label": "Control level" + }, + "encoding": "UTF-8", + "gui": { + "tags": [], + "widget": "Dropdown" + }, + "isNullable": false, + "lengthRanges": [], + "options": [ + { + "descriptor": { + "desc": "", + "label": "Full" + }, + "value": "full" + }, + { + "descriptor": { + "desc": "", + "label": "Off" + }, + "value": "off" + }, + { + "descriptor": { + "desc": "", + "label": "Initial" + }, + "value": "semi" + } + ], + "status": "Current", + "type": "string" + } + }, + "custom": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "A map structure containing String-DtValue pairs", + "label": "DtMap" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "unknown" + } + }, + "descriptor.desc": { + "_schema": { + "default": "", + "descriptor": { + "desc": "A more elaborate description of the entity", + "label": "Description" + }, + "encoding": "UTF-8", + "isNullable": false, + "lengthRanges": [], + "options": [], + "status": "Current", + "type": "string" + } + }, + "descriptor.label": { + "_schema": { + "default": "", + "descriptor": { + "desc": "A label for the described entity", + "label": "Label" + }, + "encoding": "UTF-8", + "isNullable": false, + "lengthRanges": [], + "options": [], + "status": "Current", + "type": "string" + } + }, + "deviceId": { + "_schema": { + "default": "", + "descriptor": { + "desc": "", + "label": "Device Id" + }, + "encoding": "UTF-8", + "isNullable": false, + "lengthRanges": [], + "options": [], + "status": "Current", + "type": "string" + } + }, + "extraAlertFilters": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "Extra Alert Filters" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "unknown" + } + }, + "fDescriptor.desc": { + "_schema": { + "default": "", + "descriptor": { + "desc": "A more elaborate description of the entity", + "label": "Description" + }, + "encoding": "UTF-8", + "isNullable": false, + "lengthRanges": [], + "options": [], + "status": "Current", + "type": "string" + } + }, + "fDescriptor.label": { + "_schema": { + "default": "", + "descriptor": { + "desc": "A label for the described entity", + "label": "Label" + }, + "encoding": "UTF-8", + "isNullable": false, + "lengthRanges": [], + "options": [], + "status": "Current", + "type": "string" + } + }, + "gpid.component": { + "_schema": { + "default": 0, + "descriptor": { + "desc": "", + "label": "Source component" + }, + "gui": { + "tags": [], + "widget": "Dropdown" + }, + "isNullable": false, + "options": [ + { + "descriptor": { + "desc": "", + "label": "Alert Manager" + }, + "value": 5 + }, + { + "descriptor": { + "desc": "", + "label": "Booking Manager" + }, + "value": 8 + }, + { + "descriptor": { + "desc": "", + "label": "Config" + }, + "value": 101 + }, + { + "descriptor": { + "desc": "", + "label": "Configuration Manager" + }, + "value": 7 + }, + { + "descriptor": { + "desc": "", + "label": "Device" + }, + "value": 1 + }, + { + "descriptor": { + "desc": "", + "label": "Device Manager" + }, + "value": 6 + }, + { + "descriptor": { + "desc": "", + "label": "Domain" + }, + "value": 13 + }, + { + "descriptor": { + "desc": "", + "label": "External Manager" + }, + "value": 16 + }, + { + "descriptor": { + "desc": "", + "label": "GPIO Manager" + }, + "value": 19 + }, + { + "descriptor": { + "desc": "", + "label": "Generic" + }, + "value": 0 + }, + { + "descriptor": { + "desc": "", + "label": "Licensing" + }, + "value": 10 + }, + { + "descriptor": { + "desc": "", + "label": "Matrix Manager" + }, + "value": 18 + }, + { + "descriptor": { + "desc": "", + "label": "Metrics" + }, + "value": 102 + }, + { + "descriptor": { + "desc": "", + "label": "NDCP Manager" + }, + "value": 17 + }, + { + "descriptor": { + "desc": "", + "label": "NSO Manager" + }, + "value": 22 + }, + { + "descriptor": { + "desc": "", + "label": "Reachability" + }, + "value": 14 + }, + { + "descriptor": { + "desc": "", + "label": "Redundancy Controller" + }, + "value": 12 + }, + { + "descriptor": { + "desc": "", + "label": "Resource" + }, + "value": 99 + }, + { + "descriptor": { + "desc": "", + "label": "REST" + }, + "value": 11 + }, + { + "descriptor": { + "desc": "", + "label": "Service" + }, + "value": 100 + }, + { + "descriptor": { + "desc": "", + "label": "System" + }, + "value": 2 + }, + { + "descriptor": { + "desc": "", + "label": "System Topology" + }, + "value": 9 + }, + { + "descriptor": { + "desc": "", + "label": "Tally Manager" + }, + "value": 20 + }, + { + "descriptor": { + "desc": "", + "label": "Topology Manager" + }, + "value": 15 + }, + { + "descriptor": { + "desc": "", + "label": "Unknown" + }, + "value": 999 + }, + { + "descriptor": { + "desc": "", + "label": "User" + }, + "value": 4 + }, + { + "descriptor": { + "desc": "", + "label": "Virtual Router Manager" + }, + "value": 21 + } + ], + "ranges": [], + "status": "Current", + "type": "number", + "units": "" + } + }, + "gpid.pointId": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "A hierarchical ID with dot separated levels", + "label": "Point ID" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "unknown" + } + }, + "imgUrl": { + "_schema": { + "default": "", + "descriptor": { + "desc": "", + "label": "Image URL" + }, + "encoding": "UTF-8", + "isNullable": false, + "lengthRanges": [], + "options": [], + "status": "Current", + "type": "string" + } + }, + "isVirtual": { + "_schema": { + "default": true, + "descriptor": { + "desc": "True, if vertex does not correspond to physical resource/driver.", + "label": "Virtual" + }, + "gui": { + "tags": [], + "widget": "CheckBox" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "bool" + } + }, + "maps": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "Collection of object coordinates for various maps/contexts.", + "label": "Maps" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "unknown" + } + }, + "parkPort": { + "_schema": { + "default": 0, + "descriptor": { + "desc": "", + "label": "Park Port" + }, + "isNullable": true, + "options": [], + "ranges": [], + "status": "Current", + "type": "number", + "units": "" + } + }, + "sipsMode": { + "_schema": { + "default": "", + "descriptor": { + "desc": "", + "label": "SIPS mode type" + }, + "encoding": "UTF-8", + "gui": { + "tags": [], + "widget": "Dropdown" + }, + "isNullable": false, + "lengthRanges": [], + "options": [ + { + "descriptor": { + "desc": "", + "label": "NONE" + }, + "value": "NONE" + }, + { + "descriptor": { + "desc": "", + "label": "SIPS Auto" + }, + "value": "SIPSAuto" + }, + { + "descriptor": { + "desc": "", + "label": "SIPS Duplicate" + }, + "value": "SIPSDuplicate" + }, + { + "descriptor": { + "desc": "", + "label": "SIPS Merge" + }, + "value": "SIPSMerge" + }, + { + "descriptor": { + "desc": "", + "label": "SIPS Split" + }, + "value": "SIPSSplit" + } + ], + "status": "Current", + "type": "string" + } + }, + "tags": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "User defined tags", + "label": "Tags" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "unknown" + } + }, + "useAsEndpoint": { + "_schema": { + "default": true, + "descriptor": { + "desc": "", + "label": "Use as Endpoint" + }, + "gui": { + "tags": [], + "widget": "CheckBox" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "bool" + } + }, + "vertexType": { + "_schema": { + "default": "", + "descriptor": { + "desc": "", + "label": "Vertex type" + }, + "encoding": "UTF-8", + "gui": { + "tags": [], + "widget": "Dropdown" + }, + "isNullable": false, + "lengthRanges": [], + "options": [ + { + "descriptor": { + "desc": "", + "label": "BiDirectional" + }, + "value": "BiDirectional" + }, + { + "descriptor": { + "desc": "", + "label": "Input" + }, + "value": "In" + }, + { + "descriptor": { + "desc": "", + "label": "Internal" + }, + "value": "Internal" + }, + { + "descriptor": { + "desc": "", + "label": "Output" + }, + "value": "Out" + }, + { + "descriptor": { + "desc": "", + "label": "Undecided" + }, + "value": "Undecided" + } + ], + "status": "Current", + "type": "string" + } + } + } + }, + "_vid": "routerVertex" + }, + { + "_id": "unidirectionalEdge", + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "UnidirectionalEdge" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "map", + "values": { + "active": { + "_schema": { + "default": true, + "descriptor": { + "desc": "True, if the edge is active and used in service routing.", + "label": "Active" + }, + "gui": { + "tags": [], + "widget": "CheckBox" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "bool" + } + }, + "bandwidth": { + "_schema": { + "default": -1.0, + "descriptor": { + "desc": "Max allowed bandwidth.", + "label": "Bandwidth capacity" + }, + "gui": { + "tags": [], + "widget": "TextField" + }, + "isNullable": false, + "options": [], + "ranges": [], + "status": "Current", + "type": "number", + "units": "Mbit/s" + } + }, + "capacity": { + "_schema": { + "default": 1, + "descriptor": { + "desc": "Max number of simultaneous services.", + "label": "Services capacity" + }, + "gui": { + "tags": [], + "widget": "TextField" + }, + "isNullable": false, + "options": [], + "ranges": [], + "status": "Current", + "type": "number", + "units": "" + } + }, + "conflictPri": { + "_schema": { + "default": 0, + "descriptor": { + "desc": "", + "label": "Conflict priority" + }, + "gui": { + "tags": [], + "widget": "Dropdown" + }, + "isNullable": false, + "options": [ + { + "descriptor": { + "desc": "", + "label": "High" + }, + "value": 1 + }, + { + "descriptor": { + "desc": "", + "label": "Low" + }, + "value": 3 + }, + { + "descriptor": { + "desc": "", + "label": "Normal" + }, + "value": 2 + }, + { + "descriptor": { + "desc": "", + "label": "Off" + }, + "value": 0 + } + ], + "ranges": [], + "status": "Current", + "type": "number", + "units": "" + } + }, + "descriptor.desc": { + "_schema": { + "default": "", + "descriptor": { + "desc": "A more elaborate description of the entity", + "label": "Description" + }, + "encoding": "UTF-8", + "isNullable": false, + "lengthRanges": [], + "options": [], + "status": "Current", + "type": "string" + } + }, + "descriptor.label": { + "_schema": { + "default": "", + "descriptor": { + "desc": "A label for the described entity", + "label": "Label" + }, + "encoding": "UTF-8", + "isNullable": false, + "lengthRanges": [], + "options": [], + "status": "Current", + "type": "string" + } + }, + "excludeFormats": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "Exclude Formats" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "unknown" + } + }, + "fDescriptor.desc": { + "_schema": { + "default": "", + "descriptor": { + "desc": "A more elaborate description of the entity", + "label": "Description" + }, + "encoding": "UTF-8", + "isNullable": false, + "lengthRanges": [], + "options": [], + "status": "Current", + "type": "string" + } + }, + "fDescriptor.label": { + "_schema": { + "default": "", + "descriptor": { + "desc": "A label for the described entity", + "label": "Label" + }, + "encoding": "UTF-8", + "isNullable": false, + "lengthRanges": [], + "options": [], + "status": "Current", + "type": "string" + } + }, + "fromId": { + "_schema": { + "default": "", + "descriptor": { + "desc": "ID of the from-vertex", + "label": "fromId" + }, + "encoding": "UTF-8", + "isNullable": false, + "lengthRanges": [], + "options": [], + "status": "Current", + "type": "string" + } + }, + "includeFormats": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "Include Formats" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "unknown" + } + }, + "redundancyMode": { + "_schema": { + "default": "", + "descriptor": { + "desc": "", + "label": "RedundancyMode" + }, + "encoding": "UTF-8", + "gui": { + "tags": [], + "widget": "Dropdown" + }, + "isNullable": false, + "lengthRanges": [], + "options": [ + { + "descriptor": { + "desc": "", + "label": "Any" + }, + "value": "Any" + }, + { + "descriptor": { + "desc": "", + "label": "OnlyMain" + }, + "value": "OnlyMain" + }, + { + "descriptor": { + "desc": "", + "label": "OnlySpare" + }, + "value": "OnlySpare" + } + ], + "status": "Current", + "type": "string" + } + }, + "tags": { + "_schema": { + "default": {}, + "descriptor": { + "desc": "", + "label": "Tags" + }, + "isNullable": false, + "options": [], + "status": "Current", + "type": "unknown" + } + }, + "toId": { + "_schema": { + "default": "", + "descriptor": { + "desc": "ID of the to-vertex", + "label": "toId" + }, + "encoding": "UTF-8", + "isNullable": false, + "lengthRanges": [], + "options": [], + "status": "Current", + "type": "string" + } + }, + "weight": { + "_schema": { + "default": 1, + "descriptor": { + "desc": "The edge weight/cost for routing.", + "label": "Fixed weight" + }, + "gui": { + "tags": [], + "widget": "TextField" + }, + "isNullable": false, + "options": [], + "ranges": [], + "status": "Current", + "type": "number", + "units": "" + } + }, + "weightFactors.bandwidth.weight": { + "_schema": { + "default": 1, + "descriptor": { + "desc": "Enables bandwidth-based weight calculation. The number corresponds to the weight at 100% link utilization.", + "label": "Bandwidth weight factor" + }, + "gui": { + "tags": [], + "widget": "TextField" + }, + "isNullable": false, + "options": [], + "ranges": [], + "status": "Current", + "type": "number", + "units": "" + } + }, + "weightFactors.service.max": { + "_schema": { + "default": 100, + "descriptor": { + "desc": "The maximum value that service weighting will contribute with. Useful to define an absolute.", + "label": "Max total" + }, + "gui": { + "tags": [], + "widget": "TextField" + }, + "isNullable": false, + "options": [], + "ranges": [], + "status": "Current", + "type": "number", + "units": "" + } + }, + "weightFactors.service.weight": { + "_schema": { + "default": 1, + "descriptor": { + "desc": "Enables service-based weight calculation. The given number is the weight that each service contributes with.", + "label": "Weight per service" + }, + "gui": { + "tags": [], + "widget": "TextField" + }, + "isNullable": false, + "options": [], + "ranges": [], + "status": "Current", + "type": "number", + "units": "" + } + } + } + }, + "_vid": "unidirectionalEdge" + } + ] + } + } + } + } +} \ No newline at end of file From b3c1a236be9bf582c7d19987b31b5f51ff148575 Mon Sep 17 00:00:00 2001 From: Jonas Scholl <43819845+JonasScholl@users.noreply.github.com> Date: Thu, 23 Jul 2026 21:26:11 +0200 Subject: [PATCH 27/34] skip e2e tests for apps that are not supported by the connected videoipath instance version --- .../apps/videoipath_app.py | 7 ++- tests/e2e/apps/test_inspect.py | 2 + tests/e2e/apps/test_topology.py | 5 +- tests/e2e/conftest.py | 51 ++++++++++++++----- 4 files changed, 50 insertions(+), 15 deletions(-) diff --git a/src/videoipath_automation_tool/apps/videoipath_app.py b/src/videoipath_automation_tool/apps/videoipath_app.py index 6c13121..8ddf651 100644 --- a/src/videoipath_automation_tool/apps/videoipath_app.py +++ b/src/videoipath_automation_tool/apps/videoipath_app.py @@ -7,6 +7,7 @@ from videoipath_automation_tool.apps.preferences.preferences_app import PreferencesApp from videoipath_automation_tool.apps.profile.profile_app import ProfileApp from videoipath_automation_tool.apps.security.security_app import SecurityApp +from videoipath_automation_tool.apps.topology.errors import TopologyUnsupportedError from videoipath_automation_tool.apps.topology.topology_app import TopologyApp from videoipath_automation_tool.connector.vip_connector import VideoIPathConnector from videoipath_automation_tool.settings import Settings @@ -229,7 +230,11 @@ def __init__( # --- For Development Environment, load the APIs directly and map them to the VideoIPathApp for easier access --- if environment == "DEV": self._inventory_api = self.inventory._inventory_api - self._topology_api = self.topology._topology_api + try: + self._topology_api = self.topology._topology_api + except TopologyUnsupportedError as exc: + self._logger.warning(str(exc)) + self._topology_api = None self._preferences_api = self.preferences._preferences_api self._profile_api = self.profile._profile_api self._inspect_api = self.inspect._inspect_api diff --git a/tests/e2e/apps/test_inspect.py b/tests/e2e/apps/test_inspect.py index 410cb56..ac3829a 100644 --- a/tests/e2e/apps/test_inspect.py +++ b/tests/e2e/apps/test_inspect.py @@ -1,5 +1,7 @@ """Focused Inspect app suite: read/write capabilities against a live instance. +Skipped when the live VideoIPath major year is less than 2025 (see e2e conftest). + Every test builds its **own** minimal topology (1–3 mock devices) via the ``topology_builder`` fixture — unique labels/addresses per test. Assertions are scoped to the test's own device ids so other suites on a shared instance never interfere. ``E2E-`` artifacts persist until the next e2e diff --git a/tests/e2e/apps/test_topology.py b/tests/e2e/apps/test_topology.py index 40c3d0d..bc5f8c0 100644 --- a/tests/e2e/apps/test_topology.py +++ b/tests/e2e/apps/test_topology.py @@ -1,8 +1,9 @@ -"""Focused Topology app suite (legacy path), skipped on VideoIPath 2026.x+. +"""Focused Topology app suite (legacy path), skipped when VideoIPath major > 2025. Builds a device via Inspect (``topology_builder``), then exercises the classic Topology API: ``get_device``, ``find_device_id_by_label``, and a label / position round-trip. TopologyApp is -deprecated on 2025.x and raises ``TopologyUnsupportedError`` on 2026.x — prefer Inspect elsewhere. +deprecated on 2025.x and unsupported above 2025 — prefer Inspect elsewhere. The e2e conftest +skips this module when the live server major year is greater than 2025. Run with:: diff --git a/tests/e2e/conftest.py b/tests/e2e/conftest.py index 794edc3..17b9e17 100644 --- a/tests/e2e/conftest.py +++ b/tests/e2e/conftest.py @@ -2,8 +2,11 @@ These tests are excluded by default (``-m "not e2e"`` in ``pyproject.toml``) and only run when: * you invoke an e2e entry point (``poetry run test-e2e``, ``poetry run test``, or VS Code **E2E Tests**), **and** - * connection vars are set in the project root ``.env`` (copy from ``.env.template``), **and** - * the target server is a verified version (>= 2025.4). + * connection vars are set in the project root ``.env`` (copy from ``.env.template``). + +Version gates (by VideoIPath major year): + * Topology e2e (``apps/test_topology.py``) is skipped when major > 2025. + * Inspect e2e (``apps/test_inspect.py`` and ``workflows/``) is skipped when major < 2025. E2e entry points load ``.env`` and enable the suite automatically. E2e runs never collect coverage (``--no-cov``). @@ -23,11 +26,12 @@ import os from itertools import count -from typing import Iterator +from pathlib import Path +from typing import Iterator, Optional import pytest -from videoipath_automation_tool.apps.inspect.app.app import _MIN_VERIFIED_VERSION, _parse_version +from videoipath_automation_tool.apps.inspect.app.app import _parse_version from videoipath_automation_tool.apps.videoipath_app import VideoIPathApp from vipat_cli_scripts.project_env import load_project_env @@ -37,26 +41,49 @@ _STEP_FAILED_KEY = pytest.StashKey[str]() +# TopologyApp is unsupported above this major year; InspectApp is the replacement. +_TOPOLOGY_MAX_MAJOR = 2025 +# Inspect e2e requires this major year or newer. +_INSPECT_MIN_MAJOR = 2025 + def _e2e_enabled() -> bool: return os.environ.get("VIPAT_E2E_ENABLED", "").strip() == "1" +def _server_major(app: VideoIPathApp) -> Optional[int]: + parsed = _parse_version(app._videoipath_connector.videoipath_version) + return parsed[0] if parsed is not None else None + + @pytest.fixture(scope="session") def app() -> VideoIPathApp: - """A live ``VideoIPathApp`` built from the project ``.env``; skips unless E2E is enabled + verified.""" + """A live ``VideoIPathApp`` built from the project ``.env``; skips unless E2E is enabled.""" load_project_env() if not _e2e_enabled(): pytest.skip("E2E disabled (use poetry run test-e2e, poetry run test, or the VS Code E2E launch config).") - application = VideoIPathApp() - version = application._videoipath_connector.videoipath_version - parsed = _parse_version(version) - if parsed is None or parsed < _MIN_VERIFIED_VERSION: + return VideoIPathApp() + + +@pytest.fixture(autouse=True) +def _gate_topology_and_inspect_e2e(request: pytest.FixtureRequest, app: VideoIPathApp) -> None: + """Skip Topology/Inspect suites when the live server major year is out of range.""" + path = Path(str(request.path)) + major = _server_major(app) + if major is None: + return + + is_topology = path.name == "test_topology.py" + is_inspect = path.name == "test_inspect.py" or "workflows" in path.parts + version = app._videoipath_connector.videoipath_version + + if is_topology and major > _TOPOLOGY_MAX_MAJOR: pytest.skip( - f"Server version '{version}' is below the verified Inspect baseline " - f"{_MIN_VERIFIED_VERSION[0]}.{_MIN_VERIFIED_VERSION[1]}." + f"Topology e2e skipped on VideoIPath {version} (major > {_TOPOLOGY_MAX_MAJOR}). " + "Use InspectApp (app.inspect) instead." ) - return application + if is_inspect and major < _INSPECT_MIN_MAJOR: + pytest.skip(f"Inspect e2e skipped on VideoIPath {version} (major < {_INSPECT_MIN_MAJOR}).") @pytest.fixture(scope="session", autouse=True) From 7ecc86a290092660c33e2cf1dfe1a8a3e2ac62b0 Mon Sep 17 00:00:00 2001 From: Jonas Scholl <43819845+JonasScholl@users.noreply.github.com> Date: Thu, 23 Jul 2026 21:32:09 +0200 Subject: [PATCH 28/34] inspect app beta warning --- docs/getting-started-guide/03_B_Inspect.md | 3 ++ .../apps/inspect/app/app.py | 17 ++++++++++- tests/inspect/test_beta_warning.py | 30 +++++++++++++++++++ 3 files changed, 49 insertions(+), 1 deletion(-) create mode 100644 tests/inspect/test_beta_warning.py diff --git a/docs/getting-started-guide/03_B_Inspect.md b/docs/getting-started-guide/03_B_Inspect.md index faa24b2..2065f6c 100644 --- a/docs/getting-started-guide/03_B_Inspect.md +++ b/docs/getting-started-guide/03_B_Inspect.md @@ -4,6 +4,9 @@ > topology workflows with different implementations. This page is the recommended > path on VideoIPath **2025.4+**. +**⚠️ BETA ⚠️**: The Inspect App is still in beta. The API and behaviour may change +in future releases. Accessing `app.inspect` emits a `UserWarning` and a log warning. + ### Compatibility & deprecation | Server version | Status | diff --git a/src/videoipath_automation_tool/apps/inspect/app/app.py b/src/videoipath_automation_tool/apps/inspect/app/app.py index cf4d487..8591734 100644 --- a/src/videoipath_automation_tool/apps/inspect/app/app.py +++ b/src/videoipath_automation_tool/apps/inspect/app/app.py @@ -2,11 +2,14 @@ Read-only monitoring plus commit-style topology writes, built entirely on the collector API ([ADR-0008]). Composed from focused mixins, mirroring the Inventory/Topology app layout. + +This app is currently in beta; the API and behaviour may change in future releases. """ from __future__ import annotations import logging +import warnings from typing import TYPE_CHECKING, Optional from videoipath_automation_tool.apps.inspect.api import InspectAPI @@ -19,6 +22,10 @@ from .read import InspectReadMixin, LoadMode from .write import InspectWriteMixin +_BETA_MESSAGE = ( + "InspectApp is in beta. The API and behaviour may change in future releases; use with care in production workflows." +) + class InspectApp(InspectReadMixin, InspectWriteMixin, InspectActionsMixin): def __init__( @@ -27,12 +34,15 @@ def __init__( logger: Optional[logging.Logger] = None, load: LoadMode = "skeleton", ) -> None: - """Inspect App: read the topology/status and apply commit-style topology changes. + """Inspect App (beta): read the topology/status and apply commit-style topology changes. The app keeps a single internal topology view that is loaded lazily on the first read and kept up to date across writes; interact with it entirely through this app (``app.inspect``), the same way as the other apps. Call :meth:`refresh` to reload it from the server. + .. note:: + InspectApp is in beta. Construction emits a :class:`UserWarning` and a log warning. + Args: vip_connector (VideoIPathConnector): connector handling the VideoIPath connection. logger (Optional[logging.Logger]): logger instance. @@ -44,9 +54,14 @@ def __init__( self._vip_connector = vip_connector self._load_mode: LoadMode = load self._snapshot: Optional[InspectSnapshot] = None + self._warn_beta() self._warn_if_version_unverified() self._logger.debug("Inspect APP initialized.") + def _warn_beta(self) -> None: + warnings.warn(_BETA_MESSAGE, UserWarning, stacklevel=3) + self._logger.warning(_BETA_MESSAGE) + def _warn_if_version_unverified(self) -> None: version = self._vip_connector.videoipath_version parsed = _parse_version(version) diff --git a/tests/inspect/test_beta_warning.py b/tests/inspect/test_beta_warning.py new file mode 100644 index 0000000..1a802c3 --- /dev/null +++ b/tests/inspect/test_beta_warning.py @@ -0,0 +1,30 @@ +"""InspectApp beta status warning.""" + +from __future__ import annotations + +import logging +import warnings +from types import SimpleNamespace + +import pytest + +from videoipath_automation_tool.apps.inspect.app.app import InspectApp + + +def _fake_connector(version: str = "2025.4.9") -> SimpleNamespace: + return SimpleNamespace(videoipath_version=version) + + +def test_inspect_app_emits_beta_user_warning() -> None: + with pytest.warns(UserWarning, match="InspectApp is in beta") as record: + app = InspectApp(vip_connector=_fake_connector()) # type: ignore[arg-type] + assert app is not None + assert len(record) == 1 + + +def test_inspect_app_logs_beta_warning(caplog: pytest.LogCaptureFixture) -> None: + logger = logging.getLogger("test_inspect_beta_warning") + with caplog.at_level(logging.WARNING, logger=logger.name), warnings.catch_warnings(): + warnings.simplefilter("ignore", UserWarning) + InspectApp(vip_connector=_fake_connector(), logger=logger) # type: ignore[arg-type] + assert any("InspectApp is in beta" in message for message in caplog.messages) From 218483ddfbb752caf172dac8970767f019081244 Mon Sep 17 00:00:00 2001 From: Jonas Scholl <43819845+JonasScholl@users.noreply.github.com> Date: Thu, 23 Jul 2026 21:41:27 +0200 Subject: [PATCH 29/34] use literals for fixed string value sets --- .../apps/inspect/app/write.py | 9 ++- .../apps/inspect/domain/port.py | 13 ++-- .../apps/inspect/domain/vertex.py | 15 ++-- .../apps/inspect/model/actions.py | 27 +++++--- .../apps/inspect/model/collector.py | 12 ++-- .../apps/inspect/model/common.py | 16 +++++ .../apps/inspect/model/ngraph.py | 30 +++++--- .../apps/inspect/model/virtual.py | 18 +++-- .../apps/inspect/transaction.py | 9 ++- tests/e2e/helpers.py | 69 ++++++++++++++----- tests/inspect/test_models.py | 23 +++++++ 11 files changed, 177 insertions(+), 64 deletions(-) diff --git a/src/videoipath_automation_tool/apps/inspect/app/write.py b/src/videoipath_automation_tool/apps/inspect/app/write.py index c25752e..d0a24a4 100644 --- a/src/videoipath_automation_tool/apps/inspect/app/write.py +++ b/src/videoipath_automation_tool/apps/inspect/app/write.py @@ -20,11 +20,14 @@ from videoipath_automation_tool.apps.inspect.api import InspectAPI from videoipath_automation_tool.apps.inspect.model.common import ( + InspectCodecFormat, InspectConfigPriority, + InspectControl, InspectIconSize, InspectIconType, InspectRedundancyMode, InspectSdpStrategy, + InspectSipsMode, ) from videoipath_automation_tool.apps.inspect.transaction import ( CommitResult, @@ -150,8 +153,8 @@ def update_vertex( form_tags: Optional[list[str]] = None, description: Optional[str] = None, active: Optional[bool] = None, - sips_mode: Optional[str] = None, - control: Optional[str] = None, + sips_mode: Optional[InspectSipsMode | str] = None, + control: Optional[InspectControl | str] = None, control_props: Optional[Any] = None, extra_alert_filters: Optional[list[Any]] = None, custom: Optional[dict[str, Any]] = None, @@ -174,7 +177,7 @@ def update_vertex( sdp_support: Optional[bool] = None, is_igmp_source: Optional[bool] = None, specific_type: Optional[str] = None, - codec_format: Optional[str] = None, + codec_format: Optional[InspectCodecFormat | str] = None, multiplicity: Optional[int] = None, codec_public: Optional[bool] = None, extra_formats: Optional[list[Any]] = None, diff --git a/src/videoipath_automation_tool/apps/inspect/domain/port.py b/src/videoipath_automation_tool/apps/inspect/domain/port.py index d4b4f56..5144dd5 100644 --- a/src/videoipath_automation_tool/apps/inspect/domain/port.py +++ b/src/videoipath_automation_tool/apps/inspect/domain/port.py @@ -8,7 +8,12 @@ InspectApiDoubleVertexInfo, InspectApiSingleVertexInfo, ) -from videoipath_automation_tool.apps.inspect.model.common import InspectFrozenModel +from videoipath_automation_tool.apps.inspect.model.common import ( + InspectCodecFormat, + InspectFrozenModel, + InspectVertexType, +) +from videoipath_automation_tool.apps.inspect.model.ngraph import InspectApiNGraphElementType from videoipath_automation_tool.apps.inspect.model.virtual import ( InspectApiVirtualPortFromTemplate, InspectApiVirtualTemplateItem, @@ -50,9 +55,9 @@ class InspectPortTemplate(InspectFrozenModel): id: str label: str - kind: str | None = None - direction: str | None = None - codec_format: str | None = None + kind: InspectApiNGraphElementType | str | None = None + direction: InspectVertexType | str | None = None + codec_format: InspectCodecFormat | str | None = None vertex: dict[str, Any] = Field(default_factory=dict) @classmethod diff --git a/src/videoipath_automation_tool/apps/inspect/domain/vertex.py b/src/videoipath_automation_tool/apps/inspect/domain/vertex.py index c11595b..7abb08e 100644 --- a/src/videoipath_automation_tool/apps/inspect/domain/vertex.py +++ b/src/videoipath_automation_tool/apps/inspect/domain/vertex.py @@ -25,7 +25,10 @@ from videoipath_automation_tool.apps.inspect.model.collector import InspectApiSingleVertexInfo from videoipath_automation_tool.apps.inspect.model.common import ( + InspectCodecFormat, + InspectControl, InspectEditableModel, + InspectSipsMode, InspectVertexKind, InspectVertexType, _STAGED_MISSING, @@ -147,11 +150,11 @@ def use_as_endpoint(self, value: bool) -> None: self._stage("useAsEndpoint", value) @property - def sips_mode(self) -> str | None: + def sips_mode(self) -> InspectSipsMode | str | None: return self._staged_or("sipsMode", lambda: self._form_get("sipsMode")) @sips_mode.setter - def sips_mode(self, value: str) -> None: + def sips_mode(self, value: InspectSipsMode | str) -> None: self._stage("sipsMode", value) @property @@ -163,7 +166,7 @@ def control_props(self, value: Any) -> None: self._stage("controlProps", value) @property - def control(self) -> str | None: + def control(self) -> InspectControl | str | None: """Best-effort ``control`` scalar (``"full"`` / ``"off"`` / ``"semi"``). The verified 2025.4.9 vertex edit form exposes ``controlProps`` rather than a ``control`` @@ -173,7 +176,7 @@ def control(self) -> str | None: return self._staged_or("control", lambda: self._form_get("control")) @control.setter - def control(self, value: str) -> None: + def control(self, value: InspectControl | str) -> None: warnings.warn( "InspectVertex.control stages a best-effort top-level 'control' field; the verified " "2025.4.9 edit form exposes controlProps instead. Prefer control_props when possible.", @@ -413,11 +416,11 @@ class InspectCodecVertex(InspectVertex): read from the ``typeFields.generic`` / ``typeFields.specific`` blocks (verified 2025.4.9).""" @property - def codec_format(self) -> str | None: + def codec_format(self) -> InspectCodecFormat | str | None: return self._generic_get("codecFormat", "typeFields.generic.codecFormat") @codec_format.setter - def codec_format(self, value: str) -> None: + def codec_format(self, value: InspectCodecFormat | str) -> None: self._stage("typeFields.generic.codecFormat", value) @property diff --git a/src/videoipath_automation_tool/apps/inspect/model/actions.py b/src/videoipath_automation_tool/apps/inspect/model/actions.py index ca26566..017f26c 100644 --- a/src/videoipath_automation_tool/apps/inspect/model/actions.py +++ b/src/videoipath_automation_tool/apps/inspect/model/actions.py @@ -9,6 +9,15 @@ InspectApiDescriptor, InspectApiPostRequestHeader, InspectApiRestV2Header, + InspectCodecFormat, + InspectConfigPriority, + InspectIconSize, + InspectIconType, + InspectRedundancyMode, + InspectSdpStrategy, + InspectSipsMode, + InspectVertexKind, + InspectVertexType, ) from videoipath_automation_tool.apps.inspect.model.virtual import InspectApiVirtualDeviceFields @@ -28,10 +37,10 @@ class InspectApiAssignedTags(InspectApiBaseModel): class InspectApiLookupInspectDeviceFields(InspectApiBaseModel): coordinates: dict[str, float | int] | None = None descriptor: InspectApiDescriptor - iconSize: str | None = None - iconType: str | None = None + iconSize: InspectIconSize | str | None = None + iconType: InspectIconType | str | None = None localAssignedTags: list[str] = Field(default_factory=list) - sdpStrategy: str | None = None + sdpStrategy: InspectSdpStrategy | str | None = None siteId: str | None = None tags: list[str] = Field(default_factory=list) virtualDeviceFields: InspectApiVirtualDeviceFields | None = None @@ -55,7 +64,7 @@ class InspectApiCodecGeneric(InspectApiBaseModel): endpoint blocks are kept as dicts for lossless round-tripping (``extra="allow"`` on the base).""" bidirPartnerId: str | None = None - codecFormat: str | None = None + codecFormat: InspectCodecFormat | str | None = None extraFormats: list[Any] = Field(default_factory=list) mainDstInfo: dict[str, Any] | None = None mainSrcInfo: dict[str, Any] | None = None @@ -88,7 +97,7 @@ class InspectApiVertexTypeFields(InspectApiBaseModel): supportsStaticIgmpCfg: bool | None = None supportsVlanCfg: bool | None = None supportsVplsCfg: bool | None = None - type: str | None = None + type: InspectVertexKind | str | None = None vlanId: str | None = None vrfId: str | None = None # Codec vertices additionally carry ``generic`` / ``specific`` blocks here; they are preserved @@ -100,7 +109,7 @@ class InspectApiVertexTypeFields(InspectApiBaseModel): class InspectApiVertexControlProps(InspectApiBaseModel): """Control properties of a controlled vertex (verified against a live 2025.4.9 server).""" - configPriority: str | None = None + configPriority: InspectConfigPriority | str | None = None onlyInitial: bool | None = None @@ -117,7 +126,7 @@ class InspectApiVertexEditForm(InspectApiBaseModel): label: str = "" localAssignedTags: list[str] = Field(default_factory=list) queueable: bool | None = None - sipsMode: str | None = None + sipsMode: InspectSipsMode | str | None = None tags: list[str] = Field(default_factory=list) typeFields: InspectApiVertexTypeFields | None = None useAsEndpoint: bool | None = None @@ -140,7 +149,7 @@ class InspectApiLookupVertexResponseData(InspectApiBaseModel): fields: InspectApiVertexEditForm id: str isVirtual: bool | None = None - vertexType: str | None = None + vertexType: InspectVertexType | str | None = None class InspectApiLookupVertexResponse(InspectApiBaseModel): @@ -169,7 +178,7 @@ class InspectApiEdgeForm(InspectApiBaseModel): fDescriptor: InspectApiDescriptor = Field(default_factory=InspectApiDescriptor) fromId: str includeFormats: list[str] = Field(default_factory=list) - redundancyMode: str = "Any" + redundancyMode: InspectRedundancyMode | str = "Any" tags: list[str] = Field(default_factory=list) toId: str weight: int = 1 diff --git a/src/videoipath_automation_tool/apps/inspect/model/collector.py b/src/videoipath_automation_tool/apps/inspect/model/collector.py index 0d39a37..9dc7b6a 100644 --- a/src/videoipath_automation_tool/apps/inspect/model/collector.py +++ b/src/videoipath_automation_tool/apps/inspect/model/collector.py @@ -11,8 +11,12 @@ InspectApiEndpointStatus, InspectApiRestV2Header, InspectApiStatusContext, + InspectIconSize, + InspectIconType, + InspectSdpStrategy, InspectServiceStatus, InspectApiStatusSummary, + InspectVertexType, ) @@ -68,11 +72,11 @@ class InspectApiPathItem(InspectApiBaseModel): class InspectApiNodeMeta(InspectApiBaseModel): coordinates: dict[str, float | int | str | None] | None = None hwPanelType: str | None = None - iconSize: str | None = None - iconType: str | None = None + iconSize: InspectIconSize | str | None = None + iconType: InspectIconType | str | None = None isCore: bool | None = None isVirtual: bool | None = None - sdpStrategy: str | None = None + sdpStrategy: InspectSdpStrategy | str | None = None siteId: str | None = None tags: list[str] = Field(default_factory=list) @@ -87,7 +91,7 @@ class InspectApiSingleVertexInfo(InspectApiBaseModel): type: Literal["single"] = "single" id: str | None = None label: str | None = None - vertexType: str | None = None + vertexType: InspectVertexType | str | None = None fields: InspectApiVertexInfoFields | None = None diff --git a/src/videoipath_automation_tool/apps/inspect/model/common.py b/src/videoipath_automation_tool/apps/inspect/model/common.py index 99d56b3..b34ccdd 100644 --- a/src/videoipath_automation_tool/apps/inspect/model/common.py +++ b/src/videoipath_automation_tool/apps/inspect/model/common.py @@ -64,6 +64,18 @@ CONFLICT_PRIORITY_TO_INT: dict[str, int] = {"off": 0, "high": 1, "normal": 2, "low": 3} CONFLICT_PRIORITY_BY_INT: dict[int, str] = {value: name for name, value in CONFLICT_PRIORITY_TO_INT.items()} +# SIPS mode on vertices (mirrors the topology app's ``SipsMode``). +InspectSipsMode = Literal["NONE", "SIPSAuto", "SIPSDuplicate", "SIPSMerge", "SIPSSplit"] + +# Vertex control level (mirrors the topology app's ``Control``). +InspectControl = Literal["full", "off", "semi"] + +# Codec format on codec vertices (mirrors the topology app's ``CodecFormat``). +InspectCodecFormat = Literal["Video", "Audio", "ASI", "Ancillary"] + +# Map coordinate type on nGraph map elements (mirrors topology ``cType``). +InspectMapCType = Literal["Topology", "Geo"] + class InspectApiBaseModel(BaseModel): model_config = ConfigDict(populate_by_name=True, validate_assignment=True, extra="allow") @@ -198,11 +210,15 @@ class InspectApiActionValidationErrorResponse(InspectApiBaseModel): "InspectApiStatusSummary", "CONFLICT_PRIORITY_BY_INT", "CONFLICT_PRIORITY_TO_INT", + "InspectCodecFormat", "InspectConfigPriority", + "InspectControl", "InspectIconSize", "InspectIconType", + "InspectMapCType", "InspectRedundancyMode", "InspectSdpStrategy", + "InspectSipsMode", "InspectVertexKind", "InspectVertexType", "_STAGED_MISSING", diff --git a/src/videoipath_automation_tool/apps/inspect/model/ngraph.py b/src/videoipath_automation_tool/apps/inspect/model/ngraph.py index 80fb466..f1d816d 100644 --- a/src/videoipath_automation_tool/apps/inspect/model/ngraph.py +++ b/src/videoipath_automation_tool/apps/inspect/model/ngraph.py @@ -7,6 +7,16 @@ from videoipath_automation_tool.apps.inspect.model.common import ( InspectApiBaseModel, InspectApiDescriptor, + InspectCodecFormat, + InspectConfigPriority, + InspectControl, + InspectIconSize, + InspectIconType, + InspectMapCType, + InspectRedundancyMode, + InspectSdpStrategy, + InspectSipsMode, + InspectVertexType, ) @@ -26,7 +36,7 @@ class InspectApiGpid(InspectApiBaseModel): class InspectApiMapElement(InspectApiBaseModel): - cType: str = "Topology" + cType: InspectMapCType = "Topology" id: str = "" name: str = "" visible: bool = True @@ -46,27 +56,27 @@ class InspectApiNGraphElement(InspectApiBaseModel): class InspectApiBaseDevice(InspectApiNGraphElement): type: Literal["baseDevice"] = "baseDevice" - iconSize: str = "medium" - iconType: str = "default" + iconSize: InspectIconSize | str = "medium" + iconType: InspectIconType | str = "default" isVirtual: bool = False maps: list[InspectApiMapElement] = Field(default_factory=list) - sdpStrategy: str = "always" + sdpStrategy: InspectSdpStrategy | str = "always" siteId: str | None = None class InspectApiVertex(InspectApiNGraphElement): deviceId: str gpid: InspectApiGpid | None = None - configPriority: str | int | None = None - control: str | int | None = None + configPriority: InspectConfigPriority | str | int | None = None + control: InspectControl | str | int | None = None custom: dict[str, Any] = Field(default_factory=dict) extraAlertFilters: list[Any] = Field(default_factory=list) imgUrl: str | None = None isVirtual: bool = False maps: list[InspectApiMapElement] = Field(default_factory=list) - sipsMode: str | None = None + sipsMode: InspectSipsMode | str | None = None useAsEndpoint: bool | None = None - vertexType: str | None = None + vertexType: InspectVertexType | str | None = None class InspectApiIpVertex(InspectApiVertex): @@ -88,7 +98,7 @@ class InspectApiIpVertex(InspectApiVertex): class InspectApiCodecVertex(InspectApiVertex): type: Literal["codecVertex"] = "codecVertex" - codecFormat: str | None = None + codecFormat: InspectCodecFormat | str | None = None codecType: str | None = None @@ -119,7 +129,7 @@ class InspectApiUnidirectionalEdge(InspectApiNGraphElement): excludeFormats: list[str] = Field(default_factory=list) fromId: str includeFormats: list[str] = Field(default_factory=list) - redundancyMode: str = "Any" + redundancyMode: InspectRedundancyMode | str = "Any" toId: str weight: int = 0 weightFactors: InspectApiWeightFactors = Field(default_factory=InspectApiWeightFactors) diff --git a/src/videoipath_automation_tool/apps/inspect/model/virtual.py b/src/videoipath_automation_tool/apps/inspect/model/virtual.py index ee22055..b38ac80 100644 --- a/src/videoipath_automation_tool/apps/inspect/model/virtual.py +++ b/src/videoipath_automation_tool/apps/inspect/model/virtual.py @@ -18,7 +18,13 @@ InspectApiPostRequestHeader, InspectApiRestV2Header, InspectApiSimpleActionResult, + InspectCodecFormat, + InspectConfigPriority, + InspectControl, + InspectSipsMode, + InspectVertexType, ) +from videoipath_automation_tool.apps.inspect.model.ngraph import InspectApiNGraphElementType class InspectApiVirtualPortFromTemplate(InspectApiBaseModel): @@ -88,13 +94,13 @@ class InspectApiUpdateVirtualInstancesResponse(InspectApiBaseModel): class InspectApiVirtualTemplateVertex(InspectApiBaseModel): """Vertex config embedded in a port template (lossless; ``extra="allow"``).""" - type: str | None = None - vertexType: str | None = None - codecFormat: str | None = None + type: InspectApiNGraphElementType | str | None = None + vertexType: InspectVertexType | str | None = None + codecFormat: InspectCodecFormat | str | None = None isVirtual: bool | None = None active: bool | None = None - control: str | None = None - configPriority: str | None = None + control: InspectControl | str | None = None + configPriority: InspectConfigPriority | str | None = None useAsEndpoint: bool | None = None deviceId: str | None = None descriptor: dict[str, Any] | None = None @@ -102,7 +108,7 @@ class InspectApiVirtualTemplateVertex(InspectApiBaseModel): custom: dict[str, Any] = Field(default_factory=dict) tags: list[str] = Field(default_factory=list) maps: list[Any] = Field(default_factory=list) - sipsMode: str | None = None + sipsMode: InspectSipsMode | str | None = None imgUrl: str | None = None extraAlertFilters: list[Any] = Field(default_factory=list) gpid: dict[str, Any] | None = None diff --git a/src/videoipath_automation_tool/apps/inspect/transaction.py b/src/videoipath_automation_tool/apps/inspect/transaction.py index 3a4a3fb..d0a5ddc 100644 --- a/src/videoipath_automation_tool/apps/inspect/transaction.py +++ b/src/videoipath_automation_tool/apps/inspect/transaction.py @@ -45,13 +45,16 @@ from videoipath_automation_tool.apps.inspect.model.common import ( CONFLICT_PRIORITY_TO_INT, InspectApiSimpleActionResponse, + InspectCodecFormat, InspectConfigPriority, + InspectControl, InspectFrozenModel, InspectIconSize, InspectIconType, InspectInternalModel, InspectRedundancyMode, InspectSdpStrategy, + InspectSipsMode, ) from videoipath_automation_tool.apps.inspect.model.tags import module_resource_id from videoipath_automation_tool.apps.inspect.model.update_topology import ( @@ -235,8 +238,8 @@ def update_vertex( form_tags: Optional[list[str]] = None, description: Optional[str] = None, active: Optional[bool] = None, - sips_mode: Optional[str] = None, - control: Optional[str] = None, + sips_mode: Optional[InspectSipsMode | str] = None, + control: Optional[InspectControl | str] = None, control_props: Optional[Any] = None, extra_alert_filters: Optional[list[Any]] = None, custom: Optional[dict[str, Any]] = None, @@ -261,7 +264,7 @@ def update_vertex( sdp_support: Optional[bool] = None, is_igmp_source: Optional[bool] = None, specific_type: Optional[str] = None, - codec_format: Optional[str] = None, + codec_format: Optional[InspectCodecFormat | str] = None, multiplicity: Optional[int] = None, codec_public: Optional[bool] = None, extra_formats: Optional[list[Any]] = None, diff --git a/tests/e2e/helpers.py b/tests/e2e/helpers.py index 9720a93..1f941cc 100644 --- a/tests/e2e/helpers.py +++ b/tests/e2e/helpers.py @@ -31,14 +31,14 @@ MOCK_DRIVER = "com.nevion.mock-0.1.0" # Catalog tags created via simple API requests for the Inspect tag tests. Tag references are -# ``Category~~name`` ids; they live in the existing "Video" format category. -TEST_TAG_CATEGORY = "Video" +# ``~~``-joined ids under the default Format tree (e.g. Format~~Video~~E2E-VIDEO-TAG). +TEST_TAG_PATH = ("Format", "Video") TEST_TAG_NAME = "E2E-VIDEO-TAG" -TEST_TAG_ID = f"{TEST_TAG_CATEGORY}~~{TEST_TAG_NAME}" +TEST_TAG_ID = "~~".join((*TEST_TAG_PATH, TEST_TAG_NAME)) -MODULE_TEST_TAG_CATEGORY = "Video" +MODULE_TEST_TAG_PATH = ("Format", "Video") MODULE_TEST_TAG_NAME = "E2E-MODULE-TAG" -MODULE_TEST_TAG_ID = f"{MODULE_TEST_TAG_CATEGORY}~~{MODULE_TEST_TAG_NAME}" +MODULE_TEST_TAG_ID = "~~".join((*MODULE_TEST_TAG_PATH, MODULE_TEST_TAG_NAME)) def unique_label(base: str) -> str: @@ -354,18 +354,35 @@ def __exit__(self, *exc: object) -> None: # --- Test tag catalog (simple API requests) -------------------------------------------------------- -def create_catalog_tag(app: "VideoIPathApp", *, category: str, name: str) -> str: - """Create a catalog tag under ``category`` (idempotent). Returns the ``Category~~name`` id.""" - cat = _tag_category(app, category) +def create_catalog_tag(app: "VideoIPathApp", *, path: tuple[str, ...], name: str) -> str: + """Create a catalog tag under ``path`` (idempotent). Returns the ``~~``-joined id. + + ``path`` is ``(root_tree_id, *intermediate_node_names)``, e.g. ``(\"Format\", \"Video\")``. + """ + if not path: + raise ValueError("path must include at least the root tag tree id") + root_id, *intermediates = path + cat = _tag_category(app, root_id) if cat is None: - raise RuntimeError(f"Tag category '{category}' not found on the server.") + raise RuntimeError(f"Tag category '{root_id}' not found on the server.") + children = dict(cat.get("children") or {}) - children[name] = {"exclusive": False, "children": {}} + parent_children = children + for segment in intermediates: + if segment not in parent_children: + raise RuntimeError(f"Tag path segment '{segment}' not found under '{root_id}'.") + node = dict(parent_children[segment]) + node_children = dict(node.get("children") or {}) + node["children"] = node_children + parent_children[segment] = node + parent_children = node_children + + parent_children[name] = {"_id": name, "exclusive": False, "children": {}, "color": ""} body = { "actions": [ { "_action": "update", - "_id": category, + "_id": root_id, "_rev": cat["_rev"], "children": children, "type": cat.get("type", "format"), @@ -376,18 +393,32 @@ def create_catalog_tag(app: "VideoIPathApp", *, category: str, name: str) -> str ] } _raw_request(app, "patch", "/rest/v2/data/config/tags/tagTrees", body) - return f"{category}~~{name}" + return "~~".join((*path, name)) -def catalog_tag_exists(app: "VideoIPathApp", *, category: str, name: str) -> bool: - cat = _tag_category(app, category) - return cat is not None and name in (cat.get("children") or {}) +def catalog_tag_exists(app: "VideoIPathApp", *, path: tuple[str, ...], name: str) -> bool: + if not path: + return False + root_id, *intermediates = path + cat = _tag_category(app, root_id) + if cat is None: + return False + node_children: dict[str, Any] = cat.get("children") or {} + for segment in intermediates: + if segment not in node_children: + return False + node_children = node_children[segment].get("children") or {} + return name in node_children def delete_catalog_tag(app: "VideoIPathApp", tag_id: str) -> None: """Force-delete a catalog tag (removes it and any resource bindings) if it exists.""" - category, _, name = tag_id.partition("~~") - if not category or not name or not catalog_tag_exists(app, category=category, name=name): + parts = tag_id.split("~~") + if len(parts) < 2: + return + path = tuple(parts[:-1]) + name = parts[-1] + if not catalog_tag_exists(app, path=path, name=name): return _raw_request( app, @@ -399,7 +430,7 @@ def delete_catalog_tag(app: "VideoIPathApp", tag_id: str) -> None: def create_test_tag(app: "VideoIPathApp") -> None: """Create the E2E port/vertex test video tag in the catalog (idempotent).""" - create_catalog_tag(app, category=TEST_TAG_CATEGORY, name=TEST_TAG_NAME) + create_catalog_tag(app, path=TEST_TAG_PATH, name=TEST_TAG_NAME) def delete_test_tag(app: "VideoIPathApp") -> None: @@ -409,7 +440,7 @@ def delete_test_tag(app: "VideoIPathApp") -> None: def create_module_test_tag(app: "VideoIPathApp") -> None: """Create the E2E module test tag in the catalog (idempotent).""" - create_catalog_tag(app, category=MODULE_TEST_TAG_CATEGORY, name=MODULE_TEST_TAG_NAME) + create_catalog_tag(app, path=MODULE_TEST_TAG_PATH, name=MODULE_TEST_TAG_NAME) def delete_module_test_tag(app: "VideoIPathApp") -> None: diff --git a/tests/inspect/test_models.py b/tests/inspect/test_models.py index 57d9399..6f56730 100644 --- a/tests/inspect/test_models.py +++ b/tests/inspect/test_models.py @@ -198,6 +198,29 @@ def test_inspect_icon_type_matches_topology_icon_type() -> None: assert set(get_args(InspectIconType)) == set(get_args(IconType)) +def test_inspect_literals_match_topology_literals() -> None: + """Drift guard: inspect Literals that mirror topology must stay in sync.""" + from typing import get_args, get_type_hints + + from videoipath_automation_tool.apps.inspect.model.common import ( + InspectCodecFormat, + InspectControl, + InspectMapCType, + InspectSipsMode, + ) + from videoipath_automation_tool.apps.topology.model.n_graph_elements.topology_codec_vertex import CodecFormat + from videoipath_automation_tool.apps.topology.model.n_graph_elements.topology_n_graph_element import ( + Control, + MapsElement, + SipsMode, + ) + + assert set(get_args(InspectSipsMode)) == set(get_args(SipsMode)) + assert set(get_args(InspectControl)) == set(get_args(Control)) + assert set(get_args(InspectCodecFormat)) == set(get_args(CodecFormat)) + assert set(get_args(InspectMapCType)) == set(get_args(get_type_hints(MapsElement)["cType"])) + + def test_port_assigned_tags_from_tags_info() -> None: port = InspectPortStatus.model_validate( { From 247f9d14cb5d3fbc3f599641c3ad339a28144c11 Mon Sep 17 00:00:00 2001 From: Jonas Scholl <43819845+JonasScholl@users.noreply.github.com> Date: Thu, 23 Jul 2026 22:05:03 +0200 Subject: [PATCH 30/34] descriptive inspect status enums --- docs/architecture/inspect-app/models.md | 59 +++++++- docs/getting-started-guide/03_B_Inspect.md | 9 +- .../apps/inspect/__init__.py | 2 + .../apps/inspect/api/inspect_api.py | 7 + .../apps/inspect/api/queries.py | 17 +++ .../apps/inspect/domain/__init__.py | 2 + .../apps/inspect/domain/alarm.py | 72 ++++++++++ .../apps/inspect/domain/device.py | 15 +- .../apps/inspect/domain/edge.py | 6 + .../apps/inspect/domain/module.py | 6 + .../apps/inspect/domain/port.py | 6 + .../apps/inspect/domain/service.py | 6 + .../apps/inspect/model/__init__.py | 3 + .../apps/inspect/model/alarms.py | 64 +++++++++ .../apps/inspect/model/collector.py | 24 +++- .../apps/inspect/model/common.py | 62 +++++++- .../apps/inspect/snapshot.py | 104 +++++++++++++- tests/e2e/apps/test_inspect.py | 41 ++++++ .../fixtures/2025.4.9/alarms_current.json | 135 ++++++++++++++++++ tests/inspect/test_exports.py | 2 + tests/inspect/test_models.py | 64 +++++++++ tests/inspect/test_queries.py | 8 ++ tests/inspect/test_snapshot.py | 59 ++++++++ 23 files changed, 756 insertions(+), 17 deletions(-) create mode 100644 src/videoipath_automation_tool/apps/inspect/domain/alarm.py create mode 100644 src/videoipath_automation_tool/apps/inspect/model/alarms.py create mode 100644 tests/inspect/fixtures/2025.4.9/alarms_current.json diff --git a/docs/architecture/inspect-app/models.md b/docs/architecture/inspect-app/models.md index 6e5c999..747c0c8 100644 --- a/docs/architecture/inspect-app/models.md +++ b/docs/architecture/inspect-app/models.md @@ -151,6 +151,7 @@ hydrated and when it was fetched. | Edge connectivity, endpoint ports/labels, status severities | Edge skeleton query | Snapshot construction | | Device `ports` (modules, port status, `vertexInfo`, port `tagsInfo`, PTP), port-level path drill-down | Per-device `nodeStatus` subtree (`modules/*` projection) | First access on that device | | `services`, service path structures | `inspect/paths` section query | First access to any service data | +| Active alarms (`.alarms`, `status_message`) | `status/alarms/current` section query | First access to any alarm data | | Maintenance bookings, super profiles, tag info | Section queries | First access, if exposed | | Edge bandwidth values, edge `pathDescriptions` | Full per-pair edge shape | Not in the skeleton; loaded with the owning section/entity detail | @@ -162,6 +163,46 @@ lazy loading inert. These are the classes package users should prefer for read-side workflows. +### Status severity (`InspectSeverity`) + +Status and alarm severity fields (`status.severity`, `status.sa`, `sync_severity`, +edge live `alarm` / `ptp` / `maintenance` / `bandwidth`) are mapped to +`InspectSeverity`, an `IntEnum` so both the label and the wire int remain usable: + +| Value | Label | +| --- | --- | +| `0` | None | +| `1` | OK | +| `2` | Notice | +| `3` | Warning | +| `4` | Minor | +| `5` | Major | +| `6` | Critical | + +```python +device.status.severity # InspectSeverity.OK +str(device.status.severity) # "OK" +int(device.status.severity) # 1 +device.status.severity == 1 # True +``` + +Unknown wire codes pass through as raw `int` / `str` (never crash). + +### Active alarms (`InspectAlarm`) + +Per-resource alarm messages come from `status/alarms/current` (loaded lazily as a +snapshot section). Each of `device` / `module` / `port` / `edge` / `service` exposes +`.alarms` — a list of `InspectAlarm` sorted worst-severity first. Device also has +`status_message` (the worst alarm's text). + +| Field / property | Meaning | +| --- | --- | +| `message` | Alarm text (e.g. `"Mock driver in use"`, `"Loss of protection"`) | +| `severity` / `sa` | Mapped `InspectSeverity` | +| `acknowledged` / `hidden` | Alarm acknowledgement flags | +| `point_labels` | Human labels for the alarm's point path | +| `alert_id` / `component` | Alarm identity | + ### `InspectDevice` Represents one topology device/node with status, sync hints, and relations. @@ -170,8 +211,10 @@ Represents one topology device/node with status, sync hints, and relations. | --- | --- | | `id` | Device identifier, for example `device-a` | | `label` | Display label | -| `status` | Device status summary | -| `sync_severity` | Sync severity from node status | +| `status` | Device status summary (`sa` / `severity` as `InspectSeverity`) | +| `sync_severity` | Sync severity from node status (`InspectSeverity`) | +| `alarms` | Active alarms on this device (worst first) | +| `status_message` | Message of the worst active alarm, if any | | `tags` | Assigned tags | | `coordinates` | Topology map position when available | | `ports` | Ports on this device | @@ -184,6 +227,9 @@ Lookup: ```python device = app.inspect.get_device("device-a") matches = app.inspect.find_devices_by_label("Example Device A") +print(device.status.severity, device.status_message) +for alarm in device.alarms: + print(alarm.severity, alarm.message) ``` ### `InspectPort` @@ -197,7 +243,8 @@ external edge to another device. | `label` | Display label | | `device` | Owning `InspectDevice` | | `module_id` | Owning module | -| `status` | Port status summary | +| `status` | Port status summary (`InspectSeverity` fields) | +| `alarms` | Active alarms correlated to this port | | `vertex_id` | Linked topology vertex when available | | `tags` | Vertex tag bindings when hydrated (`tagsInfo` from `nodeStatus`) | | `edge` | External edge when this port connects to another device; otherwise `None` | @@ -212,7 +259,8 @@ Represents one external edge status entry between two endpoint devices/ports. | `from_device` / `to_device` | Endpoint devices | | `from_port` / `to_port` | Endpoint ports | | `bandwidth` / `max_bandwidth` | Bandwidth values | -| `status` | Alarm, bandwidth, maintenance, and PTP summary | +| `status` | Alarm, bandwidth, maintenance, and PTP summary (`InspectSeverity`) | +| `alarms` | Active alarms correlated to this edge / pair | | `services` | Services touching either endpoint device | ### `InspectService` @@ -226,7 +274,8 @@ Represents one service/booking path across devices and ports. | `source` / `destination` | Endpoint labels | | `source_device` / `destination_device` | Endpoint devices | | `source_port` / `destination_port` | Endpoint ports | -| `status` | Service status summary | +| `status` | Service status summary (`InspectSeverity` fields) | +| `alarms` | Active alarms correlated to this booking | | `path_devices` | Ordered devices in the path | | `path_ports` | Ports encountered in the path | diff --git a/docs/getting-started-guide/03_B_Inspect.md b/docs/getting-started-guide/03_B_Inspect.md index 2065f6c..43bd148 100644 --- a/docs/getting-started-guide/03_B_Inspect.md +++ b/docs/getting-started-guide/03_B_Inspect.md @@ -49,7 +49,12 @@ without any per-device I/O: device = app.inspect.get_device("device10") device = app.inspect.find_device_by_label("BORDERLEAF-26B") -print(device.label, device.coordinates, device.status, device.sync_severity, device.tags) +print(device.label, device.coordinates, device.tags) +print(device.status.severity if device.status else None, device.sync_severity) +# InspectSeverity is an IntEnum: str(...) → "OK" / "Notice" / …; int(...) / == N still work. +print(device.status_message) # worst active alarm text, if any +for alarm in device.alarms: # lazy section load of status/alarms/current + print(alarm.severity, alarm.message) for device in app.inspect.devices: # all devices print(device.id, device.label) @@ -67,6 +72,8 @@ for port in device.ports: # triggers one hydration fetch for this d for edge in device.edges: # local, no hydration print(edge.from_port, "->", edge.to_port, edge.status) + if edge.status: + print(edge.status.alarm, edge.status.ptp) # InspectSeverity labels for other in device.linked_devices: # local graph walk print(other.label) diff --git a/src/videoipath_automation_tool/apps/inspect/__init__.py b/src/videoipath_automation_tool/apps/inspect/__init__.py index a002dcb..8a07f0c 100644 --- a/src/videoipath_automation_tool/apps/inspect/__init__.py +++ b/src/videoipath_automation_tool/apps/inspect/__init__.py @@ -4,6 +4,7 @@ from videoipath_automation_tool.apps.inspect.app.actions import ConflictStrategy as ConflictStrategy from videoipath_automation_tool.apps.inspect.transaction import CommitResult as CommitResult from videoipath_automation_tool.apps.inspect.transaction import InspectTransaction as InspectTransaction +from videoipath_automation_tool.apps.inspect.domain import InspectAlarm as InspectAlarm from videoipath_automation_tool.apps.inspect.domain import InspectDevice as InspectDevice from videoipath_automation_tool.apps.inspect.domain import InspectEdge as InspectEdge from videoipath_automation_tool.apps.inspect.domain import InspectModule as InspectModule @@ -28,6 +29,7 @@ __all__ = [ "CommitResult", "ConflictStrategy", + "InspectAlarm", "InspectApp", "InspectCommitConflictError", "InspectCommitError", diff --git a/src/videoipath_automation_tool/apps/inspect/api/inspect_api.py b/src/videoipath_automation_tool/apps/inspect/api/inspect_api.py index 97fe783..82f4a36 100644 --- a/src/videoipath_automation_tool/apps/inspect/api/inspect_api.py +++ b/src/videoipath_automation_tool/apps/inspect/api/inspect_api.py @@ -25,6 +25,7 @@ InspectApiSyncDevicesRequest, InspectApiSyncDevicesRequestData, ) +from videoipath_automation_tool.apps.inspect.model.alarms import InspectApiAlarmItem from videoipath_automation_tool.apps.inspect.model.collector import ( InspectApiCollectorResponse, InspectApiExternalEdgesByDeviceKeyItem, @@ -100,6 +101,12 @@ def get_paths_section(self) -> list[InspectApiPathItem]: items = _extract_items(response.data, "status", "collector", "inspect", "paths") return [InspectApiPathItem.model_validate(item) for item in items] + def get_alarms_section(self) -> list[InspectApiAlarmItem]: + """The current-alarms section (``status/alarms/current``).""" + response = self.vip_connector.rest.get(queries.alarms_section(), allow_projection=True) + items = _extract_items(response.data, "status", "alarms", "current") + return [InspectApiAlarmItem.model_validate(item) for item in items] + def get_collector_full(self) -> InspectApiCollectorResponse: """The full collector aggregate (eager / fallback mode).""" response = self.vip_connector.rest.get(queries.collector_full(), allow_projection=True) diff --git a/src/videoipath_automation_tool/apps/inspect/api/queries.py b/src/videoipath_automation_tool/apps/inspect/api/queries.py index 757d1a4..8ab28a8 100644 --- a/src/videoipath_automation_tool/apps/inspect/api/queries.py +++ b/src/videoipath_automation_tool/apps/inspect/api/queries.py @@ -60,6 +60,11 @@ def paths_section() -> str: return _build(_PATHS_SECTION) +def alarms_section() -> str: + """GET path for the current-alarms section (``status/alarms/current``).""" + return _build(_ALARMS_SECTION) + + def collector_full() -> str: """GET path for the full collector aggregate (eager / fallback mode).""" return _build(_COLLECTOR_FULL) @@ -118,6 +123,17 @@ def virtual_devices() -> str: "/.../inputStatus,outputStatus/label,pid" ) +# Current alarms: lean projection of identity, acknowledgement, point labels, and severity/message. +# Verified 2026.2.0: two `/.../` pops after each selected sub-tree (same grammar as the collector +# skeleton); three pops omit ``info``. +_ALARMS_SECTION = ( + "/status/alarms/current/*" + "/acked,hidden" + "/.../id/**" + "/.../.../desc/**" + "/.../.../info/details,severity,sa,headSeverity,time" +) + # Full aggregate (eager / fallback mode). _COLLECTOR_FULL = "/status/collector/**" @@ -141,6 +157,7 @@ def _build(path: str) -> str: "edge_skeleton", "edge_pair", "paths_section", + "alarms_section", "collector_full", "virtual_templates", "virtual_devices", diff --git a/src/videoipath_automation_tool/apps/inspect/domain/__init__.py b/src/videoipath_automation_tool/apps/inspect/domain/__init__.py index a08a91e..0087641 100644 --- a/src/videoipath_automation_tool/apps/inspect/domain/__init__.py +++ b/src/videoipath_automation_tool/apps/inspect/domain/__init__.py @@ -1,5 +1,6 @@ from __future__ import annotations +from videoipath_automation_tool.apps.inspect.domain.alarm import InspectAlarm from videoipath_automation_tool.apps.inspect.domain.device import InspectDevice, VirtualDeviceSpec from videoipath_automation_tool.apps.inspect.domain.edge import InspectEdge from videoipath_automation_tool.apps.inspect.domain.module import InspectModule, VirtualModuleSpec @@ -14,6 +15,7 @@ ) __all__ = [ + "InspectAlarm", "InspectCodecVertex", "InspectDevice", "InspectEdge", diff --git a/src/videoipath_automation_tool/apps/inspect/domain/alarm.py b/src/videoipath_automation_tool/apps/inspect/domain/alarm.py new file mode 100644 index 0000000..d15cd64 --- /dev/null +++ b/src/videoipath_automation_tool/apps/inspect/domain/alarm.py @@ -0,0 +1,72 @@ +from __future__ import annotations + +from videoipath_automation_tool.apps.inspect.model.alarms import InspectApiAlarmItem +from videoipath_automation_tool.apps.inspect.model.common import InspectFrozenModel, InspectSeverity + + +class InspectAlarm(InspectFrozenModel): + """One active alarm from ``status/alarms/current``, correlated onto a topology resource.""" + + item: InspectApiAlarmItem + + @property + def id(self) -> str | None: + return self.item.id_field + + @property + def message(self) -> str | None: + info = self.item.info + return info.details if info is not None else None + + @property + def severity(self) -> InspectSeverity | int | str | None: + info = self.item.info + return info.severity if info is not None else None + + @property + def sa(self) -> InspectSeverity | int | str | None: + """Service-affecting severity of this alarm (``info.sa``).""" + info = self.item.info + return info.sa if info is not None else None + + @property + def service_affecting(self) -> InspectSeverity | int | str | None: + return self.sa + + @property + def acknowledged(self) -> bool | None: + return self.item.acked + + @property + def hidden(self) -> bool | None: + return self.item.hidden + + @property + def time(self) -> int | None: + info = self.item.info + return info.time if info is not None else None + + @property + def alert_id(self) -> str | None: + alarm_id = self.item.id + return alarm_id.alertId if alarm_id is not None else None + + @property + def component(self) -> int | None: + alarm_id = self.item.id + return alarm_id.component if alarm_id is not None else None + + @property + def point_id(self) -> list[str]: + alarm_id = self.item.id + return list(alarm_id.pointId) if alarm_id is not None else [] + + @property + def point_labels(self) -> list[str]: + desc = self.item.desc + if desc is None: + return [] + return [entry.label for entry in desc.pointId if entry.label] + + +__all__ = ["InspectAlarm"] diff --git a/src/videoipath_automation_tool/apps/inspect/domain/device.py b/src/videoipath_automation_tool/apps/inspect/domain/device.py index ba0aea1..d25658f 100644 --- a/src/videoipath_automation_tool/apps/inspect/domain/device.py +++ b/src/videoipath_automation_tool/apps/inspect/domain/device.py @@ -14,6 +14,7 @@ InspectIconType, InspectInternalModel, InspectSdpStrategy, + InspectSeverity, InspectVertexKind, InspectVertexType, ) @@ -22,6 +23,7 @@ from videoipath_automation_tool.validators.virtual_device_id import is_virtual_device_id if TYPE_CHECKING: + from videoipath_automation_tool.apps.inspect.domain.alarm import InspectAlarm from videoipath_automation_tool.apps.inspect.domain.edge import InspectEdge from videoipath_automation_tool.apps.inspect.domain.module import InspectModule from videoipath_automation_tool.apps.inspect.domain.port import InspectPort @@ -131,9 +133,20 @@ def status(self) -> InspectApiStatusSummary | None: return self._record().node.status @property - def sync_severity(self) -> int | str | None: + def sync_severity(self) -> InspectSeverity | int | str | None: return self._record().node.syncSeverity + @property + def alarms(self) -> list[InspectAlarm]: + """Active alarms correlated to this device (worst severity first).""" + return self.snapshot.get_alarms_for_device(self.id) + + @property + def status_message(self) -> str | None: + """Message of the worst active alarm on this device, if any.""" + alarms = self.alarms + return alarms[0].message if alarms else None + @property def tags(self) -> list[str]: return self._staged_or("tags", lambda: list(self._record().node.tags), adapt=list) diff --git a/src/videoipath_automation_tool/apps/inspect/domain/edge.py b/src/videoipath_automation_tool/apps/inspect/domain/edge.py index d38f28d..c20af05 100644 --- a/src/videoipath_automation_tool/apps/inspect/domain/edge.py +++ b/src/videoipath_automation_tool/apps/inspect/domain/edge.py @@ -13,6 +13,7 @@ from videoipath_automation_tool.apps.inspect.snapshot import InspectSnapshot, _IndexedEdge if TYPE_CHECKING: + from videoipath_automation_tool.apps.inspect.domain.alarm import InspectAlarm from videoipath_automation_tool.apps.inspect.domain.device import InspectDevice from videoipath_automation_tool.apps.inspect.domain.port import InspectPort from videoipath_automation_tool.apps.inspect.domain.service import InspectService @@ -79,6 +80,11 @@ def status(self) -> InspectApiExternalEdgeLiveStatus | None: the per-edge status (per direction) appears in the full edge shape.""" return self.indexed.edge.status or self.indexed.pair_status + @property + def alarms(self) -> list[InspectAlarm]: + """Active alarms correlated to this edge or its pair (worst severity first).""" + return self.snapshot.get_alarms_for_edge(self.id, pair_id=self.pair_id) + @property def services(self) -> list[InspectService]: services: list[InspectService] = [] diff --git a/src/videoipath_automation_tool/apps/inspect/domain/module.py b/src/videoipath_automation_tool/apps/inspect/domain/module.py index dafea86..1133fe5 100644 --- a/src/videoipath_automation_tool/apps/inspect/domain/module.py +++ b/src/videoipath_automation_tool/apps/inspect/domain/module.py @@ -14,6 +14,7 @@ from videoipath_automation_tool.apps.inspect.snapshot import InspectSnapshot, _STAGED_MISSING if TYPE_CHECKING: + from videoipath_automation_tool.apps.inspect.domain.alarm import InspectAlarm from videoipath_automation_tool.apps.inspect.domain.device import InspectDevice from videoipath_automation_tool.apps.inspect.domain.port import InspectPort from videoipath_automation_tool.apps.inspect.domain.vertex import InspectVertex @@ -75,6 +76,11 @@ def status(self) -> InspectApiStatusSummary | None: status = self._status() return status.status if status is not None else None + @property + def alarms(self) -> list[InspectAlarm]: + """Active alarms correlated to this module (worst severity first).""" + return self.snapshot.get_alarms_for_module(self.device_id, self.module_id) + @property def tags(self) -> list[str]: """Locally assigned module tags (``tagsInfo.assigned.local``; writable via assign/unassign).""" diff --git a/src/videoipath_automation_tool/apps/inspect/domain/port.py b/src/videoipath_automation_tool/apps/inspect/domain/port.py index 5144dd5..f8680be 100644 --- a/src/videoipath_automation_tool/apps/inspect/domain/port.py +++ b/src/videoipath_automation_tool/apps/inspect/domain/port.py @@ -25,6 +25,7 @@ ) if TYPE_CHECKING: + from videoipath_automation_tool.apps.inspect.domain.alarm import InspectAlarm from videoipath_automation_tool.apps.inspect.domain.device import InspectDevice from videoipath_automation_tool.apps.inspect.domain.edge import InspectEdge from videoipath_automation_tool.apps.inspect.domain.module import InspectModule @@ -109,6 +110,11 @@ def description(self) -> str | None: def status(self) -> InspectApiStatusSummary | None: return self.indexed.port.status + @property + def alarms(self) -> list[InspectAlarm]: + """Active alarms correlated to this port (worst severity first).""" + return self.snapshot.get_alarms_for_port(self.id, device_id=self.indexed.device_id) + @property def tags(self) -> list[str]: """Tags assigned to this port (as ``Category~~name`` ids). Assign them with diff --git a/src/videoipath_automation_tool/apps/inspect/domain/service.py b/src/videoipath_automation_tool/apps/inspect/domain/service.py index e9342ae..8032214 100644 --- a/src/videoipath_automation_tool/apps/inspect/domain/service.py +++ b/src/videoipath_automation_tool/apps/inspect/domain/service.py @@ -7,6 +7,7 @@ from videoipath_automation_tool.apps.inspect.snapshot import InspectSnapshot, _port_id_from_endpoint if TYPE_CHECKING: + from videoipath_automation_tool.apps.inspect.domain.alarm import InspectAlarm from videoipath_automation_tool.apps.inspect.domain.device import InspectDevice from videoipath_automation_tool.apps.inspect.domain.port import InspectPort @@ -66,6 +67,11 @@ def is_main(self) -> bool | None: def status(self) -> InspectServiceStatus | None: return self.path_item.serviceFields.serviceStatus + @property + def alarms(self) -> list[InspectAlarm]: + """Active alarms correlated to this service/booking (worst severity first).""" + return self.snapshot.get_alarms_for_service(self.booking_id) + @property def path_devices(self) -> list[InspectDevice]: devices: list[InspectDevice] = [] diff --git a/src/videoipath_automation_tool/apps/inspect/model/__init__.py b/src/videoipath_automation_tool/apps/inspect/model/__init__.py index f747815..bed24fe 100644 --- a/src/videoipath_automation_tool/apps/inspect/model/__init__.py +++ b/src/videoipath_automation_tool/apps/inspect/model/__init__.py @@ -2,6 +2,7 @@ from videoipath_automation_tool.apps.inspect.model import ( actions, + alarms, collector, common, ngraph, @@ -10,6 +11,7 @@ virtual, ) from videoipath_automation_tool.apps.inspect.model.actions import * +from videoipath_automation_tool.apps.inspect.model.alarms import * from videoipath_automation_tool.apps.inspect.model.collector import * from videoipath_automation_tool.apps.inspect.model.common import * from videoipath_automation_tool.apps.inspect.model.ngraph import * @@ -19,6 +21,7 @@ __all__ = [ *actions.__all__, + *alarms.__all__, *collector.__all__, *common.__all__, *ngraph.__all__, diff --git a/src/videoipath_automation_tool/apps/inspect/model/alarms.py b/src/videoipath_automation_tool/apps/inspect/model/alarms.py new file mode 100644 index 0000000..10b3e63 --- /dev/null +++ b/src/videoipath_automation_tool/apps/inspect/model/alarms.py @@ -0,0 +1,64 @@ +from __future__ import annotations + +from typing import Any + +from pydantic import Field, field_validator + +from videoipath_automation_tool.apps.inspect.model.common import ( + InspectApiBaseModel, + InspectApiDescriptor, + InspectSeverity, + map_severity, +) + + +class InspectApiAlarmId(InspectApiBaseModel): + alertId: str | None = None + component: int | None = None + pointId: list[str] = Field(default_factory=list) + + +class InspectApiAlarmDesc(InspectApiBaseModel): + alertId: InspectApiDescriptor | None = None + pointId: list[InspectApiDescriptor] = Field(default_factory=list) + + +class InspectApiAlarmInfo(InspectApiBaseModel): + details: str | None = None + evtType: int | None = None + headId: str | None = None + headSeverity: InspectSeverity | int | str | None = None + headTime: int | None = None + id: str | None = None + links: Any = None + oTime: int | None = None + origin: Any = None + relations: list[Any] = Field(default_factory=list) + sa: InspectSeverity | int | str | None = None + seqno: int | None = None + severity: InspectSeverity | int | str | None = None + time: int | None = None + + @field_validator("headSeverity", "sa", "severity", mode="before") + @classmethod + def _map_severity_fields(cls, value: Any) -> Any: + return map_severity(value) + + +class InspectApiAlarmItem(InspectApiBaseModel): + id_field: str | None = Field(default=None, alias="_id") + vid: str | None = Field(default=None, alias="_vid") + acked: bool | None = None + desc: InspectApiAlarmDesc | None = None + hidden: bool | None = None + history: list[Any] = Field(default_factory=list) + id: InspectApiAlarmId | None = None + info: InspectApiAlarmInfo | None = None + + +__all__ = [ + "InspectApiAlarmDesc", + "InspectApiAlarmId", + "InspectApiAlarmInfo", + "InspectApiAlarmItem", +] diff --git a/src/videoipath_automation_tool/apps/inspect/model/collector.py b/src/videoipath_automation_tool/apps/inspect/model/collector.py index 9dc7b6a..2eedeb8 100644 --- a/src/videoipath_automation_tool/apps/inspect/model/collector.py +++ b/src/videoipath_automation_tool/apps/inspect/model/collector.py @@ -2,7 +2,7 @@ from typing import Any, Literal -from pydantic import Field +from pydantic import Field, field_validator from videoipath_automation_tool.apps.inspect.model.common import ( InspectApiBaseModel, @@ -16,7 +16,9 @@ InspectSdpStrategy, InspectServiceStatus, InspectApiStatusSummary, + InspectSeverity, InspectVertexType, + map_severity, ) @@ -232,10 +234,15 @@ class InspectApiNodeStatusItem(InspectApiBaseModel): relatedNodeTags: list[str] = Field(default_factory=list) resourceId: str | None = None status: InspectApiStatusSummary | None = None - syncSeverity: int | str | None = None + syncSeverity: InspectSeverity | int | str | None = None tags: list[str] = Field(default_factory=list) tagsInfo: dict[str, Any] | None = None + @field_validator("syncSeverity", mode="before") + @classmethod + def _map_sync_severity(cls, value: Any) -> Any: + return map_severity(value) + @property def effective_label(self) -> str | None: """The label the UI shows: user ``descriptor.label``, falling back to the device-reported @@ -262,10 +269,15 @@ def coordinates(self) -> dict[str, float | int | str | None] | None: class InspectApiExternalEdgeLiveStatus(InspectApiBaseModel): - alarm: int | str | None = None - bandwidth: int | float | str | None = None - maintenance: int | str | None = None - ptp: int | str | None = None + alarm: InspectSeverity | int | str | None = None + bandwidth: InspectSeverity | int | float | str | None = None + maintenance: InspectSeverity | int | str | None = None + ptp: InspectSeverity | int | str | None = None + + @field_validator("alarm", "bandwidth", "maintenance", "ptp", mode="before") + @classmethod + def _map_severity_fields(cls, value: Any) -> Any: + return map_severity(value) class InspectApiExternalEdgeStatus(InspectApiBaseModel): diff --git a/src/videoipath_automation_tool/apps/inspect/model/common.py b/src/videoipath_automation_tool/apps/inspect/model/common.py index b34ccdd..3078d55 100644 --- a/src/videoipath_automation_tool/apps/inspect/model/common.py +++ b/src/videoipath_automation_tool/apps/inspect/model/common.py @@ -2,9 +2,10 @@ from abc import ABC, abstractmethod from collections.abc import Callable +from enum import IntEnum from typing import TYPE_CHECKING, Any, Literal, TypeVar -from pydantic import BaseModel, ConfigDict, Field +from pydantic import BaseModel, ConfigDict, Field, field_validator if TYPE_CHECKING: from videoipath_automation_tool.apps.inspect.snapshot import InspectSnapshot @@ -15,6 +16,41 @@ _T = TypeVar("_T") + +class InspectSeverity(IntEnum): + """VideoIPath status/alarm severity. Int-valued so ``== N``, ``int(x)``, and ordering still + work; ``str(x)`` / ``.label`` give the human label. Unknown wire codes are kept as raw ints + by the model validators (never crash). + + Scale derived from VideoIPath 2026.2.0 (built-in correlation templates + live alarms + sync + info); higher is worse. + """ + + NONE = 0 + OK = 1 + NOTICE = 2 + WARNING = 3 + MINOR = 4 + MAJOR = 5 + CRITICAL = 6 + + def __str__(self) -> str: + return self.label + + @property + def label(self) -> str: + return _SEVERITY_LABELS[self] + + +def map_severity(raw: Any) -> InspectSeverity | int | str | None: + """Known int → :class:`InspectSeverity`; ``None`` / str / unknown int pass through unchanged.""" + if isinstance(raw, InspectSeverity): + return raw + if isinstance(raw, int) and not isinstance(raw, bool) and raw in _SEVERITY_LABELS: + return InspectSeverity(raw) + return raw + + # The icon types selectable in the VideoIPath UI (mirrors the topology app's ``IconType``; live data # may contain further values, so read/write surfaces use the permissive ``InspectIconType | str``). InspectIconType = Literal[ @@ -140,8 +176,13 @@ class InspectApiCollection(InspectApiBaseModel): class InspectApiStatusSummary(InspectApiBaseModel): - sa: int | str | None = None - severity: int | str | None = None + sa: InspectSeverity | int | str | None = None + severity: InspectSeverity | int | str | None = None + + @field_validator("sa", "severity", mode="before") + @classmethod + def _map_severity_fields(cls, value: Any) -> Any: + return map_severity(value) class InspectApiStatusContext(InspectApiBaseModel): @@ -192,6 +233,19 @@ class InspectApiActionValidationErrorResponse(InspectApiBaseModel): header: InspectApiRestV2Header +# --- Internal --- + +_SEVERITY_LABELS: dict[InspectSeverity, str] = { + InspectSeverity.NONE: "None", + InspectSeverity.OK: "OK", + InspectSeverity.NOTICE: "Notice", + InspectSeverity.WARNING: "Warning", + InspectSeverity.MINOR: "Minor", + InspectSeverity.MAJOR: "Major", + InspectSeverity.CRITICAL: "Critical", +} + + __all__ = [ "InspectApiActionValidationErrorResponse", "InspectApiBaseModel", @@ -218,8 +272,10 @@ class InspectApiActionValidationErrorResponse(InspectApiBaseModel): "InspectMapCType", "InspectRedundancyMode", "InspectSdpStrategy", + "InspectSeverity", "InspectSipsMode", "InspectVertexKind", "InspectVertexType", + "map_severity", "_STAGED_MISSING", ] diff --git a/src/videoipath_automation_tool/apps/inspect/snapshot.py b/src/videoipath_automation_tool/apps/inspect/snapshot.py index 05c1924..a47c47d 100644 --- a/src/videoipath_automation_tool/apps/inspect/snapshot.py +++ b/src/videoipath_automation_tool/apps/inspect/snapshot.py @@ -22,6 +22,7 @@ from pydantic import Field +from videoipath_automation_tool.apps.inspect.model.alarms import InspectApiAlarmItem from videoipath_automation_tool.apps.inspect.model.collector import ( InspectApiCollectorResponse, InspectApiExternalEdgeLiveStatus, @@ -36,10 +37,12 @@ from videoipath_automation_tool.apps.inspect.model.common import ( InspectFrozenModel, InspectInternalModel, + InspectSeverity, _STAGED_MISSING, ) if TYPE_CHECKING: + from videoipath_automation_tool.apps.inspect.domain.alarm import InspectAlarm from videoipath_automation_tool.apps.inspect.domain.device import InspectDevice from videoipath_automation_tool.apps.inspect.domain.edge import InspectEdge from videoipath_automation_tool.apps.inspect.domain.module import InspectModule @@ -67,6 +70,7 @@ def __init__( *, device_level: HydrationLevel = HydrationLevel.SKELETON, path_items: Optional[list[InspectApiPathItem]] = None, + alarm_items: Optional[list[InspectApiAlarmItem]] = None, ) -> None: self._fetcher = fetcher self._lock = threading.RLock() @@ -96,9 +100,14 @@ def __init__( # Section: services / paths self._paths_by_booking_id: dict[str, InspectApiPathItem] = {} self._services_by_device_id: dict[str, list[str]] = {} - self._section_loaded: dict[str, bool] = {"paths": False} + self._section_loaded: dict[str, bool] = {"paths": False, "alarms": False} self._section_fetched_at: dict[str, datetime] = {} + # Section: current alarms (status/alarms/current), indexed by resource key + self._alarms: list[InspectApiAlarmItem] = [] + self._alarms_by_device_id: dict[str, list[InspectApiAlarmItem]] = {} + self._alarms_by_resource_key: dict[str, list[InspectApiAlarmItem]] = {} + # Domain-object caches self._device_cache: dict[str, "InspectDevice"] = {} self._module_cache: dict[tuple[str, str], "InspectModule"] = {} @@ -121,6 +130,10 @@ def __init__( self._index_paths(path_items) self._section_loaded["paths"] = True self._section_fetched_at["paths"] = self._created_at + if alarm_items is not None: + self._index_alarms(alarm_items) + self._section_loaded["alarms"] = True + self._section_fetched_at["alarms"] = self._created_at # --- Construction --- @@ -366,6 +379,38 @@ def get_services_for_device(self, device_id: str) -> list["InspectService"]: result.append(self._wrap_service(item)) return result + # --- Alarm reads (section, trigger section load) --- + + def get_alarms_for_device(self, device_id: str) -> list["InspectAlarm"]: + self._ensure_section_alarms() + return _sorted_alarms(self._alarms_by_device_id.get(device_id, [])) + + def get_alarms_for_resource(self, resource_key: str) -> list["InspectAlarm"]: + """Alarms whose joined ``pointId`` equals ``resource_key`` (module/port pid, edge id, …).""" + self._ensure_section_alarms() + return _sorted_alarms(self._alarms_by_resource_key.get(resource_key, [])) + + def get_alarms_for_module(self, device_id: str, module_id: str) -> list["InspectAlarm"]: + """Alarms whose joined ``pointId`` equals the module pid (device_id reserved for callers).""" + _ = device_id + return self.get_alarms_for_resource(module_id) + + def get_alarms_for_port(self, port_id: str | None, *, device_id: str | None = None) -> list["InspectAlarm"]: + _ = device_id + if not port_id: + return [] + return self.get_alarms_for_resource(port_id) + + def get_alarms_for_edge(self, edge_id: str, *, pair_id: str | None = None) -> list["InspectAlarm"]: + self._ensure_section_alarms() + items = list(self._alarms_by_resource_key.get(edge_id, [])) + if pair_id and pair_id != edge_id: + items.extend(self._alarms_by_resource_key.get(pair_id, [])) + return _sorted_alarms(items) + + def get_alarms_for_service(self, booking_id: str) -> list["InspectAlarm"]: + return self.get_alarms_for_resource(booking_id) + # --- Bulk preload (ADR-0004) --- def preload(self, devices: Optional[list[str]] = None) -> None: @@ -472,6 +517,7 @@ def apply_post_commit( self._try_refresh_edge_pair(pair_id) if mark_paths_stale: self._mark_paths_stale() + self._mark_alarms_stale() def apply_network_refresh(self, device_ids: list[str]) -> None: """Targeted refresh after a network action (addDevices / syncDevices): upsert the named @@ -488,6 +534,7 @@ def apply_network_refresh(self, device_ids: list[str]) -> None: self._try_refresh_device(device_id) self._reconcile_pairs_for_devices(affected) self._mark_paths_stale() + self._mark_alarms_stale() def upsert_devices_from_skeleton(self, device_ids: list[str]) -> None: """Insert or refresh named devices from one device-skeleton read. @@ -632,6 +679,13 @@ def _mark_paths_stale(self) -> None: self._paths_by_booking_id.clear() self._services_by_device_id.clear() + def _mark_alarms_stale(self) -> None: + with self._lock: + self._section_loaded["alarms"] = False + self._alarms.clear() + self._alarms_by_device_id.clear() + self._alarms_by_resource_key.clear() + def _upsert_device(self, device_id: str, detail: InspectApiNodeStatusItem) -> None: """Insert or replace a device record (FULL), keeping the label index and caches consistent.""" old = self._devices_by_id.get(device_id) @@ -665,6 +719,17 @@ def _ensure_section_paths(self) -> None: self._section_fetched_at["paths"] = _now() self._service_cache.clear() + def _ensure_section_alarms(self) -> None: + if self._section_loaded.get("alarms") or self._fetcher is None: + return + items = self._fetcher.get_alarms_section() + with self._lock: + if self._section_loaded.get("alarms"): + return + self._index_alarms(items) + self._section_loaded["alarms"] = True + self._section_fetched_at["alarms"] = _now() + # --- Internal: indexing --- def _index_device(self, node: InspectApiNodeStatusItem, level: HydrationLevel) -> None: @@ -804,6 +869,23 @@ def _index_paths(self, path_items: list[InspectApiPathItem]) -> None: if booking_id not in ids: ids.append(booking_id) + def _index_alarms(self, alarm_items: list[InspectApiAlarmItem]) -> None: + self._alarms = list(alarm_items) + self._alarms_by_device_id.clear() + self._alarms_by_resource_key.clear() + for item in alarm_items: + point_id = list(item.id.pointId) if item.id is not None else [] + if not point_id: + continue + device_id = point_id[0] + self._alarms_by_device_id.setdefault(device_id, []).append(item) + resource_key = ".".join(point_id) + self._alarms_by_resource_key.setdefault(resource_key, []).append(item) + # Edge pair / directed edge keys appear as a single pointId element containing "::". + for part in point_id: + if "::" in part: + self._alarms_by_resource_key.setdefault(part, []).append(item) + def _apply_removals(self, removed_ids: list[str]) -> None: if not removed_ids: return @@ -902,6 +984,26 @@ def _now() -> datetime: return datetime.now(timezone.utc) +def _severity_rank(value: Any) -> int: + """Sort key: higher severity first; unknown / missing treated as lowest.""" + if isinstance(value, InspectSeverity): + return int(value) + if isinstance(value, int) and not isinstance(value, bool): + return value + return -1 + + +def _sorted_alarms(items: list[InspectApiAlarmItem]) -> list["InspectAlarm"]: + from videoipath_automation_tool.apps.inspect.domain.alarm import InspectAlarm + + ordered = sorted( + items, + key=lambda item: _severity_rank(item.info.severity if item.info is not None else None), + reverse=True, + ) + return [InspectAlarm(item=item) for item in ordered] + + class _DeviceRecord(InspectInternalModel): device_id: str node: InspectApiNodeStatusItem diff --git a/tests/e2e/apps/test_inspect.py b/tests/e2e/apps/test_inspect.py index ac3829a..9fcf22d 100644 --- a/tests/e2e/apps/test_inspect.py +++ b/tests/e2e/apps/test_inspect.py @@ -18,6 +18,7 @@ from videoipath_automation_tool.apps.inspect import VirtualDeviceSpec from videoipath_automation_tool.apps.inspect.errors import InspectCommitConflictError, InspectCommitError +from videoipath_automation_tool.apps.inspect.model.common import InspectSeverity from videoipath_automation_tool.apps.videoipath_app import VideoIPathApp from ..helpers import ( @@ -36,6 +37,46 @@ pytestmark = pytest.mark.e2e +def test_status_severity_enums_and_device_alarms(app: VideoIPathApp, topology_builder: TopologyBuilder) -> None: + """Live severities parse to InspectSeverity; mock-driver alarms correlate onto their device.""" + (device_id,) = topology_builder.add_devices([("STATUS-A", 2)]) + app.inspect.refresh() + device = app.inspect.get_device(device_id) + assert device is not None + + known = set(InspectSeverity) + if device.status is not None: + if device.status.severity is not None: + assert device.status.severity in known, f"unmapped severity: {device.status.severity!r}" + if device.status.sa is not None: + assert device.status.sa in known, f"unmapped sa: {device.status.sa!r}" + if device.sync_severity is not None: + assert device.sync_severity in known, f"unmapped sync_severity: {device.sync_severity!r}" + + for edge in app.inspect.edges[:20]: + status = edge.status + if status is None: + continue + for value in (status.alarm, status.ptp, status.maintenance, status.bandwidth): + if value is None: + continue + assert value in known, f"unmapped edge status value: {value!r}" + + # Newly onboarded mocks may not have raised their driver notice yet; correlate against any + # device that currently carries the Mock alarm (verified shape on this server). + mock_carrier = None + for candidate in app.inspect.devices: + matches = [alarm for alarm in candidate.alarms if alarm.message == "Mock driver in use"] + if matches: + mock_carrier = (candidate, matches[0]) + break + if mock_carrier is None: + pytest.skip("No active 'Mock driver in use' alarm on this server right now.") + carrier, alarm = mock_carrier + assert alarm.severity is InspectSeverity.NOTICE + assert carrier.status_message == "Mock driver in use" + + def test_skeleton_read_no_hydration(app: VideoIPathApp, topology_builder: TopologyBuilder) -> None: id_a, id_b = topology_builder.add_devices([("SKEL-A", 2), ("SKEL-B", 2)]) topology_builder.link(id_a, id_b) diff --git a/tests/inspect/fixtures/2025.4.9/alarms_current.json b/tests/inspect/fixtures/2025.4.9/alarms_current.json new file mode 100644 index 0000000..67fa733 --- /dev/null +++ b/tests/inspect/fixtures/2025.4.9/alarms_current.json @@ -0,0 +1,135 @@ +{ + "data": { + "status": { + "alarms": { + "current": { + "_items": [ + { + "_id": "1:device-a.dev:Mock", + "_vid": "_:1:device-a.dev:Mock", + "acked": false, + "desc": { + "alertId": { + "desc": "", + "label": "" + }, + "pointId": [ + { + "desc": "Type: mock\nIP: 10.0.0.1", + "label": "Mock device 'device-a' [10.0.0.1]" + }, + { + "desc": "", + "label": "" + } + ] + }, + "hidden": false, + "history": [], + "id": { + "alertId": "Mock", + "component": 1, + "pointId": ["device-a", "dev"] + }, + "info": { + "details": "Mock driver in use", + "evtType": 1, + "headSeverity": 2, + "sa": 2, + "severity": 2, + "time": 1700000000000 + } + }, + { + "_id": "1:device-a.dev.module-1:PortAlarm", + "_vid": "_:1:device-a.dev.module-1:PortAlarm", + "acked": true, + "desc": { + "alertId": { + "desc": "", + "label": "Port Alarm" + }, + "pointId": [ + { + "desc": "", + "label": "device-a" + }, + { + "desc": "", + "label": "module-1" + } + ] + }, + "hidden": false, + "history": [], + "id": { + "alertId": "PortAlarm", + "component": 1, + "pointId": ["device-a", "dev", "module-1"] + }, + "info": { + "details": "Loss of protection", + "evtType": 1, + "headSeverity": 5, + "sa": 1, + "severity": 5, + "time": 1700000001000 + } + }, + { + "_id": "1:device-a.dev.module-1.port-out-1:Signal", + "_vid": "_:1:device-a.dev.module-1.port-out-1:Signal", + "acked": false, + "desc": { + "alertId": { + "desc": "", + "label": "Signal" + }, + "pointId": [ + { + "desc": "", + "label": "device-a" + }, + { + "desc": "", + "label": "module-1" + }, + { + "desc": "", + "label": "port-out-1" + } + ] + }, + "hidden": false, + "history": [], + "id": { + "alertId": "Signal", + "component": 1, + "pointId": ["device-a", "dev", "module-1", "port-out-1"] + }, + "info": { + "details": "Loss of disjunctivity", + "evtType": 1, + "headSeverity": 4, + "sa": 0, + "severity": 4, + "time": 1700000002000 + } + } + ] + } + } + } + }, + "header": { + "auth": true, + "caption": "OK", + "code": "OK", + "errorCodes": [], + "errorDetails": [], + "id": "alarms-fixture", + "msg": [], + "ok": true, + "user": "test-user" + } +} diff --git a/tests/inspect/test_exports.py b/tests/inspect/test_exports.py index 8c791c1..e348479 100644 --- a/tests/inspect/test_exports.py +++ b/tests/inspect/test_exports.py @@ -8,6 +8,7 @@ from videoipath_automation_tool.apps.inspect import model from videoipath_automation_tool.apps.inspect.model import ( actions, + alarms, collector, common, ngraph, @@ -20,6 +21,7 @@ def test_model_package_all_aggregates_submodules() -> None: expected = { *actions.__all__, + *alarms.__all__, *collector.__all__, *common.__all__, *ngraph.__all__, diff --git a/tests/inspect/test_models.py b/tests/inspect/test_models.py index 6f56730..4ca43a3 100644 --- a/tests/inspect/test_models.py +++ b/tests/inspect/test_models.py @@ -12,14 +12,21 @@ InspectApiLookupVertexResponse, InspectApiLookupVerticesResponse, ) +from videoipath_automation_tool.apps.inspect.model.alarms import InspectApiAlarmItem from videoipath_automation_tool.apps.inspect.model.collector import ( InspectApiDoubleVertexInfo, + InspectApiExternalEdgeLiveStatus, InspectApiExternalEdgesByDeviceKeyItem, InspectApiNodeStatusItem, InspectApiPathItem, InspectApiSingleVertexInfo, InspectPortStatus, ) +from videoipath_automation_tool.apps.inspect.model.common import ( + InspectApiStatusSummary, + InspectSeverity, + map_severity, +) from videoipath_automation_tool.apps.inspect.model.update_topology import ( InspectApiUpdateTopologyData, InspectApiUpdateTopologyResponse, @@ -232,6 +239,63 @@ def test_port_assigned_tags_from_tags_info() -> None: assert InspectPortStatus.model_validate({"_id": "device-a.1.p2"}).assigned_tags == [] +def test_inspect_severity_labels_and_int_compat() -> None: + assert InspectSeverity.OK == 1 + assert int(InspectSeverity.CRITICAL) == 6 + assert str(InspectSeverity.NOTICE) == "Notice" + assert InspectSeverity.MAJOR.label == "Major" + assert InspectSeverity.NONE < InspectSeverity.OK < InspectSeverity.CRITICAL + + +def test_map_severity_known_unknown_and_passthrough() -> None: + assert map_severity(0) is InspectSeverity.NONE + assert map_severity(6) is InspectSeverity.CRITICAL + assert map_severity(99) == 99 + assert map_severity("ok") == "ok" + assert map_severity(None) is None + assert map_severity(InspectSeverity.MINOR) is InspectSeverity.MINOR + + +def test_status_summary_maps_severity_fields() -> None: + summary = InspectApiStatusSummary.model_validate({"sa": 0, "severity": 1}) + assert summary.sa is InspectSeverity.NONE + assert summary.severity is InspectSeverity.OK + assert summary.severity == 1 + + unknown = InspectApiStatusSummary.model_validate({"sa": 99, "severity": None}) + assert unknown.sa == 99 + assert unknown.severity is None + + +def test_edge_live_status_and_sync_severity_map() -> None: + live = InspectApiExternalEdgeLiveStatus.model_validate( + {"alarm": 1, "bandwidth": None, "maintenance": None, "ptp": 1} + ) + assert live.alarm is InspectSeverity.OK + assert live.ptp is InspectSeverity.OK + assert live.bandwidth is None + + node = InspectApiNodeStatusItem.model_validate({"_id": "device-a", "syncSeverity": 2}) + assert node.syncSeverity is InspectSeverity.NOTICE + + +def test_alarm_item_parses_and_maps_severity(load: Callable[[str], dict[str, Any]]) -> None: + items = load("alarms_current.json")["data"]["status"]["alarms"]["current"]["_items"] + alarm = InspectApiAlarmItem.model_validate(items[0]) + assert alarm.id_field == "1:device-a.dev:Mock" + assert alarm.id is not None and alarm.id.pointId == ["device-a", "dev"] + assert alarm.info is not None + assert alarm.info.details == "Mock driver in use" + assert alarm.info.severity is InspectSeverity.NOTICE + assert alarm.info.sa is InspectSeverity.NOTICE + assert alarm.acked is False + + major = InspectApiAlarmItem.model_validate(items[1]) + assert major.info is not None + assert major.info.severity is InspectSeverity.MAJOR + assert major.info.details == "Loss of protection" + + # --- Internal --- diff --git a/tests/inspect/test_queries.py b/tests/inspect/test_queries.py index b5e26f9..78d7922 100644 --- a/tests/inspect/test_queries.py +++ b/tests/inspect/test_queries.py @@ -13,6 +13,7 @@ def test_all_queries_within_length_limit() -> None: queries.device_skeleton, queries.edge_skeleton, queries.paths_section, + queries.alarms_section, queries.collector_full, queries.virtual_templates, queries.virtual_devices, @@ -27,6 +28,13 @@ def test_collector_queries_use_collector_namespace() -> None: assert build().startswith("/rest/v2/data/status/collector/") +def test_alarms_query_uses_alarms_namespace() -> None: + path = urllib.parse.unquote(queries.alarms_section()) + assert path.startswith("/rest/v2/data/status/alarms/current/") + for field in ("acked", "hidden", "id", "desc", "info"): + assert field in path + + def test_virtual_queries_use_network_namespace() -> None: assert "/status/network/virtualTemplates/**" in queries.virtual_templates() assert "/status/network/virtualDevices/**" in queries.virtual_devices() diff --git a/tests/inspect/test_snapshot.py b/tests/inspect/test_snapshot.py index 4ac676e..25d7ca7 100644 --- a/tests/inspect/test_snapshot.py +++ b/tests/inspect/test_snapshot.py @@ -11,11 +11,13 @@ InspectApiLookupEdgesResponse, InspectApiLookupVerticesResponse, ) +from videoipath_automation_tool.apps.inspect.model.alarms import InspectApiAlarmItem from videoipath_automation_tool.apps.inspect.model.collector import ( InspectApiExternalEdgesByDeviceKeyItem, InspectApiNodeStatusItem, InspectApiPathItem, ) +from videoipath_automation_tool.apps.inspect.model.common import InspectSeverity from videoipath_automation_tool.apps.inspect.snapshot import HydrationLevel, InspectSnapshot from .conftest import load_fixture @@ -84,6 +86,57 @@ def test_services_section_is_lazy(snapshot: tuple[InspectSnapshot, FakeFetcher]) assert {d.id for d in snap.get_services_for_device("leaf-a")[0].path_devices} == {"leaf-a", "spine-a"} +def test_alarms_section_is_lazy_and_correlates(snapshot: tuple[InspectSnapshot, FakeFetcher]) -> None: + snap, fetcher = snapshot + fetcher._alarms = [ + InspectApiAlarmItem.model_validate(raw) + for raw in load_fixture("alarms_current.json")["data"]["status"]["alarms"]["current"]["_items"] + ] + # Remap fixture device-a alarms onto leaf-a for this snapshot's devices. + for item in fetcher._alarms: + assert item.id is not None + item.id.pointId = ["leaf-a", *item.id.pointId[1:]] + + assert fetcher.alarm_section_calls == 0 + device = snap.get_device("leaf-a") + alarms = device.alarms + assert fetcher.alarm_section_calls == 1 + assert len(alarms) == 3 + assert alarms[0].severity == InspectSeverity.MAJOR # worst first + assert alarms[0].message == "Loss of protection" + assert device.status_message == "Loss of protection" + _ = device.alarms + assert fetcher.alarm_section_calls == 1 + + module_alarms = snap.get_alarms_for_module("leaf-a", "leaf-a.dev.module-1") + assert len(module_alarms) == 1 + assert module_alarms[0].message == "Loss of protection" + + port_alarms = snap.get_alarms_for_port("leaf-a.dev.module-1.port-out-1") + assert len(port_alarms) == 1 + assert port_alarms[0].message == "Loss of disjunctivity" + assert port_alarms[0].severity == InspectSeverity.MINOR + + +def test_post_commit_marks_alarms_stale(snapshot: tuple[InspectSnapshot, FakeFetcher]) -> None: + snap, fetcher = snapshot + fetcher._alarms = [ + InspectApiAlarmItem.model_validate( + { + "_id": "1:leaf-a.dev:Mock", + "acked": False, + "id": {"alertId": "Mock", "component": 1, "pointId": ["leaf-a", "dev"]}, + "info": {"details": "Mock driver in use", "severity": 2, "sa": 2}, + } + ) + ] + assert len(snap.get_device("leaf-a").alarms) == 1 + assert fetcher.alarm_section_calls == 1 + snap.apply_post_commit(mark_paths_stale=True) + assert len(snap.get_device("leaf-a").alarms) == 1 + assert fetcher.alarm_section_calls == 2 + + def test_linked_devices_from_edges(snapshot: tuple[InspectSnapshot, FakeFetcher]) -> None: snap, _ = snapshot assert {d.id for d in snap.get_device("leaf-a").linked_devices} == {"spine-a"} @@ -473,12 +526,14 @@ def __init__(self) -> None: self.vertex_lookup_calls: list[list[str]] = [] self.edge_lookup_calls: list[list[str]] = [] self.section_calls = 0 + self.alarm_section_calls = 0 self.skeleton_calls = 0 self._details = { "spine-a": _detail_node("spine-a", "SPINE-A", ["spine-a.dev.0.swp1", "spine-a.dev.0.swp2"]), "leaf-a": _detail_node("leaf-a", "LEAF-A", ["leaf-a.dev.0.up1", "leaf-a.dev.0.host1"]), } self._paths = [_path_item("1001", "leaf-a", "spine-a")] + self._alarms: list[InspectApiAlarmItem] = [] def get_device_skeleton(self) -> list[InspectApiNodeStatusItem]: self.skeleton_calls += 1 @@ -500,6 +555,10 @@ def get_paths_section(self) -> list[InspectApiPathItem]: self.section_calls += 1 return self._paths + def get_alarms_section(self) -> list[InspectApiAlarmItem]: + self.alarm_section_calls += 1 + return list(self._alarms) + def lookup_vertices(self, vertex_ids: list[str]) -> InspectApiLookupVerticesResponse: self.vertex_lookup_calls.append(list(vertex_ids)) data = {vertex_id: _vertex_lookup_item(vertex_id) for vertex_id in vertex_ids} From aad72c97b1e55507302a86fb30af59cbba337214 Mon Sep 17 00:00:00 2001 From: Jonas Scholl <43819845+JonasScholl@users.noreply.github.com> Date: Thu, 23 Jul 2026 22:09:22 +0200 Subject: [PATCH 31/34] repr and str for domain classes --- .../apps/inspect/api/queries.py | 6 +- .../apps/inspect/domain/alarm.py | 12 +- .../apps/inspect/domain/device.py | 16 ++ .../apps/inspect/domain/edge.py | 11 + .../apps/inspect/domain/module.py | 16 ++ .../apps/inspect/domain/port.py | 27 +++ .../apps/inspect/domain/service.py | 7 +- .../apps/inspect/domain/vertex.py | 10 + .../apps/inspect/model/common.py | 17 ++ .../apps/inspect/snapshot.py | 35 +++ .../apps/inspect/transaction.py | 20 ++ tests/inspect/test_repr.py | 217 ++++++++++++++++++ 12 files changed, 387 insertions(+), 7 deletions(-) create mode 100644 tests/inspect/test_repr.py diff --git a/src/videoipath_automation_tool/apps/inspect/api/queries.py b/src/videoipath_automation_tool/apps/inspect/api/queries.py index 8ab28a8..583d45d 100644 --- a/src/videoipath_automation_tool/apps/inspect/api/queries.py +++ b/src/videoipath_automation_tool/apps/inspect/api/queries.py @@ -127,11 +127,7 @@ def virtual_devices() -> str: # Verified 2026.2.0: two `/.../` pops after each selected sub-tree (same grammar as the collector # skeleton); three pops omit ``info``. _ALARMS_SECTION = ( - "/status/alarms/current/*" - "/acked,hidden" - "/.../id/**" - "/.../.../desc/**" - "/.../.../info/details,severity,sa,headSeverity,time" + "/status/alarms/current/*/acked,hidden/.../id/**/.../.../desc/**/.../.../info/details,severity,sa,headSeverity,time" ) # Full aggregate (eager / fallback mode). diff --git a/src/videoipath_automation_tool/apps/inspect/domain/alarm.py b/src/videoipath_automation_tool/apps/inspect/domain/alarm.py index d15cd64..8214225 100644 --- a/src/videoipath_automation_tool/apps/inspect/domain/alarm.py +++ b/src/videoipath_automation_tool/apps/inspect/domain/alarm.py @@ -1,7 +1,9 @@ from __future__ import annotations from videoipath_automation_tool.apps.inspect.model.alarms import InspectApiAlarmItem -from videoipath_automation_tool.apps.inspect.model.common import InspectFrozenModel, InspectSeverity +from videoipath_automation_tool.apps.inspect.model.common import InspectFrozenModel, InspectSeverity, format_repr + +_ALARM_MESSAGE_REPR_LIMIT = 60 class InspectAlarm(InspectFrozenModel): @@ -68,5 +70,13 @@ def point_labels(self) -> list[str]: return [] return [entry.label for entry in desc.pointId if entry.label] + def __repr__(self) -> str: + message = self.message + if message is not None and len(message) > _ALARM_MESSAGE_REPR_LIMIT: + message = message[:_ALARM_MESSAGE_REPR_LIMIT] + "…" + return format_repr(self, id=self.id, severity=self.severity, message=message) + + __str__ = __repr__ + __all__ = ["InspectAlarm"] diff --git a/src/videoipath_automation_tool/apps/inspect/domain/device.py b/src/videoipath_automation_tool/apps/inspect/domain/device.py index d25658f..1213ca8 100644 --- a/src/videoipath_automation_tool/apps/inspect/domain/device.py +++ b/src/videoipath_automation_tool/apps/inspect/domain/device.py @@ -17,6 +17,7 @@ InspectSeverity, InspectVertexKind, InspectVertexType, + format_repr, ) from videoipath_automation_tool.apps.inspect.model.virtual import InspectApiVirtualDeviceWriteBody from videoipath_automation_tool.apps.inspect.snapshot import InspectSnapshot @@ -321,6 +322,16 @@ def services(self) -> list[InspectService]: def linked_devices(self) -> list[InspectDevice]: return self.snapshot.get_linked_devices(self.id) + def __repr__(self) -> str: + return format_repr( + self, + id=self.id, + label=lambda: self._record().label, + virtual=True if is_virtual_device_id(self.id) else None, + ) + + __str__ = __repr__ + def _record(self) -> "_DeviceRecord": record = self.snapshot.get_device_record(self.id) if record is None: @@ -374,6 +385,11 @@ def add_port(self, template_id: str, count: int = 1, *, module_index: int = -1) def to_wire(self) -> InspectApiVirtualDeviceWriteBody: return InspectApiVirtualDeviceWriteBody(modules=[module.to_wire() for module in self.modules]) + def __repr__(self) -> str: + return format_repr(self, modules=len(self.modules)) + + __str__ = __repr__ + @classmethod def from_ports(cls, *ports: PortFromTemplate | tuple[str, int] | str) -> VirtualDeviceSpec: """Build a single-module device from port specs.""" diff --git a/src/videoipath_automation_tool/apps/inspect/domain/edge.py b/src/videoipath_automation_tool/apps/inspect/domain/edge.py index c20af05..6272f5c 100644 --- a/src/videoipath_automation_tool/apps/inspect/domain/edge.py +++ b/src/videoipath_automation_tool/apps/inspect/domain/edge.py @@ -9,6 +9,7 @@ InspectConfigPriority, InspectEditableModel, InspectRedundancyMode, + format_repr, ) from videoipath_automation_tool.apps.inspect.snapshot import InspectSnapshot, _IndexedEdge @@ -256,6 +257,16 @@ def weight_per_service(self) -> int | None: def weight_per_service(self, value: int) -> None: self._stage("weightFactors.service.weight", value) + def __repr__(self) -> str: + return format_repr( + self, + id=self.id, + from_device=self.indexed.from_device_id, + to_device=self.indexed.to_device_id, + ) + + __str__ = __repr__ + def _edit_form(self) -> InspectApiEdgeForm | None: return self.snapshot.get_edge_details(self.id) diff --git a/src/videoipath_automation_tool/apps/inspect/domain/module.py b/src/videoipath_automation_tool/apps/inspect/domain/module.py index 1133fe5..8467429 100644 --- a/src/videoipath_automation_tool/apps/inspect/domain/module.py +++ b/src/videoipath_automation_tool/apps/inspect/domain/module.py @@ -9,6 +9,7 @@ InspectApiStatusSummary, InspectEditableModel, InspectInternalModel, + format_repr, ) from videoipath_automation_tool.apps.inspect.model.virtual import InspectApiVirtualModule from videoipath_automation_tool.apps.inspect.snapshot import InspectSnapshot, _STAGED_MISSING @@ -33,6 +34,11 @@ def to_wire(self) -> InspectApiVirtualModule: vertices=[port.to_wire() for port in self.ports], ) + def __repr__(self) -> str: + return format_repr(self, ports=len(self.ports), module_number=self.module_number) + + __str__ = __repr__ + class InspectModule(InspectEditableModel): """A device module / slot. A module owns many ports, each of which carries one or more vertices, so a module @@ -113,6 +119,16 @@ def vertices(self) -> list[InspectVertex]: typed kind).""" return [vertex for port in self.ports for vertex in port._vertices()] + def __repr__(self) -> str: + return format_repr( + self, + id=self.id, + device=self.device_id, + label=lambda: status.effective_label if (status := self._status()) is not None else None, + ) + + __str__ = __repr__ + def _status(self) -> InspectApiModuleStatus | None: return self.snapshot.get_module_status(self.device_id, self.module_id) diff --git a/src/videoipath_automation_tool/apps/inspect/domain/port.py b/src/videoipath_automation_tool/apps/inspect/domain/port.py index f8680be..37d4b26 100644 --- a/src/videoipath_automation_tool/apps/inspect/domain/port.py +++ b/src/videoipath_automation_tool/apps/inspect/domain/port.py @@ -12,6 +12,7 @@ InspectCodecFormat, InspectFrozenModel, InspectVertexType, + format_repr, ) from videoipath_automation_tool.apps.inspect.model.ngraph import InspectApiNGraphElementType from videoipath_automation_tool.apps.inspect.model.virtual import ( @@ -50,6 +51,11 @@ def _validate_count(self) -> Self: def to_wire(self) -> InspectApiVirtualPortFromTemplate: return InspectApiVirtualPortFromTemplate(templateId=self.template_id, count=self.count) + def __repr__(self) -> str: + return format_repr(self, template_id=self.template_id, count=self.count) + + __str__ = __repr__ + class InspectPortTemplate(InspectFrozenModel): """A port template (UI term; API: virtual template).""" @@ -73,6 +79,17 @@ def from_wire(cls, wire: InspectApiVirtualTemplateItem) -> InspectPortTemplate: vertex=vertex, ) + def __repr__(self) -> str: + return format_repr( + self, + id=self.id, + label=self.label, + kind=self.kind, + direction=self.direction, + ) + + __str__ = __repr__ + class InspectPort(InspectFrozenModel): """A port (the Inspect UI's "Module" edit modal): a lean container that owns one or more vertices. @@ -159,6 +176,16 @@ def edges(self) -> list[InspectEdge]: return [] return self.snapshot.get_edges_for_port(self.indexed.device_id, port_id) + def __repr__(self) -> str: + return format_repr( + self, + id=self.id, + label=self.label, + device=self.indexed.device_id, + ) + + __str__ = __repr__ + def _vertex_by_type(self, vertex_type: str) -> InspectVertex | None: for vid, side in self._vertex_sides(): if side.vertexType == vertex_type: diff --git a/src/videoipath_automation_tool/apps/inspect/domain/service.py b/src/videoipath_automation_tool/apps/inspect/domain/service.py index 8032214..9b59bbc 100644 --- a/src/videoipath_automation_tool/apps/inspect/domain/service.py +++ b/src/videoipath_automation_tool/apps/inspect/domain/service.py @@ -3,7 +3,7 @@ from typing import TYPE_CHECKING from videoipath_automation_tool.apps.inspect.model.collector import InspectApiPathItem, InspectServiceStatus -from videoipath_automation_tool.apps.inspect.model.common import InspectFrozenModel +from videoipath_automation_tool.apps.inspect.model.common import InspectFrozenModel, format_repr from videoipath_automation_tool.apps.inspect.snapshot import InspectSnapshot, _port_id_from_endpoint if TYPE_CHECKING: @@ -107,6 +107,11 @@ def path_ports(self) -> list[InspectPort]: seen.add(key) return ports + def __repr__(self) -> str: + return format_repr(self, booking_id=self.booking_id, label=self.label) + + __str__ = __repr__ + def _resolve_endpoint_port(self, port_id: str | None) -> InspectPort | None: if not port_id: return None diff --git a/src/videoipath_automation_tool/apps/inspect/domain/vertex.py b/src/videoipath_automation_tool/apps/inspect/domain/vertex.py index 7abb08e..cd14cf2 100644 --- a/src/videoipath_automation_tool/apps/inspect/domain/vertex.py +++ b/src/videoipath_automation_tool/apps/inspect/domain/vertex.py @@ -32,6 +32,7 @@ InspectVertexKind, InspectVertexType, _STAGED_MISSING, + format_repr, ) from videoipath_automation_tool.apps.inspect.snapshot import InspectSnapshot @@ -256,6 +257,15 @@ def park_port(self, value: int) -> None: def type_fields(self) -> InspectApiVertexTypeFields | None: return self._form_get("typeFields") + def __repr__(self) -> str: + return format_repr( + self, + id=self.id, + type=self.vertex_info.vertexType if self.vertex_info is not None else None, + ) + + __str__ = __repr__ + # --- Internal --- def _info_flag(self, name: str) -> bool | None: diff --git a/src/videoipath_automation_tool/apps/inspect/model/common.py b/src/videoipath_automation_tool/apps/inspect/model/common.py index 3078d55..14ccf11 100644 --- a/src/videoipath_automation_tool/apps/inspect/model/common.py +++ b/src/videoipath_automation_tool/apps/inspect/model/common.py @@ -113,6 +113,22 @@ def map_severity(raw: Any) -> InspectSeverity | int | str | None: InspectMapCType = Literal["Topology", "Geo"] +def format_repr(obj: object, /, **fields: Any) -> str: + """Concise ``ClassName(k=v, ...)`` repr. Callable values are evaluated defensively + (skipped on error); None values are omitted. Never raises.""" + parts: list[str] = [] + for key, value in fields.items(): + if callable(value): + try: + value = value() + except Exception: + continue + if value is None: + continue + parts.append(f"{key}={value!r}") + return f"{type(obj).__name__}({', '.join(parts)})" + + class InspectApiBaseModel(BaseModel): model_config = ConfigDict(populate_by_name=True, validate_assignment=True, extra="allow") @@ -276,6 +292,7 @@ class InspectApiActionValidationErrorResponse(InspectApiBaseModel): "InspectSipsMode", "InspectVertexKind", "InspectVertexType", + "format_repr", "map_severity", "_STAGED_MISSING", ] diff --git a/src/videoipath_automation_tool/apps/inspect/snapshot.py b/src/videoipath_automation_tool/apps/inspect/snapshot.py index a47c47d..ffb45f9 100644 --- a/src/videoipath_automation_tool/apps/inspect/snapshot.py +++ b/src/videoipath_automation_tool/apps/inspect/snapshot.py @@ -39,6 +39,7 @@ InspectInternalModel, InspectSeverity, _STAGED_MISSING, + format_repr, ) if TYPE_CHECKING: @@ -135,6 +136,15 @@ def __init__( self._section_loaded["alarms"] = True self._section_fetched_at["alarms"] = self._created_at + def __repr__(self) -> str: + return format_repr( + self, + devices=len(self._devices_by_id), + edge_pairs=len(self._edge_pairs), + ) + + __str__ = __repr__ + # --- Construction --- @classmethod @@ -1018,12 +1028,27 @@ def label(self) -> str | None: def pid(self) -> str | None: return self.node.pid or self.node.deviceId + def __repr__(self) -> str: + return format_repr(self, device_id=self.device_id, level=self.level) + + __str__ = __repr__ + class _IndexedPort(InspectFrozenModel): device_id: str module_id: str | None port: InspectPortStatus + def __repr__(self) -> str: + return format_repr( + self, + device_id=self.device_id, + module_id=self.module_id, + port_id=_port_id_from_status(self.port), + ) + + __str__ = __repr__ + class _IndexedEdge(InspectFrozenModel): edge_id: str @@ -1037,6 +1062,16 @@ class _IndexedEdge(InspectFrozenModel): to_device_id: str | None to_port_id: str | None + def __repr__(self) -> str: + return format_repr( + self, + edge_id=self.edge_id, + from_device_id=self.from_device_id, + to_device_id=self.to_device_id, + ) + + __str__ = __repr__ + # --- Module-level helpers (kept stable for the domain layer) --- diff --git a/src/videoipath_automation_tool/apps/inspect/transaction.py b/src/videoipath_automation_tool/apps/inspect/transaction.py index d0a5ddc..6187f7c 100644 --- a/src/videoipath_automation_tool/apps/inspect/transaction.py +++ b/src/videoipath_automation_tool/apps/inspect/transaction.py @@ -55,6 +55,7 @@ InspectRedundancyMode, InspectSdpStrategy, InspectSipsMode, + format_repr, ) from videoipath_automation_tool.apps.inspect.model.tags import module_resource_id from videoipath_automation_tool.apps.inspect.model.update_topology import ( @@ -94,6 +95,15 @@ def ok(self) -> bool: def validation(self) -> Any: return self.response.data.validation if self.response is not None else None + def __repr__(self) -> str: + return format_repr( + self, + applied=len(self.applied_ids), + created=len(self.created_ids), + ) + + __str__ = __repr__ + class InspectTransaction: """Single-use, atomic batch of Inspect topology changes. @@ -143,6 +153,16 @@ def staged(self) -> list[tuple[str, str]]: def __len__(self) -> int: return len(self._entries) + def __repr__(self) -> str: + return format_repr( + self, + staged=len(self._entries), + committed=self._committed or None, + discarded=self._discarded or None, + ) + + __str__ = __repr__ + # --- Staging: domain objects --- def update(self, obj: Editable | Sequence[Editable]) -> "InspectTransaction": diff --git a/tests/inspect/test_repr.py b/tests/inspect/test_repr.py new file mode 100644 index 0000000..66316c1 --- /dev/null +++ b/tests/inspect/test_repr.py @@ -0,0 +1,217 @@ +"""Concise, side-effect-free __repr__/__str__ for Inspect developer-facing classes.""" + +from __future__ import annotations + +from types import SimpleNamespace + +import pytest + +from videoipath_automation_tool.apps.inspect.domain.alarm import InspectAlarm +from videoipath_automation_tool.apps.inspect.domain.device import InspectDevice, VirtualDeviceSpec +from videoipath_automation_tool.apps.inspect.domain.module import VirtualModuleSpec +from videoipath_automation_tool.apps.inspect.domain.port import InspectPortTemplate, PortFromTemplate +from videoipath_automation_tool.apps.inspect.domain.vertex import InspectVertex +from videoipath_automation_tool.apps.inspect.model.alarms import InspectApiAlarmItem +from videoipath_automation_tool.apps.inspect.model.collector import InspectApiSingleVertexInfo +from videoipath_automation_tool.apps.inspect.snapshot import InspectSnapshot +from videoipath_automation_tool.apps.inspect.transaction import CommitResult, InspectTransaction + +from .conftest import load_fixture +from .test_snapshot import FakeFetcher + + +def _assert_clean_repr(obj: object, *, class_name: str, contains: str) -> str: + text = repr(obj) + assert text.startswith(f"{class_name}(") + assert contains in text + assert "InspectSnapshot object" not in text + assert "path_item=" not in text + assert "_items" not in text + assert str(obj) == text + return text + + +@pytest.fixture +def snap() -> tuple[InspectSnapshot, FakeFetcher]: + fetcher = FakeFetcher() + snapshot = InspectSnapshot( + fetcher=fetcher, + device_items=fetcher.get_device_skeleton(), + edge_items=fetcher.get_edge_skeleton(), + ) + fetcher.skeleton_calls = 0 + return snapshot, fetcher + + +def test_device_repr_is_concise_and_does_not_hydrate(snap: tuple[InspectSnapshot, FakeFetcher]) -> None: + snapshot, fetcher = snap + device = snapshot.get_device("leaf-a") + assert device is not None + text = _assert_clean_repr(device, class_name="InspectDevice", contains="leaf-a") + assert "label='LEAF-A'" in text + assert "virtual=" not in text + assert device.is_hydrated is False + assert fetcher.device_detail_calls == [] + + +def test_device_repr_marks_virtual_id() -> None: + fetcher = FakeFetcher() + snapshot = InspectSnapshot(fetcher=fetcher, device_items=[], edge_items=[]) + device = InspectDevice(snapshot=snapshot, id="virtual.2") + text = repr(device) + assert text.startswith("InspectDevice(") + assert "id='virtual.2'" in text + assert "virtual=True" in text + assert str(device) == text + + +def test_device_repr_survives_missing_record(snap: tuple[InspectSnapshot, FakeFetcher]) -> None: + snapshot, _ = snap + device = snapshot.get_device("leaf-a") + assert device is not None + snapshot._devices_by_id.pop("leaf-a") + text = repr(device) + assert text.startswith("InspectDevice(") + assert "id='leaf-a'" in text + assert "label=" not in text + assert str(device) == text + + +def test_port_module_edge_service_vertex_reprs(snap: tuple[InspectSnapshot, FakeFetcher]) -> None: + snapshot, _ = snap + device = snapshot.get_device("leaf-a") + assert device is not None + + ports = device.ports + assert ports + port = ports[0] + _assert_clean_repr(port, class_name="InspectPort", contains=port.id or "") + assert f"device='{port.indexed.device_id}'" in repr(port) + + modules = device.modules + assert modules + module = modules[0] + text = _assert_clean_repr(module, class_name="InspectModule", contains=module.id) + assert "device='leaf-a'" in text + + edge = snapshot.edges[0] + text = _assert_clean_repr(edge, class_name="InspectEdge", contains=edge.id) + assert "from_device=" in text and "to_device=" in text + + services = snapshot.services + assert services + service = services[0] + text = _assert_clean_repr(service, class_name="InspectService", contains=service.booking_id) + assert "path_item=" not in text + + vertex = port._offline_vertices()[0] + text = _assert_clean_repr(vertex, class_name="InspectVertex", contains=vertex.id) + assert "type=" in text + + +def test_typed_vertex_repr_uses_subclass_name(snap: tuple[InspectSnapshot, FakeFetcher]) -> None: + snapshot, _ = snap + vertex = snapshot.get_vertex("leaf-a.0.up1") + assert vertex is not None + text = _assert_clean_repr(vertex, class_name="InspectIpVertex", contains=vertex.id) + assert text.startswith("InspectIpVertex(") + + +def test_snapshot_repr(snap: tuple[InspectSnapshot, FakeFetcher]) -> None: + snapshot, _ = snap + text = _assert_clean_repr(snapshot, class_name="InspectSnapshot", contains="devices=2") + assert "edge_pairs=1" in text + + +def test_internal_index_record_reprs(snap: tuple[InspectSnapshot, FakeFetcher]) -> None: + snapshot, _ = snap + device = snapshot.get_device("leaf-a") + assert device is not None + _ = device.ports + + record = snapshot.get_device_record("leaf-a") + assert record is not None + text = _assert_clean_repr(record, class_name="_DeviceRecord", contains="leaf-a") + assert "level=" in text + + indexed_port = snapshot._ports_by_device_id["leaf-a"][0] + text = _assert_clean_repr(indexed_port, class_name="_IndexedPort", contains="leaf-a") + assert "port_id=" in text + + indexed_edge = snapshot._edges_by_device_id["leaf-a"][0] + text = _assert_clean_repr(indexed_edge, class_name="_IndexedEdge", contains=indexed_edge.edge_id) + assert "from_device_id=" in text + + +def test_builder_reprs() -> None: + port = PortFromTemplate(template_id="tpl-a", count=3) + text = _assert_clean_repr(port, class_name="PortFromTemplate", contains="tpl-a") + assert "count=3" in text + + module = VirtualModuleSpec(ports=[port], module_number=1) + text = _assert_clean_repr(module, class_name="VirtualModuleSpec", contains="ports=1") + assert "module_number=1" in text + + device_spec = VirtualDeviceSpec(modules=[module, VirtualModuleSpec()]) + text = _assert_clean_repr(device_spec, class_name="VirtualDeviceSpec", contains="modules=2") + + template = InspectPortTemplate(id="tpl-a", label="Port A", kind="ip", direction="Out", vertex={"type": "ip"}) + text = _assert_clean_repr(template, class_name="InspectPortTemplate", contains="tpl-a") + assert "vertex=" not in text + assert "kind='ip'" in text + + +def test_alarm_repr_truncates_long_message() -> None: + item = InspectApiAlarmItem.model_validate( + load_fixture("alarms_current.json")["data"]["status"]["alarms"]["current"]["_items"][0] + ) + alarm = InspectAlarm(item=item) + text = _assert_clean_repr(alarm, class_name="InspectAlarm", contains=alarm.id or "") + assert "severity=" in text + assert "message=" in text + + long_details = "x" * 80 + long_item = InspectApiAlarmItem.model_validate( + { + "_id": "alarm-long", + "info": {"details": long_details, "severity": 3}, + } + ) + long_alarm = InspectAlarm(item=long_item) + text = repr(long_alarm) + assert "…" in text + assert long_details not in text + + +def test_transaction_and_commit_result_reprs() -> None: + tx = InspectTransaction(api=SimpleNamespace()) + text = _assert_clean_repr(tx, class_name="InspectTransaction", contains="staged=0") + assert "committed=" not in text + assert "discarded=" not in text + + tx._committed = True + assert "committed=True" in repr(tx) + + result = CommitResult(applied_ids=["device-a", "device-b"], created_ids=["virtual.1"]) + text = _assert_clean_repr(result, class_name="CommitResult", contains="applied=2") + assert "created=1" in text + assert "response=" not in text + + +def test_format_repr_skips_failing_callables() -> None: + from videoipath_automation_tool.apps.inspect.model.common import format_repr + + class _Probe: + pass + + text = format_repr(_Probe(), id="a", label=lambda: (_ for _ in ()).throw(RuntimeError("boom")), extra=None) + assert text == "_Probe(id='a')" + + +def test_offline_vertex_repr_without_lookup(snap: tuple[InspectSnapshot, FakeFetcher]) -> None: + snapshot, fetcher = snap + info = InspectApiSingleVertexInfo.model_validate({"id": "leaf-a.0.up1", "vertexType": "Out"}) + vertex = InspectVertex(snapshot=snapshot, id="leaf-a.0.up1", vertex_info=info) + text = _assert_clean_repr(vertex, class_name="InspectVertex", contains="leaf-a.0.up1") + assert "type='Out'" in text + assert fetcher.vertex_lookup_calls == [] From 6156b6ea5e3e62d81082f702fda9a5353754d70c Mon Sep 17 00:00:00 2001 From: Jonas Scholl <43819845+JonasScholl@users.noreply.github.com> Date: Thu, 23 Jul 2026 22:18:48 +0200 Subject: [PATCH 32/34] cleanup --- .../apps/inspect/domain/device.py | 16 +++---- .../apps/inspect/domain/module.py | 8 ++-- .../apps/inspect/snapshot.py | 25 ++++++++--- tests/inspect/test_snapshot.py | 43 +++++++++++++++++++ 4 files changed, 74 insertions(+), 18 deletions(-) diff --git a/src/videoipath_automation_tool/apps/inspect/domain/device.py b/src/videoipath_automation_tool/apps/inspect/domain/device.py index 1213ca8..352768f 100644 --- a/src/videoipath_automation_tool/apps/inspect/domain/device.py +++ b/src/videoipath_automation_tool/apps/inspect/domain/device.py @@ -242,10 +242,11 @@ def get_vertices_by_module_label( module_ids = {m.id for m in self.modules if m.label == module_label} if not module_ids: return [] + ports = [port for port in self.ports if port.indexed.module_id in module_ids] + sides = [side for port in ports for side in port._vertex_sides()] + self.snapshot.get_vertex_details_many([vid for vid, _ in sides]) result: list[InspectVertex] = [] - for port in self.ports: - if port.indexed.module_id not in module_ids: - continue + for port in ports: for vertex in port._vertices(): if kind is not None and vertex.vertex_kind != kind: continue @@ -257,9 +258,7 @@ def get_vertex_by_id(self, vertex_id: str) -> InspectVertex | None: for vertex in self._all_vertices(): if vertex.id == vertex_id: return vertex - if vertex_id.startswith(self.id + ".") or ( - self.id.startswith("virtual.") and vertex_id.startswith(self.id + ".") - ): + if vertex_id.startswith(self.id + "."): return self.snapshot.get_vertex(vertex_id) return None @@ -343,10 +342,11 @@ def _meta_get(self, attr: str, default: Any = None) -> Any: return getattr(meta, attr, default) if meta is not None else default def _all_vertices(self) -> list[InspectVertex]: - sides = [side for port in self.ports for side in port._vertex_sides()] + ports = self.ports + sides = [side for port in ports for side in port._vertex_sides()] self.snapshot.get_vertex_details_many([vid for vid, _ in sides]) result: list[InspectVertex] = [] - for port in self.ports: + for port in ports: result.extend(port._vertices()) return result diff --git a/src/videoipath_automation_tool/apps/inspect/domain/module.py b/src/videoipath_automation_tool/apps/inspect/domain/module.py index 8467429..fc9fd09 100644 --- a/src/videoipath_automation_tool/apps/inspect/domain/module.py +++ b/src/videoipath_automation_tool/apps/inspect/domain/module.py @@ -115,9 +115,11 @@ def ports(self) -> list[InspectPort]: @property def vertices(self) -> list[InspectVertex]: - """Every vertex across the module's ports (triggers a lazy vertex lookup per port for the - typed kind).""" - return [vertex for port in self.ports for vertex in port._vertices()] + """Every vertex across the module's ports (hydrates + batches vertex lookups).""" + ports = self.ports + sides = [side for port in ports for side in port._vertex_sides()] + self.snapshot.get_vertex_details_many([vid for vid, _ in sides]) + return [vertex for port in ports for vertex in port._vertices()] def __repr__(self) -> str: return format_repr( diff --git a/src/videoipath_automation_tool/apps/inspect/snapshot.py b/src/videoipath_automation_tool/apps/inspect/snapshot.py index ffb45f9..e78f463 100644 --- a/src/videoipath_automation_tool/apps/inspect/snapshot.py +++ b/src/videoipath_automation_tool/apps/inspect/snapshot.py @@ -424,15 +424,19 @@ def get_alarms_for_service(self, booking_id: str) -> list["InspectAlarm"]: # --- Bulk preload (ADR-0004) --- def preload(self, devices: Optional[list[str]] = None) -> None: - """Hydrate multiple devices in parallel to avoid N+1 when detail is needed for many.""" + """Hydrate multiple devices in parallel to avoid N+1 when detail is needed for many. + + Best-effort: a failed per-device fetch is marked stale and logged so the rest of the + preload still completes (mirrors :meth:`_try_refresh_device`). + """ target = devices if devices is not None else list(self._devices_by_id) pending = [d for d in target if not self.is_device_hydrated(d)] if not pending or self._fetcher is None: for device_id in pending: - self._ensure_device_detail(device_id) + self._try_ensure_device_detail(device_id) return with ThreadPoolExecutor(max_workers=min(_PRELOAD_WORKERS, len(pending))) as pool: - list(pool.map(self._ensure_device_detail, pending)) + list(pool.map(self._try_ensure_device_detail, pending)) # --- Pending domain edits (setters → update()) --- @@ -626,6 +630,14 @@ def _refresh_edge_pair(self, pair_id: str) -> None: # --- Internal: resilient refresh + lazy-stale self-heal (ADR-0010) --- + def _try_ensure_device_detail(self, device_id: str) -> None: + """Hydrate a device; on failure mark it stale (lazy self-heal on next access) and log.""" + try: + self._ensure_device_detail(device_id) + except Exception as exc: + self._stale_devices.add(device_id) + _logger.warning("Inspect snapshot: preload of device '%s' failed: %s", device_id, exc) + def _try_refresh_device(self, device_id: str) -> None: """Re-fetch a device; on failure mark it stale (lazy self-heal on next access) and log.""" try: @@ -662,16 +674,15 @@ def _reconcile_pairs_for_devices(self, device_ids: set[str]) -> None: """Reconcile every edge pair touching an affected device from one fresh edge-skeleton read.""" if self._fetcher is None or not device_ids: return + affected_pairs = {indexed.pair_id for d in device_ids for indexed in self._edges_by_device_id.get(d, [])} try: pairs = self._fetcher.get_edge_skeleton() except Exception as exc: - self._stale_pairs.update( - indexed.pair_id for d in device_ids for indexed in self._edges_by_device_id.get(d, []) - ) + self._stale_pairs.update(affected_pairs) _logger.warning("Inspect snapshot: edge reconcile after network action failed: %s", exc) return with self._lock: - for pair_id in {indexed.pair_id for d in device_ids for indexed in self._edges_by_device_id.get(d, [])}: + for pair_id in affected_pairs: self._drop_edge_pair(pair_id) for pair in pairs: if self._pair_touches(pair, device_ids): diff --git a/tests/inspect/test_snapshot.py b/tests/inspect/test_snapshot.py index 25d7ca7..ed96824 100644 --- a/tests/inspect/test_snapshot.py +++ b/tests/inspect/test_snapshot.py @@ -684,6 +684,49 @@ def test_device_modules_group_ports(snapshot: tuple[InspectSnapshot, FakeFetcher assert leaf.get_module("no-such-module") is None +def test_module_vertices_uses_one_batched_lookup(snapshot: tuple[InspectSnapshot, FakeFetcher]) -> None: + snap, fetcher = snapshot + module = snap.get_device("leaf-a").get_module("leaf-a.dev.0") + assert module is not None + vertices = module.vertices + assert {v.id for v in vertices} == {"leaf-a.0.up1", "leaf-a.0.host1"} + assert len(fetcher.vertex_lookup_calls) == 1 + assert set(fetcher.vertex_lookup_calls[0]) == {"leaf-a.0.up1", "leaf-a.0.host1"} + + +def test_get_vertices_by_module_label_uses_one_batched_lookup( + snapshot: tuple[InspectSnapshot, FakeFetcher], +) -> None: + snap, fetcher = snapshot + fetcher._details["leaf-a"] = _filter_detail_node("leaf-a", "LEAF-A") + leaf = snap.get_device("leaf-a") + vertices = leaf.get_vertices_by_module_label("Slot 1") + assert {v.id for v in vertices} == {"leaf-a.1.bidi1.out", "leaf-a.1.bidi1.in"} + assert len(fetcher.vertex_lookup_calls) == 1 + assert set(fetcher.vertex_lookup_calls[0]) == {"leaf-a.1.bidi1.out", "leaf-a.1.bidi1.in"} + ip_only = leaf.get_vertices_by_module_label("Slot 1", kind="ip") + assert {v.id for v in ip_only} == {"leaf-a.1.bidi1.out", "leaf-a.1.bidi1.in"} + assert len(fetcher.vertex_lookup_calls) == 1 # cached → no further lookups + + +def test_preload_continues_when_one_device_fails(snapshot: tuple[InspectSnapshot, FakeFetcher]) -> None: + snap, fetcher = snapshot + + original = fetcher.get_device_detail + + def flaky_detail(device_id: str) -> InspectApiNodeStatusItem | None: + if device_id == "spine-a": + raise RuntimeError("simulated detail failure") + return original(device_id) + + fetcher.get_device_detail = flaky_detail # type: ignore[method-assign] + snap.preload() + assert "leaf-a" in fetcher.device_detail_calls + assert snap.get_device("leaf-a").is_hydrated is True + assert snap.get_device("spine-a").is_hydrated is False + assert "spine-a" in snap._stale_devices + + def test_device_exposes_multiple_modules(snapshot: tuple[InspectSnapshot, FakeFetcher]) -> None: snap, fetcher = snapshot fetcher._details["leaf-a"] = _filter_detail_node("leaf-a", "LEAF-A") From be6fb27c8fab333cca1b9140bf795456769cdcf5 Mon Sep 17 00:00:00 2001 From: Jonas Scholl <43819845+JonasScholl@users.noreply.github.com> Date: Thu, 23 Jul 2026 22:33:46 +0200 Subject: [PATCH 33/34] documentation refinement and cleanup --- .../future/unified-domain-architecture.md | 51 ++- docs/architecture/inspect-app/README.md | 97 ++---- docs/architecture/inspect-app/concepts.md | 242 ++++---------- .../decisions/0001-api-paradigm.md | 65 ---- .../decisions/0002-loading-and-state.md | 81 ----- .../decisions/0003-websocket-subscriptions.md | 72 ----- .../decisions/0004-async-strategy.md | 60 ---- .../inspect-app/decisions/0005-e2e-testing.md | 54 ---- .../decisions/0006-commit-write-model.md | 294 ------------------ .../decisions/0007-lazy-snapshot-loading.md | 126 -------- .../0008-collector-only-endpoints.md | 100 ------ .../decisions/0009-write-consistency.md | 139 --------- .../inspect-app/decisions/001-api-paradigm.md | 23 ++ .../0010-post-commit-snapshot-refresh.md | 119 ------- .../decisions/002-async-strategy.md | 23 ++ .../inspect-app/decisions/003-e2e-testing.md | 25 ++ .../decisions/004-commit-write-model.md | 43 +++ .../decisions/005-lazy-snapshot-loading.md | 39 +++ .../decisions/006-collector-only-endpoints.md | 37 +++ .../decisions/007-write-consistency.md | 45 +++ .../008-post-commit-snapshot-refresh.md | 42 +++ .../inspect-app/decisions/README.md | 59 +--- docs/architecture/inspect-app/endpoints.md | 87 ++---- docs/architecture/inspect-app/models.md | 64 +++- .../apps/inspect/__init__.py | 2 +- .../apps/inspect/api/inspect_api.py | 10 +- .../apps/inspect/api/queries.py | 4 +- .../apps/inspect/app/actions.py | 4 +- .../apps/inspect/app/app.py | 4 +- .../apps/inspect/app/read.py | 4 +- .../apps/inspect/app/write.py | 4 +- .../apps/inspect/domain/device.py | 2 +- .../apps/inspect/domain/vertex.py | 2 +- .../apps/inspect/errors.py | 6 +- .../apps/inspect/snapshot.py | 16 +- .../apps/inspect/transaction.py | 14 +- tests/e2e/conftest.py | 2 +- tests/e2e/helpers.py | 2 +- tests/inspect/test_actions.py | 2 +- tests/inspect/test_snapshot.py | 2 +- tests/inspect/test_transaction.py | 4 +- 41 files changed, 528 insertions(+), 1543 deletions(-) delete mode 100644 docs/architecture/inspect-app/decisions/0001-api-paradigm.md delete mode 100644 docs/architecture/inspect-app/decisions/0002-loading-and-state.md delete mode 100644 docs/architecture/inspect-app/decisions/0003-websocket-subscriptions.md delete mode 100644 docs/architecture/inspect-app/decisions/0004-async-strategy.md delete mode 100644 docs/architecture/inspect-app/decisions/0005-e2e-testing.md delete mode 100644 docs/architecture/inspect-app/decisions/0006-commit-write-model.md delete mode 100644 docs/architecture/inspect-app/decisions/0007-lazy-snapshot-loading.md delete mode 100644 docs/architecture/inspect-app/decisions/0008-collector-only-endpoints.md delete mode 100644 docs/architecture/inspect-app/decisions/0009-write-consistency.md create mode 100644 docs/architecture/inspect-app/decisions/001-api-paradigm.md delete mode 100644 docs/architecture/inspect-app/decisions/0010-post-commit-snapshot-refresh.md create mode 100644 docs/architecture/inspect-app/decisions/002-async-strategy.md create mode 100644 docs/architecture/inspect-app/decisions/003-e2e-testing.md create mode 100644 docs/architecture/inspect-app/decisions/004-commit-write-model.md create mode 100644 docs/architecture/inspect-app/decisions/005-lazy-snapshot-loading.md create mode 100644 docs/architecture/inspect-app/decisions/006-collector-only-endpoints.md create mode 100644 docs/architecture/inspect-app/decisions/007-write-consistency.md create mode 100644 docs/architecture/inspect-app/decisions/008-post-commit-snapshot-refresh.md diff --git a/docs/architecture/future/unified-domain-architecture.md b/docs/architecture/future/unified-domain-architecture.md index a37b489..9446664 100644 --- a/docs/architecture/future/unified-domain-architecture.md +++ b/docs/architecture/future/unified-domain-architecture.md @@ -221,7 +221,7 @@ Design principles: `0.0`). - **Status is a first-class, read-only projection**, separate from config. This matches the config-plane vs. status-plane split - ([ADR-0001](../inspect-app/decisions/0001-api-paradigm.md)) and keeps the live + ([ADR-001](../inspect-app/decisions/001-api-paradigm.md)) and keeps the live story clean. - **Make illegal states unrepresentable** where cheap (enums for `Direction`, `PortKind`, `Redundancy`, `LifecycleState`, `SyncState`). @@ -251,7 +251,7 @@ is the most likely source of subtle bugs. ## 8. Reads — the collector snapshot as the aggregate root The `collector` tree is the aggregate root for reads. Loading follows -[ADR-0007](../inspect-app/decisions/0007-lazy-snapshot-loading.md): +[ADR-005](../inspect-app/decisions/005-lazy-snapshot-loading.md): a **skeleton** query pair fetches the whole graph structure (all devices without module/port detail + all edges with a lean projection) in one consistent round, and per-device subtrees are **lazily hydrated** on demand. @@ -313,12 +313,12 @@ operations. The ACL owns this table; the user never sees it. | `network.connect(a, b, …)` | Resolve port→vertex ids → `updateTopology` `replaceEdges` / `addExternalEdges` (paired edges) | | `network.get(...)` / `list_*()` | `collector` snapshot read (+ inventory status where needed) | | `network.disconnect(...)` / `remove_device(...)` | `updateTopology` `remove` and/or Inventory RPC remove | -| `device.refresh()` / `network.refresh()` | Re-fetch `collector` snapshot (§11.3); after own commits: targeted refresh ([ADR-0010](../inspect-app/decisions/0010-post-commit-snapshot-refresh.md)) | +| `device.refresh()` / `network.refresh()` | Re-fetch `collector` snapshot (§11.3); after own commits: targeted refresh ([ADR-008](../inspect-app/decisions/008-post-commit-snapshot-refresh.md)) | All topology-plane operations go through the **Inspect surface only** (`updateTopology`, collector reads, `addDevices`/`syncDevices`) — never through `PATCH nGraphElements` -([ADR-0008](../inspect-app/decisions/0008-collector-only-endpoints.md)). The +([ADR-006](../inspect-app/decisions/006-collector-only-endpoints.md)). The legacy Topology path remains available solely via the `app.topology` escape hatch. @@ -326,8 +326,8 @@ hatch. Make the **change set the unit of work** — this is genuinely how the server behaves for topology/connection edits -([ADR-0006](../inspect-app/decisions/0006-commit-write-model.md)), so it is a -semantic to **expose**, not hide. Adopt ADR-0006 option 3 (explicit change set + +([ADR-004](../inspect-app/decisions/004-commit-write-model.md)), so it is a +semantic to **expose**, not hide. Adopt ADR-004 option 3 (explicit change set + convenience auto-commit), with a context manager as the ergonomic default. Proposed shape: @@ -337,9 +337,9 @@ with app.network.change_set() as cs: cs.place(device_id, at=Point(x, y)) conn = cs.connect(port_a, port_b, bandwidth=Gbps(10), redundancy=Redundancy.ANY) cs.remove(old_connection_id) - result = cs.validate() # client-side conflict check (ADR-0009); server validation runs at commit + result = cs.validate() # client-side conflict check (ADR-007); server validation runs at commit if result.ok: - cs.commit() # conflict re-check → one updateTopology POST → targeted refresh (ADR-0010) + cs.commit() # conflict re-check → one updateTopology POST → targeted refresh (ADR-008) # on exception → auto-discard; on clean exit without commit → configurable # Convenience — single change auto-commits @@ -349,7 +349,7 @@ app.network.connect(port_a, port_b, bandwidth=Gbps(10)) What the domain layer owns (and the ACL translates): - **Commit ≠ HTTP success.** A captured failed delete returned `header.ok: true` - but `data.res.ok: false` / `data.validation.result.ok: false` (ADR-0006). + but `data.res.ok: false` / `data.validation.result.ok: false` (ADR-004). This must surface as a typed `CommitResult` / `CommitFailed` carrying the per-entity validation details (`status`, `rev`, `resolvable`, message) — never a raw envelope. @@ -372,8 +372,8 @@ The three planes do **not** share a write/consistency model: | Plane | Write mechanism | Concurrency control | | ----- | --------------- | ------------------- | | Inventory | RPC `/api/updateDevices` | No revision/strict mode (RPC semantics) | -| Topology (escape hatch only, [ADR-0008](../inspect-app/decisions/0008-collector-only-endpoints.md)) | `PATCH nGraphElements` | `_rev` optimistic locking, `mode: strict` | -| Inspect | `updateTopology` action | Commit-time validation; **last-writer-wins** (verified 2025.4.9 — `_rev` ignored); client-side compare-and-commit ([ADR-0009](../inspect-app/decisions/0009-write-consistency.md)) | +| Topology (escape hatch only, [ADR-006](../inspect-app/decisions/006-collector-only-endpoints.md)) | `PATCH nGraphElements` | `_rev` optimistic locking, `mode: strict` | +| Inspect | `updateTopology` action | Commit-time validation; **last-writer-wins** (verified 2025.4.9 — `_rev` ignored); client-side compare-and-commit ([ADR-007](../inspect-app/decisions/007-write-consistency.md)) | So a single domain write that touches multiple planes has **no single transaction and no uniform conflict story**. The ACL must define, per @@ -393,11 +393,11 @@ config plane (`nGraphElements`). Consequences: `syncSeverity` are not backed by any revision. There is **no cheap "did anything change?" probe** for status — the only ways to learn current status are to re-fetch the collector (whole or subtree) or subscribe via WebSocket - ([ADR-0003](../inspect-app/decisions/0003-websocket-subscriptions.md)). + ([ADR-001](../inspect-app/decisions/001-api-paradigm.md)). 2. **Config-plane rev-polling is off the table anyway.** Polling `nGraphElements .../id,rev` could catch "someone re-wired" (never "a connection went into alarm"), but the package does not call that surface - ([ADR-0008](../inspect-app/decisions/0008-collector-only-endpoints.md)) — + ([ADR-006](../inspect-app/decisions/006-collector-only-endpoints.md)) — and it would still miss the status changes that matter for monitoring. 3. **There is no write token at all on the Inspect surface.** The collector read is rev-less and `updateTopology` ignores `_rev` (last-writer-wins, @@ -405,7 +405,7 @@ config plane (`nGraphElements`). Consequences: not merely inconvenient. A domain object built from the collector **cannot support an optimistic write**; concurrent-edit detection is the change set's job via stage-time baselines + pre-commit compare - ([ADR-0009](../inspect-app/decisions/0009-write-consistency.md)). + ([ADR-007](../inspect-app/decisions/007-write-consistency.md)). ### 11.3 Freshness strategy — re-snapshot, not rev-diff @@ -416,7 +416,7 @@ graph), poor for **incremental freshness**. Therefore: - The collector snapshot is **replaced wholesale on `refresh()`** — never patched by diffing. Within its lifetime it *accretes*: skeleton-first, with lazily hydrated subtrees merged in - ([ADR-0007](../inspect-app/decisions/0007-lazy-snapshot-loading.md)). + ([ADR-005](../inspect-app/decisions/005-lazy-snapshot-loading.md)). - `refresh()` means "fetch a new snapshot," not "diff revisions." Stamp each entity/section with a **client-side fetch time** as the freshness marker, since the server provides no token. @@ -428,12 +428,11 @@ graph), poor for **incremental freshness**. Therefore: - **After the package's own commits**, freshness is cheaper: the change set knows what it touched, so the snapshot is updated by targeted invalidation + scoped re-fetch instead of a full re-snapshot - ([ADR-0010](../inspect-app/decisions/0010-post-commit-snapshot-refresh.md)). + ([ADR-008](../inspect-app/decisions/008-post-commit-snapshot-refresh.md)). - This is the strongest argument for making **WebSocket the real freshness channel for status**, while config edits keep the rev-based path - ([ADR-0001](../inspect-app/decisions/0001-api-paradigm.md), - [ADR-0002](../inspect-app/decisions/0002-loading-and-state.md), - [ADR-0003](../inspect-app/decisions/0003-websocket-subscriptions.md)). + ([ADR-001](../inspect-app/decisions/001-api-paradigm.md), + [ADR-005](../inspect-app/decisions/005-lazy-snapshot-loading.md)). ### 11.4 Separate the read snapshot from the write handle @@ -442,13 +441,13 @@ domain mutation (`connect`, `remove`) must not pretend a read object can optimistically write. The clean split: - **Read snapshot** — rev-less, replace wholesale on refresh; accretes lazily - hydrated detail within its lifetime (ADR-0007); after own commits it is + hydrated detail within its lifetime (ADR-005); after own commits it is updated by targeted scoped re-reads - ([ADR-0010](../inspect-app/decisions/0010-post-commit-snapshot-refresh.md)). + ([ADR-008](../inspect-app/decisions/008-post-commit-snapshot-refresh.md)). - **Write handle / change set** — fetches stage-time baselines via Inspect-surface lookups, re-checks them immediately before the `updateTopology` POST, and owns the commit/validate/discard/conflict - lifecycle ([ADR-0009](../inspect-app/decisions/0009-write-consistency.md)). + lifecycle ([ADR-007](../inspect-app/decisions/007-write-consistency.md)). ## 12. Limits of the abstraction @@ -491,10 +490,10 @@ The single most important artifact of this approach. Proposed starting point | Device lifecycle (onboard → place → connect → monitor) | **Expose** as `LifecycleState` | Users must reason about *where* a device is, not *which app* | | Change set / commit | **Expose** (first-class) | Genuine server semantic; enables atomic multi-edits | | Commit validation result & affected services | **Expose** (typed result) | `header.ok` lies; users must see real outcome | -| Concurrent-write conflicts | **Expose** as explicit conflict check + typed conflict error ([ADR-0009](../inspect-app/decisions/0009-write-consistency.md)) | No rev token exists on the Inspect surface (`updateTopology` is last-writer-wins); pretending otherwise would fake a guarantee | +| Concurrent-write conflicts | **Expose** as explicit conflict check + typed conflict error ([ADR-007](../inspect-app/decisions/007-write-consistency.md)) | No rev token exists on the Inspect surface (`updateTopology` is last-writer-wins); pretending otherwise would fake a guarantee | | Multi-dimensional status (`alarm`/`bandwidth`/`maintenance`/`ptp`, `sa`/`severity`) | **Expose** (preserve dimensions + provide rollup) | Lossy to collapse; monitoring users need the detail | | Snapshot freshness (no `_rev`) | **Expose** via explicit `refresh()` + fetch-time stamp | Honest about the lack of a server token | -| Lazy hydration on property access ([ADR-0007](../inspect-app/decisions/0007-lazy-snapshot-loading.md)) | **Expose** (documented behaviour) | Getters may perform one fetch and raise connector errors; hiding it would misrepresent cost and failure modes | +| Lazy hydration on property access ([ADR-005](../inspect-app/decisions/005-lazy-snapshot-loading.md)) | **Expose** (documented behaviour) | Getters may perform one fetch and raise connector errors; hiding it would misrepresent cost and failure modes | | Driver-specific custom settings | **Cannot hide** — abstract the entry point only | A device *is* its driver schema (§12) | ## 14. Migration & coexistence @@ -530,8 +529,8 @@ alternative to the additive approach — see §15. | Lifecycle model surface | Explicit `LifecycleState` enum **vs.** implicit (methods just work) | §5; explicit aids reasoning & errors | | Completeness bar for v1 | Full coverage **vs.** 80% happy path + escape hatch for the rest | Recommend the latter; lowest cost | | Hide-vs-expose ledger (§13) | Ratify explicitly | The most consequential artifact | -| Snapshot vs. WS for status freshness | re-snapshot now, WS as the real channel | §11.3; ties into ADR-0002 / ADR-0003 | -| Read-snapshot ↔ write-handle bridge | ~~How the change set resolves `_rev` at commit~~ **Decided**: stage-time baselines + pre-commit compare ([ADR-0009](../inspect-app/decisions/0009-write-consistency.md)); post-commit targeted refresh ([ADR-0010](../inspect-app/decisions/0010-post-commit-snapshot-refresh.md)) | §11.4 | +| Snapshot vs. WS for status freshness | re-snapshot now, WS as the real channel | §11.3; ties into ADR-001 / ADR-005 | +| Read-snapshot ↔ write-handle bridge | ~~How the change set resolves `_rev` at commit~~ **Decided**: stage-time baselines + pre-commit compare ([ADR-007](../inspect-app/decisions/007-write-consistency.md)); post-commit targeted refresh ([ADR-008](../inspect-app/decisions/008-post-commit-snapshot-refresh.md)) | §11.4 | | Relationship to existing docs | Supersede `python-module-architecture.md` design goal **vs.** coexist as "current vs. target" | Currently coexists as the target picture | ## 16. Field-mapping reference (wire → domain) diff --git a/docs/architecture/inspect-app/README.md b/docs/architecture/inspect-app/README.md index 06b9b15..42cb29a 100644 --- a/docs/architecture/inspect-app/README.md +++ b/docs/architecture/inspect-app/README.md @@ -1,74 +1,45 @@ -# Inspect App — Architecture & Concept Docs +# Inspect App — Architecture -Planning and architecture-decision documents for adding the VideoIPath **Inspect** -app to the package. In the VideoIPath product, Inspect is the newer app that -**replaces the Topology app** — building topologies and connecting devices — and -adds service monitoring with live (WebSocket-based) updates. It does **not** -replace **Inventory**: devices are still onboarded in Inventory first, then placed -and connected in Inspect. Inspect also uses a **commit-style** write model, where -create/edit/delete actions are gathered into a change set and committed together. +Design record for the VideoIPath **Inspect** app in this package +(`src/videoipath_automation_tool/apps/inspect/`). -In **this package**, `app.inspect` is the replacement for `app.topology`: -`TopologyApp` emits a deprecation warning on VideoIPath 2025.x and raises on -2026.x+. `app.inventory` remains unchanged and required for device onboarding. +In the VideoIPath product, Inspect replaces the Topology app for building +topologies and connecting devices, and adds service monitoring. It does **not** +replace **Inventory**: devices are still onboarded in Inventory first, then +placed and connected in Inspect. Writes use a **commit-style** model — create / +edit / delete actions are gathered into a change set and committed together. -These docs are **living documents** — meant to be edited and refined as the -design firms up and as the real Inspect API is reverse-engineered. The official -[VideoIPath Public API 2025 LTS](https://documenter.getpostman.com/view/11222813/2sBXihpCS8#intro) -reference is now a primary source. - -> **Status: implemented.** The `app.inspect` surface described here is shipped -> (`src/videoipath_automation_tool/apps/inspect/`), with offline unit tests under -> `tests/inspect/` and a developer-run live E2E suite under `tests/e2e/inspect/`. -> All ten ADRs are Accepted. For usage, see the -> [Inspect getting-started page](../../getting-started-guide/03_B_Inspect.md). +In this package, `app.inspect` replaces `app.topology`: `TopologyApp` emits a +deprecation warning on VideoIPath 2025.x and raises on 2026.x+. +`app.inventory` remains unchanged. Offline unit tests live under +`tests/inspect/`; live E2E under `tests/e2e/inspect/`. For usage, see the +[Inspect getting-started page](../../getting-started-guide/03_B_Inspect.md). ## Reading order -1. **[concepts.md](./concepts.md)** — what Inspect is, its domain model (including - the Inspect-vs-Topology tagging split: vertex tags in `device_tags`, not - `nGraphElements`), how it maps onto the existing package, and the - endpoint/WebSocket discovery template (the `[VERIFY]` items to confirm against - a real server). Cross-references the - [Public API 2025 LTS](https://documenter.getpostman.com/view/11222813/2sBXihpCS8#intro) - reference. -2. **[models.md](./models.md)** — practical model guide for transport `InspectApi*` - DTOs, `InspectSnapshot`, and user-facing domain objects such as `InspectDevice`. +1. **[concepts.md](./concepts.md)** — what Inspect is, the collector facade, + domain model (including the Inspect-vs-Topology tagging split), and how it + maps onto the package. +2. **[models.md](./models.md)** — transport `InspectApi*` DTOs, `InspectSnapshot`, + and user-facing domain objects (`InspectDevice`, `InspectPort`, …). 3. **[endpoints.md](./endpoints.md)** — anonymized endpoint reference with - concrete request and response shapes captured from a local test instance, - including the live-server verification results (VideoIPath 2025.4.9, - 2026-07-08). -4. **[decisions/](./decisions/)** — the architecture decisions (ADRs), one per - topic. Start with [the index](./decisions/README.md). -5. **[implementation-plan.md](./implementation-plan.md)** — phased, non-breaking - rollout, target package layout, milestones, and risks. + concrete request/response shapes (verified on VideoIPath 2025.4.9). +4. **[decisions/](./decisions/)** — architecture decisions. Start with + [the index](./decisions/README.md). -> **Wider context:** the package-wide re-think that grew out of this Inspect -> work — one unified `Device` / `Connection` domain model that hides the -> Inventory / Topology / Inspect split entirely — lives in -> [`../future/domain-architecture.md`](../future/domain-architecture.md). +> **Wider context:** a package-wide re-think that grew out of this work — one +> unified `Device` / `Connection` domain model — lives in +> [`../future/unified-domain-architecture.md`](../future/unified-domain-architecture.md). ## Decision log -| Question (from the brief) | Decision record | Status | -| -------------------------------------------------- | ------------------------------------------------------------ | -------- | -| Data-driven vs. event/action-driven API? | [ADR-0001](./decisions/0001-api-paradigm.md) | Open | -| Always sync/load vs. lazy load vs. cached state? | [ADR-0002](./decisions/0002-loading-and-state.md) → [ADR-0007](./decisions/0007-lazy-snapshot-loading.md) | Decided (ADR-0007) | -| Use WebSockets for event subscriptions? | [ADR-0003](./decisions/0003-websocket-subscriptions.md) | Open | -| Make the package async-ready? Support non-async? | [ADR-0004](./decisions/0004-async-strategy.md) | Open | -| How to test E2E (low-effort, stable, trustworthy)? | [ADR-0005](./decisions/0005-e2e-testing.md) | Open | -| How are config writes applied (immediate vs. commit)? | [ADR-0006](./decisions/0006-commit-write-model.md) | Open | -| Which API surface may the package call? | [ADR-0008](./decisions/0008-collector-only-endpoints.md) | Decided (collector-only) | -| How are concurrent writes detected (no server `_rev` check)? | [ADR-0009](./decisions/0009-write-consistency.md) | Decided (compare-and-commit) | -| How does the snapshot catch up after a commit? | [ADR-0010](./decisions/0010-post-commit-snapshot-refresh.md) | Decided (targeted refresh) | - -## Status legend - -- **Draft** — concept/plan being shaped. -- **Proposed / Accepted / Superseded** — for ADRs, see - [the decisions index](./decisions/README.md). - -> All ADRs are **Accepted** and the `[VERIFY]` items in -> [concepts.md](./concepts.md) / [endpoints.md](./endpoints.md) were confirmed -> against VideoIPath 2025.4.9. The app is implemented; these docs are retained as -> the design record. +| Question | Decision | Status | +| -------- | -------- | ------ | +| Data-driven vs. event/action-driven API? | [ADR-001](./decisions/001-api-paradigm.md) | Accepted | +| Make the package async-ready? | [ADR-002](./decisions/002-async-strategy.md) | Accepted | +| How to test E2E? | [ADR-003](./decisions/003-e2e-testing.md) | Accepted | +| How are config writes applied? | [ADR-004](./decisions/004-commit-write-model.md) | Accepted | +| Always sync/load vs. lazy load vs. cached state? | [ADR-005](./decisions/005-lazy-snapshot-loading.md) | Accepted | +| Which API surface may the package call? | [ADR-006](./decisions/006-collector-only-endpoints.md) | Accepted | +| How are concurrent writes detected? | [ADR-007](./decisions/007-write-consistency.md) | Accepted | +| How does the snapshot catch up after a commit? | [ADR-008](./decisions/008-post-commit-snapshot-refresh.md) | Accepted | diff --git a/docs/architecture/inspect-app/concepts.md b/docs/architecture/inspect-app/concepts.md index 65eefe2..9758a64 100644 --- a/docs/architecture/inspect-app/concepts.md +++ b/docs/architecture/inspect-app/concepts.md @@ -1,13 +1,9 @@ # Inspect App — Concepts & Technical Model -> Status: **Draft** · Last updated: 2026-07-08 -> -> This document captures what the *Inspect* app is, how it maps onto the -> existing package, and which technical details still need to be verified -> against a real server. Anything not yet confirmed is marked **[VERIFY]**. -> The official -> [VideoIPath Public API 2025 LTS](https://documenter.getpostman.com/view/11222813/2sBXihpCS8#intro) -> reference is a primary source for the wire format. +Design record for the shipped Inspect app. Wire shapes and endpoint details live +in [endpoints.md](./endpoints.md); the official +[VideoIPath Public API 2025 LTS](https://documenter.getpostman.com/view/11222813/2sBXihpCS8#intro) +is a secondary reference for the documented surface. ## 1. What Inspect is @@ -20,16 +16,14 @@ In recent VideoIPath releases the Inspect app **replaces the Topology app** in the product UI: it becomes the entry point for building the network connectivity model (vertices, edges, device placement), connecting devices, and watching operational status. The server exposes live update capabilities, but this -package deliberately stays request/response only (see -[ADR-0001](./decisions/0001-api-paradigm.md) and -[ADR-0003](./decisions/0003-websocket-subscriptions.md)). Inspect applies -configuration changes with a **commit-style** model: create/edit/delete actions -are gathered into a client-side change set and committed together (see -[ADR-0006](./decisions/0006-commit-write-model.md)). +package stays request/response only (see +[ADR-001](./decisions/001-api-paradigm.md)). Inspect applies configuration +changes with a **commit-style** model: create/edit/delete actions are gathered +into a client-side change set and committed together (see +[ADR-004](./decisions/004-commit-write-model.md)). Inspect does **not** replace the **Inventory** app. Devices are still onboarded -in Inventory first; only then can they be placed and connected in Inspect. So -Inventory remains a separate, required prerequisite. +in Inventory first; only then can they be placed and connected in Inspect. In **this package**, `app.inspect` replaces `app.topology`: `TopologyApp` is deprecated on VideoIPath 2025.x and unsupported on 2026.x+. `app.inventory` @@ -44,24 +38,22 @@ contract is mostly net-new** relative to what `app.topology` and `app.inventory` use today. **Reads** — scoped queries against the collector tree -([ADR-0007](./decisions/0007-lazy-snapshot-loading.md)): +([ADR-005](./decisions/005-lazy-snapshot-loading.md)): - The collector sub-paths accept `* where ` filters, `limit N`, and deep - field projections — confirmed by a WebSocket capture of the Inspect UI, which - never loads the full tree (see + field projections (see [endpoints.md — Collector Scoped Queries](./endpoints.md#collector-scoped-queries-captured-from-the-inspect-ui)). - Default loading model: a **skeleton** read (all devices without modules/ports + all edges with a lean projection) followed by **lazy per-device hydration** and section-level loads for services. - `GET /rest/v2/data/status/collector/**` → `data.status.collector` (§3.1) - remains the eager/fallback mode: the whole tree — topology nodes, - inter-device edges, services/paths, status, and security context — in a - single response with `_items[]` collections. + remains the eager/fallback mode: the whole tree in a single response with + `_items[]` collections. **Writes** — one bulk action per commit: - `POST /rest/v2/actions/status/collector/updateTopology` - ([ADR-0006](./decisions/0006-commit-write-model.md)) + ([ADR-004](./decisions/004-commit-write-model.md)) - Sends a client-assembled delta: `replaceDevices`, `replaceVertices`, `replaceEdges`, `replaceResourceTransforms`, `addExternalEdges`, `remove`, `force`. @@ -71,11 +63,9 @@ contract is mostly net-new** relative to what `app.topology` and **Namespace** — the `collector` API entry points live under `status` (`data/status/collector` for reads, `actions/status/collector` for writes), but the **underlying store is the existing config plane**: `updateTopology` mutations -land in `config/network/nGraphElements`. Confirmed by capture — an anonymized -edge updated via `updateTopology` appeared in -`GET …/data/config/network/nGraphElements/**` with the changed value and a -bumped `_rev`. So the collector is a **facade**: a status-namespace read/action -surface in front of the revisioned `nGraphElements` config store (§3.3). +land in `config/network/nGraphElements`. The collector is a **facade**: a +status-namespace read/action surface in front of the revisioned `nGraphElements` +config store (§3.3). **Shared with existing apps** — the underlying store and wire conventions, not model classes: @@ -88,8 +78,7 @@ model classes: - Vertex ids (`device-a.module-1.port-out-1.out`), edge keys (`fromId::toId`) - `descriptor` / `fDescriptor` objects, `sa` / `severity` status semantics - Device positions as float coordinates — `meta.coordinates` in the collector - aggregate, `maps[].x/y` in `nGraphElements`, same *Inspect Topology* format as - `inspect_app_format` in the topology app + aggregate, `maps[].x/y` in `nGraphElements` **Net-new for `app.inspect`:** @@ -97,7 +86,7 @@ model classes: | ----- | -------------- | | Collector read/parse | Parse `data.status.collector` with `InspectApi*` transport DTOs, then expose user-facing `InspectDevice` / `InspectService` objects via `InspectSnapshot` | | Change-set / commit write | Assemble `updateTopology` payloads with `InspectApi*` DTOs, handle validation responses | -| Lookup / network actions | Model captured lookup, add-device, and sync-device action envelopes with `InspectApi*` DTOs | +| Lookup / network actions | Model lookup, add-device, and sync-device action envelopes with `InspectApi*` DTOs | Inventory onboarding stays on the existing path (`config/devman/devices`, `/api/updateDevices`). `app.topology` and `app.inventory` remain unchanged; @@ -105,27 +94,21 @@ Inventory onboarding stays on the existing path (`config/devman/devices`, ## 3. Domain model -How Inspect concepts map onto existing package concepts: - | Inspect concept | Inspect API (collector) | Existing model / app | | ---------------------- | ------------------------------------------------------ | --------------------------------------------- | | Device (inventory) | Prerequisite — not part of collector; onboard via `config/devman/devices` | `InventoryDevice` (`apps/inventory`) | | Device (topology node) | Read: `collector.inspect.nodeStatus`; stored as `baseDevice` in `nGraphElements` | Store shape overlaps with Topology, but Inspect uses `InspectApiBaseDevice` | | Vertices / Edges | Read: `nodeStatus` `vertexInfo` / `externalEdgesByDeviceKey`; stored as `ipVertex` / `codecVertex` / `unidirectionalEdge` in `nGraphElements` | Store shape overlaps with Topology, but Inspect uses `InspectApi*` nGraph DTOs | | Vertex tags | Read: per-port `tagsInfo` on hydrated `nodeStatus`; editable form via `lookupInspectVertexByIds` (`assignedTags`, `fields.tags`, `fields.localAssignedTags`) | **Not in `nGraphElements`** — `app.topology` has no vertex-tag concept; server-side bindings live in `videoipath_docs.device_tags`, not the `ngraph` table (§3.4) | -| Change set / commit | `POST …/actions/status/collector/updateTopology` → writes `nGraphElements` ([ADR-0006](./decisions/0006-commit-write-model.md)) | _commit flow net-new; target store is existing `nGraphElements`_ | +| Change set / commit | `POST …/actions/status/collector/updateTopology` → writes `nGraphElements` ([ADR-004](./decisions/004-commit-write-model.md)) | _commit flow net-new; target store is existing `nGraphElements`_ | | Services / paths | `collector.inspect.paths`, `pathDescriptions` on nodes/edges | _none — net-new_ | | Device / edge status | Embedded in collector (`status`, `sa`/`severity`, bandwidth, PTP) | `inventory.model.device_status`, `status/network/*` — partial overlap | | Sync status | `syncSeverity` on nodeStatus items | `TopologySynchronize` via `status/network/nGraphSyncStatus` | -| Lookup / network actions | `lookupInspectDevice`, `lookupInspectEdgesByIds`, `lookupInspectVertexByIds`, `lookupSyncInfo`, `addDevices`, `syncDevices` | request/response envelopes captured and modelled | +| Lookup / network actions | `lookupInspectDevice`, `lookupInspectEdgesByIds`, `lookupInspectVertexByIds`, `lookupSyncInfo`, `addDevices`, `syncDevices` | request/response envelopes modelled | | Connections / Partial connections | `collector.inspect.paths` + `conman.services`; linked via `serviceFields.bid` / `bookingId` in `pathDescriptions` ([endpoints.md](./endpoints.md#get-restv2datastatuscollectorinspectpaths)) | _read via collector + conman; no separate Connections REST on this instance_ | ### 3.1 Collector aggregate — primary read surface -Inspect reads a single server-built aggregate under the `collector` namespace -— the same namespace used for writes -(`actions/status/collector/updateTopology`, [ADR-0006](./decisions/0006-commit-write-model.md)): - | Item | Value | | ---- | ----- | | Method / path | `GET /rest/v2/data/status/collector/**` | @@ -139,7 +122,7 @@ Top-level sections under `data.status.collector`: | `inspect.nodeStatus` | Topology nodes: devices → modules → ports, with live status, `meta.coordinates`, `vertexInfo`, and embedded `pathDescriptions` | | `inspect.paths` | Service/path list: booking segments, endpoint labels, aggregated `serviceFields` | | `externalEdgesByDeviceKey` | Inter-device link status grouped by device pair; `primary` / `secondary` each hold edges keyed by `"fromId::toId"` | -| `maintenanceBookings` | Maintenance bookings (empty in capture) | +| `maintenanceBookings` | Maintenance bookings | | `security` | Security context for devices, profiles, matrices, … | | `superProfiles` | Routing profiles | | `tagInfo` | Tag → profile mappings | @@ -167,8 +150,7 @@ Top-level sections under `data.status.collector`: **Drill-down / service linkage:** `pathDescriptions` on ports and edges embed both `deviceLevel` (local hop: input → output within a device) and `serviceLevel` (end-to-end service: `bookingId`, `serviceLabel`, `fromStatus` / -`toStatus`, `isMain`, `serviceStatus`). This is how the UI connects topology -elements to booked services. +`toStatus`, `isMain`, `serviceStatus`). **Edge live status** (`externalEdgesByDeviceKey`): each edge carries `bandwidth`, `fromStatus` / `toStatus`, `pathDescriptions`, and aggregate @@ -185,72 +167,60 @@ port `tagsInfo` requires per-device hydration (`modules/*`). **Package implications:** - `app.inspect` reads the collector through **scoped queries** - ([ADR-0007](./decisions/0007-lazy-snapshot-loading.md)): skeleton first + ([ADR-005](./decisions/005-lazy-snapshot-loading.md)): skeleton first (`inspect/nodeStatus` with `modules/"_noId"`, `externalEdgesByDeviceKey` with a lean projection), then per-device hydration (`modules/*` detail projection) and section-level loads (`inspect/paths`). The full `GET …/data/status/collector/**` fetch is the eager/fallback mode. -- The collector query language is confirmed via UI capture: `* where ` - (with `and`/`or`, `contains()`, `lower()`), `limit N`, field projections - with `/.../` up-navigation, `**` subtrees, and the `"_noId"` - expansion-suppressor (see +- The collector query language: `* where ` (with `and`/`or`, + `contains()`, `lower()`), `limit N`, field projections with `/.../` + up-navigation, `**` subtrees, and the `"_noId"` expansion-suppressor (see [endpoints.md](./endpoints.md#collector-scoped-queries-captured-from-the-inspect-ui)). - Edge keys and vertex ids from reads map directly onto `updateTopology` write payloads. -- Commit validation references the same `bookingId`s visible in - `pathDescriptions` (e.g. failed delete for an anonymized booking / main path - edge). - Scoped collector queries work as REST GETs when the URL fits within the server - URI limit ([endpoints.md](./endpoints.md#collector-scoped-queries-captured-from-the-inspect-ui)). The full UI - projection hits **HTTP 414**; use a trimmed skeleton projection or `/**` - fallback. Both `nodeStatus//…` and + URI limit. The full UI projection hits **HTTP 414**; use a trimmed skeleton + projection or `/**` fallback. Both `nodeStatus//…` and `* where deviceId='…' limit 1/…` work for single-device hydration. Omitting `where` does **not** require `limit`. ### 3.2 API planes — existing apps vs. Inspect The VideoIPath backend separates **config** (mutable, revisioned) and -**status** (read-only, subscription-friendly) -([Public API 2025 LTS](https://documenter.getpostman.com/view/11222813/2sBXihpCS8#intro)). -Inspect's `collector` API entry points sit under `status`, but its topology -edits resolve to the **config** plane (`nGraphElements`). Network action -endpoints under `actions/status/network/*` are also relevant for device add/sync -workflows: +**status** (read-only, subscription-friendly). Inspect's `collector` API entry +points sit under `status`, but its topology edits resolve to the **config** +plane (`nGraphElements`). Network action endpoints under +`actions/status/network/*` are also relevant for device add/sync workflows: | Plane | Used by | Read | Write | | ----- | ------- | ---- | ----- | | Config | `app.topology`, `app.inventory`, **Inspect (effective store)** | `GET …/data/config/…` | `PATCH …/data/config/…` (revisioned) or RPC | | Status | Inventory status reads, Inspect status reads | `GET …/data/status/…` | — | -| Collector (facade) | `app.inspect` | Scoped queries on `…/data/status/collector/…` (skeleton + hydration, ADR-0007); `GET …/**` as eager/fallback | `POST …/actions/status/collector/updateTopology` → `nGraphElements` | +| Collector (facade) | `app.inspect` | Scoped queries on `…/data/status/collector/…` (skeleton + hydration, ADR-005); `GET …/**` as eager/fallback | `POST …/actions/status/collector/updateTopology` → `nGraphElements` | | Network actions | `app.inspect` device topology workflows | `GET …/data/status/network/virtualDevices/**`, `…/virtualTemplates/**` | `POST …/actions/status/network/addDevices`, `…/syncDevices`, `…/updateVirtualInstances` (**create** virtual devices), `…/updateVirtualTemplates`, `…/addVirtualTopology`. After create, virtual devices use the same `updateTopology` / write methods as physical devices (`InspectDevice.is_virtual`). | So Inspect's read aggregate is status-namespace and net-new in shape, but its writes are commit-time-validated bulk actions that land in the revisioned -`config/network/nGraphElements` store (§3.3). The client gathers edits locally -until commit; ADR-0006 accepts that there is no separate server-side change-set -id for the verified `updateTopology` flow. +`config/network/nGraphElements` store (§3.3). Staging is client-side until +commit; there is no separate server-side change-set id for the verified +`updateTopology` flow (ADR-004). -**Endpoint policy** ([ADR-0008](./decisions/0008-collector-only-endpoints.md)): +**Endpoint policy** ([ADR-006](./decisions/006-collector-only-endpoints.md)): the Inspect package calls **only** the Inspect surface — collector data reads, collector actions, and the network actions (`addDevices`, `syncDevices`, -virtual-device / port-template actions). The -config-plane row above is context, not a call path: the package never issues +virtual-device / port-template actions). The config-plane row above is context, +not a call path: the package never issues `GET`/`PATCH …/config/network/nGraphElements` (that stays `app.topology`'s surface). Consequence: no `_rev` is available to Inspect, and since `updateTopology` ignores revisions anyway (last-writer-wins, verified), concurrent-write detection is client-side compare-and-commit -([ADR-0009](./decisions/0009-write-consistency.md)); after a commit the +([ADR-007](./decisions/007-write-consistency.md)); after a commit the snapshot catches up via targeted scoped re-reads -([ADR-0010](./decisions/0010-post-commit-snapshot-refresh.md)). - -The server can expose status-plane subscriptions, but WebSockets are out of -scope for this package. Fresh status is obtained by explicit re-fetches -([ADR-0001](./decisions/0001-api-paradigm.md), -[ADR-0003](./decisions/0003-websocket-subscriptions.md)). +([ADR-008](./decisions/008-post-commit-snapshot-refresh.md)). -This framing underpins the API-paradigm and loading decisions -([ADR-0001](./decisions/0001-api-paradigm.md), -[ADR-0002](./decisions/0002-loading-and-state.md)). +Fresh status is obtained by explicit re-fetches +([ADR-001](./decisions/001-api-paradigm.md)). WebSocket subscriptions are out +of scope for this package. ### 3.3 Config store — `nGraphElements` (write target) @@ -262,7 +232,7 @@ standalone `InspectApi*` DTOs. > Documented here as store/background knowledge only — the package does **not** > read or write this endpoint at runtime -> ([ADR-0008](./decisions/0008-collector-only-endpoints.md)). The persisted +> ([ADR-006](./decisions/006-collector-only-endpoints.md)). The persisted > element *shape* still matters: `updateTopology` `replace*` payloads carry it. | Field | Notes | @@ -272,7 +242,7 @@ standalone `InspectApi*` DTOs. | `type` | Element kind (see below) | | `descriptor` / `fDescriptor` | User label/desc vs. fallback (device-reported) label/desc | -Element `type` values seen in capture: +Element `type` values: | `type` | Represents | Key fields | | ------ | ---------- | ---------- | @@ -281,17 +251,12 @@ Element `type` values seen in capture: | `codecVertex` | Codec/SDI endpoint vertex | `vertexType` (`In`/`Out`), `codecFormat`, `useAsEndpoint`, `control`, SIPS/SDP fields — **not** vertex tag bindings (§3.4) | | `unidirectionalEdge` | Directed link/route between vertices | `fromId`, `toId`, `weight`, `capacity`, `bandwidth`, `redundancyMode`, `weightFactors`, `conflictPri` | -**Write round-trip confirmed:** an anonymized edge edited via `updateTopology` -is present here with the changed value and a bumped `_rev`, and inter-device -links appear as paired unidirectional edges (one in each direction, typically -with `capacity: 65535`). Internal fan-out edges (vertex→vertex within a device) -use `capacity: 1`. - **Implication:** Inspect's underlying topology store is `nGraphElements`, but the package keeps a separate Inspect model namespace (`InspectApiBaseDevice`, -`InspectApiIpVertex`, `InspectApiUnidirectionalEdge`, …). Do not reuse topology app -model classes in Inspect DTOs. `updateTopology` is **last-writer-wins** — a -stale `_rev` in the payload is ignored ([endpoints.md](./endpoints.md#post-restv2actionsstatuscollectorupdatetopology)). +`InspectApiIpVertex`, `InspectApiUnidirectionalEdge`, …). Do not reuse topology +app model classes in Inspect DTOs. `updateTopology` is **last-writer-wins** — a +stale `_rev` in the payload is ignored +([endpoints.md](./endpoints.md#post-restv2actionsstatuscollectorupdatetopology)). ### 3.4 Tagging — device vs. vertex vs. module (Inspect vs. Topology) @@ -317,7 +282,7 @@ entries in `nGraphElements`). source of truth for tag bindings — confirmed empty in captures while `lookupInspectVertexByIds` carries `assignedTags` and `fields.tags`. - Stage-time baselines and compare-and-commit for vertex edits must use - `lookupInspectVertexByIds` for tag fields ([ADR-0009](./decisions/0009-write-consistency.md)), + `lookupInspectVertexByIds` for tag fields ([ADR-007](./decisions/007-write-consistency.md)), not `nGraphElements` or the collector skeleton. - Module tags are **not** written via `updateTopology`. The Inspect UI uses `POST …/actions/status/tags/assignTag` and `…/unassignTag` with a single @@ -328,9 +293,7 @@ entries in `nGraphElements`). - Collector `tagInfo` provides tag → profile metadata for the aggregate; it does not replace per-vertex `assignedTags` on the lookup response. -## 4. How the transport works today (recap) - -So the new layer fits the existing patterns rather than reinventing them: +## 4. How the transport works - `connector/` is a thin sync HTTP client built on `requests`, with two sub-connectors: REST v2 (`GET`/`PATCH`/`POST`) and RPC (`POST /api/*`). Basic @@ -338,7 +301,7 @@ So the new layer fits the existing patterns rather than reinventing them: - Each connector enforces an **allow-list of URL prefixes** (`ALLOWED_URLS` / `ALLOWED_EXACT_MATCHES`). New Inspect endpoints must be added there — but only Inspect-surface prefixes; `config/network/nGraphElements` is not added - for the Inspect app ([ADR-0008](./decisions/0008-collector-only-endpoints.md)). + for the Inspect app ([ADR-006](./decisions/006-collector-only-endpoints.md)). - Responses are wrapped in a common envelope (`ResponseV2Get`/`…Patch`/`…Post`) with a `header` (`code`, `auth`, …) and a `data`/`result` body, validated by Pydantic. @@ -347,101 +310,10 @@ So the new layer fits the existing patterns rather than reinventing them: and rebuilds the aggregate each time). Inspect deviates deliberately: state is **snapshot-scoped** — a skeleton is loaded up front, detail is lazily hydrated into the same snapshot, and freshness means building a new snapshot - ([ADR-0007](./decisions/0007-lazy-snapshot-loading.md)). There is still no + ([ADR-005](./decisions/005-lazy-snapshot-loading.md)). There is still no cache across snapshots. - Inspect models live in two layers: - `apps/inspect/model` — `InspectApi*` transport DTOs for HTTP payloads - `apps/inspect/domain` and `apps/inspect/snapshot.py` — user-facing read models backed by a collector snapshot and internal indexes -- App/API methods should own fetching, staging, committing, and error handling. - -## 5. Endpoint discovery — how to fill in the **[VERIFY]** gaps - -Two complementary sources: the **official reference** (authoritative for the -documented surface) and **browser capture** (authoritative for what the Inspect -GUI actually does, including undocumented calls). WebSocket *subscriptions* -stay out of scope for the package per ADR-0003, but captured WS frames are a -first-class **discovery source**: the subscription `path`s address the same -data tree as REST v2 and revealed the collector's query language and the UI's -skeleton-first load strategy (see -[endpoints.md — Collector Scoped Queries](./endpoints.md#collector-scoped-queries-captured-from-the-inspect-ui), -[ADR-0007](./decisions/0007-lazy-snapshot-loading.md)). - -0. **Start from the official reference.** The - [VideoIPath Public API 2025 LTS](https://documenter.getpostman.com/view/11222813/2sBXihpCS8#intro) - collection documents `Connections`, `Partial Connections`, and - `Import and Export (preview)`, plus the `config` / `status` / `experimental` - top-level split. Use it as the primary map of the documented surface. - > Inspect uses `/rest/v2/` for collector read/write. Other resources may - > still use v1 paths documented in the public reference — **[VERIFY]** per - > resource during capture. -1. **Capture browser traffic.** Open the Inspect app in the VideoIPath web UI - with the browser DevTools → Network tab. Record a full session (HAR export): - open a device, add/sync devices, connect two devices, **commit a change set**, - and drill into a service. Note REST calls and payloads. -2. **Replay through the package logger.** The connector logs every response at - `DEBUG` (`_log_response`). Set `log_level="DEBUG"` and call candidate - endpoints to confirm payload shapes. -3. **Diff against known resources.** Compare captured paths to the prefixes the - package already allows. New prefixes ⇒ new Inspect resources. -4. **Save representative payloads as fixtures.** Store sanitised JSON under - `tests/inspect/fixtures//…`. These feed the offline tests in - [ADR-0005](./decisions/0005-e2e-testing.md) and double as living - documentation of the wire format. - -### 5.1 Discovery checklist (fill in as confirmed) - -REST: - -- [x] Base path: Inspect uses `/rest/v2/` for collector read/write (`data/status/collector/**`, `actions/status/collector/updateTopology`) -- [x] Service / monitoring aggregate: `GET /rest/v2/data/status/collector/**` → `data.status.collector` with `inspect.nodeStatus`, `inspect.paths`, `externalEdgesByDeviceKey`, `security`, `superProfiles`, `tagInfo` (§3.1) -- [x] Drill-down: services linked via `pathDescriptions` on ports/edges (`deviceLevel` + `serviceLevel`) and `inspect.paths._items[]` (`bookingId`, path segments, `serviceFields`) -- [x] Collector sub-paths & query language: confirmed via Inspect UI WebSocket capture — `* where `, `limit N`, deep projections with `/.../` up-navigation, `**`, `"_noId"` expansion-suppressor; UI loads skeleton-first (`modules/"_noId"`) — decoded queries in [endpoints.md](./endpoints.md#collector-scoped-queries-captured-from-the-inspect-ui) ([ADR-0007](./decisions/0007-lazy-snapshot-loading.md)) -- [x] Scoped-query REST GET equivalence: confirmed for practical query lengths; full UI projection → HTTP 414 ([endpoints.md](./endpoints.md#collector-scoped-queries-captured-from-the-inspect-ui)) -- [x] Single-device hydration form: both `nodeStatus//…` and `* where deviceId='…' limit 1/…` work; prefer direct id -- [x] Exact `"_noId"` semantics: subtree expansion suppressor → `modules: {}` at collection level, key omitted on single-device queries -- [x] Populated `modules/*` detail payload: confirmed on synced devices; `syncSeverity=2` devices return empty modules until synced -- [x] Skeleton vs. full aggregate: ~92 KB skeleton (27 devices + 40 edges) vs ~19 MB `/**` on this instance; `limit` not required when `where` is omitted -- [x] Connections / Partial Connections: services via `collector.inspect.paths` + `conman.services`; `bid` ↔ `connection.id` -- [x] Change set / commit endpoint: `POST /rest/v2/actions/status/collector/updateTopology` — bulk delta with `replaceDevices`, `replaceVertices`, `replaceEdges` (key `"fromId::toId"`), `replaceResourceTransforms`, `addExternalEdges`, `remove`, `force` ([ADR-0006](./decisions/0006-commit-write-model.md)) -- [x] Change set staging: client-side gather until `updateTopology`; no separate server-side change-set id for the verified flow (ADR-0006) -- [x] Commit failure response: check `data.res.ok` / `data.validation.result.ok` (not `header.ok`); `validation.details[id]` carries `status`, `rev`, `resolvable`, `type`; failed delete example: `"A required edge was not found. (main)"`, `items: []` ([ADR-0006](./decisions/0006-commit-write-model.md)) -- [x] Commit success response for no-op: `data.items: []`, `data.res.ok: true`, `data.validation.result.ok: true` ([endpoints.md](./endpoints.md#post-restv2actionsstatuscollectorupdatetopology)) -- [x] Commit success response with real applied changes: `data.items[]` with `{id, idx, res.ok}` per applied entity ([endpoints.md](./endpoints.md#post-restv2actionsstatuscollectorupdatetopology)) -- [x] Commit semantics: reject-before-apply; invalid ops return `items: []`; client-side staging only (no server change-set id) -- [x] Import / Export (preview): **unregistered** on 2025.4.9 — empty data namespaces; GET action schema empty; POST → `No action node in request` -- [x] Write/action endpoint: `/rest/v2/actions/status/collector/updateTopology` -- [x] Collector action payloads captured and modelled: `/rest/v2/actions/status/collector/lookupInspectDevice`, `/rest/v2/actions/status/collector/lookupSyncInfo` -- [x] Network action request shapes, normal action responses, and validation-error responses captured: `/rest/v2/actions/status/network/addDevices`, `/rest/v2/actions/status/network/syncDevices`, `/rest/v2/actions/status/network/updateVirtualInstances`, `/rest/v2/actions/status/network/updateVirtualTemplates`, `/rest/v2/actions/status/network/addVirtualTopology` -- [x] Virtual device / port-template status reads: `GET …/status/network/virtualDevices/**`, `GET …/status/network/virtualTemplates/**`; `lookupInspectDevice.fields.virtualDeviceFields` typed as `{dynamic, manual}` module lists -- [x] Config store / write target: `updateTopology` lands in `GET /rest/v2/data/config/network/nGraphElements/**` (`_items[]`, `_rev`, `type` ∈ `baseDevice` / `codecVertex` / `ipVertex` / `unidirectionalEdge`); model with standalone `InspectApi*` DTOs (§3.3) -- [x] `_rev` handling on commit: last-writer-wins; `_rev` in `replaceEdges` payload is ignored -- [x] Version gating: collector read/write and `updateTopology` verified on **2025.4.9** -- [x] DTO coverage: add typed request/response models for captured lookup, action result, and validation-error endpoint payloads in [endpoints.md](./endpoints.md) - -Write consistency & post-commit refresh ([ADR-0009](./decisions/0009-write-consistency.md), [ADR-0010](./decisions/0010-post-commit-snapshot-refresh.md)) — verified live on 2025.4.9 (2026-07-08): - -- [x] Persisted-element lookups: `lookupInspectEdgesByIds` returns the **full persisted edge form** (batched); `lookupInspectVertexById`/`…ByIds` and `lookupInspectDevice` return editable forms; **no `_rev` in any lookup response**; `lookupGraphElement` does **not** exist; `lookupNodeInfo`/`lookupEdgeInfo`/`lookupDeviceVertices` are display-oriented ([endpoints.md](./endpoints.md#post-restv2actionsstatuscollectorlookupinspectedgesbyids)) -- [x] Vertex tag bindings: separate from `nGraphElements` — stored server-side in `videoipath_docs.device_tags`; surfaced on `lookupInspectVertexByIds` (`assignedTags`, `fields.tags`, `fields.localAssignedTags`) and hydrated port `tagsInfo` in `nodeStatus`; not modelled by `app.topology` (§3.4) -- [x] Module tag bindings: read from hydrated module `tagsInfo`; write via `assignTag` / `unassignTag` with `device:{modulePid}` element ids (not `updateTopology`) (§3.4) -- [x] UI edit/commit flows avoid `nGraphElements`: the Inspect UI bundle contains zero references; the edge edit flow calls `lookupInspectEdgesByIds` with both pair directions ([ADR-0008](./decisions/0008-collector-only-endpoints.md)) -- [x] Batched lookups for compare-and-commit baselines: `…ByIds` actions take id lists natively -- [x] Direct edge-pair addressing for targeted refresh: `externalEdgesByDeviceKey//` returns the single pair item -- [x] Collector propagation delay: measured via device coordinate commit + revert (three samples) — change visible on the **first poll ~25 ms after the POST returned**; no post-commit retry window needed ([ADR-0010](./decisions/0010-post-commit-snapshot-refresh.md)) -- [x] `replaceDevices` payload shape: the **edit form** (`lookupInspectDevice.fields`; `coordinates`/`localAssignedTags` mandatory) — the raw persisted `baseDevice` element is rejected with HTTP 400; round-trip verified byte-identical except `_rev` ([endpoints.md](./endpoints.md#post-restv2actionsstatuscollectorupdatetopology)) -- [x] `replaceVertices` payload shape: the **vertex edit form** (`lookupInspectVertexById.fields`) — verified via desc round-trip on a live vertex (byte-identical revert). **Update-only**: an unknown vertex id fails validation with *"Vertex with id … was not found in graph"* — standalone vertices cannot be created via `updateTopology` (they originate from device sync / virtual-device definitions) ([endpoints.md](./endpoints.md#post-restv2actionsstatuscollectorupdatetopology)) -- [ ] Re-check on every server upgrade: does `updateTopology` gain `_rev` enforcement (would allow replacing client-side compare-and-commit) - -## 6. Open questions - -_All VERIFY items are resolved (results merged into -[endpoints.md](./endpoints.md) and the §5.1 checklist) or documented as -confirmed limitations / unregistered stubs._ - -Remaining follow-up (only if a future server version registers new actions): - -- Re-run `GET /rest/v2/actions/status/collector/` schema check after - upgrade; probe POST only for newly registered actions — and re-check whether - `updateTopology` gains `_rev` enforcement (ADR-0009 would be revisited). -- Capture a `resolvable: true` validation case if one appears in the UI during - normal operator workflows. +- App/API methods own fetching, staging, committing, and error handling. diff --git a/docs/architecture/inspect-app/decisions/0001-api-paradigm.md b/docs/architecture/inspect-app/decisions/0001-api-paradigm.md deleted file mode 100644 index c123cdb..0000000 --- a/docs/architecture/inspect-app/decisions/0001-api-paradigm.md +++ /dev/null @@ -1,65 +0,0 @@ -# ADR-0001: API paradigm — data-driven, request/response - -> Status: **Accepted** -> Date: 2026-06-15 · Deciders: Paul Winterstein, Jonas Scholl - -## Context - -The package today is **data-driven**: the user fetches a configuration -aggregate (`InventoryDevice`, `TopologyDevice`), mutates the object locally, and -calls `update_*`, which diffs against the server state and `PATCH`es only the -changed elements (revision-checked). It is stateless and request/response based. - -Inspect adds a **monitoring** dimension (services, status, drill-down). The -primary consumers are **deterministic pipeline automations** — short-lived, -scripted runs that load state, apply changes, and exit. For that usage model, -live event streams and WebSocket subscriptions add complexity without clear -benefit: each run should start from an explicit server read and produce a known -outcome. - -The question was whether to keep everything data-driven, move to an -event/action-driven model, or combine the two. This maps onto the config-plane -vs. status-plane split described in -[concepts.md](../concepts.md#3-domain-model). - -## Options - -1. **Data-driven only (status via polling).** - - Pros: zero conceptual change; consistent with existing apps; trivial to - test; no new dependencies. - - Cons: polling is chatty and laggy for live monitoring; ignores the new - WebSocket capability that motivated the work. - -2. **Event/action-driven only.** - - Pros: best fit for live monitoring; matches Inspect's UX. - - Cons: poor fit for configuration CRUD (which is inherently - request/response + revisioned); large departure from existing apps; high - migration cost; harder to reason about for simple scripts. - -3. **Hybrid: data-driven CRUD for the config plane, event-driven observation for the status plane.** - - Pros: each plane uses the model that fits it; preserves the existing CRUD - surface and tests; isolates the new event machinery; lets users opt into - live features only when needed. - - Cons: two interaction styles in one app; must document clearly which - methods are "read/modify/write" vs. "subscribe/observe". - -## Decision - -**Option 1: data-driven only (request/response).** - -The package stays fully data-driven. Reads fetch aggregates from the server; -writes apply changes via explicit API calls. No WebSocket subscriptions, no live -update layer, no event-driven observation API. - -Status reads use the same request/response model as configuration CRUD. If -freshness is needed, the caller re-fetches explicitly. - -## Consequences - -- Consistent with existing apps and the automation/pipeline usage model. -- No WebSocket client, subscription machinery, or dual interaction styles to - build or document. -- Live monitoring UX (as in the Inspect UI) is out of scope for the package; - automations get predictable, reproducible runs instead. -- See [ADR-0003](./0003-websocket-subscriptions.md) (deprecated) and - [ADR-0004](./0004-async-strategy.md) for related decisions. diff --git a/docs/architecture/inspect-app/decisions/0002-loading-and-state.md b/docs/architecture/inspect-app/decisions/0002-loading-and-state.md deleted file mode 100644 index 90a79f6..0000000 --- a/docs/architecture/inspect-app/decisions/0002-loading-and-state.md +++ /dev/null @@ -1,81 +0,0 @@ -# ADR-0002: Loading & state model - -> Status: **Superseded by [ADR-0007](./0007-lazy-snapshot-loading.md)** -> Date: 2026-06-15 · Deciders: Paul Winterstein, Jonas Scholl -> -> The per-read decision below ("always load the full aggregate") did not hold -> up for large environments; a WebSocket capture of the Inspect UI later -> confirmed collector projection/filter support and showed the vendor UI -> loading skeleton-first. ADR-0007 replaces the per-read model with -> skeleton-first loading + lazy hydration. The "no client-side cache across -> reads/snapshots" part of this ADR still stands. - -## Context - -This ADR is about **how data is fetched from the VideoIPath API** — eager vs. -lazy, how much is pulled per call, and whether anything is cached client-side. -It is **not** about app-object construction; the lazy `app.inspect` property -(built on first access, like the other apps) is a separate, settled detail and -out of scope here. - -Existing apps are **stateless** and **fetch-on-demand**: each call re-reads from -the server and rebuilds objects. A single read can be chatty — e.g. -`topology.get_device` issues several `GET`s and reassembles the aggregate every -time. - -**Usage context matters:** this package is used for **automations, pipelines and -bulk operations** — typically short-lived, scripted, do-and-exit runs — not -long-lived dashboards or applications. That favours **predictability and -freshness** over client-side caching, and makes **efficient bulk reads** the -real performance concern. - -Two sub-questions follow: - -1. **Per read** — fetch the full aggregate eagerly, or lazily/narrowly? -2. **Across reads** — stay stateless, or cache client-side? - -## Options - -1. **Stateless, eager per-entity (status quo).** Each call fetches the full - aggregate for the requested entity. - - Pros: predictable, always fresh, no cache-invalidation, easy to test, - matches existing apps; yields one coherent object. - - Cons: chatty; pulls more than a bulk job often needs; N+1 when iterating - many entities. - -2. **Stateless + field/section projection & bulk helpers.** Default to eager per - entity, but let callers request only the fields/sections they need and offer - list-with-projection helpers. (REST v2 already supports projections, e.g. - `/id,rev,vid`, `/maps/0/x,y`.) - - Pros: keeps statelessness & freshness; cuts payload and request count for - pipelines/bulk ops; still simple to test. - - Cons: more method surface (projection params); caller must know which - sections it needs. - -3. **Client-side cache / long-lived snapshot.** Hydrate once and serve - subsequent reads locally (optionally refreshed via WebSocket). - - Pros: cheap repeated reads for a long-running process. - - Cons: stateful; staleness & cache-invalidation risk; consistency issues vs. - concurrent writers; little benefit for short-lived automations; harder to - test. - -## Decision - -**Option 1: stateless, eager per-entity — always load the full aggregate.** - -Each read fetches the **complete device aggregate** for the requested entity, -including edges, connections, vertices, and related sections. No lazy partial -objects, no field/section projection as a default path, and no client-side cache -or long-lived snapshots. - -Bulk helpers may be added later to reduce N+1 when iterating many entities, but -each entity returned is still a full aggregate. - -## Consequences - -- Predictable object shape: callers always receive a coherent, complete device. -- Always fresh on each read; no cache-invalidation logic. -- Chatty per-entity reads remain a known cost; internal parallelism for - multi-request reads is handled separately ([ADR-0004](./0004-async-strategy.md)). -- Projection/narrow reads and client-side caching are deferred unless a concrete - pipeline need emerges. diff --git a/docs/architecture/inspect-app/decisions/0003-websocket-subscriptions.md b/docs/architecture/inspect-app/decisions/0003-websocket-subscriptions.md deleted file mode 100644 index c24fcaa..0000000 --- a/docs/architecture/inspect-app/decisions/0003-websocket-subscriptions.md +++ /dev/null @@ -1,72 +0,0 @@ -# ADR-0003: WebSocket event subscriptions - -> Status: **Deprecated** -> Date: 2026-06-15 · Deciders: Paul Winterstein, Jonas Scholl - -## Context - -The VideoIPath server exposes a **WebSocket channel** for live status/event -updates. This ADR originally explored how the Python package might consume -those subscriptions. - -The package has no WebSocket client today; the connector layer is sync -`requests`. [ADR-0001](./0001-api-paradigm.md) now accepts a fully -data-driven, request/response model for deterministic pipeline automations, -which makes WebSocket support unnecessary for the package's scope. - -The [Public API 2025 LTS](https://documenter.getpostman.com/view/11222813/2sBXihpCS8#intro) -reference **confirms** the high-level model: the `status` plane is "frequently -updated and a good candidate for subscriptions", and subscription handling uses -**event-based (delta) change reporting** from server to client. Protocol details -verified on 2025.4.9 ([endpoints.md — subscription transport](../endpoints.md#observed-subscription-transport-reference-only--out-of-package-scope)): - -| Item | Value | -| ---- | ----- | -| URL | `{ws\|wss}:///rest/v2/sessions/me/ws?exclusive=false` | -| Auth | Session cookies on handshake | -| Subscribe | `{"channel":"subsc","messageId":N,"path":"…","id":""}` | -| Reply | `{"channel":"subsc","id":"…","messageId":N,"payload":{"_id":"…","_ver":[1,1],"data":{…}}}` | -| Unsubscribe | `{"channel":"unsubsc","messageId":N,"id":""}` | - -Captured frames also in `websocket-messages.txt`. See -[concepts.md §5](../concepts.md#5-endpoint-discovery--how-to-fill-in-the-verify-gaps). - -## Options - -1. **Skip WebSocket; poll status endpoints.** - - Pros: no new dependency or concurrency; trivial. - - Cons: laggy, chatty, misses the feature's point; doesn't scale to many - watched entities. - -2. **Async-native WebSocket only** (e.g. `websockets`/`httpx-ws`), exposed via - `async`/`await` + async iterators. - - Pros: idiomatic, efficient, composes with other async I/O. - - Cons: forces asyncio on all consumers unless wrapped; bigger change now. - -3. **Sync-friendly WebSocket with a background thread** (e.g. - `websocket-client`) exposing callbacks / a blocking iterator / a `queue`. - - Pros: no asyncio required by users; drops into existing sync scripts. - - Cons: thread lifecycle management; not as clean for high-concurrency. - -4. **Both, behind one façade:** a transport-agnostic subscription API with a - sync default (thread-backed) and an async implementation, sharing typed event - models. - -## Decision - -**Option 1: skip WebSocket; no live subscriptions in the package.** - -WebSocket event subscriptions are **out of scope**. The package does not -implement a subscription API, background listener thread, or async event stream. -Callers that need updated status re-fetch via the normal read methods. - -This ADR is **deprecated** — kept for historical context on the server -capability, not as an active design direction. - -## Consequences - -- No new WebSocket dependency or concurrency machinery. -- Removes the primary driver for an async public API surface. -- Server-side subscription capability remains documented in - [concepts.md](../concepts.md) for reference; a future ADR would be needed to - revisit this if long-lived monitoring clients become a package goal. diff --git a/docs/architecture/inspect-app/decisions/0004-async-strategy.md b/docs/architecture/inspect-app/decisions/0004-async-strategy.md deleted file mode 100644 index fcb69c4..0000000 --- a/docs/architecture/inspect-app/decisions/0004-async-strategy.md +++ /dev/null @@ -1,60 +0,0 @@ -# ADR-0004: Async readiness & migration - -> Status: **Accepted** -> Date: 2026-06-15 · Deciders: Paul Winterstein, Jonas Scholl - -## Context - -The package is **sync**, built on `requests`. Bulk reads (e.g. assembling a full -device aggregate) can benefit from **concurrent I/O** when a single high-level -action requires multiple API requests. - -There is an **existing sync user base and sync test suite**, and a published -PyPI package (`0.8.x`). We must not break sync users, and we should avoid a -risky big-bang rewrite. WebSocket subscriptions ([ADR-0003](./0003-websocket-subscriptions.md)) -are out of scope, removing the main reason for an async public API. - -Key structural fact: the **Pydantic models and diff/patch logic are -I/O-agnostic**. Only the connector and api/app methods are I/O-bound. - -## Options - -1. **Stay sync-only; thread the WebSocket.** - - Pros: lowest risk; no breaking changes; ships now. - - Cons: no concurrency for bulk ops; doesn't future-proof. - -2. **Async-first, sync as a thin shim** (`asyncio.run` / run-in-thread wrappers). - - Pros: one source of truth (async); future-proof. - - Cons: breaking for current sync users; sync-over-async shims have real - footguns (event-loop reentrancy, nested loops in notebooks, thread-pool - surprises); large immediate change. - -3. **Dual-stack on a shared core:** migrate the transport to - `httpx` (one library, sync **and** async clients with the same API), keep the - Pydantic models + diff logic shared, and provide both a sync and an async API - surface. Maintain the two surfaces from one source using a code transform - (e.g. [`unasync`](https://github.com/python-trio/unasync)) rather than by - hand. - - Pros: sync users keep working; async users get first-class support; - WebSocket fits the async side; minimal duplicated logic. - - Cons: build-time codegen/tooling to set up; both surfaces must be tested. - -## Decision - -**Stay sync at the package boundary; use internal parallelism only where a -single action needs multiple API requests.** - -The public API remains synchronous — no `async`/`await` surface, no dual-stack -codegen, no migration to `httpx` async clients. When one high-level operation -(e.g. loading a full device aggregate) requires several independent `GET`s, -those requests may be issued **in parallel internally** (e.g. thread pool) to -reduce latency. This is an implementation detail of the connector/read path, not -a new interaction model for callers. - -## Consequences - -- Zero breaking change for existing sync users and scripts. -- No async test matrix, no `unasync` tooling, no sync-over-async footguns. -- Performance gains are limited to multi-request reads/writes inside the - library; callers do not manage concurrency themselves. -- A future async public API would require a new ADR; it is not planned now. diff --git a/docs/architecture/inspect-app/decisions/0005-e2e-testing.md b/docs/architecture/inspect-app/decisions/0005-e2e-testing.md deleted file mode 100644 index d86b6c3..0000000 --- a/docs/architecture/inspect-app/decisions/0005-e2e-testing.md +++ /dev/null @@ -1,54 +0,0 @@ -# ADR-0005: E2E testing strategy - -> Status: **Accepted** -> Date: 2026-06-15 · Deciders: Paul Winterstein, Jonas Scholl - -## Context - -Inspect spans config CRUD and status reads against a proprietary server whose -payloads vary by version. Tests must be **low-effort and trustworthy** — when -green they should genuinely mean "this works against VideoIPath". - -Today the suite is small (validators) and a live server is configured via -`tests/.env.test` + `pytest-dotenv`. There is precedent for "validate our -assumptions against the live server": `advanced_driver_schema_check` compares -local vs. server driver schemas. - -We want to keep testing **simple** for now: one layer, run locally by the -developer against a real instance — not a multi-tier strategy with mocks, -cassettes, and fake servers. - -## Options - -- **Live server only** — trustworthy, but flaky/stateful/slow; bad for CI on - every push. -- **Recorded HTTP interactions** (`respx` for `httpx` / `vcrpy` for `requests`) - — record real request/response pairs once, replay offline. Fast, stable, - realistic — as long as cassettes are periodically re-recorded. -- **Fake VideoIPath server** (small FastAPI app emulating endpoints + WS) — more - setup, but enables deterministic **end-to-end incl. WebSocket** flows that - cassettes can't easily replay. -- **Fixture/contract tests** — assert our Pydantic models parse real captured - payloads and our diff logic produces expected patches. Cheap, deterministic, - catches model drift. - -## Decision - -**Live server E2E only — developer-run, locally, against a real VideoIPath instance.** - -E2E tests use the Python package to execute full test scenarios against a live -server. The developer provides credentials and connection details (via -`tests/.env.test` or equivalent). No recorded HTTP cassettes, no fake VideoIPath -server, and no separate contract/fixture test layer for now. - -These tests are **not** required on every CI push; they are run locally when a -developer has an instance available. - -## Consequences - -- Highest confidence: tests exercise the real API, auth, and payload shapes. -- Simple setup: one test style, one configuration path, no cassette maintenance. -- Tests are stateful, environment-dependent, and slower — acceptable trade-off - for the current team size and Inspect scope. -- Mock/cassette/fake-server layers can be revisited via a new ADR if CI - automation or faster feedback loops become a priority. diff --git a/docs/architecture/inspect-app/decisions/0006-commit-write-model.md b/docs/architecture/inspect-app/decisions/0006-commit-write-model.md deleted file mode 100644 index 6f97054..0000000 --- a/docs/architecture/inspect-app/decisions/0006-commit-write-model.md +++ /dev/null @@ -1,294 +0,0 @@ -# ADR-0006: Commit-style write model (change sets) - -> Status: **Accepted** -> Date: 2026-06-18 · Deciders: Paul Winterstein, Jonas Scholl -> -> Concurrency handling for this write model is decided in -> [ADR-0009](./0009-write-consistency.md) (compare-and-commit; the server is -> last-writer-wins). Post-commit snapshot refresh is decided in -> [ADR-0010](./0010-post-commit-snapshot-refresh.md). - -## Context - -Inspect applies configuration changes with a **commit-style** model: when the -operator creates, edits, or deletes topology elements / connections, the actions -are first **gathered into a change set** and then **committed together**, rather -than each change hitting the server immediately. - -This differs from the existing apps. Today the write path is **immediate and -per-call**: - -- Topology: `get_device` → mutate locally → `update_device` diffs against the - server and applies the changes with a revisioned - `PATCH /rest/v2/data/config/network/nGraphElements` (mode `strict`, `_rev` - checked). -- Inventory: `add_device` / `update_device` go through the RPC - `/api/updateDevices` call. - -There is **no commit, transaction, or staging concept** in the package today -(the topology "staged device" is only an in-memory diff input, not a server-side -batch). So Inspect's commit behaviour is a **net-new write path**, not a reuse of -the existing one. - -**Data vs. operations:** Pydantic models / DTOs hold **data only** — fields, -nested structures, validation. They do not expose `add`, `update`, `delete`, or -`commit` methods. All mutations go through the **app instance** or an optional -**transaction** object that stages changes and commits them to the server. - -### Verified wire format (browser capture, 2026-06-18) - -Inspect applies topology edits with a **single bulk action** — not the existing -revisioned `PATCH …/data/config/network/nGraphElements` path: - -| Item | Value | -| ---- | ----- | -| Method / path | `POST /rest/v2/actions/status/collector/updateTopology` | -| Auth | Session cookies (`VipathSession`, …) + `XSRF-TOKEN` cookie and matching `x-xsrf-token` header | -| Envelope | Standard v2 `{ "header": { "id": 0 }, "data": { … } }` | - -The `data` object carries the **entire delta** in one request. Empty maps / -arrays mean “no change” for that category: - -| Field | Shape | Purpose | -| ----- | ----- | ------- | -| `replaceDevices` | `Record` — the `lookupInspectDevice.fields` shape (`coordinates`, `localAssignedTags` mandatory; raw `baseDevice` element rejected — verified 2025.4.9) | Upsert devices | -| `replaceVertices` | `Record` — the `lookupInspectVertexById.fields` shape (verified 2025.4.9); **update-only**: unknown ids fail validation | Update vertices | -| `replaceEdges` | `Record<"fromId::toId", edge>` — raw persisted edge form (composite key) | Upsert edges | -| `replaceResourceTransforms` | `Record` | Upsert resource transforms | -| `addExternalEdges` | `edge[]` | Add external edges | -| `remove` | `string[]` | Remove entities by id | -| `force` | `boolean` | Force apply (example used `false`) | - -**Edge key:** `"::"` — e.g. -`device0.1.10.out::device1.1.11.in`. - -**Minimal example** (set edge `weight` to `1`; other categories empty): - -```json -{ - "header": { "id": 0 }, - "data": { - "replaceDevices": {}, - "replaceVertices": {}, - "replaceEdges": { - "device0.1.10.out::device1.1.11.in": { - "fromId": "device0.1.10.out", - "toId": "device1.1.11.in", - "descriptor": { "label": "Eth 1.10 (out) -> Eth 1.11 (in)", "desc": "" }, - "fDescriptor": { "label": "", "desc": "" }, - "tags": [], - "active": true, - "weight": 1, - "capacity": 65535, - "bandwidth": -1, - "weightFactors": { - "bandwidth": { "weight": 0 }, - "service": { "weight": 0, "max": 100 } - }, - "redundancyMode": "Any", - "conflictPri": 0, - "includeFormats": [], - "excludeFormats": [] - } - }, - "replaceResourceTransforms": {}, - "addExternalEdges": [], - "remove": [], - "force": false - } -} -``` - -This confirms the **gather-then-commit** mental model at the API boundary: the -Inspect UI accumulates edits client-side, then sends one `updateTopology` payload -with populated `replace*` / `add*` / `remove` sections. Reads use the matching -**collector** namespace: `GET …/data/status/collector/**` returns the composed -topology + status + services tree ([concepts.md §3.1](../concepts.md#31-collector-aggregate--primary-read-surface)). -Edge keys (`fromId::toId`) and vertex ids are consistent between read and write. - -#### Response shape — successful commit (verified 2026-07-08, VideoIPath 2025.4.9) - -Edge `weight` change applied via `replaceEdges`: - -```json -{ - "data": { - "items": [ - { - "external": null, - "id": "device-a.dev.module-1.port-out-1.out::device-b.dev.module-1.port-in-1.in", - "idx": 0, - "res": { "msg": [""], "ok": true } - } - ], - "res": { "msg": [], "ok": true }, - "validation": { - "createIds": [], - "details": {}, - "result": { "msg": [], "ok": true } - } - }, - "header": { "ok": true, "code": "OK" } -} -``` - -#### Response shape — failed commit (browser capture, 2026-06-18) - -**Important:** HTTP / envelope success is **not** commit success. On a failed -delete-device attempt the top-level `header` still reported success: - -| Field | Failed-commit value | Meaning | -| ----- | ------------------- | ------- | -| `header.ok` | `true` | Transport / auth succeeded | -| `header.code` | `"OK"` | Envelope accepted | -| `data.res.ok` | `false` | Commit rejected | -| `data.res.msg` | `["Validation failed"]` | High-level failure reason | -| `data.validation.result.ok` | `false` | Validation gate failed | -| `data.validation.result.msg` | e.g. `["A required edge was not found. (main)"]` | Actionable validation message | -| `data.items` | `[]` | No applied changes returned | - -Per-entity validation details live in `data.validation.details`, keyed by id -(e.g. `"booking-1001"`): - -| Field | Example | Notes | -| ----- | ------- | ----- | -| `status` | `-22` | Server-specific error code | -| `rev` | `"2-2026-06-15T13:03:50.631612311Z[UTC]"` | Entity revision at validation time | -| `resolvable` | `false` | Whether the client can fix/retry in-place | -| `type` | `"generic"` | Error category | -| `isCancel` / `isProduct` | `false` | Flags on the validation item | - -```json -{ - "header": { - "auth": true, - "caption": "Operation Successful", - "code": "OK", - "ok": true, - "user": "api-user" - }, - "data": { - "items": [], - "res": { - "msg": ["Validation failed"], - "ok": false - }, - "validation": { - "createIds": [], - "details": { - "booking-1001": { - "isCancel": false, - "isProduct": false, - "resolvable": false, - "rev": "2-2026-06-15T13:03:50.000000000Z[UTC]", - "status": -22, - "type": "generic" - } - }, - "result": { - "msg": ["A required edge was not found. (main)"], - "ok": false - } - } - } -} -``` - -The package must treat a commit as failed when `data.res.ok` or -`data.validation.result.ok` is `false`, even if `header.ok` is `true`. Revisions -appear on validation detail entries (`rev`), not as a top-level `_rev` conflict -like the existing `PATCH nGraphElements` path. On 2025.4.9, `updateTopology` is -**last-writer-wins** — a stale `_rev` in the payload is ignored -([endpoints.md](../endpoints.md#post-restv2actionsstatuscollectorupdatetopology)). - -### Write target — the `nGraphElements` config store (confirmed) - -`updateTopology` is a status-namespace **action**, but it persists to the -existing **`config/network/nGraphElements`** store. Confirmed by capture: the -edge `device0.1.10.out::device1.1.11.in` set to `weight: 1` via this action -appears in `GET /rest/v2/data/config/network/nGraphElements/**` with `weight: 1` -and a bumped `_rev` (`3-…`). Elements are revisioned (`_rev` = `N-`) -and typed (`baseDevice`, `codecVertex`, `ipVertex`, `unidirectionalEdge`) — the -same model `app.topology` already uses. Inter-device links persist as **paired -unidirectional edges** (`a::b` and `b::a`, `capacity: 65535`); the failed-delete -error *"A required edge was not found. (main)"* refers to this edge model. See -[concepts.md §3.3](../concepts.md#33-config-store--ngraphelements-write-target). - -This means the change-set write layer does **not** need new topology element -models — it can reuse the existing ones for the persisted form, while the -collector read aggregate keeps its own status-oriented shape. **Confirmed -last-writer-wins** on 2025.4.9 — `updateTopology` does not enforce `_rev` -checks; stale `_rev` in `replaceEdges` is ignored -([endpoints.md](../endpoints.md#post-restv2actionsstatuscollectorupdatetopology)). - -What remains unverified: - -- ~~Whether the server exposes a **separate staging / change-set id** API~~ - **Confirmed client-side only** on 2025.4.9. -- ~~**Successful** commit response shape~~ **Confirmed** (see above). -- ~~**Partial apply** after validation~~ **Confirmed reject-before-apply**. -- ~~Validation `status` codes and `resolvable`~~ **Confirmed** for booking-blocked - delete (`status: -22`, `resolvable: false`). `resolvable: true` not observed. -- ~~`validateTopology` / `discardTopology` / `importTopology` / `exportTopology`~~ - **Confirmed unregistered** on 2025.4.9 via - `GET /rest/v2/actions/status/collector/` schema (empty `collector: {}`); - 27+ POST payload variants tried. See - [endpoints.md — Action registration discovery](../endpoints.md#action-registration-discovery). -- ~~Import / Export (preview)~~ **Confirmed unregistered** — empty data - namespaces and empty GET action schema. -- Other `…/actions/status/collector/*` lookup endpoints — captured in - [endpoints.md](../endpoints.md). - -## Options - -1. **Auto-commit per call (hide the change set).** Each `add/update/delete` - transparently opens a change set, applies one action, and commits — mimicking - the existing immediate-write UX. - - Pros: familiar; matches the current topology/inventory mental model; simple - for one-off scripts. - - Cons: defeats the point of batching (atomic multi-element changes, fewer - round-trips, single validation/commit); doesn't expose commit-time conflict - handling; may not even match how the server expects connections to be built. - -2. **Explicit change set only.** Force callers to open a change set, stage - actions, then commit (or discard). - - Pros: faithful to the server model; enables atomic multi-step edits and a - single pre-commit validation; explicit conflict/rollback handling. - - Cons: more ceremony for the trivial single-change case; easy to leak an - un-committed change set. - -3. **Hybrid: explicit change set with a convenience auto-commit default.** - Provide a first-class change-set object (ideally a context manager) for - batched, atomic edits, *and* a convenience path where a single - `add/update/delete` auto-commits when no change set is open. - - Pros: ergonomic for both one-off scripts and batched pipeline edits; - exposes commit/validate/discard when needed; degrades to simple usage. - - Cons: two usage styles to document; must define what happens to an open - change set on error / context exit. - -## Decision - -**Option 3 (hybrid): explicit transaction via context manager, plus direct write -operations on the app.** - -- **Data-only DTOs** — models represent server payloads; no behaviour methods. -- **App-level direct writes** — `add` / `update` / `delete` on the inspect app - apply and commit immediately (one change set, one `updateTopology` call), for - simple one-off scripts. -- **Optional transaction** — a context manager (e.g. `with app.inspect.transaction()` - or similar) stages multiple actions and commits them atomically on exit; discard - on error or explicit cancel. - -Staging is **client-side** until the `POST …/updateTopology` commit; there is no -separate server-side change-set id (per verified wire format below). - -## Consequences - -- Two documented usage styles, but both map to the same underlying commit - payload shape. -- Context manager must define exit behaviour: commit on success, discard/raise - on failure; document what happens to an uncommitted transaction. -- DTOs stay portable and serializable; business logic lives in the app/transaction - layer, consistent with existing topology/inventory patterns. -- Single-change scripts stay ergonomic via direct writes; pipeline edits that - touch multiple elements use the transaction path for atomicity. diff --git a/docs/architecture/inspect-app/decisions/0007-lazy-snapshot-loading.md b/docs/architecture/inspect-app/decisions/0007-lazy-snapshot-loading.md deleted file mode 100644 index 670ee20..0000000 --- a/docs/architecture/inspect-app/decisions/0007-lazy-snapshot-loading.md +++ /dev/null @@ -1,126 +0,0 @@ -# ADR-0007: Skeleton-first snapshot loading with lazy hydration - -> Status: **Accepted** — supersedes the per-read decision of -> [ADR-0002](./0002-loading-and-state.md) -> Date: 2026-07-08 · Deciders: Jonas Scholl - -## Context - -[ADR-0002](./0002-loading-and-state.md) decided "stateless, eager per-entity — -always load the full aggregate." For the Inspect snapshot this means -`GET /rest/v2/data/status/collector/**`: one request whose payload is -O(entire network) and dominated by module/port subtrees and the heavily -denormalized `pathDescriptions`. On large environments (thousands of nodes) -this is too slow for the common automation case, which typically touches the -graph structure of all devices but the *detail* of only a few. - -Two facts changed since ADR-0002 was accepted (see -[endpoints.md — Collector Scoped Queries](../endpoints.md#collector-scoped-queries-captured-from-the-inspect-ui)): - -1. A WebSocket capture of the Inspect UI proves the collector supports - **scoped queries** on its sub-paths: `* where ` filters, `limit N`, - `order by`, and deep field projections — the `[VERIFY]` item from - ADR-0002/concepts §6 is resolved. -2. The **vendor UI itself loads skeleton-first**: its main topology query - suppresses the module subtree (`modules/"_noId"` → `modules: {}`) and its - edge query projects only ids, labels, and status severities. Full module/ - port detail is fetched by separate, narrower queries. - -The usage context from ADR-0002 still holds: short-lived automation runs that -favour predictability and freshness — but the "efficient bulk reads" concern -now outweighs "one request returns everything." - -## Options - -1. **Eager full aggregate (status quo, ADR-0002).** One `/**` fetch per - snapshot. - - Pros: single request; point-in-time consistent; simplest. - - Cons: O(network) payload; seconds–minutes on large plants; most of the - data is never touched. - -2. **Skeleton + transparent per-entity lazy hydration into one accreting - snapshot state.** - - Pros: initial load ~proportional to device/edge count, not port count; - detail cost paid only for entities actually inspected; mirrors the vendor - UI's own strategy; domain surface unchanged (property access just works). - - Cons: hidden HTTP on property access; snapshot no longer single-point-in- - time; N+1 risk when iterating details; more bookkeeping. - -3. **Skeleton + explicit `load_details()` calls.** - - Pros: no hidden I/O; explicit cost model. - - Cons: leaks the loading mechanics into every user workflow; unloaded - property access needs error or `None` semantics — worse ergonomics. - -4. **Per-field projections on every access.** - - Pros: minimal bytes per read. - - Cons: extremely chatty; complex merge bookkeeping; no coherent entity - objects. - -## Decision - -**Option 2: skeleton-first snapshot with transparent per-entity lazy -hydration.** The internal-state concept of `InspectSnapshot` is unchanged — -one snapshot, one set of indexes, domain objects resolve through it — but the -state is allowed to be **partially populated** and accretes as details are -fetched. - -- **Skeleton load.** A snapshot is built from two parallel scoped GETs, - using the queries captured from the UI (endpoints.md): - - *Device skeleton*: `inspect/nodeStatus` with the UI's projection and - `modules/"_noId"` — identity, descriptor, `meta`/coordinates, `status`, - `syncSeverity`, tags, `ptpDeviceStatus`; no modules/ports. - - *Edge skeleton*: `externalEdgesByDeviceKey` with the lean projection — - device pair, edge ids, endpoint port `context`/labels, status severities; - no `pathDescriptions`, no bandwidth values. - - The graph structure (all devices, all edges) is therefore complete from the - start; module/port detail and services are not. - -- **Per-entity hydration.** First access to an unloaded device property - (`ports`, port status, path drill-down, …) fetches that one device's - `nodeStatus` subtree using the UI's detail projection (`modules/*`) scoped - to the device. The parsed item replaces the skeleton record; port indexes - for that device are built incrementally. Hydration is idempotent and cached - — repeated access is local. - -- **Section-level lazy loads.** Services are cross-device, so `inspect/paths` - loads as a whole section (UI service-list projection) on first touch of - `get_services()` / `device.services`. `maintenanceBookings`, - `superProfiles`, and `tagInfo` load the same way if and when exposed. - -- **Snapshot construction.** The snapshot holds a reference to the inspect - API layer to perform hydration fetches. Fixture-built snapshots - ([ADR-0005](./0005-e2e-testing.md)) are constructed from a full collector - response with lazy loading inert. An eager mode - (e.g. `get_snapshot(load="full")` → `/**` fetch) is retained for small - environments and for callers needing point-in-time consistency. Scoped - queries are verified to work as REST GETs on 2025.4.9; only the full - UI-length projection hits HTTP 414 — use a trimmed skeleton projection - ([endpoints.md](../endpoints.md#collector-scoped-queries-captured-from-the-inspect-ui)). - -What ADR-0002 got right still stands: **no client-side cache across -snapshots**. All accreted state lives inside one snapshot's lifetime; fresh -data means building a new snapshot. - -## Consequences - -- **Hidden HTTP on property access.** Domain getters may perform one - hydration request and can therefore raise connector errors and add latency. - This is documented, deliberate behaviour (models.md). -- **No single point in time.** Skeleton and hydrated subtrees are fetched at - different moments. The snapshot records a fetch timestamp per entity/ - section; `refresh()` still means "build a new snapshot," never - re-fetch-and-diff. -- **N+1 returns for detail iteration.** Touching detail on every device in a - loop degenerates to one request per device. Mitigation: bulk preload - helpers (e.g. `snapshot.preload_devices([...])`, `get_devices(detail=True)`) - that batch or parallelize hydration - ([ADR-0004](./0004-async-strategy.md)); the skeleton alone answers most - bulk questions (inventory of nodes, connectivity, sync/status severities). -- **Verification status**: all core loading items are confirmed on 2025.4.9 — - GET equivalence, `"_noId"` semantics, single-device hydration forms, - populated `modules/*`, payload sizes (~92 KB skeleton vs ~19 MB full). The - only accepted limitation is HTTP 414 for the untrimmed UI projection. See - [endpoints.md](../endpoints.md#collector-scoped-queries-captured-from-the-inspect-ui) - and the checklist in - [concepts.md §5.1](../concepts.md#51-discovery-checklist-fill-in-as-confirmed). diff --git a/docs/architecture/inspect-app/decisions/0008-collector-only-endpoints.md b/docs/architecture/inspect-app/decisions/0008-collector-only-endpoints.md deleted file mode 100644 index b93348b..0000000 --- a/docs/architecture/inspect-app/decisions/0008-collector-only-endpoints.md +++ /dev/null @@ -1,100 +0,0 @@ -# ADR-0008: Collector-only endpoint policy (no legacy topology API) - -> Status: **Accepted** -> Date: 2026-07-08 · Deciders: Jonas Scholl - -## Context - -The Inspect package sits on top of two API generations that can both read and -write the same topology data: - -- **Legacy topology surface** (used by `app.topology` today): - `GET/PATCH /rest/v2/data/config/network/nGraphElements/**` — revisioned - (`_rev`, `mode: strict`) — plus older status views such as - `GET /rest/v2/data/status/network/edgesByDevice/**`. -- **Inspect surface**: collector data reads - (`GET /rest/v2/data/status/collector/…`, scoped or `/**`), collector actions - (`POST /rest/v2/actions/status/collector/*` — `updateTopology`, - `lookupInspectDevice`, `lookupSyncInfo`, …), and the network actions used by - Inspect workflows (`POST /rest/v2/actions/status/network/{addDevices,syncDevices}`). - -In the product, Inspect **replaces** the Topology app; `nGraphElements` remains -the underlying store (`updateTopology` writes land there, ADR-0006), but it is -an implementation detail behind the Inspect facade. Mixing the two surfaces in -one package layer would mean two write models (revisioned strict PATCH vs. -commit-time-validated bulk action), two read shapes for the same entities, and -a version-gating story spanning both. - -The Inspect UI's captured traffic (initial load) uses **only** the Inspect -surface, and the UI bundle (`/assets/index-*.js`, 2025.4.9) contains **zero -references to `nGraphElements`** — the edit and commit flows are built on the -collector lookups (`lookupInspectEdgesByIds`, `lookupInspectVertexByIds`, …) -and `updateTopology` -([endpoints.md](../endpoints.md#action-registration-discovery)). The vendor's -own client never touches the legacy surface. - -## Options - -1. **Collector-only (Inspect surface only).** The package never calls - `nGraphElements` or other legacy topology endpoints at runtime. - - Pros: one API generation; one write model; matches what the vendor UI - does; simple version gating; no accidental coupling to the app Inspect - replaces. - - Cons: gives up the only **server-enforced** optimistic locking - (`PATCH nGraphElements` strict mode) and the only revision-bearing read — - consistency must be solved client-side (ADR-0009). - -2. **Hybrid: Inspect surface + `nGraphElements` reads for `_rev`.** - - Pros: real revision tokens for conflict detection. - - Cons: reads and writes disagree (`_rev` from config plane is **ignored** - by `updateTopology` — verified last-writer-wins on 2025.4.9, so the token - buys detection only, not enforcement); couples the package to the legacy - surface anyway. - -3. **Hybrid: `updateTopology` for bulk, strict `PATCH nGraphElements` for - single-entity writes needing hard concurrency guarantees.** - - Pros: server-enforced locking where it matters. - - Cons: two write paths with different validation semantics (`updateTopology` - runs booking/service validation; a raw PATCH bypasses it) — dangerous, not - just inconsistent. - -## Decision - -**Option 1: the Inspect package uses only the Inspect surface.** Allowed at -runtime: - -| Kind | Endpoints | -| ---- | --------- | -| Data reads | `GET /rest/v2/data/status/collector/…` (scoped queries and `/**`) | -| Collector actions | `POST /rest/v2/actions/status/collector/*` (`updateTopology`, `lookupInspectDevice`, `lookupSyncInfo`, and further registered lookups) | -| Network actions | `POST /rest/v2/actions/status/network/addDevices`, `…/syncDevices` | -| System probes | `GET /rest/v2/data/status/system/about/…` (version gating) | - -Explicitly **not called** by the package: - -- `GET`/`PATCH /rest/v2/data/config/network/nGraphElements/**` -- `GET /rest/v2/data/status/network/edgesByDevice/**` -- RPC topology calls - -These stay documented in [endpoints.md](../endpoints.md) as **store -documentation and discovery/cross-check references** only. `app.topology` -remains the public escape hatch for raw, revisioned `nGraphElements` access — -unchanged and out of scope here. - -## Consequences - -- **No `_rev` is available to the Inspect package** (collector items and all - verified lookup responses carry none), and the write path enforces none - (last-writer-wins, verified 2025.4.9). Write consistency is therefore - solved client-side — [ADR-0009](./0009-write-consistency.md). -- The persisted form of an element (all config fields needed to build full - `replace*` payloads) comes from collector-namespace lookups — verified on - 2025.4.9: `lookupInspectDevice` (devices), `lookupInspectVertexById`/`…ByIds` - (vertices), `lookupInspectEdgesByIds` (edges, full persisted form, batched; - see [endpoints.md](../endpoints.md#post-restv2actionsstatuscollectorlookupinspectedgesbyids)) - — not from `nGraphElements` reads. (`lookupGraphElement` does not exist.) -- The connector URL allow-list for Inspect gains only Inspect-surface prefixes; - `config/network/nGraphElements` is not added for the Inspect app. -- If a future server version changes the Inspect surface (e.g. registers - `validateTopology`, adds rev enforcement to `updateTopology`), the policy is - re-evaluated per version — never by silently reaching for the legacy API. diff --git a/docs/architecture/inspect-app/decisions/0009-write-consistency.md b/docs/architecture/inspect-app/decisions/0009-write-consistency.md deleted file mode 100644 index 9c05fb2..0000000 --- a/docs/architecture/inspect-app/decisions/0009-write-consistency.md +++ /dev/null @@ -1,139 +0,0 @@ -# ADR-0009: Write consistency — client-side compare-and-commit - -> Status: **Accepted** — complements [ADR-0006](./0006-commit-write-model.md) -> (commit model) under the [ADR-0008](./0008-collector-only-endpoints.md) -> endpoint policy -> Date: 2026-07-08 · Deciders: Jonas Scholl - -## Context - -Requirement: a write must not silently overwrite changes somebody else made -between our read and our commit (lost update). What the server gives us -(verified 2025.4.9, see -[endpoints.md — `updateTopology`](../endpoints.md#post-restv2actionsstatuscollectorupdatetopology)): - -- **`updateTopology` is last-writer-wins.** A stale `_rev` in `replace*` - payloads is **ignored** — there is no server-enforced optimistic locking on - the Inspect write path. Commit-time validation protects *service integrity* - (booking-blocked deletes, dangling edge refs), **not** concurrent edits. -- **Collector reads carry no `_rev`.** The only revisioned surface is - `nGraphElements`, which the package does not call - ([ADR-0008](./0008-collector-only-endpoints.md)). -- **`replace*` entries are full-object upserts** (ADR-0006): committing an edge - weight change means sending the *complete* edge. A payload built from stale - state clobbers every other field — so a write is exposed to lost updates even - when the caller only intends to touch one field. -- **No server-side staging**: `validateTopology`/`discardTopology` are - unregistered stubs; the change set exists only client-side until the one - `updateTopology` POST (atomic, reject-before-apply). - -## Options - -1. **Accept last-writer-wins.** Document the risk, do nothing. - - Pros: zero cost. - - Cons: violates the requirement; full-object upserts make silent clobbering - likely, not just possible. - -2. **Client-side compare-and-commit.** Record a baseline of every touched - entity at staging time; immediately before the commit POST, re-read the - entities and compare against the baseline; abort on any drift. - - Pros: detects concurrent modification with Inspect-surface endpoints only; - the stage-time read is needed anyway to build full `replace*` payloads; - conflicts surface as one typed error before anything is written. - - Cons: a race window remains between the pre-commit re-read and the POST - (TOCTOU) — detection, not enforcement; costs one extra scoped read per - touched entity at commit time. - -3. **Rev tokens from `nGraphElements`.** - - Cons: rejected by ADR-0008; and pointless as enforcement — `updateTopology` - ignores `_rev`, so the token would still only enable client-side detection - (same guarantee as option 2, plus a legacy-surface dependency). - -4. **Strict `PATCH nGraphElements` for writes.** - - Cons: rejected by ADR-0008; bypasses `updateTopology`'s booking/service - validation — trades one integrity mechanism for another. - -## Decision - -**Option 2: compare-and-commit, built into the change-set lifecycle -(ADR-0006's transaction / direct-write paths both get it).** - -1. **Baseline at staging time.** When an entity is first staged (replace or - remove), the change set fetches and stores its current form via - Inspect-surface lookups (all verified 2025.4.9, payloads in - [endpoints.md](../endpoints.md#post-restv2actionsstatuscollectorlookupinspectedgesbyids); - none of them exposes a `_rev`): - - edges: `lookupInspectEdgesByIds` — the **full persisted edge form** - (every `replaceEdges` field), batched; the UI's own edit flow calls it - with both directions of a connection. - - vertices: `lookupInspectVertexByIds` (batched) — editable vertex form - (`fields` incl. label, tags, `typeFields` capability flags, - `useAsEndpoint`). Vertex tag bindings come from this lookup (and hydrated - port `tagsInfo`), **not** from `nGraphElements` — they are stored in - `videoipath_docs.device_tags` server-side - ([concepts.md §3.4](../concepts.md#34-tagging--device-vs-vertex-inspect-vs-topology)). - - devices: `lookupInspectDevice` — editable device form (coordinates, - descriptor, iconType, sdpStrategy, tags). - - The lookup forms **are** the write shapes — no client-side mapping - (all three verified 2025.4.9 by live commit + byte-identical revert): - `replaceEdges` takes the persisted edge form exactly as - `lookupInspectEdgesByIds` returns it; `replaceDevices` takes exactly - `lookupInspectDevice`'s `fields` object (`coordinates`/`localAssignedTags` - mandatory; the raw persisted `baseDevice` element is rejected with HTTP - 400 — the server maps `coordinates` → `maps[]` itself); `replaceVertices` - takes exactly `lookupInspectVertexById`'s `fields` object. Note - `replaceVertices` is **update-only**: an unknown vertex id fails - validation (*"Vertex … was not found in graph"*) — vertices originate from - device sync, not from commits. The baseline serves double duty: it is the - basis for building the full `replace*` payload (caller mutations applied - on top) *and* the reference for conflict detection, compared on the - editable fields. For `remove` entries the baseline is the element's - existence + form. - -2. **Pre-commit conflict check.** `commit()` re-fetches the same entities and - deep-compares against the baselines over the fields the package models - (volatile status fields excluded). Any mismatch aborts the whole commit — - consistent with the server's all-or-nothing apply — and raises a typed - conflict error carrying the entity ids and per-field diffs. The caller - decides: re-stage on top of fresh state, or override. - -3. **Override is explicit.** A caller can skip the check - (`commit(check_conflicts=False)` or equivalent) — that is deliberate - last-writer-wins, stated in the call. The server's `force` flag is - unrelated (it does not bypass apply-gate errors; verified) and stays an - independent, documented option. - -4. **Commit result still rules.** Compare-and-commit runs *before* the POST; - `data.res.ok` / `data.validation.result.ok` evaluation after the POST is - unchanged (ADR-0006). - -## Consequences - -- **Honest guarantee: detection, not enforcement.** The re-read→POST window - cannot be closed with the current server. This is the strongest guarantee - available on the Inspect surface; state it plainly in user docs. Re-check - per server version whether `updateTopology` gains rev enforcement - (**[VERIFY]** on upgrades; concepts.md §5.1). -- One extra lookup round per stage and per commit — bounded by change-set - size, not network size, and batchable: `lookupInspectEdgesByIds` and - `lookupInspectVertexByIds` take id lists natively (verified). -- The conflict check needs a stable field-level equality over the persisted - form — DTO comparison must exclude server-managed/volatile fields; document - the excluded set alongside the DTOs. -- Snapshot data (rev-less, possibly stale) is explicitly **not** used as the - baseline; baselines always come from fresh lookups at stage time. The - snapshot is a read surface, not a write token - ([ADR-0007](./0007-lazy-snapshot-loading.md), - ADR-0010 for post-commit refresh). -- The lookup→write round-trip is exact for devices **and vertices** (both - verified: commit + revert left the persisted element byte-identical except - `_rev`) and trivial for edges. One caveat, applying to devices and vertices - alike: the lookups (and the collector) return the **effective** label — the - persisted `descriptor` merged with the `fDescriptor` fallback. - `descriptor` is stored verbatim on commit, so a client that round-trips the - lookup unchanged pins the fallback label into `descriptor` (the label stops - tracking device-reported names). The persisted-vs-fallback distinction is - not observable on the Inspect surface; the UI has the same property. - Document it; don't touch `descriptor`/label fields unless the caller set - them. diff --git a/docs/architecture/inspect-app/decisions/001-api-paradigm.md b/docs/architecture/inspect-app/decisions/001-api-paradigm.md new file mode 100644 index 0000000..f375be9 --- /dev/null +++ b/docs/architecture/inspect-app/decisions/001-api-paradigm.md @@ -0,0 +1,23 @@ +# ADR-001: API paradigm — data-driven, request/response + +> Status: **Accepted** + +## Decision + +The package stays fully **data-driven and request/response**. Reads fetch +aggregates from the server; writes apply changes via explicit API calls. No +WebSocket subscriptions, no live update layer, no event-driven observation API. + +Status reads use the same request/response model as configuration CRUD. If +freshness is needed, the caller re-fetches explicitly. + +Primary consumers are deterministic pipeline automations — short-lived, scripted +runs that load state, apply changes, and exit. + +## Consequences + +- Consistent with existing apps and the automation/pipeline usage model. +- No WebSocket client, subscription machinery, or dual interaction styles. +- Live monitoring UX (as in the Inspect UI) is out of scope; automations get + predictable, reproducible runs instead. +- See [ADR-002](./002-async-strategy.md) for the related async decision. diff --git a/docs/architecture/inspect-app/decisions/0010-post-commit-snapshot-refresh.md b/docs/architecture/inspect-app/decisions/0010-post-commit-snapshot-refresh.md deleted file mode 100644 index 90d65b3..0000000 --- a/docs/architecture/inspect-app/decisions/0010-post-commit-snapshot-refresh.md +++ /dev/null @@ -1,119 +0,0 @@ -# ADR-0010: Post-commit snapshot maintenance — targeted invalidation + scoped re-fetch - -> Status: **Accepted** — extends [ADR-0007](./0007-lazy-snapshot-loading.md) -> to the write path ([ADR-0006](./0006-commit-write-model.md) commit model) -> Date: 2026-07-08 · Deciders: Jonas Scholl - -## Context - -After a successful `updateTopology` commit, the caller's `InspectSnapshot` is -stale exactly where the commit touched it. Automation flows typically continue -working with the snapshot right after a write ("connect, then assert the edge -exists"), so the state must catch up — efficiently: - -- A full re-snapshot costs ~19 MB (`/**`) on the 30-device test instance - (measured sizes in - [endpoints.md](../endpoints.md#observed-subscription-transport-reference-only--out-of-package-scope)) - and scales with network size — unacceptable per write. -- Scoped reads are cheap: one device subtree ≈ 21 KB, the complete edge - skeleton ≈ 84 KB. -- The change set knows precisely which entities it touched, and the commit - response confirms them (`data.items[]` with per-item `res.ok`). -- The collector is a **status-plane projection** of the config store the - commit writes to; measured on 2025.4.9, the projection updates effectively - synchronously with the commit (~25 ms to first-poll visibility — see - Decision, point 5). - -## Options - -1. **Full re-snapshot after every commit.** Correct but O(network) per write — - defeats ADR-0007. - -2. **Optimistic local apply.** Translate the committed write DTOs into the - snapshot's read shapes and patch the indexes locally, no re-read. - - Pros: zero extra requests. - - Cons: write shapes (`nGraphElements` element form) ≠ collector read shapes - (status projection with `vertexInfo`, live status, `pathDescriptions`); - the translation re-implements server logic and cannot produce the - server-derived fields — the snapshot would hold fabricated entries. - -3. **Targeted invalidation + scoped re-fetch of affected entities.** Reuse the - ADR-0007 hydration machinery: mark what the commit touched as stale, re-read - only that. - -4. **Invalidate-only (fully lazy).** Like 3, but never re-fetch eagerly; next - property access re-hydrates. - - Pros: cheapest when the caller never looks again. - - Cons: "commit, then assert" — the common automation pattern — hits the - propagation delay unmanaged, on an access that looks local. - -## Decision - -**Option 3, with lazy fallback (option 4) for sections.** After a commit whose -result is success (`res.ok` and `validation.result.ok`): - -1. **Compute the affected set** from the change set and the commit response - `items[]`: - - *devices*: keys of `replaceDevices`, removed device ids, plus the owning - device of every replaced/removed vertex and edge endpoint (derivable from - the id conventions — vertex `device-a.module-1.port-out-1.out` → device - `device-a`). - - *edge pairs*: `replaceEdges` / `addExternalEdges` keys and removed edge - ids, mapped to their `deviceA::deviceB` pair keys (both directions belong - to one pair item). -2. **Apply removes locally**: deleted entities leave the indexes and caches - immediately (no read needed to know they are gone). -3. **Invalidate and eagerly re-fetch** the remaining affected entities with the - existing scoped queries: `nodeStatus//…` per affected device and - the edge-pair item per affected pair — direct pair addressing - `externalEdgesByDeviceKey//` is **verified** - on 2025.4.9 (returns the single pair item). Cached domain objects for these - ids are dropped or updated so subsequent property access sees the new - state; per-entity fetch timestamps are updated (ADR-0007). -4. **Sections go stale, not eager**: if the services section (`inspect/paths`) - or other section-level data was loaded, mark it stale and let the next - access re-load it (ADR-0007 lazy path). Commits change services only - indirectly; eager re-reads here are usually wasted. -5. **Propagation handling**: measured on 2025.4.9 (device coordinate commit, - three samples): the change was visible in collector reads on the **first - poll ~25 ms after the POST returned** — the projection updates effectively - synchronously with the commit. No retry window is needed; the targeted - re-fetch doubles as the verification read (log if the committed value is - unexpectedly absent, don't loop). - -A failed commit changes nothing server-side (reject-before-apply, verified) — -the snapshot is left untouched. - -### Extensions - -- **Network actions** (`addDevices` / `syncDevices`) get the same automatic - update: on success they call `apply_network_refresh(device_ids)`, which - upserts the named devices (new **or** restructured) and reconciles the edge - pairs touching them. Unlike a commit, these actions do not report the exact - touched entities and can create pairs to previously-unconnected devices, so - edges are reconciled from **one** cheap edge-skeleton read scoped to pairs - touching an affected device (per-device detail stays targeted). As on the - write path, this only runs when a snapshot is already loaded. -- **Refresh is resilient, never all-or-nothing.** A scoped re-fetch that fails - (network blip) marks just that entity stale (`_stale_devices` / - `_stale_pairs`) and logs; it never propagates, so a post-write hook cannot - lose the caller's already-successful result. Stale entities are re-fetched - lazily on the next access that touches them (the ADR-0007 hydration path), - making the snapshot self-healing without a full reload. - -## Consequences - -- Post-commit cost is proportional to the **change set size**, not the network: - N affected devices ⇒ ~N × 21 KB + one edge query. -- The snapshot stays a pure read model built from collector responses — no - fabricated entries (option 2 rejected), no divergence between "what we wrote" - and "what the server derived". -- The refresh step needs the affected-set derivation to be exact; the id - conventions it relies on are already documented (concepts.md §3.1) and - fixture-tested. -- All refresh mechanics are verified on 2025.4.9: direct pair addressing, - scoped single-device reads, and ~synchronous collector propagation - (~25 ms to first-poll visibility). Re-measure propagation if a future - server version decouples the collector projection from the commit path. -- Together with ADR-0009: `commit()` = pre-commit conflict check → POST → - result evaluation → targeted refresh. One documented lifecycle. diff --git a/docs/architecture/inspect-app/decisions/002-async-strategy.md b/docs/architecture/inspect-app/decisions/002-async-strategy.md new file mode 100644 index 0000000..3a616d8 --- /dev/null +++ b/docs/architecture/inspect-app/decisions/002-async-strategy.md @@ -0,0 +1,23 @@ +# ADR-002: Async readiness & migration + +> Status: **Accepted** + +## Decision + +**Stay sync at the package boundary; use internal parallelism only where a +single action needs multiple API requests.** + +The public API remains synchronous — no `async`/`await` surface, no dual-stack +codegen, no migration to `httpx` async clients. When one high-level operation +(e.g. skeleton load of devices + edges, or bulk device preload) requires several +independent `GET`s, those requests may be issued **in parallel internally** +(e.g. thread pool). This is an implementation detail, not a new interaction +model for callers. + +## Consequences + +- Zero breaking change for existing sync users and scripts. +- No async test matrix, no `unasync` tooling, no sync-over-async footguns. +- Performance gains are limited to multi-request reads/writes inside the + library; callers do not manage concurrency themselves. +- A future async public API would require a new ADR; it is not planned now. diff --git a/docs/architecture/inspect-app/decisions/003-e2e-testing.md b/docs/architecture/inspect-app/decisions/003-e2e-testing.md new file mode 100644 index 0000000..967d381 --- /dev/null +++ b/docs/architecture/inspect-app/decisions/003-e2e-testing.md @@ -0,0 +1,25 @@ +# ADR-003: E2E testing strategy + +> Status: **Accepted** + +## Decision + +**Live server E2E only — developer-run, locally, against a real VideoIPath +instance.** Offline unit tests use anonymized fixtures under +`tests/inspect/fixtures/`. + +E2E tests use the Python package against a live server. Credentials and +connection details come from `.env` (see `.env.template`). No recorded HTTP +cassettes, no fake VideoIPath server. + +These tests are **not** required on every CI push; they are run locally when a +developer has an instance available (`poetry run test-e2e`). + +## Consequences + +- Highest confidence: tests exercise the real API, auth, and payload shapes. +- Simple setup: one E2E style, one configuration path, no cassette maintenance. +- Tests are stateful, environment-dependent, and slower — acceptable for the + current team size and Inspect scope. +- Mock/cassette/fake-server layers can be revisited via a new ADR if CI + automation or faster feedback loops become a priority. diff --git a/docs/architecture/inspect-app/decisions/004-commit-write-model.md b/docs/architecture/inspect-app/decisions/004-commit-write-model.md new file mode 100644 index 0000000..8a98b2c --- /dev/null +++ b/docs/architecture/inspect-app/decisions/004-commit-write-model.md @@ -0,0 +1,43 @@ +# ADR-004: Commit-style write model (change sets) + +> Status: **Accepted** +> +> Concurrency: [ADR-007](./007-write-consistency.md). Post-commit refresh: +> [ADR-008](./008-post-commit-snapshot-refresh.md). + +## Decision + +**Hybrid: explicit transaction via context manager, plus direct write +operations on the app.** + +- **Data-only DTOs** — models represent server payloads; no behaviour methods. +- **App-level direct writes** — `place_device` / `update_*` / `connect` / + `disconnect` / `remove_*` on the inspect app each open a single-change + transaction and commit immediately. +- **Optional transaction** — `with app.inspect.transaction() as tx:` stages + multiple actions; call `tx.commit()` explicitly. Exit without commit + discards. + +Staging is **client-side** until `POST …/updateTopology`. There is no separate +server-side change-set id. + +Wire facts (verified 2025.4.9): + +| Field | Shape | +| ----- | ----- | +| `replaceDevices` | `lookupInspectDevice.fields` (`coordinates`, `localAssignedTags` mandatory) | +| `replaceVertices` | `lookupInspectVertexById.fields` — **update-only** | +| `replaceEdges` | Raw persisted edge form, keyed `"fromId::toId"` | +| `remove` / `addExternalEdges` / `force` | id list / edge list / boolean | + +Commit success requires `header.ok and data.res.ok and data.validation.result.ok`. +Apply is reject-before-apply (all-or-nothing). `updateTopology` is +last-writer-wins (ignores `_rev`). + +## Consequences + +- Two usage styles, both mapping to the same `updateTopology` payload. +- Context manager: commit explicitly; exit without commit discards. +- DTOs stay portable; business logic lives in the app/transaction layer. +- Single-change scripts stay ergonomic; multi-element pipeline edits use the + transaction path for atomicity. diff --git a/docs/architecture/inspect-app/decisions/005-lazy-snapshot-loading.md b/docs/architecture/inspect-app/decisions/005-lazy-snapshot-loading.md new file mode 100644 index 0000000..270215d --- /dev/null +++ b/docs/architecture/inspect-app/decisions/005-lazy-snapshot-loading.md @@ -0,0 +1,39 @@ +# ADR-005: Skeleton-first snapshot loading with lazy hydration + +> Status: **Accepted** + +## Decision + +**Skeleton-first snapshot with transparent per-entity lazy hydration.** The +`InspectSnapshot` state is allowed to be partially populated and accretes as +details are fetched. There is **no client-side cache across snapshots** — +fresh data means building a new snapshot. + +- **Skeleton load.** Two parallel scoped GETs (queries in + [endpoints.md](../endpoints.md#collector-scoped-queries-captured-from-the-inspect-ui)): + - *Device skeleton*: `inspect/nodeStatus` with `modules/"_noId"` — identity, + descriptor, coordinates, status, syncSeverity, tags; no modules/ports. + - *Edge skeleton*: `externalEdgesByDeviceKey` lean projection — device pair, + edge ids, endpoint labels/context, status severities. +- **Per-entity hydration.** First access to an unloaded device property + (`ports`, …) fetches that device's `nodeStatus` subtree (`modules/*`). + Hydration is idempotent and cached. +- **Section-level lazy loads.** Services (`inspect/paths`) and alarms + (`status/alarms/current`) load as whole sections on first touch. +- **Eager mode.** `load="full"` → one `GET …/collector/**` for small + environments or point-in-time consistency. Fixture-built snapshots are fully + hydrated with lazy loading inert. + +## Consequences + +- **Hidden HTTP on property access.** Domain getters may perform one hydration + request and can therefore raise connector errors and add latency. Documented, + deliberate behaviour ([models.md](../models.md)). +- **No single point in time.** Skeleton and hydrated subtrees are fetched at + different moments; the snapshot records a fetch timestamp per entity/section. +- **N+1 for detail iteration.** Mitigation: bulk preload helpers + (`app.inspect.preload([...])`, `get_devices(detail=True)`) that parallelize + hydration ([ADR-002](./002-async-strategy.md)); the skeleton alone answers + most bulk questions. +- Scoped queries verified on 2025.4.9; the untrimmed UI projection hits + HTTP 414 — use a trimmed skeleton projection. diff --git a/docs/architecture/inspect-app/decisions/006-collector-only-endpoints.md b/docs/architecture/inspect-app/decisions/006-collector-only-endpoints.md new file mode 100644 index 0000000..1068016 --- /dev/null +++ b/docs/architecture/inspect-app/decisions/006-collector-only-endpoints.md @@ -0,0 +1,37 @@ +# ADR-006: Collector-only endpoint policy (no legacy topology API) + +> Status: **Accepted** + +## Decision + +**The Inspect package uses only the Inspect surface.** Allowed at runtime: + +| Kind | Endpoints | +| ---- | --------- | +| Data reads | `GET /rest/v2/data/status/collector/…` (scoped queries and `/**`) | +| Collector actions | `POST /rest/v2/actions/status/collector/*` (`updateTopology`, lookups, …) | +| Network actions | `POST /rest/v2/actions/status/network/{addDevices,syncDevices,updateVirtualInstances,updateVirtualTemplates,addVirtualTopology}` | +| Tag actions | `POST /rest/v2/actions/status/tags/{assignTag,unassignTag}` | +| Alarm reads | `GET /rest/v2/data/status/alarms/current/…` | +| Virtual reads | `GET /rest/v2/data/status/network/{virtualDevices,virtualTemplates}/**` | +| System probes | `GET /rest/v2/data/status/system/about/…` (version gating) | + +Explicitly **not called** by the package: + +- `GET`/`PATCH /rest/v2/data/config/network/nGraphElements/**` +- `GET /rest/v2/data/status/network/edgesByDevice/**` +- RPC topology calls + +These stay documented in [endpoints.md](../endpoints.md) as store documentation +only. `app.topology` remains the escape hatch for raw, revisioned +`nGraphElements` access. + +## Consequences + +- **No `_rev` is available** to the Inspect package, and the write path + enforces none (last-writer-wins). Write consistency is solved client-side — + [ADR-007](./007-write-consistency.md). +- Persisted forms for `replace*` payloads come from collector-namespace lookups + (`lookupInspectDevice`, `lookupInspectVertexByIds`, + `lookupInspectEdgesByIds`), not from `nGraphElements` reads. +- The connector URL allow-list for Inspect gains only Inspect-surface prefixes. diff --git a/docs/architecture/inspect-app/decisions/007-write-consistency.md b/docs/architecture/inspect-app/decisions/007-write-consistency.md new file mode 100644 index 0000000..992d765 --- /dev/null +++ b/docs/architecture/inspect-app/decisions/007-write-consistency.md @@ -0,0 +1,45 @@ +# ADR-007: Write consistency — client-side compare-and-commit + +> Status: **Accepted** — complements [ADR-004](./004-commit-write-model.md) +> under the [ADR-006](./006-collector-only-endpoints.md) endpoint policy + +## Decision + +**Compare-and-commit, built into the change-set lifecycle** (transaction and +direct-write paths both get it). + +1. **Baseline at staging time.** When an entity is first staged, the change set + fetches and stores its current form via Inspect-surface lookups (verified + 2025.4.9; none exposes a `_rev`): + - edges: `lookupInspectEdgesByIds` — full persisted edge form, batched + - vertices: `lookupInspectVertexByIds` — editable form (incl. tag bindings; + see [concepts.md §3.4](../concepts.md#34-tagging--device-vs-vertex-vs-module-inspect-vs-topology)) + - devices: `lookupInspectDevice` — editable form + + The lookup forms **are** the write shapes — no client-side mapping. + Caller mutations are applied on top of the baseline. + +2. **Pre-commit conflict check.** `commit()` re-fetches the same entities and + deep-compares against the baselines. Any mismatch aborts the whole commit + and raises `InspectCommitConflictError` (entity ids + field diffs). + +3. **Override is explicit.** `commit(check_conflicts=False)` skips the check — + deliberate last-writer-wins. The server's `force` flag is unrelated. + +4. **Commit result still rules.** Compare-and-commit runs *before* the POST; + `data.res.ok` / `data.validation.result.ok` evaluation after the POST is + unchanged (ADR-004). + +## Consequences + +- **Honest guarantee: detection, not enforcement.** The re-read→POST window + (TOCTOU) cannot be closed with the current server. Re-check per server + version whether `updateTopology` gains rev enforcement. +- One extra lookup round per stage and per commit — bounded by change-set size, + batchable via the `…ByIds` actions. +- Snapshot data is **not** used as the baseline; baselines always come from + fresh lookups at stage time. +- Lookups return the **effective** label (persisted `descriptor` merged with + `fDescriptor` fallback). Round-tripping without an explicit label change pins + the fallback into `descriptor` — don't touch label fields unless the caller + set them. diff --git a/docs/architecture/inspect-app/decisions/008-post-commit-snapshot-refresh.md b/docs/architecture/inspect-app/decisions/008-post-commit-snapshot-refresh.md new file mode 100644 index 0000000..88faa02 --- /dev/null +++ b/docs/architecture/inspect-app/decisions/008-post-commit-snapshot-refresh.md @@ -0,0 +1,42 @@ +# ADR-008: Post-commit snapshot maintenance — targeted invalidation + scoped re-fetch + +> Status: **Accepted** — extends [ADR-005](./005-lazy-snapshot-loading.md) +> to the write path ([ADR-004](./004-commit-write-model.md)) + +## Decision + +**Targeted invalidation + scoped re-fetch of affected entities**, with lazy +fallback for sections. After a successful commit: + +1. **Compute the affected set** from the change set and the commit response + `items[]`: devices (replaced/removed + owners of touched vertices/edges) and + edge pairs (`deviceA::deviceB`). +2. **Apply removes locally** — deleted entities leave the indexes immediately. +3. **Invalidate and eagerly re-fetch** remaining affected entities with the + existing scoped queries (`nodeStatus//…`, + `externalEdgesByDeviceKey//…`). Drop cached domain objects; + update per-entity fetch timestamps. +4. **Sections go stale, not eager** — services and alarms re-load lazily on next + access. +5. **No retry window** — on 2025.4.9 the collector projection updates + effectively synchronously with the commit (~25 ms to first-poll visibility). + The targeted re-fetch doubles as the verification read. + +A failed commit changes nothing server-side — the snapshot is left untouched. + +**Extensions:** + +- **Network actions** (`addDevices` / `syncDevices`) call + `apply_network_refresh(device_ids)`: upsert named devices and reconcile + edge pairs from one edge-skeleton read scoped to pairs touching an affected + device. +- **Refresh is resilient.** A failed scoped re-fetch marks just that entity + stale and logs; never propagates. Stale entities self-heal on next access. + +## Consequences + +- Post-commit cost is proportional to **change-set size**, not network size. +- The snapshot stays a pure read model built from collector responses — no + fabricated entries. +- Together with ADR-007: `commit()` = pre-commit conflict check → POST → + result evaluation → targeted refresh. diff --git a/docs/architecture/inspect-app/decisions/README.md b/docs/architecture/inspect-app/decisions/README.md index cd8e8f2..23b5693 100644 --- a/docs/architecture/inspect-app/decisions/README.md +++ b/docs/architecture/inspect-app/decisions/README.md @@ -1,53 +1,16 @@ # Architecture Decision Records — Inspect App -Each ADR captures the **context, research, and options** for one architectural -question. Once a choice is made, the **Decision** and **Consequences** sections -are filled in and the status moves to **Accepted**. ADRs are immutable once -accepted — to change a decision, add a new ADR that supersedes the old one -(update the `Status` line of both). - -## Status legend - -- **Proposed** — context and options documented; decision not yet made. -- **Accepted** — agreed; implement accordingly. -- **Superseded by ADR-XXXX** — replaced; kept for history. -- **Deprecated** — no longer relevant. +Each ADR captures one architectural decision and its consequences. ## Index -| ADR | Title | Status | -| ----------------------------------------------------- | ------------------------------------ | ---------- | -| [0001](./0001-api-paradigm.md) | API paradigm: data-driven | Accepted | -| [0002](./0002-loading-and-state.md) | Loading & state model | Superseded by ADR-0007 | -| [0003](./0003-websocket-subscriptions.md) | WebSocket event subscriptions | Deprecated | -| [0004](./0004-async-strategy.md) | Async readiness & migration | Accepted | -| [0005](./0005-e2e-testing.md) | E2E testing strategy | Accepted | -| [0006](./0006-commit-write-model.md) | Commit-style write model (change sets) | Accepted | -| [0007](./0007-lazy-snapshot-loading.md) | Skeleton-first loading & lazy hydration | Accepted | -| [0008](./0008-collector-only-endpoints.md) | Collector-only endpoint policy | Accepted | -| [0009](./0009-write-consistency.md) | Write consistency (compare-and-commit) | Accepted | -| [0010](./0010-post-commit-snapshot-refresh.md) | Post-commit snapshot refresh | Accepted | - -## Template - -```markdown -# ADR-XXXX: - -> Status: Proposed | Accepted | Superseded by ADR-YYYY -> Date: YYYY-MM-DD · Deciders: - -## Context -What problem are we solving? What constraints apply (existing code, users, -versions)? - -## Options -1. Option A — pros / cons -2. Option B — pros / cons -3. ... - -## Decision -_To be decided._ (fill in once a choice is made) - -## Consequences -_Add once a decision is made._ -``` +| ADR | Title | Status | +| --- | ----- | ------ | +| [001](./001-api-paradigm.md) | API paradigm: data-driven | Accepted | +| [002](./002-async-strategy.md) | Async readiness & migration | Accepted | +| [003](./003-e2e-testing.md) | E2E testing strategy | Accepted | +| [004](./004-commit-write-model.md) | Commit-style write model (change sets) | Accepted | +| [005](./005-lazy-snapshot-loading.md) | Skeleton-first loading & lazy hydration | Accepted | +| [006](./006-collector-only-endpoints.md) | Collector-only endpoint policy | Accepted | +| [007](./007-write-consistency.md) | Write consistency (compare-and-commit) | Accepted | +| [008](./008-post-commit-snapshot-refresh.md) | Post-commit snapshot refresh | Accepted | diff --git a/docs/architecture/inspect-app/endpoints.md b/docs/architecture/inspect-app/endpoints.md index 977825f..0cd939f 100644 --- a/docs/architecture/inspect-app/endpoints.md +++ b/docs/architecture/inspect-app/endpoints.md @@ -7,14 +7,15 @@ read-only requests and one empty no-op `updateTopology` POST were executed. The Inspect package scope follows the accepted ADRs: -- Request/response only; no WebSocket subscription API. Subscription *captures* - are still used as an endpoint-discovery source — see +- Request/response only; no WebSocket subscription API + ([ADR-001](./decisions/001-api-paradigm.md)). Subscription *captures* are + still used as an endpoint-discovery source — see [Collector Scoped Queries](#collector-scoped-queries-captured-from-the-inspect-ui). - Snapshot-scoped reads: a snapshot loads a skeleton first and lazily hydrates detail; fresh status means building a new snapshot - ([ADR-0007](./decisions/0007-lazy-snapshot-loading.md)). -- Data-only DTOs; write/commit behaviour belongs in the future app/transaction - layer, not in the model classes. + ([ADR-005](./decisions/005-lazy-snapshot-loading.md)). +- Data-only DTOs; write/commit behaviour lives in the app/transaction layer + (`transaction.py`), not in the model classes. ## Common Envelope @@ -85,7 +86,7 @@ Response example: Purpose: full Inspect read aggregate — services, path drill-down, external edge status, and topology node status in one response. -> Since [ADR-0007](./decisions/0007-lazy-snapshot-loading.md) this full fetch is +> Since [ADR-005](./decisions/005-lazy-snapshot-loading.md) this full fetch is > the **eager/fallback mode** only. The default read path uses the scoped > queries documented in > [Collector Scoped Queries](#collector-scoped-queries-captured-from-the-inspect-ui), @@ -149,7 +150,7 @@ Source: a browser WebSocket capture of the Inspect app's **initial load** The UI never fetches `/status/collector/**`. It opens ~8 **parallel scoped subscriptions**, each addressing a collector sub-path with a filter and a deep field projection. These queries are the concrete basis for the skeleton + -lazy-hydration loading model ([ADR-0007](./decisions/0007-lazy-snapshot-loading.md)). +lazy-hydration loading model ([ADR-005](./decisions/005-lazy-snapshot-loading.md)). All ids, labels, and addresses below are anonymized. Decoded paths are shown with URL encoding removed (`%20` → space, `%22` → `"`, `%2C` → `,`, @@ -183,13 +184,12 @@ Protocol details (verified by live connection and the UI bundle on 2025.4.9): | Delta frames | `payload.data._e` (observed in the UI decoder) | | Reconnect | UI re-opens when the socket is gone and the tab is visible; no dedicated heartbeat channel | -The package stays request/response ([ADR-0001](./decisions/0001-api-paradigm.md), -[ADR-0003](./decisions/0003-websocket-subscriptions.md)): the same query paths -work as `GET /rest/v2/data`. **Confirmed** on VideoIPath 2025.4.9 -for practical query lengths; URL encoding of spaces (`%20`), quotes (`%22`), -parentheses (`%28`/`%29`), and `=` (`%3D`) works as captured. The **full** UI -projection below returns **HTTP 414** (URI Too Long) as a REST GET — a -proxy/URL-length constraint, not a server bug — so the package uses a trimmed +The package stays request/response ([ADR-001](./decisions/001-api-paradigm.md)): +the same query paths work as `GET /rest/v2/data`. **Confirmed** on +VideoIPath 2025.4.9 for practical query lengths; URL encoding of spaces (`%20`), +quotes (`%22`), parentheses (`%28`/`%29`), and `=` (`%3D`) works as captured. The +**full** UI projection below returns **HTTP 414** (URI Too Long) as a REST GET — +a proxy/URL-length constraint, not a server bug — so the package uses a trimmed skeleton projection or the `/**` fallback. Measured payload sizes (2025.4.9 instance, 30 devices / 40 edge pairs): @@ -302,7 +302,7 @@ With `modules/*` the projection expands modules → ports → `vertexInfo`, (`deviceLevel` + `serviceLevel`) — the full drill-down detail. This is the template for **per-device lazy hydration** -([ADR-0007](./decisions/0007-lazy-snapshot-loading.md)): reuse the detail +([ADR-005](./decisions/005-lazy-snapshot-loading.md)): reuse the detail projection but scope it to one device. **Confirmed** on 2025.4.9 — both work; prefer the shorter direct-id form: @@ -668,7 +668,7 @@ Observed response on this instance: ## `GET /rest/v2/data/status/network/edgesByDevice/**` > **Reference only — not called by the package** -> ([ADR-0008](./decisions/0008-collector-only-endpoints.md)). +> ([ADR-006](./decisions/006-collector-only-endpoints.md)). Purpose: existing status-plane edge view. This is not the collector facade, but it is useful for cross-checking edge payload fields during discovery when @@ -725,12 +725,12 @@ Response fragment: ## `GET /rest/v2/data/config/network/nGraphElements/**` > **Reference only — not called by the package** -> ([ADR-0008](./decisions/0008-collector-only-endpoints.md)). This is +> ([ADR-006](./decisions/006-collector-only-endpoints.md)). This is > `app.topology`'s surface; it is documented here because `updateTopology` > persists into it and the `replace*` payloads carry the persisted element > shape. Its `_rev` is irrelevant to Inspect writes — `updateTopology` ignores > revisions (last-writer-wins; see -> [ADR-0009](./decisions/0009-write-consistency.md)). +> [ADR-007](./decisions/007-write-consistency.md)). Purpose: revisioned config store that `updateTopology` persists into. Inspect models include independent `InspectApi*` nGraph DTOs for this persisted shape, but @@ -741,7 +741,7 @@ they do not import or subclass topology app models. > `videoipath_docs.device_tags` (separate from the `ngraph` table). Inspect > surfaces vertex tags via `lookupInspectVertexByIds` and hydrated port > `tagsInfo` in `nodeStatus` — see -> [concepts.md §3.4](./concepts.md#34-tagging--device-vs-vertex-inspect-vs-topology). +> [concepts.md §3.4](./concepts.md#34-tagging--device-vs-vertex-vs-module-inspect-vs-topology). > `app.topology` only knows the `nGraphElements` shape and therefore has no > vertex-tag API. @@ -783,11 +783,9 @@ GET /rest/v2/data/config/network/nGraphElements/* where type='unidirectionalEdge GET /rest/v2/data/config/network/nGraphElements/* where type='nGraphResourceTransform' /** ``` -## Known Action Endpoints Needing Payload Capture +## Action Endpoints -Manual endpoint discovery also identified the following Inspect-related action -endpoints. The examples below are anonymized and were captured with safe lookup -or no-op requests. +Anonymized request/response shapes for Inspect collector and network actions. ### `POST /rest/v2/actions/status/collector/lookupInspectEdgesByIds` @@ -796,7 +794,7 @@ form** — every field a `replaceEdges` payload needs (`weight`, `capacity`, `bandwidth`, `redundancyMode`, `weightFactors`, `descriptor`, `fDescriptor`, `tags`, `conflictPri`, `includeFormats`, `excludeFormats`). Batched by design. This is the stage-time baseline read for compare-and-commit -([ADR-0009](./decisions/0009-write-consistency.md)). The UI bundle's edge edit +([ADR-007](./decisions/007-write-consistency.md)). The UI bundle's edge edit flow calls it with **both directions of a connection** (`[edgeId, pairedEdgeId]`) before opening the dialog. **No `_rev` anywhere in the response.** @@ -855,7 +853,7 @@ or batched (`…ByIds`, `data: ["", …]`, response keyed by id). The UI bun uses only the batched form. **No `_rev`.** Together with `lookupInspectDevice` (devices, above) and `lookupInspectEdgesByIds` this covers all three `replace*` element kinds for stage-time baselines -(ADR-0009). +(ADR-007). > **Vertex tags.** Tag bindings on a vertex are **not** part of the topology > `nGraphElements` store (`app.topology` has no equivalent). Server-side they @@ -864,7 +862,7 @@ covers all three `replace*` element kinds for stage-time baselines > `assignedTags` (with `all`, `inherited`, `local`, `inheritedConflict`) and > `fields.tags` / `fields.localAssignedTags`. Hydrated `nodeStatus` ports carry > the same bindings under `tagsInfo` for read-side display -> ([concepts.md §3.4](./concepts.md#34-tagging--device-vs-vertex-inspect-vs-topology)). +> ([concepts.md §3.4](./concepts.md#34-tagging--device-vs-vertex-vs-module-inspect-vs-topology)). Response (single form; `…ByIds` nests this per id): @@ -1608,9 +1606,9 @@ committed = response.header.ok and response.data.res.ok and response.data.valida Concurrency: the action performs **no revision check** — a stale `_rev` in `replace*` payloads is ignored (last-writer-wins, verified 2025.4.9). Conflict detection is client-side compare-and-commit -([ADR-0009](./decisions/0009-write-consistency.md)); after a successful commit +([ADR-007](./decisions/007-write-consistency.md)); after a successful commit the snapshot is refreshed with targeted scoped reads -([ADR-0010](./decisions/0010-post-commit-snapshot-refresh.md)). +([ADR-008](./decisions/008-post-commit-snapshot-refresh.md)). Failure modes (verified 2025.4.9): @@ -1729,37 +1727,6 @@ verified 2025.4.9): } ``` -Earlier anonymized failure shape (browser capture, 2026-06-18): - -```json -{ - "data": { - "items": [], - "res": { - "msg": ["Validation failed"], - "ok": false - }, - "validation": { - "createIds": [], - "details": { - "booking-1001": { - "isCancel": false, - "isProduct": false, - "resolvable": false, - "rev": "2-2026-01-01T00:00:00.000000000Z[UTC]", - "status": -22, - "type": "generic" - } - }, - "result": { - "msg": ["A required edge was not found. (main)"], - "ok": false - } - } - } -} -``` - ## `POST /rest/v2/actions/status/tags/assignTag` / `…/unassignTag` Module (and other resource) tag bindings that are **not** carried on @@ -1849,4 +1816,4 @@ references `updateTopology`, `addDevices`, `syncDevices`, `lookupSyncInfo`, `lookupNodeInfo`, `lookupConfigDesc`, `updateVirtualInstances`, `updateVirtualTemplates`, and `addVirtualTopology` — and contains **zero references to `nGraphElements`** -([ADR-0008](./decisions/0008-collector-only-endpoints.md)). +([ADR-006](./decisions/006-collector-only-endpoints.md)). diff --git a/docs/architecture/inspect-app/models.md b/docs/architecture/inspect-app/models.md index 747c0c8..87ba262 100644 --- a/docs/architecture/inspect-app/models.md +++ b/docs/architecture/inspect-app/models.md @@ -35,7 +35,7 @@ flowchart LR ``` A snapshot is built **skeleton-first** -([ADR-0007](./decisions/0007-lazy-snapshot-loading.md)): two parallel scoped +([ADR-005](./decisions/005-lazy-snapshot-loading.md)): two parallel scoped collector queries load all devices (without modules/ports) and all external edges (lean projection). Detail is **lazily hydrated** — accessing an unloaded property fetches that one device's full `nodeStatus` subtree (or, for @@ -70,7 +70,7 @@ The loading contract: - Because hydration is HTTP, touching an unloaded property can add latency and raise connector errors — this is deliberate, documented behaviour. - Iterating detail over many devices hydrates one device per iteration (N+1); - use bulk preload helpers (e.g. `snapshot.preload_devices(...)` / + use bulk preload helpers (e.g. `app.inspect.preload(...)` / `get_devices(detail=True)`) for that pattern. - The snapshot is **not** a single point in time: skeleton and hydrated subtrees carry their own fetch timestamps. @@ -232,6 +232,23 @@ for alarm in device.alarms: print(alarm.severity, alarm.message) ``` +### `InspectModule` + +Represents one device module / slot. Modules own ports (and therefore vertices). +Editable attributes (e.g. tags) stage pending intents on the snapshot; flush with +`app.inspect.update(module)` or `tx.update(...)`. Module tags commit via +`assignTag` / `unassignTag`, not `updateTopology`. + +| Field / property | Meaning | +| --- | --- | +| `id` | Module pid | +| `label` / `description` | Display fields | +| `device` | Owning `InspectDevice` | +| `status` | Module status summary (`InspectSeverity` fields) | +| `alarms` | Active alarms correlated to this module | +| `tags` | Locally assigned tags (writable) | +| `ports` | Ports on this module | + ### `InspectPort` Represents one module port with status, optional vertex linkage, and an optional @@ -249,6 +266,28 @@ external edge to another device. | `tags` | Vertex tag bindings when hydrated (`tagsInfo` from `nodeStatus`) | | `edge` | External edge when this port connects to another device; otherwise `None` | +### `InspectVertex` + +Base read/write view of a single topology vertex (one directed endpoint of a +port). Concrete subclasses expose kind-specific fields: + +- `InspectIpVertex` — IP config (address, netmask, VLAN, VRF, support flags) +- `InspectCodecVertex` — codec config (`typeFields.generic` / `typeFields.specific`) +- `InspectGenericVertex` / `InspectResourceTransformVertex` — base fields only + +Built by `InspectSnapshot.get_vertex`, which picks the subclass from the edit +form's `typeFields.type`. Editable attributes stage pending intents; flush with +`app.inspect.update(vertex)` or `tx.update(...)`. + +| Field / property | Meaning | +| --- | --- | +| `id` | Vertex id (e.g. `device-a.module-1.port-out-1.out`) | +| `label` / `description` | Display fields (writable) | +| `vertex_type` | Direction: `"In"` / `"Out"` / … | +| `is_active` / `is_controlled` / `is_endpoint` | Offline status flags from port `vertexInfo` | +| `tags` | Locally assigned vertex tags (writable via `localAssignedTags`) | +| `use_as_endpoint` | Whether usable as a service endpoint | + ### `InspectEdge` Represents one external edge status entry between two endpoint devices/ports. @@ -282,8 +321,8 @@ Represents one service/booking path across devices and ports. Lookup: ```python -service = snapshot.get_service_by_booking_id("booking-1001") -all_services = snapshot.get_services() +service = app.inspect.get_service_by_booking_id("booking-1001") +all_services = app.inspect.services ``` ## Transport DTO Examples @@ -521,7 +560,7 @@ tag bindings** (not available from `nGraphElements` or `app.topology`): ``` This is the stage-time baseline for vertex tag fields in compare-and-commit -([ADR-0009](./decisions/0009-write-consistency.md)). +([ADR-007](./decisions/007-write-consistency.md)). `lookupSyncInfo` returns per-device sync differences: @@ -577,7 +616,7 @@ shape to parse. > concept. Inspect reads vertex tags from hydrated port `tagsInfo` in > `nodeStatus` and from `lookupInspectVertexByIds` (`assignedTags`, > `fields.tags`, `fields.localAssignedTags`) — see -> [concepts.md §3.4](./concepts.md#34-tagging--device-vs-vertex-inspect-vs-topology). +> [concepts.md §3.4](./concepts.md#34-tagging--device-vs-vertex-vs-module-inspect-vs-topology). > A `tags` field may appear on vertex elements in `nGraphElements` wire examples > but is not the authoritative store for vertex tag bindings. @@ -737,7 +776,7 @@ inspect `data.res`, `data.validation.result`, and `data.validation.details`. `updateTopology` enforces no revisions (last-writer-wins), `replace*` entries are full-object upserts, and collector reads carry no `_rev` — so the change set protects callers itself -([ADR-0009](./decisions/0009-write-consistency.md)): +([ADR-007](./decisions/007-write-consistency.md)): - **Staging an entity fetches its baseline**: the current form, read via Inspect-surface lookups (`lookupInspectDevice` for devices, @@ -762,7 +801,7 @@ revisions, possibly stale by design (lazy hydration). A successful commit leaves the caller's `InspectSnapshot` stale exactly where the change set touched it. Instead of a full re-snapshot (~MBs), the snapshot catches up with **targeted scoped re-reads** -([ADR-0010](./decisions/0010-post-commit-snapshot-refresh.md)): +([ADR-008](./decisions/008-post-commit-snapshot-refresh.md)): - removed entities are dropped from the indexes locally; - affected devices and edge pairs (derived from the change-set keys and the @@ -787,14 +826,21 @@ Transport DTOs are split by payload area under `apps/inspect/model/`: - `ngraph.py` — persisted `nGraphElements` wire models - `actions.py` — lookup/add/sync action request and response DTOs - `update_topology.py` — `updateTopology` change-set and commit response DTOs +- `alarms.py` — current-alarm wire models (`status/alarms/current`) +- `tags.py` — `assignTag` / `unassignTag` request DTOs +- `virtual.py` — virtual-device and port-template wire models -User-facing read models live alongside the snapshot: +User-facing domain models and the snapshot: - `snapshot.py` — `InspectSnapshot` and internal indexes +- `transaction.py` — `InspectTransaction` (commit-style writes) - `domain/device.py` — `InspectDevice` +- `domain/module.py` — `InspectModule` - `domain/port.py` — `InspectPort` +- `domain/vertex.py` — `InspectVertex` and subclasses - `domain/edge.py` — `InspectEdge` - `domain/service.py` — `InspectService` +- `domain/alarm.py` — `InspectAlarm` When adding transport fields, prefer extending the nearest existing `InspectApi*` DTO. When adding user-facing behaviour, extend the domain layer and snapshot diff --git a/src/videoipath_automation_tool/apps/inspect/__init__.py b/src/videoipath_automation_tool/apps/inspect/__init__.py index 8a07f0c..e591354 100644 --- a/src/videoipath_automation_tool/apps/inspect/__init__.py +++ b/src/videoipath_automation_tool/apps/inspect/__init__.py @@ -23,7 +23,7 @@ from videoipath_automation_tool.apps.inspect.app import InspectApp as InspectApp from videoipath_automation_tool.apps.inspect.model import * -# InspectSnapshot is an internal implementation detail of InspectApp ([ADR-0007]); it is not part of +# InspectSnapshot is an internal implementation detail of InspectApp; it is not part of # the public API. Interact with the topology entirely through ``app.inspect``. __all__ = [ diff --git a/src/videoipath_automation_tool/apps/inspect/api/inspect_api.py b/src/videoipath_automation_tool/apps/inspect/api/inspect_api.py index 82f4a36..f56e426 100644 --- a/src/videoipath_automation_tool/apps/inspect/api/inspect_api.py +++ b/src/videoipath_automation_tool/apps/inspect/api/inspect_api.py @@ -1,7 +1,7 @@ """Raw Inspect API layer: one method per verified endpoint, typed responses, no business logic. -All reads use the collector namespace only ([ADR-0008]); scoped queries come from -``queries.py`` ([ADR-0007]). Writes go through ``updateTopology`` and the network +All reads use the collector namespace only; scoped queries come from +``queries.py``. Writes go through ``updateTopology`` and the network actions (``addDevices``, ``syncDevices``, virtual-device actions). """ @@ -65,7 +65,7 @@ def __init__(self, vip_connector: VideoIPathConnector, logger: Optional[logging. self.vip_connector = vip_connector self._logger.debug("Inspect API initialized.") - # --- Collector reads (scoped, ADR-0007) --- + # --- Collector reads (scoped) --- def get_device_skeleton(self) -> list[InspectApiNodeStatusItem]: """All devices without module/port detail (skeleton load).""" @@ -88,7 +88,7 @@ def get_edge_skeleton(self) -> list[InspectApiExternalEdgesByDeviceKeyItem]: return [InspectApiExternalEdgesByDeviceKeyItem.model_validate(item) for item in items] def get_edge_pair(self, pair_id: str) -> Optional[InspectApiExternalEdgesByDeviceKeyItem]: - """A single external-edge device pair (targeted refresh, ADR-0010).""" + """A single external-edge device pair (targeted refresh).""" response = self.vip_connector.rest.get(queries.edge_pair(pair_id), allow_projection=True) items = _extract_items(response.data, "status", "collector", "externalEdgesByDeviceKey") if not items: @@ -126,7 +126,7 @@ def get_virtual_devices(self) -> list[InspectApiVirtualDeviceInstance]: items = _extract_items(response.data, "status", "network", "virtualDevices") return [InspectApiVirtualDeviceInstance.model_validate(item) for item in items] - # --- Lookups (baselines for compare-and-commit, ADR-0009) --- + # --- Lookups (baselines for compare-and-commit) --- def lookup_inspect_device(self, device_id: str) -> InspectApiLookupInspectDeviceResponse: request = InspectApiLookupInspectDeviceRequest(data=device_id) diff --git a/src/videoipath_automation_tool/apps/inspect/api/queries.py b/src/videoipath_automation_tool/apps/inspect/api/queries.py index 583d45d..d853562 100644 --- a/src/videoipath_automation_tool/apps/inspect/api/queries.py +++ b/src/videoipath_automation_tool/apps/inspect/api/queries.py @@ -1,7 +1,7 @@ """Scoped collector query catalogue for the Inspect app. Every query string here is **verified live against VideoIPath 2025.4.9** and is the -concrete basis for the skeleton-first + lazy-hydration loading model ([ADR-0007]). +concrete basis for the skeleton-first + lazy-hydration loading model. The Inspect UI issues the same paths over WebSocket subscriptions; the package issues them as REST GETs (see docs/architecture/inspect-app/endpoints.md). @@ -51,7 +51,7 @@ def edge_skeleton() -> str: def edge_pair(pair_id: str) -> str: - """GET path for a single external-edge device pair (targeted refresh, [ADR-0010]).""" + """GET path for a single external-edge device pair (targeted refresh).""" return _build(f"/status/collector/externalEdgesByDeviceKey/{pair_id}" + _EDGE_LEAN_TAIL) diff --git a/src/videoipath_automation_tool/apps/inspect/app/actions.py b/src/videoipath_automation_tool/apps/inspect/app/actions.py index f696b05..b76d6ce 100644 --- a/src/videoipath_automation_tool/apps/inspect/app/actions.py +++ b/src/videoipath_automation_tool/apps/inspect/app/actions.py @@ -4,7 +4,7 @@ These wrap the ``actions/status/network/*`` and ``lookupSyncInfo`` endpoints used by the Inspect device-onboarding-into-topology workflows. Placement, metadata edits, connections, and removal of -virtual devices go through the same write/transaction path as physical devices ([ADR-0006]); only +virtual devices go through the same write/transaction path as physical devices; only creating a virtual device (and managing port templates / adding ports from templates) uses these dedicated network actions. """ @@ -302,7 +302,7 @@ def _refresh_after_network_action(self: _HasInspectApi, device_ids: list[str]) - """Update the internal snapshot for the affected devices after a successful network action. Only refreshes when a snapshot is already loaded, so a pure-action workflow never triggers - an unnecessary topology read (mirrors the write path; [ADR-0010]).""" + an unnecessary topology read (mirrors the write path).""" if self._snapshot is not None: self._snapshot.apply_network_refresh(device_ids) diff --git a/src/videoipath_automation_tool/apps/inspect/app/app.py b/src/videoipath_automation_tool/apps/inspect/app/app.py index 8591734..77e9688 100644 --- a/src/videoipath_automation_tool/apps/inspect/app/app.py +++ b/src/videoipath_automation_tool/apps/inspect/app/app.py @@ -1,7 +1,7 @@ """InspectApp — the user-facing entry point for the VideoIPath Inspect surface. -Read-only monitoring plus commit-style topology writes, built entirely on the collector API -([ADR-0008]). Composed from focused mixins, mirroring the Inventory/Topology app layout. +Read-only monitoring plus commit-style topology writes, built entirely on the collector API. +Composed from focused mixins, mirroring the Inventory/Topology app layout. This app is currently in beta; the API and behaviour may change in future releases. """ diff --git a/src/videoipath_automation_tool/apps/inspect/app/read.py b/src/videoipath_automation_tool/apps/inspect/app/read.py index 58f3bb5..8012d8d 100644 --- a/src/videoipath_automation_tool/apps/inspect/app/read.py +++ b/src/videoipath_automation_tool/apps/inspect/app/read.py @@ -1,6 +1,6 @@ """Read-side user methods for the Inspect app. -The Inspect app owns a single internal :class:`InspectSnapshot` ([ADR-0007]); users never handle it +The Inspect app owns a single internal :class:`InspectSnapshot`; users never handle it directly. It is built lazily on the first read and reused (skeleton-first, then hydrated on demand). Writes update it in place; :meth:`refresh` rebuilds it from the server. """ @@ -111,7 +111,7 @@ def get_services_for_device(self: _HasInspectState, device_id: str) -> list["Ins # --- Internal snapshot lifecycle --- def _get_snapshot(self: _HasInspectState) -> InspectSnapshot: - """Return the internal snapshot, building it on first access ([ADR-0007]).""" + """Return the internal snapshot, building it on first access.""" if self._snapshot is None: self._snapshot = self._load_snapshot(self._load_mode) return self._snapshot diff --git a/src/videoipath_automation_tool/apps/inspect/app/write.py b/src/videoipath_automation_tool/apps/inspect/app/write.py index d0a24a4..e4272be 100644 --- a/src/videoipath_automation_tool/apps/inspect/app/write.py +++ b/src/videoipath_automation_tool/apps/inspect/app/write.py @@ -1,4 +1,4 @@ -"""User-facing topology writes: direct auto-commit sugar + the explicit transaction ([ADR-0006]). +"""User-facing topology writes: direct auto-commit sugar + the explicit transaction. Direct methods (``place_device``, ``update_device``, ``connect`` …) each open a single-change transaction and commit it immediately. For batched, atomic changes use ``transaction()`` as a @@ -9,7 +9,7 @@ auto-commit, or ``tx.update(...)`` to stage into an open transaction. Every write is bound to the app's internal snapshot: on a successful commit the touched entities are -refreshed in place ([ADR-0010]) — but only if the snapshot has already been loaded, so a pure-write +refreshed in place — but only if the snapshot has already been loaded, so a pure-write workflow never triggers an unnecessary topology read. """ diff --git a/src/videoipath_automation_tool/apps/inspect/domain/device.py b/src/videoipath_automation_tool/apps/inspect/domain/device.py index 352768f..eb48c30 100644 --- a/src/videoipath_automation_tool/apps/inspect/domain/device.py +++ b/src/videoipath_automation_tool/apps/inspect/domain/device.py @@ -36,7 +36,7 @@ class InspectDevice(InspectEditableModel): """A topology device/node. Skeleton fields (id, label, coordinates, status, sync, tags) are available immediately; ``ports`` and ``services`` lazily hydrate from the server on first - access ([ADR-0007]). The record is resolved live from the snapshot, so a held reference sees + access. The record is resolved live from the snapshot, so a held reference sees hydrated/refreshed data transparently. Editable attributes use property setters that stage pending intents on the snapshot diff --git a/src/videoipath_automation_tool/apps/inspect/domain/vertex.py b/src/videoipath_automation_tool/apps/inspect/domain/vertex.py index cd14cf2..8f7e070 100644 --- a/src/videoipath_automation_tool/apps/inspect/domain/vertex.py +++ b/src/videoipath_automation_tool/apps/inspect/domain/vertex.py @@ -1,4 +1,4 @@ -"""Typed vertex domain views ([ADR-0007]). +"""Typed vertex domain views. A vertex is one directed endpoint of a port (a port carries one vertex, or an ``out``/``in`` pair). ``InspectVertex`` is the base read/write view over the vertex edit form (``lookupInspectVertexById``); diff --git a/src/videoipath_automation_tool/apps/inspect/errors.py b/src/videoipath_automation_tool/apps/inspect/errors.py index 3ac0682..53f51a3 100644 --- a/src/videoipath_automation_tool/apps/inspect/errors.py +++ b/src/videoipath_automation_tool/apps/inspect/errors.py @@ -1,8 +1,8 @@ """Typed exceptions for the Inspect app. The Inspect surface has semantics that a raw HTTP envelope cannot express: -commit success is a three-flag check (see [ADR-0006]), concurrent writes are -detected client-side (see [ADR-0009]), and over-long projection URLs are +commit success is a three-flag check, concurrent writes are +detected client-side, and over-long projection URLs are rejected by the proxy before they reach the server. These exceptions carry the structured detail callers need to react without parsing raw responses. """ @@ -64,7 +64,7 @@ def __init__(self, response: "InspectApiUpdateTopologyResponse") -> None: class InspectConflict: - """One entity whose server state changed between staging and commit (see [ADR-0009]).""" + """One entity whose server state changed between staging and commit.""" def __init__(self, entity_id: str, kind: str, field_diffs: dict[str, tuple[object, object]]) -> None: self.entity_id = entity_id diff --git a/src/videoipath_automation_tool/apps/inspect/snapshot.py b/src/videoipath_automation_tool/apps/inspect/snapshot.py index e78f463..bf773e1 100644 --- a/src/videoipath_automation_tool/apps/inspect/snapshot.py +++ b/src/videoipath_automation_tool/apps/inspect/snapshot.py @@ -1,4 +1,4 @@ -"""InspectSnapshot: skeleton-first, lazily-hydrated, accreting read state ([ADR-0007]). +"""InspectSnapshot: skeleton-first, lazily-hydrated, accreting read state. A snapshot is built from two scoped skeleton reads (all devices without module/port detail, all external-edge pairs). Detail is hydrated on demand — the first access to a device's ports fetches @@ -8,7 +8,7 @@ across snapshots. After a successful commit the transaction calls the post-commit hooks here to update only the -touched entities via targeted scoped re-reads ([ADR-0010]) instead of a full reload. +touched entities via targeted scoped re-reads instead of a full reload. """ from __future__ import annotations @@ -90,11 +90,11 @@ def __init__( self._ports_by_pid: dict[str, list[_IndexedPort]] = {} self._modules_by_device_id: dict[str, dict[str, InspectApiModuleStatus]] = {} - # Vertex edit-form details, fetched lazily per vertex ([ADR-0007]) and invalidated when the + # Vertex edit-form details, fetched lazily per vertex and invalidated when the # owning device's ports are rebuilt after a refresh/commit. self._vertex_details: dict[str, "InspectApiLookupVertexResponseData"] = {} - # Edge edit-form details, fetched lazily per edge ([ADR-0007]) and invalidated when the + # Edge edit-form details, fetched lazily per edge and invalidated when the # owning edge pair is dropped/re-indexed after a refresh/commit. self._edge_details: dict[str, "InspectApiEdgeForm"] = {} @@ -115,7 +115,7 @@ def __init__( self._edge_cache: dict[str, "InspectEdge"] = {} self._service_cache: dict[str, "InspectService"] = {} - # Entities whose post-write re-fetch failed; re-fetched lazily on next access ([ADR-0010]). + # Entities whose post-write re-fetch failed; re-fetched lazily on next access. self._stale_devices: set[str] = set() self._stale_pairs: set[str] = set() @@ -421,7 +421,7 @@ def get_alarms_for_edge(self, edge_id: str, *, pair_id: str | None = None) -> li def get_alarms_for_service(self, booking_id: str) -> list["InspectAlarm"]: return self.get_alarms_for_resource(booking_id) - # --- Bulk preload (ADR-0004) --- + # --- Bulk preload --- def preload(self, devices: Optional[list[str]] = None) -> None: """Hydrate multiple devices in parallel to avoid N+1 when detail is needed for many. @@ -507,7 +507,7 @@ def refresh(self) -> "InspectSnapshot": edge_items=self._fetcher.get_edge_skeleton(), ) - # --- Post-commit hooks (ADR-0010) --- + # --- Post-commit hooks --- def apply_post_commit( self, @@ -628,7 +628,7 @@ def _refresh_edge_pair(self, pair_id: str) -> None: self._index_edge_pair(pair) self._stale_pairs.discard(pair_id) - # --- Internal: resilient refresh + lazy-stale self-heal (ADR-0010) --- + # --- Internal: resilient refresh + lazy-stale self-heal --- def _try_ensure_device_detail(self, device_id: str) -> None: """Hydrate a device; on failure mark it stale (lazy self-heal on next access) and log.""" diff --git a/src/videoipath_automation_tool/apps/inspect/transaction.py b/src/videoipath_automation_tool/apps/inspect/transaction.py index 6187f7c..74314b5 100644 --- a/src/videoipath_automation_tool/apps/inspect/transaction.py +++ b/src/videoipath_automation_tool/apps/inspect/transaction.py @@ -1,10 +1,10 @@ -"""Transaction for Inspect topology writes ([ADR-0006]/[ADR-0009]/[ADR-0010]). +"""Transaction for Inspect topology writes. A transaction stages topology changes, captures a per-entity baseline via the lookup endpoints -(the lookup forms *are* the ``updateTopology`` write shapes — [ADR-0009]), and applies them +(the lookup forms *are* the ``updateTopology`` write shapes), and applies them atomically on ``commit()``. Commit runs a client-side compare-and-commit conflict check against freshly re-fetched baselines, then a single ``updateTopology`` POST, then a three-flag success -evaluation ([ADR-0006]). On success it drives a targeted snapshot refresh ([ADR-0010]) instead of +evaluation. On success it drives a targeted snapshot refresh instead of a full reload. Caller mutations are field-level *intents* recorded against the staged baseline and applied at @@ -77,7 +77,7 @@ class CommitResult(InspectFrozenModel): - """Outcome of a successful commit ([ADR-0006]); a failed commit raises ``InspectCommitError``. + """Outcome of a successful commit; a failed commit raises ``InspectCommitError``. ``response`` is ``None`` when the commit only applied module tag assign/unassign ops (no ``updateTopology`` call). @@ -537,7 +537,7 @@ def update_module( def commit(self, check_conflicts: bool = True) -> CommitResult: """Validate, send, and (on success) refresh. Raises on conflict or server rejection. - Topology entries go through ``updateTopology`` ([ADR-0006]). Module tag intents are applied + Topology entries go through ``updateTopology``. Module tag intents are applied afterward via ``assignTag`` / ``unassignTag`` (not one atomic server transaction). Raises: @@ -723,7 +723,7 @@ def _lookup_edge_form(self, edge_id: str) -> Optional[InspectApiEdgeForm]: item = response.data.get(edge_id) return item.edge if item is not None else None - # --- Internal: conflict check (compare-and-commit, ADR-0009) --- + # --- Internal: conflict check (compare-and-commit) --- def _check_conflicts(self) -> None: current = self._refetch_baselines() @@ -819,7 +819,7 @@ def _module_local_tags(self, module_id: str) -> list[str]: local = status.local_assigned_tags return list(local) if local else list(status.assigned_tags) - # --- Internal: post-commit targeted refresh (ADR-0010) --- + # --- Internal: post-commit targeted refresh --- def _refresh_snapshot(self) -> None: if self._snapshot is None: diff --git a/tests/e2e/conftest.py b/tests/e2e/conftest.py index 17b9e17..ca345d9 100644 --- a/tests/e2e/conftest.py +++ b/tests/e2e/conftest.py @@ -1,4 +1,4 @@ -"""Gating and shared fixtures for the developer-run live-server E2E suite ([ADR-0005]). +"""Gating and shared fixtures for the developer-run live-server E2E suite. These tests are excluded by default (``-m "not e2e"`` in ``pyproject.toml``) and only run when: * you invoke an e2e entry point (``poetry run test-e2e``, ``poetry run test``, or VS Code **E2E Tests**), **and** diff --git a/tests/e2e/helpers.py b/tests/e2e/helpers.py index 1f941cc..ba3c4d4 100644 --- a/tests/e2e/helpers.py +++ b/tests/e2e/helpers.py @@ -1,4 +1,4 @@ -"""Shared helpers for the developer-run live-server E2E suite ([ADR-0005]). +"""Shared helpers for the developer-run live-server E2E suite. Everything the suite writes is namespaced (``E2E-`` label prefix + ``vipat-e2e`` tag) so a shared local instance stays safe. Cleanup is a single session-start :func:`sweep_e2e_namespace` that diff --git a/tests/inspect/test_actions.py b/tests/inspect/test_actions.py index 971ec84..71a5ccd 100644 --- a/tests/inspect/test_actions.py +++ b/tests/inspect/test_actions.py @@ -109,7 +109,7 @@ def test_empty_lists_rejected() -> None: app.get_sync_info([]) -# --- Post-action snapshot refresh (ADR-0010) --- +# --- Post-action snapshot refresh --- def test_add_devices_refreshes_snapshot_when_loaded() -> None: diff --git a/tests/inspect/test_snapshot.py b/tests/inspect/test_snapshot.py index ed96824..51e5e50 100644 --- a/tests/inspect/test_snapshot.py +++ b/tests/inspect/test_snapshot.py @@ -181,7 +181,7 @@ def test_post_commit_marks_paths_stale(snapshot: tuple[InspectSnapshot, FakeFetc def test_post_commit_reindexes_changed_label(snapshot: tuple[InspectSnapshot, FakeFetcher]) -> None: - # Latent-bug guard: a committed label change must re-point the label index (ADR-0010). + # Latent-bug guard: a committed label change must re-point the label index. snap, fetcher = snapshot fetcher._details["leaf-a"] = _detail_node("leaf-a", "LEAF-A-RENAMED", ["leaf-a.dev.0.up1"]) snap.apply_post_commit(device_ids=["leaf-a"], mark_paths_stale=False) diff --git a/tests/inspect/test_transaction.py b/tests/inspect/test_transaction.py index f1b05c6..068aa9e 100644 --- a/tests/inspect/test_transaction.py +++ b/tests/inspect/test_transaction.py @@ -338,7 +338,7 @@ def test_empty_commit_rejected() -> None: tx.commit() -# --- Conflict detection (ADR-0009) --- +# --- Conflict detection --- def test_conflict_detected_on_baseline_change() -> None: @@ -390,7 +390,7 @@ def test_rebase_refetches_baseline() -> None: assert api.update_calls[0].replaceEdges[EDGE_ID].weight == 99 -# --- Post-commit refresh hook derivation (ADR-0010) --- +# --- Post-commit refresh hook derivation --- def test_post_commit_refresh_derives_affected_ids() -> None: From 4f02e7ff09484f0c3fdce1a4bb115648f573b54d Mon Sep 17 00:00:00 2001 From: Paul Winterstein Date: Fri, 24 Jul 2026 16:06:29 +0200 Subject: [PATCH 34/34] clarify legacy and new tag handling --- docs/architecture/inspect-app/concepts.md | 47 +++++++++++++++-------- 1 file changed, 32 insertions(+), 15 deletions(-) diff --git a/docs/architecture/inspect-app/concepts.md b/docs/architecture/inspect-app/concepts.md index 9758a64..00b5694 100644 --- a/docs/architecture/inspect-app/concepts.md +++ b/docs/architecture/inspect-app/concepts.md @@ -99,7 +99,7 @@ Inventory onboarding stays on the existing path (`config/devman/devices`, | Device (inventory) | Prerequisite — not part of collector; onboard via `config/devman/devices` | `InventoryDevice` (`apps/inventory`) | | Device (topology node) | Read: `collector.inspect.nodeStatus`; stored as `baseDevice` in `nGraphElements` | Store shape overlaps with Topology, but Inspect uses `InspectApiBaseDevice` | | Vertices / Edges | Read: `nodeStatus` `vertexInfo` / `externalEdgesByDeviceKey`; stored as `ipVertex` / `codecVertex` / `unidirectionalEdge` in `nGraphElements` | Store shape overlaps with Topology, but Inspect uses `InspectApi*` nGraph DTOs | -| Vertex tags | Read: per-port `tagsInfo` on hydrated `nodeStatus`; editable form via `lookupInspectVertexByIds` (`assignedTags`, `fields.tags`, `fields.localAssignedTags`) | **Not in `nGraphElements`** — `app.topology` has no vertex-tag concept; server-side bindings live in `videoipath_docs.device_tags`, not the `ngraph` table (§3.4) | +| Vertex tags | Read: per-port `tagsInfo` on hydrated `nodeStatus`; editable form via `lookupInspectVertexByIds` (`assignedTags`, `fields.tags`, `fields.localAssignedTags`) | Legacy `tags` in `nGraphElements` are separate from the new framework; they are not synchronized or migrated. New-framework bindings live server-side in `videoipath_docs.device_tags`, not the `ngraph` table (§3.4) | | Change set / commit | `POST …/actions/status/collector/updateTopology` → writes `nGraphElements` ([ADR-004](./decisions/004-commit-write-model.md)) | _commit flow net-new; target store is existing `nGraphElements`_ | | Services / paths | `collector.inspect.paths`, `pathDescriptions` on nodes/edges | _none — net-new_ | | Device / edge status | Embedded in collector (`status`, `sa`/`severity`, bandwidth, PTP) | `inventory.model.device_status`, `status/network/*` — partial overlap | @@ -246,10 +246,10 @@ Element `type` values: | `type` | Represents | Key fields | | ------ | ---------- | ---------- | -| `baseDevice` | Topology device node | `maps[]` (`cType: "Topology"`, integer `x`/`y`), `iconType`, `sdpStrategy`, `isVirtual`, `tags` (device-level only) | -| `ipVertex` | Ethernet/IP port vertex (`.in` / `.out`) | `vertexType`, `gpid.pointId`, `supports*Cfg` capability flags — **not** vertex tag bindings (§3.4) | -| `codecVertex` | Codec/SDI endpoint vertex | `vertexType` (`In`/`Out`), `codecFormat`, `useAsEndpoint`, `control`, SIPS/SDP fields — **not** vertex tag bindings (§3.4) | -| `unidirectionalEdge` | Directed link/route between vertices | `fromId`, `toId`, `weight`, `capacity`, `bandwidth`, `redundancyMode`, `weightFactors`, `conflictPri` | +| `baseDevice` | Topology device node | `maps[]` (`cType: "Topology"`, integer `x`/`y`), `iconType`, `sdpStrategy`, `isVirtual`, legacy `tags` | +| `ipVertex` | Ethernet/IP port vertex (`.in` / `.out`) | `vertexType`, `gpid.pointId`, `supports*Cfg` capability flags, legacy `tags` — separate from new-framework tag bindings (§3.4) | +| `codecVertex` | Codec/SDI endpoint vertex | `vertexType` (`In`/`Out`), `codecFormat`, `useAsEndpoint`, `control`, SIPS/SDP fields, legacy `tags` — separate from new-framework tag bindings (§3.4) | +| `unidirectionalEdge` | Directed link/route between vertices | `fromId`, `toId`, `weight`, `capacity`, `bandwidth`, `redundancyMode`, `weightFactors`, `conflictPri`, legacy `tags` | **Implication:** Inspect's underlying topology store is `nGraphElements`, but the package keeps a separate Inspect model namespace (`InspectApiBaseDevice`, @@ -260,22 +260,39 @@ stale `_rev` in the payload is ignored ### 3.4 Tagging — device vs. vertex vs. module (Inspect vs. Topology) -Inspect distinguishes three tag scopes. This is a **key difference from -`app.topology`**, which only models tags on topology **devices** (`baseDevice` -entries in `nGraphElements`). +The new Tags framework (introduced in VideoIPath 2025.3) is centrally managed +in **Settings** under **Tags and Filters**. Its hierarchical **Location**, +**Format**, and **General** categories support tag inheritance, unlike the +flat, local `tags` lists on elements in the legacy Topology app. + +Upgrading to VideoIPath 2025.3+ performs a one-time migration of supported +legacy tags (see Admin Guide). + +From VideoIPath 2025 LTS onward, only new-framework tags are considered across +apps and for UI filtering. They must be linked to devices, modules, and +vertices in the Inspect app. Legacy Topology tags remain local; there is no +runtime synchronization or reconciliation with new-framework bindings. + +The framework supports tags on profiles, devices, modules, vertices, junctions, +and endpoint groups. A tag on a device is inherited by its modules and vertices; a +module tag is inherited by its vertices; and an endpoint-group tag is inherited +by its endpoints. A child tag also implicitly applies its parent tags for +filtering. Format tags can additionally be inherited by vertices whose codec +format is linked to the tag. | Scope | What is tagged | Topology / `nGraphElements` | Inspect read surface | Write path | | ----- | -------------- | --------------------------- | -------------------- | ---------- | -| **Device** | Topology node (`baseDevice`) | `tags` on the `baseDevice` item | `nodeStatus` `tags` / `meta.tags` / `tagsInfo`; `lookupInspectDevice` | `updateTopology` `replaceDevices` | -| **Vertex** | Individual port vertex (`ipVertex`, `codecVertex`, …) | **Not present** — no vertex-tag field on persisted graph elements | Hydrated port `tagsInfo`; editable form in `lookupInspectVertexByIds` | `updateTopology` `replaceVertices` (`localAssignedTags`) | -| **Module** | Device module / slot | **Not present** | Hydrated module `tagsInfo` on `nodeStatus` | `assignTag` / `unassignTag` with `elementIds: ["device:{modulePid}"]` | +| **Device** | Topology node (`baseDevice`) | Legacy `tags` on the `baseDevice` item | `nodeStatus` `tags` / `meta.tags` / `tagsInfo`; `lookupInspectDevice` | `updateTopology` `replaceDevices` | +| **Vertex** | Individual port vertex (`ipVertex`, `codecVertex`, …) | Legacy `tags`; no new-framework tag binding | Hydrated port `tagsInfo`; editable form in `lookupInspectVertexByIds` | `updateTopology` `replaceVertices` (`localAssignedTags`) | +| **Module** | Device module / slot | Not an `nGraphElements` item; legacy and new-framework tags are separate | Hydrated module `tagsInfo` on `nodeStatus` | `assignTag` / `unassignTag` with `elementIds: ["device:{modulePid}"]` | **Implications for the package:** -- `app.topology` reads/writes device tags via `nGraphElements` only. It has no - API for binding tags to a vertex id such as - `device-a.module-1.port-out-1.out`, nor for module resource ids such as - `device:device-a.dev.0`. +- `app.topology` reads/writes legacy per-element tags through + `nGraphElements`. It has no API for new-framework tag bindings to a vertex id + such as `device-a.module-1.port-out-1.out`, nor to module resource ids such + as `device:device-a.dev.0`; the package must not derive those bindings from + legacy tags. - `app.inspect` must treat vertex tags as a **separate concern** from the persisted graph element shape. Do not assume a vertex's `tags` array in an `nGraphElements` `ipVertex` / `codecVertex` item (if present at all) is the