diff --git a/2026/2026-10-20--forgejo-host-ro-users.md b/2026/2026-10-20--forgejo-host-ro-users.md deleted file mode 100644 index e602ad3..0000000 --- a/2026/2026-10-20--forgejo-host-ro-users.md +++ /dev/null @@ -1,57 +0,0 @@ -# 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` diff --git a/AGENTS.md b/AGENTS.md index 8ffc768..ba7a565 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,12 +1,5 @@ # AGENTS.md -## §0 Secrets (HIGH — pointer) - -**Common SoT:** `$HOME/git/llm/llm-configs/operator-rules/secrets-policy.md` (`PATH[admin-secrets]`). -Secret **values** never enter chat/ctx as **input or output** (prompts, pasted tool results, commit subjects, admin-log prose). Handle via **scripts** only (disk `*.env`; report `STATUS=` / `KEY_PRESENT=` / paths) or else a **path-only local-model instruction**. Session stub: `$HOME/.grok/AGENTS.md` §0. Do not fork a second full copy here. - ---- - Session Must / Must not: `$HOME/.grok/AGENTS.md` only — do not duplicate. **Sync (must-sync trees / wenn Git allein nicht reicht):** siehe [SYNC.md](SYNC.md). diff --git a/configs/systemd/user/dossiers-ngi-branch-status.service b/configs/systemd/user/dossiers-ngi-branch-status.service index 971c93b..76ef361 100644 --- a/configs/systemd/user/dossiers-ngi-branch-status.service +++ b/configs/systemd/user/dossiers-ngi-branch-status.service @@ -14,7 +14,7 @@ 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) +# Rebuild only when .edition-tip differs / missing (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 diff --git a/scripts/dossiers-ngi/README.md b/scripts/dossiers-ngi/README.md index a288176..064b830 100644 --- a/scripts/dossiers-ngi/README.md +++ b/scripts/dossiers-ngi/README.md @@ -58,12 +58,9 @@ Per branch in JSON / sticky bar: | `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) | +| `vs_main_plus` / `vs_main_minus` | `git diff --numstat` tip vs `main` tip (sticky bar +grün/−rot; omitted on `main`) | -Rebuild runs **only when** the current origin tip is not already a successful freeze (`freeze_ok`; timer never forces). +Rebuild runs **only when** `.edition-tip` ≠ current tip (timer never forces). Each run also: `git fetch --prune`, `worktree prune`, drop stale `editions//` (gone remotes), purge leftover `tdpd-*` tmp dirs. @@ -74,23 +71,6 @@ 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: @@ -108,14 +88,13 @@ rsync -avz --exclude .DS_Store "$SITE/branch-status.json" "$WWW/branch-status.js `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 +main @ d2fbd79 · tip 2026-09-01 16:52 · build 8.4s · at 2026-09-30 15:23 +├── feat/sm-content @ … · tip YYYY-MM-DD HH:MM · … +│ └── … +└── project_template @ … ``` -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). +`tip` / `at` always include **date + HH:MM** (ISO already in JSON; clock-only was misleading across days). The bar is **viewport-centered** (max-width 45em, mobile-friendly). Poll every 30s **updates tip/build/at text in place** when the branch structure is unchanged (no full tree remount). Each name links to `/_cascade/use/` (cookie + 302 `/`). Caddy map: diff --git a/scripts/dossiers-ngi/branch-status-hourly.py b/scripts/dossiers-ngi/branch-status-hourly.py index 7b32e78..9bed2c3 100755 --- a/scripts/dossiers-ngi/branch-status-hourly.py +++ b/scripts/dossiers-ngi/branch-status-hourly.py @@ -235,11 +235,11 @@ def tip_recorded(editions: Path, slug: str) -> str: -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: +def diff_vs_main(repo: Path, main_tip: str, tip: str) -> tuple[int, int]: + """Insertions/deletions of tip vs main tip (git diff --numstat).""" + if not main_tip or not tip or main_tip == tip: return 0, 0 - cp = git(repo, "diff", "--numstat", a, b, check=False) + cp = git(repo, "diff", "--numstat", main_tip, tip, check=False) if cp.returncode != 0: return 0, 0 plus = minus = 0 @@ -258,17 +258,6 @@ def diff_numstat(repo: Path, a: str, b: str) -> tuple[int, int]: 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) @@ -301,42 +290,6 @@ def write_build_meta(editions: Path, slug: str, seconds: float) -> None: (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"} @@ -439,19 +392,13 @@ def run_freeze(work: Path, branch: str, slug: str) -> None: 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( + 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: @@ -460,10 +407,12 @@ def rebuild_edition(repo: Path, branch: str, tip: str, editions: Path) -> dict: 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]: + if ( + not force + and tip_recorded(editions, slug) == tip + and (dest / "index.html").is_file() + ): 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 @@ -475,12 +424,9 @@ def rebuild_edition(repo: Path, branch: str, tip: str, editions: Path) -> dict: 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, + "status": f"error:worktree:{cp.stderr.strip()[:200]}", "seconds": round(time.perf_counter() - t0, 3), } @@ -488,8 +434,6 @@ def rebuild_edition(repo: Path, branch: str, tip: str, editions: Path) -> dict: 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) @@ -497,8 +441,6 @@ def rebuild_edition(repo: Path, branch: str, tip: str, editions: Path) -> dict: ".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: @@ -516,16 +458,12 @@ def rebuild_edition(repo: Path, branch: str, tip: str, editions: Path) -> dict: 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, + "status": f"error:{type(exc).__name__}:{exc}", "seconds": round(time.perf_counter() - t0, 3), } finally: @@ -552,19 +490,10 @@ def sync_flask_alias(editions: Path, tips: dict[str, str]) -> None: 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", - ): + for name in (".edition-built-at", ".edition-build-seconds"): s = src / name if s.is_file(): shutil.copy2(s, dst / name) - else: - p = dst / name - if p.exists(): - p.unlink() inject_bar(dst) @@ -621,13 +550,15 @@ def build_payload(repo: Path, tips: dict[str, str], rebuild: bool) -> dict: 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) + main_tip = tips.get("main", "") + vs_main = { + name: diff_vs_main(repo, main_tip, tip) for name, tip in tips.items() + } branches_out = [] for row in rows: name = row["branch"] @@ -653,23 +584,11 @@ def build_payload(repo: Path, tips: dict[str, str], rebuild: bool) -> dict: 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) + # sticky bar: +grün / −rot vs main (omit on main itself) + if name != "main" and main_tip: + plus, minus = vs_main.get(name, (0, 0)) + entry["vs_main_plus"] = plus + entry["vs_main_minus"] = minus branches_out.append(entry) payload = { diff --git a/scripts/dossiers-ngi/inject-editions-bar.sh b/scripts/dossiers-ngi/inject-editions-bar.sh deleted file mode 100755 index f30790c..0000000 --- a/scripts/dossiers-ngi/inject-editions-bar.sh +++ /dev/null @@ -1,80 +0,0 @@ -#!/usr/bin/env bash -# Inject cascade-bar include into every editions/ 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"(]*>)", 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 diff --git a/scripts/dossiers-ngi/smoke-branch-tree.sh b/scripts/dossiers-ngi/smoke-branch-tree.sh index 7703693..6c6340b 100755 --- a/scripts/dossiers-ngi/smoke-branch-tree.sh +++ b/scripts/dossiers-ngi/smoke-branch-tree.sh @@ -62,8 +62,6 @@ 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 @@ -79,26 +77,6 @@ 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" # delete smoke branch on origin; fetch --prune drops remote-tracking ref cd "$CLONE" diff --git a/scripts/pepsi/add-mail-v6-static.sh b/scripts/pepsi/add-mail-v6-static.sh deleted file mode 100755 index 26b4014..0000000 --- a/scripts/pepsi/add-mail-v6-static.sh +++ /dev/null @@ -1,83 +0,0 @@ -#!/usr/bin/env bash -# Add dedicated mail IPv6 on koopa eno1 (not Tor SLAAC). -# Addr: 2a02:168:53a8::25/64 (hernani-dns KOOPA_WAN_AAAA) -# Keeps ipv6.method=auto so SLAAC (Tor) stays. Adds one static address. -# Freigabe: human this session. Run on koopa as root / sudo. -# Live: ip -6 addr add; persist: nmcli connection modify +ipv6.addresses -set -euo pipefail - -IFACE="${IFACE:-eno1}" -ADDR="${MAIL_V6_ADDR:-2a02:168:53a8::25}" -PREFIX="${MAIL_V6_PREFIX:-64}" -CIDR="${ADDR}/${PREFIX}" -CON="${NM_CON:-}" - -if [[ ${EUID:-$(id -u)} -ne 0 ]]; then - echo "STATUS=need_root HINT='sudo $0'" - exit 2 -fi - -echo "STATUS=start HOST=$(hostname -s) IFACE=$IFACE CIDR=$CIDR" - -if ! ip link show "$IFACE" >/dev/null 2>&1; then - echo "STATUS=no_iface IFACE=$IFACE" - exit 3 -fi - -# Resolve NM connection for IFACE if not set -if [[ -z "$CON" ]]; then - CON=$(nmcli -g NAME,DEVICE con show --active 2>/dev/null \ - | awk -F: -v d="$IFACE" '$2==d {print $1; exit}') - if [[ -z "$CON" ]]; then - CON=$(nmcli -g NAME,DEVICE con show 2>/dev/null \ - | awk -F: -v d="$IFACE" '$2==d {print $1; exit}') - fi -fi -if [[ -z "$CON" ]]; then - CON="$IFACE" -fi -echo "NM_CON=$CON" - -# Idempotent live address -if ip -6 addr show dev "$IFACE" | grep -qE "inet6[[:space:]]+${ADDR}/"; then - echo "LIVE=present ADDR=$ADDR" - LIVE_ADDED=0 -else - ip -6 addr add "$CIDR" dev "$IFACE" - echo "LIVE=added ADDR=$CIDR" - LIVE_ADDED=1 -fi - -# Persist via NetworkManager (append; do not replace method=auto) -EXISTING=$(nmcli -g ipv6.addresses con show "$CON" 2>/dev/null || true) -if echo "$EXISTING" | grep -qF "$ADDR"; then - echo "NM=present ADDR=$ADDR" - NM_ADDED=0 -else - nmcli con mod "$CON" +ipv6.addresses "$CIDR" - echo "NM=added ADDR=$CIDR" - NM_ADDED=1 - # Apply without full down/up if possible - if nmcli con up "$CON" >/dev/null 2>&1; then - echo "NM=reapplied CON=$CON" - else - echo "NM=reapply_warn HINT='nmcli con up $CON' (addr already live)" - fi -fi - -# Verify -if ip -6 addr show dev "$IFACE" | grep -qE "inet6[[:space:]]+${ADDR}/"; then - echo "VERIFY=ok ADDR=$ADDR" - echo "STATUS=done LIVE_ADDED=$LIVE_ADDED NM_ADDED=$NM_ADDED CIDR=$CIDR" - # Show Tor SLAAC still present (informational) - if ip -6 addr show dev "$IFACE" | grep -q '2a02:168:53a8:0:d584:9e34:13cf:15d6'; then - echo "TOR_SLAAC=present" - else - echo "TOR_SLAAC=absent_or_changed HINT=ok_if_privacy_temp_rotated" - fi - exit 0 -fi - -echo "STATUS=verify_fail ADDR=$ADDR" -ip -6 addr show dev "$IFACE" || true -exit 1 diff --git a/scripts/pepsi/ensure-mon-users.sh b/scripts/pepsi/ensure-mon-users.sh deleted file mode 100755 index 661edaa..0000000 --- a/scripts/pepsi/ensure-mon-users.sh +++ /dev/null @@ -1,57 +0,0 @@ -#!/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 diff --git a/scripts/pepsi/firewalld-pepsi-highports.sh b/scripts/pepsi/firewalld-pepsi-highports.sh deleted file mode 100755 index 1387fa1..0000000 --- a/scripts/pepsi/firewalld-pepsi-highports.sh +++ /dev/null @@ -1,66 +0,0 @@ -#!/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 diff --git a/scripts/pepsi/hostnet-pepsi-highports.sh b/scripts/pepsi/hostnet-pepsi-highports.sh deleted file mode 100755 index aeacc55..0000000 --- a/scripts/pepsi/hostnet-pepsi-highports.sh +++ /dev/null @@ -1,204 +0,0 @@ -#!/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 diff --git a/scripts/pepsi/todo631-pepsi-kreis.sh b/scripts/pepsi/todo631-pepsi-kreis.sh deleted file mode 100755 index cb5a2ba..0000000 --- a/scripts/pepsi/todo631-pepsi-kreis.sh +++ /dev/null @@ -1,100 +0,0 @@ -#!/usr/bin/env bash -# TODO=631: g0v mon-* Kreis-Test (local circle via pepsi TRUST_LOOPBACK / :25). -# Inject via koopa container loopback :25. Empfang = Maildir MARK grep. -# Real OS users (ensure-mon-users.sh): mon-smtp maildir dkim bounce local. -# No secrets. Freigabe-free (local inject only). -set -euo pipefail - -KOOPA_HOST="${KOOPA_HOST:-koopa}" -CONTAINER="${PEPSI_CONTAINER:-koopa-pepsi}" -OUTDIR="${HOME}/git/llm/llm-work-input" -MARK="${MARK:-$(date +%Y%m%dT%H%M%S%z)-kreis}" -USERS=(mon-smtp mon-maildir mon-dkim mon-bounce mon-local) -DRAIN_SEC="${DRAIN_SEC:-6}" - -echo "STATUS=start MARK=${MARK} CONTAINER=${CONTAINER} N=${#USERS[@]}" - -# --- preflight: users exist --- -for u in "${USERS[@]}"; do - ssh -o BatchMode=yes "${KOOPA_HOST}" \ - "podman exec ${CONTAINER} getent passwd ${u}" >/dev/null \ - || { echo "STATUS=fail PREFLIGHT user=${u} missing"; exit 2; } -done -echo "STATUS=preflight USERS_OK=${#USERS[@]}" - -# --- send circle: each → next --- -SEND_PY=$(mktemp) -trap 'rm -f "${SEND_PY}"' EXIT -{ - echo 'import smtplib, time, sys' - echo 'from email.message import EmailMessage' - echo "MARK = ${MARK@Q}" - echo -n 'USERS = [' - for u in "${USERS[@]}"; do printf '%s,' "${u@Q}"; done - echo ']' - cat <<'PY' -ok = fail = 0 -n = len(USERS) -for i, u in enumerate(USERS): - to_u = USERS[(i + 1) % n] - frm = f"{u}@g0v.ch" - to = f"{to_u}@g0v.ch" - msg = EmailMessage() - msg["From"] = frm - msg["To"] = to - msg["Subject"] = f"[pepsi-kreis] TODO631 {i+1}/{n} {MARK}" - msg["Message-ID"] = f""${SEND_PY}" - -ssh -o BatchMode=yes "${KOOPA_HOST}" "podman exec -i ${CONTAINER} python3 -" <"${SEND_PY}" -echo "MARK=${MARK}" >"${OUTDIR}/todo631-kreis.stamp" - -sleep "${DRAIN_SEC}" - -# --- Maildir HIT per recipient (next-hop of each sender) --- -OUT="${OUTDIR}/todo631-kreis-test.txt" -{ - echo "STATUS=ok MARK=kreis-test TODO=631 TS=$(date -Iseconds)" - echo "MARK_PROBE=${MARK} USERS=$(IFS=,; echo "${USERS[*]}")" - echo "SEND=via container :25 loopback" - echo "EXPECT=each user MARK_HITS>=1 (circle next-hop)" - echo "NOTE_vacation=PONG may also land; SPF-fail path skips vacation" -} >"${OUT}" - -HIT_TOTAL=0 -MISS=0 -for u in "${USERS[@]}"; do - hits=$(ssh -o BatchMode=yes "${KOOPA_HOST}" \ - "podman exec ${CONTAINER} bash -c 'grep -lF ${MARK@Q} /home/${u}/Maildir/new/* /home/${u}/Maildir/cur/* 2>/dev/null | wc -l'" \ - | tr -d '[:space:]') - hits=${hits:-0} - echo "HIT_${u}=${hits}" | tee -a "${OUT}" - if [[ "${hits}" -ge 1 ]]; then - HIT_TOTAL=$((HIT_TOTAL + 1)) - else - MISS=$((MISS + 1)) - fi -done - -if [[ "${MISS}" -eq 0 ]]; then - echo "STATUS=PASS HIT_USERS=${HIT_TOTAL}/5 MARK=${MARK}" | tee -a "${OUT}" - exit 0 -fi -echo "STATUS=PARTIAL HIT_USERS=${HIT_TOTAL}/5 MISS=${MISS} MARK=${MARK}" | tee -a "${OUT}" -exit 1 diff --git a/sites/dossiers.ngi.hacktivism.ch/_cascade/bar.css b/sites/dossiers.ngi.hacktivism.ch/_cascade/bar.css index d0a638e..ae291e2 100644 --- a/sites/dossiers.ngi.hacktivism.ch/_cascade/bar.css +++ b/sites/dossiers.ngi.hacktivism.ch/_cascade/bar.css @@ -1,47 +1,37 @@ -html { - padding-top: var(--cascade-pad, 0px); -} .cascade-bar { - position: fixed; + position: sticky; top: 0; - left: 0; - right: 0; - z-index: 1000; + z-index: 10; display: flex; justify-content: center; box-sizing: border-box; - width: 100%; - max-width: none; - margin: 0; - padding: 0.2rem 0 0.25rem; + width: 100vw; + max-width: 100vw; + margin: -1.25rem calc(50% - 50vw) 1rem; + padding: 0.55rem 0.75rem 0.65rem; 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; + font-size: 12px; + line-height: 1.45; } .cascade-tree { - /* 45rem = site #navigation/#content 45em at root 16px (bar font is 11px) */ width: 100%; - max-width: 45rem; + max-width: 45em; margin: 0 auto; box-sizing: border-box; } .cascade-tree-lines { - display: flex; - flex-direction: column; + white-space: pre; 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; + display: block; color: #7ec8ff; text-decoration: none; - padding: 0 0.15rem; + padding: 0.05rem 0.15rem; border-radius: 2px; } a.cascade-tree-row:hover { @@ -60,51 +50,33 @@ a.cascade-tree-row.is-active:hover { 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; +.cascade-bar .tip { color: #8a96a3; + white-space: pre-wrap; +} +.cascade-bar .cascade-default { + color: #6a9080; } .cascade-timing { - display: none; + margin-top: 0.35rem; + color: #6a7888; + font-size: 11px; } -@media (max-width: 45rem) { +@media (max-width: 40em) { .cascade-bar { - padding: 0.15rem 0.4rem 0.2rem; - font-size: 10px; + margin-top: -1rem; + padding: 0.45rem 0.5rem 0.55rem; + font-size: 11px; } .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-timing { + font-size: 10px; } } -.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; @@ -113,8 +85,3 @@ a.cascade-tree-row.is-active:hover { color: #f85149; font-weight: 600; } -.cascade-bar .diff-loc, -.cascade-bar .diff-loc-unit { - color: #8a96a3; - font-weight: 600; -} diff --git a/sites/dossiers.ngi.hacktivism.ch/_cascade/bar.js b/sites/dossiers.ngi.hacktivism.ch/_cascade/bar.js index 8fe4765..a4f4ea4 100644 --- a/sites/dossiers.ngi.hacktivism.ch/_cascade/bar.js +++ b/sites/dossiers.ngi.hacktivism.ch/_cascade/bar.js @@ -1,19 +1,5 @@ (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=([^;]*)/); @@ -33,14 +19,13 @@ .replace(/"/g, """); } - /** ISO → "sep30 16:12" (3-letter English month + DD + HH:MM). */ + /** ISO → "YYYY-MM-DD HH:MM" (date required; clock-only was misleading across days). */ 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]; - } + var m = String(iso).match( + /^(\d{4}-\d{2}-\d{2})T(\d{2}:\d{2})(?::\d{2})?/ + ); + if (m) return m[1] + " " + m[2]; return String(iso).slice(0, 16).replace("T", " "); } @@ -52,92 +37,57 @@ 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 ( - '' + - (ok ? "✓" : "✗") + - "" - ); - } - - function plusHtml(row) { - if (row.vs_parent_plus != null) { - var p = Number(row.vs_parent_plus); - if (!isFinite(p)) return ""; - return '+' + esc(String(p)) + ""; - } - if (row.loc != null) { - var n = Number(row.loc); - if (!isFinite(n)) return ""; - return '' + esc(String(n)) + ""; - } - return ""; - } - - function minusHtml(row) { - if (row.vs_parent_minus != null) { - var m = Number(row.vs_parent_minus); - if (!isFinite(m)) return ""; - return '−' + esc(String(m)) + ""; - } - if (row.loc != null) { - return 'LOC'; - } - return ""; - } - - function setText(el, text) { - if (!el) return; - var t = text || ""; - if (el.textContent !== t) el.textContent = t; - } - - function rowTitle(row) { + function metaHtml(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 builtAt = row.built_at || ""; + var buildSec = row.build_seconds; + var parts = []; + if (tip) parts.push(esc("@" + tip)); + if (tipTime) parts.push(esc("tip " + shortTime(tipTime))); + if (buildSec != null && buildSec !== "") + parts.push(esc("build " + fmtBuildSec(buildSec))); + if (builtAt) parts.push(esc("at " + shortTime(builtAt))); + // vs main: +grün / −rot (omit on main / when both zero absent) + var plus = row.vs_main_plus; + var minus = row.vs_main_minus; + if (plus != null || minus != null) { + var p = plus == null ? 0 : Number(plus); + var m = minus == null ? 0 : Number(minus); + if (isFinite(p) && isFinite(m) && (p > 0 || m > 0 || row.branch !== "main")) { + parts.push( + '+' + + esc(String(p)) + + ' −' + + esc(String(m)) + + "" + ); + } + } + return parts.join(" · "); + } + + function rowTitle(row, buildSec) { + var tip = row.tip || ""; + var tipTime = row.tip_time || ""; + var plus = row.vs_main_plus; + var minus = row.vs_main_minus; + var vs = ""; + if (plus != null || minus != null) { + vs = + " vs main +" + + String(plus == null ? 0 : plus) + + "/−" + + String(minus == null ? 0 : minus); } - var built = builtText(row); return ( "Switch build to " + (row.branch || "") + (tip ? " @ " + tip : "") + - (tipTime ? " tip=" + shortTime(tipTime) : "") + - (built ? " build=" + built : "") + - " " + - freezeLabel(row) + + (tipTime ? " tip=" + tipTime : "") + + (buildSec != null && buildSec !== "" + ? " build=" + fmtBuildSec(buildSec) + : "") + vs ); } @@ -159,17 +109,6 @@ 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++) { @@ -213,27 +152,28 @@ var a = rows[i]; if (a.getAttribute("data-edition") !== (row.edition || "")) return false; if (a.getAttribute("data-branch") !== (row.branch || "")) return false; + var tipHtml = metaHtml(row); + var tipEl = a.querySelector(".tip"); + var wantTip = tipHtml ? " " + tipHtml : ""; + if (tipEl) { + if (tipEl.innerHTML !== wantTip) tipEl.innerHTML = wantTip; + } else if (wantTip) { + tipEl = document.createElement("span"); + tipEl.className = "tip"; + tipEl.innerHTML = wantTip; + a.appendChild(tipEl); + } + var defEl = a.querySelector(".cascade-default"); 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; + if (isDefault && !defEl) { + defEl = document.createElement("span"); + defEl.className = "cascade-default"; + defEl.textContent = " · default"; + a.appendChild(defEl); + } else if (!isDefault && defEl) { + defEl.parentNode.removeChild(defEl); } - 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)); + a.setAttribute("title", rowTitle(row, row.build_seconds)); } var timingEl = mount.querySelector(".cascade-timing"); var t = timingText(timing, list.length); @@ -248,7 +188,6 @@ timingEl.parentNode.removeChild(timingEl); } markActive(mount, cookieEdition()); - pinBar(t); return true; } @@ -258,7 +197,6 @@ 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)) { @@ -272,9 +210,20 @@ var row = list[i]; var edition = row.edition || ""; var branch = row.branch || ""; + var tip = row.tip || ""; var prefix = row.prefix || ""; var parent = row.parent || ""; + var tipTime = row.tip_time || ""; + var buildSec = row.build_seconds; var isDefault = branch === defaultBranch; + var tipHtml = metaHtml(row); + var label = + esc(prefix) + + "" + + esc(branch) + + "" + + (tipHtml ? ' ' + tipHtml + "" : "") + + (isDefault ? ' · default' : ""); html.push( '' + - freezeMarkHtml(row) + - '' + - esc(prefix) + - "" + - esc(branch) + - "" + - '' + - esc(shortTime(row.tip_time || "")) + - "" + - '' + - esc(builtText(row)) + - "" + - '' + - plusHtml(row) + - "" + - '' + - minusHtml(row) + - "" + + label + "" ); } @@ -317,7 +249,6 @@ mount.innerHTML = html.join(""); mount.setAttribute("data-structure", structureKey(list)); markActive(mount, cookieEdition()); - pinBar(t); } function loadStatus(soft) { @@ -333,10 +264,9 @@ .catch(function () { if (!soft && mount && !mount.querySelector("a[data-edition]")) { mount.innerHTML = - 'flask' + + 'flask' + ' (branch-status unavailable)'; markActive(mount, cookieEdition()); - pinBar(); } }); } @@ -345,7 +275,4 @@ setInterval(function () { loadStatus(true); }, POLL_MS); - window.addEventListener("resize", function () { - pinBar(); - }); })(); diff --git a/sites/dossiers.ngi.hacktivism.ch/_cascade/index.html b/sites/dossiers.ngi.hacktivism.ch/_cascade/index.html deleted file mode 100644 index 036ead3..0000000 --- a/sites/dossiers.ngi.hacktivism.ch/_cascade/index.html +++ /dev/null @@ -1,16 +0,0 @@ - - - - - -cascade editions - - - - -

