diff --git a/.dependency-cruiser-known-violations.json b/.dependency-cruiser-known-violations.json index 57b5e0ac..6f0eea04 100644 --- a/.dependency-cruiser-known-violations.json +++ b/.dependency-cruiser-known-violations.json @@ -232,7 +232,7 @@ }, { "type": "dependency", - "from": "ui/src/views/leases-model.ts", + "from": "ui/src/model/leases.ts", "to": "src/contract/schemas.ts", "unresolvedTo": "../../../src/contract/schemas", "dependencyTypes": ["local", "type-only", "import"], @@ -243,7 +243,7 @@ }, { "type": "dependency", - "from": "ui/src/views/waiting-model.ts", + "from": "ui/src/model/waiting.ts", "to": "src/contract/schemas.ts", "unresolvedTo": "../../../src/contract/schemas", "dependencyTypes": ["local", "type-only", "import"], @@ -254,7 +254,7 @@ }, { "type": "dependency", - "from": "ui/src/views/workers-model.ts", + "from": "ui/src/model/workers.ts", "to": "src/contract/schemas.ts", "unresolvedTo": "../../../src/contract/schemas", "dependencyTypes": ["local", "type-only", "import"], diff --git a/.dependency-cruiser.mjs b/.dependency-cruiser.mjs index 3b4f611a..06d5a8a1 100644 --- a/.dependency-cruiser.mjs +++ b/.dependency-cruiser.mjs @@ -14,6 +14,8 @@ function findComponents(dir) { const escape = (path) => path.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); const testFile = "(\\.test\\.tsx?|/test-[^/]+\\.tsx?)$"; const linkTestFile = "(\\.test\\.tsx?|/test-[^/]+\\.tsx?|/testing\\.ts)$"; +// why: the console layers below the screens are plain TypeScript, so a test drives them without a browser. +const uiLibrary = "(^|node_modules/)(react|react-dom|recharts|@tanstack/[^/]+)(/|$)"; const fsOrProcess = "^(node:)?(fs|fs/promises|child_process)$"; // why: e2e imports the compiled package from dist/, which mirrors src/, so each component's rule covers its compiled copy too. @@ -195,6 +197,42 @@ export default { "^src/(core|leasing|drivers|gateway|contract)/", { path: "^src/(modules|module-registry)/" }, ), + direction( + "ui-ds-stands-alone", + "the console's design system imports nothing else of the console", + "^ui/src/ds/", + { path: "^ui/src/(app|screens|state|data|model)/" }, + ), + direction( + "ui-model-is-pure", + "the console's business rules import no other layer and no UI library", + "^ui/src/model/", + { path: `^ui/src/(app|screens|state|data|ds)/|${uiLibrary}` }, + ), + direction( + "ui-data-is-below-the-ui", + "the console's network and storage layer imports no state, screen, design system or UI library", + "^ui/src/data/", + { path: `^ui/src/(app|screens|state|ds)/|${uiLibrary}` }, + ), + direction( + "ui-state-feeds-screens", + "the console's state layer imports no screen, and from the design system only the types it fills", + "^ui/src/state/", + { path: "^ui/src/(app|screens)/" }, + ), + direction( + "ui-state-ds-types-only", + "the console's state layer imports no screen, and from the design system only the types it fills", + "^ui/src/state/", + { path: "^ui/src/ds/", dependencyTypesNot: ["type-only"] }, + ), + direction( + "ui-screens-present-only", + "a console screen reaches data only through the state layer: no network, storage or business rule of its own", + "^ui/src/screens/", + { path: "^ui/src/(app|data|model)/" }, + ), direction( "contract-imports-nothing", "the contract imports nothing outside src/contract/", diff --git a/docs/CONSOLE.md b/docs/CONSOLE.md index 7641107a..e1e1518c 100644 --- a/docs/CONSOLE.md +++ b/docs/CONSOLE.md @@ -8,34 +8,46 @@ internet. The console is for people who operate Simlock. It needs an operator token. Coding agents keep using the CLI, the MCP server, the client and the HTTP API. -This version has sign-in and a page for each tab along the top of the -console: Workers, Leases, Waiting, Attention and Events. On a narrow screen -the row of tabs scrolls sideways. - -Each page starts with a row of cards that count what the page shows, such as -the leases held and how many expire within 15 minutes. A long id, such as a -lease's, is cut to fit its column; point at it to see it whole, or open its -page. +## Finding your way + +A sidebar on the left leads to every page: **Overview**, **Workers**, +**Leases**, **Waiting**, **Attention** and **Events**. **Attention** shows how +many items need you, on every page. At the bottom of the sidebar are the +connection (see [Live updates](#live-updates)), **Sign out**, and the button +that collapses the sidebar to its icons and widens it again. The console +remembers your choice in this browser, and changing pages never changes it. +A window narrower than 1280 pixels starts with the sidebar collapsed until you +choose. On a phone the sidebar is a bar along the top with a menu button. + +Every page but the overview is a list on the left and the one you choose on +the right. The address names what you chose, such as `/workers/`, so a +reload, the browser's back button or a link you share opens the same one. With +nothing chosen, a wide window shows the first in the list; a phone shows the +list, and the chosen one in its place after you select it. + +A list row is short on purpose: a square of colour, a name, one figure. The +colour always has a word behind it: point at the square to read it, and a +screen reader reads it as the square's name. The page on the right has +everything else. A long id is cut to fit; point at it to see it whole. ## Long lists -Every table of workers, devices, leases, requests or attention items shows 25 -rows at a time. Under it, the pager says which rows you -see, such as `26–50 of 1,240`, and has **Previous**, **Next**, the page -number, and **Per page**: 25, 50 or 100. When everything fits on one page, -there is no pager. (The **Minute by minute** table under a chart always lists -its 60 minutes.) The worker cards on **Workers** page the same way, 24 at a -time, or 48 or 96. +Every list of workers, leases, requests or attention items shows 25 at a time. +Under it, the pager says which ones you see, such as `26–50 of 1,240`, and +has **Previous**, **Next**, the page number, and **Per page**: 25, 50 or 100. +When everything fits on one page, there is no pager. Tables on a page, such +as a worker's devices, page the same way. (The **Minute by minute** table +under a chart always lists its 60 minutes.) -The page you are on is in the address, such as `/leases?page=3&size=50`, so -a reload or a link you share opens the same page. A page with two tables, -such as a worker's, keeps each one's page under its own name: -`?devices-page=2&leases-page=3`. +The page you are on is in the address, such as `/leases?page=3&size=50`, so a +reload or a link you share opens the same page. A page with two tables, such +as a worker's devices and its warm pool, keeps each one's page under its own +name: `?devices-page=2`. -Rows keep their order as the page refreshes itself, so a row stays on its -page unless the list changes. When the list shrinks below the page you are -on, you see its last page instead. Leases are listed oldest first, by when -they were granted. +Rows keep their order as the page refreshes itself, so a row stays on its page +unless the list changes. When the list shrinks below the page you are on, you +see its last page instead. Leases are listed oldest first, by when they were +granted. ## Turn it on @@ -121,77 +133,88 @@ The console keeps the token in this browser tab only. Some browsers copy a tab's storage into a duplicated or reopened tab, so such a tab may still be signed in. +## Overview + +The console opens on **Overview**: the whole fleet on one page. + +- **Tiles**: how full the fleet is (leases held, as a share of every running + slot on connected workers), the leases held, the requests waiting and the + longest wait, the workers connected of all, and the leases granted in the + last hour. A tile that has a page of its own opens it. +- **Leases held, last hour**: a chart of how many leases were held at the end + of each minute. It starts from the leases held now and counts back through + the lease events of the last hour, the same events the Events page shows, so + it can only go back as far as those. A lease that ends with no event, such + as one on a worker removed from a gateway, is not counted back. The line + under it says now, the highest and the lowest, and **Minute by minute** + lists each minute's number. +- **Capacity**: per platform, the devices running of the limit, and a bar + split into leased, ready and booting. +- **Workers**: a row per worker with its status square, a square per device + slot (leased, ready, booting, quarantined, absent or free), its CPU and + memory, and the leases it holds. Select a worker to open it. +- **Attention** and **Activity**: what needs you, and the latest events. Each + opens its own page. + ## Workers **Workers** lists every worker the daemon knows, as `simlock worker list` does. On a gateway that is every worker that has joined it. On a single host it is one worker: the host itself. The console looks the same either way. -Each worker shows: +Each row is the worker's status square, its name, its devices running of its +limit and a bar of how full it is. The square shows the worst of what is wrong +with the worker: -| Fact | What it says | +| Square | When | | --- | --- | -| Connection | `connected`, `disconnected` or `incompatible`, and `drained` when the gateway sends it no new leases | -| Health | The worker daemon's own health: `running`, `starting` or `failed` | -| Protocol | `compatible`, or `incompatible` with the protocol versions each side speaks, so you can see which one to upgrade | -| Version | The worker's Simlock version | -| Devices | How many devices are running, and how many are leased | -| Capacity | Devices running out of the limit, per platform | -| Capabilities | Each optional module the worker knows of, such as agent-device: `available`, `off`, or `unavailable` with its reason | -| Load | The machine's CPU, memory and disk use, such as `CPU 34% · memory 9.8 of 32.0 GiB · disk 112.4 GiB free`. A figure the worker limits itself to, such as a container's, is marked `(limit)`, and one it could not read says `unavailable`. A disconnected worker says "No reading while disconnected" | - -A worker the gateway could not read from yet says "Not reported" where it -has nothing to show. A worker that is still starting shows its health as -`starting`, and leaves out its devices, capacity and capabilities: they appear once it has -finished starting. Its page shows the status and the host, and says that devices, -leases and capacity appear once startup finishes. - -Above the workers, **Leases, last hour** charts how many leases were held at -the end of each minute. It starts from the leases held now and counts back -through the lease events of the last hour, the same events the Events page -shows, so it can only go back as far as those. A lease that ends with no -event, such as one on a worker removed from a gateway, is not counted back. **Minute by minute** under the -chart lists each minute's number. **Busiest workers** ranks the five workers -holding the most leases now. With more than five workers, a link under it, -such as **All 12 workers**, takes you to the cards for the rest. - -Select a worker to open its page. It adds: - -- **Devices**: every device on the worker, with its state, platform, type - (Physical or Virtual), model, runtime, mode and image tag (both `—` for a - physical device, which has neither). A leased device shows how long ago its lease - was granted, and a device being provisioned or reclaimed on a connected - worker how long that has taken so far. Other states show `—`: the daemon - reports no time for them. -- **Leases**: the leases on this worker, as the Leases page shows them. -- **Host**: the operating system, the CPU architecture, and the version of - each platform tool, such as Xcode or the Android emulator. -- **Catalog**: the models and runtimes the worker can lease. It is read again +| Red | The worker is `disconnected` or `incompatible`, or its health is `failed`; or its memory pressure is `critical`, or its CPU, memory or disk is 95% full or more | +| Amber | The worker is `drained`, `starting` or over its RAM budget; or its memory pressure is `warn`, or a figure is 85% full or more | +| Green | Nothing is wrong | + +**Issues** shows only the workers whose square is not green, and **Busy** the +ones using three quarters of their slots or more. + +Select a worker to see it on the right, under three tabs: + +- **Overview**: + - **CPU**, **Memory** and **Disk**, each as the figure the worker reads, + such as `28% busy, 12 cores` or `20.9 GiB of 36.0 GiB available`, with a + bar under it showing how full it is. A bar is neutral until the figure is + **85% full or more**, amber to 95%, red from there: the console's own + rule, read off the one figure it is drawn from. Where memory reports a + pressure (`normal`, `warn` or `critical`), that word decides memory's + colour instead. Swap used and when the reading was taken are beside them. + A figure the worker limits itself to, such as a container's, is marked + `(limit)`, and one it could not read says `Unavailable`. A disconnected + worker says "No reading while disconnected". + - **Leases**: how many the worker holds. + - **iOS** and **Android**: a square per slot the worker may run, shaded by + what each device is doing, and the devices running of the limit. + - **Devices**: every device, with its state, model and runtime, Physical or + Virtual, its mode where it is not the worker's default, and its image tag. + A leased device names its holder, linked to the lease, and how long the + lease has left. A device being provisioned or reclaimed on a connected + worker shows how long that has taken so far. +- **Host**: the worker's id, connection, health and protocol (`compatible`, + or `incompatible` with the protocol versions each side speaks, so you can + see which one to upgrade); the operating system, the CPU architecture and + the version of each platform tool; each optional module, such as + agent-device, as `available`, `off`, or `unavailable` with its reason; and + the models and runtimes the worker can lease. The catalog is read again straight after a component is installed, and otherwise every 30 seconds. -- **Load**: how busy the machine is right now: CPU busy share and cores, - memory available of total, swap used, memory pressure (`normal`, `warn` or - `critical`, left out where the system does not report it), and disk free of - total. CPU, memory and disk each have a bar under the figure showing how full - it is, for scanning at a glance. A bar is neutral until the figure is - **85% full or more**, amber to 95%, red from there — the console's own rule, - read off the one figure it is drawn from and nothing else. Where memory - reports a pressure, that word wins instead: its bar is green, amber or red for - `normal`, `warn` or `critical` however full it is. Only the bar is coloured - this way: the figures themselves are printed as numbers, and `simlock status` - reports them without a verdict. A part the worker could not read says - `Unavailable`, and has no bar. **Read** says how - long ago the reading was taken, and it ticks up until the next one. A - worker that sent no load says "Not reported.", and a disconnected one "No - reading while disconnected." - **Warm pool**: whether the worker keeps devices warm, the running slots it holds back per platform, and a row for each kind of device the targets name - (targets that resolve to the same device are one row, their counts added) - with the model, runtime and mode it keeps ready, how many it wants, how many are ready and how many are booting. - A target the worker could do nothing for shows the reason, such as - `runtime-missing`, in the last column, and `—` when it has none. It says "Not - reported" for a worker that has not sent it. -- **Installs in progress**: each runtime or system image being downloaded or - waiting to, how long it has been going, and how many requests wait on it. + (targets that resolve to the same device are one row, their counts added), + with how many it wants, how many are ready and how many are booting. A + target the worker could do nothing for shows the reason, such as + `runtime-missing`. Under it, **Installs in progress**: each runtime or + system image being downloaded or waiting to, how long it has been going, + and how many requests wait on it. + +A worker the gateway could not read from yet says "Not reported" where it has +nothing to show. A worker that is still starting leaves out its devices, +capacity and warm pool: they appear once it has finished starting. A page the daemon cannot answer on a single host says "Not available on a single host" in place of its content. @@ -202,28 +225,28 @@ single host" in place of its content. does. On a gateway that is every lease on every worker, including the ones a worker granted itself. On a single host it is the host's own leases. -Each lease shows: +Each row is who holds the lease (see below), how long it has left, the +device's model and worker, and a bar of the time left. The time turns amber, +and the lease reads `expiring`, when less than 15 minutes are left unless it +is renewed. **Expiring**, **iOS** and **Android** narrow the list. -| Fact | What it says | -| --- | --- | -| Holder | Who holds the lease (see below) | -| Worker | The worker the device is on, by its label, or its id when it has none | -| Device | The device's model and id | -| Mode | `slim` or `full`, or `—` for a lease on a physical device, which has none | -| Image tag | The image tag the device was created from, or `—` | -| Granted | How long ago the lease was granted | -| Expires in | How long until the lease expires unless it is renewed. It counts down every second. | -| Last renewed | How long ago the lease was last renewed, or granted if it never was | - -Select a lease to open its page. It shows the same facts, the device's UDID, -and the id of the request that was granted the lease, while the daemon still -keeps it. A lease that has been released or has -expired says "Lease not found". +Select a lease to see it on the right: + +- **Expires in**: the time left, counting down every second, how long the + lease has been held, how long ago it was last renewed, and when it was + granted. +- **Device**: the model, runtime, mode (none for a physical device), image + tag, Physical or Virtual, the worker it is on, and the device's id and UDID. +- **Lease**: the lease's id and the id of the request that was granted it, + while the daemon still keeps it. +- **Screen**, beside them: the device's live picture (see below). + +A lease that has been released or has expired says "Lease not found." ### Watching a lease's screen -A lease's page ends with a **Screen** panel: one button, **Show screen**, and -while it is on, the device's picture with the facts beside it — its size and +A lease's page has a **Screen** panel: one button, **Show screen**, and +while it is on, the device's picture with the facts under it — its size and codec, the frames painted, the rate they arrive at, the bandwidth they cost, and how many were dropped rather than drawn behind an earlier one. @@ -235,7 +258,8 @@ live on the page. What you are looking at is either moving or gone. The panel is on every lease's page, and streams wherever the worker the lease runs on has screen streaming turned on (see [`capabilities.stream.enabled`](CONFIGURATION.md)). -Where it is off, the panel says so; where the lease is on a device a screen cannot be +Where it is off, the panel says so before you press anything, and the button +cannot be pressed; where the lease is on a device a screen cannot be captured from — a physical iPhone or Android phone — it says that a live screen comes only from a simulator or an emulator. Either way it starts nothing. @@ -245,9 +269,9 @@ start shows the worker's reason rather than a timeout. ### Who holds a lease -A lease taken over the HTTP API belongs to the token that took it. The console -shows that token's label, with the token id beside it: `ci-runner-3 -tok_9f2c`. Give each agent's token a label when you create it, so you can tell +A lease taken over the HTTP API belongs to the token that took it. The list +shows that token's label, `ci-runner-3`, and the lease's page the token id +beside it: `ci-runner-3 tok_9f2c`. Give each agent's token a label when you create it, so you can tell them apart: ```sh @@ -264,39 +288,43 @@ directly shows its holder's id as that worker reports it. ## Waiting -**Waiting** lists every request waiting for a device, oldest first, as -`simlock list --requests` does. Each shows: +**Waiting** lists every request waiting for a device, as `simlock list +--requests` does. Each row is its place in its queue, the agent that asked, +how long it has waited, the device it asked for, and its stage: `queued` +while it waits its turn, `starting` while the daemon is working on it +(placing it as it arrives, or finding, creating, booting or downloading a +device for it). + +Select a request to see it on the right: | Fact | What it says | | --- | --- | -| Requester | The agent that asked | +| Waiting | How long since the daemon received it | +| Place | Where it stands in its own line, counting from 1, the requests ahead of it in that line that are already starting included; `—` while starting. A request for a physical device waits in a line of its own, and says so | +| Stage | `queued` or `starting` | | Device | The platform and the model it asked for, or the class for a request that named a class, or only the platform for one that named neither; and the runtime, mode and image tag when it named them | -| Worker | The worker whose own queue it waits in, or `—` for a request in a gateway's queue | -| Stage | `queued` while it waits its turn, `starting` while the daemon is working on it: placing it as it arrives, or finding, creating, booting or downloading a device for it | -| Place in queue | Where it stands in its own line, counting from 1, the requests ahead of it in that line that are already starting included; `—` while starting. A request for a physical device waits in a line of its own and shows `(physical)` after its place, for example `1 (physical)` | -| Waiting for | How long since the daemon received it | +| Queue | The worker whose own queue it waits in, or the gateway's queue | On a gateway the list has the gateway's own queue and, for each connected worker, the requests its own agents sent to it directly. A request the -gateway has passed to a worker shows once, as the gateway's. On a single -host every request shows that host as its worker. +gateway has passed to a worker shows once, as the gateway's. A request leaves the list as soon as it gets its device, fails, or is -cancelled. +cancelled; its page then says "Request not waiting." ## Attention -**Attention** lists everything that needs you, in one place. Each item names -its worker and links to that worker's page. An item leaves the list as soon as -the daemon reports that its condition cleared. A gateway keeps a disconnected -worker's devices and RAM use as the worker last reported them, so their items -stay until the worker reconnects or is removed. The console only shows these; -it does not fix them. +**Attention** lists everything that needs you, in one place. Each row is the +condition, its colour, and the worker or device it is about. An item leaves the +list as soon as the daemon reports that its condition cleared, and its page +then says "Resolved." A gateway keeps a disconnected worker's devices and RAM +use as the worker last reported them, so their items stay until the worker +reconnects or is removed. The console only shows these; it does not fix them. | Item | What it means | | --- | --- | | `disconnected` | The gateway has lost its connection to the worker. | -| `incompatible` | The worker and the gateway have no protocol version in common. The worker's page says which versions each speaks. | +| `incompatible` | The worker and the gateway have no protocol version in common. Its page says which versions each speaks. | | `drained` | The worker gets no new leases (`simlock worker drain`). | | `over RAM budget` | The devices on the worker use more RAM than its budget allows, as `simlock status` reports with `(over limit)`. | | `quarantined` | A device Simlock could not clean up. It retries the cleanup in the background and leases the device to no one meanwhile. | @@ -306,8 +334,10 @@ The first three come from a gateway only: a single host has no gateway to lose or disagree with, and cannot be drained. The other three show on a single host too. -The **Attention** tab shows how many items there are, on every page. It shows -no number while there are none. +Select an item to see the facts that matter for it, such as when a +disconnected worker was last seen or how much RAM a worker uses of its budget, +and the events of the last hour about that worker or device. **Open worker** +opens the worker's page. Each item shows within about a second of the daemon reporting it. A stalled device is the exception on a gateway: a device becomes stalled when its time @@ -319,36 +349,31 @@ reads that worker, which it does at least every 30 seconds. **Events** lists recent events, newest first: the same events as `simlock events --since 1h`. New events appear at the top as they happen. -Each event shows: - -- its time, on your computer's clock face -- its name, such as `lease.granted` -- on a gateway, the worker it came from, by its label, or by its id if the - worker has no label or the gateway no longer knows it. A `worker.rejected` - event names no worker here: its id is only what the refused worker claimed, - and it shows in the payload. -- its payload, as each key and its value, exactly as the daemon sent it - -An event this version of the console does not know still shows, with its -whole payload. - -**Show** picks which events to list: all of them, or only those about leases, -devices, workers or components. **Other** lists the rest, such as -`daemon.started`. - -The events sit in a box of their own that scrolls; the page around it stays -put. Click in the box, or Tab to it, to scroll it with the keyboard. While -you are at the top of the box, new events push the list down, so the newest -is always in view. Once you scroll down to read, what you are reading stays -where it is, and a button at the top counts the events that came in since, -such as **3 new events**. Select it to go back to the top. +Each row is the event's time, on your computer's clock face, its name, such as +`lease.granted`, and a line saying who, which device and which worker it is +about, from what its payload names. An event this version of the console does +not know still shows. + +Above the list, **Events per minute** charts how many of the events the page +holds happened in each minute of the last hour, and **Minute by minute** lists +each minute's number. The filter picks which events to list: all of them, or +only those about leases, devices, workers or components. **Other** lists the +rest, such as `daemon.started`. The filter is in the address too. + +The list scrolls in a box of its own; the page around it stays put. Click in +the box, or Tab to it, to scroll it with the keyboard. While you are at the +top, new events push the list down, so the newest is always in view. Once you +scroll down to read, what you are reading stays where it is, and a button at +the top counts the events that came in since, such as **3 new events**. +Select it to go back to the top. + +Select an event to see its whole payload, each key and its value exactly as +the daemon sent it, and a button to the lease, worker, request or attention +item it is about, when the console has a page for it. An event older than the +last hour says "Event not in the last hour." The page keeps the newest 10,000 events. Older ones drop off the end. -**Events per minute** charts how many of the events the page holds happened in -each minute of the last hour, whatever **Show** picks. **Minute by minute** -under it lists each minute's number. - When the console loses the daemon, or the tab was hidden, it loads the events it missed as soon as it is back, so the list has no gap. An event the console already shows is never shown twice, also after the daemon restarts. @@ -369,8 +394,9 @@ are off by as much. A hidden tab stops asking the daemon for anything. When you come back to it, it catches up at once. -The box at the top right, beside **Sign out**, says how the console's -connection is: +The bottom of the sidebar, above **Sign out**, says how the console's +connection is (with the sidebar collapsed, as its square: point at it to read +it): | It says | What it means | | --- | --- | @@ -380,7 +406,7 @@ connection is: While reconnecting, the console keeps the last data on screen and tries the daemon again after 1, 2, 4 and 8 seconds, then every 10 seconds. When the -daemon answers, the console refreshes the page and the box goes back to +daemon answers, the console refreshes the page and it goes back to Connected. You do not need to reload. ## What the page loads diff --git a/docs/internal/ARCHITECTURE.md b/docs/internal/ARCHITECTURE.md index e3c0699e..87da983b 100644 --- a/docs/internal/ARCHITECTURE.md +++ b/docs/internal/ARCHITECTURE.md @@ -58,7 +58,7 @@ worker daemon ── uplink (WebSocket to /v1/uplink, worker dials) ──┘ | agent-identity | Default requester id for CLI and MCP | [agent-identity](../../src/agent-identity/AGENTS.md) | | daemon-protocol | Socket framing and the protocol version | [daemon-protocol](../../src/daemon-protocol/AGENTS.md) | | test-support | Shared test helpers | [test-support](../../src/test-support/AGENTS.md) | -| `ui/` | Web console, served by the daemon | [CONSOLE.md](../CONSOLE.md) | +| `ui/` | Web console, served by the daemon: data, model, state, design system and screens in layers | [ui](../../ui/AGENTS.md) · [CONSOLE.md](../CONSOLE.md) | ## Dependencies diff --git a/docs/internal/DESIGN.md b/docs/internal/DESIGN.md index 8c757d99..bad0b606 100644 --- a/docs/internal/DESIGN.md +++ b/docs/internal/DESIGN.md @@ -1,11 +1,13 @@ # Design guide How Simlock looks. The web console follows it today, and the landing page will -follow it when it is built. The tokens live in `ui/src/styles.css`; change this -guide and that file together. +follow it when it is built. The tokens live in `ui/src/ds/foundation.css`; change +this guide and that file together. The style is Callstack's console style, the one Apex uses: an off-white page, square cards a shade darker, mono labels in capitals, and one accent colour. +Few words: a page says what it shows with its numbers, its statuses and its +shape, not with sentences about them. Simlock's accent is orange where Apex's is green. ## Type @@ -25,11 +27,11 @@ Simlock's accent is orange where Apex's is green. | Token | Size | Use | | --- | --- | --- | - | `--text-xs` | 12px | labels on cards and tables, the chip, a tab's count | - | `--text-sm` | 14px | tabs, buttons, captions, descriptions, table cells | + | `--text-xs` | 12px | labels, units, a row's second line, a link's count | + | `--text-sm` | 14px | links in the sidebar, buttons, table cells, facts | | `--text-md` | 16px | body text, page subtitles, card and panel titles | - | `--text-lg` | 20px | the wordmark | - | `--text-xl` | 28px | a page's title (`h1`), a stat card's number | + | `--text-lg` | 20px | a list's title, an inspector title that is an id | + | `--text-xl` | 28px | a page's and an inspector's title, a tile's number | | `--text-2xl` | 32px | the landing page's headings only | - Weights: 400 for text, 500 for labels and big numbers, 600 for titles, 700 @@ -54,8 +56,8 @@ have the same structure; only the values change. | `--border` | `#dededb` | `#2b2b2f` | card edges, dividers, grid lines | | `--border-strong` | `#85857f` | `#75757d` | an input's border, an outlined button | | `--text` | `#111110` | `#f2f2f0` | text | -| `--text-muted` | `#5a5a55` | `#a1a1a8` | subtitles, captions, labels, inactive tabs | -| `--accent` | `#f76b15` | `#ff7a1a` | orange fills: the wordmark, filled buttons, the tab underline, charts | +| `--text-muted` | `#5a5a55` | `#a1a1a8` | units, second lines, labels, inactive links and tabs | +| `--accent` | `#f76b15` | `#ff7a1a` | orange fills: the wordmark, filled buttons, the current page's and row's bar, the tab underline, charts | | `--accent-hover` | `#e2560a` | `#ff9442` | a hovered filled button | | `--on-accent` | `#111110` | `#0e0e0f` | text on an orange fill | | `--accent-text` | `#b93d0b` | `#ff8c42` | orange as text or a line: links, the focus ring | @@ -68,8 +70,9 @@ links and the focus ring use `--accent-text`, which passes AA there. ### Orange is for brand and interaction only Use the accent the way Apex uses its green: the wordmark's block, the current -tab's underline, filled buttons, links, the focus ring, selection, and the -line or columns of a chart. Never use it to say something about state: not +page's bar in the sidebar, the chosen row's bar, the current tab's underline, +filled buttons, links, the focus ring, selection, and the line or columns of a +chart. Never use it to say something about state: not for "busy", not for "warning", not for "new". A reader who sees orange should think "this is Simlock" or "I can act here", nothing else. @@ -78,18 +81,27 @@ think "this is Simlock" or "I can act here", nothing else. | Token | Light | Dark | Means | | --- | --- | --- | --- | | `--status-ok` | `#15803d` | `#4ade80` | healthy, connected, ready | -| `--status-warn` | `#a16207` | `#facc15` | needs a look soon: drained, stalled, over a limit, absent (a physical device that is enrolled and not there) | +| `--status-warn` | `#92570a` | `#facc15` | needs a look soon: drained, stalled, over a limit, absent (a physical device that is enrolled and not there) | | `--status-error` | `#b91c1c` | `#f87171` | broken: disconnected, incompatible, quarantined, a refused sign-in | | `--status-idle` | `#57534e` | `#a8a29e` | neither: shut down, waiting, unknown | -**A status always shows a word.** The colour is a second signal, never the only -one: `● connected`, not a green dot. Use the daemon's own word for the state -(`leased`, `quarantined`, `drained`), so the console and the CLI agree. In the -console a status is an 8px square of its colour, then the word in `--text` -(`Status` in `ui/src/status.tsx`). A word the console does not know, from a -newer daemon, shows as itself in `--status-idle`. A count, such as the one on -the Attention tab, is a number, not a status: it has neither a status colour -nor the accent. +One more fill is a neutral, not a status: `--slot-leased` (`#3f3f3b` light, +`#d6d6d0` dark), for a leased device's slot and a share of leased capacity. A +leased device is neither healthy nor in trouble; it is in use. + +**A status always has a word.** The colour is a second signal, never the only +one. Use the daemon's own word for the state (`leased`, `quarantined`, +`drained`), so the console and the CLI agree. In the console a status is an 8px +square of its colour, then the word in `--text` (`Status` in +`ui/src/ds/status.tsx`). In a list row, where there is room for the square +alone (`StatusDot`), the word is the square's accessible name and its tooltip, +and the chosen row's inspector shows it in full. A worker's square shows the +worst of what is wrong with it: red for a lost or failed worker, amber for a +drained or starting one, one over its RAM budget, or a load figure past its +threshold, green otherwise (`workerHealth` in `ui/src/model/fleet.ts`). A word +the console does not know, from a newer daemon, shows as itself in +`--status-idle`. A count, such as the one on the Attention link, is a number, +not a status: it has neither a status colour nor the accent. Every pair of text and background colour in both themes meets WCAG AA: 4.5:1 for text, and 3:1 for the focus ring and an input's border against what is @@ -116,38 +128,60 @@ scale; never a value between. ## Components -- **Header.** Left: the "SIMLOCK" wordmark, `--on-accent` on an `--accent` - block, then the "By Callstack" chip. Right: the connection in a bordered box, - as a status (its word and its colour), then **Sign out** as a secondary - button. The sign-in screen has the same header, without the right side. -- **Tab bar.** Under the header, one tab per view in a fixed order: Workers, - Leases, Waiting, Attention, Events. A tab is a label in `--text-muted`; the - current one is `--text` with a 2px `--accent` underline and - `aria-current="page"`. A 1px `--border` line runs under the bar. Attention - carries its count in a small bordered box, and none at zero. -- **Page header.** The view's title, a one-line subtitle in `--text-muted` - under it, and the view's actions on the right, such as **All workers** on a - worker's page. -- **Stat cards.** A row of cards under the page header: a label, a big number - (`--text-xl`), and a caption in `--text-muted` saying what it counts. A - card shows only numbers the view already has; it never makes a request of - its own. -- **Panel.** A card with a header: a title, a description in - `--text-muted`, actions on the right, and a 1px line between the header and - its body. A table, a chart or a list sits in a panel. A panel is a region - named by its title. +The components live in `ui/src/ds/` (the design system); `ui/AGENTS.md` lists them. A screen is +put together from them and draws nothing of its own that one of them already draws. + +- **Sidebar.** The console's navigation, on the left of every signed-in page: the "SIMLOCK" + wordmark (`--on-accent` on an `--accent` block), one link per page in a fixed order (Overview, + Workers, Leases, Waiting, Attention, Events), then at the bottom the connection as a status, + **Sign out** and the collapse button. A link is an icon and its label in `--text-muted`; the + current one is `--text` on `--field` with a 2px `--accent` bar on its left and + `aria-current="page"`. Attention carries its count in a small bordered box, and none at zero. + Collapsed, the sidebar is 64px wide: the labels go, every icon sits on its centre line, each + link keeps its name (and a tooltip), the wordmark becomes "S" and the connection its square. + Only the operator collapses or widens it, with the button at its bottom; the browser keeps the + choice, and changing pages never changes it. With no choice made, a window under 1280px starts + it collapsed. Under 768px it is a bar along the top: the wordmark and a **menu** button that + opens the links and the bottom block under it. +- **Page.** A page that is not a list, as the overview: its title (`h1`) and its actions on the + right, then its content. No subtitle: the title and what is under it say what the page is. +- **List and inspector.** Every page of things (workers, leases, waiting requests, attention + items, events) is a list on the left, 260–360px wide (wider for events), and the chosen thing in + an inspector filling the rest. The list has a header (the page's title as `h1`, a short count + in mono beside it, then its filters), its rows, and the pager under them. A row is one or two + short lines: a status square, a title, one figure on the right, and at most one muted line or + bar under them; the chosen row is `--surface` with a 2px `--accent` bar on its left and + `aria-current="page"`. The address names the chosen thing (`/workers/`). The inspector + has a breadcrumb, the thing's title (`h2`), one muted line under it, actions on the right, and + then cards, facts, tabs and panels. +- **Stat tiles.** A row of tiles: a label, a big number (`--text-xl`), and a short unit in mono + beside it ("of 48", "last hour"). A tile that leads somewhere is a link as a whole. A tile + shows only numbers its page already has; it never makes a request of its own. +- **Panel.** A card with a header: a title and, on the right, its actions. No description: a + panel's title says what it holds. A panel is a region named by its title. +- **Facts.** A label and its value side by side, the label as a label. Facts that show a + payload keep the keys as the daemon spelled them (`leaseId`), in mono, not in capitals. +- **Filters.** A row of chips, one choice of several: a radio group drawn as small mono boxes, + the chosen one filled `--field` with a `--border-strong` edge. +- **Tabs.** Labels in a row over a 1px `--border` line; the current one `--text` with a 2px + `--accent` underline. +- **Slots.** One square per device a worker may run, shaded by what the device is doing: + leased (a neutral fill, `--slot-leased`), ready (`--status-ok`), booting (`--status-idle`), + quarantined (`--status-error`), absent (`--status-warn`), free (a dashed `--border-strong` + outline). A group of slots carries its counts as words in its accessible name ("2 leased, 1 + ready, 3 free") and each square its device in a tooltip; a legend names the shades. - **Table.** Fills its panel edge to edge. Headers are labels in `--text-muted`. Rows are roomy, 16px above and below, with a 1px line between them. Numbers, durations included, are mono and right-aligned. - Every table of a view's items is the one table component - (`ui/src/table.tsx`), so every such table pages the same way. A chart's + Every table of a page's items is the one table component + (`ui/src/ds/table.tsx`), so every such table pages the same way. A chart's "Minute by minute" table is not one: it always has its 60 rows. - **Pager.** Under a table, edge to edge in its panel, with a 1px `--border` line above it like a row's: the rows shown of how many in mono (`26–50 of 1,240`), **Per page** as a select, then **Previous**, the page number in mono `--text-muted` (`Page 2 of 50`), and **Next**, as secondary buttons. - It wraps on a phone. Under a grid of cards it is a bar of its own, a - `--surface` card with a `--border` edge. It is hidden when every row fits + It wraps on a phone. Under a list it sits in the list's footer, its parts + stacked to fit the list's width. It is hidden when every row fits on one page. At either end, the button that would leave the list keeps its place and its focus, says so with `aria-disabled`, and goes quiet: its text `--text-muted`, its edge `--border`. @@ -174,49 +208,34 @@ scale; never a value between. `--accent`, an area with a faint fill of it (8%), horizontal grid lines in `--border`, axis labels in Geist Mono `--text-muted`. Every colour comes from the stylesheet by class. Nothing moves: animation is off. Every chart has a - title, a caption saying what it counts, its numbers as a line of text under - it, and every minute as a table behind a "Minute by minute" disclosure, so + title saying what it counts, its numbers as a line of text under it, and every minute as a table behind a "Minute by minute" disclosure, so nothing is visible only in the picture. A chart can be focused, and the arrow keys step through it. Its focus ring goes round the whole chart, for keyboard focus only: a click on a chart draws none. -- **Load gauges.** On a worker's page, each of the Load panel's CPU, memory and - disk figures carries a bar in a line of its own under it: 8px high, `--field` - track, `max-width: 20rem`, square, and never the accent. Its fill takes a - status colour by class; its level arrives inline: `style-src 'self'` refuses a - style attribute in markup, and the level arrives anyway because React writes - styles through the CSSOM, where the policy does not reach. ADR 0011 allows the - `style` prop because it creates no `