Flow canvas draws panel I/O ports as panel:<id>:<port> endpoints that open their panel in the Panels dialog
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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=<message>`), 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"
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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 (
|
||||
<DialogContent className="max-h-[80vh] overflow-y-auto sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
@@ -146,6 +155,7 @@ export function PanelsDialog() {
|
||||
<PanelRow
|
||||
key={panel.id}
|
||||
panel={panel}
|
||||
focused={panel.id === focus}
|
||||
host={host}
|
||||
remoteHost={remoteHost}
|
||||
canEdit={canEdit}
|
||||
@@ -211,6 +221,7 @@ export function PanelsDialog() {
|
||||
|
||||
function PanelRow({
|
||||
panel,
|
||||
focused,
|
||||
host,
|
||||
remoteHost,
|
||||
canEdit,
|
||||
@@ -219,6 +230,8 @@ function PanelRow({
|
||||
onRemove,
|
||||
}: {
|
||||
panel: PanelDef
|
||||
/** The one a link arrived about, marked so it is found at a glance. */
|
||||
focused: boolean
|
||||
/** Where this instance answers, as it knows itself. */
|
||||
host: string
|
||||
/** Where the portal serves this instance, when it is enrolled. */
|
||||
@@ -288,7 +301,14 @@ function PanelRow({
|
||||
: `Remove ${panel.id}`
|
||||
|
||||
return (
|
||||
<div className="grid gap-3" data-testid={`panel-${panel.id}`}>
|
||||
<div
|
||||
className={cn(
|
||||
"grid scroll-mt-2 gap-3",
|
||||
// An outline rather than a border, so the row does not shift.
|
||||
focused && "rounded-md outline-2 outline-offset-4 outline-primary/40",
|
||||
)}
|
||||
data-testid={`panel-${panel.id}`}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
value={panel.title}
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import { Handle, type NodeProps, Position } from "@xyflow/react"
|
||||
import { LayoutDashboard, LogIn, LogOut, Workflow } from "lucide-react"
|
||||
import {
|
||||
LayoutDashboard,
|
||||
LogIn,
|
||||
LogOut,
|
||||
MonitorSmartphone,
|
||||
Workflow,
|
||||
} from "lucide-react"
|
||||
import { memo, useEffect, useMemo, useRef, useState } from "react"
|
||||
|
||||
import { useIsMobile } from "@/hooks/useMobile"
|
||||
@@ -10,6 +16,8 @@ import { useLiveValues } from "./liveStore"
|
||||
|
||||
const KIND_ICONS = {
|
||||
dashboard: LayoutDashboard,
|
||||
// The same glyph the dashboards screen opens its panels with.
|
||||
panel: MonitorSmartphone,
|
||||
flow: Workflow,
|
||||
input: LogIn,
|
||||
output: LogOut,
|
||||
|
||||
@@ -487,6 +487,12 @@ function FlowEditorInner({
|
||||
to: "/dashboards/$name",
|
||||
params: { name: (rest ?? "").split(":")[0] },
|
||||
})
|
||||
} else if (kind === "panel") {
|
||||
// A panel has no page of its own, only its row in the Panels dialog.
|
||||
navigate({
|
||||
to: "/dashboards",
|
||||
search: { panels: true, panel: (rest ?? "").split(":")[0] },
|
||||
})
|
||||
} else if (kind === "flow") {
|
||||
navigate({
|
||||
to: "/flows/$flowName",
|
||||
|
||||
@@ -7,8 +7,8 @@ import { displayName, portOf, qualify } from "./deriveEdges"
|
||||
/**
|
||||
* Canvas elements for things wired into a flow that are not nodes in it.
|
||||
*
|
||||
* A dashboard control setting one of its messages, a tile showing one, or a
|
||||
* node in another flow across a dotted name. Without these the canvas shows a
|
||||
* 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 across a dotted name. Without these the canvas shows a
|
||||
* value changing with nothing to account for it — and pulses whichever node
|
||||
* happens to be drawn as a producer, which did nothing.
|
||||
*
|
||||
@@ -21,7 +21,7 @@ import { displayName, portOf, qualify } from "./deriveEdges"
|
||||
export const ENDPOINT_TYPE = "endpoint"
|
||||
|
||||
/** The id prefixes an endpoint may carry, none of which is a node id. */
|
||||
const PREFIXES = ["dashboard:", "flow:", "input:", "output:"]
|
||||
const PREFIXES = ["dashboard:", "panel:", "flow:", "input:", "output:"]
|
||||
|
||||
export type EndpointNodeData = {
|
||||
label: string
|
||||
|
||||
@@ -35,7 +35,7 @@ import {
|
||||
import useCustomToast from "@/hooks/useCustomToast"
|
||||
import { handleError } from "@/utils"
|
||||
|
||||
type Search = { panels?: boolean }
|
||||
type Search = { panels?: boolean; panel?: string }
|
||||
|
||||
export const Route = createFileRoute("/_layout/dashboards/")({
|
||||
component: Dashboards,
|
||||
@@ -43,6 +43,8 @@ export const Route = createFileRoute("/_layout/dashboards/")({
|
||||
// else — the global search among them — arrives at it.
|
||||
validateSearch: (search: Record<string, unknown>): 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. */}
|
||||
<Dialog open={panelsOpen ?? false} onOpenChange={setPanelsOpen}>
|
||||
<PanelsDialog />
|
||||
<PanelsDialog focus={panelFocus} />
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||
|
||||
Reference in New Issue
Block a user