PROTEUS docs
v0.1.0 Jeremy Hall

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.

Proteus is MIT-licensed. Built by Jeremy Hall.

Synthetic telemetry for CloudWatch Omni. Everything it emits is fabricated; nothing in it describes a real system.