Stuck on an edition without a bar? Pick a build above, or reset to flask.

- - - diff --git a/sites/dossiers.ngi.hacktivism.ch/cascade/_cascade/bar.css b/sites/dossiers.ngi.hacktivism.ch/cascade/_cascade/bar.css index d0a638e..ae291e2 100644 --- a/sites/dossiers.ngi.hacktivism.ch/cascade/_cascade/bar.css +++ b/sites/dossiers.ngi.hacktivism.ch/cascade/_cascade/bar.css @@ -1,47 +1,37 @@ -html { - padding-top: var(--cascade-pad, 0px); -} .cascade-bar { - position: fixed; + position: sticky; top: 0; - left: 0; - right: 0; - z-index: 1000; + z-index: 10; display: flex; justify-content: center; box-sizing: border-box; - width: 100%; - max-width: none; - margin: 0; - padding: 0.2rem 0 0.25rem; + width: 100vw; + max-width: 100vw; + margin: -1.25rem calc(50% - 50vw) 1rem; + padding: 0.55rem 0.75rem 0.65rem; 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; + font-size: 12px; + line-height: 1.45; } .cascade-tree { - /* 45rem = site #navigation/#content 45em at root 16px (bar font is 11px) */ width: 100%; - max-width: 45rem; + max-width: 45em; margin: 0 auto; box-sizing: border-box; } .cascade-tree-lines { - display: flex; - flex-direction: column; + white-space: pre; 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; + display: block; color: #7ec8ff; text-decoration: none; - padding: 0 0.15rem; + padding: 0.05rem 0.15rem; border-radius: 2px; } a.cascade-tree-row:hover { @@ -60,51 +50,33 @@ a.cascade-tree-row.is-active:hover { 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; +.cascade-bar .tip { color: #8a96a3; + white-space: pre-wrap; +} +.cascade-bar .cascade-default { + color: #6a9080; } .cascade-timing { - display: none; + margin-top: 0.35rem; + color: #6a7888; + font-size: 11px; } -@media (max-width: 45rem) { +@media (max-width: 40em) { .cascade-bar { - padding: 0.15rem 0.4rem 0.2rem; - font-size: 10px; + margin-top: -1rem; + padding: 0.45rem 0.5rem 0.55rem; + font-size: 11px; } .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-timing { + font-size: 10px; } } -.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; @@ -113,8 +85,3 @@ a.cascade-tree-row.is-active:hover { color: #f85149; font-weight: 600; } -.cascade-bar .diff-loc, -.cascade-bar .diff-loc-unit { - color: #8a96a3; - font-weight: 600; -} diff --git a/sites/dossiers.ngi.hacktivism.ch/cascade/_cascade/bar.js b/sites/dossiers.ngi.hacktivism.ch/cascade/_cascade/bar.js index 8fe4765..a4f4ea4 100644 --- a/sites/dossiers.ngi.hacktivism.ch/cascade/_cascade/bar.js +++ b/sites/dossiers.ngi.hacktivism.ch/cascade/_cascade/bar.js @@ -1,19 +1,5 @@ (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=([^;]*)/); @@ -33,14 +19,13 @@ .replace(/"/g, """); } - /** ISO → "sep30 16:12" (3-letter English month + DD + HH:MM). */ + /** ISO → "YYYY-MM-DD HH:MM" (date required; clock-only was misleading across days). */ 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]; - } + var m = String(iso).match( + /^(\d{4}-\d{2}-\d{2})T(\d{2}:\d{2})(?::\d{2})?/ + ); + if (m) return m[1] + " " + m[2]; return String(iso).slice(0, 16).replace("T", " "); } @@ -52,92 +37,57 @@ 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 ( - '' + - (ok ? "✓" : "✗") + - "" - ); - } - - function plusHtml(row) { - if (row.vs_parent_plus != null) { - var p = Number(row.vs_parent_plus); - if (!isFinite(p)) return ""; - return '+' + esc(String(p)) + ""; - } - if (row.loc != null) { - var n = Number(row.loc); - if (!isFinite(n)) return ""; - return '' + esc(String(n)) + ""; - } - return ""; - } - - function minusHtml(row) { - if (row.vs_parent_minus != null) { - var m = Number(row.vs_parent_minus); - if (!isFinite(m)) return ""; - return '−' + esc(String(m)) + ""; - } - if (row.loc != null) { - return 'LOC'; - } - return ""; - } - - function setText(el, text) { - if (!el) return; - var t = text || ""; - if (el.textContent !== t) el.textContent = t; - } - - function rowTitle(row) { + function metaHtml(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 builtAt = row.built_at || ""; + var buildSec = row.build_seconds; + var parts = []; + if (tip) parts.push(esc("@" + tip)); + if (tipTime) parts.push(esc("tip " + shortTime(tipTime))); + if (buildSec != null && buildSec !== "") + parts.push(esc("build " + fmtBuildSec(buildSec))); + if (builtAt) parts.push(esc("at " + shortTime(builtAt))); + // vs main: +grün / −rot (omit on main / when both zero absent) + var plus = row.vs_main_plus; + var minus = row.vs_main_minus; + if (plus != null || minus != null) { + var p = plus == null ? 0 : Number(plus); + var m = minus == null ? 0 : Number(minus); + if (isFinite(p) && isFinite(m) && (p > 0 || m > 0 || row.branch !== "main")) { + parts.push( + '+' + + esc(String(p)) + + ' −' + + esc(String(m)) + + "" + ); + } + } + return parts.join(" · "); + } + + function rowTitle(row, buildSec) { + var tip = row.tip || ""; + var tipTime = row.tip_time || ""; + var plus = row.vs_main_plus; + var minus = row.vs_main_minus; + var vs = ""; + if (plus != null || minus != null) { + vs = + " vs main +" + + String(plus == null ? 0 : plus) + + "/−" + + String(minus == null ? 0 : minus); } - var built = builtText(row); return ( "Switch build to " + (row.branch || "") + (tip ? " @ " + tip : "") + - (tipTime ? " tip=" + shortTime(tipTime) : "") + - (built ? " build=" + built : "") + - " " + - freezeLabel(row) + + (tipTime ? " tip=" + tipTime : "") + + (buildSec != null && buildSec !== "" + ? " build=" + fmtBuildSec(buildSec) + : "") + vs ); } @@ -159,17 +109,6 @@ 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++) { @@ -213,27 +152,28 @@ var a = rows[i]; if (a.getAttribute("data-edition") !== (row.edition || "")) return false; if (a.getAttribute("data-branch") !== (row.branch || "")) return false; + var tipHtml = metaHtml(row); + var tipEl = a.querySelector(".tip"); + var wantTip = tipHtml ? " " + tipHtml : ""; + if (tipEl) { + if (tipEl.innerHTML !== wantTip) tipEl.innerHTML = wantTip; + } else if (wantTip) { + tipEl = document.createElement("span"); + tipEl.className = "tip"; + tipEl.innerHTML = wantTip; + a.appendChild(tipEl); + } + var defEl = a.querySelector(".cascade-default"); 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; + if (isDefault && !defEl) { + defEl = document.createElement("span"); + defEl.className = "cascade-default"; + defEl.textContent = " · default"; + a.appendChild(defEl); + } else if (!isDefault && defEl) { + defEl.parentNode.removeChild(defEl); } - 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)); + a.setAttribute("title", rowTitle(row, row.build_seconds)); } var timingEl = mount.querySelector(".cascade-timing"); var t = timingText(timing, list.length); @@ -248,7 +188,6 @@ timingEl.parentNode.removeChild(timingEl); } markActive(mount, cookieEdition()); - pinBar(t); return true; } @@ -258,7 +197,6 @@ 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)) { @@ -272,9 +210,20 @@ var row = list[i]; var edition = row.edition || ""; var branch = row.branch || ""; + var tip = row.tip || ""; var prefix = row.prefix || ""; var parent = row.parent || ""; + var tipTime = row.tip_time || ""; + var buildSec = row.build_seconds; var isDefault = branch === defaultBranch; + var tipHtml = metaHtml(row); + var label = + esc(prefix) + + "" + + esc(branch) + + "" + + (tipHtml ? ' ' + tipHtml + "" : "") + + (isDefault ? ' · default' : ""); html.push( '' + - freezeMarkHtml(row) + - '' + - esc(prefix) + - "" + - esc(branch) + - "" + - '' + - esc(shortTime(row.tip_time || "")) + - "" + - '' + - esc(builtText(row)) + - "" + - '' + - plusHtml(row) + - "" + - '' + - minusHtml(row) + - "" + + label + "" ); } @@ -317,7 +249,6 @@ mount.innerHTML = html.join(""); mount.setAttribute("data-structure", structureKey(list)); markActive(mount, cookieEdition()); - pinBar(t); } function loadStatus(soft) { @@ -333,10 +264,9 @@ .catch(function () { if (!soft && mount && !mount.querySelector("a[data-edition]")) { mount.innerHTML = - 'flask' + + 'flask' + ' (branch-status unavailable)'; markActive(mount, cookieEdition()); - pinBar(); } }); } @@ -345,7 +275,4 @@ setInterval(function () { loadStatus(true); }, POLL_MS); - window.addEventListener("resize", function () { - pinBar(); - }); })(); diff --git a/sites/dossiers.ngi.hacktivism.ch/cascade/_cascade/index.html b/sites/dossiers.ngi.hacktivism.ch/cascade/_cascade/index.html deleted file mode 100644 index 036ead3..0000000 --- a/sites/dossiers.ngi.hacktivism.ch/cascade/_cascade/index.html +++ /dev/null @@ -1,16 +0,0 @@ - - - - - -cascade editions - - - - -

Stuck on an edition without a bar? Pick a build above, or reset to flask.

- - -