feat(apps): toon uit welk sjabloon een app gerenderd wordt
All checks were successful
Deploy server-up (dev) / deploy (push) Successful in 5m17s

Sjablonen komen uit de git-cache van een app-repo. Loopt die achter, dan trekt
'Instellingen wijzigen' een oude versie over een werkende app heen zonder dat
iets dat zegt — precies wat er bij UniFi gebeurde.

- git.cache_status geeft commit, datum, synchronisatiemoment en de VERSION uit
  de cache terug.
- /api/repos en /api/stacks/<naam>/config sturen die stand mee, met een
  outdated-vlag als de repo-VERSION ouder is dan de draaiende versie.
- Het wijzigscherm toont repo, branch en hoe lang geleden er opgehaald is, en
  waarschuwt bij een achterlopende cache.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H9Vcwu7owJZ3nj5dCf4VFT
This commit is contained in:
Ramon 2026-08-06 23:21:21 +02:00
parent c658418b10
commit b40a6a5a4c
8 changed files with 226 additions and 4 deletions

View file

@ -1,3 +1,18 @@
# v0.8.25-beta — Zichtbaar uit welk sjabloon een app komt
Sjablonen komen uit de git-cache van een app-repo, niet uit het image van
Server Up. Die twee kunnen ver uit elkaar lopen: staat de repo op een branch
die achterloopt, dan rendert 'Instellingen wijzigen' een oud sjabloon over een
werkende app heen — en daar was aan niets te zien.
- **Het wijzigscherm noemt zijn bron.** Repo, branch en hoe lang geleden er
gesynchroniseerd is, boven de velden waar je op opslaan drukt.
- **Een achterlopende repo waarschuwt.** Levert de repo zelf een `VERSION` mee
(zoals deze) en is die ouder dan de draaiende versie, dan zegt het scherm dat
met de versie erbij.
- **De repolijst toont de stand.** Commit en synchronisatiemoment per repo,
zodat je niet hoeft te gokken of een sync nog nodig is.
# v0.8.24-beta — Koppelen laat je database met rust # v0.8.24-beta — Koppelen laat je database met rust
Koppelen aan het gedeelde netwerk hing élke service van een stack eraan, ook de Koppelen aan het gedeelde netwerk hing élke service van een stack eraan, ook de

View file

@ -1 +1 @@
0.8.24-beta 0.8.25-beta

View file

@ -1115,7 +1115,25 @@ def api_stack_config(name):
app_networks=meta.get("app_networks") or [], app_networks=meta.get("app_networks") or [],
app_services=meta.get("app_services") or [], app_services=meta.get("app_services") or [],
source=meta.get("source", ""), repo_id=meta.get("repo_id", ""), source=meta.get("source", ""), repo_id=meta.get("repo_id", ""),
name=meta.get("name", name)) name=meta.get("name", name),
# Waar dit sjabloon vandaan komt. Opslaan rendert de app
# opnieuw uit déze cache; loopt die achter, dan haal je een
# oude versie over je werkende stack heen.
source_repo=_sjabloon_herkomst(meta.get("repo_id", "")))
def _sjabloon_herkomst(rid: str) -> dict:
"""Repo, branch en stand van de cache waar een sjabloon uit komt."""
if not rid:
return {}
repo = next((r for r in cfg.load().get("APP_REPOS", [])
if isinstance(r, dict) and r.get("id") == rid), None)
if not repo:
return {"id": rid, "missing": True}
stand = git.cache_status(rid)
stand["outdated"] = _cache_loopt_achter(stand.get("version", ""))
return {"id": rid, "name": repo.get("name") or rid,
"branch": repo.get("branch") or "main", **stand}
@app.route("/api/stacks/<name>/reconfigure", methods=["POST"]) @app.route("/api/stacks/<name>/reconfigure", methods=["POST"])
@ -1432,10 +1450,28 @@ def api_repos():
if r["id"] in gezien: if r["id"] in gezien:
continue continue
gezien.add(r["id"]) gezien.add(r["id"])
result.append({**r, "cloned": git.cache_dir(r["id"]).exists()}) # Mét de stand van de cache: welke commit, van wanneer, en loopt de
# meegeleverde VERSION achter op wat hier draait? Zonder dat is niet te
# zien dat een app een oud sjabloon rendert.
stand = git.cache_status(r["id"])
stand["outdated"] = _cache_loopt_achter(stand.get("version", ""))
result.append({**r, "cloned": git.cache_dir(r["id"]).exists(),
"cache": stand})
return jsonify(result) return jsonify(result)
def _cache_loopt_achter(repo_versie: str) -> bool:
"""Is de VERSION in de git-cache ouder dan de draaiende Server Up?
Alleen zinvol voor repo's die zelf een VERSION meeleveren; voor de rest
zegt dit niets en blijft het antwoord False.
"""
if not repo_versie:
return False
hier, daar = updater._parse(VERSION), updater._parse(repo_versie)
return bool(hier and daar and daar < hier)
@app.route("/api/repos", methods=["POST"]) @app.route("/api/repos", methods=["POST"])
def api_repos_add(): def api_repos_add():
d = request.json or {} d = request.json or {}

