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. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0138N7k5PT4dcR1iEqXaaQ9k
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
a9d453c9d9
commit
ad76200d3d
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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/")
|
||||
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 =>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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"}`}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user