# admin-canvas

The sys-admin-llm's display surface, from [Phase 13 of the project plan](../docs/project-plan.md).

Where `digest-engine`/`digest-web` (Phase 12) render a scheduled 4x/day synthesis run,
this is the on-demand equivalent: whenever Home Assistant's tool-calling LLM (the
household's "sys-admin-llm", in its admin/ops-facing role — e.g. answering "show me
the kitchen outlet's power draw") decides something should be shown, it pushes a small
JSON document here and the thin client's admin workspace (`4:admin`, see
`hosts/thin-client/agent/thinclient_agent/admin_canvas.py`) picks it up on its next
poll.

Same read/write split as the digest, for the same reason (the write side needs to
validate untrusted-ish input; the read side is dumb, static, and LAN-published):

- **`admin-canvas`** (this directory) — a small always-on Python HTTP service.
  Accepts `POST /show` and `POST /media/<filename>`, both bearer-token gated,
  writes to a shared `output/` volume. Has **no published port** — reachable only
  from other containers on the compose network (i.e. Home Assistant), the same
  trust boundary `mosquitto`/`homeassistant` already share.
- **`admin-web`** (nginx, defined in `setup-container-host.sh`, not here) — serves
  `output/`, `render/canvas-sdk/`, and `render/templates/canvas.html` read-only to
  the LAN, exactly like `digest-web`.

**This does not add a network path to the thin client.** The MQTT "Show admin
canvas" button only ever tells the thin client to switch workspace and open its
fixed, locally-configured `canvas.html` URL — see the security-boundary note in
`hosts/thin-client/README.md`. Content reaches that page over a completely separate
path: sys-admin-llm → HA service call → here → `admin-web` → the browser's own poll.

## Configure

```sh
cp admin-canvas/admin-canvas.env.example /opt/smart-home/admin-canvas/admin-canvas.env
openssl rand -hex 32   # put the result in ADMIN_CANVAS_TOKEN below
chmod 600 /opt/smart-home/admin-canvas/admin-canvas.env
$EDITOR /opt/smart-home/admin-canvas/admin-canvas.env
```

`ADMIN_CANVAS_TOKEN` is required — the service fails closed (rejects every request)
while it is empty, not "auth optional". The same value has to be pasted into HA's
`rest_command:` config below; there is no way for this repo to push it there for you,
same as every other credential pair in this project that spans two machines.

## The `/show` schema

```
POST /show
Authorization: Bearer <ADMIN_CANVAS_TOKEN>
Content-Type: application/json

{
  "windows": [ { "kind": "...", "title": "...", "content": {...} }, ... ]
}
```

Overwrites `output/latest.json` wholesale — there is no history, no merge, no
per-run directories like the digest has; this is a single "what's on screen right
now" document, because unlike the digest, nothing here needs to know whether a
previous push was ever viewed. `admin-canvas/server.py`'s `validate_show_payload()`
rejects anything that doesn't match this shape (HTTP 400) before it is ever written.

### `stat` — a big number

```json
{ "kind": "stat", "title": "Kitchen fridge",
  "content": { "value": 42, "unit": "W", "label": "current draw", "trend": "up" } }
```
`trend` is optional: `"up"` / `"down"` / anything else renders as flat.

### `chart` — dependency-free inline SVG (bar or sparkline)

```json
{ "kind": "chart", "title": "Kitchen outlets — right now",
  "content": { "type": "bar", "unit": "W",
    "points": [ { "label": "Fridge", "value": 42 }, { "label": "Kettle", "value": 1800 } ] } }
```
```json
{ "kind": "chart", "title": "Living room draw — last 6h",
  "content": { "type": "sparkline", "unit": "W", "points": [180, 172, 190, 640, 210, 205] } }
```

### `image` / `video` — media already uploaded via `POST /media/<filename>`

```json
{ "kind": "image", "title": "CPU — last 24h (Netdata)",
  "content": { "src": "media/netdata-cpu.png", "alt": "CPU utilization graph" } }
```
`content.src` **must** match `media/<filename>` — no scheme, no leading `/`, no
`..`. This is the same "a payload never becomes a URL host" invariant
`thinclient_agent/mqtt_discovery.py` documents for the thin client's own control
surface, applied here to what a browser is told to fetch. Upload the file first:

```sh
curl -X POST "http://<container-host>:8092/media/netdata-cpu.png" \
  -H "Authorization: Bearer $ADMIN_CANVAS_TOKEN" \
  --data-binary @netdata-cpu.png
```

Allowed extensions: `.png .jpg .jpeg .gif .webp .mp4 .webm`. Filenames are
allowlist-validated (`^[A-Za-z0-9][A-Za-z0-9_.-]{0,63}$` plus that extension list) —
anything else is refused with HTTP 400, specifically so nothing that isn't a media
file can ever land in the directory `admin-web` serves statically.

### No `kind` — plain text/markdown-ish content

Omit `kind` entirely to reuse the exact same tiny renderer the digest windows use
(bold/italic/inline-code/paragraphs, or a bulleted list if `content` is an array).

All kinds accept optional `x`/`y`/`w`/`h` (percentages) for a deliberately composed
layout; omitted, a window just takes its place in the canvas's normal grid flow.

## Example: wiring this to Home Assistant

**Nothing under this repo builds the HA side** — same convention as every other HA
integration point in this project (see `hosts/thin-client/README.md`'s note on the
Lovelace card nobody here builds either). This is what the household's own HA config
needs, sketched for reference:

```yaml
# configuration.yaml
rest_command:
  admin_canvas_show:
    url: "http://admin-canvas:8092/show"
    method: POST
    headers:
      Authorization: "Bearer !secret admin_canvas_token"
      Content-Type: "application/json"
    payload: "{{ payload }}"
```

A sys-admin-llm tool call (AI Task / Assist) that wants to show the kitchen outlet's
current draw would then resolve the reading from whatever HA entity/history holds
it, build the `stat` JSON above, and call
`rest_command.admin_canvas_show(payload=<that JSON>)`. Getting the thin client to
actually display it is a second, separate step — the existing "Show admin canvas"
MQTT button/service call documented in `hosts/thin-client/README.md`.

## Local testing without HA

```sh
cd admin-canvas
ADMIN_CANVAS_TOKEN=devtoken ADMIN_CANVAS_OUTPUT_DIR=/tmp/admin-canvas-output python3 server.py &

curl -X POST http://localhost:8092/show \
  -H "Authorization: Bearer devtoken" -H "Content-Type: application/json" \
  -d '{"windows":[{"kind":"stat","title":"Kitchen fridge","content":{"value":42,"unit":"W","label":"current draw"}}]}'

python3 -m http.server 8094 --directory /tmp/admin-canvas-output &  # crude stand-in for admin-web
```

Then open `render/templates/canvas.html` directly in a browser (or serve `render/`
alongside the directory above) to see it render — no container host required.
