Public: anyone can read this; the crew can change it.
/apps and an owner's page draw their app shelf under 40 ms with 40 apps #6138
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/6123-apps-and-an-owner-s-page-draw-their-app → main 4 commits fork 3 threads · 3 unresolved
5 files
changed +1043
−77
src/apps/runtime.ts +40 −13
| @@ -57,7 +57,7 @@ import { createLog } from "../kernel/log.ts"; | ||
| 57 | 57 | import type { Result } from "../kernel/result.ts"; |
| 58 | 58 | import { holdLease, STALE_AFTER_MS } from "../runs/lease.ts"; |
| 59 | 59 | import { appendRunLog } from "../runs/log.ts"; |
| 60 | import { primaryDomain } from "../core/domains.ts"; | |
| 60 | import { primaryDomain, primaryHosts } from "../core/domains.ts"; | |
| 61 | 61 | import { |
| 62 | 62 | beatAwake, |
| 63 | 63 | closeAllAwake, |
| @@ -394,6 +394,14 @@ export function envsFor( | ||
| 394 | 394 | return runtime.status(record); |
| 395 | 395 | } |
| 396 | 396 | |
| 397 | /** Production first, then previews by pull number — a number, so `pull-10` follows `pull-9`. */ | |
| 398 | const byEnv = (a: EnvStatus, b: EnvStatus): number => | |
| 399 | a.env === "production" | |
| 400 | ? -1 | |
| 401 | : b.env === "production" | |
| 402 | ? 1 | |
| 403 | : (envPull(a.env) ?? 0) - (envPull(b.env) ?? 0); | |
| 404 | ||
| 397 | 405 | export class AppsRuntime { |
| 398 | 406 | private readonly envs = new Map<string, Environment>(); |
| 399 | 407 | /** Whether this host can hold an app in a cell (`apps/contain.ts`), asked once at start. */ |
| @@ -449,10 +457,16 @@ export class AppsRuntime { | ||
| 449 | 457 | * domain carries no port: it reaches the forge through whatever fronts |
| 450 | 458 | * it, which holds its own. |
| 451 | 459 | */ |
| 452 | urlFor(record: AppRecord, env: EnvName): string { | |
| 460 | urlFor( | |
| 461 | record: AppRecord, | |
| 462 | env: EnvName, | |
| 463 | hosts?: ReadonlyMap<string, string>, | |
| 464 | ): string { | |
| 453 | 465 | if (env === "production") { |
| 454 | const own = primaryDomain(this.app.db, record.id); | |
| 455 | if (own !== undefined) return `${this.options.scheme}://${own.host}`; | |
| 466 | const host = hosts | |
| 467 | ? hosts.get(record.id) | |
| 468 | : primaryDomain(this.app.db, record.id)?.host; | |
| 469 | if (host !== undefined) return `${this.options.scheme}://${host}`; | |
| 456 | 470 | } |
| 457 | 471 | const port = |
| 458 | 472 | this.options.publicPort === undefined || |
| @@ -471,6 +485,22 @@ export class AppsRuntime { | ||
| 471 | 485 | return this.describe(state); |
| 472 | 486 | } |
| 473 | 487 | |
| 488 | /** | |
| 489 | * `status` for many apps in one pass over the environments and one read | |
| 490 | * of their domains; nothing per app touches the database. The caller has | |
| 491 | * already decided which apps the viewer may see (`envsFor`'s rule). | |
| 492 | */ | |
| 493 | statusMany(records: readonly AppRecord[]): Map<string, EnvStatus[]> { | |
| 494 | const out = new Map<string, EnvStatus[]>(); | |
| 495 | const wanted = new Set(records.map((r) => r.id)); | |
| 496 | const hosts = primaryHosts(this.app.db, [...wanted]); | |
| 497 | for (const id of wanted) out.set(id, []); | |
| 498 | for (const state of this.envs.values()) | |
| 499 | out.get(state.app.id)?.push(this.describe(state, hosts)); | |
| 500 | for (const list of out.values()) list.sort(byEnv); | |
| 501 | return out; | |
| 502 | } | |
| 503 | ||
| 474 | 504 | /** Every environment of an app, production first. */ |
| 475 | 505 | status(record: AppRecord): EnvStatus[] { |
| 476 | 506 | const out: EnvStatus[] = []; |
| @@ -479,17 +509,14 @@ export class AppsRuntime { | ||
| 479 | 509 | } |
| 480 | 510 | // Production first, then previews by pull number — a number, not a |
| 481 | 511 | // string, so `pull-10` follows `pull-9`. |
| 482 | return out.sort((a, b) => | |
| 483 | a.env === "production" | |
| 484 | ? -1 | |
| 485 | : b.env === "production" | |
| 486 | ? 1 | |
| 487 | : (envPull(a.env) ?? 0) - (envPull(b.env) ?? 0), | |
| 488 | ); | |
| 512 | return out.sort(byEnv); | |
| 489 | 513 | } |
| 490 | 514 | |
| 491 | private describe(state: Environment): EnvStatus { | |
| 492 | const url = this.urlFor(state.app, state.env); | |
| 515 | private describe( | |
| 516 | state: Environment, | |
| 517 | hosts?: ReadonlyMap<string, string>, | |
| 518 | ): EnvStatus { | |
| 519 | const url = this.urlFor(state.app, state.env, hosts); | |
| 493 | 520 | // Once a deploy's own commit has answered and swapped in, it is |
| 494 | 521 | // "running" from here — even though its run stays open, checking its |
| 495 | 522 | // tests, until they finish. Only a build still short of that point (no |
src/core/domains.ts +14
| @@ -177,6 +177,20 @@ export function primaryDomain( | ||
| 177 | 177 | return row ? toDomain(row) : undefined; |
| 178 | 178 | } |
| 179 | 179 | |
| 180 | /** `primaryDomain` for many apps in one read: app id to host, apps with none left out. */ | |
| 181 | export function primaryHosts(db: Database, appIds: readonly string[]): Map<string, string> { | |
| 182 | const out = new Map<string, string>(); | |
| 183 | if (appIds.length === 0) return out; | |
| 184 | const marks = appIds.map(() => "?").join(","); | |
| 185 | const rows = db | |
| 186 | .query<{ app_id: string; host: string }, string[]>( | |
| 187 | `SELECT app_id, host FROM app_domain WHERE app_id IN (${marks}) AND verified_at IS NOT NULL ORDER BY verified_at, host`, | |
| 188 | ) | |
| 189 | .all(...appIds); | |
| 190 | for (const r of rows) if (!out.has(r.app_id)) out.set(r.app_id, r.host); | |
| 191 | return out; | |
| 192 | } | |
| 193 | ||
| 180 | 194 | export function removeDomain(db: Database, host: string): boolean { |
| 181 | 195 | return ( |
| 182 | 196 | db.query("DELETE FROM app_domain WHERE host = ?1").run(normalizeHost(host)) |
src/web/studio/shelf.ts +106 −64
| @@ -25,11 +25,12 @@ | ||
| 25 | 25 | */ |
| 26 | 26 | |
| 27 | 27 | import { spanWords } from "../../apps/golden_path.ts"; |
| 28 | import { appsRuntime, type EnvStatus, envsFor } from "../../apps/runtime.ts"; | |
| 29 | import { appsForRepos } from "../../core/apps.ts"; | |
| 28 | import { appsRuntime, type EnvStatus } from "../../apps/runtime.ts"; | |
| 29 | import { appsForRepos, type AppRecord } from "../../core/apps.ts"; | |
| 30 | 30 | import { reposWithBuilder } from "../../core/agents.ts"; |
| 31 | 31 | import { reposWithUnclaimedTell } from "../../core/issues.ts"; |
| 32 | 32 | import { openPullsForRepos } from "../../core/pulls.ts"; |
| 33 | import { notePhase } from "../../core/route_timing.ts"; | |
| 33 | 34 | import { enterableApps, ownerFactRepoIds, type Repo } from "../../core/repos.ts"; |
| 34 | 35 | import { |
| 35 | 36 | getRunsByIds, |
| @@ -67,38 +68,51 @@ export interface ShelfReads { | ||
| 67 | 68 | } |
| 68 | 69 | |
| 69 | 70 | /** |
| 70 | * Production's own fact for every one of these repositories, in one batch: | |
| 71 | * `runtime.status` is in-memory (no query), so the only read is the run rows | |
| 72 | * a fact points to — one `IN` for the whole shelf, never one per row. | |
| 71 | * Production's status for each of these apps, in one batch: the runtime's | |
| 72 | * environments are in memory and their domains are one read, so nothing per | |
| 73 | * app touches the database. `enterable` is who the viewer may see into | |
| 74 | * (`enterableApps`, the rule `envsFor` holds); the rest read as no status. | |
| 73 | 75 | */ |
| 74 | export function productionFactsForRepos( | |
| 76 | function productionStatuses( | |
| 75 | 77 | ctx: Ctx, |
| 76 | 78 | repos: readonly Repo[], |
| 77 | ): Map<string, ProductionFact> { | |
| 78 | const db = ctx.app.db; | |
| 79 | records: ReadonlyMap<string, AppRecord>, | |
| 80 | enterable: ReadonlySet<string>, | |
| 81 | ): Map<string, EnvStatus | undefined> { | |
| 82 | const out = new Map<string, EnvStatus | undefined>(); | |
| 79 | 83 | const runtime = appsRuntime(ctx.app); |
| 80 | const out = new Map<string, ProductionFact>(); | |
| 81 | 84 | if (runtime === undefined) return out; |
| 82 | const records = appsForRepos( | |
| 83 | db, | |
| 84 | repos.map((r) => r.id), | |
| 85 | ); | |
| 86 | const productions = new Map<string, EnvStatus | undefined>(); | |
| 87 | const runIds = new Set<string>(); | |
| 85 | const seen = repos.flatMap((r) => { | |
| 86 | const record = records.get(r.id); | |
| 87 | return record !== undefined && enterable.has(record.id) ? [record] : []; | |
| 88 | }); | |
| 89 | const statuses = runtime.statusMany(seen); | |
| 88 | 90 | for (const repo of repos) { |
| 89 | 91 | const record = records.get(repo.id); |
| 90 | const production = record | |
| 91 | ? envsFor(ctx.app, ctx.auth.user, record).find( | |
| 92 | (e) => e.env === "production", | |
| 93 | ) | |
| 94 | : undefined; | |
| 95 | productions.set(repo.id, production); | |
| 92 | out.set( | |
| 93 | repo.id, | |
| 94 | record === undefined | |
| 95 | ? undefined | |
| 96 | : statuses.get(record.id)?.find((e) => e.env === "production"), | |
| 97 | ); | |
| 98 | } | |
| 99 | return out; | |
| 100 | } | |
| 101 | ||
| 102 | /** Production's own fact for each status, reading the run rows it points to in one `IN`. */ | |
| 103 | function factsOf( | |
| 104 | ctx: Ctx, | |
| 105 | productions: ReadonlyMap<string, EnvStatus | undefined>, | |
| 106 | ): Map<string, ProductionFact> { | |
| 107 | const out = new Map<string, ProductionFact>(); | |
| 108 | const runIds = new Set<string>(); | |
| 109 | for (const production of productions.values()) { | |
| 96 | 110 | if (production?.runId !== undefined && production.createdAt !== undefined) |
| 97 | 111 | runIds.add(production.runId); |
| 98 | 112 | if (production?.failedDeploy !== undefined) |
| 99 | 113 | runIds.add(production.failedDeploy.runId); |
| 100 | 114 | } |
| 101 | const runs = getRunsByIds(db, [...runIds]); | |
| 115 | const runs = getRunsByIds(ctx.app.db, [...runIds]); | |
| 102 | 116 | for (const [repoId, production] of productions) { |
| 103 | 117 | const fact = productionFact(production, runs); |
| 104 | 118 | if (fact !== undefined) out.set(repoId, fact); |
| @@ -106,6 +120,33 @@ export function productionFactsForRepos( | ||
| 106 | 120 | return out; |
| 107 | 121 | } |
| 108 | 122 | |
| 123 | /** | |
| 124 | * Production's own fact for every one of these repositories, in one batch: | |
| 125 | * `runtime.status` is in-memory (no query), so the only reads are the app | |
| 126 | * records, who may enter them, their domains and the run rows a fact points | |
| 127 | * to — one each for the whole shelf, never one per row. | |
| 128 | */ | |
| 129 | export function productionFactsForRepos( | |
| 130 | ctx: Ctx, | |
| 131 | repos: readonly Repo[], | |
| 132 | ): Map<string, ProductionFact> { | |
| 133 | if (appsRuntime(ctx.app) === undefined) return new Map(); | |
| 134 | const records = appsForRepos( | |
| 135 | ctx.app.db, | |
| 136 | repos.map((r) => r.id), | |
| 137 | ); | |
| 138 | const enterable = enterableApps( | |
| 139 | ctx.app.db, | |
| 140 | repos.flatMap((repo) => { | |
| 141 | const app = records.get(repo.id); | |
| 142 | return app === undefined ? [] : [{ repo, app }]; | |
| 143 | }), | |
| 144 | ctx.auth.user, | |
| 145 | { named: false }, | |
| 146 | ); | |
| 147 | return factsOf(ctx, productionStatuses(ctx, repos, records, enterable)); | |
| 148 | } | |
| 149 | ||
| 109 | 150 | /** The row of a private app the viewer may not enter: its name, nothing it says. */ |
| 110 | 151 | function sealedRow(repo: Repo, user: User | undefined): Html { |
| 111 | 152 | const mine = user !== undefined && repo.ownerId === user.id; |
| @@ -136,48 +177,19 @@ export async function shelfRows( | ||
| 136 | 177 | const user = ctx.auth.user; |
| 137 | 178 | const runtime = appsRuntime(ctx.app); |
| 138 | 179 | const history = reads.history ?? servingHistory(); |
| 139 | const records = appsForRepos( | |
| 140 | db, | |
| 141 | repos.map((r) => r.id), | |
| 142 | ); | |
| 180 | const repoIds = repos.map((r) => r.id); | |
| 181 | // Each stretch is named for Server-Timing, so a slow shelf says where. | |
| 182 | let mark = performance.now(); | |
| 183 | const lap = (name: string): void => { | |
| 184 | const now = performance.now(); | |
| 185 | notePhase(ctx.request, name, now - mark); | |
| 186 | mark = now; | |
| 187 | }; | |
| 188 | const records = appsForRepos(db, repoIds); | |
| 143 | 189 | // A public app you can only read appears here too, but who is on its |
| 144 | 190 | // crew is its owner's fact, not yours to see — same predicate as the |
| 145 | 191 | // app's own page. |
| 146 | 192 | const ownerFacts = ownerFactRepoIds(db, repos, user); |
| 147 | // One read for the newest run of every app, one for who ran them: the | |
| 148 | // shelf must not get slower as the apps grow. | |
| 149 | const latestRuns = latestAgentRunsForRepos( | |
| 150 | db, | |
| 151 | repos.map((r) => r.id), | |
| 152 | ); | |
| 153 | // Production's own fact, per app, one batch — a deploy that just landed or | |
| 154 | // just failed, the other half of what "the last thing that happened" can | |
| 155 | // mean now that it is not only an agent run. | |
| 156 | const productionFacts = | |
| 157 | reads.productionFacts ?? productionFactsForRepos(ctx, repos); | |
| 158 | const actors = getUsersByIds( | |
| 159 | db, | |
| 160 | [ | |
| 161 | ...[...latestRuns.values()].map((r) => r.actorId), | |
| 162 | ...[...productionFacts.values()].map((f) => f.actorId), | |
| 163 | ].filter((id): id is string => id !== null), | |
| 164 | ); | |
| 165 | // The newest open change of every app, one read — a person's own change | |
| 166 | // counts here too, not only one an agent run produced. | |
| 167 | const openPulls = openPullsForRepos( | |
| 168 | db, | |
| 169 | repos.map((r) => r.id), | |
| 170 | ); | |
| 171 | // A tell nobody can pick up (the crew has no builder) is stuck, not quiet — | |
| 172 | // both reads batched across the whole shelf, never once per row. | |
| 173 | const repoIds = repos.map((r) => r.id); | |
| 174 | const withBuilder = reposWithBuilder(db, repoIds); | |
| 175 | const stuck = new Set( | |
| 176 | [...reposWithUnclaimedTell(db, repoIds)].filter( | |
| 177 | (id) => !withBuilder.has(id), | |
| 178 | ), | |
| 179 | ); | |
| 180 | ||
| 181 | 193 | // Who may enter which private app, for the whole shelf in one batch. |
| 182 | 194 | const enterable = enterableApps( |
| 183 | 195 | db, |
| @@ -188,8 +200,38 @@ export async function shelfRows( | ||
| 188 | 200 | user, |
| 189 | 201 | { named: false }, |
| 190 | 202 | ); |
| 203 | lap("records"); | |
| 204 | // One read for the newest run of every app, one for who ran them: the | |
| 205 | // shelf must not get slower as the apps grow. | |
| 206 | const latestRuns = latestAgentRunsForRepos(db, repoIds); | |
| 207 | lap("runs"); | |
| 208 | // Production's own fact, per app, one batch — a deploy that just landed or | |
| 209 | // just failed, the other half of what "the last thing that happened" can | |
| 210 | // mean now that it is not only an agent run. | |
| 211 | const productions = productionStatuses(ctx, repos, records, enterable); | |
| 212 | const productionFacts = reads.productionFacts ?? factsOf(ctx, productions); | |
| 213 | lap("production"); | |
| 214 | const actors = getUsersByIds( | |
| 215 | db, | |
| 216 | [ | |
| 217 | ...[...latestRuns.values()].map((r) => r.actorId), | |
| 218 | ...[...productionFacts.values()].map((f) => f.actorId), | |
| 219 | ].filter((id): id is string => id !== null), | |
| 220 | ); | |
| 221 | // The newest open change of every app, one read — a person's own change | |
| 222 | // counts here too, not only one an agent run produced. | |
| 223 | const openPulls = openPullsForRepos(db, repoIds); | |
| 224 | // A tell nobody can pick up (the crew has no builder) is stuck, not quiet — | |
| 225 | // both reads batched across the whole shelf, never once per row. | |
| 226 | const withBuilder = reposWithBuilder(db, repoIds); | |
| 227 | const stuck = new Set( | |
| 228 | [...reposWithUnclaimedTell(db, repoIds)].filter( | |
| 229 | (id) => !withBuilder.has(id), | |
| 230 | ), | |
| 231 | ); | |
| 232 | lap("pulls"); | |
| 191 | 233 | |
| 192 | return await Promise.all( | |
| 234 | const rows = await Promise.all( | |
| 193 | 235 | repos.map(async (repo) => { |
| 194 | 236 | const record = records.get(repo.id); |
| 195 | 237 | // A private app the viewer may not enter is a name on a shelf: no |
| @@ -197,9 +239,7 @@ export async function shelfRows( | ||
| 197 | 239 | // and crew's (`envsFor`, `mayReadRuns`). |
| 198 | 240 | if (record !== undefined && !enterable.has(record.id)) |
| 199 | 241 | return sealedRow(repo, user); |
| 200 | const envs = | |
| 201 | record === undefined ? [] : envsFor(ctx.app, ctx.auth.user, record); | |
| 202 | const production = envs.find((e) => e.env === "production"); | |
| 242 | const production = productions.get(repo.id); | |
| 203 | 243 | const latest = latestRuns.get(repo.id); |
| 204 | 244 | // A run still in flight is the thing that is moving, and outranks a |
| 205 | 245 | // change sitting open; once nothing is moving, an open change — an |
| @@ -315,4 +355,6 @@ export async function shelfRows( | ||
| 315 | 355 | }); |
| 316 | 356 | }), |
| 317 | 357 | ); |
| 358 | lap("rows"); | |
| 359 | return rows; | |
| 318 | 360 | } |
test/fixtures/shelf-speed.html added +759
| @@ -0,0 +1,759 @@ | ||
| 1 | <li> | |
| 2 | <a href="/alice/app-00" class="row" | |
| 3 | ||
| 4 | data-nav tabindex="-1"><span class="row-lead"><span | |
| 5 | class="dot" | |
| 6 | ||
| 7 | ></span></span> | |
| 8 | <span class="row-main"> | |
| 9 | <span | |
| 10 | class="row-title" | |
| 11 | title="app number 0" | |
| 12 | >app-00</span | |
| 13 | > | |
| 14 | <span class="row-meta"><span class="muted">quiet — tell the crew something</span> · app number 0</span> | |
| 15 | </span> | |
| 16 | <span class="row-trail"> | |
| 17 | <span class="badge" data-tone="default">live</span></span></a> | |
| 18 | </li> | |
| 19 | --- | |
| 20 | <li> | |
| 21 | <a href="/alice/app-01" class="row" | |
| 22 | ||
| 23 | data-nav tabindex="-1"><span class="row-lead"><span | |
| 24 | class="dot" | |
| 25 | ||
| 26 | ></span></span> | |
| 27 | <span class="row-main"> | |
| 28 | <span | |
| 29 | class="row-title" | |
| 30 | title="app number 1" | |
| 31 | >app-01</span | |
| 32 | > | |
| 33 | <span class="row-meta"><span class="muted">quiet — tell the crew something</span> · app number 1</span> | |
| 34 | </span> | |
| 35 | <span class="row-trail"> | |
| 36 | <span class="badge" data-tone="default">not running</span></span></a> | |
| 37 | </li> | |
| 38 | --- | |
| 39 | <li> | |
| 40 | <a href="/alice/app-02" class="row" | |
| 41 | ||
| 42 | data-nav tabindex="-1"><span class="row-lead"><span | |
| 43 | class="dot" | |
| 44 | ||
| 45 | ></span></span> | |
| 46 | <span class="row-main"> | |
| 47 | <span | |
| 48 | class="row-title" | |
| 49 | title="app number 2" | |
| 50 | >app-02</span | |
| 51 | > | |
| 52 | <span class="row-meta"><span class="muted">quiet — tell the crew something</span> · app number 2</span> | |
| 53 | </span> | |
| 54 | <span class="row-trail"> | |
| 55 | <span class="badge" data-tone="default">live</span></span></a> | |
| 56 | </li> | |
| 57 | --- | |
| 58 | <li> | |
| 59 | <a href="/alice/app-03" class="row" | |
| 60 | ||
| 61 | data-nav tabindex="-1"><span class="row-lead"><span | |
| 62 | class="dot" | |
| 63 | ||
| 64 | ></span></span> | |
| 65 | <span class="row-main"> | |
| 66 | <span | |
| 67 | class="row-title" | |
| 68 | title="app number 3" | |
| 69 | >app-03</span | |
| 70 | > | |
| 71 | <span class="row-meta"><span class="muted">quiet — tell the crew something</span> · app number 3</span> | |
| 72 | </span> | |
| 73 | <span class="row-trail"> | |
| 74 | <span class="badge" data-tone="default">not running</span></span></a> | |
| 75 | </li> | |
| 76 | --- | |
| 77 | <li> | |
| 78 | <a href="/alice/app-04" class="row" | |
| 79 | ||
| 80 | data-nav tabindex="-1"><span class="row-lead"><span | |
| 81 | class="dot" | |
| 82 | ||
| 83 | ></span></span> | |
| 84 | <span class="row-main"> | |
| 85 | <span | |
| 86 | class="row-title" | |
| 87 | title="app number 4" | |
| 88 | >app-04</span | |
| 89 | > | |
| 90 | <span class="row-meta"><span class="muted">quiet — tell the crew something</span> · app number 4</span> | |
| 91 | </span> | |
| 92 | <span class="row-trail"> | |
| 93 | <span class="badge" data-tone="default">live</span></span></a> | |
| 94 | </li> | |
| 95 | --- | |
| 96 | <li> | |
| 97 | <a href="/alice/app-05" class="row" | |
| 98 | ||
| 99 | data-nav tabindex="-1"><span class="row-lead"><span | |
| 100 | class="dot" | |
| 101 | ||
| 102 | ></span></span> | |
| 103 | <span class="row-main"> | |
| 104 | <span | |
| 105 | class="row-title" | |
| 106 | title="app number 5" | |
| 107 | >app-05</span | |
| 108 | > | |
| 109 | <span class="row-meta"><span class="muted">quiet — tell the crew something</span> · app number 5</span> | |
| 110 | </span> | |
| 111 | <span class="row-trail"> | |
| 112 | <span class="badge" data-tone="default">not running</span></span></a> | |
| 113 | </li> | |
| 114 | --- | |
| 115 | <li> | |
| 116 | <a href="/alice/app-06" class="row" | |
| 117 | ||
| 118 | data-nav tabindex="-1"><span class="row-lead"><span | |
| 119 | class="dot" | |
| 120 | ||
| 121 | ></span></span> | |
| 122 | <span class="row-main"> | |
| 123 | <span | |
| 124 | class="row-title" | |
| 125 | title="app number 6" | |
| 126 | >app-06</span | |
| 127 | > | |
| 128 | <span class="row-meta"><span class="muted">quiet — tell the crew something</span> · app number 6</span> | |
| 129 | </span> | |
| 130 | <span class="row-trail"> | |
| 131 | <span class="badge" data-tone="default">live</span></span></a> | |
| 132 | </li> | |
| 133 | --- | |
| 134 | <li> | |
| 135 | <a href="/alice/app-07" class="row" | |
| 136 | ||
| 137 | data-nav tabindex="-1"><span class="row-lead"><span | |
| 138 | class="dot" | |
| 139 | ||
| 140 | ></span></span> | |
| 141 | <span class="row-main"> | |
| 142 | <span | |
| 143 | class="row-title" | |
| 144 | title="app number 7" | |
| 145 | >app-07</span | |
| 146 | > | |
| 147 | <span class="row-meta"><span class="muted">quiet — tell the crew something</span> · app number 7</span> | |
| 148 | </span> | |
| 149 | <span class="row-trail"> | |
| 150 | <span class="badge" data-tone="default">not running</span></span></a> | |
| 151 | </li> | |
| 152 | --- | |
| 153 | <li> | |
| 154 | <a href="/alice/app-08" class="row" | |
| 155 | ||
| 156 | data-nav tabindex="-1"><span class="row-lead"><span | |
| 157 | class="dot" | |
| 158 | ||
| 159 | ></span></span> | |
| 160 | <span class="row-main"> | |
| 161 | <span | |
| 162 | class="row-title" | |
| 163 | title="app number 8" | |
| 164 | >app-08</span | |
| 165 | > | |
| 166 | <span class="row-meta"><span class="muted">quiet — tell the crew something</span> · app number 8</span> | |
| 167 | </span> | |
| 168 | <span class="row-trail"> | |
| 169 | <span class="badge" data-tone="default">live</span></span></a> | |
| 170 | </li> | |
| 171 | --- | |
| 172 | <li> | |
| 173 | <a href="/alice/app-09" class="row" | |
| 174 | ||
| 175 | data-nav tabindex="-1"><span class="row-lead"><span | |
| 176 | class="dot" | |
| 177 | ||
| 178 | ></span></span> | |
| 179 | <span class="row-main"> | |
| 180 | <span | |
| 181 | class="row-title" | |
| 182 | title="app number 9" | |
| 183 | >app-09</span | |
| 184 | > | |
| 185 | <span class="row-meta"><span class="muted">quiet — tell the crew something</span> · app number 9</span> | |
| 186 | </span> | |
| 187 | <span class="row-trail"> | |
| 188 | <span class="badge" data-tone="default">not running</span></span></a> | |
| 189 | </li> | |
| 190 | --- | |
| 191 | <li> | |
| 192 | <a href="/alice/app-10" class="row" | |
| 193 | ||
| 194 | data-nav tabindex="-1"><span class="row-lead"><span | |
| 195 | class="dot" | |
| 196 | ||
| 197 | ></span></span> | |
| 198 | <span class="row-main"> | |
| 199 | <span | |
| 200 | class="row-title" | |
| 201 | title="app number 10" | |
| 202 | >app-10</span | |
| 203 | > | |
| 204 | <span class="row-meta"><span class="muted">quiet — tell the crew something</span> · app number 10</span> | |
| 205 | </span> | |
| 206 | <span class="row-trail"> | |
| 207 | <span class="badge" data-tone="default">live</span></span></a> | |
| 208 | </li> | |
| 209 | --- | |
| 210 | <li> | |
| 211 | <a href="/alice/app-11" class="row" | |
| 212 | ||
| 213 | data-nav tabindex="-1"><span class="row-lead"><span | |
| 214 | class="dot" | |
| 215 | ||
| 216 | ></span></span> | |
| 217 | <span class="row-main"> | |
| 218 | <span | |
| 219 | class="row-title" | |
| 220 | title="app number 11" | |
| 221 | >app-11</span | |
| 222 | > | |
| 223 | <span class="row-meta"><span class="muted">quiet — tell the crew something</span> · app number 11</span> | |
| 224 | </span> | |
| 225 | <span class="row-trail"> | |
| 226 | <span class="badge" data-tone="default">not running</span></span></a> | |
| 227 | </li> | |
| 228 | --- | |
| 229 | <li> | |
| 230 | <a href="/alice/app-12" class="row" | |
| 231 | ||
| 232 | data-nav tabindex="-1"><span class="row-lead"><span | |
| 233 | class="dot" | |
| 234 | ||
| 235 | ></span></span> | |
| 236 | <span class="row-main"> | |
| 237 | <span | |
| 238 | class="row-title" | |
| 239 | title="app number 12" | |
| 240 | >app-12</span | |
| 241 | > | |
| 242 | <span class="row-meta"><span class="muted">quiet — tell the crew something</span> · app number 12</span> | |
| 243 | </span> | |
| 244 | <span class="row-trail"> | |
| 245 | <span class="badge" data-tone="default">live</span></span></a> | |
| 246 | </li> | |
| 247 | --- | |
| 248 | <li> | |
| 249 | <a href="/alice/app-13" class="row" | |
| 250 | ||
| 251 | data-nav tabindex="-1"><span class="row-lead"><span | |
| 252 | class="dot" | |
| 253 | ||
| 254 | ></span></span> | |
| 255 | <span class="row-main"> | |
| 256 | <span | |
| 257 | class="row-title" | |
| 258 | title="app number 13" | |
| 259 | >app-13</span | |
| 260 | > | |
| 261 | <span class="row-meta"><span class="muted">quiet — tell the crew something</span> · app number 13</span> | |
| 262 | </span> | |
| 263 | <span class="row-trail"> | |
| 264 | <span class="badge" data-tone="default">not running</span></span></a> | |
| 265 | </li> | |
| 266 | --- | |
| 267 | <li> | |
| 268 | <a href="/alice/app-14" class="row" | |
| 269 | ||
| 270 | data-nav tabindex="-1"><span class="row-lead"><span | |
| 271 | class="dot" | |
| 272 | ||
| 273 | ></span></span> | |
| 274 | <span class="row-main"> | |
| 275 | <span | |
| 276 | class="row-title" | |
| 277 | title="app number 14" | |
| 278 | >app-14</span | |
| 279 | > | |
| 280 | <span class="row-meta"><span class="muted">quiet — tell the crew something</span> · app number 14</span> | |
| 281 | </span> | |
| 282 | <span class="row-trail"> | |
| 283 | <span class="badge" data-tone="default">live</span></span></a> | |
| 284 | </li> | |
| 285 | --- | |
| 286 | <li> | |
| 287 | <a href="/alice/app-15" class="row" | |
| 288 | ||
| 289 | data-nav tabindex="-1"><span class="row-lead"><span | |
| 290 | class="dot" | |
| 291 | ||
| 292 | ></span></span> | |
| 293 | <span class="row-main"> | |
| 294 | <span | |
| 295 | class="row-title" | |
| 296 | title="app number 15" | |
| 297 | >app-15</span | |
| 298 | > | |
| 299 | <span class="row-meta"><span class="muted">quiet — tell the crew something</span> · app number 15</span> | |
| 300 | </span> | |
| 301 | <span class="row-trail"> | |
| 302 | <span class="badge" data-tone="default">not running</span></span></a> | |
| 303 | </li> | |
| 304 | --- | |
| 305 | <li> | |
| 306 | <a href="/alice/app-16" class="row" | |
| 307 | ||
| 308 | data-nav tabindex="-1"><span class="row-lead"><span | |
| 309 | class="dot" | |
| 310 | ||
| 311 | ></span></span> | |
| 312 | <span class="row-main"> | |
| 313 | <span | |
| 314 | class="row-title" | |
| 315 | title="app number 16" | |
| 316 | >app-16</span | |
| 317 | > | |
| 318 | <span class="row-meta"><span class="muted">quiet — tell the crew something</span> · app number 16</span> | |
| 319 | </span> | |
| 320 | <span class="row-trail"> | |
| 321 | <span class="badge" data-tone="default">live</span></span></a> | |
| 322 | </li> | |
| 323 | --- | |
| 324 | <li> | |
| 325 | <a href="/alice/app-17" class="row" | |
| 326 | ||
| 327 | data-nav tabindex="-1"><span class="row-lead"><span | |
| 328 | class="dot" | |
| 329 | ||
| 330 | ></span></span> | |
| 331 | <span class="row-main"> | |
| 332 | <span | |
| 333 | class="row-title" | |
| 334 | title="app number 17" | |
| 335 | >app-17</span | |
| 336 | > | |
| 337 | <span class="row-meta"><span class="muted">quiet — tell the crew something</span> · app number 17</span> | |
| 338 | </span> | |
| 339 | <span class="row-trail"> | |
| 340 | <span class="badge" data-tone="default">not running</span></span></a> | |
| 341 | </li> | |
| 342 | --- | |
| 343 | <li> | |
| 344 | <a href="/alice/app-18" class="row" | |
| 345 | ||
| 346 | data-nav tabindex="-1"><span class="row-lead"><span | |
| 347 | class="dot" | |
| 348 | ||
| 349 | ></span></span> | |
| 350 | <span class="row-main"> | |
| 351 | <span | |
| 352 | class="row-title" | |
| 353 | title="app number 18" | |
| 354 | >app-18</span | |
| 355 | > | |
| 356 | <span class="row-meta"><span class="muted">quiet — tell the crew something</span> · app number 18</span> | |
| 357 | </span> | |
| 358 | <span class="row-trail"> | |
| 359 | <span class="badge" data-tone="default">live</span></span></a> | |
| 360 | </li> | |
| 361 | --- | |
| 362 | <li> | |
| 363 | <a href="/alice/app-19" class="row" | |
| 364 | ||
| 365 | data-nav tabindex="-1"><span class="row-lead"><span | |
| 366 | class="dot" | |
| 367 | ||
| 368 | ></span></span> | |
| 369 | <span class="row-main"> | |
| 370 | <span | |
| 371 | class="row-title" | |
| 372 | title="app number 19" | |
| 373 | >app-19</span | |
| 374 | > | |
| 375 | <span class="row-meta"><span class="muted">quiet — tell the crew something</span> · app number 19</span> | |
| 376 | </span> | |
| 377 | <span class="row-trail"> | |
| 378 | <span class="badge" data-tone="default">not running</span></span></a> | |
| 379 | </li> | |
| 380 | --- | |
| 381 | <li> | |
| 382 | <a href="/alice/app-20" class="row" | |
| 383 | ||
| 384 | data-nav tabindex="-1"><span class="row-lead"><span | |
| 385 | class="dot" | |
| 386 | ||
| 387 | ></span></span> | |
| 388 | <span class="row-main"> | |
| 389 | <span | |
| 390 | class="row-title" | |
| 391 | title="app number 20" | |
| 392 | >app-20</span | |
| 393 | > | |
| 394 | <span class="row-meta"><span class="muted">quiet — tell the crew something</span> · app number 20</span> | |
| 395 | </span> | |
| 396 | <span class="row-trail"> | |
| 397 | <span class="badge" data-tone="default">live</span></span></a> | |
| 398 | </li> | |
| 399 | --- | |
| 400 | <li> | |
| 401 | <a href="/alice/app-21" class="row" | |
| 402 | ||
| 403 | data-nav tabindex="-1"><span class="row-lead"><span | |
| 404 | class="dot" | |
| 405 | ||
| 406 | ></span></span> | |
| 407 | <span class="row-main"> | |
| 408 | <span | |
| 409 | class="row-title" | |
| 410 | title="app number 21" | |
| 411 | >app-21</span | |
| 412 | > | |
| 413 | <span class="row-meta"><span class="muted">quiet — tell the crew something</span> · app number 21</span> | |
| 414 | </span> | |
| 415 | <span class="row-trail"> | |
| 416 | <span class="badge" data-tone="default">not running</span></span></a> | |
| 417 | </li> | |
| 418 | --- | |
| 419 | <li> | |
| 420 | <a href="/alice/app-22" class="row" | |
| 421 | ||
| 422 | data-nav tabindex="-1"><span class="row-lead"><span | |
| 423 | class="dot" | |
| 424 | ||
| 425 | ></span></span> | |
| 426 | <span class="row-main"> | |
| 427 | <span | |
| 428 | class="row-title" | |
| 429 | title="app number 22" | |
| 430 | >app-22</span | |
| 431 | > | |
| 432 | <span class="row-meta"><span class="muted">quiet — tell the crew something</span> · app number 22</span> | |
| 433 | </span> | |
| 434 | <span class="row-trail"> | |
| 435 | <span class="badge" data-tone="default">live</span></span></a> | |
| 436 | </li> | |
| 437 | --- | |
| 438 | <li> | |
| 439 | <a href="/alice/app-23" class="row" | |
| 440 | ||
| 441 | data-nav tabindex="-1"><span class="row-lead"><span | |
| 442 | class="dot" | |
| 443 | ||
| 444 | ></span></span> | |
| 445 | <span class="row-main"> | |
| 446 | <span | |
| 447 | class="row-title" | |
| 448 | title="app number 23" | |
| 449 | >app-23</span | |
| 450 | > | |
| 451 | <span class="row-meta"><span class="muted">quiet — tell the crew something</span> · app number 23</span> | |
| 452 | </span> | |
| 453 | <span class="row-trail"> | |
| 454 | <span class="badge" data-tone="default">not running</span></span></a> | |
| 455 | </li> | |
| 456 | --- | |
| 457 | <li> | |
| 458 | <a href="/alice/app-24" class="row" | |
| 459 | ||
| 460 | data-nav tabindex="-1"><span class="row-lead"><span | |
| 461 | class="dot" | |
| 462 | ||
| 463 | ></span></span> | |
| 464 | <span class="row-main"> | |
| 465 | <span | |
| 466 | class="row-title" | |
| 467 | title="app number 24" | |
| 468 | >app-24</span | |
| 469 | > | |
| 470 | <span class="row-meta"><span class="muted">quiet — tell the crew something</span> · app number 24</span> | |
| 471 | </span> | |
| 472 | <span class="row-trail"> | |
| 473 | <span class="badge" data-tone="default">live</span></span></a> | |
| 474 | </li> | |
| 475 | --- | |
| 476 | <li> | |
| 477 | <a href="/alice/app-25" class="row" | |
| 478 | ||
| 479 | data-nav tabindex="-1"><span class="row-lead"><span | |
| 480 | class="dot" | |
| 481 | ||
| 482 | ></span></span> | |
| 483 | <span class="row-main"> | |
| 484 | <span | |
| 485 | class="row-title" | |
| 486 | title="app number 25" | |
| 487 | >app-25</span | |
| 488 | > | |
| 489 | <span class="row-meta"><span class="muted">quiet — tell the crew something</span> · app number 25</span> | |
| 490 | </span> | |
| 491 | <span class="row-trail"> | |
| 492 | <span class="badge" data-tone="default">not running</span></span></a> | |
| 493 | </li> | |
| 494 | --- | |
| 495 | <li> | |
| 496 | <a href="/alice/app-26" class="row" | |
| 497 | ||
| 498 | data-nav tabindex="-1"><span class="row-lead"><span | |
| 499 | class="dot" | |
| 500 | ||
| 501 | ></span></span> | |
| 502 | <span class="row-main"> | |
| 503 | <span | |
| 504 | class="row-title" | |
| 505 | title="app number 26" | |
| 506 | >app-26</span | |
| 507 | > | |
| 508 | <span class="row-meta"><span class="muted">quiet — tell the crew something</span> · app number 26</span> | |
| 509 | </span> | |
| 510 | <span class="row-trail"> | |
| 511 | <span class="badge" data-tone="default">live</span></span></a> | |
| 512 | </li> | |
| 513 | --- | |
| 514 | <li> | |
| 515 | <a href="/alice/app-27" class="row" | |
| 516 | ||
| 517 | data-nav tabindex="-1"><span class="row-lead"><span | |
| 518 | class="dot" | |
| 519 | ||
| 520 | ></span></span> | |
| 521 | <span class="row-main"> | |
| 522 | <span | |
| 523 | class="row-title" | |
| 524 | title="app number 27" | |
| 525 | >app-27</span | |
| 526 | > | |
| 527 | <span class="row-meta"><span class="muted">quiet — tell the crew something</span> · app number 27</span> | |
| 528 | </span> | |
| 529 | <span class="row-trail"> | |
| 530 | <span class="badge" data-tone="default">not running</span></span></a> | |
| 531 | </li> | |
| 532 | --- | |
| 533 | <li> | |
| 534 | <a href="/alice/app-28" class="row" | |
| 535 | ||
| 536 | data-nav tabindex="-1"><span class="row-lead"><span | |
| 537 | class="dot" | |
| 538 | ||
| 539 | ></span></span> | |
| 540 | <span class="row-main"> | |
| 541 | <span | |
| 542 | class="row-title" | |
| 543 | title="app number 28" | |
| 544 | >app-28</span | |
| 545 | > | |
| 546 | <span class="row-meta"><span class="muted">quiet — tell the crew something</span> · app number 28</span> | |
| 547 | </span> | |
| 548 | <span class="row-trail"> | |
| 549 | <span class="badge" data-tone="default">live</span></span></a> | |
| 550 | </li> | |
| 551 | --- | |
| 552 | <li> | |
| 553 | <a href="/alice/app-29" class="row" | |
| 554 | ||
| 555 | data-nav tabindex="-1"><span class="row-lead"><span | |
| 556 | class="dot" | |
| 557 | ||
| 558 | ></span></span> | |
| 559 | <span class="row-main"> | |
| 560 | <span | |
| 561 | class="row-title" | |
| 562 | title="app number 29" | |
| 563 | >app-29</span | |
| 564 | > | |
| 565 | <span class="row-meta"><span class="muted">quiet — tell the crew something</span> · app number 29</span> | |
| 566 | </span> | |
| 567 | <span class="row-trail"> | |
| 568 | <span class="badge" data-tone="default">not running</span></span></a> | |
| 569 | </li> | |
| 570 | --- | |
| 571 | <li> | |
| 572 | <a href="/alice/app-30" class="row" | |
| 573 | ||
| 574 | data-nav tabindex="-1"><span class="row-lead"><span | |
| 575 | class="dot" | |
| 576 | ||
| 577 | ></span></span> | |
| 578 | <span class="row-main"> | |
| 579 | <span | |
| 580 | class="row-title" | |
| 581 | title="app number 30" | |
| 582 | >app-30</span | |
| 583 | > | |
| 584 | <span class="row-meta"><span class="muted">quiet — tell the crew something</span> · app number 30</span> | |
| 585 | </span> | |
| 586 | <span class="row-trail"> | |
| 587 | <span class="badge" data-tone="default">live</span></span></a> | |
| 588 | </li> | |
| 589 | --- | |
| 590 | <li> | |
| 591 | <a href="/alice/app-31" class="row" | |
| 592 | ||
| 593 | data-nav tabindex="-1"><span class="row-lead"><span | |
| 594 | class="dot" | |
| 595 | ||
| 596 | ></span></span> | |
| 597 | <span class="row-main"> | |
| 598 | <span | |
| 599 | class="row-title" | |
| 600 | title="app number 31" | |
| 601 | >app-31</span | |
| 602 | > | |
| 603 | <span class="row-meta"><span class="muted">quiet — tell the crew something</span> · app number 31</span> | |
| 604 | </span> | |
| 605 | <span class="row-trail"> | |
| 606 | <span class="badge" data-tone="default">not running</span></span></a> | |
| 607 | </li> | |
| 608 | --- | |
| 609 | <li> | |
| 610 | <a href="/alice/app-32" class="row" | |
| 611 | ||
| 612 | data-nav tabindex="-1"><span class="row-lead"><span | |
| 613 | class="dot" | |
| 614 | ||
| 615 | ></span></span> | |
| 616 | <span class="row-main"> | |
| 617 | <span | |
| 618 | class="row-title" | |
| 619 | title="app number 32" | |
| 620 | >app-32</span | |
| 621 | > | |
| 622 | <span class="row-meta"><span class="muted">quiet — tell the crew something</span> · app number 32</span> | |
| 623 | </span> | |
| 624 | <span class="row-trail"> | |
| 625 | <span class="badge" data-tone="default">live</span></span></a> | |
| 626 | </li> | |
| 627 | --- | |
| 628 | <li> | |
| 629 | <a href="/alice/app-33" class="row" | |
| 630 | ||
| 631 | data-nav tabindex="-1"><span class="row-lead"><span | |
| 632 | class="dot" | |
| 633 | ||
| 634 | ></span></span> | |
| 635 | <span class="row-main"> | |
| 636 | <span | |
| 637 | class="row-title" | |
| 638 | title="app number 33" | |
| 639 | >app-33</span | |
| 640 | > | |
| 641 | <span class="row-meta"><span class="muted">quiet — tell the crew something</span> · app number 33</span> | |
| 642 | </span> | |
| 643 | <span class="row-trail"> | |
| 644 | <span class="badge" data-tone="default">not running</span></span></a> | |
| 645 | </li> | |
| 646 | --- | |
| 647 | <li> | |
| 648 | <a href="/alice/app-34" class="row" | |
| 649 | ||
| 650 | data-nav tabindex="-1"><span class="row-lead"><span | |
| 651 | class="dot" | |
| 652 | ||
| 653 | ></span></span> | |
| 654 | <span class="row-main"> | |
| 655 | <span | |
| 656 | class="row-title" | |
| 657 | title="app number 34" | |
| 658 | >app-34</span | |
| 659 | > | |
| 660 | <span class="row-meta"><span class="muted">quiet — tell the crew something</span> · app number 34</span> | |
| 661 | </span> | |
| 662 | <span class="row-trail"> | |
| 663 | <span class="badge" data-tone="default">live</span></span></a> | |
| 664 | </li> | |
| 665 | --- | |
| 666 | <li> | |
| 667 | <a href="/alice/app-35" class="row" | |
| 668 | ||
| 669 | data-nav tabindex="-1"><span class="row-lead"><span | |
| 670 | class="dot" | |
| 671 | ||
| 672 | ></span></span> | |
| 673 | <span class="row-main"> | |
| 674 | <span | |
| 675 | class="row-title" | |
| 676 | title="app number 35" | |
| 677 | >app-35</span | |
| 678 | > | |
| 679 | <span class="row-meta"><span class="muted">quiet — tell the crew something</span> · app number 35</span> | |
| 680 | </span> | |
| 681 | <span class="row-trail"> | |
| 682 | <span class="badge" data-tone="default">not running</span></span></a> | |
| 683 | </li> | |
| 684 | --- | |
| 685 | <li> | |
| 686 | <a href="/alice/app-36" class="row" | |
| 687 | ||
| 688 | data-nav tabindex="-1"><span class="row-lead"><span | |
| 689 | class="dot" | |
| 690 | ||
| 691 | ></span></span> | |
| 692 | <span class="row-main"> | |
| 693 | <span | |
| 694 | class="row-title" | |
| 695 | title="app number 36" | |
| 696 | >app-36</span | |
| 697 | > | |
| 698 | <span class="row-meta"><span class="muted">quiet — tell the crew something</span> · app number 36</span> | |
| 699 | </span> | |
| 700 | <span class="row-trail"> | |
| 701 | <span class="badge" data-tone="default">live</span></span></a> | |
| 702 | </li> | |
| 703 | --- | |
| 704 | <li> | |
| 705 | <a href="/alice/app-37" class="row" | |
| 706 | ||
| 707 | data-nav tabindex="-1"><span class="row-lead"><span | |
| 708 | class="dot" | |
| 709 | ||
| 710 | ></span></span> | |
| 711 | <span class="row-main"> | |
| 712 | <span | |
| 713 | class="row-title" | |
| 714 | title="app number 37" | |
| 715 | >app-37</span | |
| 716 | > | |
| 717 | <span class="row-meta"><span class="muted">quiet — tell the crew something</span> · app number 37</span> | |
| 718 | </span> | |
| 719 | <span class="row-trail"> | |
| 720 | <span class="badge" data-tone="default">not running</span></span></a> | |
| 721 | </li> | |
| 722 | --- | |
| 723 | <li> | |
| 724 | <a href="/alice/app-38" class="row" | |
| 725 | ||
| 726 | data-nav tabindex="-1"><span class="row-lead"><span | |
| 727 | class="dot" | |
| 728 | ||
| 729 | ></span></span> | |
| 730 | <span class="row-main"> | |
| 731 | <span | |
| 732 | class="row-title" | |
| 733 | title="app number 38" | |
| 734 | >app-38</span | |
| 735 | > | |
| 736 | <span class="row-meta"><span class="muted">quiet — tell the crew something</span> · app number 38</span> | |
| 737 | </span> | |
| 738 | <span class="row-trail"> | |
| 739 | <span class="badge" data-tone="default">live</span></span></a> | |
| 740 | </li> | |
| 741 | --- | |
| 742 | <li> | |
| 743 | <a href="/alice/app-39" class="row" | |
| 744 | ||
| 745 | data-nav tabindex="-1"><span class="row-lead"><span | |
| 746 | class="dot" | |
| 747 | ||
| 748 | ></span></span> | |
| 749 | <span class="row-main"> | |
| 750 | <span | |
| 751 | class="row-title" | |
| 752 | title="app number 39" | |
| 753 | >app-39</span | |
| 754 | > | |
| 755 | <span class="row-meta"><span class="muted">quiet — tell the crew something</span> · app number 39</span> | |
| 756 | </span> | |
| 757 | <span class="row-trail"> | |
| 758 | <span class="badge" data-tone="default">not running</span></span></a> | |
| 759 | </li>↵ |
test/shelf-speed.test.ts added +124
| @@ -0,0 +1,124 @@ | ||
| 1 | /** The shelf of 40 apps is drawn in a fixed number of reads, under 40 ms, and says what it said before (#6123). */ | |
| 2 | import { afterAll, beforeAll, expect, test } from "bun:test"; | |
| 3 | import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; | |
| 4 | import { tmpdir } from "node:os"; | |
| 5 | import { join } from "node:path"; | |
| 6 | import { createApp, type App } from "../src/app.ts"; | |
| 7 | import { createRepo } from "../src/actions/repos.ts"; | |
| 8 | import { enableApp } from "../src/actions/apps.ts"; | |
| 9 | import { AppsRuntime } from "../src/apps/runtime.ts"; | |
| 10 | import { createRun, claimRun, finishRun } from "../src/core/runs.ts"; | |
| 11 | import { createUser, type User } from "../src/core/users.ts"; | |
| 12 | import type { Repo } from "../src/core/repos.ts"; | |
| 13 | import type { Ctx } from "../src/http/context.ts"; | |
| 14 | import { takePhases } from "../src/core/route_timing.ts"; | |
| 15 | import { shelfRows } from "../src/web/studio/shelf.ts"; | |
| 16 | import { expectBudget } from "./helpers/budget.ts"; | |
| 17 | import { appsForRepos } from "../src/core/apps.ts"; | |
| 18 | import { primaryHosts } from "../src/core/domains.ts"; | |
| 19 | import { quietLogs } from "./helpers/globals.ts"; | |
| 20 | import { proveAll } from "./helpers/verified.ts"; | |
| 21 | ||
| 22 | quietLogs(); | |
| 23 | ||
| 24 | const GOLDEN = join(import.meta.dir, "fixtures", "shelf-speed.html"); | |
| 25 | let root = ""; | |
| 26 | let app: App; | |
| 27 | let alice: User; | |
| 28 | let repos: Repo[] = []; | |
| 29 | ||
| 30 | const ctx = (): Ctx => ({ | |
| 31 | app, | |
| 32 | request: new Request("http://forge.test/apps"), | |
| 33 | url: new URL("http://forge.test/apps"), | |
| 34 | params: {}, | |
| 35 | auth: { user: alice, scope: undefined, via: "session" }, | |
| 36 | theme: undefined, | |
| 37 | }); | |
| 38 | ||
| 39 | /** The relative times and ids a run leaves are the clock's, not the shelf's. */ | |
| 40 | const steady = (s: string): string => | |
| 41 | s.replace(/datetime="[^"]*"/g, 'datetime="T"').replace(/>[^<>]*\bago\b[^<>]*</g, ">T<").replace(/>(now|just now|\d+[smhd])</g, ">T<"); | |
| 42 | ||
| 43 | beforeAll(async () => { | |
| 44 | root = mkdtempSync(join(tmpdir(), "forge-shelf-speed-")); | |
| 45 | app = createApp({ dataDir: root, port: 0 }); | |
| 46 | const u = await createUser(app.db, { | |
| 47 | origin: "seed", trusted: true, username: "alice", email: "[email protected]", | |
| 48 | password: "a-good-passphrase", adminIfFirst: true, | |
| 49 | }); | |
| 50 | if (!u.ok) throw new Error(u.error); | |
| 51 | alice = u.value; | |
| 52 | proveAll(app); | |
| 53 | const runtime = new AppsRuntime(app, { domain: "apps.test", scheme: "http", publicPort: 80, forgeUrl: "http://forge.test", concurrency: 1 }); | |
| 54 | for (let i = 0; i < 40; i++) { | |
| 55 | const made = await createRepo(app, alice, { name: `app-${String(i).padStart(2, "0")}`, visibility: i % 4 === 0 ? "private" : "public", description: `app number ${i}` }); | |
| 56 | if (!made.ok) throw new Error(String(made.error)); | |
| 57 | const enabled = enableApp(app, alice, made.value, { slug: `app-${i}` }); | |
| 58 | if (!enabled.ok) throw new Error(String(enabled.error)); | |
| 59 | repos.push(made.value); | |
| 60 | // Every other app has served: an environment asleep on a deploy run. | |
| 61 | if (i % 2 === 0) { | |
| 62 | const run = createRun(app.db, { repoId: made.value.id, kind: "deploy", env: "production", commitOid: "a".repeat(40), reason: "push" }); | |
| 63 | claimRun(app.db, run.id); | |
| 64 | finishRun(app.db, run.id, { state: i % 6 === 0 ? "failed" : "ok", summary: "deployed" }); | |
| 65 | // biome-ignore lint: reaching the runtime's own table is how a test seeds an environment | |
| 66 | const state = (runtime as any).envFor(enabled.value, "production"); | |
| 67 | state.asleep = { commitOid: "a".repeat(40), dir: root, manifest: {}, runId: run.id, since: new Date().toISOString() }; | |
| 68 | } | |
| 69 | } | |
| 70 | }); | |
| 71 | afterAll(() => { | |
| 72 | app.close(); | |
| 73 | rmSync(root, { recursive: true, force: true }); | |
| 74 | }); | |
| 75 | ||
| 76 | test("40 apps are drawn within budget, in the phases Server-Timing names", async () => { | |
| 77 | await shelfRows(ctx(), repos); // warm | |
| 78 | let best = Infinity; | |
| 79 | let phases: string[] = []; | |
| 80 | for (let i = 0; i < 5; i++) { | |
| 81 | const c = ctx(); | |
| 82 | const started = performance.now(); | |
| 83 | await shelfRows(c, repos); | |
| 84 | best = Math.min(best, performance.now() - started); | |
| 85 | phases = takePhases(c.request).map((p) => p.name); | |
| 86 | } | |
| 87 | expect(phases).toEqual(["records", "runs", "production", "pulls", "rows"]); | |
| 88 | // The yardstick is one batched read of the same shelf, taken in this run. | |
| 89 | let against = Infinity; | |
| 90 | for (let i = 0; i < 5; i++) { | |
| 91 | const t = performance.now(); | |
| 92 | appsForRepos(app.db, repos.map((r) => r.id)); | |
| 93 | against = Math.min(against, performance.now() - t); | |
| 94 | } | |
| 95 | expectBudget(best, { ms: 40, against }); | |
| 96 | }); | |
| 97 | ||
| 98 | test("its rows are byte-identical to the ones drawn before the batch", async () => { | |
| 99 | const rows = (await shelfRows(ctx(), repos)).map((r) => steady(String(r))).join("\n---\n"); | |
| 100 | if (process.env.WRITE_GOLDEN === "1") writeFileSync(GOLDEN, rows); | |
| 101 | expect(rows).toBe(readFileSync(GOLDEN, "utf8")); | |
| 102 | }); | |
| 103 | ||
| 104 | test("primaryHosts names only verified hosts, the earliest verified first", () => { | |
| 105 | const [a, b, c] = repos.slice(1, 4).map((r) => appsForRepos(app.db, [r.id]).get(r.id)!.id); | |
| 106 | const add = (host: string, appId: string, at: string | null) => | |
| 107 | app.db.query("INSERT INTO app_domain (host, app_id, env, token, created_at, verified_at) VALUES (?1, ?2, 'production', ?1, '2026-01-01T00:00:00Z', ?3)").run(host, appId, at); | |
| 108 | add("late.test", a!, "2026-03-01T00:00:00Z"); | |
| 109 | add("early.test", a!, "2026-02-01T00:00:00Z"); | |
| 110 | add("unproven.test", b!, null); | |
| 111 | expect(primaryHosts(app.db, [a!, b!, c!])).toEqual(new Map([[a!, "early.test"]])); | |
| 112 | expect(primaryHosts(app.db, [])).toEqual(new Map()); | |
| 113 | }); | |
| 114 | ||
| 115 | test("a viewer who may not enter a private app sees a sealed row for it", async () => { | |
| 116 | const b = await createUser(app.db, { origin: "seed", trusted: true, username: "bob", email: "[email protected]", password: "a-good-passphrase" }); | |
| 117 | if (!b.ok) throw new Error(b.error); | |
| 118 | const c = { ...ctx(), auth: { user: b.value, scope: undefined, via: "session" as const } }; | |
| 119 | const rows = (await shelfRows(c, repos.slice(0, 2))).map(String); | |
| 120 | expect(rows[0]).toContain("private app"); | |
| 121 | expect(rows[0]).not.toContain(">live<"); | |
| 122 | expect(rows[0]).not.toContain("not running"); | |
| 123 | expect(rows[1]).not.toContain("private app"); | |
| 124 | }); |
new above
offline
This is a wall-clock bound in CI. Best-of-5 makes a flake unlikely, but if it flakes on a loaded check, assert the read count instead (the claim the batch actually makes).