Public: anyone can read this; the crew can change it.
Contraction: fold the dev app page into the studio app page #433
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/395-contraction-fold-the-dev-app-page-into-t → main 2 commits fork
15 files
changed +231
−271
docs/DESIGN.md +7 −5
| @@ -186,8 +186,8 @@ specification. | ||
| 186 | 186 | | **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 | |
| 187 | 187 | | **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]` | |
| 188 | 188 | | **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 | | |
| 191 | 191 | | **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 | |
| 192 | 192 | | **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 | |
| 193 | 193 | | **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 | ||
| 317 | 317 | private one and a stranger gets `not_found` from the forge rather than a |
| 318 | 318 | guess at a hostname. |
| 319 | 319 | |
| 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. | |
| 323 | 325 | |
| 324 | 326 | **Assign** — the assignee card on an issue and a pull: nobody, then people |
| 325 | 327 | 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 | ||
| 89 | 89 | the head card and previews list in place, so a change serving in its |
| 90 | 90 | preview shows up without a reload. **Redeploy** and **Stop** therefore stay |
| 91 | 91 | 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. | |
| 97 | 109 | |
| 98 | 110 | **How this app runs itself** (`/:owner/:name/settings`, in studio, on an |
| 99 | 111 | 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 | ||
| 148 | 160 | every app_). A pull's own preview card shows its own command the same |
| 149 | 161 | way, since a preview answers to a different login than production's. A |
| 150 | 162 | 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 | |
| 152 | 164 | the studio page never carry two ideas of what the command is. |
| 153 | 165 | |
| 154 | 166 | **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 | ||
| 302 | 314 | into rows, from grouped reads, so every page that lists apps lists them the |
| 303 | 315 | same way and the home only decides the card around them. **New app** is the |
| 304 | 316 | 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 | |
| 310 | 321 | the toggle in the top bar — it switches this browser to dev from then on; |
| 311 | 322 | `/?mode=studio` switches it back, the same way. |
| 312 | 323 |
forge.db added
Binary file not shown.
src/web/chrome.ts +3 −2
| @@ -237,11 +237,12 @@ export function scopeHeader( | ||
| 237 | 237 | }); |
| 238 | 238 | } |
| 239 | 239 | // 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. | |
| 241 | 242 | if (repo !== undefined && isApp && !studio) |
| 242 | 243 | items.push({ |
| 243 | 244 | label: "App", |
| 244 | href: `${base}/app`, | |
| 245 | href: `${base}?mode=studio`, | |
| 245 | 246 | icon: "rocket", |
| 246 | 247 | current: active === "app", |
| 247 | 248 | shortcut: "g a", |
src/web/pages/apps.ts +43 −211
| @@ -1,8 +1,9 @@ | ||
| 1 | 1 | /** |
| 2 | 2 | * 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. | |
| 6 | 7 | * |
| 7 | 8 | * Every mutation is an action; a page only puts its answer into words. |
| 8 | 9 | * Opening an environment goes through `openEnvUrl`, which is what makes a |
| @@ -19,10 +20,9 @@ import { | ||
| 19 | 20 | stopEnv, |
| 20 | 21 | terminalFor, |
| 21 | 22 | } from "../../actions/apps.ts"; |
| 22 | import { listAllAgents, listCrew } from "../../actions/crew.ts"; | |
| 23 | import { listAllAgents } from "../../actions/crew.ts"; | |
| 23 | 24 | import { planningAvailable } from "../../crew/plan.ts"; |
| 24 | 25 | import { appNameJs, deriveAppName } from "./apps-name.ts"; |
| 25 | import { listRepoRuns } from "../../actions/runs.ts"; | |
| 26 | 26 | import { envPull, previewEnv, type EnvName } from "../../apps/data.ts"; |
| 27 | 27 | import { appsRuntime, type EnvStatus } from "../../apps/runtime.ts"; |
| 28 | 28 | import { appHostSuffix } from "../../apps/host.ts"; |
| @@ -30,7 +30,6 @@ import { appsForRepos, type AppRecord } from "../../core/apps.ts"; | ||
| 30 | 30 | import { accessAllows, listOrgsForUser, orgAccess } from "../../core/orgs.ts"; |
| 31 | 31 | import type { Pull } from "../../core/pulls.ts"; |
| 32 | 32 | import { |
| 33 | canAdmin, | |
| 34 | 33 | canWrite, |
| 35 | 34 | getRepoById, |
| 36 | 35 | listReposVisibleTo, |
| @@ -40,19 +39,15 @@ import type { User } from "../../core/users.ts"; | ||
| 40 | 39 | import { safeBack, type Ctx } from "../../http/context.ts"; |
| 41 | 40 | import { isHostPort, resolveAppHostname } from "../../apps/proxy.ts"; |
| 42 | 41 | import { |
| 43 | crumbsFor, | |
| 44 | 42 | loadRepo, |
| 45 | 43 | notFoundPage, |
| 46 | 44 | pageHead, |
| 47 | 45 | repoBase, |
| 48 | repoHeader, | |
| 49 | repoPalette, | |
| 50 | 46 | signInFirst, |
| 51 | 47 | } from "../chrome.ts"; |
| 52 | 48 | import { field, readForm } from "../form.ts"; |
| 53 | 49 | import { html, when, type Html } from "../html.ts"; |
| 54 | 50 | import { flashTo, page, redirect } from "../layout.ts"; |
| 55 | import { liveClientJs, liveMount } from "../live.ts"; | |
| 56 | 51 | import { shelfRows } from "../studio/shelf.ts"; |
| 57 | 52 | import { |
| 58 | 53 | badge, |
| @@ -60,7 +55,6 @@ import { | ||
| 60 | 55 | card, |
| 61 | 56 | dot, |
| 62 | 57 | empty, |
| 63 | envActions, | |
| 64 | 58 | envFacts, |
| 65 | 59 | envPhrase, |
| 66 | 60 | envStateBadge, |
| @@ -72,11 +66,8 @@ import { | ||
| 72 | 66 | selectField, |
| 73 | 67 | sha, |
| 74 | 68 | terminalLine, |
| 75 | time, | |
| 76 | userLink, | |
| 77 | 69 | field as formField, |
| 78 | 70 | } from "../ui.ts"; |
| 79 | import { actorsFor, issuesFor, runRow } from "./runs.ts"; | |
| 80 | 71 | |
| 81 | 72 | // ---- what the viewer can see ---- |
| 82 | 73 | |
| @@ -128,7 +119,7 @@ export function envLabel(env: EnvName): string { | ||
| 128 | 119 | |
| 129 | 120 | /** |
| 130 | 121 | * 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 | |
| 132 | 123 | * one (a link inside a link tears the row apart). |
| 133 | 124 | */ |
| 134 | 125 | function appRow(entry: AppEntry): Html { |
| @@ -137,7 +128,7 @@ function appRow(entry: AppEntry): Html { | ||
| 137 | 128 | return row({ |
| 138 | 129 | nav: true, |
| 139 | 130 | lead: dot(envPhrase(entry.production).tone), |
| 140 | title: html`<a href="${base}/app">${name}</a>`, | |
| 131 | title: html`<a href="${base}">${name}</a>`, | |
| 141 | 132 | titleAttr: name, |
| 142 | 133 | meta: html`${entry.url === undefined |
| 143 | 134 | ? html`<span>not serving</span>` |
| @@ -532,10 +523,9 @@ export async function newAppSubmit(ctx: Ctx): Promise<Response> { | ||
| 532 | 523 | : plan |
| 533 | 524 | ? ` ${plan.agent} was picked for #${issue.number} — ${plan.summary}` |
| 534 | 525 | : ` #${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}` }); | |
| 539 | 529 | } |
| 540 | 530 | |
| 541 | 531 | // ---- the repo home's strip ---- |
| @@ -543,7 +533,8 @@ export async function newAppSubmit(ctx: Ctx): Promise<Response> { | ||
| 543 | 533 | /** |
| 544 | 534 | * One line under the repo header, for a repository that runs: production's |
| 545 | 535 | * 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. | |
| 547 | 538 | */ |
| 548 | 539 | export function appStrip(ctx: Ctx, repo: Repo): Html { |
| 549 | 540 | const view = appView(ctx.app, ctx.auth.user, repo); |
| @@ -580,7 +571,7 @@ export function appStrip(ctx: Ctx, repo: Repo): Html { | ||
| 580 | 571 | )} |
| 581 | 572 | <span class="spacer"></span> |
| 582 | 573 | ${btn("App", { |
| 583 | href: `${base}/app`, | |
| 574 | href: appHref(ctx, repo), | |
| 584 | 575 | size: "sm", |
| 585 | 576 | variant: "ghost", |
| 586 | 577 | icon: "chevronRight", |
| @@ -590,180 +581,27 @@ export function appStrip(ctx: Ctx, repo: Repo): Html { | ||
| 590 | 581 | |
| 591 | 582 | // ---- the app page ---- |
| 592 | 583 | |
| 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`; | |
| 621 | 593 | } |
| 622 | 594 | |
| 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. | |
| 624 | 598 | export function appPage(ctx: Ctx): Response { |
| 625 | 599 | const repo = loadRepo(ctx); |
| 626 | 600 | 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" }, | |
| 767 | 605 | }); |
| 768 | 606 | } |
| 769 | 607 | |
| @@ -870,7 +708,7 @@ export function appOpen(ctx: Ctx): Response { | ||
| 870 | 708 | if (!link.ok) { |
| 871 | 709 | if (link.error === "unauthorized") return signInFirst(ctx); |
| 872 | 710 | if (link.error === "no_runtime") |
| 873 | return flashTo(ctx, `${repoBase(repo)}/app`, { | |
| 711 | return flashTo(ctx, repoBase(repo), { | |
| 874 | 712 | tone: "attn", |
| 875 | 713 | message: "Nothing is serving apps on this forge.", |
| 876 | 714 | }); |
| @@ -895,19 +733,16 @@ async function envForm( | ||
| 895 | 733 | } |
| 896 | 734 | |
| 897 | 735 | /** |
| 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. | |
| 906 | 743 | */ |
| 907 | 744 | 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); | |
| 911 | 746 | } |
| 912 | 747 | |
| 913 | 748 | function envFailure(ctx: Ctx, repo: Repo, error: string): Response { |
| @@ -932,17 +767,14 @@ export async function appRedeploy(ctx: Ctx): Promise<Response> { | ||
| 932 | 767 | const { repo, env, user } = gate; |
| 933 | 768 | const queued = await redeployEnv(ctx.app, user, repo, env); |
| 934 | 769 | if (!queued.ok) return envFailure(ctx, repo, queued.error); |
| 935 | const base = repoBase(repo); | |
| 936 | 770 | 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. | |
| 940 | 774 | const to = |
| 941 | ctx.mode === "studio" | |
| 775 | ctx.mode === "studio" || run === undefined | |
| 942 | 776 | ? envBack(ctx, repo) |
| 943 | : run === undefined | |
| 944 | ? `${base}/app` | |
| 945 | : `${base}/runs/${run.id}`; | |
| 777 | : `${repoBase(repo)}/runs/${run.id}`; | |
| 946 | 778 | return redirect(to, { |
| 947 | 779 | url: ctx.url, |
| 948 | 780 | flash: { |
src/web/pages/settings.ts +3 −2
| @@ -86,6 +86,7 @@ import { | ||
| 86 | 86 | settingsLayout, |
| 87 | 87 | } from "../chrome.ts"; |
| 88 | 88 | import { studioCrewSettings } from "../studio/crew.ts"; |
| 89 | import { appHref } from "./apps.ts"; | |
| 89 | 90 | import { field, readForm, type Form } from "../form.ts"; |
| 90 | 91 | import { html, raw, when, type Html } from "../html.ts"; |
| 91 | 92 | import { icon } from "../icons.ts"; |
| @@ -462,7 +463,7 @@ function appSettingsView( | ||
| 462 | 463 | })} |
| 463 | 464 | ${when(record !== undefined, () => |
| 464 | 465 | btn("Environments", { |
| 465 | href: `${base}/app`, | |
| 466 | href: appHref(ctx, repo), | |
| 466 | 467 | variant: "ghost", |
| 467 | 468 | icon: "rocket", |
| 468 | 469 | }), |
| @@ -758,7 +759,7 @@ export async function appSettingsUpdate(ctx: Ctx): Promise<Response> { | ||
| 758 | 759 | return flashTo( |
| 759 | 760 | ctx, |
| 760 | 761 | record === undefined |
| 761 | ? `${repoBase(repo)}/app` | |
| 762 | ? appHref(ctx, repo) | |
| 762 | 763 | : `${repoBase(repo)}/settings/app`, |
| 763 | 764 | { |
| 764 | 765 | message: |
src/web/studio/app.ts +13 −5
| @@ -1,9 +1,12 @@ | ||
| 1 | 1 | /** |
| 2 | 2 | * The app in the studio: one page per app, and the front door a person acts |
| 3 | 3 | * 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. | |
| 7 | 10 | */ |
| 8 | 11 | |
| 9 | 12 | import { audit } from "../../contract/audit.ts"; |
| @@ -13,7 +16,7 @@ import { listCrewRoles } from "../../core/agents.ts"; | ||
| 13 | 16 | import type { AppRecord } from "../../core/apps.ts"; |
| 14 | 17 | import { decisionStats, type DecisionStat } from "../../core/decisions.ts"; |
| 15 | 18 | 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"; | |
| 17 | 20 | import { |
| 18 | 21 | agentRunTotals, |
| 19 | 22 | previewLatency, |
| @@ -406,10 +409,15 @@ export async function studioApp( | ||
| 406 | 409 | extra: "about", |
| 407 | 410 | }, |
| 408 | 411 | ); |
| 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. | |
| 409 | 414 | const more = html`<p class="muted small"> |
| 410 | 415 | <a href="${base}/map">The map</a> · |
| 411 | 416 | <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 | )} | |
| 413 | 421 | </p>`; |
| 414 | 422 | |
| 415 | 423 | const body = html`${repoHeader(ctx, repo, "code", { |
src/web/studio/now.ts +1 −1
| @@ -212,7 +212,7 @@ export async function nowCard( | ||
| 212 | 212 | const number = envPull(env.env); |
| 213 | 213 | return [ |
| 214 | 214 | row({ |
| 215 | href: `${base}/app`, | |
| 215 | href: `${base}#app-head`, | |
| 216 | 216 | nav: true, |
| 217 | 217 | lead: icon(number === undefined ? "rocket" : "pull"), |
| 218 | 218 | title: number === undefined ? "production" : `preview of #${number}`, |
src/web/studio/previews.ts +22 −7
| @@ -84,7 +84,9 @@ function previewBadge(env: EnvStatus | undefined): Html { | ||
| 84 | 84 | /** |
| 85 | 85 | * One open pull's preview, as one row: its host is the click, its state is |
| 86 | 86 | * 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. | |
| 88 | 90 | */ |
| 89 | 91 | function previewRow( |
| 90 | 92 | base: string, |
| @@ -92,6 +94,7 @@ function previewRow( | ||
| 92 | 94 | record: AppRecord, |
| 93 | 95 | pull: Pull, |
| 94 | 96 | env: EnvStatus | undefined, |
| 97 | writer: boolean, | |
| 95 | 98 | ): Html { |
| 96 | 99 | const href = |
| 97 | 100 | runtime === undefined |
| @@ -112,6 +115,17 @@ function previewRow( | ||
| 112 | 115 | const dataNote = previewDataNote(record.previewData, pull.createdAt); |
| 113 | 116 | if (dataNote !== null) bits.push(dataNote); |
| 114 | 117 | } |
| 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 | }); | |
| 115 | 129 | return row({ |
| 116 | 130 | ...(href === undefined ? {} : { href, nav: true }), |
| 117 | 131 | lead: icon("rocket"), |
| @@ -119,7 +133,7 @@ function previewRow( | ||
| 119 | 133 | host === undefined |
| 120 | 134 | ? `#${pull.number} ${pull.title}` |
| 121 | 135 | : html`<span class="mono">${host}</span>`, |
| 122 | meta: bits.reduce((a, b) => html`${a} · ${b}`), | |
| 136 | meta, | |
| 123 | 137 | trail: previewBadge(env), |
| 124 | 138 | }); |
| 125 | 139 | } |
| @@ -136,6 +150,7 @@ function previewsPanel( | ||
| 136 | 150 | record: AppRecord, |
| 137 | 151 | openPulls: readonly Pull[], |
| 138 | 152 | envs: readonly EnvStatus[], |
| 153 | writer: boolean, | |
| 139 | 154 | ): Html | null { |
| 140 | 155 | if (openPulls.length === 0) return null; |
| 141 | 156 | const byPull = new Map( |
| @@ -145,7 +160,7 @@ function previewsPanel( | ||
| 145 | 160 | ); |
| 146 | 161 | return listCard( |
| 147 | 162 | openPulls.map((p) => |
| 148 | previewRow(base, runtime, record, p, byPull.get(p.number)), | |
| 163 | previewRow(base, runtime, record, p, byPull.get(p.number), writer), | |
| 149 | 164 | ), |
| 150 | 165 | { |
| 151 | 166 | head: html`${icon("rocket")} |
| @@ -248,13 +263,13 @@ export function appPanels( | ||
| 248 | 263 | const base = repoBase(repo); |
| 249 | 264 | const runtime = appsRuntime(ctx.app); |
| 250 | 265 | 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); | |
| 252 | 268 | const production = envs.find((e) => e.env === "production"); |
| 253 | 269 | const url = runtime?.urlFor(record, "production"); |
| 254 | const writer = canSeeOwnerFacts(db, repo, ctx.auth.user); | |
| 255 | 270 | 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. | |
| 258 | 273 | const live = envPhrase(production); |
| 259 | 274 | const head = html`<div class="page-head studio-head"> |
| 260 | 275 | <div class="stack stack-tight"> |
src/web/style/content.ts +4 −6
| @@ -649,15 +649,13 @@ form.inline { display: inline; margin-left: auto; } | ||
| 649 | 649 | } |
| 650 | 650 | .app-strip .spacer { flex: 1; } |
| 651 | 651 | /* 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. */ | |
| 656 | 656 | .env-card .card-head { gap: 10px; } |
| 657 | 657 | .env-facts { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; font-size: 13px; color: var(--muted-fg); } |
| 658 | 658 | .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; } | |
| 661 | 659 | /* The look: a phone beside a desktop, each in a hairline frame. The phone |
| 662 | 660 | keeps a phone's narrowness; the desktop takes what is left, never more |
| 663 | 661 | 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)", () => { | ||
| 1700 | 1700 | expect(html).toContain("#1 is assigned to bot."); |
| 1701 | 1701 | }); |
| 1702 | 1702 | |
| 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 () => { | |
| 1704 | 1704 | const submitted = await fetch(`${base}/apps/new`, { |
| 1705 | 1705 | method: "POST", |
| 1706 | 1706 | 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)", () => { | ||
| 1714 | 1714 | redirect: "manual", |
| 1715 | 1715 | }); |
| 1716 | 1716 | expect(submitted.status).toBe(303); |
| 1717 | expect(submitted.headers.get("location")).toBe("/priya/tracker/app"); | |
| 1717 | expect(submitted.headers.get("location")).toBe("/priya/tracker"); | |
| 1718 | 1718 | const flash = (submitted.headers.get("set-cookie") ?? "").split(";")[0]!; |
| 1719 | 1719 | expect(flash).toStartWith("forge_flash="); |
| 1720 | 1720 |
test/studio.test.ts +88 −6
| @@ -7259,9 +7259,9 @@ describe("every studio page's headings form one outline", () => { | ||
| 7259 | 7259 | * to leave the studio — Stop for dev's App page, Redeploy for the deploy's |
| 7260 | 7260 | * run page. Both now come back to the page the form was pressed on, because |
| 7261 | 7261 | * 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. | |
| 7265 | 7265 | */ |
| 7266 | 7266 | describe("the studio's Stop and Redeploy come back to the page they were pressed on", () => { |
| 7267 | 7267 | let dataDir: string; |
| @@ -7466,10 +7466,10 @@ describe("the studio's Stop and Redeploy come back to the page they were pressed | ||
| 7466 | 7466 | ).toBeGreaterThan(0); |
| 7467 | 7467 | }); |
| 7468 | 7468 | |
| 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`); | |
| 7471 | 7471 | 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"); | |
| 7473 | 7473 | expect(flashOf(stopped)).toContain("Stopped production."); |
| 7474 | 7474 | |
| 7475 | 7475 | 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 | ||
| 7478 | 7478 | expect(queued.headers.get("location")).toBe(`/alice/notes/runs/${run?.id}`); |
| 7479 | 7479 | expect(flashOf(queued)).toContain("Redeploying production."); |
| 7480 | 7480 | }); |
| 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 | }); | |
| 7481 | 7563 | }); |
| 7482 | 7564 | |
| 7483 | 7565 | /** |
test/web-apps.test.ts +19 −13
| @@ -193,7 +193,7 @@ describe("apps, runs and the crew on the web", () => { | ||
| 193 | 193 | }, |
| 194 | 194 | }); |
| 195 | 195 | expect(created.status).toBe(303); |
| 196 | expect(created.headers.get("location")).toBe("/alice/hello/app"); | |
| 196 | expect(created.headers.get("location")).toBe("/alice/hello"); | |
| 197 | 197 | expect(flashOf(created)).toContain( |
| 198 | 198 | `hello is starting at http://hello.${DOMAIN}:${servers.port}`, |
| 199 | 199 | ); |
| @@ -212,7 +212,7 @@ describe("apps, runs and the crew on the web", () => { | ||
| 212 | 212 | expect(plain.status).toBe(303); |
| 213 | 213 | const plainHome = await bodyOf(alice, "/alice/plain"); |
| 214 | 214 | 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"'); | |
| 216 | 216 | }); |
| 217 | 217 | |
| 218 | 218 | test("a refused New app keeps what was typed", async () => { |
| @@ -243,18 +243,24 @@ describe("apps, runs and the crew on the web", () => { | ||
| 243 | 243 | |
| 244 | 244 | // ---- the app page and the runs ---- |
| 245 | 245 | |
| 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 () => { | |
| 247 | 247 | const runs = await runsOf(hello); |
| 248 | 248 | 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"); | |
| 253 | 260 | expect(html).toContain('action="/alice/hello/app/production/redeploy"'); |
| 254 | expect(html).toContain('href="/alice/hello/app/production/open"'); | |
| 255 | 261 | expect(html).toContain('href="/alice/hello/settings/app"'); |
| 256 | 262 | // 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"); | |
| 258 | 264 | expect(readers).not.toContain("/redeploy"); |
| 259 | 265 | expect(readers).not.toContain('href="/alice/hello/settings/app"'); |
| 260 | 266 | }); |
| @@ -268,7 +274,7 @@ describe("apps, runs and the crew on the web", () => { | ||
| 268 | 274 | if (runs.every((run) => isTerminal(run.state))) break; |
| 269 | 275 | await Bun.sleep(50); |
| 270 | 276 | } |
| 271 | const html = await bodyOf(alice, "/alice/hello/app"); | |
| 277 | const html = await bodyOf(alice, "/alice/hello?mode=studio"); | |
| 272 | 278 | expect(html).toContain('id="live"'); |
| 273 | 279 | expect(html).toContain('data-repo="/alice/hello"'); |
| 274 | 280 | |
| @@ -514,10 +520,10 @@ describe("apps, runs and the crew on the web", () => { | ||
| 514 | 520 | form: { slug: "plain", access: "private", previewData: "empty" }, |
| 515 | 521 | }); |
| 516 | 522 | 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"); | |
| 518 | 524 | expect(getAppByRepo(app.db, plain.id)?.previewData).toBe("empty"); |
| 519 | 525 | expect(await bodyOf(alice, "/alice/plain")).toContain( |
| 520 | 'href="/alice/plain/app"', | |
| 526 | 'href="/alice/plain?mode=studio"', | |
| 521 | 527 | ); |
| 522 | 528 | |
| 523 | 529 | // Disabling goes through the confirm page, which words itself. |
| @@ -554,7 +560,7 @@ describe("apps, runs and the crew on the web", () => { | ||
| 554 | 560 | expect(`${path} → ${response.status}`).toBe(`${path} → 404`); |
| 555 | 561 | } |
| 556 | 562 | } |
| 557 | expect((await web(alice, "/alice/secret/app")).status).toBe(200); | |
| 563 | expect((await web(alice, "/alice/secret/app")).status).toBe(302); | |
| 558 | 564 | }); |
| 559 | 565 | |
| 560 | 566 | // ---- the crew ---- |
new above
offline