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

Terminal: list and set the account quota keys (repositories, apps, previews, runs, checks) #2192

Merged opened this

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

Merged

scout/forge:scout/2191-terminal-list-and-set-the-account-quota → main 1 commit fork 1 thread · 1 unresolved

  • Merged. scout/2191-terminal-list-and-set-the-account-quota was merged into main as f5ae598.

Review

Sign in to review this change.
4 files changed +241 −1
README.md +1 −1

Diff not shown — a line 2,209 characters wide (limit 2,000). +1 −1

View file
src/tui/forge.ts +9
@@ -42,6 +42,7 @@ import { dashboardScreen } from "./screens/dashboard.ts";
4242 import { mayManageFlags } from "../core/flags.ts";
4343 import { accessScreen } from "./screens/access.ts";
4444 import { flagsScreen } from "./screens/flags.ts";
45 import { quotasScreen } from "./screens/quotas.ts";
4546 import { domainsScreen } from "./screens/domains.ts";
4647 import { resilienceLines, resilienceScreen } from "./screens/resilience.ts";
4748 import { flowLines, flowScreen } from "./screens/flow.ts";
@@ -344,6 +345,14 @@ export function forgeCommands(session: Session): (ctx: Ctx) => Command[] {
344345 hint: "declared flags and their live values",
345346 run: () => ctx.push(flagsScreen(session, ctx)),
346347 });
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 });
347356 // Each failure domain and its last drill, as Settings → Failure domains says it (#1945).
348357 if (session.user?.isAdmin === true && session.user.kind !== "agent")
349358 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"

The cast to a QuotaKey could be dropped. Use isQuotaKey from settings/quotas, or type save's key as QuotaKey.

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 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs