20 Commits
Author SHA1 Message Date
homelab-updaterandClaude Opus 5.5 9045ee0b25 fix(deploy): keep VM103's disk from filling with images and build cache
ci / check (push) Successful in 1m37s
ci / publish (push) Skipped
Every CI build and deploy left a 1.2 GB image of this app on the VM and
the build cache kept growing, until the root disk was full and apt in
CI failed. After a good deploy update.sh keeps the running image, the
previous one and the three newest; the publish job prunes build cache
older than two days.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 13:07:45 +02:00
homelab-updaterandClaude Opus 5.5 a2f289b4fb feat(deploy): Keycloak login in front of the app, open beyond the LAN
ci / check (push) Successful in 2m48s
ci / publish (push) Successful in 2m46s
An auth service (oauth2-proxy v7.15.4, pinned) answers Traefik's
forwardAuth for every request: without a session the browser goes to
Keycloak (realm home) and only the addresses in AUTH_ALLOWED_EMAILS get
in; the LAN-only ipwhitelist goes. Secrets stay in .env on VM103 and
compose refuses without them. update.sh now checks every service of the
compose file after a deploy instead of a fixed list.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 12:31:29 +02:00
homelab-updaterandClaude Opus 5.5 fc68d30734 feat(web): relative times, a go-to palette and dry runs of all updates
ci / check (push) Successful in 1m35s
ci / publish (push) Successful in 1m22s
Lists say "5 min ago" or "in 3 h" (full time on hover, refreshed each
minute). Ctrl+K (or /, or the sidebar's Search) opens a palette that
jumps to pages, apps, recent runs, workflows and functions. The apps
page starts a dry run of every enabled app with an available update,
after one confirmation, and says which were skipped.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 12:27:24 +02:00
homelab-updaterandClaude Opus 5.5 280d9dc0c9 feat(web): decide from the phone
ci / check (push) Successful in 1m32s
ci / publish (push) Skipped
On a narrow screen the gate's decision buttons stick to the bottom
while the owner reads the gate, larger and two per row; the decision
card gets the window look. A web app manifest, icon and theme color let
the app sit on the phone's home screen and open like an app.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 12:23:14 +02:00
homelab-updaterandClaude Opus 5.5 dbe9abeebf feat: quiet hours and a morning report
ci / check (push) Successful in 1m32s
ci / publish (push) Successful in 1m25s
Owner settings (quiet hours 01:00-07:30, report at 07:30, schedules'
time zone) live in settings, with GET/PUT /api/notification-settings and
a Settings page. In quiet hours the driver holds every push; once a day
from the report time it pushes what waits for the owner, what failed or
is down and what changed since the last report. The overview shows the
same report on top.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 12:21:17 +02:00
homelab-updaterandClaude Opus 5.5 055e892f92 feat(web): an app's version history and apps that often need care
ci / check (push) Successful in 1m30s
ci / publish (push) Successful in 1m33s
Real runs of an app become its history, newest first: updated from what
to what, not updated (skipped, rolled back) or failed, with a repair by
the model and the release notes verdict and summary of that run. An app
with two or more such runs among its last ten gets a mark on its card.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 12:15:18 +02:00
homelab-updaterandClaude Opus 5.5 3798b8950d feat: what runs on VM103 right now and the age of the VM backup
ci / check (push) Successful in 1m31s
ci / publish (push) Successful in 1m25s
The driver looks once a minute: one docker ps of the compose containers
(a GET through the read-only proxy) and the Proxmox timer's vzdump
record. It keeps the latest look in settings; GET /api/host-status
serves it. The overview shows how many enabled apps run well (which do
not) and how old the newest VM backup is; app cards and the table get a
dot for each app's container.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 12:12:53 +02:00
homelab-updaterandClaude Opus 5.5 dbb17ea5b9 feat(config): manage Trilium (TriliumNext) with a Sunday schedule
ci / check (push) Successful in 1m40s
ci / publish (push) Successful in 1m27s
Auto stays at patch: on 0.x every minor migrates the database one way.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 12:01:54 +02:00
homelab-updaterandClaude Opus 5.5 12cca5586b docs: TASK_STATE with the deploy button
ci / publish (push) Successful in 1m17s
ci / check (push) Successful in 1m31s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 09:36:37 +02:00
homelab-updaterandClaude Opus 5.5 136f9d2fb0 feat(deploy): deploy a release by hand from Gitea Actions
ci / check (push) Successful in 1m30s
ci / publish (push) Successful in 1m22s
A deploy workflow, started only by hand (or through the API) and only
from master, pulls the chosen image (release tag, sha or latest) and
runs that image's own update.sh against VM103's compose directory
through the Docker socket. update.sh now copies the database through the
running server first, installs the image's compose.yaml and update.sh
with --install, and restores the previous compose file together with
the previous image when the new version does not come up. CI also
builds release tags v*.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 09:30:19 +02:00
homelab-updaterandClaude Opus 5.5 119dd18d43 docs: TASK_STATE after deploying phase G to VM103
ci / check (push) Successful in 1m29s
ci / publish (push) Successful in 1m18s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 08:24:46 +02:00
homelab-updaterandClaude Opus 5.5 aec96b2273 chore(web): style the existing notes, drop a heading rule cards outgrew; docs for phase G
ci / check (push) Successful in 1m29s
ci / publish (push) Successful in 1m44s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 07:55:12 +02:00
homelab-updaterandClaude Opus 5.5 0fbad32c87 feat(web): model spend per day, share by model and outcomes per kind
ci / check (push) Successful in 1m32s
ci / publish (push) Skipped
The models page charts the last 30 days of spend stacked by model, each
model's share of all spend and what the models concluded per kind of
question; profiles sit in a window and a profile is edited in the side
panel.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 07:52:59 +02:00
homelab-updaterandClaude Opus 5.5 0289663494 feat(web): steps of a run over time as a Gantt chart
ci / check (push) Successful in 1m34s
ci / publish (push) Skipped
One row per node in the order the run reached it, one bar per attempt
colored by how it went, an open attempt reaching to now; clicking a bar
shows that node's details. The run's history sits in its own window.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 07:50:41 +02:00
homelab-updaterandClaude Opus 5.5 76e8baa1e5 feat(web): apps as cards with versions and last runs, app run durations
ci / check (push) Successful in 1m36s
ci / publish (push) Skipped
Each project is one card: what runs and what is available, the last ten
runs as status dots, the next run and a dry-run button; the table stays
one click away and the choice is remembered. An app's page is split into
windows and charts how long its last runs took.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 01:41:17 +02:00
homelab-updaterandClaude Opus 5.5 896314d4dd feat(web): overview dashboard with stat tiles, activity and schedule charts
ci / check (push) Successful in 1m32s
ci / publish (push) Skipped
Tiles for what waits, what runs, the 30-day success rate against the 30
days before, model spend with a sparkline and apps with an update; runs
per day stacked by status; the next 7 days of schedules on a time axis.

The workflow graph no longer retries a failed canvas start on every
render: the retry cleared its container and wiped the fallback list.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 01:37:51 +02:00
homelab-updaterandClaude Opus 5.5 615bf95ab5 feat(web): chart foundation on ECharts, run and cost aggregates
ci / check (push) Successful in 1m30s
ci / publish (push) Skipped
New dependency echarts: stacked bars, Gantt (custom series), donut and
sparklines with tooltips and theming, instead of a hand-written chart
engine; only the used parts are registered and loaded on demand.

stats.ts counts runs per day, success rate with the window before,
durations, apps with an update and model spend per day; option builders
take the theme's palette so both themes share one set of charts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 01:31:12 +02:00
homelab-updaterandClaude Opus 5.5 df726618a6 feat(web): confirmation window, run preview side panel and toasts
ci / check (push) Failing after 1m27s
ci / publish (push) Skipped
Every window.confirm becomes an in-app window (red for what changes an
app or ends a run); the runs list opens a run's preview in a side panel;
saving or removing a schedule and cancelling a run show a toast.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 01:26:49 +02:00
homelab-updaterandClaude Opus 5.5 3d57472252 feat(web): sidebar shell, dark-first theme with toggle, card and stat components
ci / check (push) Successful in 1m35s
ci / publish (push) Skipped
Restyles shared tables, headers, tabs and modals on new surface, shadow
and radius tokens; the runs list sits in a card with its filters.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 01:22:07 +02:00
homelab-updater 2fd0cf812a Merge foundations into master for Git and Arcane rollout
ci / check (push) Successful in 1m28s
ci / publish (push) Successful in 1m26s
2026-09-30 01:16:19 +02:00
72 changed files with 4979 additions and 270 deletions

No files matched your search

+2
View File
@@ -6,6 +6,8 @@ data
docs
tools
deploy
!deploy/compose.yaml
!deploy/update.sh
.env
.env.*
*.log
+5 -3
View File
@@ -1,5 +1,5 @@
# CI: `npm run check` on every push; on master/foundations also build the image and push it to the Gitea registry.
# It does not deploy: VM103 pulls the image with deploy/update.sh (docs/DEPLOY.md).
# CI: `npm run check` on every push; on master/foundations and on release tags (v…) also build the image and push it to
# the Gitea registry. It does not deploy: that is the deploy workflow, started by hand (docs/DEPLOY.md).
name: ci
on:
@@ -28,7 +28,7 @@ jobs:
publish:
needs: check
if: github.ref_name == 'master' || github.ref_name == 'foundations'
if: github.ref_name == 'master' || github.ref_name == 'foundations' || startsWith(github.ref, 'refs/tags/v')
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
@@ -53,3 +53,5 @@ jobs:
# Push only these refs: the daemon is the VM's, so `--all-tags` would also push the local :rollback tag from update.sh.
for ref in "${refs[@]}"; do docker push "${ref}"; done
docker logout "${REGISTRY}"
# Builds run on the VM's daemon: without this its build cache grows until the disk is full.
docker builder prune -f --filter until=48h >/dev/null 2>&1 || true
+61
View File
@@ -0,0 +1,61 @@
# Deploys one published image to VM103, only when started by hand: Actions → deploy → Run workflow, or the Gitea API.
# The new image's own deploy/update.sh does the work against /opt/homelab/compose/homelab-upgrade, so its guards stay:
# it refuses while a workflow run is in the middle of a step, copies the database first, installs the image's
# compose.yaml, waits for healthy and goes back to the previous image and compose file if the new version does not come up.
name: deploy
on:
workflow_dispatch:
inputs:
version:
description: "Image to deploy: a release tag (v2026.10.1), a full commit sha, or latest (master)"
required: true
default: latest
type: string
force:
description: "Restart even while a workflow run is in the middle of a step, or without a database copy"
required: false
default: false
type: boolean
env:
REGISTRY: git.mariuszpacek.pl
DEPLOY_DIR: /opt/homelab/compose/homelab-upgrade
jobs:
deploy:
# Only the reviewed definition on master may restart production.
if: github.ref_name == 'master'
runs-on: ubuntu-latest
steps:
- name: Check the version
env:
VERSION: ${{ inputs.version }}
run: |
[[ "$VERSION" =~ ^(latest|v[0-9][0-9A-Za-z._-]{0,40}|[0-9a-f]{40})$ ]] || { echo "version must be latest, a v… tag or a full commit sha, not: $VERSION"; exit 2; }
- name: Docker CLI available (talks to the VM daemon through the mounted socket)
run: command -v docker >/dev/null || (apt-get update -qq && apt-get install -y -qq docker.io >/dev/null); docker version --format '{{.Client.Version}} -> {{.Server.Version}}'
- name: Pull the image
env:
VERSION: ${{ inputs.version }}
run: |
set -euo pipefail
image="${REGISTRY}/${GITHUB_REPOSITORY_OWNER}/homelab-upgrade:${VERSION}"
docker pull -q "$image"
echo "revision: $(docker inspect --format '{{index .Config.Labels "org.opencontainers.image.revision"}}' "$image")"
- name: Deploy
env:
VERSION: ${{ inputs.version }}
FORCE: ${{ inputs.force }}
run: |
set -euo pipefail
image="${REGISTRY}/${GITHUB_REPOSITORY_OWNER}/homelab-upgrade:${VERSION}"
args=("$VERSION" --install)
[ "$FORCE" = true ] && args+=(--force)
# root for the Docker socket and the root-owned compose directory; the same path inside, so compose resolves
# the project (name, .env, paths) exactly as on the VM.
docker run --rm --user 0 --entrypoint /app/deploy/update.sh \
-e DEPLOY_DIR="$DEPLOY_DIR" \
-v /var/run/docker.sock:/var/run/docker.sock \
-v "$DEPLOY_DIR:$DEPLOY_DIR" \
"$image" "${args[@]}"
+3 -1
View File
@@ -48,6 +48,8 @@ COPY --from=codex /opt/codex /opt/codex
COPY bin ./bin
COPY src ./src
COPY config ./config
RUN chmod -R a+rX ./src ./config ./bin
# The deploy workflow runs this image's own update.sh against VM103's compose directory (docs/DEPLOY.md).
COPY deploy/compose.yaml deploy/update.sh ./deploy/
RUN chmod -R a+rX ./src ./config ./bin ./deploy
USER node
CMD ["node", "src/main/server.ts"]
+14 -15
View File
@@ -1,33 +1,32 @@
# Stan zadania — publikacja Git/Arcane
# Stan zadania — nowoczesny frontend (Faza G)
## Cel
Opublikować poprawki z foundations i scalić je do master zgodnie ze zleceniem właściciela.
Bardziej nowoczesny i graficzny frontend: okna/karty, wykresy z danych, które API już zwraca. Plan i status: `docs/IMPLEMENTATION.md`, „Faza G”.
## Decyzje
- Właściciel zatwierdził commit, push i merge do master. Potwierdził konfigurację konta Arcane przez Keycloak dla `pacekm90@gmail.com`; nie weryfikowano jej niezależnie.
- Compose: prywatne homelab-compose, master. Executor publikuje Git, Arcane wdraża, verify ocenia wynik; rollback przez git revert. Env, klucze i webhooki pozostają lokalne.
- Harmonogramy Europe/Warsaw; krytyczne aplikacje piątek/sobota wieczorem. InfluxDB wyłączony do migracji klientów; Dockge wycofany. Arcane/homelab-upgrade nie synchronizują samych siebie, upgrade-lab direct.
- Sidebar + karty, ciemny motyw domyślny z przełącznikiem (`src/web/theme.ts`).
- ECharts (`src/web/charts/echarts.ts`, osobny chunk), opcje wykresów jako czyste funkcje w `src/web/charts/options.ts`, agregacje w `src/web/stats.ts`; kolory z tokenów CSS, kategorie `--series-1..8`.
- Okna: `src/web/components/dialogs.tsx` (`useConfirm`, `useToast`, `Drawer`); `window.confirm` nie jest już używany.
- Bez zmian w backendzie; lista runów pobierana w całości — przy dużej liczbie runów rozważyć endpoint agregatów.
## Zrobione
- Repo Compose opublikowane: 19 projektów i archiwum Dockge. Autosync i webhooki 18 projektów Arcane; osobne deploy keys RW/RO i przypięte host keys. Token instalacyjny unieważniony (API 401).
- Publikacja Git dla apply/migrate/rollback i hl write/up; kontrola SHA, brudnego checkoutu i sekretów inline. Lokalny env przed pushem, env-only rollback przez redeploy. Pilot Jackett i git revert zweryfikowane na VM.
- Env 10 projektów przekonwertowane z raw przy identycznym docker compose config. Regexy poprawione, 20 harmonogramów zapisanych i porównanych przez API, 18 aktywnych.
- Czyszczenie Dockera: utrzymanie stdin dial-stdio i obsługa chunked; próby obu endpointów udane. Cron: jeden formatter i kandydaci według godzin UTC, obsługa DST i ułamkowych stref. API harmonogramów około 0,34 s, idle CPU około 0,05%.
- VM: server homelab-upgrade:git-arcane-20260930-r4, executor ...-r2, pozostałe usługi na b42c3d6. Ostatnia kontrola: wszystkie zdrowe.
- Dockge zatrzymany bez usuwania wolumenów. Compose/appdata i 29 kopii (31 ścieżek) zachowane w /srv/appdata/to_delete/20260930; lokalny cache Python w ignorowanym to_delete.
- G0–G7 scalone do `master` (`aec96b2`, fast-forward z `frontend-refresh`) i wdrożone 2026-09-30 na VM103: `./update.sh aec96b2…`, wszystkie usługi na obrazie CI (build 174), bez obrazów hotfix; `compose.yaml` na VM = `deploy/compose.yaml`. Kopie przed wdrożeniem: `/srv/appdata/homelab-upgrade/data/queue.sqlite.before-phase-g-*`, `/srv/appdata/homelab-upgrade/compose.yaml.before-phase-g-*`.
- Wdrożenie przyciskiem: workflow `deploy` (Actions → deploy → Run workflow z `master`, albo `dispatch_workflow` przez MCP) uruchamia `update.sh` z wdrażanego obrazu (`--install`: kopia bazy, `compose.yaml` z obrazu, powrót do poprzedniego obrazu i compose). Tagi `v*` budują obrazy wydań. Sprawdzone na VM103: run 206 wdrożył `136f9d2` (build 177), run 207 odrzucił niepoprawną wersję przed jakąkolwiek zmianą. Opis: `docs/DEPLOY.md`, „Aktualizacja”.
- Gitea (`homelab-compose`, `gitea/compose.yaml`): router `gitea-web-lan` bez limitu zapytań dla LAN, VPN i sieci Dockera (`ClientIP`, priorytet 200); limit Traefika zostaje dla internetu. Powód: CI dostawało 429 przy `docker login`.
- Graf Cytoscape nie ponawia nieudanego startu canvas przy każdym renderze (wcześniej czyścił listę zastępczą).
## Pozostałe
- Publikacja foundations i scalenie/push master; następnie sprawdzenie CI. Przejście z lokalnych obrazów hotfix na obraz CI wymaga osobnego uruchomienia update.sh.
- Migracja klientów Influx przed włączeniem aktualizacji. Dawne zadania właściciela: push HA na telefonie i logowanie Codex. Stary homelab-modernization pozostaje potrzebny przez odwołania w skillach ops i wyłączonej jednostce systemd.
- Przegląd na żywo w przeglądarce (oba motywy, 375 px i desktop), poprawki wyglądu po przeglądzie.
- Ze strumienia Git/Arcane: migracja klientów Influx przed włączeniem aktualizacji.
## Następny krok
Wypchnąć foundations, scalić do master i wypchnąć master; sprawdzić wynik CI.
Właściciel ogląda nowe UI na https://upgrade.mariuszpacek.pl i zgłasza poprawki.
## Walidacja
npm run check: 104 pliki, 1240 testów PASS. Testy Git na lokalnych repozytoriach; publikacja/rollback, harmonogramy, API i zdrowie kontenerów sprawdzone na VM w poprzednim etapie.
`npm run check` zielone: 107 plików, 1260+ testów. Testy agregacji i opcji wykresów: `npx vitest run src/web/stats.test.ts src/web/charts`.
+2 -1
View File
@@ -18,5 +18,6 @@
{"app": "ombi", "service": "ombi", "imageRepo": "linuxserver/ombi", "tagPattern": "^v[0-9]+\\.[0-9]+\\.[0-9]+-ls[0-9]+$", "auto": "patch", "httpUrl": "https://ombi.mariuszpacek.pl/", "expectStatus": 200},
{"app": "orcaslicer", "service": "orcaslicer", "imageRepo": "linuxserver/orcaslicer", "tagPattern": "^v[0-9]+\\.[0-9]+\\.[0-9]+-ls[0-9]+$", "auto": "minor", "httpUrl": "https://orca.mariuszpacek.pl/", "expectStatus": 401},
{"app": "radarr", "service": "radarr", "imageRepo": "ghcr.io/linuxserver/radarr", "tagPattern": "^[0-9]+\\.[0-9]+\\.[0-9]+\\.[0-9]+-ls[0-9]+$", "auto": "patch", "httpUrl": "https://radarr.mariuszpacek.pl/", "expectStatus": 302},
{"app": "sonarr", "service": "sonarr", "imageRepo": "ghcr.io/linuxserver/sonarr", "tagPattern": "^[0-9]+\\.[0-9]+\\.[0-9]+\\.[0-9]+-ls[0-9]+$", "auto": "patch", "httpUrl": "https://sonarr.mariuszpacek.pl/", "expectStatus": 302}
{"app": "sonarr", "service": "sonarr", "imageRepo": "ghcr.io/linuxserver/sonarr", "tagPattern": "^[0-9]+\\.[0-9]+\\.[0-9]+\\.[0-9]+-ls[0-9]+$", "auto": "patch", "httpUrl": "https://sonarr.mariuszpacek.pl/", "expectStatus": 302},
{"app": "trilium", "service": "trilium", "imageRepo": "triliumnext/trilium", "tagPattern": "^v[0-9]+\\.[0-9]+\\.[0-9]+$", "auto": "patch", "httpUrl": "https://trilium.mariuszpacek.pl/api/health-check", "expectStatus": 200, "notes": "0.x: every minor migrates the database one way"}
]
+15
View File
@@ -0,0 +1,15 @@
# Trilium — VM103
Usługa `trilium`, obraz `triliumnext/trilium` — TriliumNext, utrzymywany fork `zadam/trilium` (oryginał w trybie utrzymania). Domena `trilium.mariuszpacek.pl` (także `notes.mariuszpacek.pl`), port 8080 tylko w sieci `proxy`, bez portu na hoście. Whitelist traefika jak w bazarr (LAN, VPN, `172.16.0.1/32` dla drivera). Aplikacja ma własne logowanie hasłem.
Dane: `/home/node/trilium-data` ← `/srv/appdata/trilium/data` (`document.db` SQLite, `config.ini`, `backup/`, `log/`), właściciel `1000:1000`, tryb `700`. Entrypoint startuje jako root, robi `chown -R node:node /home/node` i uruchamia serwer jako `node` (uid 1000) — nie ustawiaj `user:` w compose.
Obraz ma healthcheck (`/api/health-check`) i etykietę OCI `org.opencontainers.image.version` z tagiem.
Źródła: [wydania](https://github.com/TriliumNext/Trilium/releases), [dokumentacja](https://docs.triliumnotes.org/), [Docker](https://docs.triliumnotes.org/user-guide/setup/server/installation/docker).
Wersje: tagi `vX.Y.Z` (bez `-rc`, `main`, `sha-*`, `latest`). Seria 0.x: każdy minor to wydanie z nowymi funkcjami i zwykle z jednokierunkową migracją bazy — stąd auto `patch`, a minor czeka na decyzję właściciela.
Migracja bazy przy starcie w jednej transakcji; przed nią Trilium sam zapisuje `backup/backup-before-migration.db`. Historia: 2026-09-30 baza przeniesiona z `/fast-vm/docker/trilium/mariusz` na Proxmoksie (Trilium 0.60, dbVersion 214) i zmigrowana na v0.106.0 (dbVersion 240); kopia sprzed tej migracji: `backup/backup-before-migration-v060.db`.
Kopia: codzienny vzdump VM103 obejmuje `/srv/appdata`; workflow kopiuje compose przed zmianą. Dodatkowa kopia bazy nie jest wymagana, bo Trilium robi ją sam przed migracją.
Odbiór: kontener `healthy` na przypiętym obrazie; `/api/health-check` przez HTTPS → 200; w logu `Migration to version N has been successful` (jeśli była migracja) i `All consistency checks passed`; brak `Error` przy starcie. Treść notatek sprawdza właściciel po zalogowaniu.
STOP: log pokazuje nieudaną migrację (`migration failed`, crash przy starcie) albo błędy spójności bazy — nie naprawiaj bazy, opisz i zostaw decyzję właścicielowi.
Powrót: rollback przywraca compose sprzed aktualizacji, ale starsza wersja odmówi otwarcia bazy po migracji. Wtedy trzeba przy zatrzymanym kontenerze przywrócić `document.db` z `backup/backup-before-migration.db` (albo z vzdumpu) — tylko za zgodą właściciela.
+6
View File
@@ -120,6 +120,12 @@
"cron": "0 17 * * 6",
"mode": "real",
"enabled": false
},
{
"workflowId": "update-trilium",
"cron": "0 16 * * 0",
"mode": "real",
"enabled": true
}
]
}
+61 -3
View File
@@ -37,13 +37,15 @@ services:
traefik.http.routers.homelab-upgrade.rule: 'Host(`upgrade.mariuszpacek.pl`)'
traefik.http.routers.homelab-upgrade.entrypoints: "web-secured"
traefik.http.routers.homelab-upgrade.priority: "100"
traefik.http.routers.homelab-upgrade.middlewares: "homelab-upgrade-lan"
traefik.http.routers.homelab-upgrade.middlewares: "homelab-upgrade-login"
traefik.http.routers.homelab-upgrade.tls: "true"
traefik.http.routers.homelab-upgrade.tls.certresolver: "http"
traefik.http.routers.homelab-upgrade.service: "homelab-upgrade"
traefik.http.services.homelab-upgrade.loadbalancer.server.port: "8080"
# No login yet: LAN only.
traefik.http.middlewares.homelab-upgrade-lan.ipwhitelist.sourcerange: "192.168.0.1/24,10.0.0.0/16"
# Every request asks the auth service first; without a session the browser goes to Keycloak (service auth below).
traefik.http.middlewares.homelab-upgrade-login.forwardauth.address: "http://homelab-upgrade-auth:4180/"
traefik.http.middlewares.homelab-upgrade-login.forwardauth.trustForwardHeader: "true"
traefik.http.middlewares.homelab-upgrade-login.forwardauth.authResponseHeaders: "X-Auth-Request-Email,X-Auth-Request-User"
# Tile on dash.mariuszpacek.pl: homepage discovers it through its own Docker connection (my-docker), no edit of its files.
homepage.group: "Infra"
homepage.name: "Upgrade"
@@ -53,6 +55,57 @@ services:
homepage.server: "my-docker"
homepage.container: "homelab-upgrade-server-1"
# Login in front of the app (oauth2-proxy, Traefik forwardAuth with a static upstream): a request without a session
# is sent to Keycloak (realm home), and only the e-mail addresses in AUTH_ALLOWED_EMAILS get in. The client secret,
# the cookie secret and the addresses live in .env on VM103 (docs/DEPLOY.md, "Logowanie"). The session cookie lasts
# 7 days and is refreshed hourly. The manifest and the icon stay public: a phone fetches them without the cookie.
auth:
image: quay.io/oauth2-proxy/oauth2-proxy:v7.15.4@sha256:b1b2021fe8f4004573e8d690dec6c7bb29cc44364572cf8510a05bf3a0ae2ded
restart: unless-stopped
user: "65532:65532"
read_only: true
cap_drop: [ALL]
security_opt: ["no-new-privileges:true"]
environment:
OAUTH2_PROXY_PROVIDER: keycloak-oidc
OAUTH2_PROXY_OIDC_ISSUER_URL: https://login.mariuszpacek.pl/realms/home
OAUTH2_PROXY_CLIENT_ID: ${AUTH_CLIENT_ID:-homelab-upgrade}
OAUTH2_PROXY_CLIENT_SECRET: ${AUTH_CLIENT_SECRET:?AUTH_CLIENT_SECRET is not set in .env}
OAUTH2_PROXY_COOKIE_SECRET: ${AUTH_COOKIE_SECRET:?AUTH_COOKIE_SECRET is not set in .env}
OAUTH2_PROXY_CODE_CHALLENGE_METHOD: S256
OAUTH2_PROXY_REDIRECT_URL: https://upgrade.mariuszpacek.pl/oauth2/callback
OAUTH2_PROXY_HTTP_ADDRESS: 0.0.0.0:4180
OAUTH2_PROXY_REVERSE_PROXY: "true"
OAUTH2_PROXY_UPSTREAMS: static://202
OAUTH2_PROXY_EMAIL_DOMAINS: "*"
OAUTH2_PROXY_AUTHENTICATED_EMAILS_FILE: /etc/oauth2-proxy/emails
OAUTH2_PROXY_SKIP_PROVIDER_BUTTON: "true"
OAUTH2_PROXY_SKIP_AUTH_ROUTES: "GET=^/manifest\\.webmanifest$$,GET=^/icon\\.svg$$"
OAUTH2_PROXY_COOKIE_NAME: _homelab_upgrade
OAUTH2_PROXY_COOKIE_SECURE: "true"
OAUTH2_PROXY_COOKIE_EXPIRE: 168h
OAUTH2_PROXY_COOKIE_REFRESH: 1h
OAUTH2_PROXY_SET_XAUTHREQUEST: "true"
OAUTH2_PROXY_SHOW_DEBUG_ON_ERROR: "false"
configs:
- source: auth-emails
target: /etc/oauth2-proxy/emails
networks:
proxy:
# A name no other project on the proxy network uses: Traefik's forwardAuth address points at it.
aliases: [homelab-upgrade-auth]
labels:
traefik.enable: "true"
traefik.docker.network: "proxy"
# The sign-in callback and sign-out go straight to the auth service.
traefik.http.routers.homelab-upgrade-auth.rule: 'Host(`upgrade.mariuszpacek.pl`) && PathPrefix(`/oauth2/`)'
traefik.http.routers.homelab-upgrade-auth.entrypoints: "web-secured"
traefik.http.routers.homelab-upgrade-auth.priority: "200"
traefik.http.routers.homelab-upgrade-auth.tls: "true"
traefik.http.routers.homelab-upgrade-auth.tls.certresolver: "http"
traefik.http.routers.homelab-upgrade-auth.service: "homelab-upgrade-auth"
traefik.http.services.homelab-upgrade-auth.loadbalancer.server.port: "4180"
# The driver: runs queued workflow runs one step at a time and pushes to the owner's phone when a run waits for them,
# fails, or a real run ends. It finds the running containers by their compose labels (docker ps through the socket proxy
# below, GET only) and asks the registries what is newer. It reads no compose project file and changes nothing itself:
@@ -205,6 +258,11 @@ services:
cap_drop: [ALL]
security_opt: ["no-new-privileges:true"]
configs:
# One address per line; nobody else gets in, whatever else Keycloak's realm holds.
auth-emails:
content: ${AUTH_ALLOWED_EMAILS:?AUTH_ALLOWED_EMAILS is not set in .env}
networks:
proxy:
name: proxy
+89 -6
View File
@@ -3,25 +3,37 @@
# Lives next to compose.yaml on VM103 (/opt/homelab/compose/homelab-upgrade). See docs/DEPLOY.md.
#
# ./update.sh deploy the tag from .env (TAG=..., default "latest")
# ./update.sh <tag> deploy <tag> (a branch name or a commit sha) and remember it in .env
# ./update.sh --force skip the "a workflow run is in the middle of a step" guard
# ./update.sh <tag> deploy <tag> (a release tag, a branch name or a commit sha) and remember it in .env
# ./update.sh --force skip the "a workflow run is in the middle of a step" guard and a failed database copy
# ./update.sh --install first put this script's compose.yaml and update.sh into DEPLOY_DIR: the deploy workflow
# runs the copy inside the new image (/app/deploy) against the VM's directory
set -euo pipefail
cd "$(dirname "$(readlink -f "$0")")"
script_dir="$(dirname "$(readlink -f "$0")")"
cd "${DEPLOY_DIR:-$script_dir}"
image_repo=git.mariuszpacek.pl/mariusz.pacek/homelab-upgrade
healthy_timeout=${HEALTHY_TIMEOUT:-90}
backups_kept=${BACKUPS_KEPT:-5}
images_kept=${IMAGES_KEPT:-3}
tag_arg=""
force=0
install=0
for arg in "$@"; do
case "$arg" in
--force) force=1 ;;
--install) install=1 ;;
-*) echo "unknown option: $arg" >&2; exit 2 ;;
*) tag_arg="$arg" ;;
esac
done
if ((install == 1)) && [[ "$(pwd -P)" == "$(readlink -f "$script_dir")" ]]; then
echo "--install needs DEPLOY_DIR set to another directory: a script cannot replace itself while it runs" >&2
exit 2
fi
env_tag() {
[[ -f .env ]] && sed -n 's/^TAG=//p' .env | tail -n 1 || true
}
@@ -42,6 +54,59 @@ step_in_progress() {
esac
}
# A consistent online copy of the database through SQLite's backup API, taken by the running server (it owns /data);
# the newest $backups_kept copies stay.
backup_database() {
docker compose exec -T server node -e "
const { DatabaseSync, backup } = require('node:sqlite');
const { readdirSync, rmSync } = require('node:fs');
const prefix = 'queue.sqlite.before-deploy-';
const name = prefix + new Date().toISOString().replace(/[-:]/g, '').replace(/[.][0-9]+Z\$/, 'Z');
const db = new DatabaseSync('/data/queue.sqlite', { readOnly: true });
backup(db, '/data/' + name).then(() => {
db.close();
for (const old of readdirSync('/data').filter((file) => file.startsWith(prefix)).sort().reverse().slice($backups_kept)) rmSync('/data/' + old);
console.log('database copy: /data/' + name);
}, (error) => { console.error(error.message); process.exit(1); });
"
}
# The new compose.yaml and update.sh come from the image being deployed; the old compose file is kept, so a version
# that does not come up goes back together with the compose file it ran with.
install_files() {
cp -p compose.yaml compose.yaml.previous
install -m 600 "$script_dir/compose.yaml" compose.yaml
install -m 755 "$script_dir/update.sh" update.sh
if cmp -s compose.yaml.previous compose.yaml; then
echo "compose.yaml unchanged"
else
echo "compose.yaml changed:"
diff -u compose.yaml.previous compose.yaml || true
fi
}
restore_compose() {
if ((install == 1)) && [[ -f compose.yaml.previous ]]; then
echo "restoring the previous compose.yaml" >&2
cp -p compose.yaml.previous compose.yaml
fi
}
# Each CI build and each deploy leaves an image of this app on the VM (over a gigabyte each), and a full disk stops
# every app on it. Kept: the one running, the previous one (to go back) and the newest $images_kept; a failure here
# never fails the deploy.
prune_images() {
local running id count=0
running=$(docker inspect --format '{{.Image}}' "$(service_container server)" 2>/dev/null || true)
# `docker images` lists the newest first; an image with several tags appears once per tag.
for id in $(docker images --no-trunc --format '{{.ID}}' "$image_repo" | awk '!seen[$0]++'); do
count=$((count + 1))
[[ "$id" == "$running" || "$id" == "$previous_image" ]] && continue
((count <= images_kept)) && continue
docker image rm -f "$id" >/dev/null 2>&1 || true
done
}
wait_until_up() {
local deadline=$((SECONDS + healthy_timeout)) status
while ((SECONDS < deadline)); do
@@ -50,9 +115,10 @@ wait_until_up() {
sleep 3
done
[[ "$status" == healthy ]] || return 1
sleep 5 # the driver and the socket proxy have no healthcheck: they must still be running, and not restarting, after a few seconds
sleep 5 # services without a healthcheck must still be running, and not restarting, after a few seconds
local service
for service in driver docker-proxy executor model-claude model-codex function-runtime; do
for service in $(docker compose config --services); do
[[ "$service" == server ]] && continue
[[ "$(docker inspect --format '{{.State.Status}} {{.RestartCount}}' "$(service_container "$service")" 2>/dev/null)" == "running 0" ]] || return 1
done
# The executor has a healthcheck of its own; give it the rest of the time to pass it.
@@ -71,10 +137,25 @@ if ((force == 0)) && [[ -n "$(service_container server)" ]] && step_in_progress;
exit 3
fi
if [[ -n "$(service_container server)" ]] && backup_database; then
:
elif ((force == 0)); then
echo "could not copy the database (is the server running?); fix it or use --force" >&2
exit 5
else
echo "warning: no database copy (--force)" >&2
fi
previous_image=$(docker inspect --format '{{.Image}}' "$(service_container server)" 2>/dev/null || true)
((install == 1)) && install_files
echo "pulling $image_repo:$tag"
TAG="$tag" docker compose pull
if ! TAG="$tag" docker compose pull; then
echo "could not pull $image_repo:$tag; nothing was restarted" >&2
restore_compose
exit 1
fi
TAG="$tag" docker compose up -d --remove-orphans
if wait_until_up; then
@@ -84,12 +165,14 @@ if wait_until_up; then
{ grep -v '^TAG=' .env || true; printf 'TAG=%s\n' "$tag"; } > .env.new && chmod --reference=.env .env.new && mv .env.new .env
fi
revision=$(docker inspect --format '{{index .Config.Labels "org.opencontainers.image.revision"}}' "$(service_container server)")
prune_images
echo "ok: $image_repo:$tag revision ${revision:-unknown}"
exit 0
fi
echo "new version did not come up healthy" >&2
docker compose logs --tail 30 >&2 || true
restore_compose
if [[ -n "$previous_image" ]]; then
echo "rolling back to $previous_image" >&2
docker tag "$previous_image" "$image_repo:rollback"
+44 -9
View File
@@ -5,7 +5,7 @@ Stan na 2026-09-19: na VM103 działa łańcuch prawdziwej aktualizacji przez sid
| | |
|---|---|
| Adres | https://upgrade.mariuszpacek.pl (traefik, `web-secured`, certyfikat `http`, wildcard DNS na 192.168.0.139) |
| Dostęp | tylko LAN: middleware `ipwhitelist` `192.168.0.1/24,10.0.0.0/16`; **appka nie ma logowania** |
| Dostęp | z LAN i z internetu, **tylko po zalogowaniu Keycloakiem** (realm `home`, usługa `auth` = oauth2-proxy jako forwardAuth Traefika; sekcja „Logowanie”) |
| Manifest | `/opt/homelab/compose/homelab-upgrade/compose.yaml` (kopia `deploy/compose.yaml`) |
| Obraz | `git.mariuszpacek.pl/mariusz.pacek/homelab-upgrade:<tag>` z rejestru Gitei, budowany przez Gitea Actions (etykieta `org.opencontainers.image.revision` = sha commita) |
| Dashboard | kafelek „Upgrade” w grupie Infra na https://dash.mariuszpacek.pl z etykiet `homepage.*` na `server` (homepage czyta je przez swoje połączenie z Dockerem; jego pliki nie są zmieniane) |
@@ -21,20 +21,55 @@ Port 8080 na hoście VM jest zajęty przez inną usługę, dlatego appka nie pub
## Aktualizacja
Obieg: `git push` → Gitea Actions (`.gitea/workflows/ci.yaml`): `npm run check`, a na `master` i `foundations` także build i push obrazu do rejestru Gitei → na VM103 jedna komenda:
Obieg: `git push` → Gitea Actions (`.gitea/workflows/ci.yaml`): `npm run check`, a na `master`, `foundations` i tagach wydań `v…` także build i push obrazu do rejestru Gitei → wdrożenie **tylko ręcznie**, przyciskiem albo przez API. Arcane nie zarządza tym projektem.
Wydanie: tag `v<rok>.<miesiąc>.<numer>` (np. `v2026.10.1`) na commicie z `master`, najlepiej jako release w Gitei; CI buduje obraz `:v2026.10.1` (i `:<sha>`). Stopka pokazuje wtedy `v2026.10.1 · build N`.
Wdrożenie: Gitea → repo → Actions → **deploy** → *Run workflow* (gałąź `master`), pole `version`: tag wydania, pełny sha albo `latest` (ostatni `master`); `force` tylko świadomie. To samo przez API/MCP: `dispatch_workflow` z `workflow_id: deploy.yaml`, `ref: master`, `inputs: { version, force }`; wynik i log w runie.
Workflow (`.gitea/workflows/deploy.yaml`) działa tylko z definicji na `master`: sprawdza format wersji, pobiera obraz i uruchamia **skrypt z tego obrazu** (`/app/deploy/update.sh --install`) na katalogu `/opt/homelab/compose/homelab-upgrade` przez socket Dockera. `update.sh`:
1. odmawia (kod 3), gdy instancja silnika jest w stanie `running` (run na bramce lub w kolejce restart przeżywa, przerwany węzeł wznawia się w UI przyciskiem „Retry the step”);
2. robi spójną kopię bazy przez działający serwer (`/data/queue.sqlite.before-deploy-<czas>`, zostaje 5 najnowszych); bez kopii odmawia (kod 5);
3. z `--install` wgrywa `compose.yaml` i `update.sh` z obrazu (poprzedni compose zostaje jako `compose.yaml.previous`, diff w logu);
4. `docker compose pull` (gdy obrazu nie ma: przywraca compose i kończy bez restartu) i `up -d`;
5. czeka do 90 s na `healthy` serwera i executora oraz na stabilne pozostałe usługi; gdy nowa wersja nie wstaje, wraca do poprzedniego obrazu (lokalny tag `:rollback`) i poprzedniego `compose.yaml`, nie zmienia `.env` i kończy się kodem 1.
`--force` pomija krok 1 i pozwala na brak kopii z kroku 2. Wycofanie to ponowne wdrożenie starszej wersji tym samym przyciskiem. Stare obrazy zostają na VM.
Awaryjnie, gdy Gitea lub runner nie działa, ten sam skrypt ręcznie na VM103:
```bash
cd /opt/homelab/compose/homelab-upgrade
./update.sh # tag z .env (domyślnie latest)
./update.sh foundations # inny tag (gałąź albo pełny sha) i zapamiętanie go w .env
./update.sh --force # pomija blokadę „trwa krok / węzeł runu”
./update.sh v2026.10.1 # inna wersja (tag, gałąź albo pełny sha) i zapamiętanie jej w .env
./update.sh --force # pomija blokadę „trwa krok / węzeł runu” i brak kopii bazy
```
`update.sh` (kopia `deploy/update.sh`): odmawia restartu, gdy driver ma krok starego łańcucha w stanie `running` albo instancja nowego silnika jest w stanie `running` (kod 3; run czekający na bramce lub w kolejce restart przeżywa, a przerwany węzeł wznawia się w UI przyciskiem „Retry the step”); robi `docker compose pull` i `up -d`; czeka do 90 s na `healthy` serwera i sprawdza, że driver i `docker-proxy` działają i się nie restartowały. Gdy nowa wersja nie wstaje, wraca do poprzedniego obrazu (lokalny tag `:rollback`), nie zmienia `.env` i kończy się kodem 1. Stare obrazy zostają na VM, więc wycofanie do dowolnego commita to `./update.sh <sha>`.
Ręczny `update.sh` nie wgrywa `compose.yaml`; po zmianie `deploy/compose.yaml` wdrażaj przyciskiem albo skopiuj plik.
CI niczego nie wdraża: deploy jest świadomym krokiem na VM (na razie bez timera, bo restart w trakcie kroku byłby groźny dopiero po dodaniu prawdziwych handlerów).
Przy zmianie schematu bazy nowa wersja migruje bazę przy starcie (migracje są transakcyjne; nowszej bazy niż kod odmawia otwarcia). Wycofanie do starszego obrazu po zmianie schematu nie zadziała, bo starszy kod odmówi otwarcia nowszej bazy: wtedy przywraca się kopię `queue.sqlite.before-deploy-*` z kroku 2.
Przy zmianie schematu bazy nowa wersja migruje bazę przy starcie (migracje są transakcyjne; nowszej bazy niż kod odmawia otwarcia). Wycofanie do starszego obrazu po zmianie schematu nie zadziała, bo starszy kod odmówi otwarcia nowszej bazy: przed takim wdrożeniem zrób kopię `queue.sqlite*`.
### Logowanie (Keycloak)
Usługa `auth` (oauth2-proxy, `deploy/compose.yaml`) stoi przed aplikacją: Traefik pyta ją o każde zapytanie (forwardAuth); bez sesji przeglądarka idzie do `https://login.mariuszpacek.pl/realms/home`, po powrocie wpuszczane są tylko adresy z `AUTH_ALLOWED_EMAILS`. Sesja trwa 7 dni (odświeżana co godzinę), wylogowanie: `https://upgrade.mariuszpacek.pl/oauth2/sign_out`. Publiczne zostają tylko `manifest.webmanifest` i `icon.svg`.
Jednorazowo w Keycloaku (realm `home`):
1. Clients → Create client: typ OpenID Connect, Client ID `homelab-upgrade`; Client authentication **on**, Standard flow **on**, Direct access grants off.
2. Valid redirect URIs `https://upgrade.mariuszpacek.pl/oauth2/callback`, Web origins `https://upgrade.mariuszpacek.pl`.
3. Client scopes → `homelab-upgrade-dedicated` → Add mapper → By configuration → **Audience**: Included Client Audience `homelab-upgrade`, Add to access token on (oauth2-proxy sprawdza audience).
4. Credentials → skopiuj Client secret.
W `/opt/homelab/compose/homelab-upgrade/.env` na VM103 (root, 600):
```bash
AUTH_CLIENT_SECRET=<client secret z Keycloaka>
AUTH_COOKIE_SECRET=<openssl rand -base64 32 | tr -- '+/' '-_'>
AUTH_ALLOWED_EMAILS=<adres e-mail konta w Keycloaku>
```
Bez tych trzech wartości `docker compose` odmawia (wdrożenie kończy się bez restartu, `update.sh` przywraca poprzedni `compose.yaml`).
### Jednorazowa konfiguracja
@@ -43,7 +78,7 @@ Przy zmianie schematu bazy nowa wersja migruje bazę przy starcie (migracje są
3. **Sekret repo** `REGISTRY_TOKEN` (repo → Settings → Actions → Secrets): token `write:package`.
4. **Runner** (`deploy/runner/`, na VM103 w `/opt/homelab/compose/gitea-runner/`; dane w `/srv/appdata/gitea-runner/data`). Token rejestracyjny: repo → Settings → Actions → Runners → Create new runner (token na poziomie repo zamiast całej instancji). Runner ma `docker.sock`, czyli praktycznie root na VM: rejestruj go tylko dla tego repo.
5. **`docker login`** na VM103: pomijamy, bo pull jest anonimowy. Gdyby pakiet stał się prywatny: `docker login git.mariuszpacek.pl -u mariusz.pacek` tokenem `read:package`.
6. **Przełączenie z lokalnego obrazu**: skopiuj `deploy/compose.yaml` i `deploy/update.sh` do `/opt/homelab/compose/homelab-upgrade/`, po pierwszym zielonym buildzie na `foundations` uruchom `./update.sh foundations`. Kopie trzeba odświeżyć ręcznie, gdy te dwa pliki się zmienią.
6. **Przełączenie z lokalnego obrazu**: skopiuj `deploy/compose.yaml` i `deploy/update.sh` do `/opt/homelab/compose/homelab-upgrade/`, po pierwszym zielonym buildzie uruchom `./update.sh <sha>`. Później oba pliki wgrywa workflow deploy.
Ograniczenia, które warto znać: job dostaje repo z `http://192.168.0.139:13080` (port Gitei na VM), bo whitelist traefika nie obejmuje sieci jobów; `docker push` idzie z demona VM, więc źródłowy adres to 192.168.0.139 z whitelisty Gitei.
@@ -162,7 +197,7 @@ Arcane v2.13.1 działa pod `https://arcane.mariuszpacek.pl`. Projekty używają
Początkowe synchronizacje 18 projektów zachowały SHA plików env i nie zmieniły startów kontenerów; Arcane może utworzyć pusty `.env`. Źródła nie wymagały restartu podczas importu. Test Jackett przez API executora potwierdził ścieżkę komentarz → commit `47c2b7f` → push → udany sync Arcane i HTTP 301; rollback executora wykonał `git revert`, przywrócił pierwotny SHA i zakończył się powodzeniem.
`config/workflow-schedules.json` rozkłada starty w strefie `Europe/Warsaw` (czas polski): poniedziałek Bazarr 19:00, Jackett 21:00; wtorek Radarr 19:00, Sonarr 21:00; środa Deluge 19:00, Ombi 21:00; czwartek Homepage 19:00, OrcaSlicer 21:00; piątek Gitea 18:00, baza 19:00, Keycloak 20:00, baza 22:00; sobota Home Assistant 18:00, Nextcloud 20:00, baza 22:00, Redis 23:00; niedziela Grafana 18:00, Jellyfin 20:00. InfluxDB i Dockge są wyłączone.
`config/workflow-schedules.json` rozkłada starty w strefie `Europe/Warsaw` (czas polski): poniedziałek Bazarr 19:00, Jackett 21:00; wtorek Radarr 19:00, Sonarr 21:00; środa Deluge 19:00, Ombi 21:00; czwartek Homepage 19:00, OrcaSlicer 21:00; piątek Gitea 18:00, baza 19:00, Keycloak 20:00, baza 22:00; sobota Home Assistant 18:00, Nextcloud 20:00, baza 22:00, Redis 23:00; niedziela Trilium 16:00, Grafana 18:00, Jellyfin 20:00. InfluxDB i Dockge są wyłączone.
Konfiguracja prywatna `/srv/appdata/homelab-upgrade/git-config/deployments.json` jest montowana jako `/git-config` tylko do odczytu, a `GIT_DEPLOYMENTS_FILE=/git-config/deployments.json`. Schemat v1: `repoPath=/git/repo`, `branch=master`, `sshKeyPath=/git-ssh/id_ed25519`, `knownHostsPath=/git-ssh/known_hosts`; projekty mają tryb `direct` albo `git`, `webhookUrl` i opcjonalny `redeployWebhookUrl`. Executor ma checkout do zapisu, katalog `git-ssh` tylko do odczytu oraz sieci `executor` i `default`; git i OpenSSH są częścią obrazu. Klucz executora z prawem zapisu znajduje się w `/srv/appdata/homelab-upgrade/git-ssh/id_ed25519`, klucz Arcane tylko do odczytu w `/srv/appdata/arcane/git-ssh/id_ed25519`. `known_hosts` przypina trzy publiczne algorytmy Gitei porównane z plikiem `.pub` kontenera. Nie umieszczaj tokenów ani webhook URL zawierających sekrety w dokumentacji, repo ani logach.
+24
View File
@@ -156,3 +156,27 @@ Decyzje: zostajemy przy własnym silniku (BPMN nie daje gwarancji projektu: jede
- **8** widoki na telefonie: nawigacja się zawija, tabele chowają kolumny drugorzędne poniżej 720 px, przyciski decyzji na pełną szerokość, niższy graf.
- **9** Arcane i repo compose: 9a–9e wykonane. Prywatne repo `homelab-compose` na `master` zawiera 19 projektów i archiwum Dockge; Arcane v2.13.1 ma włączony autosync co 5 minut dla 18 projektów, a executor obsługuje publikację i rollback git oraz `hl write/up`. Dockge zatrzymano, a jego compose/appdata i 29 starych kopii przeniesiono do `/srv/appdata/to_delete/20260930`; 20 wpisów harmonogramu zapisanych i sprawdzonych przez API.
- Podglądy harmonogramów: `nextCronRuns` oblicza kandydatów według godziny UTC z jednym formatterem Intl zamiast tworzyć formatter dla każdego kandydata; zachowane są obsługa DST, rozdzielczość 30 minut i strefy takie jak Asia/Kathmandu. Hotfix wdrożono na lokalnym obrazie; przejście na obraz CI wymaga `update.sh`. `npm run check`: 104 pliki, 1240 testów PASS.
### Faza G — nowoczesny, graficzny frontend (zatwierdzona przez właściciela 2026-09-30)
Decyzje: sidebar + karty, ciemny motyw domyślny z przełącznikiem; wykresy w ECharts (rejestrowane tylko potrzebne części, osobny chunk ładowany leniwie); własne okno potwierdzeń, panel boczny i toasty zamiast `window.confirm`; bez zmian w backendzie — agregacje w przeglądarce z istniejących endpointów (`src/web/stats.ts`), budowniczki opcji wykresów jako czyste funkcje (`src/web/charts/options.ts`), kolory z tokenów CSS. Gałąź `frontend-refresh`.
- **G0** powłoka: sidebar z ikonami (na telefonie górny pasek), tokeny powierzchni/cieni, `Card`, `StatTile`, `Icon`, przełącznik motywu.
- **G1** okna: `DialogProvider` (`useConfirm`, `useToast`), `Drawer`; podgląd runu w panelu z listy Runs.
- **G2** ECharts i agregacje: runy dziennie wg statusu, wskaźnik sukcesu z okresem poprzednim, czasy trwania, koszt dziennie wg modelu; paleta kategorii sprawdzona pod daltonizm.
- **G3** Overview: kafle KPI, aktywność 30 dni, harmonogramy na 7 dni.
- **G4** Apps: karty projektów (wersje, 10 ostatnich runów, następny run), przełącznik karty/tabela; strona aplikacji z wykresem czasu trwania runów.
- **G5** strona runu: Gantt kroków (klik zaznacza węzeł), historia w oknie.
- **G6** Models: koszt dziennie wg modelu, udział modeli, wyniki wg rodzaju pytania; edycja profilu w panelu bocznym.
- **G7** porządki stylów, dokumentacja. Przegląd na żywo w przeglądarce jeszcze nie zrobiony (brak przeglądarki na hoście).
### Faza H — wygoda na co dzień (zatwierdzona przez właściciela 2026-09-30)
Decyzje: logowanie Keycloak dla wszystkich (oauth2-proxy jako forwardAuth Traefika) i dostęp spoza LAN; cisza nocna 01:00–07:30 (wieczorne awarie pushują od razu), w ciszy nic nie dzwoni, bramki i błędy trafiają do porannego raportu o 7:30; godziny zmienia się w UI. Etapy wdrażane przyciskiem `deploy` bez osobnej zgody.
- **H1** stan „tu i teraz”: driver co minutę zbiera `docker ps` kontenerów compose (przez proxy tylko do odczytu) i rekord vzdump, zapisuje migawkę w `settings`; Overview pokazuje zdrowie aplikacji i wiek kopii VM.
- **H2** historia wersji aplikacji (z runów: z czego na co, którym runem, naprawy i rollbacki) i oznaczenie aplikacji, które często wymagają uwagi.
- **H3** ustawienia powiadomień w UI, cisza nocna i poranny raport (push + karta „Ostatnia noc” na Overview).
- **H4** decyzja z telefonu: strona runu z bramką na górze i przyciskami przyklejonymi do dołu ekranu; push otwiera ją bezpośrednio.
- **H5** czasy względne, szybkie wyszukiwanie `Ctrl+K`, dry run wszystkich aplikacji z dostępną aktualizacją.
- **H6** logowanie Keycloak i zdjęcie ograniczenia do LAN.
+26
View File
@@ -10,6 +10,7 @@
"dependencies": {
"@fastify/static": "^10.1.4",
"cytoscape": "^3.34.3",
"echarts": "^6.1.0",
"fastify": "^5.12.5",
"monaco-editor": "^0.56.0",
"preact": "^10.29.8",
@@ -2337,6 +2338,16 @@
"url": "https://github.com/fb55/domutils?sponsor=1"
}
},
"node_modules/echarts": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/echarts/-/echarts-6.1.0.tgz",
"integrity": "sha512-q0yaFPggC9FUdsWH4blavRWFmxdrIodbkoKNAjJudAI6CA9gNPxHtV2RcZNEepZVlk4yvBYkOkbk6HIVpIyHZA==",
"license": "Apache-2.0",
"dependencies": {
"tslib": "2.3.0",
"zrender": "6.1.0"
}
},
"node_modules/electron-to-chromium": {
"version": "1.5.432",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.432.tgz",
@@ -4308,6 +4319,12 @@
"typescript": ">=4.8.4"
}
},
"node_modules/tslib": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.0.tgz",
"integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==",
"license": "0BSD"
},
"node_modules/type-check": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
@@ -4759,6 +4776,15 @@
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
},
"node_modules/zrender": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/zrender/-/zrender-6.1.0.tgz",
"integrity": "sha512-oEGMDB6pOP2S6OwRR4PdVv610zrjnA3Bh+JnSG12fYJlBKjtNAoEb5fSUoCOOINlH96I2fU38/A2UpRKs67xYQ==",
"license": "BSD-3-Clause",
"dependencies": {
"tslib": "2.3.0"
}
}
}
}
+1
View File
@@ -23,6 +23,7 @@
"dependencies": {
"@fastify/static": "^10.1.4",
"cytoscape": "^3.34.3",
"echarts": "^6.1.0",
"fastify": "^5.12.5",
"monaco-editor": "^0.56.0",
"preact": "^10.29.8",
+43
View File
@@ -0,0 +1,43 @@
import { describe, expect, it } from 'vitest';
import { isQuiet, sendDigestIfDue } from './digest.ts';
import { createSqliteNotificationSettingsStore } from '../infra/sqlite/notification-settings-store.ts';
import { openDatabase } from '../infra/sqlite/open.ts';
import { FakeClock } from '../testing/fake-clock.ts';
import { RecordingNotifier } from '../testing/fakes.ts';
// 05:30 UTC is 07:30 in Warsaw (summer time).
function setup(start: string) {
const clock = new FakeClock(start);
const settings = createSqliteNotificationSettingsStore(openDatabase(':memory:'));
const notifier = new RecordingNotifier();
const failed = { app: 'bazarr', workflowId: 'update-bazarr', status: 'failed', mode: 'real' as const, current: '1', candidate: '2', running: '1', outcome: 'Verify failed', step: null, updatedAt: '2026-09-30T01:00:00.000Z' };
return { clock, settings, notifier, deps: { clock, timeZone: 'Europe/Warsaw', settings, notifier, runs: () => [failed], down: () => ['grafana'] } };
}
describe('sendDigestIfDue', () => {
it('sends the report once a day from 07:30 on, with what happened since the last one', async () => {
const { clock, notifier, settings, deps } = setup('2026-09-30T05:29:00.000Z');
expect(await sendDigestIfDue(deps)).toBe(false);
await clock.advance(60_000);
expect(await sendDigestIfDue(deps)).toBe(true);
expect(await sendDigestIfDue(deps)).toBe(false);
expect(notifier.sent).toEqual([
{ runId: 'digest-2026-09-30', title: 'Night report: 2 need you', detail: 'Failed: bazarr (Verify failed)\nDown now: grafana', link: '#/overview' },
]);
expect(settings.lastDigest()).toEqual({ day: '2026-09-30', at: '2026-09-30T05:30:00.000Z' });
});
it('tries again next time when the push fails', async () => {
const { notifier, settings, deps } = setup('2026-09-30T06:00:00.000Z');
notifier.failWith = new Error('HA down');
await expect(sendDigestIfDue(deps)).rejects.toThrow('HA down');
expect(settings.lastDigest()).toBeNull();
});
});
describe('isQuiet', () => {
it('follows the owner’s quiet hours in the schedules’ time zone', () => {
expect(isQuiet(setup('2026-09-29T23:30:00.000Z').deps)).toBe(true); // 01:30 in Warsaw
expect(isQuiet(setup('2026-09-29T20:30:00.000Z').deps)).toBe(false); // 22:30 in Warsaw
});
});
+51
View File
@@ -0,0 +1,51 @@
import { zonedMinute } from '../domain/cron.ts';
import { digestDue, inQuietHours, nightDigest } from '../domain/notification-settings.ts';
import type { DigestRun } from '../domain/notification-settings.ts';
import type { Clock, Notifier, NotificationSettingsStore } from '../domain/ports.ts';
export interface DigestDeps {
readonly clock: Clock;
/** The schedules' time zone: quiet hours and the report time are wall-clock there. */
readonly timeZone: string;
readonly settings: NotificationSettingsStore;
readonly runs: () => readonly DigestRun[];
/** Enabled apps whose container is down right now (the driver's latest look). */
readonly down: () => readonly string[];
readonly notifier: Notifier;
}
const DAY_MS = 86_400_000;
/** True while the owner's quiet hours hold pushes back. */
export function isQuiet(deps: Pick<DigestDeps, 'clock' | 'timeZone' | 'settings'>): boolean {
return inQuietHours(deps.settings.get(), zonedMinute(deps.clock.now(), deps.timeZone));
}
/** Sends the morning report when it is due; true when it went out. A failed push is not recorded, so it is tried again. */
export async function sendDigestIfDue(deps: DigestDeps): Promise<boolean> {
const now = deps.clock.now();
const at = zonedMinute(now, deps.timeZone);
const last = deps.settings.lastDigest();
if (!digestDue(deps.settings.get(), at, last?.day ?? null)) return false;
const day = at.key.slice(0, 10);
const digest = nightDigest({ runs: deps.runs(), since: last?.at ?? new Date(now.getTime() - DAY_MS).toISOString(), down: deps.down() });
await deps.notifier.notify({ runId: `digest-${day}`, title: digest.title, detail: digest.detail, link: '#/overview' });
deps.settings.recordDigest(day, now.toISOString());
return true;
}
/** Checks once per `intervalMs` whether the report is due, until `signal` aborts. */
export async function runDigestLoop(deps: DigestDeps & { readonly intervalMs: number; readonly signal: AbortSignal; readonly onError: (cause: unknown) => void }): Promise<void> {
while (!deps.signal.aborted) {
try {
await sendDigestIfDue(deps);
} catch (cause) {
deps.onError(cause);
}
try {
await deps.clock.sleep(deps.intervalMs, deps.signal);
} catch {
return; // stopped
}
}
}
+58
View File
@@ -0,0 +1,58 @@
import { describe, expect, it } from 'vitest';
import { collectHostStatus, runHostStatusLoop } from './host-status.ts';
import type { HostStatus } from '../domain/host-status.ts';
import { FakeCommandRunner } from '../testing/fakes.ts';
import { FakeClock } from '../testing/fake-clock.ts';
const PS = ['docker', 'ps', '-a', '--filter', 'label=com.docker.compose.project', '--format', '{{json .}}'].join(' ');
const APPS = [{ app: 'jellyfin', project: 'jellyfin', service: 'jellyfin', enabled: true }];
const running = JSON.stringify({ Labels: 'com.docker.compose.project=jellyfin,com.docker.compose.service=jellyfin', State: 'running', Status: 'Up 1 hour (healthy)' });
function deps(runner: FakeCommandRunner, clock: FakeClock, backup: string | null) {
return { runner, clock, apps: () => APPS, readBackup: () => Promise.resolve(backup), backupVmid: 103 };
}
describe('collectHostStatus', () => {
it('lists every app with its container and the newest VM backup', async () => {
const clock = new FakeClock('2026-09-30T06:00:00.000Z');
const runner = new FakeCommandRunner().on(PS, { exitCode: 0, stdout: running, stderr: '' });
const record = JSON.stringify({ vmid: 103, storage: 'pbs', latest: { volid: 'pbs:backup/vm/103/2026-09-30T00:00:00Z', ctime: Date.parse('2026-09-30T00:00:00Z') / 1000 }, checked_at: '2026-09-30T05:00:00Z' });
expect(await collectHostStatus(deps(runner, clock, record))).toEqual({
checkedAt: '2026-09-30T06:00:00.000Z',
apps: [{ app: 'jellyfin', enabled: true, state: 'running', health: 'healthy' }],
backup: { ok: true, createdAt: '2026-09-30T00:00:00.000Z', ageHours: 6, volid: 'pbs:backup/vm/103/2026-09-30T00:00:00Z' },
error: null,
});
});
it('says why the containers are unknown and keeps a bad backup record as a finding', async () => {
const clock = new FakeClock();
const runner = new FakeCommandRunner().on(PS, { exitCode: 1, stdout: '', stderr: 'proxy down' });
const status = await collectHostStatus(deps(runner, clock, '{"vmid": "x"}'));
expect(status.apps).toEqual([]);
expect(status.error).toContain('docker ps');
expect(status.backup?.ok).toBe(false);
});
it('has no backup entry without a record', async () => {
const runner = new FakeCommandRunner().on(PS, { exitCode: 0, stdout: '', stderr: '' });
expect((await collectHostStatus(deps(runner, new FakeClock(), null))).backup).toBeNull();
});
});
describe('runHostStatusLoop', () => {
it('stores a fresh status every interval until stopped', async () => {
const clock = new FakeClock();
const runner = new FakeCommandRunner().on(PS, { exitCode: 0, stdout: running, stderr: '' });
const stored: HostStatus[] = [];
const stop = new AbortController();
const loop = runHostStatusLoop({ ...deps(runner, clock, null), store: { put: (status) => stored.push(status) }, intervalMs: 60_000, signal: stop.signal, onError: () => undefined });
await new Promise((resolve) => setTimeout(resolve, 0));
expect(stored).toHaveLength(1);
await clock.advance(60_000);
await new Promise((resolve) => setTimeout(resolve, 0));
expect(stored).toHaveLength(2);
stop.abort();
await loop;
});
});
+66
View File
@@ -0,0 +1,66 @@
import { backupVerdict, HostBackupRecord } from '../domain/backup-check.ts';
import { appHealth, parseLabeledDockerPs } from '../domain/host-status.ts';
import type { BackupStatus, HostStatus } from '../domain/host-status.ts';
import type { Clock, CommandRunner } from '../domain/ports.ts';
import { describeError } from '../domain/mask.ts';
export interface HostStatusDeps {
/** Commands see only a read-only socket proxy: `docker ps` is a GET. */
readonly runner: CommandRunner;
readonly clock: Clock;
readonly apps: () => readonly { readonly app: string; readonly project: string; readonly service: string; readonly enabled: boolean }[];
/** The Proxmox timer's record of the newest vzdump; null when there is none. */
readonly readBackup: () => Promise<string | null>;
readonly backupVmid: number;
}
const PS_ARGS = ['ps', '-a', '--filter', 'label=com.docker.compose.project', '--format', '{{json .}}'];
async function backupStatus(deps: HostStatusDeps): Promise<BackupStatus | null> {
try {
const raw = await deps.readBackup();
if (raw === null) return null;
const record = HostBackupRecord.safeParse(JSON.parse(raw));
if (!record.success) return { ok: false, detail: 'the host backup record is not readable' };
const verdict = backupVerdict(record.data, { vmid: deps.backupVmid, now: deps.clock.now() });
return verdict.ok ? { ok: true, createdAt: verdict.result.created_at, ageHours: verdict.result.age_hours, volid: verdict.result.volid } : { ok: false, detail: verdict.detail };
} catch (cause) {
return { ok: false, detail: `the host backup record is not readable: ${describeError(cause)}` };
}
}
/** One look at VM103: the containers of every configured app and the newest VM backup. Never throws. */
export async function collectHostStatus(deps: HostStatusDeps, signal?: AbortSignal): Promise<HostStatus> {
const checkedAt = deps.clock.now().toISOString();
const backup = await backupStatus(deps);
try {
const ps = await deps.runner.run({ command: 'docker', args: PS_ARGS, ...(signal === undefined ? {} : { signal }) });
if (ps.exitCode !== 0) return { checkedAt, apps: [], backup, error: `docker ps exited with ${String(ps.exitCode)}: ${ps.stderr.trim().slice(0, 200)}` };
return { checkedAt, apps: appHealth(parseLabeledDockerPs(ps.stdout), deps.apps()), backup, error: null };
} catch (cause) {
return { checkedAt, apps: [], backup, error: `docker ps failed: ${describeError(cause)}` };
}
}
/** Looks again every `intervalMs` and stores what it saw, until `signal` aborts. */
export async function runHostStatusLoop(
deps: HostStatusDeps & {
readonly store: { put(status: HostStatus): void };
readonly intervalMs: number;
readonly signal: AbortSignal;
readonly onError: (cause: unknown) => void;
},
): Promise<void> {
while (!deps.signal.aborted) {
try {
deps.store.put(await collectHostStatus(deps, deps.signal));
} catch (cause) {
deps.onError(cause);
}
try {
await deps.clock.sleep(deps.intervalMs, deps.signal);
} catch {
return; // stopped
}
}
}
+7
View File
@@ -82,4 +82,11 @@ describe('notifyRun', () => {
expect(await notifyRun(deps, 'nope')).toBe(false);
expect(notifier.sent).toHaveLength(1);
});
it('holds the push in quiet hours', async () => {
const notifier = new RecordingNotifier();
const deps = { get: () => instance('failed'), report: () => report({}), notifier, quiet: () => true };
expect(await notifyRun(deps, 'run-1')).toBe(false);
expect(notifier.sent).toHaveLength(0);
});
});
+9 -3
View File
@@ -61,15 +61,21 @@ export function runNotification(instance: WorkflowInstance, report: RunReport):
}
}
/** Pushes the run's news, if any; true when a push went out. A failed push is the caller's to log. */
/** Pushes the run's news, if any, outside quiet hours; true when a push went out. A failed push is the caller's to log. */
export async function notifyRun(
deps: { readonly get: (instanceId: string) => WorkflowInstance | null; readonly report: (instance: WorkflowInstance) => RunReport; readonly notifier: Notifier },
deps: {
readonly get: (instanceId: string) => WorkflowInstance | null;
readonly report: (instance: WorkflowInstance) => RunReport;
readonly notifier: Notifier;
/** True in the owner's quiet hours: nothing rings, the morning report covers the run (app/digest.ts). */
readonly quiet?: () => boolean;
},
instanceId: string,
): Promise<boolean> {
const instance = deps.get(instanceId);
if (instance === null) return false;
const notification = runNotification(instance, deps.report(instance));
if (notification === null) return false;
if (notification === null || deps.quiet?.() === true) return false;
await deps.notifier.notify(notification);
return true;
}
+64
View File
@@ -0,0 +1,64 @@
import { describe, expect, it } from 'vitest';
import { appHealth, downApps, parseLabeledDockerPs, worstHealth } from './host-status.ts';
import type { LabeledContainer } from './host-status.ts';
const row = (project: string, service: string, state: string, status: string) =>
JSON.stringify({ Labels: `com.docker.compose.project=${project},traefik.rule=Host(\`a,b\`),com.docker.compose.service=${service}`, State: state, Status: status, Image: 'img' });
describe('parseLabeledDockerPs', () => {
it('reads the compose project and service from the labels, and health from the status', () => {
const rows = parseLabeledDockerPs([row('grafana', 'grafana', 'running', 'Up 2 days (healthy)'), row('grafana', 'influxdb', 'restarting', 'Restarting (1) 3 seconds ago')].join('\n'));
expect(rows).toEqual([
{ project: 'grafana', service: 'grafana', state: 'running', health: 'healthy' },
{ project: 'grafana', service: 'influxdb', state: 'restarting', health: null },
]);
});
it('skips containers that compose did not start', () => {
expect(parseLabeledDockerPs(JSON.stringify({ Labels: 'other=1', State: 'running', Status: 'Up' }))).toEqual([]);
});
});
describe('appHealth', () => {
const apps = [
{ app: 'grafana', project: 'grafana', service: 'grafana', enabled: true },
{ app: 'grafana-influxdb', project: 'grafana', service: 'influxdb', enabled: false },
{ app: 'jellyfin', project: 'jellyfin', service: 'jellyfin', enabled: true },
{ app: 'ombi', project: 'ombi', service: 'ombi', enabled: true },
];
it('gives every app the state of its service: missing when no container, restarting before running, the worst health', () => {
const rows: LabeledContainer[] = [
{ project: 'grafana', service: 'grafana', state: 'running', health: 'healthy' },
{ project: 'grafana', service: 'influxdb', state: 'exited', health: null },
{ project: 'ombi', service: 'ombi', state: 'running', health: 'healthy' },
{ project: 'ombi', service: 'ombi', state: 'restarting', health: null },
];
expect(appHealth(rows, apps)).toEqual([
{ app: 'grafana', enabled: true, state: 'running', health: 'healthy' },
{ app: 'grafana-influxdb', enabled: false, state: 'exited', health: null },
{ app: 'jellyfin', enabled: true, state: 'missing', health: null },
{ app: 'ombi', enabled: true, state: 'restarting', health: 'healthy' },
]);
});
});
describe('downApps', () => {
it('names enabled apps that do not run or run unhealthy', () => {
const apps = [
{ app: 'a', enabled: true, state: 'running', health: null },
{ app: 'b', enabled: true, state: 'running', health: 'unhealthy' as const },
{ app: 'c', enabled: true, state: 'missing', health: null },
{ app: 'd', enabled: false, state: 'exited', health: null },
];
expect(downApps({ apps })).toEqual(['b', 'c']);
});
});
describe('worstHealth', () => {
it('orders unhealthy over starting over healthy over no check', () => {
expect(worstHealth([null, 'healthy', 'starting'])).toBe('starting');
expect(worstHealth(['healthy', 'unhealthy'])).toBe('unhealthy');
expect(worstHealth([null])).toBeNull();
});
});
+93
View File
@@ -0,0 +1,93 @@
import { z } from 'zod';
import { healthFromStatus } from './compose.ts';
// What runs on VM103 right now, as the driver last saw it: the containers of every configured app and the age of the
// newest VM backup. The driver writes it, the server shows it; nothing decides on it.
export const ContainerHealth = z.enum(['healthy', 'unhealthy', 'starting']);
export type ContainerHealth = z.infer<typeof ContainerHealth>;
export const AppHealth = z.object({
app: z.string(),
enabled: z.boolean(),
/** Docker's state of the service's container (`running`, `restarting`, `exited`…), or `missing` when there is none. */
state: z.string(),
health: ContainerHealth.nullable(),
});
export type AppHealth = z.infer<typeof AppHealth>;
export const BackupStatus = z.discriminatedUnion('ok', [
z.object({ ok: z.literal(true), createdAt: z.string(), ageHours: z.number(), volid: z.string() }),
z.object({ ok: z.literal(false), detail: z.string() }),
]);
export type BackupStatus = z.infer<typeof BackupStatus>;
export const HostStatus = z.object({
checkedAt: z.string(),
apps: z.array(AppHealth),
/** Null when the driver has no backup record to read. */
backup: BackupStatus.nullable(),
/** Why the containers could not be listed; the apps list is then empty. */
error: z.string().nullable(),
});
export type HostStatus = z.infer<typeof HostStatus>;
export interface LabeledContainer {
readonly project: string;
readonly service: string;
readonly state: string;
readonly health: ContainerHealth | null;
}
// Label values may contain commas (traefik rules), so each compose label is matched by its full name at an entry start.
const PROJECT_LABEL = /(?:^|,)com\.docker\.compose\.project=([^,]*)/;
const SERVICE_LABEL = /(?:^|,)com\.docker\.compose\.service=([^,]*)/;
const PsRow = z.object({ Labels: z.string().default(''), State: z.string().default('unknown'), Status: z.string().default('') });
/** Rows of `docker ps -a --format '{{json .}}'`, one JSON object per line; containers without compose labels are left out. */
export function parseLabeledDockerPs(raw: string): LabeledContainer[] {
const out: LabeledContainer[] = [];
for (const line of raw.split('\n')) {
if (line.trim() === '') continue;
const row = PsRow.parse(JSON.parse(line));
const project = PROJECT_LABEL.exec(row.Labels)?.[1];
const service = SERVICE_LABEL.exec(row.Labels)?.[1];
if (project === undefined || service === undefined) continue;
out.push({ project, service, state: row.State, health: healthFromStatus(row.Status) });
}
return out;
}
const HEALTH_RANK: Readonly<Record<ContainerHealth, number>> = { healthy: 1, starting: 2, unhealthy: 3 };
export function worstHealth(values: readonly (ContainerHealth | null)[]): ContainerHealth | null {
let worst: ContainerHealth | null = null;
for (const value of values) {
if (value !== null && (worst === null || HEALTH_RANK[value] > HEALTH_RANK[worst])) worst = value;
}
return worst;
}
// A restarting container outweighs a running one of the same service; otherwise the first container's state stands.
function worstState(states: readonly string[]): string {
if (states.length === 0) return 'missing';
if (states.includes('restarting')) return 'restarting';
return states.every((state) => state === 'running') ? 'running' : (states.find((state) => state !== 'running') ?? 'unknown');
}
/** Enabled apps whose container is not running, or runs unhealthy. */
export function downApps(status: Pick<HostStatus, 'apps'>): string[] {
return status.apps.filter((app) => app.enabled && (app.state !== 'running' || app.health === 'unhealthy')).map((app) => app.app);
}
/** Every configured app with the state of its compose service's containers, in the apps' order. */
export function appHealth(
containers: readonly LabeledContainer[],
apps: readonly { readonly app: string; readonly project: string; readonly service: string; readonly enabled: boolean }[],
): AppHealth[] {
return apps.map((app) => {
const own = containers.filter((container) => container.project === app.project && container.service === app.service);
return { app: app.app, enabled: app.enabled, state: worstState(own.map((container) => container.state)), health: worstHealth(own.map((container) => container.health)) };
});
}
+68
View File
@@ -0,0 +1,68 @@
import { describe, expect, it } from 'vitest';
import { DEFAULT_NOTIFICATION_SETTINGS, digestDue, inQuietHours, nightDigest } from './notification-settings.ts';
const at = (time: string, day = '2026-09-30') => ({ hour: Number(time.slice(0, 2)), minute: Number(time.slice(3)), key: `${day}T${time}` });
describe('inQuietHours', () => {
it('holds pushes from 01:00 to 07:30 by default, and not on the evening before', () => {
const s = DEFAULT_NOTIFICATION_SETTINGS;
expect(inQuietHours(s, at('00:59'))).toBe(false);
expect(inQuietHours(s, at('01:00'))).toBe(true);
expect(inQuietHours(s, at('07:29'))).toBe(true);
expect(inQuietHours(s, at('07:30'))).toBe(false);
expect(inQuietHours(s, at('22:00'))).toBe(false);
});
it('wraps over midnight and is off when both ends are equal', () => {
const late = { ...DEFAULT_NOTIFICATION_SETTINGS, quietFrom: '22:00', quietUntil: '06:00' };
expect(inQuietHours(late, at('23:15'))).toBe(true);
expect(inQuietHours(late, at('05:59'))).toBe(true);
expect(inQuietHours(late, at('12:00'))).toBe(false);
expect(inQuietHours({ ...late, quietUntil: '22:00' }, at('23:00'))).toBe(false);
});
});
describe('digestDue', () => {
it('is due once a day from the report time on', () => {
const s = DEFAULT_NOTIFICATION_SETTINGS;
expect(digestDue(s, at('07:29'), null)).toBe(false);
expect(digestDue(s, at('07:30'), null)).toBe(true);
expect(digestDue(s, at('09:00'), '2026-09-29')).toBe(true);
expect(digestDue(s, at('09:00'), '2026-09-30')).toBe(false);
expect(digestDue({ ...s, digestEnabled: false }, at('09:00'), null)).toBe(false);
});
});
describe('nightDigest', () => {
const run = (overrides: Record<string, unknown>) => ({
app: 'sonarr', workflowId: 'update-sonarr', status: 'succeeded', mode: 'real' as const, current: '4.0.1', candidate: '4.0.2', running: '4.0.2', outcome: 'Updated', step: null, updatedAt: '2026-09-30T02:00:00.000Z',
...overrides,
});
it('lists what needs the owner first, then what changed since the last report', () => {
const digest = nightDigest({
since: '2026-09-29T05:30:00.000Z',
runs: [
run({}),
run({ app: 'radarr', running: '5.1', current: '5.1', candidate: '5.2', outcome: 'Rolled back' }),
run({ app: 'bazarr', status: 'failed', outcome: 'Verify failed', running: '1.0', current: '1.0' }),
run({ app: 'jellyfin', status: 'waiting', step: 'Owner decides', updatedAt: '2026-09-28T00:00:00.000Z' }),
run({ app: 'ombi', mode: 'shadow' as const }),
run({ app: 'old', updatedAt: '2026-09-20T00:00:00.000Z' }),
],
down: ['grafana'],
});
expect(digest.title).toBe('Night report: 3 need you');
expect(digest.detail.split('\n')).toEqual([
'Waiting for you: jellyfin (Owner decides)',
'Failed: bazarr (Verify failed)',
'Down now: grafana',
'Updated: sonarr 4.0.1 → 4.0.2',
'Not updated: radarr (Rolled back)',
]);
});
it('says all is well when nothing happened', () => {
expect(nightDigest({ since: '2026-09-29T05:30:00.000Z', runs: [], down: [] })).toEqual({ title: 'Night report: all good', detail: 'No real update, nothing failed, nothing waits for you.', needsYou: 0 });
});
});
+87
View File
@@ -0,0 +1,87 @@
import { z } from 'zod';
// When the owner's phone may ring and what the morning report says. Quiet hours hold every push (the report covers what
// happened in them); the evening before is not quiet, so a failure then still rings. Times are wall-clock in the
// schedules' time zone.
const Time = z.string().regex(/^([01]\d|2[0-3]):[0-5]\d$/, 'must be HH:MM');
export const NotificationSettings = z.object({
quietFrom: Time,
quietUntil: Time,
digestAt: Time,
digestEnabled: z.boolean(),
});
export type NotificationSettings = z.infer<typeof NotificationSettings>;
export const DEFAULT_NOTIFICATION_SETTINGS: NotificationSettings = { quietFrom: '01:00', quietUntil: '07:30', digestAt: '07:30', digestEnabled: true };
interface WallClock {
readonly hour: number;
readonly minute: number;
/** `YYYY-MM-DDTHH:MM` (domain/cron.ts zonedMinute): its day names the report. */
readonly key: string;
}
const minutes = (time: string): number => Number(time.slice(0, 2)) * 60 + Number(time.slice(3, 5));
export function inQuietHours(settings: NotificationSettings, at: WallClock): boolean {
const from = minutes(settings.quietFrom);
const until = minutes(settings.quietUntil);
const now = at.hour * 60 + at.minute;
if (from === until) return false;
return from < until ? now >= from && now < until : now >= from || now < until;
}
/** The report goes out once a day, at its time or at the first look after it (the driver may have been down). */
export function digestDue(settings: NotificationSettings, at: WallClock, lastDay: string | null): boolean {
return settings.digestEnabled && at.hour * 60 + at.minute >= minutes(settings.digestAt) && lastDay !== at.key.slice(0, 10);
}
export interface DigestRun {
readonly app: string | null;
readonly workflowId: string;
readonly status: string;
readonly mode: 'shadow' | 'real';
readonly current: string | null;
readonly candidate: string | null;
readonly running: string | null;
readonly outcome: string | null;
readonly step: string | null;
readonly updatedAt: string;
}
export interface Digest {
readonly title: string;
readonly detail: string;
/** Runs waiting at a gate, failures and apps down: what the owner should look at. */
readonly needsYou: number;
}
/**
* The morning report: what waits for the owner and what is broken first, then what changed since the last report.
* A run still waiting counts whenever it started; the rest only when it ended after `since`. Dry runs change nothing.
*/
export function nightDigest(input: { readonly runs: readonly DigestRun[]; readonly since: string; readonly down: readonly string[] }): Digest {
const since = Date.parse(input.since);
const name = (run: DigestRun) => run.app ?? run.workflowId;
const recent = input.runs.filter((run) => Date.parse(run.updatedAt) > since);
const waiting = input.runs.filter((run) => run.status === 'waiting').map((run) => `${name(run)} (${run.step ?? 'decision'})`);
const failed = recent.filter((run) => run.status === 'failed').map((run) => `${name(run)} (${run.outcome ?? 'failed'})`);
const real = recent.filter((run) => run.mode === 'real' && run.status === 'succeeded');
const updated = real.filter((run) => run.running !== null && run.running !== run.current).map((run) => `${name(run)} ${run.current ?? '?'} → ${run.running ?? '?'}`);
const kept = real.filter((run) => run.running === null || run.running === run.current).map((run) => `${name(run)} (${run.outcome ?? 'not updated'})`);
const lines = [
waiting.length > 0 ? `Waiting for you: ${waiting.join(', ')}` : null,
failed.length > 0 ? `Failed: ${failed.join(', ')}` : null,
input.down.length > 0 ? `Down now: ${input.down.join(', ')}` : null,
updated.length > 0 ? `Updated: ${updated.join(', ')}` : null,
kept.length > 0 ? `Not updated: ${kept.join(', ')}` : null,
].filter((line): line is string => line !== null);
const needsYou = waiting.length + failed.length + input.down.length;
return {
title: needsYou > 0 ? `Night report: ${String(needsYou)} need you` : 'Night report: all good',
detail: lines.length > 0 ? lines.join('\n') : 'No real update, nothing failed, nothing waits for you.',
needsYou,
};
}
+16
View File
@@ -26,6 +26,8 @@ import type { PreflightReport } from './preflight.ts';
import type { Skill, SkillVersion } from './skill.ts';
import type { AppConfigRevision } from './app-config.ts';
import type { AppConfig, Executor, JsonValue } from './steps.ts';
import type { HostStatus } from './host-status.ts';
import type { NotificationSettings } from './notification-settings.ts';
import type { AppendableWorkflowInstanceEventInput, PublishedWorkflowVersion, WorkflowDraft, WorkflowDocument, WorkflowInstance, WorkflowInstanceCreateInput, WorkflowInstanceEvent, WorkflowInstanceStatus, WorkflowValidation } from './workflow.ts';
import type { ExecutionCommand, NodeAttempt, ReplayStatus, WorkflowExecutionProjection } from './workflow-execution.ts';
import type { ExecutionFinalizeOutcome } from './workflow-execution-ledger.ts';
@@ -433,6 +435,20 @@ export interface ModelResult {
export type ModelFailure = 'rate_limited' | 'auth_failed' | 'invalid_output' | 'unavailable' | 'busy';
/** The owner's trusted release notes sources, one list for all apps (http(s) pages or `github:owner/repo`). */
/** The driver's latest look at VM103 (domain/host-status.ts); null before its first look. */
export interface HostStatusStore {
get(): HostStatus | null;
put(status: HostStatus): void;
}
/** The owner's quiet hours and morning report (domain/notification-settings.ts), and when the last report went out. */
export interface NotificationSettingsStore {
get(): NotificationSettings;
put(settings: NotificationSettings, now: string): void;
lastDigest(): { readonly day: string; readonly at: string } | null;
recordDigest(day: string, at: string): void;
}
export interface ReleaseNotesSourcesStore {
get(): string[];
put(sources: readonly string[], now: string): void;
+6
View File
@@ -1,3 +1,5 @@
import { HostStatus } from '../domain/host-status.ts';
import { NotificationSettings } from '../domain/notification-settings.ts';
import { z } from 'zod';
import { SkillContent } from '../domain/skill.ts';
import { AutoLevel, Executor, Json } from '../domain/steps.ts';
@@ -383,3 +385,7 @@ export type ModelProfilePublishRequest = z.infer<typeof ModelProfilePublishReque
/** GET/PUT /api/release-notes-sources: the owner's trusted release notes sources for all apps. */
export const ReleaseNotesSourcesResponse = z.object({ sources: z.array(z.string()) });
/** GET /api/host-status: the driver's latest look at VM103, null before its first. */
export const HostStatusResponse = z.object({ status: HostStatus.nullable() });
/** GET/PUT /api/notification-settings: quiet hours and the morning report, in the schedules' time zone. */
export const NotificationSettingsResponse = z.object({ settings: NotificationSettings, timeZone: z.string(), lastDigestAt: z.string().nullable() });
+21 -1
View File
@@ -24,7 +24,7 @@ import type {
WorkflowRunInputStore,
ModelActivityStore,
WorkflowScheduleStore,
ReleaseNotesSourcesStore,
HostStatusStore, NotificationSettingsStore, ReleaseNotesSourcesStore,
} from '../domain/ports.ts';
import { compileTagPattern, tagShapePattern } from '../domain/tags.ts';
import { buildAppOverview } from '../domain/overview.ts';
@@ -71,6 +71,7 @@ import {
import { buildWorkflowTimeline, decisionContext } from '../domain/workflow-timeline.ts';
import { saveSchedule, scheduleNextRuns } from '../app/workflow-schedules.ts';
import type { MigrationDecisionContext } from '../domain/workflow-timeline.ts';
import { NotificationSettings } from '../domain/notification-settings.ts';
import { totalCostUsd, workflowModelCalls } from '../domain/workflow-model-calls.ts';
import { createWorkflowExtensions } from '../domain/workflow-extensions.ts';
@@ -99,6 +100,10 @@ export interface ServerDeps {
readonly modelProfiles: ModelProfileStore;
/** The owner's trusted release notes sources for all apps. */
readonly releaseNotesSources: ReleaseNotesSourcesStore;
/** The driver's latest look at VM103's containers and backup; unset: the overview shows none. */
readonly hostStatus?: Pick<HostStatusStore, 'get'>;
/** Quiet hours and the morning report; the driver reads them. */
readonly notificationSettings: NotificationSettingsStore;
/** What the owner typed into a migration form. */
readonly workflowRunInputs: WorkflowRunInputStore;
/** The models' executor session logs per node attempt (written by the driver); unset: the run page shows none. */
@@ -913,6 +918,21 @@ export async function buildServer(deps: ServerDeps): Promise<FastifyInstance> {
});
app.get('/api/release-notes-sources', (_request, reply) => reply.send({ sources: deps.releaseNotesSources.get() }));
app.get('/api/host-status', (_request, reply) => reply.send({ status: deps.hostStatus?.get() ?? null }));
const notificationSettings = () => ({
settings: deps.notificationSettings.get(),
timeZone: deps.schedules.timeZone,
lastDigestAt: deps.notificationSettings.lastDigest()?.at ?? null,
});
app.get('/api/notification-settings', (_request, reply) => reply.send(notificationSettings()));
app.put('/api/notification-settings', (request, reply) => {
const body = NotificationSettings.strict().safeParse(request.body);
if (!body.success) return fail(reply, 400, 'invalid_request', issues(body.error));
deps.notificationSettings.put(body.data, clock.now().toISOString());
hub.notify();
return reply.send(notificationSettings());
});
app.put('/api/release-notes-sources', (request, reply) => {
const body = z.strictObject({ sources: ReleaseNotesSources }).safeParse(request.body);
@@ -0,0 +1,15 @@
import { describe, expect, it } from 'vitest';
import { openDatabase } from './open.ts';
import { createSqliteHostStatusStore } from './host-status-store.ts';
describe('host status store', () => {
it('keeps only the latest look', () => {
const store = createSqliteHostStatusStore(openDatabase(':memory:'));
expect(store.get()).toBeNull();
const first = { checkedAt: '2026-09-30T06:00:00.000Z', apps: [], backup: null, error: 'docker ps failed' };
const second = { checkedAt: '2026-09-30T06:01:00.000Z', apps: [{ app: 'a', enabled: true, state: 'running', health: null }], backup: { ok: false as const, detail: 'none' }, error: null };
store.put(first);
store.put(second);
expect(store.get()).toEqual(second);
});
});
+20
View File
@@ -0,0 +1,20 @@
import type { DatabaseSync } from 'node:sqlite';
import { HostStatus } from '../../domain/host-status.ts';
import type { HostStatusStore } from '../../domain/ports.ts';
const KEY = 'hostStatus';
export function createSqliteHostStatusStore(db: DatabaseSync): HostStatusStore {
return {
get() {
const row = db.prepare('SELECT value FROM settings WHERE key = :key').get({ key: KEY });
// A value an older or newer driver wrote in another shape shows as "not looked yet" until the next look.
return row === undefined ? null : (HostStatus.safeParse(JSON.parse(String(row['value']))).data ?? null);
},
put(status) {
db.prepare(`INSERT INTO settings (key, value, updated_at) VALUES (:key, :value, :now)
ON CONFLICT (key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at`)
.run({ key: KEY, value: JSON.stringify(HostStatus.parse(status)), now: status.checkedAt });
},
};
}
@@ -0,0 +1,17 @@
import { describe, expect, it } from 'vitest';
import { openDatabase } from './open.ts';
import { createSqliteNotificationSettingsStore } from './notification-settings-store.ts';
import { DEFAULT_NOTIFICATION_SETTINGS } from '../../domain/notification-settings.ts';
describe('notification settings store', () => {
it('starts from the defaults, keeps the owner’s settings and the last report', () => {
const store = createSqliteNotificationSettingsStore(openDatabase(':memory:'));
expect(store.get()).toEqual(DEFAULT_NOTIFICATION_SETTINGS);
expect(store.lastDigest()).toBeNull();
store.put({ ...DEFAULT_NOTIFICATION_SETTINGS, quietFrom: '00:30' }, '2026-09-30T08:00:00.000Z');
store.recordDigest('2026-09-30', '2026-09-30T05:30:00.000Z');
expect(store.get().quietFrom).toBe('00:30');
expect(store.lastDigest()).toEqual({ day: '2026-09-30', at: '2026-09-30T05:30:00.000Z' });
expect(() => store.put({ ...DEFAULT_NOTIFICATION_SETTINGS, digestAt: '7:30' }, '2026-09-30T08:00:00.000Z')).toThrow();
});
});
@@ -0,0 +1,27 @@
import type { DatabaseSync } from 'node:sqlite';
import { z } from 'zod';
import { DEFAULT_NOTIFICATION_SETTINGS, NotificationSettings } from '../../domain/notification-settings.ts';
import type { NotificationSettingsStore } from '../../domain/ports.ts';
const SETTINGS = 'notificationSettings';
const LAST_DIGEST = 'lastDigest';
const LastDigest = z.object({ day: z.string(), at: z.string() });
export function createSqliteNotificationSettingsStore(db: DatabaseSync): NotificationSettingsStore {
const read = (key: string): unknown => {
const row = db.prepare('SELECT value FROM settings WHERE key = :key').get({ key });
return row === undefined ? undefined : JSON.parse(String(row['value']));
};
const write = (key: string, value: unknown, now: string) => {
db.prepare(`INSERT INTO settings (key, value, updated_at) VALUES (:key, :value, :now)
ON CONFLICT (key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at`)
.run({ key, value: JSON.stringify(value), now });
};
return {
// Until the owner saves their own, the defaults apply.
get: () => NotificationSettings.safeParse(read(SETTINGS)).data ?? DEFAULT_NOTIFICATION_SETTINGS,
put: (settings, now) => write(SETTINGS, NotificationSettings.parse(settings), now),
lastDigest: () => LastDigest.safeParse(read(LAST_DIGEST)).data ?? null,
recordDigest: (day, at) => write(LAST_DIGEST, { day, at }, at),
};
}
+38 -1
View File
@@ -14,6 +14,11 @@ import { openDatabase } from '../infra/sqlite/open.ts';
import { createSqliteAppConfigStore } from '../infra/sqlite/app-config-store.ts';
import { createSqliteSkillStore } from '../infra/sqlite/skill-store.ts';
import { createSqliteReleaseNotesSourcesStore } from '../infra/sqlite/release-notes-sources-store.ts';
import { createSqliteHostStatusStore } from '../infra/sqlite/host-status-store.ts';
import { runHostStatusLoop } from '../app/host-status.ts';
import { isQuiet, runDigestLoop } from '../app/digest.ts';
import { downApps } from '../domain/host-status.ts';
import { createSqliteNotificationSettingsStore } from '../infra/sqlite/notification-settings-store.ts';
import { systemClock } from '../infra/system-clock.ts';
import { createComponentOperationExecutor } from '../app/component-operation-executor.ts';
import { groupMembers } from '../app/ops/group.ts';
@@ -147,7 +152,38 @@ const runner = createWorkflowRunner({
runInputs,
});
const notifyDeps = { get: (id: string) => instanceStore.get(id), report: (instance: WorkflowInstance) => reportRun(instanceStore, instance), notifier };
const notificationSettings = createSqliteNotificationSettingsStore(db);
const hostStatusStore = createSqliteHostStatusStore(db);
const quietDeps = { clock: systemClock, timeZone: config.scheduleTimeZone, settings: notificationSettings };
// In the owner's quiet hours nothing rings; the morning report covers those runs.
const notifyDeps = { get: (id: string) => instanceStore.get(id), report: (instance: WorkflowInstance) => reportRun(instanceStore, instance), notifier, quiet: () => isQuiet(quietDeps) };
// The morning report: what waits for the owner, what failed or is down, what changed since the last report.
const digestLoop = runDigestLoop({
...quietDeps,
notifier,
runs: () => instanceStore.listInstances({}).map((instance) => ({ ...reportRun(instanceStore, instance), workflowId: instance.workflowId, status: instance.projection.status, updatedAt: instance.updatedAt })),
down: () => {
const status = hostStatusStore.get();
return status === null ? [] : downApps(status);
},
intervalMs: 60_000,
signal: shutdown.signal,
onError: (cause) => logger.error('digest.failed', { error: cause instanceof Error ? cause.message : String(cause) }),
});
// Once a minute: what runs on VM103 and how old the newest VM backup is, for the overview (read-only, through the proxy).
const hostStatusLoop = runHostStatusLoop({
runner: processRunner,
clock: systemClock,
apps: () => appConfig.list(),
readBackup: hostBackup.read,
backupVmid: config.backupVmid,
store: hostStatusStore,
intervalMs: 60_000,
signal: shutdown.signal,
onError: (cause) => logger.error('host_status.failed', { error: cause instanceof Error ? cause.message : String(cause) }),
});
await runWorkflowLoop(runner, {
signal: shutdown.signal,
@@ -164,6 +200,7 @@ await runWorkflowLoop(runner, {
if (result.kind === 'finalized' || result.kind === 'cancelled') removeFinishedInputs(runInputs);
},
});
await Promise.all([hostStatusLoop, digestLoop]);
db.close();
/** Never lets a cleanup failure stop the loop: the inputs are only sensitive, not load-bearing. */
+4
View File
@@ -6,6 +6,8 @@ import { buildServer } from '../http/server.ts';
import { dataVersion, openDatabase } from '../infra/sqlite/open.ts';
import { createSqliteAppConfigStore } from '../infra/sqlite/app-config-store.ts';
import { createSqliteSkillStore } from '../infra/sqlite/skill-store.ts';
import { createSqliteNotificationSettingsStore } from '../infra/sqlite/notification-settings-store.ts';
import { createSqliteHostStatusStore } from '../infra/sqlite/host-status-store.ts';
import { createSqliteReleaseNotesSourcesStore } from '../infra/sqlite/release-notes-sources-store.ts';
import { createSqliteWorkflowCatalogStore } from '../infra/sqlite/workflow-catalog-store.ts';
import { createSqliteWorkflowLayoutStore } from '../infra/sqlite/workflow-layout-store.ts';
@@ -76,6 +78,8 @@ const app = await buildServer({
privilegedActionStore,
modelProfiles,
releaseNotesSources: createSqliteReleaseNotesSourcesStore(db),
hostStatus: createSqliteHostStatusStore(db),
notificationSettings: createSqliteNotificationSettingsStore(db),
workflowRunInputs: createSqliteWorkflowRunInputStore(db),
modelActivity: createSqliteModelActivityStore(db),
startWorkflowRun: (name, mode) => startWorkflowRun(runDeps, name, mode),
+4
View File
@@ -5,6 +5,8 @@ import { createIsolatedFunctionRuntime } from '../infra/runtime/isolated-functio
import { openDatabase } from '../infra/sqlite/open.ts';
import { createSqliteAppConfigStore } from '../infra/sqlite/app-config-store.ts';
import { createSqliteModelProfileStore } from '../infra/sqlite/model-profile-store.ts';
import { createSqliteNotificationSettingsStore } from '../infra/sqlite/notification-settings-store.ts';
import { createSqliteHostStatusStore } from '../infra/sqlite/host-status-store.ts';
import { createSqliteReleaseNotesSourcesStore } from '../infra/sqlite/release-notes-sources-store.ts';
import { createSqlitePrivilegedActionStore } from '../infra/sqlite/privileged-action-store.ts';
import { createSqliteSkillStore } from '../infra/sqlite/skill-store.ts';
@@ -34,6 +36,8 @@ export function sqliteServerDeps(db: DatabaseSync = openDatabase(':memory:')) {
privilegedActionStore: createSqlitePrivilegedActionStore(db),
modelProfiles: createSqliteModelProfileStore(db),
releaseNotesSources: createSqliteReleaseNotesSourcesStore(db),
hostStatus: createSqliteHostStatusStore(db),
notificationSettings: createSqliteNotificationSettingsStore(db),
workflowRunInputs: createSqliteWorkflowRunInputStore(db),
modelActivity: createSqliteModelActivityStore(db),
schedules: { store: createSqliteWorkflowScheduleStore(db), timeZone: 'Europe/Warsaw' },
+7
View File
@@ -18,11 +18,15 @@ import {
ModelCallsResponse,
ModelProfileListResponse,
ReleaseNotesSourcesResponse,
HostStatusResponse,
NotificationSettingsResponse,
ModelProfileSummaryResponse,
TagPreviewResponse,
} from '../http/contract.ts';
import type { AppConfigPatch, ModelProfilePublishRequest } from '../http/contract.ts';
type NotificationSettingsResponse = z.infer<typeof NotificationSettingsResponse>;
export class ApiError extends Error {
status: number;
@@ -115,6 +119,9 @@ export const api = {
modelCalls: (limit = 50) => call(ModelCallsResponse, `/api/model-calls?limit=${String(limit)}`),
modelProfiles: () => call(ModelProfileListResponse, '/api/model-profiles'),
releaseNotesSources: () => call(ReleaseNotesSourcesResponse, '/api/release-notes-sources'),
hostStatus: () => call(HostStatusResponse, '/api/host-status'),
notificationSettings: () => call(NotificationSettingsResponse, '/api/notification-settings'),
saveNotificationSettings: (settings: NotificationSettingsResponse['settings']) => call(NotificationSettingsResponse, '/api/notification-settings', settings, 'PUT'),
saveReleaseNotesSources: (sources: readonly string[]) => call(ReleaseNotesSourcesResponse, '/api/release-notes-sources', { sources }, 'PUT'),
modelProfileVersions: (id: string) => call(z.array(ModelProfileSummaryResponse), `/api/model-profiles/${encodeURIComponent(id)}/versions`),
modelProfileVersion: (id: string, version: number) => call(ModelProfileSummaryResponse, `/api/model-profiles/${encodeURIComponent(id)}/versions/${String(version)}`),
+87 -1
View File
@@ -35,6 +35,7 @@ class FakeEventSource {
let server: FastifyInstance;
let appConfig: AppConfigStore;
let schedules: ReturnType<typeof sqliteServerDeps>['schedules'];
let hostStatus: ReturnType<typeof sqliteServerDeps>['hostStatus'];
let requests: { method: string; url: string; body?: unknown }[];
let container: HTMLElement;
@@ -42,7 +43,7 @@ beforeEach(async () => {
FakeEventSource.instances = [];
requests = [];
const deps = sqliteServerDeps();
({ appConfig, schedules } = deps);
({ appConfig, schedules, hostStatus } = deps);
seedTestApps(appConfig);
const registry = new FakeRegistryClient({ 'demo/bazarr': ['1.0.0', '1.1.0', '1.2.0'] });
server = await buildServer({ ...deps, registry, build: { branch: 'foundations', number: 6, revision: 'de5ee92a1b2c3d4e5f60718293a4b5c6d7e8f901' } });
@@ -57,6 +58,7 @@ beforeEach(async () => {
});
window.location.hash = '';
window.localStorage.clear(); // the apps layout the owner chose
container = document.createElement('div');
document.body.replaceChildren(container);
render(<App />, container);
@@ -88,6 +90,16 @@ const button = (root: ParentNode, label: string) => {
return found;
};
/** The apps page opens on cards; the table is one click away. */
async function showAppsTable() {
const toggle = await eventually(() => {
const found = [...container.querySelectorAll('.segmented button')].find((candidate) => candidate.textContent.trim() === 'Table');
if (!(found instanceof HTMLButtonElement)) throw new Error('no layout switch');
return found;
});
toggle.click();
}
const nextTask = () => new Promise((resolve) => setTimeout(resolve, 0));
/** Like a person: type, and let the UI render before the next click. */
@@ -126,6 +138,7 @@ describe('live updates', () => {
it('refreshes the view when the server announces a change, without any click', async () => {
window.location.hash = '#/apps';
await showAppsTable();
await eventually(() => expect(container.querySelectorAll('tbody tr')).toHaveLength(4));
appConfig.upsert({ ...appConfig.get('grafana')!, enabled: false });
expect(text()).not.toContain('disabled'); // nothing told the page yet
@@ -248,6 +261,7 @@ describe('skill', () => {
describe('other views', () => {
it('shows every app with its latest run, and an app’s runs and schedule', async () => {
window.location.hash = '#/apps';
await showAppsTable();
await eventually(() => expect(container.querySelectorAll('tbody tr')).toHaveLength(4));
const row = [...container.querySelectorAll('tbody tr')].find((r) => r.textContent.includes('grafana'))!;
expect(row.textContent).toContain('never ran');
@@ -274,6 +288,7 @@ describe('other views', () => {
it('shows an app’s next scheduled run on the apps list', async () => {
schedules.store.put({ workflowId: 'update-grafana', cron: '0 3 * * *', mode: 'shadow', enabled: true, now: '2026-09-18T01:00:00.000Z' });
window.location.hash = '#/apps';
await showAppsTable();
await eventually(() => expect(container.querySelectorAll('tbody tr')).toHaveLength(4));
const row = await eventually(() => {
const found = [...container.querySelectorAll('tbody tr')].find((r) => r.textContent.includes('grafana'));
@@ -286,6 +301,7 @@ describe('other views', () => {
it('shows the components of one project together, the later ones under the first', async () => {
appConfig.upsert({ ...appConfig.get('grafana')!, app: 'grafana-influxdb', service: 'influxdb', imageRepo: 'influxdb', position: 99 });
window.location.hash = '#/apps';
await showAppsTable();
await eventually(() => expect(container.querySelectorAll('tbody tr')).toHaveLength(5));
const rows = [...container.querySelectorAll('tbody tr')].map((row) => row.querySelector('td')?.textContent ?? '');
const at = rows.findIndex((text) => text.startsWith('grafana'));
@@ -293,6 +309,76 @@ describe('other views', () => {
expect(container.querySelector<HTMLAnchorElement>(`tbody tr:nth-child(${String(at + 2)}) a`)?.getAttribute('href')).toBe('#/apps/grafana-influxdb');
});
it('shows apps as cards by default, a project’s components on one card, and remembers the table', async () => {
appConfig.upsert({ ...appConfig.get('grafana')!, app: 'grafana-influxdb', service: 'influxdb', imageRepo: 'influxdb', position: 99 });
window.location.hash = '#/apps';
const cards = await eventually(() => {
const found = container.querySelectorAll('.app-card');
expect(found).toHaveLength(4);
return [...found];
});
const grafana = cards.find((card) => card.querySelector('.app-card-title a')?.textContent === 'grafana')!;
expect(grafana.querySelector('.app-member a')?.getAttribute('href')).toBe('#/apps/grafana-influxdb');
expect(grafana.textContent).toContain('never ran');
await showAppsTable();
await eventually(() => expect(container.querySelectorAll('tbody tr')).toHaveLength(5));
render(null, container);
render(<App />, container);
await eventually(() => expect(container.querySelectorAll('tbody tr')).toHaveLength(5));
});
it('shows on the overview which apps are down and how old the VM backup is', async () => {
const now = Date.now();
hostStatus.put({
checkedAt: new Date(now).toISOString(),
apps: [
{ app: 'grafana', enabled: true, state: 'exited', health: null },
{ app: 'bazarr', enabled: true, state: 'running', health: 'healthy' },
],
backup: { ok: true, createdAt: new Date(now - 5 * 3_600_000).toISOString(), ageHours: 5, volid: 'pbs:vm/103' },
error: null,
});
window.location.hash = '#/overview';
await eventually(() => expect(text()).toContain('1/2'));
expect(text()).toContain('grafana exited');
expect(text()).toContain('5 h ago');
window.location.hash = '#/apps';
await eventually(() => expect(container.querySelector('.health-down')?.getAttribute('title')).toBe('down: exited'));
});
it('saves the quiet hours and shows the morning report on the overview', async () => {
window.location.hash = '#/settings';
const from = await eventually(() => {
const found = container.querySelectorAll<HTMLInputElement>('input[type="time"]')[0];
if (found === undefined) throw new Error('no time field');
return found;
});
expect(from.value).toBe('01:00');
await type(from, '00:30');
button(container, 'Save').click();
await eventually(() => expect(text()).toContain('Notification settings saved.'));
expect((await server.inject('/api/notification-settings')).json()).toMatchObject({ settings: { quietFrom: '00:30', quietUntil: '07:30' }, timeZone: 'Europe/Warsaw' });
window.location.hash = '#/overview';
await eventually(() => expect(text()).toContain('All good'));
expect(text()).toContain('No real update, nothing failed, nothing waits for you.');
});
it('jumps to an app from the palette opened with Ctrl+K', async () => {
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'k', ctrlKey: true }));
const input = await eventually(() => {
const found = container.querySelector<HTMLInputElement>('.palette input');
if (found === null) throw new Error('no palette');
return found;
});
await eventually(() => expect(container.querySelector('.palette-list')?.textContent).toContain('bazarr'));
await type(input, 'bazarr app');
input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }));
await eventually(() => expect(window.location.hash).toBe('#/apps/bazarr'));
expect(container.querySelector('.palette')).toBeNull();
});
it('says so for an unknown page', async () => {
window.location.hash = '#/nowhere';
await eventually(() => expect(text()).toContain('Page not found'));
+53 -21
View File
@@ -7,6 +7,11 @@ import type { LiveStatus } from './live.ts';
import { formatBuild } from './format.ts';
import type { BuildInfo } from '../http/contract.ts';
import { hrefFor, parseRoute } from './route.ts';
import { useTheme } from './theme.ts';
import { Icon } from './components/ui.tsx';
import { DialogProvider } from './components/dialogs.tsx';
import { CommandPalette, useCommandPalette } from './components/command-palette.tsx';
import type { IconName } from './components/ui.tsx';
import type { Route } from './route.ts';
import { AppDetailView, AppsView } from './views/apps.tsx';
import { OverviewView } from './views/overview.tsx';
@@ -17,6 +22,7 @@ import { FunctionEditorView } from './views/function-editor.tsx';
import { WorkflowInstancesView } from './views/instances.tsx';
import { InstanceTimelineView } from './views/instance-timeline.tsx';
import { ModelsView } from './views/models.tsx';
import { SettingsView } from './views/settings.tsx';
function useRoute(): Route {
const [route, setRoute] = useState(() => parseRoute(window.location.hash));
@@ -31,10 +37,11 @@ function useRoute(): Route {
const LIVE_LABEL: Record<LiveStatus, string> = { connecting: 'connecting…', live: 'live', offline: 'offline — retrying' };
function Nav({ route, status }: { route: Route; status: LiveStatus }) {
function Sidebar({ route, status, onSearch }: { route: Route; status: LiveStatus; onSearch: () => void }) {
const gates = useResource(() => api.workflowInstances({ status: 'waiting' }), []);
const { theme, toggle } = useTheme();
const waiting = gates.data?.length ?? 0;
const link = (view: Route['view'], label: string, target: Parameters<typeof hrefFor>[0], count = 0) => (
const link = (view: Route['view'], label: string, icon: IconName, target: Parameters<typeof hrefFor>[0], count = 0) => (
<a
href={hrefFor(target)}
aria-current={
@@ -47,25 +54,40 @@ function Nav({ route, status }: { route: Route; status: LiveStatus }) {
: undefined
}
>
{label}
<Icon name={icon} />
<span class="nav-label">{label}</span>
{count > 0 ? <span class="count">{count}</span> : null}
</a>
);
return (
<header class="topbar">
<span class="brand">Homelab upgrade</span>
<nav>
{link('overview', 'Overview', { view: 'overview' }, waiting)}
{link('apps', 'Apps', { view: 'apps' })}
{link('runs', 'Runs', { view: 'runs' })}
{link('workflows', 'Workflows', { view: 'workflows' })}
{link('functions', 'Functions', { view: 'functions' })}
{link('models', 'Models', { view: 'models' })}
</nav>
<span class={`live live-${status}`} title="Live updates from the server">
{LIVE_LABEL[status]}
<aside class="sidebar">
<span class="brand">
<span class="brand-mark" aria-hidden="true" />
Homelab upgrade
</span>
</header>
<button type="button" class="search-button" onClick={onSearch} title="Search (Ctrl+K)">
<Icon name="search" size={16} />
<span class="nav-label">Search</span>
<kbd>Ctrl K</kbd>
</button>
<nav>
{link('overview', 'Overview', 'overview', { view: 'overview' }, waiting)}
{link('apps', 'Apps', 'apps', { view: 'apps' })}
{link('runs', 'Runs', 'runs', { view: 'runs' })}
{link('workflows', 'Workflows', 'workflows', { view: 'workflows' })}
{link('functions', 'Functions', 'functions', { view: 'functions' })}
{link('models', 'Models', 'models', { view: 'models' })}
{link('settings', 'Settings', 'settings', { view: 'settings' })}
</nav>
<div class="sidebar-foot">
<span class={`live live-${status}`} title="Live updates from the server">
{LIVE_LABEL[status]}
</span>
<button type="button" class="icon-button" onClick={toggle} title={theme === 'dark' ? 'Switch to light theme' : 'Switch to dark theme'}>
<Icon name={theme === 'dark' ? 'sun' : 'moon'} />
</button>
</div>
</aside>
);
}
@@ -95,6 +117,8 @@ function View({ route }: { route: Route }): ComponentChildren {
return <FunctionEditorView functionId={route.functionId} version={route.version} />;
case 'models':
return <ModelsView />;
case 'settings':
return <SettingsView />;
case 'not_found':
return (
<section>
@@ -133,13 +157,21 @@ function Footer() {
export function App() {
const route = useRoute();
const { revision, status, refresh } = useLive();
const [paletteOpen, setPaletteOpen] = useCommandPalette();
return (
<LiveContext.Provider value={{ revision, refresh }}>
<Nav route={route} status={status} />
<main>
<View route={route} />
</main>
<Footer />
<DialogProvider>
<div class="shell">
<Sidebar route={route} status={status} onSearch={() => setPaletteOpen(true)} />
<div class="content">
<main>
<View route={route} />
</main>
<Footer />
</div>
</div>
{paletteOpen ? <CommandPalette onClose={() => setPaletteOpen(false)} /> : null}
</DialogProvider>
</LiveContext.Provider>
);
}
+9
View File
@@ -0,0 +1,9 @@
import * as echarts from 'echarts/core';
import { BarChart, CustomChart, LineChart, PieChart, ScatterChart } from 'echarts/charts';
import { DataZoomComponent, GridComponent, LegendComponent, MarkLineComponent, TooltipComponent } from 'echarts/components';
import { SVGRenderer } from 'echarts/renderers';
// Only the parts the views draw; this module is loaded on demand, so the charts live in their own chunk.
echarts.use([BarChart, CustomChart, LineChart, PieChart, ScatterChart, DataZoomComponent, GridComponent, LegendComponent, MarkLineComponent, TooltipComponent, SVGRenderer]);
export { echarts };
+157
View File
@@ -0,0 +1,157 @@
import { describe, expect, it } from 'vitest';
import { activityOption, costOption, durationOption, formatUsd, ganttOption, ganttRows, modelColors, outcomesOption, scheduleOption } from './options.ts';
import type { Palette } from './options.ts';
const PALETTE: Palette = {
text: '#fff',
muted: '#999',
line: '#333',
surface: '#111',
accent: '#00f',
status: { succeeded: 'green', failed: 'red', waiting: 'amber', running: 'blue', cancelled: 'grey' },
series: ['s1', 's2', 's3'],
};
type Bar = { name: string; data: number[]; itemStyle: { color: string; borderRadius: number | number[] } };
describe('activityOption', () => {
it('stacks only the statuses that occur, grey between green and red, and rounds the top of the stack', () => {
const option = activityOption(
[
{ day: '2026-09-29', succeeded: 2, failed: 1, cancelled: 0, waiting: 0, running: 0 },
{ day: '2026-09-30', succeeded: 1, failed: 0, cancelled: 1, waiting: 1, running: 0 },
],
PALETTE,
);
const series = option.series as Bar[];
expect(series.map((item) => [item.name, item.itemStyle.color])).toEqual([
['Done', 'green'],
['Cancelled', 'grey'],
['Failed', 'red'],
['Waiting for you', 'amber'],
]);
expect(series[0]!.data).toEqual([2, 1]);
expect(series.map((item) => item.itemStyle.borderRadius)).toEqual([0, 0, 0, [4, 4, 0, 0]]);
expect((option.xAxis as { data: string[] }).data).toEqual(['09-29', '09-30']);
});
});
describe('modelColors', () => {
it('gives each model the same hue whatever it spent', () => {
const colors = modelColors(['sonnet', 'haiku', 'opus'], PALETTE);
expect([...colors.entries()]).toEqual([
['haiku', 's1'],
['opus', 's2'],
['sonnet', 's3'],
]);
});
});
describe('costOption', () => {
it('stacks spend per model with the model’s own hue', () => {
const option = costOption(
{
days: ['2026-09-29', '2026-09-30'],
series: [
{ model: 'sonnet', values: [0.3, 0.2], total: 0.5 },
{ model: 'haiku', values: [0, 0.01], total: 0.01 },
],
total: 0.51,
},
PALETTE,
);
const series = option.series as Bar[];
expect(series.map((item) => [item.name, item.itemStyle.color])).toEqual([
['sonnet', 's2'],
['haiku', 's1'],
]);
});
});
describe('formatUsd', () => {
it('shows cents precisely under a dollar', () => {
expect(formatUsd(0)).toBe('$0');
expect(formatUsd(0.0123)).toBe('$0.012');
expect(formatUsd(12.345)).toBe('$12.35');
});
});
describe('scheduleOption', () => {
it('puts each run on its workflow’s row within the window, dry and real apart', () => {
const now = Date.parse('2026-09-30T00:00:00.000Z');
const option = scheduleOption(
[
{ workflowId: 'update-a', at: '2026-10-01T03:00:00.000Z', mode: 'shadow' },
{ workflowId: 'update-b', at: '2026-10-02T03:00:00.000Z', mode: 'real' },
{ workflowId: 'update-a', at: '2026-11-01T03:00:00.000Z', mode: 'shadow' }, // past the window
],
{ now, days: 7 },
PALETTE,
);
const series = option.series as { name: string; data: [number, string][] }[];
expect(series.map((item) => [item.name, item.data])).toEqual([
['Dry run', [[Date.parse('2026-10-01T03:00:00.000Z'), 'update-a']]],
['Real', [[Date.parse('2026-10-02T03:00:00.000Z'), 'update-b']]],
]);
expect((option.yAxis as { data: string[] }).data).toEqual(['update-b', 'update-a']);
});
});
describe('durationOption', () => {
it('draws one bar per run in minutes, colored by how it ended', () => {
const option = durationOption(
[
{ id: 'a', startedAt: '2026-09-28T03:00:00.000Z', status: 'succeeded', minutes: 4.256 },
{ id: 'b', startedAt: '2026-09-29T03:00:00.000Z', status: 'failed', minutes: 12 },
],
PALETTE,
);
const [series] = option.series as { data: { value: number; itemStyle: { color: string } }[] }[];
expect(series!.data.map((bar) => [bar.value, bar.itemStyle.color])).toEqual([
[4.26, 'green'],
[12, 'red'],
]);
});
});
describe('ganttOption', () => {
it('puts nodes on rows in the order the run reached them, with one bar per attempt', () => {
const bars = [
{ nodeId: 'plan', start: 0, end: 1000, status: 'succeeded' as const, open: false },
{ nodeId: 'verify', start: 1000, end: 2000, status: 'failed' as const, open: false },
{ nodeId: 'verify', start: 3000, end: 4000, status: 'succeeded' as const, open: false },
{ nodeId: 'gate', start: 4000, end: 9000, status: 'waiting' as const, open: true },
];
expect(ganttRows(bars)).toEqual(['plan', 'verify', 'gate']);
const option = ganttOption(bars, (nodeId) => nodeId.toUpperCase(), PALETTE);
expect((option.yAxis as { data: string[] }).data).toEqual(['PLAN', 'VERIFY', 'GATE']);
const [series] = option.series as { data: { value: number[]; itemStyle: { color: string } }[] }[];
expect(series!.data.map((item) => [item.value, item.itemStyle.color])).toEqual([
[[0, 0, 1000], 'green'],
[[1, 1000, 2000], 'red'],
[[1, 3000, 4000], 'green'],
[[2, 4000, 9000], 'amber'],
]);
});
});
describe('outcomesOption', () => {
it('counts outcomes per kind of question, in the verdict colors', () => {
const option = outcomesOption(
[
{ kind: 'releaseNotes', outcome: 'clear' },
{ kind: 'releaseNotes', outcome: 'blocked' },
{ kind: 'repair', outcome: 'clear' },
{ kind: 'repair', outcome: 'odd' },
],
PALETTE,
);
expect((option.yAxis as { data: string[] }).data).toEqual(['Release notes', 'Repair']);
const series = option.series as Bar[];
expect(series.map((item) => [item.name, item.data, item.itemStyle.color])).toEqual([
['blocked', [1, 0], 'amber'],
['clear', [1, 1], 'green'],
['odd', [0, 1], '#999'],
]);
});
});
+333
View File
@@ -0,0 +1,333 @@
import type { EChartsOption } from 'echarts';
import type { AttemptStatus, CostPerDay, DayRuns, GanttBar, RunBucket } from '../stats.ts';
import { runStatusLabel } from '../labels.ts';
/** Colors a chart needs, read from the theme's CSS tokens by the chart component. */
export interface Palette {
readonly text: string;
readonly muted: string;
readonly line: string;
readonly surface: string;
readonly accent: string;
readonly status: Readonly<Record<RunBucket, string>>;
/** Categorical hues in fixed order. */
readonly series: readonly string[];
}
export interface ChartClick {
readonly dataIndex: number;
readonly seriesIndex: number;
readonly name: string;
}
// Neighbours in the stack keep green and red apart (the grey sits between), for colour-blind readers.
const STACK_ORDER: readonly RunBucket[] = ['succeeded', 'cancelled', 'failed', 'waiting', 'running'];
const BUCKET_LABEL: Readonly<Record<RunBucket, string>> = {
succeeded: runStatusLabel('succeeded'),
cancelled: runStatusLabel('cancelled'),
failed: runStatusLabel('failed'),
waiting: runStatusLabel('waiting'),
running: 'In progress',
};
/** `09-30` from `2026-09-30`: the year is the same across a month's chart. */
export function shortDay(day: string): string {
return day.slice(5);
}
export function formatUsd(value: number): string {
return value === 0 ? '$0' : `$${value < 1 ? value.toFixed(3) : value.toFixed(2)}`;
}
function axisBase(palette: Palette) {
return {
axisLine: { lineStyle: { color: palette.line } },
axisTick: { show: false },
axisLabel: { color: palette.muted },
splitLine: { lineStyle: { color: palette.line, opacity: 0.6 } },
};
}
function tooltipBase(palette: Palette) {
return {
backgroundColor: palette.surface,
borderColor: palette.line,
textStyle: { color: palette.text },
extraCssText: 'border-radius: 10px; box-shadow: 0 8px 24px rgba(0,0,0,.25);',
};
}
function legendBase(palette: Palette) {
return { top: 0, icon: 'roundRect', itemWidth: 10, itemHeight: 10, textStyle: { color: palette.muted } };
}
/** Runs per day, stacked by what they are now. */
export function activityOption(days: readonly DayRuns[], palette: Palette): EChartsOption {
const used = STACK_ORDER.filter((bucket) => days.some((day) => day[bucket] > 0));
const top = used.at(-1);
return {
animationDuration: 500,
grid: { left: 8, right: 8, top: 36, bottom: 4, containLabel: true },
legend: { ...legendBase(palette), data: used.map((bucket) => BUCKET_LABEL[bucket]) },
tooltip: { ...tooltipBase(palette), trigger: 'axis', axisPointer: { type: 'shadow' } },
xAxis: { type: 'category', data: days.map((day) => shortDay(day.day)), ...axisBase(palette), splitLine: { show: false } },
yAxis: { type: 'value', minInterval: 1, ...axisBase(palette) },
series: used.map((bucket) => ({
name: BUCKET_LABEL[bucket],
type: 'bar',
stack: 'runs',
barMaxWidth: 18,
data: days.map((day) => day[bucket]),
itemStyle: {
color: palette.status[bucket],
borderColor: palette.surface,
borderWidth: 1,
borderRadius: bucket === top ? [4, 4, 0, 0] : 0,
},
emphasis: { focus: 'series' },
})),
};
}
/** A small line under a number: its shape over the days, nothing else. */
export function sparklineOption(values: readonly number[], color: string, palette: Palette, format: (value: number) => string = String): EChartsOption {
return {
animationDuration: 500,
grid: { left: 0, right: 0, top: 4, bottom: 0 },
tooltip: { ...tooltipBase(palette), trigger: 'axis', valueFormatter: (value) => format(Number(value)) },
xAxis: { type: 'category', show: false, boundaryGap: false, data: values.map((_, index) => String(index)) },
yAxis: { type: 'value', show: false, min: 0 },
series: [
{
type: 'line',
data: [...values],
smooth: 0.3,
showSymbol: false,
lineStyle: { color, width: 2 },
areaStyle: { color, opacity: 0.15 },
},
],
};
}
/**
* A model's color follows its name, not its rank this month: models sorted by name take the hues in order, so a model
* keeps its color while spend moves.
*/
export function modelColors(models: readonly string[], palette: Palette): ReadonlyMap<string, string> {
const sorted = [...new Set(models)].sort();
return new Map(sorted.map((model, index) => [model, palette.series[index % Math.max(palette.series.length, 1)] ?? palette.muted]));
}
/** Model spend per day, stacked by model. */
export function costOption(cost: CostPerDay, palette: Palette): EChartsOption {
const colors = modelColors(cost.series.map((item) => item.model), palette);
const top = cost.series.at(-1)?.model;
return {
animationDuration: 500,
grid: { left: 8, right: 8, top: 36, bottom: 4, containLabel: true },
legend: { ...legendBase(palette), data: cost.series.map((item) => item.model) },
tooltip: { ...tooltipBase(palette), trigger: 'axis', axisPointer: { type: 'shadow' }, valueFormatter: (value) => formatUsd(Number(value)) },
xAxis: { type: 'category', data: cost.days.map(shortDay), ...axisBase(palette), splitLine: { show: false } },
yAxis: { type: 'value', ...axisBase(palette), axisLabel: { color: palette.muted, formatter: (value: number) => formatUsd(value) } },
series: cost.series.map((item) => ({
name: item.model,
type: 'bar',
stack: 'cost',
barMaxWidth: 18,
data: [...item.values],
itemStyle: { color: colors.get(item.model), borderColor: palette.surface, borderWidth: 1, borderRadius: item.model === top ? [4, 4, 0, 0] : 0 },
emphasis: { focus: 'series' },
})),
};
}
/** Each model's share of all spend. */
export function modelShareOption(totals: readonly { readonly model: string; readonly costUsd: number; readonly calls: number }[], palette: Palette): EChartsOption {
const colors = modelColors(totals.map((item) => item.model), palette);
return {
animationDuration: 500,
tooltip: { ...tooltipBase(palette), trigger: 'item', valueFormatter: (value) => formatUsd(Number(value)) },
legend: { ...legendBase(palette), top: 'bottom', type: 'scroll' },
series: [
{
type: 'pie',
radius: ['52%', '78%'],
center: ['50%', '44%'],
padAngle: 2,
itemStyle: { borderRadius: 6, borderColor: palette.surface, borderWidth: 2 },
label: { show: false },
data: totals.map((item) => ({ name: item.model, value: item.costUsd, itemStyle: { color: colors.get(item.model) } })),
},
],
};
}
export interface ScheduledRun {
readonly workflowId: string;
readonly at: string;
readonly mode: 'shadow' | 'real';
}
/** When each scheduled workflow runs next over the coming days: one row per workflow, dry and real runs in two hues. */
export function scheduleOption(runs: readonly ScheduledRun[], window: { readonly now: number; readonly days: number }, palette: Palette): EChartsOption {
const workflows = [...new Set(runs.map((run) => run.workflowId))].sort().reverse();
const end = window.now + window.days * 24 * 60 * 60 * 1000;
const modes = [
{ mode: 'shadow' as const, name: 'Dry run', color: palette.series[0] ?? palette.accent },
{ mode: 'real' as const, name: 'Real', color: palette.series[1] ?? palette.accent },
].filter((entry) => runs.some((run) => run.mode === entry.mode));
return {
animationDuration: 400,
grid: { left: 8, right: 16, top: 36, bottom: 4, containLabel: true },
legend: { ...legendBase(palette), data: modes.map((entry) => entry.name) },
tooltip: {
...tooltipBase(palette),
trigger: 'item',
formatter: (params) => {
const item = Array.isArray(params) ? params[0] : params;
const [at, workflowId] = (item?.value ?? [0, '']) as [number, string];
return `${workflowId}<br/>${new Date(at).toLocaleString('sv-SE', { dateStyle: 'short', timeStyle: 'short' })}`;
},
},
xAxis: { type: 'time', min: window.now, max: end, ...axisBase(palette), axisLabel: { color: palette.muted, hideOverlap: true } },
yAxis: { type: 'category', data: workflows, ...axisBase(palette), splitLine: { show: true, lineStyle: { color: palette.line, opacity: 0.4 } } },
series: modes.map((entry) => ({
name: entry.name,
type: 'scatter',
symbolSize: 12,
itemStyle: { color: entry.color, borderColor: palette.surface, borderWidth: 2 },
data: runs
.filter((run) => run.mode === entry.mode && Date.parse(run.at) <= end)
.map((run) => [Date.parse(run.at), run.workflowId]),
})),
};
}
export interface RunDuration {
readonly id: string;
readonly startedAt: string;
readonly status: RunBucket;
readonly minutes: number;
}
/** How long each ended run took, oldest on the left, colored by how it ended. */
export function durationOption(runs: readonly RunDuration[], palette: Palette): EChartsOption {
return {
animationDuration: 500,
grid: { left: 8, right: 8, top: 12, bottom: 4, containLabel: true },
tooltip: {
...tooltipBase(palette),
trigger: 'item',
formatter: (params) => {
const item = Array.isArray(params) ? params[0] : params;
const run = runs[item?.dataIndex ?? 0];
return run === undefined ? '' : `${run.startedAt.slice(0, 16).replace('T', ' ')}<br/>${BUCKET_LABEL[run.status]} · ${run.minutes.toFixed(1)} min`;
},
},
xAxis: { type: 'category', data: runs.map((run) => shortDay(run.startedAt.slice(0, 10))), ...axisBase(palette), splitLine: { show: false } },
yAxis: { type: 'value', name: 'min', nameTextStyle: { color: palette.muted }, ...axisBase(palette) },
series: [
{
type: 'bar',
barMaxWidth: 22,
data: runs.map((run) => ({ value: Number(run.minutes.toFixed(2)), itemStyle: { color: palette.status[run.status], borderRadius: [4, 4, 0, 0] } })),
},
],
};
}
const ATTEMPT_LABEL: Readonly<Record<AttemptStatus, string>> = { running: 'Running', succeeded: 'Done', failed: 'Failed', waiting: 'Waiting for you' };
const ATTEMPT_COLOR: Readonly<Record<AttemptStatus, RunBucket>> = { running: 'running', succeeded: 'succeeded', failed: 'failed', waiting: 'waiting' };
function formatSpan(ms: number): string {
if (ms < 1000) return `${String(Math.round(ms))} ms`;
if (ms < 60_000) return `${(ms / 1000).toFixed(1)} s`;
return `${(ms / 60_000).toFixed(1)} min`;
}
/** Rows in the order the run first reached each node, top to bottom. */
export function ganttRows(bars: readonly GanttBar[]): string[] {
return [...new Set(bars.map((bar) => bar.nodeId))];
}
/** The run's steps over time: one row per node, one bar per attempt, colored by how it went. */
export function ganttOption(bars: readonly GanttBar[], label: (nodeId: string) => string, palette: Palette): EChartsOption {
const rows = ganttRows(bars);
const rowOf = new Map(rows.map((nodeId, index) => [nodeId, index]));
return {
animationDuration: 400,
grid: { left: 8, right: 16, top: 8, bottom: 4, containLabel: true },
tooltip: {
...tooltipBase(palette),
trigger: 'item',
formatter: (params) => {
const item = Array.isArray(params) ? params[0] : params;
const bar = bars[item?.dataIndex ?? 0];
if (bar === undefined) return '';
return `<strong>${label(bar.nodeId)}</strong><br/>${ATTEMPT_LABEL[bar.status]} · ${bar.open ? 'for ' : ''}${formatSpan(bar.end - bar.start)}`;
},
},
xAxis: { type: 'time', min: 'dataMin', max: 'dataMax', ...axisBase(palette), axisLabel: { color: palette.muted, hideOverlap: true } },
yAxis: {
type: 'category',
inverse: true,
data: rows.map(label),
...axisBase(palette),
axisLabel: { color: palette.text, width: 180, overflow: 'truncate' },
splitLine: { show: true, lineStyle: { color: palette.line, opacity: 0.4 } },
},
series: [
{
type: 'custom',
encode: { x: [1, 2], y: 0 },
renderItem: (_params, api) => {
const row = api.value(0);
const from = api.coord([api.value(1), row]);
const to = api.coord([api.value(2), row]);
const size = api.size?.([0, 1]);
const height = (Array.isArray(size) ? (size[1] ?? 20) : 20) * 0.56;
const x = from[0] ?? 0;
const y = (from[1] ?? 0) - height / 2;
// An instant step still shows as a sliver the owner can hover and click.
return { type: 'rect', shape: { x, y, width: Math.max((to[0] ?? x) - x, 4), height, r: 4 }, style: { fill: api.visual('color') as string } };
},
data: bars.map((bar) => ({
value: [rowOf.get(bar.nodeId) ?? 0, bar.start, bar.end],
itemStyle: { color: palette.status[ATTEMPT_COLOR[bar.status]], opacity: bar.open ? 0.75 : 1 },
})),
},
],
};
}
const KIND_LABEL: Readonly<Record<string, string>> = { releaseNotes: 'Release notes', postUpgrade: 'After the update', repair: 'Repair' };
// The model's verdicts in the colors the run page gives them.
const OUTCOME_COLOR: Readonly<Record<string, RunBucket>> = { clear: 'succeeded', updated: 'succeeded', attention: 'running', blocked: 'waiting', failed: 'failed' };
/** What the models concluded, per kind of question: one bar per kind, stacked by outcome. */
export function outcomesOption(calls: readonly { readonly kind: string; readonly outcome: string }[], palette: Palette): EChartsOption {
const kinds = [...new Set(calls.map((call) => call.kind))];
const outcomes = [...new Set(calls.map((call) => call.outcome))].sort();
const count = (kind: string, outcome: string) => calls.filter((call) => call.kind === kind && call.outcome === outcome).length;
return {
animationDuration: 500,
grid: { left: 8, right: 16, top: 36, bottom: 4, containLabel: true },
legend: { ...legendBase(palette), data: outcomes },
tooltip: { ...tooltipBase(palette), trigger: 'axis', axisPointer: { type: 'shadow' } },
xAxis: { type: 'value', minInterval: 1, ...axisBase(palette) },
yAxis: { type: 'category', inverse: true, data: kinds.map((kind) => KIND_LABEL[kind] ?? kind), ...axisBase(palette), axisLabel: { color: palette.text } },
series: outcomes.map((outcome) => {
const bucket = OUTCOME_COLOR[outcome];
return {
name: outcome,
type: 'bar',
stack: 'outcomes',
barMaxWidth: 22,
data: kinds.map((kind) => count(kind, outcome)),
itemStyle: { color: bucket === undefined ? palette.muted : palette.status[bucket], borderColor: palette.surface, borderWidth: 1 },
emphasis: { focus: 'series' },
};
}),
};
}
+12
View File
@@ -1,5 +1,7 @@
import type { ComponentChildren } from 'preact';
import { runModeLabel, runStatusLabel } from './labels.ts';
import { formatInstant, formatRelative } from './format.ts';
import { useNow } from './hooks.ts';
/** A workflow run's status in words, colored by status. */
export function RunBadge({ status }: { status: string }) {
@@ -26,3 +28,13 @@ export function ErrorNote({ message }: { message: string }) {
export function Empty({ children }: { children: ComponentChildren }) {
return <p class="empty">{children}</p>;
}
/** An instant as "5 min ago" or "in 3 h", with the full time on hover. */
export function When({ iso }: { readonly iso: string | null }) {
const now = useNow();
return (
<time dateTime={iso ?? undefined} title={formatInstant(iso)}>
{formatRelative(iso, now)}
</time>
);
}
+100
View File
@@ -0,0 +1,100 @@
import { useEffect, useRef, useState } from 'preact/hooks';
import type { EChartsOption } from 'echarts';
import type { EChartsType } from 'echarts/core';
import type { ChartClick, Palette } from '../charts/options.ts';
function token(styles: CSSStyleDeclaration, name: string): string {
return styles.getPropertyValue(name).trim();
}
/** The theme's colors as the charts need them, read from the CSS tokens so both themes stay in one place. */
function readPalette(): Palette {
const styles = getComputedStyle(document.documentElement);
return {
text: token(styles, '--text'),
muted: token(styles, '--muted'),
line: token(styles, '--line'),
surface: token(styles, '--surface'),
accent: token(styles, '--accent'),
status: {
succeeded: token(styles, '--ok'),
failed: token(styles, '--error'),
waiting: token(styles, '--stop'),
running: token(styles, '--running'),
cancelled: token(styles, '--queued'),
},
series: [1, 2, 3, 4, 5, 6, 7, 8].map((slot) => token(styles, `--series-${String(slot)}`)),
};
}
/** Bumps when the owner switches the theme, so charts redraw with the other colors. */
function useThemeTick(): number {
const [tick, setTick] = useState(0);
useEffect(() => {
const onChange = () => setTick((n) => n + 1);
window.addEventListener('themechange', onChange);
return () => window.removeEventListener('themechange', onChange);
}, []);
return tick;
}
/**
* An ECharts chart. ECharts loads on first use in its own chunk. Without ResizeObserver (jsdom in tests) the chart is
* left out: every chart repeats numbers the page shows elsewhere, so nothing is lost.
*/
export function Chart({
option,
label,
height = 220,
onClick,
}: {
readonly option: (palette: Palette) => EChartsOption;
readonly label: string;
readonly height?: number;
readonly onClick?: (click: ChartClick) => void;
}) {
const container = useRef<HTMLDivElement>(null);
const chart = useRef<EChartsType | null>(null);
const lastOption = useRef('');
const clickRef = useRef(onClick);
clickRef.current = onClick;
const [ready, setReady] = useState(false);
const themeTick = useThemeTick();
const supported = typeof ResizeObserver !== 'undefined';
useEffect(() => {
if (!supported) return undefined;
let disposed = false;
let observer: ResizeObserver | null = null;
void import('../charts/echarts.ts').then(({ echarts }) => {
const element = container.current;
if (disposed || element === null) return;
const instance = echarts.init(element, null, { renderer: 'svg' });
instance.on('click', (params) => {
clickRef.current?.({ dataIndex: params.dataIndex, seriesIndex: params.seriesIndex ?? 0, name: params.name });
});
observer = new ResizeObserver(() => instance.resize());
observer.observe(element);
chart.current = instance;
setReady(true);
});
return () => {
disposed = true;
observer?.disconnect();
chart.current?.dispose();
chart.current = null;
};
}, [supported]);
useEffect(() => {
if (!ready || chart.current === null) return;
const built = option(readPalette());
// Views re-render on every live event; only a different picture is drawn again (and animated).
const key = JSON.stringify(built) + String(themeTick);
if (key === lastOption.current) return;
lastOption.current = key;
chart.current.setOption(built, { notMerge: true });
});
return <div ref={container} class="chart" role="img" aria-label={label} style={{ height: `${String(height)}px`, cursor: onClick === undefined ? undefined : 'pointer' }} />;
}
@@ -0,0 +1,16 @@
import { describe, expect, it } from 'vitest';
import { filterItems } from './command-palette.tsx';
describe('filterItems', () => {
const items = [
{ label: 'Overview', hint: 'page', href: '#/overview', icon: 'overview' as const },
{ label: 'bazarr', hint: 'app · bazarr', href: '#/apps/bazarr', icon: 'apps' as const },
{ label: 'bazarr: Failed', hint: 'run · Verify failed', href: '#/runs/r1', icon: 'runs' as const },
];
it('keeps items with every word of the query, case aside, in their order', () => {
expect(filterItems(items, 'BAZ').map((item) => item.href)).toEqual(['#/apps/bazarr', '#/runs/r1']);
expect(filterItems(items, 'baz run').map((item) => item.href)).toEqual(['#/runs/r1']);
expect(filterItems(items, ' ')).toHaveLength(3);
});
});
+146
View File
@@ -0,0 +1,146 @@
import { useEffect, useLayoutEffect, useRef, useState } from 'preact/hooks';
import { api } from '../api.ts';
import { runStatusLabel } from '../labels.ts';
import { hrefFor } from '../route.ts';
import { Icon } from './ui.tsx';
import type { IconName } from './ui.tsx';
export interface PaletteItem {
readonly label: string;
readonly hint: string;
readonly href: string;
readonly icon: IconName;
}
const PAGES: readonly PaletteItem[] = [
{ label: 'Overview', hint: 'page', href: hrefFor({ view: 'overview' }), icon: 'overview' },
{ label: 'Apps', hint: 'page', href: hrefFor({ view: 'apps' }), icon: 'apps' },
{ label: 'Runs', hint: 'page', href: hrefFor({ view: 'runs' }), icon: 'runs' },
{ label: 'Workflows', hint: 'page', href: hrefFor({ view: 'workflows' }), icon: 'workflows' },
{ label: 'Functions', hint: 'page', href: hrefFor({ view: 'functions' }), icon: 'functions' },
{ label: 'Models', hint: 'page', href: hrefFor({ view: 'models' }), icon: 'models' },
{ label: 'Settings', hint: 'page', href: hrefFor({ view: 'settings' }), icon: 'settings' },
];
const RECENT_RUNS = 15;
const SHOWN = 12;
/** Every word of the query must appear in the item's label or hint; the order of the items stays. */
export function filterItems(items: readonly PaletteItem[], query: string): PaletteItem[] {
const words = query.toLowerCase().split(/\s+/).filter((word) => word !== '');
return items.filter((item) => {
const text = `${item.label} ${item.hint}`.toLowerCase();
return words.every((word) => text.includes(word));
});
}
/** Everything the palette can jump to; a list that fails to load is left out rather than failing the palette. */
async function loadItems(): Promise<PaletteItem[]> {
const [apps, runs, workflows, functions] = await Promise.allSettled([api.apps(), api.workflowInstances(), api.workflows(), api.functions()]);
return [
...PAGES,
...(apps.status === 'fulfilled' ? apps.value.map((app) => ({ label: app.app, hint: `app · ${app.project}`, href: hrefFor({ view: 'app', app: app.app }), icon: 'apps' as const })) : []),
...(runs.status === 'fulfilled'
? runs.value.slice(0, RECENT_RUNS).map((run) => ({
label: `${run.app ?? run.workflowId}: ${runStatusLabel(run.status)}`,
hint: `run · ${run.outcome ?? run.step ?? run.id}`,
href: hrefFor({ view: 'run', runId: run.id }),
icon: 'runs' as const,
}))
: []),
...(workflows.status === 'fulfilled' ? workflows.value.map((wf) => ({ label: wf.workflowId, hint: `workflow · ${wf.name}`, href: hrefFor({ view: 'workflow', workflowId: wf.workflowId }), icon: 'workflows' as const })) : []),
...(functions.status === 'fulfilled' ? functions.value.map((fn) => ({ label: fn.functionId, hint: `function · ${fn.name}`, href: hrefFor({ view: 'function', functionId: fn.functionId }), icon: 'functions' as const })) : []),
];
}
/** Opens on Ctrl+K (⌘K) anywhere, or `/` outside a text field; the sidebar's search button opens it too. */
export function useCommandPalette(): [boolean, (open: boolean) => void] {
const [open, setOpen] = useState(false);
useLayoutEffect(() => {
const onKey = (event: KeyboardEvent) => {
const typing = event.target instanceof HTMLElement && (event.target.isContentEditable || ['INPUT', 'TEXTAREA', 'SELECT'].includes(event.target.tagName));
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'k') {
event.preventDefault();
setOpen((current) => !current);
} else if (event.key === '/' && !typing) {
event.preventDefault();
setOpen(true);
}
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, []);
return [open, setOpen];
}
export function CommandPalette({ onClose }: { readonly onClose: () => void }) {
const [items, setItems] = useState<readonly PaletteItem[]>(PAGES);
const [query, setQuery] = useState('');
const [active, setActive] = useState(0);
const input = useRef<HTMLInputElement>(null);
useLayoutEffect(() => input.current?.focus(), []);
useEffect(() => {
let current = true;
void loadItems().then((loaded) => {
if (current) setItems(loaded);
});
return () => {
current = false;
};
}, []);
const shown = filterItems(items, query).slice(0, SHOWN);
const go = (item: PaletteItem | undefined) => {
if (item === undefined) return;
window.location.hash = item.href;
onClose();
};
const onKey = (event: KeyboardEvent) => {
if (event.key === 'ArrowDown') {
event.preventDefault();
setActive((index) => Math.min(index + 1, shown.length - 1));
} else if (event.key === 'ArrowUp') {
event.preventDefault();
setActive((index) => Math.max(index - 1, 0));
} else if (event.key === 'Enter') {
event.preventDefault();
go(shown[active]);
} else if (event.key === 'Escape') {
onClose();
}
};
return (
<div class="modal-backdrop palette-backdrop" onClick={(event) => event.target === event.currentTarget && onClose()}>
<div class="palette" role="dialog" aria-modal="true" aria-label="Go to">
<div class="palette-input">
<Icon name="search" />
<input
ref={input}
type="text"
placeholder="Go to an app, a run, a workflow or a page…"
value={query}
onInput={(event) => {
setQuery((event.target as HTMLInputElement).value);
setActive(0);
}}
onKeyDown={onKey}
aria-label="Search"
/>
<kbd>Esc</kbd>
</div>
{shown.length === 0 ? (
<p class="palette-empty">Nothing matches.</p>
) : (
<ul class="palette-list" role="listbox">
{shown.map((item, index) => (
<li key={item.href} role="option" aria-selected={index === active} class={index === active ? 'active' : undefined} onMouseEnter={() => setActive(index)} onClick={() => go(item)}>
<Icon name={item.icon} size={16} />
<span class="palette-label">{item.label}</span>
<span class="palette-hint">{item.hint}</span>
</li>
))}
</ul>
)}
</div>
</div>
);
}
+5 -1
View File
@@ -130,6 +130,9 @@ export function CytoscapeGraph({
const positionsRef = useRef<Record<string, WorkflowLayoutNodePosition>>(layout?.positions ?? computeDefaultDagLayout(document));
const [expandedSubworkflows] = useState<readonly string[]>(layout?.expandedSubworkflows ?? []);
const [renderUnavailable, setRenderUnavailable] = useState(false);
// Once the canvas is known to be missing, later renders do not try again: a failed start clears the container,
// which would wipe the fallback list rendered into it.
const unavailableRef = useRef(false);
const errorsByNode = new Map<string, number>();
for (const diag of diagnostics) {
@@ -154,7 +157,7 @@ export function CytoscapeGraph({
}, [layout, document]);
useEffect(() => {
if (!cyContainerRef.current) return;
if (!cyContainerRef.current || unavailableRef.current) return;
const positions = positionsRef.current;
const edges = edgesOf(document);
@@ -243,6 +246,7 @@ export function CytoscapeGraph({
});
} catch {
// No usable canvas 2D context (e.g. a headless test environment): fall back to a plain node list.
unavailableRef.current = true;
setRenderUnavailable(true);
return;
}
+148
View File
@@ -0,0 +1,148 @@
import type { ComponentChildren } from 'preact';
import { createContext } from 'preact';
import { useCallback, useContext, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'preact/hooks';
import { Icon } from './ui.tsx';
export interface ConfirmRequest {
readonly title: string;
readonly message?: ComponentChildren;
readonly confirmLabel?: string;
/** Changes the app or ends something for good: the button is red and Enter does not confirm by default. */
readonly danger?: boolean;
}
export type ToastTone = 'ok' | 'error';
interface Dialogs {
/** Asks the owner in a window; resolves true only when they confirm. */
readonly confirm: (request: ConfirmRequest) => Promise<boolean>;
/** A short note in the corner that goes away on its own. */
readonly toast: (text: string, tone?: ToastTone) => void;
}
// Outside a provider nothing can be confirmed, so nothing risky runs by accident.
const DialogContext = createContext<Dialogs>({ confirm: () => Promise.resolve(false), toast: () => undefined });
export const useConfirm = () => useContext(DialogContext).confirm;
export const useToast = () => useContext(DialogContext).toast;
const TOAST_MS = 4000;
interface Pending {
readonly request: ConfirmRequest;
readonly resolve: (ok: boolean) => void;
}
interface Toast {
readonly id: number;
readonly tone: ToastTone;
readonly text: string;
}
/** Closes on Escape while `active`; listens from the first paint, before a plain effect would run. */
function useEscape(active: boolean, onEscape: () => void) {
useLayoutEffect(() => {
if (!active) return undefined;
const onKey = (event: KeyboardEvent) => {
if (event.key === 'Escape') onEscape();
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [active, onEscape]);
}
function ConfirmDialog({ request, onAnswer }: { readonly request: ConfirmRequest; readonly onAnswer: (ok: boolean) => void }) {
const cancelRef = useRef<HTMLButtonElement>(null);
const confirmRef = useRef<HTMLButtonElement>(null);
const cancel = useCallback(() => onAnswer(false), [onAnswer]);
useEscape(true, cancel);
useEffect(() => {
(request.danger === true ? cancelRef : confirmRef).current?.focus();
}, [request]);
return (
<div class="modal-backdrop" onClick={(event) => event.target === event.currentTarget && cancel()}>
<div class={`modal-dialog${request.danger === true ? ' modal-danger' : ''}`} role="alertdialog" aria-modal="true" aria-labelledby="confirm-title">
<div class="modal-title">
{request.danger === true ? (
<span class="modal-icon">
<Icon name="alert" />
</span>
) : null}
<h2 id="confirm-title">{request.title}</h2>
</div>
{request.message === undefined ? null : <div class="modal-message">{request.message}</div>}
<div class="modal-actions">
<button type="button" ref={cancelRef} onClick={cancel} data-dialog="cancel">
Cancel
</button>
<button type="button" ref={confirmRef} class={request.danger === true ? 'danger' : 'primary'} onClick={() => onAnswer(true)} data-dialog="confirm">
{request.confirmLabel ?? 'Confirm'}
</button>
</div>
</div>
</div>
);
}
/** Holds the confirmation window and the toasts for everything under it. */
export function DialogProvider({ children }: { readonly children: ComponentChildren }) {
const [pending, setPending] = useState<Pending | null>(null);
const pendingRef = useRef<Pending | null>(null);
const [toasts, setToasts] = useState<readonly Toast[]>([]);
const nextToast = useRef(0);
const confirm = useCallback(
(request: ConfirmRequest) =>
new Promise<boolean>((resolve) => {
pendingRef.current?.resolve(false); // a newer question replaces an unanswered one
pendingRef.current = { request, resolve };
setPending(pendingRef.current);
}),
[],
);
const answer = useCallback((ok: boolean) => {
pendingRef.current?.resolve(ok);
pendingRef.current = null;
setPending(null);
}, []);
const toast = useCallback((text: string, tone: ToastTone = 'ok') => {
nextToast.current += 1;
const id = nextToast.current;
setToasts((current) => [...current, { id, tone, text }]);
setTimeout(() => setToasts((current) => current.filter((item) => item.id !== id)), TOAST_MS);
}, []);
const value = useMemo(() => ({ confirm, toast }), [confirm, toast]);
return (
<DialogContext.Provider value={value}>
{children}
{pending === null ? null : <ConfirmDialog request={pending.request} onAnswer={answer} />}
<div class="toasts" aria-live="polite">
{toasts.map((item) => (
<div key={item.id} class={`toast toast-${item.tone}`} role={item.tone === 'error' ? 'alert' : 'status'}>
<Icon name={item.tone === 'ok' ? 'check' : 'alert'} size={16} />
{item.text}
</div>
))}
</div>
</DialogContext.Provider>
);
}
/** A panel that slides in from the right over the page; closes on Escape, the backdrop or its close button. */
export function Drawer({ title, onClose, children }: { readonly title: ComponentChildren; readonly onClose: () => void; readonly children: ComponentChildren }) {
useEscape(true, onClose);
return (
<div class="drawer-backdrop" onClick={(event) => event.target === event.currentTarget && onClose()}>
<aside class="drawer" role="dialog" aria-modal="true" aria-labelledby="drawer-title">
<header class="drawer-head">
<h2 id="drawer-title">{title}</h2>
<button type="button" class="icon-button" onClick={onClose} title="Close">
<Icon name="close" />
</button>
</header>
<div class="drawer-body">{children}</div>
</aside>
</div>
);
}
+8 -4
View File
@@ -8,6 +8,7 @@ import { describeNode } from '../../domain/workflow-node-descriptions.ts';
import { COMPONENT_OPERATION_DESCRIPTIONS, PRIVILEGED_ACTION_DESCRIPTIONS } from '../../domain/workflow-catalog-descriptions.ts';
import { api } from '../api.ts';
import { useResource } from '../hooks.ts';
import { useConfirm } from './dialogs.tsx';
import { addNode, dataSource, nodeEvents, removeNode, setConfigField, setOwnerActions, setRoute } from '../node-edit.ts';
import type { NewNodeKind } from '../node-edit.ts';
import { hrefFor } from '../route.ts';
@@ -61,6 +62,7 @@ export function NodeForm({ document, node, readOnly, onChange, onSelect }: {
readonly onSelect: (nodeId: string | null) => void;
}) {
const [error, setError] = useState<string | null>(null);
const confirm = useConfirm();
const described = describeNode(node, document);
const config = node.type === 'extension' ? record(node.config) : {};
const binding = record(config['binding']);
@@ -152,10 +154,12 @@ export function NodeForm({ document, node, readOnly, onChange, onSelect }: {
{!readOnly && (
<button type="button" class="btn-sm" onClick={() => {
if (!window.confirm(`Remove the block “${described.title}” and the routes into it?`)) return;
const removed = removeNode(document, node.nodeId);
if (!removed.ok) setError(removed.reason);
else { onChange(removed.document); onSelect(null); }
void confirm({ title: `Remove the block “${described.title}”?`, message: 'The routes into it are removed too.', confirmLabel: 'Remove', danger: true }).then((ok) => {
if (!ok) return;
const removed = removeNode(document, node.nodeId);
if (!removed.ok) setError(removed.reason);
else { onChange(removed.document); onSelect(null); }
});
}}>Remove this block</button>
)}
{error && <p class="error-note">{error}</p>}
+104
View File
@@ -0,0 +1,104 @@
import type { ComponentChildren } from 'preact';
/** Stroke icons on a 24×24 grid; one path string per icon keeps them inline and dependency-free. */
const ICONS = {
overview: 'M3 3h7v9H3zM14 3h7v5h-7zM14 12h7v9h-7zM3 16h7v5H3z',
apps: 'M21 8 12 3 3 8v8l9 5 9-5zM3 8l9 5 9-5M12 13v8',
runs: 'M22 12h-4l-3 9L9 3l-3 9H2',
workflows: 'M6 3v12M18 9a3 3 0 1 0 0-6 3 3 0 0 0 0 6zM6 21a3 3 0 1 0 0-6 3 3 0 0 0 0 6zM18 9a9 9 0 0 1-9 9',
functions: 'M16 18l6-6-6-6M8 6l-6 6 6 6',
models: 'M9 3v2M15 3v2M9 19v2M15 19v2M3 9h2M3 15h2M19 9h2M19 15h2M7 5h10a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V7a2 2 0 0 1 2-2zM9 9h6v6H9z',
sun: 'M12 17a5 5 0 1 0 0-10 5 5 0 0 0 0 10zM12 1v2M12 21v2M4.2 4.2l1.4 1.4M18.4 18.4l1.4 1.4M1 12h2M21 12h2M4.2 19.8l1.4-1.4M18.4 5.6l1.4-1.4',
moon: 'M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z',
clock: 'M12 22a10 10 0 1 0 0-20 10 10 0 0 0 0 20zM12 6v6l4 2',
alert: 'M10.3 3.9 1.8 18a2 2 0 0 0 1.7 3h17a2 2 0 0 0 1.7-3L13.7 3.9a2 2 0 0 0-3.4 0zM12 9v4M12 17h.01',
check: 'M20 6 9 17l-5-5',
close: 'M18 6 6 18M6 6l12 12',
arrow: 'M5 12h14M12 5l7 7-7 7',
coin: 'M12 22a10 10 0 1 0 0-20 10 10 0 0 0 0 20zM15 9.5a3 2.5 0 0 0-3-1.5c-1.7 0-3 .9-3 2s1.3 2 3 2 3 .9 3 2-1.3 2-3 2a3 2.5 0 0 1-3-1.5M12 6v2M12 16v2',
upgrade: 'M12 19V5M5 12l7-7 7 7',
grid: 'M3 3h7v7H3zM14 3h7v7h-7zM14 14h7v7h-7zM3 14h7v7H3z',
list: 'M8 6h13M8 12h13M8 18h13M3 6h.01M3 12h.01M3 18h.01',
settings: 'M4 21v-7M4 10V3M12 21v-9M12 8V3M20 21v-5M20 12V3M1 14h6M9 8h6M17 16h6',
bell: 'M18 8a6 6 0 0 0-12 0c0 7-3 9-3 9h18s-3-2-3-9M13.7 21a2 2 0 0 1-3.4 0',
search: 'M11 19a8 8 0 1 0 0-16 8 8 0 0 0 0 16zM21 21l-4.3-4.3',
} as const;
export type IconName = keyof typeof ICONS;
export function Icon({ name, size = 18 }: { readonly name: IconName; readonly size?: number }) {
return (
<svg class="icon" width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d={ICONS[name]} />
</svg>
);
}
/** A window on the page: a titled surface with optional actions on the right of its header. */
export function Card({
title,
icon,
actions,
tone,
class: extra,
children,
}: {
readonly title?: ComponentChildren;
readonly icon?: IconName;
readonly actions?: ComponentChildren;
readonly tone?: 'attention';
readonly class?: string;
readonly children: ComponentChildren;
}) {
const classes = ['panel', tone === undefined ? '' : `panel-${tone}`, extra ?? ''].filter((part) => part !== '').join(' ');
return (
<section class={classes}>
{title === undefined && actions === undefined ? null : (
<header class="panel-head">
{title === undefined ? <span /> : (
<h2>
{icon === undefined ? null : <Icon name={icon} />}
{title}
</h2>
)}
{actions === undefined ? null : <div class="panel-actions">{actions}</div>}
</header>
)}
<div class="panel-body">{children}</div>
</section>
);
}
export type Tone = 'ok' | 'error' | 'stop' | 'running' | 'accent' | 'neutral';
/** One number that matters, with what it means and, optionally, how it moved and a small chart under it. */
export function StatTile({
label,
value,
icon,
tone = 'neutral',
hint,
href,
children,
}: {
readonly label: string;
readonly value: ComponentChildren;
readonly icon: IconName;
readonly tone?: Tone;
readonly hint?: ComponentChildren;
readonly href?: string;
readonly children?: ComponentChildren;
}) {
const body = (
<>
<span class="stat-icon">
<Icon name={icon} size={16} />
</span>
<span class="stat-label">{label}</span>
<strong class="stat-value">{value}</strong>
{hint === undefined ? null : <span class="stat-hint">{hint}</span>}
{children}
</>
);
return href === undefined ? <div class={`stat stat-${tone}`}>{body}</div> : <a class={`stat stat-${tone}`} href={href}>{body}</a>;
}
+21 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest';
import { formatBuild, formatCost, formatDuration, formatInstant, humanizeCode, nodeRunStates, runVersions, traversedEdges } from './format.ts';
import { formatBuild, formatCost, formatDuration, formatInstant, formatRelative, humanizeCode, nodeRunStates, runVersions, traversedEdges } from './format.ts';
describe('formatInstant', () => {
it('shows date and time in the given zone', () => {
@@ -122,3 +122,23 @@ describe('traversedEdges', () => {
expect(traversedEdges({ a: 'done', b: 'done', c: 'current' }, edges)).toEqual([true, true, false, false]);
});
});
describe('formatRelative', () => {
const now = Date.parse('2026-09-30T12:00:00.000Z');
it.each([
['2026-09-30T11:59:40.000Z', 'just now'],
['2026-09-30T11:55:00.000Z', '5 min ago'],
['2026-09-30T09:00:00.000Z', '3 h ago'],
['2026-09-29T10:00:00.000Z', 'yesterday'],
['2026-09-27T12:00:00.000Z', '3 days ago'],
['2026-09-30T14:30:00.000Z', 'in 3 h'],
['2026-10-01T12:00:00.000Z', 'tomorrow'],
['2026-09-10T12:00:00.000Z', '2026-09-10'],
])('%s -> %s', (iso, text) => {
expect(formatRelative(iso, now, 'UTC')).toBe(text);
});
it('shows a dash for nothing', () => {
expect(formatRelative(null, now)).toBe('—');
});
});
+21
View File
@@ -100,3 +100,24 @@ export function runVersions(run: { readonly running: string | null; readonly can
if (run === null) return { running: null, available: null };
return { running: run.running, available: run.candidate !== null && run.candidate !== run.running ? run.candidate : null };
}
/**
* `5 min ago`, `in 3 h`, `yesterday`: how far an instant is from now, for lists read at a glance. Past a week it is the
* date (`2026-09-18`). The full time belongs in the element's title.
*/
export function formatRelative(iso: string | null, now: number, timeZone?: string): string {
if (iso === null) return DASH;
const time = Date.parse(iso);
if (Number.isNaN(time)) return DASH;
const diff = time - now;
const minutes = Math.round(Math.abs(diff) / 60_000);
const say = (amount: string) => (diff < 0 ? `${amount} ago` : `in ${amount}`);
if (minutes < 1) return diff < 0 ? 'just now' : 'in a moment';
if (minutes < 60) return say(`${String(minutes)} min`);
const hours = Math.round(minutes / 60);
if (hours < 24) return say(`${String(hours)} h`);
const days = Math.round(hours / 24);
if (days === 1) return diff < 0 ? 'yesterday' : 'tomorrow';
if (days < 7) return say(`${String(days)} days`);
return new Intl.DateTimeFormat('sv-SE', { dateStyle: 'short', ...(timeZone === undefined ? {} : { timeZone }) }).format(time);
}
+10
View File
@@ -38,3 +38,13 @@ export function useResource<T>(load: () => Promise<T>, deps: readonly unknown[])
return state;
}
/** The current time, refreshed every `everyMs`, so relative times ("5 min ago") keep up without a reload. */
export function useNow(everyMs = 60_000): number {
const [now, setNow] = useState(() => Date.now());
useEffect(() => {
const timer = setInterval(() => setNow(Date.now()), everyMs);
return () => clearInterval(timer);
}, [everyMs]);
return now;
}
+8 -1
View File
@@ -2,8 +2,15 @@
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<title>Homelab upgrade</title>
<meta name="theme-color" content="#0d1117" />
<meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<link rel="icon" href="/icon.svg" type="image/svg+xml" />
<link rel="apple-touch-icon" href="/icon.svg" />
<link rel="manifest" href="/manifest.webmanifest" />
</head>
<body>
<div id="app"></div>
+3
View File
@@ -1,6 +1,9 @@
import { render } from 'preact';
import { App } from './app.tsx';
import './styles.css';
import { applyInitialTheme } from './theme.ts';
applyInitialTheme();
const root = document.getElementById('app');
if (root === null) throw new Error('missing #app element');
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><defs><linearGradient id="g" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="#7c9cff"/><stop offset="1" stop-color="#2dd4bf"/></linearGradient></defs><rect width="64" height="64" rx="14" fill="#0d1117"/><rect x="8" y="8" width="48" height="48" rx="12" fill="url(#g)"/><path d="M32 44V20M22 30l10-10 10 10" fill="none" stroke="#0d1117" stroke-width="5" stroke-linecap="round" stroke-linejoin="round"/></svg>

After

Width:  |  Height:  |  Size: 476 B

+9
View File
@@ -0,0 +1,9 @@
{
"name": "Homelab upgrade",
"short_name": "Upgrade",
"start_url": "./#/overview",
"display": "standalone",
"background_color": "#0d1117",
"theme_color": "#0d1117",
"icons": [{ "src": "icon.svg", "sizes": "any", "type": "image/svg+xml", "purpose": "any" }]
}
+2
View File
@@ -23,6 +23,8 @@ describe('parseRoute', () => {
['#/instances', { view: 'runs' }],
['#/instances/inst-123', { view: 'run', runId: 'inst-123' }],
['#/models', { view: 'models' }],
['#/settings', { view: 'settings' }],
['#/settings/x', { view: 'not_found' }],
['#/nope', { view: 'not_found' }],
['#/runs/a/b', { view: 'not_found' }],
])('%j -> %j', (hash, route) => {
+4
View File
@@ -11,6 +11,7 @@ export type Route =
| { readonly view: 'function'; readonly functionId: string }
| { readonly view: 'function_version'; readonly functionId: string; readonly version: number }
| { readonly view: 'models' }
| { readonly view: 'settings' }
| { readonly view: 'not_found' };
const NOT_FOUND: Route = { view: 'not_found' };
@@ -58,6 +59,7 @@ export function parseRoute(hash: string): Route {
if (head === 'models' && arg === undefined) {
return { view: 'models' };
}
if (head === 'settings' && arg === undefined) return { view: 'settings' };
return NOT_FOUND;
}
@@ -87,5 +89,7 @@ export function hrefFor(route: Exclude<Route, { view: 'not_found' }>): string {
return `#/functions/${encodeURIComponent(route.functionId)}/v/${String(route.version)}`;
case 'models':
return '#/models';
case 'settings':
return '#/settings';
}
}
+247
View File
@@ -0,0 +1,247 @@
import { describe, expect, it } from 'vitest';
import { appCondition, appsWithUpdate, costPerDay, ganttBars, hostSummary, lastDays, recentAppRuns, runDurationMs, runDurations, runsPerDay, successRate, troubleCount, versionHistory } from './stats.ts';
import type { WorkflowInstanceSummaryResponse, WorkflowModelCallResponse } from '../http/contract.ts';
const NOW = Date.parse('2026-09-30T12:00:00.000Z');
const UTC = { now: NOW, timeZone: 'UTC' } as const;
function run(overrides: Partial<WorkflowInstanceSummaryResponse>): WorkflowInstanceSummaryResponse {
return {
id: 'r',
workflowId: 'update-demo',
workflowVersion: 1,
status: 'succeeded',
dueAt: '2026-09-30T10:00:00.000Z',
createdAt: '2026-09-30T10:00:00.000Z',
updatedAt: '2026-09-30T10:05:00.000Z',
seq: 1,
currentNode: null,
mode: 'shadow',
costUsd: 0,
app: 'demo',
step: null,
outcome: null,
current: null,
candidate: null,
running: null,
...overrides,
};
}
function call(overrides: Partial<WorkflowModelCallResponse>): WorkflowModelCallResponse {
return {
instanceId: 'r',
workflowId: 'update-demo',
nodeId: 'notes',
kind: 'releaseNotes',
model: 'claude-sonnet-5',
outcome: 'clear',
summary: '',
actions: [],
costUsd: 0.1,
at: '2026-09-30T10:00:00.000Z',
...overrides,
};
}
describe('lastDays', () => {
it('lists the days of the window oldest first, ending today in the given time zone', () => {
expect(lastDays(3, UTC)).toEqual(['2026-09-28', '2026-09-29', '2026-09-30']);
// 12:00 UTC is already the next day in Auckland.
expect(lastDays(1, { now: NOW, timeZone: 'Pacific/Auckland' })).toEqual(['2026-10-01']);
});
});
describe('runsPerDay', () => {
it('counts runs by the day they started and by what they are now; queued counts as in progress', () => {
const days = runsPerDay(
[
run({ status: 'succeeded', createdAt: '2026-09-30T01:00:00.000Z' }),
run({ status: 'failed', createdAt: '2026-09-30T02:00:00.000Z' }),
run({ status: 'queued', createdAt: '2026-09-29T02:00:00.000Z' }),
run({ status: 'waiting', createdAt: '2026-09-29T03:00:00.000Z' }),
run({ status: 'succeeded', createdAt: '2026-09-01T03:00:00.000Z' }), // outside the window
],
{ ...UTC, days: 2 },
);
expect(days).toEqual([
{ day: '2026-09-29', succeeded: 0, failed: 0, cancelled: 0, waiting: 1, running: 1 },
{ day: '2026-09-30', succeeded: 1, failed: 1, cancelled: 0, waiting: 0, running: 0 },
]);
});
});
describe('successRate', () => {
it('is the share of succeeded among succeeded and failed runs that ended in the window, with the window before it', () => {
const rate = successRate(
[
run({ status: 'succeeded', updatedAt: '2026-09-29T00:00:00.000Z' }),
run({ status: 'succeeded', updatedAt: '2026-09-28T00:00:00.000Z' }),
run({ status: 'succeeded', updatedAt: '2026-09-27T00:00:00.000Z' }),
run({ status: 'failed', updatedAt: '2026-09-26T00:00:00.000Z' }),
run({ status: 'cancelled', updatedAt: '2026-09-26T00:00:00.000Z' }), // the owner's choice, not a failure
run({ status: 'failed', updatedAt: '2026-09-20T00:00:00.000Z' }), // the window before
run({ status: 'running', updatedAt: '2026-09-29T00:00:00.000Z' }),
],
{ now: NOW, days: 7 },
);
expect(rate).toEqual({ rate: 0.75, previous: 0, ended: 4 });
});
it('has no rate when nothing ended', () => {
expect(successRate([], { now: NOW, days: 7 })).toEqual({ rate: null, previous: null, ended: 0 });
});
});
describe('runDurationMs', () => {
it('measures ended runs only', () => {
expect(runDurationMs(run({ status: 'failed' }))).toBe(5 * 60 * 1000);
expect(runDurationMs(run({ status: 'running' }))).toBeNull();
});
});
describe('recentAppRuns', () => {
it('keeps the newest runs of one app, oldest first so they read left to right', () => {
const runs = [run({ id: 'c', app: 'demo' }), run({ id: 'x', app: 'other' }), run({ id: 'b', app: 'demo' }), run({ id: 'a', app: 'demo' })];
expect(recentAppRuns(runs, 'demo', 2).map((item) => item.id)).toEqual(['b', 'c']);
});
});
describe('appsWithUpdate', () => {
it('lists enabled apps whose latest run found a newer image than the one running', () => {
const runs = [
run({ app: 'a', running: '1.0', candidate: '1.1' }),
run({ app: 'a', running: '0.9', candidate: '0.9' }), // older run of a: ignored
run({ app: 'b', running: '2.0', candidate: '2.0' }),
run({ app: 'c', running: '3.0', candidate: '3.1' }),
];
const apps = [
{ app: 'a', enabled: true },
{ app: 'b', enabled: true },
{ app: 'c', enabled: false },
];
expect(appsWithUpdate(apps, runs)).toEqual(['a']);
});
});
describe('costPerDay', () => {
it('sums each model’s cost per day over the window, most expensive model first', () => {
const cost = costPerDay(
[
call({ model: 'haiku', costUsd: 0.01, at: '2026-09-30T01:00:00.000Z' }),
call({ model: 'sonnet', costUsd: 0.2, at: '2026-09-30T02:00:00.000Z' }),
call({ model: 'sonnet', costUsd: 0.3, at: '2026-09-29T02:00:00.000Z' }),
call({ model: null, costUsd: 0.05, at: '2026-09-29T03:00:00.000Z' }),
call({ model: 'sonnet', costUsd: 9, at: '2026-08-01T00:00:00.000Z' }), // outside
],
{ ...UTC, days: 2 },
);
expect(cost.days).toEqual(['2026-09-29', '2026-09-30']);
expect(cost.series).toEqual([
{ model: 'sonnet', values: [0.3, 0.2], total: 0.5 },
{ model: 'unknown', values: [0.05, 0], total: 0.05 },
{ model: 'haiku', values: [0, 0.01], total: 0.01 },
]);
expect(cost.total).toBeCloseTo(0.56);
});
});
describe('runDurations', () => {
it('keeps the newest ended runs, oldest first, in minutes', () => {
const runs = [
run({ id: 'd', status: 'running' }),
run({ id: 'c', status: 'failed', updatedAt: '2026-09-30T10:10:00.000Z' }),
run({ id: 'b', status: 'succeeded' }),
run({ id: 'a', status: 'succeeded' }),
];
expect(runDurations(runs, 2)).toEqual([
{ id: 'b', startedAt: '2026-09-30T10:00:00.000Z', status: 'succeeded', minutes: 5 },
{ id: 'c', startedAt: '2026-09-30T10:00:00.000Z', status: 'failed', minutes: 10 },
]);
});
});
describe('ganttBars', () => {
const attempt = (nodeId: string, timestamp: string, durationMs: number | null, status: 'running' | 'succeeded' | 'failed' | 'waiting') => ({
type: 'NODE_ATTEMPT',
nodeId,
timestamp,
durationMs,
status,
});
it('draws each attempt from its start to its end, and an unfinished one to now', () => {
const bars = ganttBars(
[
{ type: 'INSTANCE_CREATED', nodeId: null, timestamp: '2026-09-30T11:00:00.000Z', durationMs: null, status: 'info' as const },
attempt('verify', '2026-09-30T11:00:00.000Z', 2000, 'failed'),
attempt('verify', '2026-09-30T11:01:00.000Z', 1000, 'succeeded'),
attempt('gate', '2026-09-30T11:02:00.000Z', null, 'waiting'),
],
NOW,
);
expect(bars).toEqual([
{ nodeId: 'verify', start: Date.parse('2026-09-30T11:00:00.000Z'), end: Date.parse('2026-09-30T11:00:02.000Z'), status: 'failed', open: false },
{ nodeId: 'verify', start: Date.parse('2026-09-30T11:01:00.000Z'), end: Date.parse('2026-09-30T11:01:01.000Z'), status: 'succeeded', open: false },
{ nodeId: 'gate', start: Date.parse('2026-09-30T11:02:00.000Z'), end: NOW, status: 'waiting', open: true },
]);
});
});
describe('appCondition and hostSummary', () => {
const app = (name: string, state: string, health: string | null, enabled = true) => ({ app: name, enabled, state, health });
it('calls an app down when its container restarts, stopped, is missing or unhealthy; a disabled one is off', () => {
expect(appCondition(app('a', 'running', null))).toBe('ok');
expect(appCondition(app('a', 'running', 'healthy'))).toBe('ok');
expect(appCondition(app('a', 'running', 'starting'))).toBe('starting');
expect(appCondition(app('a', 'running', 'unhealthy'))).toBe('down');
expect(appCondition(app('a', 'restarting', null))).toBe('down');
expect(appCondition(app('a', 'missing', null))).toBe('down');
expect(appCondition(app('a', 'exited', null, false))).toBe('off');
});
it('counts enabled apps that run well and lists the others, down first', () => {
const summary = hostSummary(
{
checkedAt: '2026-09-30T11:57:30.000Z',
apps: [app('ombi', 'running', 'starting'), app('jellyfin', 'running', 'healthy'), app('bazarr', 'exited', null), app('old', 'exited', null, false)],
},
NOW,
);
expect(summary).toEqual({
up: 1,
total: 3,
problems: [
{ app: 'bazarr', condition: 'down', state: 'exited' },
{ app: 'ombi', condition: 'starting', state: 'running' },
],
ageMinutes: 2,
});
});
});
describe('versionHistory', () => {
it('keeps real ended runs as updates, runs that left the app as it was, and failures, with repairs and release notes', () => {
const runs = [
run({ id: 'dry', mode: 'shadow', status: 'succeeded', current: '1.2', candidate: '1.3' }),
run({ id: 'live', mode: 'real', status: 'running', current: '1.2', candidate: '1.3' }),
run({ id: 'up', mode: 'real', status: 'succeeded', current: '1.1', candidate: '1.2', running: '1.2', outcome: 'Updated' }),
run({ id: 'back', mode: 'real', status: 'succeeded', current: '1.1', candidate: '1.2', running: '1.1', outcome: 'Rolled back' }),
run({ id: 'bad', mode: 'real', status: 'failed', current: '1.0', candidate: '1.1', running: '1.0', outcome: 'Verify failed' }),
];
const calls = [
call({ instanceId: 'up', kind: 'releaseNotes', outcome: 'clear', summary: 'Bug fixes.' }),
call({ instanceId: 'up', kind: 'repair', outcome: 'fixed', summary: '' }),
];
const history = versionHistory(runs, calls);
expect(history.map((entry) => [entry.runId, entry.kind, entry.from, entry.to, entry.repaired])).toEqual([
['up', 'updated', '1.1', '1.2', true],
['back', 'kept', '1.1', '1.2', false],
['bad', 'failed', '1.0', '1.1', false],
]);
expect(history[0]?.releaseNotes).toEqual({ verdict: 'clear', summary: 'Bug fixes.' });
expect(troubleCount(history)).toBe(3);
expect(troubleCount(history, 1)).toBe(1);
});
});
+243
View File
@@ -0,0 +1,243 @@
import { runVersions } from './format.ts';
import type { WorkflowInstanceSummaryResponse, WorkflowModelCallResponse, WorkflowTimelineItem } from '../http/contract.ts';
/** What the charts count from the lists the API already serves; pure, so every number is tested without a browser. */
const DAY_MS = 24 * 60 * 60 * 1000;
const ENDED = new Set(['succeeded', 'failed', 'cancelled']);
export interface Window {
readonly now: number;
/** For tests; the browser's own time zone otherwise. */
readonly timeZone?: string;
}
function dayOf(time: number, timeZone: string | undefined): string {
// The en-CA locale prints dates as YYYY-MM-DD.
return new Intl.DateTimeFormat('en-CA', { year: 'numeric', month: '2-digit', day: '2-digit', ...(timeZone === undefined ? {} : { timeZone }) }).format(time);
}
/** The last `days` calendar days, oldest first, ending today. */
export function lastDays(days: number, window: Window): string[] {
const out: string[] = [];
for (let back = days - 1; back >= 0; back -= 1) out.push(dayOf(window.now - back * DAY_MS, window.timeZone));
return out;
}
export type RunBucket = 'succeeded' | 'failed' | 'cancelled' | 'waiting' | 'running';
export const RUN_BUCKETS: readonly RunBucket[] = ['succeeded', 'failed', 'cancelled', 'waiting', 'running'];
export type DayRuns = { readonly day: string } & Readonly<Record<RunBucket, number>>;
function bucketOf(status: string): RunBucket | null {
if (status === 'queued' || status === 'running') return 'running';
return (RUN_BUCKETS as readonly string[]).includes(status) ? (status as RunBucket) : null;
}
/** Runs per day they started, split by what each run is now. */
export function runsPerDay(runs: readonly WorkflowInstanceSummaryResponse[], window: Window & { readonly days: number }): DayRuns[] {
const days = lastDays(window.days, window);
const counts = new Map(days.map((day) => [day, { succeeded: 0, failed: 0, cancelled: 0, waiting: 0, running: 0 }]));
for (const run of runs) {
const bucket = bucketOf(run.status);
const day = counts.get(dayOf(Date.parse(run.createdAt), window.timeZone));
if (bucket !== null && day !== undefined) day[bucket] += 1;
}
return days.map((day) => ({ day, ...counts.get(day)! }));
}
export interface SuccessRate {
/** Succeeded / (succeeded + failed) of runs that ended in the window; null when none did. */
readonly rate: number | null;
/** The same over the window just before, to show the trend. */
readonly previous: number | null;
readonly ended: number;
}
function rateBetween(runs: readonly WorkflowInstanceSummaryResponse[], from: number, to: number) {
let succeeded = 0;
let failed = 0;
for (const run of runs) {
const at = Date.parse(run.updatedAt);
if (at < from || at >= to) continue;
if (run.status === 'succeeded') succeeded += 1;
else if (run.status === 'failed') failed += 1;
}
const ended = succeeded + failed;
return { rate: ended === 0 ? null : succeeded / ended, ended };
}
/** Cancelled runs are the owner's choice, so they count neither for nor against. */
export function successRate(runs: readonly WorkflowInstanceSummaryResponse[], window: { readonly now: number; readonly days: number }): SuccessRate {
const span = window.days * DAY_MS;
const current = rateBetween(runs, window.now - span, window.now + 1);
const previous = rateBetween(runs, window.now - 2 * span, window.now - span);
return { rate: current.rate, previous: previous.rate, ended: current.ended };
}
export function runDurationMs(run: WorkflowInstanceSummaryResponse): number | null {
if (!ENDED.has(run.status)) return null;
const ms = Date.parse(run.updatedAt) - Date.parse(run.createdAt);
return Number.isNaN(ms) || ms < 0 ? null : ms;
}
/** An app's newest `count` runs from a newest-first list, oldest first so they read left to right. */
export function recentAppRuns(runs: readonly WorkflowInstanceSummaryResponse[], app: string, count: number): WorkflowInstanceSummaryResponse[] {
return runs
.filter((run) => run.app === app)
.slice(0, count)
.reverse();
}
/** Enabled apps whose latest run found a newer image than the one they run. */
export function appsWithUpdate(apps: readonly { readonly app: string; readonly enabled: boolean }[], runs: readonly WorkflowInstanceSummaryResponse[]): string[] {
return apps
.filter((entry) => entry.enabled && runVersions(runs.find((run) => run.app === entry.app) ?? null).available !== null)
.map((entry) => entry.app);
}
export interface ModelCost {
readonly model: string;
/** One value per day of `days`. */
readonly values: readonly number[];
readonly total: number;
}
export interface CostPerDay {
readonly days: readonly string[];
/** Most expensive model first. */
readonly series: readonly ModelCost[];
readonly total: number;
}
export function costPerDay(calls: readonly WorkflowModelCallResponse[], window: Window & { readonly days: number }): CostPerDay {
const days = lastDays(window.days, window);
const index = new Map(days.map((day, position) => [day, position]));
const byModel = new Map<string, number[]>();
for (const call of calls) {
const position = index.get(dayOf(Date.parse(call.at), window.timeZone));
if (position === undefined) continue;
const model = call.model ?? 'unknown';
const values = byModel.get(model) ?? days.map(() => 0);
values[position] = (values[position] ?? 0) + call.costUsd;
byModel.set(model, values);
}
const series = [...byModel.entries()]
.map(([model, values]) => ({ model, values, total: values.reduce((sum, value) => sum + value, 0) }))
.sort((a, b) => b.total - a.total);
return { days, series, total: series.reduce((sum, item) => sum + item.total, 0) };
}
/** Ended runs of a newest-first list with their duration, oldest first, at most `count`. */
export function runDurations(runs: readonly WorkflowInstanceSummaryResponse[], count: number): { id: string; startedAt: string; status: RunBucket; minutes: number }[] {
const out: { id: string; startedAt: string; status: RunBucket; minutes: number }[] = [];
for (const run of runs) {
const ms = runDurationMs(run);
const status = bucketOf(run.status);
if (ms === null || status === null) continue;
out.push({ id: run.id, startedAt: run.createdAt, status, minutes: ms / 60_000 });
if (out.length === count) break;
}
return out.reverse();
}
export type AttemptStatus = 'running' | 'succeeded' | 'failed' | 'waiting';
export interface GanttBar {
readonly nodeId: string;
readonly start: number;
readonly end: number;
readonly status: AttemptStatus;
/** Still going (or waiting): the bar reaches to now. */
readonly open: boolean;
}
/** Every node attempt of a run as a bar from its start to its end, or to now while it lasts. */
export function ganttBars(items: readonly Pick<WorkflowTimelineItem, 'type' | 'nodeId' | 'timestamp' | 'durationMs' | 'status'>[], now: number): GanttBar[] {
const bars: GanttBar[] = [];
for (const item of items) {
if (item.type !== 'NODE_ATTEMPT' || item.nodeId === null || item.status === 'info') continue;
const start = Date.parse(item.timestamp);
if (Number.isNaN(start)) continue;
const open = item.durationMs === null;
bars.push({ nodeId: item.nodeId, start, end: open ? Math.max(now, start) : start + (item.durationMs ?? 0), status: item.status, open });
}
return bars;
}
export type AppCondition = 'ok' | 'starting' | 'down' | 'off';
/** How an app is doing right now, from its container: down covers restarting, stopped, missing and unhealthy. */
export function appCondition(health: { readonly enabled: boolean; readonly state: string; readonly health: string | null }): AppCondition {
if (health.state === 'running') return health.health === 'unhealthy' ? 'down' : health.health === 'starting' ? 'starting' : 'ok';
return health.enabled ? 'down' : 'off';
}
export interface HostSummary {
/** Enabled apps whose container runs well, of all enabled apps. */
readonly up: number;
readonly total: number;
/** Enabled apps that are down or still starting, worst first. */
readonly problems: readonly { readonly app: string; readonly condition: AppCondition; readonly state: string }[];
/** Minutes since the driver last looked: an old look means the driver is not looking. */
readonly ageMinutes: number;
}
export function hostSummary(status: { readonly checkedAt: string; readonly apps: readonly { readonly app: string; readonly enabled: boolean; readonly state: string; readonly health: string | null }[] }, now: number): HostSummary {
const enabled = status.apps.filter((app) => app.enabled);
const problems = enabled
.map((app) => ({ app: app.app, condition: appCondition(app), state: app.health === 'unhealthy' ? 'unhealthy' : app.state }))
.filter((app) => app.condition !== 'ok')
.sort((a, b) => (a.condition === b.condition ? a.app.localeCompare(b.app) : a.condition === 'down' ? -1 : 1));
return { up: enabled.length - problems.length, total: enabled.length, problems, ageMinutes: Math.max(0, Math.floor((now - Date.parse(status.checkedAt)) / 60_000)) };
}
export type HistoryKind = 'updated' | 'kept' | 'failed';
export interface HistoryEntry {
readonly runId: string;
readonly at: string;
readonly kind: HistoryKind;
readonly from: string | null;
readonly to: string | null;
readonly outcome: string | null;
/** The model repaired the app during this run. */
readonly repaired: boolean;
/** What the model read in the release notes before the update, when it was asked. */
readonly releaseNotes: { readonly verdict: string; readonly summary: string } | null;
}
type HistoryCall = Pick<WorkflowModelCallResponse, 'instanceId' | 'kind' | 'outcome' | 'summary'>;
/**
* An app's real runs as its version history, newest first: an update (from → to), a run that ended but left the app as
* it was (skipped, rolled back), or a failure. Dry runs and runs in progress change nothing and are left out.
*/
export function versionHistory(runs: readonly WorkflowInstanceSummaryResponse[], calls: readonly HistoryCall[]): HistoryEntry[] {
const out: HistoryEntry[] = [];
for (const run of runs) {
if (run.mode !== 'real' || !ENDED.has(run.status)) continue;
const kind: HistoryKind = run.status === 'failed' ? 'failed' : run.status === 'succeeded' && run.running !== null && run.running !== run.current ? 'updated' : 'kept';
const own = calls.filter((call) => call.instanceId === run.id);
const notes = own.find((call) => call.kind === 'releaseNotes');
out.push({
runId: run.id,
at: run.updatedAt,
kind,
from: run.current,
to: kind === 'updated' ? run.running : run.candidate,
outcome: run.outcome,
repaired: own.some((call) => call.kind === 'repair'),
releaseNotes: notes === undefined ? null : { verdict: notes.outcome, summary: notes.summary },
});
}
return out;
}
/** How many of the app's last `window` real runs did not simply update it: failed, left as it was, or needed a repair. */
export function troubleCount(history: readonly HistoryEntry[], window = 10): number {
return history.slice(0, window).filter((entry) => entry.kind !== 'updated' || entry.repaired).length;
}
/** Two troubled runs among the last ten make an app worth a closer look. */
export const TROUBLE_THRESHOLD = 2;
+1197 -85
View File
File diff suppressed because it is too large. Load diff
+15
View File
@@ -0,0 +1,15 @@
import { describe, expect, it } from 'vitest';
import { initialTheme } from './theme.ts';
describe('initialTheme', () => {
it('keeps a saved choice over the system preference', () => {
expect(initialTheme('light', false)).toBe('light');
expect(initialTheme('dark', true)).toBe('dark');
});
it('follows the system when nothing valid is saved, dark by default', () => {
expect(initialTheme(null, true)).toBe('light');
expect(initialTheme(null, false)).toBe('dark');
expect(initialTheme('sepia', false)).toBe('dark');
});
});
+49
View File
@@ -0,0 +1,49 @@
import { useEffect, useState } from 'preact/hooks';
export type Theme = 'dark' | 'light';
const KEY = 'homelab-upgrade.theme';
/** The owner's choice if one was saved, else the system's preference; dark when the system says nothing. */
export function initialTheme(saved: string | null, prefersLight: boolean): Theme {
if (saved === 'dark' || saved === 'light') return saved;
return prefersLight ? 'light' : 'dark';
}
function savedTheme(): string | null {
try {
return window.localStorage.getItem(KEY);
} catch {
return null; // storage can be blocked (private window); the system preference decides
}
}
function prefersLight(): boolean {
return typeof window.matchMedia === 'function' && window.matchMedia('(prefers-color-scheme: light)').matches;
}
/** Sets the theme on <html> before the first render, so the page does not flash the other theme. */
export function applyInitialTheme(): Theme {
const theme = initialTheme(savedTheme(), prefersLight());
document.documentElement.dataset['theme'] = theme;
return theme;
}
/** The current theme and a toggle that applies it to <html> and remembers it in this browser. */
export function useTheme(): { theme: Theme; toggle: () => void } {
const [theme, setTheme] = useState<Theme>(() => (document.documentElement.dataset['theme'] === 'light' ? 'light' : 'dark'));
useEffect(() => {
document.documentElement.dataset['theme'] = theme;
window.dispatchEvent(new Event('themechange'));
}, [theme]);
const toggle = () => {
const next: Theme = theme === 'dark' ? 'light' : 'dark';
try {
window.localStorage.setItem(KEY, next);
} catch {
// not remembered; the toggle still works for this page
}
setTheme(next);
};
return { theme, toggle };
}
+370 -52
View File
@@ -1,13 +1,23 @@
import { Empty, ErrorNote, Loading, ModeTag, RunBadge } from '../components.tsx';
import { useContext, useState } from 'preact/hooks';
import { useConfirm, useToast } from '../components/dialogs.tsx';
import { Empty, ErrorNote, Loading, ModeTag, RunBadge, When } from '../components.tsx';
import { Card, Icon } from '../components/ui.tsx';
import { Chart } from '../components/chart.tsx';
import { durationOption } from '../charts/options.ts';
import { runStatusLabel } from '../labels.ts';
import { appCondition, appsWithUpdate, recentAppRuns, runDurations, troubleCount, TROUBLE_THRESHOLD, versionHistory } from '../stats.ts';
import type { AppCondition, HistoryKind } from '../stats.ts';
import { api } from '../api.ts';
import { formatCost, formatInstant, runVersions } from '../format.ts';
import { useResource } from '../hooks.ts';
import { LiveContext, useResource } from '../hooks.ts';
import { hrefFor } from '../route.ts';
import { AppConfigPanel } from './app-config.tsx';
import { SkillPanel } from './skill.tsx';
import { RunButton, ScheduleCell } from './workflows.tsx';
import { appWorkflowId } from '../../domain/workflow-run-summary.ts';
import type { WorkflowInstanceSummaryResponse } from '../../http/contract.ts';
import type { AppOverview, WorkflowInstanceSummaryResponse, WorkflowModelCallResponse } from '../../http/contract.ts';
type HistoryCall = Pick<WorkflowModelCallResponse, 'instanceId' | 'kind' | 'outcome' | 'summary'>;
/** A workflow run's status and where it is or ended (terminal_updated, terminal_dry_run, a gate…). */
function RunStatus({ run }: { readonly run: WorkflowInstanceSummaryResponse }) {
@@ -21,79 +31,380 @@ function RunStatus({ run }: { readonly run: WorkflowInstanceSummaryResponse }) {
);
}
type AppsLayout = 'cards' | 'table';
const LAYOUT_KEY = 'homelab-upgrade.apps-layout';
const RECENT_RUNS = 10;
function savedLayout(): AppsLayout {
try {
return window.localStorage.getItem(LAYOUT_KEY) === 'table' ? 'table' : 'cards';
} catch {
return 'cards';
}
}
/** Cards to look around, the table to compare; the owner's choice is remembered in this browser. */
function useLayout(): [AppsLayout, (layout: AppsLayout) => void] {
const [layout, setLayout] = useState<AppsLayout>(savedLayout);
const choose = (next: AppsLayout) => {
try {
window.localStorage.setItem(LAYOUT_KEY, next);
} catch {
// not remembered; the switch still works on this page
}
setLayout(next);
};
return [layout, choose];
}
type AppHealthData = NonNullable<Awaited<ReturnType<typeof api.hostStatus>>['status']>['apps'][number];
interface AppsData {
readonly apps: readonly AppOverview[];
readonly runs: readonly WorkflowInstanceSummaryResponse[] | null;
readonly nextRun: (app: string) => string | null;
/** The app's container as the driver last saw it; null when unknown. */
readonly health: (app: string) => AppHealthData | null;
/** Real runs among the app's last ten that failed, left it as it was or needed a repair. */
readonly trouble: (app: string) => number;
}
const CONDITION_LABEL: Readonly<Record<AppCondition, string>> = { ok: 'running', starting: 'starting', down: 'down', off: 'off' };
/** A dot for how the app's container runs right now. */
function HealthDot({ health }: { readonly health: AppHealthData | null }) {
if (health === null) return null;
const condition = appCondition(health);
const title = `${CONDITION_LABEL[condition]}: ${health.state}${health.health === null ? '' : `, ${health.health}`}`;
return (
<>
<span class={`health-dot health-${condition}`} title={title}>
<span class="sr-only">{title}</span>
</span>{' '}
</>
);
}
/** The last runs of an app as dots, oldest on the left, each a link to its run. */
function RunDots({ runs }: { readonly runs: readonly WorkflowInstanceSummaryResponse[] }) {
if (runs.length === 0) return <span class="muted">never ran</span>;
return (
<span class="run-dots" aria-label={`Last ${String(runs.length)} runs`}>
{runs.map((run) => (
<a key={run.id} href={hrefFor({ view: 'run', runId: run.id })} class={`run-dot dot-${run.status}`} title={`${runStatusLabel(run.status)} · ${run.outcome ?? run.step ?? ''} · ${formatInstant(run.createdAt)}`}>
<span class="sr-only">{runStatusLabel(run.status)}</span>
</a>
))}
</span>
);
}
function Versions({ run }: { readonly run: WorkflowInstanceSummaryResponse | null }) {
const versions = runVersions(run);
if (versions.running === null && versions.available === null) return <span class="muted">version not known yet</span>;
return (
<span class="versions">
<code class="version">{versions.running ?? '?'}</code>
{versions.available === null ? (
<span class="version-current" title="No newer image">
<Icon name="check" size={14} /> current
</span>
) : (
<>
<Icon name="arrow" size={14} />
<code class="version version-new">{versions.available}</code>
</>
)}
</span>
);
}
function AppCard({ group, data }: { readonly group: readonly AppOverview[]; readonly data: AppsData }) {
const [first, ...members] = group;
if (first === undefined) return null;
const latest = (app: string) => data.runs?.find((run) => run.app === app) ?? null;
const run = latest(first.app);
const hasUpdate = runVersions(run).available !== null;
return (
<article class={`app-card${first.enabled ? '' : ' app-off'}${hasUpdate ? ' app-update' : ''}`}>
<header class="app-card-head">
<span class="app-avatar" aria-hidden="true">{first.app.slice(0, 2)}</span>
<div class="app-card-title">
<span>
<HealthDot health={data.health(first.app)} /><a href={hrefFor({ view: 'app', app: first.app })}>{first.app}</a>
</span>
<span class="muted">
{first.enabled ? `auto: ${first.auto ?? '—'}` : first.configured ? 'disabled' : 'not configured'}
</span>
</div>
<ModeTag mode={first.apply ? 'real' : 'shadow'} />
</header>
<Versions run={run} />
{data.trouble(first.app) >= TROUBLE_THRESHOLD ? (
<span class="badge badge-warning" title="Failed, not updated or repaired by the model in several of the last ten real runs">
often needs care
</span>
) : null}
{members.map((member) => (
<div key={member.app} class="app-member">
<span>
<HealthDot health={data.health(member.app)} /><a href={hrefFor({ view: 'app', app: member.app })}>└ {member.service ?? member.app}</a>
</span>
<Versions run={latest(member.app)} />
</div>
))}
<div class="app-card-runs">
<span class="muted">Last runs</span>
{data.runs === null ? <span class="muted">…</span> : <RunDots runs={recentAppRuns(data.runs, first.app, RECENT_RUNS)} />}
</div>
<footer class="app-card-foot">
<span class="muted">
<Icon name="clock" size={14} /> <When iso={data.nextRun(first.app)} />
</span>
{first.enabled ? <RunButton app={first.app} mode="shadow" /> : null}
</footer>
</article>
);
}
/** Later components of a project sit with its first one (they run after it). */
function byProject(apps: readonly AppOverview[]): AppOverview[][] {
const groups: AppOverview[][] = [];
for (const [index, entry] of apps.entries()) {
const last = groups.at(-1);
if (index > 0 && last !== undefined && apps[index - 1]?.project === entry.project) last.push(entry);
else groups.push([entry]);
}
return groups;
}
function AppCards({ data }: { readonly data: AppsData }) {
return (
<div class="app-grid">
{byProject(data.apps).map((group) => (
<AppCard key={group[0]?.app} group={group} data={data} />
))}
</div>
);
}
function AppsTable({ data }: { readonly data: AppsData }) {
// Newest first, so the first run of an app's workflow is its latest.
const latestRun = (app: string) => data.runs?.find((run) => run.app === app) ?? null;
return (
<div class="table-wrap">
<table>
<thead>
<tr>
<th>App</th>
<th class="col-secondary">Auto</th>
<th class="col-secondary">Updates</th>
<th>Running</th>
<th>Available</th>
<th class="col-secondary">Last run</th>
<th>Next run</th>
</tr>
</thead>
<tbody>
{data.apps.map((entry, index) => {
// Later components of a project sit under its first one: `└ influxdb` (they run after it).
const member = index > 0 && data.apps[index - 1]?.project === entry.project;
const run = latestRun(entry.app);
const versions = runVersions(run);
return (
<tr key={entry.app} class={[entry.enabled ? '' : 'row-off', member ? 'row-member' : ''].join(' ').trim()}>
<td>
<HealthDot health={data.health(entry.app)} />
<a href={hrefFor({ view: 'app', app: entry.app })}>{member ? `└ ${entry.service ?? entry.app}` : entry.app}</a>
{entry.enabled ? null : <span class="muted"> · {entry.configured ? 'disabled' : 'not configured'}</span>}
</td>
<td class="col-secondary">{entry.auto ?? '—'}</td>
<td class="apply col-secondary"><ModeTag mode={entry.apply ? 'real' : 'shadow'} /></td>
<td>{versions.running ?? '—'}</td>
<td>{versions.available ?? '—'}</td>
<td class="col-secondary">
{run !== null ? <RunStatus run={run} /> : <span class="muted">never ran</span>}
</td>
<td>{formatInstant(data.nextRun(entry.app))}</td>
</tr>
);
})}
</tbody>
</table>
</div>
);
}
/**
* Starts a dry run of every enabled app whose latest run found a newer image, one after another. A dry run changes
* nothing; an app with a run still active is skipped and counted.
*/
function DryRunUpdates({ apps, runs }: { readonly apps: readonly AppOverview[]; readonly runs: readonly WorkflowInstanceSummaryResponse[] }) {
const confirm = useConfirm();
const toast = useToast();
const { refresh } = useContext(LiveContext);
const [busy, setBusy] = useState(false);
const targets = appsWithUpdate(apps, runs);
if (targets.length === 0) return null;
const start = async () => {
const ok = await confirm({
title: `Dry run ${String(targets.length)} ${targets.length === 1 ? 'app' : 'apps'} with an update?`,
message: `${targets.join(', ')}. A dry run checks the new version and the release notes and changes nothing.`,
confirmLabel: 'Start dry runs',
});
if (!ok) return;
setBusy(true);
let started = 0;
const skipped: string[] = [];
for (const app of targets) {
try {
await api.startWorkflowRun(app, 'shadow');
started += 1;
} catch {
skipped.push(app);
}
}
setBusy(false);
refresh();
toast(skipped.length === 0 ? `Started ${String(started)} dry runs.` : `Started ${String(started)} dry runs; skipped ${skipped.join(', ')} (a run is still active).`, skipped.length === 0 ? 'ok' : 'error');
};
return (
<button type="button" disabled={busy} onClick={() => void start()}>
<Icon name="upgrade" size={16} /> {busy ? 'Starting…' : `Dry run ${String(targets.length)} with updates`}
</button>
);
}
export function AppsView() {
const { data, error } = useResource(() => api.apps(), []);
const runs = useResource(() => api.workflowInstances(), []);
const schedules = useResource(() => api.workflowSchedules(), []);
// Newest first, so the first run of an app's workflow is its latest.
const latestRun = (app: string) => runs.data?.find((run) => run.app === app) ?? null;
const host = useResource(() => api.hostStatus(), []);
const [layout, setLayout] = useLayout();
const health = (app: string) => host.data?.status?.apps.find((entry) => entry.app === app) ?? null;
const calls = useResource(() => api.modelCalls(500), []);
const trouble = (app: string) => troubleCount(versionHistory((runs.data ?? []).filter((run) => run.app === app), calls.data?.calls ?? []));
const nextRun = (app: string) => schedules.data?.find((entry) => entry.workflowId === appWorkflowId(app))?.next[0] ?? null;
return (
<section>
<h1>Apps</h1>
<div class="page-head">
<div>
<h1>Apps</h1>
<p>What each app runs, what is available and how its updates went.</p>
</div>
<div class="page-actions">
{data === null || runs.data === null ? null : <DryRunUpdates apps={data} runs={runs.data} />}
<div class="segmented" role="group" aria-label="Layout">
<button type="button" aria-pressed={layout === 'cards'} onClick={() => setLayout('cards')}>
<Icon name="grid" size={16} /> Cards
</button>
<button type="button" aria-pressed={layout === 'table'} onClick={() => setLayout('table')}>
<Icon name="list" size={16} /> Table
</button>
</div>
</div>
</div>
{error === null ? null : <ErrorNote message={error} />}
{data === null ? (
error === null ? <Loading /> : null
) : data.length === 0 ? (
<Empty>No apps configured yet.</Empty>
) : layout === 'cards' ? (
<AppCards data={{ apps: data, runs: runs.data, nextRun, health, trouble }} />
) : (
<div class="table-wrap">
<table>
<thead>
<tr>
<th>App</th>
<th class="col-secondary">Auto</th>
<th class="col-secondary">Updates</th>
<th>Running</th>
<th>Available</th>
<th class="col-secondary">Last run</th>
<th>Next run</th>
</tr>
</thead>
<tbody>
{data.map((entry, index) => {
// Later components of a project sit under its first one: `└ influxdb` (they run after it).
const member = index > 0 && data[index - 1]?.project === entry.project;
const run = latestRun(entry.app);
const versions = runVersions(run);
return (
<tr key={entry.app} class={[entry.enabled ? '' : 'row-off', member ? 'row-member' : ''].join(' ').trim()}>
<td>
<a href={hrefFor({ view: 'app', app: entry.app })}>{member ? `└ ${entry.service ?? entry.app}` : entry.app}</a>
{entry.enabled ? null : <span class="muted"> · {entry.configured ? 'disabled' : 'not configured'}</span>}
</td>
<td class="col-secondary">{entry.auto ?? '—'}</td>
<td class="apply col-secondary"><ModeTag mode={entry.apply ? 'real' : 'shadow'} /></td>
<td>{versions.running ?? '—'}</td>
<td>{versions.available ?? '—'}</td>
<td class="col-secondary">
{run !== null ? <RunStatus run={run} /> : <span class="muted">never ran</span>}
</td>
<td>{formatInstant(nextRun(entry.app))}</td>
</tr>
);
})}
</tbody>
</table>
</div>
<AppsTable data={{ apps: data, runs: runs.data, nextRun, health, trouble }} />
)}
</section>
);
}
const DURATION_RUNS = 20;
const HISTORY_LABEL: Readonly<Record<HistoryKind, string>> = { updated: 'Updated', kept: 'Not updated', failed: 'Failed' };
const HISTORY_SHOWN = 15;
/** The app's real runs as a version history: what it ran before and after, and what the release notes said. */
function VersionHistory({ runs, calls }: { readonly runs: readonly WorkflowInstanceSummaryResponse[]; readonly calls: readonly HistoryCall[] }) {
const history = versionHistory(runs, calls);
const trouble = troubleCount(history);
return (
<Card title="Version history" icon="upgrade" actions={trouble >= TROUBLE_THRESHOLD ? <span class="badge badge-warning">{trouble} of the last {Math.min(history.length, 10)} real runs needed care</span> : undefined}>
{history.length === 0 ? (
<p class="muted">No real update yet: dry runs do not change the app.</p>
) : (
<ol class="history">
{history.slice(0, HISTORY_SHOWN).map((entry) => (
<li key={entry.runId} class={`history-${entry.kind}`}>
<span class="history-mark" aria-hidden="true" />
<div class="history-body">
<div class="history-head">
<strong>{HISTORY_LABEL[entry.kind]}</strong>
<span class="versions">
<code class="version">{entry.from ?? '?'}</code>
<Icon name="arrow" size={14} />
<code class={`version${entry.kind === 'updated' ? ' version-new' : ''}`}>{entry.to ?? '?'}</code>
</span>
{entry.repaired ? <span class="badge badge-warning">repaired by the model</span> : null}
<a class="muted history-when" href={hrefFor({ view: 'run', runId: entry.runId })}>
<When iso={entry.at} />
</a>
</div>
{entry.kind === 'updated' || entry.outcome === null ? null : <p class="muted">{entry.outcome}</p>}
{entry.releaseNotes === null ? null : (
<details>
<summary>Release notes: {entry.releaseNotes.verdict}</summary>
<p>{entry.releaseNotes.summary}</p>
</details>
)}
</div>
</li>
))}
</ol>
)}
</Card>
);
}
/** How long the app's last runs took: a run that suddenly takes much longer is worth a look. */
function DurationChart({ runs }: { readonly runs: readonly WorkflowInstanceSummaryResponse[] }) {
const durations = runDurations(runs, DURATION_RUNS);
if (durations.length < 2) return null;
return (
<div class="app-duration">
<h3>Run duration</h3>
<Chart
label={`Duration of the last ${String(durations.length)} ended runs, in minutes`}
height={180}
option={(palette) => durationOption(durations, palette)}
onClick={({ dataIndex }) => {
const run = durations[dataIndex];
if (run !== undefined) window.location.hash = hrefFor({ view: 'run', runId: run.id });
}}
/>
</div>
);
}
export function AppDetailView({ app }: { app: string }) {
const config = useResource(() => api.appConfig(app), [app]);
const runs = useResource(() => api.workflowInstances({ app }), [app]);
const schedules = useResource(() => api.workflowSchedules(), []);
const schedule = schedules.data?.find((entry) => entry.workflowId === appWorkflowId(app));
const calls = useResource(() => api.modelCalls(500), []);
return (
<section>
<p class="crumbs">
<a href={hrefFor({ view: 'apps' })}>Apps</a> / {app}
</p>
<h1>{app}</h1>
<h2>Runs</h2>
<p>
<div class="page-head">
<div class="app-card-head">
<span class="app-avatar" aria-hidden="true">{app.slice(0, 2)}</span>
<h1>{app}</h1>
</div>
{runs.data === null ? null : <Versions run={runs.data[0] ?? null} />}
</div>
<Card title="Runs" icon="runs">
<p class="app-run-actions">
<RunButton app={app} mode="shadow" />{' '}
{config.data?.enabled === false
? <span class="muted">Enable this app in the configuration below to start a real run.</span>
@@ -110,6 +421,8 @@ export function AppDetailView({ app }: { app: string }) {
) : runs.data.length === 0 ? (
<Empty>No workflow runs for this app yet.</Empty>
) : (
<>
<DurationChart runs={runs.data} />
<div class="table-wrap">
<table class="app-runs">
<thead>
@@ -132,11 +445,16 @@ export function AppDetailView({ app }: { app: string }) {
</tbody>
</table>
</div>
</>
)}
<h2>Configuration</h2>
<AppConfigPanel app={app} />
<h2>Skill</h2>
{config.data === null ? null : <SkillPanel project={config.data.project} />}
</Card>
{runs.data === null ? null : <VersionHistory runs={runs.data} calls={calls.data?.calls ?? []} />}
<Card title="Configuration" icon="workflows">
<AppConfigPanel app={app} />
</Card>
<Card title="Skill" icon="models">
{config.data === null ? <Loading /> : <SkillPanel project={config.data.project} />}
</Card>
</section>
);
}
+8 -1
View File
@@ -3,6 +3,7 @@ import { api } from '../api.ts';
import { ErrorNote, Loading } from '../components.tsx';
import { CodeEditor } from '../components/code-editor.tsx';
import { hrefFor } from '../route.ts';
import { useConfirm } from '../components/dialogs.tsx';
import type {
FunctionCapability,
FunctionOutcome,
@@ -25,6 +26,7 @@ export function FunctionEditorView({
readonly version?: number;
}) {
const isVersionView = version !== undefined;
const confirm = useConfirm();
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [draft, setDraft] = useState<{
@@ -173,7 +175,12 @@ export function FunctionEditorView({
const handlePublish = async () => {
if (!draft) return;
const next = (publishedVersions.at(-1)?.version ?? 0) + 1;
if (!window.confirm(`Publish as version ${String(next)}? Workflows that use this function pick it when they are changed to it.`)) return;
const ok = await confirm({
title: `Publish as version ${String(next)}?`,
message: 'Workflows that use this function pick it when they are changed to it.',
confirmLabel: 'Publish',
});
if (!ok) return;
setSaving(true);
setActionError(null);
try {
+43 -3
View File
@@ -9,12 +9,19 @@ import { describeNode } from '../../domain/workflow-node-descriptions.ts';
import { collapseSame, lineDiff, type DiffLine } from '../../domain/line-diff.ts';
import { LiveContext, useResource } from '../hooks.ts';
import { hrefFor } from '../route.ts';
import { useConfirm, useToast } from '../components/dialogs.tsx';
import { Card } from '../components/ui.tsx';
import { Chart } from '../components/chart.tsx';
import { ganttOption, ganttRows } from '../charts/options.ts';
import { ganttBars } from '../stats.ts';
import type { DecisionContextResponse, ModelSessionResponse, WorkflowTimelineItem } from '../../http/contract.ts';
import { ASSESSMENT_VERDICT } from '../../domain/agent-report.ts';
import { WorkflowDocumentV2 } from '../../domain/workflow-graph.ts';
export function InstanceTimelineView({ instanceId }: { readonly instanceId: string }) {
const { refresh } = useContext(LiveContext);
const confirm = useConfirm();
const toast = useToast();
const { data: timeline, error } = useResource(() => api.workflowTimeline(instanceId), [instanceId]);
const document = useWorkflowDocument(timeline?.workflowId ?? null, timeline?.workflowVersion ?? null);
const nodeStates = useMemo(() => (timeline ? nodeRunStates(timeline) : {}), [timeline]);
@@ -27,7 +34,7 @@ export function InstanceTimelineView({ instanceId }: { readonly instanceId: stri
const handleSubmitDecision = async (action: string) => {
if (!timeline) return;
const question = CONFIRM_ACTION[action];
if (question !== undefined && !window.confirm(question)) return;
if (question !== undefined && !(await confirm({ title: question, confirmLabel: ACTION_LABELS[action] ?? action, danger: true }))) return;
setSubmitting(true);
setSubmitError(null);
setSubmitSuccess(null);
@@ -63,11 +70,13 @@ export function InstanceTimelineView({ instanceId }: { readonly instanceId: stri
};
const handleCancel = async () => {
if (!timeline || !window.confirm('Cancel this run? It cannot be resumed.')) return;
if (!timeline) return;
if (!(await confirm({ title: 'Cancel this run?', message: 'It cannot be resumed.', confirmLabel: 'Cancel run', danger: true }))) return;
setSubmitting(true);
setSubmitError(null);
try {
await api.cancelWorkflowInstance(instanceId, timeline.lifecycleSeq);
toast('Run cancelled.');
refresh();
} catch (err) {
setSubmitError(err instanceof Error ? err.message : 'Failed to cancel');
@@ -227,8 +236,10 @@ export function InstanceTimelineView({ instanceId }: { readonly instanceId: stri
<NodeInfo document={document} nodeId={selectedNodeId} items={timeline.items} sessions={timeline.modelSessions} />
)}
<StepsOverTime items={timeline.items} label={(nodeId) => nodeLabel(document, nodeId)} onSelect={setSelectedNodeId} />
<Card title={`History (${String(timeline.items.length)})`} icon="clock">
<div class="jira-timeline">
<h3>History ({String(timeline.items.length)})</h3>
<div class="timeline-track">
{timeline.items.map((item: WorkflowTimelineItem) => (
<div key={item.id} class={`timeline-entry type-${item.type} status-${item.status}`}>
@@ -260,10 +271,39 @@ export function InstanceTimelineView({ instanceId }: { readonly instanceId: stri
))}
</div>
</div>
</Card>
</section>
);
}
/** Each step of the run as a bar on a time axis: where the time went, which step failed and was tried again. */
function StepsOverTime({
items,
label,
onSelect,
}: {
readonly items: readonly WorkflowTimelineItem[];
readonly label: (nodeId: string) => string;
readonly onSelect: (nodeId: string) => void;
}) {
const bars = ganttBars(items, Date.now());
if (bars.length === 0) return null;
const rows = ganttRows(bars).length;
return (
<Card title="Steps over time" icon="runs">
<Chart
label={`Steps of this run over time, ${String(bars.length)} attempts`}
height={Math.max(120, 40 + rows * 30)}
option={(palette) => ganttOption(bars, label, palette)}
onClick={({ dataIndex }) => {
const bar = bars[dataIndex];
if (bar !== undefined) onSelect(bar.nodeId);
}}
/>
</Card>
);
}
/** The block the owner clicked: what it is, whether it can change the app, and what it did last in this run. */
function NodeInfo({
document, nodeId, items, sessions,
+70 -11
View File
@@ -1,8 +1,10 @@
import { useState } from 'preact/hooks';
import { formatCost, formatDuration, formatInstant } from '../format.ts';
import { formatCost, formatDuration, formatInstant, runVersions } from '../format.ts';
import { api } from '../api.ts';
import { Empty, ErrorNote, Loading, ModeTag, RunBadge } from '../components.tsx';
import { useResource } from '../hooks.ts';
import { Card, Icon } from '../components/ui.tsx';
import { Drawer } from '../components/dialogs.tsx';
import { hrefFor } from '../route.ts';
import type { WorkflowInstanceSummaryResponse } from '../../http/contract.ts';
@@ -16,10 +18,53 @@ type Filter = (typeof FILTERS)[number]['status'];
const ENDED = new Set(['succeeded', 'failed', 'cancelled']);
function RunRow({ run }: { readonly run: WorkflowInstanceSummaryResponse }) {
/** A run at a glance, in the side panel: the owner opens the full page only when they need it. */
function RunPreview({ run }: { readonly run: WorkflowInstanceSummaryResponse }) {
const versions = runVersions(run);
return (
<>
<p>
<RunBadge status={run.status} /> <ModeTag mode={run.mode} />
</p>
<dl class="facts">
<dt>Workflow</dt>
<dd>
{run.workflowId}, version {run.workflowVersion}
</dd>
<dt>{ENDED.has(run.status) ? 'Result' : 'Now'}</dt>
<dd>{run.outcome ?? run.step ?? '—'}</dd>
{versions.running === null ? null : (
<>
<dt>Running</dt>
<dd>{versions.running}</dd>
</>
)}
{versions.available === null ? null : (
<>
<dt>Available</dt>
<dd>{versions.available}</dd>
</>
)}
<dt>Started</dt>
<dd>{formatInstant(run.createdAt)}</dd>
<dt>Took</dt>
<dd>{ENDED.has(run.status) ? formatDuration(run.createdAt, run.updatedAt) : 'still going'}</dd>
<dt>Model cost</dt>
<dd>{formatCost(run.costUsd)}</dd>
</dl>
<p>
<a class="button-link primary" href={hrefFor({ view: 'run', runId: run.id })}>
Open run <Icon name="arrow" size={16} />
</a>
</p>
</>
);
}
function RunRow({ run, onPreview }: { readonly run: WorkflowInstanceSummaryResponse; readonly onPreview: () => void }) {
const open = hrefFor({ view: 'run', runId: run.id });
return (
<tr>
<tr class="row-clickable" onClick={(event) => (event.target as HTMLElement).closest('a') === null && onPreview()}>
<td>
<strong>
<a href={open}>{run.app ?? run.workflowId}</a>
@@ -44,20 +89,28 @@ function RunRow({ run }: { readonly run: WorkflowInstanceSummaryResponse }) {
export function WorkflowInstancesView() {
const [filter, setFilter] = useState<Filter>(undefined);
const { data: runs, error } = useResource(() => api.workflowInstances(filter === undefined ? {} : { status: filter }), [filter]);
const [previewId, setPreviewId] = useState<string | null>(null);
const preview = runs?.find((run) => run.id === previewId) ?? null;
return (
<section class="workflow-instances-view">
<div class="view-header">
<h1>Runs</h1>
</div>
<div class="run-filters" role="tablist">
{FILTERS.map((option) => (
<button key={option.label} type="button" role="tab" aria-selected={filter === option.status} class={filter === option.status ? 'tab-btn active' : 'tab-btn'} onClick={() => setFilter(option.status)}>
{option.label}
</button>
))}
</div>
{error === null ? null : <ErrorNote message={error} />}
<Card
title="Workflow runs"
icon="runs"
actions={
<div class="run-filters" role="tablist">
{FILTERS.map((option) => (
<button key={option.label} type="button" role="tab" aria-selected={filter === option.status} class={filter === option.status ? 'tab-btn active' : 'tab-btn'} onClick={() => setFilter(option.status)}>
{option.label}
</button>
))}
</div>
}
>
{runs === null ? (
error === null ? <Loading /> : null
) : runs.length === 0 ? (
@@ -78,12 +131,18 @@ export function WorkflowInstancesView() {
</thead>
<tbody>
{runs.map((run) => (
<RunRow key={run.id} run={run} />
<RunRow key={run.id} run={run} onPreview={() => setPreviewId(run.id)} />
))}
</tbody>
</table>
</div>
)}
</Card>
{preview === null ? null : (
<Drawer title={preview.app ?? preview.workflowId} onClose={() => setPreviewId(null)}>
<RunPreview run={preview} />
</Drawer>
)}
</section>
);
}
+68 -17
View File
@@ -4,12 +4,65 @@ import { formatCost } from '../format.ts';
import { useContext, useState } from 'preact/hooks';
import { api } from '../api.ts';
import { ErrorNote, Loading } from '../components.tsx';
import { Card } from '../components/ui.tsx';
import { Chart } from '../components/chart.tsx';
import { Drawer } from '../components/dialogs.tsx';
import { costOption, formatUsd, modelShareOption, outcomesOption } from '../charts/options.ts';
import { costPerDay } from '../stats.ts';
import { LiveContext, useResource } from '../hooks.ts';
import type { PublishedModelProfileVersion, ModelProfilePublishRequest } from '../../http/contract.ts';
const CALLS_LIMIT = 500;
const SPEND_DAYS = 30;
type ModelCalls = Awaited<ReturnType<typeof api.modelCalls>>;
/** Where the model money went: per day and model, each model's share, and what the models concluded. */
function ModelSpend({ calls }: { readonly calls: ModelCalls }) {
const spend = costPerDay(calls.calls, { now: Date.now(), days: SPEND_DAYS });
const total = calls.totals.reduce((sum, item) => sum + item.costUsd, 0);
return (
<>
<div class="panel-grid">
<Card title={`Spend per day, last ${String(SPEND_DAYS)} days`} icon="coin" actions={<strong>{formatUsd(spend.total)}</strong>}>
{spend.total === 0 ? (
<p class="muted">Nothing spent in the last {SPEND_DAYS} days.</p>
) : (
<Chart label={`Model spend per day over ${String(SPEND_DAYS)} days, by model`} height={240} option={(palette) => costOption(spend, palette)} />
)}
</Card>
<Card title="Share by model" icon="models" actions={<span class="muted">{formatUsd(total)} in all</span>}>
{total === 0 ? null : <Chart label="Each model's share of all model spend" height={200} option={(palette) => modelShareOption(calls.totals, palette)} />}
<ul class="model-totals">
{calls.totals.map((item) => (
<li key={item.model}>
<strong>{item.model}</strong>
<span class="muted">
{String(item.calls)} call{item.calls === 1 ? '' : 's'}
</span>
<span>{formatCost(item.costUsd)}</span>
</li>
))}
</ul>
</Card>
</div>
<Card title="What the models concluded" icon="check">
<Chart
label="Outcomes of model calls per kind of question"
height={Math.max(140, 60 + new Set(calls.calls.map((call) => call.kind)).size * 44)}
option={(palette) => outcomesOption(calls.calls, palette)}
/>
</Card>
<Card title="Model calls in workflow runs" icon="runs">
<ModelActivity calls={calls.calls} showRun />
</Card>
</>
);
}
export function ModelsView() {
const { data: profiles, error } = useResource(() => api.modelProfiles(), []);
const modelCalls = useResource(() => api.modelCalls(), []);
const modelCalls = useResource(() => api.modelCalls(CALLS_LIMIT), []);
const { refresh } = useContext(LiveContext);
const [selectedProfileId, setSelectedProfileId] = useState<string | null>(null);
const [versions, setVersions] = useState<PublishedModelProfileVersion[] | null>(null);
@@ -131,29 +184,23 @@ export function ModelsView() {
<ReleaseNotesSourcesEditor />
<h2>Model calls in workflow runs</h2>
{modelCalls.error !== null && <ErrorNote message={modelCalls.error} />}
{modelCalls.data === null ? (
modelCalls.error === null ? <Loading /> : null
) : modelCalls.data.calls.length === 0 ? (
<p class="muted">No workflow run has asked a model yet.</p>
<Card title="Model calls in workflow runs" icon="models">
<p class="muted">No workflow run has asked a model yet.</p>
</Card>
) : (
<>
<p>
{modelCalls.data.totals.map((total) => (
<span key={total.model} class="model-total">
<strong>{total.model}</strong>: {String(total.calls)} call{total.calls === 1 ? '' : 's'}, {formatCost(total.costUsd)}{' '}
</span>
))}
</p>
<ModelActivity calls={modelCalls.data.calls} showRun />
</>
<ModelSpend calls={modelCalls.data} />
)}
{showPublishForm && (
<div class="modal-backdrop">
<div class="modal-card">
<h3>{formExpectedVersion > 1 ? `Change ${formProfileId} (saved as version ${String(formExpectedVersion)})` : 'New model profile'}</h3>
<Drawer
title={formExpectedVersion > 1 ? `Change ${formProfileId} (saved as version ${String(formExpectedVersion)})` : 'New model profile'}
onClose={() => setShowPublishForm(false)}
>
<div class="profile-form">
{publishError && <p class="error-note">{publishError}</p>}
<form onSubmit={(event) => void handlePublish(event)}>
<div class="form-row">
@@ -249,12 +296,14 @@ export function ModelsView() {
</div>
</form>
</div>
</div>
</Drawer>
)}
<Card title="Profiles" icon="models">
{profiles.length === 0 ? (
<p class="empty-note">No model profiles configured.</p>
) : (
<div class="table-wrap">
<table class="data-table">
<thead>
<tr>
@@ -336,7 +385,9 @@ export function ModelsView() {
))}
</tbody>
</table>
</div>
)}
</Card>
</section>
);
}
+200 -16
View File
@@ -1,9 +1,16 @@
import { api } from '../api.ts';
import { Empty, ErrorNote, Loading, ModeTag, RunBadge } from '../components.tsx';
import { Empty, ErrorNote, Loading, ModeTag, RunBadge, When } from '../components.tsx';
import { formatInstant } from '../format.ts';
import { useResource } from '../hooks.ts';
import { hrefFor } from '../route.ts';
import type { WorkflowInstanceSummaryResponse, WorkflowScheduleResponse } from '../../http/contract.ts';
import { Card, StatTile } from '../components/ui.tsx';
import { Chart } from '../components/chart.tsx';
import { activityOption, formatUsd, scheduleOption, sparklineOption } from '../charts/options.ts';
import { appsWithUpdate, costPerDay, hostSummary, RUN_BUCKETS, runsPerDay, successRate } from '../stats.ts';
import { downApps } from '../../domain/host-status.ts';
import { nightDigest } from '../../domain/notification-settings.ts';
import type { SuccessRate } from '../stats.ts';
import type { AppOverview, WorkflowInstanceSummaryResponse, WorkflowScheduleResponse } from '../../http/contract.ts';
const RECENT_DAYS = 7;
const RECENT_LIMIT = 10;
@@ -27,7 +34,7 @@ function Waiting({ runs }: { readonly runs: readonly WorkflowInstanceSummaryResp
<strong><RunName run={run} /></strong>
<span>{run.step ?? 'Owner decides'}</span>
<ModeTag mode={run.mode} />
<span class="muted">since {formatInstant(run.updatedAt)}</span>
<span class="muted">since <When iso={run.updatedAt} /></span>
<OpenRun run={run} label="Decide →" />
</li>
))}
@@ -62,7 +69,7 @@ function Recent({ runs }: { readonly runs: readonly WorkflowInstanceSummaryRespo
<RunBadge status={run.status} />
<span>{run.outcome ?? ''}</span>
<ModeTag mode={run.mode} />
<span class="muted">{formatInstant(run.updatedAt)}</span>
<span class="muted"><When iso={run.updatedAt} /></span>
<OpenRun run={run} label="Open" />
</li>
))}
@@ -80,7 +87,7 @@ function Scheduled({ schedules }: { readonly schedules: readonly WorkflowSchedul
{upcoming.map((schedule) => (
<li key={schedule.workflowId} class="overview-item">
<strong><a href={hrefFor({ view: 'workflow', workflowId: schedule.workflowId })}>{schedule.workflowId}</a></strong>
<span>{formatInstant(schedule.next[0] ?? null)}</span>
<span><When iso={schedule.next[0] ?? null} /></span>
<ModeTag mode={schedule.mode} />
{schedule.lastResult === null ? null : <span class="muted">last: {schedule.lastResult}</span>}
</li>
@@ -89,32 +96,209 @@ function Scheduled({ schedules }: { readonly schedules: readonly WorkflowSchedul
);
}
/** The home page: what waits for the owner, what runs, what ended lately and what runs next. */
type ModelCallsResponse = Awaited<ReturnType<typeof api.modelCalls>>;
const STATS_DAYS = 30;
const SCHEDULE_DAYS = 7;
function percent(rate: number | null): string {
return rate === null ? '—' : `${String(Math.round(rate * 100))}%`;
}
/** How the success rate moved against the 30 days before, in points. */
function RateTrend({ rate }: { readonly rate: SuccessRate }) {
if (rate.rate === null) return <>no run ended in {STATS_DAYS} days</>;
const ended = `${String(rate.ended)} ended`;
if (rate.previous === null) return <>{ended}</>;
const points = Math.round((rate.rate - rate.previous) * 100);
if (points === 0) return <>{ended} · same as before</>;
return (
<>
{ended} · <span class={points > 0 ? 'delta-good' : 'delta-bad'}>{points > 0 ? '▲' : '▼'} {Math.abs(points)} pts</span>
</>
);
}
type HostStatusData = Awaited<ReturnType<typeof api.hostStatus>>['status'];
/** Minutes after which the driver's look counts as old: it looks once a minute. */
const STALE_MINUTES = 5;
function AppsUp({ status }: { readonly status: HostStatusData | undefined }) {
if (status === undefined) return <StatTile label="Apps running" value="…" icon="apps" />;
if (status === null) return <StatTile label="Apps running" value="—" icon="apps" hint="the driver has not looked yet" />;
if (status.error !== null) return <StatTile label="Apps running" value="?" icon="apps" tone="error" hint={status.error} />;
const summary = hostSummary(status, Date.now());
const stale = summary.ageMinutes >= STALE_MINUTES;
const hint = stale
? `last look ${String(summary.ageMinutes)} min ago`
: summary.problems.length === 0
? 'all up'
: summary.problems.slice(0, 3).map((item) => `${item.app} ${item.state}`).join(', ') + (summary.problems.length > 3 ? '…' : '');
const down = summary.problems.some((item) => item.condition === 'down');
return (
<StatTile
label="Apps running"
value={`${String(summary.up)}/${String(summary.total)}`}
icon="apps"
tone={stale ? 'stop' : down ? 'error' : summary.problems.length > 0 ? 'running' : 'ok'}
hint={hint}
href={hrefFor({ view: 'apps' })}
/>
);
}
function hoursAgo(hours: number): string {
return hours < 1 ? 'under 1 h ago' : hours < 48 ? `${String(Math.round(hours))} h ago` : `${String(Math.round(hours / 24))} days ago`;
}
function VmBackup({ status }: { readonly status: HostStatusData | undefined }) {
const backup = status?.backup;
if (backup === undefined) return <StatTile label="VM backup" value="…" icon="clock" />;
if (backup === null) return <StatTile label="VM backup" value="—" icon="clock" hint="no record from the Proxmox timer" />;
if (!backup.ok) return <StatTile label="VM backup" value="!" icon="clock" tone="error" hint={backup.detail} />;
// A nightly backup: older than a day means last night's did not happen; real updates refuse past 48 h.
return <StatTile label="VM backup" value={hoursAgo(backup.ageHours)} icon="clock" tone={backup.ageHours > 26 ? 'stop' : 'ok'} hint={formatInstant(backup.createdAt)} />;
}
function Stats({ runs, apps, calls, host }: { readonly runs: readonly WorkflowInstanceSummaryResponse[]; readonly apps: readonly AppOverview[] | null; readonly calls: ModelCallsResponse | null; readonly host: HostStatusData | undefined }) {
const now = Date.now();
const waiting = runs.filter((run) => run.status === 'waiting').length;
const live = runs.filter((run) => LIVE.has(run.status)).length;
const rate = successRate(runs, { now, days: STATS_DAYS });
const cost = calls === null ? null : costPerDay(calls.calls, { now, days: STATS_DAYS });
const daily = cost === null ? [] : cost.days.map((_, index) => cost.series.reduce((sum, item) => sum + (item.values[index] ?? 0), 0));
const updates = apps === null ? null : appsWithUpdate(apps, runs);
return (
<div class="stats">
<StatTile label="Needs you" value={waiting} icon="alert" tone={waiting > 0 ? 'stop' : 'neutral'} hint={waiting > 0 ? 'runs wait at a gate' : 'nothing waits'} href={hrefFor({ view: 'runs' })} />
<StatTile label="In progress" value={live} icon="runs" tone={live > 0 ? 'running' : 'neutral'} hint={live > 0 ? 'queued or running' : 'all quiet'} />
<StatTile label={`Success, ${String(STATS_DAYS)} days`} value={percent(rate.rate)} icon="check" tone={rate.rate === null ? 'neutral' : rate.rate >= 0.9 ? 'ok' : 'error'} hint={<RateTrend rate={rate} />} />
<StatTile label={`Model cost, ${String(STATS_DAYS)} days`} value={cost === null ? '…' : formatUsd(cost.total)} icon="coin" tone="accent">
{cost === null || cost.total === 0 ? null : (
<Chart label={`Model cost per day over ${String(STATS_DAYS)} days`} height={40} option={(palette) => sparklineOption(daily, palette.accent, palette, formatUsd)} />
)}
</StatTile>
<StatTile
label="Updates available"
value={updates === null ? '…' : updates.length}
icon="upgrade"
tone={updates !== null && updates.length > 0 ? 'accent' : 'neutral'}
hint={updates === null || updates.length === 0 ? 'every app is current' : updates.slice(0, 3).join(', ') + (updates.length > 3 ? '…' : '')}
href={hrefFor({ view: 'apps' })}
/>
<AppsUp status={host} />
<VmBackup status={host} />
</div>
);
}
function Activity({ runs }: { readonly runs: readonly WorkflowInstanceSummaryResponse[] }) {
const days = runsPerDay(runs, { now: Date.now(), days: STATS_DAYS });
if (days.every((day) => RUN_BUCKETS.every((bucket) => day[bucket] === 0))) return <Empty>No run started in the last {STATS_DAYS} days.</Empty>;
return (
<Chart
label={`Workflow runs per day over ${String(STATS_DAYS)} days, by status`}
height={240}
option={(palette) => activityOption(days, palette)}
onClick={() => {
window.location.hash = hrefFor({ view: 'runs' });
}}
/>
);
}
function Upcoming({ schedules }: { readonly schedules: readonly WorkflowScheduleResponse[] }) {
const runs = schedules.filter((schedule) => schedule.enabled).flatMap((schedule) => schedule.next.map((at) => ({ workflowId: schedule.workflowId, at, mode: schedule.mode })));
const rows = new Set(runs.map((run) => run.workflowId)).size;
return (
<>
{runs.length === 0 ? null : (
<Chart
label={`Scheduled runs over the next ${String(SCHEDULE_DAYS)} days`}
height={Math.max(140, 56 + rows * 26)}
option={(palette) => scheduleOption(runs, { now: Date.now(), days: SCHEDULE_DAYS }, palette)}
/>
)}
<Scheduled schedules={schedules} />
</>
);
}
const DAY_MS = 24 * 60 * 60 * 1000;
/** The morning report as a card: what needs the owner and what changed since the last report went out. */
function SinceLastReport({ runs, host, lastDigestAt }: { readonly runs: readonly WorkflowInstanceSummaryResponse[]; readonly host: HostStatusData | undefined; readonly lastDigestAt: string | null }) {
const since = lastDigestAt ?? new Date(Date.now() - DAY_MS).toISOString();
const digest = nightDigest({ runs, since, down: host === undefined || host === null ? [] : downApps(host) });
return (
<Card
title={digest.needsYou > 0 ? `${String(digest.needsYou)} ${digest.needsYou === 1 ? 'thing needs' : 'things need'} you` : 'All good'}
icon={digest.needsYou > 0 ? 'alert' : 'check'}
tone={digest.needsYou > 0 ? 'attention' : undefined}
actions={<span class="muted">since {lastDigestAt === null ? 'yesterday' : `the report of ${formatInstant(lastDigestAt)}`}</span>}
class="report"
>
<ul class="report-lines">
{digest.detail.split('\n').map((line) => (
<li key={line}>{line}</li>
))}
</ul>
</Card>
);
}
/** The home page: what waits for the owner, how runs went lately, what they cost and what runs next. */
export function OverviewView() {
const runs = useResource(() => api.workflowInstances(), []);
const schedules = useResource(() => api.workflowSchedules(), []);
const apps = useResource(() => api.apps(), []);
const calls = useResource(() => api.modelCalls(500), []);
const host = useResource(() => api.hostStatus(), []);
const notifications = useResource(() => api.notificationSettings(), []);
const all = runs.data ?? [];
const since = Date.now() - RECENT_DAYS * 24 * 60 * 60 * 1000;
const recent = all.filter((run) => ENDED.has(run.status) && Date.parse(run.updatedAt) >= since).slice(0, RECENT_LIMIT);
const waiting = all.filter((run) => run.status === 'waiting');
return (
<section class="overview">
<h1>Overview</h1>
<div class="page-head">
<div>
<h1>Overview</h1>
<p>Nightly updates of the Docker apps at a glance.</p>
</div>
</div>
{runs.error === null ? null : <ErrorNote message={runs.error} />}
{runs.data === null ? (
runs.error === null ? <Loading /> : null
) : (
<>
<h2>Needs your decision</h2>
<Waiting runs={all.filter((run) => run.status === 'waiting')} />
<h2>In progress</h2>
<Live runs={all.filter((run) => LIVE.has(run.status))} />
<h2>Ended recently</h2>
<Recent runs={recent} />
<SinceLastReport runs={all} host={host.data?.status ?? undefined} lastDigestAt={notifications.data?.lastDigestAt ?? null} />
<Stats runs={all} apps={apps.data} calls={calls.data} host={host.data === null ? (host.error === null ? undefined : null) : host.data.status} />
<div class="panel-grid">
<Card title="Needs your decision" icon="alert" tone={waiting.length > 0 ? 'attention' : undefined}>
<Waiting runs={waiting} />
</Card>
<Card title="In progress" icon="runs">
<Live runs={all.filter((run) => LIVE.has(run.status))} />
</Card>
</div>
<Card title={`Activity, last ${String(STATS_DAYS)} days`} icon="overview" actions={<a href={hrefFor({ view: 'runs' })}>All runs →</a>}>
<Activity runs={all} />
</Card>
</>
)}
<h2>Next scheduled</h2>
{schedules.error === null ? null : <ErrorNote message={schedules.error} />}
{schedules.data === null ? (schedules.error === null ? <Loading /> : null) : <Scheduled schedules={schedules.data} />}
<div class="panel-grid">
<Card title={`Next ${String(SCHEDULE_DAYS)} days`} icon="clock">
{schedules.error === null ? null : <ErrorNote message={schedules.error} />}
{schedules.data === null ? (schedules.error === null ? <Loading /> : null) : <Upcoming schedules={schedules.data} />}
</Card>
{runs.data === null ? null : (
<Card title="Ended recently" icon="check">
<Recent runs={recent} />
</Card>
)}
</div>
</section>
);
}
+88
View File
@@ -0,0 +1,88 @@
import { useState } from 'preact/hooks';
import { api } from '../api.ts';
import { ErrorNote, Loading } from '../components.tsx';
import { Card } from '../components/ui.tsx';
import { useToast } from '../components/dialogs.tsx';
import { formatInstant } from '../format.ts';
import { useResource } from '../hooks.ts';
type Settings = Awaited<ReturnType<typeof api.notificationSettings>>['settings'];
function TimeField({ label, value, onChange }: { readonly label: string; readonly value: string; readonly onChange: (value: string) => void }) {
return (
<label>
{label}
<input type="time" value={value} required onInput={(event) => onChange((event.target as HTMLInputElement).value)} />
</label>
);
}
/** When the phone may ring, and when the morning report goes out. Remounted (by key) when the saved settings change. */
function NotificationsForm({ initial, timeZone, lastDigestAt }: { readonly initial: Settings; readonly timeZone: string; readonly lastDigestAt: string | null }) {
const [settings, setSettings] = useState(initial);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const toast = useToast();
const set = (patch: Partial<Settings>) => setSettings((current) => ({ ...current, ...patch }));
const save = async (event: Event) => {
event.preventDefault();
setSaving(true);
setError(null);
try {
await api.saveNotificationSettings(settings);
toast('Notification settings saved.');
} catch (cause) {
setError(cause instanceof Error ? cause.message : 'Cannot save the settings');
} finally {
setSaving(false);
}
};
return (
<form class="settings-form" onSubmit={(event) => void save(event)}>
<fieldset>
<legend>Quiet hours</legend>
<p class="muted">Nothing rings in these hours: gates and failures wait for the morning report. A failure in the evening before still rings.</p>
<div class="settings-row">
<TimeField label="From" value={settings.quietFrom} onChange={(quietFrom) => set({ quietFrom })} />
<TimeField label="Until" value={settings.quietUntil} onChange={(quietUntil) => set({ quietUntil })} />
</div>
</fieldset>
<fieldset>
<legend>Morning report</legend>
<p class="muted">One push a day: what waits for you, what failed or is down, what was updated since the last report.</p>
<div class="settings-row">
<label class="check">
<input type="checkbox" checked={settings.digestEnabled} onChange={(event) => set({ digestEnabled: (event.target as HTMLInputElement).checked })} /> Send the report
</label>
<TimeField label="At" value={settings.digestAt} onChange={(digestAt) => set({ digestAt })} />
</div>
<p class="muted">Last report: {lastDigestAt === null ? 'none yet' : formatInstant(lastDigestAt)}</p>
</fieldset>
<p class="muted">Times are in {timeZone}, like the schedules.</p>
{error === null ? null : <ErrorNote message={error} />}
<div class="actions">
<button type="submit" class="primary" disabled={saving}>
{saving ? 'Saving…' : 'Save'}
</button>
</div>
</form>
);
}
export function SettingsView() {
const { data, error } = useResource(() => api.notificationSettings(), []);
return (
<section>
<div class="page-head">
<div>
<h1>Settings</h1>
<p>How and when the app tells you what happened.</p>
</div>
</div>
<Card title="Notifications" icon="bell">
{error === null ? null : <ErrorNote message={error} />}
{data === null ? (error === null ? <Loading /> : null) : <NotificationsForm key={JSON.stringify(data.settings)} initial={data.settings} timeZone={data.timeZone} lastDigestAt={data.lastDigestAt} />}
</Card>
</section>
);
}
+8 -1
View File
@@ -4,6 +4,7 @@ import { ErrorNote, Loading } from '../components.tsx';
import { CytoscapeGraph } from '../components/cytoscape-graph.tsx';
import { AddNodeForm, NodeForm } from '../components/node-form.tsx';
import { hrefFor } from '../route.ts';
import { useConfirm } from '../components/dialogs.tsx';
import { workflowApp } from './workflows.tsx';
import { WorkflowDocumentV2 } from '../../domain/workflow-graph.ts';
import type { WorkflowNode } from '../../domain/workflow-graph.ts';
@@ -24,6 +25,7 @@ export function WorkflowEditorView({
readonly version?: number;
}) {
const isVersionView = version !== undefined;
const confirm = useConfirm();
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [draft, setDraft] = useState<{
@@ -170,7 +172,12 @@ export function WorkflowEditorView({
const handlePublish = async () => {
if (!draft) return;
const next = (publishedVersions.at(-1)?.version ?? 0) + 1;
if (!window.confirm(`Publish as version ${String(next)}? New runs use it; runs already started finish on their version.`)) return;
const ok = await confirm({
title: `Publish as version ${String(next)}?`,
message: 'New runs use it; runs already started finish on their version.',
confirmLabel: 'Publish',
});
if (!ok) return;
setSaving(true);
setActionError(null);
try {
+21 -5
View File
@@ -92,6 +92,14 @@ describe('Web UI Stage 7 Views', () => {
}
/** Waits for rendered text instead of a fixed delay: data loads take longer on the CI runner. */
/** Answers the confirmation window the last click opened. */
async function answerDialog(ok: boolean) {
await flush();
const button = container.querySelector<HTMLButtonElement>(`.modal-dialog [data-dialog="${ok ? 'confirm' : 'cancel'}"]`);
expect(button).not.toBeNull();
button!.click();
}
async function waitForText(text: string, timeoutMs = 3000) {
const deadline = Date.now() + timeoutMs;
while (!container.textContent?.includes(text) && Date.now() < deadline) await flush();
@@ -207,8 +215,8 @@ describe('Web UI Stage 7 Views', () => {
json.value = JSON.stringify({ ...doc, nodes: [doc.nodes[0], { nodeId: 'node_b', type: 'fail' }] });
json.dispatchEvent(new Event('input', { bubbles: true }));
await flush();
window.confirm = () => true;
[...container.querySelectorAll('button')].find((button) => button.textContent === 'Publish')!.click();
await answerDialog(true);
await waitForText('Published version 1.');
expect(workflowCatalog.getPublishedVersion('wf-publish', 1)?.document).toMatchObject({ nodes: [{ nodeId: 'node_a' }, { nodeId: 'node_b', type: 'fail' }] });
});
@@ -338,8 +346,8 @@ describe('Web UI Stage 7 Views', () => {
expect(container.textContent).toContain('Run Tests');
// Publish saves, checks and tests the draft first.
window.confirm = () => true;
[...container.querySelectorAll('button')].find((button) => button.textContent === 'Publish')!.click();
await answerDialog(true);
await waitForText('Published version 1.', 10_000);
expect(functionCatalog.getPublishedVersion('fn-test', 1)?.document.source).toBe(fnDoc.source);
});
@@ -397,6 +405,14 @@ describe('Web UI Stage 7 Views', () => {
await waitForText('wf-inst');
expect(container.textContent).toContain('Queued');
// A row opens the run's preview in the side panel; Escape closes it.
(container.querySelector('tr.row-clickable td:nth-child(2)') as HTMLElement).click();
await flush();
expect(container.querySelector('.drawer a.button-link')?.getAttribute('href')).toBe('#/runs/inst-test-123');
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }));
await flush();
expect(container.querySelector('.drawer')).toBeNull();
window.location.hash = '#/instances/inst-test-123';
window.dispatchEvent(new Event('hashchange'));
await waitForText('History');
@@ -406,10 +422,10 @@ describe('Web UI Stage 7 Views', () => {
await waitForText('finish: finish (end: success)');
expect([...container.querySelectorAll('.graph-fallback-list li')].map((li) => li.textContent)).toEqual(['start: start (step)', 'finish: finish (end: success)']);
window.confirm = () => true;
const cancel = [...container.querySelectorAll('button')].find((button) => button.textContent === 'Cancel run')!;
cancel.click();
await waitForText('Run cancelled');
await answerDialog(true);
await waitForText('Result: Cancelled');
expect(container.querySelector('.run-outcome')?.textContent).toBe('Result: Cancelled');
expect(workflowInstanceStore.get('inst-test-123')?.projection.status).toBe('cancelled');
expect([...container.querySelectorAll('button')].some((button) => button.textContent === 'Cancel run')).toBe(false);
@@ -476,8 +492,8 @@ describe('Web UI Stage 7 Views', () => {
const actions = [...decisionActions!.querySelectorAll<HTMLButtonElement>('button')];
expect(actions.map((button) => [button.dataset['action'], button.textContent])).toEqual([['proceed', 'Proceed'], ['skip', 'Skip']]);
// Skipping ends the run: nothing is sent unless the owner confirms.
window.confirm = () => false;
actions[1]!.click();
await answerDialog(false);
await flush();
expect(workflowInstanceStore.get('inst-gate')?.projection.status).toBe('waiting');
await waitForText('gate: Owner decides (waits for the owner)');
+21 -7
View File
@@ -2,6 +2,7 @@ import { useState } from 'preact/hooks';
import { api } from '../api.ts';
import { ErrorNote, Loading, ModeTag } from '../components.tsx';
import { useResource } from '../hooks.ts';
import { useConfirm, useToast } from '../components/dialogs.tsx';
import { hrefFor } from '../route.ts';
import type { WorkflowScheduleResponse, WorkflowSummaryResponse } from '../../http/contract.ts';
import { appOfWorkflow } from '../../domain/workflow-run-summary.ts';
@@ -18,8 +19,15 @@ const RUN_REFUSALS: Record<string, string> = {
export function SystemRunButton({ workflowId }: { readonly workflowId: string }) {
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const confirm = useConfirm();
const run = async () => {
if (!window.confirm(`Run ${workflowId} now?\n\nIt runs for real: what it may change is decided at its gates and by the policy.`)) return;
const ok = await confirm({
title: `Run ${workflowId} now?`,
message: 'It runs for real: what it may change is decided at its gates and by the policy.',
confirmLabel: 'Run',
danger: true,
});
if (!ok) return;
setBusy(true);
setError(null);
try {
@@ -40,8 +48,8 @@ export function SystemRunButton({ workflowId }: { readonly workflowId: string })
);
}
const REAL_RUN_WARNING = (app: string) =>
`Update ${app} for real?\n\nThe run backs the app up, applies the new image, verifies it and lets a model check it (the model may restart the service or fix its compose file). You decide at every gate.`;
const REAL_RUN_WARNING =
'The run backs the app up, applies the new image, verifies it and lets a model check it (the model may restart the service or fix its compose file). You decide at every gate.';
/** Both runs of an app's workflow; a real run is refused (with the reason shown) while the app's real updates are off. */
export function RunButtons({ app }: { readonly app: string }) {
@@ -56,8 +64,9 @@ export function RunButtons({ app }: { readonly app: string }) {
export function RunButton({ app, mode }: { readonly app: string; readonly mode: 'shadow' | 'real' }) {
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const confirm = useConfirm();
const run = async () => {
if (mode === 'real' && !window.confirm(REAL_RUN_WARNING(app))) return;
if (mode === 'real' && !(await confirm({ title: `Update ${app} for real?`, message: REAL_RUN_WARNING, confirmLabel: 'Update', danger: true }))) return;
setBusy(true);
setError(null);
try {
@@ -109,12 +118,15 @@ export function ScheduleCell({ workflowId, schedule }: { readonly workflowId: st
const [enabled, setEnabled] = useState(schedule?.enabled ?? true);
const [error, setError] = useState<string | null>(null);
const isApp = workflowApp(workflowId) !== null;
const confirm = useConfirm();
const toast = useToast();
const act = async (action: () => Promise<unknown>) => {
const act = async (action: () => Promise<unknown>, done: string) => {
setError(null);
try {
await action();
setEditing(false);
toast(done);
} catch (err) {
setError(err instanceof Error ? err.message : 'Cannot save the schedule');
}
@@ -151,10 +163,12 @@ export function ScheduleCell({ workflowId, schedule }: { readonly workflowId: st
</select>
)}
<label><input type="checkbox" checked={enabled} onChange={(e) => setEnabled((e.target as HTMLInputElement).checked)} /> on</label>
<button type="button" class="btn-sm" onClick={() => void act(() => api.saveWorkflowSchedule(workflowId, { cron, mode, enabled }))}>Save</button>
<button type="button" class="btn-sm" onClick={() => void act(() => api.saveWorkflowSchedule(workflowId, { cron, mode, enabled }), `Schedule of ${workflowId} saved.`)}>Save</button>
{schedule !== undefined && (
<button type="button" class="btn-sm" onClick={() => {
if (window.confirm(`Remove the schedule of ${workflowId}? It stops running on its own.`)) void act(() => api.removeWorkflowSchedule(workflowId));
void confirm({ title: `Remove the schedule of ${workflowId}?`, message: 'It stops running on its own.', confirmLabel: 'Remove', danger: true }).then((ok) => {
if (ok) void act(() => api.removeWorkflowSchedule(workflowId), `Schedule of ${workflowId} removed.`);
});
}}>Remove</button>
)}
<button type="button" class="btn-link" onClick={() => setEditing(false)}>Cancel</button>