View file

@ -48,6 +48,44 @@ def cache_dir(repo_id: str) -> Path:
return CACHE / repo_id return CACHE / repo_id
def cache_status(repo_id: str) -> dict:
"""Welke versie van de sjablonen staat er in de cache van deze repo?
Sjablonen komen hiervandaan en niet uit het image van Server Up. Die twee
kunnen ver uit elkaar lopen: staat de repo op een branch die achterloopt,
dan rendert een app een oud sjabloon terwijl de code splinternieuw is en
daar is aan niets te zien.
"""
d = cache_dir(repo_id)
if not (d / ".git").exists():
return {"cloned": False}
uit: dict = {"cloned": True}
try:
r = subprocess.run(["git", "log", "-1", "--format=%h%x1f%cI%x1f%s"],
cwd=str(d), capture_output=True, text=True, timeout=10)
if r.returncode == 0 and "\x1f" in r.stdout:
sha, datum, onderwerp = r.stdout.strip().split("\x1f", 2)
uit.update(commit=sha, commit_date=datum, subject=onderwerp[:120])
except Exception:
pass
# Wanneer is er voor het laatst opgehaald? FETCH_HEAD wordt bij elke fetch
# aangeraakt; bij een verse kloon bestaat hij nog niet.
for naam in ("FETCH_HEAD", "HEAD"):
p = d / ".git" / naam
if p.exists():
uit["synced_at"] = p.stat().st_mtime
break
# Repo's die zelf een VERSION meeleveren (zoals deze) verraden daarmee of de
# cache achterloopt op de draaiende versie.
versie = d / "VERSION"
if versie.is_file():
try:
uit["version"] = versie.read_text(encoding="utf-8").strip()[:40]
except Exception:
pass
return uit
def clone_or_pull(repo: dict, log_fn=None) -> tuple[bool, str]: def clone_or_pull(repo: dict, log_fn=None) -> tuple[bool, str]:
_ensure_git_safe() _ensure_git_safe()
url = repo.get("url", "").strip() url = repo.get("url", "").strip()

View file

