admin console: read-only /admin screens served by distd (P6.1)

Subplan 06, first slice. src/server/admin.sx server-renders the
operator console the same way the index and install pages are built —
sx string rendering, no client framework, no build step:

  /admin                 apps overview (platform coverage, channel and
                         release counts, latest release)
  /admin/apps/<slug>     metadata + iOS install-mode chip, channels with
                         policy/rollout/retention, releases newest-first
  /admin/releases/<id>   artifacts with validation chips and download
                         links, channels serving the release, audit
                         timeline (by target, by metadata, by artifact
                         id prefix)
  /admin/tokens          lifecycle status via token_status; never a
                         secret or hash
  /admin/audit           the full log, newest first

Reads are public like every distd GET in v0; mutations stay on the
token-gated POST endpoints (UI actions are a later slice). Unknown
slug/release/route answer 404 JSON errors with stable codes
(admin.unknown_app / admin.unknown_release / http.not_found). The
module is self-contained (adm_-prefixed helpers) so distd can import it
without a cycle; timestamps render through a local civil-from-days
formatter. The public index footer links the console.

tests/server_admin.sx drives the built binary over curl and pins every
screen, the no-secret-material guarantee, and the 404 codes.
make test 24/24 green.
This commit is contained in:
agra
2026-06-12 19:50:24 +03:00
parent fd30f4a17b
commit eef3d5c437
3 changed files with 778 additions and 1 deletions

555
src/server/admin.sx Normal file
View File

