Compare commits
20
Commits
foundations
...
phase-h
No files matched your search
@@ -6,6 +6,8 @@ data
|
||||
docs
|
||||
tools
|
||||
deploy
|
||||
!deploy/compose.yaml
|
||||
!deploy/update.sh
|
||||
.env
|
||||
.env.*
|
||||
*.log
|
||||
|
||||
@@ -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
|
||||
@@ -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
@@ -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
@@ -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
@@ -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"}
|
||||
]
|
||||
@@ -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.
|
||||
@@ -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
@@ -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
@@ -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
@@ -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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
Generated
+26
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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)) };
|
||||
});
|
||||
}
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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. */
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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'],
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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' },
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,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>}
|
||||
|
||||
@@ -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
@@ -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('—');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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 |
@@ -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" }]
|
||||
}
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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
File diff suppressed because it is too large.
Load diff
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)');
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user