@ -1392,9 +1392,17 @@ tailwind.config = {
<summary class="px-5 py-4 cursor-pointer font-bold text-wh dark:text-wh-dark text-sm flex items-center gap-2.5"><span class="mdi mdi-source-branch text-ac dark:text-ac-dark text-lg"></span> Git Repositories</summary> <summary class="px-5 py-4 cursor-pointer font-bold text-wh dark:text-wh-dark text-sm flex items-center gap-2.5"><span class="mdi mdi-source-branch text-ac dark:text-ac-dark text-lg"></span> Git Repositories</summary>
<div class="px-5 pb-5 space-y-2"> <div class="px-5 pb-5 space-y-2">
<template x-for="r in repos" :key="r.id"> <template x-for="r in repos" :key="r.id">
<div class="flex items-center gap-2 py-2 border-b border-bd dark:border-bd-dark last:border-0"> <div class="flex items-center gap-2 py-2 border-b border-bd dark:border-bd-dark last:border-0 flex-wrap">
<span class="mdi mdi-source-branch text-dm dark:text-dm-dark"></span> <span class="mdi mdi-source-branch text-dm dark:text-dm-dark"></span>
<span class="font-mono text-xs flex-1 truncate" x-text="r.name"></span> <span class="font-mono text-xs flex-1 truncate" x-text="r.name"></span>
<!-- De stand van de cache: hieruit komen de sjablonen, en die
kan ver achterlopen op de draaiende versie. -->
<span x-show="(r.cache||{}).outdated" class="badge badge-warn"
:title="t('cache_outdated_hint')" x-text="(r.cache||{}).version"></span>
<span x-show="(r.cache||{}).commit && !(r.cache||{}).outdated"
class="text-[10px] font-mono text-dm dark:text-dm-dark"
:title="(r.cache||{}).subject"
x-text="(r.cache||{}).commit + ' · ' + geleden((r.cache||{}).synced_at)"></span>
<!-- De branch bepaalt welke sjablonen je krijgt; die stilzwijgend <!-- De branch bepaalt welke sjablonen je krijgt; die stilzwijgend
weglaten maakte het onmogelijk te zien waarom een app een weglaten maakte het onmogelijk te zien waarom een app een
oude versie bleef renderen. --> oude versie bleef renderen. -->
@ -1765,6 +1773,27 @@ tailwind.config = {
<span x-text="t('reconfigure_warning')"></span> <span x-text="t('reconfigure_warning')"></span>
</p> </p>
<!-- Waar het sjabloon vandaan komt. Opslaan rendert de app opnieuw uit
déze cache: loopt die achter, dan trek je een oude versie over een
werkende stack heen zonder dat iets dat zegt. -->
<div x-show="install.mode === 'edit' && install.source_repo && install.source_repo.id"
class="text-[11px] rounded-xl border px-3 py-2 flex items-start gap-2"
:class="(install.source_repo||{}).outdated
? 'border-wr/40 bg-wr/5 text-wr'
: 'border-bd dark:border-bd-dark text-t2 dark:text-t2-dark'">
<span class="mdi shrink-0"
:class="(install.source_repo||{}).outdated ? 'mdi-alert-outline' : 'mdi-source-branch'"></span>
<span class="min-w-0">
<span x-text="t('template_from', {
repo: (install.source_repo||{}).name || (install.source_repo||{}).id,
branch: (install.source_repo||{}).branch || 'main'})"></span>
<span x-show="(install.source_repo||{}).synced_at"
x-text="' · ' + t('synced_ago', {ago: geleden((install.source_repo||{}).synced_at)})"></span>
<span x-show="(install.source_repo||{}).outdated" class="block mt-0.5"
x-text="t('cache_outdated', {version: (install.source_repo||{}).version, current: version})"></span>
</span>
</div>
<!-- Uitleg bij het veld waar je op het info-icoon klikte --> <!-- Uitleg bij het veld waar je op het info-icoon klikte -->
<div x-show="fieldInfo" x-cloak <div x-show="fieldInfo" x-cloak
class="rounded-xl border border-ac/30 bg-ac/5 px-3.5 py-3"> class="rounded-xl border border-ac/30 bg-ac/5 px-3.5 py-3">
@ -4090,6 +4119,7 @@ function app() {
repo_id: d.repo_id || '', repo_id: d.repo_id || '',
app_networks: d.app_networks || (d.connected ? [this.sharedNetwork] : []), app_networks: d.app_networks || (d.connected ? [this.sharedNetwork] : []),
app_services: d.app_services || [], service_info: [], app_services: d.app_services || [], service_info: [],
source_repo: d.source_repo || {},
}; };
await this.loadAppNets(stack); await this.loadAppNets(stack);
this.fieldInfo = null; this.fieldInfo = null;
@ -4276,6 +4306,17 @@ function app() {
if (!ts) return ''; if (!ts) return '';
return new Date(ts * 1000).toLocaleString(); return new Date(ts * 1000).toLocaleString();
}, },
// "3 dagen geleden" leest bij een synchronisatie prettiger dan een datum:
// de vraag is niet wannéér het was, maar of het lang geleden is.
geleden(ts) {
if (!ts) return '';
const sec = Math.max(0, Date.now() / 1000 - ts);
const stappen = [[86400, 'd'], [3600, 'u'], [60, 'm']];
for (const [n, letter] of stappen) {
if (sec >= n) return Math.floor(sec / n) + letter;
}
return this.t('just_now');
},
// Bijwerken: de container vervangt zichzelf, dus na het startsein is deze // Bijwerken: de container vervangt zichzelf, dus na het startsein is deze
// pagina zijn server kwijt. We pollen /healthz tot de nieuwe versie leeft. // pagina zijn server kwijt. We pollen /healthz tot de nieuwe versie leeft.

