From 345d0972e5ff5ddbd3e7b91dbbf145c317db6178 Mon Sep 17 00:00:00 2001 From: Riley Scheid Date: Wed, 9 Sep 2026 22:10:30 +0000 Subject: [PATCH 1/4] Tell the page where the application is `rbt dashboard` reads `dev run --port=` from the `.rbtrc` and the dashboard serves the URL at `/application`, for the pages that read the running application. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01XLQRHLi4oR66qUCugR13Dz --- reboot/cli/commands/dashboard.py | 28 +++++++++++++++++++++++ reboot/dashboard/backend/constants.py | 12 ++++++++++ reboot/dashboard/backend/main.py | 11 ++++++++- tests/reboot/cli/dashboard_tests.py | 32 +++++++++++++++++++++++++++ 4 files changed, 82 insertions(+), 1 deletion(-) 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/tests/reboot/cli/dashboard_tests.py b/tests/reboot/cli/dashboard_tests.py index 1592bee42..9d89c5279 100644 --- a/tests/reboot/cli/dashboard_tests.py +++ b/tests/reboot/cli/dashboard_tests.py @@ -66,11 +66,38 @@ async def test_the_application_comes_from_dev_run(self) -> None: port=DEFAULT_DASHBOARD_PORT, api_directory=dashboard._api_directory(parser), application=dashboard._application(parser), + application_url=dashboard._application_url(parser), generated_directory=dashboard._generated_directory(parser), ) self.assertEqual(env['RBT_APPLICATION'], 'backend/src/main.py') + async def test_the_application_url_comes_from_dev_run(self) -> None: + """The port `rbt dev run` serves on, named once, where `rbt dev + run` already needs it; a config's own line does not count.""" + with tempfile.TemporaryDirectory() as state_directory: + _, parser = self._parse( + state_directory, + rbtrc=( + 'generate api/\n' + 'dev run --port=8000\n' + 'dev run:other --port=8001' + ), + ) + + self.assertEqual( + dashboard._application_url(parser), 'http://localhost:8000' + ) + + async def test_an_rbtrc_that_names_no_port(self) -> None: + """Serves where `rbt dev run` serves by default.""" + with tempfile.TemporaryDirectory() as state_directory: + _, parser = self._parse(state_directory, rbtrc='generate api/') + + self.assertEqual( + dashboard._application_url(parser), 'http://localhost:9991' + ) + async def test_an_rbtrc_that_names_no_application(self) -> None: """Somebody who names none gets a dashboard that looks for no implementations, rather than an error.""" @@ -83,6 +110,7 @@ async def test_an_rbtrc_that_names_no_application(self) -> None: port=DEFAULT_DASHBOARD_PORT, api_directory=dashboard._api_directory(parser), application=dashboard._application(parser), + application_url=dashboard._application_url(parser), generated_directory=dashboard._generated_directory(parser), ) @@ -123,6 +151,7 @@ async def test_env_is_isolated_from_any_application(self) -> None: port=DEFAULT_DASHBOARD_PORT, api_directory=dashboard._api_directory(parser), application=dashboard._application(parser), + application_url=dashboard._application_url(parser), generated_directory=dashboard._generated_directory(parser), ) @@ -167,6 +196,7 @@ async def test_keys_differ_from_any_application(self) -> None: port=DEFAULT_DASHBOARD_PORT, api_directory=dashboard._api_directory(parser), application=dashboard._application(parser), + application_url=dashboard._application_url(parser), generated_directory=dashboard._generated_directory(parser), ) @@ -180,6 +210,7 @@ async def test_keys_differ_from_any_application(self) -> None: port=DEFAULT_DASHBOARD_PORT, api_directory=dashboard._api_directory(parser), application=dashboard._application(parser), + application_url=dashboard._application_url(parser), generated_directory=dashboard._generated_directory(parser), ) self.assertEqual( @@ -197,6 +228,7 @@ async def test_is_told_where_the_api_files_are(self) -> None: port=DEFAULT_DASHBOARD_PORT, api_directory=dashboard._api_directory(parser), application=dashboard._application(parser), + application_url=dashboard._application_url(parser), generated_directory=dashboard._generated_directory(parser), ) From 6c3c949ae4eab7ca31fa178282aa82070c97d658 Mon Sep 17 00:00:00 2001 From: Riley Scheid Date: Thu, 10 Sep 2026 16:31:48 +0000 Subject: [PATCH 2/4] Show a model's instances under its type When the types pane opens on a state type from the models page, it lists under the type every instance the running application holds of it, live, and a row opens to the state's data as JSON. The divider between the two is dragged. When the application is not running, the section says to start it with `rbt dev run`. Co-Authored-By: Claude Fable 5.1 --- reboot/dashboard/web/BUILD.bazel | 6 + reboot/dashboard/web/dashboard.css | 330 ++++++++++++++++++++++-- reboot/dashboard/web/src/application.ts | 291 +++++++++++++++++++++ reboot/dashboard/web/src/constants.ts | 1 + reboot/dashboard/web/src/json_tree.tsx | 144 +++++++++++ reboot/dashboard/web/src/main.tsx | 241 +++++++---------- reboot/dashboard/web/src/picker.tsx | 145 +++++++++++ reboot/dashboard/web/src/states.tsx | 326 +++++++++++++++++++++++ 8 files changed, 1317 insertions(+), 167 deletions(-) create mode 100644 reboot/dashboard/web/src/application.ts create mode 100644 reboot/dashboard/web/src/json_tree.tsx create mode 100644 reboot/dashboard/web/src/picker.tsx create mode 100644 reboot/dashboard/web/src/states.tsx diff --git a/reboot/dashboard/web/BUILD.bazel b/reboot/dashboard/web/BUILD.bazel index bb445ea17..bb4f37f49 100644 --- a/reboot/dashboard/web/BUILD.bazel +++ b/reboot/dashboard/web/BUILD.bazel @@ -9,14 +9,18 @@ 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/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 +28,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 +47,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..9c32f55da 100644 --- a/reboot/dashboard/web/dashboard.css +++ b/reboot/dashboard/web/dashboard.css @@ -2099,9 +2099,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 +2115,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 +2131,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 +2150,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 +2178,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 +2640,303 @@ 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; +} diff --git a/reboot/dashboard/web/src/application.ts b/reboot/dashboard/web/src/application.ts new file mode 100644 index 000000000..2ec557767 --- /dev/null +++ b/reboot/dashboard/web/src/application.ts @@ -0,0 +1,291 @@ +// 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"; + +// Where the application serves, `http://localhost:9991`, which the +// dashboard application learned from the `.rbtrc` and serves at +// `APPLICATION_PATH`. `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(url); + } + }); + 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 }; +}; 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/json_tree.tsx b/reboot/dashboard/web/src/json_tree.tsx new file mode 100644 index 000000000..69f87b1a5 --- /dev/null +++ b/reboot/dashboard/web/src/json_tree.tsx @@ -0,0 +1,144 @@ +// A state's data, drawn as the tree its JSON is: every object and +// array opens and closes, a closed one says how many items it holds, +// and every leaf is labelled with what it is. The top level starts +// open and everything under it closed, so a state reads as its +// fields first. The application sends the whole state again whenever +// it changes, and a value that differs from the one drawn before +// flashes, so a change is seen rather than found. +import type { JsonValue } from "@bufbuild/protobuf"; +import { type FC, useEffect, useRef, useState } from "react"; + +// How many times `printed` has changed since it was first drawn. +// Used as a key on what shows it, so that each change remounts the +// element and restarts its animation. +const useChanges = (printed: string): number => { + const previous = useRef(printed); + const [changes, setChanges] = useState(0); + useEffect(() => { + if (previous.current !== printed) { + previous.current = printed; + setChanges((n) => n + 1); + } + }, [printed]); + return changes; +}; + +// What a value is, as the label beside it; an array's and an object's +// stand in for the tree under them while it is closed. +const kindOf = (value: JsonValue): string => + value === null + ? "null" + : Array.isArray(value) + ? "array" + : typeof value === "object" + ? "object" + : typeof value; + +// A value's items: an array's by index, an object's by key, and +// nothing for a leaf. +const entriesOf = (value: JsonValue): [string, JsonValue][] => + value === null || typeof value !== "object" + ? [] + : Array.isArray(value) + ? value.map((item, index) => [String(index), item]) + : Object.entries(value); + +const countWithNoun = (n: number, noun: string): string => + `${n} ${n === 1 ? noun : `${noun}s`}`; + +// One leaf, as JSON prints it, so a string is quoted and a number is +// not; flashing when it is not what it was. +const Leaf: FC<{ value: JsonValue }> = ({ value }) => { + const printed = JSON.stringify(value); + const changes = useChanges(printed); + return ( + 0 ? " is-changed" : "" + }`} + key={changes} + > + {printed} + + ); +}; + +// One value and, when it is an array or an object and open, the +// items under it, each named and stepped in. +const Node: FC<{ + name: string | undefined; + value: JsonValue; + open: boolean; +}> = ({ name, value, open: initiallyOpen }) => { + const [open, setOpen] = useState(initiallyOpen); + const kind = kindOf(value); + const entries = entriesOf(value); + const [opening, closing] = kind === "array" ? ["[", "]"] : ["{", "}"]; + const count = countWithNoun(entries.length, "item"); + // Open, the items under it flash for themselves, and the count + // only when their number changed; closed, the count stands for + // everything under it, and flashes for any change there. + const changes = useChanges(open ? count : JSON.stringify(value)); + + if (kind !== "array" && kind !== "object") { + return ( +
+ {name !== undefined && ( + <> + {name} + : + + )} + {kind} + +
+ ); + } + + return ( +
+ + {open && ( + <> +
+ {entries.map(([key, item]) => ( + + ))} +
+
+ {closing} +
+ + )} +
+ ); +}; + +export const JsonTree: FC<{ value: JsonValue }> = ({ value }) => ( +
+ +
+); diff --git a/reboot/dashboard/web/src/main.tsx b/reboot/dashboard/web/src/main.tsx index 94bdaeab4..9968d9bb2 100644 --- a/reboot/dashboard/web/src/main.tsx +++ b/reboot/dashboard/web/src/main.tsx @@ -113,6 +113,9 @@ import { undescribedMethods, } from "./features"; import { drawnCallCount, GraphPage } from "./graph"; +import { useApplicationUrl, useStateIds, useStateTypes } from "./application"; +import { Picker } from "./picker"; +import { type Instances, InstancesSplit } from "./states"; // One subscriber per tab, for as long as the tab is open. const SUBSCRIBER_ID = uuidv4(); @@ -257,7 +260,9 @@ const Description: FC<{ className: string; text: string }> = ({ // do, each joined with the scenarios, state types and code that make // it up; and `changelog` is its history. The state types the API // declares and the data types those declare in turn are not pages -// but the types pane, which every page carries on its right. +// but the types pane, which every page carries on its right. On the +// models page the pane also lists the type's instances, read from the +// running application. const PAGES = ["models", "features", "changelog"] as const; type Page = typeof PAGES[number]; @@ -392,7 +397,7 @@ const PaneAnchor: FC<{ id: string }> = ({ id }) => ( // `NavLink` is active when the route is this page or an id within it // (a `pathOfTypeOnPage` route), and sets `aria-current` itself. The // links carry the types pane's search parameter, so switching pages -// keeps the pane as it is. +// keeps the pane as it is, except to a page that has no pane. const PageSelector: FC<{ // Which pages have something the developer has not seen: what // changed since the page was last open. The others say nothing. @@ -934,11 +939,13 @@ const DataType: FC<{ // The types pane: one type, state or data, slid open by a link to it // from the graph or a page, every method expanded; the X closes it. -// A link naming a method flashes the method. +// A link naming a method flashes the method. Given the type's +// instances, the pane lists them under the type. const TypesPane: FC<{ apis: APIs; linkedDataTypes: LinkedDataType[]; target: PaneTarget; + instances?: Instances; // The property a followed link named, if any. propertyName?: string; // The history entry that named the target, so a repeated link @@ -953,6 +960,7 @@ const TypesPane: FC<{ apis, linkedDataTypes, target, + instances, propertyName, flashKey, bodyRef, @@ -981,6 +989,36 @@ const TypesPane: FC<{ propertyName === undefined || flashKey === undefined ? undefined : { id: idOfPropertyInPane(typeId, propertyName), key: flashKey }; + const definition = ( +
onScroll(event.currentTarget.scrollTop)} + > + {foundDataType !== undefined ? ( + + ) : found === undefined ? ( +
+ {shortNameOfTypeName(typeId)} is not declared in your + API, just used by your code. +
+ ) : ( + + )} +
+ ); return (
@@ -995,34 +1033,11 @@ const TypesPane: FC<{ ×
-
onScroll(event.currentTarget.scrollTop)} - > - {foundDataType !== undefined ? ( - - ) : found === undefined ? ( -
- {shortNameOfTypeName(typeId)} is not declared in your - API, just used by your code. -
- ) : ( - - )} -
+ {instances === undefined ? ( + definition + ) : ( + + )}
); }; @@ -1790,124 +1805,6 @@ const TagPill: FC<{ tag: "wip" | "blocked"; title?: string }> = ({ ); -// A state type as a chip that filters the index by it; lit while it -// is filtering. -const StateTypeChip: FC<{ - type: string; - active: boolean; - onToggle: (type: string) => void; -}> = ({ type, active, onToggle }) => ( - -); - -// The state types the index is filtered by, chosen in a box that -// holds the chosen ones as chips and, at the cursor after them, lists -// the rest as you type: a click on a listed type adds it and shows -// the list again, Enter takes the first listed, Escape closes the -// list, and a click on a chosen chip removes it. -const StateTypePicker: FC<{ - stateTypes: string[]; - selected: string[]; - onToggle: (type: string) => void; -}> = ({ stateTypes, selected, onToggle }) => { - const [text, setText] = useState(""); - const [open, setOpen] = useState(false); - const input = useRef(null); - const listed = stateTypes.filter( - (type) => - !selected.includes(type) && - type.toLowerCase().includes(text.trim().toLowerCase()) - ); - const choose = (type: string) => { - onToggle(type); - setText(""); - input.current?.focus(); - }; - return ( -
{ - // A click on the box's empty part puts the cursor there; a - // click on a chip or the list is theirs to handle. - if (event.target === event.currentTarget) { - event.preventDefault(); - input.current?.focus(); - } - }} - > - {selected.map((type) => ( - - ))} - 0 - ? `State type, e.g., ${stateTypes[0]} ...` - : "" - } - value={text} - size={Math.max(text.length, selected.length === 0 ? 26 : 2)} - onChange={(event) => setText(event.target.value)} - onFocus={() => setOpen(true)} - onBlur={() => setOpen(false)} - onKeyDown={(event) => { - if (event.key === "Enter" && listed.length > 0) { - event.preventDefault(); - choose(listed[0]); - } else if (event.key === "Escape") { - setText(""); - input.current?.blur(); - } else if ( - event.key === "Backspace" && - text === "" && - selected.length > 0 - ) { - onToggle(selected[selected.length - 1]); - } - }} - aria-label="Filter by state type" - /> - {open && ( -
    - {listed.length === 0 ? ( -
  • - {stateTypes.length === selected.length - ? "Every state type is chosen" - : "No state type matches"} -
  • - ) : ( - listed.map((type) => ( -
  • { - event.preventDefault(); - choose(type); - }} - key={type} - > - {type} -
  • - )) - )} -
- )} -
- ); -}; - // What the index shows: words to find anywhere in a feature, and // the state types a feature must name, chosen from every type the // features name. @@ -1939,10 +1836,12 @@ const FeaturesSearch: FC<{ />
filter by - (null); - const location = useLocation(); const loaded = !(isLoading && stateTypeCount === 0) && preferencesLoaded; const typesBody = useRef(null); @@ -3049,6 +2985,7 @@ const Overview: FC<{ apis={apis} linkedDataTypes={linkedDataTypes} target={paneTarget} + instances={instances} propertyName={paneProperty} flashKey={returning ? undefined : location.key} bodyRef={typesBody} diff --git a/reboot/dashboard/web/src/picker.tsx b/reboot/dashboard/web/src/picker.tsx new file mode 100644 index 000000000..78e7db40d --- /dev/null +++ b/reboot/dashboard/web/src/picker.tsx @@ -0,0 +1,145 @@ +// A box that holds the chosen items as chips and, at the cursor after +// them, lists the rest as you type: a click on a listed item adds it +// and shows the list again, Enter takes the first listed, Escape +// closes the list, and a click on a chosen chip, or Backspace in the +// empty box, removes one. `what` names an item for the reader, "state +// type" or "instance id". +import { type FC, useRef, useState } from "react"; + +// At most this many items are listed: a list of thousands of ids +// would be scrolled, not read, and typing narrows it faster. +const MAX_LISTED = 50; + +// One item as a chip; lit while it is chosen. +export const Chip: FC<{ + item: string; + what: string; + active: boolean; + onToggle: (item: string) => void; +}> = ({ item, what, active, onToggle }) => ( + +); + +export const Picker: FC<{ + items: string[]; + selected: string[]; + what: string; + onToggle: (item: string) => void; + // What is typed, as it is typed, for a caller that filters by it + // while nothing is chosen. + onText?: (text: string) => void; + // Whether the empty box lists every item when the cursor enters + // it, which suits a few state types and not thousands of ids. + listsAllWhenEmpty: boolean; +}> = ({ items, selected, what, onToggle, onText, listsAllWhenEmpty }) => { + const [text, setText] = useState(""); + const [open, setOpen] = useState(false); + const input = useRef(null); + const matching = + text.trim() === "" && !listsAllWhenEmpty + ? [] + : items.filter( + (item) => + !selected.includes(item) && + item.toLowerCase().includes(text.trim().toLowerCase()) + ); + const listed = matching.slice(0, MAX_LISTED); + const listing = listsAllWhenEmpty || text.trim() !== ""; + const type = (value: string) => { + setText(value); + onText?.(value); + }; + const choose = (item: string) => { + onToggle(item); + type(""); + input.current?.focus(); + }; + return ( +
{ + // A click on the box's empty part puts the cursor there; a + // click on a chip or the list is theirs to handle. + if (event.target === event.currentTarget) { + event.preventDefault(); + input.current?.focus(); + } + }} + > + {selected.map((item) => ( + + ))} + 0 + ? `${what[0].toUpperCase()}${what.slice(1)}, e.g., ${items[0]} ...` + : "" + } + value={text} + size={Math.max(text.length, selected.length === 0 ? 26 : 2)} + onChange={(event) => type(event.target.value)} + onFocus={() => setOpen(true)} + onBlur={() => setOpen(false)} + onKeyDown={(event) => { + if (event.key === "Enter" && listed.length > 0) { + event.preventDefault(); + choose(listed[0]); + } else if (event.key === "Escape") { + type(""); + input.current?.blur(); + } else if ( + event.key === "Backspace" && + text === "" && + selected.length > 0 + ) { + onToggle(selected[selected.length - 1]); + } + }} + aria-label={`Filter by ${what}`} + /> + {open && listing && ( +
    + {listed.length === 0 ? ( +
  • + {items.length === selected.length + ? `Every ${what} is chosen` + : `No ${what} matches`} +
  • + ) : ( + listed.map((item) => ( +
  • { + event.preventDefault(); + choose(item); + }} + key={item} + > + {item} +
  • + )) + )} + {matching.length > listed.length && ( +
  • + and {matching.length - listed.length} more; keep typing +
  • + )} +
+ )} +
+ ); +}; diff --git a/reboot/dashboard/web/src/states.tsx b/reboot/dashboard/web/src/states.tsx new file mode 100644 index 000000000..f277bb85e --- /dev/null +++ b/reboot/dashboard/web/src/states.tsx @@ -0,0 +1,326 @@ +// A model's instances: every state of one type the running +// application holds, by id, listed under the type in the types pane. +import type { JsonValue } from "@bufbuild/protobuf"; +import { type FC, type ReactNode, useEffect, useRef, useState } from "react"; +import { Group, Panel, Separator } from "react-resizable-panels"; +import { useStateData } from "./application"; +import { JsonTree } from "./json_tree"; +import { Picker } from "./picker"; + +// A button that copies one string, quiet until hovered, and a word +// while it says it copied. +export const CopyButton: FC<{ + text: string; + what: string; + className?: string; +}> = ({ text, what, className }) => { + const [copied, setCopied] = useState(false); + useEffect(() => { + if (!copied) { + return; + } + const timer = setTimeout(() => setCopied(false), 1500); + return () => clearTimeout(timer); + }, [copied]); + return ( + + ); +}; + +// One state's data, read live for as long as its row is open. +const StateData: FC<{ + url: string | undefined; + stateType: string; + stateId: string; +}> = ({ url, stateType, stateId }) => { + const { value, unreachable, status } = useStateData(url, stateType, stateId); + const json: JsonValue | undefined = value; + const empty = + json !== undefined && + json !== null && + typeof json === "object" && + !Array.isArray(json) && + Object.keys(json).length === 0; + return ( +
+
+ {stateType} + {json !== undefined && ( + + )} +
+ {status !== undefined ? ( +
+ Could not read the state: {status.message ?? `status ${status.code}`} +
+ ) : unreachable ? ( +
The application isn't running.
+ ) : json === undefined ? ( +
Reading…
+ ) : empty ? ( +
+ no fields — the key is the whole state +
+ ) : ( + + )} +
+ ); +}; + +// What the pane knows of a type's instances: the application is not +// running, is still being read, does not serve the type, or holds +// these ids, each of which a row opens to the data of. +export type Instances = + | { kind: "not-running" } + | { kind: "reading" } + | { kind: "not-served" } + | { kind: "ids"; url: string | undefined; stateType: string; ids: string[] }; + +// How many rows may be open at once. Each open row holds a stream to +// the application, and a browser holds at most six connections to +// it; the pane's other streams take the rest. Opening one more +// closes the one open longest. +const MAX_OPEN_ROWS = 3; + +// One instance's row: its id, which opens and closes its data. +const InstanceRow: FC<{ + url: string | undefined; + stateType: string; + stateId: string; + open: boolean; + onToggle: () => void; +}> = ({ url, stateType, stateId, open, onToggle }) => ( +
  • +
    + + +
    + {open && } +
  • +); + +// The rows, and which of them are open, which is the pane's own +// business, not the URL's. The ids to inspect are chosen in the +// picker as chips, and the rows are then those, each opened; with +// none chosen they are every id, narrowed by what is typed. +const InstanceRows: FC<{ + url: string | undefined; + stateType: string; + ids: string[]; +}> = ({ url, stateType, ids }) => { + const [query, setQuery] = useState(""); + const [chosen, setChosen] = useState([]); + const [open, setOpen] = useState>(() => new Set()); + const toggle = (stateId: string): void => { + setOpen((previous) => { + const next = new Set(previous); + if (next.has(stateId)) { + next.delete(stateId); + } else { + next.add(stateId); + // A set iterates in insertion order, so the first is the + // one open longest. + while (next.size > MAX_OPEN_ROWS) { + next.delete(next.values().next().value!); + } + } + return next; + }); + }; + + // Choosing an id opens it, which is what it is chosen for; letting + // it go closes it. + const toggleChosen = (stateId: string): void => { + if (chosen.includes(stateId)) { + setChosen(chosen.filter((id) => id !== stateId)); + setOpen((previous) => { + const next = new Set(previous); + next.delete(stateId); + return next; + }); + } else { + setChosen([...chosen, stateId]); + if (!open.has(stateId)) { + toggle(stateId); + } + } + }; + + const shown = + chosen.length > 0 + ? chosen.filter((id) => ids.includes(id)) + : query === "" + ? ids + : ids.filter((id) => id.toLowerCase().includes(query.toLowerCase())); + + return ( + <> +
    + + {chosen.length === 0 && query !== "" && ( + + {shown.length} of {ids.length} match + + )} +
    + {shown.length === 0 ? ( +

    + {chosen.length > 0 + ? "None of the chosen ids exists any more." + : "No instance ids match."} +

    + ) : ( +
      + {shown.map((stateId) => ( + toggle(stateId)} + key={stateId} + /> + ))} +
    + )} + + ); +}; + +// The instances section's heights, pixels the way `Panel` reads plain +// numbers. The height is the browser's to remember, like the frontend +// window's place: it is not worth a change to the dashboard's state. +const INSTANCES_HEIGHT = { default: 240, min: 96, max: 720 }; +const HEIGHT_KEY = "instances-height"; + +const readHeight = (): number => { + try { + const stored = Number(window.localStorage.getItem(HEIGHT_KEY)); + return Number.isFinite(stored) && stored >= INSTANCES_HEIGHT.min + ? Math.min(stored, INSTANCES_HEIGHT.max) + : INSTANCES_HEIGHT.default; + } catch { + return INSTANCES_HEIGHT.default; + } +}; + +const writeHeight = (height: number): void => { + try { + window.localStorage.setItem(HEIGHT_KEY, String(height)); + } catch { + // Nothing to remember it in; the divider still drags. + } +}; + +// The type's definition above, its instances below, and between them +// a divider that is dragged. The height is written back once the +// divider is let go, not on every movement. With no ids to list, only +// a line saying why, the section takes just that line's height and +// the divider is not drawn. +export const InstancesSplit: FC<{ + definition: ReactNode; + instances: Instances; +}> = ({ definition, instances }) => { + const [height] = useState(readHeight); + const resizing = useRef(height); + if (instances.kind !== "ids") { + return ( + <> + {definition} + + + ); + } + return ( + { + if (isUserInteraction) { + writeHeight(resizing.current); + } + }} + > + {definition} + + { + resizing.current = Math.round(inPixels); + }} + > + + + + ); +}; + +// The section itself: its head, then the ids scrolling, or one line +// saying why there are none to show. `brief` is that line's case, +// where the section is as short as it can be. +const InstancesList: FC<{ instances: Instances; brief?: boolean }> = ({ + instances, + brief, +}) => ( +
    +
    instances
    + {instances.kind === "not-running" ? ( +

    + To see your model instances, start your application with{" "} + rbt dev run. +

    + ) : instances.kind === "reading" ? ( +

    Reading…

    + ) : instances.kind === "not-served" ? ( +

    + The running application does not serve this type. +

    + ) : instances.ids.length === 0 ? ( +

    No instances yet.

    + ) : ( + + )} +
    +); From 5472ecf013c6bcd129677d188088ae79aff6b03c Mon Sep 17 00:00:00 2001 From: Riley Scheid Date: Thu, 10 Sep 2026 16:34:03 +0000 Subject: [PATCH 3/4] Show the frontend in a window over the models page The developer's web frontend in a small window that is dragged, resized, folded and closed, so what it does can be watched arriving in a model's instances. It frames what the application serves at `/__/frontend/web/`, else the URL typed into its bar. The button that opens it is there only while the application runs. Frontend URLs are renamed to the page's own hostname, since a frame on another site than its page loses its cookies in some browsers. Co-Authored-By: Claude Fable 5.1 --- reboot/dashboard/web/BUILD.bazel | 1 + reboot/dashboard/web/dashboard.css | 191 ++++++++++ reboot/dashboard/web/src/application.ts | 91 ++++- reboot/dashboard/web/src/frontend_window.tsx | 360 +++++++++++++++++++ reboot/dashboard/web/src/main.tsx | 68 +++- 5 files changed, 699 insertions(+), 12 deletions(-) create mode 100644 reboot/dashboard/web/src/frontend_window.tsx diff --git a/reboot/dashboard/web/BUILD.bazel b/reboot/dashboard/web/BUILD.bazel index bb4f37f49..6d7ff4dcc 100644 --- a/reboot/dashboard/web/BUILD.bazel +++ b/reboot/dashboard/web/BUILD.bazel @@ -15,6 +15,7 @@ ts_project( "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", diff --git a/reboot/dashboard/web/dashboard.css b/reboot/dashboard/web/dashboard.css index 9c32f55da..10d1dbde9 100644 --- a/reboot/dashboard/web/dashboard.css +++ b/reboot/dashboard/web/dashboard.css @@ -2940,3 +2940,194 @@ header .state-type-description { 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 index 2ec557767..8b4841aa3 100644 --- a/reboot/dashboard/web/src/application.ts +++ b/reboot/dashboard/web/src/application.ts @@ -22,9 +22,33 @@ import { } 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`. `undefined` until it has been read. +// `APPLICATION_PATH`, on the page's own hostname. `undefined` until +// it has been read. export const useApplicationUrl = (): string | undefined => { const [url, setUrl] = useState(); useEffect(() => { @@ -33,7 +57,7 @@ export const useApplicationUrl = (): string | undefined => { .then((response) => response.json()) .then(({ url }: { url: string }) => { if (!cancelled) { - setUrl(url); + setUrl(sameSiteUrl(url, window.location.hostname)); } }); return () => { @@ -289,3 +313,66 @@ export const useStateData = ( }); 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/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. +
    + ) : ( +