Public: anyone can read this; the crew can change it.
The /forges and /forges/new pages speak the home page's language, with looks before and after #9703
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/9647-the-forges-and-forges-new-pages-speak-th → main 5 commits fork 3 threads · 3 unresolved
5 files
changed +75
−17
docs/features/forges-side-by-side.md +4
| @@ -5,3 +5,7 @@ | ||
| 5 | 5 | Visible to this forge's admins; anyone else gets `not_found`. |
| 6 | 6 | |
| 7 | 7 | The table is for comparing; the list below it stays for what needs doing (claim state, a fresh claim link, spend, next step). The terminal's "Forges you sent" card ends each line with the same size, use and heartbeat words. A cell that is not running reads "not running" under each use column; its limits are in the Size column. The disk reading is cached for 30 s per forge and forgotten after five minutes unasked. |
| 8 | ||
| 9 | ## The pages' shape (#9647) | |
| 10 | ||
| 11 | `/forges` is plain rows, no boxed cards: each forge's line starts with who it is for and the apps it was given, then its state (awake, size, spend, last activity, claim state). `/forges/new` asks the name, who it is for, what it is for, the apps and an optional fresh app up front; follow mode, crew, owner access, model key, API base URL, budget, template and size sit behind "More options", inside the same form so they submit as before. The disclosure opens by itself when the door refuses a value in one of them. No field or action was removed. The terminal's forge form is unchanged. |
src/web/pages/forges.ts +7 −10
| @@ -44,11 +44,11 @@ function spendNote(r: ClientRow): Html | null { | ||
| 44 | 44 | function forgeRow(r: ClientRow): Html { |
| 45 | 45 | const state = claimState(r); |
| 46 | 46 | const meta = [ |
| 47 | r.for === null ? null : html`for ${r.for}`, | |
| 48 | r.apps.length === 0 ? null : html`${r.apps.join(", ")}`, | |
| 47 | 49 | state === "unknown" ? html`it did not answer just now` : null, |
| 48 | 50 | r.made ? badge(r.awake === true ? "awake" : "asleep", r.awake === true ? { tone: "success" } : { variant: "outline" }) : null, |
| 49 | 51 | r.made ? html`${sizeWords(r.size)}` : null, |
| 50 | r.for === null ? null : html`for ${r.for}`, | |
| 51 | r.apps.length === 0 ? null : html`${r.apps.join(", ")}`, | |
| 52 | 52 | runningNote(r), |
| 53 | 53 | spendNote(r), |
| 54 | 54 | r.sentAt === null ? null : html`sent ${time(r.sentAt)}`, |
| @@ -144,7 +144,7 @@ export async function forgesPage(ctx: Ctx): Promise<Response> { | ||
| 144 | 144 | title: "Forges you sent", |
| 145 | 145 | body: html`<div class="narrow-wide" data-forges> |
| 146 | 146 | ${pageHead("Forges you sent", { |
| 147 | sub: "For whom each is, what it was given, and whether its person has claimed it.", | |
| 147 | sub: "Each forge you sent: who it is for and what runs there.", | |
| 148 | 148 | actions: action, |
| 149 | 149 | })} |
| 150 | 150 | ${rows.length === 0 |
| @@ -154,15 +154,12 @@ export async function forgesPage(ctx: Ctx): Promise<Response> { | ||
| 154 | 154 | note: "A whole forge for someone: the apps you choose, a crew, and one link to send.", |
| 155 | 155 | action, |
| 156 | 156 | }) |
| 157 | : card(sideBySide(rows), { pad: false, id: "forges-compare" })} | |
| 157 | : html`<section id="forges-compare">${sideBySide(rows)}</section>`} | |
| 158 | 158 | ${rows.length === 0 |
| 159 | 159 | ? null |
| 160 | : card( | |
| 161 | html`<ul class="list setting-list"> | |
| 162 | ${rows.map(forgeRow)} | |
| 163 | </ul>`, | |
| 164 | { pad: false }, | |
| 165 | )} | |
| 160 | : html`<ul class="list setting-list"> | |
| 161 | ${rows.map(forgeRow)} | |
| 162 | </ul>`} | |
| 166 | 163 | <p class="mt"><a href="/fleet/graph">Fleet graph</a></p> |
| 167 | 164 | </div>`, |
| 168 | 165 | }); |
src/web/pages/new-forge.ts +13 −7
| @@ -66,6 +66,9 @@ const EMPTY: Values = { | ||
| 66 | 66 | hadKey: false, |
| 67 | 67 | }; |
| 68 | 68 | |
| 69 | /** The fields behind "More options": an error on one opens it. */ | |
| 70 | const MORE_FIELDS = ["followMode", "crew", "keepAccess", "modelKey", "modelEndpoint", "budgetUsd", "template", "size"]; | |
| 71 | ||
| 69 | 72 | type Errors = Partial<Record<string, string>>; |
| 70 | 73 | |
| 71 | 74 | const all = (form: Form, name: string): string[] => |
| @@ -152,11 +155,11 @@ function formView( | ||
| 152 | 155 | return html`<div class="narrow-wide" data-forges> |
| 153 | 156 | ${pageHead("New forge", { |
| 154 | 157 | sub: anon |
outdated
Principle 1: this sub-line, the hosting-terms paragraph below it and the 'Never copied' paragraph all explain the page. Keep one. |
||
| 155 | ? "Your own forge: pick a name, and the public apps to start from. Whoever opens the claim link becomes its admin." | |
| 156 | : "Your own forge, or one for someone else: the apps you choose, a crew, and one link to claim.", | |
| 158 | ? "Pick a name and the apps to start from. Whoever opens the claim link becomes its admin." | |
| 159 | : "Pick a name and the apps to start from; you get one link to send.", | |
| 157 | 160 | actions: btn("Forges you sent", { href: "/forges", size: "sm" }), |
| 158 | 161 | })} |
| 159 | <p class="muted small" id="hosting-terms">Free within limits while hosting is not sold: a few forges per person, and a forge sleeps when idle and wakes on its next visit. Its crew stays off until you bring your own model credential.</p> | |
| 162 | <p class="muted small" id="hosting-terms">Free within limits: it sleeps when idle, and its crew stays off until you bring a model key.</p> | |
| 160 | 163 | ${quota ? html`<p role="status" id="quota-full">This forge cannot hold another child: ${quota}.</p>` : null} |
| 161 | 164 | ${errors["form"] |
| 162 | 165 | ? html`<p class="error mb" role="alert">${errors["form"]}</p>` |
| @@ -179,7 +182,7 @@ function formView( | ||
| 179 | 182 | label: "For whom", |
| 180 | 183 | name: "for", |
| 181 | 184 | value: values.forWhom, |
| 182 | hint: `A person, a client or an organisation, for your list of forges sent (at most ${MAX_FOR_CHARS} characters). Never copied into the child.`, | |
| 185 | hint: `A person or a client, for your list (at most ${MAX_FOR_CHARS} characters). Not copied into the forge.`, | |
| 183 | 186 | ...(errors["for"] ? { error: errors["for"] } : {}), |
| 184 | 187 | })} |
| 185 | 188 | ${formField({ |
| @@ -209,6 +212,8 @@ function formView( | ||
| 209 | 212 | hint: `One line on what it is; the child gets a small app to start from (at most ${MAX_FRESH_CHARS} characters).`, |
outdated
Principle 3, and this is untested. Add a test: a bad hidden field gives |
||
| 210 | 213 | ...(errors["fresh"] ? { error: errors["fresh"] } : {}), |
| 211 | 214 | })} |
| 215 | <details class="disclosure" ${Object.keys(errors).some((k) => MORE_FIELDS.includes(k)) ? html`open` : null}> | |
| 216 | <summary>More options</summary> | |
| 212 | 217 | <fieldset class="field" ${errors["followMode"] ? html`data-invalid` : null}> |
| 213 | 218 | <legend>How it follows this forge's code</legend> |
| 214 | 219 | <label class="check"> |
| @@ -307,9 +312,10 @@ function formView( | ||
| 307 | 312 | options: sizeOptions(values.size), |
| 308 | 313 | ...(errors["size"] ? { error: errors["size"] } : {}), |
| 309 | 314 | })} |
| 310 | <p class="muted small"> | |
| 311 | Never copied: ${LEFT_BEHIND.join(", ")}. The child mints its own. | |
| 312 | </p> | |
| 315 | <p class="muted small"> | |
| 316 | Never copied: ${LEFT_BEHIND.join(", ")}. The child mints its own. | |
| 317 | </p> | |
| 318 | </details> | |
| 313 | 319 | <div class="form-actions"> |
| 314 | 320 | <button class="btn" type="submit" name="do" value="plan"> |
| 315 | 321 | See the plan |
test/client-list.test.ts +5
| @@ -199,6 +199,11 @@ describe("the client list", () => { | ||
| 199 | 199 | const maria = html.slice(html.indexOf('data-forge="maria"')); |
| 200 | 200 | expect(maria).toContain("Maria's bakery"); |
| 201 | 201 | expect(maria).toContain("alice/bakery"); |
| 202 | // One plain line first: for whom, then the apps; the state facts follow. | |
| 203 | const line = maria.slice(0, maria.indexOf("</li>")); | |
| 204 | expect(line.indexOf("Maria's bakery")).toBeLessThan(line.indexOf("alice/bakery")); | |
| 205 | expect(line.indexOf("alice/bakery")).toBeLessThan(line.indexOf("has not reported yet")); | |
| 206 | expect(line).not.toContain('class="card'); | |
| 202 | 207 | expect(maria).toContain('href="https://maria--forge.test"'); |
| 203 | 208 | expect(maria).toMatch(/data-claimed="no"/); |
| 204 | 209 | expect(html).not.toContain("forges-sent"); |
test/new-forge.test.ts +46
| @@ -382,3 +382,49 @@ describe("+ New forge", () => { | ||
| 382 | 382 | expect(childrenForMemory(GiB)).toBe(5); |
| 383 | 383 | }); |
| 384 | 384 | }); |
| 385 | ||
| 386 | describe("/forges/new: More options (#9647)", () => { | |
| 387 | let dir = ""; | |
| 388 | let app: App; | |
| 389 | let servers: Servers; | |
| 390 | let cookie = ""; | |
| 391 | beforeAll(async () => { | |
| 392 | dir = await mkdtemp(join(tmpdir(), "forge-new-forge-more-")); | |
| 393 | app = createApp({ dataDir: dir, url: "https://forge.test", port: 0 }); | |
| 394 | const a = await createUser(app.db, { origin: "seed", trusted: true, username: "alice", email: "[email protected]", password: PASSWORD, isAdmin: true }); | |
| 395 | if (!a.ok) throw new Error(a.error); | |
| 396 | servers = await serve(app); | |
| 397 | const res = await fetch(`${servers.url}/signin`, { method: "POST", body: new URLSearchParams({ username: "alice", password: PASSWORD }), redirect: "manual" }); | |
| 398 | cookie = (res.headers.get("set-cookie") ?? "").split(";")[0]!; | |
| 399 | }); | |
| 400 | afterAll(async () => { | |
| 401 | servers.stop(); | |
| 402 | app.close(); | |
| 403 | await rm(dir, { recursive: true, force: true }); | |
| 404 | }); | |
| 405 | const post = async (form: [string, string][]) => | |
| 406 | (await fetch(`${servers.url}/forges/new`, { method: "POST", headers: { cookie, origin: "https://forge.test" }, body: new URLSearchParams(form), redirect: "manual" })).text(); | |
| 407 | const opening = (html: string) => /<details class="disclosure"[^>]*>/.exec(html)?.[0] ?? ""; | |
| 408 | ||
| 409 | test("a fresh form keeps the rarer fields in a closed disclosure inside the form, so they submit", async () => { | |
| 410 | const html = await (await fetch(`${servers.url}/forges/new`, { headers: { cookie } })).text(); | |
| 411 | expect(opening(html)).not.toContain("open"); | |
| 412 | const form = html.slice(html.indexOf('id="new-forge"')); | |
| 413 | const more = form.slice(form.indexOf("<summary>More options</summary>"), form.indexOf("</details>")); | |
| 414 | expect(more.length).toBeGreaterThan(0); | |
| 415 | for (const name of ["followMode", "keepAccess", "modelKey", "modelEndpoint", "budgetUsd", "template", "size"]) | |
| 416 | expect(more).toContain(`name="${name}"`); | |
| 417 | expect(form.indexOf("</details>")).toBeLessThan(form.indexOf("</form>")); | |
| 418 | // The visible fields stay out of it. | |
| 419 | expect(form.indexOf('name="direction"')).toBeLessThan(form.indexOf("<summary>More options</summary>")); | |
| 420 | }); | |
| 421 | test("a bad value in a hidden field opens it", async () => { | |
| 422 | const html = await post([["name", "okname"], ["budgetUsd", "not-a-number"], ["modelKey", KEY]]); | |
| 423 | expect(opening(html)).toContain("open"); | |
| 424 | }); | |
| 425 | test("an error on the name alone leaves it closed", async () => { | |
| 426 | const html = await post([["name", "Bad Name!"]]); | |
| 427 | expect(html).toContain("name"); | |
| 428 | expect(opening(html)).not.toContain("open"); | |
| 429 | }); | |
| 430 | }); |
Meta order changed (who it is for, then apps first). Pin it with a test so the 'one plain line each' reading cannot regress.