Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d744671b86 | ||
|
|
1cc2b9a8f2 | ||
|
|
1e5854ee0a | ||
|
|
ed531f9fb6 | ||
|
|
336f9c0428 | ||
|
|
3d13f744e6 | ||
|
|
dfb0556eda | ||
|
|
d3ccd6f94e | ||
|
|
ea062af86a | ||
|
|
bcd956d11a | ||
|
|
9f99d132e3 |
@@ -24,8 +24,10 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout
|
||||
run: |
|
||||
git clone --depth=50 https://${{ gitea.actor }}:${{ gitea.token }}@git.produktor.io/${{ gitea.repository }}.git .
|
||||
git checkout ${{ gitea.sha }}
|
||||
git init .
|
||||
git remote add origin https://${{ gitea.actor }}:${{ gitea.token }}@git.produktor.io/${{ gitea.repository }}.git
|
||||
git fetch --depth=50 origin "${{ gitea.ref }}"
|
||||
git checkout FETCH_HEAD
|
||||
|
||||
- name: Install dependencies
|
||||
run: pip install --quiet -r simulator/requirements.txt
|
||||
@@ -61,8 +63,10 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout
|
||||
run: |
|
||||
git clone --depth=1 https://${{ gitea.actor }}:${{ gitea.token }}@git.produktor.io/${{ gitea.repository }}.git .
|
||||
git checkout ${{ gitea.sha }}
|
||||
git init .
|
||||
git remote add origin https://${{ gitea.actor }}:${{ gitea.token }}@git.produktor.io/${{ gitea.repository }}.git
|
||||
git fetch --depth=1 origin "${{ gitea.ref }}"
|
||||
git checkout FETCH_HEAD
|
||||
|
||||
- name: Install Trivy
|
||||
run: |
|
||||
|
||||
@@ -29,3 +29,52 @@ See [`CONTRIBUTING.md`](CONTRIBUTING.md) for the development workflow and TDD re
|
||||
- TypeScript: strict mode, functional components and hooks, no extra UI libraries.
|
||||
- YAML: 2-space indent.
|
||||
- Diagrams: Mermaid inside Markdown.
|
||||
|
||||
## Decision records
|
||||
|
||||
Significant architectural decisions are recorded chronologically as **ADRs** in
|
||||
[`docs/adr/`](docs/adr/) — one immutable file per decision, in the
|
||||
Status / Context / Options / Decision / Consequences format. Start from
|
||||
[`docs/adr/README.md`](docs/adr/README.md).
|
||||
|
||||
- **ADR** = a point-in-time decision for *this* repo → `docs/adr/`.
|
||||
- **ASR** = a standing, ecosystem-wide standard (Go-first, secrets, layout) →
|
||||
lives in the `inventar` repo, **not** here.
|
||||
|
||||
When a change alters a boundary, contract, or trade-off, add an ADR (copy
|
||||
[`docs/adr/TEMPLATE.md`](docs/adr/TEMPLATE.md), take the next number, update the
|
||||
index) and cross-link it from the affected design doc. A later reversal gets a
|
||||
new ADR that supersedes the old one — never edit an Accepted record.
|
||||
|
||||
## Runtime & operations (sim environment)
|
||||
|
||||
The k3d ground miniature is `swarm-sim`. Services are exposed as NodePorts:
|
||||
|
||||
| Service | URL |
|
||||
| --- | --- |
|
||||
| Explorer (T1 live lake) | `http://localhost:30088` |
|
||||
| Warehouse explorer (T3) | `http://localhost:30089` |
|
||||
| Grafana (dashboards `swarm-fleet`, `swarm-platform`) | `http://localhost:30300` |
|
||||
| Prometheus | `http://localhost:30990` |
|
||||
| MinIO console | `http://localhost:30901` |
|
||||
| Prototype dev server (`npm run dev`) | `http://localhost:5173` |
|
||||
|
||||
Rebuild the simulator image and roll it out:
|
||||
|
||||
```bash
|
||||
cd simulator && docker build -t swarm-house/simulator:dev .
|
||||
k3d image import swarm-house/simulator:dev -c swarm-sim
|
||||
kubectl rollout restart -n swarm statefulset/drone deployment/explorer deployment/exporter
|
||||
cd infra/terraform/sim-env && terraform apply
|
||||
```
|
||||
|
||||
**CPU budget knobs** (see [ADR-0009](docs/adr/ADR-0009-bounded-lake-scans.md)):
|
||||
`METRIC_FLIGHT_WINDOW` (scan only recent flights), `SCAN_INTERVAL_S`,
|
||||
`VIEW_REFRESH_S`, `TREE_CACHE_S`, `KEEP_ALIVE=1` (drones idle after seal instead
|
||||
of restarting), `IMU_HZ`. Never scan the full lake on a hot path — it grows with
|
||||
pod churn. Long-horizon analysis is a T3 warehouse query, not an exporter job.
|
||||
|
||||
Runtime data lives under `var/t1` / `var/t3`, owned by uid **10001**
|
||||
([ADR-0007](docs/adr/ADR-0007-non-root-image.md),
|
||||
[ADR-0008](docs/adr/ADR-0008-runtime-data-paths.md)); both are gitignored.
|
||||
Terraform state is local and never committed.
|
||||
|
||||
@@ -81,9 +81,18 @@ identifying names of people, companies, or locations.
|
||||
- [ ] Tests added or updated; `pytest tests/ -q` passes locally
|
||||
- [ ] `docker build` in `simulator/` succeeds (test stage included)
|
||||
- [ ] Documentation updated if behaviour or boundaries changed
|
||||
- [ ] **ADR added** if the change alters an architectural boundary, contract, or trade-off (see [`docs/adr/`](docs/adr/))
|
||||
- [ ] English only; no identifying references
|
||||
- [ ] Conventional commit message
|
||||
|
||||
## Decisions
|
||||
|
||||
Architectural decisions are recorded chronologically as ADRs in
|
||||
[`docs/adr/`](docs/adr/). Read [`docs/adr/README.md`](docs/adr/README.md) before
|
||||
changing a boundary or contract; add a new ADR (from
|
||||
[`docs/adr/TEMPLATE.md`](docs/adr/TEMPLATE.md)) rather than editing an Accepted
|
||||
one when you reverse course.
|
||||
|
||||
## Questions
|
||||
|
||||
Open design questions live in [`docs/09-open-questions.md`](docs/09-open-questions.md).
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
PROPRIETARY LICENSE
|
||||
|
||||
Copyright (c) 2026 Andriy Oblivantsev. All rights reserved.
|
||||
|
||||
This material is proprietary and confidential.
|
||||
|
||||
No person or organization may, without prior written permission from the copyright holder:
|
||||
|
||||
- Publish or republish this material, in whole or in part.
|
||||
- Copy, reproduce, distribute, transmit, forward, or share this material by any means.
|
||||
- Use, modify, adapt, translate, create derivative works from, or incorporate this material into any other work.
|
||||
- Make this material available to any third party, whether publicly or privately.
|
||||
- Store this material in any repository, archive, or knowledge base accessible to others.
|
||||
|
||||
Permission must be obtained in writing before any of the above activities are undertaken.
|
||||
|
||||
Unauthorized use, publication, forwarding, reproduction, distribution, or disclosure of this material is strictly prohibited and may result in civil and criminal penalties under applicable copyright and intellectual property laws.
|
||||
|
||||
This license grants no rights except the right to view the material for its intended purpose by an authorized recipient.
|
||||
@@ -33,6 +33,10 @@ This repository describes how to build, deliver, test, and operate that platform
|
||||
| [09 — Open questions](docs/09-open-questions.md) | Known unknowns and proposed answers |
|
||||
| [10 — Domain context](docs/10-domain-context.md) | Swarm autonomy principles this design builds on |
|
||||
| [11 — CI/CD & delivery](docs/11-cicd-delivery.md) | Pipelines, registry, dev containers, fleet releases |
|
||||
| [12 — Design journey](docs/12-design-journey.md) | **Start here** — a narrative walk-through of how the design came together, linking into the code |
|
||||
| [ADRs](docs/adr/) | Architecture Decision Records — the decisions behind the above, in the order they were made |
|
||||
|
||||
New here? Read the [design journey](docs/12-design-journey.md) first: it tells the story chronologically and links straight into the code and decisions. The design principles below are the *what*; the [ADRs](docs/adr/) are the *why and when* — each principle traces to a dated, immutable decision record.
|
||||
|
||||
## Runnable parts
|
||||
|
||||
@@ -73,3 +77,5 @@ Contributing (TDD-first workflow): [`CONTRIBUTING.md`](CONTRIBUTING.md).
|
||||
## CI & releases
|
||||
|
||||
Every push runs the pipeline in [`.github/workflows/release.yaml`](.github/workflows/release.yaml): **pytest unit tests**, a compile check, a 10-second virtual smoke flight verified with DuckDB, and a Trivy vulnerability/misconfiguration scan. The simulator Docker build also runs `pytest` — a failing test blocks the image. Pushes to `main` then cut a semantic version from the conventional-commit type (`feat:` → minor, `!`/`BREAKING CHANGE` → major, else patch), tag the commit, and publish a release with a **fleet release manifest** (versioned artifact list with checksums — the delivery unit described in [11 — CI/CD & delivery](docs/11-cicd-delivery.md)) plus a source bundle.
|
||||
|
||||
[LICENSE](LICENSE)
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
Zero trust, fully air-gapped, everything provisioned before wheels-up.
|
||||
|
||||
> **Decision:** [ADR-0005 — WireGuard beneath SSH](adr/ADR-0005-wireguard-beneath-ssh.md).
|
||||
|
||||
## Trust model
|
||||
|
||||
- **Nothing joins the swarm at runtime.** Every device receives its identity (key pair + certificate) during ground provisioning, signed by the fleet's offline CA. There is no trust-on-first-use, no runtime enrollment endpoint, no exception path.
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
Three infrastructures, one data contract. The layout, schemas, and pipelines are identical everywhere; only scale and lifetime differ.
|
||||
|
||||
> **Decisions:** [ADR-0006 — IaC boundaries](adr/ADR-0006-iac-boundaries.md), [ADR-0008 — runtime data paths](adr/ADR-0008-runtime-data-paths.md).
|
||||
|
||||
```mermaid
|
||||
graph LR
|
||||
subgraph dev [3 — Dev and simulation]
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
The platform watches itself with the same discipline it applies to sensor data — and largely through the same pipeline.
|
||||
|
||||
> **Decision:** [ADR-0009 — bound observability lake scans to a flight window and cache](adr/ADR-0009-bounded-lake-scans.md).
|
||||
|
||||
## Two kinds of signals, one storage
|
||||
|
||||
| Signal | Examples | Where it goes |
|
||||
@@ -53,6 +55,13 @@ graph LR
|
||||
| Which link degraded first, and was it distance or interference? | RSSI telemetry joined with pose distance |
|
||||
| Is the new writer version flushing slower on ARM64? | CI simulation runs emit the same metrics; diff across fleet releases |
|
||||
|
||||
A working miniature of this doctrine ships in [`simulator/`](../simulator/): the `monitoring` Compose profile runs a DuckDB-based exporter over the generated Parquet plus Prometheus and a provisioned Grafana dashboard — fleet statistics derived from the data platform itself, with no agent on the (virtual) drones.
|
||||
A working miniature of this doctrine ships in [`simulator/`](../simulator/): the `monitoring` Compose profile runs a DuckDB-based exporter over the generated Parquet plus Prometheus and two provisioned Grafana dashboards:
|
||||
|
||||
| Dashboard | Focus |
|
||||
| --- | --- |
|
||||
| **Swarm Fleet — generated data** | Row counts, detections, pose frames, Parquet bytes, battery, RSSI |
|
||||
| **Swarm Platform — CPU & scan health** | Node CPU/memory (node-exporter), exporter/explorer scan durations, flight-window gauge |
|
||||
|
||||
The exporter and explorer **scan only the most recent flight partitions** (`METRIC_FLIGHT_WINDOW`, default 5) and cache tree/views — otherwise CPU climbs as every pod restart appends a new `flight=` tree to the shared lake. Drones in k3d set `KEEP_ALIVE=1` after sealing so they idle instead of exiting and spawning another flight.
|
||||
|
||||
Alerting on the ground follows standard practice (Prometheus alert rules for infrastructure, CI gates for regression in simulated staleness/throughput budgets). In flight there is nobody to page — the platform's job is to degrade in the documented order and record everything for the post-mortem.
|
||||
|
||||
@@ -61,3 +61,9 @@ Who owns the device registry (drone ids, key issuance, revocation) organizationa
|
||||
Sub-250 g class units cannot run the full stack (no GPU, minimal CPU/storage).
|
||||
|
||||
*Proposal:* define a minimal profile early — state publisher + UDP-multicast pose broadcast, no local Parquet store, no video — so the swarm protocol never assumes full-stack peers.
|
||||
|
||||
## 11 — Lake retention and partition pruning
|
||||
|
||||
The observability path now scans only the most recent flights ([ADR-0009](adr/ADR-0009-bounded-lake-scans.md)), but old `flight=` partitions still accumulate on T1 disk after offload to T3.
|
||||
|
||||
*Proposal:* a scheduled prune of T1 partitions whose flights are confirmed present in the T3 warehouse (offload as the retention gate), configurable by age and free-space watermark. On the drone the same policy is bounded by the NVMe quota ladder ([03](03-data-platform.md)); in the sim environment it is a ground CronJob alongside the offload job.
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
How software, models, and configuration reach the fleet — reproducibly, scanned, versioned, and atomically rollback-able. Everything lives inside the air gap.
|
||||
|
||||
> **Decisions:** [ADR-0006 — IaC boundaries & fleet manifest](adr/ADR-0006-iac-boundaries.md), [ADR-0007 — non-root image](adr/ADR-0007-non-root-image.md).
|
||||
|
||||
## Source and pipelines: GitLab
|
||||
|
||||
GitLab (self-managed, on-prem) is the backbone: repositories, CI/CD, and the container registry in one system.
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
# 12 — Design journey
|
||||
|
||||
How this design came together, told in the order the thinking actually happened.
|
||||
It is a walk-through, not a report. The formal decisions, with options and
|
||||
trade-offs, live as [Architecture Decision Records](adr/README.md). This is the
|
||||
story behind them, and each chapter links straight into the code it produced.
|
||||
|
||||
---
|
||||
|
||||
## 1. Starting point
|
||||
|
||||
At the start there was only a rough brief and a few adjacent interests to lean
|
||||
on. Key rotation matters in every pipeline: a cipher that is safe today may not
|
||||
be safe next year, so the infrastructure has to leave room to react, without
|
||||
spending too much latency or bandwidth to do it. That balance, security against
|
||||
speed and volume, is a recurring trade-off. Geospatial data is older ground:
|
||||
routing, indexing, fast spatial lookups at scale. So the first instinct was that
|
||||
the value might be less about new storage and more about optimizing access to
|
||||
data that already exists.
|
||||
|
||||
## 2. From words to a model, then to a picture
|
||||
|
||||
Once the shape was clear, an autonomous swarm where each unit talks to any peer
|
||||
in range and [no controller spans the swarm](adr/ADR-0001-no-swarm-orchestrator.md),
|
||||
the next step was to make the model visible. A small 2D prototype was enough to
|
||||
check it: units patrolling, static and moving obstacles, links forming and
|
||||
breaking, and a rough count of how much data crosses the air. Speed was not the
|
||||
question yet, only volume, because volume is what you size the infrastructure
|
||||
for. Starting from a picture gives an anchor before building anything.
|
||||
|
||||
> **Read the code**
|
||||
> - [`prototype/src/sim.ts`](https://git.produktor.io/eSlider/swarm-house/src/branch/main/prototype/src/sim.ts#L219-L287) — the tick: links form and break by range, pose broadcasts and bulk sync accumulate bytes
|
||||
> - [`prototype/src/sim.ts`](https://git.produktor.io/eSlider/swarm-house/src/branch/main/prototype/src/sim.ts#L57-L63) — the pose-frame size and rate that drive the volume estimate
|
||||
|
||||
The picture is running, not just described: the visual prototype is live at
|
||||
[swarm.produktor.io](https://swarm.produktor.io/) (same access as this
|
||||
repository). It draws units patrolling, links forming and breaking by range, and
|
||||
a running estimate of how much data crosses the air.
|
||||
|
||||
## 3. Naming the limits
|
||||
|
||||
Before any code, the limits had to be named. Maximum distance between units in
|
||||
the air. The fidelity data is stored at. The fidelity it can be transmitted at.
|
||||
Those drive volume, and volume drives the whole shape of the system. The
|
||||
conclusion: constrain the transport, but do not constrain on-board capture. Keep
|
||||
everything locally. Send only what peers truly need.
|
||||
|
||||
> **Read more**
|
||||
> - [01 — Problem statement](01-problem-statement.md) and [03 — Data platform](03-data-platform.md) — the constraints and the storage tiers
|
||||
|
||||
## 4. Three tiers inside each unit
|
||||
|
||||
That gave [three layers](03-data-platform.md) on every drone:
|
||||
|
||||
1. **Raw capture**, never transformed.
|
||||
2. **ETL / reduction**, where data is cut down to what has to be shared:
|
||||
millimetres to centimetres, thinning time series where that is enough,
|
||||
dropping what nobody downstream reads.
|
||||
3. **A bidirectional interface**: on one side it broadcasts position into the
|
||||
shared channel, on the other it lets peers pull data.
|
||||
|
||||
The ETL is deliberately not over-specified. How sensor data is transformed is a
|
||||
data-engineering decision. The platform's job is the contract, meaning what gets
|
||||
stored and in which structure, not to step into that work.
|
||||
|
||||
> **Read the code**
|
||||
> - [`simulator/virtual_drone/writer.py`](https://git.produktor.io/eSlider/swarm-house/src/branch/main/simulator/virtual_drone/writer.py#L23-L80) — the Hive-partition layout and the seal step that compacts a flight
|
||||
> - [`simulator/virtual_drone/sensors.py`](https://git.produktor.io/eSlider/swarm-house/src/branch/main/simulator/virtual_drone/sensors.py) — the raw capture that feeds tier one
|
||||
|
||||
## 5. What actually needs to sync
|
||||
|
||||
The most time-critical item is where each peer is, so every unit has time to
|
||||
react. The choice was to broadcast [relative pose](04-swarm-sync.md) (x, y, z and
|
||||
time) instead of absolute coordinates. Relative is cheaper and enough for
|
||||
coordination. To avoid sending orientation, a unit is modelled as a **sphere**
|
||||
that bounds its extent. That trades a little compute for much less data, and no
|
||||
per-shape encoding. If the units are identical, their 3D model can be provisioned
|
||||
ahead instead of transmitted.
|
||||
|
||||
> **Read the code**
|
||||
> - [`simulator/virtual_drone/broadcast.py`](https://git.produktor.io/eSlider/swarm-house/src/branch/main/simulator/virtual_drone/broadcast.py#L1-L40) — the 46-byte pose frame, position quantized on the wire only
|
||||
> - [`prototype/src/sim.ts`](https://git.produktor.io/eSlider/swarm-house/src/branch/main/prototype/src/sim.ts#L260-L277) — pose broadcasts at 5 Hz and opportunistic bulk sync, made visible
|
||||
|
||||
## 6. Security as nested layers, not one wall
|
||||
|
||||
The assumption was an ad-hoc wireless mesh. Broadcasting positions in the clear,
|
||||
even over encrypted Wi-Fi, is not acceptable: anyone who joins can reconstruct
|
||||
trajectories over time, and wireless encryption has been broken by ordinary
|
||||
people for two decades. Relying on that layer alone is risky, and pushing heavy
|
||||
encryption there is wasteful. So the trust model is [layered](05-network-security.md):
|
||||
|
||||
1. Wireless transport (WPA class) as the outer shell.
|
||||
2. **WireGuard over UDP** as the mesh overlay: proven, peer-authenticated by
|
||||
static keys, cheap.
|
||||
3. **SSH inside the tunnel** as the application layer, instead of HTTP/HTTPS. SSH
|
||||
is one of the most tested remote-access technologies we have. HTTPS would pull
|
||||
in a certificate authority and extra parts, and it is the first surface a
|
||||
security researcher probes. [Fewer moving parts, on purpose](adr/ADR-0005-wireguard-beneath-ssh.md).
|
||||
|
||||
Identity and encryption solve together at provisioning time: a per-flight,
|
||||
per-unit key pair recorded in a flight registry. Each unit holds its own key pair
|
||||
plus the public keys of its peers. A separate key registry for SSH means that if
|
||||
one WireGuard key leaks, SSH access does not follow automatically.
|
||||
|
||||
> **Read the code**
|
||||
> - [`infra/ansible/drone-provision.yml`](https://git.produktor.io/eSlider/swarm-house/src/branch/main/infra/ansible/drone-provision.yml#L20-L70) — ed25519 identity, peers authorized only through a forced command, WireGuard brought up
|
||||
> - [05 — Network & security](05-network-security.md) and [ADR-0005](adr/ADR-0005-wireguard-beneath-ssh.md)
|
||||
|
||||
## 7. SQL as the contract
|
||||
|
||||
Beyond the broadcast, people and peers want richer access. The whole progression
|
||||
is familiar: fully dynamic APIs, then JSON, OpenAPI/Swagger, GraphQL, then heavier
|
||||
schemes. Each time the same pain: hard to debug in the moment, and a lot of
|
||||
infrastructure to support, test, and agree on. The simpler conclusion:
|
||||
[**SQL is the contract**](adr/ADR-0004-sql-over-ssh-contract.md). Everyone who
|
||||
touches data already speaks SQL, and we do not know in advance which slice each
|
||||
of them needs, so give them a declarative, extensible way in rather than guessing
|
||||
endpoints ahead of time.
|
||||
|
||||
Concretely: an SSH connection whose forced command runs a read-only **DuckDB**
|
||||
query directly. No extra auth handshake between the engine and the data, the way
|
||||
a long-running database needs. No new port or protocol. When access control is
|
||||
needed, it rides on Linux file permissions, because the data is discrete
|
||||
Hive-partitioned files and permissions can be set per file. Not new invention:
|
||||
coming back to foundations DevOps and sysadmins already trust.
|
||||
|
||||
> **Read the code**
|
||||
> - [`simulator/explorer/server.py`](https://git.produktor.io/eSlider/swarm-house/src/branch/main/simulator/explorer/server.py#L43-L53) — the read-only gate that rejects anything that writes
|
||||
> - [`simulator/explorer/server.py`](https://git.produktor.io/eSlider/swarm-house/src/branch/main/simulator/explorer/server.py#L111-L131) — the query path a peer or an engineer hits identically
|
||||
> - [`simulator/tests/test_sql_gate.py`](https://git.produktor.io/eSlider/swarm-house/src/branch/main/simulator/tests/test_sql_gate.py#L13-L35) — the gate is safety-critical, so it is tested first
|
||||
|
||||
## 8. Replication and offload
|
||||
|
||||
Moving accumulated data uses rsync-class tooling (rclone) over the same SSH
|
||||
channel. It transfers diffs of the Hive tree efficiently. This matters because a
|
||||
unit can be lost, and if it is, we want its data to already exist elsewhere.
|
||||
When peers have a stable link the partition tree is pulled between them, which
|
||||
gives redundancy and a way to cross-check later. On the ground, when the fleet
|
||||
returns, the same path unifies every unit's data into a
|
||||
[local warehouse](06-environments.md). The warehouse is read-mostly for
|
||||
analytics, so transaction contention is not a concern, which opens a
|
||||
DuckDB / DuckLake approach with MinIO underneath, the
|
||||
[same storage model on both ends](adr/ADR-0002-one-storage-format.md). One
|
||||
uniform structure is what lets an analyst trust a single picture even when a
|
||||
unit's data has a gap.
|
||||
|
||||
> **Read the code**
|
||||
> - [`infra/terraform/ground/main.tf`](https://git.produktor.io/eSlider/swarm-house/src/branch/main/infra/terraform/ground/main.tf#L124-L160) — the post-flight T1 to T3 offload CronJob
|
||||
> - [06 — Environments](06-environments.md) and [ADR-0002](adr/ADR-0002-one-storage-format.md)
|
||||
|
||||
## 9. A proof of concept, not slides
|
||||
|
||||
A concept can be handed over and be wrong. Instead there is a generator: a
|
||||
virtual drone producing real sensor data into the Hive layout, a small frontend
|
||||
to watch it fill, and a **Grafana** dashboard on top, not to get ahead of the
|
||||
analysts, but to see the infrastructure work end to end. Closing the loop
|
||||
(Terraform, connector, exporter, dashboard) is how a misconfiguration shows up
|
||||
early. A panel that renders wrong points at exactly which layer broke, before an
|
||||
analyst has to report that the infra is up but nothing shows. Better to exercise
|
||||
every layer now and drop any layer or technology later if there is a sound
|
||||
argument against it.
|
||||
|
||||
Watching that loop is also where the [CPU cost surfaced](adr/ADR-0009-bounded-lake-scans.md):
|
||||
scanning the whole lake on every query does not scale, so the scan is
|
||||
[bounded to a flight window and cached](07-observability.md).
|
||||
|
||||
> **Read the code**
|
||||
> - [`simulator/monitoring/exporter.py`](https://git.produktor.io/eSlider/swarm-house/src/branch/main/simulator/monitoring/exporter.py#L38-L107) — the DuckDB scan that turns Parquet into Prometheus metrics
|
||||
> - [`simulator/monitoring/lake.py`](https://git.produktor.io/eSlider/swarm-house/src/branch/main/simulator/monitoring/lake.py#L13-L33) — the flight-window scan that keeps CPU bounded as the lake grows
|
||||
> - [`simulator/monitoring/grafana/dashboards/`](https://git.produktor.io/eSlider/swarm-house/src/branch/main/simulator/monitoring/grafana/dashboards) — the fleet and platform dashboards
|
||||
> - [`.github/workflows/release.yaml`](https://git.produktor.io/eSlider/swarm-house/src/branch/main/.github/workflows/release.yaml) — tests, smoke flight, Trivy, semantic release
|
||||
|
||||
## 10. Working method
|
||||
|
||||
A note on how this is meant to be built, not just what. Working together needs a
|
||||
shared language, and when the working language is nobody's first language,
|
||||
answering live is expensive. The form that scales is written-first: lay the idea
|
||||
down carefully, let others read and answer in their own time, keep the history. A
|
||||
new colleague, or an agent, can then load the full context of why a decision was
|
||||
made by reading the discussion. That is exactly why the decisions here are
|
||||
[ADRs](adr/README.md) and why the [contribution flow](../CONTRIBUTING.md) is
|
||||
test-first: the reasoning is captured where the next person will look for it.
|
||||
|
||||
The mental image is a city planner. Draw perfectly straight roads and people
|
||||
still cross the grass where it is faster. The job is not to decide for them, but
|
||||
to understand the real paths, the pain points and limits of each team, and pave
|
||||
those. So constraints get checked with the other DevOps, data engineers, and
|
||||
analysts before infrastructure is laid, instead of building roads nobody uses.
|
||||
|
||||
---
|
||||
|
||||
The platform is air-gapped and on-prem by design: nothing leaves the swarm, and
|
||||
nothing joins it at runtime. Start from the [README](../README.md) for the map,
|
||||
or the [ADR index](adr/README.md) for the decisions in full.
|
||||
@@ -0,0 +1,38 @@
|
||||
# ADR-0001: No swarm-wide orchestrator; coordinate through data
|
||||
|
||||
## Status
|
||||
|
||||
Accepted (2026-07-08)
|
||||
|
||||
## Context
|
||||
|
||||
The fleet flies fully autonomously with no internet uplink and only
|
||||
intermittent mesh connectivity between drones. A conventional control plane
|
||||
(Kubernetes across the swarm, a leader election, a central scheduler) assumes
|
||||
stable membership and low-latency links — exactly what a swarm does not have.
|
||||
Partitions are the normal case, not the exception.
|
||||
|
||||
## Options
|
||||
|
||||
| Option | Pros | Cons |
|
||||
| --- | --- | --- |
|
||||
| A — Cluster orchestrator spanning the swarm | Familiar tooling | Assumes stable membership; a partition stalls coordination; single point of failure in the air |
|
||||
| B — Each drone autonomous, coordinating through exchanged data | Survives partitions; no air-side control plane to fail | No global view; behaviour must be derivable from local state |
|
||||
|
||||
## Decision
|
||||
|
||||
Every drone is an autonomous node. There is **no orchestrator spanning the
|
||||
swarm**. Coordination happens by exchanging derived state (see
|
||||
[ADR-0003](ADR-0003-sync-derived-state-only.md)), and each drone makes its own
|
||||
in-flight decisions against its local data window. Orchestration tooling
|
||||
(Kubernetes, Flux) is confined to the **ground** segment
|
||||
([ADR-0006](ADR-0006-iac-boundaries.md)).
|
||||
|
||||
## Consequences
|
||||
|
||||
- The platform degrades gracefully under partition: a drone that loses peers
|
||||
keeps flying and recording.
|
||||
- There is no single "fleet state" to query in the air; health questions are
|
||||
answered from each drone's own store and reconciled on the ground.
|
||||
- Design principle 1 in [`../../README.md`](../../README.md) and the on-board
|
||||
model in [`../02-architecture.md`](../02-architecture.md) follow from this.
|
||||
@@ -0,0 +1,32 @@
|
||||
# ADR-0002: One storage format on every tier — Parquet + DuckDB, Hive layout
|
||||
|
||||
## Status
|
||||
|
||||
Accepted (2026-07-08)
|
||||
|
||||
## Context
|
||||
|
||||
Data lives in three places: on the drone's NVMe in flight (T1), in the ground
|
||||
warehouse after landing (T3), and in the dev/simulation environment. If each
|
||||
tier used a different format or layout, offload would need transformation code,
|
||||
schemas would drift, and a query proven on the bench would not run unchanged on
|
||||
flight data.
|
||||
|
||||
## Decision
|
||||
|
||||
Use **one storage format on every floor**: columnar **Parquet** files in an
|
||||
identical **Hive-partitioned** layout (`dataset=…/flight=…/drone=…/…`), queried
|
||||
with **DuckDB**. Flight offload T1 → T3 is therefore a plain **mirror**
|
||||
(copy/rsync of partition directories), not an ETL step.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Offload is a file operation, testable with a checksum; no format converters
|
||||
to maintain or version.
|
||||
- The same SQL runs against T1, T3, and simulated data — the simulator
|
||||
([`../../simulator/`](../../simulator/)) generates the *real* layout, so tests
|
||||
exercise the production format.
|
||||
- Partitioning choices are load-bearing; changing them is a schema migration.
|
||||
Detailed in [`../03-data-platform.md`](../03-data-platform.md).
|
||||
- DuckDB's single-file, no-server model fits an air-gapped drone with no room
|
||||
for a database daemon.
|
||||
@@ -0,0 +1,33 @@
|
||||
# ADR-0003: Sync derived state only; raw telemetry stays local
|
||||
|
||||
## Status
|
||||
|
||||
Accepted (2026-07-08)
|
||||
|
||||
## Context
|
||||
|
||||
Each drone produces high-rate sensor telemetry and on-board video detections —
|
||||
far more than the mesh can carry. But peers only need enough to coordinate
|
||||
flight: where everyone is, where they are heading, what was detected. Pushing
|
||||
raw feeds across the air would saturate the radio and drain batteries for data
|
||||
nobody reads in flight.
|
||||
|
||||
## Decision
|
||||
|
||||
Only **derived state** crosses the air — position, attitude, detections — as
|
||||
small pub/sub broadcasts (~5 Hz). **Raw telemetry stays on local NVMe** until
|
||||
the drone lands, then travels to the warehouse during offload
|
||||
([ADR-0002](ADR-0002-one-storage-format.md)). The design is **event-driven**:
|
||||
new derived data triggers downstream action through hooks; nothing polls asking
|
||||
"anything new yet?".
|
||||
|
||||
## Consequences
|
||||
|
||||
- Air bandwidth scales with the number of peers and the broadcast rate, not
|
||||
with sensor resolution.
|
||||
- The warehouse is the only place with the full picture; in-flight decisions use
|
||||
the local window plus peer broadcasts.
|
||||
- Broadcast staleness per peer becomes a first-class health signal
|
||||
([`../07-observability.md`](../07-observability.md)).
|
||||
- What syncs and what does not is specified in
|
||||
[`../04-swarm-sync.md`](../04-swarm-sync.md).
|
||||
@@ -0,0 +1,41 @@
|
||||
# ADR-0004: Read-only SQL-over-SSH as the peer query contract; no debug API
|
||||
|
||||
## Status
|
||||
|
||||
Accepted (2026-07-08). Supersedes the earlier sketch of a bespoke query service.
|
||||
|
||||
## Context
|
||||
|
||||
Beyond the 5 Hz broadcast, a peer (or an engineer on the bench) sometimes needs
|
||||
to *ask* a drone a richer question: "show me your detections in the last minute
|
||||
near this position". Inventing a service for this means a new port, a new
|
||||
protocol, an auth layer, and a second code path that only runs during
|
||||
debugging — the classic "works on the bench, untested in flight" trap.
|
||||
|
||||
## Options
|
||||
|
||||
| Option | Pros | Cons |
|
||||
| --- | --- | --- |
|
||||
| A — Custom query microservice + REST API | Flexible | New port/protocol/auth to secure; a debug-only path that never flies |
|
||||
| B — Read-only DuckDB SQL over an SSH forced command | Reuses SSH trust + the query language already on both ends; identical path on bench and in flight | SQL must be gated to read-only |
|
||||
|
||||
## Decision
|
||||
|
||||
Peer data access is **read-only DuckDB SQL executed through an SSH forced
|
||||
command**. SQL is already the query language on both ends
|
||||
([ADR-0002](ADR-0002-one-storage-format.md)), and SSH already carries the trust
|
||||
model ([ADR-0005](ADR-0005-wireguard-beneath-ssh.md)), so no new service, port,
|
||||
or protocol is invented. A **SQL gate** rejects anything that writes
|
||||
(`COPY`, `INSERT`, `ATTACH`, pragmas). There is **no separate debug API**: an
|
||||
engineer debugging on the ground runs the identical query through the identical
|
||||
wrapper, permissions, and output format a peer drone would use.
|
||||
|
||||
## Consequences
|
||||
|
||||
- One access path is built, secured, and tested — "what you test is what
|
||||
flies".
|
||||
- The SQL gate is safety-critical and is covered by unit tests
|
||||
(`simulator/tests/test_sql_gate.py`).
|
||||
- The explorer and the prototype's live mode are *just another read-only
|
||||
consumer* of this same contract ([`../04-swarm-sync.md`](../04-swarm-sync.md)).
|
||||
- Design principles 7 and 8 in [`../../README.md`](../../README.md) restate this.
|
||||
@@ -0,0 +1,40 @@
|
||||
# ADR-0005: WireGuard beneath SSH for the mesh transport
|
||||
|
||||
## Status
|
||||
|
||||
Accepted (2026-07-08)
|
||||
|
||||
## Context
|
||||
|
||||
Peer bulk sync and SQL-over-SSH ([ADR-0004](ADR-0004-sql-over-ssh-contract.md))
|
||||
need an encrypted, authenticated channel across an ad-hoc radio mesh. SSH alone
|
||||
would work at the application layer, but it leaves the drones' listening
|
||||
surface (sshd, any other service port) exposed on the raw mesh network, and it
|
||||
gives no cheap, uniform way to authenticate *the peer* before the application
|
||||
handshake.
|
||||
|
||||
## Options
|
||||
|
||||
| Option | Pros | Cons |
|
||||
| --- | --- | --- |
|
||||
| A — SSH only | One daemon, familiar | Service ports exposed on the raw mesh; per-service auth; no network-layer peer identity |
|
||||
| B — WireGuard tunnel, SSH inside it | Network-layer peer auth via static keys; only the WG port is exposed; SSH speaks over a private, encrypted subnet | Two layers to provision |
|
||||
|
||||
## Decision
|
||||
|
||||
Run **WireGuard as the network layer** and **SSH on top of it**. WireGuard
|
||||
authenticates peers by pre-shared static public keys and presents a private
|
||||
encrypted subnet; SSH forced commands then carry the SQL and bulk-sync contract
|
||||
over that subnet. Keys are issued **per device before deployment** — nothing
|
||||
joins the mesh at runtime (design principle 5).
|
||||
|
||||
## Consequences
|
||||
|
||||
- The only thing exposed on the raw radio is the WireGuard port; sshd and the
|
||||
data services listen only on the WG interface.
|
||||
- Peer identity is established once, at the network layer, and reused by every
|
||||
application above it.
|
||||
- Provisioning must place both WG and SSH keys during host prep — handled by
|
||||
`infra/ansible/drone-provision.yml` ([ADR-0006](ADR-0006-iac-boundaries.md)).
|
||||
- Rationale and the "why not SSH alone" argument live in
|
||||
[`../05-network-security.md`](../05-network-security.md).
|
||||
@@ -0,0 +1,41 @@
|
||||
# ADR-0006: IaC split — Ansible hosts, Terraform ground, Flux ground-only, fleet manifest for drones
|
||||
|
||||
## Status
|
||||
|
||||
Accepted (2026-07-08)
|
||||
|
||||
## Context
|
||||
|
||||
Three very different things need provisioning: (1) drone hosts, which are
|
||||
air-gapped and never reachable by a controller in flight; (2) the ground
|
||||
segment (warehouse, observability, offload), a normal k3s cluster; (3) the
|
||||
simulation environment that mirrors the ground segment locally. Using one tool
|
||||
for all three would force a GitOps controller or a Terraform apply loop onto the
|
||||
drone — impossible for an autonomous, disconnected node
|
||||
([ADR-0001](ADR-0001-no-swarm-orchestrator.md)).
|
||||
|
||||
## Decision
|
||||
|
||||
Split infrastructure ownership by lifecycle:
|
||||
|
||||
| Layer | Tool | Scope |
|
||||
| --- | --- | --- |
|
||||
| Host preparation | **Ansible** | Drone bench prep (keys, forced commands, WireGuard, Compose bundle) and the local k3d sim cluster |
|
||||
| Ground workloads | **Terraform** | k3s/k3d resources: virtual fleet + observability (`sim-env`), warehouse + T1→T3 offload (`ground`) |
|
||||
| Ground continuous delivery | **Flux (GitOps)** | Ground segment **only** — policy labels, dashboard bundles |
|
||||
| Drone delivery | **Fleet release manifest** | A versioned, digest-pinned artifact list; no controller pulls to the drone |
|
||||
|
||||
Drones are delivered by the **fleet release manifest** (one version for the
|
||||
whole fleet, atomic rollback), never by a controller reaching into the air.
|
||||
|
||||
## Consequences
|
||||
|
||||
- No GitOps agent or `terraform apply` ever targets a drone; the air side stays
|
||||
controller-free.
|
||||
- Terraform state is environment-local and kept out of Git
|
||||
([ADR-0008](ADR-0008-runtime-data-paths.md)).
|
||||
- The sim cluster is a faithful miniature: the same Terraform describes it and
|
||||
the ground segment.
|
||||
- Boundaries are documented in
|
||||
[`../06-environments.md`](../06-environments.md#iac-boundaries); delivery in
|
||||
[`../11-cicd-delivery.md`](../11-cicd-delivery.md).
|
||||
@@ -0,0 +1,27 @@
|
||||
# ADR-0007: Run the simulator image as a non-root user
|
||||
|
||||
## Status
|
||||
|
||||
Accepted (2026-07-08)
|
||||
|
||||
## Context
|
||||
|
||||
The Trivy scan in CI flagged **DS-0002**: the simulator image ran as `root`
|
||||
because no `USER` was set. A container writing the data lake as root also
|
||||
produces root-owned Parquet on bind mounts, which then blocks a later non-root
|
||||
process from the same files.
|
||||
|
||||
## Decision
|
||||
|
||||
Create a dedicated unprivileged user **`swarm` (uid/gid 10001)** in the
|
||||
`simulator/Dockerfile` and switch to it with `USER swarm` before the entrypoint.
|
||||
The uid is fixed (not auto-assigned) so host-side ownership of the shared data
|
||||
paths ([ADR-0008](ADR-0008-runtime-data-paths.md)) is deterministic.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Trivy DS-0002 clears; the image follows least-privilege.
|
||||
- Bind-mounted data is owned by `10001:10001`; host prep (`sim-cluster.yml`) and
|
||||
Ansible `chown` the `var/t1` / `var/t3` paths to this uid so k3d pods can write.
|
||||
- Any future service image reusing this data must run as the same uid or share
|
||||
the group.
|
||||
@@ -0,0 +1,40 @@
|
||||
# ADR-0008: Shared `var/t1` / `var/t3` runtime paths; state out of Git
|
||||
|
||||
## Status
|
||||
|
||||
Accepted (2026-07-08)
|
||||
|
||||
## Context
|
||||
|
||||
Compose, the k3d fleet, and the ground offload job all need to read and write
|
||||
the same data lake, so that "go live" in the prototype shows the data the
|
||||
simulator just generated. Early on, Terraform state and the provider cache were
|
||||
accidentally committed (~6 MB of `*.tfstate` and a vendored provider binary),
|
||||
polluting history and risking stale/secret data in Git.
|
||||
|
||||
## Decision
|
||||
|
||||
Standardise two **gitignored** runtime directories at the repo root:
|
||||
|
||||
| Path | Tier | Contents |
|
||||
| --- | --- | --- |
|
||||
| `var/t1/` | T1 live lake | In-flight Parquet from Compose or the k3d fleet |
|
||||
| `var/t3/` | T3 warehouse | Offloaded historical partitions |
|
||||
|
||||
They are created automatically and overridable via
|
||||
`SWARM_T1_DIR` / `SWARM_WAREHOUSE_DATA` / `SWARM_SIM_DATA`. Treat `var/` as
|
||||
**runtime-only** per the ecosystem canonical layout. `.gitignore` excludes
|
||||
`*.tfstate`, `*.tfstate.backup`, and `.terraform/` while **keeping**
|
||||
`.terraform.lock.hcl` (the lock is source, the state is not).
|
||||
|
||||
## Consequences
|
||||
|
||||
- One lake feeds every component; the live prototype reflects real generated
|
||||
data.
|
||||
- Git history carries no machine state or large binaries; `terraform` treats
|
||||
its state as local/remote-backend concern, not versioned source.
|
||||
- Ownership of these paths is fixed to uid 10001
|
||||
([ADR-0007](ADR-0007-non-root-image.md)).
|
||||
- Recovery from earlier state drift is documented in
|
||||
[`../../infra/terraform/README.md`](../../infra/terraform/README.md)
|
||||
(`terraform import`).
|
||||
@@ -0,0 +1,52 @@
|
||||
# ADR-0009: Bound observability lake scans to a flight window and cache
|
||||
|
||||
## Status
|
||||
|
||||
Accepted (2026-07-08)
|
||||
|
||||
## Context
|
||||
|
||||
The k3d sim node was running at ~245% CPU. Three causes compounded:
|
||||
|
||||
1. **Explorer** rebuilt a DuckDB view over the *entire* lake on every HTTP
|
||||
request (~2000 Parquet files, 200 MB+) — roughly 1.5 cores.
|
||||
2. **Exporter** scanned the full lake every 5 s — roughly 0.7 cores.
|
||||
3. **Drones** exited after sealing a flight; Kubernetes restarted each pod,
|
||||
and every restart appended a fresh `flight=` partition tree, so the lake grew
|
||||
without bound and every scan got more expensive.
|
||||
|
||||
The cost is inherent to scanning an append-only lake whose size grows with pod
|
||||
churn, not to any single slow query. Metrics and the live view only ever need
|
||||
recent flights.
|
||||
|
||||
## Decision
|
||||
|
||||
Bound and cache the scans instead of reading the whole lake:
|
||||
|
||||
- **Flight window.** The exporter and explorer scan only the most recent
|
||||
`METRIC_FLIGHT_WINDOW` flights (default 5) via a shared helper
|
||||
(`simulator/monitoring/lake.py`). Older partitions stay on disk but out of the
|
||||
hot path.
|
||||
- **Caching.** Explorer caches DuckDB views (`VIEW_REFRESH_S=30`) and the
|
||||
partition tree (`TREE_CACHE_S=15`); the exporter scans every
|
||||
`SCAN_INTERVAL_S=30` instead of 5 s.
|
||||
- **Stop restart churn.** Drones set `KEEP_ALIVE=1` and idle after sealing
|
||||
instead of exiting, so no new flight tree is created per restart. In k3d,
|
||||
`IMU_HZ=20` and resource limits cap per-drone load; the live prototype polls
|
||||
every 2 s.
|
||||
- **Self-observability.** Add node-exporter, a
|
||||
`swarm_exporter_scan_duration_seconds` metric, and a **Swarm Platform**
|
||||
Grafana dashboard (node CPU/memory + scan health) alongside the existing fleet
|
||||
dashboard.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Steady state dropped to explorer ~29 m, exporter ~16 m CPU; the k3d node to
|
||||
~56% (from ~245%).
|
||||
- Metrics reflect recent flights only; long-horizon analysis is a warehouse
|
||||
(T3) query, not an exporter concern — consistent with
|
||||
[ADR-0003](ADR-0003-sync-derived-state-only.md).
|
||||
- Old flight partitions accumulate on disk; pruning them is a follow-up
|
||||
(tracked in [`../09-open-questions.md`](../09-open-questions.md)).
|
||||
- Observability design is documented in
|
||||
[`../07-observability.md`](../07-observability.md).
|
||||
@@ -0,0 +1,40 @@
|
||||
# Architecture Decision Records
|
||||
|
||||
This directory records the significant decisions behind Swarm House, in the
|
||||
order they were made. Each record is immutable once **Accepted** — a later
|
||||
decision that changes course gets a new number and supersedes the old one
|
||||
rather than editing it.
|
||||
|
||||
## ADR vs ASR
|
||||
|
||||
| Kind | What it captures | Where it lives |
|
||||
| --- | --- | --- |
|
||||
| **ADR** (Architecture Decision Record) | A point-in-time decision for *this* repository, with the options considered and their consequences | Here, in `docs/adr/` |
|
||||
| **ASR** (Architecture Standard Record) | A standing, cross-repository policy every project must obey (Go-first, secrets, layout) | The ecosystem `inventar` repo, not here |
|
||||
|
||||
The rules in [`../../AGENTS.md`](../../AGENTS.md) are this repo's local standing
|
||||
constraints; anything ecosystem-wide belongs in `inventar` as an ASR. Design
|
||||
decisions specific to the platform are ADRs and belong here.
|
||||
|
||||
## Index
|
||||
|
||||
| ADR | Decision | Status |
|
||||
| --- | --- | --- |
|
||||
| [ADR-0001](ADR-0001-no-swarm-orchestrator.md) | No swarm-wide orchestrator; coordinate through data | Accepted |
|
||||
| [ADR-0002](ADR-0002-one-storage-format.md) | One storage format on every tier — Parquet + DuckDB, Hive layout | Accepted |
|
||||
| [ADR-0003](ADR-0003-sync-derived-state-only.md) | Sync derived state only; raw telemetry stays local | Accepted |
|
||||
| [ADR-0004](ADR-0004-sql-over-ssh-contract.md) | Read-only SQL-over-SSH as the peer query contract; no debug API | Accepted |
|
||||
| [ADR-0005](ADR-0005-wireguard-beneath-ssh.md) | WireGuard beneath SSH for the mesh transport | Accepted |
|
||||
| [ADR-0006](ADR-0006-iac-boundaries.md) | IaC split: Ansible hosts, Terraform ground, Flux ground-only, fleet manifest for drones | Accepted |
|
||||
| [ADR-0007](ADR-0007-non-root-image.md) | Run the simulator image as a non-root user | Accepted |
|
||||
| [ADR-0008](ADR-0008-runtime-data-paths.md) | Shared `var/t1` / `var/t3` runtime paths; state out of Git | Accepted |
|
||||
| [ADR-0009](ADR-0009-bounded-lake-scans.md) | Bound observability lake scans to a flight window and cache | Accepted |
|
||||
|
||||
## Writing a new ADR
|
||||
|
||||
1. Copy [`TEMPLATE.md`](TEMPLATE.md) to `ADR-NNNN-short-slug.md` (next free number).
|
||||
2. Fill in Context, Options (if more than one was real), Decision, Consequences.
|
||||
3. Add a row to the index above.
|
||||
4. Cross-link the ADR from the design doc it affects (and vice versa).
|
||||
|
||||
Keep the tone laconic: state the decision and why, skip the narrative.
|
||||
@@ -0,0 +1,29 @@
|
||||
# ADR-NNNN: Short decision title
|
||||
|
||||
## Status
|
||||
|
||||
Proposed | Accepted | Superseded by ADR-XXXX
|
||||
|
||||
## Context
|
||||
|
||||
What forces are in play? What problem or constraint triggered the decision?
|
||||
Keep it to the facts that actually shaped the choice.
|
||||
|
||||
## Options
|
||||
|
||||
| Option | Pros | Cons |
|
||||
| --- | --- | --- |
|
||||
| A — … | … | … |
|
||||
| B — … | … | … |
|
||||
|
||||
(Drop this section when only one option was ever real.)
|
||||
|
||||
## Decision
|
||||
|
||||
The choice, stated plainly. One paragraph.
|
||||
|
||||
## Consequences
|
||||
|
||||
- What becomes easier.
|
||||
- What becomes harder or is now forbidden.
|
||||
- Follow-ups, links to affected docs.
|
||||
@@ -29,12 +29,16 @@
|
||||
path: "{{ data_dir }}"
|
||||
state: directory
|
||||
mode: "0777"
|
||||
owner: "10001"
|
||||
group: "10001"
|
||||
|
||||
- name: Create warehouse directory (T3 host path inside the k3d node)
|
||||
ansible.builtin.file:
|
||||
path: "{{ warehouse_dir }}"
|
||||
state: directory
|
||||
mode: "0777"
|
||||
owner: "10001"
|
||||
group: "10001"
|
||||
|
||||
- name: List existing clusters
|
||||
ansible.builtin.command: k3d cluster list -o json
|
||||
|
||||
@@ -63,6 +63,14 @@ resource "kubernetes_stateful_set" "drone" {
|
||||
name = "DATA_DIR"
|
||||
value = "/data"
|
||||
}
|
||||
env {
|
||||
name = "KEEP_ALIVE"
|
||||
value = "1"
|
||||
}
|
||||
env {
|
||||
name = "IMU_HZ"
|
||||
value = "20"
|
||||
}
|
||||
|
||||
volume_mount {
|
||||
name = "lake"
|
||||
@@ -118,9 +126,21 @@ resource "kubernetes_deployment" "exporter" {
|
||||
name = "EXPORTER_PORT"
|
||||
value = "9105"
|
||||
}
|
||||
env {
|
||||
name = "SCAN_INTERVAL_S"
|
||||
value = "30"
|
||||
}
|
||||
env {
|
||||
name = "METRIC_FLIGHT_WINDOW"
|
||||
value = "5"
|
||||
}
|
||||
port {
|
||||
container_port = 9105
|
||||
}
|
||||
resources {
|
||||
requests = { cpu = "50m", memory = "64Mi" }
|
||||
limits = { cpu = "500m", memory = "256Mi" }
|
||||
}
|
||||
volume_mount {
|
||||
name = "lake"
|
||||
mount_path = "/data"
|
||||
@@ -182,9 +202,25 @@ resource "kubernetes_deployment" "explorer" {
|
||||
name = "EXPLORER_PORT"
|
||||
value = "8088"
|
||||
}
|
||||
env {
|
||||
name = "VIEW_REFRESH_S"
|
||||
value = "30"
|
||||
}
|
||||
env {
|
||||
name = "TREE_CACHE_S"
|
||||
value = "15"
|
||||
}
|
||||
env {
|
||||
name = "METRIC_FLIGHT_WINDOW"
|
||||
value = "5"
|
||||
}
|
||||
port {
|
||||
container_port = 8088
|
||||
}
|
||||
resources {
|
||||
requests = { cpu = "50m", memory = "64Mi" }
|
||||
limits = { cpu = "750m", memory = "256Mi" }
|
||||
}
|
||||
volume_mount {
|
||||
name = "lake"
|
||||
mount_path = "/data"
|
||||
@@ -226,11 +262,17 @@ resource "kubernetes_config_map" "prometheus" {
|
||||
data = {
|
||||
"prometheus.yml" = <<-EOT
|
||||
global:
|
||||
scrape_interval: 5s
|
||||
scrape_interval: 15s
|
||||
scrape_configs:
|
||||
- job_name: swarm
|
||||
static_configs:
|
||||
- targets: ["exporter:9105"]
|
||||
- job_name: explorer
|
||||
static_configs:
|
||||
- targets: ["explorer:8088"]
|
||||
- job_name: node
|
||||
static_configs:
|
||||
- targets: ["node-exporter:9100"]
|
||||
EOT
|
||||
}
|
||||
}
|
||||
@@ -298,8 +340,12 @@ resource "kubernetes_config_map" "grafana_provisioning" {
|
||||
data = {
|
||||
"datasource.yml" = <<-EOT
|
||||
apiVersion: 1
|
||||
deleteDatasources:
|
||||
- name: Prometheus
|
||||
orgId: 1
|
||||
datasources:
|
||||
- name: Prometheus
|
||||
uid: swarm-prom
|
||||
type: prometheus
|
||||
access: proxy
|
||||
url: http://prometheus:9090
|
||||
@@ -323,7 +369,91 @@ resource "kubernetes_config_map" "grafana_dashboard" {
|
||||
namespace = kubernetes_namespace.swarm.metadata[0].name
|
||||
}
|
||||
data = {
|
||||
"swarm.json" = file("${path.module}/../../../simulator/monitoring/grafana/dashboards/swarm.json")
|
||||
"swarm.json" = file("${path.module}/../../../simulator/monitoring/grafana/dashboards/swarm.json")
|
||||
"swarm-platform.json" = file("${path.module}/../../../simulator/monitoring/grafana/dashboards/swarm-platform.json")
|
||||
}
|
||||
}
|
||||
|
||||
# Host metrics for the k3d node (CPU / memory on the platform dashboard)
|
||||
resource "kubernetes_deployment" "node_exporter" {
|
||||
metadata {
|
||||
name = "node-exporter"
|
||||
namespace = kubernetes_namespace.swarm.metadata[0].name
|
||||
labels = local.labels
|
||||
}
|
||||
|
||||
spec {
|
||||
replicas = 1
|
||||
selector {
|
||||
match_labels = { app = "node-exporter" }
|
||||
}
|
||||
template {
|
||||
metadata {
|
||||
labels = merge(local.labels, { app = "node-exporter" })
|
||||
}
|
||||
spec {
|
||||
host_network = true
|
||||
host_pid = true
|
||||
container {
|
||||
name = "node-exporter"
|
||||
image = "prom/node-exporter:v1.8.2"
|
||||
args = [
|
||||
"--path.procfs=/host/proc",
|
||||
"--path.sysfs=/host/sys",
|
||||
"--path.rootfs=/host/root",
|
||||
"--collector.filesystem.mount-points-exclude=^/(sys|proc|dev|host|etc)($$|/)",
|
||||
]
|
||||
port {
|
||||
container_port = 9100
|
||||
}
|
||||
volume_mount {
|
||||
name = "proc"
|
||||
mount_path = "/host/proc"
|
||||
read_only = true
|
||||
}
|
||||
volume_mount {
|
||||
name = "sys"
|
||||
mount_path = "/host/sys"
|
||||
read_only = true
|
||||
}
|
||||
volume_mount {
|
||||
name = "root"
|
||||
mount_path = "/host/root"
|
||||
read_only = true
|
||||
}
|
||||
resources {
|
||||
requests = { cpu = "20m", memory = "32Mi" }
|
||||
limits = { cpu = "200m", memory = "64Mi" }
|
||||
}
|
||||
}
|
||||
volume {
|
||||
name = "proc"
|
||||
host_path { path = "/proc" }
|
||||
}
|
||||
volume {
|
||||
name = "sys"
|
||||
host_path { path = "/sys" }
|
||||
}
|
||||
volume {
|
||||
name = "root"
|
||||
host_path { path = "/" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
resource "kubernetes_service" "node_exporter" {
|
||||
metadata {
|
||||
name = "node-exporter"
|
||||
namespace = kubernetes_namespace.swarm.metadata[0].name
|
||||
}
|
||||
spec {
|
||||
selector = { app = "node-exporter" }
|
||||
port {
|
||||
port = 9100
|
||||
target_port = 9100
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
A 2D top-down view of the swarm data plane from [04 — Swarm sync](../docs/04-swarm-sync.md): drones patrol an area with static and mobile obstacles, exchange 5 Hz pose broadcasts (blue link flashes), and run opportunistic bulk sync of sealed partitions (green links with live rate and cumulative up/down counters).
|
||||
|
||||
Optional **3d** checkbox in the header switches to a Three.js perimeter patrol scene (WebGPU/WebGL, shader terrain, transit escort, search panel). Off by default.
|
||||
|
||||
No backend — a pure client-side simulation of the same behavioral model the Python simulator implements over real Parquet and UDP.
|
||||
|
||||
## Run
|
||||
@@ -17,3 +19,4 @@ npm run dev # http://localhost:5173
|
||||
- **Busy links turn green** and show current rate plus total transferred (`↑` / `↓`) — the bandwidth budget from the sync design made visible.
|
||||
- **Per-drone label**: id, current Wi-Fi channel (hopping), battery.
|
||||
- Controls: drone count, simulation speed, pause.
|
||||
- **3d** — perimeter patrol, altitude, detection beams, 4-drone escort sphere
|
||||
|
||||
Generated
+66
-1
@@ -9,11 +9,13 @@
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1"
|
||||
"react-dom": "^18.3.1",
|
||||
"three": "^0.172.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.3.12",
|
||||
"@types/react-dom": "^18.3.1",
|
||||
"@types/three": "^0.172.0",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"typescript": "^5.6.3",
|
||||
"vite": "^6.0.3"
|
||||
@@ -1150,6 +1152,13 @@
|
||||
"win32"
|
||||
]
|
||||
},
|
||||
"node_modules/@tweenjs/tween.js": {
|
||||
"version": "23.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-23.1.3.tgz",
|
||||
"integrity": "sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/babel__core": {
|
||||
"version": "7.20.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
|
||||
@@ -1230,6 +1239,35 @@
|
||||
"@types/react": "^18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/stats.js": {
|
||||
"version": "0.17.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/stats.js/-/stats.js-0.17.4.tgz",
|
||||
"integrity": "sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/three": {
|
||||
"version": "0.172.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/three/-/three-0.172.0.tgz",
|
||||
"integrity": "sha512-LrUtP3FEG26Zg5WiF0nbg8VoXiKokBLTcqM2iLvM9vzcfEiYmmBAPGdBgV0OYx9fvWlY3R/3ERTZcD9X5sc0NA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@tweenjs/tween.js": "~23.1.3",
|
||||
"@types/stats.js": "*",
|
||||
"@types/webxr": "*",
|
||||
"@webgpu/types": "*",
|
||||
"fflate": "~0.8.2",
|
||||
"meshoptimizer": "~0.18.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/webxr": {
|
||||
"version": "0.5.24",
|
||||
"resolved": "https://registry.npmjs.org/@types/webxr/-/webxr-0.5.24.tgz",
|
||||
"integrity": "sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@vitejs/plugin-react": {
|
||||
"version": "4.7.0",
|
||||
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz",
|
||||
@@ -1251,6 +1289,13 @@
|
||||
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@webgpu/types": {
|
||||
"version": "0.1.71",
|
||||
"resolved": "https://registry.npmjs.org/@webgpu/types/-/types-0.1.71.tgz",
|
||||
"integrity": "sha512-mMy8/ODcKhab808co15eW+yN+HgXoQxRQHTiBV9Mrvl1r0ufnid7YOcI+gi4eUWSWl9ezD6TW2KXccrL8HCh2A==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/baseline-browser-mapping": {
|
||||
"version": "2.10.42",
|
||||
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.42.tgz",
|
||||
@@ -1428,6 +1473,13 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/fflate": {
|
||||
"version": "0.8.3",
|
||||
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz",
|
||||
"integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fsevents": {
|
||||
"version": "2.3.3",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||
@@ -1507,6 +1559,13 @@
|
||||
"yallist": "^3.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/meshoptimizer": {
|
||||
"version": "0.18.1",
|
||||
"resolved": "https://registry.npmjs.org/meshoptimizer/-/meshoptimizer-0.18.1.tgz",
|
||||
"integrity": "sha512-ZhoIoL7TNV4s5B6+rx5mC//fw8/POGyNxS/DZyCJeiZ12ScLfVwRE/GfsxwiTkMYYD5DmK2/JXnEVXqL4rF+Sw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/ms": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
@@ -1701,6 +1760,12 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/three": {
|
||||
"version": "0.172.0",
|
||||
"resolved": "https://registry.npmjs.org/three/-/three-0.172.0.tgz",
|
||||
"integrity": "sha512-6HMgMlzU97MsV7D/tY8Va38b83kz8YJX+BefKjspMNAv0Vx6dxMogHOrnRl/sbMIs3BPUKijPqDqJ/+UwJbIow==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tinyglobby": {
|
||||
"version": "0.2.17",
|
||||
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
|
||||
|
||||
@@ -10,11 +10,13 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1"
|
||||
"react-dom": "^18.3.1",
|
||||
"three": "^0.172.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.3.12",
|
||||
"@types/react-dom": "^18.3.1",
|
||||
"@types/three": "^0.172.0",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"typescript": "^5.6.3",
|
||||
"vite": "^6.0.3"
|
||||
|
||||
+729
-105
@@ -1,23 +1,28 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { DEFAULT_EXPLORER_URL, fetchLivePoses, toLiveWorld } from "./live";
|
||||
import { searchWeb, type SearchHit } from "./search";
|
||||
import {
|
||||
AREA,
|
||||
MAX_ASPECT,
|
||||
SWARM_ALGORITHM_LABELS,
|
||||
areaWidth,
|
||||
fmtBytes,
|
||||
houseRoadNetwork,
|
||||
makeWorld,
|
||||
seed,
|
||||
tick,
|
||||
type RouteMode,
|
||||
type SwarmAlgorithm,
|
||||
type World,
|
||||
} from "./sim";
|
||||
import { SwarmView3D } from "./SwarmView3D";
|
||||
import type { RenderBackend } from "./SwarmScene";
|
||||
|
||||
const VIEW = 1000;
|
||||
const PANEL_W = 320;
|
||||
const HEADER_H = 70;
|
||||
const LIVE_POLL_MS = 2000;
|
||||
|
||||
// Patrol field aspect ratio for the current window: wide monitors get a
|
||||
// wider field instead of empty side margins. Quantized to 0.25 steps so
|
||||
// minor resizes don't rebuild the world.
|
||||
function displayAspect(): number {
|
||||
const w = Math.max(320, window.innerWidth - PANEL_W);
|
||||
const h = Math.max(320, window.innerHeight - HEADER_H);
|
||||
@@ -25,27 +30,57 @@ function displayAspect(): number {
|
||||
return Math.max(1, Math.min(MAX_ASPECT, q));
|
||||
}
|
||||
|
||||
const LIVE_POLL_MS = 1000;
|
||||
function routeModeFor(_view3d: boolean): RouteMode {
|
||||
return "perimeter";
|
||||
}
|
||||
|
||||
export default function App(): JSX.Element {
|
||||
const [view3d, setView3d] = useState(false);
|
||||
const [droneCount, setDroneCount] = useState(8);
|
||||
const [swarmAlgo, setSwarmAlgo] = useState<SwarmAlgorithm>("boids");
|
||||
const [speedup, setSpeedup] = useState(1);
|
||||
const [paused, setPaused] = useState(false);
|
||||
const [aspect, setAspect] = useState(displayAspect);
|
||||
const [mode, setMode] = useState<"sim" | "live">("sim");
|
||||
const [liveError, setLiveError] = useState<string | null>(null);
|
||||
const [world, setWorld] = useState<World>(() => {
|
||||
const [backend, setBackend] = useState<RenderBackend>("webgl");
|
||||
const [searchQ, setSearchQ] = useState("drone swarm perimeter patrol");
|
||||
const [searchBusy, setSearchBusy] = useState(false);
|
||||
const [searchErr, setSearchErr] = useState<string | null>(null);
|
||||
const [hits, setHits] = useState<SearchHit[]>([]);
|
||||
const [, bump] = useState(0);
|
||||
|
||||
const worldRef = useRef<World | null>(null);
|
||||
const swarmAlgoRef = useRef(swarmAlgo);
|
||||
swarmAlgoRef.current = swarmAlgo;
|
||||
if (worldRef.current === null) {
|
||||
seed(42);
|
||||
return makeWorld(8, displayAspect());
|
||||
});
|
||||
worldRef.current = { ...makeWorld(8, displayAspect(), "perimeter"), swarmAlgo };
|
||||
}
|
||||
|
||||
const raf = useRef(0);
|
||||
const last = useRef(performance.now());
|
||||
const liveWorld = useRef<World | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const resetWorld = useCallback((): void => {
|
||||
seed(42);
|
||||
setWorld(makeWorld(droneCount, aspect));
|
||||
}, [droneCount, aspect]);
|
||||
worldRef.current = {
|
||||
...makeWorld(droneCount, aspect, routeModeFor(view3d)),
|
||||
swarmAlgo: swarmAlgoRef.current,
|
||||
};
|
||||
bump((n) => n + 1);
|
||||
}, [droneCount, aspect, view3d]);
|
||||
|
||||
useEffect(() => {
|
||||
if (worldRef.current) {
|
||||
worldRef.current = { ...worldRef.current, swarmAlgo };
|
||||
bump((n) => n + 1);
|
||||
}
|
||||
}, [swarmAlgo]);
|
||||
|
||||
useEffect(() => {
|
||||
resetWorld();
|
||||
}, [resetWorld]);
|
||||
|
||||
useEffect(() => {
|
||||
let timer = 0;
|
||||
@@ -61,20 +96,21 @@ export default function App(): JSX.Element {
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (mode !== "sim") return;
|
||||
if (view3d || mode !== "sim") return;
|
||||
const loop = (now: number): void => {
|
||||
const dt = Math.min(0.05, (now - last.current) / 1000);
|
||||
last.current = now;
|
||||
if (!paused && dt > 0) {
|
||||
setWorld((w) => tick(w, dt * speedup));
|
||||
worldRef.current = tick(worldRef.current!, dt * speedup);
|
||||
bump((n) => n + 1);
|
||||
}
|
||||
raf.current = requestAnimationFrame(loop);
|
||||
};
|
||||
last.current = performance.now();
|
||||
raf.current = requestAnimationFrame(loop);
|
||||
return () => cancelAnimationFrame(raf.current);
|
||||
}, [paused, speedup, mode]);
|
||||
}, [view3d, paused, speedup, mode]);
|
||||
|
||||
// Live mode: poll the explorer's read-only SQL API for real poses
|
||||
useEffect(() => {
|
||||
if (mode !== "live") return;
|
||||
let cancelled = false;
|
||||
@@ -84,7 +120,11 @@ export default function App(): JSX.Element {
|
||||
if (cancelled) return;
|
||||
setLiveError(poses.length === 0 ? "no drone data in the lake yet" : null);
|
||||
liveWorld.current = toLiveWorld(poses, liveWorld.current, LIVE_POLL_MS / 1000);
|
||||
setWorld(liveWorld.current);
|
||||
if (view3d) {
|
||||
liveWorld.current = { ...liveWorld.current, routeMode: "perimeter" };
|
||||
}
|
||||
worldRef.current = liveWorld.current;
|
||||
bump((n) => n + 1);
|
||||
} catch (err) {
|
||||
if (!cancelled) setLiveError(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
@@ -95,34 +135,80 @@ export default function App(): JSX.Element {
|
||||
cancelled = true;
|
||||
window.clearInterval(timer);
|
||||
};
|
||||
}, [mode]);
|
||||
}, [mode, view3d]);
|
||||
|
||||
useEffect(() => {
|
||||
if (mode === "sim") {
|
||||
liveWorld.current = null;
|
||||
setLiveError(null);
|
||||
seed(42);
|
||||
setWorld(makeWorld(droneCount, aspect));
|
||||
resetWorld();
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- reset only on mode change
|
||||
}, [mode]);
|
||||
}, [mode, resetWorld]);
|
||||
|
||||
const simEnabled = useCallback(() => mode === "sim" && !paused, [mode, paused]);
|
||||
|
||||
const onStep3d = useCallback(
|
||||
(dt: number) => {
|
||||
worldRef.current = tick(worldRef.current!, dt * speedup);
|
||||
},
|
||||
[speedup],
|
||||
);
|
||||
|
||||
const onUiPulse = useCallback(() => bump((n) => n + 1), []);
|
||||
|
||||
const runSearch = async (): Promise<void> => {
|
||||
setSearchBusy(true);
|
||||
setSearchErr(null);
|
||||
try {
|
||||
const res = await searchWeb(searchQ);
|
||||
setHits(res.results);
|
||||
} catch (err) {
|
||||
setSearchErr(err instanceof Error ? err.message : String(err));
|
||||
setHits([]);
|
||||
} finally {
|
||||
setSearchBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!view3d) return;
|
||||
void runSearch();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [view3d]);
|
||||
|
||||
const world = worldRef.current!;
|
||||
const activeLinks = useMemo(
|
||||
() => [...world.links.values()].sort((a, b) => b.rate - a.rate),
|
||||
[world.links],
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[world.links, world.t],
|
||||
);
|
||||
const busiest = activeLinks.slice(0, view3d ? 5 : 6);
|
||||
const roads = useMemo(
|
||||
() => houseRoadNetwork(world.obstacles),
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[world.obstacles, aspect],
|
||||
);
|
||||
const busiest = activeLinks.slice(0, 6);
|
||||
|
||||
return (
|
||||
<div style={styles.shell}>
|
||||
<div style={{ ...styles.shell, ...(view3d ? styles.shell3d : {}) }}>
|
||||
<header style={styles.header}>
|
||||
<div>
|
||||
<div style={styles.title}>SWARM HOUSE</div>
|
||||
<div style={styles.subtitle}>
|
||||
swarm data plane — pose broadcast + opportunistic bulk sync
|
||||
{view3d
|
||||
? `perimeter patrol — ${backend.toUpperCase()} renderer · shader ground grid`
|
||||
: "perimeter patrol — even spread on observation loop"}
|
||||
</div>
|
||||
</div>
|
||||
<div style={styles.controls}>
|
||||
<label style={styles.checkLabel} title="Three.js perimeter patrol view">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={view3d}
|
||||
onChange={(e) => setView3d(e.target.checked)}
|
||||
/>
|
||||
3d
|
||||
</label>
|
||||
<button
|
||||
style={{ ...styles.button, ...(mode === "live" ? styles.buttonActive : {}) }}
|
||||
onClick={() => setMode((m) => (m === "sim" ? "live" : "sim"))}
|
||||
@@ -137,6 +223,23 @@ export default function App(): JSX.Element {
|
||||
)}
|
||||
{mode === "sim" && (
|
||||
<>
|
||||
<label style={styles.label}>
|
||||
swarm
|
||||
<select
|
||||
style={styles.select}
|
||||
value={swarmAlgo}
|
||||
onChange={(e) => setSwarmAlgo(e.target.value as SwarmAlgorithm)}
|
||||
title="Natural swarm algorithm for perimeter patrol"
|
||||
>
|
||||
{(Object.entries(SWARM_ALGORITHM_LABELS) as [SwarmAlgorithm, string][]).map(
|
||||
([key, label]) => (
|
||||
<option key={key} value={key}>
|
||||
{label}
|
||||
</option>
|
||||
),
|
||||
)}
|
||||
</select>
|
||||
</label>
|
||||
<label style={styles.label}>
|
||||
drones
|
||||
<input
|
||||
@@ -169,90 +272,165 @@ export default function App(): JSX.Element {
|
||||
</header>
|
||||
|
||||
<div style={styles.main}>
|
||||
<svg
|
||||
viewBox={`0 0 ${sx(areaWidth())} ${VIEW}`}
|
||||
style={styles.canvas}
|
||||
preserveAspectRatio="xMidYMid meet"
|
||||
>
|
||||
<defs>
|
||||
<radialGradient id="ground" cx="50%" cy="50%" r="75%">
|
||||
<stop offset="0%" stopColor="#101826" />
|
||||
<stop offset="100%" stopColor="#0a0f18" />
|
||||
</radialGradient>
|
||||
</defs>
|
||||
<rect width={sx(areaWidth())} height={VIEW} fill="url(#ground)" />
|
||||
{gridLines()}
|
||||
{view3d ? (
|
||||
<SwarmView3D
|
||||
worldRef={worldRef as React.MutableRefObject<World>}
|
||||
simEnabled={simEnabled}
|
||||
onStep={onStep3d}
|
||||
onUiPulse={onUiPulse}
|
||||
onBackend={setBackend}
|
||||
/>
|
||||
) : (
|
||||
<svg
|
||||
viewBox={`0 0 ${sx(areaWidth())} ${VIEW}`}
|
||||
style={styles.canvas2d}
|
||||
preserveAspectRatio="xMidYMid meet"
|
||||
>
|
||||
<defs>
|
||||
<radialGradient id="ground" cx="50%" cy="50%" r="75%">
|
||||
<stop offset="0%" stopColor="#101826" />
|
||||
<stop offset="100%" stopColor="#0a0f18" />
|
||||
</radialGradient>
|
||||
<filter id="tronGlow" x="-50%" y="-50%" width="200%" height="200%">
|
||||
<feGaussianBlur stdDeviation="2.8" result="blur" />
|
||||
<feMerge>
|
||||
<feMergeNode in="blur" />
|
||||
<feMergeNode in="SourceGraphic" />
|
||||
</feMerge>
|
||||
</filter>
|
||||
<filter id="ufoGlow" x="-80%" y="-80%" width="260%" height="260%">
|
||||
<feGaussianBlur stdDeviation="3.5" result="blur" />
|
||||
<feMerge>
|
||||
<feMergeNode in="blur" />
|
||||
<feMergeNode in="SourceGraphic" />
|
||||
</feMerge>
|
||||
</filter>
|
||||
</defs>
|
||||
<rect width={sx(areaWidth())} height={VIEW} fill="url(#ground)" />
|
||||
{gridLines()}
|
||||
{world.basePad && (
|
||||
<LaunchPad pad={world.basePad} t={world.t} queue={world.landingQueue?.length ?? 0} />
|
||||
)}
|
||||
<TronRoads segments={roads} t={world.t} />
|
||||
|
||||
{/* Mesh links */}
|
||||
{activeLinks.map((l) => {
|
||||
const a = world.drones[l.a];
|
||||
const b = world.drones[l.b];
|
||||
if (!a || !b) return null;
|
||||
const mx = (a.pos.x + b.pos.x) / 2;
|
||||
const my = (a.pos.y + b.pos.y) / 2;
|
||||
const hot = l.rate > 20_000;
|
||||
return (
|
||||
<g key={l.key}>
|
||||
<line
|
||||
x1={sx(a.pos.x)}
|
||||
y1={sy(a.pos.y)}
|
||||
x2={sx(b.pos.x)}
|
||||
y2={sy(b.pos.y)}
|
||||
stroke={hot ? "#39d98a" : "#3d7bd9"}
|
||||
strokeWidth={hot ? 2.2 : 1}
|
||||
opacity={0.25 + l.flash * 0.75}
|
||||
{activeLinks.map((l) => {
|
||||
const a = world.drones[l.a];
|
||||
const b = world.drones[l.b];
|
||||
if (!a || !b) return null;
|
||||
const mx = (a.pos.x + b.pos.x) / 2;
|
||||
const my = (a.pos.y + b.pos.y) / 2;
|
||||
const hot = l.rate > 20_000;
|
||||
return (
|
||||
<g key={l.key}>
|
||||
<line
|
||||
x1={sx(a.pos.x)}
|
||||
y1={sy(a.pos.y)}
|
||||
x2={sx(b.pos.x)}
|
||||
y2={sy(b.pos.y)}
|
||||
stroke={hot ? "#39d98a" : "#3d7bd9"}
|
||||
strokeWidth={hot ? 2.2 : 1}
|
||||
opacity={0.25 + l.flash * 0.75}
|
||||
/>
|
||||
{hot && (
|
||||
<text x={sx(mx)} y={sy(my) - 6} style={styles.linkLabel as never}>
|
||||
{fmtBytes(l.rate)}/s · Σ{fmtBytes(l.totalUp + l.totalDown)}
|
||||
</text>
|
||||
)}
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
|
||||
{world.obstacles.map((ob) =>
|
||||
ob.moving ? (
|
||||
<TransitUfo
|
||||
key={ob.id}
|
||||
x={sx(ob.pos.x)}
|
||||
y={sy(ob.pos.y)}
|
||||
r={(ob.radius / AREA) * VIEW * 0.95}
|
||||
heading={Math.atan2(ob.vel.y, ob.vel.x)}
|
||||
/>
|
||||
{hot && (
|
||||
<text x={sx(mx)} y={sy(my) - 6} style={styles.linkLabel as never}>
|
||||
{fmtBytes(l.rate)}/s · Σ{fmtBytes(l.totalUp + l.totalDown)}
|
||||
</text>
|
||||
)}
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
) : (
|
||||
<StaticHouse
|
||||
key={ob.id}
|
||||
id={ob.id}
|
||||
x={sx(ob.pos.x)}
|
||||
y={sy(ob.pos.y)}
|
||||
r={(ob.radius / AREA) * VIEW * 1.15}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
|
||||
{/* Obstacles */}
|
||||
{world.obstacles.map((ob) => (
|
||||
<g key={ob.id}>
|
||||
<circle
|
||||
cx={sx(ob.pos.x)}
|
||||
cy={sy(ob.pos.y)}
|
||||
r={(ob.radius / AREA) * VIEW}
|
||||
fill={ob.moving ? "#5a2a35" : "#2a3245"}
|
||||
stroke={ob.moving ? "#c05a6a" : "#46536e"}
|
||||
strokeWidth={1.5}
|
||||
opacity={0.85}
|
||||
/>
|
||||
{ob.moving && (
|
||||
<text x={sx(ob.pos.x)} y={sy(ob.pos.y) + 4} style={styles.obLabel as never}>
|
||||
transit
|
||||
{world.drones.map((d) => (
|
||||
<g
|
||||
key={d.id}
|
||||
transform={`translate(${sx(d.pos.x)} ${sy(d.pos.y)}) rotate(${(d.heading * 180) / Math.PI + 90})`}
|
||||
opacity={d.phase === "landed" ? 0.85 : 1}
|
||||
>
|
||||
<polygon
|
||||
points="0,-11 7,9 0,5 -7,9"
|
||||
fill={d.phase === "landed" ? "#a8c4e8" : "#e8ecf4"}
|
||||
stroke={d.phase === "return" ? "#ffb347" : "#7ea0d0"}
|
||||
strokeWidth={1}
|
||||
/>
|
||||
<text
|
||||
y={24}
|
||||
style={styles.droneLabel as never}
|
||||
transform={`rotate(${-((d.heading * 180) / Math.PI + 90)})`}
|
||||
>
|
||||
{d.name ?? `dr-${String(d.id + 1).padStart(2, "0")} · ch${d.channel}`} ·{" "}
|
||||
{d.battery.toFixed(0)}%
|
||||
{d.phase === "landed" && d.offloadProgress !== undefined
|
||||
? ` · ↑${(d.offloadProgress * 100).toFixed(0)}%`
|
||||
: ""}
|
||||
{(d.dataBuffer ?? 0) > 0 && d.phase === "patrol"
|
||||
? ` · ${fmtBytes(d.dataBuffer ?? 0)}`
|
||||
: ""}
|
||||
</text>
|
||||
)}
|
||||
</g>
|
||||
))}
|
||||
|
||||
{/* Drones */}
|
||||
{world.drones.map((d) => (
|
||||
<g
|
||||
key={d.id}
|
||||
transform={`translate(${sx(d.pos.x)} ${sy(d.pos.y)}) rotate(${(d.heading * 180) / Math.PI + 90})`}
|
||||
>
|
||||
<polygon points="0,-11 7,9 0,5 -7,9" fill="#e8ecf4" stroke="#7ea0d0" strokeWidth={1} />
|
||||
<text y={24} style={styles.droneLabel as never} transform={`rotate(${-((d.heading * 180) / Math.PI + 90)})`}>
|
||||
{d.name ?? `dr-${String(d.id + 1).padStart(2, "0")} · ch${d.channel}`} · {d.battery.toFixed(0)}%
|
||||
</text>
|
||||
</g>
|
||||
))}
|
||||
</svg>
|
||||
</g>
|
||||
))}
|
||||
</svg>
|
||||
)}
|
||||
|
||||
<aside style={styles.panel}>
|
||||
<Section title="Mission">
|
||||
<Stat name="sim time" value={`${world.t.toFixed(0)} s`} />
|
||||
{view3d && <Stat name="field" value={`${areaWidth()} × ${AREA} m`} />}
|
||||
<Stat name="drones" value={String(world.drones.length)} />
|
||||
{mode === "sim" && world.swarmAlgo && (
|
||||
<Stat name="swarm algo" value={SWARM_ALGORITHM_LABELS[world.swarmAlgo]} />
|
||||
)}
|
||||
<Stat name="active links" value={String(world.links.size)} />
|
||||
<Stat name="pose broadcasts" value={String(world.broadcasts)} />
|
||||
{!view3d && <Stat name="pose broadcasts" value={String(world.broadcasts)} />}
|
||||
<Stat name="total transferred" value={fmtBytes(world.totalBytes)} />
|
||||
{view3d && <Stat name="renderer" value={backend.toUpperCase()} />}
|
||||
</Section>
|
||||
|
||||
{mode === "sim" && world.flightStats && (
|
||||
<Section title="Flight stats (8 s window)">
|
||||
<Stat name="avg speed" value={`${world.flightStats.avgSpeed.toFixed(1)} m/s`} />
|
||||
<Stat name="idle" value={`${world.flightStats.idlePct.toFixed(0)}%`} />
|
||||
<Stat name="spin / hover" value={`${world.flightStats.spinPct.toFixed(0)}%`} />
|
||||
<Stat name="pass over" value={`${world.flightStats.passOverPct.toFixed(0)}%`} />
|
||||
<Stat name="bypass" value={`${world.flightStats.bypassPct.toFixed(0)}%`} />
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{mode === "sim" && world.basePad && (
|
||||
<Section title="Base pad">
|
||||
<Stat name="offloaded Σ" value={fmtBytes(world.basePad.totalOffloaded)} />
|
||||
<Stat
|
||||
name="on pad"
|
||||
value={
|
||||
world.basePad.offloadingIds.length > 0
|
||||
? world.basePad.offloadingIds.map((id) => `dr-${id + 1}`).join(", ")
|
||||
: "—"
|
||||
}
|
||||
/>
|
||||
<Stat name="landing queue" value={String(world.landingQueue?.length ?? 0)} />
|
||||
<Stat name="max concurrent" value="2" />
|
||||
</Section>
|
||||
)}
|
||||
|
||||
<Section title="Busiest links">
|
||||
{busiest.map((l) => (
|
||||
<div key={l.key} style={styles.linkRow}>
|
||||
@@ -267,11 +445,80 @@ export default function App(): JSX.Element {
|
||||
))}
|
||||
{busiest.length === 0 && <div style={styles.dim}>no links in range</div>}
|
||||
</Section>
|
||||
|
||||
{view3d && (
|
||||
<Section title="Search">
|
||||
<form
|
||||
style={styles.searchForm}
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
void runSearch();
|
||||
}}
|
||||
>
|
||||
<input
|
||||
style={styles.searchInput}
|
||||
value={searchQ}
|
||||
onChange={(e) => setSearchQ(e.target.value)}
|
||||
placeholder="query search.produktor.io"
|
||||
spellCheck={false}
|
||||
/>
|
||||
<button style={styles.button} type="submit" disabled={searchBusy}>
|
||||
{searchBusy ? "…" : "search"}
|
||||
</button>
|
||||
</form>
|
||||
{searchErr && <div style={styles.liveError}>{searchErr}</div>}
|
||||
<div style={styles.hits}>
|
||||
{hits.map((h) => (
|
||||
<a
|
||||
key={h.url}
|
||||
href={h.url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
style={styles.hitLink}
|
||||
>
|
||||
<div style={styles.hitTitle}>{h.title}</div>
|
||||
<div style={styles.hitSnippet}>{h.content || h.url}</div>
|
||||
<div style={styles.hitMeta}>
|
||||
score {h.score.toFixed(1)} · {h.engines.slice(0, 2).join(", ")}
|
||||
</div>
|
||||
</a>
|
||||
))}
|
||||
{!searchBusy && hits.length === 0 && !searchErr && (
|
||||
<div style={styles.dim}>no results</div>
|
||||
)}
|
||||
</div>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
<Section title="Legend">
|
||||
<div style={styles.dim}>blue line — pose broadcast (5 Hz, ~46 B)</div>
|
||||
<div style={styles.dim}>green line — bulk sync (sealed partitions)</div>
|
||||
<div style={styles.dim}>flash — broadcast event delivered</div>
|
||||
<div style={styles.dim}>red circle — transit object crossing the area</div>
|
||||
{view3d ? (
|
||||
<>
|
||||
<div style={styles.dim}>green ring — perimeter patrol path</div>
|
||||
<div style={styles.dim}>amber hex pad — base station · offload & recharge</div>
|
||||
<div style={styles.dim}>house mesh — static ground obstacle</div>
|
||||
<div style={styles.dim}>cyan vector — tron road between houses</div>
|
||||
<div style={styles.dim}>blue line — pose broadcast mesh link</div>
|
||||
<div style={styles.dim}>green line — bulk sync (hot link)</div>
|
||||
<div style={styles.dim}>link flash — transit intel shared over mesh</div>
|
||||
<div style={styles.dim}>cyan beam — direct detection flashlight (spotter only)</div>
|
||||
<div style={styles.dim}>even spread on perimeter, keep ≥1 mesh link</div>
|
||||
<div style={styles.dim}>cyan saucer — transit UFO · orange when fleet tracks</div>
|
||||
<div style={styles.dim}>drag orbit · scroll zoom</div>
|
||||
<div style={styles.dim}>hypha / mycelium — link-flow routing on perimeter</div>
|
||||
<div style={styles.dim}>frontier — pathfinders + exploit/prune</div>
|
||||
<div style={styles.dim}>wave — travelling coverage pulse</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div style={styles.dim}>blue line — pose broadcast (5 Hz, ~46 B)</div>
|
||||
<div style={styles.dim}>green line — bulk sync (sealed partitions)</div>
|
||||
<div style={styles.dim}>flash — broadcast event delivered</div>
|
||||
<div style={styles.dim}>roof footprint — static house (top-down)</div>
|
||||
<div style={styles.dim}>cyan vector — tron road between houses</div>
|
||||
<div style={styles.dim}>cyan saucer — transit UFO crossing the area</div>
|
||||
<div style={styles.dim}>amber hex — base pad · data offload & recharge</div>
|
||||
</>
|
||||
)}
|
||||
</Section>
|
||||
</aside>
|
||||
</div>
|
||||
@@ -286,9 +533,319 @@ function sy(y: number): number {
|
||||
return (y / AREA) * VIEW;
|
||||
}
|
||||
|
||||
function LaunchPad({
|
||||
pad,
|
||||
t,
|
||||
queue,
|
||||
}: {
|
||||
pad: NonNullable<World["basePad"]>;
|
||||
t: number;
|
||||
queue: number;
|
||||
}): JSX.Element {
|
||||
const cx = sx(pad.pos.x);
|
||||
const cy = sy(pad.pos.y);
|
||||
const r = (pad.radius / AREA) * VIEW;
|
||||
const pulse = 0.5 + 0.5 * (0.5 + 0.5 * Math.sin(t * 3.2));
|
||||
const offloading = pad.offloadingIds.length > 0;
|
||||
const hex = Array.from({ length: 6 }, (_, i) => {
|
||||
const a = (i / 6) * Math.PI * 2 - Math.PI / 6;
|
||||
return `${cx + Math.cos(a) * r},${cy + Math.sin(a) * r * 0.92}`;
|
||||
}).join(" ");
|
||||
|
||||
return (
|
||||
<g>
|
||||
<polygon points={hex} fill="#0f1a28" stroke="#ffb347" strokeWidth={2} opacity={0.92} />
|
||||
<polygon
|
||||
points={hex}
|
||||
fill="none"
|
||||
stroke="#ffdd88"
|
||||
strokeWidth={1}
|
||||
opacity={0.35 + pulse * 0.25}
|
||||
/>
|
||||
<circle cx={cx} cy={cy} r={r * 0.12} fill="#ffb347" opacity={0.85 + pulse * 0.15} />
|
||||
<text x={cx} y={cy + 4} style={styles.padLabel as never}>
|
||||
BASE
|
||||
</text>
|
||||
{offloading && (
|
||||
<>
|
||||
<circle
|
||||
cx={cx}
|
||||
cy={cy}
|
||||
r={r * 0.55}
|
||||
fill="none"
|
||||
stroke="#39d98a"
|
||||
strokeWidth={2}
|
||||
strokeDasharray="8 6"
|
||||
opacity={0.5 + pulse * 0.4}
|
||||
/>
|
||||
<text x={cx} y={cy - r * 0.72} style={styles.padOffload as never}>
|
||||
offloading…
|
||||
</text>
|
||||
</>
|
||||
)}
|
||||
{queue > 0 && !offloading && (
|
||||
<text x={cx} y={cy - r * 0.72} style={styles.padQueue as never}>
|
||||
queue {queue}
|
||||
</text>
|
||||
)}
|
||||
</g>
|
||||
);
|
||||
}
|
||||
|
||||
function TronRoads({
|
||||
segments,
|
||||
t,
|
||||
}: {
|
||||
segments: ReturnType<typeof houseRoadNetwork>;
|
||||
t: number;
|
||||
}): JSX.Element {
|
||||
return (
|
||||
<g>
|
||||
{segments.map((seg, i) => {
|
||||
const pulse = 0.5 + 0.5 * (0.5 + 0.5 * Math.sin(t * 2.6 + i * 0.65));
|
||||
const x1 = sx(seg.ax);
|
||||
const y1 = sy(seg.ay);
|
||||
const x2 = sx(seg.bx);
|
||||
const y2 = sy(seg.by);
|
||||
return (
|
||||
<g key={`${seg.ax}-${seg.ay}-${seg.bx}-${seg.by}-${i}`}>
|
||||
<line
|
||||
x1={x1}
|
||||
y1={y1}
|
||||
x2={x2}
|
||||
y2={y2}
|
||||
stroke="#00eeff"
|
||||
strokeWidth={5}
|
||||
opacity={0.12 * pulse}
|
||||
filter="url(#tronGlow)"
|
||||
/>
|
||||
<line
|
||||
x1={x1}
|
||||
y1={y1}
|
||||
x2={x2}
|
||||
y2={y2}
|
||||
stroke="#00eeff"
|
||||
strokeWidth={1.4}
|
||||
opacity={0.35 + 0.45 * pulse}
|
||||
/>
|
||||
<line
|
||||
x1={x1}
|
||||
y1={y1}
|
||||
x2={x2}
|
||||
y2={y2}
|
||||
stroke="#aafcff"
|
||||
strokeWidth={0.6}
|
||||
opacity={0.65 + 0.25 * pulse}
|
||||
/>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
</g>
|
||||
);
|
||||
}
|
||||
|
||||
function StaticHouse({
|
||||
id,
|
||||
x,
|
||||
y,
|
||||
r,
|
||||
}: {
|
||||
id: number;
|
||||
x: number;
|
||||
y: number;
|
||||
r: number;
|
||||
}): JSX.Element {
|
||||
const variant = id % 3;
|
||||
const roofLight = ["#5a6880", "#627088", "#6a7890"][variant];
|
||||
const roofDark = ["#46566c", "#4e6078", "#566880"][variant];
|
||||
const eaves = ["#7a8898", "#8290a0", "#8a98a8"][variant];
|
||||
const rot = (id * 29) % 360;
|
||||
|
||||
return (
|
||||
<g transform={`translate(${x} ${y}) rotate(${rot})`}>
|
||||
<ellipse cx={0} cy={r * 0.06} rx={r * 0.88} ry={r * 0.72} fill="#03060c" opacity={0.28} />
|
||||
|
||||
{variant === 0 && (
|
||||
<>
|
||||
<rect
|
||||
x={-r * 0.82}
|
||||
y={-r * 0.58}
|
||||
width={r * 1.64}
|
||||
height={r * 1.16}
|
||||
rx={r * 0.06}
|
||||
fill={roofDark}
|
||||
stroke={eaves}
|
||||
strokeWidth={1.1}
|
||||
/>
|
||||
<rect x={-r * 0.82} y={-r * 0.58} width={r * 0.82} height={r * 1.16} fill={roofLight} opacity={0.92} />
|
||||
<line
|
||||
x1={0}
|
||||
y1={-r * 0.58}
|
||||
x2={0}
|
||||
y2={r * 0.58}
|
||||
stroke={eaves}
|
||||
strokeWidth={0.9}
|
||||
opacity={0.75}
|
||||
/>
|
||||
<rect x={r * 0.28} y={-r * 0.22} width={r * 0.14} height={r * 0.14} fill="#4a5464" stroke={eaves} strokeWidth={0.5} />
|
||||
</>
|
||||
)}
|
||||
|
||||
{variant === 1 && (
|
||||
<>
|
||||
<rect
|
||||
x={-r * 0.78}
|
||||
y={-r * 0.52}
|
||||
width={r * 1.28}
|
||||
height={r * 1.04}
|
||||
rx={r * 0.05}
|
||||
fill={roofDark}
|
||||
stroke={eaves}
|
||||
strokeWidth={1.1}
|
||||
/>
|
||||
<rect x={-r * 0.78} y={-r * 0.52} width={r * 0.64} height={r * 1.04} fill={roofLight} opacity={0.9} />
|
||||
<line
|
||||
x1={-r * 0.14}
|
||||
y1={-r * 0.52}
|
||||
x2={-r * 0.14}
|
||||
y2={r * 0.52}
|
||||
stroke={eaves}
|
||||
strokeWidth={0.85}
|
||||
opacity={0.7}
|
||||
/>
|
||||
<rect
|
||||
x={r * 0.12}
|
||||
y={-r * 0.18}
|
||||
width={r * 0.62}
|
||||
height={r * 0.56}
|
||||
rx={r * 0.04}
|
||||
fill={roofDark}
|
||||
stroke={eaves}
|
||||
strokeWidth={1}
|
||||
/>
|
||||
<rect x={r * 0.12} y={-r * 0.18} width={r * 0.31} height={r * 0.56} fill={roofLight} opacity={0.88} />
|
||||
<line
|
||||
x1={r * 0.43}
|
||||
y1={-r * 0.18}
|
||||
x2={r * 0.43}
|
||||
y2={r * 0.38}
|
||||
stroke={eaves}
|
||||
strokeWidth={0.75}
|
||||
opacity={0.65}
|
||||
/>
|
||||
<rect x={-r * 0.48} y={-r * 0.08} width={r * 0.12} height={r * 0.12} fill="#4a5464" stroke={eaves} strokeWidth={0.5} />
|
||||
</>
|
||||
)}
|
||||
|
||||
{variant === 2 && (
|
||||
<>
|
||||
<rect
|
||||
x={-r * 0.88}
|
||||
y={-r * 0.48}
|
||||
width={r * 1.76}
|
||||
height={r * 0.96}
|
||||
rx={r * 0.05}
|
||||
fill={roofDark}
|
||||
stroke={eaves}
|
||||
strokeWidth={1.1}
|
||||
/>
|
||||
<rect x={-r * 0.88} y={-r * 0.48} width={r * 0.88} height={r * 0.96} fill={roofLight} opacity={0.9} />
|
||||
<line
|
||||
x1={0}
|
||||
y1={-r * 0.48}
|
||||
x2={0}
|
||||
y2={r * 0.48}
|
||||
stroke={eaves}
|
||||
strokeWidth={0.85}
|
||||
opacity={0.7}
|
||||
/>
|
||||
<line
|
||||
x1={-r * 0.88}
|
||||
y1={0}
|
||||
x2={r * 0.88}
|
||||
y2={0}
|
||||
stroke={eaves}
|
||||
strokeWidth={0.65}
|
||||
opacity={0.45}
|
||||
/>
|
||||
<rect x={r * 0.52} y={-r * 0.12} width={r * 0.1} height={r * 0.1} fill="#4a5464" stroke={eaves} strokeWidth={0.5} />
|
||||
<rect
|
||||
x={-r * 0.22}
|
||||
y={-r * 0.12}
|
||||
width={r * 0.18}
|
||||
height={r * 0.12}
|
||||
fill="#ffe099"
|
||||
opacity={0.55}
|
||||
stroke={eaves}
|
||||
strokeWidth={0.4}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</g>
|
||||
);
|
||||
}
|
||||
|
||||
function TransitUfo({
|
||||
x,
|
||||
y,
|
||||
r,
|
||||
heading,
|
||||
}: {
|
||||
x: number;
|
||||
y: number;
|
||||
r: number;
|
||||
heading: number;
|
||||
alt?: number;
|
||||
}): JSX.Element {
|
||||
const deg = (heading * 180) / Math.PI;
|
||||
const lights = Array.from({ length: 8 }, (_, i) => {
|
||||
const a = (i / 8) * Math.PI * 2;
|
||||
return { cx: Math.cos(a) * r * 0.82, cy: Math.sin(a) * r * 0.26, key: i };
|
||||
});
|
||||
|
||||
return (
|
||||
<g transform={`translate(${x} ${y})`}>
|
||||
<ellipse
|
||||
cx={0}
|
||||
cy={r * 0.42}
|
||||
rx={r * 1.05}
|
||||
ry={r * 0.32}
|
||||
fill="#000000"
|
||||
opacity={0.2}
|
||||
/>
|
||||
<g transform={`rotate(${deg})`} filter="url(#ufoGlow)">
|
||||
<ellipse rx={r} ry={r * 0.33} fill="#0a1828" stroke="#22ddff" strokeWidth={1.4} opacity={0.95} />
|
||||
<ellipse rx={r * 0.88} ry={r * 0.19} cy={r * 0.03} fill="#2a5070" opacity={0.9} />
|
||||
<ellipse
|
||||
rx={r * 0.42}
|
||||
ry={r * 0.28}
|
||||
cy={-r * 0.12}
|
||||
fill="#6ab0e8"
|
||||
stroke="#b8f0ff"
|
||||
strokeWidth={1}
|
||||
opacity={0.92}
|
||||
/>
|
||||
<ellipse rx={r * 0.16} ry={r * 0.1} cy={-r * 0.1} fill="#ccffff" opacity={0.9} />
|
||||
{lights.map((l) => (
|
||||
<circle key={l.key} cx={l.cx} cy={l.cy} r={r * 0.055} fill="#00ffdd" opacity={0.95} />
|
||||
))}
|
||||
<ellipse
|
||||
rx={r * 0.54}
|
||||
ry={r * 0.12}
|
||||
cy={r * 0.08}
|
||||
fill="none"
|
||||
stroke="#66eeff"
|
||||
strokeWidth={1}
|
||||
opacity={0.65}
|
||||
/>
|
||||
</g>
|
||||
</g>
|
||||
);
|
||||
}
|
||||
|
||||
function gridLines(): JSX.Element {
|
||||
const width = sx(areaWidth());
|
||||
const step = VIEW / 10; // one cell per 100 m in both directions
|
||||
const step = VIEW / 10;
|
||||
const lines = [];
|
||||
for (let p = step; p < width; p += step) {
|
||||
lines.push(
|
||||
@@ -330,6 +887,11 @@ const styles: Record<string, React.CSSProperties> = {
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
},
|
||||
shell3d: {
|
||||
height: "100vh",
|
||||
minHeight: 0,
|
||||
overflow: "hidden",
|
||||
},
|
||||
header: {
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
@@ -338,11 +900,31 @@ const styles: Record<string, React.CSSProperties> = {
|
||||
borderBottom: "1px solid #1a2436",
|
||||
flexWrap: "wrap",
|
||||
gap: 12,
|
||||
minHeight: HEADER_H,
|
||||
boxSizing: "border-box",
|
||||
},
|
||||
title: { fontSize: 18, fontWeight: 700, letterSpacing: 4, color: "#8fb4e8" },
|
||||
subtitle: { fontSize: 11, color: "#5a6a85", marginTop: 2 },
|
||||
controls: { display: "flex", gap: 18, alignItems: "center", flexWrap: "wrap" },
|
||||
label: { display: "flex", gap: 8, alignItems: "center", fontSize: 12, color: "#8b98b0" },
|
||||
select: {
|
||||
background: "#16233a",
|
||||
color: "#c8d8f0",
|
||||
border: "1px solid #2a3c5c",
|
||||
borderRadius: 4,
|
||||
padding: "4px 8px",
|
||||
fontSize: 12,
|
||||
maxWidth: 180,
|
||||
},
|
||||
checkLabel: {
|
||||
display: "flex",
|
||||
gap: 6,
|
||||
alignItems: "center",
|
||||
fontSize: 12,
|
||||
color: "#8fb4e8",
|
||||
cursor: "pointer",
|
||||
userSelect: "none",
|
||||
},
|
||||
value: { minWidth: 30, color: "#dde4f0" },
|
||||
button: {
|
||||
background: "#16233a",
|
||||
@@ -357,14 +939,15 @@ const styles: Record<string, React.CSSProperties> = {
|
||||
buttonActive: { background: "#1a3a2a", color: "#39d98a", borderColor: "#2a5c3c" },
|
||||
liveOk: { fontSize: 11, color: "#39d98a" },
|
||||
liveError: { fontSize: 11, color: "#c05a6a" },
|
||||
main: { display: "flex", flex: 1, gap: 0, minHeight: 0 },
|
||||
canvas: { flex: 1, minWidth: 0, display: "block" },
|
||||
main: { display: "flex", flex: 1, gap: 0, minHeight: 0, overflow: "hidden" },
|
||||
canvas2d: { flex: 1, minWidth: 0, display: "block" },
|
||||
panel: {
|
||||
width: 320,
|
||||
width: PANEL_W,
|
||||
borderLeft: "1px solid #1a2436",
|
||||
padding: 16,
|
||||
overflowY: "auto",
|
||||
background: "#0a0f18",
|
||||
scrollbarGutter: "stable",
|
||||
},
|
||||
section: { marginBottom: 22 },
|
||||
sectionTitle: {
|
||||
@@ -389,6 +972,47 @@ const styles: Record<string, React.CSSProperties> = {
|
||||
linkStat: { color: "#5a8c6e", fontSize: 10, marginTop: 1 },
|
||||
dim: { color: "#5a6a85", fontSize: 11, padding: "2px 0" },
|
||||
linkLabel: { fill: "#39d98a", fontSize: 10, textAnchor: "middle", fontFamily: "inherit" },
|
||||
padLabel: {
|
||||
fontSize: 11,
|
||||
fill: "#1a1208",
|
||||
fontWeight: 700,
|
||||
textAnchor: "middle",
|
||||
letterSpacing: 1.2,
|
||||
},
|
||||
padOffload: {
|
||||
fontSize: 10,
|
||||
fill: "#39d98a",
|
||||
textAnchor: "middle",
|
||||
},
|
||||
padQueue: {
|
||||
fontSize: 10,
|
||||
fill: "#ffb347",
|
||||
textAnchor: "middle",
|
||||
},
|
||||
droneLabel: { fill: "#6a7a95", fontSize: 9, textAnchor: "middle", fontFamily: "inherit" },
|
||||
obLabel: { fill: "#c05a6a", fontSize: 9, textAnchor: "middle", fontFamily: "inherit" },
|
||||
searchForm: { display: "flex", gap: 8, marginBottom: 10 },
|
||||
searchInput: {
|
||||
flex: 1,
|
||||
background: "#0f1624",
|
||||
border: "1px solid #1a2436",
|
||||
borderRadius: 6,
|
||||
color: "#dde4f0",
|
||||
fontFamily: "inherit",
|
||||
fontSize: 11,
|
||||
padding: "7px 10px",
|
||||
},
|
||||
hits: { display: "flex", flexDirection: "column", gap: 8 },
|
||||
hitLink: {
|
||||
display: "block",
|
||||
textDecoration: "none",
|
||||
color: "inherit",
|
||||
padding: "8px 10px",
|
||||
borderRadius: 6,
|
||||
border: "1px solid #141d2e",
|
||||
background: "#0c121c",
|
||||
},
|
||||
hitTitle: { fontSize: 11, color: "#8fb4e8", marginBottom: 4, lineHeight: 1.35 },
|
||||
hitSnippet: { fontSize: 10, color: "#6a7a95", lineHeight: 1.45 },
|
||||
hitMeta: { fontSize: 9, color: "#5a6a85", marginTop: 5 },
|
||||
};
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,77 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { mountSwarmScene, type RenderBackend } from "./SwarmScene";
|
||||
import type { World } from "./sim";
|
||||
|
||||
export interface SwarmView3DProps {
|
||||
worldRef: React.MutableRefObject<World>;
|
||||
simEnabled: () => boolean;
|
||||
onStep: (dt: number) => void;
|
||||
onUiPulse: () => void;
|
||||
onBackend: (backend: RenderBackend) => void;
|
||||
}
|
||||
|
||||
export function SwarmView3D({
|
||||
worldRef,
|
||||
simEnabled,
|
||||
onStep,
|
||||
onUiPulse,
|
||||
onBackend,
|
||||
}: SwarmView3DProps): JSX.Element {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const simRef = useRef({ simEnabled, onStep });
|
||||
simRef.current = { simEnabled, onStep };
|
||||
|
||||
useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
const canvas = canvasRef.current;
|
||||
if (!container || !canvas) return;
|
||||
|
||||
let handle: { dispose(): void; backend: RenderBackend } | null = null;
|
||||
let cancelled = false;
|
||||
|
||||
void mountSwarmScene(container, canvas, () => worldRef.current, {
|
||||
enabled: () => simRef.current.simEnabled(),
|
||||
step: (dt) => simRef.current.onStep(dt),
|
||||
onUiPulse,
|
||||
uiIntervalMs: 250,
|
||||
}).then((h) => {
|
||||
if (cancelled) {
|
||||
h.dispose();
|
||||
return;
|
||||
}
|
||||
handle = h;
|
||||
onBackend(h.backend);
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
handle?.dispose();
|
||||
};
|
||||
}, [worldRef, onUiPulse, onBackend]);
|
||||
|
||||
return (
|
||||
<div ref={containerRef} style={styles.viewport}>
|
||||
<canvas ref={canvasRef} style={styles.canvas} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const styles: Record<string, React.CSSProperties> = {
|
||||
viewport: {
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
minHeight: 0,
|
||||
position: "relative",
|
||||
overflow: "hidden",
|
||||
},
|
||||
canvas: {
|
||||
position: "absolute",
|
||||
inset: 0,
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
display: "block",
|
||||
touchAction: "none",
|
||||
outline: "none",
|
||||
},
|
||||
};
|
||||
+29
-17
@@ -20,24 +20,24 @@ export interface LivePose {
|
||||
// latest battery reading from telemetry. arg_max keeps it a single scan.
|
||||
const LIVE_SQL = `
|
||||
WITH pose AS (
|
||||
SELECT drone_id,
|
||||
SELECT drone,
|
||||
arg_max(pos_x, ts_ns) AS x,
|
||||
arg_max(pos_y, ts_ns) AS y,
|
||||
arg_max(yaw, ts_ns) AS yaw,
|
||||
max(ts_ns) AS ts_ns
|
||||
FROM state
|
||||
WHERE direction = 'sent'
|
||||
GROUP BY drone_id
|
||||
GROUP BY drone
|
||||
),
|
||||
batt AS (
|
||||
SELECT drone_id, arg_max(level_pct, ts_ns) AS battery
|
||||
SELECT drone, arg_max(level_pct, ts_ns) AS battery
|
||||
FROM telemetry
|
||||
WHERE sensor = 'battery'
|
||||
GROUP BY drone_id
|
||||
GROUP BY drone
|
||||
)
|
||||
SELECT p.drone_id, p.x, p.y, p.yaw, p.ts_ns, b.battery
|
||||
FROM pose p LEFT JOIN batt b USING (drone_id)
|
||||
ORDER BY p.drone_id`;
|
||||
SELECT p.drone, p.x, p.y, p.yaw, p.ts_ns, b.battery
|
||||
FROM pose p LEFT JOIN batt b USING (drone)
|
||||
ORDER BY p.drone`;
|
||||
|
||||
interface QueryResponse {
|
||||
columns?: string[];
|
||||
@@ -79,16 +79,27 @@ function linkKey(a: number, b: number): string {
|
||||
/** Convert live poses into the World shape the renderer already speaks. */
|
||||
export function toLiveWorld(poses: LivePose[], prev: World | null, dtS: number): World {
|
||||
const k = fitScale(poses);
|
||||
const drones = poses.map((p, i) => ({
|
||||
id: i,
|
||||
name: p.id,
|
||||
pos: { x: p.x * k, y: p.y * k },
|
||||
vel: { x: 0, y: 0 },
|
||||
heading: (p.yawDeg * Math.PI) / 180,
|
||||
waypoint: 0,
|
||||
battery: p.battery ?? 100,
|
||||
channel: 0,
|
||||
}));
|
||||
const drones = poses.map((p, i) => {
|
||||
const t = (prev?.t ?? 0) + dtS;
|
||||
const band = 16 + (i % 6) * 4.5;
|
||||
const alt =
|
||||
band +
|
||||
Math.sin(t * 0.62 + i * 0.91) * 4.5 +
|
||||
Math.sin(p.x * 0.011 + t * 0.45) * 2;
|
||||
const altVel = Math.cos(t * 0.62 + i * 0.91) * 2.8;
|
||||
return {
|
||||
id: i,
|
||||
name: p.id,
|
||||
pos: { x: p.x * k, y: p.y * k },
|
||||
vel: { x: 0, y: 0 },
|
||||
heading: (p.yawDeg * Math.PI) / 180,
|
||||
waypoint: 0,
|
||||
battery: p.battery ?? 100,
|
||||
channel: 0,
|
||||
alt,
|
||||
altVel,
|
||||
};
|
||||
});
|
||||
|
||||
// Every in-range pair exchanges pose broadcasts; accumulate totals so the
|
||||
// panel keeps its meaning between polls.
|
||||
@@ -99,6 +110,7 @@ export function toLiveWorld(poses: LivePose[], prev: World | null, dtS: number):
|
||||
const dist = Math.hypot(
|
||||
drones[i].pos.x - drones[j].pos.x,
|
||||
drones[i].pos.y - drones[j].pos.y,
|
||||
(drones[i].alt ?? 0) - (drones[j].alt ?? 0),
|
||||
);
|
||||
if (dist > LINK_RANGE) continue;
|
||||
const key = linkKey(i, j);
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
export interface SearchHit {
|
||||
url: string;
|
||||
title: string;
|
||||
content: string;
|
||||
score: number;
|
||||
engines: string[];
|
||||
}
|
||||
|
||||
export interface SearchResponse {
|
||||
query: string;
|
||||
results: SearchHit[];
|
||||
}
|
||||
|
||||
interface RawSearchResult {
|
||||
url?: string;
|
||||
title?: string;
|
||||
content?: string;
|
||||
score?: number;
|
||||
engines?: string[];
|
||||
}
|
||||
|
||||
interface RawSearchResponse {
|
||||
query?: string;
|
||||
results?: RawSearchResult[];
|
||||
}
|
||||
|
||||
export async function searchWeb(query: string): Promise<SearchResponse> {
|
||||
const q = query.trim();
|
||||
if (!q) return { query: q, results: [] };
|
||||
|
||||
const params = new URLSearchParams({ q, format: "json" });
|
||||
const res = await fetch(`/api/search?${params.toString()}`);
|
||||
if (!res.ok) throw new Error(`search responded ${res.status}`);
|
||||
|
||||
const data = (await res.json()) as RawSearchResponse;
|
||||
const results = (data.results ?? [])
|
||||
.filter((r): r is RawSearchResult & { url: string; title: string } =>
|
||||
Boolean(r.url && r.title),
|
||||
)
|
||||
.map((r) => ({
|
||||
url: r.url,
|
||||
title: r.title,
|
||||
content: (r.content ?? "").slice(0, 220),
|
||||
score: r.score ?? 0,
|
||||
engines: r.engines ?? [],
|
||||
}))
|
||||
.slice(0, 12);
|
||||
|
||||
return { query: data.query ?? q, results };
|
||||
}
|
||||
+1680
-75
File diff suppressed because it is too large
Load Diff
@@ -3,4 +3,24 @@ import react from "@vitejs/plugin-react";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
"/api/search": {
|
||||
target: "https://search.produktor.io",
|
||||
changeOrigin: true,
|
||||
rewrite: (p) => p.replace(/^\/api\/search/, "/search"),
|
||||
},
|
||||
},
|
||||
},
|
||||
preview: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
"/api/search": {
|
||||
target: "https://search.produktor.io",
|
||||
changeOrigin: true,
|
||||
rewrite: (p) => p.replace(/^\/api\/search/, "/search"),
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -24,7 +24,8 @@ services:
|
||||
command: ["python", "monitoring/exporter.py"]
|
||||
environment:
|
||||
DATA_DIR: /data
|
||||
SCAN_INTERVAL_S: "5"
|
||||
SCAN_INTERVAL_S: "30"
|
||||
METRIC_FLIGHT_WINDOW: "5"
|
||||
volumes:
|
||||
- ${SWARM_T1_DIR:-../var/t1}:/data:ro
|
||||
- ./monitoring:/app/monitoring:ro
|
||||
@@ -37,6 +38,9 @@ services:
|
||||
command: ["python", "explorer/server.py"]
|
||||
environment:
|
||||
DATA_DIR: /data
|
||||
VIEW_REFRESH_S: "30"
|
||||
TREE_CACHE_S: "15"
|
||||
METRIC_FLIGHT_WINDOW: "5"
|
||||
volumes:
|
||||
- ${SWARM_T1_DIR:-../var/t1}:/data:ro
|
||||
- ./explorer:/app/explorer:ro
|
||||
|
||||
@@ -1,33 +1,28 @@
|
||||
"""Data-plane explorer: a web view over the Hive-partitioned Parquet lake.
|
||||
|
||||
Serves three things:
|
||||
/ single-page UI (partition tree + read-only SQL console)
|
||||
/api/tree partition hierarchy with file counts and bytes, live
|
||||
/api/query gated read-only DuckDB SQL, same statement rules as the
|
||||
peer query channel (SELECT/WITH only, single statement)
|
||||
|
||||
The point is doctrinal, not just convenient: the explorer reuses the exact
|
||||
read-only SQL contract that drones expose to each other, so "looking at the
|
||||
data plane" on the bench exercises the same path a peer would use in flight.
|
||||
"""
|
||||
"""Data-plane explorer: a web view over the Hive-partitioned Parquet lake."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from pathlib import Path
|
||||
|
||||
import duckdb
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "monitoring"))
|
||||
from lake import flight_window, iter_parquet_files, parquet_reader # noqa: E402
|
||||
|
||||
DATA_DIR = Path(os.environ.get("DATA_DIR", "./data"))
|
||||
PORT = int(os.environ.get("EXPLORER_PORT", "8088"))
|
||||
ROW_LIMIT = int(os.environ.get("ROW_LIMIT", "500"))
|
||||
VIEW_REFRESH_S = float(os.environ.get("VIEW_REFRESH_S", "30"))
|
||||
TREE_CACHE_S = float(os.environ.get("TREE_CACHE_S", "15"))
|
||||
STATIC_DIR = Path(__file__).parent
|
||||
|
||||
# Same spirit as the forced-command gate on a real drone: one statement,
|
||||
# must be a read, no statement that could write, configure, or reach out.
|
||||
_ALLOWED_START = re.compile(r"^\s*(SELECT|WITH|DESCRIBE|SUMMARIZE|SHOW)\b", re.IGNORECASE)
|
||||
_FORBIDDEN = re.compile(
|
||||
r"\b(INSERT|UPDATE|DELETE|CREATE|DROP|ALTER|ATTACH|DETACH|COPY|EXPORT"
|
||||
@@ -35,9 +30,17 @@ _FORBIDDEN = re.compile(
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
_lock = threading.Lock()
|
||||
_con: duckdb.DuckDBPyConnection | None = None
|
||||
_views_at = 0.0
|
||||
_tree_cache: dict | None = None
|
||||
_tree_at = 0.0
|
||||
_last_tree_s = 0.0
|
||||
_last_query_s = 0.0
|
||||
_query_count = 0
|
||||
|
||||
|
||||
def gate(sql: str) -> str | None:
|
||||
"""Return a rejection reason, or None if the statement passes."""
|
||||
stripped = re.sub(r"--[^\n]*|/\*.*?\*/", " ", sql, flags=re.DOTALL).strip().rstrip(";")
|
||||
if not stripped:
|
||||
return "empty statement"
|
||||
@@ -50,27 +53,39 @@ def gate(sql: str) -> str | None:
|
||||
return None
|
||||
|
||||
|
||||
def connect() -> duckdb.DuckDBPyConnection:
|
||||
"""Fresh connection with the three datasets pre-registered as views."""
|
||||
def _refresh_views() -> None:
|
||||
global _con, _views_at
|
||||
con = duckdb.connect()
|
||||
for ds in ("telemetry", "detections", "state"):
|
||||
pattern = f"{DATA_DIR}/dataset={ds}/**/*.parquet"
|
||||
reader = parquet_reader(DATA_DIR, ds)
|
||||
try:
|
||||
con.execute(
|
||||
f"CREATE VIEW {ds} AS SELECT * FROM read_parquet("
|
||||
f"'{pattern}', hive_partitioning=true, union_by_name=true)"
|
||||
)
|
||||
con.execute(f"CREATE OR REPLACE VIEW {ds} AS SELECT * FROM read_parquet({reader})")
|
||||
except duckdb.Error:
|
||||
pass # dataset not written yet; view simply won't exist
|
||||
return con
|
||||
pass
|
||||
with _lock:
|
||||
if _con is not None:
|
||||
_con.close()
|
||||
_con = con
|
||||
_views_at = time.monotonic()
|
||||
|
||||
|
||||
def connect() -> duckdb.DuckDBPyConnection:
|
||||
if _con is None or time.monotonic() - _views_at > VIEW_REFRESH_S:
|
||||
_refresh_views()
|
||||
assert _con is not None
|
||||
return _con
|
||||
|
||||
|
||||
def tree() -> dict:
|
||||
"""Partition hierarchy: dataset -> flight -> drone -> leafs, with sizes."""
|
||||
global _tree_cache, _tree_at, _last_tree_s
|
||||
now = time.monotonic()
|
||||
if _tree_cache is not None and now - _tree_at < TREE_CACHE_S:
|
||||
return _tree_cache
|
||||
started = now
|
||||
root: dict = {}
|
||||
total_bytes = 0
|
||||
total_files = 0
|
||||
for f in sorted(DATA_DIR.rglob("*.parquet")):
|
||||
for f in sorted(iter_parquet_files(DATA_DIR)):
|
||||
rel = f.relative_to(DATA_DIR)
|
||||
size = f.stat().st_size
|
||||
total_bytes += size
|
||||
@@ -80,7 +95,6 @@ def tree() -> dict:
|
||||
node = node.setdefault("children", {}).setdefault(part, {})
|
||||
leaf = node.setdefault("children", {}).setdefault(rel.parts[-1], {})
|
||||
leaf["bytes"] = size
|
||||
# roll sizes up the tree
|
||||
node = root
|
||||
node["bytes"] = node.get("bytes", 0) + size
|
||||
node["files"] = node.get("files", 0) + 1
|
||||
@@ -88,27 +102,50 @@ def tree() -> dict:
|
||||
node = node["children"][part]
|
||||
node["bytes"] = node.get("bytes", 0) + size
|
||||
node["files"] = node.get("files", 0) + 1
|
||||
return {"tree": root, "total_bytes": total_bytes, "total_files": total_files}
|
||||
_last_tree_s = time.monotonic() - started
|
||||
_tree_cache = {"tree": root, "total_bytes": total_bytes, "total_files": total_files}
|
||||
_tree_at = now
|
||||
return _tree_cache
|
||||
|
||||
|
||||
def run_query(sql: str) -> dict:
|
||||
global _last_query_s, _query_count
|
||||
reason = gate(sql)
|
||||
if reason:
|
||||
return {"error": f"rejected by read-only gate: {reason}"}
|
||||
con = connect()
|
||||
try:
|
||||
cur = con.sql(sql)
|
||||
columns = cur.columns
|
||||
rows = cur.fetchmany(ROW_LIMIT)
|
||||
return {
|
||||
"columns": columns,
|
||||
"rows": [[repr(v) if isinstance(v, bytes) else v for v in row] for row in rows],
|
||||
"truncated": len(rows) == ROW_LIMIT,
|
||||
}
|
||||
except duckdb.Error as exc:
|
||||
return {"error": str(exc)}
|
||||
finally:
|
||||
con.close()
|
||||
started = time.monotonic()
|
||||
with _lock:
|
||||
con = connect()
|
||||
try:
|
||||
cur = con.sql(sql)
|
||||
columns = cur.columns
|
||||
rows = cur.fetchmany(ROW_LIMIT)
|
||||
_query_count += 1
|
||||
_last_query_s = time.monotonic() - started
|
||||
return {
|
||||
"columns": columns,
|
||||
"rows": [[repr(v) if isinstance(v, bytes) else v for v in row] for row in rows],
|
||||
"truncated": len(rows) == ROW_LIMIT,
|
||||
}
|
||||
except duckdb.Error as exc:
|
||||
return {"error": str(exc)}
|
||||
|
||||
|
||||
def metrics_text() -> str:
|
||||
return "\n".join([
|
||||
"# HELP swarm_explorer_query_duration_seconds Wall time of the last SQL query",
|
||||
"# TYPE swarm_explorer_query_duration_seconds gauge",
|
||||
f"swarm_explorer_query_duration_seconds {_last_query_s:.4f}",
|
||||
"# HELP swarm_explorer_tree_duration_seconds Wall time of the last partition tree build",
|
||||
"# TYPE swarm_explorer_tree_duration_seconds gauge",
|
||||
f"swarm_explorer_tree_duration_seconds {_last_tree_s:.4f}",
|
||||
"# HELP swarm_explorer_queries_total Read-only queries served",
|
||||
"# TYPE swarm_explorer_queries_total counter",
|
||||
f"swarm_explorer_queries_total {_query_count}",
|
||||
"# HELP swarm_metric_flight_window Flight partitions in DuckDB views",
|
||||
"# TYPE swarm_metric_flight_window gauge",
|
||||
f"swarm_metric_flight_window {flight_window()}",
|
||||
]) + "\n"
|
||||
|
||||
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
@@ -116,13 +153,11 @@ class Handler(BaseHTTPRequestHandler):
|
||||
self.send_response(code)
|
||||
self.send_header("Content-Type", ctype)
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
# Dev CORS: lets the prototype's live mode poll the API from another
|
||||
# origin. Everything behind this is read-only by construction.
|
||||
self.send_header("Access-Control-Allow-Origin", "*")
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
|
||||
def do_OPTIONS(self) -> None: # noqa: N802 — http.server API
|
||||
def do_OPTIONS(self) -> None: # noqa: N802
|
||||
self.send_response(204)
|
||||
self.send_header("Access-Control-Allow-Origin", "*")
|
||||
self.send_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
|
||||
@@ -132,15 +167,17 @@ class Handler(BaseHTTPRequestHandler):
|
||||
def _json(self, payload: dict, code: int = 200) -> None:
|
||||
self._send(code, json.dumps(payload, default=str).encode(), "application/json")
|
||||
|
||||
def do_GET(self) -> None: # noqa: N802 — http.server API
|
||||
def do_GET(self) -> None: # noqa: N802
|
||||
if self.path in ("/", "/index.html"):
|
||||
self._send(200, (STATIC_DIR / "index.html").read_bytes(), "text/html; charset=utf-8")
|
||||
elif self.path == "/api/tree":
|
||||
self._json(tree())
|
||||
elif self.path == "/metrics":
|
||||
self._send(200, metrics_text().encode(), "text/plain; version=0.0.4")
|
||||
else:
|
||||
self._send(404, b"not found", "text/plain")
|
||||
|
||||
def do_POST(self) -> None: # noqa: N802 — http.server API
|
||||
def do_POST(self) -> None: # noqa: N802
|
||||
if self.path != "/api/query":
|
||||
self._send(404, b"not found", "text/plain")
|
||||
return
|
||||
@@ -156,6 +193,20 @@ class Handler(BaseHTTPRequestHandler):
|
||||
pass
|
||||
|
||||
|
||||
def _view_loop() -> None:
|
||||
while True:
|
||||
try:
|
||||
_refresh_views()
|
||||
except Exception:
|
||||
pass
|
||||
time.sleep(VIEW_REFRESH_S)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
print(f"data-plane explorer on :{PORT}, reading {DATA_DIR}")
|
||||
_refresh_views()
|
||||
threading.Thread(target=_view_loop, daemon=True).start()
|
||||
print(
|
||||
f"data-plane explorer on :{PORT}, views refresh every {VIEW_REFRESH_S}s, "
|
||||
f"last {flight_window()} flights, reading {DATA_DIR}"
|
||||
)
|
||||
ThreadingHTTPServer(("", PORT), Handler).serve_forever()
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
"""Prometheus exporter over the simulator's Parquet output.
|
||||
|
||||
Periodically scans DATA_DIR with DuckDB and exposes fleet statistics as
|
||||
/metrics. Zero dependencies beyond duckdb: the exposition format is plain
|
||||
text, served with the standard library HTTP server.
|
||||
/metrics. Scans only the most recent flight partitions by default so CPU
|
||||
stays bounded as the lake grows across pod restarts.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
@@ -15,27 +16,29 @@ from pathlib import Path
|
||||
|
||||
import duckdb
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
from lake import flight_window, iter_parquet_files, parquet_reader # noqa: E402
|
||||
|
||||
DATA_DIR = Path(os.environ.get("DATA_DIR", "./data"))
|
||||
PORT = int(os.environ.get("EXPORTER_PORT", "9105"))
|
||||
SCAN_INTERVAL_S = float(os.environ.get("SCAN_INTERVAL_S", "5"))
|
||||
SCAN_INTERVAL_S = float(os.environ.get("SCAN_INTERVAL_S", "15"))
|
||||
|
||||
_lock = threading.Lock()
|
||||
_payload = "# swarm exporter starting\n"
|
||||
_last_scan_s = 0.0
|
||||
|
||||
|
||||
def _q(con: duckdb.DuckDBPyConnection, sql: str) -> list[tuple]:
|
||||
try:
|
||||
return con.sql(sql).fetchall()
|
||||
except duckdb.Error:
|
||||
return [] # partitions may not exist yet while the swarm warms up
|
||||
return []
|
||||
|
||||
|
||||
def collect() -> str:
|
||||
global _last_scan_s
|
||||
started = time.monotonic()
|
||||
con = duckdb.connect()
|
||||
# union_by_name: sensors have different schemas under one dataset glob
|
||||
glob = lambda ds: ( # noqa: E731
|
||||
f"'{DATA_DIR}/dataset={ds}/**/*.parquet', hive_partitioning=true, union_by_name=true"
|
||||
)
|
||||
lines: list[str] = []
|
||||
|
||||
def metric(name: str, help_text: str, mtype: str, rows: list[str]) -> None:
|
||||
@@ -43,27 +46,38 @@ def collect() -> str:
|
||||
lines.append(f"# TYPE {name} {mtype}")
|
||||
lines.extend(rows)
|
||||
|
||||
metric(
|
||||
"swarm_rows_total", "Telemetry rows written per drone and sensor", "gauge",
|
||||
[f'swarm_rows_total{{drone="{d}",sensor="{s}"}} {n}'
|
||||
for d, s, n in _q(con, f"SELECT drone, sensor, count(*) FROM read_parquet({glob('telemetry')}) GROUP BY 1,2")],
|
||||
)
|
||||
metric(
|
||||
"swarm_detections_total", "Detection events per drone and class", "gauge",
|
||||
[f'swarm_detections_total{{drone="{d}",cls="{c}"}} {n}'
|
||||
for d, c, n in _q(con, f"SELECT drone, cls, count(*) FROM read_parquet({glob('detections')}) GROUP BY 1,2")],
|
||||
)
|
||||
metric(
|
||||
"swarm_state_frames_total", "Pose broadcast frames per drone and direction", "gauge",
|
||||
[f'swarm_state_frames_total{{drone="{d}",direction="{dr}"}} {n}'
|
||||
for d, dr, n in _q(con, f"SELECT drone, direction, count(*) FROM read_parquet({glob('state')}) GROUP BY 1,2")],
|
||||
)
|
||||
for ds, name in (
|
||||
("telemetry", "swarm_rows_total"),
|
||||
("detections", "swarm_detections_total"),
|
||||
("state", "swarm_state_frames_total"),
|
||||
):
|
||||
reader = parquet_reader(DATA_DIR, ds)
|
||||
if ds == "telemetry":
|
||||
metric(
|
||||
name, f"Rows in dataset={ds} (recent {flight_window()} flights)", "gauge",
|
||||
[f'{name}{{drone="{d}",sensor="{s}"}} {n}'
|
||||
for d, s, n in _q(con, f"SELECT drone, sensor, count(*) FROM read_parquet({reader}) GROUP BY 1,2")],
|
||||
)
|
||||
elif ds == "detections":
|
||||
metric(
|
||||
name, "Detection events per drone and class", "gauge",
|
||||
[f'{name}{{drone="{d}",cls="{c}"}} {n}'
|
||||
for d, c, n in _q(con, f"SELECT drone, cls, count(*) FROM read_parquet({reader}) GROUP BY 1,2")],
|
||||
)
|
||||
else:
|
||||
metric(
|
||||
name, "Pose broadcast frames per drone and direction", "gauge",
|
||||
[f'{name}{{drone="{d}",direction="{dr}"}} {n}'
|
||||
for d, dr, n in _q(con, f"SELECT drone, direction, count(*) FROM read_parquet({reader}) GROUP BY 1,2")],
|
||||
)
|
||||
|
||||
telem = parquet_reader(DATA_DIR, "telemetry")
|
||||
metric(
|
||||
"swarm_battery_pct", "Latest battery level per drone", "gauge",
|
||||
[f'swarm_battery_pct{{drone="{d}"}} {v}'
|
||||
for d, v in _q(con, f"""
|
||||
SELECT drone, arg_max(level_pct, ts_ns)
|
||||
FROM read_parquet({glob('telemetry')})
|
||||
FROM read_parquet({telem})
|
||||
WHERE sensor='battery' GROUP BY drone""")],
|
||||
)
|
||||
metric(
|
||||
@@ -71,26 +85,24 @@ def collect() -> str:
|
||||
[f'swarm_rssi_dbm{{drone="{d}",peer="{p}"}} {v}'
|
||||
for d, p, v in _q(con, f"""
|
||||
SELECT drone, peer_id, arg_max(rssi_dbm, ts_ns)
|
||||
FROM read_parquet({glob('telemetry')})
|
||||
WHERE sensor='rssi' GROUP BY drone, peer_id""")],
|
||||
)
|
||||
metric(
|
||||
"swarm_peer_distance_m", "Latest inter-drone distance estimate", "gauge",
|
||||
[f'swarm_peer_distance_m{{drone="{d}",peer="{p}"}} {v}'
|
||||
for d, p, v in _q(con, f"""
|
||||
SELECT drone, peer_id, arg_max(distance_m, ts_ns)
|
||||
FROM read_parquet({glob('telemetry')})
|
||||
FROM read_parquet({telem})
|
||||
WHERE sensor='rssi' GROUP BY drone, peer_id""")],
|
||||
)
|
||||
|
||||
files = list(DATA_DIR.rglob("*.parquet"))
|
||||
files = iter_parquet_files(DATA_DIR)
|
||||
metric(
|
||||
"swarm_parquet_bytes", "Bytes on disk per dataset", "gauge",
|
||||
"swarm_parquet_bytes", "Bytes on disk per dataset (recent flights)", "gauge",
|
||||
[f'swarm_parquet_bytes{{dataset="{ds}"}} {sum(f.stat().st_size for f in files if f"dataset={ds}" in str(f))}'
|
||||
for ds in ("telemetry", "detections", "state")],
|
||||
)
|
||||
metric("swarm_parquet_files", "Parquet files on disk", "gauge",
|
||||
metric("swarm_parquet_files", "Parquet files scanned (recent flights)", "gauge",
|
||||
[f"swarm_parquet_files {len(files)}"])
|
||||
metric("swarm_metric_flight_window", "Flight partitions included per dataset", "gauge",
|
||||
[f"swarm_metric_flight_window {flight_window()}"])
|
||||
|
||||
_last_scan_s = time.monotonic() - started
|
||||
metric("swarm_exporter_scan_duration_seconds", "Wall time of the last metrics scan", "gauge",
|
||||
[f"swarm_exporter_scan_duration_seconds {_last_scan_s:.4f}"])
|
||||
con.close()
|
||||
return "\n".join(lines) + "\n"
|
||||
|
||||
@@ -101,11 +113,11 @@ def scanner() -> None:
|
||||
started = time.monotonic()
|
||||
try:
|
||||
payload = collect()
|
||||
except Exception as exc: # keep serving stale metrics over dying
|
||||
except Exception as exc:
|
||||
payload = f"# collect error: {exc}\n"
|
||||
with _lock:
|
||||
_payload = payload
|
||||
time.sleep(max(0.5, SCAN_INTERVAL_S - (time.monotonic() - started)))
|
||||
time.sleep(max(1.0, SCAN_INTERVAL_S - (time.monotonic() - started)))
|
||||
|
||||
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
@@ -123,10 +135,13 @@ class Handler(BaseHTTPRequestHandler):
|
||||
self.wfile.write(body)
|
||||
|
||||
def log_message(self, *_args: object) -> None:
|
||||
pass # scrapes every few seconds; keep the log quiet
|
||||
pass
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
threading.Thread(target=scanner, daemon=True).start()
|
||||
print(f"swarm exporter on :{PORT}/metrics, scanning {DATA_DIR} every {SCAN_INTERVAL_S}s")
|
||||
print(
|
||||
f"swarm exporter on :{PORT}/metrics, scanning last {flight_window()} flights "
|
||||
f"every {SCAN_INTERVAL_S}s under {DATA_DIR}"
|
||||
)
|
||||
ThreadingHTTPServer(("", PORT), Handler).serve_forever()
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
{
|
||||
"uid": "swarm-platform",
|
||||
"title": "Swarm Platform — CPU & scan health",
|
||||
"tags": ["swarm", "platform"],
|
||||
"timezone": "utc",
|
||||
"schemaVersion": 39,
|
||||
"version": 1,
|
||||
"refresh": "10s",
|
||||
"time": { "from": "now-30m", "to": "now" },
|
||||
"panels": [
|
||||
{
|
||||
"id": 1, "type": "timeseries", "title": "Node CPU %",
|
||||
"gridPos": { "h": 8, "w": 12, "x": 0, "y": 0 },
|
||||
"datasource": { "type": "prometheus", "uid": "swarm-prom" },
|
||||
"targets": [{
|
||||
"expr": "100 - (avg by (instance) (rate(node_cpu_seconds_total{mode=\"idle\"}[1m])) * 100)",
|
||||
"legendFormat": "{{instance}}", "refId": "A"
|
||||
}],
|
||||
"fieldConfig": { "defaults": { "unit": "percent", "min": 0, "max": 100 }, "overrides": [] }
|
||||
},
|
||||
{
|
||||
"id": 2, "type": "timeseries", "title": "Node memory used %",
|
||||
"gridPos": { "h": 8, "w": 12, "x": 12, "y": 0 },
|
||||
"datasource": { "type": "prometheus", "uid": "swarm-prom" },
|
||||
"targets": [{
|
||||
"expr": "(1 - node_memory_MemAvailable_bytes / node_memory_MemTotal_bytes) * 100",
|
||||
"legendFormat": "used", "refId": "A"
|
||||
}],
|
||||
"fieldConfig": { "defaults": { "unit": "percent", "min": 0, "max": 100 }, "overrides": [] }
|
||||
},
|
||||
{
|
||||
"id": 3, "type": "timeseries", "title": "Exporter scan duration",
|
||||
"gridPos": { "h": 7, "w": 8, "x": 0, "y": 8 },
|
||||
"datasource": { "type": "prometheus", "uid": "swarm-prom" },
|
||||
"targets": [{
|
||||
"expr": "swarm_exporter_scan_duration_seconds",
|
||||
"legendFormat": "scan seconds", "refId": "A"
|
||||
}],
|
||||
"fieldConfig": { "defaults": { "unit": "s" }, "overrides": [] }
|
||||
},
|
||||
{
|
||||
"id": 4, "type": "timeseries", "title": "Explorer query duration",
|
||||
"gridPos": { "h": 7, "w": 8, "x": 8, "y": 8 },
|
||||
"datasource": { "type": "prometheus", "uid": "swarm-prom" },
|
||||
"targets": [{
|
||||
"expr": "swarm_explorer_query_duration_seconds",
|
||||
"legendFormat": "last query", "refId": "A"
|
||||
}],
|
||||
"fieldConfig": { "defaults": { "unit": "s" }, "overrides": [] }
|
||||
},
|
||||
{
|
||||
"id": 5, "type": "timeseries", "title": "Explorer tree build duration",
|
||||
"gridPos": { "h": 7, "w": 8, "x": 16, "y": 8 },
|
||||
"datasource": { "type": "prometheus", "uid": "swarm-prom" },
|
||||
"targets": [{
|
||||
"expr": "swarm_explorer_tree_duration_seconds",
|
||||
"legendFormat": "tree seconds", "refId": "A"
|
||||
}],
|
||||
"fieldConfig": { "defaults": { "unit": "s" }, "overrides": [] }
|
||||
},
|
||||
{
|
||||
"id": 6, "type": "stat", "title": "Parquet files scanned",
|
||||
"gridPos": { "h": 5, "w": 6, "x": 0, "y": 15 },
|
||||
"datasource": { "type": "prometheus", "uid": "swarm-prom" },
|
||||
"targets": [{ "expr": "swarm_parquet_files", "instant": true, "refId": "A" }],
|
||||
"options": { "reduceOptions": { "calcs": ["lastNotNull"] } }
|
||||
},
|
||||
{
|
||||
"id": 7, "type": "stat", "title": "Flight window",
|
||||
"gridPos": { "h": 5, "w": 6, "x": 6, "y": 15 },
|
||||
"datasource": { "type": "prometheus", "uid": "swarm-prom" },
|
||||
"targets": [{ "expr": "swarm_metric_flight_window", "instant": true, "refId": "A" }],
|
||||
"options": { "reduceOptions": { "calcs": ["lastNotNull"] } }
|
||||
},
|
||||
{
|
||||
"id": 8, "type": "stat", "title": "Telemetry rows (window)",
|
||||
"gridPos": { "h": 5, "w": 6, "x": 12, "y": 15 },
|
||||
"datasource": { "type": "prometheus", "uid": "swarm-prom" },
|
||||
"targets": [{ "expr": "sum(swarm_rows_total)", "instant": true, "refId": "A" }],
|
||||
"options": { "reduceOptions": { "calcs": ["lastNotNull"] } }
|
||||
},
|
||||
{
|
||||
"id": 9, "type": "stat", "title": "Min fleet battery %",
|
||||
"gridPos": { "h": 5, "w": 6, "x": 18, "y": 15 },
|
||||
"datasource": { "type": "prometheus", "uid": "swarm-prom" },
|
||||
"targets": [{ "expr": "min(swarm_battery_pct)", "instant": true, "refId": "A" }],
|
||||
"fieldConfig": { "defaults": { "unit": "percent", "min": 0, "max": 100 }, "overrides": [] }
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
"""Scan the Hive-partitioned lake without re-reading every historical flight."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def flight_window() -> int:
|
||||
return max(1, int(os.environ.get("METRIC_FLIGHT_WINDOW", "5")))
|
||||
|
||||
|
||||
def recent_flights(data_dir: Path, dataset: str, limit: int | None = None) -> list[Path]:
|
||||
"""Most recently modified flight= partitions for a dataset."""
|
||||
limit = limit or flight_window()
|
||||
base = data_dir / f"dataset={dataset}"
|
||||
if not base.is_dir():
|
||||
return []
|
||||
flights = [p for p in base.iterdir() if p.is_dir() and p.name.startswith("flight=")]
|
||||
flights.sort(key=lambda p: p.stat().st_mtime, reverse=True)
|
||||
return flights[:limit]
|
||||
|
||||
|
||||
def parquet_reader(data_dir: Path, dataset: str, *, limit: int | None = None) -> str:
|
||||
"""DuckDB read_parquet() source limited to recent flights."""
|
||||
flights = recent_flights(data_dir, dataset, limit)
|
||||
if not flights:
|
||||
path = data_dir / f"dataset={dataset}" / "**" / "*.parquet"
|
||||
return f"'{path}', hive_partitioning=true, union_by_name=true"
|
||||
if len(flights) == 1:
|
||||
return f"'{flights[0]}/**/*.parquet', hive_partitioning=true, union_by_name=true"
|
||||
inner = ", ".join(f"'{f}/**/*.parquet'" for f in flights)
|
||||
return f"[{inner}], hive_partitioning=true, union_by_name=true"
|
||||
|
||||
|
||||
def iter_parquet_files(data_dir: Path, *, flight_limit: int | None = None) -> list[Path]:
|
||||
"""Parquet paths under recent flights only — avoids full-lake rglob."""
|
||||
limit = flight_limit or flight_window()
|
||||
out: list[Path] = []
|
||||
for ds in ("telemetry", "detections", "state"):
|
||||
for flight in recent_flights(data_dir, ds, limit):
|
||||
out.extend(flight.rglob("*.parquet"))
|
||||
return out
|
||||
@@ -1,7 +1,10 @@
|
||||
global:
|
||||
scrape_interval: 5s
|
||||
scrape_interval: 15s
|
||||
|
||||
scrape_configs:
|
||||
- job_name: swarm
|
||||
static_configs:
|
||||
- targets: ["exporter:9105"]
|
||||
- job_name: explorer
|
||||
static_configs:
|
||||
- targets: ["explorer:8088"]
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
"""Lake scan helpers — bounded to recent flight partitions."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from monitoring.lake import flight_window, iter_parquet_files, parquet_reader, recent_flights
|
||||
|
||||
|
||||
def test_parquet_reader_limits_to_recent_flights(tmp_path: Path) -> None:
|
||||
for i, name in enumerate(("flight=aaa", "flight=bbb", "flight=ccc")):
|
||||
hour = tmp_path / f"dataset=telemetry/{name}/drone=dr-01/sensor=imu/year=2026/month=07/day=08/hour=10"
|
||||
hour.mkdir(parents=True)
|
||||
f = hour / "data.parquet"
|
||||
f.write_bytes(b"x" * (i + 1))
|
||||
# Make later names newer
|
||||
import os
|
||||
import time
|
||||
os.utime(f, (time.time() + i, time.time() + i))
|
||||
|
||||
reader = parquet_reader(tmp_path, "telemetry", limit=1)
|
||||
assert "flight=ccc" in reader
|
||||
assert "flight=bbb" not in reader
|
||||
|
||||
|
||||
def test_iter_parquet_files_skips_old_flights(tmp_path: Path) -> None:
|
||||
old = tmp_path / "dataset=state/flight=old/drone=dr-01/year=2026/month=07/day=08/hour=09"
|
||||
old.mkdir(parents=True)
|
||||
(old / "data.parquet").write_bytes(b"old")
|
||||
new = tmp_path / "dataset=state/flight=new/drone=dr-01/year=2026/month=07/day=08/hour=10"
|
||||
new.mkdir(parents=True)
|
||||
new_file = new / "data.parquet"
|
||||
new_file.write_bytes(b"new")
|
||||
import os
|
||||
import time
|
||||
os.utime(new_file, (time.time() + 10, time.time() + 10))
|
||||
|
||||
files = iter_parquet_files(tmp_path, flight_limit=1)
|
||||
assert len(files) == 1
|
||||
assert "flight=new" in str(files[0])
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import random
|
||||
import select
|
||||
import time
|
||||
@@ -108,6 +109,10 @@ def run(cfg: Config) -> None:
|
||||
writer.seal()
|
||||
print(f"[{cfg.drone_id}] done: {frames_sent} state frames sent, "
|
||||
f"{len(peers_seen)} peers seen {sorted(peers_seen)}; sealed to {root}")
|
||||
if os.environ.get("KEEP_ALIVE", "0") == "1":
|
||||
print(f"[{cfg.drone_id}] KEEP_ALIVE=1 — idle after seal (no pod restart churn)")
|
||||
while True:
|
||||
time.sleep(3600)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
Reference in New Issue
Block a user