The web GUI
Inside proteus serve — a no-build ES module front end, the state/render split, the SVG camera, the theming token chain, and the HTTP surface.
proteus serve runs a local web GUI for composing an estate, dragging faults onto
it, watching the projected cost move, and injecting. This page is its internals.
For how to use it, see Operations.
Two documents govern it. WEBAPI.md fixes the HTTP boundary and CONTRACTS.md
still governs the Python side. Neither side may change a route or payload shape
without changing WEBAPI.md first.
Three principles#
The GUI is a view over the world model, not a second source of truth. Every
mutation goes through proteus.model.store and proteus.model.schema validation,
exactly as the CLI does. There is no GUI-only path into the model.
The one exception is canvas node positions, which live in a sidecar file because
model/schema.py is frozen and node coordinates are not telemetry.
Nothing is sent to AWS without an explicit inject call. Composing an estate, dragging faults and watching the cost figure move are all local.
No build step. The UI is plain ES modules and CSS loaded directly by the browser. No npm, no bundler, no JSX, no TypeScript.
The server#
proteus/web/server.py is standard library only:
http.server.ThreadingHTTPServer with a BaseHTTPRequestHandler subclass. No
FastAPI, no Flask, no uvicorn.
Threading is not decoration. An SSE stream holds its connection open for the whole of a 14-day backfill, and a single-threaded server would let that one subscriber freeze the rest of the API.
Security, all of it load-bearing#
This process holds AWS credentials and can spend money.
It binds 127.0.0.1 only, never 0.0.0.0.
server = ThreadingHTTPServer(("127.0.0.1", port), handler)
It rejects a cross-origin request. Any request whose Origin header is
present and is not http://127.0.0.1:<port> or http://localhost:<port> gets a
403. That is what stops a page in another tab driving the injector.
Static paths are resolved before the containment check. resolve_static
percent-decodes first, then fully resolves the candidate and refuses anything whose
resolved path is neither the static root nor inside it — so neither .. nor
%2e%2e nor a symlink reaches outside.
MAX_BODY_BYTES = 8 * 1024 * 1024, so a malformed Content-Length cannot make
the server allocate freely.
No credential, secret or full ARN appears in any response body. api.py
enforces that with a redaction pass, because the doctor's own checks quote caller
identity:
_REDACTIONS = (
(re.compile(r"arn:[a-z0-9-]*:[^\s\"']+"), "arn:<redacted>"),
(re.compile(r"(?<!\d)\d{12}(?!\d)"), "<account-redacted>"),
)
Every static response sets Cache-Control: no-store, so a reload always picks up
an edit.
Routing#
ROUTES is a flat tuple of (method, pattern, handler). A pattern segment written
<name> captures one path segment into Request.params. Patterns are compared
segment by segment, so /api/pricing/refresh and /api/inject/<job_id>/cancel
cannot shadow one another.
Handlers are pure (Context, Request) -> Response. They do no socket work and know
nothing about HTTP framing, which is what lets the whole API be exercised from a
test without a listening socket.
An exception from a handler becomes a 500 with the exception type and message — never a traceback to the browser.
The API surface#
| Route | Handler | Notes |
|---|---|---|
GET /api/model |
api.get_model |
includes hidden faults; the client filters |
PUT /api/model |
api.put_model |
validates, backs up, bumps revision, saves |
GET /api/presets |
api.get_presets |
|
POST /api/preset |
api.post_preset |
|
GET /api/catalog |
api.get_catalog |
built from the live registries |
GET /api/metrics |
api_ops.get_metrics |
not in the original WEBAPI.md |
POST /api/projection |
api.post_projection |
fast enough to call on every change |
GET /api/pricing |
api.get_pricing |
rate card plus provenance |
POST /api/pricing/refresh |
api.post_pricing_refresh |
forces a re-fetch |
POST /api/inject |
api_ops.post_inject |
412 without confirmed, 409 when busy |
GET /api/inject/<job_id>/events |
streamed by the handler | SSE; not in ROUTES |
POST /api/inject/<job_id>/cancel |
api_ops.post_inject_cancel |
cooperative |
GET /api/doctor |
api_ops.get_doctor |
read-only, redacted |
GET/PUT /api/layout |
api_ops.get_layout / put_layout |
the sidecar |
GET/POST /api/hunts |
api_ops.get_hunts / post_hunts |
|
POST /api/hunts/<id>/guess |
api_ops.post_hunt_guess |
GET /api/metrics is an addition and says so in its own docstring: it returns every
metric the live estate would emit, with kind, unit and the attributes that become
dimensions, so an operator can see the whole metric surface before anything is sent.
It is read-only and local — it probes the registered generators, it does not inject.
POST /api/projection remains the authority on volume and cost; the counts there are
metric names, not datapoints.
PUT /api/model rejects atomically#
PUT /api/model <- WorldModel.to_dict()
-> {"ok": true, "revision": 5, "fingerprint": "4cfb59fa74ddf795"}
-> 400 {"error": "...", "errors": ["checkout depends on 'nope'"]}
The handler parses through WorldModel.from_dict, rejects on any dangling
depends_on / uses_infra / tenant reference, a severity outside 0..1, or a fault
window outside the ingestible range; then store.backup(), bump revision,
store.save(). Reject atomically — a half-applied edit is worse than a refused
one.
proteus/web/validate.py holds the rule set as pure functions over a WorldModel,
and its bias is stated plainly: refuse rather than warn. Every rule there catches
a model that would save cleanly, inject successfully, and be quietly wrong in the
console hours later. A 400 with a reason is recoverable; a silently empty node is
not.
Inject jobs#
One job at a time, process-wide. A second POST /api/inject gets 409 with the
running job's id.
Under any guard that requires confirmation, the server answers 412 with the
projection body unless the request says "confirmed": true. The browser confirm
dialog is not a substitute — a scripted POST hits the same gate. See
the cost guard.
The SSE route is deliberately not in ROUTES: it needs the raw socket, so the
handler streams it directly.
for event in job.stream():
if event is None:
self.wfile.write(b": keepalive\n\n")
else:
self.wfile.write(f"data: {json.dumps(event)}\n\n".encode("utf-8"))
self.wfile.flush()
jobs.KEEPALIVE_S = 15.0, so an idle connection gets a comment rather than being
dropped, and every event is flushed immediately. PROGRESS_MIN_INTERVAL_S = 0.25
throttles progress events to roughly 4/s — batched, not one per request.
A browser that navigates away closes the socket mid-write, so BrokenPipeError,
ConnectionResetError and OSError simply drop that subscriber and return. That
must not raise inside the job thread.
The stream always terminates with exactly one done or one fatal error, so
the UI can always leave its running state. Cancellation is cooperative — it stops at
the next batch boundary and reports what was already sent in done with
reason: "cancelled", never claiming nothing was sent when data has already gone.
On KeyboardInterrupt, serve() cancels a running job before shutting down:
cancelling job-7f3a1c at the next batch boundary
When the UI is not there#
If proteus/web/static/ does not exist, / returns 503 with a message saying the
JSON API under /api/ is fully available in the meantime, and serve() prints the
same thing on startup. The API and the UI are independently usable.
The front end#
proteus/web/static/
index.html
brand/design-tokens.css the authoritative palette; never edited
css/ theme.css app.css controls.css brand.css views.css footer.css
js/ main.js state.js api.js canvas.js viewport.js layout.js
panels.js palette.js dnd.js timeline.js cost.js inject.js
dialog.js icons.js theme.js theme-boot.js util.js
layout.check.mjs node-only regression check, not loaded by the page
index.html is 81 lines. It declares the regions and loads one module:
<script type="module" src="/static/js/main.js"></script>
Everything else is DOM built in JavaScript through util.js::el, svgEl and
fill. There is no template language.
Module responsibilities#
| Module | Owns |
|---|---|
main.js |
bootstrap, the header, the backend banner, the doctor dialog |
state.js |
the client-side model store, the event bus, every mutation |
api.js |
every call to the backend; nothing else may call fetch() |
canvas.js |
drawing the topology, node drag, drop handling |
viewport.js |
the camera: zoom, pan, fit |
layout.js |
auto-layout arithmetic; pure, no DOM |
panels.js |
the inspector (right panel) |
palette.js |
the workload and fault palette (left panel) |
dnd.js |
the two mutations a palette can trigger, and the target rule |
timeline.js |
fault blocks across the ingestible window |
cost.js |
the debounced projection meter |
inject.js |
inject controls and live job progress |
icons.js |
the inline icon set and the family/tenant colour mappings |
theme.js, theme-boot.js |
the theme switch |
The state/render split#
state.js holds the model and a tiny event bus. Nothing in it touches the DOM.
Every view subscribes to a topic and re-renders itself:
on('model', renderSummary);
on('layout', renderCanvas);
on('selection', updateSelection);
on('drag', updateDropHints);
Optimistic commit with rollback#
export function commit(mutate) {
const run = async () => {
const before = deepClone(state.model);
try { mutate(state.model); } catch (err) { state.model = before; return {ok: false, ...}; }
emit('model', state.model);
try {
const res = await api.putModel(state.model);
...
return { ok: true, errors: [], revision: res?.revision };
} catch (err) {
state.model = before;
emit('model', state.model);
return { ok: false, errors };
}
};
chain = chain.then(run, run);
return chain;
}
The mutation is applied locally, the model is PUT, and the whole thing is rolled back if the server refuses. The server is the validator — the client never decides that an edit is legal.
chain = chain.then(run, run) serialises commits. Two overlapping PUTs of the same
document would otherwise race, and the second would overwrite the first's
revision. The run is passed as both handlers so a rejected commit does not break
the chain for the next one.
commit never throws for a validation failure; it resolves to
{ok, errors}. The callers surface errors as a toast.
Selection does not re-render the canvas#
// Selection only toggles a class: a full re-render here would detach the
// node element mid-drag, since selecting happens on pointerdown.
on('selection', updateSelection);
That comment is the whole reason updateSelection exists separately from
renderCanvas.
The layout sidecar is debounced#
export function setNodePos(id, x, y, { save = true } = {}) {
state.layout.nodes[id] = { x: Math.round(x), y: Math.round(y) };
if (!save) return;
pendingLayout = state.layout.nodes;
if (layoutTimer) clearTimeout(layoutTimer);
layoutTimer = setTimeout(flushLayout, 400);
}
Drags are noisy, so writes coalesce over 400 ms. A failed save becomes a warning, not an error: canvas positions are not telemetry.
Positions live at $PROTEUS_HOME/layout.json. A node with no saved position gets
auto-layout from the dependency graph, and a corrupt or missing file is not an
error — the server starts from auto-layout and says so in a warning.
Boot fails piecewise#
boot() fetches the catalog, model, layout and presets in one Promise.all where
each has its own rejection handler pushing a warning. Every piece fails
independently and lands as a toast; a missing layout sidecar does not stop the model
loading.
api.js is also the only place that knows whether the backend is up:
onBackendStatus(fn) fires after every request, and main.js uses it to show a
banner with a retry button. An ApiError with status === 0 means the server never
answered at all (ApiError.unreachable).
Hidden faults are filtered client-side#
GET /api/model returns faults with hidden: true, because the renderer needs
them. The client must not display one.
export function visibleFaults() {
return (state.model?.faults || []).filter((f) => !f.hidden);
}
/** How many faults are hidden. The only thing the UI may say about them. */
export function hiddenFaultCount() {
return (state.model?.faults || []).filter((f) => f.hidden).length;
}
Every fault read in canvas.js, timeline.js and panels.js goes through
visibleFaults(), faultsOn() or faultById(), all of which filter. The header
shows a count and nothing else. updateFault and deleteFault refuse a hidden
fault outright.
This is not caution for its own sake: the first live hunt leaked its own answer
through proteus show, and the GUI must not reintroduce that.
The canvas is a viewBox camera#
canvas.js draws inline SVG by hand. No graph library.
The camera design is a rewrite, and viewport.js records why. Before it, the
<svg> was sized to the full layout extent and the wrapper scrolled — so an estate
wider than the viewport (northwind-rx is) left four nodes cut off at the right
edge, with only a thin scrollbar as the way back and no way to see the whole graph
at once.
Now the <svg> always fills its wrapper and the viewBox is the camera:
svg.setAttribute('viewBox', `${x} ${y} ${w} ${h}`);
The view rect is in world units. Screen scale is wrap.clientWidth / view.w, and
the view's aspect ratio is kept equal to the wrapper's so that scale holds on both
axes and xMidYMid meet never letterboxes. A panel resize is observed with
ResizeObserver and reshapes the view, because otherwise the scale silently differs
between axes.
Everything that converts between a pointer and the model goes through toWorld
or toWorldDelta. Nothing else may divide by the scale itself. A node drag has to
come back through the zoom or the node lags the cursor:
const { dx, dy } = toWorldDelta(screenDx, screenDy);
placeNode(moveDrag.id, moveDrag.originX + dx, moveDrag.originY + dy, moveDrag.group);
| Constant | Value | Meaning |
|---|---|---|
MIN_SCALE / MAX_SCALE |
0.2 / 2.5 | zoom bounds |
FIT_PAD |
28 | fitting to exact bounds puts nodes hard against the edge |
SLACK |
0.45 | how far past the content the camera may wander, as a fraction of the view |
WHEEL_ZOOM |
0.0018 | wheel sensitivity |
KEY_PAN |
60 | arrow-key pan step, in world units |
PX_TILE |
28 | the pixel-dissolve field's tile at scale 1 |
fit() never zooms past 1:1 — a two-node estate blown up to fill a 4K panel looks
broken, not helpful. The first render with something in it auto-fits; after that the
camera is the user's and only the fit control moves it.
The pixel-dissolve background is kept registered to the world by writing CSS
custom properties from paintField(), so the motif pans and zooms with the
topology instead of floating over it, and the dots thin out as you zoom out rather
than turning into a grey wash.
Non-finite numbers are refused, twice#
A NaN reaching an SVG width, height or viewBox attribute makes the browser
discard the whole viewport. That shows up as a blank canvas with nothing pointing at
the cause, so both layers check explicitly:
// viewport.js
if (![x, y, w, h].every(Number.isFinite) || w <= 0 || h <= 0) {
console.error('proteus viewport: refusing a non-finite viewBox', view);
return;
}
// canvas.js
if (!Number.isFinite(width) || !Number.isFinite(height)) {
console.error('proteus canvas: refusing to render, layout bounds are not finite', ...);
return;
}
layout.js guards the arithmetic that produces them, and the reason is worth
remembering: Math.max(48, NaN) is NaN, and Math.min() of an empty spread is
Infinity. Neither clamps; both propagate. So every average and minimum there is
taken over finite values only, with an explicit fallback.
layout.check.mjs is a browserless regression check — layout.js is pure, so it
runs directly under Node and asserts that every returned coordinate and both bounds
are finite:
node proteus/web/static/js/layout.check.mjs [path/to/world.json]
Auto-layout#
layout.js places any node without a saved position in four steps: layer services
by dependency depth (layer 0 is a node nothing depends on, and every node sits at
least one layer right of its deepest dependent); pull each node as close to its own
dependencies as that allows, so a batch job whose only dependency is a database
lands beside it rather than stranded on the left edge; order within a layer by
barycentre over four passes to cut edge crossings; then put infra in its own band
under the services, each node near the mean x of the services that use it.
That last step is the one that matters visually: infra interleaved with services is
what makes the picture unreadable. A layer taller than LAYER_ROWS_MAX = 5 is split
into staggered sub-columns, so one wide fan-out does not make the canvas twice as
tall as the viewport.
Nodes are NODE_W = 158 × NODE_H = 56 world units. A dependency edge leaves from
the side and reads left to right; an infra edge drops vertically into the band below.
Both are cubic Béziers with a bend proportional to the distance.
Drop rules#
dnd.js holds the two mutations a palette can trigger, because each is reachable
two ways — drag-and-drop on the canvas and Enter on a palette row — and neither
caller should duplicate the refusal handling. Nothing in dnd.js touches the DOM.
export function targetsNode(targets, type) {
return targets === 'both' || targets === type;
}
targets comes from GET /api/catalog per fault kind, so the UI never hard-codes
which faults aim at infra. A fault dragged over a node it cannot target gets
dropEffect = 'none', a drop-bad class, and on drop a refusal naming both sides:
noisy-neighbor only targets infra nodes; checkout is service
A workload dropped onto an existing node is refused too — a workload goes on empty canvas, not on top of a node — with a 600 ms drop-refused flash on the node.
state.js::addService handles the archetype/profile split the API documents:
...(kind ? { kind } : {}),
kind is omitted entirely when null rather than sent as null, because
PUT /api/model rejects a kind set on any archetype other than custom.
Keyboard parity#
Everything the mouse can do on the canvas has a keyboard path, and the canvas
wrapper is focusable (the skip link lands on it). Arrow keys pan the camera; +,
- and 0 zoom and fit. On a focused node, Enter or Space selects and arrow keys
move it — 12 world units, or 2 with Shift. Enter on a palette row creates the
workload without a drag.
Theming#
One attribute is the whole switch: data-theme on <html>.
The token chain has three links, and each has exactly one job:
brand/design-tokens.css :root = light, [data-theme="dark"] = dark
| the authoritative palette; read-only, never edited
v
css/theme.css the ONLY file that reads a --p-* token directly;
| maps them onto semantic names
v
app.css controls.css brand.css views.css footer.css
use the semantic names only
The kit defines six monochrome greens (--p-forest-950 through --p-mint-100) plus
surface, text and status colours, with the dark theme as a complete --p-* override
under [data-theme="dark"]. Because theme.css is the only consumer, the whole UI
retunes from the kit and nowhere else.
Where theme.css departs from a kit value it says why in a comment. Light surfaces
are tinted into the mint end of the ramp rather than taken as pure white — the kit's
--p-surface: #ffffff on --p-bg: #f4f8f5 is full-luminance white panels over most
of a large display. And --ok is pushed to #1a7f37 because the kit sets
--p-success to the same hex as --p-accent, which made an "ok" verdict
indistinguishable from ordinary chrome.
The kit ramp is right for the mark but leaves the UI unable to distinguish anything
by colour — four telemetry signals, nine workload families and every verdict all in
the same emerald. So theme.css adds extended hues (--fam-compute … --fam-security,
redefined per theme) for exactly that purpose.
theme-boot.js runs before first paint#
<script src="/static/js/theme-boot.js"></script>
A classic (non-module) script in <head>. Module scripts are deferred, which would
let the document paint under the default theme and then snap to the stored one.
index.html already carries data-theme="dark", so this only ever has to act when
the stored choice is light. theme.js owns every write to the same key
(proteus.theme); theme-boot.js only reads, and a localStorage failure in
private mode simply keeps the default.
Both brand lockups ship in the page and CSS shows the one the active theme needs, so the wordmark never flashes the wrong colour on load.
Icons are inlined, not <img>#
icons.js reproduces the brand icon geometry verbatim — same 24-unit grid, same
1.7 stroke, same round joins — but renders it inline with stroke: currentColor.
The kit files hard-code stroke="#007a4e", which cannot follow a theme, a hover
state or a danger button, and an <img> cannot inherit colour at all. Glyphs the
kit does not ship are marked bespoke and drawn on the same grid.
An unknown icon name logs a warning and returns an empty <svg> rather than
throwing. title makes an icon announced; without one it is aria-hidden.
Fill versus stroke on a node#
This is the one colour decision worth reading the CSS comment for, because it is both a design rule and a source-order dependency.
A node carries two orthogonal channels. Tenant says whose a node is, and is drawn as a stroked stripe down the left edge. Family says what it is, and is drawn as a 10% fill tint.
/* Fill only, deliberately: stroke already carries selection (--accent) and fault
* (--warn) state, and neither may become ambiguous. The tint is 10%, which keeps
* the node name at full contrast over it.
*
* This block sits above `:hover`, `.is-faulted` and `.is-selected` because
* `.node[data-family="x"] .node-box` has the same specificity as
* `.node.is-selected .node-box`, so source order is what decides. Moving it below
* them would silently disable the selection highlight. */
So: stroke is state, fill is identity. Family tint must never touch the stroke,
or a selected node and a faulted node stop being distinguishable. And the family
block must stay above the state rules in views.css — equal specificity means
source order decides, and moving it down turns off selection with no error anywhere.
The tenant stripe has a related economy. Tenant slots 3–5 reuse the three brand
anchor colours with a notched stroke-dasharray rather than introducing three more
hues, so six tenants are distinguishable within a palette that only has three
anchors.
Families are resolved through the catalog, not the model:
export function familyForNode(node, catalog) { ... }
export function workloadFamily(spec) { ... }
FAMILY_BY_NAMESPACE is keyed on aws_namespace rather than on the profile kind,
because the namespace is what AWS itself groups by — so a profile added to the
registry lands in the right family without touching the table. null is a valid
answer: an unmapped namespace falls back to the plain accent rather than being
forced into a family it does not belong to.
Two corrections in that table are worth noting as a pattern. Not every namespace
carries the AWS/ prefix — Glue and CloudWatchSynthetics do not, and both were
missed by an earlier version that assumed they did. And the 12 hand-written
archetypes carry no aws_namespace at all, so they need FAMILY_BY_ARCHETYPE;
without it the most recognisable workloads in a default estate — Postgres, Kafka,
the load balancer, OpenSearch — were the ones left grey, which is the opposite of
useful.
The cost meter#
cost.js calls POST /api/projection debounced 250 ms behind every model or
settings change — which is why that endpoint renders one representative tick and
multiplies rather than rendering the whole window (see
the renderer). est_usd is shown with its pricing
source beside it and labelled an estimate, never a quote, and the meter warns at
80% of the 1,000,000 metric-series quota, matching the guard's own threshold. The
arithmetic is the cost guard.
Accessibility and motion#
The regions in index.html carry real roles and labels: a skip link to the canvas,
role="alert" on the backend banner, aria-live="polite" on the toast region and
the inject progress area, role="toolbar" on the canvas tools, and role="button"
with a descriptive aria-label on every node. Node labels come from
describeNode, so a screen reader gets checkout, service, tenant northwind, 2 faults rather than a bare id. brand.css and controls.css each settle their
animations under prefers-reduced-motion: reduce.
Changing the wire format#
If you need a new route or a changed payload: edit WEBAPI.md first, then both
sides. Both halves are written against that document, and it is the only thing
keeping the Python handlers and the browser modules in agreement.