diff --git a/NOTEPAD.md b/NOTEPAD.md index f925d5c..e60406b 100644 --- a/NOTEPAD.md +++ b/NOTEPAD.md @@ -22,12 +22,29 @@ Deferring because out of scope is fine, but don't mention deferring than. - BUG/UI: clicking outside the panel does not discard the flow edit panel - FEAT/UX: for the same type of node show suggestions in the setting fields based on other nodes; I.e. if I create an mqtt node and I already configured one in another flow, allow selecting the values of the exiting mqtt node as a drop-down option (identical to consumables) when clicking inside the corresponding field in the new node (same for all other nodes) - BUG/UI: the graph showed in the node edit panel should also be shown for a specific edge inside the pop-up panel when clicking the edge +- PERF/FLOW: every save rebuilds the whole pipeline. Fine at the current flow count; rebuild + only the touched flow when it starts to show. +- FEAT/UI: the chart widget is stored and validated but not drawn — it is filtered out of + the editor's add row. Needs a charting library and the `--chart-*` tokens below. +- FEAT/UI: dashboard widgets are sized with a wider/narrower control rather than dragged. + A grid library would give drag-and-resize and per-breakpoint layouts; the document + already stores layout per breakpoint, so only the editor changes. +- FEAT/UI/MOBILE: dashboards render in the padded admin shell, so a wall panel gets the + sidebar, footer and a `max-w-7xl` column. A full-bleed shell would suit a panel better. +- FEAT/UI: reintroduce `--chart-*` tokens as one designed sequential scale when the first + chart lands. The node sparkline draws one series in `--primary` and needs none. - FEAT/FLOW: single-stepping a paused flow. The work queue it needed now exists: pausing parks claimed items per flow and resuming drains them, so a step button is a matter of popping one parked item instead of all of them. - FEAT/UI: interrupting a node that is already running. Pause holds nodes that have not been submitted yet; one already executing runs to completion. +- FEAT/UI: flow-level edits (title, inputs) go through `FlowPanel.onChange` and stay outside + the undo stack. - BUG/UI: the enlarged panel (for code editing) should still maintain its floating style +- FEAT/UI: no screen for alerting. Channels and rules are API-only (`/alerts/config`), + so setting up a phone notification means calling the endpoint by hand. +- FEAT/UI: there is no screen for managing the secrets store itself. A node parameter marked + `x-secret` offers the stored secrets, but they can only be created through the API. We should introduce ad dedicated page (in the sidebar) to handle secrets in a centralized way - BUG/UI: `SidePanel`'s mobile branch does not set `data-testid` on the `SheetContent`, so `[data-testid=node-panel]` does not exist on a phone. Mobile specs cannot address the panel. - CHORE/API: node source saves carry no version precondition, so two clients editing the same @@ -43,18 +60,12 @@ Deferring because out of scope is fine, but don't mention deferring than. - CHORE/INFRA: `requires-python` is capped below 3.14 because the MCP SDK wants a newer starlette there than the pinned `sentry-sdk<2` allows. Lift the cap when sentry-sdk moves to 2.x. -- FEAT/UI: no screen for alerting. Channels and rules are API-only (`/alerts/config`), - so setting up a phone notification means calling the endpoint by hand. -- FEAT/UI: there is no screen for managing the secrets store itself. A node parameter marked - `x-secret` offers the stored secrets, but they can only be created through the API. - FEAT/FLOW: input discretization drops the trailing edge — if a producer goes quiet inside the window, the consumer never sees the last value. Delivering it at the end of the window needs a timer per (node, port). - CHORE/FLOW: shared node sources bypass the draft/publish split. Editing one writes the library copy and reloads immediately, since the code is not any single flow's to hold back. Deliberate, but it means a shared node is the one thing publish does not gate. -- FEAT/UI: flow-level edits (title, inputs) go through `FlowPanel.onChange` and stay outside - the undo stack. - CHORE/UI: `make test-backend` cannot reach Postgres while the integrated stack is up — `compose.local.yml` does `db: ports: !reset []`. Run it against the container's address, or move the suite inside the compose network. @@ -68,26 +79,12 @@ Deferring because out of scope is fine, but don't mention deferring than. - CHORE/INFRA: `test-backend.yml` runs pytest on the runner host, so it keeps the fixed container name and port 5432 that `compose.ci.yml` resets everywhere else. Two concurrent runs of that one job collide. -- PERF/UI: (deferred for now) the Monaco chunk is 2.6 MB. It only loads when a node panel opens, but the - editor could be trimmed further or swapped for CodeMirror if that becomes a problem. -- CHORE/FLOW: four modules of `app/flow/nodes/` are still excluded from strict mypy - (`[[tool.mypy.overrides]]` in `pyproject.toml`): `mqtt`, `http`, `influx` and `delay`. They - carry prototype typing, `croniter` ships no stubs and `influxdb_client` does not re-export - its names. `base` and `mlp` are checked; shrink the rest as each integration is revisited. +- FEAT/UI: an endpoint's edge routes straight across the graph, so it can pass behind a + node that sits between the lane and the node it wires to. Readable, but a routed edge + would be tidier. - CHORE/FLOW: a node function returning something other than a dict raises `AttributeError` in `Node._to_messages` rather than a named error. Outputs are keyed by port, so a non-dict cannot be one — say so where the return value is mapped. -- PERF/FLOW: every save rebuilds the whole pipeline. Fine at the current flow count; rebuild - only the touched flow when it starts to show. -- FEAT/UI: the chart widget is stored and validated but not drawn — it is filtered out of - the editor's add row. Needs a charting library and the `--chart-*` tokens below. -- FEAT/UI: dashboard widgets are sized with a wider/narrower control rather than dragged. - A grid library would give drag-and-resize and per-breakpoint layouts; the document - already stores layout per breakpoint, so only the editor changes. -- FEAT/UI/MOBILE: dashboards render in the padded admin shell, so a wall panel gets the - sidebar, footer and a `max-w-7xl` column. A full-bleed shell would suit a panel better. -- FEAT/UI: reintroduce `--chart-*` tokens as one designed sequential scale when the first - chart lands. The node sparkline draws one series in `--primary` and needs none. - PERF/UI: the app's entry chunk is 680 kB (210 kB gzipped) and exceeds the warning threshold. React Flow and Monaco are already lazy; a manualChunks split measured no better, so this needs route-level work on the shell rather than chunking config. @@ -95,23 +92,23 @@ Deferring because out of scope is fine, but don't mention deferring than. - CHORE/INFRA: `bun run --filter frontend build` fails on this workspace with `crypto.hash is not a function` — Vite 7 wants Node 20.12+ and the host has 18. The Docker image builds fine, so it only bites local bundling; `bunx tsc` still type-checks. +- PERF/UI: (deferred for now) the Monaco chunk is 2.6 MB. It only loads when a node panel opens, but the + editor could be trimmed further or swapped for CodeMirror if that becomes a problem. +- CHORE/FLOW: four modules of `app/flow/nodes/` are still excluded from strict mypy + (`[[tool.mypy.overrides]]` in `pyproject.toml`): `mqtt`, `http`, `influx` and `delay`. They + carry prototype typing, `croniter` ships no stubs and `influxdb_client` does not re-export + its names. `base` and `mlp` are checked; shrink the rest as each integration is revisited. - FEAT/NODE: the connectors only read. Enable the write paths with someone watching: WF-RAC `setAirconStat` (needs an operatorId registered with the unit first, which is itself a write) and Art-Net `transmit`. -- FEAT/NODE: the second WF-RAC unit (the one Node-RED addresses with operatorId "0") - closes the connection on an anonymous read. It likely wants an account registered; - the first unit answers without one. - CHORE/NODE: `wfrac` reports `mode` as "unknown" while the unit is off, because the mode bits hold a value outside the known set. Faithful to the reference decoder, but "off" would read better. -- FEAT/UI: an endpoint's edge routes straight across the graph, so it can pass behind a - node that sits between the lane and the node it wires to. Readable, but a routed edge - would be tidier. -- FEAT/UI: endpoints are always shown. A flow read by a twenty-tile wall panel gets twenty - labels, and only the input ones affect the graph — dimming or collapsing consumers is - the obvious next move if it turns out busy. +- FEAT/NODE: the second WF-RAC unit (the one Node-RED addresses with operatorId "0") + closes the connection on an anonymous read. It likely wants an account registered; + the first unit answers without one. ## Blocked diff --git a/frontend/scripts/capture-screenshots.mjs b/frontend/scripts/capture-screenshots.mjs index 3483be4..774781a 100644 --- a/frontend/scripts/capture-screenshots.mjs +++ b/frontend/scripts/capture-screenshots.mjs @@ -62,15 +62,40 @@ for (const theme of ["light", "dark"]) { await page.screenshot({ path: `${dir}/app-dashboard.png` }) await captureFlows(page, dir) + await captureDashboards(page, dir) await context.close() console.log( - ` wrote ${dir}/{website-hero,app-login,app-dashboard,app-flows,app-flow-panel}.png`, + ` wrote ${dir}/{website-hero,app-login,app-dashboard,app-flows,app-flow-panel,app-panel}.png`, ) } await browser.close() +/** + * A dashboard as a wall panel sees it. Seeds one if the instance has none, so + * the shot shows the grid rather than an empty-state message. + */ +async function captureDashboards(page, dir) { + await page.goto(`${APP_URL}/dashboards`, { waitUntil: "networkidle" }) + + if (!(await page.getByTestId("dashboard-card").count())) { + await page.getByTestId("new-dashboard-name").fill("panel") + await page.getByTestId("create-dashboard").click() + await page.waitForURL(/\/dashboards\/.+/, { timeout: 15000 }) + // A widget, so the grid has something in it worth photographing. + await page.getByTestId("add-widget-stat").click() + await page.waitForSelector("[data-testid=widget-settings]") + await page.getByTestId("toggle-edit").click() + } else { + await page.getByTestId("dashboard-card").first().click() + await page.waitForURL(/\/dashboards\/.+/, { timeout: 15000 }) + } + + await page.waitForTimeout(1500) + await page.screenshot({ path: `${dir}/app-panel.png` }) +} + /** * The flow editor, empty-handed if the instance has no flows yet: seeds one * with a node so the canvas and the node panel are both worth looking at. diff --git a/frontend/scripts/verify-dashboard.mjs b/frontend/scripts/verify-dashboard.mjs deleted file mode 100644 index 08d96fe..0000000 --- a/frontend/scripts/verify-dashboard.mjs +++ /dev/null @@ -1,78 +0,0 @@ -/** Feature check: build a dashboard, read a live value, move a control. */ -import { mkdir } from "node:fs/promises" -import { chromium } from "@playwright/test" - -const APP_URL = process.env.APP_URL || "http://app.localhost" -const EMAIL = process.env.FIRST_SUPERUSER -const PASSWORD = process.env.FIRST_SUPERUSER_PASSWORD -const OUT = process.env.SCREENSHOT_DIR || "screenshots" -const NAME = process.env.DASHBOARD_NAME || "house" - -const browser = await chromium.launch() -for (const theme of ["light", "dark"]) { - const dir = `${OUT}/${theme}` - await mkdir(dir, { recursive: true }) - const context = await browser.newContext({ - viewport: { width: 1440, height: 900 }, - colorScheme: theme, - }) - await context.addInitScript((t) => { - localStorage.setItem("fluksio-ui-theme", t) - }, theme) - const page = await context.newPage() - - await page.goto(`${APP_URL}/login`, { waitUntil: "networkidle" }) - await page.getByTestId("email-input").fill(EMAIL) - await page.getByTestId("password-input").fill(PASSWORD) - await page.getByRole("button", { name: /log in/i }).click() - await page.waitForURL(`${APP_URL}/`, { timeout: 15000 }) - - await page.goto(`${APP_URL}/dashboards`, { waitUntil: "networkidle" }) - - // Light builds it; dark just looks at what light left behind. - if (theme === "light") { - await page.getByTestId("new-dashboard-name").fill(NAME) - await page.getByTestId("create-dashboard").click() - await page.waitForURL(/\/dashboards\/.+/, { timeout: 15000 }) - - // A reading, a dial and a control over the same message. - for (const [kind, message] of [ - ["stat", "heating.applied"], - ["gauge", "heating.setpoint"], - ["slider", "heating.setpoint"], - ]) { - await page.getByTestId(`add-widget-${kind}`).click() - await page.waitForSelector("[data-testid=widget-settings]") - await page.getByTestId("widget-message").click() - await page.getByRole("option", { name: message }).click() - await page.waitForTimeout(400) - } - await page.waitForTimeout(1500) // let the autosave land - } else { - await page.getByTestId("dashboard-card").first().click() - await page.waitForURL(/\/dashboards\/.+/, { timeout: 15000 }) - } - - // View mode is what a panel shows. - const done = page.getByTestId("toggle-edit") - if ((await done.textContent())?.includes("Done")) await done.click() - await page.waitForTimeout(1200) - await page.screenshot({ path: `${dir}/app-dashboard-view.png` }) - - if (theme === "light") { - // Move the slider and confirm the value actually reached the engine. - const slider = page.locator('input[type="range"]').first() - await slider.click() - await slider.press("ArrowRight") - await page.waitForTimeout(1500) - await page.screenshot({ path: `${dir}/app-dashboard-control.png` }) - - await page.getByTestId("toggle-edit").click() - await page.waitForTimeout(1000) - await page.screenshot({ path: `${dir}/app-dashboard-edit.png` }) - } - - console.log(` ${theme}: done`) - await context.close() -} -await browser.close() diff --git a/frontend/scripts/verify-endpoints.mjs b/frontend/scripts/verify-endpoints.mjs deleted file mode 100644 index 7e260de..0000000 --- a/frontend/scripts/verify-endpoints.mjs +++ /dev/null @@ -1,82 +0,0 @@ -/** The reported bug, end to end: move the real slider, watch the canvas. */ -import { mkdir } from "node:fs/promises" -import { chromium } from "@playwright/test" - -const APP_URL = process.env.APP_URL || "http://app.localhost" -const OUT = process.env.SCREENSHOT_DIR || "screenshots" -const browser = await chromium.launch() - -async function login(page) { - await page.goto(`${APP_URL}/login`, { waitUntil: "networkidle" }) - await page.getByTestId("email-input").fill(process.env.FIRST_SUPERUSER) - await page - .getByTestId("password-input") - .fill(process.env.FIRST_SUPERUSER_PASSWORD) - await page.getByRole("button", { name: /log in/i }).click() - await page.waitForURL(`${APP_URL}/`, { timeout: 15000 }) -} - -for (const theme of ["light", "dark"]) { - const dir = `${OUT}/${theme}` - await mkdir(dir, { recursive: true }) - const context = await browser.newContext({ - viewport: { width: 1440, height: 900 }, - colorScheme: theme, - }) - await context.addInitScript((t) => { - localStorage.setItem("fluksio-ui-theme", t) - }, theme) - const page = await context.newPage() - await login(page) - - await page.goto(`${APP_URL}/flows/probe`, { waitUntil: "networkidle" }) - await page.keyboard.press("Escape") - await page.waitForTimeout(2000) - await page.screenshot({ path: `${dir}/endpoints-canvas.png` }) - - if (theme === "light") { - // Watch the canvas while a second tab moves the real slider. - const watch = page.evaluate(async () => { - const seen = new Set() - const scan = () => { - for (const el of document.querySelectorAll(".edge-live")) { - const holder = el.closest("[data-id]") - if (holder) seen.add(holder.getAttribute("data-id")) - } - } - const observer = new MutationObserver(scan) - observer.observe(document.body, { - attributes: true, - subtree: true, - attributeFilter: ["class"], - }) - await new Promise((r) => setTimeout(r, 6000)) - observer.disconnect() - return [...seen] - }) - - const other = await context.newPage() - await other.goto(`${APP_URL}/dashboards/probe`, { - waitUntil: "networkidle", - }) - await other.waitForTimeout(1200) - const slider = other.locator('input[type="range"]').first() - await slider.click() - for (let i = 0; i < 3; i++) { - await slider.press("ArrowRight") - await other.waitForTimeout(700) - } - const pulsed = await watch - console.log( - " edges that pulsed while the slider moved:", - JSON.stringify(pulsed), - ) - await other.close() - await page.waitForTimeout(500) - await page.screenshot({ path: `${dir}/endpoints-after-slider.png` }) - } - - console.log(` ${theme}: done`) - await context.close() -} -await browser.close() diff --git a/frontend/scripts/verify-live.mjs b/frontend/scripts/verify-live.mjs deleted file mode 100644 index 4220eb0..0000000 --- a/frontend/scripts/verify-live.mjs +++ /dev/null @@ -1,41 +0,0 @@ -/** Screenshot the dashboard and the flow canvas as a user will find them. */ -import { mkdir } from "node:fs/promises" -import { chromium } from "@playwright/test" - -const APP_URL = process.env.APP_URL || "http://app.localhost" -const OUT = process.env.SCREENSHOT_DIR || "screenshots" -const browser = await chromium.launch() - -for (const theme of ["light", "dark"]) { - const dir = `${OUT}/${theme}` - await mkdir(dir, { recursive: true }) - const context = await browser.newContext({ - viewport: { width: 1440, height: 900 }, - colorScheme: theme, - }) - await context.addInitScript((t) => { - localStorage.setItem("fluksio-ui-theme", t) - }, theme) - const page = await context.newPage() - - await page.goto(`${APP_URL}/login`, { waitUntil: "networkidle" }) - await page.getByTestId("email-input").fill(process.env.FIRST_SUPERUSER) - await page - .getByTestId("password-input") - .fill(process.env.FIRST_SUPERUSER_PASSWORD) - await page.getByRole("button", { name: /log in/i }).click() - await page.waitForURL(`${APP_URL}/`, { timeout: 15000 }) - - await page.goto(`${APP_URL}/dashboards/aircon`, { waitUntil: "networkidle" }) - await page.waitForTimeout(2500) - await page.screenshot({ path: `${dir}/live-dashboard.png` }) - - await page.goto(`${APP_URL}/flows/aircon`, { waitUntil: "networkidle" }) - await page.keyboard.press("Escape") - await page.waitForTimeout(2500) - await page.screenshot({ path: `${dir}/live-flow.png` }) - - console.log(` ${theme}: done`) - await context.close() -} -await browser.close() diff --git a/frontend/scripts/verify-params.mjs b/frontend/scripts/verify-params.mjs deleted file mode 100644 index 3bc10d1..0000000 --- a/frontend/scripts/verify-params.mjs +++ /dev/null @@ -1,72 +0,0 @@ -/** Feature check: the free-form settings editor on a function node. */ -import { mkdir } from "node:fs/promises" -import { chromium } from "@playwright/test" - -const APP_URL = process.env.APP_URL || "http://app.localhost" -const EMAIL = process.env.FIRST_SUPERUSER -const PASSWORD = process.env.FIRST_SUPERUSER_PASSWORD -const OUT = process.env.SCREENSHOT_DIR || "screenshots" - -const browser = await chromium.launch() -for (const theme of ["light", "dark"]) { - const dir = `${OUT}/${theme}` - await mkdir(dir, { recursive: true }) - const context = await browser.newContext({ - viewport: { width: 1440, height: 900 }, - colorScheme: theme, - }) - await context.addInitScript((t) => { - localStorage.setItem("fluksio-ui-theme", t) - }, theme) - const page = await context.newPage() - - await page.goto(`${APP_URL}/login`, { waitUntil: "networkidle" }) - await page.getByTestId("email-input").fill(EMAIL) - await page.getByTestId("password-input").fill(PASSWORD) - await page.getByRole("button", { name: /log in/i }).click() - await page.waitForURL(`${APP_URL}/`, { timeout: 15000 }) - - await page.goto(`${APP_URL}/flows`, { waitUntil: "networkidle" }) - const seed = page.getByTestId("create-first-flow") - if (await seed.count()) { - await seed.click() - await page.waitForURL(/\/flows\/.+/, { timeout: 15000 }) - } - if (!(await page.locator(".react-flow__node").count())) { - await page.getByTestId("add-node").click() - await page - .getByRole("option", { name: /function/i }) - .first() - .click() - await page.waitForSelector(".react-flow__node") - } - await page.locator(".react-flow__node").first().click() - await page.waitForSelector("[data-testid=node-panel]", { timeout: 15000 }) - - // Add two settings and give them values. - await page.getByTestId("add-param").click() - await page.waitForTimeout(300) - const names = page.getByLabel("Setting name") - const values = page.getByLabel("Setting value") - await names.first().fill("threshold") - await names.first().blur() - await page.waitForTimeout(200) - await page.getByLabel("Type").first().click() - await page.getByRole("option", { name: "number" }).click() - await page.waitForTimeout(200) - await values.first().fill("21.5") - await page.waitForTimeout(200) - - await page.getByTestId("add-param").click() - await page.waitForTimeout(300) - await names.nth(1).fill("label") - await names.nth(1).blur() - await page.waitForTimeout(200) - await values.nth(1).fill("living room") - await page.waitForTimeout(1200) - - await page.screenshot({ path: `${dir}/app-node-params.png` }) - console.log(` wrote ${dir}/app-node-params.png`) - await context.close() -} -await browser.close() diff --git a/frontend/src/components/Flow/EndpointNode.tsx b/frontend/src/components/Flow/EndpointNode.tsx index a47ec95..dbdf376 100644 --- a/frontend/src/components/Flow/EndpointNode.tsx +++ b/frontend/src/components/Flow/EndpointNode.tsx @@ -26,7 +26,11 @@ function EndpointNodeComponent({ data, selected }: NodeProps) { return (