Compare commits
22 commits
clementine
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4f74662202 | ||
|
|
8370ad2e33 | ||
|
|
eb4349085d | ||
|
|
fe0c1e2b53 | ||
|
|
37ac6a2171 | ||
|
|
197e0ab19a | ||
|
|
8ad49de231 | ||
|
|
c14e215ad8 | ||
|
|
8cb55e57e8 | ||
|
|
3feb694084 | ||
|
|
b24dd17ee9 | ||
|
|
c2bf7a4263 | ||
|
|
ce5269b745 | ||
|
|
686a9fb906 | ||
|
|
84623b7c9a | ||
|
|
737d37a76d | ||
|
|
e35205b6cd | ||
|
|
8a09314eaf | ||
|
|
ff934ce5e9 | ||
|
|
f2a0d533b8 | ||
|
|
9035b9600b | ||
|
|
6e57fceb19 |
45 changed files with 3565 additions and 406 deletions
|
|
@ -9,7 +9,7 @@ Ladder (each tip autonomously `make html`; each depends on the previous):
|
||||||
| 2 | `feat/sm-content` | `089ef1f` | http://127.0.0.1:8102/ | `dossiers.3.ngi.hacktivism.ch` (optional) |
|
| 2 | `feat/sm-content` | `089ef1f` | http://127.0.0.1:8102/ | `dossiers.3.ngi.hacktivism.ch` (optional) |
|
||||||
| 3 | `feat/website-features` | `53a3176` | http://127.0.0.1:8103/ | `dossiers.2.ngi.hacktivism.ch` (optional) |
|
| 3 | `feat/website-features` | `53a3176` | http://127.0.0.1:8103/ | `dossiers.2.ngi.hacktivism.ch` (optional) |
|
||||||
| 4 | `feat/website-design` | `2e26c80` | http://127.0.0.1:8104/ | — (no public host after views deploy) |
|
| 4 | `feat/website-design` | `2e26c80` | http://127.0.0.1:8104/ | — (no public host after views deploy) |
|
||||||
| 5 | `feat/website-views` | `7aca0e8` (Semigrafik `1b2fcf7`; unpushed, ahead origin `bbafc38` by 2) | http://127.0.0.1:8105/ | path roots under blank landing (see Live) |
|
| 5 | `feat/website-views` | `bbafc38` (origin tip; local soft-reset dropped bad `1b2fcf7`/`7aca0e8`) | http://127.0.0.1:8105/ | path roots under blank landing (see Live) |
|
||||||
|
|
||||||
Caddy roots (SoT `scripts/caddy/Caddyfile.koopa-live-wdGet`):
|
Caddy roots (SoT `scripts/caddy/Caddyfile.koopa-live-wdGet`):
|
||||||
|
|
||||||
|
|
@ -29,9 +29,10 @@ basic_auth: `scripts/caddy/apply-dossiers-ngi-basicauth.sh`
|
||||||
|
|
||||||
## Live
|
## Live
|
||||||
|
|
||||||
- [dossiers.ngi](https://dossiers.ngi.hacktivism.ch/) ← **blank cascade landing** (kal tip `56ee97a`, sites/dossiers.ngi path roots; HTTP 200 SIZE≈3505)
|
- [dossiers.ngi](https://dossiers.ngi.hacktivism.ch/) ← **blank cascade landing** (kal `sites/dossiers.ngi.hacktivism.ch/`; EN + ASCII ladder; views tip cite `bbafc38`)
|
||||||
- Path Unterseiten: `/main/`, `/feat-sm-content/`, `/feat-website-features/`, `/feat-website-design/`, `/feat-website-views/`
|
- Path editions: `/main/`, `/feat-sm-content/`, `/feat-website-features/`, `/feat-website-design/`, `/feat-website-views/`
|
||||||
- Local freeze Semigrafik + hourly branch-status: tdpd `feat/website-views` @ `7aca0e8` ← `1b2fcf7` (unpushed)
|
- Hourly branch-status builder: **kal** `scripts/dossiers-ngi/branch-status-hourly.py` → `sites/…/branch-status.json` (+ user timer); **not** inside tdpd
|
||||||
|
- tdpd `feat/website-views` local HEAD = origin = `bbafc38` (NO_PUSH; bad tips discarded locally)
|
||||||
- [dossiers.2.ngi](https://dossiers.2.ngi.hacktivism.ch/) ← website-features (optional)
|
- [dossiers.2.ngi](https://dossiers.2.ngi.hacktivism.ch/) ← website-features (optional)
|
||||||
- [dossiers.3.ngi](https://dossiers.3.ngi.hacktivism.ch/) ← sm-content (optional)
|
- [dossiers.3.ngi](https://dossiers.3.ngi.hacktivism.ch/) ← sm-content (optional)
|
||||||
|
|
||||||
|
|
@ -43,6 +44,8 @@ Earlier primary: rsync `website/build/` from tip `feat/website-views` →
|
||||||
|
|
||||||
**Result 2026-09-28 ~22:25+02 (design @ `2e26c80`):** `RSYNC_OK` — ~2698 files / ~27M; live `dossier/sm/index.html` 41693 bytes.
|
**Result 2026-09-28 ~22:25+02 (design @ `2e26c80`):** `RSYNC_OK` — ~2698 files / ~27M; live `dossier/sm/index.html` 41693 bytes.
|
||||||
|
|
||||||
**Result 2026-09-29 ~04:30+02 (views Semigrafik era):** `RSYNC_OK` — live `/` HTTP 200; Semigrafik ladder on dossiers.ngi; later remade as tdpd tips `1b2fcf7` → `7aca0e8` (hourly builder). Push offen (eigene Freigabe).
|
**Result 2026-09-29 ~04:30+02 (views-era freeze):** `RSYNC_OK` — live `/` HTTP 200. Local tdpd later briefly carried wrong tips `1b2fcf7` → `7aca0e8` (ladder rewrite + hourly in tdpd); those were **dropped** locally back to `bbafc38` (TODO=430). Never push those tips.
|
||||||
|
|
||||||
**Result 2026-09-29 (blank landing kal @ `56ee97a`):** `RSYNC_OK` — live `/` HTTP 200 SIZE=3505 title `cascade landing`; path-root Unterseiten owned by koopa-admin-log `sites/dossiers.ngi.hacktivism.ch/`; NO_PUSH.
|
**Result 2026-09-29 (blank landing kal @ `56ee97a`):** `RSYNC_OK` — live `/` HTTP 200 SIZE=3505 title `cascade landing`; path-root editions owned by koopa-admin-log `sites/dossiers.ngi.hacktivism.ch/`; NO_PUSH.
|
||||||
|
|
||||||
|
**TODO=430 follow-up (local, NO_PUSH):** kal landing EN/ASCII + tip cite `bbafc38`; hourly builder + systemd under kal; daylog tip table corrected. Deploy landing / enable timer on koopa needs Freigabe.
|
||||||
|
|
|
||||||
57
2026/2026-10-20--forgejo-host-ro-users.md
Normal file
57
2026/2026-10-20--forgejo-host-ro-users.md
Normal file
|
|
@ -0,0 +1,57 @@
|
||||||
|
# Forgejo host RO users (rabbithole-ro, amanita-ro, firecuda-ro)
|
||||||
|
|
||||||
|
Created 2026-10-20 on koopa Forgejo for targeted **read-only** clones of selected `hernani/*` repos (replace host-local tree copies via git, no rsync/scp of SoT).
|
||||||
|
|
||||||
|
## Users
|
||||||
|
|
||||||
|
| Username | ID | Email | Flags |
|
||||||
|
|----------|----|-------|-------|
|
||||||
|
| `rabbithole-ro` | 182 | `rabbithole-ro@hosts.hacktivism.ch` | active, restricted, not admin |
|
||||||
|
| `amanita-ro` | 183 | `amanita-ro@hosts.hacktivism.ch` | active, restricted, not admin |
|
||||||
|
| `firecuda-ro` | 184 | `firecuda-ro@hosts.hacktivism.ch` | active, restricted, not admin |
|
||||||
|
|
||||||
|
## Repo access
|
||||||
|
|
||||||
|
- Collaborator **read** on `hernani/waldpfad` (all three).
|
||||||
|
- SSH user keys titled `*-host-ro` (ed25519) uploaded via oneshot admin token.
|
||||||
|
|
||||||
|
## Secrets (enc only)
|
||||||
|
|
||||||
|
Laptop SoT (git-crypt):
|
||||||
|
|
||||||
|
`PATH[koopa-admin-secrets-enc]/koopa/home-hernani/koopa-forgejo/hosts/{rabbithole,amanita,firecuda}-ro/`
|
||||||
|
|
||||||
|
Per host: `password.txt` (600), `ssh/id_ed25519` (+ `.pub`), `README.md`. Never commit plaintext; never grant write collab.
|
||||||
|
|
||||||
|
## Create recipe (idempotent)
|
||||||
|
|
||||||
|
On koopa as `hernani`, Forgejo HTTP **9024**:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
podman exec koopa-forgejo gitea admin user create \
|
||||||
|
--username USER --email USER@hosts.hacktivism.ch \
|
||||||
|
--random-password --must-change-password=false --restricted
|
||||||
|
```
|
||||||
|
|
||||||
|
SSH key + read collab via oneshot token (`write:admin,write:repository,write:user`) against `http://127.0.0.1:9024/api/v1`:
|
||||||
|
|
||||||
|
- `POST /admin/users/USER/keys` with `{"title","key"}`
|
||||||
|
- `PUT /repos/hernani/waldpfad/collaborators/USER` with `{"permission":"read"}`
|
||||||
|
|
||||||
|
Purge leftover `oneshot-*` tokens in Forgejo UI (API delete often 403). Wipe `/tmp/*.pub` / oneshot scripts after use.
|
||||||
|
|
||||||
|
## Clone pattern (on target host)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
GIT_SSH_COMMAND='ssh -i /path/to/id_ed25519 -o IdentitiesOnly=yes -o BatchMode=yes' \
|
||||||
|
git clone --depth 1 --single-branch --branch master \
|
||||||
|
ssh://git@git.hacktivism.ch:9200/hernani/waldpfad.git
|
||||||
|
```
|
||||||
|
|
||||||
|
Or Host stanza with `IdentityFile` for that RO key and `User git` / Port 9200 (ProxyJump as needed).
|
||||||
|
|
||||||
|
## Related
|
||||||
|
|
||||||
|
- Forgejo rootless: `2026-07-10--forgejo-rootless.md`
|
||||||
|
- Oneshot token pattern: `2026-09-07--git-meta-origin-forgejo.md`
|
||||||
|
- Host skill: Forgejo ports 9200 SSH / 9024 HTTP; secrets always `*enc`
|
||||||
13
2026/2026-10-21--castopod-php-stay-off.md
Normal file
13
2026/2026-10-21--castopod-php-stay-off.md
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
# Castopod stays off
|
||||||
|
|
||||||
|
Rechecked 2026-10-21 after the stop note. Web unit container-koopa-castopod.service is disabled, inactive, dead. Container koopa-castopod is exited, finished 2026-10-21 22:48:06 +0200. Nothing listens on :9020. MariaDB and Redis user units stay loaded and active. No second stop. Caddy vhost unchanged.
|
||||||
|
|
||||||
|
Log review found no sign of a successful remote code execution. Overlay file /tmp/fw-cp.php (1448 bytes, mode 600, sha256 7bb279bffa12dbb81787183108a2641b78a6657b069cbef7967950d80e13b5bc, mtime 2026-09-07 03:25:59 +0200) is a local database script for cp_users, outside the docroot, not a web shell. /tmp/os-dry.out is an apt dry-run. Five theme files differ by one branding line from 2026-07-13. Media volume has no scripts. Access logs run 2026-10-17 through the stop and show no 2xx on a non-index script. Critical lines on 2026-09-07 match other days.
|
||||||
|
|
||||||
|
Limits: access logs start 2026-10-17, so HTTP from 2026-09-07 is absent. A memory-only or already rotated request is not ruled out. Whether the local script ran is not in the logs. MariaDB was not opened.
|
||||||
|
|
||||||
|
Stays down until an image ships PHP newer than 8.4.18. Hub tag :1 from 2026-02-24 is not that image. Parent 537, slug castopod-restart-after-php-fix.
|
||||||
|
|
||||||
|
Restore only then:
|
||||||
|
|
||||||
|
systemctl --user enable --now container-koopa-castopod.service
|
||||||
23
2026/2026-10-21--castopod-php-stop.md
Normal file
23
2026/2026-10-21--castopod-php-stop.md
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
# Castopod PHP stopped
|
||||||
|
|
||||||
|
Checked 2026-10-21. Host has no php-fpm and no php package. The only PHP process was Castopod FrankenPHP in container koopa-castopod.
|
||||||
|
|
||||||
|
| Item | Value |
|
||||||
|
|------|--------|
|
||||||
|
| image | docker.io/castopod/castopod:1 |
|
||||||
|
| repo digest | sha256:4e4f0440520f45257bfeac7be4347defd20048b4efef8f53d73ec9ed3a4f7966 |
|
||||||
|
| amd64 digest | sha256:757f3c2a96b31b81753c23bc092dfcf05039d9df4d13cc12c428bb752fce882b |
|
||||||
|
| image created | 2026-02-24 |
|
||||||
|
| hub tags 1 / latest / 1.15.5 | same digest, pushed 2026-02-24 |
|
||||||
|
| PHP | 8.4.18 (12 Feb 2026) |
|
||||||
|
| FrankenPHP | v2.10.2 |
|
||||||
|
| listen | 0.0.0.0:9020 -> 8080 |
|
||||||
|
| public | https://castopod.hacktivism.ch/ |
|
||||||
|
|
||||||
|
Stable Hub tags have not moved since 2026-02-24. develop last push 2026-06-05 and is not a stable security update. php.net lists 8.4.26 (24 Sep 2026) and 8.4.27 (22 Oct 2026) after 8.4.18, with a banner to update for critical remote code execution. Pulling :1 repeats the same image. FrankenPHP ships its own PHP binary.
|
||||||
|
|
||||||
|
Stopped and disabled user unit container-koopa-castopod.service. ExecStop is `podman stop -t 30 koopa-castopod`. Container status after stop: Exited (0). :9020 not listening. MariaDB and Redis units left running. Caddy vhost unchanged.
|
||||||
|
|
||||||
|
Bring the web container back only when a newer image exists:
|
||||||
|
|
||||||
|
systemctl --user enable --now container-koopa-castopod.service
|
||||||
41
2026/2026-10-24--todo704-tor-cachyos-cookieauth.md
Normal file
41
2026/2026-10-24--todo704-tor-cachyos-cookieauth.md
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
# TODO=704 Tor CachyOS CTR + CookieAuth (koopa)
|
||||||
|
|
||||||
|
Relay was down (stale PIDFile / start-timeout on forking unit), keys intact. Recreated **same identity** on CachyOS container; CookieAuth for ControlPort; host tor disabled.
|
||||||
|
|
||||||
|
## Stand after APPLY (2026-10-24 ~21:47–21:51 Europe/Zurich)
|
||||||
|
|
||||||
|
| Item | Value |
|
||||||
|
|------|--------|
|
||||||
|
| CTR | `koopa-tor-relay` Up, image `localhost/koopa-tor-relay:latest` (=`:cachyos`) |
|
||||||
|
| Tor | 0.4.9.14 (CachyOS/Arch extra) |
|
||||||
|
| Unit | `container-koopa-tor-relay.service` Type=simple, active |
|
||||||
|
| Host tor | `tor.service` disabled + failed/inactive |
|
||||||
|
| FP | `02C7EECCDF7814FBA2DB1CBAED000D06E8A32EE8` (keys mtime 2024-11-14) |
|
||||||
|
| ORPort | 8080 |
|
||||||
|
| ControlPort | 127.0.0.1:9051 CookieAuthentication=1, CookieAuthFile=/var/lib/tor/control_auth_cookie |
|
||||||
|
| Bootstrap | PROGRESS=100 done; status/circuit-established=1 |
|
||||||
|
| Unauth ControlPort | 514 Authentication required |
|
||||||
|
| Cookie AUTH | 250 OK |
|
||||||
|
|
||||||
|
## Steps
|
||||||
|
|
||||||
|
1. Dry-run `/tmp/tor.sh` on koopa → STATUS=dry-run-ok; NEXT=`sudo APPLY=1 /tmp/tor.sh` (host tor was still enabled).
|
||||||
|
2. Human: `systemctl disable --now tor.service`, then `sudo APPLY=1 /tmp/tor.sh`.
|
||||||
|
3. APPLY: wrote CachyOS Containerfile, pulled `docker.io/cachyos/cachyos:latest`, build (cache hit), `podman create --replace --network host --user 1000:1000 --userns keep-id`, torrc bak `torrc.bak-cookie-20261024-214720`, CookieAuth on.
|
||||||
|
4. First APPLY hit dbus fail under sudo (`$DBUS_SESSION_BUS_ADDRESS` / `$XDG_RUNTIME_DIR` unset). Unit rewrite + enable finished with XDG/DBUS set; script path re-exec as SUDO_USER for later runs.
|
||||||
|
5. Verify from magikoopa SSH: unit active, CTR Up, FP match, circuit-established=1, unauth 514.
|
||||||
|
|
||||||
|
## Live paths
|
||||||
|
|
||||||
|
- `~/koopa-tor-relay/{torrc,data,log,build/Containerfile}`
|
||||||
|
- `~/.config/systemd/user/container-koopa-tor-relay.service`
|
||||||
|
- Driver: `/tmp/tor.sh` (TARGET=koopa); upgrade later: `~/upgrade-containers.sh --apply --rebuild-local --only koopa-tor-relay`
|
||||||
|
|
||||||
|
## Akte mirrors
|
||||||
|
|
||||||
|
- `configs/tor/torrc`
|
||||||
|
- `configs/tor/build/Containerfile`
|
||||||
|
- `configs/systemd/user/container-koopa-tor-relay.service`
|
||||||
|
- `host/tor/README.md` updated (was stale host `tor.service`)
|
||||||
|
|
||||||
|
Sibling: firecuda brew tor CookieAuth same day (FP `52BB94DDC1292F950CF728708AC48523E018A718`, MyFamily both ways). Parent TODO=704.
|
||||||
21
configs/systemd/user/container-koopa-tor-relay.service
Normal file
21
configs/systemd/user/container-koopa-tor-relay.service
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
# container-koopa-tor-relay.service — regenerated by tor.sh 2026-10-24T21:47:24+02:00
|
||||||
|
# Prefer simple over podman-generate-systemd forking+PIDFile (stale CID storm).
|
||||||
|
|
||||||
|
[Unit]
|
||||||
|
Description=Podman koopa-tor-relay
|
||||||
|
Wants=network-online.target
|
||||||
|
After=network-online.target
|
||||||
|
RequiresMountsFor=%t/containers
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Environment=PODMAN_SYSTEMD_UNIT=%n
|
||||||
|
Restart=always
|
||||||
|
RestartSec=5
|
||||||
|
TimeoutStartSec=120
|
||||||
|
TimeoutStopSec=70
|
||||||
|
ExecStart=/usr/bin/podman start --attach koopa-tor-relay
|
||||||
|
ExecStop=/usr/bin/podman stop -t 10 koopa-tor-relay
|
||||||
|
Type=simple
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=default.target
|
||||||
25
configs/systemd/user/dossiers-ngi-branch-status.service
Normal file
25
configs/systemd/user/dossiers-ngi-branch-status.service
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
[Unit]
|
||||||
|
Description=dossiers.ngi branch-status JSON + on-demand edition rebuild
|
||||||
|
Documentation=file:%h/src/koopa/koopa-admin-log/scripts/dossiers-ngi/README.md
|
||||||
|
After=default.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=oneshot
|
||||||
|
Nice=10
|
||||||
|
RemainAfterExit=no
|
||||||
|
# Full freezes per changed tip can take a while; skip overlap via flock
|
||||||
|
TimeoutStartSec=1h
|
||||||
|
Environment=TZ=Europe/Zurich
|
||||||
|
Environment=ADMIN_LOG=%h/src/koopa/koopa-admin-log
|
||||||
|
Environment=TDPD_ROOT=%h/git/src/ngi/tech-dossier-project-data
|
||||||
|
Environment=OUT=/var/www/monitoring-sites/dossiers.ngi.hacktivism.ch/branch-status.json
|
||||||
|
Environment=EDITIONS_ROOT=/var/www/monitoring-sites/dossiers.ngi.hacktivism.ch/editions
|
||||||
|
# Rebuild only when current tip freeze_ok is not already true (never force from timer)
|
||||||
|
Environment=REBUILD=1
|
||||||
|
EnvironmentFile=-%h/.config/dossiers-ngi/branch-status.env
|
||||||
|
# flock -n: second fire while busy exits 0 without stacking garbage
|
||||||
|
ExecStart=/usr/bin/flock -n %t/dossiers-ngi-branch-status.lock /usr/bin/python3 %h/src/koopa/koopa-admin-log/scripts/dossiers-ngi/branch-status-hourly.py --rebuild
|
||||||
|
SuccessExitStatus=0 1
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=default.target
|
||||||
14
configs/systemd/user/dossiers-ngi-branch-status.timer
Normal file
14
configs/systemd/user/dossiers-ngi-branch-status.timer
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
[Unit]
|
||||||
|
Description=Timer · dossiers.ngi branch-status JSON (every 10 min)
|
||||||
|
Documentation=file:%h/src/koopa/koopa-admin-log/scripts/dossiers-ngi/README.md
|
||||||
|
|
||||||
|
[Timer]
|
||||||
|
OnBootSec=3min
|
||||||
|
OnUnitActiveSec=10min
|
||||||
|
AccuracySec=1min
|
||||||
|
RandomizedDelaySec=30
|
||||||
|
Persistent=true
|
||||||
|
Unit=dossiers-ngi-branch-status.service
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=timers.target
|
||||||
24
configs/tor/build/Containerfile
Normal file
24
configs/tor/build/Containerfile
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
# koopa-tor-relay — Tor non-exit relay on CachyOS (Arch extra/tor)
|
||||||
|
# Same pattern as koopa-meet-web:cachyos. Rootless: tor user -> uid/gid 1000.
|
||||||
|
# Recreate: --user 1000:1000 --userns=keep-id (see README / tor.sh)
|
||||||
|
ARG CACHYOS_IMAGE=docker.io/cachyos/cachyos:latest
|
||||||
|
FROM ${CACHYOS_IMAGE}
|
||||||
|
ENV TZ=Europe/Zurich
|
||||||
|
RUN pacman -Syu --noconfirm --needed tor nyx ca-certificates \
|
||||||
|
&& pacman -Scc --noconfirm \
|
||||||
|
&& rm -rf /var/cache/pacman/pkg/* /tmp/* \
|
||||||
|
&& mkdir -p /var/lib/tor /var/log/tor \
|
||||||
|
&& if id tor >/dev/null 2>&1; then \
|
||||||
|
groupmod -g 1000 tor 2>/dev/null || true; \
|
||||||
|
usermod -u 1000 -g 1000 tor 2>/dev/null || true; \
|
||||||
|
elif id debian-tor >/dev/null 2>&1; then \
|
||||||
|
groupmod -g 1000 debian-tor; usermod -u 1000 -g 1000 debian-tor; \
|
||||||
|
else \
|
||||||
|
groupadd -g 1000 tor; useradd -u 1000 -g 1000 -d /var/lib/tor -s /usr/bin/nologin tor; \
|
||||||
|
fi \
|
||||||
|
&& chown -R 1000:1000 /var/lib/tor /var/log/tor \
|
||||||
|
&& chmod 700 /var/lib/tor
|
||||||
|
EXPOSE 8080 9051
|
||||||
|
USER 1000:1000
|
||||||
|
ENTRYPOINT ["/usr/bin/tor"]
|
||||||
|
CMD ["-f", "/etc/tor/torrc", "--runasdaemon", "0"]
|
||||||
18
configs/tor/torrc
Normal file
18
configs/tor/torrc
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
# KoopaRelay — same policy as live /etc/tor/torrc + admin-log configs/tor/torrc
|
||||||
|
# Identity: keys in DataDirectory (migrate from host /var/lib/tor)
|
||||||
|
Log notice file /var/log/tor/notices.log
|
||||||
|
Nickname KoopaRelay
|
||||||
|
ContactInfo info+koopa@hacktivism.ch
|
||||||
|
# dual-stack: bind all addresses on 8080 (IPv4 + IPv6), same as host tor
|
||||||
|
ORPort 8080
|
||||||
|
ControlPort 127.0.0.1:9051
|
||||||
|
CookieAuthentication 1
|
||||||
|
CookieAuthFile /var/lib/tor/control_auth_cookie
|
||||||
|
ExitRelay 0
|
||||||
|
SocksPort 0
|
||||||
|
MyFamily 52BB94DDC1292F950CF728708AC48523E018A718
|
||||||
|
BandwidthRate 2000 MBytes
|
||||||
|
BandwidthBurst 2000 MBytes
|
||||||
|
RelayBandwidthRate 2000 MBytes
|
||||||
|
RelayBandwidthBurst 2000 MBytes
|
||||||
|
DataDirectory /var/lib/tor
|
||||||
|
|
@ -1,13 +1,30 @@
|
||||||
# Tor on koopa
|
# Tor on koopa
|
||||||
|
|
||||||
**Runtime:** host **`tor.service`** (not a podman container).
|
**Runtime:** rootless podman container **`koopa-tor-relay`** (CachyOS base, Arch `extra/tor`), managed by user unit **`container-koopa-tor-relay.service`** (`Type=simple`, `podman start --attach`).
|
||||||
**Ports:** ORPort **8080** · ControlPort **9051** (loopback).
|
**Host `tor.service`:** disabled / must stay off (port clash on ORPort 8080 + ControlPort 9051).
|
||||||
**Non-exit** relay (`ExitRelay 0`).
|
**Ports:** ORPort **8080** · ControlPort **127.0.0.1:9051** (CookieAuthentication + CookieAuthFile).
|
||||||
|
**Non-exit** relay (`ExitRelay 0`). Nickname **KoopaRelay**.
|
||||||
|
**Fingerprint (stable keys under `~/koopa-tor-relay/data/keys/`):** `02C7EECCDF7814FBA2DB1CBAED000D06E8A32EE8`
|
||||||
|
**Family:** firecuda FireCudaRelay `52BB94DDC1292F950CF728708AC48523E018A718`
|
||||||
|
|
||||||
|
Live tree: `~/koopa-tor-relay/` (`torrc`, `data/`, `log/`, `build/Containerfile`).
|
||||||
|
Mirrors in this repo: `configs/tor/torrc`, `configs/tor/build/Containerfile`, `configs/systemd/user/container-koopa-tor-relay.service`.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
systemctl status tor
|
# status
|
||||||
ss -lntp | grep -E '8080|9051'
|
systemctl --user status container-koopa-tor-relay.service
|
||||||
|
podman ps --filter name=koopa-tor-relay
|
||||||
|
# ControlPort cookie auth (hex)
|
||||||
|
COOKIE=$(xxd -p -c 256 ~/koopa-tor-relay/data/control_auth_cookie)
|
||||||
|
printf 'AUTHENTICATE %s\r\nGETINFO fingerprint\r\nGETINFO status/circuit-established\r\nQUIT\r\n' "$COOKIE" | nc -w 3 127.0.0.1 9051
|
||||||
```
|
```
|
||||||
|
|
||||||
login-health: WARN if `tor.service` not active.
|
Apply / recreate (after host tor off):
|
||||||
Legacy `koopa-tor-relay` / `configs/tor/` removed from live inventory (git history keeps them).
|
|
||||||
|
```bash
|
||||||
|
sudo APPLY=1 /tmp/tor.sh
|
||||||
|
# later upgrades:
|
||||||
|
~/upgrade-containers.sh --apply --rebuild-local --only koopa-tor-relay
|
||||||
|
```
|
||||||
|
|
||||||
|
login-health: expect user unit active + CTR Up; host `tor.service` inactive. Parent TODO=704.
|
||||||
|
|
|
||||||
|
|
@ -41,6 +41,10 @@ Host runtime: `/etc/caddy/secrets/dossiers-ngi-basicauth.txt` (root-only).
|
||||||
SoT: `koopa-admin-secrets-enc` (git-crypt) → `koopa/home-hernani/caddy/`.
|
SoT: `koopa-admin-secrets-enc` (git-crypt) → `koopa/home-hernani/caddy/`.
|
||||||
Hosts: `dossiers.ngi` / `dossiers.2.ngi` / `dossiers.3.ngi`.hacktivism.ch
|
Hosts: `dossiers.ngi` / `dossiers.2.ngi` / `dossiers.3.ngi`.hacktivism.ch
|
||||||
|
|
||||||
|
HTTP→HTTPS 308 for `dossiers.ngi` (add host to the shared ACME list, leave
|
||||||
|
HTTPS body alone): `scripts/dossiers-ngi/apply-dossiers-ngi-http-308.sh`
|
||||||
|
then `sudo ~/bin/caddy-apply`.
|
||||||
|
|
||||||
## silverbullet.hacktivism.ch — merge + basic_auth
|
## silverbullet.hacktivism.ch — merge + basic_auth
|
||||||
|
|
||||||
After podman cutover (`scripts/notes/cutover-to-silverbullet.sh --apply`) and DNS:
|
After podman cutover (`scripts/notes/cutover-to-silverbullet.sh --apply`) and DNS:
|
||||||
|
|
|
||||||
165
scripts/dossiers-ngi/README.md
Normal file
165
scripts/dossiers-ngi/README.md
Normal file
|
|
@ -0,0 +1,165 @@
|
||||||
|
# dossiers-ngi — dynamic edition bar + 10‑min rebuild
|
||||||
|
|
||||||
|
SoT for the sticky edition bar on **dossiers.ngi.hacktivism.ch**. Live freezes
|
||||||
|
sit under `editions/<slug>/` on koopa; cookie `tdpd_edition` selects the build.
|
||||||
|
The bar renders a **semi-graphic parent→children tree** from
|
||||||
|
`/branch-status.json` (all `origin/*` tips, not a hard-coded ladder).
|
||||||
|
|
||||||
|
| Path | Role |
|
||||||
|
|------|------|
|
||||||
|
| `sites/…/_cascade/` | Sticky bar shell + JS tree renderer |
|
||||||
|
| `editions/` on koopa www | Full freezes switched by the bar |
|
||||||
|
| `sites/…/cascade/` | Legacy tip stubs (optional) |
|
||||||
|
| `scripts/dossiers-ngi/branch-status-hourly.py` | Fetch remotes → tree JSON → on-demand rebuild |
|
||||||
|
| `scripts/dossiers-ngi/smoke-branch-tree.sh` | Temp-branch smoke (creates/deletes local origin only) |
|
||||||
|
| `configs/systemd/user/dossiers-ngi-branch-status.{timer,service}` | Every **10 min** oneshot on koopa (`flock` against overlap) |
|
||||||
|
|
||||||
|
## Env
|
||||||
|
|
||||||
|
| Var | Default | Meaning |
|
||||||
|
|-----|---------|---------|
|
||||||
|
| `TDPD_ROOT` | `$HOME/git/src/ngi/tech-dossier-project-data` | tdpd git checkout (may be git-dir only) |
|
||||||
|
| `TDPD_VENV` | `(TDPD_ROOT)/.venv` | Frozen-Flask venv; symlinked into worktrees |
|
||||||
|
| `OUT` | kal `sites/…/branch-status.json` | write path (live www on koopa) |
|
||||||
|
| `EDITIONS_ROOT` | (none) | `…/editions` — required for `--rebuild` |
|
||||||
|
| `REBUILD` / `--rebuild` | off | rebuild when `.edition-tip` ≠ tip |
|
||||||
|
| `FORCE_REBUILD` / `--force` | off | rebuild every branch (timing) |
|
||||||
|
| `FETCH` / `--no-fetch` | fetch on | `git fetch --prune origin` |
|
||||||
|
| `FREEZE_CMD` | (make/venv freeze) | stub for smoke tests; `{root}` `{branch}` `{slug}` |
|
||||||
|
| `DEFAULT_BRANCH` | `feat/website-views` | behind cookie `flask` |
|
||||||
|
| `HOST` | `https://dossiers.ngi.hacktivism.ch` | public host in JSON |
|
||||||
|
|
||||||
|
On koopa: `%h/src/koopa/koopa-admin-log` +
|
||||||
|
`~/.config/dossiers-ngi/branch-status.env` for path overrides.
|
||||||
|
NGI clone token lives in **koopa-admin-secrets-enc** (not this tree).
|
||||||
|
|
||||||
|
## Manual run
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# JSON only
|
||||||
|
python3 scripts/dossiers-ngi/branch-status-hourly.py
|
||||||
|
|
||||||
|
# rebuild stale freezes + timing block in JSON
|
||||||
|
EDITIONS_ROOT=/var/www/monitoring-sites/dossiers.ngi.hacktivism.ch/editions \
|
||||||
|
python3 scripts/dossiers-ngi/branch-status-hourly.py --rebuild
|
||||||
|
|
||||||
|
# force all branches (wall clock → rebuild_timing)
|
||||||
|
… --rebuild --force
|
||||||
|
```
|
||||||
|
|
||||||
|
`rebuild_timing` fields: `wall_seconds`, `built`, `skipped`, `errors`,
|
||||||
|
`built_seconds_sum`, `built_seconds_max`. Per-branch `rebuild[name].seconds`.
|
||||||
|
|
||||||
|
Per branch in JSON / sticky bar:
|
||||||
|
|
||||||
|
| Field | Meaning |
|
||||||
|
|-------|---------|
|
||||||
|
| `tip` | short SHA |
|
||||||
|
| `tip_time` | committer time of tip (`%cI`) |
|
||||||
|
| `built_at` | when this edition freeze was written |
|
||||||
|
| `build_seconds` | wall time of that freeze |
|
||||||
|
| `freeze_ok` | `true` when the current origin tip freeze succeeded (sticky bar green ✓); `false` when it failed, is a placeholder, or the recorded tip is stale (red ✗) |
|
||||||
|
| `freeze_error` | last freeze error text (only when not ok) |
|
||||||
|
| `vs_parent_plus` / `vs_parent_minus` | `git diff --numstat` tip vs **parent** tip (sticky bar +grün/−rot; omitted when `parent` is empty or not in the tree, e.g. `main` and `feature/nl-net-injestion-injestor-update`) |
|
||||||
|
| `loc` | text-file line count of the tip vs the empty tree (gray `N LOC` in the plus/minus columns; only when there is no parent) |
|
||||||
|
|
||||||
|
Rebuild runs **only when** the current origin tip is not already a successful freeze (`freeze_ok`; timer never forces). Cookie alias `editions/flask` copies from `DEFAULT_BRANCH` unless that alias is already `freeze_ok` for the same tip **and** `.edition-built-at` matches the source freeze.
|
||||||
|
Each run also: `git fetch --prune`, `worktree prune`, drop stale
|
||||||
|
`editions/<slug>/` (gone remotes), purge leftover `tdpd-*` tmp dirs.
|
||||||
|
|
||||||
|
## Smoke
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash scripts/dossiers-ngi/smoke-branch-tree.sh
|
||||||
|
# → SMOKE_OK (temp origin + smoke/temp-sibling deleted)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Edition freeze missing the bar (cookie trap)
|
||||||
|
|
||||||
|
`feat/website-views` freeze can omit `{% include "_cascade/bar.html" %}`.
|
||||||
|
Cookie `tdpd_edition` then has no switcher. **No zypper / Caddy plugin.**
|
||||||
|
Way back: `https://dossiers.ngi.hacktivism.ch/_cascade/use/flask`
|
||||||
|
or `/_cascade/` (`index.html` switcher).
|
||||||
|
|
||||||
|
On koopa (hernani, no root):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash scripts/dossiers-ngi/inject-editions-bar.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Hourly rebuild injects on skip **and** freeze-error (keep existing HTML + include).
|
||||||
|
|
||||||
|
The bar is `position: fixed` (not sticky); `100vw` overflow is avoided.
|
||||||
|
|
||||||
|
## Deploy landing (Freigabe)
|
||||||
|
|
||||||
|
Do **not** `--delete` the whole www root. Sync:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
SITE="$HOME/git/admin-logs/local/koopa-admin-log/sites/dossiers.ngi.hacktivism.ch"
|
||||||
|
WWW=hernani@koopa:/var/www/monitoring-sites/dossiers.ngi.hacktivism.ch
|
||||||
|
rsync -avz --exclude .DS_Store "$SITE/_cascade/" "$WWW/_cascade/"
|
||||||
|
rsync -avz --exclude .DS_Store "$SITE/branch-status.json" "$WWW/branch-status.json"
|
||||||
|
# editions/ from a --rebuild run
|
||||||
|
```
|
||||||
|
|
||||||
|
## Sticky cascade bar
|
||||||
|
|
||||||
|
`bar.js` fetches `/branch-status.json` and draws:
|
||||||
|
|
||||||
|
```
|
||||||
|
feature/nl-net-injestion-injestor-update … N LOC
|
||||||
|
main … N LOC
|
||||||
|
├── feat/sm-content … +N −M # vs main
|
||||||
|
│ └── feat/website-features … +N −M # vs sm-content
|
||||||
|
└── project_template … +N −M # vs main
|
||||||
|
```
|
||||||
|
|
||||||
|
Visible cells: freeze mark (green ✓ / red ✗) · name (tree prefix + branch) · tip `monDD HH:MM` · `Ns monDD HH:MM` (build seconds + freeze time) · plus column · minus column. `+N −M` vs the **parent** tip, in two right-aligned columns so `+898` and `+1200` share an edge. Roots with no parent show gray `N` in the plus column and `LOC` in the minus column. No `@sha`, no `default` label (`data-default` stays for flask/cookie). Inner tree `max-width: 45rem` (same as site `#navigation` / `#content` 45em at root 16px), so names line up with About and the minus column with the page edge; below 45rem the tree is full width with a small pad. Rear cells are right-aligned `tabular-nums`. Poll every 30s **updates mark/when/built/plus/minus in place** when the branch structure is unchanged (no full tree remount).
|
||||||
|
|
||||||
|
Each name links to `/_cascade/use/<edition>` (cookie + 302 `/`). Caddy map:
|
||||||
|
|
||||||
|
```
|
||||||
|
~^[A-Za-z0-9_-]+$ {http.request.cookie.tdpd_edition}
|
||||||
|
```
|
||||||
|
|
||||||
|
Also serve `/cascade/*` from the site www root (path stubs). Live Caddy must not use a trailing `~.* flask` catch-all.
|
||||||
|
|
||||||
|
Edition purity: responses send `Vary: Cookie` and `Cache-Control: private, no-cache` so `/static/css/style.css` (same path, different bodies per edition) cannot stick the green design CSS onto `main` after a views visit.
|
||||||
|
|
||||||
|
so new branches need no Caddy edit — only an `editions/<slug>/` tree.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash ~/bin/apply-dossiers-ngi-edition-switch.sh
|
||||||
|
sudo ~/bin/caddy-apply
|
||||||
|
```
|
||||||
|
|
||||||
|
Stanza SoT: `scripts/dossiers-ngi/caddy-dossiers-ngi.snippet`.
|
||||||
|
|
||||||
|
## HTTP → HTTPS 308
|
||||||
|
|
||||||
|
Shared Caddy HTTP list already `redir https://{host}{uri} permanent` (308).
|
||||||
|
`dossiers.ngi.hacktivism.ch` was missing there → HTTP `200` empty body /
|
||||||
|
Firefox `NS_ERROR_NET_EMPTY_RESPONSE`. HTTPS site body stays untouched.
|
||||||
|
|
||||||
|
On koopa (hernani, then root apply):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# SoT: scripts/dossiers-ngi/apply-dossiers-ngi-http-308.sh
|
||||||
|
cp /path/to/apply-dossiers-ngi-http-308.sh ~/bin/
|
||||||
|
chmod +x ~/bin/apply-dossiers-ngi-http-308.sh
|
||||||
|
bash ~/bin/apply-dossiers-ngi-http-308.sh
|
||||||
|
sudo ~/bin/caddy-apply
|
||||||
|
```
|
||||||
|
|
||||||
|
Smoke after apply:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -sSI http://dossiers.ngi.hacktivism.ch/ | grep -Ei 'HTTP|location'
|
||||||
|
# 308 Location: https://dossiers.ngi.hacktivism.ch/
|
||||||
|
```
|
||||||
|
|
||||||
|
`.2` / `.3` stay off this list unless `SITE=` is set.
|
||||||
|
|
||||||
|
|
||||||
|
Caddy edition map must list each `editions/<slug>` explicitly. A map output of `{http.request.cookie.tdpd_edition}` did not expand on Caddy v2.11.4 (only `flask` worked → 404 for `main`/views). After new tips appear, refresh the snippet map (or regenerate from `ls editions`) before `apply-dossiers-ngi-edition-switch` + `caddy-apply`.
|
||||||
82
scripts/dossiers-ngi/apply-dossiers-ngi-edition-switch.sh
Executable file
82
scripts/dossiers-ngi/apply-dossiers-ngi-edition-switch.sh
Executable file
|
|
@ -0,0 +1,82 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
# Host-local on koopa. Replaces dossiers.ngi.hacktivism.ch site-block body
|
||||||
|
# with cookie→editions sticky-bar switch. Leaves .2/.3 alone.
|
||||||
|
# Next: sudo ~/bin/caddy-apply
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
CADDY="${CADDY:-$HOME/koopa-caddy/Caddyfile}"
|
||||||
|
SITE="dossiers.ngi.hacktivism.ch"
|
||||||
|
SNIPPET="${SNIPPET:-$HOME/bin/caddy-dossiers-ngi.snippet}"
|
||||||
|
|
||||||
|
if [[ ! -f "$CADDY" ]]; then
|
||||||
|
echo "FAIL: missing $CADDY" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if [[ ! -f "$SNIPPET" ]]; then
|
||||||
|
echo "FAIL: missing snippet $SNIPPET" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
ts="$(date +%Y%m%d-%H%M%S)"
|
||||||
|
bak="${CADDY}.bak-dossiers-edition-${ts}"
|
||||||
|
cp -a "$CADDY" "$bak"
|
||||||
|
echo "bak: $bak"
|
||||||
|
|
||||||
|
python3 - "$CADDY" "$SITE" "$SNIPPET" <<'PY'
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
caddy_path, site, snippet_path = Path(sys.argv[1]), sys.argv[2], Path(sys.argv[3])
|
||||||
|
text = caddy_path.read_text()
|
||||||
|
raw_snip = snippet_path.read_text().splitlines()
|
||||||
|
body_lines = []
|
||||||
|
for line in raw_snip:
|
||||||
|
if line.startswith("#") or not line.strip():
|
||||||
|
continue
|
||||||
|
body_lines.append(line if line.startswith("\t") else "\t" + line.lstrip())
|
||||||
|
body = "\n".join(body_lines).rstrip() + "\n"
|
||||||
|
if "edition_dir" not in body or "tdpd_edition" not in body:
|
||||||
|
print("FAIL: snippet body looks empty/wrong", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
needle = site + " {"
|
||||||
|
start = text.find(needle)
|
||||||
|
if start < 0:
|
||||||
|
# allow no space
|
||||||
|
needle = site + "{"
|
||||||
|
start = text.find(needle)
|
||||||
|
if start < 0:
|
||||||
|
print(f"FAIL: site block not found: {site}", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
brace_open = text.find("{", start)
|
||||||
|
if brace_open < 0:
|
||||||
|
print("FAIL: no opening brace", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
depth = 0
|
||||||
|
end = None
|
||||||
|
for i, ch in enumerate(text[brace_open:], start=brace_open):
|
||||||
|
if ch == "{":
|
||||||
|
depth += 1
|
||||||
|
elif ch == "}":
|
||||||
|
depth -= 1
|
||||||
|
if depth == 0:
|
||||||
|
end = i
|
||||||
|
break
|
||||||
|
if end is None:
|
||||||
|
print("FAIL: unclosed site block", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
# keep optional newline after closing brace
|
||||||
|
close_end = end + 1
|
||||||
|
if close_end < len(text) and text[close_end] == "\n":
|
||||||
|
close_end += 1
|
||||||
|
|
||||||
|
new_text = text[: brace_open + 1] + "\n" + body + "}" + ("\n" if text[end+1:end+2] == "\n" or True else "")
|
||||||
|
# normalize: site {\n BODY }\n
|
||||||
|
new_text = text[: brace_open + 1] + "\n" + body + "}\n" + text[close_end:]
|
||||||
|
caddy_path.write_text(new_text)
|
||||||
|
print(f"OK: replaced body of {site} ({len(body_lines)} lines)")
|
||||||
|
print("Next: sudo ~/bin/caddy-apply")
|
||||||
|
PY
|
||||||
764
scripts/dossiers-ngi/branch-status-hourly.py
Executable file
764
scripts/dossiers-ngi/branch-status-hourly.py
Executable file
|
|
@ -0,0 +1,764 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Discover all origin branches, emit branch-status.json, optional edition rebuild.
|
||||||
|
|
||||||
|
Dynamic cascade for dossiers.ngi.hacktivism.ch:
|
||||||
|
|
||||||
|
• every origin/* tip (not a hard-coded ladder)
|
||||||
|
• parent = nearest other tip that is a git ancestor
|
||||||
|
• sticky bar reads this JSON and draws a semi-graphic tree
|
||||||
|
• optional --rebuild / REBUILD=1: worktree + make html into editions/<slug>/
|
||||||
|
only when .edition-tip differs (on demand; timer ~10min)
|
||||||
|
|
||||||
|
Env:
|
||||||
|
|
||||||
|
TDPD_ROOT tech-dossier-project-data checkout (may be git-dir only)
|
||||||
|
OUT branch-status.json path (live www on koopa)
|
||||||
|
EDITIONS_ROOT editions/ dir on www (required for rebuild)
|
||||||
|
REBUILD 1 → rebuild stale/missing freezes
|
||||||
|
FETCH 0 → skip git fetch (default 1)
|
||||||
|
FREEZE_CMD override build (default: make html); `{root}` `{branch}` `{slug}`
|
||||||
|
DEFAULT_BRANCH branch behind cookie flask (default feat/website-views)
|
||||||
|
HOST public host URL
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import time
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
ADMIN_LOG = Path(__file__).resolve().parents[2]
|
||||||
|
_DEFAULT_OUT = ADMIN_LOG / "sites" / "dossiers.ngi.hacktivism.ch" / "branch-status.json"
|
||||||
|
_PLACEHOLDER = (
|
||||||
|
ADMIN_LOG / "sites" / "dossiers.ngi.hacktivism.ch" / "_edition-placeholder" / "index.html"
|
||||||
|
)
|
||||||
|
HOST_DEFAULT = "https://dossiers.ngi.hacktivism.ch"
|
||||||
|
DEFAULT_TDPD = Path.home() / "git" / "src" / "ngi" / "tech-dossier-project-data"
|
||||||
|
INJECT = Path(__file__).resolve().parent / "inject-cascade-bar.py"
|
||||||
|
SLUG_RE = re.compile(r"[^A-Za-z0-9_-]+")
|
||||||
|
|
||||||
|
|
||||||
|
def env_path(name: str, default: Path | None = None) -> Path | None:
|
||||||
|
raw = os.environ.get(name, "").strip()
|
||||||
|
if raw:
|
||||||
|
return Path(raw).expanduser()
|
||||||
|
return default
|
||||||
|
|
||||||
|
|
||||||
|
def out_path() -> Path:
|
||||||
|
return env_path("OUT", _DEFAULT_OUT) # type: ignore[return-value]
|
||||||
|
|
||||||
|
|
||||||
|
def tdpd_root() -> Path:
|
||||||
|
return env_path("TDPD_ROOT", DEFAULT_TDPD) # type: ignore[return-value]
|
||||||
|
|
||||||
|
|
||||||
|
def editions_root() -> Path | None:
|
||||||
|
return env_path("EDITIONS_ROOT", None)
|
||||||
|
|
||||||
|
|
||||||
|
def host() -> str:
|
||||||
|
return os.environ.get("HOST", "").strip() or HOST_DEFAULT
|
||||||
|
|
||||||
|
|
||||||
|
def default_branch() -> str:
|
||||||
|
return os.environ.get("DEFAULT_BRANCH", "").strip() or "feat/website-views"
|
||||||
|
|
||||||
|
|
||||||
|
def edition_slug(branch: str) -> str:
|
||||||
|
return SLUG_RE.sub("-", branch.replace("/", "-")).strip("-") or "branch"
|
||||||
|
|
||||||
|
|
||||||
|
def git(repo: Path, *args: str, check: bool = True, text: bool = True) -> subprocess.CompletedProcess:
|
||||||
|
return subprocess.run(
|
||||||
|
["git", "-C", str(repo), *args],
|
||||||
|
check=check,
|
||||||
|
text=text,
|
||||||
|
capture_output=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_origin(repo: Path) -> None:
|
||||||
|
if os.environ.get("FETCH", "1").strip() == "0":
|
||||||
|
return
|
||||||
|
subprocess.run(
|
||||||
|
["git", "-C", str(repo), "fetch", "--prune", "origin"],
|
||||||
|
check=False,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def list_origin_branches(repo: Path) -> dict[str, str]:
|
||||||
|
"""branch name → full SHA for refs/remotes/origin/* (no HEAD)."""
|
||||||
|
cp = git(repo, "for-each-ref", "--format=%(refname:short)|%(objectname)", "refs/remotes/origin")
|
||||||
|
out: dict[str, str] = {}
|
||||||
|
for line in cp.stdout.splitlines():
|
||||||
|
if "|" not in line:
|
||||||
|
continue
|
||||||
|
short, sha = line.split("|", 1)
|
||||||
|
if short in ("origin", "origin/HEAD") or "->" in short:
|
||||||
|
continue
|
||||||
|
if not short.startswith("origin/"):
|
||||||
|
continue
|
||||||
|
name = short[len("origin/") :]
|
||||||
|
if name == "HEAD":
|
||||||
|
continue
|
||||||
|
out[name] = sha
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def is_ancestor(repo: Path, anc: str, desc: str) -> bool:
|
||||||
|
cp = git(repo, "merge-base", "--is-ancestor", anc, desc, check=False)
|
||||||
|
return cp.returncode == 0
|
||||||
|
|
||||||
|
|
||||||
|
def commits_between(repo: Path, a: str, b: str) -> int:
|
||||||
|
cp = git(repo, "rev-list", "--count", f"{a}..{b}", check=False)
|
||||||
|
if cp.returncode != 0:
|
||||||
|
return 10**9
|
||||||
|
try:
|
||||||
|
return int(cp.stdout.strip() or "0")
|
||||||
|
except ValueError:
|
||||||
|
return 10**9
|
||||||
|
|
||||||
|
|
||||||
|
def short_sha(sha: str) -> str:
|
||||||
|
return sha[:7] if sha else ""
|
||||||
|
|
||||||
|
|
||||||
|
def nearest_parents(repo: Path, tips: dict[str, str]) -> dict[str, str]:
|
||||||
|
"""For each branch, nearest other tip that is a strict ancestor."""
|
||||||
|
parents: dict[str, str] = {}
|
||||||
|
names = list(tips)
|
||||||
|
for b in names:
|
||||||
|
tip_b = tips[b]
|
||||||
|
cands: list[tuple[int, str]] = []
|
||||||
|
for a in names:
|
||||||
|
if a == b:
|
||||||
|
continue
|
||||||
|
tip_a = tips[a]
|
||||||
|
if tip_a == tip_b:
|
||||||
|
continue
|
||||||
|
if not is_ancestor(repo, tip_a, tip_b):
|
||||||
|
continue
|
||||||
|
cands.append((commits_between(repo, tip_a, tip_b), a))
|
||||||
|
parents[b] = min(cands)[1] if cands else ""
|
||||||
|
return parents
|
||||||
|
|
||||||
|
|
||||||
|
def children_map(parents: dict[str, str]) -> dict[str, list[str]]:
|
||||||
|
kids: dict[str, list[str]] = {b: [] for b in parents}
|
||||||
|
orphans: list[str] = []
|
||||||
|
for b, p in parents.items():
|
||||||
|
if p and p in kids:
|
||||||
|
kids[p].append(b)
|
||||||
|
else:
|
||||||
|
orphans.append(b)
|
||||||
|
for p in kids:
|
||||||
|
kids[p].sort()
|
||||||
|
orphans.sort()
|
||||||
|
# roots = orphans (parent missing or empty)
|
||||||
|
kids[""] = orphans
|
||||||
|
return kids
|
||||||
|
|
||||||
|
|
||||||
|
def walk_tree(
|
||||||
|
parents: dict[str, str],
|
||||||
|
tips: dict[str, str],
|
||||||
|
) -> list[dict]:
|
||||||
|
"""Flat rows in DFS order with semi-graphic prefix fields."""
|
||||||
|
kids = children_map(parents)
|
||||||
|
rows: list[dict] = []
|
||||||
|
|
||||||
|
def walk(name: str, prefix: str, is_last: bool, is_root: bool) -> None:
|
||||||
|
tip = tips[name]
|
||||||
|
if is_root:
|
||||||
|
branch_prefix = ""
|
||||||
|
child_prefix = ""
|
||||||
|
else:
|
||||||
|
branch_prefix = prefix + ("└── " if is_last else "├── ")
|
||||||
|
child_prefix = prefix + (" " if is_last else "│ ")
|
||||||
|
rows.append(
|
||||||
|
{
|
||||||
|
"branch": name,
|
||||||
|
"edition": edition_slug(name),
|
||||||
|
"tip": short_sha(tip),
|
||||||
|
"tip_full": tip,
|
||||||
|
"parent": parents.get(name, ""),
|
||||||
|
"prefix": branch_prefix,
|
||||||
|
"url": f"/_cascade/use/{edition_slug(name)}",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
ch = kids.get(name, [])
|
||||||
|
for i, c in enumerate(ch):
|
||||||
|
walk(c, child_prefix, i == len(ch) - 1, False)
|
||||||
|
|
||||||
|
roots = kids.get("", [])
|
||||||
|
for i, r in enumerate(roots):
|
||||||
|
walk(r, "", i == len(roots) - 1, True)
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_placeholder(editions: Path, slug: str, tip: str) -> None:
|
||||||
|
dest = editions / slug
|
||||||
|
dest.mkdir(parents=True, exist_ok=True)
|
||||||
|
idx = dest / "index.html"
|
||||||
|
if not idx.exists():
|
||||||
|
src = _PLACEHOLDER
|
||||||
|
if src.is_file():
|
||||||
|
idx.write_text(src.read_text(encoding="utf-8"), encoding="utf-8")
|
||||||
|
else:
|
||||||
|
idx.write_text(
|
||||||
|
"<!DOCTYPE html><html><body>"
|
||||||
|
'{% include "_cascade/bar.html" . %}'
|
||||||
|
"<h1>edition pending</h1></body></html>\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
tip_path = dest / ".edition-tip"
|
||||||
|
if not tip_path.exists():
|
||||||
|
tip_path.write_text(tip + "\n", encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def tip_recorded(editions: Path, slug: str) -> str:
|
||||||
|
p = editions / slug / ".edition-tip"
|
||||||
|
if not p.is_file():
|
||||||
|
return ""
|
||||||
|
return p.read_text(encoding="utf-8").strip()
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def diff_numstat(repo: Path, a: str, b: str) -> tuple[int, int]:
|
||||||
|
"""Insertions/deletions of b vs a (git diff --numstat)."""
|
||||||
|
if not a or not b or a == b:
|
||||||
|
return 0, 0
|
||||||
|
cp = git(repo, "diff", "--numstat", a, b, check=False)
|
||||||
|
if cp.returncode != 0:
|
||||||
|
return 0, 0
|
||||||
|
plus = minus = 0
|
||||||
|
for line in (cp.stdout or "").splitlines():
|
||||||
|
parts = line.split(" ")
|
||||||
|
if len(parts) < 3:
|
||||||
|
continue
|
||||||
|
a, d = parts[0], parts[1]
|
||||||
|
if a == "-" or d == "-":
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
plus += int(a)
|
||||||
|
minus += int(d)
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
return plus, minus
|
||||||
|
|
||||||
|
|
||||||
|
def empty_tree_oid(repo: Path) -> str:
|
||||||
|
cp = git(repo, "hash-object", "-t", "tree", "/dev/null")
|
||||||
|
return (cp.stdout or "").strip()
|
||||||
|
|
||||||
|
|
||||||
|
def loc_at_tip(repo: Path, tip: str, empty: str) -> int:
|
||||||
|
"""Text-file line count of tip vs the empty tree."""
|
||||||
|
plus, _ = diff_numstat(repo, empty, tip)
|
||||||
|
return plus
|
||||||
|
|
||||||
|
|
||||||
|
def tip_commit_time(repo: Path, tip: str) -> str:
|
||||||
|
"""Committer ISO time of tip (empty on failure)."""
|
||||||
|
cp = git(repo, "log", "-1", "--format=%cI", tip, check=False)
|
||||||
|
if cp.returncode != 0:
|
||||||
|
return ""
|
||||||
|
return (cp.stdout or "").strip()
|
||||||
|
|
||||||
|
|
||||||
|
def read_build_meta(editions: Path, slug: str) -> dict:
|
||||||
|
dest = editions / slug
|
||||||
|
out: dict = {}
|
||||||
|
at = dest / ".edition-built-at"
|
||||||
|
sec = dest / ".edition-build-seconds"
|
||||||
|
if at.is_file():
|
||||||
|
out["built_at"] = at.read_text(encoding="utf-8").strip()
|
||||||
|
if sec.is_file():
|
||||||
|
raw = sec.read_text(encoding="utf-8").strip()
|
||||||
|
try:
|
||||||
|
out["build_seconds"] = float(raw)
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def write_build_meta(editions: Path, slug: str, seconds: float) -> None:
|
||||||
|
dest = editions / slug
|
||||||
|
dest.mkdir(parents=True, exist_ok=True)
|
||||||
|
now = datetime.now(timezone.utc).astimezone().isoformat(timespec="seconds")
|
||||||
|
(dest / ".edition-built-at").write_text(now + "\n", encoding="utf-8")
|
||||||
|
(dest / ".edition-build-seconds").write_text(f"{seconds:.3f}\n", encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def write_freeze_status(editions: Path, slug: str, ok: bool, error: str = "") -> None:
|
||||||
|
dest = editions / slug
|
||||||
|
dest.mkdir(parents=True, exist_ok=True)
|
||||||
|
(dest / ".edition-freeze-ok").write_text("1\n" if ok else "0\n", encoding="utf-8")
|
||||||
|
errp = dest / ".edition-freeze-error"
|
||||||
|
if ok or not error:
|
||||||
|
if errp.exists():
|
||||||
|
errp.unlink()
|
||||||
|
return
|
||||||
|
errp.write_text(error[:500] + "\n", encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def freeze_ok_for(editions: Path | None, slug: str, tip_full: str) -> tuple[bool, str]:
|
||||||
|
"""Current origin tip freeze succeeded (green ✓) or not (red ✗)."""
|
||||||
|
if editions is None or not tip_full:
|
||||||
|
return False, ""
|
||||||
|
dest = editions / slug
|
||||||
|
err = ""
|
||||||
|
errp = dest / ".edition-freeze-error"
|
||||||
|
if errp.is_file():
|
||||||
|
err = errp.read_text(encoding="utf-8").strip()
|
||||||
|
recorded = tip_recorded(editions, slug)
|
||||||
|
okp = dest / ".edition-freeze-ok"
|
||||||
|
if okp.is_file():
|
||||||
|
file_ok = okp.read_text(encoding="utf-8").strip().lower() in (
|
||||||
|
"1",
|
||||||
|
"true",
|
||||||
|
"ok",
|
||||||
|
"yes",
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
file_ok = (dest / ".edition-built-at").is_file()
|
||||||
|
ok = bool(file_ok and recorded == tip_full and (dest / "index.html").is_file())
|
||||||
|
return ok, err
|
||||||
|
|
||||||
|
|
||||||
|
def cleanup_stale_editions(editions: Path, tips: dict[str, str]) -> list[str]:
|
||||||
|
"""Drop edition dirs whose branch tip is gone (keep flask alias)."""
|
||||||
|
keep = {edition_slug(b) for b in tips} | {"flask"}
|
||||||
|
removed: list[str] = []
|
||||||
|
if not editions.is_dir():
|
||||||
|
return removed
|
||||||
|
for child in list(editions.iterdir()):
|
||||||
|
if not child.is_dir():
|
||||||
|
continue
|
||||||
|
if child.name in keep:
|
||||||
|
continue
|
||||||
|
shutil.rmtree(child, ignore_errors=True)
|
||||||
|
removed.append(child.name)
|
||||||
|
return removed
|
||||||
|
|
||||||
|
|
||||||
|
def cleanup_orphan_tmps(max_age_sec: int = 3600) -> int:
|
||||||
|
"""Remove leftover tdpd-* workdirs older than max_age_sec."""
|
||||||
|
bases: list[Path] = []
|
||||||
|
home_tmp = Path.home() / "tmp"
|
||||||
|
if home_tmp.is_dir():
|
||||||
|
bases.append(home_tmp)
|
||||||
|
bases.append(Path(tempfile.gettempdir()))
|
||||||
|
cutoff = time.time() - max_age_sec
|
||||||
|
n = 0
|
||||||
|
for base in bases:
|
||||||
|
try:
|
||||||
|
kids = list(base.glob("tdpd-*"))
|
||||||
|
except OSError:
|
||||||
|
continue
|
||||||
|
for path in kids:
|
||||||
|
if not path.is_dir():
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
if path.stat().st_mtime >= cutoff:
|
||||||
|
continue
|
||||||
|
except OSError:
|
||||||
|
continue
|
||||||
|
shutil.rmtree(path, ignore_errors=True)
|
||||||
|
n += 1
|
||||||
|
return n
|
||||||
|
|
||||||
|
|
||||||
|
def cleanup_repo(repo: Path) -> None:
|
||||||
|
git(repo, "worktree", "prune", check=False)
|
||||||
|
|
||||||
|
|
||||||
|
def _attach_venv(work: Path) -> None:
|
||||||
|
"""Worktrees lack .venv; reuse TDPD_VENV or sibling checkout .venv via symlink."""
|
||||||
|
if (work / ".venv" / "bin" / "python").exists():
|
||||||
|
return
|
||||||
|
candidates: list[Path] = []
|
||||||
|
raw = os.environ.get("TDPD_VENV", "").strip()
|
||||||
|
if raw:
|
||||||
|
candidates.append(Path(raw).expanduser())
|
||||||
|
root = tdpd_root()
|
||||||
|
candidates.append(root / ".venv")
|
||||||
|
# laptop SoT checkout even when TDPD_ROOT is git-dir-only on koopa
|
||||||
|
candidates.append(DEFAULT_TDPD / ".venv")
|
||||||
|
for venv in candidates:
|
||||||
|
py = venv / "bin" / "python"
|
||||||
|
if py.is_file():
|
||||||
|
dest = work / ".venv"
|
||||||
|
if dest.exists() or dest.is_symlink():
|
||||||
|
return
|
||||||
|
dest.symlink_to(venv.resolve())
|
||||||
|
return
|
||||||
|
|
||||||
|
|
||||||
|
def run_freeze(work: Path, branch: str, slug: str) -> None:
|
||||||
|
tmpl = os.environ.get("FREEZE_CMD", "").strip()
|
||||||
|
env = os.environ.copy()
|
||||||
|
env["TDPD_BRANCH"] = branch
|
||||||
|
env["TDPD_SLUG"] = slug
|
||||||
|
if tmpl:
|
||||||
|
cmd = tmpl.format(root=str(work), branch=branch, slug=slug)
|
||||||
|
subprocess.run(cmd, shell=True, check=True, cwd=str(work), env=env)
|
||||||
|
return
|
||||||
|
_attach_venv(work)
|
||||||
|
# Older branches hardcode `python3 -m website.freeze` without .venv;
|
||||||
|
# prefer the attached venv interpreter directly.
|
||||||
|
py = work / ".venv" / "bin" / "python"
|
||||||
|
freeze_mod = work / "website" / "freeze.py"
|
||||||
|
if py.is_file() and freeze_mod.is_file():
|
||||||
|
build = work / "website" / "build"
|
||||||
|
if build.exists():
|
||||||
|
shutil.rmtree(build)
|
||||||
|
subprocess.run(
|
||||||
|
[str(py), "-m", "website.freeze"],
|
||||||
|
check=True,
|
||||||
|
cwd=str(work),
|
||||||
|
env=env,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
makefile = work / "Makefile"
|
||||||
|
if not makefile.is_file():
|
||||||
|
raise FileNotFoundError(f"no Makefile in {work}")
|
||||||
|
subprocess.run(["make", "html"], check=True, cwd=str(work), env=env)
|
||||||
|
|
||||||
|
|
||||||
|
def inject_bar(edition_dir: Path) -> None:
|
||||||
|
if not INJECT.is_file():
|
||||||
|
print(f"WARN: inject script missing: {INJECT}", file=sys.stderr)
|
||||||
|
return
|
||||||
|
cp = subprocess.run(
|
||||||
|
[sys.executable, str(INJECT), str(edition_dir)],
|
||||||
|
check=False,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
if cp.stdout:
|
||||||
|
print(cp.stdout, end="" if str(cp.stdout).endswith("\n") else "\n", file=sys.stderr)
|
||||||
|
if cp.returncode != 0:
|
||||||
|
err = (cp.stderr or "").strip()[:300]
|
||||||
|
print(f"WARN: inject_bar {edition_dir}: {err}", file=sys.stderr)
|
||||||
|
|
||||||
|
|
||||||
|
def rebuild_edition(repo: Path, branch: str, tip: str, editions: Path) -> dict:
|
||||||
|
"""Return {status, seconds} — status skipped|built|placeholder|error:…"""
|
||||||
|
slug = edition_slug(branch)
|
||||||
|
dest = editions / slug
|
||||||
|
t0 = time.perf_counter()
|
||||||
|
force = os.environ.get("FORCE_REBUILD", "").strip() in ("1", "true", "yes")
|
||||||
|
if not force and freeze_ok_for(editions, slug, tip)[0]:
|
||||||
|
inject_bar(dest)
|
||||||
|
if not (dest / ".edition-freeze-ok").is_file():
|
||||||
|
write_freeze_status(editions, slug, True)
|
||||||
|
return {"status": "skipped", "seconds": round(time.perf_counter() - t0, 3)}
|
||||||
|
|
||||||
|
wt: Path | None = None
|
||||||
|
try:
|
||||||
|
tmp_base = Path.home() / "tmp" if (Path.home() / "tmp").is_dir() else Path(tempfile.gettempdir())
|
||||||
|
wt = Path(tempfile.mkdtemp(prefix=f"tdpd-{slug}-", dir=str(tmp_base)))
|
||||||
|
cp = git(repo, "worktree", "add", "--detach", str(wt), tip, check=False)
|
||||||
|
if cp.returncode != 0:
|
||||||
|
git(repo, "worktree", "prune", check=False)
|
||||||
|
cp = git(repo, "worktree", "add", "--detach", str(wt), tip, check=False)
|
||||||
|
if cp.returncode != 0:
|
||||||
|
st = f"error:worktree:{cp.stderr.strip()[:200]}"
|
||||||
|
ensure_placeholder(editions, slug, tip)
|
||||||
|
write_freeze_status(editions, slug, False, st)
|
||||||
|
inject_bar(dest)
|
||||||
|
return {
|
||||||
|
"status": st,
|
||||||
|
"seconds": round(time.perf_counter() - t0, 3),
|
||||||
|
}
|
||||||
|
|
||||||
|
run_freeze(wt, branch, slug)
|
||||||
|
build = wt / "website" / "build"
|
||||||
|
if not build.is_dir():
|
||||||
|
ensure_placeholder(editions, slug, tip)
|
||||||
|
write_freeze_status(editions, slug, False, "error:no-build")
|
||||||
|
inject_bar(dest)
|
||||||
|
return {"status": "error:no-build", "seconds": round(time.perf_counter() - t0, 3)}
|
||||||
|
|
||||||
|
dest.mkdir(parents=True, exist_ok=True)
|
||||||
|
keep_meta = {
|
||||||
|
".edition-tip",
|
||||||
|
".edition-built-at",
|
||||||
|
".edition-build-seconds",
|
||||||
|
".edition-freeze-ok",
|
||||||
|
".edition-freeze-error",
|
||||||
|
}
|
||||||
|
for child in list(dest.iterdir()):
|
||||||
|
if child.name in keep_meta:
|
||||||
|
continue
|
||||||
|
if child.is_dir():
|
||||||
|
shutil.rmtree(child)
|
||||||
|
else:
|
||||||
|
child.unlink()
|
||||||
|
for child in build.iterdir():
|
||||||
|
target = dest / child.name
|
||||||
|
if child.is_dir():
|
||||||
|
shutil.copytree(child, target)
|
||||||
|
else:
|
||||||
|
shutil.copy2(child, target)
|
||||||
|
secs = round(time.perf_counter() - t0, 3)
|
||||||
|
(dest / ".edition-tip").write_text(tip + "\n", encoding="utf-8")
|
||||||
|
write_build_meta(editions, slug, secs)
|
||||||
|
write_freeze_status(editions, slug, True)
|
||||||
|
inject_bar(dest)
|
||||||
|
return {"status": "built", "seconds": secs}
|
||||||
|
except Exception as exc: # noqa: BLE001 — hourly must continue
|
||||||
|
st = f"error:{type(exc).__name__}:{exc}"
|
||||||
|
ensure_placeholder(editions, slug, tip)
|
||||||
|
write_freeze_status(editions, slug, False, st)
|
||||||
|
inject_bar(dest)
|
||||||
|
return {
|
||||||
|
"status": st,
|
||||||
|
"seconds": round(time.perf_counter() - t0, 3),
|
||||||
|
}
|
||||||
|
finally:
|
||||||
|
if wt is not None:
|
||||||
|
git(repo, "worktree", "remove", "--force", str(wt), check=False)
|
||||||
|
shutil.rmtree(wt, ignore_errors=True)
|
||||||
|
|
||||||
|
|
||||||
|
def _edition_built_at(editions: Path, slug: str) -> str:
|
||||||
|
p = editions / slug / ".edition-built-at"
|
||||||
|
if not p.is_file():
|
||||||
|
return ""
|
||||||
|
return p.read_text(encoding="utf-8").strip()
|
||||||
|
|
||||||
|
|
||||||
|
def sync_flask_alias(editions: Path, tips: dict[str, str]) -> None:
|
||||||
|
"""Keep editions/flask in sync with DEFAULT_BRANCH freeze when present."""
|
||||||
|
src_branch = default_branch()
|
||||||
|
if src_branch not in tips:
|
||||||
|
return
|
||||||
|
src_slug = edition_slug(src_branch)
|
||||||
|
src = editions / src_slug
|
||||||
|
dst = editions / "flask"
|
||||||
|
tip = tips[src_branch]
|
||||||
|
flask_ok, _ = freeze_ok_for(editions, "flask", tip)
|
||||||
|
src_built = _edition_built_at(editions, src_slug)
|
||||||
|
flask_built = _edition_built_at(editions, "flask")
|
||||||
|
if flask_ok and src_built and src_built == flask_built:
|
||||||
|
return
|
||||||
|
if not (src / "index.html").is_file():
|
||||||
|
ensure_placeholder(editions, "flask", tip)
|
||||||
|
return
|
||||||
|
if dst.exists():
|
||||||
|
shutil.rmtree(dst)
|
||||||
|
shutil.copytree(src, dst)
|
||||||
|
(dst / ".edition-tip").write_text(tip + "\n", encoding="utf-8")
|
||||||
|
# reuse src build meta if present
|
||||||
|
for name in (
|
||||||
|
".edition-built-at",
|
||||||
|
".edition-build-seconds",
|
||||||
|
".edition-freeze-ok",
|
||||||
|
".edition-freeze-error",
|
||||||
|
):
|
||||||
|
s = src / name
|
||||||
|
if s.is_file():
|
||||||
|
shutil.copy2(s, dst / name)
|
||||||
|
else:
|
||||||
|
p = dst / name
|
||||||
|
if p.exists():
|
||||||
|
p.unlink()
|
||||||
|
inject_bar(dst)
|
||||||
|
|
||||||
|
|
||||||
|
def build_payload(repo: Path, tips: dict[str, str], rebuild: bool) -> dict:
|
||||||
|
parents = nearest_parents(repo, tips)
|
||||||
|
rows = walk_tree(parents, tips)
|
||||||
|
rebuild_status: dict[str, dict] = {}
|
||||||
|
rebuild_timing: dict | None = None
|
||||||
|
ed_root = editions_root()
|
||||||
|
cleanup_repo(repo)
|
||||||
|
n_tmp = cleanup_orphan_tmps()
|
||||||
|
if n_tmp:
|
||||||
|
print(f"cleanup_tmp removed={n_tmp}", file=sys.stderr)
|
||||||
|
if ed_root is not None:
|
||||||
|
ed_root.mkdir(parents=True, exist_ok=True)
|
||||||
|
removed = cleanup_stale_editions(ed_root, tips)
|
||||||
|
if removed:
|
||||||
|
print(f"cleanup_editions removed={removed}", file=sys.stderr)
|
||||||
|
if rebuild and ed_root is not None:
|
||||||
|
wall0 = time.perf_counter()
|
||||||
|
for name, tip in sorted(tips.items()):
|
||||||
|
# rebuild_edition itself skips when .edition-tip matches tip
|
||||||
|
rebuild_status[name] = rebuild_edition(repo, name, tip, ed_root)
|
||||||
|
sync_flask_alias(ed_root, tips)
|
||||||
|
cleanup_repo(repo)
|
||||||
|
cleanup_orphan_tmps(max_age_sec=600)
|
||||||
|
wall = round(time.perf_counter() - wall0, 3)
|
||||||
|
built_secs = [
|
||||||
|
v["seconds"]
|
||||||
|
for v in rebuild_status.values()
|
||||||
|
if v.get("status") == "built"
|
||||||
|
]
|
||||||
|
rebuild_timing = {
|
||||||
|
"wall_seconds": wall,
|
||||||
|
"branches": len(tips),
|
||||||
|
"built": sum(1 for v in rebuild_status.values() if v.get("status") == "built"),
|
||||||
|
"skipped": sum(1 for v in rebuild_status.values() if v.get("status") == "skipped"),
|
||||||
|
"errors": sum(
|
||||||
|
1 for v in rebuild_status.values() if str(v.get("status", "")).startswith("error")
|
||||||
|
),
|
||||||
|
"built_seconds_sum": round(sum(built_secs), 3) if built_secs else 0.0,
|
||||||
|
"built_seconds_max": round(max(built_secs), 3) if built_secs else 0.0,
|
||||||
|
}
|
||||||
|
print(
|
||||||
|
f"REBUILD_TIMING wall_seconds={wall} built={rebuild_timing['built']} "
|
||||||
|
f"skipped={rebuild_timing['skipped']} errors={rebuild_timing['errors']} "
|
||||||
|
f"built_seconds_sum={rebuild_timing['built_seconds_sum']} "
|
||||||
|
f"built_seconds_max={rebuild_timing['built_seconds_max']}",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
elif ed_root is not None:
|
||||||
|
ed_root.mkdir(parents=True, exist_ok=True)
|
||||||
|
for name, tip in tips.items():
|
||||||
|
slug = edition_slug(name)
|
||||||
|
if not (ed_root / slug / "index.html").is_file():
|
||||||
|
ensure_placeholder(ed_root, slug, tip)
|
||||||
|
write_freeze_status(ed_root, slug, False, "placeholder")
|
||||||
|
rebuild_status[name] = {"status": "placeholder", "seconds": 0.0}
|
||||||
|
if not (ed_root / "flask" / "index.html").is_file():
|
||||||
|
sync_flask_alias(ed_root, tips)
|
||||||
|
|
||||||
|
tip_times = {name: tip_commit_time(repo, tip) for name, tip in tips.items()}
|
||||||
|
empty = empty_tree_oid(repo)
|
||||||
|
branches_out = []
|
||||||
|
for row in rows:
|
||||||
|
name = row["branch"]
|
||||||
|
slug = row["edition"]
|
||||||
|
meta = read_build_meta(ed_root, slug) if ed_root is not None else {}
|
||||||
|
# prefer this-run built seconds when present
|
||||||
|
rb = rebuild_status.get(name) or {}
|
||||||
|
if rb.get("status") == "built" and "seconds" in rb:
|
||||||
|
meta = {
|
||||||
|
**meta,
|
||||||
|
"build_seconds": rb["seconds"],
|
||||||
|
}
|
||||||
|
entry = {
|
||||||
|
"branch": name,
|
||||||
|
"edition": slug,
|
||||||
|
"tip": row["tip"],
|
||||||
|
"tip_time": tip_times.get(name, ""),
|
||||||
|
"parent": row["parent"],
|
||||||
|
"prefix": row["prefix"],
|
||||||
|
"url": row["url"],
|
||||||
|
}
|
||||||
|
if meta.get("built_at"):
|
||||||
|
entry["built_at"] = meta["built_at"]
|
||||||
|
if "build_seconds" in meta:
|
||||||
|
entry["build_seconds"] = meta["build_seconds"]
|
||||||
|
freeze_ok, freeze_err = freeze_ok_for(ed_root, slug, tips[name])
|
||||||
|
st = str(rb.get("status") or "")
|
||||||
|
if st == "built":
|
||||||
|
freeze_ok, freeze_err = True, ""
|
||||||
|
elif st.startswith("error") or st == "placeholder":
|
||||||
|
freeze_ok, freeze_err = False, st
|
||||||
|
entry["freeze_ok"] = freeze_ok
|
||||||
|
if freeze_err:
|
||||||
|
entry["freeze_error"] = freeze_err
|
||||||
|
# sticky bar: +grün/−rot vs parent tip; gray loc when no parent in tree
|
||||||
|
parent = row["parent"]
|
||||||
|
if parent and parent in tips:
|
||||||
|
plus, minus = diff_numstat(repo, tips[parent], row["tip"])
|
||||||
|
entry["vs_parent_plus"] = plus
|
||||||
|
entry["vs_parent_minus"] = minus
|
||||||
|
else:
|
||||||
|
entry["loc"] = loc_at_tip(repo, row["tip"], empty)
|
||||||
|
branches_out.append(entry)
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"generated": datetime.now(timezone.utc).astimezone().isoformat(timespec="seconds"),
|
||||||
|
"host": host(),
|
||||||
|
"default_branch": default_branch(),
|
||||||
|
"default_edition": "flask",
|
||||||
|
"branches": branches_out,
|
||||||
|
}
|
||||||
|
if rebuild_status:
|
||||||
|
payload["rebuild"] = rebuild_status
|
||||||
|
if rebuild_timing is not None:
|
||||||
|
payload["rebuild_timing"] = rebuild_timing
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
|
||||||
|
p = argparse.ArgumentParser(description=__doc__)
|
||||||
|
p.add_argument(
|
||||||
|
"--rebuild",
|
||||||
|
action="store_true",
|
||||||
|
help="rebuild stale editions into EDITIONS_ROOT",
|
||||||
|
)
|
||||||
|
p.add_argument(
|
||||||
|
"--force",
|
||||||
|
action="store_true",
|
||||||
|
help="rebuild every edition even when .edition-tip matches (timing)",
|
||||||
|
)
|
||||||
|
p.add_argument(
|
||||||
|
"--no-fetch",
|
||||||
|
action="store_true",
|
||||||
|
help="skip git fetch --prune origin",
|
||||||
|
)
|
||||||
|
return p.parse_args(argv)
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv: list[str] | None = None) -> int:
|
||||||
|
args = parse_args(argv)
|
||||||
|
repo = tdpd_root()
|
||||||
|
if not (repo / ".git").exists() and not (repo / "HEAD").exists():
|
||||||
|
# allow bare / gitfile
|
||||||
|
gitfile = repo / ".git"
|
||||||
|
if not repo.is_dir():
|
||||||
|
print(f"TDPD_ROOT missing or not a dir: {repo}", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
if not gitfile.exists() and not (repo / "objects").exists():
|
||||||
|
print(f"TDPD_ROOT has no git metadata: {repo}", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
if args.no_fetch:
|
||||||
|
os.environ["FETCH"] = "0"
|
||||||
|
if args.force:
|
||||||
|
os.environ["FORCE_REBUILD"] = "1"
|
||||||
|
fetch_origin(repo)
|
||||||
|
|
||||||
|
tips = list_origin_branches(repo)
|
||||||
|
if not tips:
|
||||||
|
print("no origin/* branches found", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
rebuild = (
|
||||||
|
args.rebuild
|
||||||
|
or args.force
|
||||||
|
or os.environ.get("REBUILD", "").strip() in ("1", "true", "yes")
|
||||||
|
)
|
||||||
|
if rebuild and editions_root() is None:
|
||||||
|
print("REBUILD set but EDITIONS_ROOT missing", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
payload = build_payload(repo, tips, rebuild)
|
||||||
|
dest = out_path()
|
||||||
|
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
dest.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8")
|
||||||
|
print(f"wrote {dest} branches={len(payload['branches'])}", file=sys.stderr)
|
||||||
|
if "rebuild" in payload:
|
||||||
|
print(f"rebuild={payload['rebuild']}", file=sys.stderr)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
68
scripts/dossiers-ngi/caddy-dossiers-ngi.snippet
Normal file
68
scripts/dossiers-ngi/caddy-dossiers-ngi.snippet
Normal file
|
|
@ -0,0 +1,68 @@
|
||||||
|
# Stanza body for dossiers.ngi.hacktivism.ch only (hand-check / re-seed).
|
||||||
|
# Apply on koopa (host-local):
|
||||||
|
# bash ~/bin/apply-dossiers-ngi-edition-switch.sh
|
||||||
|
# sudo ~/bin/caddy-apply
|
||||||
|
#
|
||||||
|
# Sticky bar switches build via cookie tdpd_edition → editions/<name>/.
|
||||||
|
# Stock Caddy only (templates + map + handle). No replace-response plugin.
|
||||||
|
# Keep .2 / .3 blocks untouched.
|
||||||
|
#
|
||||||
|
# Site-block body (no outer braces):
|
||||||
|
|
||||||
|
header Alt-Svc "clear"
|
||||||
|
# Cookie selects editions/<slug>/; same paths (/static/css/style.css) must not
|
||||||
|
# share one browser/CDN cache entry across editions (main has no green CSS).
|
||||||
|
header Vary "Cookie"
|
||||||
|
header Cache-Control "private, no-cache"
|
||||||
|
|
||||||
|
@use path_regexp use ^/_cascade/use/([A-Za-z0-9_-]+)/?$
|
||||||
|
handle @use {
|
||||||
|
header Set-Cookie "tdpd_edition={re.use.1}; Path=/; Max-Age=31536000; SameSite=Lax"
|
||||||
|
header Cache-Control "private, no-store"
|
||||||
|
# Keep the caller's path (?next=). Never bounce to /. No next → bar page.
|
||||||
|
@next query_regexp next ^(/|/[^/].*)$
|
||||||
|
redir @next {query.next} 302
|
||||||
|
redir * /_cascade/ 302
|
||||||
|
}
|
||||||
|
|
||||||
|
handle /_cascade/* {
|
||||||
|
root * /var/www/monitoring-sites/dossiers.ngi.hacktivism.ch
|
||||||
|
header Cache-Control "private, no-cache"
|
||||||
|
file_server
|
||||||
|
}
|
||||||
|
# Path-root cascade stubs live under sites/…/cascade/
|
||||||
|
handle /cascade/* {
|
||||||
|
root * /var/www/monitoring-sites/dossiers.ngi.hacktivism.ch
|
||||||
|
header Cache-Control "private, no-cache"
|
||||||
|
file_server
|
||||||
|
try_files {path} {path}/ {path}/index.html
|
||||||
|
}
|
||||||
|
handle /branch-status.json {
|
||||||
|
root * /var/www/monitoring-sites/dossiers.ngi.hacktivism.ch
|
||||||
|
header Cache-Control "private, no-cache"
|
||||||
|
file_server
|
||||||
|
}
|
||||||
|
|
||||||
|
# Explicit edition_dir map (placeholder map output broke on Caddy v2.11.4 —
|
||||||
|
# non-flask cookies 404'd). Refresh this list when new origin tips appear
|
||||||
|
# (branch-status rebuild creates editions/<slug>/).
|
||||||
|
map {http.request.cookie.tdpd_edition} {edition_dir} {
|
||||||
|
default flask
|
||||||
|
flask flask
|
||||||
|
feat-sm-content feat-sm-content
|
||||||
|
feat-website-design feat-website-design
|
||||||
|
feat-website-features feat-website-features
|
||||||
|
feat-website-views feat-website-views
|
||||||
|
feature-nl-net-injestion-injestor-update feature-nl-net-injestion-injestor-update
|
||||||
|
main main
|
||||||
|
project_template project_template
|
||||||
|
}
|
||||||
|
|
||||||
|
root * /var/www/monitoring-sites/dossiers.ngi.hacktivism.ch/editions/{edition_dir}
|
||||||
|
templates {
|
||||||
|
mime text/html
|
||||||
|
between "{%" "%}"
|
||||||
|
root /var/www/monitoring-sites/dossiers.ngi.hacktivism.ch
|
||||||
|
}
|
||||||
|
file_server
|
||||||
|
try_files {path} {path}/ {path}/index.html
|
||||||
56
scripts/dossiers-ngi/inject-cascade-bar.py
Executable file
56
scripts/dossiers-ngi/inject-cascade-bar.py
Executable file
|
|
@ -0,0 +1,56 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Insert {% include "_cascade/bar.html" . %} after <body> in freeze HTML.
|
||||||
|
|
||||||
|
Idempotent. Skips files that already contain the include or live under
|
||||||
|
_cascade/. Usage:
|
||||||
|
|
||||||
|
python3 inject-cascade-bar.py /path/to/edition/root
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
INCLUDE = '{% include "_cascade/bar.html" . %}'
|
||||||
|
BODY_RE = re.compile(r"(<body[^>]*>)", re.IGNORECASE)
|
||||||
|
|
||||||
|
|
||||||
|
def inject(text: str) -> str | None:
|
||||||
|
if INCLUDE in text:
|
||||||
|
return None
|
||||||
|
if "_cascade/bar" in text:
|
||||||
|
return None
|
||||||
|
m = BODY_RE.search(text)
|
||||||
|
if not m:
|
||||||
|
return None
|
||||||
|
i = m.end()
|
||||||
|
return text[:i] + "\n" + INCLUDE + "\n" + text[i:]
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
if len(sys.argv) != 2:
|
||||||
|
print(f"usage: {sys.argv[0]} EDITION_ROOT", file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
root = Path(sys.argv[1]).resolve()
|
||||||
|
if not root.is_dir():
|
||||||
|
print(f"not a dir: {root}", file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
changed = 0
|
||||||
|
scanned = 0
|
||||||
|
for path in root.rglob("*.html"):
|
||||||
|
if "/_cascade/" in path.as_posix() + "/":
|
||||||
|
continue
|
||||||
|
scanned += 1
|
||||||
|
raw = path.read_text(encoding="utf-8", errors="ignore")
|
||||||
|
out = inject(raw)
|
||||||
|
if out is None:
|
||||||
|
continue
|
||||||
|
path.write_text(out, encoding="utf-8")
|
||||||
|
changed += 1
|
||||||
|
print(f"OK: scanned={scanned} injected={changed} root={root}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
80
scripts/dossiers-ngi/inject-editions-bar.sh
Executable file
80
scripts/dossiers-ngi/inject-editions-bar.sh
Executable file
|
|
@ -0,0 +1,80 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
# Inject cascade-bar include into every editions/<slug> freeze on koopa.
|
||||||
|
# No zypper, no Caddy plugin, no root — hernani must own $WWW.
|
||||||
|
#
|
||||||
|
# bash scripts/dossiers-ngi/inject-editions-bar.sh
|
||||||
|
# WWW=/var/www/monitoring-sites/dossiers.ngi.hacktivism.ch bash …
|
||||||
|
#
|
||||||
|
# Way back without the bar: https://dossiers.ngi.hacktivism.ch/_cascade/use/flask
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
WWW="${WWW:-/var/www/monitoring-sites/dossiers.ngi.hacktivism.ch}"
|
||||||
|
ED="${ED:-$WWW/editions}"
|
||||||
|
|
||||||
|
if [[ ! -d "$ED" ]]; then
|
||||||
|
echo "FAIL: not a dir: $ED" >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
|
||||||
|
python3 - "$ED" <<'PY'
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
INCLUDE = '{% include "_cascade/bar.html" . %}'
|
||||||
|
BODY_RE = re.compile(r"(<body[^>]*>)", re.IGNORECASE)
|
||||||
|
|
||||||
|
|
||||||
|
def inject(text: str) -> str | None:
|
||||||
|
if INCLUDE in text or "_cascade/bar" in text:
|
||||||
|
return None
|
||||||
|
m = BODY_RE.search(text)
|
||||||
|
if not m:
|
||||||
|
return None
|
||||||
|
i = m.end()
|
||||||
|
return text[:i] + "\n" + INCLUDE + "\n" + text[i:]
|
||||||
|
|
||||||
|
|
||||||
|
root = Path(sys.argv[1]).resolve()
|
||||||
|
scanned = changed = skipped = nobody = 0
|
||||||
|
print(f"ED={root}")
|
||||||
|
for slug_dir in sorted(p for p in root.iterdir() if p.is_dir()):
|
||||||
|
for path in slug_dir.rglob("*.html"):
|
||||||
|
posix = path.as_posix()
|
||||||
|
if "/_cascade/" in posix + "/":
|
||||||
|
continue
|
||||||
|
scanned += 1
|
||||||
|
raw = path.read_text(encoding="utf-8", errors="ignore")
|
||||||
|
if INCLUDE in raw or "_cascade/bar" in raw:
|
||||||
|
skipped += 1
|
||||||
|
continue
|
||||||
|
out = inject(raw)
|
||||||
|
if out is None:
|
||||||
|
nobody += 1
|
||||||
|
print(f"NOBODY {path.relative_to(root)}")
|
||||||
|
continue
|
||||||
|
path.write_text(out, encoding="utf-8")
|
||||||
|
changed += 1
|
||||||
|
print(f"INJECT {path.relative_to(root)}")
|
||||||
|
|
||||||
|
print(f"OK scanned={scanned} injected={changed} already={skipped} nobody={nobody}")
|
||||||
|
if nobody:
|
||||||
|
sys.exit(1)
|
||||||
|
PY
|
||||||
|
|
||||||
|
echo "----- index.html bar? -----"
|
||||||
|
python3 - "$ED" <<'PY'
|
||||||
|
from pathlib import Path
|
||||||
|
import sys
|
||||||
|
root = Path(sys.argv[1])
|
||||||
|
for d in sorted(p for p in root.iterdir() if p.is_dir()):
|
||||||
|
idx = d / "index.html"
|
||||||
|
if not idx.is_file():
|
||||||
|
print(f"MISSING {d.name}/index.html")
|
||||||
|
continue
|
||||||
|
t = idx.read_text(encoding="utf-8", errors="ignore")
|
||||||
|
flag = "BAR" if ("cascade-bar" in t or "_cascade/bar" in t) else "NOBAR"
|
||||||
|
print(f"{flag} {d.name}")
|
||||||
|
PY
|
||||||
138
scripts/dossiers-ngi/smoke-branch-tree.sh
Executable file
138
scripts/dossiers-ngi/smoke-branch-tree.sh
Executable file
|
|
@ -0,0 +1,138 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
# Local smoke: temp origin with extra branches → tree JSON + stub rebuild → cleanup.
|
||||||
|
# Does not touch origin on git.ngi-0.eu.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
|
||||||
|
SCRIPT="$ROOT/scripts/dossiers-ngi/branch-status-hourly.py"
|
||||||
|
TMP="$(mktemp -d "${TMPDIR:-/tmp}/tdpd-smoke-XXXXXX")"
|
||||||
|
cleanup() { rm -rf "$TMP"; }
|
||||||
|
trap cleanup EXIT
|
||||||
|
|
||||||
|
echo "TMP=$TMP"
|
||||||
|
|
||||||
|
BARE="$TMP/origin.git"
|
||||||
|
CLONE="$TMP/tdpd"
|
||||||
|
EDITIONS="$TMP/editions"
|
||||||
|
OUT="$TMP/branch-status.json"
|
||||||
|
|
||||||
|
git init --bare "$BARE" >/dev/null
|
||||||
|
git clone "$BARE" "$CLONE" >/dev/null 2>&1
|
||||||
|
cd "$CLONE"
|
||||||
|
git config user.email "smoke@example.test"
|
||||||
|
git config user.name "smoke"
|
||||||
|
echo "root" > README
|
||||||
|
git add README
|
||||||
|
git commit -m "main root" >/dev/null
|
||||||
|
git branch -M main
|
||||||
|
git push -u origin main >/dev/null 2>&1
|
||||||
|
|
||||||
|
# ladder: main → feat/a → feat/b
|
||||||
|
git checkout -b feat/a >/dev/null 2>&1
|
||||||
|
echo a > a.txt && git add a.txt && git commit -m "feat a" >/dev/null
|
||||||
|
git push -u origin feat/a >/dev/null 2>&1
|
||||||
|
git checkout -b feat/b >/dev/null 2>&1
|
||||||
|
echo b > b.txt && git add b.txt && git commit -m "feat b" >/dev/null
|
||||||
|
git push -u origin feat/b >/dev/null 2>&1
|
||||||
|
|
||||||
|
# sibling off main
|
||||||
|
git checkout main >/dev/null 2>&1
|
||||||
|
git checkout -b smoke/temp-sibling >/dev/null 2>&1
|
||||||
|
echo s > s.txt && git add s.txt && git commit -m "smoke sibling" >/dev/null
|
||||||
|
git push -u origin smoke/temp-sibling >/dev/null 2>&1
|
||||||
|
|
||||||
|
# stub freeze: write website/build/index.html
|
||||||
|
FREEZE_CMD='mkdir -p "{root}/website/build" && printf "%s\n" "<!DOCTYPE html><html><body><h1>{branch}</h1></body></html>" > "{root}/website/build/index.html"'
|
||||||
|
|
||||||
|
mkdir -p "$EDITIONS"
|
||||||
|
# seed a Makefile so non-stub path would find one; stub overrides via FREEZE_CMD
|
||||||
|
FETCH=0 TDPD_ROOT="$CLONE" OUT="$OUT" EDITIONS_ROOT="$EDITIONS" \
|
||||||
|
FREEZE_CMD="$FREEZE_CMD" REBUILD=1 \
|
||||||
|
python3 "$SCRIPT" --rebuild --no-fetch
|
||||||
|
|
||||||
|
python3 - "$OUT" <<'PY'
|
||||||
|
import json, sys
|
||||||
|
from pathlib import Path
|
||||||
|
data = json.loads(Path(sys.argv[1]).read_text())
|
||||||
|
branches = {b["branch"]: b for b in data["branches"]}
|
||||||
|
assert "main" in branches
|
||||||
|
assert "feat/a" in branches
|
||||||
|
assert "feat/b" in branches
|
||||||
|
assert "smoke/temp-sibling" in branches
|
||||||
|
assert branches["feat/a"]["parent"] == "main", branches["feat/a"]
|
||||||
|
assert branches["feat/b"]["parent"] == "feat/a", branches["feat/b"]
|
||||||
|
assert branches["smoke/temp-sibling"]["parent"] == "main", branches["smoke/temp-sibling"]
|
||||||
|
for b in data["branches"]:
|
||||||
|
assert b.get("freeze_ok") is True, b
|
||||||
|
# prefixes semi-graphic
|
||||||
|
prefs = {b["branch"]: b["prefix"] for b in data["branches"]}
|
||||||
|
assert any("├──" in p or "└──" in p for p in prefs.values()), prefs
|
||||||
|
# editions built
|
||||||
|
print("OK tree:")
|
||||||
|
for b in data["branches"]:
|
||||||
|
print(f" {b['prefix']}{b['branch']} @ {b['tip']} -> {b['edition']} parent={b['parent']!r}")
|
||||||
|
print("rebuild", data.get("rebuild"))
|
||||||
|
PY
|
||||||
|
|
||||||
|
# editions exist with stub content
|
||||||
|
test -f "$EDITIONS/feat-a/index.html"
|
||||||
|
test -f "$EDITIONS/smoke-temp-sibling/index.html"
|
||||||
|
grep -q "feat/a" "$EDITIONS/feat-a/index.html"
|
||||||
|
grep -q "smoke/temp-sibling" "$EDITIONS/smoke-temp-sibling/index.html"
|
||||||
|
test "$(cat "$EDITIONS/feat-a/.edition-freeze-ok")" = "1"
|
||||||
|
|
||||||
|
# red ✗ when last freeze of this tip failed
|
||||||
|
printf '0\n' > "$EDITIONS/feat-a/.edition-freeze-ok"
|
||||||
|
printf 'error:smoke\n' > "$EDITIONS/feat-a/.edition-freeze-error"
|
||||||
|
FETCH=0 TDPD_ROOT="$CLONE" OUT="$OUT" EDITIONS_ROOT="$EDITIONS" \
|
||||||
|
FREEZE_CMD="$FREEZE_CMD" \
|
||||||
|
python3 "$SCRIPT" --no-fetch
|
||||||
|
python3 - "$OUT" <<'PY'
|
||||||
|
import json, sys
|
||||||
|
from pathlib import Path
|
||||||
|
data = json.loads(Path(sys.argv[1]).read_text())
|
||||||
|
rows = {b["branch"]: b for b in data["branches"]}
|
||||||
|
assert rows["feat/a"]["freeze_ok"] is False, rows["feat/a"]
|
||||||
|
assert rows["feat/a"].get("freeze_error") == "error:smoke", rows["feat/a"]
|
||||||
|
assert rows["main"]["freeze_ok"] is True, rows["main"]
|
||||||
|
print("OK freeze_ok false after failed mark")
|
||||||
|
PY
|
||||||
|
printf '1\n' > "$EDITIONS/feat-a/.edition-freeze-ok"
|
||||||
|
rm -f "$EDITIONS/feat-a/.edition-freeze-error"
|
||||||
|
|
||||||
|
# flask alias: matching tip + stale HTML must copy from DEFAULT_BRANCH
|
||||||
|
MAIN_TIP="$(tr -d '\n' < "$EDITIONS/main/.edition-tip")"
|
||||||
|
mkdir -p "$EDITIONS/flask"
|
||||||
|
printf '%s\n' "$MAIN_TIP" > "$EDITIONS/flask/.edition-tip"
|
||||||
|
printf '%s\n' '<html>stale-flask</html>' > "$EDITIONS/flask/index.html"
|
||||||
|
printf '%s\n' '2026-09-30T17:35:26+02:00' > "$EDITIONS/flask/.edition-built-at"
|
||||||
|
FETCH=0 TDPD_ROOT="$CLONE" OUT="$OUT" EDITIONS_ROOT="$EDITIONS" \
|
||||||
|
FREEZE_CMD="$FREEZE_CMD" REBUILD=1 DEFAULT_BRANCH=main \
|
||||||
|
python3 "$SCRIPT" --rebuild --no-fetch
|
||||||
|
grep -q "main" "$EDITIONS/flask/index.html"
|
||||||
|
if grep -q "stale-flask" "$EDITIONS/flask/index.html"; then
|
||||||
|
echo "FAIL flask alias kept stale HTML"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
test "$(tr -d '\n' < "$EDITIONS/flask/.edition-freeze-ok")" = "1"
|
||||||
|
test "$(tr -d '\n' < "$EDITIONS/flask/.edition-built-at")" = "$(tr -d '\n' < "$EDITIONS/main/.edition-built-at")"
|
||||||
|
echo "OK flask alias copied after stale skip"
|
||||||
|
|
||||||
|
# delete smoke branch on origin; fetch --prune drops remote-tracking ref
|
||||||
|
cd "$CLONE"
|
||||||
|
git push origin --delete smoke/temp-sibling >/dev/null 2>&1
|
||||||
|
git branch -D smoke/temp-sibling >/dev/null 2>&1 || true
|
||||||
|
TDPD_ROOT="$CLONE" OUT="$OUT" EDITIONS_ROOT="$EDITIONS" \
|
||||||
|
FREEZE_CMD="$FREEZE_CMD" \
|
||||||
|
python3 "$SCRIPT"
|
||||||
|
python3 - "$OUT" <<'PY'
|
||||||
|
import json, sys
|
||||||
|
from pathlib import Path
|
||||||
|
data = json.loads(Path(sys.argv[1]).read_text())
|
||||||
|
names = {b["branch"] for b in data["branches"]}
|
||||||
|
assert "smoke/temp-sibling" not in names, names
|
||||||
|
assert names == {"main", "feat/a", "feat/b"}, names
|
||||||
|
print("OK smoke branch removed from tree")
|
||||||
|
PY
|
||||||
|
|
||||||
|
echo "SMOKE_OK"
|
||||||
57
scripts/pepsi/ensure-mon-users.sh
Executable file
57
scripts/pepsi/ensure-mon-users.sh
Executable file
|
|
@ -0,0 +1,57 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
# Idempotent: ensure mon-* OS users + Maildir inside koopa-pepsi.
|
||||||
|
# Needed after every container recreate: /home is not in the image and is
|
||||||
|
# not a volume by default → useradd state vanishes while DB login_assignment
|
||||||
|
# survives.
|
||||||
|
#
|
||||||
|
# UIDs fixed 1000–1004 (match login_assignment / prior live).
|
||||||
|
# STATUS= only; no secrets. Run on koopa as hernani (rootless podman exec).
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
CTR="${PEPSI_CTR:-koopa-pepsi}"
|
||||||
|
USERS=(
|
||||||
|
"mon-smtp:1000"
|
||||||
|
"mon-maildir:1001"
|
||||||
|
"mon-dkim:1002"
|
||||||
|
"mon-bounce:1003"
|
||||||
|
"mon-local:1004"
|
||||||
|
)
|
||||||
|
|
||||||
|
echo "STATUS=start CTR=$CTR HOST=$(hostname -s)"
|
||||||
|
|
||||||
|
if ! podman container exists "$CTR" 2>/dev/null; then
|
||||||
|
echo "STATUS=missing_ctr CTR=$CTR"
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
if [[ "$(podman inspect -f '{{.State.Running}}' "$CTR" 2>/dev/null || echo false)" != "true" ]]; then
|
||||||
|
echo "STATUS=ctr_not_running CTR=$CTR"
|
||||||
|
exit 3
|
||||||
|
fi
|
||||||
|
|
||||||
|
created=0
|
||||||
|
present=0
|
||||||
|
for entry in "${USERS[@]}"; do
|
||||||
|
u="${entry%%:*}"
|
||||||
|
uid="${entry##*:}"
|
||||||
|
if podman exec "$CTR" getent passwd "$u" >/dev/null 2>&1; then
|
||||||
|
echo "USER=$u STATE=present"
|
||||||
|
present=$((present + 1))
|
||||||
|
else
|
||||||
|
podman exec -u root "$CTR" useradd -u "$uid" -U -m -d "/home/$u" -s /usr/sbin/nologin "$u"
|
||||||
|
echo "USER=$u STATE=created UID=$uid"
|
||||||
|
created=$((created + 1))
|
||||||
|
fi
|
||||||
|
# Maildir skeleton (cur/new/tmp) — safe if already present
|
||||||
|
podman exec -u root "$CTR" bash -c "
|
||||||
|
set -e
|
||||||
|
home=/home/$u
|
||||||
|
mkdir -p \"\$home/Maildir\"/{cur,new,tmp}
|
||||||
|
chown -R $u:$u \"\$home\"
|
||||||
|
chmod 700 \"\$home\"
|
||||||
|
"
|
||||||
|
echo "MAILDIR=$u OK=1"
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "STATUS=done CREATED=$created PRESENT_BEFORE=$present EXPECT=${#USERS[@]}"
|
||||||
|
podman exec "$CTR" getent passwd | grep '^mon-' || true
|
||||||
|
exit 0
|
||||||
66
scripts/pepsi/firewalld-pepsi-highports.sh
Executable file
66
scripts/pepsi/firewalld-pepsi-highports.sh
Executable file
|
|
@ -0,0 +1,66 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
# Open rootless pepsi high-ports on koopa (firewalld).
|
||||||
|
# Compose map (LAN bind 192.168.100.95):
|
||||||
|
# 1025→25 1465→465 1587→587 1993→993 18443→443
|
||||||
|
# Freigabe: human this session. Run on koopa as root / sudo.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
PORTS=(1025/tcp 1465/tcp 1587/tcp 1993/tcp 18443/tcp)
|
||||||
|
ZONE="${FIREWALLD_ZONE:-}"
|
||||||
|
|
||||||
|
if [[ ${EUID:-$(id -u)} -ne 0 ]]; then
|
||||||
|
echo "STATUS=need_root HINT='sudo $0'"
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! command -v firewall-cmd >/dev/null 2>&1; then
|
||||||
|
echo "STATUS=no_firewalld"
|
||||||
|
exit 3
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! firewall-cmd --state >/dev/null 2>&1; then
|
||||||
|
echo "STATUS=firewalld_down"
|
||||||
|
exit 4
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -z "$ZONE" ]]; then
|
||||||
|
# Prefer the zone that owns the LAN NIC; else default.
|
||||||
|
ZONE=$(firewall-cmd --get-zone-of-interface=eno1 2>/dev/null || true)
|
||||||
|
if [[ -z "$ZONE" ]]; then
|
||||||
|
ZONE=$(firewall-cmd --get-default-zone)
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "STATUS=start ZONE=$ZONE HOST=$(hostname -s)"
|
||||||
|
|
||||||
|
added=0
|
||||||
|
present=0
|
||||||
|
for p in "${PORTS[@]}"; do
|
||||||
|
if firewall-cmd --zone="$ZONE" --query-port="$p" >/dev/null 2>&1; then
|
||||||
|
echo "PORT=$p STATE=present"
|
||||||
|
present=$((present + 1))
|
||||||
|
else
|
||||||
|
firewall-cmd --zone="$ZONE" --add-port="$p" --permanent
|
||||||
|
echo "PORT=$p STATE=added_permanent"
|
||||||
|
added=$((added + 1))
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
firewall-cmd --reload
|
||||||
|
echo "STATUS=reloaded"
|
||||||
|
|
||||||
|
ok=0
|
||||||
|
for p in "${PORTS[@]}"; do
|
||||||
|
if firewall-cmd --zone="$ZONE" --query-port="$p" >/dev/null 2>&1; then
|
||||||
|
echo "VERIFY=$p OK=1"
|
||||||
|
ok=$((ok + 1))
|
||||||
|
else
|
||||||
|
echo "VERIFY=$p OK=0"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "STATUS=done ZONE=$ZONE ADDED=$added PRESENT_BEFORE=$present VERIFY_OK=$ok EXPECT=${#PORTS[@]}"
|
||||||
|
if [[ "$ok" -eq "${#PORTS[@]}" ]]; then
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
exit 1
|
||||||
204
scripts/pepsi/hostnet-pepsi-highports.sh
Executable file
204
scripts/pepsi/hostnet-pepsi-highports.sh
Executable file
|
|
@ -0,0 +1,204 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
# Switch koopa-pepsi to network_mode:host on high ports so SMTP client IP
|
||||||
|
# is preserved (rootlessport SNAT otherwise forces 10.89.16.4 → SPF fail).
|
||||||
|
#
|
||||||
|
# Vecigate stays pure DNAT (25/465/587 → 1025/1465/1587). No mail on router.
|
||||||
|
# DB stays bridged; publish 127.0.0.1:15432→5432 for host-net pepsi.
|
||||||
|
#
|
||||||
|
# Freigabe: human this session (Script hinlegen + apply+restart).
|
||||||
|
# Run on koopa as hernani (rootless podman). Never echoes DB secrets.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
PEPSI_DIR="${PEPSI_DIR:-$HOME/koopa-pepsi}"
|
||||||
|
TS="$(date +%Y%m%d-%H%M%S)"
|
||||||
|
BAK="${PEPSI_DIR}/bak-hostnet-${TS}"
|
||||||
|
COMPOSE_BIN=""
|
||||||
|
|
||||||
|
echo "STATUS=start HOST=$(hostname -s) DIR=$PEPSI_DIR"
|
||||||
|
|
||||||
|
if [[ ! -d "$PEPSI_DIR" ]]; then
|
||||||
|
echo "STATUS=missing_dir PATH=$PEPSI_DIR"
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
cd "$PEPSI_DIR"
|
||||||
|
|
||||||
|
if command -v podman-compose >/dev/null 2>&1; then
|
||||||
|
COMPOSE_BIN="podman-compose"
|
||||||
|
elif podman compose version >/dev/null 2>&1; then
|
||||||
|
COMPOSE_BIN="podman compose"
|
||||||
|
else
|
||||||
|
echo "STATUS=no_compose"
|
||||||
|
exit 3
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ ! -f compose.yml || ! -f conf/pepsi.conf ]]; then
|
||||||
|
echo "STATUS=missing_files"
|
||||||
|
exit 4
|
||||||
|
fi
|
||||||
|
|
||||||
|
already=0
|
||||||
|
if grep -q 'network_mode:[[:space:]]*host' compose.yml \
|
||||||
|
&& grep -qE '^PORT = 1025$' conf/pepsi.conf \
|
||||||
|
&& grep -qE '@127\.0\.0\.1:15432/' conf/pepsi.conf; then
|
||||||
|
already=1
|
||||||
|
echo "STATUS=already_hostnet HINT=skip_patch"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$already" -eq 0 ]]; then
|
||||||
|
mkdir -p "$BAK"
|
||||||
|
cp -a compose.yml conf/pepsi.conf "$BAK/"
|
||||||
|
echo "STATUS=bak PATH=$BAK"
|
||||||
|
|
||||||
|
python3 - <<'PY'
|
||||||
|
from pathlib import Path
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
|
||||||
|
root = Path(".")
|
||||||
|
compose_path = root / "compose.yml"
|
||||||
|
conf_path = root / "conf" / "pepsi.conf"
|
||||||
|
compose = compose_path.read_text()
|
||||||
|
conf = conf_path.read_text()
|
||||||
|
|
||||||
|
# Split services crudely on "\n pepsi:" / "\n db:"
|
||||||
|
def patch_compose(text: str) -> str:
|
||||||
|
# Retarget env DB URLs first (password stays ${VAR})
|
||||||
|
text = text.replace("@db:5432/", "@127.0.0.1:15432/")
|
||||||
|
|
||||||
|
# Ensure db publishes 127.0.0.1:15432:5432
|
||||||
|
if "15432:5432" not in text:
|
||||||
|
text = re.sub(
|
||||||
|
r"(container_name: koopa-pepsi-db\n)",
|
||||||
|
r'\1 ports:\n - "127.0.0.1:15432:5432"\n',
|
||||||
|
text,
|
||||||
|
count=1,
|
||||||
|
)
|
||||||
|
|
||||||
|
# pepsi service: add network_mode host; remove its ports: block
|
||||||
|
# Match from " pepsi:" until next top-level service or "volumes:"
|
||||||
|
m = re.search(r"(?ms)^ pepsi:\n(.*?)(?=^volumes:|^ [a-z])", text)
|
||||||
|
if not m:
|
||||||
|
print("STATUS=pepsi_service_not_found")
|
||||||
|
sys.exit(5)
|
||||||
|
body = m.group(1)
|
||||||
|
# drop ports block inside pepsi
|
||||||
|
body2 = re.sub(r"(?m)^ ports:\n(?: [^\n]*\n)+", "", body)
|
||||||
|
if "network_mode:" not in body2:
|
||||||
|
body2 = re.sub(
|
||||||
|
r"(container_name: koopa-pepsi\n)",
|
||||||
|
r"\1 network_mode: host\n",
|
||||||
|
body2,
|
||||||
|
count=1,
|
||||||
|
)
|
||||||
|
text = text[: m.start(1)] + body2 + text[m.end(1) :]
|
||||||
|
return text
|
||||||
|
|
||||||
|
|
||||||
|
def set_port(text: str, section: str, port: int) -> str:
|
||||||
|
pat = rf"(\[{re.escape(section)}\]\n(?:.*\n)*?PORT = )\d+"
|
||||||
|
new, n = re.subn(pat, rf"\g<1>{port}", text, count=1)
|
||||||
|
if n != 1:
|
||||||
|
print(f"STATUS=port_patch_fail SECTION={section} N={n}")
|
||||||
|
sys.exit(6)
|
||||||
|
return new
|
||||||
|
|
||||||
|
|
||||||
|
compose = patch_compose(compose)
|
||||||
|
conf = conf.replace("@db:5432/", "@127.0.0.1:15432/")
|
||||||
|
conf = set_port(conf, "pepsi-ingress-listener-mx", 1025)
|
||||||
|
conf = set_port(conf, "pepsi-ingress-listener-submissions", 1465)
|
||||||
|
conf = set_port(conf, "pepsi-ingress-listener-submission", 1587)
|
||||||
|
conf = set_port(conf, "pepsi-httpd-listener-https", 18443)
|
||||||
|
|
||||||
|
compose_path.write_text(compose)
|
||||||
|
conf_path.write_text(conf)
|
||||||
|
|
||||||
|
# sanity STATUS (no secrets)
|
||||||
|
checks = [
|
||||||
|
("network_mode_host", bool(re.search(r"(?m)^ network_mode:\s*host\s*$", compose))),
|
||||||
|
("db_pub_15432", "15432:5432" in compose),
|
||||||
|
("no_db_hostname", "@db:5432/" not in compose and "@db:5432/" not in conf),
|
||||||
|
("port_1025", bool(re.search(r"(?m)^PORT = 1025\s*$", conf))),
|
||||||
|
("port_1465", bool(re.search(r"(?m)^PORT = 1465\s*$", conf))),
|
||||||
|
("port_1587", bool(re.search(r"(?m)^PORT = 1587\s*$", conf))),
|
||||||
|
("port_18443", bool(re.search(r"(?m)^PORT = 18443\s*$", conf))),
|
||||||
|
("pepsi_ports_gone", "192.168.100.95:1025" not in compose),
|
||||||
|
]
|
||||||
|
for k, v in checks:
|
||||||
|
print(f"CHECK={k} OK={int(v)}")
|
||||||
|
if not all(v for _, v in checks):
|
||||||
|
print("STATUS=patch_sanity_fail")
|
||||||
|
sys.exit(7)
|
||||||
|
print("STATUS=patched")
|
||||||
|
PY
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "STATUS=compose_up BIN=$COMPOSE_BIN"
|
||||||
|
# shellcheck disable=SC2086
|
||||||
|
$COMPOSE_BIN up -d --force-recreate
|
||||||
|
sleep 4
|
||||||
|
|
||||||
|
echo "STATUS=verify"
|
||||||
|
ss_out="$(ss -lntp 2>/dev/null | grep -E ':1025|:1465|:1587|:18443|:15432' || true)"
|
||||||
|
# redact pid/user clutter lightly
|
||||||
|
echo "$ss_out" | sed -E 's/users:\(\("([^"]+)".*/users=(("\1"…)/' || true
|
||||||
|
|
||||||
|
rootless_hit=0
|
||||||
|
if echo "$ss_out" | grep -q rootlessport; then
|
||||||
|
rootless_hit=1
|
||||||
|
fi
|
||||||
|
db_pub=0
|
||||||
|
if echo "$ss_out" | grep -qE '127\.0\.0\.1:15432'; then
|
||||||
|
db_pub=1
|
||||||
|
fi
|
||||||
|
mx_ok=0
|
||||||
|
if echo "$ss_out" | grep -qE ':1025\b'; then
|
||||||
|
mx_ok=1
|
||||||
|
fi
|
||||||
|
|
||||||
|
podman ps --format '{{.Names}} {{.Status}}' | grep pepsi || true
|
||||||
|
|
||||||
|
if command -v pg_isready >/dev/null 2>&1; then
|
||||||
|
if pg_isready -h 127.0.0.1 -p 15432 -d pepsi >/dev/null 2>&1; then
|
||||||
|
echo "STATUS=db_ready HOST=127.0.0.1 PORT=15432"
|
||||||
|
else
|
||||||
|
echo "STATUS=db_not_ready HOST=127.0.0.1 PORT=15432"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
if timeout 2 bash -c 'echo >/dev/tcp/127.0.0.1/15432' 2>/dev/null; then
|
||||||
|
echo "STATUS=db_tcp_open HOST=127.0.0.1 PORT=15432"
|
||||||
|
else
|
||||||
|
echo "STATUS=db_tcp_closed HOST=127.0.0.1 PORT=15432"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# recent log lines (strip any postgres URLs)
|
||||||
|
podman logs --tail 40 koopa-pepsi 2>/dev/null \
|
||||||
|
| grep -Ei 'listen|error|fatal|spf|client|1025|postgres|ready' \
|
||||||
|
| sed -E 's#postgres://[^[:space:]]+#postgres://*#g' \
|
||||||
|
| tail -12 || true
|
||||||
|
|
||||||
|
echo "STATUS=done MX_1025=$mx_ok DB_PUB=$db_pub ROOTLESSPORT_ON_HIGH=$rootless_hit BAK=${BAK:-none}"
|
||||||
|
echo "HINT=next ensure-mon-users.sh after recreate; then taler-mail send --expect-pong"
|
||||||
|
echo "HINT=Vecigate DNAT unchanged (no mail on router); /home ephemeral unless volume+ensure"
|
||||||
|
# Optional: run ensure if sibling script present (same dir or PEPSI_DIR/scripts)
|
||||||
|
ENSURE=""
|
||||||
|
here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
for cand in "$here/ensure-mon-users.sh" "$PEPSI_DIR/scripts/ensure-mon-users.sh" "$PEPSI_DIR/ensure-mon-users.sh"; do
|
||||||
|
if [[ -x "$cand" ]]; then
|
||||||
|
ENSURE="$cand"
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
if [[ -n "$ENSURE" ]]; then
|
||||||
|
echo "STATUS=ensure_mon_users SCRIPT=$ENSURE"
|
||||||
|
PEPSI_CTR=koopa-pepsi "$ENSURE" || echo "STATUS=ensure_warn"
|
||||||
|
fi
|
||||||
|
if [[ "$mx_ok" -eq 1 && "$rootless_hit" -eq 0 && "$db_pub" -eq 1 ]]; then
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
if [[ "$mx_ok" -eq 1 && "$db_pub" -eq 1 ]]; then
|
||||||
|
echo "STATUS=warn_rootlessport_still_present"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
exit 1
|
||||||
141
sites/dossiers.ngi.hacktivism.ch/_cascade/bar.css
Normal file
141
sites/dossiers.ngi.hacktivism.ch/_cascade/bar.css
Normal file
|
|
@ -0,0 +1,141 @@
|
||||||
|
html {
|
||||||
|
padding-top: var(--cascade-pad, 0px);
|
||||||
|
}
|
||||||
|
.cascade-bar {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
z-index: 1000;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
box-sizing: border-box;
|
||||||
|
width: 100%;
|
||||||
|
max-width: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0.2rem 0 0.25rem;
|
||||||
|
background: #10141a;
|
||||||
|
border-bottom: 1px solid #243040;
|
||||||
|
color: #c8d0d8;
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 1.25;
|
||||||
|
}
|
||||||
|
.cascade-tree {
|
||||||
|
/* 45rem = site #navigation/#content 45em at root 16px (bar font is 11px) */
|
||||||
|
width: 100%;
|
||||||
|
max-width: 45rem;
|
||||||
|
margin: 0 auto;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
.cascade-tree-lines {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow-x: auto;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
}
|
||||||
|
a.cascade-tree-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1.05rem minmax(6rem, 1fr) 7.2rem 10.5rem 4.4rem 3.6rem;
|
||||||
|
column-gap: 0.55rem;
|
||||||
|
align-items: baseline;
|
||||||
|
color: #7ec8ff;
|
||||||
|
text-decoration: none;
|
||||||
|
padding: 0 0.15rem;
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
a.cascade-tree-row:hover {
|
||||||
|
background: #1a2430;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
a.cascade-tree-row.is-active {
|
||||||
|
color: #e8eef4;
|
||||||
|
background: #1e2a38;
|
||||||
|
box-shadow: inset 3px 0 0 #e8eef4;
|
||||||
|
}
|
||||||
|
a.cascade-tree-row.is-active:hover {
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.cascade-bar code {
|
||||||
|
color: inherit;
|
||||||
|
font-size: inherit;
|
||||||
|
}
|
||||||
|
.cascade-name {
|
||||||
|
white-space: pre;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
min-width: 0;
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
.cascade-when,
|
||||||
|
.cascade-built,
|
||||||
|
.cascade-plus,
|
||||||
|
.cascade-minus {
|
||||||
|
text-align: right;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
white-space: nowrap;
|
||||||
|
color: #8a96a3;
|
||||||
|
}
|
||||||
|
.cascade-timing {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 45rem) {
|
||||||
|
.cascade-bar {
|
||||||
|
padding: 0.15rem 0.4rem 0.2rem;
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
.cascade-tree {
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
a.cascade-tree-row {
|
||||||
|
grid-template-columns: 0.9rem minmax(5rem, 1fr) 5.6rem 8.4rem 3.6rem 3rem;
|
||||||
|
column-gap: 0.35rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.cascade-mark {
|
||||||
|
text-align: center;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
.cascade-bar .cascade-ok {
|
||||||
|
color: #3fb950;
|
||||||
|
}
|
||||||
|
.cascade-bar .cascade-fail {
|
||||||
|
color: #f85149;
|
||||||
|
}
|
||||||
|
.cascade-bar .diff-plus {
|
||||||
|
color: #3fb950;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.cascade-bar .diff-minus {
|
||||||
|
color: #f85149;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.cascade-bar .diff-loc,
|
||||||
|
.cascade-bar .diff-loc-unit {
|
||||||
|
color: #8a96a3;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.cascade-hint {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 45rem;
|
||||||
|
margin: 0.15rem auto 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
padding: 0.2rem 0.35rem;
|
||||||
|
color: #f0c674;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.cascade-hint[hidden] {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.cascade-missing-msg {
|
||||||
|
max-width: 45rem;
|
||||||
|
margin: 1.5rem auto;
|
||||||
|
padding: 0 0.75rem;
|
||||||
|
color: #c8d0d8;
|
||||||
|
font-family: ui-sans-serif, system-ui, sans-serif;
|
||||||
|
font-size: 1rem;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
6
sites/dossiers.ngi.hacktivism.ch/_cascade/bar.html
Normal file
6
sites/dossiers.ngi.hacktivism.ch/_cascade/bar.html
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
<link rel="stylesheet" href="/_cascade/bar.css">
|
||||||
|
<nav class="cascade-bar" aria-label="cascade builds" id="cascade-bar">
|
||||||
|
<div class="cascade-tree" id="cascade-tree" role="tree">Loading builds…</div>
|
||||||
|
<div class="cascade-hint" id="cascade-hint" role="status" hidden></div>
|
||||||
|
</nav>
|
||||||
|
<script src="/_cascade/bar.js" defer></script>
|
||||||
466
sites/dossiers.ngi.hacktivism.ch/_cascade/bar.js
Normal file
466
sites/dossiers.ngi.hacktivism.ch/_cascade/bar.js
Normal file
|
|
@ -0,0 +1,466 @@
|
||||||
|
(function () {
|
||||||
|
var POLL_MS = 30000;
|
||||||
|
var MONTHS = [
|
||||||
|
"jan",
|
||||||
|
"feb",
|
||||||
|
"mar",
|
||||||
|
"apr",
|
||||||
|
"may",
|
||||||
|
"jun",
|
||||||
|
"jul",
|
||||||
|
"aug",
|
||||||
|
"sep",
|
||||||
|
"oct",
|
||||||
|
"nov",
|
||||||
|
"dec",
|
||||||
|
];
|
||||||
|
|
||||||
|
function cookieEdition() {
|
||||||
|
var m = document.cookie.match(/(?:^|; )tdpd_edition=([^;]*)/);
|
||||||
|
if (!m) return "flask";
|
||||||
|
try {
|
||||||
|
return decodeURIComponent(m[1]) || "flask";
|
||||||
|
} catch (e) {
|
||||||
|
return "flask";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setEditionCookie(edition) {
|
||||||
|
document.cookie =
|
||||||
|
"tdpd_edition=" +
|
||||||
|
encodeURIComponent(edition) +
|
||||||
|
"; Path=/; Max-Age=31536000; SameSite=Lax";
|
||||||
|
}
|
||||||
|
|
||||||
|
function keepPath() {
|
||||||
|
return location.pathname + location.search;
|
||||||
|
}
|
||||||
|
|
||||||
|
function useHref(edition) {
|
||||||
|
var path = keepPath();
|
||||||
|
var href = "/_cascade/use/" + encodeURIComponent(edition);
|
||||||
|
if (!path || path.indexOf("/_cascade/use/") === 0) return href;
|
||||||
|
return href + "?next=" + encodeURIComponent(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
function hintEl() {
|
||||||
|
return document.getElementById("cascade-hint");
|
||||||
|
}
|
||||||
|
|
||||||
|
function showHint(msg) {
|
||||||
|
var el = hintEl();
|
||||||
|
if (!el) {
|
||||||
|
var bar = document.getElementById("cascade-bar");
|
||||||
|
if (!bar) return;
|
||||||
|
el = document.createElement("div");
|
||||||
|
el.id = "cascade-hint";
|
||||||
|
el.className = "cascade-hint";
|
||||||
|
el.setAttribute("role", "status");
|
||||||
|
bar.appendChild(el);
|
||||||
|
}
|
||||||
|
el.hidden = false;
|
||||||
|
el.textContent = msg;
|
||||||
|
pinBar();
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearHint() {
|
||||||
|
var el = hintEl();
|
||||||
|
if (!el) return;
|
||||||
|
el.hidden = true;
|
||||||
|
el.textContent = "";
|
||||||
|
pinBar();
|
||||||
|
}
|
||||||
|
|
||||||
|
function responseKeepsBar(res, text) {
|
||||||
|
if (res && res.ok) return true;
|
||||||
|
var body = text || "";
|
||||||
|
return (
|
||||||
|
body.indexOf("cascade-bar") !== -1 ||
|
||||||
|
body.indexOf("cascade-missing") !== -1
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function switchEdition(edition, branchLabel) {
|
||||||
|
var prev = cookieEdition();
|
||||||
|
var here = keepPath() || "/";
|
||||||
|
var label = branchLabel || edition;
|
||||||
|
if (!edition) return;
|
||||||
|
if (edition === prev) {
|
||||||
|
clearHint();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setEditionCookie(edition);
|
||||||
|
fetch(here, { cache: "no-store", credentials: "same-origin" })
|
||||||
|
.then(function (res) {
|
||||||
|
return res.text().then(function (text) {
|
||||||
|
return { res: res, text: text };
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.then(function (got) {
|
||||||
|
if (responseKeepsBar(got.res, got.text)) {
|
||||||
|
location.replace(here + location.hash);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setEditionCookie(prev);
|
||||||
|
showHint(
|
||||||
|
"This path is not in " +
|
||||||
|
label +
|
||||||
|
". Choose another branch above."
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.catch(function () {
|
||||||
|
setEditionCookie(prev);
|
||||||
|
showHint(
|
||||||
|
"This path is not in " +
|
||||||
|
label +
|
||||||
|
". Choose another branch above."
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function esc(s) {
|
||||||
|
return String(s)
|
||||||
|
.replace(/&/g, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/"/g, """);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** ISO → "sep30 16:12" (3-letter English month + DD + HH:MM). */
|
||||||
|
function shortTime(iso) {
|
||||||
|
if (!iso) return "";
|
||||||
|
var m = String(iso).match(/^(\d{4})-(\d{2})-(\d{2})T(\d{2}:\d{2})/);
|
||||||
|
if (m) {
|
||||||
|
var mon = MONTHS[Number(m[2]) - 1] || m[2];
|
||||||
|
return mon + m[3] + " " + m[4];
|
||||||
|
}
|
||||||
|
return String(iso).slice(0, 16).replace("T", " ");
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtBuildSec(sec) {
|
||||||
|
if (sec == null || sec === "") return "";
|
||||||
|
var n = Number(sec);
|
||||||
|
if (!isFinite(n)) return "";
|
||||||
|
if (n < 10) return n.toFixed(1) + "s";
|
||||||
|
return Math.round(n) + "s";
|
||||||
|
}
|
||||||
|
|
||||||
|
function builtText(row) {
|
||||||
|
var sec = fmtBuildSec(row.build_seconds);
|
||||||
|
var at = shortTime(row.built_at || "");
|
||||||
|
if (sec && at) return sec + " " + at;
|
||||||
|
return sec || at;
|
||||||
|
}
|
||||||
|
|
||||||
|
function freezeOk(row) {
|
||||||
|
return row && row.freeze_ok === true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function freezeLabel(row) {
|
||||||
|
if (freezeOk(row)) return "freeze ok";
|
||||||
|
var err = row && row.freeze_error ? String(row.freeze_error) : "";
|
||||||
|
return err ? "freeze failed: " + err : "freeze failed";
|
||||||
|
}
|
||||||
|
|
||||||
|
function freezeMarkHtml(row) {
|
||||||
|
var ok = freezeOk(row);
|
||||||
|
return (
|
||||||
|
'<span class="cascade-mark ' +
|
||||||
|
(ok ? "cascade-ok" : "cascade-fail") +
|
||||||
|
'" title="' +
|
||||||
|
esc(freezeLabel(row)) +
|
||||||
|
'" aria-label="' +
|
||||||
|
esc(freezeLabel(row)) +
|
||||||
|
'">' +
|
||||||
|
(ok ? "✓" : "✗") +
|
||||||
|
"</span>"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function plusHtml(row) {
|
||||||
|
if (row.vs_parent_plus != null) {
|
||||||
|
var p = Number(row.vs_parent_plus);
|
||||||
|
if (!isFinite(p)) return "";
|
||||||
|
return '<span class="diff-plus">+' + esc(String(p)) + "</span>";
|
||||||
|
}
|
||||||
|
if (row.loc != null) {
|
||||||
|
var n = Number(row.loc);
|
||||||
|
if (!isFinite(n)) return "";
|
||||||
|
return '<span class="diff-loc">' + esc(String(n)) + "</span>";
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function minusHtml(row) {
|
||||||
|
if (row.vs_parent_minus != null) {
|
||||||
|
var m = Number(row.vs_parent_minus);
|
||||||
|
if (!isFinite(m)) return "";
|
||||||
|
return '<span class="diff-minus">−' + esc(String(m)) + "</span>";
|
||||||
|
}
|
||||||
|
if (row.loc != null) {
|
||||||
|
return '<span class="diff-loc-unit">LOC</span>';
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function setText(el, text) {
|
||||||
|
if (!el) return;
|
||||||
|
var t = text || "";
|
||||||
|
if (el.textContent !== t) el.textContent = t;
|
||||||
|
}
|
||||||
|
|
||||||
|
function rowTitle(row) {
|
||||||
|
var tip = row.tip || "";
|
||||||
|
var tipTime = row.tip_time || "";
|
||||||
|
var vs = "";
|
||||||
|
if (row.vs_parent_plus != null || row.vs_parent_minus != null) {
|
||||||
|
vs =
|
||||||
|
" vs parent +" +
|
||||||
|
String(row.vs_parent_plus == null ? 0 : row.vs_parent_plus) +
|
||||||
|
"/−" +
|
||||||
|
String(row.vs_parent_minus == null ? 0 : row.vs_parent_minus);
|
||||||
|
} else if (row.loc != null) {
|
||||||
|
vs = " loc=" + String(row.loc);
|
||||||
|
}
|
||||||
|
var built = builtText(row);
|
||||||
|
return (
|
||||||
|
"Switch build to " +
|
||||||
|
(row.branch || "") +
|
||||||
|
(tip ? " @ " + tip : "") +
|
||||||
|
(tipTime ? " tip=" + shortTime(tipTime) : "") +
|
||||||
|
(built ? " build=" + built : "") +
|
||||||
|
" " +
|
||||||
|
freezeLabel(row) +
|
||||||
|
vs
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function structureKey(list) {
|
||||||
|
var parts = [];
|
||||||
|
for (var i = 0; i < list.length; i++) {
|
||||||
|
var row = list[i];
|
||||||
|
parts.push(
|
||||||
|
(row.edition || "") +
|
||||||
|
"\t" +
|
||||||
|
(row.branch || "") +
|
||||||
|
"\t" +
|
||||||
|
(row.prefix || "") +
|
||||||
|
"\t" +
|
||||||
|
(row.parent || "")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return parts.join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
function pinBar(timingMsg) {
|
||||||
|
var bar = document.getElementById("cascade-bar");
|
||||||
|
if (!bar) return;
|
||||||
|
if (timingMsg) {
|
||||||
|
bar.setAttribute("data-timing", timingMsg);
|
||||||
|
bar.setAttribute("title", timingMsg);
|
||||||
|
}
|
||||||
|
var h = bar.offsetHeight || 0;
|
||||||
|
document.documentElement.style.setProperty("--cascade-pad", h + "px");
|
||||||
|
}
|
||||||
|
|
||||||
|
function markActive(root, edition) {
|
||||||
|
var links = root.querySelectorAll("a[data-edition]");
|
||||||
|
for (var i = 0; i < links.length; i++) {
|
||||||
|
var a = links[i];
|
||||||
|
var ed = a.getAttribute("data-edition");
|
||||||
|
var on =
|
||||||
|
ed === edition ||
|
||||||
|
(edition === "flask" && a.getAttribute("data-default") === "1");
|
||||||
|
if (on) {
|
||||||
|
a.classList.add("is-active");
|
||||||
|
a.setAttribute("aria-current", "page");
|
||||||
|
} else {
|
||||||
|
a.classList.remove("is-active");
|
||||||
|
a.removeAttribute("aria-current");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function timingText(timing, listLen) {
|
||||||
|
if (!timing || timing.wall_seconds == null) return "";
|
||||||
|
return (
|
||||||
|
"last full pass ~" +
|
||||||
|
String(timing.wall_seconds) +
|
||||||
|
"s · built " +
|
||||||
|
String(timing.built || 0) +
|
||||||
|
" / skipped " +
|
||||||
|
String(timing.skipped || 0) +
|
||||||
|
" / " +
|
||||||
|
String(timing.branches || listLen)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateValues(mount, data) {
|
||||||
|
var list = (data && data.branches) || [];
|
||||||
|
var defaultBranch = (data && data.default_branch) || "feat/website-views";
|
||||||
|
var timing = (data && data.rebuild_timing) || null;
|
||||||
|
var rows = mount.querySelectorAll("a.cascade-tree-row[data-edition]");
|
||||||
|
if (rows.length !== list.length) return false;
|
||||||
|
for (var i = 0; i < list.length; i++) {
|
||||||
|
var row = list[i];
|
||||||
|
var a = rows[i];
|
||||||
|
if (a.getAttribute("data-edition") !== (row.edition || "")) return false;
|
||||||
|
if (a.getAttribute("data-branch") !== (row.branch || "")) return false;
|
||||||
|
var isDefault = (row.branch || "") === defaultBranch;
|
||||||
|
if (isDefault) a.setAttribute("data-default", "1");
|
||||||
|
else a.removeAttribute("data-default");
|
||||||
|
setText(a.querySelector(".cascade-when"), shortTime(row.tip_time || ""));
|
||||||
|
setText(a.querySelector(".cascade-built"), builtText(row));
|
||||||
|
var markEl = a.querySelector(".cascade-mark");
|
||||||
|
if (markEl) {
|
||||||
|
var markHtml = freezeMarkHtml(row);
|
||||||
|
if (markEl.outerHTML !== markHtml) markEl.outerHTML = markHtml;
|
||||||
|
}
|
||||||
|
var plusEl = a.querySelector(".cascade-plus");
|
||||||
|
if (plusEl) {
|
||||||
|
var ph = plusHtml(row);
|
||||||
|
if (plusEl.innerHTML !== ph) plusEl.innerHTML = ph;
|
||||||
|
}
|
||||||
|
var minusEl = a.querySelector(".cascade-minus");
|
||||||
|
if (minusEl) {
|
||||||
|
var mh = minusHtml(row);
|
||||||
|
if (minusEl.innerHTML !== mh) minusEl.innerHTML = mh;
|
||||||
|
}
|
||||||
|
a.setAttribute("title", rowTitle(row));
|
||||||
|
}
|
||||||
|
var timingEl = mount.querySelector(".cascade-timing");
|
||||||
|
var t = timingText(timing, list.length);
|
||||||
|
if (t) {
|
||||||
|
if (!timingEl) {
|
||||||
|
timingEl = document.createElement("div");
|
||||||
|
timingEl.className = "cascade-timing";
|
||||||
|
mount.appendChild(timingEl);
|
||||||
|
}
|
||||||
|
if (timingEl.textContent !== t) timingEl.textContent = t;
|
||||||
|
} else if (timingEl) {
|
||||||
|
timingEl.parentNode.removeChild(timingEl);
|
||||||
|
}
|
||||||
|
markActive(mount, cookieEdition());
|
||||||
|
pinBar(t);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderTree(data, soft) {
|
||||||
|
var mount = document.getElementById("cascade-tree");
|
||||||
|
if (!mount) return;
|
||||||
|
var list = (data && data.branches) || [];
|
||||||
|
if (!list.length) {
|
||||||
|
mount.textContent = "No branches in branch-status.json";
|
||||||
|
pinBar();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (soft && mount.getAttribute("data-structure") === structureKey(list)) {
|
||||||
|
if (updateValues(mount, data)) return;
|
||||||
|
}
|
||||||
|
var defaultBranch = (data && data.default_branch) || "feat/website-views";
|
||||||
|
var timing = (data && data.rebuild_timing) || null;
|
||||||
|
var html = [];
|
||||||
|
html.push('<div class="cascade-tree-lines" role="group">');
|
||||||
|
for (var i = 0; i < list.length; i++) {
|
||||||
|
var row = list[i];
|
||||||
|
var edition = row.edition || "";
|
||||||
|
var branch = row.branch || "";
|
||||||
|
var prefix = row.prefix || "";
|
||||||
|
var parent = row.parent || "";
|
||||||
|
var isDefault = branch === defaultBranch;
|
||||||
|
html.push(
|
||||||
|
'<a class="cascade-tree-row" role="treeitem" href="' +
|
||||||
|
esc(useHref(edition)) +
|
||||||
|
'" data-edition="' +
|
||||||
|
esc(edition) +
|
||||||
|
'" data-branch="' +
|
||||||
|
esc(branch) +
|
||||||
|
'"' +
|
||||||
|
(parent ? ' data-parent="' + esc(parent) + '"' : "") +
|
||||||
|
(isDefault ? ' data-default="1"' : "") +
|
||||||
|
' title="' +
|
||||||
|
esc(rowTitle(row)) +
|
||||||
|
'">' +
|
||||||
|
freezeMarkHtml(row) +
|
||||||
|
'<span class="cascade-name">' +
|
||||||
|
esc(prefix) +
|
||||||
|
"<code>" +
|
||||||
|
esc(branch) +
|
||||||
|
"</code></span>" +
|
||||||
|
'<span class="cascade-when">' +
|
||||||
|
esc(shortTime(row.tip_time || "")) +
|
||||||
|
"</span>" +
|
||||||
|
'<span class="cascade-built">' +
|
||||||
|
esc(builtText(row)) +
|
||||||
|
"</span>" +
|
||||||
|
'<span class="cascade-plus">' +
|
||||||
|
plusHtml(row) +
|
||||||
|
"</span>" +
|
||||||
|
'<span class="cascade-minus">' +
|
||||||
|
minusHtml(row) +
|
||||||
|
"</span>" +
|
||||||
|
"</a>"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
html.push("</div>");
|
||||||
|
var t = timingText(timing, list.length);
|
||||||
|
if (t) {
|
||||||
|
html.push('<div class="cascade-timing">' + esc(t) + "</div>");
|
||||||
|
}
|
||||||
|
mount.innerHTML = html.join("");
|
||||||
|
mount.setAttribute("data-structure", structureKey(list));
|
||||||
|
markActive(mount, cookieEdition());
|
||||||
|
pinBar(t);
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadStatus(soft) {
|
||||||
|
var mount = document.getElementById("cascade-tree");
|
||||||
|
return fetch("/branch-status.json", { cache: "no-store" })
|
||||||
|
.then(function (res) {
|
||||||
|
if (!res.ok) throw new Error("status");
|
||||||
|
return res.json();
|
||||||
|
})
|
||||||
|
.then(function (data) {
|
||||||
|
renderTree(data, !!soft);
|
||||||
|
})
|
||||||
|
.catch(function () {
|
||||||
|
if (!soft && mount && !mount.querySelector("a[data-edition]")) {
|
||||||
|
mount.innerHTML =
|
||||||
|
'<a class="cascade-tree-row" href="' +
|
||||||
|
esc(useHref("flask")) +
|
||||||
|
'" data-edition="flask" data-branch="flask"><span class="cascade-name"><code>flask</code></span></a>' +
|
||||||
|
'<span class="tip"> (branch-status unavailable)</span>';
|
||||||
|
markActive(mount, cookieEdition());
|
||||||
|
pinBar();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener("click", function (ev) {
|
||||||
|
var a = ev.target.closest
|
||||||
|
? ev.target.closest("a.cascade-tree-row[data-edition]")
|
||||||
|
: null;
|
||||||
|
if (!a || ev.defaultPrevented) return;
|
||||||
|
if (ev.button !== 0 || ev.metaKey || ev.ctrlKey || ev.shiftKey || ev.altKey)
|
||||||
|
return;
|
||||||
|
ev.preventDefault();
|
||||||
|
switchEdition(
|
||||||
|
a.getAttribute("data-edition"),
|
||||||
|
a.getAttribute("data-branch") || a.getAttribute("data-edition")
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (document.body && document.body.classList.contains("cascade-missing")) {
|
||||||
|
showHint(
|
||||||
|
"This path is not in the current branch. Choose another branch above."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
loadStatus(false);
|
||||||
|
setInterval(function () {
|
||||||
|
loadStatus(true);
|
||||||
|
}, POLL_MS);
|
||||||
|
window.addEventListener("resize", function () {
|
||||||
|
pinBar();
|
||||||
|
});
|
||||||
|
})();
|
||||||
17
sites/dossiers.ngi.hacktivism.ch/_cascade/index.html
Normal file
17
sites/dossiers.ngi.hacktivism.ch/_cascade/index.html
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>cascade editions</title>
|
||||||
|
<link rel="stylesheet" href="/_cascade/bar.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<nav class="cascade-bar" aria-label="cascade builds" id="cascade-bar">
|
||||||
|
<div class="cascade-tree" id="cascade-tree" role="tree">Loading builds…</div>
|
||||||
|
<div class="cascade-hint" id="cascade-hint" role="status" hidden></div>
|
||||||
|
</nav>
|
||||||
|
<p>Stuck on an edition without a bar? Pick a build above, or <a href="/_cascade/use/flask">reset to flask</a>.</p>
|
||||||
|
<script src="/_cascade/bar.js" defer></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
17
sites/dossiers.ngi.hacktivism.ch/_cascade/missing.html
Normal file
17
sites/dossiers.ngi.hacktivism.ch/_cascade/missing.html
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>path not in this branch</title>
|
||||||
|
<link rel="stylesheet" href="/_cascade/bar.css">
|
||||||
|
</head>
|
||||||
|
<body class="cascade-missing">
|
||||||
|
<nav class="cascade-bar" aria-label="cascade builds" id="cascade-bar">
|
||||||
|
<div class="cascade-tree" id="cascade-tree" role="tree">Loading builds…</div>
|
||||||
|
<div class="cascade-hint" id="cascade-hint" role="status" hidden></div>
|
||||||
|
</nav>
|
||||||
|
<p class="cascade-missing-msg">This address is not in the current branch. Choose another branch above.</p>
|
||||||
|
<script src="/_cascade/bar.js" defer></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
@ -0,0 +1,17 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>edition pending</title>
|
||||||
|
<style>
|
||||||
|
:root { color-scheme: dark; }
|
||||||
|
body { margin:0; padding:1.5rem; font-family: ui-monospace, monospace; background:#0b0d10; color:#c8d0d8; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
{% include "_cascade/bar.html" . %}
|
||||||
|
<h1>edition pending</h1>
|
||||||
|
<p>Full freeze for this tip is not staged under <code>editions/</code> yet. Sticky bar still switches other builds.</p>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
124
sites/dossiers.ngi.hacktivism.ch/branch-status.json
Normal file
124
sites/dossiers.ngi.hacktivism.ch/branch-status.json
Normal file
|
|
@ -0,0 +1,124 @@
|
||||||
|
{
|
||||||
|
"generated": "2026-09-30T15:34:26+02:00",
|
||||||
|
"host": "koopa",
|
||||||
|
"default_branch": "feat/website-views",
|
||||||
|
"default_edition": "flask",
|
||||||
|
"branches": [
|
||||||
|
{
|
||||||
|
"branch": "feature/nl-net-injestion-injestor-update",
|
||||||
|
"edition": "feature-nl-net-injestion-injestor-update",
|
||||||
|
"tip": "ba68fe9",
|
||||||
|
"tip_time": "2026-08-27T10:42:02+02:00",
|
||||||
|
"parent": "",
|
||||||
|
"prefix": "",
|
||||||
|
"url": "/_cascade/use/feature-nl-net-injestion-injestor-update",
|
||||||
|
"built_at": "2026-09-30T15:23:10+02:00",
|
||||||
|
"build_seconds": 3.841
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"branch": "main",
|
||||||
|
"edition": "main",
|
||||||
|
"tip": "d2fbd79",
|
||||||
|
"tip_time": "2026-09-01T16:52:35+02:00",
|
||||||
|
"parent": "",
|
||||||
|
"prefix": "",
|
||||||
|
"url": "/_cascade/use/main",
|
||||||
|
"built_at": "2026-09-30T15:23:10+02:00",
|
||||||
|
"build_seconds": 8.447
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"branch": "feat/sm-content",
|
||||||
|
"edition": "feat-sm-content",
|
||||||
|
"tip": "089ef1f",
|
||||||
|
"tip_time": "2026-09-28T15:52:12+02:00",
|
||||||
|
"parent": "main",
|
||||||
|
"prefix": "\u251c\u2500\u2500 ",
|
||||||
|
"url": "/_cascade/use/feat-sm-content",
|
||||||
|
"built_at": "2026-09-30T15:23:10+02:00",
|
||||||
|
"build_seconds": 12.845
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"branch": "feat/website-features",
|
||||||
|
"edition": "feat-website-features",
|
||||||
|
"tip": "53a3176",
|
||||||
|
"tip_time": "2026-09-28T19:38:22+02:00",
|
||||||
|
"parent": "feat/sm-content",
|
||||||
|
"prefix": "\u2502 \u2514\u2500\u2500 ",
|
||||||
|
"url": "/_cascade/use/feat-website-features",
|
||||||
|
"built_at": "2026-09-30T15:23:10+02:00",
|
||||||
|
"build_seconds": 8.34
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"branch": "feat/website-design",
|
||||||
|
"edition": "feat-website-design",
|
||||||
|
"tip": "2e26c80",
|
||||||
|
"tip_time": "2026-09-28T22:42:08+02:00",
|
||||||
|
"parent": "feat/website-features",
|
||||||
|
"prefix": "\u2502 \u2514\u2500\u2500 ",
|
||||||
|
"url": "/_cascade/use/feat-website-design",
|
||||||
|
"built_at": "2026-09-30T15:23:10+02:00",
|
||||||
|
"build_seconds": 10.358
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"branch": "feat/website-views",
|
||||||
|
"edition": "feat-website-views",
|
||||||
|
"tip": "bbafc38",
|
||||||
|
"tip_time": "2026-09-28T23:17:42+02:00",
|
||||||
|
"parent": "feat/website-design",
|
||||||
|
"prefix": "\u2502 \u2514\u2500\u2500 ",
|
||||||
|
"url": "/_cascade/use/feat-website-views",
|
||||||
|
"built_at": "2026-09-30T15:23:10+02:00",
|
||||||
|
"build_seconds": 5.576
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"branch": "project_template",
|
||||||
|
"edition": "project_template",
|
||||||
|
"tip": "a97c263",
|
||||||
|
"tip_time": "2026-09-28T15:03:35+02:00",
|
||||||
|
"parent": "main",
|
||||||
|
"prefix": "\u2514\u2500\u2500 ",
|
||||||
|
"url": "/_cascade/use/project_template",
|
||||||
|
"built_at": "2026-09-30T15:23:10+02:00",
|
||||||
|
"build_seconds": 2.245
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"rebuild": {
|
||||||
|
"feat/sm-content": {
|
||||||
|
"status": "skipped",
|
||||||
|
"seconds": 0.077
|
||||||
|
},
|
||||||
|
"feat/website-design": {
|
||||||
|
"status": "skipped",
|
||||||
|
"seconds": 0.083
|
||||||
|
},
|
||||||
|
"feat/website-features": {
|
||||||
|
"status": "skipped",
|
||||||
|
"seconds": 0.091
|
||||||
|
},
|
||||||
|
"feat/website-views": {
|
||||||
|
"status": "skipped",
|
||||||
|
"seconds": 0.09
|
||||||
|
},
|
||||||
|
"feature/nl-net-injestion-injestor-update": {
|
||||||
|
"status": "skipped",
|
||||||
|
"seconds": 0.098
|
||||||
|
},
|
||||||
|
"main": {
|
||||||
|
"status": "skipped",
|
||||||
|
"seconds": 0.102
|
||||||
|
},
|
||||||
|
"project_template": {
|
||||||
|
"status": "skipped",
|
||||||
|
"seconds": 0.083
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"rebuild_timing": {
|
||||||
|
"wall_seconds": 0.627,
|
||||||
|
"branches": 7,
|
||||||
|
"built": 0,
|
||||||
|
"skipped": 7,
|
||||||
|
"errors": 0,
|
||||||
|
"built_seconds_sum": 0.0,
|
||||||
|
"built_seconds_max": 0.0
|
||||||
|
}
|
||||||
|
}
|
||||||
141
sites/dossiers.ngi.hacktivism.ch/cascade/_cascade/bar.css
Normal file
141
sites/dossiers.ngi.hacktivism.ch/cascade/_cascade/bar.css
Normal file
|
|
@ -0,0 +1,141 @@
|
||||||
|
html {
|
||||||
|
padding-top: var(--cascade-pad, 0px);
|
||||||
|
}
|
||||||
|
.cascade-bar {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
z-index: 1000;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
box-sizing: border-box;
|
||||||
|
width: 100%;
|
||||||
|
max-width: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0.2rem 0 0.25rem;
|
||||||
|
background: #10141a;
|
||||||
|
border-bottom: 1px solid #243040;
|
||||||
|
color: #c8d0d8;
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 1.25;
|
||||||
|
}
|
||||||
|
.cascade-tree {
|
||||||
|
/* 45rem = site #navigation/#content 45em at root 16px (bar font is 11px) */
|
||||||
|
width: 100%;
|
||||||
|
max-width: 45rem;
|
||||||
|
margin: 0 auto;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
.cascade-tree-lines {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow-x: auto;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
}
|
||||||
|
a.cascade-tree-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1.05rem minmax(6rem, 1fr) 7.2rem 10.5rem 4.4rem 3.6rem;
|
||||||
|
column-gap: 0.55rem;
|
||||||
|
align-items: baseline;
|
||||||
|
color: #7ec8ff;
|
||||||
|
text-decoration: none;
|
||||||
|
padding: 0 0.15rem;
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
a.cascade-tree-row:hover {
|
||||||
|
background: #1a2430;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
a.cascade-tree-row.is-active {
|
||||||
|
color: #e8eef4;
|
||||||
|
background: #1e2a38;
|
||||||
|
box-shadow: inset 3px 0 0 #e8eef4;
|
||||||
|
}
|
||||||
|
a.cascade-tree-row.is-active:hover {
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.cascade-bar code {
|
||||||
|
color: inherit;
|
||||||
|
font-size: inherit;
|
||||||
|
}
|
||||||
|
.cascade-name {
|
||||||
|
white-space: pre;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
min-width: 0;
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
.cascade-when,
|
||||||
|
.cascade-built,
|
||||||
|
.cascade-plus,
|
||||||
|
.cascade-minus {
|
||||||
|
text-align: right;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
white-space: nowrap;
|
||||||
|
color: #8a96a3;
|
||||||
|
}
|
||||||
|
.cascade-timing {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 45rem) {
|
||||||
|
.cascade-bar {
|
||||||
|
padding: 0.15rem 0.4rem 0.2rem;
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
.cascade-tree {
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
a.cascade-tree-row {
|
||||||
|
grid-template-columns: 0.9rem minmax(5rem, 1fr) 5.6rem 8.4rem 3.6rem 3rem;
|
||||||
|
column-gap: 0.35rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.cascade-mark {
|
||||||
|
text-align: center;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
.cascade-bar .cascade-ok {
|
||||||
|
color: #3fb950;
|
||||||
|
}
|
||||||
|
.cascade-bar .cascade-fail {
|
||||||
|
color: #f85149;
|
||||||
|
}
|
||||||
|
.cascade-bar .diff-plus {
|
||||||
|
color: #3fb950;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.cascade-bar .diff-minus {
|
||||||
|
color: #f85149;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.cascade-bar .diff-loc,
|
||||||
|
.cascade-bar .diff-loc-unit {
|
||||||
|
color: #8a96a3;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.cascade-hint {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 45rem;
|
||||||
|
margin: 0.15rem auto 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
padding: 0.2rem 0.35rem;
|
||||||
|
color: #f0c674;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.cascade-hint[hidden] {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.cascade-missing-msg {
|
||||||
|
max-width: 45rem;
|
||||||
|
margin: 1.5rem auto;
|
||||||
|
padding: 0 0.75rem;
|
||||||
|
color: #c8d0d8;
|
||||||
|
font-family: ui-sans-serif, system-ui, sans-serif;
|
||||||
|
font-size: 1rem;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,6 @@
|
||||||
|
<link rel="stylesheet" href="/_cascade/bar.css">
|
||||||
|
<nav class="cascade-bar" aria-label="cascade builds" id="cascade-bar">
|
||||||
|
<div class="cascade-tree" id="cascade-tree" role="tree">Loading builds…</div>
|
||||||
|
<div class="cascade-hint" id="cascade-hint" role="status" hidden></div>
|
||||||
|
</nav>
|
||||||
|
<script src="/_cascade/bar.js" defer></script>
|
||||||
466
sites/dossiers.ngi.hacktivism.ch/cascade/_cascade/bar.js
Normal file
466
sites/dossiers.ngi.hacktivism.ch/cascade/_cascade/bar.js
Normal file
|
|
@ -0,0 +1,466 @@
|
||||||
|
(function () {
|
||||||
|
var POLL_MS = 30000;
|
||||||
|
var MONTHS = [
|
||||||
|
"jan",
|
||||||
|
"feb",
|
||||||
|
"mar",
|
||||||
|
"apr",
|
||||||
|
"may",
|
||||||
|
"jun",
|
||||||
|
"jul",
|
||||||
|
"aug",
|
||||||
|
"sep",
|
||||||
|
"oct",
|
||||||
|
"nov",
|
||||||
|
"dec",
|
||||||
|
];
|
||||||
|
|
||||||
|
function cookieEdition() {
|
||||||
|
var m = document.cookie.match(/(?:^|; )tdpd_edition=([^;]*)/);
|
||||||
|
if (!m) return "flask";
|
||||||
|
try {
|
||||||
|
return decodeURIComponent(m[1]) || "flask";
|
||||||
|
} catch (e) {
|
||||||
|
return "flask";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setEditionCookie(edition) {
|
||||||
|
document.cookie =
|
||||||
|
"tdpd_edition=" +
|
||||||
|
encodeURIComponent(edition) +
|
||||||
|
"; Path=/; Max-Age=31536000; SameSite=Lax";
|
||||||
|
}
|
||||||
|
|
||||||
|
function keepPath() {
|
||||||
|
return location.pathname + location.search;
|
||||||
|
}
|
||||||
|
|
||||||
|
function useHref(edition) {
|
||||||
|
var path = keepPath();
|
||||||
|
var href = "/_cascade/use/" + encodeURIComponent(edition);
|
||||||
|
if (!path || path.indexOf("/_cascade/use/") === 0) return href;
|
||||||
|
return href + "?next=" + encodeURIComponent(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
function hintEl() {
|
||||||
|
return document.getElementById("cascade-hint");
|
||||||
|
}
|
||||||
|
|
||||||
|
function showHint(msg) {
|
||||||
|
var el = hintEl();
|
||||||
|
if (!el) {
|
||||||
|
var bar = document.getElementById("cascade-bar");
|
||||||
|
if (!bar) return;
|
||||||
|
el = document.createElement("div");
|
||||||
|
el.id = "cascade-hint";
|
||||||
|
el.className = "cascade-hint";
|
||||||
|
el.setAttribute("role", "status");
|
||||||
|
bar.appendChild(el);
|
||||||
|
}
|
||||||
|
el.hidden = false;
|
||||||
|
el.textContent = msg;
|
||||||
|
pinBar();
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearHint() {
|
||||||
|
var el = hintEl();
|
||||||
|
if (!el) return;
|
||||||
|
el.hidden = true;
|
||||||
|
el.textContent = "";
|
||||||
|
pinBar();
|
||||||
|
}
|
||||||
|
|
||||||
|
function responseKeepsBar(res, text) {
|
||||||
|
if (res && res.ok) return true;
|
||||||
|
var body = text || "";
|
||||||
|
return (
|
||||||
|
body.indexOf("cascade-bar") !== -1 ||
|
||||||
|
body.indexOf("cascade-missing") !== -1
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function switchEdition(edition, branchLabel) {
|
||||||
|
var prev = cookieEdition();
|
||||||
|
var here = keepPath() || "/";
|
||||||
|
var label = branchLabel || edition;
|
||||||
|
if (!edition) return;
|
||||||
|
if (edition === prev) {
|
||||||
|
clearHint();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setEditionCookie(edition);
|
||||||
|
fetch(here, { cache: "no-store", credentials: "same-origin" })
|
||||||
|
.then(function (res) {
|
||||||
|
return res.text().then(function (text) {
|
||||||
|
return { res: res, text: text };
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.then(function (got) {
|
||||||
|
if (responseKeepsBar(got.res, got.text)) {
|
||||||
|
location.replace(here + location.hash);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setEditionCookie(prev);
|
||||||
|
showHint(
|
||||||
|
"This path is not in " +
|
||||||
|
label +
|
||||||
|
". Choose another branch above."
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.catch(function () {
|
||||||
|
setEditionCookie(prev);
|
||||||
|
showHint(
|
||||||
|
"This path is not in " +
|
||||||
|
label +
|
||||||
|
". Choose another branch above."
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function esc(s) {
|
||||||
|
return String(s)
|
||||||
|
.replace(/&/g, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/"/g, """);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** ISO → "sep30 16:12" (3-letter English month + DD + HH:MM). */
|
||||||
|
function shortTime(iso) {
|
||||||
|
if (!iso) return "";
|
||||||
|
var m = String(iso).match(/^(\d{4})-(\d{2})-(\d{2})T(\d{2}:\d{2})/);
|
||||||
|
if (m) {
|
||||||
|
var mon = MONTHS[Number(m[2]) - 1] || m[2];
|
||||||
|
return mon + m[3] + " " + m[4];
|
||||||
|
}
|
||||||
|
return String(iso).slice(0, 16).replace("T", " ");
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtBuildSec(sec) {
|
||||||
|
if (sec == null || sec === "") return "";
|
||||||
|
var n = Number(sec);
|
||||||
|
if (!isFinite(n)) return "";
|
||||||
|
if (n < 10) return n.toFixed(1) + "s";
|
||||||
|
return Math.round(n) + "s";
|
||||||
|
}
|
||||||
|
|
||||||
|
function builtText(row) {
|
||||||
|
var sec = fmtBuildSec(row.build_seconds);
|
||||||
|
var at = shortTime(row.built_at || "");
|
||||||
|
if (sec && at) return sec + " " + at;
|
||||||
|
return sec || at;
|
||||||
|
}
|
||||||
|
|
||||||
|
function freezeOk(row) {
|
||||||
|
return row && row.freeze_ok === true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function freezeLabel(row) {
|
||||||
|
if (freezeOk(row)) return "freeze ok";
|
||||||
|
var err = row && row.freeze_error ? String(row.freeze_error) : "";
|
||||||
|
return err ? "freeze failed: " + err : "freeze failed";
|
||||||
|
}
|
||||||
|
|
||||||
|
function freezeMarkHtml(row) {
|
||||||
|
var ok = freezeOk(row);
|
||||||
|
return (
|
||||||
|
'<span class="cascade-mark ' +
|
||||||
|
(ok ? "cascade-ok" : "cascade-fail") +
|
||||||
|
'" title="' +
|
||||||
|
esc(freezeLabel(row)) +
|
||||||
|
'" aria-label="' +
|
||||||
|
esc(freezeLabel(row)) +
|
||||||
|
'">' +
|
||||||
|
(ok ? "✓" : "✗") +
|
||||||
|
"</span>"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function plusHtml(row) {
|
||||||
|
if (row.vs_parent_plus != null) {
|
||||||
|
var p = Number(row.vs_parent_plus);
|
||||||
|
if (!isFinite(p)) return "";
|
||||||
|
return '<span class="diff-plus">+' + esc(String(p)) + "</span>";
|
||||||
|
}
|
||||||
|
if (row.loc != null) {
|
||||||
|
var n = Number(row.loc);
|
||||||
|
if (!isFinite(n)) return "";
|
||||||
|
return '<span class="diff-loc">' + esc(String(n)) + "</span>";
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function minusHtml(row) {
|
||||||
|
if (row.vs_parent_minus != null) {
|
||||||
|
var m = Number(row.vs_parent_minus);
|
||||||
|
if (!isFinite(m)) return "";
|
||||||
|
return '<span class="diff-minus">−' + esc(String(m)) + "</span>";
|
||||||
|
}
|
||||||
|
if (row.loc != null) {
|
||||||
|
return '<span class="diff-loc-unit">LOC</span>';
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function setText(el, text) {
|
||||||
|
if (!el) return;
|
||||||
|
var t = text || "";
|
||||||
|
if (el.textContent !== t) el.textContent = t;
|
||||||
|
}
|
||||||
|
|
||||||
|
function rowTitle(row) {
|
||||||
|
var tip = row.tip || "";
|
||||||
|
var tipTime = row.tip_time || "";
|
||||||
|
var vs = "";
|
||||||
|
if (row.vs_parent_plus != null || row.vs_parent_minus != null) {
|
||||||
|
vs =
|
||||||
|
" vs parent +" +
|
||||||
|
String(row.vs_parent_plus == null ? 0 : row.vs_parent_plus) +
|
||||||
|
"/−" +
|
||||||
|
String(row.vs_parent_minus == null ? 0 : row.vs_parent_minus);
|
||||||
|
} else if (row.loc != null) {
|
||||||
|
vs = " loc=" + String(row.loc);
|
||||||
|
}
|
||||||
|
var built = builtText(row);
|
||||||
|
return (
|
||||||
|
"Switch build to " +
|
||||||
|
(row.branch || "") +
|
||||||
|
(tip ? " @ " + tip : "") +
|
||||||
|
(tipTime ? " tip=" + shortTime(tipTime) : "") +
|
||||||
|
(built ? " build=" + built : "") +
|
||||||
|
" " +
|
||||||
|
freezeLabel(row) +
|
||||||
|
vs
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function structureKey(list) {
|
||||||
|
var parts = [];
|
||||||
|
for (var i = 0; i < list.length; i++) {
|
||||||
|
var row = list[i];
|
||||||
|
parts.push(
|
||||||
|
(row.edition || "") +
|
||||||
|
"\t" +
|
||||||
|
(row.branch || "") +
|
||||||
|
"\t" +
|
||||||
|
(row.prefix || "") +
|
||||||
|
"\t" +
|
||||||
|
(row.parent || "")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return parts.join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
function pinBar(timingMsg) {
|
||||||
|
var bar = document.getElementById("cascade-bar");
|
||||||
|
if (!bar) return;
|
||||||
|
if (timingMsg) {
|
||||||
|
bar.setAttribute("data-timing", timingMsg);
|
||||||
|
bar.setAttribute("title", timingMsg);
|
||||||
|
}
|
||||||
|
var h = bar.offsetHeight || 0;
|
||||||
|
document.documentElement.style.setProperty("--cascade-pad", h + "px");
|
||||||
|
}
|
||||||
|
|
||||||
|
function markActive(root, edition) {
|
||||||
|
var links = root.querySelectorAll("a[data-edition]");
|
||||||
|
for (var i = 0; i < links.length; i++) {
|
||||||
|
var a = links[i];
|
||||||
|
var ed = a.getAttribute("data-edition");
|
||||||
|
var on =
|
||||||
|
ed === edition ||
|
||||||
|
(edition === "flask" && a.getAttribute("data-default") === "1");
|
||||||
|
if (on) {
|
||||||
|
a.classList.add("is-active");
|
||||||
|
a.setAttribute("aria-current", "page");
|
||||||
|
} else {
|
||||||
|
a.classList.remove("is-active");
|
||||||
|
a.removeAttribute("aria-current");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function timingText(timing, listLen) {
|
||||||
|
if (!timing || timing.wall_seconds == null) return "";
|
||||||
|
return (
|
||||||
|
"last full pass ~" +
|
||||||
|
String(timing.wall_seconds) +
|
||||||
|
"s · built " +
|
||||||
|
String(timing.built || 0) +
|
||||||
|
" / skipped " +
|
||||||
|
String(timing.skipped || 0) +
|
||||||
|
" / " +
|
||||||
|
String(timing.branches || listLen)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateValues(mount, data) {
|
||||||
|
var list = (data && data.branches) || [];
|
||||||
|
var defaultBranch = (data && data.default_branch) || "feat/website-views";
|
||||||
|
var timing = (data && data.rebuild_timing) || null;
|
||||||
|
var rows = mount.querySelectorAll("a.cascade-tree-row[data-edition]");
|
||||||
|
if (rows.length !== list.length) return false;
|
||||||
|
for (var i = 0; i < list.length; i++) {
|
||||||
|
var row = list[i];
|
||||||
|
var a = rows[i];
|
||||||
|
if (a.getAttribute("data-edition") !== (row.edition || "")) return false;
|
||||||
|
if (a.getAttribute("data-branch") !== (row.branch || "")) return false;
|
||||||
|
var isDefault = (row.branch || "") === defaultBranch;
|
||||||
|
if (isDefault) a.setAttribute("data-default", "1");
|
||||||
|
else a.removeAttribute("data-default");
|
||||||
|
setText(a.querySelector(".cascade-when"), shortTime(row.tip_time || ""));
|
||||||
|
setText(a.querySelector(".cascade-built"), builtText(row));
|
||||||
|
var markEl = a.querySelector(".cascade-mark");
|
||||||
|
if (markEl) {
|
||||||
|
var markHtml = freezeMarkHtml(row);
|
||||||
|
if (markEl.outerHTML !== markHtml) markEl.outerHTML = markHtml;
|
||||||
|
}
|
||||||
|
var plusEl = a.querySelector(".cascade-plus");
|
||||||
|
if (plusEl) {
|
||||||
|
var ph = plusHtml(row);
|
||||||
|
if (plusEl.innerHTML !== ph) plusEl.innerHTML = ph;
|
||||||
|
}
|
||||||
|
var minusEl = a.querySelector(".cascade-minus");
|
||||||
|
if (minusEl) {
|
||||||
|
var mh = minusHtml(row);
|
||||||
|
if (minusEl.innerHTML !== mh) minusEl.innerHTML = mh;
|
||||||
|
}
|
||||||
|
a.setAttribute("title", rowTitle(row));
|
||||||
|
}
|
||||||
|
var timingEl = mount.querySelector(".cascade-timing");
|
||||||
|
var t = timingText(timing, list.length);
|
||||||
|
if (t) {
|
||||||
|
if (!timingEl) {
|
||||||
|
timingEl = document.createElement("div");
|
||||||
|
timingEl.className = "cascade-timing";
|
||||||
|
mount.appendChild(timingEl);
|
||||||
|
}
|
||||||
|
if (timingEl.textContent !== t) timingEl.textContent = t;
|
||||||
|
} else if (timingEl) {
|
||||||
|
timingEl.parentNode.removeChild(timingEl);
|
||||||
|
}
|
||||||
|
markActive(mount, cookieEdition());
|
||||||
|
pinBar(t);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderTree(data, soft) {
|
||||||
|
var mount = document.getElementById("cascade-tree");
|
||||||
|
if (!mount) return;
|
||||||
|
var list = (data && data.branches) || [];
|
||||||
|
if (!list.length) {
|
||||||
|
mount.textContent = "No branches in branch-status.json";
|
||||||
|
pinBar();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (soft && mount.getAttribute("data-structure") === structureKey(list)) {
|
||||||
|
if (updateValues(mount, data)) return;
|
||||||
|
}
|
||||||
|
var defaultBranch = (data && data.default_branch) || "feat/website-views";
|
||||||
|
var timing = (data && data.rebuild_timing) || null;
|
||||||
|
var html = [];
|
||||||
|
html.push('<div class="cascade-tree-lines" role="group">');
|
||||||
|
for (var i = 0; i < list.length; i++) {
|
||||||
|
var row = list[i];
|
||||||
|
var edition = row.edition || "";
|
||||||
|
var branch = row.branch || "";
|
||||||
|
var prefix = row.prefix || "";
|
||||||
|
var parent = row.parent || "";
|
||||||
|
var isDefault = branch === defaultBranch;
|
||||||
|
html.push(
|
||||||
|
'<a class="cascade-tree-row" role="treeitem" href="' +
|
||||||
|
esc(useHref(edition)) +
|
||||||
|
'" data-edition="' +
|
||||||
|
esc(edition) +
|
||||||
|
'" data-branch="' +
|
||||||
|
esc(branch) +
|
||||||
|
'"' +
|
||||||
|
(parent ? ' data-parent="' + esc(parent) + '"' : "") +
|
||||||
|
(isDefault ? ' data-default="1"' : "") +
|
||||||
|
' title="' +
|
||||||
|
esc(rowTitle(row)) +
|
||||||
|
'">' +
|
||||||
|
freezeMarkHtml(row) +
|
||||||
|
'<span class="cascade-name">' +
|
||||||
|
esc(prefix) +
|
||||||
|
"<code>" +
|
||||||
|
esc(branch) +
|
||||||
|
"</code></span>" +
|
||||||
|
'<span class="cascade-when">' +
|
||||||
|
esc(shortTime(row.tip_time || "")) +
|
||||||
|
"</span>" +
|
||||||
|
'<span class="cascade-built">' +
|
||||||
|
esc(builtText(row)) +
|
||||||
|
"</span>" +
|
||||||
|
'<span class="cascade-plus">' +
|
||||||
|
plusHtml(row) +
|
||||||
|
"</span>" +
|
||||||
|
'<span class="cascade-minus">' +
|
||||||
|
minusHtml(row) +
|
||||||
|
"</span>" +
|
||||||
|
"</a>"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
html.push("</div>");
|
||||||
|
var t = timingText(timing, list.length);
|
||||||
|
if (t) {
|
||||||
|
html.push('<div class="cascade-timing">' + esc(t) + "</div>");
|
||||||
|
}
|
||||||
|
mount.innerHTML = html.join("");
|
||||||
|
mount.setAttribute("data-structure", structureKey(list));
|
||||||
|
markActive(mount, cookieEdition());
|
||||||
|
pinBar(t);
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadStatus(soft) {
|
||||||
|
var mount = document.getElementById("cascade-tree");
|
||||||
|
return fetch("/branch-status.json", { cache: "no-store" })
|
||||||
|
.then(function (res) {
|
||||||
|
if (!res.ok) throw new Error("status");
|
||||||
|
return res.json();
|
||||||
|
})
|
||||||
|
.then(function (data) {
|
||||||
|
renderTree(data, !!soft);
|
||||||
|
})
|
||||||
|
.catch(function () {
|
||||||
|
if (!soft && mount && !mount.querySelector("a[data-edition]")) {
|
||||||
|
mount.innerHTML =
|
||||||
|
'<a class="cascade-tree-row" href="' +
|
||||||
|
esc(useHref("flask")) +
|
||||||
|
'" data-edition="flask" data-branch="flask"><span class="cascade-name"><code>flask</code></span></a>' +
|
||||||
|
'<span class="tip"> (branch-status unavailable)</span>';
|
||||||
|
markActive(mount, cookieEdition());
|
||||||
|
pinBar();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener("click", function (ev) {
|
||||||
|
var a = ev.target.closest
|
||||||
|
? ev.target.closest("a.cascade-tree-row[data-edition]")
|
||||||
|
: null;
|
||||||
|
if (!a || ev.defaultPrevented) return;
|
||||||
|
if (ev.button !== 0 || ev.metaKey || ev.ctrlKey || ev.shiftKey || ev.altKey)
|
||||||
|
return;
|
||||||
|
ev.preventDefault();
|
||||||
|
switchEdition(
|
||||||
|
a.getAttribute("data-edition"),
|
||||||
|
a.getAttribute("data-branch") || a.getAttribute("data-edition")
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (document.body && document.body.classList.contains("cascade-missing")) {
|
||||||
|
showHint(
|
||||||
|
"This path is not in the current branch. Choose another branch above."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
loadStatus(false);
|
||||||
|
setInterval(function () {
|
||||||
|
loadStatus(true);
|
||||||
|
}, POLL_MS);
|
||||||
|
window.addEventListener("resize", function () {
|
||||||
|
pinBar();
|
||||||
|
});
|
||||||
|
})();
|
||||||
17
sites/dossiers.ngi.hacktivism.ch/cascade/_cascade/index.html
Normal file
17
sites/dossiers.ngi.hacktivism.ch/cascade/_cascade/index.html
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>cascade editions</title>
|
||||||
|
<link rel="stylesheet" href="/_cascade/bar.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<nav class="cascade-bar" aria-label="cascade builds" id="cascade-bar">
|
||||||
|
<div class="cascade-tree" id="cascade-tree" role="tree">Loading builds…</div>
|
||||||
|
<div class="cascade-hint" id="cascade-hint" role="status" hidden></div>
|
||||||
|
</nav>
|
||||||
|
<p>Stuck on an edition without a bar? Pick a build above, or <a href="/_cascade/use/flask">reset to flask</a>.</p>
|
||||||
|
<script src="/_cascade/bar.js" defer></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
@ -0,0 +1,31 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>feat/sm-content</title>
|
||||||
|
<style>
|
||||||
|
:root { color-scheme: dark; }
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
body {
|
||||||
|
margin: 0; padding: 1.5rem;
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
|
||||||
|
font-size: 14px; line-height: 1.45;
|
||||||
|
background: #0b0d10; color: #c8d0d8;
|
||||||
|
}
|
||||||
|
a { color: #7ec8ff; text-decoration: none; }
|
||||||
|
a:hover { text-decoration: underline; }
|
||||||
|
h1 { font-size: 1.15rem; font-weight: 600; margin: 0 0 0.5rem; color: #e8eef4; }
|
||||||
|
.meta { color: #8a96a3; margin: 0 0 1rem; }
|
||||||
|
ul.plain { padding-left: 1.2rem; margin: 0; }
|
||||||
|
li { margin: 0.25rem 0; }
|
||||||
|
.dim { color: #6a7682; }
|
||||||
|
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
{% include "_cascade/bar.html" . %}
|
||||||
|
<h1><code>/feat-sm-content/</code></h1>
|
||||||
|
<p class="meta">last-built: 2026-09-30T02:30:50+02:00 <span class="dim">·</span> <code>feat/sm-content</code> @ <code>089ef1f</code></p>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
@ -0,0 +1,31 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>feat/website-design</title>
|
||||||
|
<style>
|
||||||
|
:root { color-scheme: dark; }
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
body {
|
||||||
|
margin: 0; padding: 1.5rem;
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
|
||||||
|
font-size: 14px; line-height: 1.45;
|
||||||
|
background: #0b0d10; color: #c8d0d8;
|
||||||
|
}
|
||||||
|
a { color: #7ec8ff; text-decoration: none; }
|
||||||
|
a:hover { text-decoration: underline; }
|
||||||
|
h1 { font-size: 1.15rem; font-weight: 600; margin: 0 0 0.5rem; color: #e8eef4; }
|
||||||
|
.meta { color: #8a96a3; margin: 0 0 1rem; }
|
||||||
|
ul.plain { padding-left: 1.2rem; margin: 0; }
|
||||||
|
li { margin: 0.25rem 0; }
|
||||||
|
.dim { color: #6a7682; }
|
||||||
|
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
{% include "_cascade/bar.html" . %}
|
||||||
|
<h1><code>/feat-website-design/</code></h1>
|
||||||
|
<p class="meta">last-built: 2026-09-30T02:30:50+02:00 <span class="dim">·</span> <code>feat/website-design</code> @ <code>2e26c80</code></p>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
@ -0,0 +1,31 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>feat/website-features</title>
|
||||||
|
<style>
|
||||||
|
:root { color-scheme: dark; }
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
body {
|
||||||
|
margin: 0; padding: 1.5rem;
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
|
||||||
|
font-size: 14px; line-height: 1.45;
|
||||||
|
background: #0b0d10; color: #c8d0d8;
|
||||||
|
}
|
||||||
|
a { color: #7ec8ff; text-decoration: none; }
|
||||||
|
a:hover { text-decoration: underline; }
|
||||||
|
h1 { font-size: 1.15rem; font-weight: 600; margin: 0 0 0.5rem; color: #e8eef4; }
|
||||||
|
.meta { color: #8a96a3; margin: 0 0 1rem; }
|
||||||
|
ul.plain { padding-left: 1.2rem; margin: 0; }
|
||||||
|
li { margin: 0.25rem 0; }
|
||||||
|
.dim { color: #6a7682; }
|
||||||
|
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
{% include "_cascade/bar.html" . %}
|
||||||
|
<h1><code>/feat-website-features/</code></h1>
|
||||||
|
<p class="meta">last-built: 2026-09-30T02:30:50+02:00 <span class="dim">·</span> <code>feat/website-features</code> @ <code>53a3176</code></p>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
@ -0,0 +1,31 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>feat/website-views</title>
|
||||||
|
<style>
|
||||||
|
:root { color-scheme: dark; }
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
body {
|
||||||
|
margin: 0; padding: 1.5rem;
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
|
||||||
|
font-size: 14px; line-height: 1.45;
|
||||||
|
background: #0b0d10; color: #c8d0d8;
|
||||||
|
}
|
||||||
|
a { color: #7ec8ff; text-decoration: none; }
|
||||||
|
a:hover { text-decoration: underline; }
|
||||||
|
h1 { font-size: 1.15rem; font-weight: 600; margin: 0 0 0.5rem; color: #e8eef4; }
|
||||||
|
.meta { color: #8a96a3; margin: 0 0 1rem; }
|
||||||
|
ul.plain { padding-left: 1.2rem; margin: 0; }
|
||||||
|
li { margin: 0.25rem 0; }
|
||||||
|
.dim { color: #6a7682; }
|
||||||
|
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
{% include "_cascade/bar.html" . %}
|
||||||
|
<h1><code>/feat-website-views/</code></h1>
|
||||||
|
<p class="meta">last-built: 2026-09-30T02:30:50+02:00 <span class="dim">·</span> <code>feat/website-views</code> @ <code>bbafc38</code></p>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
39
sites/dossiers.ngi.hacktivism.ch/cascade/index.html
Normal file
39
sites/dossiers.ngi.hacktivism.ch/cascade/index.html
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>cascade landing</title>
|
||||||
|
<style>
|
||||||
|
:root { color-scheme: dark; }
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
body {
|
||||||
|
margin: 0; padding: 1.5rem;
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
|
||||||
|
font-size: 14px; line-height: 1.45;
|
||||||
|
background: #0b0d10; color: #c8d0d8;
|
||||||
|
}
|
||||||
|
a { color: #7ec8ff; text-decoration: none; }
|
||||||
|
a:hover { text-decoration: underline; }
|
||||||
|
h1 { font-size: 1.15rem; font-weight: 600; margin: 0 0 0.5rem; color: #e8eef4; }
|
||||||
|
.meta { color: #8a96a3; margin: 0 0 1rem; }
|
||||||
|
ul.plain { padding-left: 1.2rem; margin: 0; }
|
||||||
|
li { margin: 0.25rem 0; }
|
||||||
|
.dim { color: #6a7682; }
|
||||||
|
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
{% include "_cascade/bar.html" . %}
|
||||||
|
<h1>cascade</h1>
|
||||||
|
<p class="meta">Flask freeze lives at <a href="/"><code>/</code></a> (views). This ladder is tip stubs only.</p>
|
||||||
|
<p class="meta">last-built: 2026-09-30T02:30:50+02:00</p>
|
||||||
|
<ul class="plain">
|
||||||
|
<li><a href="/cascade/main/"><code>/main/</code></a> <span class="dim">·</span> <code>main</code> @ <code>d2fbd79</code></li>
|
||||||
|
<li><a href="/cascade/feat-sm-content/"><code>/feat-sm-content/</code></a> <span class="dim">·</span> <code>feat/sm-content</code> @ <code>089ef1f</code></li>
|
||||||
|
<li><a href="/cascade/feat-website-features/"><code>/feat-website-features/</code></a> <span class="dim">·</span> <code>feat/website-features</code> @ <code>53a3176</code></li>
|
||||||
|
<li><a href="/cascade/feat-website-design/"><code>/feat-website-design/</code></a> <span class="dim">·</span> <code>feat/website-design</code> @ <code>2e26c80</code></li>
|
||||||
|
<li><a href="/cascade/feat-website-views/"><code>/feat-website-views/</code></a> <span class="dim">·</span> <code>feat/website-views</code> @ <code>bbafc38</code></li>
|
||||||
|
</ul>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
31
sites/dossiers.ngi.hacktivism.ch/cascade/main/index.html
Normal file
31
sites/dossiers.ngi.hacktivism.ch/cascade/main/index.html
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>main</title>
|
||||||
|
<style>
|
||||||
|
:root { color-scheme: dark; }
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
body {
|
||||||
|
margin: 0; padding: 1.5rem;
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
|
||||||
|
font-size: 14px; line-height: 1.45;
|
||||||
|
background: #0b0d10; color: #c8d0d8;
|
||||||
|
}
|
||||||
|
a { color: #7ec8ff; text-decoration: none; }
|
||||||
|
a:hover { text-decoration: underline; }
|
||||||
|
h1 { font-size: 1.15rem; font-weight: 600; margin: 0 0 0.5rem; color: #e8eef4; }
|
||||||
|
.meta { color: #8a96a3; margin: 0 0 1rem; }
|
||||||
|
ul.plain { padding-left: 1.2rem; margin: 0; }
|
||||||
|
li { margin: 0.25rem 0; }
|
||||||
|
.dim { color: #6a7682; }
|
||||||
|
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
{% include "_cascade/bar.html" . %}
|
||||||
|
<h1><code>/main/</code></h1>
|
||||||
|
<p class="meta">last-built: 2026-09-30T02:30:50+02:00 <span class="dim">·</span> <code>main</code> @ <code>d2fbd79</code></p>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
@ -1,64 +0,0 @@
|
||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
||||||
<title>feat/sm-content · cascade</title>
|
|
||||||
<style>
|
|
||||||
:root { color-scheme: dark; }
|
|
||||||
* { box-sizing: border-box; }
|
|
||||||
body {
|
|
||||||
margin: 0; padding: 1.5rem;
|
|
||||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
|
|
||||||
font-size: 14px; line-height: 1.45;
|
|
||||||
background: #0b0d10; color: #c8d0d8;
|
|
||||||
}
|
|
||||||
a { color: #7ec8ff; text-decoration: none; }
|
|
||||||
a:hover { text-decoration: underline; }
|
|
||||||
h1 { font-size: 1.15rem; font-weight: 600; margin: 0 0 0.75rem; color: #e8eef4; }
|
|
||||||
h2 { font-size: 1rem; font-weight: 600; margin: 1.25rem 0 0.5rem; color: #e8eef4; }
|
|
||||||
.meta { color: #8a96a3; margin-bottom: 1rem; }
|
|
||||||
.box {
|
|
||||||
border: 1px solid #2a3340; background: #12161c;
|
|
||||||
padding: 0.85rem 1rem; margin: 0.75rem 0; white-space: pre; overflow-x: auto;
|
|
||||||
}
|
|
||||||
.nav { margin: 0.5rem 0 1rem; }
|
|
||||||
.nav a { margin-right: 0.85rem; }
|
|
||||||
.note { color: #b8a46a; margin-top: 1rem; max-width: 72ch; white-space: normal; }
|
|
||||||
.ok { color: #7dcea0; }
|
|
||||||
.dim { color: #6a7682; }
|
|
||||||
ul.plain { padding-left: 1.2rem; }
|
|
||||||
li { margin: 0.25rem 0; }
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
|
|
||||||
<h1>edition: feat/sm-content</h1>
|
|
||||||
<p class="meta">path <code>/feat-sm-content/</code> · tip <code>089ef1f</code> · depends on <a href="/main/"><code>main</code></a></p>
|
|
||||||
<div class="nav">
|
|
||||||
<a href="/">home</a>
|
|
||||||
<a href="/main/">/main/</a>
|
|
||||||
<a href="/feat-sm-content/">/feat-sm-content/</a>
|
|
||||||
<a href="/feat-website-features/">/feat-website-features/</a>
|
|
||||||
<a href="/feat-website-design/">/feat-website-design/</a>
|
|
||||||
<a href="/feat-website-views/">/feat-website-views/</a>
|
|
||||||
</div>
|
|
||||||
<h2>ladder position</h2>
|
|
||||||
<div class="box">cascade ladder (path roots on dossiers.ngi only)
|
|
||||||
|
|
||||||
<a href='/main/'>main</a> @ d2fbd79
|
|
||||||
<span class='dim'> |</span>
|
|
||||||
<span class='ok'>* +-- feat/sm-content @ 089ef1f <-- this edition</span>
|
|
||||||
<span class='dim'> |</span>
|
|
||||||
<span class='dim'> +--</span> <a href='/feat-website-features/'>feat/website-features</a> @ 53a3176
|
|
||||||
<span class='dim'> |</span>
|
|
||||||
<span class='dim'> +--</span> <a href='/feat-website-design/'>feat/website-design</a> @ 2e26c80
|
|
||||||
<span class='dim'> |</span>
|
|
||||||
<span class='dim'> +--</span> <a href='/feat-website-views/'>feat/website-views</a> @ bbafc38</div>
|
|
||||||
<h2>what this looks like</h2>
|
|
||||||
<p>SM dossier prose content on top of main.</p>
|
|
||||||
<p class="dim">Blank relational stub. Frozen Flask build for this tip is separate; this page only shows cascade relation and path root.</p>
|
|
||||||
|
|
||||||
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
|
|
@ -1,64 +0,0 @@
|
||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
||||||
<title>feat/website-design · cascade</title>
|
|
||||||
<style>
|
|
||||||
:root { color-scheme: dark; }
|
|
||||||
* { box-sizing: border-box; }
|
|
||||||
body {
|
|
||||||
margin: 0; padding: 1.5rem;
|
|
||||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
|
|
||||||
font-size: 14px; line-height: 1.45;
|
|
||||||
background: #0b0d10; color: #c8d0d8;
|
|
||||||
}
|
|
||||||
a { color: #7ec8ff; text-decoration: none; }
|
|
||||||
a:hover { text-decoration: underline; }
|
|
||||||
h1 { font-size: 1.15rem; font-weight: 600; margin: 0 0 0.75rem; color: #e8eef4; }
|
|
||||||
h2 { font-size: 1rem; font-weight: 600; margin: 1.25rem 0 0.5rem; color: #e8eef4; }
|
|
||||||
.meta { color: #8a96a3; margin-bottom: 1rem; }
|
|
||||||
.box {
|
|
||||||
border: 1px solid #2a3340; background: #12161c;
|
|
||||||
padding: 0.85rem 1rem; margin: 0.75rem 0; white-space: pre; overflow-x: auto;
|
|
||||||
}
|
|
||||||
.nav { margin: 0.5rem 0 1rem; }
|
|
||||||
.nav a { margin-right: 0.85rem; }
|
|
||||||
.note { color: #b8a46a; margin-top: 1rem; max-width: 72ch; white-space: normal; }
|
|
||||||
.ok { color: #7dcea0; }
|
|
||||||
.dim { color: #6a7682; }
|
|
||||||
ul.plain { padding-left: 1.2rem; }
|
|
||||||
li { margin: 0.25rem 0; }
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
|
|
||||||
<h1>edition: feat/website-design</h1>
|
|
||||||
<p class="meta">path <code>/feat-website-design/</code> · tip <code>2e26c80</code> · depends on <a href="/feat-website-features/"><code>feat/website-features</code></a></p>
|
|
||||||
<div class="nav">
|
|
||||||
<a href="/">home</a>
|
|
||||||
<a href="/main/">/main/</a>
|
|
||||||
<a href="/feat-sm-content/">/feat-sm-content/</a>
|
|
||||||
<a href="/feat-website-features/">/feat-website-features/</a>
|
|
||||||
<a href="/feat-website-design/">/feat-website-design/</a>
|
|
||||||
<a href="/feat-website-views/">/feat-website-views/</a>
|
|
||||||
</div>
|
|
||||||
<h2>ladder position</h2>
|
|
||||||
<div class="box">cascade ladder (path roots on dossiers.ngi only)
|
|
||||||
|
|
||||||
<a href='/main/'>main</a> @ d2fbd79
|
|
||||||
<span class='dim'> |</span>
|
|
||||||
<span class='dim'> +--</span> <a href='/feat-sm-content/'>feat/sm-content</a> @ 089ef1f
|
|
||||||
<span class='dim'> |</span>
|
|
||||||
<span class='dim'> +--</span> <a href='/feat-website-features/'>feat/website-features</a> @ 53a3176
|
|
||||||
<span class='dim'> |</span>
|
|
||||||
<span class='ok'>* +-- feat/website-design @ 2e26c80 <-- this edition</span>
|
|
||||||
<span class='dim'> |</span>
|
|
||||||
<span class='dim'> +--</span> <a href='/feat-website-views/'>feat/website-views</a> @ bbafc38</div>
|
|
||||||
<h2>what this looks like</h2>
|
|
||||||
<p>Design/CSS layer on top of features.</p>
|
|
||||||
<p class="dim">Blank relational stub. Frozen Flask build for this tip is separate; this page only shows cascade relation and path root.</p>
|
|
||||||
|
|
||||||
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
|
|
@ -1,64 +0,0 @@
|
||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
||||||
<title>feat/website-features · cascade</title>
|
|
||||||
<style>
|
|
||||||
:root { color-scheme: dark; }
|
|
||||||
* { box-sizing: border-box; }
|
|
||||||
body {
|
|
||||||
margin: 0; padding: 1.5rem;
|
|
||||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
|
|
||||||
font-size: 14px; line-height: 1.45;
|
|
||||||
background: #0b0d10; color: #c8d0d8;
|
|
||||||
}
|
|
||||||
a { color: #7ec8ff; text-decoration: none; }
|
|
||||||
a:hover { text-decoration: underline; }
|
|
||||||
h1 { font-size: 1.15rem; font-weight: 600; margin: 0 0 0.75rem; color: #e8eef4; }
|
|
||||||
h2 { font-size: 1rem; font-weight: 600; margin: 1.25rem 0 0.5rem; color: #e8eef4; }
|
|
||||||
.meta { color: #8a96a3; margin-bottom: 1rem; }
|
|
||||||
.box {
|
|
||||||
border: 1px solid #2a3340; background: #12161c;
|
|
||||||
padding: 0.85rem 1rem; margin: 0.75rem 0; white-space: pre; overflow-x: auto;
|
|
||||||
}
|
|
||||||
.nav { margin: 0.5rem 0 1rem; }
|
|
||||||
.nav a { margin-right: 0.85rem; }
|
|
||||||
.note { color: #b8a46a; margin-top: 1rem; max-width: 72ch; white-space: normal; }
|
|
||||||
.ok { color: #7dcea0; }
|
|
||||||
.dim { color: #6a7682; }
|
|
||||||
ul.plain { padding-left: 1.2rem; }
|
|
||||||
li { margin: 0.25rem 0; }
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
|
|
||||||
<h1>edition: feat/website-features</h1>
|
|
||||||
<p class="meta">path <code>/feat-website-features/</code> · tip <code>53a3176</code> · depends on <a href="/feat-sm-content/"><code>feat/sm-content</code></a></p>
|
|
||||||
<div class="nav">
|
|
||||||
<a href="/">home</a>
|
|
||||||
<a href="/main/">/main/</a>
|
|
||||||
<a href="/feat-sm-content/">/feat-sm-content/</a>
|
|
||||||
<a href="/feat-website-features/">/feat-website-features/</a>
|
|
||||||
<a href="/feat-website-design/">/feat-website-design/</a>
|
|
||||||
<a href="/feat-website-views/">/feat-website-views/</a>
|
|
||||||
</div>
|
|
||||||
<h2>ladder position</h2>
|
|
||||||
<div class="box">cascade ladder (path roots on dossiers.ngi only)
|
|
||||||
|
|
||||||
<a href='/main/'>main</a> @ d2fbd79
|
|
||||||
<span class='dim'> |</span>
|
|
||||||
<span class='dim'> +--</span> <a href='/feat-sm-content/'>feat/sm-content</a> @ 089ef1f
|
|
||||||
<span class='dim'> |</span>
|
|
||||||
<span class='ok'>* +-- feat/website-features @ 53a3176 <-- this edition</span>
|
|
||||||
<span class='dim'> |</span>
|
|
||||||
<span class='dim'> +--</span> <a href='/feat-website-design/'>feat/website-design</a> @ 2e26c80
|
|
||||||
<span class='dim'> |</span>
|
|
||||||
<span class='dim'> +--</span> <a href='/feat-website-views/'>feat/website-views</a> @ bbafc38</div>
|
|
||||||
<h2>what this looks like</h2>
|
|
||||||
<p>Website feature routes on top of sm-content.</p>
|
|
||||||
<p class="dim">Blank relational stub. Frozen Flask build for this tip is separate; this page only shows cascade relation and path root.</p>
|
|
||||||
|
|
||||||
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
|
|
@ -1,68 +0,0 @@
|
||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
||||||
<title>feat/website-views · cascade</title>
|
|
||||||
<style>
|
|
||||||
:root { color-scheme: dark; }
|
|
||||||
* { box-sizing: border-box; }
|
|
||||||
body {
|
|
||||||
margin: 0; padding: 1.5rem;
|
|
||||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
|
|
||||||
font-size: 14px; line-height: 1.45;
|
|
||||||
background: #0b0d10; color: #c8d0d8;
|
|
||||||
}
|
|
||||||
a { color: #7ec8ff; text-decoration: none; }
|
|
||||||
a:hover { text-decoration: underline; }
|
|
||||||
h1 { font-size: 1.15rem; font-weight: 600; margin: 0 0 0.75rem; color: #e8eef4; }
|
|
||||||
h2 { font-size: 1rem; font-weight: 600; margin: 1.25rem 0 0.5rem; color: #e8eef4; }
|
|
||||||
.meta { color: #8a96a3; margin-bottom: 1rem; }
|
|
||||||
.box {
|
|
||||||
border: 1px solid #2a3340; background: #12161c;
|
|
||||||
padding: 0.85rem 1rem; margin: 0.75rem 0; white-space: pre; overflow-x: auto;
|
|
||||||
}
|
|
||||||
.nav { margin: 0.5rem 0 1rem; }
|
|
||||||
.nav a { margin-right: 0.85rem; }
|
|
||||||
.note { color: #b8a46a; margin-top: 1rem; max-width: 72ch; white-space: normal; }
|
|
||||||
.ok { color: #7dcea0; }
|
|
||||||
.dim { color: #6a7682; }
|
|
||||||
ul.plain { padding-left: 1.2rem; }
|
|
||||||
li { margin: 0.25rem 0; }
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
|
|
||||||
<h1>edition: feat/website-views</h1>
|
|
||||||
<p class="meta">path <code>/feat-website-views/</code> · tip <code>bbafc38</code> · depends on <a href="/feat-website-design/"><code>feat/website-design</code></a></p>
|
|
||||||
<div class="nav">
|
|
||||||
<a href="/">home</a>
|
|
||||||
<a href="/main/">/main/</a>
|
|
||||||
<a href="/feat-sm-content/">/feat-sm-content/</a>
|
|
||||||
<a href="/feat-website-features/">/feat-website-features/</a>
|
|
||||||
<a href="/feat-website-design/">/feat-website-design/</a>
|
|
||||||
<a href="/feat-website-views/">/feat-website-views/</a>
|
|
||||||
</div>
|
|
||||||
<h2>ladder position</h2>
|
|
||||||
<div class="box">cascade ladder (path roots on dossiers.ngi only)
|
|
||||||
|
|
||||||
<a href='/main/'>main</a> @ d2fbd79
|
|
||||||
<span class='dim'> |</span>
|
|
||||||
<span class='dim'> +--</span> <a href='/feat-sm-content/'>feat/sm-content</a> @ 089ef1f
|
|
||||||
<span class='dim'> |</span>
|
|
||||||
<span class='dim'> +--</span> <a href='/feat-website-features/'>feat/website-features</a> @ 53a3176
|
|
||||||
<span class='dim'> |</span>
|
|
||||||
<span class='dim'> +--</span> <a href='/feat-website-design/'>feat/website-design</a> @ 2e26c80
|
|
||||||
<span class='dim'> |</span>
|
|
||||||
<span class='ok'>* +-- feat/website-views @ bbafc38 <-- this edition</span></div>
|
|
||||||
<h2>what this looks like</h2>
|
|
||||||
<p>Views/list layer on top of design (origin tip).</p>
|
|
||||||
<p class="dim">Blank relational stub. Frozen Flask build for this tip is separate; this page only shows cascade relation and path root.</p>
|
|
||||||
|
|
||||||
<p class="note"><strong>views gap vs dossiers.ngi-0.eu:</strong>
|
|
||||||
Frozen Flask on this tip exposes project_list / project_detail / dossier_list / blank.
|
|
||||||
It does not yet mirror the Next.js category-style views on dossiers.ngi-0.eu.
|
|
||||||
This path documents that gap; category-parity views remain open work.</p>
|
|
||||||
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
|
|
@ -1,69 +0,0 @@
|
||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
||||||
<title>cascade landing</title>
|
|
||||||
<style>
|
|
||||||
:root { color-scheme: dark; }
|
|
||||||
* { box-sizing: border-box; }
|
|
||||||
body {
|
|
||||||
margin: 0; padding: 1.5rem;
|
|
||||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
|
|
||||||
font-size: 14px; line-height: 1.45;
|
|
||||||
background: #0b0d10; color: #c8d0d8;
|
|
||||||
}
|
|
||||||
a { color: #7ec8ff; text-decoration: none; }
|
|
||||||
a:hover { text-decoration: underline; }
|
|
||||||
h1 { font-size: 1.15rem; font-weight: 600; margin: 0 0 0.75rem; color: #e8eef4; }
|
|
||||||
h2 { font-size: 1rem; font-weight: 600; margin: 1.25rem 0 0.5rem; color: #e8eef4; }
|
|
||||||
.meta { color: #8a96a3; margin-bottom: 1rem; }
|
|
||||||
.box {
|
|
||||||
border: 1px solid #2a3340; background: #12161c;
|
|
||||||
padding: 0.85rem 1rem; margin: 0.75rem 0; white-space: pre; overflow-x: auto;
|
|
||||||
}
|
|
||||||
.nav { margin: 0.5rem 0 1rem; }
|
|
||||||
.nav a { margin-right: 0.85rem; }
|
|
||||||
.note { color: #b8a46a; margin-top: 1rem; max-width: 72ch; white-space: normal; }
|
|
||||||
.ok { color: #7dcea0; }
|
|
||||||
.dim { color: #6a7682; }
|
|
||||||
ul.plain { padding-left: 1.2rem; }
|
|
||||||
li { margin: 0.25rem 0; }
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
|
|
||||||
<h1>tech-dossier cascade landing</h1>
|
|
||||||
<p class="meta">host: dossiers.ngi.hacktivism.ch · blank relational index · owned by koopa-admin-log</p>
|
|
||||||
<div class="nav">
|
|
||||||
<a href="/">/</a>
|
|
||||||
<a href="/main/">/main/</a>
|
|
||||||
<a href="/feat-sm-content/">/feat-sm-content/</a>
|
|
||||||
<a href="/feat-website-features/">/feat-website-features/</a>
|
|
||||||
<a href="/feat-website-design/">/feat-website-design/</a>
|
|
||||||
<a href="/feat-website-views/">/feat-website-views/</a>
|
|
||||||
</div>
|
|
||||||
<h2>relation to main</h2>
|
|
||||||
<div class="box">cascade ladder (path roots on dossiers.ngi only)
|
|
||||||
|
|
||||||
<a href='/main/'>main</a> @ d2fbd79
|
|
||||||
<span class='dim'> |</span>
|
|
||||||
<span class='dim'> +--</span> <a href='/feat-sm-content/'>feat/sm-content</a> @ 089ef1f
|
|
||||||
<span class='dim'> |</span>
|
|
||||||
<span class='dim'> +--</span> <a href='/feat-website-features/'>feat/website-features</a> @ 53a3176
|
|
||||||
<span class='dim'> |</span>
|
|
||||||
<span class='dim'> +--</span> <a href='/feat-website-design/'>feat/website-design</a> @ 2e26c80
|
|
||||||
<span class='dim'> |</span>
|
|
||||||
<span class='dim'> +--</span> <a href='/feat-website-views/'>feat/website-views</a> @ bbafc38</div>
|
|
||||||
<h2>editions</h2>
|
|
||||||
<ul class="plain">
|
|
||||||
<li><a href="/main/"><code>/main/</code></a> — <code>main</code> @ <code>d2fbd79</code> · base<br><span class="dim">Base freeze tip.</span></li>
|
|
||||||
<li><a href="/feat-sm-content/"><code>/feat-sm-content/</code></a> — <code>feat/sm-content</code> @ <code>089ef1f</code> · depends on <code>main</code><br><span class="dim">SM dossier prose content on top of main.</span></li>
|
|
||||||
<li><a href="/feat-website-features/"><code>/feat-website-features/</code></a> — <code>feat/website-features</code> @ <code>53a3176</code> · depends on <code>feat/sm-content</code><br><span class="dim">Website feature routes on top of sm-content.</span></li>
|
|
||||||
<li><a href="/feat-website-design/"><code>/feat-website-design/</code></a> — <code>feat/website-design</code> @ <code>2e26c80</code> · depends on <code>feat/website-features</code><br><span class="dim">Design/CSS layer on top of features.</span></li>
|
|
||||||
<li><a href="/feat-website-views/"><code>/feat-website-views/</code></a> — <code>feat/website-views</code> @ <code>bbafc38</code> · depends on <code>feat/website-design</code><br><span class="dim">Views/list layer on top of design (origin tip).</span></li>
|
|
||||||
</ul>
|
|
||||||
<p class="note">Each path is its own root for that branch edition. Single public host dossiers.ngi only.</p>
|
|
||||||
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
|
|
@ -1,64 +0,0 @@
|
||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
||||||
<title>main · cascade</title>
|
|
||||||
<style>
|
|
||||||
:root { color-scheme: dark; }
|
|
||||||
* { box-sizing: border-box; }
|
|
||||||
body {
|
|
||||||
margin: 0; padding: 1.5rem;
|
|
||||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
|
|
||||||
font-size: 14px; line-height: 1.45;
|
|
||||||
background: #0b0d10; color: #c8d0d8;
|
|
||||||
}
|
|
||||||
a { color: #7ec8ff; text-decoration: none; }
|
|
||||||
a:hover { text-decoration: underline; }
|
|
||||||
h1 { font-size: 1.15rem; font-weight: 600; margin: 0 0 0.75rem; color: #e8eef4; }
|
|
||||||
h2 { font-size: 1rem; font-weight: 600; margin: 1.25rem 0 0.5rem; color: #e8eef4; }
|
|
||||||
.meta { color: #8a96a3; margin-bottom: 1rem; }
|
|
||||||
.box {
|
|
||||||
border: 1px solid #2a3340; background: #12161c;
|
|
||||||
padding: 0.85rem 1rem; margin: 0.75rem 0; white-space: pre; overflow-x: auto;
|
|
||||||
}
|
|
||||||
.nav { margin: 0.5rem 0 1rem; }
|
|
||||||
.nav a { margin-right: 0.85rem; }
|
|
||||||
.note { color: #b8a46a; margin-top: 1rem; max-width: 72ch; white-space: normal; }
|
|
||||||
.ok { color: #7dcea0; }
|
|
||||||
.dim { color: #6a7682; }
|
|
||||||
ul.plain { padding-left: 1.2rem; }
|
|
||||||
li { margin: 0.25rem 0; }
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
|
|
||||||
<h1>edition: main</h1>
|
|
||||||
<p class="meta">path <code>/main/</code> · tip <code>d2fbd79</code> · depends on <span class='dim'>none (base)</span></p>
|
|
||||||
<div class="nav">
|
|
||||||
<a href="/">home</a>
|
|
||||||
<a href="/main/">/main/</a>
|
|
||||||
<a href="/feat-sm-content/">/feat-sm-content/</a>
|
|
||||||
<a href="/feat-website-features/">/feat-website-features/</a>
|
|
||||||
<a href="/feat-website-design/">/feat-website-design/</a>
|
|
||||||
<a href="/feat-website-views/">/feat-website-views/</a>
|
|
||||||
</div>
|
|
||||||
<h2>ladder position</h2>
|
|
||||||
<div class="box">cascade ladder (path roots on dossiers.ngi only)
|
|
||||||
|
|
||||||
<span class='ok'>* main @ d2fbd79 <-- this edition</span>
|
|
||||||
<span class='dim'> |</span>
|
|
||||||
<span class='dim'> +--</span> <a href='/feat-sm-content/'>feat/sm-content</a> @ 089ef1f
|
|
||||||
<span class='dim'> |</span>
|
|
||||||
<span class='dim'> +--</span> <a href='/feat-website-features/'>feat/website-features</a> @ 53a3176
|
|
||||||
<span class='dim'> |</span>
|
|
||||||
<span class='dim'> +--</span> <a href='/feat-website-design/'>feat/website-design</a> @ 2e26c80
|
|
||||||
<span class='dim'> |</span>
|
|
||||||
<span class='dim'> +--</span> <a href='/feat-website-views/'>feat/website-views</a> @ bbafc38</div>
|
|
||||||
<h2>what this looks like</h2>
|
|
||||||
<p>Base freeze tip.</p>
|
|
||||||
<p class="dim">Blank relational stub. Frozen Flask build for this tip is separate; this page only shows cascade relation and path root.</p>
|
|
||||||
|
|
||||||
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue