Public: anyone can read this; the crew can change it.

When the app is not up, the studio says why and gives one click back #47

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

mason/forge:mason/41-when-the-app-is-not-up-the-studio-says-w → main 2 commits fork

  • Merged. mason/41-when-the-app-is-not-up-the-studio-says-w was merged into main as 9826ca4.

Review

Sign in to review this change.
6 files changed +305 −79
docs/DESIGN.md +1 −1
@@ -187,7 +187,7 @@ specification.
187187 | **run state badge** `runStateBadge()` | `.badge` | `queued` · `running` (attn) · `ok` (success: _Passed_, or _Done_ for an agent run) · `failed` (danger) · `cancelled` (outline) | the one badge a check wears on a pull, a run wears in a list, and a log page wears in its head; the log page's script repaints it on `done` | `[✓ passed]` / `[✗ failed]` / `[· running]` |
188188 | **env state badge** `envStateBadge()` | `.badge` | `stopped` (outline) · `deploying` (attn) · `running` (success) · `failed` (danger) | what production, or a preview, is doing now; `envTone()` is the same answer as a `dot()` for a list row | the same word in the same tone after the environment's name |
189189 | **app strip** | `.app-strip` | — | one line under the repo header of a repository that runs: env state badge, `sha`, the production URL as a link **through `…/app/production/open`** (never the bare hostname — a private app would 404), the preview count, App | one line under the where strip: `● running abc1234 · hello.localhost` |
190 | **environment card** | `.card.env-card` (`.env-facts`, `.env-error`) | `data-tone=danger` when failed | name and state in the head; commit, uptime and the run in the body; a failed deploy's reason on its own line; Open / Redeploy / Stop in the foot — a card and not a row so an error has a whole line to be read on | a box per environment with the same three lines |
190 | **environment card** | `.card.env-card` (`.env-facts`, `.env-error`) | `data-tone=danger` when failed | name and state in the head; `envFacts()` in the body — commit, how fast it came up, uptime, a link to the deploy run, and the deployment's own note and error, each on its own line; Open plus `envActions()` (Redeploy / Stop, writers only) in the foot. The facts and the actions are functions, not markup: a pull's preview card and the studio head render the same ones | a box per environment with the same three lines |
191191 | **checks line** | `.checks-line` (`.ok`, `.bad`, `.now`) | — | inside the merge box, in the reviews chip's voice: `✓ Checks passed · deploy abc1234 · a passing deploy is required`. Rendered only where the repository runs as an app or requires checks; a merge box gains _Waiting on checks_ | the same line under the review line |
192192 | **run log** | `pre.run-log` | `data-stream` while the run is going | the log as written, wrapped so a phone never scrolls sideways; a script tails the SSE stream and follows the bottom until the reader scrolls up, a `<meta refresh>` does it without one | a scrolling pane that follows the tail |
193193 | **assignee card** | `.card` with an `.edit-row` form | — | who is on it, then one select and one button for a writer. People first, the crew last and marked `(agent)`, because assigning an agent is what starts it working and the choice is not the same kind of choice | a picker with people, then agents |
src/web/pages/apps.ts +4 −43
@@ -59,13 +59,14 @@ import {
5959 card,
6060 dot,
6161 empty,
62 envActions,
63 envFacts,
6264 envStateBadge,
6365 envTone,
6466 icon,
6567 listCard,
6668 row,
6769 selectField,
68 servedPhrase,
6970 sha,
7071 time,
7172 userLink,
@@ -473,36 +474,11 @@ export function appStrip(ctx: Ctx, repo: Repo): Html {
473474
474475 // ---- the app page ----
475476
476 const envFacts = (base: string, env: EnvStatus): Html =>
477 html`<div class="env-facts">
478 ${when(env.commitOid !== "", () =>
479 sha(env.commitOid, `${base}/commit/${env.commitOid}`),
480 )}
481 ${when(servedPhrase(env), (phrase) => html`<span>live ${phrase}</span>`)}
482 ${when(env.since, (since) => html`<span>up ${time(since)}</span>`)}
483 ${when(
484 env.runId,
485 (id) =>
486 html`<a href="${base}/runs/${id}"
487 >${env.state === "deploying"
488 ? "deploy in progress"
489 : env.note !== undefined
490 ? "verifying tests"
491 : "last deploy"}</a
492 >`,
493 )}
494 </div>`;
495
496477 /** One environment: what it is doing, the facts, and the buttons. */
497478 function envCard(base: string, env: EnvStatus, writer: boolean): Html {
498479 const number = envPull(env.env);
499480 return card(
500 html`${envFacts(base, env)}
501 <p class="env-facts mt">
502 <span class="mono truncate" title="${env.url}">${env.url}</span>
503 </p>
504 ${when(env.note, (note) => html`<p class="muted small">${note}</p>`)}
505 ${when(env.error, (error) => html`<p class="env-error">${error}</p>`)}`,
481 envFacts(base, env, { url: true }),
506482 {
507483 extra: "env-card",
508484 ...(env.state === "failed" || env.error !== undefined
@@ -521,20 +497,7 @@ function envCard(base: string, env: EnvStatus, writer: boolean): Html {
521497 variant: "outline",
522498 icon: "external",
523499 })}
524 ${when(
525 writer,
526 () =>
527 html`<form method="post" action="${base}/app/${env.env}/redeploy">
528 ${btn("Redeploy", { size: "sm", icon: "sync" })}
529 </form>
530 ${when(
531 env.state !== "stopped",
532 () =>
533 html`<form method="post" action="${base}/app/${env.env}/stop">
534 ${btn("Stop", { size: "sm", icon: "square" })}
535 </form>`,
536 )}`,
537 )}`,
500 ${envActions(base, env, writer)}`,
538501 },
539502 );
540503 }
@@ -718,8 +681,6 @@ export function previewCard(ctx: Ctx, repo: Repo, pull: Pull): Html {
718681 }
719682 return card(
720683 html`${envFacts(base, env)}
721 ${when(env.note, (note) => html`<p class="muted small">${note}</p>`)}
722 ${when(env.error, (error) => html`<p class="env-error">${error}</p>`)}
723684 <div class="bar mt">
724685 ${btn("Open preview", {
725686 href: `${base}/app/${env.env}/open`,
src/web/pages/studio.ts +20 −32
@@ -78,15 +78,18 @@ import { liveClientJs, liveMount } from "../live.ts";
7878 import { isMode, modeCookie } from "../mode.ts";
7979 import {
8080 avatar,
81 badge,
8182 btn,
8283 card,
8384 dot,
8485 empty,
86 envActions,
87 envFacts,
88 envPhrase,
8589 icon,
8690 listCard,
8791 pill,
8892 row,
89 servedPhrase,
9093 time,
9194 userTag,
9295 type Tone,
@@ -628,35 +631,20 @@ export async function studioApp(
628631 const envs: EnvStatus[] = runtime === undefined ? [] : runtime.status(record);
629632 const production = envs.find((e) => e.env === "production");
630633 const url = runtime?.urlFor(record, "production");
631 const state = production?.state ?? "stopped";
632 const servedAfter = production ? servedPhrase(production) : undefined;
633 const liveWord =
634 state === "running"
635 ? production?.error !== undefined
636 ? "serving · tests failed"
637 : servedAfter !== undefined
638 ? `live ${servedAfter}`
639 : "live"
640 : state === "failed"
641 ? "not up"
642 : state === "stopped"
643 ? "not running"
644 : "starting";
645 const liveTone: Tone =
646 state === "running" ? "success" : state === "failed" ? "danger" : "default";
634 const writer = canWrite(db, repo, ctx.auth.user);
635 // The state in one word, and under it the same facts the App page shows —
636 // why it is not up, and the deploy whose log says the rest.
637 const live = envPhrase(production);
647638 const head = html`<div class="page-head studio-head">
648639 <div class="stack" style="gap: 4px">
649640 <h1>
650641 ${repo.name}
651 <span
652 class="badge"
653 data-variant="${liveTone === "success"
654 ? "success"
655 : liveTone === "danger"
656 ? "danger"
657 : "secondary"}"
658 >${liveWord}</span
659 >
642 ${badge(
643 live.word,
644 live.tone === "default"
645 ? { variant: "secondary" }
646 : { tone: live.tone },
647 )}
660648 </h1>
661649 <p class="muted">
662650 ${repo.description || "No description yet."}
@@ -666,13 +654,8 @@ export async function studioApp(
666654 <a href="${base}/app/production/open" class="mono"
667655 >${url.replace(/^https?:\/\//, "")}</a
668656 >`}
669 ${production?.commitOid
670 ? html`·
671 ${production.commitOid.slice(0, 7)}${production.since
672 ? html` since ${time(production.since)}`
673 : null}`
674 : null}
675657 </p>
658 ${when(production, (env) => envFacts(base, env))}
676659 </div>
677660 <span class="spacer"></span>
678661 ${lookNow(ctx, repo, record)}
@@ -681,6 +664,7 @@ export async function studioApp(
681664 variant: "primary",
682665 icon: "external",
683666 })}
667 ${when(production, (env) => envActions(base, env, writer))}
684668 </div>`;
685669
686670 // ---- the box ----
@@ -1409,6 +1393,10 @@ export const studioCss = /* css */ `
14091393 .studio-tell textarea { min-height: 72px; resize: vertical; }
14101394 .studio-tell .bar { display: flex; align-items: center; gap: 8px; margin-top: 8px; }
14111395 .studio-head h1 { display: flex; align-items: center; gap: 10px; }
1396 /* The facts under the title take the whole line; the two buttons that act
1397 on production sit with the rest, each in its own POST form. */
1398 .studio-head .env-facts { margin-top: 2px; }
1399 .studio-head form { display: inline-flex; }
14121400 .badge[data-variant="accent"] { color: var(--accent); border-color: color-mix(in srgb, var(--accent) 34%, transparent); background: color-mix(in srgb, var(--accent) 12%, transparent); }
14131401 .badge[data-word="working"] svg { animation: spin 1.1s linear infinite; }
14141402 .studio-list { list-style: none; margin: 0; padding: 0; display: grid; gap: 6px; }
src/web/style/content.ts +5 −3
@@ -553,10 +553,12 @@ a.origin-chip:hover { border-color: var(--border-strong); color: var(--fg); }
553553 .app-strip .spacer { flex: 1; }
554554 /* One environment: name and state in the head, the facts and the reason it
555555 failed in the body, its buttons in the foot. A card rather than a row so a
556 deploy error has a whole line to be read on. */
556 deploy error has a whole line to be read on. The facts and the error read
557 the same wherever they are shown — the App page's card, a pull's preview,
558 the studio head — so they are not scoped to the card. */
557559 .env-card .card-head { gap: 10px; }
558 .env-card .env-facts { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; font-size: 13px; color: var(--muted-fg); }
559 .env-card .env-error { color: var(--danger); font-size: 13px; margin-top: 6px; overflow-wrap: anywhere; text-wrap: pretty; }
560 .env-facts { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; font-size: 13px; color: var(--muted-fg); }
561 .env-error { color: var(--danger); font-size: 13px; margin-top: 6px; overflow-wrap: anywhere; text-wrap: pretty; }
560562 .env-card .card-foot { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
561563 .env-card .card-foot form { display: inline-flex; }
562564 /* The look: a phone beside a desktop, each in a hairline frame. The phone
src/web/ui.ts +97
@@ -280,6 +280,103 @@ export function envTone(state: EnvState | undefined): Tone {
280280 : "default";
281281 }
282282
283 /**
284 * An environment's state in a person's words rather than a badge's —
285 * `live`, `serving · tests failed`, `not up`, `not running` — for a head
286 * that speaks one line. A card head wears `envStateBadge` instead. How fast
287 * it came up belongs to `envFacts`, which sits under this and says it once.
288 *
289 * A serving app is never called down because its tests are red: the words
290 * say the tests failed, the tone stays the tone of something that answers.
291 */
292 export function envPhrase(env: EnvStatus | undefined): {
293 word: string;
294 tone: Tone;
295 } {
296 if (env === undefined || env.state === "stopped")
297 return { word: "not running", tone: "default" };
298 if (env.state === "failed") return { word: "not up", tone: "danger" };
299 if (env.state === "deploying") return { word: "starting", tone: "default" };
300 return {
301 word: env.error === undefined ? "live" : "serving · tests failed",
302 tone: "success",
303 };
304 }
305
306 export interface EnvFactsOptions {
307 /** Name the environment's address in full — a card has room for it, a
308 * head that already links the app does not. */
309 readonly url?: boolean;
310 }
311
312 /**
313 * What an environment is doing, as facts: the commit it runs, how fast it
314 * came up, how long it has been up, a link to the deploy that produced it,
315 * and — when the deployment has words of its own — its note and its error.
316 * Every page showing an environment renders this one, so `not up` never
317 * says less on one page than on another. Facts, not a card: the caller
318 * decides what box they sit in.
319 */
320 export function envFacts(
321 base: string,
322 env: EnvStatus,
323 o: EnvFactsOptions = {},
324 ): Html {
325 return html`<div class="env-facts">
326 ${when(env.commitOid !== "", () =>
327 sha(env.commitOid, `${base}/commit/${env.commitOid}`),
328 )}
329 ${when(servedPhrase(env), (phrase) => html`<span>live ${phrase}</span>`)}
330 ${when(env.since, (since) => html`<span>up ${time(since)}</span>`)}
331 ${when(
332 env.runId,
333 (id) =>
334 html`<a href="${base}/runs/${id}"
335 >${env.state === "deploying"
336 ? "deploy in progress"
337 : env.note !== undefined
338 ? "verifying tests"
339 : "last deploy"}</a
340 >`,
341 )}
342 </div>
343 ${when(
344 o.url === true,
345 () =>
346 html`<p class="env-facts mt">
347 <span class="mono truncate" title="${env.url}">${env.url}</span>
348 </p>`,
349 )}
350 ${when(env.note, (note) => html`<p class="muted small">${note}</p>`)}
351 ${when(env.error, (error) => html`<p class="env-error">${error}</p>`)}`;
352 }
353
354 /**
355 * The two things a writer can do to an environment. Both are POST forms —
356 * the routes they post to make the same-origin check — and a reader is
357 * handed nothing at all.
358 */
359 export function envActions(
360 base: string,
361 env: EnvStatus,
362 writer: boolean,
363 ): Html {
364 return when(
365 writer,
366 () =>
367 html`<form method="post" action="${base}/app/${env.env}/redeploy">
368 ${btn("Redeploy", { size: "sm", icon: "sync" })}
369 </form>
370 ${when(
371 env.state !== "stopped",
372 () =>
373 html`<form method="post" action="${base}/app/${env.env}/stop">
374 ${btn("Stop", { size: "sm", icon: "square" })}
375 </form>`,
376 )}`,
377 );
378 }
379
283380 export interface LabelLike {
284381 readonly name: string;
285382 readonly color: string;
test/studio.test.ts +178
@@ -8,6 +8,9 @@ import { tmpdir } from "node:os";
88 import { join } from "node:path";
99 import { createApp, type App } from "../src/app.ts";
1010 import { enableApp } from "../src/actions/apps.ts";
11 import type { EnvName } from "../src/apps/data.ts";
12 import { AppsRuntime } from "../src/apps/runtime.ts";
13 import { getAppByRepo, type AppRecord } from "../src/core/apps.ts";
1114 import { addToCrew, createAgent } from "../src/actions/crew.ts";
1215 import { openPull } from "../src/actions/pulls.ts";
1316 import { commitToBranch, createRepo } from "../src/actions/repos.ts";
@@ -512,3 +515,178 @@ describe("the studio home does not wait on the decider", () => {
512515 expect(again).toContain("by the decider");
513516 });
514517 });
518
519 /**
520 * The studio head of an app that is not up: the same facts the App page
521 * shows — why, the deploy that says the rest, and the way to try again.
522 */
523 describe("the studio head says why the app is not up", () => {
524 let dataDir: string;
525 let app: App;
526 let servers: Servers;
527 let base: string;
528 let repo: Repo;
529 let record: AppRecord;
530 let runtime: AppsRuntime;
531 let deploy: string;
532 let writer: string;
533 let reader: string;
534
535 const head = (cookie: string) =>
536 fetch(`${base}/alice/notes`, {
537 headers: { cookie: `${cookie}; forge_mode=studio` },
538 redirect: "manual",
539 }).then((r) => r.text());
540
541 const signIn = async (username: string): Promise<string> =>
542 (
543 (
544 await fetch(`${base}/signin`, {
545 method: "POST",
546 body: new URLSearchParams({
547 username,
548 password: "a-good-passphrase",
549 }),
550 redirect: "manual",
551 })
552 ).headers.get("set-cookie") ?? ""
553 ).split(";")[0]!;
554
555 /**
556 * An environment as the runtime holds it, without running a deploy: the
557 * page reads `runtime.status(record)`, and a real failure would mean a
558 * real install and a real health timeout for a rendering test.
559 */
560 interface Slot {
561 readonly app: AppRecord;
562 readonly env: EnvName;
563 readonly chain: Promise<void>;
564 readonly restarts: number[];
565 readonly live?: unknown;
566 readonly lastError?: string;
567 readonly lastRunId?: string;
568 }
569 const put = (
570 slot: Omit<Slot, "app" | "env" | "chain" | "restarts">,
571 ): void => {
572 (runtime as unknown as { envs: Map<string, Slot> }).envs.set(
573 `${record.id}/production`,
574 {
575 app: record,
576 env: "production",
577 chain: Promise.resolve(),
578 restarts: [],
579 ...slot,
580 },
581 );
582 };
583
584 beforeAll(async () => {
585 dataDir = await mkdtemp(join(tmpdir(), "forge-studio-notup-"));
586 app = createApp({ dataDir, port: 0 });
587 servers = await serve(app);
588 base = servers.url;
589 runtime = new AppsRuntime(app, {
590 domain: "apps.test",
591 scheme: "http",
592 publicPort: servers.port,
593 forgeUrl: servers.url,
594 concurrency: 1,
595 });
596 const a = await createUser(app.db, {
597 username: "alice",
598 email: "[email protected]",
599 password: "a-good-passphrase",
600 adminIfFirst: true,
601 });
602 if (!a.ok) throw new Error(a.error);
603 const b = await createUser(app.db, {
604 username: "bob",
605 email: "[email protected]",
606 password: "a-good-passphrase",
607 });
608 if (!b.ok) throw new Error(b.error);
609 writer = await signIn("alice");
610 reader = await signIn("bob");
611 const made = await createRepo(app, a.value, {
612 name: "notes",
613 visibility: "public",
614 description: "a place for notes",
615 });
616 if (!made.ok) throw new Error(String(made.error));
617 repo = made.value;
618 const seeded = await commitToBranch(app, a.value, repo, {
619 message: "Start",
620 files: [{ path: "README.md", content: "notes\n" }],
621 });
622 if (!seeded.ok) throw new Error(JSON.stringify(seeded.error));
623 const enabled = await enableApp(app, a.value, repo, { slug: "notes" });
624 if (!enabled.ok) throw new Error(String(enabled.error));
625 record = getAppByRepo(app.db, repo.id)!;
626 const run = createRun(app.db, {
627 repoId: repo.id,
628 kind: "deploy",
629 env: "production",
630 commitOid: seeded.value.commitOid,
631 reason: "push",
632 });
633 claimRun(app.db, run.id);
634 finishRun(app.db, run.id, {
635 state: "failed",
636 summary: "the deploy failed",
637 });
638 deploy = run.id;
639 });
640
641 afterAll(async () => {
642 servers.stop();
643 app.close();
644 await rm(dataDir, { recursive: true, force: true });
645 });
646
647 test("a failed production says what failed, links its deploy, and offers Redeploy to a writer only", async () => {
648 put({
649 lastError: "the deploy failed: <install> exited 1",
650 lastRunId: deploy,
651 });
652 const mine = await head(writer);
653 expect(mine).toContain("not up");
654 // The error is the app's own output: shown, and escaped.
655 expect(mine).toContain("the deploy failed: &lt;install&gt; exited 1");
656 expect(mine).not.toContain("<install>");
657 expect(mine).toContain(`href="/alice/notes/runs/${deploy}"`);
658 expect(mine).toContain('action="/alice/notes/app/production/redeploy"');
659 expect(mine).toContain('action="/alice/notes/app/production/stop"');
660
661 const theirs = await head(reader);
662 expect(theirs).toContain("not up");
663 expect(theirs).toContain("the deploy failed: &lt;install&gt; exited 1");
664 expect(theirs).toContain(`href="/alice/notes/runs/${deploy}"`);
665 expect(theirs).not.toContain("/app/production/redeploy");
666 expect(theirs).not.toContain("/app/production/stop");
667 });
668
669 test("a stopped production says it is not running and offers Redeploy, not Stop", async () => {
670 put({ lastRunId: deploy });
671 const mine = await head(writer);
672 expect(mine).toContain("not running");
673 expect(mine).toContain('action="/alice/notes/app/production/redeploy"');
674 expect(mine).not.toContain('action="/alice/notes/app/production/stop"');
675 });
676
677 test("a serving app with red tests still reads as serving, with the failure in its own words", async () => {
678 put({
679 live: {
680 commitOid: "0".repeat(40),
681 handle: { port: 4321 },
682 runId: deploy,
683 since: new Date(Date.now() - 60_000).toISOString(),
684 testsFailed: "1 of 3 tests failed",
685 },
686 });
687 const mine = await head(writer);
688 expect(mine).toContain("serving · tests failed");
689 expect(mine).not.toContain("not up");
690 expect(mine).toContain("serving · tests failed: 1 of 3 tests failed");
691 });
692 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs