diff --git a/NOTEPAD.md b/NOTEPAD.md index 934f016..9c339cb 100644 --- a/NOTEPAD.md +++ b/NOTEPAD.md @@ -12,6 +12,22 @@ Deferring because out of scope is fine, but don't mention deferring than. ### To be sorted +- CHORE/UI: the house panels are laid out for 1280x800 — twelve columns, twelve + rows — and that is as much as fits: a chart spends about eighty pixels on its + title, range picker and legend whatever height it is given, so two of them + read on that panel and three do not. The temperature history was the one + dropped; `history.climate_*` still answers, so it is a tile away. +- FEAT/UI: a bar's nested reading is captioned by its port name, which is + chosen for the graph rather than for somebody reading it across a room. + `Segment` now takes an optional `label`; the house dashboard sets one + (`Solar`), and it shows on the next frontend build. +- BUG/INFRA: the running `fluksio-frontend` image calls `https://api.localhost` + while `app/.env` says `VITE_API_URL=http://api.localhost` — the image predates + the environment being switched to `local`. Anything served from that bundle + reaches the API only where something terminates TLS for `api.localhost`. The + next frontend build changes the origin, so check how the app is actually + reached before making one. + - BUG/FLOW: **a cancelled request can leave `FlowController._lock` held forever.** Seeding nineteen flows over a client that timed out mid-request left the next `POST /flows/{name}/start` waiting on the lock indefinitely — ten minutes, until diff --git a/frontend/scripts/capture-panels.mjs b/frontend/scripts/capture-panels.mjs new file mode 100644 index 0000000..dfa4358 --- /dev/null +++ b/frontend/scripts/capture-panels.mjs @@ -0,0 +1,116 @@ +/** + * The house panels, at the size of the screen they hang on. + * + * `/view/{name}` is the wall-panel route: no sidebar, no editor. Shooting it + * at exactly the panel's pixels is the only way to see whether a tile fell off + * the bottom, because a panel does not scroll. + * + * Env: APP_URL, FIRST_SUPERUSER, FIRST_SUPERUSER_PASSWORD, + * PANELS (comma-separated), PANEL_SIZE ("1280x800"), SCREENSHOT_DIR + */ +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/panels" +const NAMES = (process.env.PANELS || "home,comfort,energy").split(",") +const [width, height] = (process.env.PANEL_SIZE || "1280x800") + .split("x") + .map(Number) + +if (!EMAIL || !PASSWORD) { + console.error("FIRST_SUPERUSER / FIRST_SUPERUSER_PASSWORD are unset.") + process.exit(1) +} + +/** What the bundle asks for, and where it is really answered. */ +const API_PUBLIC = process.env.API_PUBLIC || "https://api.localhost" +const API_ORIGIN = process.env.API_ORIGIN || "" +/** Where the bundle is really served from, for fetches outside the browser. */ +const APP_ORIGIN = process.env.APP_ORIGIN || "" + +const RESOLVER = process.env.HOST_RESOLVER_RULES +const browser = await chromium.launch( + RESOLVER ? { args: [`--host-resolver-rules=${RESOLVER}`] } : {}, +) + +for (const theme of ["light", "dark"]) { + const dir = `${OUT}/${theme}` + await mkdir(dir, { recursive: true }) + const context = await browser.newContext({ + viewport: { width, height }, + colorScheme: theme, + // A panel is a touch screen, and several controls draw a taller target + // for one. Shooting it as a mouse would misreport the layout. + hasTouch: true, + }) + await context.addInitScript((t) => { + localStorage.setItem("fluksio-ui-theme", t) + }, theme) + + // The bundle addresses the API at whatever origin it was built for, and + // this stack has no TLS in front of it — so the calls are re-issued at the + // address the container actually answers on. API_ORIGIN is that address; + // without it nothing is intercepted and the page is left alone. + if (API_ORIGIN) { + // The origin is baked into the bundle at build time, and this stack has no + // TLS in front of it. Rewriting it in the JavaScript as it is served fixes + // the websocket too — routing only the HTTP calls would leave every widget + // showing an em dash, since live values arrive over the socket. + const wsPublic = API_PUBLIC.replace(/^http/, "ws") + const wsOrigin = API_ORIGIN.replace(/^http/, "ws") + await context.route("**/*.js", async (route) => { + // route.fetch runs in Playwright's own process, which does not have the + // browser's resolver rules — so it is told the address directly. + const response = await route.fetch( + APP_ORIGIN + ? { url: route.request().url().replace(APP_URL, APP_ORIGIN) } + : {}, + ) + const body = (await response.text()) + .split(API_PUBLIC) + .join(API_ORIGIN) + .split(wsPublic) + .join(wsOrigin) + await route.fulfill({ response, body }) + }) + } + + const page = await context.newPage() + page.on("console", (m) => { + if (m.type() === "error") console.log(` console: ${m.text()}`) + }) + page.on("requestfailed", (r) => + console.log(` failed: ${r.url()} ${r.failure()?.errorText}`), + ) + 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: 20000 }) + + for (const name of NAMES) { + await page.goto(`${APP_URL}/view/${name}`, { waitUntil: "networkidle" }) + // Widgets fetch their own values; the chart draws after its answer lands. + await page.waitForTimeout(3000) + await page.screenshot({ path: `${dir}/${name}.png` }) + const overflow = await page.evaluate(() => ({ + scrollH: document.documentElement.scrollHeight, + clientH: document.documentElement.clientHeight, + scrollW: document.documentElement.scrollWidth, + clientW: document.documentElement.clientWidth, + })) + const spills = + overflow.scrollH > overflow.clientH + 1 || + overflow.scrollW > overflow.clientW + 1 + console.log( + ` ${theme}/${name}: ${spills ? "SPILLS " : "fits "} ` + + `${overflow.scrollW}x${overflow.scrollH} in ${overflow.clientW}x${overflow.clientH}`, + ) + } + await context.close() +} + +await browser.close() diff --git a/frontend/src/components/Dashboard/BarWidget.tsx b/frontend/src/components/Dashboard/BarWidget.tsx index b2e12d6..e14574b 100644 --- a/frontend/src/components/Dashboard/BarWidget.tsx +++ b/frontend/src/components/Dashboard/BarWidget.tsx @@ -34,7 +34,7 @@ export const MAX_SEGMENTS = 3 /** Outer fill left around a segment, as `inset-y-1` leaves it above and below. */ const GUTTER = "2px" -export type Segment = { message?: string; dtype?: string } +export type Segment = { message?: string; dtype?: string; label?: string } /** A segment as drawn: where it runs on the fill, and what it reads. */ type Band = { start: number; end: number; level: number; name: string } @@ -48,7 +48,13 @@ export const segmentsOf = (widget: WidgetProps["widget"]): Segment[] => { if (Array.isArray(cfg.inner)) return (cfg.inner as Segment[]).slice(0, MAX_SEGMENTS) return cfg.inner - ? [{ message: text(cfg.inner), dtype: text(cfg.inner_dtype) }] + ? [ + { + message: text(cfg.inner), + dtype: text(cfg.inner_dtype), + label: text(cfg.inner_label), + }, + ] : [] } @@ -108,8 +114,10 @@ export function BarWidget({ widget }: WidgetProps) { end: cursor, level, // The panel already carries the widget's title, so the caption names the - // reading by its port rather than repeating the flow it comes from. - name: displayName(flowOf(name), name), + // reading by its port rather than repeating the flow it comes from — or + // by whatever the author called it, since a port name is chosen for the + // graph and not for somebody reading it across a room. + name: text(segment.label) || displayName(flowOf(name), name), }) } const detail = drawn