plexus-python 0.9.0__py3-none-any.whl → 0.11.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- plexus/__init__.py +24 -3
- plexus/_skills/README.md +72 -0
- plexus/_skills/plexus/SKILL.md +183 -0
- plexus/_skills/plexus-dashboard/SKILL.md +204 -0
- plexus/_skills/plexus-firmware/SKILL.md +220 -0
- plexus/batching.py +262 -0
- plexus/cli.py +98 -0
- plexus/client.py +305 -2
- plexus/ws.py +16 -1
- {plexus_python-0.9.0.dist-info → plexus_python-0.11.0.dist-info}/METADATA +44 -1
- plexus_python-0.11.0.dist-info/RECORD +19 -0
- plexus_python-0.9.0.dist-info/RECORD +0 -14
- {plexus_python-0.9.0.dist-info → plexus_python-0.11.0.dist-info}/WHEEL +0 -0
- {plexus_python-0.9.0.dist-info → plexus_python-0.11.0.dist-info}/entry_points.txt +0 -0
- {plexus_python-0.9.0.dist-info → plexus_python-0.11.0.dist-info}/licenses/LICENSE +0 -0
plexus/__init__.py
CHANGED
|
@@ -5,10 +5,31 @@ Plexus — thin Python SDK for sending telemetry to the Plexus gateway.
|
|
|
5
5
|
|
|
6
6
|
px = Plexus(api_key="plx_xxx", source_id="device-001")
|
|
7
7
|
px.send("temperature", 72.5)
|
|
8
|
+
|
|
9
|
+
At bench rates, batch — `send()` is one WebSocket frame per call and the
|
|
10
|
+
gateway limits frames, not points:
|
|
11
|
+
|
|
12
|
+
with px.run("hotfire-03"), px.batch(interval_ms=50) as b:
|
|
13
|
+
b.send("att.rate_x", gyro.x)
|
|
8
14
|
"""
|
|
9
15
|
|
|
10
|
-
from plexus.
|
|
16
|
+
from plexus.batching import BatchSender
|
|
17
|
+
from plexus.client import (
|
|
18
|
+
AuthenticationError,
|
|
19
|
+
Plexus,
|
|
20
|
+
PlexusError,
|
|
21
|
+
RateLimitedError,
|
|
22
|
+
read_mjpeg_frames,
|
|
23
|
+
)
|
|
11
24
|
from plexus.config import RetryConfig
|
|
12
25
|
|
|
13
|
-
__version__ = "0.
|
|
14
|
-
__all__ = [
|
|
26
|
+
__version__ = "0.11.0"
|
|
27
|
+
__all__ = [
|
|
28
|
+
"AuthenticationError",
|
|
29
|
+
"BatchSender",
|
|
30
|
+
"Plexus",
|
|
31
|
+
"PlexusError",
|
|
32
|
+
"RateLimitedError",
|
|
33
|
+
"RetryConfig",
|
|
34
|
+
"read_mjpeg_frames",
|
|
35
|
+
]
|
plexus/_skills/README.md
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# Plexus agent skills
|
|
2
|
+
|
|
3
|
+
Three skills that teach a coding agent — Claude Code, or anything that reads
|
|
4
|
+
the same format — how to build against Plexus without guessing at the API.
|
|
5
|
+
|
|
6
|
+
| Skill | For |
|
|
7
|
+
| ------------------ | ------------------------------------------------------------------------- |
|
|
8
|
+
| `plexus` | The API itself: hosts, auth, every endpoint, the live stream, the pitfalls |
|
|
9
|
+
| `plexus-firmware` | Device-side ingest — ESP32, Pi, Jetson, autopilots, OBCs |
|
|
10
|
+
| `plexus-dashboard` | Scaffolding a web dashboard on the read API |
|
|
11
|
+
|
|
12
|
+
They are plain Markdown with YAML frontmatter. No install, no server, no
|
|
13
|
+
credentials — an agent reads them and writes correct code.
|
|
14
|
+
|
|
15
|
+
## Install
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
pip install plexus-python
|
|
19
|
+
plexus skills install
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
That writes all three into `~/.claude/skills`. Use `--project` to install into
|
|
23
|
+
`./.claude/skills` instead, so they travel with the repo, `--dir` to pick a
|
|
24
|
+
target, or `--list` to see what ships without writing anything.
|
|
25
|
+
|
|
26
|
+
Re-running refreshes them. These are reference docs, not config: a stale copy
|
|
27
|
+
is the failure this command exists to fix, so an existing skill is replaced —
|
|
28
|
+
reported as `updated`, never silently.
|
|
29
|
+
|
|
30
|
+
Then just ask for what you want — "send my ESP32's battery voltage to Plexus",
|
|
31
|
+
"build me a fleet dashboard" — and the agent picks the right one from its
|
|
32
|
+
`description`.
|
|
33
|
+
|
|
34
|
+
## Why these exist
|
|
35
|
+
|
|
36
|
+
An agent that has not read these invents a plausible Plexus API and gets it
|
|
37
|
+
wrong in ways that fail quietly. The three that cost the most real time:
|
|
38
|
+
|
|
39
|
+
- The ingest array is **`points`**, not `metrics`, and every point needs a
|
|
40
|
+
`class`. Getting this wrong is a 400 on every write.
|
|
41
|
+
- `timestamp` must be a **number**. An ISO-8601 string is rejected outright.
|
|
42
|
+
- The query response is **columnar** — `series[m].avg[i]`, not
|
|
43
|
+
`series[m][i].v`. Guessing wrong yields `undefined` with no error: an empty
|
|
44
|
+
chart and no clue why.
|
|
45
|
+
|
|
46
|
+
Each skill front-loads its pitfalls for that reason.
|
|
47
|
+
|
|
48
|
+
## Keeping them true
|
|
49
|
+
|
|
50
|
+
Prose does not compile, so a stale line here survives until it breaks
|
|
51
|
+
someone's code. Two checks stop that:
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
python scripts/verify_skills.py # every route, against the live API
|
|
55
|
+
pytest tests/test_skills.py # body shapes and response types, offline
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
`verify_skills.py` needs no API key. It asserts that every route the skills
|
|
59
|
+
quote exists in the live OpenAPI spec, that every WebSocket route completes a
|
|
60
|
+
real handshake, and — the useful part — that every route documented as **dead**
|
|
61
|
+
is still dead. The skills name non-existent routes on purpose, because agents
|
|
62
|
+
invent them otherwise; if one ever ships for real, the warning has become a lie
|
|
63
|
+
and the check fails.
|
|
64
|
+
|
|
65
|
+
`tests/test_skills.py` runs offline and guards the request/response shapes that
|
|
66
|
+
have actually shipped broken code. It deliberately touches no network:
|
|
67
|
+
`tests/conftest.py` points the suite at an unroutable address so nothing in it
|
|
68
|
+
can reach production, and that holds here too.
|
|
69
|
+
|
|
70
|
+
Both were written after an audit on 2026-08-28 found that all three skills had
|
|
71
|
+
drifted — the firmware one badly enough that every template in it would have
|
|
72
|
+
returned a 400.
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: plexus
|
|
3
|
+
description: Integrate with the Plexus telemetry API — send data, query metrics, subscribe to live streams, send commands to devices. Use when the user mentions Plexus, plexus.company, gateway.plexus.company, plexus-data-api.fly.dev, or plx_ API keys. ALSO USE when the request involves IoT/hardware telemetry, fleet observability, sending sensor data to a backend, querying device time-series, building a fleet dashboard, monitoring drones/satellites/robots/edge devices, or any phrase like "send my sensor readings somewhere", "store telemetry", "track a fleet", "ingest metrics", or "device observability" — even if "Plexus" is never said.
|
|
4
|
+
tools: Read, Write, Edit, Bash, WebFetch
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# Plexus
|
|
8
|
+
|
|
9
|
+
Plexus is a telemetry/observability platform for hardware fleets (drones, satellites, robots, edge devices). This skill teaches Claude how to integrate with its public API.
|
|
10
|
+
|
|
11
|
+
## When to use this skill
|
|
12
|
+
|
|
13
|
+
Trigger on any of:
|
|
14
|
+
|
|
15
|
+
- The user mentions "Plexus", "plexus.company", `plx_` keys, `gateway.plexus.company`, or `plexus-data-api.fly.dev`
|
|
16
|
+
- The user wants to ingest telemetry, query device metrics, stream live points, or send commands to a device
|
|
17
|
+
- The user is building a dashboard, alert pipeline, or analysis on top of fleet telemetry
|
|
18
|
+
- The user pastes a Plexus curl example and asks for help
|
|
19
|
+
|
|
20
|
+
If the user wants to build a **frontend dashboard** specifically, also invoke `plexus-dashboard`.
|
|
21
|
+
If they're writing **firmware / edge code**, also invoke `plexus-firmware`.
|
|
22
|
+
|
|
23
|
+
## Hosts and auth
|
|
24
|
+
|
|
25
|
+
Two base URLs. Authenticate with an `x-api-key` header on HTTP.
|
|
26
|
+
|
|
27
|
+
| Purpose | Host |
|
|
28
|
+
| -------------------------------------------------- | -------------------------------- |
|
|
29
|
+
| Ingest | `https://gateway.plexus.company` |
|
|
30
|
+
| Read API (sources, metrics, logs, fleet, commands, live stream) | `https://plexus-data-api.fly.dev` |
|
|
31
|
+
|
|
32
|
+
```
|
|
33
|
+
x-api-key: plx_...
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
The OpenAPI spec for the read API lives at `https://plexus-data-api.fly.dev/openapi.json` — fetch it when you need exact schemas. **It does not list the WebSocket endpoints** (FastAPI omits them), so use the Live section below for those.
|
|
37
|
+
|
|
38
|
+
Read keys from env, never hardcode:
|
|
39
|
+
|
|
40
|
+
- `PLEXUS_API_KEY` — server / CLI
|
|
41
|
+
- `NEXT_PUBLIC_PLEXUS_API_KEY` — client-side Next.js (only if the user explicitly accepts the trade-off)
|
|
42
|
+
|
|
43
|
+
## Endpoint cheat sheet
|
|
44
|
+
|
|
45
|
+
### Send (gateway)
|
|
46
|
+
|
|
47
|
+
`POST /ingest` — body `{ source_id, points: [...] }`, response `{ success, count, source_id }`.
|
|
48
|
+
|
|
49
|
+
```json
|
|
50
|
+
{
|
|
51
|
+
"source_id": "drone-001",
|
|
52
|
+
"points": [
|
|
53
|
+
{ "class": "metric", "metric": "battery.voltage", "value": 11.8, "timestamp": 1787848320000 }
|
|
54
|
+
]
|
|
55
|
+
}
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
Per point:
|
|
59
|
+
|
|
60
|
+
- **`class` is required** and must be `"metric"` or `"event"`. Omitting it is a 400.
|
|
61
|
+
- **The array is `points`, not `metrics`.** Sending `metrics: [...]` returns `400 {"error":"'points' array is required"}`. This is the single most common mistake.
|
|
62
|
+
- `metric` (string) and `value` are required.
|
|
63
|
+
- `timestamp` must be a **number** — an ISO-8601 string is rejected with `points[i].timestamp must be a number`. Epoch **milliseconds** is the intended unit; a positive value under `1e12` is interpreted as **seconds** and scaled up automatically.
|
|
64
|
+
- Omitting `timestamp` is safe: the point is stamped with gateway receive time. (This used to land at 1970 and be invisible — fixed, the loader now falls back to `ingested_at`.)
|
|
65
|
+
- `source_id` may be set at the envelope level, per point, or both — per point wins.
|
|
66
|
+
- The gateway creates the source on first write; no registration step.
|
|
67
|
+
|
|
68
|
+
`POST /api/v1/write` also exists as a Prometheus/Alloy/OTel/Telegraf remote-write receiver. Do not reach for it unless the user already runs one of those.
|
|
69
|
+
|
|
70
|
+
### Read (data API)
|
|
71
|
+
|
|
72
|
+
Paths are `/v1/sources/...`. `/v1/devices/...` is a deprecated alias — bare `/v1/devices` 307s to `/v1/devices/`, which 308s to `/v1/sources/`. Both preserve method and body, but just use `sources` (and `curl -L` if you inherit an old path).
|
|
73
|
+
|
|
74
|
+
- `GET /v1/sources` → `{ devices: [{ source_id, online, last_seen_ms }] }`
|
|
75
|
+
- Takes `?status=online|offline`.
|
|
76
|
+
- Note the mismatch: the **path** is `sources`, the **response key** is still `devices`. Renaming stopped halfway. Read `body.devices`.
|
|
77
|
+
- `GET /v1/sources/{id}` → `{ source_id, online, last_seen_ms }`
|
|
78
|
+
- `GET /v1/sources/{id}/metrics` → `string[]` (metric names)
|
|
79
|
+
- `GET /v1/sources/{id}/metrics/latest` → `{ metrics: { [name]: number } }`
|
|
80
|
+
- `GET /v1/sources/{id}/metrics/query?metrics=a,b&last=1h` → columnar, see below. Also takes `start`, `end`, `interval`.
|
|
81
|
+
- `GET /v1/sources/{id}/logs?last=1h&limit=1000` → log rows (also `tail`, `name`, `start`, `end`)
|
|
82
|
+
- `GET /v1/fleet/health` → `{ sources_total, sources_online }`
|
|
83
|
+
- `GET /v1/fleet/metrics?metric=X&last=1h` → `{ metric, interval, sources_online, sources_w_metric, sources: [...], truncated }`
|
|
84
|
+
|
|
85
|
+
There is **no per-source health endpoint** — `/v1/sources/{id}/health` 404s. Liveness is already on the list: each entry carries `online` and `last_seen_ms`. Use `/v1/fleet/health` for the roll-up.
|
|
86
|
+
|
|
87
|
+
#### The query response is columnar
|
|
88
|
+
|
|
89
|
+
Not an array of points. Each metric maps to parallel arrays:
|
|
90
|
+
|
|
91
|
+
```json
|
|
92
|
+
{
|
|
93
|
+
"interval": "1m",
|
|
94
|
+
"auto_downsampled": true,
|
|
95
|
+
"truncated": false,
|
|
96
|
+
"series": {
|
|
97
|
+
"my.metric": {
|
|
98
|
+
"timestamp_ms": [1787848320000, 1787848380000],
|
|
99
|
+
"min": [46.5, -0.05],
|
|
100
|
+
"max": [49.9, 49.9],
|
|
101
|
+
"avg": [48.2, 26.6],
|
|
102
|
+
"count": [4, 60]
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
So it is `series[m].timestamp_ms[i]` and `series[m].avg[i]`, not `series[m][i].t`. Zip the arrays by index to plot:
|
|
109
|
+
|
|
110
|
+
```js
|
|
111
|
+
const s = res.series[metric];
|
|
112
|
+
const points = s.timestamp_ms.map((t, i) => ({ t, v: s.avg[i] }));
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
`/v1/fleet/metrics` uses the same columnar shape, one entry per source under `sources[]`.
|
|
116
|
+
|
|
117
|
+
### Live (data API, not the gateway)
|
|
118
|
+
|
|
119
|
+
```
|
|
120
|
+
WS wss://plexus-data-api.fly.dev/v1/sources/{source_id}/metrics/stream?metrics=a,b
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
Also `/logs/stream` and `/video/stream` under the same source prefix.
|
|
124
|
+
|
|
125
|
+
**Auth is the first message, not a header.** Immediately after connect, send:
|
|
126
|
+
|
|
127
|
+
```json
|
|
128
|
+
{ "type": "auth", "api_key": "plx_..." }
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
Then just listen — there is no separate `subscribe` message, and the `?metrics=` query param does the filtering server-side. Because auth is a WS message rather than a header, a **browser can connect directly** — no backend relay needed for auth reasons alone (though you still shouldn't ship a key in public client code).
|
|
132
|
+
|
|
133
|
+
Frames you receive:
|
|
134
|
+
|
|
135
|
+
- `{"type":"telemetry","points":[ {class, metric, value, timestamp, ...}, ... ]}` — **batched**, an array per frame, same point shape as ingest. Iterate `points`.
|
|
136
|
+
- `{"type":"gateway_reconnecting","attempt":N,"delay_s":N}` — informational; the server is reconnecting upstream and will resume.
|
|
137
|
+
|
|
138
|
+
Close codes: `4401` unauthorized (bad or missing key, or no auth message within 10s), `4402` payment required (org access disabled).
|
|
139
|
+
|
|
140
|
+
You do **not** need to answer application-level pings on this endpoint; keepalive is handled at the protocol layer.
|
|
141
|
+
|
|
142
|
+
The gateway's own sockets (`/ws/device`, `/ws/browser`) are for the Python SDK and the Plexus app respectively. Don't write third-party clients against them. There is no `/v1/stream` on the gateway.
|
|
143
|
+
|
|
144
|
+
### Control (data API)
|
|
145
|
+
|
|
146
|
+
`POST /v1/sources/{id}/commands` — body `{ command, params? }`, response `{ queued: bool }`. Confirm with the user before sending — these hit physical hardware.
|
|
147
|
+
|
|
148
|
+
## Standard scaffolding
|
|
149
|
+
|
|
150
|
+
When asked to "set up Plexus" in a project, do this:
|
|
151
|
+
|
|
152
|
+
1. **Detect the language** from the project's manifest (`package.json`, `pyproject.toml`, `go.mod`, `Cargo.toml`). For Python, prefer the SDK: `pip install plexus-python`.
|
|
153
|
+
2. **Create one client module** under `src/lib/plexus.{ts,py,go}` (or the project's idiomatic location). One function per endpoint the user actually needs — don't scaffold the whole surface if they only want ingest.
|
|
154
|
+
3. **Throw a typed error class** (`PlexusError` with `status` + `message`) on non-2xx responses. Map 401 to "check your PLEXUS_API_KEY".
|
|
155
|
+
4. **Read the API key from env**, never hardcode. If the user is shipping a public client app, warn them about exposing keys and recommend a backend proxy.
|
|
156
|
+
5. **Add a single usage example** in the project — one obvious place, not five — so they can verify it works.
|
|
157
|
+
6. **Update README** with a short section: "Set `PLEXUS_API_KEY`, then run X."
|
|
158
|
+
|
|
159
|
+
## Idioms
|
|
160
|
+
|
|
161
|
+
- **Polling cadences for dashboards**: latest values 5s, charts 10s, fleet health 10s, source list 30s. Use SWR or TanStack Query with `refreshInterval`.
|
|
162
|
+
- **Time ranges**: prefer `last=1h` (relative) over `start`/`end` (absolute) — easier to reason about and less timezone footgun. `start`/`end` are ISO date-times, not epoch ms.
|
|
163
|
+
- **Batching ingest**: buffer up to 64 points or 5 seconds, whichever first. On 429 / 5xx, exponential backoff with max 3 attempts.
|
|
164
|
+
- **Source IDs are slugs**: `drone-001`, `sat-alpha-3`. Must match `^[a-z0-9][a-z0-9_-]{1,62}$`. Stable, lowercase, hyphenated. Don't use UUIDs in user-facing surfaces.
|
|
165
|
+
- **Source IDs are not deduplicated.** The gateway writes whatever `source_id` you declare. Two devices declaring the same name merge into one source.
|
|
166
|
+
- **Metric names are opaque and may be long.** Anything that round-trips a name must use the identical string on both sides or the series and its metadata will not join.
|
|
167
|
+
|
|
168
|
+
## Common pitfalls
|
|
169
|
+
|
|
170
|
+
- **`points`, not `metrics`, on ingest**, and every point needs `class`. The two most common 400s.
|
|
171
|
+
- **Numeric timestamps only.** ISO strings 400. Milliseconds unless the value is under `1e12`, in which case it's read as seconds.
|
|
172
|
+
- **The query response is columnar.** `series[m].avg[i]`, not `series[m][i].v`. Reaching for `.t`/`.v` yields `undefined` and an empty chart with no error.
|
|
173
|
+
- **The live stream is on the data API, and auths by first message.** There is no `/v1/stream` on the gateway.
|
|
174
|
+
- **Telemetry frames are batched** — `points` is an array. Handling one frame as one point silently drops data.
|
|
175
|
+
- `start`/`end` are **ISO date-times on every endpoint** that takes them — `query`, `logs` and `fleet/metrics` alike. `last=1h` is easier and works on all three.
|
|
176
|
+
- `auto_downsampled: true` in a query response means the bucket size was picked for you — surface it in the UI so users understand what they're looking at.
|
|
177
|
+
- Commands queue on the device; they don't execute synchronously. Don't promise the user "it rebooted" — promise "reboot queued".
|
|
178
|
+
|
|
179
|
+
## When unsure
|
|
180
|
+
|
|
181
|
+
Fetch `https://plexus-data-api.fly.dev/openapi.json` for the authoritative HTTP schema (it will not show WebSocket routes), and check a real response before writing parsing code. `scripts/verify_skills.py` in this repo checks these docs against the live spec — run it if something here looks stale.
|
|
182
|
+
|
|
183
|
+
This cheat sheet has drifted before. Corrected 2026-08-27 against Data API 0.1.0 (ingest array name, `sources`/`devices` paths, removed per-source health, columnar query) and again 2026-08-28 against gateway + API source (the live-stream host/path/auth/frame shape, the required `class` field, numeric-only timestamps, the redirect chain, and the now-fixed 1970 timestamp behavior).
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: plexus-dashboard
|
|
3
|
+
description: Scaffold a working web dashboard against the Plexus telemetry API — device picker, latest-value tiles, time-series charts, log viewer. Use when the user wants to build a frontend, dashboard, ops UI, mission control, or fleet view on top of Plexus data. Triggering phrases include "show me my drones/satellites/robots", "build a dashboard for my fleet", "vibe code a Plexus frontend", "fleet monitoring UI", "telemetry dashboard", "device status page", "live charts of sensor data", or "ops view for my hardware" — even if "Plexus" is never said, as long as the data source is the Plexus API.
|
|
4
|
+
tools: Read, Write, Edit, Bash, WebFetch
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# Plexus Dashboard
|
|
8
|
+
|
|
9
|
+
Scaffolds a complete web dashboard against the Plexus read API. This skill is opinionated about the stack so the user gets a working dashboard fast — they can swap pieces out later.
|
|
10
|
+
|
|
11
|
+
## When to use this skill
|
|
12
|
+
|
|
13
|
+
- The user is building a frontend / dashboard / ops UI on top of Plexus telemetry
|
|
14
|
+
- They mention "vibe code" + Plexus, or want to "build a dashboard for my fleet"
|
|
15
|
+
- They have a Plexus API key and want to _see_ their data, not just ingest it
|
|
16
|
+
|
|
17
|
+
If they need help with **ingesting** data (firmware / edge), use `plexus-firmware` instead.
|
|
18
|
+
For lower-level **API integration** without a UI, use the generic `plexus` skill.
|
|
19
|
+
|
|
20
|
+
## Preferred stack
|
|
21
|
+
|
|
22
|
+
Pick this unless the user explicitly wants something else:
|
|
23
|
+
|
|
24
|
+
- **Framework**: Next.js (App Router) — it's the default and Vercel-friendly
|
|
25
|
+
- **Styling**: Tailwind utilities only, no design system
|
|
26
|
+
- **Data fetching**: SWR (lighter than TanStack Query for this use case)
|
|
27
|
+
- **Charts**: Recharts (good defaults, easy to customize, no canvas)
|
|
28
|
+
- **Types**: TypeScript, strict
|
|
29
|
+
|
|
30
|
+
If the project is already React Router / Remix / Vite, adapt — don't force Next.js on top.
|
|
31
|
+
|
|
32
|
+
## Shape of the thing
|
|
33
|
+
|
|
34
|
+
```
|
|
35
|
+
Dashboard
|
|
36
|
+
├─ Source list (left rail) — /v1/sources, `online` + `last_seen_ms` are on each row
|
|
37
|
+
└─ Detail pane
|
|
38
|
+
├─ Tile row: every metric from /metrics/latest as a big-number card
|
|
39
|
+
├─ Chart grid: one line chart per metric over the last 1h via /metrics/query
|
|
40
|
+
└─ (Optional) Log pane: /logs in a virtualized list
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
## Polling cadences
|
|
44
|
+
|
|
45
|
+
| Endpoint | Refresh interval |
|
|
46
|
+
| --------------------------------- | ---------------- |
|
|
47
|
+
| `/v1/sources` | 30s |
|
|
48
|
+
| `/v1/sources/{id}/metrics/latest` | 5s |
|
|
49
|
+
| `/v1/sources/{id}/metrics/query` | 10s |
|
|
50
|
+
| `/v1/fleet/health` | 10s |
|
|
51
|
+
|
|
52
|
+
Always use `refreshInterval` on SWR. Never use `setInterval` directly — SWR handles tab visibility, focus revalidation, and dedup.
|
|
53
|
+
|
|
54
|
+
**There is no per-source health endpoint.** `/v1/sources/{id}/health` 404s. Online status already rides along on `/v1/sources` — every row carries `online` and `last_seen_ms`, so a status dot needs no extra request. Don't poll for it.
|
|
55
|
+
|
|
56
|
+
Use `/v1/sources` (canonical). `/v1/devices` still works via a 307→308 redirect chain but is deprecated.
|
|
57
|
+
|
|
58
|
+
## Scaffolding workflow
|
|
59
|
+
|
|
60
|
+
When the user says "build me a Plexus dashboard":
|
|
61
|
+
|
|
62
|
+
### Step 1: Confirm scope
|
|
63
|
+
|
|
64
|
+
Ask exactly two questions, no more:
|
|
65
|
+
|
|
66
|
+
1. "Which sources? All of them, or one specific source_id?"
|
|
67
|
+
2. "Existing project to extend, or a fresh one?"
|
|
68
|
+
|
|
69
|
+
### Step 2: Set up the client
|
|
70
|
+
|
|
71
|
+
Create `src/lib/plexus.ts`:
|
|
72
|
+
|
|
73
|
+
```ts
|
|
74
|
+
const BASE = "https://plexus-data-api.fly.dev";
|
|
75
|
+
const KEY = process.env.NEXT_PUBLIC_PLEXUS_API_KEY!;
|
|
76
|
+
|
|
77
|
+
export class PlexusError extends Error {
|
|
78
|
+
constructor(
|
|
79
|
+
public status: number,
|
|
80
|
+
message: string,
|
|
81
|
+
) {
|
|
82
|
+
super(message);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async function get<T>(path: string): Promise<T> {
|
|
87
|
+
const r = await fetch(`${BASE}${path}`, { headers: { "x-api-key": KEY } });
|
|
88
|
+
if (!r.ok) throw new PlexusError(r.status, await r.text());
|
|
89
|
+
return r.json();
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Note: the path is `sources`, the response key is still `devices`. */
|
|
93
|
+
export type Source = { source_id: string; online: boolean; last_seen_ms: number | null };
|
|
94
|
+
export type Latest = { metrics: Record<string, number> };
|
|
95
|
+
|
|
96
|
+
/** The query response is COLUMNAR — parallel arrays, not an array of points. */
|
|
97
|
+
export type Series = {
|
|
98
|
+
timestamp_ms: number[];
|
|
99
|
+
min: number[];
|
|
100
|
+
max: number[];
|
|
101
|
+
avg: number[];
|
|
102
|
+
count: number[];
|
|
103
|
+
};
|
|
104
|
+
export type Query = {
|
|
105
|
+
interval: string;
|
|
106
|
+
auto_downsampled: boolean;
|
|
107
|
+
truncated: boolean;
|
|
108
|
+
series: Record<string, Series>;
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
export const listSources = () => get<{ devices: Source[] }>("/v1/sources");
|
|
112
|
+
export const getLatest = (id: string) =>
|
|
113
|
+
get<Latest>(`/v1/sources/${id}/metrics/latest`);
|
|
114
|
+
export const queryMetrics = (id: string, metrics: string[], last = "1h") =>
|
|
115
|
+
get<Query>(
|
|
116
|
+
`/v1/sources/${id}/metrics/query?metrics=${metrics.join(",")}&last=${last}`,
|
|
117
|
+
);
|
|
118
|
+
export const fleetHealth = () =>
|
|
119
|
+
get<{ sources_total: number; sources_online: number }>("/v1/fleet/health");
|
|
120
|
+
|
|
121
|
+
/** Zip the columnar response into what Recharts wants. */
|
|
122
|
+
export function toPoints(s: Series) {
|
|
123
|
+
return s.timestamp_ms.map((t, i) => ({ t, v: s.avg[i] }));
|
|
124
|
+
}
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
### Step 3: Build the components
|
|
128
|
+
|
|
129
|
+
- `<SourceList>` — `useSWR("sources", listSources, { refreshInterval: 30_000 })`, renders `data.devices`
|
|
130
|
+
- `<StatusDot source>` — pure render off the `online` field already on the row. **No fetch.**
|
|
131
|
+
- `<LatestTiles sourceId>` — polls `/metrics/latest` every 5s, renders each entry as a card
|
|
132
|
+
- `<MetricChart sourceId metric range>` — `useSWR([id, metric, range], () => queryMetrics(id, [metric], range))`, then `toPoints(data.series[metric])` into a Recharts `<LineChart>`
|
|
133
|
+
- Surface `auto_downsampled` in the chart header ("1m buckets") so nobody misreads a smoothed line as raw data
|
|
134
|
+
|
|
135
|
+
### Step 4: Empty + error states
|
|
136
|
+
|
|
137
|
+
- If `/v1/sources` returns `{ devices: [] }`: show "No sources yet" with a copy-able curl pointing at `https://gateway.plexus.company/ingest`.
|
|
138
|
+
- On `PlexusError` with status 401: show "Check your PLEXUS_API_KEY".
|
|
139
|
+
- On other errors: log + small toast, don't blow up the UI.
|
|
140
|
+
|
|
141
|
+
### Step 5: Don't ship without
|
|
142
|
+
|
|
143
|
+
- Setting `NEXT_PUBLIC_PLEXUS_API_KEY` in `.env.local` (warn the user about exposure if their app is public)
|
|
144
|
+
- A README section with the env var + a one-line description
|
|
145
|
+
- One `npm run dev` smoke test before declaring done
|
|
146
|
+
|
|
147
|
+
## Upgrade path: drop polling, use the live stream
|
|
148
|
+
|
|
149
|
+
Once polling works end-to-end, swap `getLatest` polling for the WebSocket stream. Latest-value tiles update the moment a point is ingested instead of on the next 5s tick — feels dramatically better.
|
|
150
|
+
|
|
151
|
+
The stream is on the **data API**, not the gateway, and it **authenticates with its first message rather than a header** — which means a browser can connect directly, no relay needed:
|
|
152
|
+
|
|
153
|
+
```ts
|
|
154
|
+
export function subscribeMetrics(
|
|
155
|
+
sourceId: string,
|
|
156
|
+
metrics: string[],
|
|
157
|
+
onPoints: (pts: Array<{ metric: string; value: number; timestamp: number }>) => void,
|
|
158
|
+
) {
|
|
159
|
+
const qs = metrics.length ? `?metrics=${metrics.join(",")}` : "";
|
|
160
|
+
const ws = new WebSocket(
|
|
161
|
+
`wss://plexus-data-api.fly.dev/v1/sources/${sourceId}/metrics/stream${qs}`,
|
|
162
|
+
);
|
|
163
|
+
|
|
164
|
+
// Auth is the FIRST MESSAGE, not a header. Send it within 10s or the
|
|
165
|
+
// server closes with 4401.
|
|
166
|
+
ws.onopen = () => ws.send(JSON.stringify({ type: "auth", api_key: KEY }));
|
|
167
|
+
|
|
168
|
+
ws.onmessage = (e) => {
|
|
169
|
+
const m = JSON.parse(e.data);
|
|
170
|
+
// Frames are BATCHED: `points` is an array. Treating one frame as one
|
|
171
|
+
// point silently drops data.
|
|
172
|
+
if (m.type === "telemetry") onPoints(m.points);
|
|
173
|
+
// m.type === "gateway_reconnecting" is informational; it resumes itself.
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
ws.onclose = (e) => {
|
|
177
|
+
// 4401 = bad/missing key, 4402 = org access disabled. Neither is worth
|
|
178
|
+
// retrying; anything else, reconnect with exponential backoff.
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
return () => ws.close();
|
|
182
|
+
}
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
No `pong` handling is needed on this endpoint — keepalive is at the protocol layer.
|
|
186
|
+
|
|
187
|
+
**Key exposure still applies.** Auth-by-message solves the browser's header limitation, not the "don't ship a secret to the public internet" problem. If the app is publicly reachable, put a relay in front and keep the key server-side.
|
|
188
|
+
|
|
189
|
+
Wire it alongside SWR: keep `useSWR` for initial load (UI hydrates with a value immediately) and let stream frames update the same cache via `mutate(key, fn, false)`. Charts can keep polling — querying a 1h window over WS is awkward and the 10s cadence is fine.
|
|
190
|
+
|
|
191
|
+
## What NOT to do
|
|
192
|
+
|
|
193
|
+
- Don't add auth/login flows. Tier_1 users have a single API key; this is meant to be embedded in their own auth-protected app.
|
|
194
|
+
- Don't add a backend layer "for safety" unless the user asks or the app is public. Direct browser → data API is the intended pattern for prototypes.
|
|
195
|
+
- Don't poll a per-source health endpoint. It doesn't exist.
|
|
196
|
+
- Don't type the query response as an array. It is columnar, and `.map(p => p.v)` over it yields `undefined` with no error — an empty chart and no clue why.
|
|
197
|
+
- Don't mock data. If the user has no sources yet, show the empty state with the ingest curl, not fake telemetry.
|
|
198
|
+
- Don't over-style. Tailwind utilities, gray scale, one accent color. The whole point is they can iterate on the design themselves.
|
|
199
|
+
|
|
200
|
+
## When unsure about endpoint shapes
|
|
201
|
+
|
|
202
|
+
`https://plexus-data-api.fly.dev/openapi.json` is the source of truth for HTTP. It does **not** list WebSocket routes — the generic `plexus` skill documents those.
|
|
203
|
+
|
|
204
|
+
Corrected 2026-08-28: `/v1/devices` → `/v1/sources`, removed the non-existent per-source health endpoint, fixed the columnar query type, and replaced the live-stream section (the old `wss://gateway.plexus.company/v1/stream` 404s, and the "browsers can't authenticate" caveat no longer holds).
|