diff --git a/frontend/scripts/verify-live.mjs b/frontend/scripts/verify-live.mjs new file mode 100644 index 0000000..4220eb0 --- /dev/null +++ b/frontend/scripts/verify-live.mjs @@ -0,0 +1,41 @@ +/** 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/src/components/Flow/FlowEditor.tsx b/frontend/src/components/Flow/FlowEditor.tsx index a71a884..5df674b 100644 --- a/frontend/src/components/Flow/FlowEditor.tsx +++ b/frontend/src/components/Flow/FlowEditor.tsx @@ -303,6 +303,18 @@ function FlowEditorInner({ [nodeTypeInfo], ) + // Which types came from an installed connector rather than the engine. They + // cannot be in the icon map, so they share one. + const pluginTypes = useMemo( + () => + new Set( + (nodeTypeInfo ?? []) + .filter((info) => info.plugin) + .map((info) => info.type), + ), + [nodeTypeInfo], + ) + const issuesByNode = useMemo(() => { const map = new Map() for (const issue of issues) { @@ -328,11 +340,20 @@ function FlowEditorInner({ flow: flowName, typeLabel: typeLabels.get(definition?.type ?? "") ?? definition?.type ?? "", + isPlugin: pluginTypes.has(definition?.type ?? ""), issueText: nodeIssues.join("\n"), } satisfies FlowNodeData, } }), - [canvasNodes, definitions, flowName, issuesByNode, selectedId, typeLabels], + [ + canvasNodes, + definitions, + flowName, + issuesByNode, + pluginTypes, + selectedId, + typeLabels, + ], ) // A cheap fingerprint of the wiring: it changes when a name does, but not diff --git a/frontend/src/components/Flow/FlowNode.tsx b/frontend/src/components/Flow/FlowNode.tsx index b2face8..01079b5 100644 --- a/frontend/src/components/Flow/FlowNode.tsx +++ b/frontend/src/components/Flow/FlowNode.tsx @@ -10,6 +10,7 @@ import { Globe, Merge, Play, + Plug, Radio, Shuffle, Split, @@ -58,6 +59,7 @@ export type FlowNodeData = { definition: NodeDef_Input flow: string typeLabel: string + isPlugin?: boolean issueText: string [key: string]: unknown } @@ -101,10 +103,15 @@ function PortHandles({ } function FlowNodeComponent({ data, selected }: NodeProps) { - const { definition, flow, typeLabel, issueText } = data as FlowNodeData + const { definition, flow, typeLabel, isPlugin, issueText } = + data as FlowNodeData const live = useNodeStatus(`${flow}.${definition.id}`) const emits = useNodeEmits(`${flow}.${definition.id}`) - const Icon = NODE_ICONS[definition.type as keyof typeof NODE_ICONS] ?? Code2 + const Icon = + NODE_ICONS[definition.type as keyof typeof NODE_ICONS] ?? + // A connector's own type cannot be in the map above, and a device is + // not a piece of code. + (isPlugin ? Plug : Code2) // Whatever is wrong — it failed to load, it failed to run, or the graph // around it does not add up — is the same red dot with the same explanation.