Capability report: PUT /devices/{id}/report, a Capabilities section, offline devices degraded

A device reports its board, screen, camera and ports from its own
credential (16 KB, schema 1, replacing the last report), and the flows
are validated against it again. The node panel turns a reported port's
config schema into a form, a reported screen is a canvas preset and a
touch screen's first report turns Touch friendly on. An offline device
node is degraded: amber on the canvas, a warning on Home, never a fault.
This commit is contained in:
Melvin Strobl
2026-10-16 17:37:49 +02:00
parent 1dc891aa1b
commit 4286acadfd
28 changed files with 820 additions and 129 deletions
+7 -4
View File
@@ -189,10 +189,10 @@ def _device_may(payload: dict[str, Any], request: Request) -> None:
A panel credential names the account that approved the pairing, so without
this it would be that person's session hanging on a wall. What a panel
genuinely needs is small and worth writing out: the dashboards it was
assigned, its own definition, the messages its own widgets and ports bind
to, and the bytes those messages currently point at, for a tile drawing a
camera frame — plus an upload named after one of its ``in`` ports, for a
microphone whose recording is the value it publishes.
assigned, its own definition and report, the messages its own widgets and
ports bind to, and the bytes those messages currently point at, for a tile
drawing a camera frame — plus an upload named after one of its ``in``
ports, for a microphone whose recording is the value it publishes.
Publishing is in the list because a panel cannot be strictly read-only — a
control on a panel is the point of putting one there, and a querying chart
@@ -239,6 +239,9 @@ def _device_may(payload: dict[str, Any], request: Request) -> None:
f"{api}/panels/{device.id}",
):
allowed = True
elif method == "PUT" and path == f"{api}/devices/{device.id}/report":
# What it has, said about itself and nobody else.
allowed = True
elif method == "POST" and path == f"{api}/flows/ws-ticket":
# How its live socket opens; the socket applies the panel's own scope.
allowed = True
+77 -11
View File
@@ -18,8 +18,13 @@ device nodes in published flows wire its own ports to, and nothing else.
Deleting the device revokes it, and so does unpairing, which bumps the
device's nonce and leaves what it shows as it was.
``/panels/pair``, ``/panels/pair/{code}`` and ``GET /panels/{id}`` are what the
openpanel firmware calls, and stay as aliases until every device has updated.
Once paired, a device says what it has with ``PUT /devices/{id}/report``: its
board, its screen and the ports it serves, which the device node's ports are
checked against.
``/panels/pair``, ``/panels/pair/{code}`` and ``GET /panels/{id}`` are what
openpanel firmware from before devices calls, and stay as aliases until every
device has updated.
"""
from __future__ import annotations
@@ -34,7 +39,7 @@ import httpx
import redis
from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi.concurrency import run_in_threadpool
from pydantic import BaseModel, Field, field_validator
from pydantic import BaseModel, Field, ValidationError, field_validator
from fluksio.api.deps import (
CurrentUser,
@@ -52,6 +57,7 @@ from fluksio.flow.devices import (
DeviceExists,
DeviceIo,
DeviceNotFound,
DeviceReport,
DeviceStore,
is_lan,
presence,
@@ -88,6 +94,10 @@ RESERVED = {"pair", "pending"}
#: How much of what a device says about itself is kept.
HELLO_LENGTH = 64
#: The most a capability report may weigh. A panel with every header pin
#: reported is a few kB.
REPORT_MAX = 16 * 1024
class _Pending(BaseModel):
"""A device waiting to be told what it is."""
@@ -314,6 +324,7 @@ class DevicePublic(DeviceDocument):
last_seen: datetime | None = None
#: What the device says it has, as it last said so. None until it reports.
report: dict[str, Any] | None = None
reported_at: datetime | None = None
class DevicesPublic(BaseModel):
@@ -499,15 +510,19 @@ async def read_devices(store: DeviceStoreDep) -> Any:
"""Every device, what each one shows, and whether it is connected."""
def gather() -> list[DevicePublic]:
return [
DevicePublic(
**_document(device, store).model_dump(),
online=presence.online(device.id),
last_seen=presence.last_seen(device.id),
report=store.state(device.id).report,
found = []
for device in store.list():
state = store.state(device.id)
found.append(
DevicePublic(
**_document(device, store).model_dump(),
online=presence.online(device.id),
last_seen=presence.last_seen(device.id),
report=state.report,
reported_at=state.reported_at,
)
)
for device in store.list()
]
return found
data = await run_in_threadpool(gather)
return DevicesPublic(
@@ -596,6 +611,57 @@ panels_router.add_api_route(
)
@router.put(
"/{device_id}/report",
response_model=Message,
dependencies=[Depends(get_current_user)],
)
async def put_report(device_id: str, request: Request, store: DeviceStoreDep) -> Any:
"""A device says what it has: its board, its screen, its ports.
Only the device itself, through its own credential, which ``_device_may``
holds to its own id. The report replaces the one before it, and the flows
are validated again, since a device node's ports are checked against it.
The first report of a touch screen makes the device touch friendly, as
the switch would have been set by hand. Later ones leave the setting to
whoever changed it since.
"""
device: DeviceDef | None = getattr(request.state, "device", None)
if device is None or device.id != device_id:
raise HTTPException(
status_code=403, detail="Only the device itself reports what it has"
)
raw = bytearray()
async for chunk in request.stream():
raw += chunk
if len(raw) > REPORT_MAX:
raise HTTPException(
status_code=413,
detail=f"A report is at most {REPORT_MAX // 1024} KB",
)
try:
report = DeviceReport.model_validate_json(bytes(raw))
except ValidationError as exc:
raise HTTPException(
status_code=422,
detail=exc.errors(
include_url=False, include_input=False, include_context=False
),
)
first = await run_in_threadpool(store.report, device_id, report)
if first and report.display and report.display.touch and not device.touch:
with suppress(StaleVersion, DeviceNotFound):
await run_in_threadpool(
store.write,
device.model_copy(update={"touch": True}),
device.version,
)
_changed()
await _recheck(request)
return Message(message=f"{len(report.ports)} ports reported")
@router.put(
"/{device_id}",
response_model=DeviceDef,
@@ -53,6 +53,8 @@ def _epoch(column: Any) -> ColumnElement[int]:
class HealthSummary(BaseModel):
status: str
problems: list[str]
#: Worth a look, but not faults: they leave ``status`` as it is.
warnings: list[str] = []
flows: dict[str, int]
nodes: dict[str, int]
queue: dict[str, Any]
@@ -241,9 +243,24 @@ async def read_summary(request: Request, controller: FlowControllerDep) -> Any:
if invalid:
problems.append(f"{len(invalid)} flow(s) cannot run: {', '.join(invalid)}")
# Working, if not as well as they should: a device switched off, a send
# queue full. A warning, held back by the same floor as `unhealthy`.
degraded = controller.unhealthy_nodes(health="degraded")
warnings = (
[
f"{len(degraded)} node(s) degraded: "
+ ", ".join(
sorted(f"{e.id} ({e.health_detail or 'no detail'})" for e in degraded)
)
]
if degraded
else []
)
return HealthSummary(
status="degraded" if problems else "ok",
problems=problems,
warnings=warnings,
flows={
"total": len(names),
# What the engine will actually act on. Enabled is not enough:
@@ -267,6 +284,7 @@ async def read_summary(request: Request, controller: FlowControllerDep) -> Any:
"total": len(entries),
"error": len(errored),
"unhealthy": len(unhealthy),
"degraded": len(degraded),
"failing": len(failing),
},
queue=queue,
+36 -4
View File
@@ -1363,9 +1363,14 @@ class FlowController:
}
)
def unhealthy_nodes(self, flow: str | None = None) -> list[LoadedNode]:
def unhealthy_nodes(
self, flow: str | None = None, health: Health = "down"
) -> list[LoadedNode]:
"""Nodes that have been down long enough to be worth telling anyone.
Or degraded, with ``health``: working, but not as well as they should,
which is a warning rather than a fault.
The floor is on the surfacing, not on the transition: `entry.health` is
the truth the moment the node reports it, so a flow reading a node's
health is never told a stale story — only the screens wait. And only
@@ -1376,7 +1381,7 @@ class FlowController:
return [
entry
for entry in self.loaded.values()
if entry.health == "down"
if entry.health == health
and now - entry.health_since >= HEALTH_FLOOR
and (flow is None or entry.flow == flow)
]
@@ -1384,6 +1389,9 @@ class FlowController:
def _health_issues(self, flow: str | None = None) -> list[ValidationIssue]:
"""Nodes that are running but not working, as issues on their flow.
A degraded node is an advisory one: it works, if not as it should, such
as a device node whose device is switched off.
Not part of `self.issues`: that list is what a build found, and this is
what is happening now. Derived on read, so a node reporting itself well
again clears it with nothing to remember.
@@ -1399,6 +1407,17 @@ class FlowController:
node=entry.id,
)
for entry in self.unhealthy_nodes(flow)
] + [
ValidationIssue(
code="node_degraded",
message=(
f"Node '{entry.id.rpartition('.')[2]}' is degraded: "
f"{entry.health_detail or 'no detail given'}"
),
flow=entry.flow,
node=entry.id,
)
for entry in self.unhealthy_nodes(flow, "degraded")
]
def flow_issues(self, flow: str) -> list[ValidationIssue]:
@@ -2101,6 +2120,10 @@ def _collect_issues(
return issues
#: The dtypes a device port reads as a plain number.
_NUMBERS = frozenset({"int", "float"})
def _device_issues(
loaded: dict[str, LoadedNode], devices: Any
) -> list[ValidationIssue]:
@@ -2111,6 +2134,10 @@ def _device_issues(
direction or of another dtype is an error too, and one the device does not
list is a warning: the device may know it all the same. Without a report
a port is the device's business and nothing is said about it.
A port reporting ``modes`` may be wired in any of them, as a GPIO pin is
either way. ``int`` and ``float`` pass for each other: the device reads a
number either way.
"""
if devices is None:
return []
@@ -2163,12 +2190,17 @@ def _device_issues(
elif said is None:
problem = f"'{device}' does not report a port '{spec.port}'."
advisory = True
elif said.get("direction") and said["direction"] != direction:
elif said.get("direction") and direction not in (
said.get("modes") or [said["direction"]]
):
problem = (
f"'{spec.port}' of '{device}' is an {said['direction']} "
f"port, wired here as {direction}."
)
elif said.get("dtype") and said["dtype"] != spec.dtype.value:
elif said.get("dtype") and not (
said["dtype"] == spec.dtype.value
or {said["dtype"], spec.dtype.value} <= _NUMBERS
):
problem = (
f"'{spec.port}' of '{device}' carries {said['dtype']}, "
f"wired here to a {spec.dtype.value} message."
+76 -1
View File
@@ -38,7 +38,13 @@ from pydantic import BaseModel, Field, TypeAdapter, ValidationError, field_valid
from fluksio.flow.dashboards import DashboardDef, DashboardNotFound, DashboardStore
from fluksio.flow.events import event_bus
from fluksio.flow.messages import MessageSpec, flow_of, qualify, validate_qualified
from fluksio.flow.messages import (
DType,
MessageSpec,
flow_of,
qualify,
validate_qualified,
)
from fluksio.flow.schemas import FlowDef, NodeDef, _validate_name
from fluksio.flow.store import FlowStore, InvalidName, StaleVersion, checked
@@ -158,6 +164,61 @@ class DeviceState(BaseModel):
_STATES = TypeAdapter(dict[str, DeviceState])
class ReportDisplay(BaseModel):
"""The device's screen, in pixels."""
w: int = Field(ge=0)
h: int = Field(ge=0)
shape: Literal["rect", "round"] = "rect"
touch: bool = False
class ReportPort(BaseModel):
"""One port the device has, and how it may be wired."""
port: str = Field(pattern=PORT_PATTERN.pattern)
#: Its default direction: ``in`` publishes, ``out`` follows.
direction: Literal["in", "out"]
dtype: DType
#: A JSON schema of the ``config`` the device reads for this port.
config: dict[str, Any] | None = None
#: Sends only while somebody wants it, such as a camera.
on_demand: bool | None = None
#: The directions it may be wired in, when it may be either: a GPIO pin.
modes: list[Literal["in", "out"]] | None = None
class ReportCamera(BaseModel):
"""The camera sensor the device found, and what it delivers."""
sensor: str = ""
w: int = 0
h: int = 0
fps: int = 0
class DeviceReport(BaseModel):
"""What a device says it has, version 1.
Keys this version does not know are dropped, so a newer firmware still
reports to an older instance. The whole report replaces the one before.
"""
version: Literal[1] = Field(alias="schema")
platform: str = ""
board: str = ""
firmware: str = ""
display: ReportDisplay | None = None
ports: list[ReportPort] = Field(default_factory=list)
camera: ReportCamera | None = None
#: The build's features, true when there: ``{"camera": true}``.
features: dict[str, bool] = Field(default_factory=dict)
def stored(self) -> dict[str, Any]:
"""As ``devices.json`` keeps it: the device's own keys, nothing unset."""
return self.model_dump(mode="json", by_alias=True, exclude_none=True)
class DeviceNotFound(KeyError):
def __init__(self, device_id: str) -> None:
super().__init__(device_id)
@@ -372,6 +433,20 @@ class DeviceStore:
self._write_states(states)
return state.nonce
def report(self, device_id: str, report: DeviceReport) -> bool:
"""Keep what the device says it has, in place of what it said before.
Returns whether it is the first report this device made.
"""
with self._state_lock:
states = self._read_states()
state = states.setdefault(device_id, DeviceState())
first = state.report is None
state.report = report.stored()
state.reported_at = datetime.now(UTC)
self._write_states(states)
return first
# -- the panels file of an older instance -------------------------------
def migrate(self, panels_file: Path) -> None:
+4 -2
View File
@@ -31,7 +31,7 @@ def _never(**_: Any) -> None:
class DeviceNode(Node):
"""A device's ports, wired to messages. Down while the device is away."""
"""A device's ports, wired to messages. Degraded while the device is away."""
passive = True
@@ -81,7 +81,9 @@ class DeviceNode(Node):
presence.unwatch(self.cfg.device, self._presence)
def _presence(self, online: bool) -> None:
# Degraded rather than down: a panel switched off or asleep is a thing
# to notice, not a fault of the flow wiring it.
if online:
self.report_health("ok")
else:
self.report_health("down", "Offline")
self.report_health("degraded", "Offline")
+2 -1
View File
@@ -47,7 +47,7 @@ MANUAL_RUN_PREFIX = "manual-"
# neither the brain graph nor the health summary treats them as a fault.
# Lives here rather than beside the controller so `ValidationIssue` can carry
# the distinction itself, and every reader gets it for free.
ADVISORY_ISSUES = frozenset({"unauthenticated_hook", "ignored_worker"})
ADVISORY_ISSUES = frozenset({"unauthenticated_hook", "ignored_worker", "node_degraded"})
class ValidationIssue(BaseModel):
@@ -62,6 +62,7 @@ class ValidationIssue(BaseModel):
"self_loop_needs_initial",
"missing_source",
"node_unhealthy",
"node_degraded",
"ignored_worker",
"unknown_device",
"device_port",
+2 -2
View File
@@ -182,8 +182,8 @@ async def list_shared_nodes() -> Any:
@mcp.tool()
async def list_devices() -> Any:
"""Every paired screen or board, the dashboards it shows and whether it is
connected."""
"""Every paired screen or board, the dashboards it shows, whether it is
connected and what it reports having."""
return await _call("GET", "/devices/")
+92
View File
@@ -803,6 +803,98 @@ def test_a_port_wired_twice_or_an_unknown_device_is_an_issue(
client.delete(f"{FLOWS}/{flow}", headers=superuser_token_headers)
#: What a P4 panel reports: a GPIO pin either way, its backlight and touch.
REPORT = {
"schema": 1,
"platform": "esp32p4",
"board": "p4",
"firmware": "0.1.2",
"display": {"w": 1024, "h": 600, "shape": "rect", "touch": True},
"ports": [
{"port": "backlight", "direction": "out", "dtype": "int"},
{"port": "touch", "direction": "in", "dtype": "bool"},
{
"port": "gpio.4",
"direction": "in",
"modes": ["in", "out"],
"dtype": "bool",
"config": {"type": "object", "properties": {"invert": {"type": "boolean"}}},
},
],
"features": {"display": True},
# A newer firmware's key, which this version drops.
"battery": {"level": 80},
}
def test_a_device_reports_what_it_has(
client: TestClient, superuser_token_headers: dict[str, str]
) -> None:
"""Only from its own credential, at most 16 KB, the whole report replaced.
The device node's ports are checked against it from then on.
"""
_panels(
client,
superuser_token_headers,
{"panels": [{"id": "porch_io"}, {"id": "other"}]},
)
_publish_flow(client, superuser_token_headers, "porch", [PORCH_NODE])
device = _pair(client, superuser_token_headers, "porch_io")
other = _pair(client, superuser_token_headers, "other")
url = f"{PREFIX}/porch_io/report"
def issues() -> set[tuple[str | None, bool]]:
found = client.get(f"{FLOWS}/porch", headers=superuser_token_headers).json()
return {
(i["port"], i["advisory"])
for i in found["issues"]
if i["code"] == "device_port"
}
# Without a report the ports are the device's business.
assert issues() == set()
assert (
client.put(url, headers=superuser_token_headers, json=REPORT).status_code == 403
)
assert client.put(url, headers=other, json=REPORT).status_code == 403
padded = {**REPORT, "firmware": "x" * 20_000}
assert client.put(url, headers=device, json=padded).status_code == 413
assert (
client.put(url, headers=device, json={**REPORT, "schema": 2}).status_code == 422
)
put = client.put(url, headers=device, json=REPORT)
assert put.status_code == 200, put.text
listed = client.get(f"{PREFIX}/", headers=superuser_token_headers).json()
stored = next(d for d in listed["data"] if d["id"] == "porch_io")
assert stored["reported_at"]
assert "battery" not in stored["report"]
assert stored["report"]["ports"][2]["modes"] == ["in", "out"]
# A touch screen's first report makes the device touch friendly.
assert stored["touch"] is True
# What the device reads stays what it was: no report, no presence.
read = client.get(f"{PREFIX}/porch_io", headers=device).json()
assert "report" not in read and read["touch"] is True
# gpio.4 is wired as in, which its modes allow, and the backlight as int.
assert issues() == set()
# A report replaces the one before: the backlight gone, gpio.4 out only.
gpio = {"port": "gpio.4", "direction": "out", "dtype": "bool"}
assert (
client.put(url, headers=device, json={**REPORT, "ports": [gpio]}).status_code
== 200
)
assert issues() == {("gpio.4", False), ("backlight", True)}
client.delete(f"{FLOWS}/porch", headers=superuser_token_headers)
# The other tests wire porch_io without a report.
store = app.state.device_store
with store._state_lock:
states = store._read_states()
states["porch_io"].report = None
store._write_states(states)
def test_publishing_a_flow_with_device_nodes_tells_the_devices(
client: TestClient, superuser_token_headers: dict[str, str]
) -> None:
+28 -5
View File
@@ -1,4 +1,4 @@
"""A device node: never run by the engine, down while its device is away."""
"""A device node: never run by the engine, degraded while its device is away."""
import asyncio
from pathlib import Path
@@ -67,7 +67,7 @@ def test_the_engine_never_runs_a_device_node(tmp_path: Path) -> None:
asyncio.run(controller._teardown())
def test_a_device_node_is_down_while_its_device_is_away(tmp_path: Path) -> None:
def test_a_device_node_is_degraded_while_its_device_is_away(tmp_path: Path) -> None:
controller = _controller(tmp_path)
async def scenario() -> list[tuple[str, str | None]]:
@@ -85,10 +85,33 @@ def test_a_device_node_is_down_while_its_device_is_away(tmp_path: Path) -> None:
presence.closed("wall")
return seen
# Degraded, not down: a switched-off panel is a warning, never a fault.
assert asyncio.run(scenario()) == [
("down", "Offline"),
("degraded", "Offline"),
("ok", None),
("down", "Offline"),
("down", "Offline"),
("degraded", "Offline"),
("degraded", "Offline"),
]
presence.forget("wall")
def test_an_offline_device_is_a_warning_not_a_fault(tmp_path: Path) -> None:
controller = _controller(tmp_path)
async def scenario() -> list:
await controller.reload()
# Past the floor that holds back a device rebooting.
controller.loaded["hall.panel"].health_since -= 3600
found = (
controller.unhealthy_nodes(),
[e.id for e in controller.unhealthy_nodes(health="degraded")],
[(i.code, i.advisory) for i in controller.flow_issues("hall")],
)
await controller._teardown()
return list(found)
assert asyncio.run(scenario()) == [
[],
["hall.panel"],
[("node_degraded", True)],
]
+2 -1
View File
@@ -190,7 +190,7 @@ on its own, so this is for reclaiming space sooner.
| `PUT` | `/dashboards/{name}` | save a draft |
| `POST` | `/dashboards/{name}/publish`, `/dashboards/{name}/discard`, `/dashboards/{name}/rename` | publish, discard, or rename |
| `POST` | `/dashboards/{name}/duplicate` | copy into an unpublished `{name}_copy` |
| `GET` | `/devices/` | list devices, with `online` and `last_seen` |
| `GET` | `/devices/` | list devices, with `online`, `last_seen`, `report` and `reported_at` |
| `POST`, `GET`, `PUT`, `DELETE` | `/devices/{id}` | create, read, save or delete one; a save sends the `version` it read and gets 409 when someone saved first. A read adds `io` and `bindings`, its ports as the device nodes of published flows wire them |
| `POST` | `/devices/pair` | start device pairing, optionally with `{name, board, firmware}`; unauthenticated |
| `GET` | `/devices/pair/{code}` | poll pairing with its `secret`; answers `device` (and `panel` for older firmware); unauthenticated |
@@ -198,6 +198,7 @@ on its own, so this is for reclaiming space sooner.
| `GET` | `/devices/pair/{code}/device` | what is waiting on a code, before it is approved |
| `POST` | `/devices/{id}/pair` | adopt by `{code}`, or by `{request}` from the pending list for a request from your network |
| `POST` | `/devices/{id}/unpair` | revoke the device credential |
| `PUT` | `/devices/{id}/report` | what the device has, from its own credential only; at most 16 KB, replaces the last report |
`/panels/pair`, `/panels/pair/{code}` and `GET /panels/{id}` answer as their
`/devices/` counterparts, for firmware that has not updated yet.
+5 -4
View File
@@ -140,11 +140,12 @@ The canvas reports issues on the affected nodes:
| `ignored_worker` | a node other than a Function node names a worker, which nothing reads; advisory |
| `unknown_device` | a Device node names no device, or one that does not exist |
| `device_port` | a device port is wired in two places, or against the device's report the wrong way or type; a port the report does not list is advisory |
| `node_unhealthy` | a loaded node reports a runtime problem, such as a lost connection, or a device being offline |
| `node_unhealthy` | a loaded node reports a runtime problem, such as a lost connection |
| `node_degraded` | a loaded node works but not as it should, such as a device being offline; advisory, amber on the canvas |
Build issues prevent the affected flow from running. The last three are runtime
or advisory issues: other work continues, and node health clears when the
connection recovers.
Build issues prevent the affected flow from running. `node_unhealthy` and
`node_degraded` are runtime issues: other work continues, and they clear when
the node recovers. A degraded node is a warning on Home rather than a fault.
You can **stop** a live flow to remove its subscriptions and schedules, or
**pause** it to hold pending work while values still arrive. **Step** releases
+46 -8
View File
@@ -496,9 +496,9 @@ play MJPEG streams, pushed frames, images, and H.264 Baseline or MJPEG clips up
to 640×360. Re-encode other clips with the [clip recipe](../code/nodes.md#making-clips-playable-on-an-lvgl-panel).
The device decides what its [port](#the-devices-own-ports) names
mean. openpanel serves `gpio.<n>`, `backlight`, `touch`, `mic` and `speaker`,
lists each one's `config` in its README and ignores any other name. A browser
screen serves none of them. The [speech recipe](../code/nodes.md#speech-to-and-from-a-panel)
mean. openpanel [reports](#what-a-device-reports) the ones it serves, such as
`gpio.<n>`, `backlight`, `touch`, `mic` and `speaker`, with the settings each
one reads, and ignores any other name. A browser screen serves none of them. The [speech recipe](../code/nodes.md#speech-to-and-from-a-panel)
turns `mic` into text and text into `speaker`.
### The device's own ports
@@ -507,8 +507,8 @@ A device can also act as a node, with a button on its GPIO, its backlight or
its microphone. Its ports are wired in a flow with a **Device** node: pick the
device, then add each port under **Publishes** (`in`, the device sends to the
message) or **Follows** (`out`, the device reads it). **Use in flow** on the
device's page puts its node in a flow you pick, and **Ports** lists what the
published flows wire, each linked to its node.
device's page puts its node in a flow you pick, and **Capabilities** lists the
ports it reports and what the published flows wire, each linked to its node.
| Field | Meaning |
|---|---|
@@ -520,7 +520,8 @@ published flows wire, each linked to its node.
Only published flows count. A port wired in two of them, or a device node
naming a device that does not exist, is an error on the canvas. Until a device
reports what it has, a port name is free text; once it has, a port it does not
list is a warning and one wired the wrong way round an error.
list is a warning and one wired the wrong way round, or to another type, an
error. `int` and `float` pass for each other.
The device reads its ports as `io` on `GET /devices/{id}`, in a fixed order,
and is told to read them again when publishing or deleting a flow changes them.
@@ -528,8 +529,45 @@ The credential may publish to its `in` messages even when a dashboard is
locked, and may upload an artifact named after one
(`PUT /artifacts?name=<message>`), which is how a microphone sends a recording.
A value it publishes there is credited to its device node, so the node's edge
lights up on the canvas. `out` messages are read only. The node is shown down,
*Offline*, while the device is not connected.
lights up on the canvas. `out` messages are read only. The node is shown
degraded, *Offline*, in amber while the device is not connected: a warning on
Home, not a fault, since a panel switched off or asleep breaks nothing.
### What a device reports
A paired device says what it has with `PUT /devices/{id}/report`, from its own
credential only. openpanel sends it after pairing and once per boot. The report
replaces the one before, is at most 16 KB and keys this version does not know
are dropped:
```json
{
"schema": 1,
"platform": "esp32p4",
"board": "p4",
"firmware": "0.1.2",
"display": {"w": 1024, "h": 600, "shape": "rect", "touch": true},
"ports": [
{"port": "backlight", "direction": "out", "dtype": "int",
"config": {"type": "object", "properties": {"wake_s": {"type": "integer", "default": 30}}}},
{"port": "gpio.4", "direction": "in", "modes": ["in", "out"], "dtype": "bool"}
],
"camera": {"sensor": "IMX415", "w": 1920, "h": 1080, "fps": 15},
"features": {"display": true}
}
```
`config` is a JSON schema of the settings the device reads for that port, which
the Device node turns into a form. `modes` lists the directions a port may be
wired in when it may be either, as a GPIO pin. `on_demand` marks a port that
sends only while somebody wants it.
The device's page shows the report under **Capabilities**, with each port's
binding or **Use in flow** for it. A reported screen becomes a canvas preset in
the dashboard settings, named after the device, and the first report of a
touch screen turns **Touch friendly** on.
### Before device nodes
Before device nodes a device carried its ports itself. On its first start an
instance moves each one into the flow that declares its message, one device
+5 -3
View File
@@ -40,8 +40,9 @@ streaming toggle.
A **Device** node stands for a paired device instead: pick the device, then
its ports under **Publishes** and **Follows** (see
[the device's own ports](dashboards.md#the-devices-own-ports)). A device that
reports its ports offers them in a list with their type filled in, and the
command palette offers each one as a shortcut, such as *Hallway camera*.
reports its ports offers them in a list with their type filled in and a form
for the settings each one reads, and the command palette offers each one as a
shortcut, such as *Hallway camera*.
Message fields suggest existing names. Matching an output's message is how
you connect an input.
@@ -156,7 +157,8 @@ conflict. Reload and review their changes before retrying.
The canvas reports unconnected inputs, missing starting values, cycles and
code-load errors. These prevent the affected flow from running. Unauthenticated
webhooks are advisory; runtime node-health problems clear when the node
recovers. See [Flow issues](../concepts/flows.md#when-a-flow-needs-attention).
recovers. A degraded node, such as a device node whose device is offline, is
marked amber: it is a warning, and the flow keeps running. See [Flow issues](../concepts/flows.md#when-a-flow-needs-attention).
## See also
+2 -1
View File
@@ -57,7 +57,8 @@ a small sketch of each layout; open one for its live readings.
Health shows flow status, node load errors, queue depth and event-loop lag.
After you have used batch runs, it also shows running and queued runs.
A degraded instance lists the problems that need attention.
A degraded instance lists the problems that need attention. Warnings, such as
a device that is offline, are listed in amber and leave the status as it is.
### Execution time per node type
+3 -2
View File
@@ -400,9 +400,10 @@ never wake anything. Each port is named by the device, such as `gpio.4` or
| Setting | Default | Notes |
|---|---|---|
| `device` | | the device's id |
| `config` | `{}` | per port, a JSON object handed to the device unread |
| `config` | `{}` | per port, a JSON object handed to the device unread; a reported port's settings get a form |
The node is down, *Offline*, while the device is not connected.
The node is degraded, *Offline*, while the device is not connected: amber on
the canvas and a warning on Home, never a fault.
## Connectors
+21 -1
View File
@@ -1137,6 +1137,18 @@ export const DevicePublicSchema = {
}
],
title: 'Report'
},
reported_at: {
anyOf: [
{
type: 'string',
format: 'date-time'
},
{
type: 'null'
}
],
title: 'Reported At'
}
},
type: 'object',
@@ -1932,6 +1944,14 @@ export const HealthSummarySchema = {
type: 'array',
title: 'Problems'
},
warnings: {
items: {
type: 'string'
},
type: 'array',
title: 'Warnings',
default: []
},
flows: {
additionalProperties: {
type: 'integer'
@@ -4727,7 +4747,7 @@ export const ValidationIssueSchema = {
properties: {
code: {
type: 'string',
enum: ['cycle', 'unconnected_input', 'missing_initial_value', 'node_error', 'unauthenticated_hook', 'self_loop_needs_initial', 'missing_source', 'node_unhealthy', 'ignored_worker', 'unknown_device', 'device_port'],
enum: ['cycle', 'unconnected_input', 'missing_initial_value', 'node_error', 'unauthenticated_hook', 'self_loop_needs_initial', 'missing_source', 'node_unhealthy', 'node_degraded', 'ignored_worker', 'unknown_device', 'device_port'],
title: 'Code'
},
message: {
File diff suppressed because one or more lines are too long
+10 -2
View File
@@ -301,6 +301,7 @@ export type DevicePublic = {
report?: ({
[key: string]: unknown;
} | null);
reported_at?: (string | null);
};
/**
@@ -657,6 +658,7 @@ export type fluksio__flow__schemas__MessageValue = {
export type HealthSummary = {
status: string;
problems: Array<(string)>;
warnings?: Array<(string)>;
flows: {
[key: string]: (number);
};
@@ -1514,7 +1516,7 @@ export type ValidationError = {
* Something wrong with a flow — a fault, or merely advisory.
*/
export type ValidationIssue = {
code: 'cycle' | 'unconnected_input' | 'missing_initial_value' | 'node_error' | 'unauthenticated_hook' | 'self_loop_needs_initial' | 'missing_source' | 'node_unhealthy' | 'ignored_worker' | 'unknown_device' | 'device_port';
code: 'cycle' | 'unconnected_input' | 'missing_initial_value' | 'node_error' | 'unauthenticated_hook' | 'self_loop_needs_initial' | 'missing_source' | 'node_unhealthy' | 'node_degraded' | 'ignored_worker' | 'unknown_device' | 'device_port';
message: string;
flow?: string;
nodes?: Array<(string)>;
@@ -1524,7 +1526,7 @@ export type ValidationIssue = {
advisory?: boolean;
};
export type code = 'cycle' | 'unconnected_input' | 'missing_initial_value' | 'node_error' | 'unauthenticated_hook' | 'self_loop_needs_initial' | 'missing_source' | 'node_unhealthy' | 'ignored_worker' | 'unknown_device' | 'device_port';
export type code = 'cycle' | 'unconnected_input' | 'missing_initial_value' | 'node_error' | 'unauthenticated_hook' | 'self_loop_needs_initial' | 'missing_source' | 'node_unhealthy' | 'node_degraded' | 'ignored_worker' | 'unknown_device' | 'device_port';
export type ValidationResult = {
issues?: Array<ValidationIssue>;
@@ -1875,6 +1877,12 @@ export type DevicesDeleteDeviceData = {
export type DevicesDeleteDeviceResponse = (Message);
export type DevicesPutReportData = {
deviceId: string;
};
export type DevicesPutReportResponse = (Message);
export type DevicesApprovePairingData = {
deviceId: string;
requestBody: PairRequest;
+15 -3
View File
@@ -4,6 +4,8 @@ import { useState } from "react"
import type { MessageInfo, SettingDef, WidgetDef } from "@/client"
import { DEFAULT_RANGE, RANGES } from "@/components/Common/RangePicker"
import { screensOf } from "@/components/Devices/ports"
import { devicesQueryOptions } from "@/components/Devices/queries"
import {
FieldLabel,
InfoTip,
@@ -1934,6 +1936,16 @@ export function DashboardPanel({
const background = settingOf(dashboard, "background")
const paletteSetting = settingOf(dashboard, "palette")
const palette = parsePalette(paletteSetting.value)
const { data: devices } = useQuery({
...devicesQueryOptions(),
enabled: open,
})
// The screens devices reported come first and take the place of a named
// preset of the same size: that is the screen this is drawn for.
const presets = [...screensOf(devices?.data), ...CANVAS_PRESETS].filter(
(preset, index, all) =>
all.findIndex((one) => sizeKey(one) === sizeKey(preset)) === index,
)
/** Settings are a map, so one of them changing rewrites the whole of it. */
const setSetting = (name: SettingName, setting: SettingDef) =>
@@ -2012,7 +2024,7 @@ export function DashboardPanel({
>
<Select
value={
CANVAS_PRESETS.some(
presets.some(
(preset) =>
sizeKey(preset) ===
sizeKey({ ...canvas, shape: dashboard.shape }),
@@ -2021,7 +2033,7 @@ export function DashboardPanel({
: ""
}
onValueChange={(value) => {
const preset = CANVAS_PRESETS.find(
const preset = presets.find(
(candidate) => sizeKey(candidate) === value,
)
if (preset)
@@ -2037,7 +2049,7 @@ export function DashboardPanel({
<SelectValue placeholder="Custom" />
</SelectTrigger>
<SelectContent>
{CANVAS_PRESETS.map((preset) => (
{presets.map((preset) => (
<SelectItem key={sizeKey(preset)} value={sizeKey(preset)}>
{preset.label} — {preset.width}×{preset.height}
</SelectItem>
+149 -28
View File
@@ -1,6 +1,13 @@
import { useMutation, useQuery } from "@tanstack/react-query"
import { Link, useNavigate } from "@tanstack/react-router"
import { ArrowDown, ArrowLeft, ArrowRight, ArrowUp, X } from "lucide-react"
import {
ArrowDown,
ArrowLeft,
ArrowLeftRight,
ArrowRight,
ArrowUp,
X,
} from "lucide-react"
import { useState } from "react"
import {
@@ -33,9 +40,13 @@ import { ago, cn } from "@/lib/utils"
import { handleError } from "@/utils"
import {
DEVICE_TYPE,
type DeviceReport,
deviceNodeOf,
freeNodeId,
type ReportedPort,
reportedPorts,
reportOf,
wiresAs,
withPort,
} from "./ports"
import {
@@ -141,7 +152,7 @@ export function DeviceDetail({ id }: { id: string }) {
/>
</section>
<section className={CARD}>
<DevicePorts device={device} canEdit={canEdit} />
<Capabilities device={device} canEdit={canEdit} />
</section>
</div>
</div>
@@ -545,57 +556,160 @@ function Pairing({
)
}
/** "1024×600, touch", or "360×360, round, touch". */
function displayText(display: NonNullable<DeviceReport["display"]>) {
return [
`${display.w}×${display.h}`,
display.shape === "round" ? "round" : null,
display.touch ? "touch" : null,
]
.filter(Boolean)
.join(", ")
}
/**
* The device's own ports as the published flows wire them: each one, the
* message it publishes or follows, and a link to the node holding it.
* Wiring is done in a flow, so this only reads.
* What the device says it has, and its ports as the published flows wire
* them: each port it reports, with the message it publishes or follows or a
* way to wire it, and any port wired that it does not report. Wiring is done
* in a flow, so this only reads.
*/
function DevicePorts({
function Capabilities({
device,
canEdit,
}: {
device: DevicePublic
canEdit: boolean
}) {
const report = reportOf(device)
const reported = reportedPorts(device)
const bindings = device.bindings ?? []
const camera = report?.camera
const features = Object.keys(report?.features ?? {}).filter(
(name) => report?.features?.[name],
)
const facts = report
? [
["Board", [report.board, report.platform].filter(Boolean).join(" · ")],
["Firmware", report.firmware],
["Display", report.display ? displayText(report.display) : "None"],
camera?.sensor
? [
"Camera",
[
camera.sensor,
camera.w ? `${camera.w}×${camera.h} at ${camera.fps} fps` : "",
]
.filter(Boolean)
.join(", "),
]
: null,
features.length ? ["Features", features.join(", ")] : null,
].filter((fact): fact is [string, string] => Boolean(fact?.[1]))
: []
// A port the device lists, then one wired that it does not, so a typo in a
// flow shows up here as well as on the canvas.
const rows = [
...(reported ?? []).map((port) => ({ port: port.port, said: port })),
...[...new Set(bindings.map((binding) => binding.port))]
.filter((port) => !reported?.some((one) => one.port === port))
.map((port) => ({ port, said: undefined })),
]
return (
<PanelSection
title="Ports"
help="Hardware on the device itself, such as a GPIO pin or its backlight. A device node in a flow wires each port to a message."
title="Capabilities"
help="What the device reported about itself when it last connected, and its ports. A device node in a flow wires each port to a message."
>
{bindings.length === 0 ? (
<p className="text-sm text-muted-foreground">No ports wired yet.</p>
{report ? (
<dl
className="grid grid-cols-[auto_1fr] gap-x-4 gap-y-1 text-sm"
data-testid="device-capabilities"
>
{facts.map(([term, value]) => (
<div key={term} className="contents">
<dt className="text-muted-foreground">{term}</dt>
<dd className="min-w-0 truncate">{value}</dd>
</div>
))}
</dl>
) : (
<p className="text-sm text-muted-foreground">
Not reported yet. A device says what it has once it is paired and
connected.
</p>
)}
{rows.length === 0 ? (
<p className="text-sm text-muted-foreground">
{report ? "It reports no ports." : "No ports wired yet."}
</p>
) : (
<ul className="grid gap-1" data-testid="device-bindings">
{bindings.map((binding) => {
const Way = binding.direction === "in" ? ArrowRight : ArrowLeft
const way = binding.direction === "in" ? "Publishes" : "Follows"
{rows.map(({ port, said }) => {
const wired = bindings.filter((binding) => binding.port === port)
const either = (said?.modes?.length ?? 0) > 1
const direction = said?.direction ?? wired[0]?.direction
const Way = either
? ArrowLeftRight
: direction === "in"
? ArrowRight
: ArrowLeft
const way = either
? "Publishes or follows"
: direction === "in"
? "Publishes"
: "Follows"
return (
<li
key={`${binding.flow}.${binding.node}.${binding.port}.${binding.message}`}
key={port}
className="flex min-w-0 items-center gap-2 text-sm"
data-testid="device-port"
>
<span className="w-24 shrink-0 truncate font-mono">
{binding.port}
</span>
<span className="w-24 shrink-0 truncate font-mono">{port}</span>
<Way
role="img"
aria-label={way}
className="size-4 shrink-0 text-muted-foreground"
/>
<Link
to="/flows/$flowName"
params={{ flowName: binding.flow }}
search={{ node: binding.node }}
className="min-w-0 flex-1 truncate font-mono hover:underline"
>
{binding.message}
</Link>
<span className="w-12 shrink-0 truncate text-xs text-muted-foreground">
{said?.dtype ?? ""}
</span>
<span className="flex min-w-0 flex-1 flex-col">
{wired.map((binding) => (
<Link
key={`${binding.flow}.${binding.node}.${binding.message}`}
to="/flows/$flowName"
params={{ flowName: binding.flow }}
search={{ node: binding.node }}
className="min-w-0 truncate font-mono hover:underline"
>
{binding.message}
</Link>
))}
{report && !said ? (
<span className="text-xs text-muted-foreground">
Not reported by the device
</span>
) : null}
{said &&
wired.some((binding) => !wiresAs(said, binding.direction)) ? (
<span className="text-xs text-destructive">
Wired the other way round
</span>
) : null}
</span>
{canEdit && said && wired.length === 0 ? (
<UseInFlow device={device} port={said} size="sm" />
) : null}
</li>
)
})}
</ul>
)}
{device.reported_at ? (
<p className="text-xs text-muted-foreground">
Reported {ago(device.reported_at)}
</p>
) : null}
{canEdit ? <UseInFlow device={device} /> : null}
</PanelSection>
)
@@ -609,9 +723,11 @@ function DevicePorts({
export function UseInFlow({
device,
port,
size,
}: {
device: DevicePublic
port?: ReportedPort
size?: "sm"
}) {
const navigate = useNavigate()
const { showErrorToast } = useCustomToast()
@@ -657,10 +773,15 @@ export function UseInFlow({
return (
<Select value="" onValueChange={(flowName) => place.mutate(flowName)}>
<SelectTrigger
className="justify-self-start"
aria-label={`Use ${device.title || device.id} in a flow`}
size={size}
className="shrink-0 justify-self-start"
aria-label={
port
? `Use ${port.port} of ${device.title || device.id} in a flow`
: `Use ${device.title || device.id} in a flow`
}
disabled={place.isPending}
data-testid="use-in-flow"
data-testid={port ? "use-port-in-flow" : "use-in-flow"}
>
<SelectValue placeholder="Use in flow" />
</SelectTrigger>
+52 -12
View File
@@ -3,23 +3,64 @@ import type { DevicePublic, DType, MessageSpec, NodeDef_Input } from "@/client"
/** The node type a device is in a flow. */
export const DEVICE_TYPE = "device"
/** Port names the panel firmware knows; `gpio.` takes a pin number. */
// ponytail: free-text ports until a device reports what it has (D2, PUT /devices/{id}/report)
export const PORT_SUGGESTIONS = [
"gpio.",
"backlight",
"touch",
"mic",
"speaker",
]
/** One port as a device reports it. */
export type ReportedPort = {
port: string
/** Its default direction. */
direction: "in" | "out"
dtype?: DType
/** A JSON schema of the config the device reads for it. */
config?: Record<string, unknown> | null
on_demand?: boolean
/** The directions it may be wired in, when it may be either: a GPIO pin. */
modes?: ("in" | "out")[] | null
}
/** What a device says it has, `PUT /devices/{id}/report` (schema 1). */
export type DeviceReport = {
schema: number
platform?: string
board?: string
firmware?: string
display?: {
w: number
h: number
shape?: "rect" | "round"
touch?: boolean
} | null
camera?: { sensor?: string; w?: number; h?: number; fps?: number } | null
features?: Record<string, boolean>
}
/** The device's report, or null before it has made one. */
export function reportOf(device: DevicePublic | undefined) {
const report = device?.report as DeviceReport | null | undefined
return report && typeof report === "object" ? report : null
}
/**
* Each screen a device reported, as a canvas preset named after the device:
* a dashboard drawn for it then shows at 1:1 there.
*/
export function screensOf(devices: DevicePublic[] = []) {
return devices.flatMap((device) => {
const display = reportOf(device)?.display
if (!display?.w || !display.h) return []
return [
{
label: device.title || device.id,
width: display.w,
height: display.h,
...(display.shape === "round" ? { shape: "round" as const } : {}),
},
]
})
}
/** Whether a reported port may be wired in `direction`. */
export const wiresAs = (port: ReportedPort, direction: "in" | "out") =>
(port.modes?.length ? port.modes : [port.direction]).includes(direction)
/** What the server accepts as a port name. */
export const portOf = (value: string) =>
value.toLowerCase().replace(/[^a-z0-9_.]/g, "")
@@ -31,8 +72,7 @@ export const portOf = (value: string) =>
* are free text.
*/
export function reportedPorts(device: DevicePublic | undefined) {
const ports = (device?.report as { ports?: unknown } | null | undefined)
?.ports
const ports = (reportOf(device) as { ports?: unknown } | null)?.ports
if (!Array.isArray(ports)) return null
return ports.filter(
(port): port is ReportedPort =>
+18 -7
View File
@@ -424,18 +424,26 @@ function FlowEditorInner({
[nodeTypeInfo],
)
const issuesByNode = useMemo(() => {
const map = new Map<string, string[]>()
const [issuesByNode, warningsByNode] = useMemo(() => {
const faults = new Map<string, string[]>()
const warnings = new Map<string, string[]>()
for (const issue of issues) {
// A node's dot and border are its status channel, and status has no
// warning tier — an advisory is not a fault, so it says its piece in the
// dock rather than painting the node red.
if (!issue.node || issue.advisory) continue
// A node's dot and border are its status channel. An advisory is not a
// fault, so it says its piece in the dock rather than painting the node
// red. A degraded node, a device that is offline, is the one warning
// the node itself wears, in amber.
if (!issue.node) continue
const map = !issue.advisory
? faults
: issue.code === "node_degraded"
? warnings
: null
if (!map) continue
const list = map.get(issue.node) ?? []
list.push(issue.message)
map.set(issue.node, list)
}
return map
return [faults, warnings]
}, [issues])
const showLogs = useCallback((nodeId: string) => {
@@ -449,6 +457,7 @@ function FlowEditorInner({
canvasNodes.map((node) => {
const definition = definitions.find((entry) => entry.id === node.id)
const nodeIssues = issuesByNode.get(`${flowName}.${node.id}`) ?? []
const nodeWarnings = warningsByNode.get(`${flowName}.${node.id}`) ?? []
return {
...node,
selected: node.selected || node.id === selectedId,
@@ -459,6 +468,7 @@ function FlowEditorInner({
typeLabels.get(definition?.type ?? "") ?? definition?.type ?? "",
isPlugin: pluginTypes.has(definition?.type ?? ""),
issueText: nodeIssues.join("\n"),
warningText: nodeWarnings.join("\n"),
onShowLogs: showLogs,
} satisfies FlowNodeData,
}
@@ -468,6 +478,7 @@ function FlowEditorInner({
definitions,
flowName,
issuesByNode,
warningsByNode,
pluginTypes,
selectedId,
showLogs,
+24 -4
View File
@@ -77,6 +77,8 @@ const STATUS_STYLES = {
},
running: { dot: "bg-primary animate-pulse", label: "Running" },
success: { dot: "bg-status-success", label: "Last run succeeded" },
// Works, if not as it should: a device that is offline. Not a fault.
warning: { dot: "bg-status-warning", label: "Degraded" },
error: { dot: "bg-destructive", label: "Something went wrong" },
} as const
@@ -86,6 +88,8 @@ export type FlowNodeData = {
typeLabel: string
isPlugin?: boolean
issueText: string
/** Why the node is degraded, if it is: amber, never a fault. */
warningText?: string
/** Open the logs at this node's own lines. */
onShowLogs?: (nodeId: string) => void
[key: string]: unknown
@@ -126,8 +130,15 @@ function PortHandles({
}
function FlowNodeComponent({ data, selected }: NodeProps) {
const { definition, flow, typeLabel, isPlugin, issueText, onShowLogs } =
data as FlowNodeData
const {
definition,
flow,
typeLabel,
isPlugin,
issueText,
warningText,
onShowLogs,
} = data as FlowNodeData
const nodeId = `${flow}.${definition.id}`
const live = useNodeStatus(nodeId)
const emits = useNodeEmits(nodeId)
@@ -174,7 +185,12 @@ function FlowNodeComponent({ data, selected }: NodeProps) {
.join("\n")
.split("\n")
const problem = [...new Set(lines)].join("\n")
const status = problem ? "error" : live?.status
const warning = problem ? "" : (warningText ?? "")
const status = problem
? "error"
: warning && live?.status !== "running"
? "warning"
: live?.status
const running = status === "running"
const style = STATUS_STYLES[status as keyof typeof STATUS_STYLES]
// Failed at some point, fine as of the last run. It gets no dot and no
@@ -209,6 +225,7 @@ function FlowNodeComponent({ data, selected }: NodeProps) {
// Selection wins the border when it is both — the dot is the status
// channel, and it is still red underneath.
problem && "border-destructive",
status === "warning" && "border-status-warning",
selected && "border-primary shadow-e2",
)}
>
@@ -318,7 +335,10 @@ function FlowNodeComponent({ data, selected }: NodeProps) {
/>
</TooltipTrigger>
<TooltipContent className="max-h-60 max-w-xs overflow-y-auto whitespace-pre-line break-words">
{problem || live?.detail || style.label}
{problem ||
(status === "warning" ? warning : "") ||
live?.detail ||
style.label}
</TooltipContent>
</Tooltip>
) : null}
+72 -19
View File
@@ -27,10 +27,10 @@ import {
} from "@/client"
import {
DEVICE_TYPE,
PORT_SUGGESTIONS,
portOf,
type ReportedPort,
reportedPorts,
wiresAs,
} from "@/components/Devices/ports"
import { devicesQueryOptions } from "@/components/Devices/queries"
import { Button } from "@/components/ui/button"
@@ -637,8 +637,7 @@ function DevicePortList({
onChange: (next: MessageSpec[], renamed?: [string, string | null]) => void
onConfig: (port: string, next: Record<string, unknown>) => void
}) {
const offered = reported?.filter((port) => port.direction === direction)
const listId = `device-ports-${direction}`
const offered = reported?.filter((port) => wiresAs(port, direction))
// An out port is read when the device asks for it, never a reason to run.
const fresh = (port: ReportedPort | null): MessageSpec => ({
name: port ? port.port.replace(/\./g, "_") : "",
@@ -670,17 +669,19 @@ function DevicePortList({
Add
</Button>
</div>
<datalist id={listId}>
{PORT_SUGGESTIONS.map((port) => (
<option key={port} value={port} />
))}
</datalist>
{specs.length === 0 ? (
<p className="text-sm text-muted-foreground">No ports yet.</p>
) : null}
{specs.map((spec, index) => {
const port = spec.port ?? ""
const name = port || "new port"
const said = reported?.find((one) => one.port === port)
// The device says which settings it reads, so they get a form.
const schema =
said?.config &&
Object.keys((said.config.properties as object) ?? {}).length
? said.config
: undefined
return (
<div key={`port-${index}`} className="grid min-w-0 gap-1.5">
<div className="flex min-w-0 items-center gap-1.5">
@@ -730,7 +731,6 @@ function DevicePortList({
placeholder="Port, e.g. gpio.4"
aria-label={`Port of ${name}`}
autoComplete="off"
list={listId}
className="h-8 min-w-0 flex-1 font-mono text-sm"
onBlur={(event) => {
const next = portOf(event.currentTarget.value)
@@ -792,16 +792,35 @@ function DevicePortList({
autoFocus={false}
onChange={(next) => update(index, { name: next })}
/>
<JsonField
value={config[port] ?? {}}
label={`Config of ${name}`}
check={objectOnly}
className="h-8 w-24 shrink-0"
onChange={(next) => {
if (port) onConfig(port, next as Record<string, unknown>)
}}
/>
{schema ? null : (
<JsonField
value={config[port] ?? {}}
label={`Config of ${name}`}
check={objectOnly}
className="h-8 w-24 shrink-0"
onChange={(next) => {
if (port) onConfig(port, next as Record<string, unknown>)
}}
/>
)}
</div>
{reported && port && !said ? (
<p className="pl-5.5 text-xs text-muted-foreground">
The device does not report this port.
</p>
) : null}
{schema ? (
<div className="pl-5.5">
<ParamsForm
type={undefined}
title={`${port} settings`}
schema={schema}
params={(config[port] ?? {}) as Record<string, unknown>}
provides={[]}
onChange={(next) => onConfig(port, next)}
/>
</div>
) : null}
</div>
)
})}
@@ -1120,12 +1139,14 @@ function InjectPayloads({
/** A small form built from the node type's declared parameters. */
function ParamsForm({
type,
title = "Settings",
schema,
params,
provides,
onChange,
}: {
type: string | undefined
title?: string
schema: Record<string, unknown> | undefined
params: Record<string, unknown>
/** The ports an inject's payload fields follow; unused by every other type. */
@@ -1139,6 +1160,7 @@ function ParamsForm({
title?: string
description?: string
default?: unknown
enum?: unknown[]
"x-secret"?: boolean
}
>
@@ -1161,7 +1183,7 @@ function ParamsForm({
return (
<div className="grid gap-3">
<span className={SECTION}>Settings</span>
<span className={SECTION}>{title}</span>
{entries.map(([key, property]) => {
// In the schema's own place, so the payload keeps its position in the
// form even though it is now one field per port.
@@ -1261,6 +1283,37 @@ function ParamsForm({
)
}
// A closed set of choices, such as a pin's pull.
if (property.enum?.length) {
return (
<div key={key} className="grid gap-1.5">
<FieldLabel htmlFor={`param-${key}`} help={property.description}>
{label}
</FieldLabel>
<Select
value={String(value)}
onValueChange={(next) =>
set(
key,
property.enum?.find((choice) => String(choice) === next),
)
}
>
<SelectTrigger id={`param-${key}`} className="!h-8 text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
{property.enum.map((choice) => (
<SelectItem key={String(choice)} value={String(choice)}>
{String(choice)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)
}
const numeric =
property.type === "integer" || property.type === "number"
return (
@@ -85,7 +85,7 @@ type FlowEvent =
type: "node_health"
flow?: string
node: string
health: "ok" | "down" | "unknown"
health: "ok" | "degraded" | "down" | "unknown"
detail?: string | null
ts?: number
}
@@ -335,7 +335,9 @@ function open(url: string) {
// or a rebuild, never when health actually flipped. The store had a
// health map of its own and nothing ever read it.
invalidateHealth(message.flow)
if (message.health === "down") {
// Degraded is held back by the same floor, as a warning rather than
// a fault.
if (message.health === "down" || message.health === "degraded") {
// This fires at the transition, while the floor is still hiding the
// node, and the engine publishes nothing when the floor expires — so
// read again ourselves at that moment, or the issue waits for an
@@ -355,7 +357,11 @@ function open(url: string) {
type: message.type,
flow: message.flow,
node: message.node,
detail: message.detail ?? "Reported itself down.",
detail:
message.detail ??
(message.health === "down"
? "Reported itself down."
: "Reported itself degraded."),
ts: message.ts ?? Date.now() / 1000,
})
} else {
@@ -1,5 +1,6 @@
import { useQuery } from "@tanstack/react-query"
import { Link } from "@tanstack/react-router"
import { TriangleAlert } from "lucide-react"
import { type Range, RangePicker } from "@/components/Common/RangePicker"
import { PANEL_SECTION } from "@/components/Flow/SidePanel"
@@ -96,6 +97,17 @@ export function HealthOverview({
{summary.problems.join(" · ")}
</p>
) : null}
{/* Worth a look and not a fault, such as a device that is offline: the
badge above stays as it is. */}
{summary?.warnings?.length ? (
<p
className="flex items-center gap-1.5 text-sm text-status-warning"
data-testid="health-warnings"
>
<TriangleAlert aria-hidden className="size-4 shrink-0" />
<span className="min-w-0">{summary.warnings.join(" · ")}</span>
</p>
) : null}
<div
className={`grid gap-3 sm:grid-cols-2 ${runs?.length ? "lg:grid-cols-6" : "lg:grid-cols-5"}`}
+3
View File
@@ -76,6 +76,7 @@
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
--color-status-success: var(--status-success);
--color-status-warning: var(--status-warning);
--color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3);
@@ -149,6 +150,7 @@
--sidebar-border: #e4e4e4;
--sidebar-ring: #4a7189;
--status-success: #5e8b6d;
--status-warning: #b45309;
--chart-1: #4a7189;
--chart-2: #e26f00;
--chart-3: #6f49ca;
@@ -191,6 +193,7 @@
--sidebar-border: #2a2a2a;
--sidebar-ring: #7ba3b8;
--status-success: #87b596;
--status-warning: #e8a33d;
--chart-1: #7ba3b8;
--chart-2: #d26600;
--chart-3: #8d71e6;