Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
12cca5586b | ||
|
|
136f9d2fb0 | ||
|
|
119dd18d43 | ||
|
|
aec96b2273 | ||
|
|
0fbad32c87 | ||
|
|
0289663494 | ||
|
|
76e8baa1e5 | ||
|
|
896314d4dd | ||
|
|
615bf95ab5 | ||
|
|
df726618a6 | ||
|
|
3d57472252 | ||
|
|
2fd0cf812a |
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
|
||||
|
||||
@@ -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`.
|
||||
+69
-4
@@ -3,25 +3,36 @@
|
||||
# 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}
|
||||
|
||||
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 +53,44 @@ 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
|
||||
}
|
||||
|
||||
wait_until_up() {
|
||||
local deadline=$((SECONDS + healthy_timeout)) status
|
||||
while ((SECONDS < deadline)); do
|
||||
@@ -71,10 +120,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
|
||||
@@ -90,6 +154,7 @@ 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"
|
||||
|
||||
+22
-8
@@ -21,20 +21,34 @@ 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: przed takim wdrożeniem zrób kopię `queue.sqlite*`.
|
||||
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.
|
||||
|
||||
### Jednorazowa konfiguracja
|
||||
|
||||
@@ -43,7 +57,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.
|
||||
|
||||
|
||||
@@ -156,3 +156,16 @@ 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).
|
||||
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",
|
||||
|
||||
@@ -57,6 +57,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 +89,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 +137,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 +260,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 +287,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 +300,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 +308,25 @@ 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('says so for an unknown page', async () => {
|
||||
window.location.hash = '#/nowhere';
|
||||
await eventually(() => expect(text()).toContain('Page not found'));
|
||||
|
||||
+41
-21
@@ -7,6 +7,10 @@ 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 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';
|
||||
@@ -31,10 +35,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 }: { route: Route; status: LiveStatus }) {
|
||||
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 +52,34 @@ 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>
|
||||
<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' })}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -135,11 +149,17 @@ export function App() {
|
||||
const { revision, status, refresh } = useLive();
|
||||
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} />
|
||||
<div class="content">
|
||||
<main>
|
||||
<View route={route} />
|
||||
</main>
|
||||
<Footer />
|
||||
</div>
|
||||
</div>
|
||||
</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' },
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -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' }} />;
|
||||
}
|
||||
@@ -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,101 @@
|
||||
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',
|
||||
} 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>;
|
||||
}
|
||||
@@ -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,189 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { appsWithUpdate, costPerDay, ganttBars, lastDays, recentAppRuns, runDurationMs, runDurations, runsPerDay, successRate } 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 },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,166 @@
|
||||
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;
|
||||
}
|
||||
+902
-81
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 };
|
||||
}
|
||||
+238
-50
@@ -1,4 +1,10 @@
|
||||
import { useState } from 'preact/hooks';
|
||||
import { Empty, ErrorNote, Loading, ModeTag, RunBadge } 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 { recentAppRuns, runDurations } from '../stats.ts';
|
||||
import { api } from '../api.ts';
|
||||
import { formatCost, formatInstant, runVersions } from '../format.ts';
|
||||
import { useResource } from '../hooks.ts';
|
||||
@@ -7,7 +13,7 @@ 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 } from '../../http/contract.ts';
|
||||
|
||||
/** 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,66 +27,236 @@ 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];
|
||||
}
|
||||
|
||||
interface AppsData {
|
||||
readonly apps: readonly AppOverview[];
|
||||
readonly runs: readonly WorkflowInstanceSummaryResponse[] | null;
|
||||
readonly nextRun: (app: string) => string | null;
|
||||
}
|
||||
|
||||
/** 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">
|
||||
<a href={hrefFor({ view: 'app', app: first.app })}>{first.app}</a>
|
||||
<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} />
|
||||
{members.map((member) => (
|
||||
<div key={member.app} class="app-member">
|
||||
<a href={hrefFor({ view: 'app', app: member.app })}>└ {member.service ?? member.app}</a>
|
||||
<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} /> {formatInstant(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>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
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 [layout, setLayout] = useLayout();
|
||||
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="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>
|
||||
{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 }} />
|
||||
) : (
|
||||
<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 }} />
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const DURATION_RUNS = 20;
|
||||
|
||||
/** 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]);
|
||||
@@ -91,9 +267,15 @@ export function AppDetailView({ app }: { app: string }) {
|
||||
<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 +292,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 +316,15 @@ 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>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
+124
-12
@@ -3,7 +3,12 @@ import { Empty, ErrorNote, Loading, ModeTag, RunBadge } 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, RUN_BUCKETS, runsPerDay, successRate } from '../stats.ts';
|
||||
import type { SuccessRate } from '../stats.ts';
|
||||
import type { AppOverview, WorkflowInstanceSummaryResponse, WorkflowScheduleResponse } from '../../http/contract.ts';
|
||||
|
||||
const RECENT_DAYS = 7;
|
||||
const RECENT_LIMIT = 10;
|
||||
@@ -89,32 +94,139 @@ 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function Stats({ runs, apps, calls }: { readonly runs: readonly WorkflowInstanceSummaryResponse[]; readonly apps: readonly AppOverview[] | null; readonly calls: ModelCallsResponse | null }) {
|
||||
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' })}
|
||||
/>
|
||||
</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} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** 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 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} />
|
||||
<Stats runs={all} apps={apps.data} calls={calls.data} />
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -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