Compare commits
10 commits
31af42538a
...
750aa819e9
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
750aa819e9 | ||
|
|
7880893181 | ||
|
|
d3948e970e | ||
|
|
ac73624909 | ||
|
|
3198e12591 | ||
|
|
261c25c87f | ||
|
|
7397cddb83 | ||
|
|
da6f384399 | ||
|
|
52e5c1d328 | ||
|
|
27c7f4e37e |
18 changed files with 1260 additions and 238 deletions
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`
|
||||||
|
|
@ -1,5 +1,12 @@
|
||||||
# AGENTS.md
|
# 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.
|
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).
|
**Sync (must-sync trees / wenn Git allein nicht reicht):** siehe [SYNC.md](SYNC.md).
|
||||||
|
|
|
||||||
|
|
@ -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=TDPD_ROOT=%h/git/src/ngi/tech-dossier-project-data
|
||||||
Environment=OUT=/var/www/monitoring-sites/dossiers.ngi.hacktivism.ch/branch-status.json
|
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
|
Environment=EDITIONS_ROOT=/var/www/monitoring-sites/dossiers.ngi.hacktivism.ch/editions
|
||||||
# Rebuild only when .edition-tip differs / missing (never force from timer)
|
# Rebuild only when current tip freeze_ok is not already true (never force from timer)
|
||||||
Environment=REBUILD=1
|
Environment=REBUILD=1
|
||||||
EnvironmentFile=-%h/.config/dossiers-ngi/branch-status.env
|
EnvironmentFile=-%h/.config/dossiers-ngi/branch-status.env
|
||||||
# flock -n: second fire while busy exits 0 without stacking garbage
|
# flock -n: second fire while busy exits 0 without stacking garbage
|
||||||
|
|
|
||||||
|
|
@ -58,9 +58,12 @@ Per branch in JSON / sticky bar:
|
||||||
| `tip_time` | committer time of tip (`%cI`) |
|
| `tip_time` | committer time of tip (`%cI`) |
|
||||||
| `built_at` | when this edition freeze was written |
|
| `built_at` | when this edition freeze was written |
|
||||||
| `build_seconds` | wall time of that freeze |
|
| `build_seconds` | wall time of that freeze |
|
||||||
| `vs_main_plus` / `vs_main_minus` | `git diff --numstat` tip vs `main` tip (sticky bar +grün/−rot; omitted on `main`) |
|
| `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** `.edition-tip` ≠ current tip (timer never forces).
|
Rebuild runs **only when** the current origin tip is not already a successful freeze (`freeze_ok`; timer never forces).
|
||||||
Each run also: `git fetch --prune`, `worktree prune`, drop stale
|
Each run also: `git fetch --prune`, `worktree prune`, drop stale
|
||||||
`editions/<slug>/` (gone remotes), purge leftover `tdpd-*` tmp dirs.
|
`editions/<slug>/` (gone remotes), purge leftover `tdpd-*` tmp dirs.
|
||||||
|
|
||||||
|
|
@ -71,6 +74,23 @@ bash scripts/dossiers-ngi/smoke-branch-tree.sh
|
||||||
# → SMOKE_OK (temp origin + smoke/temp-sibling deleted)
|
# → 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)
|
## Deploy landing (Freigabe)
|
||||||
|
|
||||||
Do **not** `--delete` the whole www root. Sync:
|
Do **not** `--delete` the whole www root. Sync:
|
||||||
|
|
@ -88,13 +108,14 @@ rsync -avz --exclude .DS_Store "$SITE/branch-status.json" "$WWW/branch-status.js
|
||||||
`bar.js` fetches `/branch-status.json` and draws:
|
`bar.js` fetches `/branch-status.json` and draws:
|
||||||
|
|
||||||
```
|
```
|
||||||
main @ d2fbd79 · tip 2026-09-01 16:52 · build 8.4s · at 2026-09-30 15:23
|
feature/nl-net-injestion-injestor-update … N LOC
|
||||||
├── feat/sm-content @ … · tip YYYY-MM-DD HH:MM · …
|
main … N LOC
|
||||||
│ └── …
|
├── feat/sm-content … +N −M # vs main
|
||||||
└── project_template @ …
|
│ └── feat/website-features … +N −M # vs sm-content
|
||||||
|
└── project_template … +N −M # vs main
|
||||||
```
|
```
|
||||||
|
|
||||||
`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).
|
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:
|
Each name links to `/_cascade/use/<edition>` (cookie + 302 `/`). Caddy map:
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -235,11 +235,11 @@ def tip_recorded(editions: Path, slug: str) -> str:
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def diff_vs_main(repo: Path, main_tip: str, tip: str) -> tuple[int, int]:
|
def diff_numstat(repo: Path, a: str, b: str) -> tuple[int, int]:
|
||||||
"""Insertions/deletions of tip vs main tip (git diff --numstat)."""
|
"""Insertions/deletions of b vs a (git diff --numstat)."""
|
||||||
if not main_tip or not tip or main_tip == tip:
|
if not a or not b or a == b:
|
||||||
return 0, 0
|
return 0, 0
|
||||||
cp = git(repo, "diff", "--numstat", main_tip, tip, check=False)
|
cp = git(repo, "diff", "--numstat", a, b, check=False)
|
||||||
if cp.returncode != 0:
|
if cp.returncode != 0:
|
||||||
return 0, 0
|
return 0, 0
|
||||||
plus = minus = 0
|
plus = minus = 0
|
||||||
|
|
@ -258,6 +258,17 @@ def diff_vs_main(repo: Path, main_tip: str, tip: str) -> tuple[int, int]:
|
||||||
return plus, minus
|
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:
|
def tip_commit_time(repo: Path, tip: str) -> str:
|
||||||
"""Committer ISO time of tip (empty on failure)."""
|
"""Committer ISO time of tip (empty on failure)."""
|
||||||
cp = git(repo, "log", "-1", "--format=%cI", tip, check=False)
|
cp = git(repo, "log", "-1", "--format=%cI", tip, check=False)
|
||||||
|
|
@ -290,6 +301,42 @@ def write_build_meta(editions: Path, slug: str, seconds: float) -> None:
|
||||||
(dest / ".edition-build-seconds").write_text(f"{seconds:.3f}\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]:
|
def cleanup_stale_editions(editions: Path, tips: dict[str, str]) -> list[str]:
|
||||||
"""Drop edition dirs whose branch tip is gone (keep flask alias)."""
|
"""Drop edition dirs whose branch tip is gone (keep flask alias)."""
|
||||||
keep = {edition_slug(b) for b in tips} | {"flask"}
|
keep = {edition_slug(b) for b in tips} | {"flask"}
|
||||||
|
|
@ -392,13 +439,19 @@ def run_freeze(work: Path, branch: str, slug: str) -> None:
|
||||||
|
|
||||||
def inject_bar(edition_dir: Path) -> None:
|
def inject_bar(edition_dir: Path) -> None:
|
||||||
if not INJECT.is_file():
|
if not INJECT.is_file():
|
||||||
|
print(f"WARN: inject script missing: {INJECT}", file=sys.stderr)
|
||||||
return
|
return
|
||||||
subprocess.run(
|
cp = subprocess.run(
|
||||||
[sys.executable, str(INJECT), str(edition_dir)],
|
[sys.executable, str(INJECT), str(edition_dir)],
|
||||||
check=False,
|
check=False,
|
||||||
capture_output=True,
|
capture_output=True,
|
||||||
text=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:
|
def rebuild_edition(repo: Path, branch: str, tip: str, editions: Path) -> dict:
|
||||||
|
|
@ -407,12 +460,10 @@ def rebuild_edition(repo: Path, branch: str, tip: str, editions: Path) -> dict:
|
||||||
dest = editions / slug
|
dest = editions / slug
|
||||||
t0 = time.perf_counter()
|
t0 = time.perf_counter()
|
||||||
force = os.environ.get("FORCE_REBUILD", "").strip() in ("1", "true", "yes")
|
force = os.environ.get("FORCE_REBUILD", "").strip() in ("1", "true", "yes")
|
||||||
if (
|
if not force and freeze_ok_for(editions, slug, tip)[0]:
|
||||||
not force
|
|
||||||
and tip_recorded(editions, slug) == tip
|
|
||||||
and (dest / "index.html").is_file()
|
|
||||||
):
|
|
||||||
inject_bar(dest)
|
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)}
|
return {"status": "skipped", "seconds": round(time.perf_counter() - t0, 3)}
|
||||||
|
|
||||||
wt: Path | None = None
|
wt: Path | None = None
|
||||||
|
|
@ -424,9 +475,12 @@ def rebuild_edition(repo: Path, branch: str, tip: str, editions: Path) -> dict:
|
||||||
git(repo, "worktree", "prune", check=False)
|
git(repo, "worktree", "prune", check=False)
|
||||||
cp = git(repo, "worktree", "add", "--detach", str(wt), tip, check=False)
|
cp = git(repo, "worktree", "add", "--detach", str(wt), tip, check=False)
|
||||||
if cp.returncode != 0:
|
if cp.returncode != 0:
|
||||||
|
st = f"error:worktree:{cp.stderr.strip()[:200]}"
|
||||||
ensure_placeholder(editions, slug, tip)
|
ensure_placeholder(editions, slug, tip)
|
||||||
|
write_freeze_status(editions, slug, False, st)
|
||||||
|
inject_bar(dest)
|
||||||
return {
|
return {
|
||||||
"status": f"error:worktree:{cp.stderr.strip()[:200]}",
|
"status": st,
|
||||||
"seconds": round(time.perf_counter() - t0, 3),
|
"seconds": round(time.perf_counter() - t0, 3),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -434,6 +488,8 @@ def rebuild_edition(repo: Path, branch: str, tip: str, editions: Path) -> dict:
|
||||||
build = wt / "website" / "build"
|
build = wt / "website" / "build"
|
||||||
if not build.is_dir():
|
if not build.is_dir():
|
||||||
ensure_placeholder(editions, slug, tip)
|
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)}
|
return {"status": "error:no-build", "seconds": round(time.perf_counter() - t0, 3)}
|
||||||
|
|
||||||
dest.mkdir(parents=True, exist_ok=True)
|
dest.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
@ -441,6 +497,8 @@ def rebuild_edition(repo: Path, branch: str, tip: str, editions: Path) -> dict:
|
||||||
".edition-tip",
|
".edition-tip",
|
||||||
".edition-built-at",
|
".edition-built-at",
|
||||||
".edition-build-seconds",
|
".edition-build-seconds",
|
||||||
|
".edition-freeze-ok",
|
||||||
|
".edition-freeze-error",
|
||||||
}
|
}
|
||||||
for child in list(dest.iterdir()):
|
for child in list(dest.iterdir()):
|
||||||
if child.name in keep_meta:
|
if child.name in keep_meta:
|
||||||
|
|
@ -458,12 +516,16 @@ def rebuild_edition(repo: Path, branch: str, tip: str, editions: Path) -> dict:
|
||||||
secs = round(time.perf_counter() - t0, 3)
|
secs = round(time.perf_counter() - t0, 3)
|
||||||
(dest / ".edition-tip").write_text(tip + "\n", encoding="utf-8")
|
(dest / ".edition-tip").write_text(tip + "\n", encoding="utf-8")
|
||||||
write_build_meta(editions, slug, secs)
|
write_build_meta(editions, slug, secs)
|
||||||
|
write_freeze_status(editions, slug, True)
|
||||||
inject_bar(dest)
|
inject_bar(dest)
|
||||||
return {"status": "built", "seconds": secs}
|
return {"status": "built", "seconds": secs}
|
||||||
except Exception as exc: # noqa: BLE001 — hourly must continue
|
except Exception as exc: # noqa: BLE001 — hourly must continue
|
||||||
|
st = f"error:{type(exc).__name__}:{exc}"
|
||||||
ensure_placeholder(editions, slug, tip)
|
ensure_placeholder(editions, slug, tip)
|
||||||
|
write_freeze_status(editions, slug, False, st)
|
||||||
|
inject_bar(dest)
|
||||||
return {
|
return {
|
||||||
"status": f"error:{type(exc).__name__}:{exc}",
|
"status": st,
|
||||||
"seconds": round(time.perf_counter() - t0, 3),
|
"seconds": round(time.perf_counter() - t0, 3),
|
||||||
}
|
}
|
||||||
finally:
|
finally:
|
||||||
|
|
@ -490,10 +552,19 @@ def sync_flask_alias(editions: Path, tips: dict[str, str]) -> None:
|
||||||
shutil.copytree(src, dst)
|
shutil.copytree(src, dst)
|
||||||
(dst / ".edition-tip").write_text(tip + "\n", encoding="utf-8")
|
(dst / ".edition-tip").write_text(tip + "\n", encoding="utf-8")
|
||||||
# reuse src build meta if present
|
# reuse src build meta if present
|
||||||
for name in (".edition-built-at", ".edition-build-seconds"):
|
for name in (
|
||||||
|
".edition-built-at",
|
||||||
|
".edition-build-seconds",
|
||||||
|
".edition-freeze-ok",
|
||||||
|
".edition-freeze-error",
|
||||||
|
):
|
||||||
s = src / name
|
s = src / name
|
||||||
if s.is_file():
|
if s.is_file():
|
||||||
shutil.copy2(s, dst / name)
|
shutil.copy2(s, dst / name)
|
||||||
|
else:
|
||||||
|
p = dst / name
|
||||||
|
if p.exists():
|
||||||
|
p.unlink()
|
||||||
inject_bar(dst)
|
inject_bar(dst)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -550,15 +621,13 @@ def build_payload(repo: Path, tips: dict[str, str], rebuild: bool) -> dict:
|
||||||
slug = edition_slug(name)
|
slug = edition_slug(name)
|
||||||
if not (ed_root / slug / "index.html").is_file():
|
if not (ed_root / slug / "index.html").is_file():
|
||||||
ensure_placeholder(ed_root, slug, tip)
|
ensure_placeholder(ed_root, slug, tip)
|
||||||
|
write_freeze_status(ed_root, slug, False, "placeholder")
|
||||||
rebuild_status[name] = {"status": "placeholder", "seconds": 0.0}
|
rebuild_status[name] = {"status": "placeholder", "seconds": 0.0}
|
||||||
if not (ed_root / "flask" / "index.html").is_file():
|
if not (ed_root / "flask" / "index.html").is_file():
|
||||||
sync_flask_alias(ed_root, tips)
|
sync_flask_alias(ed_root, tips)
|
||||||
|
|
||||||
tip_times = {name: tip_commit_time(repo, tip) for name, tip in tips.items()}
|
tip_times = {name: tip_commit_time(repo, tip) for name, tip in tips.items()}
|
||||||
main_tip = tips.get("main", "")
|
empty = empty_tree_oid(repo)
|
||||||
vs_main = {
|
|
||||||
name: diff_vs_main(repo, main_tip, tip) for name, tip in tips.items()
|
|
||||||
}
|
|
||||||
branches_out = []
|
branches_out = []
|
||||||
for row in rows:
|
for row in rows:
|
||||||
name = row["branch"]
|
name = row["branch"]
|
||||||
|
|
@ -584,11 +653,23 @@ def build_payload(repo: Path, tips: dict[str, str], rebuild: bool) -> dict:
|
||||||
entry["built_at"] = meta["built_at"]
|
entry["built_at"] = meta["built_at"]
|
||||||
if "build_seconds" in meta:
|
if "build_seconds" in meta:
|
||||||
entry["build_seconds"] = meta["build_seconds"]
|
entry["build_seconds"] = meta["build_seconds"]
|
||||||
# sticky bar: +grün / −rot vs main (omit on main itself)
|
freeze_ok, freeze_err = freeze_ok_for(ed_root, slug, tips[name])
|
||||||
if name != "main" and main_tip:
|
st = str(rb.get("status") or "")
|
||||||
plus, minus = vs_main.get(name, (0, 0))
|
if st == "built":
|
||||||
entry["vs_main_plus"] = plus
|
freeze_ok, freeze_err = True, ""
|
||||||
entry["vs_main_minus"] = minus
|
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)
|
branches_out.append(entry)
|
||||||
|
|
||||||
payload = {
|
payload = {
|
||||||
|
|
|
||||||
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
|
||||||
|
|
@ -62,6 +62,8 @@ assert "smoke/temp-sibling" in branches
|
||||||
assert branches["feat/a"]["parent"] == "main", branches["feat/a"]
|
assert branches["feat/a"]["parent"] == "main", branches["feat/a"]
|
||||||
assert branches["feat/b"]["parent"] == "feat/a", branches["feat/b"]
|
assert branches["feat/b"]["parent"] == "feat/a", branches["feat/b"]
|
||||||
assert branches["smoke/temp-sibling"]["parent"] == "main", branches["smoke/temp-sibling"]
|
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
|
# prefixes semi-graphic
|
||||||
prefs = {b["branch"]: b["prefix"] for b in data["branches"]}
|
prefs = {b["branch"]: b["prefix"] for b in data["branches"]}
|
||||||
assert any("├──" in p or "└──" in p for p in prefs.values()), prefs
|
assert any("├──" in p or "└──" in p for p in prefs.values()), prefs
|
||||||
|
|
@ -77,6 +79,26 @@ test -f "$EDITIONS/feat-a/index.html"
|
||||||
test -f "$EDITIONS/smoke-temp-sibling/index.html"
|
test -f "$EDITIONS/smoke-temp-sibling/index.html"
|
||||||
grep -q "feat/a" "$EDITIONS/feat-a/index.html"
|
grep -q "feat/a" "$EDITIONS/feat-a/index.html"
|
||||||
grep -q "smoke/temp-sibling" "$EDITIONS/smoke-temp-sibling/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
|
# delete smoke branch on origin; fetch --prune drops remote-tracking ref
|
||||||
cd "$CLONE"
|
cd "$CLONE"
|
||||||
|
|
|
||||||
83
scripts/pepsi/add-mail-v6-static.sh
Executable file
83
scripts/pepsi/add-mail-v6-static.sh
Executable file
|
|
@ -0,0 +1,83 @@
|
||||||
|
#!/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
|
||||||
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
|
||||||
100
scripts/pepsi/todo631-pepsi-kreis.sh
Executable file
100
scripts/pepsi/todo631-pepsi-kreis.sh
Executable file
|
|
@ -0,0 +1,100 @@
|
||||||
|
#!/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"<todo631-kreis-{i}.{MARK}@g0v.ch"
|
||||||
|
msg.set_content(
|
||||||
|
f"TODO=631 kreis-test.\nFrom={frm}\nTo={to}\ni={i}\nmark={MARK}\n"
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
with smtplib.SMTP("127.0.0.1", 25, timeout=30) as s:
|
||||||
|
# force MAIL FROM / RCPT explicit for clear errors
|
||||||
|
s.sendmail(frm, [to], msg.as_string())
|
||||||
|
print(f"STATUS=sent i={i} FROM={frm} TO={to}")
|
||||||
|
ok += 1
|
||||||
|
except Exception as e:
|
||||||
|
print(f"STATUS=fail i={i} FROM={frm} TO={to} ERR={type(e).__name__}:{e}")
|
||||||
|
fail += 1
|
||||||
|
time.sleep(1.2)
|
||||||
|
print(f"STATUS=send_done OK={ok} FAIL={fail} MARK={MARK}")
|
||||||
|
sys.exit(0 if fail == 0 else 1)
|
||||||
|
PY
|
||||||
|
} >"${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
|
||||||
|
|
@ -1,37 +1,47 @@
|
||||||
|
html {
|
||||||
|
padding-top: var(--cascade-pad, 0px);
|
||||||
|
}
|
||||||
.cascade-bar {
|
.cascade-bar {
|
||||||
position: sticky;
|
position: fixed;
|
||||||
top: 0;
|
top: 0;
|
||||||
z-index: 10;
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
z-index: 1000;
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
width: 100vw;
|
width: 100%;
|
||||||
max-width: 100vw;
|
max-width: none;
|
||||||
margin: -1.25rem calc(50% - 50vw) 1rem;
|
margin: 0;
|
||||||
padding: 0.55rem 0.75rem 0.65rem;
|
padding: 0.2rem 0 0.25rem;
|
||||||
background: #10141a;
|
background: #10141a;
|
||||||
border-bottom: 1px solid #243040;
|
border-bottom: 1px solid #243040;
|
||||||
color: #c8d0d8;
|
color: #c8d0d8;
|
||||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||||
font-size: 12px;
|
font-size: 11px;
|
||||||
line-height: 1.45;
|
line-height: 1.25;
|
||||||
}
|
}
|
||||||
.cascade-tree {
|
.cascade-tree {
|
||||||
|
/* 45rem = site #navigation/#content 45em at root 16px (bar font is 11px) */
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: 45em;
|
max-width: 45rem;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
.cascade-tree-lines {
|
.cascade-tree-lines {
|
||||||
white-space: pre;
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
-webkit-overflow-scrolling: touch;
|
-webkit-overflow-scrolling: touch;
|
||||||
}
|
}
|
||||||
a.cascade-tree-row {
|
a.cascade-tree-row {
|
||||||
display: block;
|
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;
|
color: #7ec8ff;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
padding: 0.05rem 0.15rem;
|
padding: 0 0.15rem;
|
||||||
border-radius: 2px;
|
border-radius: 2px;
|
||||||
}
|
}
|
||||||
a.cascade-tree-row:hover {
|
a.cascade-tree-row:hover {
|
||||||
|
|
@ -50,33 +60,51 @@ a.cascade-tree-row.is-active:hover {
|
||||||
color: inherit;
|
color: inherit;
|
||||||
font-size: inherit;
|
font-size: inherit;
|
||||||
}
|
}
|
||||||
.cascade-bar .tip {
|
.cascade-name {
|
||||||
color: #8a96a3;
|
white-space: pre;
|
||||||
white-space: pre-wrap;
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
min-width: 0;
|
||||||
|
color: inherit;
|
||||||
}
|
}
|
||||||
.cascade-bar .cascade-default {
|
.cascade-when,
|
||||||
color: #6a9080;
|
.cascade-built,
|
||||||
|
.cascade-plus,
|
||||||
|
.cascade-minus {
|
||||||
|
text-align: right;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
white-space: nowrap;
|
||||||
|
color: #8a96a3;
|
||||||
}
|
}
|
||||||
.cascade-timing {
|
.cascade-timing {
|
||||||
margin-top: 0.35rem;
|
display: none;
|
||||||
color: #6a7888;
|
|
||||||
font-size: 11px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 40em) {
|
@media (max-width: 45rem) {
|
||||||
.cascade-bar {
|
.cascade-bar {
|
||||||
margin-top: -1rem;
|
padding: 0.15rem 0.4rem 0.2rem;
|
||||||
padding: 0.45rem 0.5rem 0.55rem;
|
font-size: 10px;
|
||||||
font-size: 11px;
|
|
||||||
}
|
}
|
||||||
.cascade-tree {
|
.cascade-tree {
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
}
|
}
|
||||||
.cascade-timing {
|
a.cascade-tree-row {
|
||||||
font-size: 10px;
|
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 {
|
.cascade-bar .diff-plus {
|
||||||
color: #3fb950;
|
color: #3fb950;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
|
|
@ -85,3 +113,8 @@ a.cascade-tree-row.is-active:hover {
|
||||||
color: #f85149;
|
color: #f85149;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
.cascade-bar .diff-loc,
|
||||||
|
.cascade-bar .diff-loc-unit {
|
||||||
|
color: #8a96a3;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,19 @@
|
||||||
(function () {
|
(function () {
|
||||||
var POLL_MS = 30000;
|
var POLL_MS = 30000;
|
||||||
|
var MONTHS = [
|
||||||
|
"jan",
|
||||||
|
"feb",
|
||||||
|
"mar",
|
||||||
|
"apr",
|
||||||
|
"may",
|
||||||
|
"jun",
|
||||||
|
"jul",
|
||||||
|
"aug",
|
||||||
|
"sep",
|
||||||
|
"oct",
|
||||||
|
"nov",
|
||||||
|
"dec",
|
||||||
|
];
|
||||||
|
|
||||||
function cookieEdition() {
|
function cookieEdition() {
|
||||||
var m = document.cookie.match(/(?:^|; )tdpd_edition=([^;]*)/);
|
var m = document.cookie.match(/(?:^|; )tdpd_edition=([^;]*)/);
|
||||||
|
|
@ -19,13 +33,14 @@
|
||||||
.replace(/"/g, """);
|
.replace(/"/g, """);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** ISO → "YYYY-MM-DD HH:MM" (date required; clock-only was misleading across days). */
|
/** ISO → "sep30 16:12" (3-letter English month + DD + HH:MM). */
|
||||||
function shortTime(iso) {
|
function shortTime(iso) {
|
||||||
if (!iso) return "";
|
if (!iso) return "";
|
||||||
var m = String(iso).match(
|
var m = String(iso).match(/^(\d{4})-(\d{2})-(\d{2})T(\d{2}:\d{2})/);
|
||||||
/^(\d{4}-\d{2}-\d{2})T(\d{2}:\d{2})(?::\d{2})?/
|
if (m) {
|
||||||
);
|
var mon = MONTHS[Number(m[2]) - 1] || m[2];
|
||||||
if (m) return m[1] + " " + m[2];
|
return mon + m[3] + " " + m[4];
|
||||||
|
}
|
||||||
return String(iso).slice(0, 16).replace("T", " ");
|
return String(iso).slice(0, 16).replace("T", " ");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -37,57 +52,92 @@
|
||||||
return Math.round(n) + "s";
|
return Math.round(n) + "s";
|
||||||
}
|
}
|
||||||
|
|
||||||
function metaHtml(row) {
|
function builtText(row) {
|
||||||
var tip = row.tip || "";
|
var sec = fmtBuildSec(row.build_seconds);
|
||||||
var tipTime = row.tip_time || "";
|
var at = shortTime(row.built_at || "");
|
||||||
var builtAt = row.built_at || "";
|
if (sec && at) return sec + " " + at;
|
||||||
var buildSec = row.build_seconds;
|
return sec || at;
|
||||||
var parts = [];
|
}
|
||||||
if (tip) parts.push(esc("@" + tip));
|
|
||||||
if (tipTime) parts.push(esc("tip " + shortTime(tipTime)));
|
function freezeOk(row) {
|
||||||
if (buildSec != null && buildSec !== "")
|
return row && row.freeze_ok === true;
|
||||||
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)
|
function freezeLabel(row) {
|
||||||
var plus = row.vs_main_plus;
|
if (freezeOk(row)) return "freeze ok";
|
||||||
var minus = row.vs_main_minus;
|
var err = row && row.freeze_error ? String(row.freeze_error) : "";
|
||||||
if (plus != null || minus != null) {
|
return err ? "freeze failed: " + err : "freeze failed";
|
||||||
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")) {
|
function freezeMarkHtml(row) {
|
||||||
parts.push(
|
var ok = freezeOk(row);
|
||||||
'<span class="diff-plus">+' +
|
return (
|
||||||
esc(String(p)) +
|
'<span class="cascade-mark ' +
|
||||||
'</span> <span class="diff-minus">−' +
|
(ok ? "cascade-ok" : "cascade-fail") +
|
||||||
esc(String(m)) +
|
'" title="' +
|
||||||
|
esc(freezeLabel(row)) +
|
||||||
|
'" aria-label="' +
|
||||||
|
esc(freezeLabel(row)) +
|
||||||
|
'">' +
|
||||||
|
(ok ? "✓" : "✗") +
|
||||||
"</span>"
|
"</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>";
|
||||||
}
|
}
|
||||||
return parts.join(" · ");
|
if (row.loc != null) {
|
||||||
|
var n = Number(row.loc);
|
||||||
|
if (!isFinite(n)) return "";
|
||||||
|
return '<span class="diff-loc">' + esc(String(n)) + "</span>";
|
||||||
|
}
|
||||||
|
return "";
|
||||||
}
|
}
|
||||||
|
|
||||||
function rowTitle(row, buildSec) {
|
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 tip = row.tip || "";
|
||||||
var tipTime = row.tip_time || "";
|
var tipTime = row.tip_time || "";
|
||||||
var plus = row.vs_main_plus;
|
|
||||||
var minus = row.vs_main_minus;
|
|
||||||
var vs = "";
|
var vs = "";
|
||||||
if (plus != null || minus != null) {
|
if (row.vs_parent_plus != null || row.vs_parent_minus != null) {
|
||||||
vs =
|
vs =
|
||||||
" vs main +" +
|
" vs parent +" +
|
||||||
String(plus == null ? 0 : plus) +
|
String(row.vs_parent_plus == null ? 0 : row.vs_parent_plus) +
|
||||||
"/−" +
|
"/−" +
|
||||||
String(minus == null ? 0 : minus);
|
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 (
|
return (
|
||||||
"Switch build to " +
|
"Switch build to " +
|
||||||
(row.branch || "") +
|
(row.branch || "") +
|
||||||
(tip ? " @ " + tip : "") +
|
(tip ? " @ " + tip : "") +
|
||||||
(tipTime ? " tip=" + tipTime : "") +
|
(tipTime ? " tip=" + shortTime(tipTime) : "") +
|
||||||
(buildSec != null && buildSec !== ""
|
(built ? " build=" + built : "") +
|
||||||
? " build=" + fmtBuildSec(buildSec)
|
" " +
|
||||||
: "") +
|
freezeLabel(row) +
|
||||||
vs
|
vs
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -109,6 +159,17 @@
|
||||||
return parts.join("\n");
|
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) {
|
function markActive(root, edition) {
|
||||||
var links = root.querySelectorAll("a[data-edition]");
|
var links = root.querySelectorAll("a[data-edition]");
|
||||||
for (var i = 0; i < links.length; i++) {
|
for (var i = 0; i < links.length; i++) {
|
||||||
|
|
@ -152,28 +213,27 @@
|
||||||
var a = rows[i];
|
var a = rows[i];
|
||||||
if (a.getAttribute("data-edition") !== (row.edition || "")) return false;
|
if (a.getAttribute("data-edition") !== (row.edition || "")) return false;
|
||||||
if (a.getAttribute("data-branch") !== (row.branch || "")) 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;
|
var isDefault = (row.branch || "") === defaultBranch;
|
||||||
if (isDefault && !defEl) {
|
if (isDefault) a.setAttribute("data-default", "1");
|
||||||
defEl = document.createElement("span");
|
else a.removeAttribute("data-default");
|
||||||
defEl.className = "cascade-default";
|
setText(a.querySelector(".cascade-when"), shortTime(row.tip_time || ""));
|
||||||
defEl.textContent = " · default";
|
setText(a.querySelector(".cascade-built"), builtText(row));
|
||||||
a.appendChild(defEl);
|
var markEl = a.querySelector(".cascade-mark");
|
||||||
} else if (!isDefault && defEl) {
|
if (markEl) {
|
||||||
defEl.parentNode.removeChild(defEl);
|
var markHtml = freezeMarkHtml(row);
|
||||||
|
if (markEl.outerHTML !== markHtml) markEl.outerHTML = markHtml;
|
||||||
}
|
}
|
||||||
a.setAttribute("title", rowTitle(row, row.build_seconds));
|
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 timingEl = mount.querySelector(".cascade-timing");
|
||||||
var t = timingText(timing, list.length);
|
var t = timingText(timing, list.length);
|
||||||
|
|
@ -188,6 +248,7 @@
|
||||||
timingEl.parentNode.removeChild(timingEl);
|
timingEl.parentNode.removeChild(timingEl);
|
||||||
}
|
}
|
||||||
markActive(mount, cookieEdition());
|
markActive(mount, cookieEdition());
|
||||||
|
pinBar(t);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -197,6 +258,7 @@
|
||||||
var list = (data && data.branches) || [];
|
var list = (data && data.branches) || [];
|
||||||
if (!list.length) {
|
if (!list.length) {
|
||||||
mount.textContent = "No branches in branch-status.json";
|
mount.textContent = "No branches in branch-status.json";
|
||||||
|
pinBar();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (soft && mount.getAttribute("data-structure") === structureKey(list)) {
|
if (soft && mount.getAttribute("data-structure") === structureKey(list)) {
|
||||||
|
|
@ -210,20 +272,9 @@
|
||||||
var row = list[i];
|
var row = list[i];
|
||||||
var edition = row.edition || "";
|
var edition = row.edition || "";
|
||||||
var branch = row.branch || "";
|
var branch = row.branch || "";
|
||||||
var tip = row.tip || "";
|
|
||||||
var prefix = row.prefix || "";
|
var prefix = row.prefix || "";
|
||||||
var parent = row.parent || "";
|
var parent = row.parent || "";
|
||||||
var tipTime = row.tip_time || "";
|
|
||||||
var buildSec = row.build_seconds;
|
|
||||||
var isDefault = branch === defaultBranch;
|
var isDefault = branch === defaultBranch;
|
||||||
var tipHtml = metaHtml(row);
|
|
||||||
var label =
|
|
||||||
esc(prefix) +
|
|
||||||
"<code>" +
|
|
||||||
esc(branch) +
|
|
||||||
"</code>" +
|
|
||||||
(tipHtml ? '<span class="tip"> ' + tipHtml + "</span>" : "") +
|
|
||||||
(isDefault ? '<span class="cascade-default"> · default</span>' : "");
|
|
||||||
html.push(
|
html.push(
|
||||||
'<a class="cascade-tree-row" role="treeitem" href="/_cascade/use/' +
|
'<a class="cascade-tree-row" role="treeitem" href="/_cascade/use/' +
|
||||||
esc(edition) +
|
esc(edition) +
|
||||||
|
|
@ -235,9 +286,26 @@
|
||||||
(parent ? ' data-parent="' + esc(parent) + '"' : "") +
|
(parent ? ' data-parent="' + esc(parent) + '"' : "") +
|
||||||
(isDefault ? ' data-default="1"' : "") +
|
(isDefault ? ' data-default="1"' : "") +
|
||||||
' title="' +
|
' title="' +
|
||||||
esc(rowTitle(row, buildSec)) +
|
esc(rowTitle(row)) +
|
||||||
'">' +
|
'">' +
|
||||||
label +
|
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>"
|
"</a>"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -249,6 +317,7 @@
|
||||||
mount.innerHTML = html.join("");
|
mount.innerHTML = html.join("");
|
||||||
mount.setAttribute("data-structure", structureKey(list));
|
mount.setAttribute("data-structure", structureKey(list));
|
||||||
markActive(mount, cookieEdition());
|
markActive(mount, cookieEdition());
|
||||||
|
pinBar(t);
|
||||||
}
|
}
|
||||||
|
|
||||||
function loadStatus(soft) {
|
function loadStatus(soft) {
|
||||||
|
|
@ -264,9 +333,10 @@
|
||||||
.catch(function () {
|
.catch(function () {
|
||||||
if (!soft && mount && !mount.querySelector("a[data-edition]")) {
|
if (!soft && mount && !mount.querySelector("a[data-edition]")) {
|
||||||
mount.innerHTML =
|
mount.innerHTML =
|
||||||
'<a class="cascade-tree-row" href="/_cascade/use/flask" data-edition="flask" data-branch="flask"><code>flask</code></a>' +
|
'<a class="cascade-tree-row" href="/_cascade/use/flask" data-edition="flask" data-branch="flask"><span class="cascade-name"><code>flask</code></span></a>' +
|
||||||
'<span class="tip"> (branch-status unavailable)</span>';
|
'<span class="tip"> (branch-status unavailable)</span>';
|
||||||
markActive(mount, cookieEdition());
|
markActive(mount, cookieEdition());
|
||||||
|
pinBar();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -275,4 +345,7 @@
|
||||||
setInterval(function () {
|
setInterval(function () {
|
||||||
loadStatus(true);
|
loadStatus(true);
|
||||||
}, POLL_MS);
|
}, POLL_MS);
|
||||||
|
window.addEventListener("resize", function () {
|
||||||
|
pinBar();
|
||||||
|
});
|
||||||
})();
|
})();
|
||||||
|
|
|
||||||
16
sites/dossiers.ngi.hacktivism.ch/_cascade/index.html
Normal file
16
sites/dossiers.ngi.hacktivism.ch/_cascade/index.html
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
<!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>
|
||||||
|
</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>
|
||||||
|
|
@ -1,37 +1,47 @@
|
||||||
|
html {
|
||||||
|
padding-top: var(--cascade-pad, 0px);
|
||||||
|
}
|
||||||
.cascade-bar {
|
.cascade-bar {
|
||||||
position: sticky;
|
position: fixed;
|
||||||
top: 0;
|
top: 0;
|
||||||
z-index: 10;
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
z-index: 1000;
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
width: 100vw;
|
width: 100%;
|
||||||
max-width: 100vw;
|
max-width: none;
|
||||||
margin: -1.25rem calc(50% - 50vw) 1rem;
|
margin: 0;
|
||||||
padding: 0.55rem 0.75rem 0.65rem;
|
padding: 0.2rem 0 0.25rem;
|
||||||
background: #10141a;
|
background: #10141a;
|
||||||
border-bottom: 1px solid #243040;
|
border-bottom: 1px solid #243040;
|
||||||
color: #c8d0d8;
|
color: #c8d0d8;
|
||||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||||
font-size: 12px;
|
font-size: 11px;
|
||||||
line-height: 1.45;
|
line-height: 1.25;
|
||||||
}
|
}
|
||||||
.cascade-tree {
|
.cascade-tree {
|
||||||
|
/* 45rem = site #navigation/#content 45em at root 16px (bar font is 11px) */
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: 45em;
|
max-width: 45rem;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
.cascade-tree-lines {
|
.cascade-tree-lines {
|
||||||
white-space: pre;
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
-webkit-overflow-scrolling: touch;
|
-webkit-overflow-scrolling: touch;
|
||||||
}
|
}
|
||||||
a.cascade-tree-row {
|
a.cascade-tree-row {
|
||||||
display: block;
|
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;
|
color: #7ec8ff;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
padding: 0.05rem 0.15rem;
|
padding: 0 0.15rem;
|
||||||
border-radius: 2px;
|
border-radius: 2px;
|
||||||
}
|
}
|
||||||
a.cascade-tree-row:hover {
|
a.cascade-tree-row:hover {
|
||||||
|
|
@ -50,33 +60,51 @@ a.cascade-tree-row.is-active:hover {
|
||||||
color: inherit;
|
color: inherit;
|
||||||
font-size: inherit;
|
font-size: inherit;
|
||||||
}
|
}
|
||||||
.cascade-bar .tip {
|
.cascade-name {
|
||||||
color: #8a96a3;
|
white-space: pre;
|
||||||
white-space: pre-wrap;
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
min-width: 0;
|
||||||
|
color: inherit;
|
||||||
}
|
}
|
||||||
.cascade-bar .cascade-default {
|
.cascade-when,
|
||||||
color: #6a9080;
|
.cascade-built,
|
||||||
|
.cascade-plus,
|
||||||
|
.cascade-minus {
|
||||||
|
text-align: right;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
white-space: nowrap;
|
||||||
|
color: #8a96a3;
|
||||||
}
|
}
|
||||||
.cascade-timing {
|
.cascade-timing {
|
||||||
margin-top: 0.35rem;
|
display: none;
|
||||||
color: #6a7888;
|
|
||||||
font-size: 11px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 40em) {
|
@media (max-width: 45rem) {
|
||||||
.cascade-bar {
|
.cascade-bar {
|
||||||
margin-top: -1rem;
|
padding: 0.15rem 0.4rem 0.2rem;
|
||||||
padding: 0.45rem 0.5rem 0.55rem;
|
font-size: 10px;
|
||||||
font-size: 11px;
|
|
||||||
}
|
}
|
||||||
.cascade-tree {
|
.cascade-tree {
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
}
|
}
|
||||||
.cascade-timing {
|
a.cascade-tree-row {
|
||||||
font-size: 10px;
|
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 {
|
.cascade-bar .diff-plus {
|
||||||
color: #3fb950;
|
color: #3fb950;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
|
|
@ -85,3 +113,8 @@ a.cascade-tree-row.is-active:hover {
|
||||||
color: #f85149;
|
color: #f85149;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
.cascade-bar .diff-loc,
|
||||||
|
.cascade-bar .diff-loc-unit {
|
||||||
|
color: #8a96a3;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,19 @@
|
||||||
(function () {
|
(function () {
|
||||||
var POLL_MS = 30000;
|
var POLL_MS = 30000;
|
||||||
|
var MONTHS = [
|
||||||
|
"jan",
|
||||||
|
"feb",
|
||||||
|
"mar",
|
||||||
|
"apr",
|
||||||
|
"may",
|
||||||
|
"jun",
|
||||||
|
"jul",
|
||||||
|
"aug",
|
||||||
|
"sep",
|
||||||
|
"oct",
|
||||||
|
"nov",
|
||||||
|
"dec",
|
||||||
|
];
|
||||||
|
|
||||||
function cookieEdition() {
|
function cookieEdition() {
|
||||||
var m = document.cookie.match(/(?:^|; )tdpd_edition=([^;]*)/);
|
var m = document.cookie.match(/(?:^|; )tdpd_edition=([^;]*)/);
|
||||||
|
|
@ -19,13 +33,14 @@
|
||||||
.replace(/"/g, """);
|
.replace(/"/g, """);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** ISO → "YYYY-MM-DD HH:MM" (date required; clock-only was misleading across days). */
|
/** ISO → "sep30 16:12" (3-letter English month + DD + HH:MM). */
|
||||||
function shortTime(iso) {
|
function shortTime(iso) {
|
||||||
if (!iso) return "";
|
if (!iso) return "";
|
||||||
var m = String(iso).match(
|
var m = String(iso).match(/^(\d{4})-(\d{2})-(\d{2})T(\d{2}:\d{2})/);
|
||||||
/^(\d{4}-\d{2}-\d{2})T(\d{2}:\d{2})(?::\d{2})?/
|
if (m) {
|
||||||
);
|
var mon = MONTHS[Number(m[2]) - 1] || m[2];
|
||||||
if (m) return m[1] + " " + m[2];
|
return mon + m[3] + " " + m[4];
|
||||||
|
}
|
||||||
return String(iso).slice(0, 16).replace("T", " ");
|
return String(iso).slice(0, 16).replace("T", " ");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -37,57 +52,92 @@
|
||||||
return Math.round(n) + "s";
|
return Math.round(n) + "s";
|
||||||
}
|
}
|
||||||
|
|
||||||
function metaHtml(row) {
|
function builtText(row) {
|
||||||
var tip = row.tip || "";
|
var sec = fmtBuildSec(row.build_seconds);
|
||||||
var tipTime = row.tip_time || "";
|
var at = shortTime(row.built_at || "");
|
||||||
var builtAt = row.built_at || "";
|
if (sec && at) return sec + " " + at;
|
||||||
var buildSec = row.build_seconds;
|
return sec || at;
|
||||||
var parts = [];
|
}
|
||||||
if (tip) parts.push(esc("@" + tip));
|
|
||||||
if (tipTime) parts.push(esc("tip " + shortTime(tipTime)));
|
function freezeOk(row) {
|
||||||
if (buildSec != null && buildSec !== "")
|
return row && row.freeze_ok === true;
|
||||||
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)
|
function freezeLabel(row) {
|
||||||
var plus = row.vs_main_plus;
|
if (freezeOk(row)) return "freeze ok";
|
||||||
var minus = row.vs_main_minus;
|
var err = row && row.freeze_error ? String(row.freeze_error) : "";
|
||||||
if (plus != null || minus != null) {
|
return err ? "freeze failed: " + err : "freeze failed";
|
||||||
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")) {
|
function freezeMarkHtml(row) {
|
||||||
parts.push(
|
var ok = freezeOk(row);
|
||||||
'<span class="diff-plus">+' +
|
return (
|
||||||
esc(String(p)) +
|
'<span class="cascade-mark ' +
|
||||||
'</span> <span class="diff-minus">−' +
|
(ok ? "cascade-ok" : "cascade-fail") +
|
||||||
esc(String(m)) +
|
'" title="' +
|
||||||
|
esc(freezeLabel(row)) +
|
||||||
|
'" aria-label="' +
|
||||||
|
esc(freezeLabel(row)) +
|
||||||
|
'">' +
|
||||||
|
(ok ? "✓" : "✗") +
|
||||||
"</span>"
|
"</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>";
|
||||||
}
|
}
|
||||||
return parts.join(" · ");
|
if (row.loc != null) {
|
||||||
|
var n = Number(row.loc);
|
||||||
|
if (!isFinite(n)) return "";
|
||||||
|
return '<span class="diff-loc">' + esc(String(n)) + "</span>";
|
||||||
|
}
|
||||||
|
return "";
|
||||||
}
|
}
|
||||||
|
|
||||||
function rowTitle(row, buildSec) {
|
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 tip = row.tip || "";
|
||||||
var tipTime = row.tip_time || "";
|
var tipTime = row.tip_time || "";
|
||||||
var plus = row.vs_main_plus;
|
|
||||||
var minus = row.vs_main_minus;
|
|
||||||
var vs = "";
|
var vs = "";
|
||||||
if (plus != null || minus != null) {
|
if (row.vs_parent_plus != null || row.vs_parent_minus != null) {
|
||||||
vs =
|
vs =
|
||||||
" vs main +" +
|
" vs parent +" +
|
||||||
String(plus == null ? 0 : plus) +
|
String(row.vs_parent_plus == null ? 0 : row.vs_parent_plus) +
|
||||||
"/−" +
|
"/−" +
|
||||||
String(minus == null ? 0 : minus);
|
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 (
|
return (
|
||||||
"Switch build to " +
|
"Switch build to " +
|
||||||
(row.branch || "") +
|
(row.branch || "") +
|
||||||
(tip ? " @ " + tip : "") +
|
(tip ? " @ " + tip : "") +
|
||||||
(tipTime ? " tip=" + tipTime : "") +
|
(tipTime ? " tip=" + shortTime(tipTime) : "") +
|
||||||
(buildSec != null && buildSec !== ""
|
(built ? " build=" + built : "") +
|
||||||
? " build=" + fmtBuildSec(buildSec)
|
" " +
|
||||||
: "") +
|
freezeLabel(row) +
|
||||||
vs
|
vs
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -109,6 +159,17 @@
|
||||||
return parts.join("\n");
|
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) {
|
function markActive(root, edition) {
|
||||||
var links = root.querySelectorAll("a[data-edition]");
|
var links = root.querySelectorAll("a[data-edition]");
|
||||||
for (var i = 0; i < links.length; i++) {
|
for (var i = 0; i < links.length; i++) {
|
||||||
|
|
@ -152,28 +213,27 @@
|
||||||
var a = rows[i];
|
var a = rows[i];
|
||||||
if (a.getAttribute("data-edition") !== (row.edition || "")) return false;
|
if (a.getAttribute("data-edition") !== (row.edition || "")) return false;
|
||||||
if (a.getAttribute("data-branch") !== (row.branch || "")) 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;
|
var isDefault = (row.branch || "") === defaultBranch;
|
||||||
if (isDefault && !defEl) {
|
if (isDefault) a.setAttribute("data-default", "1");
|
||||||
defEl = document.createElement("span");
|
else a.removeAttribute("data-default");
|
||||||
defEl.className = "cascade-default";
|
setText(a.querySelector(".cascade-when"), shortTime(row.tip_time || ""));
|
||||||
defEl.textContent = " · default";
|
setText(a.querySelector(".cascade-built"), builtText(row));
|
||||||
a.appendChild(defEl);
|
var markEl = a.querySelector(".cascade-mark");
|
||||||
} else if (!isDefault && defEl) {
|
if (markEl) {
|
||||||
defEl.parentNode.removeChild(defEl);
|
var markHtml = freezeMarkHtml(row);
|
||||||
|
if (markEl.outerHTML !== markHtml) markEl.outerHTML = markHtml;
|
||||||
}
|
}
|
||||||
a.setAttribute("title", rowTitle(row, row.build_seconds));
|
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 timingEl = mount.querySelector(".cascade-timing");
|
||||||
var t = timingText(timing, list.length);
|
var t = timingText(timing, list.length);
|
||||||
|
|
@ -188,6 +248,7 @@
|
||||||
timingEl.parentNode.removeChild(timingEl);
|
timingEl.parentNode.removeChild(timingEl);
|
||||||
}
|
}
|
||||||
markActive(mount, cookieEdition());
|
markActive(mount, cookieEdition());
|
||||||
|
pinBar(t);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -197,6 +258,7 @@
|
||||||
var list = (data && data.branches) || [];
|
var list = (data && data.branches) || [];
|
||||||
if (!list.length) {
|
if (!list.length) {
|
||||||
mount.textContent = "No branches in branch-status.json";
|
mount.textContent = "No branches in branch-status.json";
|
||||||
|
pinBar();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (soft && mount.getAttribute("data-structure") === structureKey(list)) {
|
if (soft && mount.getAttribute("data-structure") === structureKey(list)) {
|
||||||
|
|
@ -210,20 +272,9 @@
|
||||||
var row = list[i];
|
var row = list[i];
|
||||||
var edition = row.edition || "";
|
var edition = row.edition || "";
|
||||||
var branch = row.branch || "";
|
var branch = row.branch || "";
|
||||||
var tip = row.tip || "";
|
|
||||||
var prefix = row.prefix || "";
|
var prefix = row.prefix || "";
|
||||||
var parent = row.parent || "";
|
var parent = row.parent || "";
|
||||||
var tipTime = row.tip_time || "";
|
|
||||||
var buildSec = row.build_seconds;
|
|
||||||
var isDefault = branch === defaultBranch;
|
var isDefault = branch === defaultBranch;
|
||||||
var tipHtml = metaHtml(row);
|
|
||||||
var label =
|
|
||||||
esc(prefix) +
|
|
||||||
"<code>" +
|
|
||||||
esc(branch) +
|
|
||||||
"</code>" +
|
|
||||||
(tipHtml ? '<span class="tip"> ' + tipHtml + "</span>" : "") +
|
|
||||||
(isDefault ? '<span class="cascade-default"> · default</span>' : "");
|
|
||||||
html.push(
|
html.push(
|
||||||
'<a class="cascade-tree-row" role="treeitem" href="/_cascade/use/' +
|
'<a class="cascade-tree-row" role="treeitem" href="/_cascade/use/' +
|
||||||
esc(edition) +
|
esc(edition) +
|
||||||
|
|
@ -235,9 +286,26 @@
|
||||||
(parent ? ' data-parent="' + esc(parent) + '"' : "") +
|
(parent ? ' data-parent="' + esc(parent) + '"' : "") +
|
||||||
(isDefault ? ' data-default="1"' : "") +
|
(isDefault ? ' data-default="1"' : "") +
|
||||||
' title="' +
|
' title="' +
|
||||||
esc(rowTitle(row, buildSec)) +
|
esc(rowTitle(row)) +
|
||||||
'">' +
|
'">' +
|
||||||
label +
|
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>"
|
"</a>"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -249,6 +317,7 @@
|
||||||
mount.innerHTML = html.join("");
|
mount.innerHTML = html.join("");
|
||||||
mount.setAttribute("data-structure", structureKey(list));
|
mount.setAttribute("data-structure", structureKey(list));
|
||||||
markActive(mount, cookieEdition());
|
markActive(mount, cookieEdition());
|
||||||
|
pinBar(t);
|
||||||
}
|
}
|
||||||
|
|
||||||
function loadStatus(soft) {
|
function loadStatus(soft) {
|
||||||
|
|
@ -264,9 +333,10 @@
|
||||||
.catch(function () {
|
.catch(function () {
|
||||||
if (!soft && mount && !mount.querySelector("a[data-edition]")) {
|
if (!soft && mount && !mount.querySelector("a[data-edition]")) {
|
||||||
mount.innerHTML =
|
mount.innerHTML =
|
||||||
'<a class="cascade-tree-row" href="/_cascade/use/flask" data-edition="flask" data-branch="flask"><code>flask</code></a>' +
|
'<a class="cascade-tree-row" href="/_cascade/use/flask" data-edition="flask" data-branch="flask"><span class="cascade-name"><code>flask</code></span></a>' +
|
||||||
'<span class="tip"> (branch-status unavailable)</span>';
|
'<span class="tip"> (branch-status unavailable)</span>';
|
||||||
markActive(mount, cookieEdition());
|
markActive(mount, cookieEdition());
|
||||||
|
pinBar();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -275,4 +345,7 @@
|
||||||
setInterval(function () {
|
setInterval(function () {
|
||||||
loadStatus(true);
|
loadStatus(true);
|
||||||
}, POLL_MS);
|
}, POLL_MS);
|
||||||
|
window.addEventListener("resize", function () {
|
||||||
|
pinBar();
|
||||||
|
});
|
||||||
})();
|
})();
|
||||||
|
|
|
||||||
16
sites/dossiers.ngi.hacktivism.ch/cascade/_cascade/index.html
Normal file
16
sites/dossiers.ngi.hacktivism.ch/cascade/_cascade/index.html
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
<!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>
|
||||||
|
</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>
|
||||||
Loading…
Add table
Add a link
Reference in a new issue