View file

@ -52,6 +52,11 @@
"no_repos": "No repository added yet — add one to see apps.", "no_repos": "No repository added yet — add one to see apps.",
"edit_repo": "Edit repository", "edit_repo": "Edit repository",
"repo_branch_hint": "The branch decides which version of the templates you get. Saving re-fetches the repo right away; existing apps only change once you save their settings.", "repo_branch_hint": "The branch decides which version of the templates you get. Saving re-fetches the repo right away; existing apps only change once you save their settings.",
"template_from": "Template from {repo} ({branch})",
"synced_ago": "fetched {ago} ago",
"cache_outdated": "This repo is on {version} while Server Up runs {current}. Sync it or pick another branch — otherwise saving renders an older template over your app.",
"cache_outdated_hint": "The templates in this repo lag behind the running version of Server Up.",
"just_now": "just now",
"add_repo": "Add repository", "add_repo": "Add repository",
"repo_name": "Name", "repo_name": "Name",
"repo_url": "Git URL", "repo_url": "Git URL",

View file

@ -52,6 +52,11 @@
"no_repos": "Nog geen repository toegevoegd — voeg er een toe om apps te zien.", "no_repos": "Nog geen repository toegevoegd — voeg er een toe om apps te zien.",
"edit_repo": "Repository bewerken", "edit_repo": "Repository bewerken",
"repo_branch_hint": "De branch bepaalt welke versie van de sjablonen je krijgt. Na opslaan wordt de repo meteen opnieuw opgehaald; bestaande apps veranderen pas als je hun instellingen opslaat.", "repo_branch_hint": "De branch bepaalt welke versie van de sjablonen je krijgt. Na opslaan wordt de repo meteen opnieuw opgehaald; bestaande apps veranderen pas als je hun instellingen opslaat.",
"template_from": "Sjabloon uit {repo} ({branch})",
"synced_ago": "{ago} geleden opgehaald",
"cache_outdated": "Deze repo staat op {version} terwijl Server Up {current} draait. Synchroniseer hem of kies een andere branch — anders rendert opslaan een ouder sjabloon over je app heen.",
"cache_outdated_hint": "De sjablonen in deze repo lopen achter op de draaiende versie van Server Up.",
"just_now": "zojuist",
"add_repo": "Repository toevoegen", "add_repo": "Repository toevoegen",
"repo_name": "Naam", "repo_name": "Naam",
"repo_url": "Git URL", "repo_url": "Git URL",

View file

