# System Architecture

> How the X-Edge AI box is put together — services, datasource plugins, the inference pipeline, real-time eventing, and logging.

One X-Edge AI box runs a self-contained three-service stack. Everything —
configuration, model versions, history, logs — lives on the box; no cloud
dependency exists at runtime.

```mermaid
flowchart LR
  subgraph Box["X-Edge AI Box (Docker)"]
    FE["Frontend\nnginx + React"] -->|REST + SignalR| BE["Backend\nASP.NET orchestration"]
    BE -->|gRPC :50051| INF["Inference runtime\nPython + ONNX Runtime"]
    BE --- DB[("SQLite\nconfig · history · logs")]
    PLUG["Datasource plugins\nOPC UA · MQTT · CSV"] --- BE
  end
  SENSORS["PLCs · brokers · devices"] --> PLUG
  PLUG -->|predictions written back| SENSORS
  USER["Operator browser"] --> FE
```

| Service | Role | Port |
|---|---|---|
| Frontend | Dashboard UI, served by nginx | 80 (443 with TLS) |
| Backend | Orchestration: datasources, models, inference lifecycle, auth, persistence | 5000 |
| Inference | ONNX model execution on CPU/CUDA/TensorRT | 50051 (gRPC) |

## Datasource plugins

Industrial protocols are implemented as **plugins** loaded by the backend at
startup. Each plugin describes its connection settings as a typed schema, which
the UI renders as a dynamic form — adding a protocol never requires a new UI
build.

- **Inputs (read path):** OPC UA (polled every tick), MQTT (push with
  staleness guard), and CSV replay for testing.
- **Outputs (write path):** OPC UA, MQTT, and rotating CSV files.
  An output tag's address is used verbatim (an MQTT topic, an OPC UA NodeId, a
  CSV column), and each value is scaled as `value × scale + offset`.

The two paths fail differently **on purpose**: a dead *input* tag halts
inference visibly (feeding a model stale data is the worst failure mode for
anomaly detection), while a failing *output* sink never stalls inference — it
raises a coalesced **Output write failed** notification instead.

## Inference pipeline

One enabled input datasource feeds one paired model:

1. The backend samples mapped tags at the datasource's **sampling rate** and
   streams them to the inference runtime over gRPC.
2. The runtime maintains a sliding window of `window size × tag count` samples;
   once full, every new sample produces a prediction
   (`health_score`, `failure_probability`, `rul_normalized`).
3. Predictions flow back and fan out to the dashboard (SignalR), the history
   store, configured output sinks, and the on-box SQLite audit trail.

Throughput therefore scales with **sampling rate × datasources**, not with the
publisher's rate — a 100 Hz sensor sampled at 10 Hz yields 10 predictions/s.

**Backpressure** is per-consumer: slow dashboards are never allowed to throttle
inference (their events drop first), output sinks get a bounded lossless queue
that slows the pipeline end-to-end when a sink can't keep up, and non-finite
predictions (NaN/Inf) are filtered and counted. All of these are visible as
counters — see [Monitoring](/operate/monitoring/).

## Model lifecycle

Models are authored off-box with [modelctl](/configure/prepare-models/)
(convert → validate → quantize → package), then uploaded through the UI. Upload
validates the ONNX contract via the inference runtime (shapes + SHA-256) without
loading it; the model only enters the engine when its datasource is **enabled**.
Loading is a zero-downtime hot swap, and versions are immutable — re-uploading a
changed file under the same version is rejected. Rollback re-activates the
previous valid version.

## Real-time eventing

Live dashboard updates (sensor batches, predictions, health, state changes,
notifications) travel over a SignalR WebSocket hub. This stream is
**fire-and-forget and in-memory** — it is never the source of truth. If a
browser disconnects, missed events are not replayed; authoritative state always
comes from the REST API and the on-box database.

The eventing backplane is in-memory, which is correct **only for the single-node
box deployment**. Do not run two backend instances against one box (for example
behind a load balancer) — clients would silently miss events. A fleet of boxes
is fine: each box is its own single-node system.

## Logging — three bounded layers

| Layer | Purpose | Bound |
|---|---|---|
| Container stdout (`docker logs`) | Live debugging | 10 MB × 3 files per service |
| Rolling file on a persistent volume (Warning+) | Post-mortem forensics — survives container recreation and database corruption | 10 MB × 7 (backend) / × 6 (inference) |
| SQLite log store | The LogViewer page and notification alerts | 10,000 entries, swept every 60 s |

Per-tick pipeline lines are logged at Debug level; the default Info stream
carries lifecycle events, heartbeats, and warnings only.

## Configuration import / export

Admins can export the full inference configuration — input and output
datasources with their tag mappings, and the flows binding them to models — as
one versioned JSON envelope, and re-import it on another box. Flows reference
their datasources and model by name, so a file carries no database Ids.

Secrets are redacted on export and must be re-entered on the target. Imports run
in a single transaction: any per-item **failure** rolls the whole file back and
returns the complete error list, while an item **skipped** (most often a flow
whose model is not on this box) is reported and the rest commits.

An import that would rewrite a datasource the enabled flow is using is refused
before anything is written, naming the flow.

## Next steps

  - [Product Overview](/product-overview/) — What the box does and who it's for.
  - [Connecting Input Datasources](/configure/input-datasources/) — Wire OPC UA, MQTT, or CSV into the pipeline.
  - [Monitoring](/operate/monitoring/) — Latency breakdown, backpressure counters, and health.
  - [gRPC Inference API](/api-reference/grpc-inference/) — The backend ↔ inference runtime contract.
