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

Contraction: fold the dev app page into the studio app page #433

Merged opened this

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

Merged

mason/forge:mason/395-contraction-fold-the-dev-app-page-into-t → main 2 commits fork

  • Merged. mason/395-contraction-fold-the-dev-app-page-into-t was merged into main as 5606752.

Review

Sign in to review this change.
15 files changed +231 −271
docs/DESIGN.md +7 −5
@@ -186,8 +186,8 @@ specification.
186186 | **propose card** | `fieldset.editor-target.propose` (`.propose-line`) | — | replaces "Where this lands" when the change is a contribution: the whole outcome in one sentence with **both** repository names, then branch, pull title and pull body. There is no "commit directly" radio, because there is no such option | the same sentence above the commit prompt |
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 |
189 | **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; `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 |
189 | **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 (the studio app page)| 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; `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 (or Wake it) below. It is a pull's preview card; the facts and `envActions()` (Redeploy / Stop, writers only) are functions, not markup, so the studio head and its preview rows 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 |
@@ -317,9 +317,11 @@ grant for a private app — so the same link works for a public app and a
317317 private one and a stranger gets `not_found` from the forge rather than a
318318 guess at a hostname.
319319
320 **Redeploy / Stop** — two POSTs on the app page, writers only, one row per
321 environment; a stop keeps the data (a redeploy brings it back), a stopped
322 environment shows the honest word and the run that stopped it.
320 **Redeploy / Stop** — two POSTs on the studio app page (`studio/app.ts`;
321 dev's old `/:owner/:name/app` redirects there), writers only: production's
322 in the head card, each preview's on its row; a stop keeps the data (a
323 redeploy brings it back), a stopped environment shows the honest word and
324 the run that stopped it.
323325
324326 **Assign** — the assignee card on an issue and a pull: nobody, then people
325327 who can read the repository, then the crew marked as agents. Assigning an
docs/STUDIO.md +22 −11
@@ -89,11 +89,23 @@ current about deployments: a deploy run for the app being viewed refetches
8989 the head card and previews list in place, so a change serving in its
9090 preview shows up without a reload. **Redeploy** and **Stop** therefore stay
9191 where they were pressed — the app's page, or a change's — rather than
92 leaving for dev's App page or the deploy's run: the card refreshing in
93 place is what shows the result, and the flash says what was done. The page
94 to return to is the referer, and only when it is this forge's own origin
95 (`safeBack`, `src/http/context.ts`), never a form field; with no referer it
96 is the app's own studio page. Dev mode keeps its destinations.
92 leaving for the deploy's run: the card refreshing in place is what shows
93 the result, and the flash says what was done. The page to return to is the
94 referer, and only when it is this forge's own origin (`safeBack`,
95 `src/http/context.ts`), never a form field; with no referer it is the
96 repository's home. Dev mode's Redeploy still hands over to the run.
97
98 This page is the app's only page. Dev's App page folded into it (#395):
99 `/:owner/:name/app` answers with a redirect here (`appPage`,
100 `src/web/pages/apps.ts`), adding `?mode=studio` for a dev browser — dev's
101 repo home is the code — and the same not-found as ever for anyone who
102 cannot see the app. What that page had and this one lacked were the
103 environment controls, so they moved: each row in the previews list carries
104 its own **Redeploy** and **Stop** for a writer, beside its state, with its
105 host still the link that opens it; and an admin's footer carries
106 _Environment settings_, the way to the app's variables, hostname, access
107 and domains (`/:owner/:name/settings/app`). The App tab and the app strip's
108 button in dev are links to this page.
97109
98110 **How this app runs itself** (`/:owner/:name/settings`, in studio, on an
99111 app) is that one place: the crew, each with their role in the studio's
@@ -148,7 +160,7 @@ app's `forge.json` declares no `tty` (`docs/APPS.md`, _A terminal for
148160 every app_). A pull's own preview card shows its own command the same
149161 way, since a preview answers to a different login than production's. A
150162 reader sees none of this. Both surfaces call the one function that knows
151 the fact, `terminalFor` (`src/actions/apps.ts`), so the dev App page and
163 the fact, `terminalFor` (`src/actions/apps.ts`), so the pull's card and
152164 the studio page never carry two ideas of what the command is.
153165
154166 **The box** is the front door. A sentence becomes an issue — the first line
@@ -302,11 +314,10 @@ a _shelf row_ (`src/web/studio/shelf.ts`): one function turns repositories
302314 into rows, from grouped reads, so every page that lists apps lists them the
303315 same way and the home only decides the card around them. **New app** is the
304316 same form either way (`/apps/new`); once the sentence becomes a repository
305 it lands on the app's own page, `/:owner/:name/app` (`appPage`,
306 `src/web/pages/apps.ts`) — not this shelf, and not the studio's five-word
307 home for it. Its first paint carries the flash saying where it's starting,
308 each environment as it comes up, the hostname and terminal, the crew, and
309 its recent runs. `/?mode=dev` is the repositories list, and — like
317 it lands on the repository's home, `/:owner/:name` — in the studio the
318 app's own page, not this shelf; in dev the code, wearing the app strip.
319 Its first paint carries the flash saying where it's starting.
320 `/?mode=dev` is the repositories list, and — like
310321 the toggle in the top bar — it switches this browser to dev from then on;
311322 `/?mode=studio` switches it back, the same way.
312323
forge.db added
Binary file not shown.
src/web/chrome.ts +3 −2
@@ -237,11 +237,12 @@ export function scopeHeader(
237237 });
238238 }
239239 // A repository that runs gets an App tab; one that does not has no such
240 // page, and a tab that 404s is a lie about what is there.
240 // page, and a tab that 404s is a lie about what is there. The app's page
241 // is the studio's, so in dev the tab is the link that switches over.
241242 if (repo !== undefined && isApp && !studio)
242243 items.push({
243244 label: "App",
244 href: `${base}/app`,
245 href: `${base}?mode=studio`,
245246 icon: "rocket",
246247 current: active === "app",
247248 shortcut: "g a",
src/web/pages/apps.ts +43 −211
@@ -1,8 +1,9 @@
11 /**
22 * Apps on the web (docs/APPS.md): the list, the golden path's first step
3 * (New app), one app's environments with the three things you can do to
4 * one — open, redeploy, stop — and the two pieces other pages borrow: the
5 * strip a repo home wears when it runs, and the preview card a pull carries.
3 * (New app), the three things you can do to an environment — open,
4 * redeploy, stop — and the two pieces other pages borrow: the strip a repo
5 * home wears when it runs, and the preview card a pull carries. An app's
6 * own page is the studio's (`studio/app.ts`); its old dev URL redirects.
67 *
78 * Every mutation is an action; a page only puts its answer into words.
89 * Opening an environment goes through `openEnvUrl`, which is what makes a
@@ -19,10 +20,9 @@ import {
1920 stopEnv,
2021 terminalFor,
2122 } from "../../actions/apps.ts";
22 import { listAllAgents, listCrew } from "../../actions/crew.ts";
23 import { listAllAgents } from "../../actions/crew.ts";
2324 import { planningAvailable } from "../../crew/plan.ts";
2425 import { appNameJs, deriveAppName } from "./apps-name.ts";
25 import { listRepoRuns } from "../../actions/runs.ts";
2626 import { envPull, previewEnv, type EnvName } from "../../apps/data.ts";
2727 import { appsRuntime, type EnvStatus } from "../../apps/runtime.ts";
2828 import { appHostSuffix } from "../../apps/host.ts";
@@ -30,7 +30,6 @@ import { appsForRepos, type AppRecord } from "../../core/apps.ts";
3030 import { accessAllows, listOrgsForUser, orgAccess } from "../../core/orgs.ts";
3131 import type { Pull } from "../../core/pulls.ts";
3232 import {
33 canAdmin,
3433 canWrite,
3534 getRepoById,
3635 listReposVisibleTo,
@@ -40,19 +39,15 @@ import type { User } from "../../core/users.ts";
4039 import { safeBack, type Ctx } from "../../http/context.ts";
4140 import { isHostPort, resolveAppHostname } from "../../apps/proxy.ts";
4241 import {
43 crumbsFor,
4442 loadRepo,
4543 notFoundPage,
4644 pageHead,
4745 repoBase,
48 repoHeader,
49 repoPalette,
5046 signInFirst,
5147 } from "../chrome.ts";
5248 import { field, readForm } from "../form.ts";
5349 import { html, when, type Html } from "../html.ts";
5450 import { flashTo, page, redirect } from "../layout.ts";
55 import { liveClientJs, liveMount } from "../live.ts";
5651 import { shelfRows } from "../studio/shelf.ts";
5752 import {
5853 badge,
@@ -60,7 +55,6 @@ import {
6055 card,
6156 dot,
6257 empty,
63 envActions,
6458 envFacts,
6559 envPhrase,
6660 envStateBadge,
@@ -72,11 +66,8 @@ import {
7266 selectField,
7367 sha,
7468 terminalLine,
75 time,
76 userLink,
7769 field as formField,
7870 } from "../ui.ts";
79 import { actorsFor, issuesFor, runRow } from "./runs.ts";
8071
8172 // ---- what the viewer can see ----
8273
@@ -128,7 +119,7 @@ export function envLabel(env: EnvName): string {
128119
129120 /**
130121 * One app in a list. The dot is production's state; the title goes to the
131 * App page and the URL opens the app — two links, so the row is not itself
122 * repository's home and the URL opens the app — two links, so the row is not itself
132123 * one (a link inside a link tears the row apart).
133124 */
134125 function appRow(entry: AppEntry): Html {
@@ -137,7 +128,7 @@ function appRow(entry: AppEntry): Html {
137128 return row({
138129 nav: true,
139130 lead: dot(envPhrase(entry.production).tone),
140 title: html`<a href="${base}/app">${name}</a>`,
131 title: html`<a href="${base}">${name}</a>`,
141132 titleAttr: name,
142133 meta: html`${entry.url === undefined
143134 ? html`<span>not serving</span>`
@@ -532,10 +523,9 @@ export async function newAppSubmit(ctx: Ctx): Promise<Response> {
532523 : plan
533524 ? ` ${plan.agent} was picked for #${issue.number} — ${plan.summary}`
534525 : ` #${issue.number} is assigned to ${issue.assigneeName}.`;
535 // The studio's app page (repoHome, once the record exists) is where this
536 // moment belongs; dev mode keeps the controls' page.
537 const target = ctx.mode === "studio" ? repoBase(repo) : `${repoBase(repo)}/app`;
538 return flashTo(ctx, target, { message: `${first}${second}` });
526 // The repository's home: in the studio that is the app page (repoHome,
527 // once the record exists); in dev it is the code, wearing the app strip.
528 return flashTo(ctx, repoBase(repo), { message: `${first}${second}` });
539529 }
540530
541531 // ---- the repo home's strip ----
@@ -543,7 +533,8 @@ export async function newAppSubmit(ctx: Ctx): Promise<Response> {
543533 /**
544534 * One line under the repo header, for a repository that runs: production's
545535 * state, the commit it serves, its URL, how many previews are up, and the
546 * way to the App page. Nothing for a repository that is not an app.
536 * way to the app's page in the studio, where its environments are run.
537 * Nothing for a repository that is not an app.
547538 */
548539 export function appStrip(ctx: Ctx, repo: Repo): Html {
549540 const view = appView(ctx.app, ctx.auth.user, repo);
@@ -580,7 +571,7 @@ export function appStrip(ctx: Ctx, repo: Repo): Html {
580571 )}
581572 <span class="spacer"></span>
582573 ${btn("App", {
583 href: `${base}/app`,
574 href: appHref(ctx, repo),
584575 size: "sm",
585576 variant: "ghost",
586577 icon: "chevronRight",
@@ -590,180 +581,27 @@ export function appStrip(ctx: Ctx, repo: Repo): Html {
590581
591582 // ---- the app page ----
592583
593 /** One environment: what it is doing, the facts, and the buttons. */
594 function envCard(base: string, env: EnvStatus, writer: boolean): Html {
595 const number = envPull(env.env);
596 return card(
597 envFacts(base, env, { url: true }),
598 {
599 extra: "env-card",
600 ...(env.state === "failed" || env.error !== undefined
601 ? { tone: "danger" as const }
602 : env.failedDeploy !== undefined
603 ? { tone: "attn" as const }
604 : {}),
605 head: html`${icon(number === undefined ? "rocket" : "pull")}
606 <h2>
607 ${number === undefined
608 ? "production"
609 : html`preview of <a href="${base}/pulls/${number}">#${number}</a>`}
610 </h2>
611 ${envStateBadge(env.state)}`,
612 foot: html`${btn("Open", {
613 href: `${base}/app/${env.env}/open`,
614 size: "sm",
615 variant: "outline",
616 icon: "external",
617 })}
618 ${envActions(base, env, writer)}`,
619 },
620 );
584 /**
585 * Where an app's page is: the studio's (`studio/app.ts`), which is the
586 * repository's home in studio mode. A dev-mode viewer is handed the same
587 * page with `?mode=studio` on it — the one link that switches the browser
588 * (`http/server.ts`) — since dev's repo home is the code, as ever.
589 */
590 export function appHref(ctx: Ctx, repo: Repo): string {
591 const base = repoBase(repo);
592 return ctx.mode === "studio" ? base : `${base}?mode=studio`;
621593 }
622594
623 // GET /:owner/:name/app
595 // GET /:owner/:name/app — dev's App page, folded into the studio's (#395).
596 // The old URL keeps answering: a reader of an app is sent to the studio app
597 // page; anyone else gets the same not-found the page always gave them.
624598 export function appPage(ctx: Ctx): Response {
625599 const repo = loadRepo(ctx);
626600 if (!repo) return notFoundPage(ctx);
627 const view = appView(ctx.app, ctx.auth.user, repo);
628 if (!view.ok) return notFoundPage(ctx);
629 const { app: record, environments, serving } = view.value;
630 const base = repoBase(repo);
631 const writer = canWrite(ctx.app.db, repo, ctx.auth.user);
632 const admin = canAdmin(ctx.app.db, repo, ctx.auth.user);
633 // Production first, then previews by pull number: the runtime's own order.
634 const envs = environments;
635 const listed = listRepoRuns(ctx.app, ctx.auth.user, repo, { limit: 15 });
636 const runs = listed.ok ? listed.value : [];
637 const crew = listCrew(ctx.app, ctx.auth.user, repo);
638 const url = appsRuntime(ctx.app)?.urlFor(record, "production");
639 const production = envs.find((env) => env.env === "production");
640 const ssh = writer
641 ? terminalFor(ctx.app, record, "production", production)
642 : {};
643 // The same rows the runs page draws, from the same two grouped reads: a
644 // table's first column folds into a sliver at phone width, a row does not.
645 const runsHead = html`${icon("activity")}
646 <h2>Recent runs</h2>
647 <span class="spacer"></span>
648 ${btn("All runs", {
649 href: `${base}/runs`,
650 size: "sm",
651 variant: "ghost",
652 icon: "chevronRight",
653 })}`;
654 const actors = actorsFor(ctx, runs);
655 const issues = issuesFor(ctx, runs);
656 const runsCard =
657 runs.length === 0
658 ? card(html`<p class="muted small">No runs yet.</p>`, { head: runsHead })
659 : listCard(
660 runs.map((run) => runRow(base, run, actors, issues)),
661 { head: runsHead },
662 );
663 const environmentsBlock = !serving
664 ? card(
665 html`<p class="muted small">
666 Nothing is serving apps on this forge right now — it was started with
667 <span class="mono">FORGE_APPS=0</span>. The app is still declared;
668 start the forge with apps on and production comes up from the default
669 branch.
670 </p>`,
671 {
672 head: html`${icon("alert")}
673 <h2>Not serving</h2>`,
674 },
675 )
676 : envs.length === 0
677 ? empty({
678 icon: "rocket",
679 title: "Nothing is running yet",
680 note: "Production starts from the default branch; a preview starts when a pull request opens. Give it a moment, or push something.",
681 })
682 : html`<div class="stack">
683 ${envs.map((env) => envCard(base, env, writer))}
684 </div>`;
685
686 const about = card(
687 html`<dl>
688 <dt>Hostname</dt>
689 <dd>
690 ${url === undefined
691 ? html`<span class="muted">not serving</span>`
692 : html`<a href="${base}/app/production/open" class="mono">${url}</a>`}
693 </dd>
694 ${when(
695 writer,
696 () =>
697 html`<dt>Terminal</dt>
698 <dd>${terminalLine(ssh, ctx.app.config.sshHostKey)}</dd>`,
699 )}
700 <dt>Access</dt>
701 <dd>
702 ${record.access === "private"
703 ? "private — only people who can see this repository"
704 : "public — anyone with the link"}
705 </dd>
706 <dt>Preview data</dt>
707 <dd>
708 ${record.previewData === "copy"
709 ? "a copy of production's, taken when the preview starts"
710 : "empty"}
711 </dd>
712 </dl>`,
713 {
714 extra: "about",
715 head: html`${icon("rocket")}
716 <h2>${record.slug}</h2>
717 ${when(
718 admin,
719 () =>
720 html`<span class="spacer"></span> ${btn("Settings", {
721 href: `${base}/settings/app`,
722 size: "sm",
723 variant: "ghost",
724 icon: "settings",
725 })}`,
726 )}`,
727 },
728 );
729
730 const crewCard = when(
731 crew.ok && crew.value.length > 0,
732 () =>
733 html`<div class="mt">
734 ${listCard(
735 (crew.ok ? crew.value : []).map((agent) =>
736 row({
737 lead: icon("bot"),
738 title: html`${userLink(agent.user.username)}`,
739 ...(agent.model === "" ? {} : { meta: html`${agent.model}` }),
740 }),
741 ),
742 {
743 head: html`${icon("bot")}
744 <h2>Crew</h2>`,
745 },
746 )}
747 </div>`,
748 );
749
750 const body = html`${repoHeader(ctx, repo, "app", { compact: true })}
751 <div class="split">
752 <div>
753 ${environmentsBlock}
754 <div class="mt">${runsCard}</div>
755 </div>
756 <aside>${about} ${crewCard}</aside>
757 </div>
758 ${liveMount({ repo: base })}`;
759
760 return page({
761 ctx,
762 title: `App · ${repo.name}`,
763 crumbs: crumbsFor(repo),
764 palette: repoPalette(ctx, repo),
765 body,
766 scriptJs: liveClientJs,
601 if (!appView(ctx.app, ctx.auth.user, repo).ok) return notFoundPage(ctx);
602 return new Response(null, {
603 status: 302,
604 headers: { location: appHref(ctx, repo), "cache-control": "no-store" },
767605 });
768606 }
769607
@@ -870,7 +708,7 @@ export function appOpen(ctx: Ctx): Response {
870708 if (!link.ok) {
871709 if (link.error === "unauthorized") return signInFirst(ctx);
872710 if (link.error === "no_runtime")
873 return flashTo(ctx, `${repoBase(repo)}/app`, {
711 return flashTo(ctx, repoBase(repo), {
874712 tone: "attn",
875713 message: "Nothing is serving apps on this forge.",
876714 });
@@ -895,19 +733,16 @@ async function envForm(
895733 }
896734
897735 /**
898 * Where an environment form sends the person who pressed it. In the studio
899 * that is the page the form was on — the app's own page, or a change's —
900 * because the head card refreshes itself on the deploy event
901 * (`appStateFragment`, `GET /:owner/:name/app/state`), so staying put shows
902 * the result better than any page a redirect could pick. The page comes
903 * from the referer, and only when the referer is this forge's own origin
904 * (`safeBack`), never from a form field; with no referer the fallback is
905 * the app's studio page. Dev mode keeps its own App page.
736 * Where an environment form sends the person who pressed it: the page the
737 * form was on — the app's own page, a change's, a pull's — because the head
738 * card refreshes itself on the deploy event (`appStateFragment`,
739 * `GET /:owner/:name/app/state`), so staying put shows the result better
740 * than any page a redirect could pick. The page comes from the referer, and
741 * only when the referer is this forge's own origin (`safeBack`), never from
742 * a form field; with no referer the fallback is the repository's home.
906743 */
907744 function envBack(ctx: Ctx, repo: Repo): string {
908 const base = repoBase(repo);
909 if (ctx.mode !== "studio") return `${base}/app`;
910 return safeBack(ctx) ?? base;
745 return safeBack(ctx) ?? repoBase(repo);
911746 }
912747
913748 function envFailure(ctx: Ctx, repo: Repo, error: string): Response {
@@ -932,17 +767,14 @@ export async function appRedeploy(ctx: Ctx): Promise<Response> {
932767 const { repo, env, user } = gate;
933768 const queued = await redeployEnv(ctx.app, user, repo, env);
934769 if (!queued.ok) return envFailure(ctx, repo, queued.error);
935 const base = repoBase(repo);
936770 const run = queued.value;
937 // Dev's App page hands the deploy over to its run; the studio stays on the
938 // page that asked for it and watches the head card change there. When
939 // there is no run at all, both fall back to a page that exists.
771 // Dev hands the deploy over to its run; the studio stays on the page that
772 // asked for it and watches the head card change there. With no run at
773 // all, both stay where they were.
940774 const to =
941 ctx.mode === "studio"
775 ctx.mode === "studio" || run === undefined
942776 ? envBack(ctx, repo)
943 : run === undefined
944 ? `${base}/app`
945 : `${base}/runs/${run.id}`;
777 : `${repoBase(repo)}/runs/${run.id}`;
946778 return redirect(to, {
947779 url: ctx.url,
948780 flash: {
src/web/pages/settings.ts +3 −2
@@ -86,6 +86,7 @@ import {
8686 settingsLayout,
8787 } from "../chrome.ts";
8888 import { studioCrewSettings } from "../studio/crew.ts";
89 import { appHref } from "./apps.ts";
8990 import { field, readForm, type Form } from "../form.ts";
9091 import { html, raw, when, type Html } from "../html.ts";
9192 import { icon } from "../icons.ts";
@@ -462,7 +463,7 @@ function appSettingsView(
462463 })}
463464 ${when(record !== undefined, () =>
464465 btn("Environments", {
465 href: `${base}/app`,
466 href: appHref(ctx, repo),
466467 variant: "ghost",
467468 icon: "rocket",
468469 }),
@@ -758,7 +759,7 @@ export async function appSettingsUpdate(ctx: Ctx): Promise<Response> {
758759 return flashTo(
759760 ctx,
760761 record === undefined
761 ? `${repoBase(repo)}/app`
762 ? appHref(ctx, repo)
762763 : `${repoBase(repo)}/settings/app`,
763764 {
764765 message:
src/web/studio/app.ts +13 −5
@@ -1,9 +1,12 @@
11 /**
22 * The app in the studio: one page per app, and the front door a person acts
33 * from — the box they tell the crew in, what asks them, and Now, one row per
4 * thing the crew has in hand in the studio's own words. Money, the crew
5 * roster, autonomy and the decision ledger are the owner's facts and render
6 * only for a viewer who can write here; a stranger sees the app.
4 * thing the crew has in hand in the studio's own words — with every
5 * environment's controls: production's and each preview's Redeploy and Stop
6 * (`previews.ts`), and the way to the app's environment settings. Dev's old
7 * App URL (`GET /:owner/:name/app`) lands here. Money, the crew roster,
8 * autonomy and the decision ledger are the owner's facts and render only
9 * for a viewer who can write here; a stranger sees the app.
710 */
811
912 import { audit } from "../../contract/audit.ts";
@@ -13,7 +16,7 @@ import { listCrewRoles } from "../../core/agents.ts";
1316 import type { AppRecord } from "../../core/apps.ts";
1417 import { decisionStats, type DecisionStat } from "../../core/decisions.ts";
1518 import { listPulls, mergedPullCount } from "../../core/pulls.ts";
16 import { canSeeOwnerFacts, type Repo } from "../../core/repos.ts";
19 import { canAdmin, canSeeOwnerFacts, type Repo } from "../../core/repos.ts";
1720 import {
1821 agentRunTotals,
1922 previewLatency,
@@ -406,10 +409,15 @@ export async function studioApp(
406409 extra: "about",
407410 },
408411 );
412 // The rest of the app's environment — its variables, hostname, access and
413 // domains — is the admin's, on the app's settings page.
409414 const more = html`<p class="muted small">
410415 <a href="${base}/map">The map</a> ·
411416 <a href="${tellsHref(base)}">Everything told</a> ·
412 <a href="${changesHref(base)}">Every change</a>
417 <a href="${changesHref(base)}">Every change</a>${when(
418 canAdmin(db, repo, ctx.auth.user),
419 () => html` · <a href="${base}/settings/app">Environment settings</a>`,
420 )}
413421 </p>`;
414422
415423 const body = html`${repoHeader(ctx, repo, "code", {
src/web/studio/now.ts +1 −1
@@ -212,7 +212,7 @@ export async function nowCard(
212212 const number = envPull(env.env);
213213 return [
214214 row({
215 href: `${base}/app`,
215 href: `${base}#app-head`,
216216 nav: true,
217217 lead: icon(number === undefined ? "rocket" : "pull"),
218218 title: number === undefined ? "production" : `preview of #${number}`,
src/web/studio/previews.ts +22 −7
@@ -84,7 +84,9 @@ function previewBadge(env: EnvStatus | undefined): Html {
8484 /**
8585 * One open pull's preview, as one row: its host is the click, its state is
8686 * the badge, and a check still running or a check that failed says so right
87 * there — the row never hides a preview that is serving.
87 * there — the row never hides a preview that is serving. A writer also gets
88 * the preview's Redeploy and Stop beside the badge; the row then holds its
89 * own controls, so it is no longer one link — the host is.
8890 */
8991 function previewRow(
9092 base: string,
@@ -92,6 +94,7 @@ function previewRow(
9294 record: AppRecord,
9395 pull: Pull,
9496 env: EnvStatus | undefined,
97 writer: boolean,
9598 ): Html {
9699 const href =
97100 runtime === undefined
@@ -112,6 +115,17 @@ function previewRow(
112115 const dataNote = previewDataNote(record.previewData, pull.createdAt);
113116 if (dataNote !== null) bits.push(dataNote);
114117 }
118 const meta = bits.reduce((a, b) => html`${a} · ${b}`);
119 if (writer && env !== undefined)
120 return row({
121 lead: icon("rocket"),
122 title:
123 host === undefined || href === undefined
124 ? `#${pull.number} ${pull.title}`
125 : html`<a href="${href}" class="mono">${host}</a>`,
126 meta,
127 trail: html`${previewBadge(env)} ${envActions(base, env, writer)}`,
128 });
115129 return row({
116130 ...(href === undefined ? {} : { href, nav: true }),
117131 lead: icon("rocket"),
@@ -119,7 +133,7 @@ function previewRow(
119133 host === undefined
120134 ? `#${pull.number} ${pull.title}`
121135 : html`<span class="mono">${host}</span>`,
122 meta: bits.reduce((a, b) => html`${a} · ${b}`),
136 meta,
123137 trail: previewBadge(env),
124138 });
125139 }
@@ -136,6 +150,7 @@ function previewsPanel(
136150 record: AppRecord,
137151 openPulls: readonly Pull[],
138152 envs: readonly EnvStatus[],
153 writer: boolean,
139154 ): Html | null {
140155 if (openPulls.length === 0) return null;
141156 const byPull = new Map(
@@ -145,7 +160,7 @@ function previewsPanel(
145160 );
146161 return listCard(
147162 openPulls.map((p) =>
148 previewRow(base, runtime, record, p, byPull.get(p.number)),
163 previewRow(base, runtime, record, p, byPull.get(p.number), writer),
149164 ),
150165 {
151166 head: html`${icon("rocket")}
@@ -248,13 +263,13 @@ export function appPanels(
248263 const base = repoBase(repo);
249264 const runtime = appsRuntime(ctx.app);
250265 const envs: EnvStatus[] = runtime === undefined ? [] : runtime.status(record);
251 const previews = previewsPanel(base, runtime, record, openPulls, envs);
266 const writer = canSeeOwnerFacts(db, repo, ctx.auth.user);
267 const previews = previewsPanel(base, runtime, record, openPulls, envs, writer);
252268 const production = envs.find((e) => e.env === "production");
253269 const url = runtime?.urlFor(record, "production");
254 const writer = canSeeOwnerFacts(db, repo, ctx.auth.user);
255270 const tail = productionTail(ctx, repo, production, writer);
256 // The state in one word, and under it the same facts the App page shows —
257 // why it is not up, and the deploy whose log says the rest.
271 // The state in one word, and under it the facts — why it is not up, and
272 // the deploy whose log says the rest.
258273 const live = envPhrase(production);
259274 const head = html`<div class="page-head studio-head">
260275 <div class="stack stack-tight">
src/web/style/content.ts +4 −6
@@ -649,15 +649,13 @@ form.inline { display: inline; margin-left: auto; }
649649 }
650650 .app-strip .spacer { flex: 1; }
651651 /* One environment: name and state in the head, the facts and the reason it
652 failed in the body, its buttons in the foot. A card rather than a row so a
653 deploy error has a whole line to be read on. The facts and the error read
654 the same wherever they are shown — the App page's card, a pull's preview,
655 the studio head — so they are not scoped to the card. */
652 failed in the body. A card rather than a row so a deploy error has a whole
653 line to be read on. The facts and the error read the same wherever they
654 are shown — a pull's preview, the studio head — so they are not scoped to
655 the card. */
656656 .env-card .card-head { gap: 10px; }
657657 .env-facts { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; font-size: 13px; color: var(--muted-fg); }
658658 .env-error { color: var(--danger); font-size: 13px; margin-top: 6px; overflow-wrap: anywhere; text-wrap: pretty; }
659 .env-card .card-foot { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
660 .env-card .card-foot form { display: inline-flex; }
661659 /* The look: a phone beside a desktop, each in a hairline frame. The phone
662660 keeps a phone's narrowness; the desktop takes what is left, never more
663661 than its own size. A thumbnail shows the top of the phone picture; the
ssh/host_ed25519 added +3
@@ -0,0 +1,3 @@
1 -----BEGIN PRIVATE KEY-----
2 MC4CAQAwBQYDK2VwBCIEIJcIFQoeJ/tnUP4YRLfkgVdlhqtyfoINOdy0bmS3qYOf
3 -----END PRIVATE KEY-----
ssh/host_ed25519.pub added +1
@@ -0,0 +1,1 @@
1 ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIEHkZRmKOl+ECcGUt2okfxOAeGbtAPbabnnZe4K63uzd forge
test/apps.test.ts +2 −2
@@ -1700,7 +1700,7 @@ describe("New app lands on the app's page, not the flash (issue #133)", () => {
17001700 expect(html).toContain("#1 is assigned to bot.");
17011701 });
17021702
1703 test("in dev mode, POST /apps/new redirects to dev's app page, chosen from ctx.mode", async () => {
1703 test("in dev mode, POST /apps/new redirects to the repository's home, its strip carrying the URL", async () => {
17041704 const submitted = await fetch(`${base}/apps/new`, {
17051705 method: "POST",
17061706 headers: { cookie: `${cookie}; forge_mode=dev`, origin: base },
@@ -1714,7 +1714,7 @@ describe("New app lands on the app's page, not the flash (issue #133)", () => {
17141714 redirect: "manual",
17151715 });
17161716 expect(submitted.status).toBe(303);
1717 expect(submitted.headers.get("location")).toBe("/priya/tracker/app");
1717 expect(submitted.headers.get("location")).toBe("/priya/tracker");
17181718 const flash = (submitted.headers.get("set-cookie") ?? "").split(";")[0]!;
17191719 expect(flash).toStartWith("forge_flash=");
17201720
test/studio.test.ts +88 −6
@@ -7259,9 +7259,9 @@ describe("every studio page's headings form one outline", () => {
72597259 * to leave the studio — Stop for dev's App page, Redeploy for the deploy's
72607260 * run page. Both now come back to the page the form was pressed on, because
72617261 * the card refreshes itself on the deploy event and staying put shows the
7262 * result. Dev mode keeps its own destinations, and the page is taken from
7263 * the referer only when it is this forge's own (`safeBack`), never from the
7264 * form.
7262 * result. Dev mode's Redeploy still hands over to the run, and the page is
7263 * taken from the referer only when it is this forge's own (`safeBack`),
7264 * never from the form.
72657265 */
72667266 describe("the studio's Stop and Redeploy come back to the page they were pressed on", () => {
72677267 let dataDir: string;
@@ -7466,10 +7466,10 @@ describe("the studio's Stop and Redeploy come back to the page they were pressed
74667466 ).toBeGreaterThan(0);
74677467 });
74687468
7469 test("dev mode keeps its destinations: Stop to the App page, Redeploy to the run", async () => {
7470 const stopped = await press("stop", "dev", `${base}/alice/notes`);
7469 test("dev mode: Stop comes back to the page it was pressed on, Redeploy goes to the run", async () => {
7470 const stopped = await press("stop", "dev", `${base}/alice/notes/pulls/1`);
74717471 expect(stopped.status).toBe(303);
7472 expect(stopped.headers.get("location")).toBe("/alice/notes/app");
7472 expect(stopped.headers.get("location")).toBe("/alice/notes/pulls/1");
74737473 expect(flashOf(stopped)).toContain("Stopped production.");
74747474
74757475 const queued = await press("redeploy", "dev", `${base}/alice/notes`);
@@ -7478,6 +7478,88 @@ describe("the studio's Stop and Redeploy come back to the page they were pressed
74787478 expect(queued.headers.get("location")).toBe(`/alice/notes/runs/${run?.id}`);
74797479 expect(flashOf(queued)).toContain("Redeploying production.");
74807480 });
7481
7482 // Issue #395: dev's App page folded into this one. Its URL keeps
7483 // answering — a redirect to the studio app page — and the environment
7484 // controls it carried (each preview's Redeploy and Stop, the way to the
7485 // environment settings) are drawn here instead.
7486 test("GET /:owner/:name/app redirects to the studio app page, switching a dev browser over", async () => {
7487 const asStudio = await fetch(`${base}/alice/notes/app`, {
7488 headers: { cookie: `${cookie}; forge_mode=studio` },
7489 redirect: "manual",
7490 });
7491 expect(asStudio.status).toBe(302);
7492 expect(asStudio.headers.get("location")).toBe("/alice/notes");
7493
7494 const asDev = await fetch(`${base}/alice/notes/app`, {
7495 headers: { cookie: `${cookie}; forge_mode=dev` },
7496 redirect: "manual",
7497 });
7498 expect(asDev.status).toBe(302);
7499 expect(asDev.headers.get("location")).toBe("/alice/notes?mode=studio");
7500 const landed = await fetch(`${base}/alice/notes?mode=studio`, {
7501 headers: { cookie: `${cookie}; forge_mode=dev` },
7502 redirect: "manual",
7503 });
7504 expect(landed.status).toBe(200);
7505 expect(await landed.text()).toContain('id="app-head"');
7506
7507 // A public app's page is anyone's; the redirect is too.
7508 const anonymous = await fetch(`${base}/alice/notes/app`, {
7509 redirect: "manual",
7510 });
7511 expect(anonymous.status).toBe(302);
7512 expect(anonymous.headers.get("location")).toBe("/alice/notes");
7513 });
7514
7515 test("GET /:owner/:name/app is still not found for a repository that is not an app, or not there", async () => {
7516 const alice = getUserByName(app.db, "alice")!;
7517 const plain = await createRepo(app, alice, {
7518 name: "plain-notes",
7519 visibility: "public",
7520 });
7521 if (!plain.ok) throw new Error(String(plain.error));
7522 for (const path of ["/alice/plain-notes/app", "/alice/nowhere/app"]) {
7523 const response = await fetch(`${base}${path}`, {
7524 headers: { cookie: `${cookie}; forge_mode=studio` },
7525 redirect: "manual",
7526 });
7527 expect(`${path} → ${response.status}`).toBe(`${path} → 404`);
7528 }
7529 });
7530
7531 test("the studio app page carries each preview's Redeploy and Stop, and the environment settings, for a writer only", async () => {
7532 const record = getAppByRepo(app.db, repo.id)!;
7533 const env = previewEnv(1);
7534 (
7535 runtime as unknown as { envs: Map<string, Record<string, unknown>> }
7536 ).envs.set(`${record.id}/${env}`, {
7537 app: record,
7538 env,
7539 chain: Promise.resolve(),
7540 restarts: [],
7541 live: {
7542 commitOid: "b".repeat(40),
7543 handle: { port: 4322, stop: async () => {} },
7544 runId: "fake-preview",
7545 since: new Date().toISOString(),
7546 manifest: { tty: null },
7547 },
7548 });
7549 const owner = await studioPage();
7550 expect(owner).toContain(`action="/alice/notes/app/${env}/redeploy"`);
7551 expect(owner).toContain(`action="/alice/notes/app/${env}/stop"`);
7552 expect(owner).toContain(`href="/alice/notes/app/${env}/open"`);
7553 expect(owner).toContain('href="/alice/notes/settings/app"');
7554
7555 const stranger = await (
7556 await fetch(`${base}/alice/notes`, { redirect: "manual" })
7557 ).text();
7558 expect(stranger).toContain(`href="/alice/notes/app/${env}/open"`);
7559 expect(stranger).not.toContain("/redeploy");
7560 expect(stranger).not.toContain("/stop");
7561 expect(stranger).not.toContain('href="/alice/notes/settings/app"');
7562 });
74817563 });
74827564
74837565 /**
test/web-apps.test.ts +19 −13
@@ -193,7 +193,7 @@ describe("apps, runs and the crew on the web", () => {
193193 },
194194 });
195195 expect(created.status).toBe(303);
196 expect(created.headers.get("location")).toBe("/alice/hello/app");
196 expect(created.headers.get("location")).toBe("/alice/hello");
197197 expect(flashOf(created)).toContain(
198198 `hello is starting at http://hello.${DOMAIN}:${servers.port}`,
199199 );
@@ -212,7 +212,7 @@ describe("apps, runs and the crew on the web", () => {
212212 expect(plain.status).toBe(303);
213213 const plainHome = await bodyOf(alice, "/alice/plain");
214214 expect(plainHome).not.toContain('class="app-strip"');
215 expect(plainHome).not.toContain('href="/alice/plain/app"');
215 expect(plainHome).not.toContain('href="/alice/plain?mode=studio"');
216216 });
217217
218218 test("a refused New app keeps what was typed", async () => {
@@ -243,18 +243,24 @@ describe("apps, runs and the crew on the web", () => {
243243
244244 // ---- the app page and the runs ----
245245
246 test("the App page shows the environments, the runs, and the buttons a writer gets", async () => {
246 test("dev's old App URL lands on the studio's app page, which has the environment controls a writer gets", async () => {
247247 const runs = await runsOf(hello);
248248 expect(runs.length).toBeGreaterThan(0);
249 const html = await bodyOf(alice, "/alice/hello/app");
250 expect(html).toContain("production");
251 expect(html).toContain("Recent runs");
252 expect(html).toContain("Deploy production");
249 // Dev mode's repo home is the code, so the redirect switches over.
250 const old = await web(alice, "/alice/hello/app");
251 expect(old.status).toBe(302);
252 expect(old.headers.get("location")).toBe("/alice/hello?mode=studio");
253 // The code page's strip and the App tab are the same way in.
254 expect(await bodyOf(alice, "/alice/hello")).toContain(
255 'href="/alice/hello?mode=studio"',
256 );
257 // (Its Open link waits for production to be running, #118 — the
258 // dashboard's test above holds that link; this one, the controls.)
259 const html = await bodyOf(alice, "/alice/hello?mode=studio");
253260 expect(html).toContain('action="/alice/hello/app/production/redeploy"');
254 expect(html).toContain('href="/alice/hello/app/production/open"');
255261 expect(html).toContain('href="/alice/hello/settings/app"');
256262 // A reader sees no buttons; bob can read the public repo but not write.
257 const readers = await bodyOf(bob, "/alice/hello/app");
263 const readers = await bodyOf(bob, "/alice/hello?mode=studio");
258264 expect(readers).not.toContain("/redeploy");
259265 expect(readers).not.toContain('href="/alice/hello/settings/app"');
260266 });
@@ -268,7 +274,7 @@ describe("apps, runs and the crew on the web", () => {
268274 if (runs.every((run) => isTerminal(run.state))) break;
269275 await Bun.sleep(50);
270276 }
271 const html = await bodyOf(alice, "/alice/hello/app");
277 const html = await bodyOf(alice, "/alice/hello?mode=studio");
272278 expect(html).toContain('id="live"');
273279 expect(html).toContain('data-repo="/alice/hello"');
274280
@@ -514,10 +520,10 @@ describe("apps, runs and the crew on the web", () => {
514520 form: { slug: "plain", access: "private", previewData: "empty" },
515521 });
516522 expect(enabled.status).toBe(303);
517 expect(enabled.headers.get("location")).toBe("/alice/plain/app");
523 expect(enabled.headers.get("location")).toBe("/alice/plain?mode=studio");
518524 expect(getAppByRepo(app.db, plain.id)?.previewData).toBe("empty");
519525 expect(await bodyOf(alice, "/alice/plain")).toContain(
520 'href="/alice/plain/app"',
526 'href="/alice/plain?mode=studio"',
521527 );
522528
523529 // Disabling goes through the confirm page, which words itself.
@@ -554,7 +560,7 @@ describe("apps, runs and the crew on the web", () => {
554560 expect(`${path} → ${response.status}`).toBe(`${path} → 404`);
555561 }
556562 }
557 expect((await web(alice, "/alice/secret/app")).status).toBe(200);
563 expect((await web(alice, "/alice/secret/app")).status).toBe(302);
558564 });
559565
560566 // ---- the crew ----

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs