Public: anyone can read this; the crew can change it.
Terminal: list and set the account quota keys (repositories, apps, previews, runs, checks) #2192
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/2191-terminal-list-and-set-the-account-quota → main 1 commit fork 1 thread · 1 unresolved
4 files
changed +241
−1
README.md +1 −1
Diff not shown — a line 2,209 characters wide (limit 2,000). +1 −1
View filesrc/tui/forge.ts +9
| @@ -42,6 +42,7 @@ import { dashboardScreen } from "./screens/dashboard.ts"; | ||
| 42 | 42 | import { mayManageFlags } from "../core/flags.ts"; |
| 43 | 43 | import { accessScreen } from "./screens/access.ts"; |
| 44 | 44 | import { flagsScreen } from "./screens/flags.ts"; |
| 45 | import { quotasScreen } from "./screens/quotas.ts"; | |
| 45 | 46 | import { domainsScreen } from "./screens/domains.ts"; |
| 46 | 47 | import { resilienceLines, resilienceScreen } from "./screens/resilience.ts"; |
| 47 | 48 | import { flowLines, flowScreen } from "./screens/flow.ts"; |
| @@ -344,6 +345,14 @@ export function forgeCommands(session: Session): (ctx: Ctx) => Command[] { | ||
| 344 | 345 | hint: "declared flags and their live values", |
| 345 | 346 | run: () => ctx.push(flagsScreen(session, ctx)), |
| 346 | 347 | }); |
| 348 | // Account quotas, as /admin/limits has them: an admin who is not an agent (#2191). | |
| 349 | if (session.user?.isAdmin === true && session.user.kind !== "agent") | |
| 350 | commands.push({ | |
| 351 | id: "quotas", | |
| 352 | title: "Account quotas", | |
| 353 | hint: "what one account may own, and who changed it", | |
| 354 | run: () => ctx.push(quotasScreen(session, ctx)), | |
| 355 | }); | |
| 347 | 356 | // Each failure domain and its last drill, as Settings → Failure domains says it (#1945). |
| 348 | 357 | if (session.user?.isAdmin === true && session.user.kind !== "agent") |
| 349 | 358 | commands.push({ |
src/tui/screens/quotas.ts added +96
| @@ -0,0 +1,96 @@ | ||
| 1 | /** | |
| 2 | * Account quotas in the terminal, a forge admin's (#2191): every quota key | |
| 3 | * (`QUOTA_KEYS`) with its limit and the recent changes, as Account quotas on | |
| 4 | * /admin/limits shows them. Enter sets one from a positive whole number (empty | |
| 5 | * for no limit); `x` clears it. Both go through `changeQuotas`, so `parseQuota` | |
| 6 | * checks the value and the change is kept, as on the page. The gate is the | |
| 7 | * action's: an admin who is not an agent; anyone else sees nothing and sets nothing. | |
| 8 | */ | |
| 9 | ||
| 10 | import { changeQuotas, readQuotas } from "../../actions/quotas.ts"; | |
| 11 | import { QUOTA_KEYS, type QuotaChange } from "../../settings/quotas.ts"; | |
| 12 | import type { Ctx, Screen } from "../app/app.ts"; | |
| 13 | import { binding } from "../app/keys.ts"; | |
| 14 | import { style } from "../term/style.ts"; | |
| 15 | import { truncate } from "../term/width.ts"; | |
| 16 | import type { Session } from "../session.ts"; | |
| 17 | import { fill, len, split } from "../ui/layout.ts"; | |
| 18 | import { ListState, drawList, promptScreen, type ListRow } from "./common.ts"; | |
| 19 | ||
| 20 | const limit = (v: number | string | null): string => (v === null || v === "" ? "no limit" : String(v)); | |
| 21 | ||
| 22 | const changeLine = (c: QuotaChange): string => | |
| 23 | `${c.at.slice(0, 16)} ${c.key} ${limit(c.from)} → ${limit(c.to)} ${c.actorName}`; | |
| 24 | ||
| 25 | export function quotasScreen(f: Session, ctx: Ctx): Screen { | |
| 26 | const state = new ListState(); | |
| 27 | let note = ""; | |
| 28 | const read = () => readQuotas(f.app, f.user); | |
| 29 | const allowed = (): boolean => read().ok; | |
| 30 | ||
| 31 | const save = (key: string, raw: string | null): string | undefined => { | |
| 32 | const made = changeQuotas(f.app, f.user, { [key]: raw }); | |
| 33 | if (made.ok) { | |
| 34 | note = `${key}: ${limit(made.value[key as (typeof QUOTA_KEYS)[number]])}.`; | |
| 35 | ctx.invalidate(); | |
| 36 | return undefined; | |
| 37 | } | |
| 38 | return made.error === "invalid_quota" | |
| 39 | ? "A quota is a positive whole number, or empty for no limit." | |
| 40 | : "You cannot change quotas."; | |
| 41 | }; | |
| 42 | ||
| 43 | const rows = (): ListRow[] => { | |
| 44 | const r = read(); | |
| 45 | if (!r.ok) return []; | |
| 46 | const recent = (key: string) => r.value.changes.find((c) => c.key === key); | |
| 47 | return QUOTA_KEYS.map((key) => { | |
| 48 | const last = recent(key); | |
| 49 | return { | |
| 50 | title: key, | |
| 51 | secondary: limit(r.value.quotas[key]), | |
| 52 | meta: last === undefined ? "never changed" : changeLine(last), | |
| 53 | }; | |
| 54 | }); | |
| 55 | }; | |
| 56 | ||
| 57 | const screen: Screen = { | |
| 58 | title: "Account quotas", | |
| 59 | keys: [binding(["j", "k"], "move cursor"), binding(["enter"], "set"), binding(["x"], "clear")], | |
| 60 | render(area, s) { | |
| 61 | const c = ctx.theme.c; | |
| 62 | const r = read(); | |
| 63 | const [head, list] = split(area, "v", [len(1), fill(1)]); | |
| 64 | const text = | |
| 65 | note !== "" | |
| 66 | ? note | |
| 67 | : !r.ok | |
| 68 | ? "Quotas are set by a forge admin." | |
| 69 | : r.value.changes[0] === undefined | |
| 70 | ? "What one account may own. No change yet." | |
| 71 | : changeLine(r.value.changes[0]); | |
| 72 | s.text(head!.x, head!.y, truncate(text, head!.w), style(c.mutedFg, c.bg), head!.x + head!.w); | |
| 73 | drawList(s, list!, c, state, rows()); | |
| 74 | }, | |
| 75 | key(e) { | |
| 76 | if (state.key(e, allowed() ? QUOTA_KEYS.length : 0)) return true; | |
| 77 | if (e.ctrl || e.alt || !allowed()) return false; | |
| 78 | const key = QUOTA_KEYS[state.index]; | |
| 79 | if (key === undefined) return false; | |
| 80 | if (e.key === "x") save(key, null); | |
| 81 | else if (e.key === "enter") { | |
| 82 | const now = read(); | |
| 83 | ctx.push( | |
| 84 | promptScreen(ctx, screen, { | |
| 85 | title: key, | |
| 86 | label: "a positive whole number, empty for no limit", | |
| 87 | value: now.ok ? (now.value.quotas[key]?.toString() ?? "") : "", | |
| 88 | onSubmit: (raw) => save(key, raw), | |
| 89 | }), | |
| 90 | ); | |
| 91 | } else return false; | |
| 92 | return true; | |
| 93 | }, | |
| 94 | }; | |
| 95 | return screen; | |
| 96 | } |
test/tui-quotas.test.ts added +135
| @@ -0,0 +1,135 @@ | ||
| 1 | /** | |
| 2 | * Account quotas in the terminal (#2191): an admin who is not an agent lists | |
| 3 | * the quotas with their history and sets or clears one, validated by | |
| 4 | * `parseQuota`; anyone else has no command and, driving the screen directly, | |
| 5 | * can neither read nor write. | |
| 6 | */ | |
| 7 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 8 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 9 | import { tmpdir } from "node:os"; | |
| 10 | import { join } from "node:path"; | |
| 11 | import { createApp, type App as Forge } from "../src/app.ts"; | |
| 12 | import { getQuota } from "../src/settings/quotas.ts"; | |
| 13 | import { createUser, getUserByName } from "../src/core/users.ts"; | |
| 14 | import { createForgeApp } from "../src/tui/forge.ts"; | |
| 15 | import { quotasScreen } from "../src/tui/screens/quotas.ts"; | |
| 16 | import { Session, type TuiIdentity } from "../src/tui/session.ts"; | |
| 17 | import { headlessTerminal } from "../src/tui/term/terminal.ts"; | |
| 18 | import { quietLogs } from "./helpers/globals.ts"; | |
| 19 | import { settleApp } from "./helpers/tui.ts"; | |
| 20 | ||
| 21 | quietLogs(); | |
| 22 | ||
| 23 | describe("quotas in the terminal", () => { | |
| 24 | let dataDir: string; | |
| 25 | let forge: Forge; | |
| 26 | ||
| 27 | function boot(username: string) { | |
| 28 | const terminal = headlessTerminal(140, 32); | |
| 29 | const user = getUserByName(forge.db, username)!; | |
| 30 | const identity: TuiIdentity = { sshUsername: "t", remoteAddress: "127.0.0.1", user }; | |
| 31 | const { app } = createForgeApp(forge, identity, { terminal, exitHooks: false }); | |
| 32 | app.start(); | |
| 33 | const key = (k: string) => | |
| 34 | app.dispatch({ type: "key", key: k, ctrl: false, alt: false, shift: false }); | |
| 35 | return { | |
| 36 | text: () => app.screen.toText(), | |
| 37 | press: async (...keys: string[]) => { | |
| 38 | keys.forEach(key); | |
| 39 | await settleApp(app); | |
| 40 | }, | |
| 41 | ctrl: async (k: string) => { | |
| 42 | app.dispatch({ type: "key", key: k, ctrl: true, alt: false, shift: false }); | |
| 43 | await settleApp(app); | |
| 44 | }, | |
| 45 | type: async (t: string) => { | |
| 46 | for (const ch of t) key(ch); | |
| 47 | await settleApp(app); | |
| 48 | }, | |
| 49 | stop: () => app.stop(), | |
| 50 | }; | |
| 51 | } | |
| 52 | ||
| 53 | beforeAll(async () => { | |
| 54 | dataDir = await mkdtemp(join(tmpdir(), "forge-tui-quotas-")); | |
| 55 | forge = createApp({ dataDir, port: 0 }); | |
| 56 | for (const [username, adminIfFirst] of [["alice", true], ["bob", false], ["carol", false]] as const) { | |
| 57 | const made = await createUser(forge.db, { | |
| 58 | trusted: true, | |
| 59 | username, | |
| 60 | email: `${username}@forge.local`, | |
| 61 | password: "a-good-passphrase", | |
| 62 | adminIfFirst, | |
| 63 | }); | |
| 64 | if (!made.ok) throw new Error(made.error); | |
| 65 | } | |
| 66 | forge.db.query("UPDATE user SET is_admin = 1, is_agent = 1 WHERE username = 'carol'").run(); | |
| 67 | }); | |
| 68 | afterAll(async () => { | |
| 69 | forge.close(); | |
| 70 | await rm(dataDir, { recursive: true, force: true }); | |
| 71 | }); | |
| 72 | ||
| 73 | const open = async (h: ReturnType<typeof boot>) => { | |
| 74 | await h.ctrl("k"); | |
| 75 | await h.type("Account quotas"); | |
| 76 | await h.press("enter"); | |
| 77 | }; | |
| 78 | ||
| 79 | test("an admin lists the keys, sets one with its history, and clears it", async () => { | |
| 80 | const h = boot("alice"); | |
| 81 | await open(h); | |
| 82 | expect(h.text()).toContain("repositories"); | |
| 83 | expect(h.text()).toContain("apps"); | |
| 84 | expect(h.text()).toContain("no limit"); | |
| 85 | ||
| 86 | await h.press("enter"); | |
| 87 | await h.type("5"); | |
| 88 | await h.press("enter"); | |
| 89 | expect(getQuota(forge.db, "repositories")).toBe(5); | |
| 90 | expect(h.text()).toContain("repositories: 5."); | |
| 91 | expect(h.text()).toContain("no limit → 5"); | |
| 92 | expect(h.text()).toContain("alice"); | |
| 93 | ||
| 94 | await h.press("x"); | |
| 95 | expect(getQuota(forge.db, "repositories")).toBeNull(); | |
| 96 | expect(h.text()).toContain("repositories: no limit."); | |
| 97 | h.stop(); | |
| 98 | }); | |
| 99 | ||
| 100 | test("a bad value is refused and nothing changes", async () => { | |
| 101 | const h = boot("alice"); | |
| 102 | await open(h); | |
| 103 | await h.press("j", "enter"); | |
| 104 | await h.type("0"); | |
| 105 | await h.press("enter"); | |
| 106 | expect(h.text()).toContain("positive whole number"); | |
| 107 | expect(getQuota(forge.db, "apps")).toBeNull(); | |
| 108 | h.stop(); | |
| 109 | }); | |
| 110 | ||
| 111 | test("a non-admin and an admin agent have no command", async () => { | |
| 112 | for (const who of ["bob", "carol"]) { | |
| 113 | const h = boot(who); | |
| 114 | await h.ctrl("k"); | |
| 115 | await h.type("Account quotas"); | |
| 116 | expect(h.text()).not.toContain("what one account may own"); | |
| 117 | h.stop(); | |
| 118 | } | |
| 119 | }); | |
| 120 | ||
| 121 | test("a non-admin driving the screen reads nothing and writes nothing", async () => { | |
| 122 | for (const who of ["bob", "carol"]) { | |
| 123 | const user = getUserByName(forge.db, who)!; | |
| 124 | const session = new Session(forge, { sshUsername: "t", remoteAddress: "127.0.0.1", user }); | |
| 125 | const pushed: unknown[] = []; | |
| 126 | const ctx = { push: (s: unknown) => pushed.push(s), invalidate() {} } as never; | |
| 127 | const screen = quotasScreen(session, ctx); | |
| 128 | const key = (k: string) => screen.key?.({ type: "key", key: k, ctrl: false, alt: false, shift: false } as never); | |
| 129 | key("enter"); | |
| 130 | key("x"); | |
| 131 | expect(pushed).toEqual([]); | |
| 132 | expect(forge.db.query("SELECT 1 FROM forge_setting_change WHERE actor_id = ?1").all(user.id)).toEqual([]); | |
| 133 | } | |
| 134 | }); | |
| 135 | }); |
new above
offline
The cast to a QuotaKey could be dropped. Use isQuotaKey from settings/quotas, or type save's key as QuotaKey.