diff --git a/reboot/cli/commands/dashboard.py b/reboot/cli/commands/dashboard.py index 33a475b8e..947302fc8 100644 --- a/reboot/cli/commands/dashboard.py +++ b/reboot/cli/commands/dashboard.py @@ -9,6 +9,7 @@ from pathlib import Path from reboot.aio.backoff import Backoff from reboot.cli.commands.dev import ( + DEFAULT_LOCAL_ENVOY_PORT, _dashboard_reachable, _open_on_restart, _viewers, @@ -29,6 +30,7 @@ DEFAULT_DASHBOARD_PORT, ENVVAR_RBT_API_DIRECTORY, ENVVAR_RBT_APPLICATION, + ENVVAR_RBT_APPLICATION_URL, ENVVAR_RBT_GENERATED_DIRECTORY, ) from reboot.settings import ( @@ -121,6 +123,25 @@ def _application(parser: ArgumentParser) -> Optional[str]: return None +def _application_url(parser: ArgumentParser) -> str: + """Returns where the developer's application serves, which is the + port they tell `rbt dev run` to serve on, and `rbt dev run`'s + default port when they tell it none. + + Read rather than asked for again, for the same reason as the + application. Only a plain `dev run --port=` line counts: a line + written for a config, `dev run:hmr --port=`, applies only when + that config is asked for, which nothing here knows. + """ + port = DEFAULT_LOCAL_ENVOY_PORT + for argument in parser.dot_rc_arguments('dev run'): + name, separator, value = argument.partition('=') + if name == '--port' and separator == '=': + port = int(value) + + return f'http://localhost:{port}' + + def _generated_directory(parser: ArgumentParser) -> Optional[str]: """Returns the directory `rbt generate` writes Python code into, which is where its `--python=` flag points, and `None` when it @@ -145,6 +166,7 @@ def _dashboard_env( port: int, api_directory: str, application: Optional[str], + application_url: str, generated_directory: Optional[str], ) -> dict[str, str]: """The environment for the dashboard application. @@ -204,6 +226,11 @@ def _dashboard_env( if application is not None: composed[ENVVAR_RBT_APPLICATION] = application + # Where the developer's application serves, for the page to read + # its states and tasks from. Always set: the application has a + # port whether or not it is running. + composed[ENVVAR_RBT_APPLICATION_URL] = application_url + # Where the developer's generated Python is, spelled the same way # and for the same reason. Left out when the developer named no # `--python` directory, which is what tells the dashboard there is @@ -366,6 +393,7 @@ async def dashboard( port=port, api_directory=_api_directory(parser), application=_application(parser), + application_url=_application_url(parser), generated_directory=_generated_directory(parser), ) diff --git a/reboot/dashboard/backend/constants.py b/reboot/dashboard/backend/constants.py index aa03061b3..e6e8235b4 100644 --- a/reboot/dashboard/backend/constants.py +++ b/reboot/dashboard/backend/constants.py @@ -21,6 +21,12 @@ # logs, and outside the Linux ephemeral range. DEFAULT_DASHBOARD_PORT = 9871 +# Where the dashboard application tells its page the developer's +# application is, as a JSON object with the URL under `url`. The page +# runs in the browser and cannot read the environment, so the URL the +# CLI puts there has to be served to it. +APPLICATION_PATH = '/application' + # The `Dashboard` state holding everything the dashboard shows, as # the dashboard application last read it. DASHBOARD_ID = 'dashboard' @@ -47,6 +53,12 @@ # which case no implementation is looked for. ENVVAR_RBT_APPLICATION = 'RBT_APPLICATION' +# Where the developer's application serves, `http://localhost:9991`, +# from the port the `.rbtrc` gives `rbt dev run`. The page reads the +# application's states and tasks from there; nothing else in the +# dashboard reaches the application. +ENVVAR_RBT_APPLICATION_URL = 'RBT_APPLICATION_URL' + # The directory `rbt generate` writes Python code into, as the # `.rbtrc` spells it with `--python=`. The generated code is where # the state types are defined, which is what typing the developer's diff --git a/reboot/dashboard/backend/main.py b/reboot/dashboard/backend/main.py index 97791ed38..1db59bcee 100644 --- a/reboot/dashboard/backend/main.py +++ b/reboot/dashboard/backend/main.py @@ -8,6 +8,7 @@ being developed. """ import asyncio +import os from pathlib import Path from rbt.dashboard.v1.dashboard_rbt import Dashboard, Preferences from rbt.std.collections.ordered_map.v1.ordered_map_rbt import OrderedMap @@ -17,9 +18,11 @@ from reboot.aio.external import InitializeContext from reboot.bdd import recordings from reboot.dashboard.backend.constants import ( + APPLICATION_PATH, CHANGELOG_ID, DASHBOARD_ID, DASHBOARD_PATH, + ENVVAR_RBT_APPLICATION_URL, PREFERENCES_ID, PRESENCE_ID, ) @@ -32,7 +35,7 @@ ) from reboot.std.presence.v1 import presence from starlette.exceptions import HTTPException -from starlette.responses import FileResponse +from starlette.responses import FileResponse, JSONResponse from starlette.staticfiles import StaticFiles # The built page, beside this module, which is the same arrangement @@ -103,6 +106,12 @@ async def recording(relative: str) -> FileResponse: feature file under the working directory.""" return FileResponse(_recording(Path.cwd(), relative)) + @application.http.get(APPLICATION_PATH) + async def application_url() -> JSONResponse: + """Where the developer's application serves, for the page to + read its states and tasks from.""" + return JSONResponse({'url': os.environ[ENVVAR_RBT_APPLICATION_URL]}) + application.http.mount( DASHBOARD_PATH, app=StaticFiles( diff --git a/reboot/dashboard/web/BUILD.bazel b/reboot/dashboard/web/BUILD.bazel index bb445ea17..6d7ff4dcc 100644 --- a/reboot/dashboard/web/BUILD.bazel +++ b/reboot/dashboard/web/BUILD.bazel @@ -9,14 +9,19 @@ ts_config( ts_project( name = "dashboard_ts", srcs = [ + "src/application.ts", "src/callgraph.ts", "src/changelog.ts", "src/constants.ts", "src/feature_files.ts", "src/features.ts", + "src/frontend_window.tsx", "src/graph.tsx", + "src/json_tree.tsx", "src/link_properties_to_data_types.ts", "src/main.tsx", + "src/picker.tsx", + "src/states.tsx", ], declaration = True, tsconfig = ":tsconfig", @@ -24,6 +29,7 @@ ts_project( visibility = ["//tests/reboot/dashboard:__pkg__"], deps = [ "//:node_modules/@bufbuild/protobuf", + "//:node_modules/@reboot-dev/reboot-api", "//:node_modules/@reboot-dev/reboot-react", "//:node_modules/@reboot-dev/reboot-std", "//:node_modules/@reboot-dev/reboot-std-api", @@ -42,6 +48,7 @@ ts_project( "//rbt/v1alpha1/api:schema_js_proto", "//rbt/v1alpha1/bdd:feature_js_proto", "//rbt/v1alpha1/bdd:grammar_js_proto", + "//rbt/v1alpha1/inspect:inspect_js_proto", ], ) diff --git a/reboot/dashboard/web/dashboard.css b/reboot/dashboard/web/dashboard.css index 8e1b2ddb0..75c10947b 100644 --- a/reboot/dashboard/web/dashboard.css +++ b/reboot/dashboard/web/dashboard.css @@ -1470,6 +1470,29 @@ header h1 { border-radius: 10px; } +/* The box's resize controls: its sides and corners, shown while the + box is hovered, in the colour a dragged divider takes. */ +.graph-canvas .react-flow__resize-control.line { + border-color: transparent; + border-width: 3px; +} + +.graph-canvas .react-flow__resize-control.handle { + width: 8px; + height: 8px; + border: 1px solid hsl(var(--card)); + border-radius: 2px; + background: hsl(var(--primary)); + opacity: 0; + transition: opacity 120ms ease; +} + +.graph-canvas + .react-flow__node-expanded:hover + .react-flow__resize-control.handle { + opacity: 1; +} + /* 38px tall, which `graph.tsx` lays out by. */ .graph-expanded-package-head { display: flex; @@ -2099,9 +2122,9 @@ header .state-type-description { margin-right: 4px; } -/* The box the state types to filter by are chosen in: the chosen - ones as chips, then the cursor. */ -.type-picker { +/* The box the items to filter by are chosen in, state types on the + features page: the chosen ones as chips, then the cursor. */ +.picker { position: relative; display: flex; flex-wrap: wrap; @@ -2115,11 +2138,11 @@ header .state-type-description { cursor: text; } -.type-picker.is-open { +.picker.is-open { border-color: hsl(var(--border-strong)); } -.type-picker-input { +.picker-input { flex: 1; min-width: 24px; font: inherit; @@ -2131,9 +2154,9 @@ header .state-type-description { color: hsl(var(--foreground)); } -/* The state types not yet chosen, listed under the box while the - cursor is in it. */ -.type-picker-menu { +/* The items not yet chosen, listed under the box while the cursor + is in it. */ +.picker-menu { position: absolute; top: calc(100% + 4px); left: 0; @@ -2150,24 +2173,24 @@ header .state-type-description { box-shadow: 0 6px 20px hsl(0 0% 0% / 0.08); } -.type-picker-item { +.picker-item { padding: 3px 4px; border-radius: 4px; cursor: pointer; } -.type-picker-item:hover { +.picker-item:hover { background: hsl(var(--muted)); } -.type-picker-none { +.picker-none { padding: 4px 6px; font-size: 12px; color: hsl(var(--muted-foreground)); } -/* A state type that filters the index: a chip, lit while filtering. */ -.state-type-chip { +/* An item that filters a list: a chip, lit while filtering. */ +.chip { font-family: ui-monospace, Menlo, monospace; font-size: 11px; padding: 1px 7px; @@ -2178,11 +2201,11 @@ header .state-type-description { cursor: pointer; } -.state-type-chip:hover { +.chip:hover { border-color: hsl(var(--border-strong)); } -.state-type-chip.is-active { +.chip.is-active { background: hsl(var(--primary)); border-color: hsl(var(--primary)); color: hsl(var(--primary-foreground)); @@ -2640,3 +2663,494 @@ header .state-type-description { font-family: ui-monospace, Menlo, monospace; font-size: 12px; } + +/* --- Instances --- */ + +/* On the models page the types pane splits: the type above, its + instances below, and between them a divider that is dragged. */ +.types-pane-split .types-pane-body { + height: 100%; +} + +.instances-resizer { + position: relative; + height: 9px; + margin: -4px 0; + z-index: 5; +} + +.instances-resizer::after { + content: ""; + position: absolute; + inset: 4px 0; + background: transparent; + transition: background 120ms ease; +} + +.instances-resizer[data-separator="hover"]::after, +.instances-resizer[data-separator="active"]::after, +.instances-resizer:focus-visible::after { + background: hsl(var(--primary)); +} + +.instances { + height: 100%; + display: flex; + flex-direction: column; + border-top: 1px solid hsl(var(--border)); + background: hsl(var(--card)); +} + +/* With only a line to say, the section is that line tall and the + type above takes the rest. */ +.instances-brief { + height: auto; + flex: none; +} + +/* The head is set like a state type's section eyebrow. */ +.instances-head { + display: flex; + align-items: center; + gap: 10px; + padding: 10px 20px; + background: hsl(45 30% 97%); + border-bottom: 1px solid hsl(var(--border)); + font-family: ui-monospace, Menlo, monospace; + font-size: 10.5px; + letter-spacing: 0.08em; + text-transform: uppercase; + color: hsl(var(--muted-foreground)); +} + +/* Why there is nothing to list: the application is not running, or + has no instances of the type. */ +.instances-note { + margin: 0; + padding: 14px 20px; + font-size: 13px; + line-height: 1.5; + color: hsl(var(--muted-foreground)); + text-wrap: pretty; +} + +.instances-note code { + font-family: ui-monospace, Menlo, monospace; + font-size: 12px; + padding: 1px 5px; + border-radius: 4px; + background: hsl(var(--muted)); + color: hsl(var(--foreground)); +} + +/* The search over the ids, and under it how many match. An id is + long; a chip and the listed ids break rather than push the box + wide. */ +.instances-search { + display: flex; + flex-direction: column; + gap: 6px; + padding: 10px 14px; + border-bottom: 1px solid hsl(var(--border)); +} + +.instances-search .picker { + min-width: 0; +} + +.instances-search .chip { + word-break: break-all; + text-align: left; +} + +/* The ids, a row each, scrolling within the section; a row opens to + its data. */ +.instance-rows { + list-style: none; + margin: 0; + padding: 0; + flex: 1; + overflow-y: auto; +} + +.instance-row + .instance-row { + border-top: 1px solid hsl(var(--border-soft)); +} + +/* An open row is tinted so its data below reads as part of it. */ +.instance-row.is-open { + background: hsl(var(--accent) / 0.06); +} + +.instance-row-head { + display: flex; + align-items: center; + gap: 6px; + padding-right: 14px; +} + +.instance-row-head:hover { + background: hsl(var(--accent) / 0.08); +} + +/* The id is the row's button, filling it so the whole row opens. */ +.instance-row-toggle { + flex: 1; + min-width: 0; + display: flex; + align-items: center; + gap: 8px; + padding: 9px 20px; + border: 0; + background: none; + font: inherit; + text-align: left; + cursor: pointer; + color: inherit; +} + +.state-id { + font-family: ui-monospace, Menlo, monospace; + font-size: 13px; + color: hsl(var(--primary)); + word-break: break-all; +} + +/* The copy button beside an id or a state's data; quiet until + hovered, and a word while it says it copied. */ +.copy-button { + display: inline-flex; + align-items: center; + font: inherit; + font-size: 11px; + font-weight: 600; + letter-spacing: 0.04em; + text-transform: uppercase; + line-height: 1; + color: hsl(var(--muted-foreground)); + background: none; + border: 0; + padding: 2px; + opacity: 0.55; + cursor: pointer; +} + +.copy-button:hover, +.copy-button.is-copied { + color: hsl(var(--foreground)); + opacity: 1; +} + +/* A state's data, boxed under its row and stepped in past the + caret. */ +.state-data { + margin: 0 14px 12px 34px; + border: 1px solid hsl(var(--border)); + border-radius: 8px; + background: hsl(45 30% 97%); + overflow: hidden; +} + +.state-data-head { + display: flex; + align-items: center; + gap: 10px; + padding: 8px 14px; + border-bottom: 1px solid hsl(var(--border)); + font-family: ui-monospace, Menlo, monospace; + font-size: 10.5px; + letter-spacing: 0.08em; + color: hsl(var(--muted-foreground)); +} + +.state-data-head .copy-button { + margin-left: auto; +} + +.state-data-note { + padding: 12px 16px; + font-family: ui-monospace, Menlo, monospace; + font-size: 12.5px; + font-style: italic; + color: hsl(240 3.8% 55%); +} + +/* --- A state's JSON as a tree --- */ + +.json-tree { + padding: 12px 16px; + font-family: ui-monospace, Menlo, monospace; + font-size: 13px; + line-height: 1.75; +} + +.json-row { + display: flex; + align-items: baseline; + gap: 6px; + min-width: 0; +} + +/* An object's or array's row is a button that opens it; it looks + like every other row. */ +.json-toggle { + width: 100%; + border: 0; + background: none; + font: inherit; + color: inherit; + padding: 0; + text-align: left; + cursor: pointer; +} + +.json-toggle:hover .json-name, +.json-toggle:hover .json-bracket { + color: hsl(var(--primary)); +} + +.json-colon { + color: hsl(240 3.8% 55%); +} + +.json-kind { + color: hsl(28 70% 42%); + font-size: 11.5px; +} + +.json-leaf { + color: hsl(var(--primary)); + font-weight: 600; + word-break: break-all; +} + +.json-null, +.json-boolean { + color: hsl(var(--muted-foreground)); +} + +.json-count { + color: hsl(240 3.8% 55%); + font-style: italic; + font-size: 11.5px; +} + +/* A value that is not what it was: a wash of the accent behind it + that fades, so the eye is drawn to what moved. The element is + remounted on each change, which is what restarts the animation. */ +@keyframes json-flash { + from { + background: hsl(var(--accent) / 0.5); + } + to { + background: transparent; + } +} + +.json-leaf.is-changed, +.json-count.is-changed { + animation: json-flash 1.2s ease-out; + border-radius: 3px; + padding: 0 2px; + margin: 0 -2px; +} + +/* The items under an open object or array, stepped in past the + caret with a line down their left, so where each level ends can be + seen. */ +.json-items { + padding-left: 22px; + border-left: 1px solid hsl(var(--border-strong)); + margin-left: 5px; +} +/* --- The frontend window --- */ + +/* The heading with the frontend button at its right, on the same + baseline, so the button reads as part of the heading's line. */ +.heading-row { + display: flex; + align-items: flex-end; + gap: 18px; + flex-wrap: wrap; +} + +.heading-row h1 { + flex: 1; + min-width: 0; +} + +/* Lit while the window is open, the way a chosen chip is. */ +.frontend-toggle { + display: inline-flex; + align-items: center; + gap: 8px; + margin-bottom: 6px; + font: inherit; + font-size: 13px; + font-weight: 600; + padding: 8px 14px; + border-radius: 7px; + border: 1px solid hsl(var(--primary)); + background: hsl(var(--card)); + color: hsl(var(--primary)); + cursor: pointer; + white-space: nowrap; +} + +.frontend-toggle .connection-dot { + background: hsl(146 60% 40%); +} + +.frontend-toggle.is-open { + background: hsl(var(--primary)); + color: hsl(var(--primary-foreground)); +} + +/* Over everything, so it can be put anywhere on the screen. */ +.frontend-window { + position: fixed; + z-index: 30; + display: flex; + flex-direction: column; + border: 1px solid hsl(var(--border-strong)); + border-radius: 10px; + background: hsl(var(--card)); + box-shadow: 0 6px 24px hsl(215 40% 15% / 0.14); + overflow: hidden; +} + +/* The bar drags the window; what is in it that is typed or clicked + stops the drag itself. */ +.frontend-bar { + display: flex; + align-items: center; + gap: 10px; + height: 40px; + padding: 0 10px 0 14px; + background: hsl(var(--sidebar)); + border-bottom: 1px solid hsl(var(--border)); + font-family: ui-monospace, Menlo, monospace; + font-size: 11px; + letter-spacing: 0.06em; + cursor: grab; + user-select: none; + flex: none; +} + +.frontend-bar .connection-dot { + background: hsl(146 60% 40%); + flex: none; +} + +.frontend-bar-title { + font-weight: 700; + text-transform: uppercase; + flex: none; +} + +.frontend-url { + flex: 1; + min-width: 60px; + font: inherit; + font-size: 11px; + letter-spacing: 0; + padding: 3px 6px; + border: 1px solid transparent; + border-radius: 4px; + background: transparent; + color: hsl(var(--muted-foreground)); + cursor: text; +} + +.frontend-url:hover, +.frontend-url:focus { + outline: none; + border-color: hsl(var(--border)); + background: hsl(var(--card)); + color: hsl(var(--foreground)); +} + +.frontend-note { + flex: none; + font-size: 10px; + color: hsl(var(--errors)); +} + +.frontend-size { + flex: none; + font-size: 10px; + letter-spacing: 0; + color: hsl(240 3.8% 62%); +} + +.frontend-actions { + display: flex; + align-items: center; + gap: 2px; + flex: none; + margin-left: auto; +} + +.frontend-action { + display: grid; + place-items: center; + width: 24px; + height: 22px; + border: 0; + border-radius: 5px; + background: none; + font: inherit; + font-size: 13px; + color: hsl(var(--muted-foreground)); + text-decoration: none; + cursor: pointer; +} + +.frontend-action:hover { + background: hsl(var(--primary) / 0.08); + color: hsl(var(--primary)); +} + +.frontend-frame { + flex: 1; + min-height: 0; + min-width: 0; + border: 0; + background: hsl(var(--card)); +} + +/* While the window is dragged or resized the frame takes no pointer + events, so the pointer never lands in its document, and the + window's outline follows the pointer without the page inside + being laid out again on every movement. */ +.frontend-window.is-moving .frontend-frame { + pointer-events: none; +} + +.frontend-window.is-moving .frontend-bar { + cursor: grabbing; +} + +.frontend-empty { + flex: 1; + padding: 18px 20px; + font-size: 13px; + line-height: 1.55; + color: hsl(var(--muted-foreground)); + text-wrap: pretty; +} + +/* The corner that resizes, drawn as a triangle in it. */ +.frontend-resize { + position: absolute; + right: 0; + bottom: 0; + width: 18px; + height: 18px; + cursor: nwse-resize; + background: linear-gradient( + 135deg, + transparent 50%, + hsl(var(--border-strong)) 50% + ); +} diff --git a/reboot/dashboard/web/src/application.ts b/reboot/dashboard/web/src/application.ts new file mode 100644 index 000000000..8b4841aa3 --- /dev/null +++ b/reboot/dashboard/web/src/application.ts @@ -0,0 +1,378 @@ +// The developer's running application, which the models page reads +// for a type's instances and nothing else on the dashboard does. +// Everything here is a live stream: the application pushes a new +// answer whenever what it answered about changes, and a stream that +// drops is reconnected with backoff until the page leaves. +import { + type JsonValue, + type Message, + type MessageType, + Struct, +} from "@bufbuild/protobuf"; +import { Backoff, Status } from "@reboot-dev/reboot-api"; +import { grpcServerStream } from "@reboot-dev/reboot-web"; +import { useCallback, useEffect, useState } from "react"; +import { + GetStateRequest, + GetStateResponse, + GetStateTypesRequest, + GetStateTypesResponse, + ListStatesRequest, + ListStatesResponse, +} from "../../../../rbt/v1alpha1/inspect/inspect_pb"; +import { APPLICATION_PATH } from "./constants"; + +// The names a browser resolves to this machine. A page on one of +// them and a frame on another are different sites to the browser, +// whatever the ports, and a frame on a different site from its page +// loses its cookies in Safari, in Firefox's strict mode and in any +// incognito window. +const LOOPBACK_HOSTNAMES = new Set(["localhost", "127.0.0.1", "[::1]"]); + +// `url` with its host renamed to `hostname` when both name this +// machine, so that what the page frames or calls is the same site +// as the page, and its cookies work in every browser. Any other URL +// is returned as it is. +export const sameSiteUrl = (url: string, hostname: string): string => { + const parsed = new URL(url); + if ( + LOOPBACK_HOSTNAMES.has(parsed.hostname) && + LOOPBACK_HOSTNAMES.has(hostname) && + parsed.hostname !== hostname + ) { + parsed.hostname = hostname; + } + return parsed.toString().replace(/\/$/, ""); +}; + +// Where the application serves, `http://localhost:9991`, which the +// dashboard application learned from the `.rbtrc` and serves at +// `APPLICATION_PATH`, on the page's own hostname. `undefined` until +// it has been read. +export const useApplicationUrl = (): string | undefined => { + const [url, setUrl] = useState(); + useEffect(() => { + let cancelled = false; + fetch(APPLICATION_PATH) + .then((response) => response.json()) + .then(({ url }: { url: string }) => { + if (!cancelled) { + setUrl(sameSiteUrl(url, window.location.hostname)); + } + }); + return () => { + cancelled = true; + }; + }, []); + return url; +}; + +// The state ref the application routes a request by, in the +// `x-reboot-state-ref` header: the type, a colon, and the id with +// each `/` escaped to `\`, as `_state_id_encode` in +// `reboot/aio/types.py` spells it. +export const stateRefOf = (stateType: string, stateId: string): string => + `${stateType}:${stateId.replace(/\//g, "\\")}`; + +// What one call to the application yields: an answer, a status the +// application aborted with, or that the application could not be +// reached at all. The last is the one the page has to say something +// about, since it means `rbt dev run` is not running. +export type Event = + | { response: ResponseType } + | { status: Status } + | { unreachable: true }; + +// Calls a streaming RPC of the application and yields what it answers, +// reconnecting when the connection drops or cannot be made. A status +// ends the stream: the application answered, and will answer the +// same way again. +// +// Written here rather than with `grpcInfiniteStream`, which retries +// forever and only logs a connection it could not make; the page +// needs to hear about that. +export async function* applicationStream< + RequestType extends Message, + ResponseType extends Message +>({ + url, + method, + request, + responseType, + stateRef, + signal, +}: { + url: string; + method: string; + request: RequestType; + responseType: MessageType; + stateRef?: string; + signal: AbortSignal; +}): AsyncGenerator, void, unknown> { + const headers = new Headers(); + headers.set("Content-Type", "application/json"); + // Under `rbt dev run` the application takes any bearer token as the + // admin's; it only has to be there. + headers.set("Authorization", "Bearer dev"); + if (stateRef !== undefined) { + headers.set("x-reboot-state-ref", stateRef); + } + + const backoff = new Backoff(); + + while (!signal.aborted) { + try { + const responses = await grpcServerStream({ + endpoint: `${url}/${method}`, + method: "POST", + headers, + request, + responseType, + signal, + }); + for await (const response of responses) { + backoff.reset(); + yield { response }; + } + // The application closed a stream it never closes: it is + // restarting, and is unreachable until it is back. + } catch (e: unknown) { + if (signal.aborted) { + return; + } + if (e instanceof Status) { + yield { status: e }; + return; + } + } + yield { unreachable: true }; + await backoff.wait(); + } +} + +// What a page knows of one stream: the answer so far, folded from +// every response by `fold`; whether the application can be reached; +// and the status it aborted with, if it did. +export interface Streamed { + value: T | undefined; + unreachable: boolean; + status: Status | undefined; + // Drops the backoff and reconnects now. + retry: () => void; +} + +// Holds one stream of the application open for as long as the +// component is mounted, folding each response into the value with +// `fold`, which must be pure: it runs against whatever the previous +// value was when the response arrives. `url` undefined means the +// application's address is not known yet, and nothing is called. +export const useApplicationStream = < + RequestType extends Message, + ResponseType extends Message, + T +>({ + url, + method, + request, + responseType, + stateRef, + fold, + dependencies, +}: { + url: string | undefined; + method: string; + request: RequestType; + responseType: MessageType; + stateRef?: string; + fold: (previous: T | undefined, response: ResponseType) => T | undefined; + // What the request was built from, so a new one starts a new + // stream. + dependencies: readonly unknown[]; +}): Streamed => { + const [value, setValue] = useState(); + const [unreachable, setUnreachable] = useState(false); + const [status, setStatus] = useState(); + const [attempt, setAttempt] = useState(0); + + const retry = useCallback((): void => setAttempt((n) => n + 1), []); + + useEffect(() => { + if (url === undefined) { + return; + } + const controller = new AbortController(); + (async () => { + for await (const event of applicationStream({ + url, + method, + request, + responseType, + stateRef, + signal: controller.signal, + })) { + if ("response" in event) { + const { response } = event; + setValue((previous) => fold(previous, response)); + setUnreachable(false); + setStatus(undefined); + } else if ("status" in event) { + setStatus(event.status); + } else { + setUnreachable(true); + } + } + })(); + return () => { + controller.abort(); + setValue(undefined); + setUnreachable(false); + setStatus(undefined); + }; + // `request` is rebuilt every render; `dependencies` is what it + // was built from. + }, [url, method, stateRef, attempt, ...dependencies]); + + return { value, unreachable, status, retry }; +}; + +const INSPECT = "rbt.v1alpha1.inspect.Inspect"; + +// The state types the application serves, sorted, except the ones +// internal to Reboot, which the application leaves out itself. +export const useStateTypes = (url: string | undefined): Streamed => + useApplicationStream({ + url, + method: `${INSPECT}/GetStateTypes`, + request: new GetStateTypesRequest(), + responseType: GetStateTypesResponse, + fold: (_, response) => response.stateTypes.slice().sort(), + dependencies: [], + }); + +// A browser holds at most six connections to one host over plain +// HTTP, and every stream here is one of them for as long as it is +// open. The page keeps its streams well under that. + +// The ids of one type's states, sorted, live. Nothing is read while +// no type is chosen. +export const useStateIds = ( + url: string | undefined, + stateType: string | undefined +): Streamed => + useApplicationStream({ + url: stateType === undefined ? undefined : url, + method: `${INSPECT}/ListStates`, + request: new ListStatesRequest({ stateType: stateType ?? "" }), + responseType: ListStatesResponse, + fold: (_, response) => + response.stateInfos.map((info) => info.stateId).sort(), + dependencies: [stateType], + }); + +// One state's data as JSON, which the application sends as a +// `Struct` in chunks: the chunks so far, and the JSON once the last +// of them has arrived. A state that does not exist is an empty +// object. +interface Chunked { + chunks: Uint8Array[]; + json: JsonValue | undefined; +} + +export const foldChunk = ( + previous: Chunked | undefined, + response: GetStateResponse +): Chunked => { + const chunks = [...(previous?.chunks ?? []), response.data]; + if (response.chunk < response.total - 1) { + return { chunks, json: previous?.json }; + } + const length = chunks.reduce((total, chunk) => total + chunk.length, 0); + const bytes = new Uint8Array(length); + let offset = 0; + for (const chunk of chunks) { + bytes.set(chunk, offset); + offset += chunk.length; + } + return { chunks: [], json: Struct.fromBinary(bytes).toJson() }; +}; + +export const useStateData = ( + url: string | undefined, + stateType: string, + stateId: string +): Streamed => { + const { value, ...rest } = useApplicationStream({ + url, + method: `${INSPECT}/GetState`, + // The header names the state; the request carries nothing. + request: new GetStateRequest(), + responseType: GetStateResponse, + stateRef: stateRefOf(stateType, stateId), + fold: foldChunk, + dependencies: [stateType, stateId], + }); + return { value: value?.json, ...rest }; +}; + +// How long between one-shot reads of what is not worth a connection +// held open. +const POLL_MS = 10_000; + +const sleep = (ms: number): Promise => + new Promise((resolve) => setTimeout(resolve, ms)); + +// Runs `poll` now and again every `POLL_MS` for as long as the +// component is mounted and the application's address is known. +// `poll` is given a way to ask whether it should stop. +const usePoll = ( + url: string | undefined, + poll: (url: string, cancelled: () => boolean) => Promise +): void => { + useEffect(() => { + if (url === undefined) { + return; + } + let cancelled = false; + (async () => { + while (!cancelled) { + await poll(url, () => cancelled); + await sleep(POLL_MS); + } + })(); + return () => { + cancelled = true; + }; + // `poll` is what the hook's caller wrote once; a new function + // each render must not start the reads over. + }, [url]); +}; + +// Where the application serves the developer's web frontend when +// it serves one: proxied to Vite there under `rbt dev run`, or the +// built files mounted there. +export const FRONTEND_PATH = "/__/frontend/web/"; + +// Whether the application serves a frontend, asked every +// `POLL_MS`: it answers with a page when it does, and with +// not found, or Envoy's page about Vite being down, when it does +// not. `undefined` until first asked. +export const useFrontendServed = ( + url: string | undefined +): boolean | undefined => { + const [served, setServed] = useState(); + usePoll(url, async (url, cancelled) => { + let answer = false; + try { + const response = await fetch(`${url}${FRONTEND_PATH}`); + answer = + response.ok && + (response.headers.get("content-type") ?? "").includes("text/html"); + } catch { + answer = false; + } + if (!cancelled()) { + setServed(answer); + } + }); + return served; +}; diff --git a/reboot/dashboard/web/src/constants.ts b/reboot/dashboard/web/src/constants.ts index 8b9ee654f..4660306bb 100644 --- a/reboot/dashboard/web/src/constants.ts +++ b/reboot/dashboard/web/src/constants.ts @@ -4,3 +4,4 @@ export const PRESENCE_ID = "dashboard"; export const DASHBOARD_ID = "dashboard"; export const PREFERENCES_ID = "preferences"; export const CHANGELOG_ID = "changelog"; +export const APPLICATION_PATH = "/application"; diff --git a/reboot/dashboard/web/src/frontend_window.tsx b/reboot/dashboard/web/src/frontend_window.tsx new file mode 100644 index 000000000..7ad311316 --- /dev/null +++ b/reboot/dashboard/web/src/frontend_window.tsx @@ -0,0 +1,360 @@ +// The developer's web frontend in a small window over the models +// page, so that what it does can be watched arriving in the +// instances. +// The window is dragged by its bar, resized by its corner, folded to +// its bar, and closed; where it is and what it shows are remembered +// in the browser, which is the only place the page can remember +// anything without a change to the dashboard's state. +import { + type FC, + type PointerEvent as ReactPointerEvent, + useEffect, + useRef, + useState, +} from "react"; +import { FRONTEND_PATH, sameSiteUrl } from "./application"; + +export interface FrontendWindowSettings { + open: boolean; + collapsed: boolean; + x: number; + y: number; + width: number; + height: number; + // The frontend's URL as the developer typed it; empty for the one + // the application serves, when it serves one. + url: string; +} + +const STORAGE_KEY = "frontend-window"; + +const DEFAULT_SETTINGS: FrontendWindowSettings = { + open: false, + collapsed: false, + x: 480, + y: 400, + width: 460, + height: 340, + url: "", +}; + +// The window never shrinks below what a page can be read in, and +// never leaves the viewport by more than its margin. +const MIN_WIDTH = 320; +const MIN_HEIGHT = 200; +const MARGIN = 8; +const BAR_HEIGHT = 40; + +const readSettings = (): FrontendWindowSettings => { + try { + const stored = window.localStorage.getItem(STORAGE_KEY); + return stored === null + ? DEFAULT_SETTINGS + : { ...DEFAULT_SETTINGS, ...(JSON.parse(stored) as object) }; + } catch { + return DEFAULT_SETTINGS; + } +}; + +// The window's settings, read from the browser once and written +// back on every change. +export const useFrontendWindowSettings = (): [ + FrontendWindowSettings, + (change: Partial) => void +] => { + const [settings, setSettings] = useState(readSettings); + const change = (change: Partial): void => { + setSettings((previous) => { + const next = { ...previous, ...change }; + try { + window.localStorage.setItem(STORAGE_KEY, JSON.stringify(next)); + } catch { + // Nothing to remember it in; the window still works. + } + return next; + }); + }; + return [settings, change]; +}; + +// Follows the pointer from a press on `event` until it is released, +// telling `onMove` how far it has gone from the press and `onEnd` +// when it is let go. The pressed element captures the pointer, so +// the events keep coming to it while the pointer is over the frame, +// whose own document would otherwise get them and keep the release. +const follow = ( + event: ReactPointerEvent, + onMove: (dx: number, dy: number) => void, + onEnd: () => void +): void => { + event.preventDefault(); + const element = event.currentTarget; + const pointerId = event.pointerId; + const startX = event.clientX; + const startY = event.clientY; + const move = (moved: PointerEvent): void => + onMove(moved.clientX - startX, moved.clientY - startY); + const up = (): void => { + element.removeEventListener("pointermove", move); + element.removeEventListener("pointerup", up); + element.removeEventListener("pointercancel", up); + if (element.hasPointerCapture(pointerId)) { + element.releasePointerCapture(pointerId); + } + onEnd(); + }; + element.setPointerCapture(pointerId); + element.addEventListener("pointermove", move); + element.addEventListener("pointerup", up); + element.addEventListener("pointercancel", up); +}; + +const clamp = (value: number, low: number, high: number): number => + Math.max(low, Math.min(high, value)); + +// The button in the page's heading that shows and hides the window. +export const FrontendToggle: FC<{ + settings: FrontendWindowSettings; + onChange: (change: Partial) => void; + frontendUrl: string; +}> = ({ settings, onChange, frontendUrl }) => ( + +); + +export const FrontendWindow: FC<{ + settings: FrontendWindowSettings; + onChange: (change: Partial) => void; + // Where the frontend is, from what the developer typed or what the + // application serves; empty when neither says. + frontendUrl: string; + // Whether the application is serving it, so that the frame is + // loaded again when the application comes back. + served: boolean | undefined; +}> = ({ settings, onChange, frontendUrl, served }) => { + const [typed, setTyped] = useState(settings.url); + // Bumped to load the frame again, by the reload button and by the + // application coming back after a restart, which is `served` + // turning true after being false. + const [generation, setGeneration] = useState(0); + const wasServed = useRef(served); + useEffect(() => { + if (served === true && wasServed.current === false) { + setGeneration((n) => n + 1); + } + wasServed.current = served; + }, [served]); + + // Where the window is while it is being dragged or resized, kept + // here rather than written to the browser on every movement; the + // settings get the result once the pointer is let go. + const [moving, setMoving] = useState>(); + const shown = { ...settings, ...moving }; + const height = shown.collapsed ? BAR_HEIGHT : shown.height; + + const finish = (): void => { + setMoving((result) => { + if (result !== undefined) { + onChange(result); + } + return undefined; + }); + }; + + const startMove = (event: ReactPointerEvent): void => { + const { x, y, width } = settings; + follow( + event, + (dx, dy) => + setMoving({ + x: clamp(x + dx, MARGIN, window.innerWidth - width - MARGIN), + y: clamp(y + dy, MARGIN, window.innerHeight - height - MARGIN), + }), + finish + ); + }; + + const startResize = (event: ReactPointerEvent): void => { + const { x, y, width, height } = settings; + follow( + event, + (dx, dy) => + setMoving({ + width: clamp(width + dx, MIN_WIDTH, window.innerWidth - x - MARGIN), + height: clamp( + height + dy, + MIN_HEIGHT, + window.innerHeight - y - MARGIN + ), + }), + finish + ); + }; + + const apply = (): void => { + const trimmed = typed.trim(); + let url = ""; + if (trimmed !== "") { + try { + url = sameSiteUrl( + new URL( + trimmed.includes("://") ? trimmed : `http://${trimmed}` + ).toString(), + window.location.hostname + ); + } catch { + url = trimmed; + } + } + setTyped(url); + onChange({ url }); + }; + + // A frontend on another site than the page loses its cookies in + // some browsers, which nothing here can fix; the bar says so. + const crossSite = + frontendUrl !== "" && + (() => { + try { + return new URL(frontendUrl).hostname !== window.location.hostname; + } catch { + return false; + } + })(); + + return ( +
+
+
+ {!settings.collapsed && + (frontendUrl === "" ? ( +
+ The application is not serving a frontend. If yours runs on its own, + such as Vite on its own port, type its URL above. +
+ ) : ( +