skills: one per compose project, imported from the old SKILL.md files, edited in the app
ci / check (push) Successful in 41s
ci / publish (push) Successful in 21s

Facts about each app for the model (release notes, requirements such as database versions, STOP cases,
checks, the way back): table skills (migration v4), config/skills/<project>.md imported once from
the old system (frontmatter dropped, no secrets), added to the database at driver start when missing
and never overwritten, GET/POST /api/projects/:project/skill and a Skill panel on the app page.
Only the skill of the project the model works on will be loaded.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
homelab-updaterandClaude Opus 5 committed 2026-09-19 22:10:01 +02:00
1 parent 3eb1285287
commit aab8d4704c
36 files changed
+756 -8

No files matched your search

+30
View File
@@ -0,0 +1,30 @@
# Bazarr — VM103 (odczyt 2026-09-16)
Usługa: `bazarr`. Obraz: `linuxserver/bazarr`, PUID/PGID 1001. Port 6767, domena `bazarr.mariuszpacek.pl`. Healthcheck: brak.
Dane: `/config` ← bind `/srv/appdata/bazarr/config` (ext4, SQLite); `/movies`, `/tv` ← volume NFS.
Integracje: Sonarr, Radarr (Bazarr jest klientem). Konsumenci: brak.
## Źródła
| README obrazu | https://github.com/linuxserver/docker-bazarr |
|---|---|
| Wydania obrazu | https://github.com/linuxserver/docker-bazarr/releases |
| Release notes upstream | https://github.com/morpheus65535/bazarr/releases |
| Zgłoszenia | https://github.com/linuxserver/docker-bazarr/issues · https://github.com/morpheus65535/bazarr/issues |
| Przewodnik upgrade | brak |
## Wersje
Tagi: `image_tags.sh linuxserver/bazarr --pattern '^v[0-9.]+-ls[0-9]+$' --current <obecny tag>`. Auto: `patch`.
Tylko tagi `v<wersja>-ls<n>`; tagi `development-*` pomijaj.
Migracja bazy przy zmianie wersji: nieustalone — sprawdź release notes przy każdym kroku (baza SQLite w `/config`).
## Przed zmianą
brak
## Odbiór (limit 3 min)
`verify.sh` running + image + HTTPS 200. Bez wyszukiwania napisów.
## STOP
Release notes tego kroku wprost mówią o migracji schematu/breaking change/ręcznym kroku; utrata połączenia z Sonarr/Radarr.
## Powrót
Sam rebuild LinuxServer: `rollback.sh bazarr --from <backup_dir> --service bazarr`. Po migracji bazy: restore `/config` z nocnej kopii VM103.
+33
View File
@@ -0,0 +1,33 @@
# Deluge — VM103 (odczyt 2026-09-16)
Usługa: `deluge`. Obraz: `linuxserver/deluge`, PUID/PGID 1001. Web 8112, domena `deluge.mariuszpacek.pl`. Healthcheck: brak.
Porty hosta: 18112→8112, 15846→58846 (daemon), 15946→58946.
Dane: `/config` ← bind `/srv/appdata/deluge/config`; `/downloads` ← bind `/srv/appdata/deluge/data` (ext4).
Konsumenci: Sonarr, Radarr (klient pobrań); Jackett montuje `/downloads`.
## Źródła
| README obrazu | https://github.com/linuxserver/docker-deluge |
|---|---|
| Wydania obrazu | https://github.com/linuxserver/docker-deluge/releases |
| Upstream | https://github.com/deluge-torrent/deluge |
| Zgłoszenia | https://github.com/linuxserver/docker-deluge/issues |
| Przewodnik upgrade | brak |
## Wersje
Tagi: `image_tags.sh linuxserver/deluge --pattern '^[0-9][0-9.]*-ls[0-9]+$' --current <obecny tag>`. Auto: `patch`.
Tylko tagi `<wersja>-ls<n>`. Tagi `libtorrentv1-*` to inna gałąź libtorrent — pomijaj.
Obecny tag `2.2.0-r1-ls361` (pakiet Alpine); nowsze `2.2.0-ls<n>` (PIP). Ta sama wersja Deluge 2.2.0.
## Przed zmianą
brak
## Odbiór (limit 3 min)
1. `verify.sh` running + image + HTTPS 200.
2. `bash scripts/video-upgrade-check.sh sonarr check` → `passed:true` (klient pobrań widoczny w Sonarr).
Bez dodawania torrentów.
## STOP
Zmiana wersji Deluge (major/minor); Sonarr traci klienta pobrań.
## Powrót
`rollback.sh deluge --from <backup_dir> --service deluge`.
+28
View File
@@ -0,0 +1,28 @@
# Dockge — VM103 (odczyt 2026-09-17)
Usługa: `dockge`. Obraz: `louislam/dockge`, **bez tagu** — tylko digest w compose.
Domena `docker.mariuszpacek.pl`, port 5001. Healthcheck: tak (obecnie `healthy`).
Dane: `/app/data` ← bind `/srv/appdata/dockge/data`; `/var/run/docker.sock` (zarządza wszystkimi stackami compose); `/opt/homelab/compose` ← bind (RW, widzi manifesty innych projektów).
Obecna wersja działająca: `1.5.0` (odczyt `docker exec dockge-dockge-1 cat /app/package.json` — brak etykiety OCI wersji, `image_version.sh` zwraca `no_version_label`).
## Źródła
| README obrazu | https://github.com/louislam/dockge |
| Wydania | https://github.com/louislam/dockge/releases |
| Zgłoszenia | https://github.com/louislam/dockge/issues |
| Przewodnik upgrade | brak |
## Wersje
Tagi: `image_tags.sh louislam/dockge --pattern '^[0-9.]+$' --current 1.5.0`. Auto: `patch`.
Obraz bez tagu w compose → przed jakąkolwiek automatyczną akcją repin/update wymaga jawnego wpisania tagu (nie `latest`).
## Przed zmianą
brak — restart dockge nie zatrzymuje innych stacków (tylko traci socket na czas restartu).
## Odbiór (limit 5 min)
`verify.sh` running + image + HTTPS 200. Lista stacków w UI widoczna i zgodna ze stanem sprzed restartu.
## STOP
`no_version_label` przy `image_version.sh` — wersję potwierdź ręcznie (`package.json`) przed repinem, nie zgaduj z tagu.
## Powrót
`rollback.sh dockge --from <backup_dir> --service dockge`.
+28
View File
@@ -0,0 +1,28 @@
# Gitea — VM103 (odczyt 2026-09-17)
Usługi: `gitea` (obraz `gitea/gitea`, USER_UID/GID 1000), `db` (`postgres:14`, bez zmian przy update gitea).
Domena `git.mariuszpacek.pl` (web 3000→13080, SSH 22→12224, whitelist IP w Traefik). Healthcheck: brak.
Dane: `/data` ← bind `/srv/appdata/gitea/data` (ext4, 980M); DB ← bind `/srv/appdata/gitea/db`.
Konsumenci: brak zewnętrznych.
## Źródła
| README obrazu | https://github.com/go-gitea/gitea |
| Wydania | https://github.com/go-gitea/gitea/releases |
| Zgłoszenia | https://github.com/go-gitea/gitea/issues |
| Przewodnik upgrade | https://docs.gitea.com/installation/upgrade-from-gitea |
## Wersje
Tagi: `image_tags.sh gitea/gitea --pattern '^[0-9.]+$' --current <obecny tag>`. Auto: `patch`.
Bez `-rc`, `-dev`, `nightly`. Gitea wspiera tylko upgrade z bezpośrednio poprzedniej wersji minor — nie pomijaj minorów.
## Przed zmianą
Świeży `pg_dump` (backup.sh robi kopię wolumenu, nie logiczny dump) — sprawdź przed majorem/minorem.
## Odbiór (limit 5 min)
`verify.sh` running + image + HTTPS 200 (`/user/login` lub strona główna po whitelist). Sprawdź logi `gitea` bez `migration failed`.
## STOP
Skok więcej niż jeden minor naraz; błąd migracji DB w logach.
## Powrót
`rollback.sh gitea --from <backup_dir> --service gitea`. Migracja DB nieudana → restore wolumenu z backup_dir albo nocny vzdump.
+29
View File
@@ -0,0 +1,29 @@
# Grafana — VM103 (odczyt 2026-09-17)
Usługi: `grafana` (obraz `grafana/grafana`, **bez tagu** — tylko digest w compose), `influxdb` (`influxdb:1.8.4`, osobny cykl, nie ruszać przy update grafany).
Domena `grafana.mariuszpacek.pl`, port 3000. Healthcheck: brak.
Dane: `/var/lib/grafana` ← bind `/srv/appdata/grafana/data`; `/etc/grafana` ← bind `/srv/appdata/grafana/etc`; influxdb dane ← `/srv/appdata/grafana/influxdb`.
Obecna wersja działająca: `13.1.3` (odczyt `docker exec grafana-grafana-1 grafana-server -v` — brak etykiety OCI wersji na obrazie, `image_version.sh` zwraca `no_version_label`).
## Źródła
| README obrazu | https://github.com/grafana/grafana/blob/main/packaging/docker/README.md |
| Wydania | https://github.com/grafana/grafana/releases |
| Release notes | https://grafana.com/docs/grafana/latest/whatsnew/ |
| Zgłoszenia | https://github.com/grafana/grafana/issues |
| Przewodnik upgrade | https://grafana.com/docs/grafana/latest/upgrade-guide/ |
## Wersje
Tagi: `image_tags.sh grafana/grafana --pattern '^[0-9.]+$' --current 13.1.3`. Auto: `patch`.
Obraz bez tagu w compose → przed jakąkolwiek automatyczną akcją repin/update wymaga jawnego wpisania tagu (nie `latest`).
## Przed zmianą
brak
## Odbiór (limit 5 min)
`verify.sh` running + image + HTTPS 302 (root przekierowuje na `/login`, curl bez `-L` — to nie błąd). Dashboardy się ładują, źródło danych InfluxDB odpowiada (panel z danymi, nie błąd datasource).
## STOP
`no_version_label` przy `image_version.sh` — wersję potwierdź ręcznie (`grafana-server -v`) przed repinem, nie zgaduj z tagu.
## Powrót
`rollback.sh grafana --from <backup_dir> --service grafana`.
+36
View File
@@ -0,0 +1,36 @@
# Home Assistant — VM103 (odczyt 2026-09-17)
Usługa: `homeassistant` (network_mode: host, brak `ports`, healthcheck: brak). Obraz: `homeassistant/home-assistant`.
Dane: `/config` ← bind `/srv/appdata/homeassistant/config` (ext4). Recorder: domyślny SQLite, `home-assistant_v2.db` ~514 MB.
Domena: `home.mariuszpacek.pl` (Traefik, TLS). Konsumenci: brak (frontend, dostęp bezpośredni).
`custom_components` (stan 2026-09-17, odśwież `ls`): `hacs`, `ble_monitor`, `gree`, `moonraker`. `bermuda` usunięta 2026-09-17 (nieużywana, łamała się na 2025.11.3, `BaseScannerEntity` ImportError).
## Źródła
| README obrazu | https://hub.docker.com/r/homeassistant/home-assistant |
|---|---|
| Release notes (breaking changes per wydanie) | https://www.home-assistant.io/blog/categories/release-notes/ |
| Wydania (changelog skrócony) | https://github.com/home-assistant/core/releases |
| Zgłoszenia | https://github.com/home-assistant/core/issues |
| Zgodność HACS | https://hacs.xyz/docs/faq/known_issues/ · https://www.hacs.xyz |
## Wersje
Tagi: `image_tags.sh homeassistant/home-assistant --pattern '^[0-9]{4}\.[0-9]{1,2}\.[0-9]+$' --current <obecny tag>`.
Auto: `minor` (CalVer `RRRR.MM.patch`, miesięczne). Zmiana `RRRR` = `major` → STOP wg pętli. Wprost na `candidate`,
chyba że release notes pomijanego miesiąca wymagają migracji pośredniej.
## Przed zmianą
`ls /config/custom_components` (lista żywa, nie z tego pliku). Release notes każdego pomijanego miesiąca, sekcja
„Breaking changes” — wzmianka o którejkolwiek z tej listy, recorder/SQLite lub frontend → STOP.
## Odbiór (limit 10 min)
1. `verify.sh` running + image + HTTPS 200 na `home.mariuszpacek.pl`.
2. Liczba encji w `.storage/core.entity_registry` (`data.entities`) nie mniejsza niż ostatnia wartość w `docs/status.md`.
3. `ls /config/custom_components` → lista; log (5 min) bez `ERROR`/`CRITICAL` z `custom_components.` lub `for <nazwa>` z tej listy (nie z tego pliku — mogły dojść nowe przez HACS).
Nie loguj się do UI, nie klikaj integracji ręcznie jako testu.
## STOP
Breaking change dot. HACS lub dowolnego custom_component z żywego `ls`. Migracja recorder/SQLite bez downgrade
(zawsze tak dla HA) → reguła 3 pętli: powrót tylko przez rollback + backup, nie przez downgrade obrazu.
## Powrót
`rollback.sh homeassistant --from <backup_dir> --service homeassistant`. DB migrowana przy starcie — rollback obrazu
bez przywrócenia `home-assistant_v2.db` z backupu może nie wystarczyć; backup.sh musi objąć `/config` całe.
+34
View File
@@ -0,0 +1,34 @@
# Homepage — VM103 (odczyt 2026-09-17)
Usługa: `homepage` (obraz `ghcr.io/gethomepage/homepage`). Domena `dash.mariuszpacek.pl` (port 3000),
router ograniczony middleware `homepage-ipwhitelist` (LAN 192.168.0.0/24 + proxy 172.16.0.0/24 +
docker_gwbridge 172.18.0.0/16) — celowo nie publiczny, pokazuje spis wszystkich usług i status.
Healthcheck: tak (wbudowany w obraz, `health` w status.sh). Dane: config YAML ← bind
`/srv/appdata/homepage/config` (`settings.yaml`, `docker.yaml`, `services.yaml`, bez sekretów).
Docker socket: ro bind `/var/run/docker.sock` — status kontenerów czytany bezpośrednio z API, bez pingów sieciowych.
Konsumenci: brak. Serwisy w `services.yaml` referencjonowane po nazwie kontenera (`container: <project>-<service>-1`) —
zmiana nazwy kontenera w innym projekcie (np. przy przepisaniu compose) wymaga aktualizacji tego pliku ręcznie.
## Źródła
| README obrazu | https://github.com/gethomepage/homepage |
| Wydania | https://github.com/gethomepage/homepage/releases |
| Zgłoszenia | https://github.com/gethomepage/homepage/issues |
| Przewodnik upgrade | https://gethomepage.dev/latest/installation/upgrading/ |
## Wersje
Tagi: `image_tags.sh ghcr.io/gethomepage/homepage --pattern '^v[0-9]+\.[0-9]+\.[0-9]+$' --current <obecny tag>`. Auto: `minor`.
Bez `-beta`/`-rc`. Projekt aktywny, częste minory z nowymi widgetami — sprawdź release notes pod kątem
zmian formatu `services.yaml`/`settings.yaml` (bywały breaking changes configu między minorami).
## Przed zmianą
Brak (bezstanowe, config to zwykłe pliki YAML w bind mouncie — kopiowane razem z backup.sh).
## Odbiór (limit 3 min)
`verify.sh` running + healthy + image + HTTPS 200 na `/`. Wizualnie: kafelki usług się ładują, status
kontenerów (kropka) się zgadza z `docker ps`.
## STOP
Zmiana formatu configu w release notes bez jasnej ścieżki migracji.
## Powrót
`rollback.sh homepage --from <backup_dir> --service homepage`.
+32
View File
@@ -0,0 +1,32 @@
# Jackett — VM103 (odczyt 2026-09-16)
Usługa: `jackett`. Obraz: `ghcr.io/linuxserver/jackett`. Port 9117, domena `jackett.mariuszpacek.pl`.
Dane: `/config` ← bind `/srv/appdata/jackett/config` (ext4); `/downloads` ← bind `/srv/appdata/deluge/data`.
Konsumenci: Sonarr, Radarr przez `http://jackett:9117/`.
## Źródła
| README obrazu | https://github.com/linuxserver/docker-jackett |
|---|---|
| Wydania obrazu | https://github.com/linuxserver/docker-jackett/releases |
| Release notes upstream | https://github.com/Jackett/Jackett/releases |
| Zgłoszenia | https://github.com/linuxserver/docker-jackett/issues · https://github.com/Jackett/Jackett/issues |
| Przewodnik upgrade | brak (sama podmiana obrazu) |
## Wersje
Tagi: `image_tags.sh ghcr.io/linuxserver/jackett --pattern '^v[0-9.]+-ls[0-9]+$' --current <obecny tag>`. Auto: `patch`.
Skok wprost na najnowszy tag LinuxServer. Zmiany to zwykle definicje trackerów.
## Przed zmianą
`AUTO_UPDATE=false` w Env kontenera. Włączone lub nieustalone → STOP.
## Odbiór (limit 3 min, ponawiaj do limitu)
1. `bash scripts/video-upgrade-check.sh jackett check` → `passed:true` (baza: `plans/video-baseline-jackett.json`).
2. `verify.sh` running + image + HTTPS 301.
Nie uruchamiaj wyszukiwań ani pobrań. Nie drukuj URL indeksatorów.
## STOP
Zmieniona liczba/zawartość plików indeksatorów; Sonarr/Radarr nie osiągają usługi.
Błąd pojedynczego trackera (np. Cloudflare) i `Unknown indexer` w logach to nie bloker.
## Powrót
`rollback.sh jackett --from <backup_dir> --service jackett`.
+29
View File
@@ -0,0 +1,29 @@
# Jellyfin — VM103 (odczyt 2026-09-17)
Usługa: `jellyfin`. Obraz: `linuxserver/jellyfin`, PUID/PGID 1001. `DOCKER_MODS=linuxserver/mods:jellyfin-amd` (transkodowanie sprzętowe).
Domena `video.mariuszpacek.pl`, port 8096 (+8920). Healthcheck: brak.
Dane: `/config` ← bind `/srv/appdata/jellyfin/config` (ext4, 4.1G); `/data/movies`, `/data/tv` ← volume NFS (RO biblioteka).
Konsumenci: brak.
## Źródła
| README obrazu | https://github.com/linuxserver/docker-jellyfin |
| Wydania obrazu | https://github.com/linuxserver/docker-jellyfin/releases |
| Release notes upstream | https://github.com/jellyfin/jellyfin/releases |
| Zgłoszenia | https://github.com/linuxserver/docker-jellyfin/issues · https://github.com/jellyfin/jellyfin/issues |
| Przewodnik upgrade | brak |
## Wersje
Tagi: `image_tags.sh linuxserver/jellyfin --pattern '^[0-9.]+ubu[0-9]+-ls[0-9]+$' --current <obecny tag>`. Auto: `patch`.
Obecny tag `10.11.11ubu2604-ls44` (wersja Jellyfin + kodowa nazwa Ubuntu + build LinuxServer).
## Przed zmianą
brak
## Odbiór (limit 5 min)
`verify.sh` running + image + HTTPS 302 (root przekierowuje na `/web/`, curl bez `-L` — to nie błąd). Sprawdź, że transkodowanie sprzętowe (`/dev/dri`) nadal zamontowane po `up`.
## STOP
Zniknięcie `/dev/dri` z kontenera; błąd DOCKER_MODS przy starcie.
## Powrót
`rollback.sh jellyfin --from <backup_dir> --service jellyfin`.
+35
View File
@@ -0,0 +1,35 @@
# Keycloak — VM103 (odczyt 2026-09-17)
Usługi: `keycloak` (obraz `quay.io/keycloak/keycloak`, tryb `start` produkcyjny, nie `start-dev`),
`db` (`postgres:18.6`, dedykowany, bez współdzielenia z innymi appkami).
Domena `login.mariuszpacek.pl` (port 8080). Dwa routery Traefik: publiczny (login, `/realms/*`) i
`PathPrefix(/admin)` z middleware `keycloak-admin-ipwhitelist` (LAN 192.168.0.0/24 + proxy 172.16.0.0/24 +
docker_gwbridge 172.18.0.0/16). Healthcheck: brak (KC_HEALTH_ENABLED=true, ale bez compose healthcheck).
Dane: DB ← bind `/srv/appdata/keycloak/db` (właściciel uid:gid 999 — obraz postgres 18+ wymaga zapisu
przez usera postgres na całym punkcie montowania, nie tylko na podkatalogu wersji).
Konsumenci: brak (realm/clienci do odtworzenia ręcznie przez właściciela).
## Źródła
| README obrazu | https://quay.io/repository/keycloak/keycloak |
| Wydania | https://github.com/keycloak/keycloak/releases |
| Zgłoszenia | https://github.com/keycloak/keycloak/issues |
| Przewodnik upgrade | https://www.keycloak.org/docs/latest/upgrading/ |
## Wersje
Tagi: `image_tags.sh quay.io/keycloak/keycloak --pattern '^[0-9]+\.[0-9]+\.[0-9]+$' --current <obecny tag>`. Auto: `patch`.
Bez `-rc`. Keycloak wspiera upgrade tylko z bezpośrednio poprzedniej wersji major — nie pomijaj majorów.
Postgres: `image_tags.sh postgres --pattern '^1[4-9]\.[0-9]+$' --current <tag>` — major osobno, sprawdź release notes obrazu
(zmiana układu katalogu danych między majorami, jak przy 17→18).
## Przed zmianą
Major KC: sprawdź changelog schematu DB (Liquibase) w release notes. Downgrade niewspierany — powrót tylko przez rollback.sh.
## Odbiór (limit 5 min)
`verify.sh` running (keycloak, db) + image + HTTPS 200 na `/realms/master/.well-known/openid-configuration`.
Logi `keycloak-keycloak-1` bez `ERROR: Failed to start server` ani błędów Liquibase po migracji.
## STOP
Skok więcej niż jeden major KC naraz; błąd migracji schematu w logach; zmiana układu katalogu danych Postgres (jak 17→18).
## Powrót
`rollback.sh keycloak --from <backup_dir> --service keycloak` (i analogicznie `db` przy migracji bazy).
+36
View File
@@ -0,0 +1,36 @@
# Nextcloud — VM103 (odczyt 2026-09-14)
Usługi: `app`, `cron` (ten sam obraz — aktualizuj razem), `db` (MariaDB 10.6), `redis`.
Obraz: `nextcloud:<wersja>-apache`. Bindy: `/srv/appdata/nextcloud/{db,redis,html,config,custom_apps}`.
Dane użytkowników: volume `next31_nfs-data` (NFS TrueNAS).
## Źródła
| README obrazu | https://github.com/nextcloud/docker |
|---|---|
| Release notes | https://nextcloud.com/changelog/ · https://github.com/nextcloud/server/releases |
| Wsparcie wersji | https://github.com/nextcloud/server/wiki/Maintenance-and-Release-Schedule |
| Zgłoszenia | https://github.com/nextcloud/server/issues |
| Przewodnik upgrade | https://docs.nextcloud.com/server/stable/admin_manual/maintenance/upgrade.html |
## Wersje
Tagi: `image_tags.sh nextcloud --pattern '^[0-9.]+-apache$' --current <obecny tag>`. Auto: `patch`.
Najpierw najnowszy patch majora, potem jeden major na krok. Brak downgrade'u.
Przed majorem: zgodność aplikacji zewnętrznych, zakończone migracje w tle. Teraz: 31.0.12 → 31.0.14.
## Przed zmianą
Maintenance mode on → stop `app` i `cron` → zmiana obu. `db`, `redis` bez zmian.
## Odbiór (limit 10 min)
```bash
C=/opt/homelab/compose/nextcloud/compose.yaml
docker compose -f $C exec -T --user www-data app php occ status --output=json # installed, bez maintenance, bez needsDbUpgrade
docker compose -f $C exec -T --user www-data app php occ config:app:get core lastcron # świeży
docker compose -f $C exec -T cron sh -c 'pidof crond'
```
Cztery kontenery działają, HTTPS odpowiada. Bez skanów i masowych zadań.
## STOP
Nieoczekiwana migracja/repair; oczekujący upgrade DB; stary `lastcron`; major bez świeżego `mysqldump`.
## Powrót
Po migracji schematu `rollback.sh` nie wystarcza: restore zrzutu MariaDB albo `qmrestore` nocnej kopii VM103.
+30
View File
@@ -0,0 +1,30 @@
# Ombi — VM103 (odczyt 2026-09-16)
Usługa: `ombi`. Obraz: `linuxserver/ombi`, PUID/PGID 1001. Port 3579, domena `ombi.mariuszpacek.pl`. Healthcheck: brak.
Dane: `/config` ← bind `/srv/appdata/ombi/config` (ext4, SQLite, 64 MB).
Integracje: nieustalone. Konsumenci: brak.
## Źródła
| README obrazu | https://github.com/linuxserver/docker-ombi |
|---|---|
| Wydania obrazu | https://github.com/linuxserver/docker-ombi/releases |
| Release notes upstream | https://github.com/Ombi-app/Ombi/releases |
| Zgłoszenia | https://github.com/linuxserver/docker-ombi/issues · https://github.com/Ombi-app/Ombi/issues |
| Przewodnik upgrade | brak |
## Wersje
Tagi: `image_tags.sh linuxserver/ombi --pattern '^v[0-9.]+-ls[0-9]+$' --current <obecny tag>`. Auto: `patch`.
Tylko tagi `v<wersja>-ls<n>`; tagi `development-*` pomijaj.
Migracja bazy przy zmianie wersji: nieustalone — sprawdź release notes przy każdym kroku (EF Core, baza w `/config`).
## Przed zmianą
brak
## Odbiór (limit 3 min)
`verify.sh` running + image + HTTPS 200. Bez składania żądań.
## STOP
Release notes tego kroku wprost mówią o migracji schematu/breaking change/ręcznym kroku.
## Powrót
Sam rebuild LinuxServer: `rollback.sh ombi --from <backup_dir> --service ombi`. Po migracji bazy: restore `/config` z nocnej kopii VM103.
+30
View File
@@ -0,0 +1,30 @@
# OrcaSlicer — VM103 (odczyt 2026-09-16)
Usługa: `orcaslicer`. Obraz: `linuxserver/orcaslicer` (KasmVNC/Selkies), PUID/PGID 1000. Port 3000, domena `orca.mariuszpacek.pl`.
Healthcheck: brak. Dostęp chroniony (`CUSTOM_USER`/`PASSWORD`) → HTTPS 401 bez logowania.
Dane: `/config` ← bind `/srv/appdata/orcaslicer/config`; `/prints` ← bind `/srv/appdata/orcaslicer/prints` (ext4, 1.8 GB).
Konsumenci: brak.
## Źródła
| README obrazu | https://github.com/linuxserver/docker-orcaslicer |
|---|---|
| Wydania obrazu | https://github.com/linuxserver/docker-orcaslicer/releases |
| Release notes upstream | https://github.com/SoftFever/OrcaSlicer/releases |
| Zgłoszenia | https://github.com/linuxserver/docker-orcaslicer/issues · https://github.com/SoftFever/OrcaSlicer/issues |
| Przewodnik upgrade | brak |
## Wersje
Tagi: `image_tags.sh linuxserver/orcaslicer --pattern '^v[0-9.]+-ls[0-9]+$' --current <obecny tag>`. Auto: `minor`.
Skok wprost na najnowszy tag `v<wersja>-ls<n>`. Profile drukarek w `/config`.
## Przed zmianą
brak
## Odbiór (limit 3 min)
`verify.sh` running + image + HTTPS 401.
## STOP
Zmiana zmiennych `SELKIES_*` lub auth wymagana przez release notes.
## Powrót
`rollback.sh orcaslicer --from <backup_dir> --service orcaslicer`.
+26
View File
@@ -0,0 +1,26 @@
# Radarr — VM103 (odczyt 2026-09-16)
Usługa: `radarr`. Obraz: LinuxServer amd64, PUID/PGID 1001. Port 7878, domena `radarr.mariuszpacek.pl`. Bez healthchecku.
Dane: `/config` ← `/srv/appdata/radarr/config` (ext4, SQLite); `/downloads` ← `/srv/appdata/deluge/data`; `/movies` ← volume NFS.
Integracje: Jackett `http://jackett:9117`, Deluge.
## Źródła
| README obrazu | https://github.com/linuxserver/docker-radarr |
|---|---|
| Release notes | https://github.com/Radarr/Radarr/releases · https://github.com/linuxserver/docker-radarr/releases |
| Zgłoszenia | https://github.com/linuxserver/docker-radarr/issues · https://github.com/Radarr/Radarr/issues |
| Przewodnik upgrade | https://wiki.servarr.com/radarr/faq |
## Wersje
Tagi: `image_tags.sh ghcr.io/linuxserver/radarr --pattern '^[0-9.]+-ls[0-9]+$' --current <obecny tag>`. Auto: `patch`.
Gałąź v6. SQLite, schemat w tabeli `VersionInfo` — porównaj przed/po jako dowód, nie zakładaj z góry.
## Odbiór (limit 5 min)
`bash scripts/video-upgrade-check.sh radarr check` → `passed:true`; `verify.sh` running + image + HTTPS 302.
Bez wyszukiwań, importów, pobrań.
## STOP
ffprobe nie działa; błąd integralności SQLite; utrata biblioteki lub integracji.
## Powrót
Schemat bez zmian: `rollback.sh radarr --from <backup_dir> --service radarr`. Zmieniony: restore danych.
+26
View File
@@ -0,0 +1,26 @@
# Sonarr — VM103 (odczyt 2026-09-16)
Usługa: `sonarr`. Obraz: LinuxServer amd64, PUID/PGID 1001. Port 8989, domena `sonarr.mariuszpacek.pl`. Bez healthchecku.
Dane: `/config` ← `/srv/appdata/sonarr/config` (ext4, SQLite); `/downloads` ← `/srv/appdata/deluge/data`; `/tv` ← volume NFS.
Integracje: Jackett `http://jackett:9117`, Deluge.
## Źródła
| README obrazu | https://github.com/linuxserver/docker-sonarr |
|---|---|
| Release notes | https://github.com/Sonarr/Sonarr/releases · https://github.com/linuxserver/docker-sonarr/releases |
| Zgłoszenia | https://github.com/linuxserver/docker-sonarr/issues · https://github.com/Sonarr/Sonarr/issues |
| Przewodnik upgrade | https://wiki.servarr.com/sonarr/faq |
## Wersje
Tagi: `image_tags.sh ghcr.io/linuxserver/sonarr --pattern '^[0-9.]+-ls[0-9]+$' --current <obecny tag>`. Auto: `patch`.
Gałąź v4. SQLite, schemat w tabeli `VersionInfo` — porównaj przed/po jako dowód, nie zakładaj z góry.
## Odbiór (limit 5 min)
`bash scripts/video-upgrade-check.sh sonarr check` → `passed:true`; `verify.sh` running + image + HTTPS 302.
Bez wyszukiwań, importów, pobrań.
## STOP
ffprobe nie działa (LS #335); błąd integralności SQLite; utrata biblioteki lub integracji.
## Powrót
Schemat bez zmian: `rollback.sh sonarr --from <backup_dir> --service sonarr`. Zmieniony: restore danych.
+8
View File
@@ -11,6 +11,7 @@ import type {
} from './executor-api.ts';
import type { StepRecord } from './outcome.ts';
import type { PreflightReport } from './preflight.ts';
import type { Skill } from './skill.ts';
import type { AppConfig, Executor, JsonValue, Run, RunSummary, RunType, Step, StepSpec, StepStatus } from './steps.ts';
// Ports: the seams for tests and swappable adapters. Everything else is a plain function.
@@ -64,6 +65,13 @@ export interface AppConfigStore {
upsert(config: AppConfig): void;
}
/** The skills of the apps, one per compose project. */
export interface SkillStore {
get(project: string): Skill | null;
list(): Skill[];
upsert(skill: Skill): void;
}
export interface CommandResult {
readonly exitCode: number;
readonly stdout: string;
+15
View File
@@ -0,0 +1,15 @@
import { z } from 'zod';
// A skill: facts about one app (compose project) for the model: where its release notes are, what an upgrade needs, what
// is a STOP, how to check it and how to go back. Only the skill of the project the model works on is loaded.
// Mechanical parameters (image, tag pattern, auto, URL) live in app_config, not here.
export const MAX_SKILL_LENGTH = 50_000;
export const SkillContent = z.string().max(MAX_SKILL_LENGTH, `at most ${String(MAX_SKILL_LENGTH)} characters`);
export interface Skill {
readonly project: string;
readonly content: string;
readonly updatedAt: string;
}
+6
View File
@@ -1,4 +1,5 @@
import { z } from 'zod';
import { SkillContent } from '../domain/skill.ts';
import { ResolutionRequest } from '../domain/resolution.ts';
import { AutoLevel, Executor, Json, ReasonCode, RunType, StepName, StepStatus } from '../domain/steps.ts';
@@ -78,6 +79,11 @@ export type AppConfigView = z.infer<typeof AppConfigResponse>;
export { AppConfigPatch } from '../domain/app-config.ts';
/** The skill of one compose project; empty content and null updatedAt when there is none yet. */
export const SkillResponse = z.object({ project: z.string(), content: z.string(), updatedAt: z.string().nullable() });
export type SkillView = z.infer<typeof SkillResponse>;
export const SkillSave = z.strictObject({ content: SkillContent });
export const RunsResponse = z.array(RunSummarySchema);
export const RunDetailResponse = z.object({ run: RunSummarySchema, steps: z.array(StepSchema) });
export const StepsResponse = z.array(StepSchema);
+20 -2
View File
@@ -25,11 +25,11 @@ afterEach(async () => {
});
async function fixture(overrides: Partial<ServerDeps> = {}, options: { night?: boolean } = {}) {
const { store, appConfig } = createTestDatabase();
const { store, appConfig, skills } = createTestDatabase();
seedDemoConfig(appConfig);
const clock = new FakeClock();
const hub = createChangeHub();
const app = await buildServer({ store, appConfig, clock, hub, pollChangesMs: 0, ...overrides });
const app = await buildServer({ store, appConfig, skills, clock, hub, pollChangesMs: 0, ...overrides });
cleanup.push(() => app.close());
if (options.night === true) {
@@ -331,3 +331,21 @@ describe('live updates (SSE)', () => {
expect(hub.revision).toBe(1);
});
});
describe('skills', () => {
it('reads an empty skill for a configured project, saves it, and reads it back', async () => {
const { app } = await fixture();
expect((await app.inject('/api/projects/grafana/skill')).json()).toEqual({ project: 'grafana', content: '', updatedAt: null });
const saved = await app.inject({ method: 'POST', url: '/api/projects/grafana/skill', payload: { content: '# Grafana\nInfluxDB 1.8 only' } });
expect(saved.statusCode).toBe(200);
expect((await app.inject('/api/projects/grafana/skill')).json()).toMatchObject({ project: 'grafana', content: '# Grafana\nInfluxDB 1.8 only' });
});
it('refuses an unknown project, a path and an oversized skill', async () => {
const { app } = await fixture();
expect((await app.inject('/api/projects/ghost/skill')).statusCode).toBe(404);
expect((await app.inject('/api/projects/..%2Fetc/skill')).statusCode).toBe(400);
expect((await app.inject({ method: 'POST', url: '/api/projects/grafana/skill', payload: { content: 'x'.repeat(50_001) } })).statusCode).toBe(400);
expect((await app.inject({ method: 'POST', url: '/api/projects/grafana/skill', payload: { content: 'x', extra: 1 } })).statusCode).toBe(400);
});
});
+29 -2
View File
@@ -6,17 +6,19 @@ import type { FastifyInstance, FastifyReply } from 'fastify';
import { z } from 'zod';
import { nextComponentStep } from '../app/groups.ts';
import { resolveStep, startManualRun, updateAppConfig } from '../app/use-cases.ts';
import type { AppConfigStore, Clock, StepStore } from '../domain/ports.ts';
import type { AppConfigStore, Clock, SkillStore, StepStore } from '../domain/ports.ts';
import { buildAppOverview } from '../domain/overview.ts';
import { StepStatus } from '../domain/steps.ts';
import { watchChanges } from './change-hub.ts';
import type { ChangeHub } from './change-hub.ts';
import { AppConfigPatch, EVENTS_PATH, ResolutionRequest } from './contract.ts';
import { AppConfigPatch, EVENTS_PATH, ResolutionRequest, SkillSave } from './contract.ts';
import type { BuildInfo } from './contract.ts';
export interface ServerDeps {
readonly store: StepStore;
readonly appConfig: AppConfigStore;
/** Skills of the apps (one per compose project); without it the skill routes answer 404. */
readonly skills?: SkillStore;
readonly clock: Clock;
readonly hub: ChangeHub;
/** Directory with the built frontend (`dist/web`). Without it the server is API-only. */
@@ -40,6 +42,7 @@ const StepsQuery = z.object({ app: z.string().min(1).optional(), roundId: z.stri
const IdParams = z.object({ id: z.coerce.number().int().positive() });
const RoundParams = z.object({ roundId: z.string().min(1) });
const AppParams = z.object({ app: z.string().min(1) });
const ProjectParams = z.object({ project: z.string().regex(/^[a-z0-9][a-z0-9_-]*$/) });
const StartRunBody = z.object({ type: z.literal('manual') });
const CONTENT_SECURITY_POLICY = "default-src 'self'; img-src 'self' data:; frame-ancestors 'none'; base-uri 'none'; form-action 'self'";
@@ -155,6 +158,30 @@ export async function buildServer(deps: ServerDeps): Promise<FastifyInstance> {
return reply.send(result.config);
});
/** A skill belongs to a configured project; the owner edits it in the app. */
const knownProject = (project: string): boolean => appConfig.list().some((config) => config.project === project);
app.get('/api/projects/:project/skill', (request, reply) => {
const params = ProjectParams.safeParse(request.params);
if (!params.success) return fail(reply, 400, 'invalid_request', issues(params.error));
const { project } = params.data;
if (deps.skills === undefined || !knownProject(project)) return fail(reply, 404, 'not_found', 'no such project');
return reply.send(deps.skills.get(project) ?? { project, content: '', updatedAt: null });
});
app.post('/api/projects/:project/skill', (request, reply) => {
const params = ProjectParams.safeParse(request.params);
if (!params.success) return fail(reply, 400, 'invalid_request', issues(params.error));
const body = SkillSave.safeParse(request.body);
if (!body.success) return fail(reply, 400, 'invalid_request', issues(body.error));
const { project } = params.data;
if (deps.skills === undefined || !knownProject(project)) return fail(reply, 404, 'not_found', 'no such project');
const skill = { project, content: body.data.content, updatedAt: clock.now().toISOString() };
deps.skills.upsert(skill);
hub.notify();
return reply.send(skill);
});
app.post('/api/steps/:id/resolve', (request, reply) => {
const params = IdParams.safeParse(request.params);
if (!params.success) return fail(reply, 400, 'invalid_request', issues(params.error));
+6
View File
@@ -24,6 +24,12 @@ describe('migrate', () => {
]);
});
it('v4 adds the skills table', () => {
const db = new DatabaseSync(':memory:');
migrate(db);
expect(columns(db, 'skills')).toEqual(['project', 'content', 'updated_at']);
});
it('v3 adds app_config.project: every app configured so far is its own project', () => {
const db = new DatabaseSync(':memory:');
migrate(db, MIGRATIONS.slice(0, 2));
+11
View File
@@ -76,6 +76,17 @@ export const MIGRATIONS: readonly Migration[] = [
UPDATE app_config SET project = app WHERE project = '';
`,
},
{
// Skills: facts about each app for the model (one per compose project), edited in the app.
version: 4,
sql: `
CREATE TABLE skills (
project TEXT PRIMARY KEY,
content TEXT NOT NULL,
updated_at TEXT NOT NULL
) STRICT;
`,
},
];
function schemaVersion(db: DatabaseSync): number {
+14
View File
@@ -0,0 +1,14 @@
import { describe, expect, it } from 'vitest';
import { createTestDatabase } from '../../testing/test-db.ts';
describe('skill store', () => {
it('keeps one skill per project and replaces it on save', () => {
const { skills } = createTestDatabase();
expect(skills.get('nextcloud')).toBeNull();
skills.upsert({ project: 'nextcloud', content: '# Nextcloud\nMariaDB 10.6', updatedAt: '2026-09-19T20:00:00.000Z' });
skills.upsert({ project: 'grafana', content: '# Grafana', updatedAt: '2026-09-19T20:00:00.000Z' });
skills.upsert({ project: 'nextcloud', content: '# Nextcloud\nMariaDB 10.11', updatedAt: '2026-09-19T21:00:00.000Z' });
expect(skills.get('nextcloud')).toEqual({ project: 'nextcloud', content: '# Nextcloud\nMariaDB 10.11', updatedAt: '2026-09-19T21:00:00.000Z' });
expect(skills.list().map((skill) => skill.project)).toEqual(['grafana', 'nextcloud']);
});
});
+28
View File
@@ -0,0 +1,28 @@
import type { DatabaseSync } from 'node:sqlite';
import { z } from 'zod';
import type { SkillStore } from '../../domain/ports.ts';
import type { Skill } from '../../domain/skill.ts';
const SkillRow = z
.object({ project: z.string(), content: z.string(), updated_at: z.string() })
.transform((row): Skill => ({ project: row.project, content: row.content, updatedAt: row.updated_at }));
export function createSqliteSkillStore(db: DatabaseSync): SkillStore {
return {
get(project) {
const row = db.prepare('SELECT * FROM skills WHERE project = :project').get({ project });
return row === undefined ? null : SkillRow.parse(row);
},
list() {
return db.prepare('SELECT * FROM skills ORDER BY project').all().map((row) => SkillRow.parse(row));
},
upsert(skill) {
db.prepare(
`INSERT INTO skills (project, content, updated_at) VALUES (:project, :content, :updatedAt)
ON CONFLICT (project) DO UPDATE SET content = excluded.content, updated_at = excluded.updated_at`,
).run({ project: skill.project, content: skill.content, updatedAt: skill.updatedAt });
},
};
}
+2
View File
@@ -11,6 +11,7 @@ describe('loadConfig', () => {
driverIdleMs: 60_000,
demo: false,
appsFile: 'config/apps.json',
skillsDir: 'config/skills',
build: { branch: null, number: null, revision: null },
executorUrl: null,
hostBackupFile: '/host/vzdump.json',
@@ -29,6 +30,7 @@ describe('loadConfig', () => {
driverIdleMs: 1500,
demo: true,
appsFile: '/etc/apps.json',
skillsDir: 'config/skills',
build: { branch: 'foundations', number: 6, revision: 'de5ee92' },
executorUrl: 'http://executor:8081',
hostBackupFile: '/h/v.json',
+4
View File
@@ -12,6 +12,8 @@ const Env = z.object({
DEMO: z.enum(['0', '1']).default('0'),
/** The apps to load into app_config when it is empty (real mode). */
APPS_FILE: z.string().min(1).default('config/apps.json'),
/** Skills shipped with the image (config/skills/<project>.md), added to the database when missing. */
SKILLS_DIR: z.string().min(1).default('config/skills'),
/** Build facts baked into the image by CI (Dockerfile build args); empty or missing = unknown. */
BUILD_BRANCH: z.string().optional(),
BUILD_NUMBER: z.union([z.literal(''), z.coerce.number().int().positive()]).optional(),
@@ -36,6 +38,7 @@ export interface Config {
readonly driverIdleMs: number;
readonly demo: boolean;
readonly appsFile: string;
readonly skillsDir: string;
readonly build: { readonly branch: string | null; readonly number: number | null; readonly revision: string | null };
readonly executorUrl: string | null;
readonly hostBackupFile: string;
@@ -57,6 +60,7 @@ export function loadConfig(env: Readonly<Record<string, string | undefined>>): C
driverIdleMs: values.DRIVER_IDLE_MS,
demo: values.DEMO === '1',
appsFile: values.APPS_FILE,
skillsDir: values.SKILLS_DIR,
build: { branch: orNull(values.BUILD_BRANCH), number: orNull(values.BUILD_NUMBER), revision: orNull(values.BUILD_REVISION) },
executorUrl: values.EXECUTOR_URL ?? null,
hostBackupFile: values.HOST_BACKUP_FILE,
+6
View File
@@ -12,10 +12,12 @@ import { createHttpRegistryClient } from '../infra/http-registry.ts';
import { createProcessRunner } from '../infra/process-runner.ts';
import { openDatabase } from '../infra/sqlite/open.ts';
import { createSqliteAppConfigStore } from '../infra/sqlite/app-config-store.ts';
import { createSqliteSkillStore } from '../infra/sqlite/skill-store.ts';
import { createSqliteStepStore } from '../infra/sqlite/step-store.ts';
import { systemClock } from '../infra/system-clock.ts';
import { hasDemoData, missingApps, parseAppsFile } from './apps-file.ts';
import { loadConfig, secretValues } from './config.ts';
import { missingSkills, readSkillFiles } from './skills-file.ts';
const logger = createConsoleLogger();
@@ -43,6 +45,10 @@ if (config.demo) {
const added = missingApps(parseAppsFile(JSON.parse(readFileSync(config.appsFile, 'utf8')) as unknown), appConfig.list());
for (const app of added) appConfig.upsert(app);
if (added.length > 0) logger.info('apps.added', { apps: added.map((app) => app.app) });
const skills = createSqliteSkillStore(db);
const newSkills = missingSkills(readSkillFiles(config.skillsDir), skills.list(), systemClock.now().toISOString());
for (const skill of newSkills) skills.upsert(skill);
if (newSkills.length > 0) logger.info('skills.added', { projects: newSkills.map((skill) => skill.project) });
}
// Real mode: the read-only chain (status finds containers by their compose labels, reads no project file) and, with
+2
View File
@@ -5,6 +5,7 @@ import { createChangeHub } from '../http/change-hub.ts';
import { buildServer } from '../http/server.ts';
import { openDatabase } from '../infra/sqlite/open.ts';
import { createSqliteAppConfigStore } from '../infra/sqlite/app-config-store.ts';
import { createSqliteSkillStore } from '../infra/sqlite/skill-store.ts';
import { createSqliteStepStore } from '../infra/sqlite/step-store.ts';
import { systemClock } from '../infra/system-clock.ts';
import { loadConfig } from './config.ts';
@@ -23,6 +24,7 @@ const db = openDatabase(config.dbPath);
const app = await buildServer({
store: createSqliteStepStore(db),
appConfig: createSqliteAppConfigStore(db),
skills: createSqliteSkillStore(db),
clock: systemClock,
hub: createChangeHub(),
staticDir: config.webDir,
+20
View File
@@ -0,0 +1,20 @@
import { describe, expect, it } from 'vitest';
import { missingSkills, readSkillFiles } from './skills-file.ts';
describe('skill files', () => {
it('the shipped skills are one per project and none is empty', () => {
const files = readSkillFiles(new URL('../../config/skills', import.meta.url).pathname);
expect(files.map((file) => file.project)).toContain('nextcloud');
expect(files.length).toBeGreaterThanOrEqual(15);
for (const file of files) expect(file.content.trim(), file.project).not.toBe('');
});
it('adds only the skills the database lacks', () => {
const files = [
{ project: 'grafana', content: '# new' },
{ project: 'nextcloud', content: '# shipped' },
];
const existing = [{ project: 'nextcloud', content: '# edited by the owner', updatedAt: 'x' }];
expect(missingSkills(files, existing, '2026-09-19T20:00:00.000Z')).toEqual([{ project: 'grafana', content: '# new', updatedAt: '2026-09-19T20:00:00.000Z' }]);
});
});
+26
View File
@@ -0,0 +1,26 @@
import { readdirSync, readFileSync } from 'node:fs';
import { join } from 'node:path';
import { parseProjectName } from '../domain/compose.ts';
import type { Skill } from '../domain/skill.ts';
import { MAX_SKILL_LENGTH } from '../domain/skill.ts';
// The skills we ship (config/skills/<project>.md, imported once from the old system's SKILL.md files). The driver adds the
// ones the database lacks; a skill already in the database belongs to the owner (edited in the app) and is left as it is.
export function readSkillFiles(dir: string): { project: string; content: string }[] {
return readdirSync(dir)
.filter((name) => name.endsWith('.md'))
.sort()
.flatMap((name) => {
const project = parseProjectName(name.slice(0, -'.md'.length));
if (project === null) return [];
const content = readFileSync(join(dir, name), 'utf8');
if (content.length > MAX_SKILL_LENGTH) throw new Error(`skill ${name} is longer than ${String(MAX_SKILL_LENGTH)} characters`);
return [{ project, content }];
});
}
export function missingSkills(files: readonly { project: string; content: string }[], existing: readonly Skill[], now: string): Skill[] {
const known = new Set(existing.map((skill) => skill.project));
return files.filter((file) => !known.has(file.project)).map((file) => ({ ...file, updatedAt: now }));
}
+4 -2
View File
@@ -1,17 +1,19 @@
import type { DatabaseSync } from 'node:sqlite';
import type { AppConfigStore, StepStore } from '../domain/ports.ts';
import type { AppConfigStore, SkillStore, StepStore } from '../domain/ports.ts';
import { createSqliteAppConfigStore } from '../infra/sqlite/app-config-store.ts';
import { openDatabase } from '../infra/sqlite/open.ts';
import { createSqliteSkillStore } from '../infra/sqlite/skill-store.ts';
import { createSqliteStepStore } from '../infra/sqlite/step-store.ts';
export interface TestDatabase {
readonly db: DatabaseSync;
readonly store: StepStore;
readonly appConfig: AppConfigStore;
readonly skills: SkillStore;
}
/** A fresh in-memory database with the real schema and the real stores. */
export function createTestDatabase(): TestDatabase {
const db = openDatabase(':memory:');
return { db, store: createSqliteStepStore(db), appConfig: createSqliteAppConfigStore(db) };
return { db, store: createSqliteStepStore(db), appConfig: createSqliteAppConfigStore(db), skills: createSqliteSkillStore(db) };
}
+3 -1
View File
@@ -1,5 +1,5 @@
import type { z } from 'zod';
import { AppConfigResponse, AppsResponse, BuildResponse, ErrorResponse, ResolveResponse, RunDetailResponse, RunsResponse, StartRunResponse, StepsResponse } from '../http/contract.ts';
import { AppConfigResponse, AppsResponse, BuildResponse, ErrorResponse, ResolveResponse, RunDetailResponse, RunsResponse, SkillResponse, StartRunResponse, StepsResponse } from '../http/contract.ts';
import type { AppConfigPatch, ResolutionRequest } from '../http/contract.ts';
export class ApiError extends Error {
@@ -32,6 +32,8 @@ export const api = {
build: () => call(BuildResponse, '/api/build'),
appConfig: (app: string) => call(AppConfigResponse, `/api/apps/${encodeURIComponent(app)}/config`),
saveAppConfig: (app: string, patch: AppConfigPatch) => call(AppConfigResponse, `/api/apps/${encodeURIComponent(app)}/config`, patch),
skill: (project: string) => call(SkillResponse, `/api/projects/${encodeURIComponent(project)}/skill`),
saveSkill: (project: string, content: string) => call(SkillResponse, `/api/projects/${encodeURIComponent(project)}/skill`, { content }),
appSteps: (app: string) => call(StepsResponse, `/api/steps?app=${encodeURIComponent(app)}&limit=100`),
queue: () => call(StepsResponse, '/api/queue'),
startRun: () => call(StartRunResponse, '/api/runs', { type: 'manual' }),
+20 -1
View File
@@ -52,7 +52,7 @@ beforeEach(async () => {
seedDemoConfig(appConfig);
const clock = new FakeClock();
hub = createChangeHub();
server = await buildServer({ store, appConfig, clock, hub, pollChangesMs: 0, build: { branch: 'foundations', number: 6, revision: 'de5ee92a1b2c3d4e5f60718293a4b5c6d7e8f901' } });
server = await buildServer({ store, appConfig, skills: database.skills, clock, hub, pollChangesMs: 0, build: { branch: 'foundations', number: 6, revision: 'de5ee92a1b2c3d4e5f60718293a4b5c6d7e8f901' } });
const driver = createDriver({ store, clock, notifier: new RecordingNotifier(), handlers: createDemoHandlers({ appConfig, world: createDemoWorld() }) });
startManualRun(store, clock);
await driver.drain(new AbortController().signal);
@@ -264,6 +264,25 @@ describe('app configuration', () => {
});
});
describe('skill', () => {
it('edits the skill of the app\'s project on the app page', async () => {
window.location.hash = '#/apps/bazarr';
const form = await eventually(() => {
const found = container.querySelector<HTMLFormElement>('form.skill');
if (found === null) throw new Error('no skill form yet');
return found;
});
expect(form.textContent).toContain('No skill yet');
const area = form.querySelector<HTMLTextAreaElement>('textarea')!;
area.value = '# Bazarr\nNo database.';
area.dispatchEvent(new Event('input', { bubbles: true }));
await nextTask();
button(form, 'Save skill').click();
await eventually(() => expect(form.textContent).toContain('Saved'));
expect((await server.inject('/api/projects/bazarr/skill')).json()).toMatchObject({ content: '# Bazarr\nNo database.' });
});
});
describe('other views', () => {
it('lists runs with counts and opens a run with its steps', async () => {
window.location.hash = '#/runs';
+6
View File
@@ -403,6 +403,12 @@ h2 {
gap: 8px;
}
.skill textarea {
width: 100%;
font-family: ui-monospace, monospace;
font-size: 13px;
}
.config input[type='text'] {
width: 100%;
}
+4
View File
@@ -4,6 +4,7 @@ import { formatInstant } from '../format.ts';
import { useResource } from '../hooks.ts';
import { hrefFor } from '../route.ts';
import { AppConfigPanel } from './app-config.tsx';
import { SkillPanel } from './skill.tsx';
export function AppsView() {
const { data, error } = useResource(() => api.apps(), []);
@@ -63,6 +64,7 @@ export function AppsView() {
export function AppDetailView({ app }: { app: string }) {
const { data, error } = useResource(() => api.appSteps(app), [app]);
const config = useResource(() => api.appConfig(app), [app]);
return (
<section>
<p class="crumbs">
@@ -71,6 +73,8 @@ export function AppDetailView({ app }: { app: string }) {
<h1>{app}</h1>
<h2>Configuration</h2>
<AppConfigPanel app={app} />
<h2>Skill</h2>
{config.data === null ? null : <SkillPanel project={config.data.project} />}
<h2>Steps</h2>
<p class="muted">Step history, newest first.</p>
{error === null ? null : <ErrorNote message={error} />}
+60
View File
@@ -0,0 +1,60 @@
import { useState } from 'preact/hooks';
import { api } from '../api.ts';
import { ErrorNote, Loading } from '../components.tsx';
import type { SkillView } from '../../http/contract.ts';
import { useResource } from '../hooks.ts';
// The skill of the app's project: facts for the model when it repairs this app (only this project's skill is loaded).
function SkillForm({ initial }: { initial: SkillView }) {
const [saved, setSaved] = useState(initial);
const [text, setText] = useState(initial.content);
const [pending, setPending] = useState(false);
const [message, setMessage] = useState<{ kind: 'ok' | 'error'; text: string } | null>(null);
async function save(event: Event) {
event.preventDefault();
setPending(true);
try {
const next = await api.saveSkill(saved.project, text);
setSaved(next);
setText(next.content);
setMessage({ kind: 'ok', text: 'Saved.' });
} catch (cause) {
setMessage({ kind: 'error', text: cause instanceof Error ? cause.message : 'request failed' });
} finally {
setPending(false);
}
}
return (
<form class="skill card" onSubmit={(event) => void save(event)}>
<p class="muted">
Facts about <code>{saved.project}</code> for the model when it repairs this app: release notes, requirements (e.g. database versions),
what must stop an upgrade, how to check it and how to go back. {saved.updatedAt === null ? 'No skill yet.' : null}
</p>
<textarea
name="skill"
rows={16}
value={text}
disabled={pending}
onInput={(event) => {
setText(event.currentTarget.value);
setMessage(null);
}}
/>
<div class="actions">
<button type="submit" class="primary" disabled={pending || text === saved.content}>
Save skill
</button>
</div>
{message === null ? null : message.kind === 'ok' ? <p class="saved">{message.text}</p> : <ErrorNote message={message.text} />}
</form>
);
}
export function SkillPanel({ project }: { project: string }) {
const { data, error } = useResource(() => api.skill(project), [project]);
if (data === null) return error === null ? <Loading /> : <ErrorNote message={error} />;
return <SkillForm key={project} initial={data} />;
}