P4.2: HTML index at / — the install-page seed
GET / now serves a dense server-rendered console: each app with its channels' current releases and every release's artifacts as direct /download/<sha256> links. Read-only off the per-request db.json reload; text escaped for HTML. A browser hitting the server root sees the store instead of a 404.
This commit is contained in:
@@ -45,7 +45,7 @@ emit_human :: (s: string, json_mode: bool) {
|
|||||||
if json_mode { eputs(s); } else { out(s); }
|
if json_mode { eputs(s); } else { out(s); }
|
||||||
}
|
}
|
||||||
|
|
||||||
HELP :: "dist — application distribution CLI\n\nUsage:\n dist <group> <command> [flags]\n\nGroups & commands:\n ci\n ci publish publish a release from CI\n --manifest <path> publish manifest (dist.json) to read\n --local-store <dir> local artifact store + db.json directory\n release\n release promote point a channel at a release\n --app <slug> app the channel belongs to\n --channel <name> channel to move\n --release <id> release id to promote\n --local-store <dir> local artifact store + db.json directory\n release rollback move a channel back to its previous release\n --app <slug> app the channel belongs to\n --channel <name> channel to roll back\n --local-store <dir> local artifact store + db.json directory\n server\n server run serve the store read-only over HTTP (0.0.0.0)\n --local-store <dir> local artifact store + db.json directory\n --port <n> TCP port (default 8787)\n routes: /healthz, /api/apps, /api/apps/<slug>, /download/<sha256>\n\nGlobal flags:\n --json emit machine-readable JSON on stdout; human text to stderr\n -h, --help show this help and exit\n\nExit codes:\n 0 success\n 1 command failed (publish/promote/rollback aborted or server could not bind)\n 64 usage error (no command, or an unknown/missing command or flag)\n";
|
HELP :: "dist — application distribution CLI\n\nUsage:\n dist <group> <command> [flags]\n\nGroups & commands:\n ci\n ci publish publish a release from CI\n --manifest <path> publish manifest (dist.json) to read\n --local-store <dir> local artifact store + db.json directory\n release\n release promote point a channel at a release\n --app <slug> app the channel belongs to\n --channel <name> channel to move\n --release <id> release id to promote\n --local-store <dir> local artifact store + db.json directory\n release rollback move a channel back to its previous release\n --app <slug> app the channel belongs to\n --channel <name> channel to roll back\n --local-store <dir> local artifact store + db.json directory\n server\n server run serve the store read-only over HTTP (0.0.0.0)\n --local-store <dir> local artifact store + db.json directory\n --port <n> TCP port (default 8787)\n routes: / (HTML index), /healthz, /api/apps, /api/apps/<slug>, /download/<sha256>\n\nGlobal flags:\n --json emit machine-readable JSON on stdout; human text to stderr\n -h, --help show this help and exit\n\nExit codes:\n 0 success\n 1 command failed (publish/promote/rollback aborted or server could not bind)\n 64 usage error (no command, or an unknown/missing command or flag)\n";
|
||||||
|
|
||||||
// True if `name` appears as a token in `args`.
|
// True if `name` appears as a token in `args`.
|
||||||
has_flag :: (args: []string, name: string) -> bool {
|
has_flag :: (args: []string, name: string) -> bool {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
// Serves the state the CLI publishes — db.json metadata and the
|
// Serves the state the CLI publishes — db.json metadata and the
|
||||||
// content-addressed objects — over HTTP (src/server/http.sx):
|
// content-addressed objects — over HTTP (src/server/http.sx):
|
||||||
//
|
//
|
||||||
|
// GET / HTML index: apps, channels, releases, links
|
||||||
// GET /healthz {"status":"ok"} — no store access
|
// GET /healthz {"status":"ok"} — no store access
|
||||||
// GET /api/apps {"apps":[...]} every app in the store
|
// GET /api/apps {"apps":[...]} every app in the store
|
||||||
// GET /api/apps/<slug> {"app":..,"releases":[..],"channels":[..]}
|
// GET /api/apps/<slug> {"app":..,"releases":[..],"channels":[..]}
|
||||||
@@ -180,6 +181,103 @@ respond_render :: (client: s32, buf: string, n: s64, overflowed: bool) {
|
|||||||
http.respond(client, 200, "application/json", "", string.{ ptr = buf.ptr, len = n });
|
http.respond(client, 200, "application/json", "", string.{ ptr = buf.ptr, len = n });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── HTML index (the install-page seed, subplan 04 Slice 5) ───────────
|
||||||
|
|
||||||
|
// Escape &, <, >, " for HTML text/attribute positions. Returns `s`
|
||||||
|
// unchanged (no copy) when nothing needs escaping.
|
||||||
|
html_escape :: (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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// First 12 hex chars of a digest, for display next to the full-link.
|
||||||
|
short_sha :: (sha: string) -> string {
|
||||||
|
if sha.len < 12 { return sha; }
|
||||||
|
return string.{ ptr = sha.ptr, len = 12 };
|
||||||
|
}
|
||||||
|
|
||||||
|
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>";
|
||||||
|
|
||||||
|
// Server-rendered index: every app with its channels and its releases'
|
||||||
|
// artifacts as direct /download links. Dense and read-only, per the
|
||||||
|
// product shape — the full admin UI is subplan 06.
|
||||||
|
render_index :: (repo: *Repo) -> string {
|
||||||
|
page := INDEX_HEAD;
|
||||||
|
if repo.apps.len == 0 {
|
||||||
|
page = concat(page, "<p class=\"dim\">nothing published yet</p>");
|
||||||
|
}
|
||||||
|
a := 0;
|
||||||
|
while a < repo.apps.len {
|
||||||
|
app := repo.apps.items[a];
|
||||||
|
page = concat(page, concat("<h2>", concat(html_escape(app.slug), concat(" <small>", concat(html_escape(app.display_name), "</small></h2>")))));
|
||||||
|
|
||||||
|
// Channels: name -> current release pointer.
|
||||||
|
page = concat(page, "<table><tr><th>channel</th><th>current release</th></tr>");
|
||||||
|
c := 0;
|
||||||
|
while c < repo.channels.len {
|
||||||
|
ch := repo.channels.items[c];
|
||||||
|
if ch.app_id == app.id {
|
||||||
|
page = concat(page, concat("<tr><td>", concat(html_escape(ch.name), concat("</td><td>", concat(html_escape(ch.current_release_id), "</td></tr>")))));
|
||||||
|
}
|
||||||
|
c += 1;
|
||||||
|
}
|
||||||
|
page = concat(page, "</table>");
|
||||||
|
|
||||||
|
// Releases with their artifacts as download links.
|
||||||
|
page = concat(page, "<table><tr><th>release</th><th>platform</th><th>artifact</th><th>size</th><th>sha256</th></tr>");
|
||||||
|
r := 0;
|
||||||
|
while r < repo.releases.len {
|
||||||
|
rel := repo.releases.items[r];
|
||||||
|
if rel.app_id == app.id {
|
||||||
|
k := 0;
|
||||||
|
while k < repo.artifacts.len {
|
||||||
|
art := repo.artifacts.items[k];
|
||||||
|
if art.release_id == rel.id {
|
||||||
|
row := concat("<tr><td>", concat(html_escape(rel.version), concat(" <small>", concat(html_escape(rel.id), "</small></td><td>"))));
|
||||||
|
row = concat(row, concat(db.platform_str(art.platform), "</td><td>"));
|
||||||
|
row = concat(row, concat("<a href=\"/download/", concat(art.sha256, concat("\">", concat(html_escape(art.filename), "</a></td><td>")))));
|
||||||
|
row = concat(row, concat(int_to_string(art.size_bytes), "</td><td><small>"));
|
||||||
|
row = concat(row, concat(short_sha(art.sha256), "…</small></td></tr>"));
|
||||||
|
page = concat(page, row);
|
||||||
|
}
|
||||||
|
k += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
r += 1;
|
||||||
|
}
|
||||||
|
page = concat(page, "</table>");
|
||||||
|
a += 1;
|
||||||
|
}
|
||||||
|
return concat(page, INDEX_FOOT);
|
||||||
|
}
|
||||||
|
|
||||||
|
handle_index :: (client: s32, store_dir: string) {
|
||||||
|
rq := load_or_503(client, store_dir);
|
||||||
|
if rq == null { return; }
|
||||||
|
repo := rq!;
|
||||||
|
http.respond(client, 200, "text/html; charset=utf-8", "", render_index(@repo));
|
||||||
|
}
|
||||||
|
|
||||||
// ── routes ────────────────────────────────────────────────────────────
|
// ── routes ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
handle_apps_index :: (client: s32, store_dir: string) {
|
handle_apps_index :: (client: s32, store_dir: string) {
|
||||||
@@ -235,6 +333,10 @@ route :: (client: s32, store_dir: string, method: string, path: string) -> s64 {
|
|||||||
"every distd route is GET for now (writes go through the dist CLI)");
|
"every distd route is GET for now (writes go through the dist CLI)");
|
||||||
return 405;
|
return 405;
|
||||||
}
|
}
|
||||||
|
if path == "/" {
|
||||||
|
handle_index(client, store_dir);
|
||||||
|
return 200;
|
||||||
|
}
|
||||||
if path == "/healthz" {
|
if path == "/healthz" {
|
||||||
http.respond(client, 200, "application/json", "", "{\"status\":\"ok\"}");
|
http.respond(client, 200, "application/json", "", "{\"status\":\"ok\"}");
|
||||||
return 200;
|
return 200;
|
||||||
|
|||||||
@@ -5,6 +5,8 @@
|
|||||||
// `build/dist server run` on a test port in the background, waits for
|
// `build/dist server run` on a test port in the background, waits for
|
||||||
// /healthz, and asserts every route against curl:
|
// /healthz, and asserts every route against curl:
|
||||||
//
|
//
|
||||||
|
// * / → HTML index naming the app, with a
|
||||||
|
// /download/<sha> link
|
||||||
// * /healthz → {"status":"ok"}
|
// * /healthz → {"status":"ok"}
|
||||||
// * /api/apps → the published app is listed
|
// * /api/apps → the published app is listed
|
||||||
// * /api/apps/<slug> → app + its releases + channels
|
// * /api/apps/<slug> → app + its releases + channels
|
||||||
@@ -48,6 +50,24 @@ write_file :: (path: string, body: string) {
|
|||||||
process.run(cmd);
|
process.run(cmd);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// True iff `needle` occurs in `hay` (plain scan; bodies are small).
|
||||||
|
contains :: (hay: string, needle: string) -> bool {
|
||||||
|
if needle.len == 0 { return true; }
|
||||||
|
if needle.len > hay.len { return false; }
|
||||||
|
i := 0;
|
||||||
|
while i + needle.len <= hay.len {
|
||||||
|
j := 0;
|
||||||
|
ok := true;
|
||||||
|
while j < needle.len {
|
||||||
|
if hay[i + j] != needle[j] { ok = false; break; }
|
||||||
|
j += 1;
|
||||||
|
}
|
||||||
|
if ok { return true; }
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
// GET `path` and return the body (curl; 2s timeout so a dead server fails
|
// GET `path` and return the body (curl; 2s timeout so a dead server fails
|
||||||
// the test instead of hanging it).
|
// the test instead of hanging it).
|
||||||
fetch :: (path: string) -> string {
|
fetch :: (path: string) -> string {
|
||||||
@@ -113,6 +133,12 @@ main :: () -> s32 {
|
|||||||
print(" server up\n");
|
print(" server up\n");
|
||||||
|
|
||||||
// ── routes ────────────────────────────────────────────────────────
|
// ── routes ────────────────────────────────────────────────────────
|
||||||
|
process.assert(fetch_code("/") == "200", "/ serves the HTML index");
|
||||||
|
idx := fetch("/");
|
||||||
|
process.assert(contains(idx, "<title>dist</title>"), "index is the dist HTML page");
|
||||||
|
process.assert(contains(idx, "acme-app"), "index names the published app");
|
||||||
|
process.assert(contains(idx, "/download/"), "index links artifact downloads");
|
||||||
|
|
||||||
hz := parse_body(fetch("/healthz"), "/healthz", xx arena);
|
hz := parse_body(fetch("/healthz"), "/healthz", xx arena);
|
||||||
process.assert(get_str(hz, "status") == "ok", "/healthz status ok");
|
process.assert(get_str(hz, "status") == "ok", "/healthz status ok");
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user