diff --git a/backend/fluksio/api/routes/flows.py b/backend/fluksio/api/routes/flows.py index 4303578..37eb6eb 100644 --- a/backend/fluksio/api/routes/flows.py +++ b/backend/fluksio/api/routes/flows.py @@ -38,7 +38,7 @@ from fluksio.flow.controller import NODE_TYPES, FlowController from fluksio.flow.dashboards import DashboardStore from fluksio.flow.events import event_bus from fluksio.flow.messages import flow_of, qualify -from fluksio.flow.panels import messages_for +from fluksio.flow.panels import messages_for, read_config from fluksio.flow.pipeline import ValidationIssue from fluksio.flow.runs import RunRejected from fluksio.flow.schemas import ( @@ -78,18 +78,19 @@ ws_router = APIRouter(prefix="/flows", tags=["flows"]) class Endpoint(BaseModel): """Something wired into this flow that is not a node in it. - A dashboard control setting one of its messages, a tile showing one, or a - node in another flow on the far side of a dotted name. The canvas draws - these so a value never appears to come from nowhere — or worse, appears to - come from whichever node happens to be drawn as a producer. + A dashboard control setting one of its messages, a tile showing one, a + port on a panel's own hardware, or a node in another flow on the far side + of a dotted name. The canvas draws these so a value never appears to come + from nowhere — or worse, appears to come from whichever node happens to be + drawn as a producer. """ - #: dashboard or flow. + #: dashboard, panel or flow. kind: str #: Stable within its kind, and used as the canvas node id. id: str label: str - #: What sort of widget, or the node type in the other flow. + #: What sort of widget, the panel, or the node type in the other flow. detail: str = "" #: Messages of this flow it publishes, and ones it reads. provides: list[str] = [] @@ -155,6 +156,28 @@ def _endpoints(controller: FlowController, definition: FlowDef) -> list[Endpoint requires=binding["requires"], ) + # A panel's own hardware: an ``in`` port publishes like a control, an + # ``out`` port reads like a tile. The id is the source id the device + # publishes under, so its edges pulse for its values. + prefix = f"{flow}." + for panel in read_config().panels: + for port in panel.io: + if not port.message.startswith(prefix): + continue + key = f"panel:{panel.id}:{port.port}" + endpoint = found.setdefault( + key, + Endpoint( + kind="panel", + id=key, + label=port.port, + detail=panel.title or panel.id, + ), + ) + side = endpoint.provides if port.direction == "in" else endpoint.requires + if port.message not in side: + side.append(port.message) + def _flow_endpoint(key: str, detail: str) -> Endpoint: """One label per node on the far side, however many names reach it.""" return found.setdefault( diff --git a/backend/tests/flow/test_endpoints.py b/backend/tests/flow/test_endpoints.py index f448e81..dcf3bf6 100644 --- a/backend/tests/flow/test_endpoints.py +++ b/backend/tests/flow/test_endpoints.py @@ -10,8 +10,10 @@ from pathlib import Path import pytest from fluksio.api.routes.flows import _endpoints +from fluksio.core.config import settings from fluksio.flow.controller import FlowController from fluksio.flow.messages import DType, MessageSpec +from fluksio.flow.panels import PanelDef, PanelPort, PanelsConfig, write_config from fluksio.flow.schemas import FlowDef, NodeDef from fluksio.flow.store import FlowStore @@ -109,3 +111,35 @@ def test_the_wiring_index_is_read_once_per_store_revision(controller): controller.store.write_flow(FlowDef(name="shed", nodes=[])) _endpoints(controller, controller.store.read_flow("house")) assert reads == 2 + + +def test_a_panel_port_is_drawn_on_the_side_it_wires(controller, tmp_path, monkeypatch): + monkeypatch.setattr(settings, "PANELS_FILE", tmp_path / "panels.json") + write_config( + PanelsConfig( + panels=[ + PanelDef( + id="hall", + title="Hall", + io=[ + PanelPort(port="gpio.4", message="house.temp", direction="in"), + PanelPort( + port="backlight", message="house.temp", direction="out" + ), + PanelPort(port="mic", message="garage.open", direction="in"), + ], + ) + ] + ) + ) + + endpoints = { + one.id: one + for one in _endpoints(controller, controller.store.read_flow("house")) + } + + # The id is what the device publishes under, so its edges pulse for it. + assert endpoints["panel:hall:gpio.4"].provides == ["house.temp"] + assert endpoints["panel:hall:backlight"].requires == ["house.temp"] + assert endpoints["panel:hall:backlight"].detail == "Hall" + assert "panel:hall:mic" not in endpoints diff --git a/docs/interface/dashboards.md b/docs/interface/dashboards.md index 6f41cc0..3c99957 100644 --- a/docs/interface/dashboards.md +++ b/docs/interface/dashboards.md @@ -437,7 +437,9 @@ message: 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=`), which is how a microphone sends a recording. -`out` messages are read only. +`out` messages are read only. The flow canvas draws each port as an endpoint, +an `in` port feeding the nodes that consume its message and an `out` port fed +by the nodes that produce it. !!! note "If the local link is wrong" diff --git a/docs/interface/flow-editor.md b/docs/interface/flow-editor.md index e13f3b1..4bbb2e0 100644 --- a/docs/interface/flow-editor.md +++ b/docs/interface/flow-editor.md @@ -9,10 +9,11 @@ The graph runs left to right on desktop and top to bottom on phones. - **Nodes** show their execution status. - **Edges** pulse when values pass through. Click one to inspect its payload. -- **Endpoints** show flow inputs, dashboard widgets and nodes in other flows - that exchange messages with this flow. A value passing lights one up in a - sweep: left to right when it sends into the flow, right to left when it reads - out of it. +- **Endpoints** show flow inputs, dashboard widgets, panel I/O ports and nodes + in other flows that exchange messages with this flow. A value passing lights + one up in a sweep: left to right when it sends into the flow, right to left + when it reads out of it. Click a panel port to open its panel in the Panels + dialog. Values update over a websocket. A connection banner tells you when the editor cannot reach the engine. diff --git a/frontend/src/client/schemas.gen.ts b/frontend/src/client/schemas.gen.ts index e780ce1..fe4797f 100644 --- a/frontend/src/client/schemas.gen.ts +++ b/frontend/src/client/schemas.gen.ts @@ -952,10 +952,11 @@ export const EndpointSchema = { title: 'Endpoint', description: `Something wired into this flow that is not a node in it. -A dashboard control setting one of its messages, a tile showing one, or a -node in another flow on the far side of a dotted name. The canvas draws -these so a value never appears to come from nowhere — or worse, appears to -come from whichever node happens to be drawn as a producer.` +A dashboard control setting one of its messages, a tile showing one, a +port on a panel's own hardware, or a node in another flow on the far side +of a dotted name. The canvas draws these so a value never appears to come +from nowhere — or worse, appears to come from whichever node happens to be +drawn as a producer.` } as const; export const EnrollBodySchema = { diff --git a/frontend/src/client/types.gen.ts b/frontend/src/client/types.gen.ts index 7d42afa..2f5a75a 100644 --- a/frontend/src/client/types.gen.ts +++ b/frontend/src/client/types.gen.ts @@ -257,10 +257,11 @@ export type DType = 'float' | 'int' | 'str' | 'bool' | 'json' | 'series' | 'reco /** * Something wired into this flow that is not a node in it. * - * A dashboard control setting one of its messages, a tile showing one, or a - * node in another flow on the far side of a dotted name. The canvas draws - * these so a value never appears to come from nowhere — or worse, appears to - * come from whichever node happens to be drawn as a producer. + * A dashboard control setting one of its messages, a tile showing one, a + * port on a panel's own hardware, or a node in another flow on the far side + * of a dotted name. The canvas draws these so a value never appears to come + * from nowhere — or worse, appears to come from whichever node happens to be + * drawn as a producer. */ export type Endpoint = { kind: string; diff --git a/frontend/src/components/Common/GlobalSearch.tsx b/frontend/src/components/Common/GlobalSearch.tsx index 8007bee..0f7bbc7 100644 --- a/frontend/src/components/Common/GlobalSearch.tsx +++ b/frontend/src/components/Common/GlobalSearch.tsx @@ -182,7 +182,10 @@ export function GlobalSearch({ // Panels are managed in a dialog on the dashboards screen, which opens // itself when the address says so. case "panel": - return navigate({ to: "/dashboards", search: { panels: true } }) + return navigate({ + to: "/dashboards", + search: { panels: true, panel: entry.name }, + }) case "secret": return navigate({ to: "/secrets" }) case "module": diff --git a/frontend/src/components/Dashboard/PanelsDialog.tsx b/frontend/src/components/Dashboard/PanelsDialog.tsx index 6eba6e1..4750035 100644 --- a/frontend/src/components/Dashboard/PanelsDialog.tsx +++ b/frontend/src/components/Dashboard/PanelsDialog.tsx @@ -1,6 +1,6 @@ import { useMutation, useQuery } from "@tanstack/react-query" import { Plus, Trash2, X } from "lucide-react" -import { useState } from "react" +import { useEffect, useState } from "react" import { type ApiError, @@ -72,7 +72,7 @@ const objectOnly = (value: unknown) => * publish, so it lives in a dialog over the dashboards list instead of a page * of its own. Every change saves as it is made — there is no form to submit. */ -export function PanelsDialog() { +export function PanelsDialog({ focus }: { focus?: string } = {}) { const { data: config } = useQuery(panelsQueryOptions()) const { data: dashboards } = useQuery(dashboardsQueryOptions()) const { user } = useAuth() @@ -125,6 +125,15 @@ export function PanelsDialog() { const newId = slugify(name) const taken = panels.some((panel) => panel.id === newId) + // Arriving about one panel, from its port on a flow canvas, lands on it. + const focusLoaded = Boolean(config) && Boolean(focus) + useEffect(() => { + if (!focusLoaded) return + document + .querySelector(`[data-testid="panel-${focus}"]`) + ?.scrollIntoView({ block: "start" }) + }, [focusLoaded, focus]) + return ( @@ -146,6 +155,7 @@ export function PanelsDialog() { +
): Search => ({ panels: search.panels === true || search.panels === "true" || undefined, + // Which panel to scroll to, for a link that is about one of them. + panel: typeof search.panel === "string" ? search.panel : undefined, }), }) @@ -57,7 +59,7 @@ function Dashboards() { const [deleteOpen, setDeleteOpen] = useState(false) // Which screens exist is a question with an address, so anything can link to // it — the global search lands a panel here. - const { panels: panelsOpen } = Route.useSearch() + const { panels: panelsOpen, panel: panelFocus } = Route.useSearch() const setPanelsOpen = (open: boolean) => navigate({ to: "/dashboards", search: open ? { panels: true } : {} }) @@ -122,7 +124,7 @@ function Dashboards() { {/* Its own root rather than a nested one: which screens exist is a different question from which dashboards do. */} - +