@@ -0,0 +1,555 @@
// =====================================================================
// admin.sx — the read-only admin console served by distd (subplan 06,
// P6.1): server-rendered HTML, no client framework, no build step —
// the same approach as the install pages, per PLAN.md "product code
// lives in sx".
//
// /admin apps overview: platform coverage, channels,
// latest release per app
// /admin/apps/<slug> app detail: metadata, iOS install mode,
// channels (policy/rollout/retention),
// releases newest-first
// /admin/releases/<id> release detail: artifacts with validation
// status, channels pointing at it, audit
// timeline
// /admin/tokens tokens with lifecycle status — names,
// scopes, restrictions; NEVER a secret or hash
// /admin/audit the full audit log, newest first
//
// ACCESS: read-only and unauthenticated, like every distd GET in v0 —
// the console is a NAS-internal operator surface behind a reverse
// proxy. The tokens screen shows the same fields as `dist token list`
// (no secret material exists to leak; only hashes are at rest, and
// those are never rendered). Mutations stay on the token-gated POST
// /api endpoints; UI actions are a later slice.
//
// Self-contained on purpose: distd.sx imports this module, so nothing
// here may import distd.sx back; the few shared-shape helpers (loading,
// JSON errors, escaping) are local `adm_`-prefixed copies — top-level
// names resolve program-wide in sx, so the prefix also keeps them from
// colliding with distd's.
// =====================================================================
#import "modules/std.sx";
#import "modules/std/json.sx";
#import "../domain/platform.sx";
#import "../domain/app.sx";
#import "../domain/release.sx";
#import "../domain/artifact.sx";
#import "../domain/channel.sx";
#import "../domain/token.sx";
#import "../domain/audit.sx";
#import "../domain/validate.sx";
#import "../repo/repo.sx";
http :: #import "http.sx";
db :: #import "../repo/db.sx";
jout :: #import "../json_out.sx";
pl :: #import "../publish/publish.sx";
tops :: #import "../token/ops.sx";
// ── small helpers (adm_-prefixed; see module header) ─────────────────
adm_starts_with :: (s: string, prefix: string) -> bool {
if prefix.len > s.len { return false; }
i := 0;
while i < prefix.len {
if s[i] != prefix[i] { return false; }
i += 1;
}
return true;
}
adm_tail :: (s: string, prefix: string) -> string {
return string.{ ptr = @s[prefix.len], len = s.len - prefix.len };
}
// Escape &, <, >, " for HTML text/attribute positions.
adm_esc :: (s: string) -> string {
acc := "";
start := 0;
i := 0;
while i < s.len {
c := s[i];
rep := "";
if c == 38 { rep = "&amp;"; } // &
if c == 60 { rep = "&lt;"; } // <
if c == 62 { rep = "&gt;"; } // >
if c == 34 { rep = "&quot;"; } // "
if rep.len > 0 {
if i > start { acc = concat(acc, string.{ ptr = @s[start], len = i - start }); }
acc = concat(acc, rep);
start = i + 1;
}
i += 1;
}
if start == 0 { return s; }
if start < s.len { acc = concat(acc, string.{ ptr = @s[start], len = s.len - start }); }
return acc;
}
adm_sha12 :: (sha: string) -> string {
if sha.len < 12 { return sha; }
return string.{ ptr = sha.ptr, len = 12 };
}
adm_pad2 :: (v: i64) -> string {
if v < 10 { return concat("0", int_to_string(v)); }
return int_to_string(v);
}
// Epoch seconds as a compact UTC stamp ("2026-06-12 14:03Z"); 0 renders
// as a dash (the model's "never / not yet"). Civil-from-days conversion.
adm_time :: (secs: i64) -> string {
if secs <= 0 { return "&mdash;"; }
days := secs / 86400;
rem := secs % 86400;
z := days + 719468;
era := z / 146097;
doe := z - era * 146097;
yoe := (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
y := yoe + era * 400;
doy := doe - (365 * yoe + yoe / 4 - yoe / 100);
mp := (5 * doy + 2) / 153;
d := doy - (153 * mp + 2) / 5 + 1;
m := mp + 3;
if mp >= 10 { m = mp - 9; }
if m <= 2 { y += 1; }
s := concat(int_to_string(y), concat("-", adm_pad2(m)));
s = concat(s, concat("-", adm_pad2(d)));
s = concat(s, concat(" ", adm_pad2(rem / 3600)));
s = concat(s, concat(":", adm_pad2((rem % 3600) / 60)));
return concat(s, "Z");
}
// Byte count as a human size ("8 B", "12.4 KB", "3.1 MB").
adm_size :: (bytes: i64) -> string {
if bytes < 1024 { return concat(int_to_string(bytes), " B"); }
unit := " KB";
scaled := bytes * 10 / 1024;
if scaled >= 10240 {
unit = " MB";
scaled = bytes * 10 / (1024 * 1024);
}
if scaled >= 10240 {
unit = " GB";
scaled = bytes * 10 / (1024 * 1024 * 1024);
}
s := concat(int_to_string(scaled / 10), concat(".", int_to_string(scaled % 10)));
return concat(s, unit);
}
adm_has_str :: (l: *List(string), s: string) -> bool {
i := 0;
while i < l.len {
if l.items[i] == s { return true; }
i += 1;
}
return false;
}
// ── responses ─────────────────────────────────────────────────────────
adm_error :: (client: i32, code: i64, fail_code: string, fail_message: string) {
f : jout.CliFailure = .{ code = fail_code, message = fail_message };
raw : [4096]u8 = ---;
werr := false;
n := jout.write_error(f, string.{ ptr = @raw[0], len = 4096 }) catch { werr = true; 0 };
body := "{\"status\":\"error\"}";
if !werr { body = string.{ ptr = @raw[0], len = n }; }
http.respond(client, code, "application/json", "", body);
}
adm_load :: (client: i32, store_dir: string) -> ?Repo {
if !db.store_exists(store_dir) {
adm_error(client, 503, "store.load",
concat("no store database (nothing published yet): ", store_dir));
return null;
}
loaded, le := db.load(store_dir);
if le {
adm_error(client, 503, "store.load",
concat("the store database could not be loaded: ", store_dir));
return null;
}
return loaded;
}
adm_html :: (client: i32, page: string) {
http.respond(client, 200, "text/html; charset=utf-8", "", page);
}
// ── chrome ────────────────────────────────────────────────────────────
ADM_STYLE :: "<style>body{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;background:#101216;color:#d6dae1;margin:1.5rem auto;max-width:76rem;padding:0 1rem;font-size:14px}h1{font-size:1rem;letter-spacing:.04em;margin:0 0 .2rem}h2{font-size:.9rem;margin:1.6rem 0 .4rem;color:#aeb6c4}nav{margin:.2rem 0 1.2rem;color:#3a4150}nav a{margin-right:1rem}nav a.on{color:#d6dae1}table{border-collapse:collapse;width:100%;font-size:.85rem;white-space:nowrap}td,th{text-align:left;padding:.28rem .9rem .28rem 0;border-bottom:1px solid #1d212a;vertical-align:top}th{color:#8b93a3;font-weight:normal}a{color:#7ab7ff;text-decoration:none}a:hover{text-decoration:underline}small,.dim{color:#737b8c}.twrap{overflow-x:auto}.chip{display:inline-block;padding:.02rem .45rem;border-radius:.6rem;font-size:.75rem;border:1px solid #2a2f3a;color:#8b93a3}.c-ok{color:#7dd17a;border-color:#2c4a2c}.c-warn{color:#e0c068;border-color:#4a432c}.c-bad{color:#e07a7a;border-color:#4a2c2c}td.wrap{white-space:normal}</style>";
// Page head + the quiet nav row; `active` marks the current section
// ("apps" | "tokens" | "audit").
adm_head :: (title: string, active: string) -> string {
s := concat("<!doctype html><html><head><meta charset=\"utf-8\"><meta name=\"viewport\" content=\"width=device-width,initial-scale=1\"><title>", concat(adm_esc(title), "</title>"));
s = concat(s, ADM_STYLE);
s = concat(s, concat("</head><body><h1>", concat(adm_esc(title), "</h1><nav>")));
apps_cls := ""; toks_cls := ""; audit_cls := "";
if active == "apps" { apps_cls = " class=\"on\""; }
if active == "tokens" { toks_cls = " class=\"on\""; }
if active == "audit" { audit_cls = " class=\"on\""; }
s = concat(s, concat(concat("<a href=\"/admin\"", apps_cls), ">apps</a>"));
s = concat(s, concat(concat("<a href=\"/admin/tokens\"", toks_cls), ">tokens</a>"));
s = concat(s, concat(concat("<a href=\"/admin/audit\"", audit_cls), ">audit</a>"));
return concat(s, "<a href=\"/\">public index</a></nav>");
}
ADM_FOOT :: "</body></html>";
adm_chip :: (label: string, cls: string) -> string {
s := concat("<span class=\"chip", cls);
return concat(s, concat("\">", concat(adm_esc(label), "</span>")));
}
adm_validation_chip :: (s: ValidationStatus) -> string {
if s == .valid { return adm_chip("valid", " c-ok"); }
if s == .invalid { return adm_chip("invalid", " c-bad"); }
return adm_chip("pending", " c-warn");
}
// The three iOS install modes must be visually distinct (PLAN.md "iOS
// Install Policy"): artifact-only is the quiet default, the other two
// carry their install-flow claim in the label.
adm_ios_chip :: (m: IosMode) -> string {
if m == .testflight { return adm_chip("ios: testflight", " c-ok"); }
if m == .enterprise { return adm_chip("ios: enterprise/MDM", " c-warn"); }
return adm_chip("ios: artifact-only", "");
}
adm_token_chip :: (status: string) -> string {
if status == "active" { return adm_chip("active", " c-ok"); }
if status == "revoked" { return adm_chip("revoked", " c-bad"); }
return adm_chip("expired", " c-warn");
}
adm_app_link :: (slug: string) -> string {
s := concat("<a href=\"/admin/apps/", adm_esc(slug));
return concat(s, concat("\">", concat(adm_esc(slug), "</a>")));
}
adm_release_link :: (id: string, label: string) -> string {
s := concat("<a href=\"/admin/releases/", adm_esc(id));
return concat(s, concat("\">", concat(adm_esc(label), "</a>")));
}
// ── /admin — apps overview ────────────────────────────────────────────
render_admin_apps :: (repo: *Repo) -> string {
page := adm_head("dist admin — apps", "apps");
if repo.apps.len == 0 {
page = concat(page, "<p class=\"dim\">nothing published yet</p>");
return concat(page, ADM_FOOT);
}
page = concat(page, "<div class=\"twrap\"><table><tr><th>app</th><th>name</th><th>visibility</th><th>ios mode</th><th>platforms</th><th>channels</th><th>releases</th><th>latest</th><th>published</th></tr>");
a := 0;
while a < repo.apps.len {
app := repo.apps.items[a];
a += 1;
// Platform coverage: distinct platforms across the app's artifacts.
plats : List(string) = .{};
nrel := 0;
latest : Release = .{};
i := 0;
while i < repo.artifacts.len {
art := repo.artifacts.items[i];
if art.app_id == app.id {
ps := db.platform_str(art.platform);
if !adm_has_str(@plats, ps) { plats.append(ps, context.allocator); }
}
i += 1;
}
i = 0;
while i < repo.releases.len {
r := repo.releases.items[i];
if r.app_id == app.id { nrel += 1; latest = r; }
i += 1;
}
nchan := 0;
i = 0;
while i < repo.channels.len {
if repo.channels.items[i].app_id == app.id { nchan += 1; }
i += 1;
}
plist := "";
i = 0;
while i < plats.len {
if i > 0 { plist = concat(plist, " "); }
plist = concat(plist, plats.items[i]);
i += 1;
}
if plist.len == 0 { plist = "&mdash;"; }
row := concat("<tr><td>", concat(adm_app_link(app.slug), "</td><td>"));
row = concat(row, concat(adm_esc(app.display_name), "</td><td>"));
row = concat(row, concat(db.visibility_str(app.visibility), "</td><td>"));
row = concat(row, concat(adm_ios_chip(app.ios_mode), "</td><td>"));
row = concat(row, concat(plist, "</td><td>"));
row = concat(row, concat(int_to_string(nchan), "</td><td>"));
row = concat(row, concat(int_to_string(nrel), "</td><td>"));
if nrel > 0 {
row = concat(row, concat(adm_release_link(latest.id, latest.version), "</td><td>"));
row = concat(row, concat(adm_time(latest.published_at), "</td></tr>"));
} else {
row = concat(row, "&mdash;</td><td>&mdash;</td></tr>");
}
page = concat(page, row);
}
page = concat(page, "</table></div>");
return concat(page, ADM_FOOT);
}
// ── /admin/apps/<slug> — app detail ───────────────────────────────────
render_admin_app :: (repo: *Repo, app: App) -> string {
page := adm_head(concat("dist admin — ", app.slug), "apps");
page = concat(page, "<h2>app</h2><div class=\"twrap\"><table>");
page = concat(page, concat("<tr><th>slug</th><td>", concat(adm_esc(app.slug), "</td></tr>")));
page = concat(page, concat("<tr><th>name</th><td>", concat(adm_esc(app.display_name), "</td></tr>")));
if app.owner.len > 0 {
page = concat(page, concat("<tr><th>owner</th><td>", concat(adm_esc(app.owner), "</td></tr>")));
}
page = concat(page, concat("<tr><th>visibility</th><td>", concat(db.visibility_str(app.visibility), "</td></tr>")));
page = concat(page, concat("<tr><th>ios install</th><td>", concat(adm_ios_chip(app.ios_mode), "</td></tr>")));
if app.testflight_url.len > 0 {
page = concat(page, concat("<tr><th>testflight</th><td>", concat(adm_esc(app.testflight_url), "</td></tr>")));
}
b := 0;
while b < app.bundle_ids.len {
bid := app.bundle_ids.items[b];
page = concat(page, concat("<tr><th>bundle id</th><td>", concat(db.platform_str(bid.platform), concat(": ", concat(adm_esc(bid.value), "</td></tr>")))));
b += 1;
}
page = concat(page, concat("<tr><th>created</th><td>", concat(adm_time(app.created_at), "</td></tr>")));
page = concat(page, concat("<tr><th>updated</th><td>", concat(adm_time(app.updated_at), "</td></tr>")));
page = concat(page, "</table></div>");
page = concat(page, "<h2>channels</h2><div class=\"twrap\"><table><tr><th>channel</th><th>current release</th><th>policy</th><th>rollout</th><th>retention</th><th>install page</th></tr>");
i := 0;
while i < repo.channels.len {
ch := repo.channels.items[i];
i += 1;
if ch.app_id != app.id { continue; }
row := concat("<tr><td>", concat(adm_esc(ch.name), "</td><td>"));
if ch.current_release_id.len > 0 {
row = concat(row, concat(adm_release_link(ch.current_release_id, ch.current_release_id), "</td><td>"));
} else {
row = concat(row, "&mdash;</td><td>");
}
row = concat(row, concat(db.policy_str(ch.policy), "</td><td>"));
row = concat(row, concat(int_to_string(ch.rollout_percent), "%</td><td>"));
if ch.retention_keep > 0 {
row = concat(row, concat("keep ", concat(int_to_string(ch.retention_keep), "</td><td>")));
} else {
row = concat(row, "keep all</td><td>");
}
row = concat(row, concat("<a href=\"/install/", concat(adm_esc(app.slug), concat("/", concat(adm_esc(ch.name), "\">install</a></td></tr>")))));
page = concat(page, row);
}
page = concat(page, "</table></div>");
page = concat(page, "<h2>releases <small>(newest first)</small></h2><div class=\"twrap\"><table><tr><th>version</th><th>build</th><th>channel</th><th>artifacts</th><th>created</th><th>published</th></tr>");
r := repo.releases.len;
while r > 0 {
r -= 1;
rel := repo.releases.items[r];
if rel.app_id != app.id { continue; }
narts := 0;
k := 0;
while k < repo.artifacts.len {
if repo.artifacts.items[k].release_id == rel.id { narts += 1; }
k += 1;
}
row := concat("<tr><td>", concat(adm_release_link(rel.id, rel.version), concat(" <small>", concat(adm_esc(rel.id), "</small></td><td>"))));
row = concat(row, concat(int_to_string(rel.build), "</td><td>"));
row = concat(row, concat(adm_esc(rel.channel), "</td><td>"));
row = concat(row, concat(int_to_string(narts), "</td><td>"));
row = concat(row, concat(adm_time(rel.created_at), "</td><td>"));
row = concat(row, concat(adm_time(rel.published_at), "</td></tr>"));
page = concat(page, row);
}
page = concat(page, "</table></div>");
return concat(page, ADM_FOOT);
}
// ── /admin/releases/<id> — release detail ─────────────────────────────
render_admin_release :: (repo: *Repo, rel: Release) -> string {
page := adm_head(concat("dist admin — ", rel.id), "apps");
app_slug := rel.app_id;
aq := repo.get_app(rel.app_id);
if aq != null { app_slug = aq!.slug; }
page = concat(page, "<h2>release</h2><div class=\"twrap\"><table>");
page = concat(page, concat("<tr><th>app</th><td>", concat(adm_app_link(app_slug), "</td></tr>")));
page = concat(page, concat("<tr><th>version</th><td>", concat(adm_esc(rel.version), "</td></tr>")));
page = concat(page, concat("<tr><th>build</th><td>", concat(int_to_string(rel.build), "</td></tr>")));
page = concat(page, concat("<tr><th>channel</th><td>", concat(adm_esc(rel.channel), "</td></tr>")));
if rel.notes.len > 0 {
page = concat(page, concat("<tr><th>notes</th><td class=\"wrap\">", concat(adm_esc(rel.notes), "</td></tr>")));
}
page = concat(page, concat("<tr><th>created by</th><td>", concat(adm_esc(rel.created_by), "</td></tr>")));
page = concat(page, concat("<tr><th>created</th><td>", concat(adm_time(rel.created_at), "</td></tr>")));
if rel.published_at > 0 {
page = concat(page, concat("<tr><th>published</th><td>", concat(adm_time(rel.published_at), "</td></tr>")));
} else {
page = concat(page, concat("<tr><th>published</th><td>", concat(adm_chip("draft", " c-warn"), "</td></tr>")));
}
// Channels currently serving this release.
pointed := "";
i := 0;
while i < repo.channels.len {
ch := repo.channels.items[i];
if ch.app_id == rel.app_id and ch.current_release_id == rel.id {
if pointed.len > 0 { pointed = concat(pointed, " "); }
pointed = concat(pointed, adm_esc(ch.name));
}
i += 1;
}
if pointed.len > 0 {
page = concat(page, concat("<tr><th>serving</th><td>", concat(pointed, "</td></tr>")));
}
page = concat(page, "</table></div>");
page = concat(page, "<h2>artifacts</h2><div class=\"twrap\"><table><tr><th>platform</th><th>file</th><th>type</th><th>size</th><th>sha256</th><th>validation</th></tr>");
i = 0;
while i < repo.artifacts.len {
art := repo.artifacts.items[i];
i += 1;
if art.release_id != rel.id { continue; }
row := concat("<tr><td>", concat(db.platform_str(art.platform), "</td><td>"));
row = concat(row, concat("<a href=\"/download/", concat(art.sha256, concat("\">", concat(adm_esc(art.filename), "</a></td><td>")))));
row = concat(row, concat(adm_esc(art.content_type), "</td><td>"));
row = concat(row, concat(adm_size(art.size_bytes), "</td><td><small>"));
row = concat(row, concat(adm_sha12(art.sha256), "&hellip;</small></td><td>"));
row = concat(row, concat(adm_validation_chip(art.validation_status), "</td></tr>"));
page = concat(page, row);
}
page = concat(page, "</table></div>");
// Timeline: audit events that touch this release — by target, by
// metadata (promote/rollback carry the release id there), or an
// artifact event whose target id is namespaced under the release id.
page = concat(page, "<h2>timeline</h2><div class=\"twrap\"><table><tr><th>time</th><th>actor</th><th>action</th><th>target</th><th>detail</th></tr>");
artifact_prefix := concat(rel.id, "-");
i = 0;
while i < repo.audit_events.len {
e := repo.audit_events.items[i];
i += 1;
hit := e.target_id == rel.id or e.metadata == rel.id;
if !hit and e.target_type == "artifact" and adm_starts_with(e.target_id, artifact_prefix) { hit = true; }
if !hit { continue; }
row := concat("<tr><td>", concat(adm_time(e.created_at), "</td><td>"));
row = concat(row, concat(adm_esc(e.actor), "</td><td>"));
row = concat(row, concat(adm_esc(e.action), "</td><td>"));
row = concat(row, concat(adm_esc(e.target_type), concat(": ", concat(adm_esc(e.target_id), "</td><td class=\"wrap\">"))));
row = concat(row, concat(adm_esc(e.metadata), "</td></tr>"));
page = concat(page, row);
}
page = concat(page, "</table></div>");
return concat(page, ADM_FOOT);
}
// ── /admin/tokens ─────────────────────────────────────────────────────
render_admin_tokens :: (repo: *Repo) -> string {
page := adm_head("dist admin — tokens", "tokens");
if repo.tokens.len == 0 {
page = concat(page, "<p class=\"dim\">no tokens (mint one: dist token create)</p>");
return concat(page, ADM_FOOT);
}
now := pl.now_secs();
page = concat(page, "<div class=\"twrap\"><table><tr><th>name</th><th>id</th><th>status</th><th>scopes</th><th>app</th><th>channel</th><th>created</th><th>expires</th><th>last used</th></tr>");
i := 0;
while i < repo.tokens.len {
t := repo.tokens.items[i];
i += 1;
row := concat("<tr><td>", concat(adm_esc(t.name), "</td><td><small>"));
row = concat(row, concat(adm_esc(t.id), "</small></td><td>"));
row = concat(row, concat(adm_token_chip(tops.token_status(t, now)), "</td><td>"));
row = concat(row, concat(adm_esc(t.scopes), "</td><td>"));
row = concat(row, concat(if t.app_slug.len > 0 then adm_esc(t.app_slug) else "any", "</td><td>"));
row = concat(row, concat(if t.channel.len > 0 then adm_esc(t.channel) else "any", "</td><td>"));
row = concat(row, concat(adm_time(t.created_at), "</td><td>"));
row = concat(row, concat(if t.expires_at > 0 then adm_time(t.expires_at) else "never", "</td><td>"));
row = concat(row, concat(adm_time(t.last_used_at), "</td></tr>"));
page = concat(page, row);
}
page = concat(page, "</table></div>");
return concat(page, ADM_FOOT);
}
// ── /admin/audit ──────────────────────────────────────────────────────
render_admin_audit :: (repo: *Repo) -> string {
page := adm_head("dist admin — audit", "audit");
if repo.audit_events.len == 0 {
page = concat(page, "<p class=\"dim\">no audit events yet</p>");
return concat(page, ADM_FOOT);
}
page = concat(page, "<div class=\"twrap\"><table><tr><th>time</th><th>actor</th><th>action</th><th>target</th><th>detail</th></tr>");
i := repo.audit_events.len;
while i > 0 {
i -= 1;
e := repo.audit_events.items[i];
row := concat("<tr><td>", concat(adm_time(e.created_at), "</td><td>"));
row = concat(row, concat(adm_esc(e.actor), "</td><td>"));
row = concat(row, concat(adm_esc(e.action), "</td><td>"));
row = concat(row, concat(adm_esc(e.target_type), concat(": ", concat(adm_esc(e.target_id), "</td><td class=\"wrap\">"))));
row = concat(row, concat(adm_esc(e.metadata), "</td></tr>"));
page = concat(page, row);
}
page = concat(page, "</table></div>");
return concat(page, ADM_FOOT);
}
// ── routing (distd delegates every /admin path here) ─────────────────
handle_admin :: (client: i32, store_dir: string, path: string) {
rq := adm_load(client, store_dir);
if rq == null { return; }
repo := rq!;
if path == "/admin" or path == "/admin/" {
adm_html(client, render_admin_apps(@repo));
return;
}
if path == "/admin/tokens" {
adm_html(client, render_admin_tokens(@repo));
return;
}
if path == "/admin/audit" {
adm_html(client, render_admin_audit(@repo));
return;
}
if adm_starts_with(path, "/admin/apps/") {
slug := adm_tail(path, "/admin/apps/");
aq := repo.find_app_by_slug(slug);
if aq == null {
adm_error(client, 404, "admin.unknown_app",
concat("no app with that slug in the store: ", slug));
return;
}
adm_html(client, render_admin_app(@repo, aq!));
return;
}
if adm_starts_with(path, "/admin/releases/") {
id := adm_tail(path, "/admin/releases/");
relq := repo.get_release(id);
if relq == null {
adm_error(client, 404, "admin.unknown_release",
concat("no release with that id in the store: ", id));
return;
}
adm_html(client, render_admin_release(@repo, relq!));
return;
}
adm_error(client, 404, "http.not_found", concat("no admin route for ", path));
}

View File

@@ -70,6 +70,7 @@ jout :: #import "../json_out.sx";
// call sites that mirror dist.sx's.
pl :: #import "../publish/publish.sx";
ops :: #import "../release/ops.sx";
adm :: #import "admin.sx";
// Aliased so the json reader is called as `jsrv.parse` — a bare `parse`
// would bind to `std.cli`'s once both modules share the `dist` program.
jsrv :: #import "modules/std/json.sx";
@@ -245,7 +246,7 @@ short_sha :: (sha: string) -> string {
INDEX_HEAD :: "<!doctype html><html><head><meta charset=\"utf-8\"><meta name=\"viewport\" content=\"width=device-width,initial-scale=1\"><title>dist</title><style>body{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;background:#101216;color:#d6dae1;margin:2rem auto;max-width:60rem;padding:0 1rem}h1{font-size:1.1rem;letter-spacing:.04em}h2{font-size:1rem;margin-top:2rem;border-bottom:1px solid #2a2f3a;padding-bottom:.3rem}table{border-collapse:collapse;width:100%;font-size:.85rem}td,th{text-align:left;padding:.3rem .8rem .3rem 0;border-bottom:1px solid #1d212a;vertical-align:top}th{color:#8b93a3;font-weight:normal}a{color:#7ab7ff;text-decoration:none}a:hover{text-decoration:underline}small,.dim{color:#737b8c}</style></head><body><h1>dist — release console</h1>";
INDEX_FOOT :: "<p class=\"dim\"><small>API: <a href=\"/api/apps\">/api/apps</a> &middot; <a href=\"/healthz\">/healthz</a></small></p></body></html>";
INDEX_FOOT :: "<p class=\"dim\"><small><a href=\"/admin\">admin console</a> &middot; API: <a href=\"/api/apps\">/api/apps</a> &middot; <a href=\"/healthz\">/healthz</a></small></p></body></html>";
// Server-rendered index: every app with its channels and its releases'
// artifacts as direct /download links. Dense and read-only, per the
@@ -978,6 +979,10 @@ route :: (client: i32, store_dir: string, req: *http.Request) -> i64 {
handle_install_route(client, store_dir, req, tail_after(path, "/install/"));
return 200;
}
if path == "/admin" or starts_with(path, "/admin/") {
adm.handle_admin(client, store_dir, path);
return 200;
}
respond_error(client, 404, "http.not_found",
concat("no route for ", path));
return 404;