@ -357,3 +357,85 @@ def test_viewer_mag_geen_repo_bewerken(client, env):
).get_json()["csrf_token"] ).get_json()["csrf_token"]
assert client.put("/api/repos/proef", json={"branch": "dev"}, assert client.put("/api/repos/proef", json={"branch": "dev"},
headers={"X-CSRF-Token": kijker}).status_code == 403 headers={"X-CSRF-Token": kijker}).status_code == 403
# ── Herkomst van een sjabloon ────────────────────────────────────────────────
def test_repolijst_toont_de_stand_van_de_cache(client, env, tmp_path, monkeypatch):
"""Sjablonen komen uit de git-cache, niet uit het image. Loopt die achter,
dan rendert een app een oude versie terwijl de code nieuw is en daar was
aan niets te zien."""
import shutil as _shutil
from core import git
if not _shutil.which("git"):
pytest.skip("git niet aanwezig")
csrf = login(client)
bron = _repo_met_twee_takken(tmp_path)
(git.cache_dir("proef")).parent.mkdir(parents=True, exist_ok=True)
monkeypatch.setattr("core.valid_repo_url", lambda u: True)
client.post("/api/repos", json={"url": f"file://{bron}", "id": "proef",
"branch": "main"},
headers={"X-CSRF-Token": csrf})
ok, msg = git.clone_or_pull({"id": "proef", "url": f"file://{bron}",
"branch": "main"})
assert ok, msg
repo = next(r for r in client.get("/api/repos").get_json() if r["id"] == "proef")
assert repo["cache"]["cloned"] is True
assert repo["cache"]["commit"]
assert repo["cache"]["synced_at"] > 0
def test_achterlopende_repo_wordt_gemeld(client, env, tmp_path, monkeypatch):
"""Precies het geval dat drie releases lang onzichtbaar bleef: de repo staat
op een oudere versie dan Server Up zelf."""
import shutil as _shutil
from core import git
if not _shutil.which("git"):
pytest.skip("git niet aanwezig")
csrf = login(client)
bron = _repo_met_twee_takken(tmp_path)
(bron / "VERSION").write_text("0.0.01-beta\n")
import subprocess as sp
sp.run(["git", "add", "-A"], cwd=bron, check=True, capture_output=True)
sp.run(["git", "commit", "-qm", "versie"], cwd=bron, check=True,
capture_output=True)
monkeypatch.setattr("core.valid_repo_url", lambda u: True)
client.post("/api/repos", json={"url": f"file://{bron}", "id": "oud",
"branch": "main"},
headers={"X-CSRF-Token": csrf})
git.clone_or_pull({"id": "oud", "url": f"file://{bron}", "branch": "main"})
repo = next(r for r in client.get("/api/repos").get_json() if r["id"] == "oud")
assert repo["cache"]["version"] == "0.0.01-beta"
assert repo["cache"]["outdated"] is True
def test_repo_zonder_versiebestand_heet_niet_verouderd(env):
import app as app_module
assert app_module._cache_loopt_achter("") is False
assert app_module._cache_loopt_achter("geen-versie") is False
assert app_module._cache_loopt_achter("99.9.99") is False
def test_wijzigscherm_kent_de_herkomst_van_het_sjabloon(client, env):
"""Het scherm waar je op opslaan drukt hoort te zeggen waaruit hij rendert."""
import json as _json
csrf = login(client)
d = env["lib"] / "web"
d.mkdir(parents=True, exist_ok=True)
(d / "docker-compose.yml").write_text("services:\n web:\n image: nginx\n")
(d / ".serverup.json").write_text(_json.dumps({
"source": "keuzeapp", "repo_id": "server-up", "values": {"port": 80}}))
from core import git
wortel = git.cache_dir("server-up") / "apps" / "keuzeapp"
(wortel / "files").mkdir(parents=True, exist_ok=True)
(wortel / "template.json").write_text(_json.dumps({
"kind": "compose", "metadata": {"name": "keuzeapp"}, "variables": []}))
(wortel / "files" / "compose.yaml").write_text("services:\n web:\n image: nginx\n")
d2 = client.get("/api/stacks/web/config").get_json()
assert d2["ok"] is True
assert d2["source_repo"]["id"] == "server-up"
assert d2["source_repo"]["branch"]