Skip to content

Commit 1d9605e

Browse files
committed
feat(observatory): surface the global concurrency cap in the UI
The throttle backend (#1365) and its taosctl group shipped, and Jay's v1 target for Observatory queue-control is global pause + per-lane pause + throttle dials, but the app only exposed pause. Add a global concurrency-cap stepper to the steer row: it loads /api/observatory/throttle alongside the fleet, shows the current cap (or 'No cap'), and posts {scope:'global', max_concurrent} on change, clearing to null at the lowest step. Per-lane throttle is a follow-up. Covered by vitest (load, raise+post, clear-to-null); visual check deferred to a live session as with the other read-only desktop slices.
1 parent 1e28bda commit 1d9605e

2 files changed

Lines changed: 154 additions & 5 deletions

File tree

desktop/src/apps/ObservatoryApp.test.tsx

Lines changed: 69 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,12 @@ function mockFetch(
77
) {
88
return vi.fn().mockImplementation((input: string, init?: RequestInit) => {
99
const method = (init?.method ?? "GET").toUpperCase();
10-
const hit = responses[`${method} ${input}`] ?? responses[input] ?? responses["*"];
10+
let hit = responses[`${method} ${input}`] ?? responses[input] ?? responses["*"];
11+
// The app loads the throttle state alongside the fleet; default it to
12+
// "no cap" so tests that only care about the fleet need not mock it.
13+
if (!hit && method === "GET" && input === "/api/observatory/throttle") {
14+
hit = { ok: true, body: { global: null, lanes: {} } };
15+
}
1116
if (!hit) throw new Error(`Unmocked fetch: ${method} ${input}`);
1217
return Promise.resolve({
1318
ok: hit.ok,
@@ -92,6 +97,69 @@ describe("ObservatoryApp", () => {
9297
expect(sent).toEqual({ scope: "@taOS-dev-kilo-owl-alpha", paused: true });
9398
});
9499

100+
it("renders the loaded global concurrency cap", async () => {
101+
vi.stubGlobal(
102+
"fetch",
103+
mockFetch({
104+
"GET /api/observatory/fleet": { ok: true, body: fleetBody },
105+
"GET /api/observatory/throttle": { ok: true, body: { global: 4, lanes: {} } },
106+
}),
107+
);
108+
render(<ObservatoryApp windowId="w1" />);
109+
await flush();
110+
await waitFor(() =>
111+
expect(screen.getByLabelText(/concurrency cap value/i).textContent).toBe("4"),
112+
);
113+
});
114+
115+
it("raises the cap and posts the new value to the throttle endpoint", async () => {
116+
const fetchMock = mockFetch({
117+
"GET /api/observatory/fleet": { ok: true, body: fleetBody },
118+
"GET /api/observatory/throttle": { ok: true, body: { global: null, lanes: {} } },
119+
"POST /api/observatory/throttle": { ok: true, body: { global: 1, lanes: {} } },
120+
});
121+
vi.stubGlobal("fetch", fetchMock);
122+
render(<ObservatoryApp windowId="w1" />);
123+
await flush();
124+
125+
fireEvent.click(screen.getByRole("button", { name: /raise concurrency cap/i }));
126+
await flush();
127+
128+
const post = fetchMock.mock.calls.find(
129+
(c) => (c[1] as RequestInit)?.method === "POST",
130+
);
131+
expect(post![0]).toBe("/api/observatory/throttle");
132+
expect(JSON.parse((post![1] as RequestInit).body as string)).toEqual({
133+
scope: "global",
134+
max_concurrent: 1,
135+
});
136+
});
137+
138+
it("clears the cap to null from the lowest step", async () => {
139+
const fetchMock = mockFetch({
140+
"GET /api/observatory/fleet": { ok: true, body: fleetBody },
141+
"GET /api/observatory/throttle": { ok: true, body: { global: 1, lanes: {} } },
142+
"POST /api/observatory/throttle": { ok: true, body: { global: null, lanes: {} } },
143+
});
144+
vi.stubGlobal("fetch", fetchMock);
145+
render(<ObservatoryApp windowId="w1" />);
146+
await flush();
147+
await waitFor(() =>
148+
expect(screen.getByLabelText(/concurrency cap value/i).textContent).toBe("1"),
149+
);
150+
151+
fireEvent.click(screen.getByRole("button", { name: /lower concurrency cap/i }));
152+
await flush();
153+
154+
const post = fetchMock.mock.calls.find(
155+
(c) => (c[1] as RequestInit)?.method === "POST",
156+
);
157+
expect(JSON.parse((post![1] as RequestInit).body as string)).toEqual({
158+
scope: "global",
159+
max_concurrent: null,
160+
});
161+
});
162+
95163
it("shows the idle empty state when no agents are working", async () => {
96164
vi.stubGlobal(
97165
"fetch",

desktop/src/apps/ObservatoryApp.tsx

Lines changed: 85 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { useState, useEffect, useCallback } from "react";
2-
import { Radar, Pause, Play, Loader2, CircleDot } from "lucide-react";
2+
import { Radar, Pause, Play, Loader2, CircleDot, Minus, Plus } from "lucide-react";
33
import { Switch } from "@/components/ui";
44

55
interface HeldCard {
@@ -21,23 +21,38 @@ interface PauseState {
2121

2222
const EMPTY_PAUSE: PauseState = { global: false, lanes: {} };
2323

24+
// Global concurrency cap: how many cards the fleet may hold in flight at once
25+
// (the dispatch loop reads it as MAX_OPEN_PRS). null = no override, the loop
26+
// default applies. Pause is the on/off switch; this is the volume knob.
27+
function coerceCap(v: unknown): number | null {
28+
return typeof v === "number" && Number.isFinite(v) && v > 0 ? Math.floor(v) : null;
29+
}
30+
2431
export function ObservatoryApp({ windowId: _windowId }: { windowId: string }) {
2532
const [agents, setAgents] = useState<FleetAgent[]>([]);
2633
const [pause, setPause] = useState<PauseState>(EMPTY_PAUSE);
34+
const [cap, setCap] = useState<number | null>(null);
2735
const [loading, setLoading] = useState(true);
2836
const [busy, setBusy] = useState<string | null>(null);
2937

3038
const load = useCallback(async (opts?: { silent?: boolean }) => {
3139
if (!opts?.silent) setLoading(true);
3240
try {
33-
const res = await fetch("/api/observatory/fleet");
34-
if (res.ok) {
35-
const data = await res.json();
41+
const [fleetRes, throttleRes] = await Promise.all([
42+
fetch("/api/observatory/fleet"),
43+
fetch("/api/observatory/throttle"),
44+
]);
45+
if (fleetRes.ok) {
46+
const data = await fleetRes.json();
3647
setAgents(Array.isArray(data.agents) ? data.agents : []);
3748
setPause(
3849
data.paused && typeof data.paused === "object" ? data.paused : EMPTY_PAUSE,
3950
);
4051
}
52+
if (throttleRes.ok) {
53+
const data = await throttleRes.json();
54+
setCap(coerceCap(data?.global));
55+
}
4156
} catch {
4257
// Non-critical: keep the last-loaded view.
4358
} finally {
@@ -80,6 +95,26 @@ export function ObservatoryApp({ windowId: _windowId }: { windowId: string }) {
8095
[load],
8196
);
8297

98+
const setGlobalCap = useCallback(
99+
async (next: number | null) => {
100+
setBusy("cap");
101+
setCap(next); // optimistic; reconciled on the next poll
102+
try {
103+
await fetch("/api/observatory/throttle", {
104+
method: "POST",
105+
headers: { "Content-Type": "application/json" },
106+
body: JSON.stringify({ scope: "global", max_concurrent: next }),
107+
});
108+
await load({ silent: true });
109+
} catch {
110+
await load({ silent: true });
111+
} finally {
112+
setBusy(null);
113+
}
114+
},
115+
[load],
116+
);
117+
83118
return (
84119
<div className="flex h-full flex-col overflow-hidden bg-shell-bg">
85120
{/* Header + global steer */}
@@ -113,6 +148,52 @@ export function ObservatoryApp({ windowId: _windowId }: { windowId: string }) {
113148
</div>
114149
)}
115150

151+
{/* Steer: global concurrency cap (volume knob alongside the pause switch) */}
152+
<div className="flex items-center gap-3 border-b border-shell-border px-5 py-2.5">
153+
<span className="text-xs font-medium uppercase tracking-wide text-shell-text-tertiary">
154+
Concurrency cap
155+
</span>
156+
<div className="flex items-center gap-1.5">
157+
<button
158+
type="button"
159+
onClick={() => setGlobalCap(cap && cap > 1 ? cap - 1 : null)}
160+
disabled={busy === "cap" || cap == null}
161+
aria-label="Lower concurrency cap"
162+
className="flex h-7 w-7 items-center justify-center rounded-md border border-shell-border text-shell-text-secondary transition-colors hover:text-shell-text hover:border-shell-border-strong disabled:cursor-not-allowed disabled:opacity-40"
163+
>
164+
<Minus size={14} />
165+
</button>
166+
<span
167+
className="min-w-[3.5rem] text-center text-sm font-medium text-shell-text tabular-nums"
168+
aria-label="Concurrency cap value"
169+
>
170+
{cap == null ? "No cap" : cap}
171+
</span>
172+
<button
173+
type="button"
174+
onClick={() => setGlobalCap((cap ?? 0) + 1)}
175+
disabled={busy === "cap"}
176+
aria-label="Raise concurrency cap"
177+
className="flex h-7 w-7 items-center justify-center rounded-md border border-shell-border text-shell-text-secondary transition-colors hover:text-shell-text hover:border-shell-border-strong disabled:cursor-not-allowed disabled:opacity-40"
178+
>
179+
<Plus size={14} />
180+
</button>
181+
</div>
182+
{cap != null && (
183+
<button
184+
type="button"
185+
onClick={() => setGlobalCap(null)}
186+
disabled={busy === "cap"}
187+
className="text-xs text-shell-text-tertiary transition-colors hover:text-shell-text"
188+
>
189+
Clear
190+
</button>
191+
)}
192+
<span className="ml-auto text-xs text-shell-text-tertiary">
193+
Max cards the fleet holds at once
194+
</span>
195+
</div>
196+
116197
{/* Fleet (Observe) */}
117198
<div className="flex-1 overflow-y-auto px-5 py-4">
118199
<h2 className="mb-3 text-xs font-medium uppercase tracking-wide text-shell-text-tertiary">

0 commit comments

Comments
 (0)