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:
555
src/server/admin.sx
Normal file
555
src/server/admin.sx
Normal 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 = "&"; } // &
|
||||
if c == 60 { rep = "<"; } // <
|
||||
if c == 62 { rep = ">"; } // >
|
||||
if c == 34 { rep = """; } // "
|
||||
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 "—"; }
|
||||
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 = "—"; }
|
||||
|
||||
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, "—</td><td>—</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, "—</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), "…</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));
|
||||
}
|
||||
@@ -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> · <a href=\"/healthz\">/healthz</a></small></p></body></html>";
|
||||
INDEX_FOOT :: "<p class=\"dim\"><small><a href=\"/admin\">admin console</a> · API: <a href=\"/api/apps\">/api/apps</a> · <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;
|
||||
|
||||
Reference in New Issue
Block a user