diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/features/concurrency/index.ts | 1 | ||||
| -rw-r--r-- | src/features/concurrency/logic/view-model.test.ts | 37 | ||||
| -rw-r--r-- | src/features/concurrency/logic/view-model.ts | 13 | ||||
| -rw-r--r-- | src/features/concurrency/ui/ConcurrencyLimitRow.svelte | 39 | ||||
| -rw-r--r-- | src/features/concurrency/ui/ConcurrencyView.svelte | 17 | ||||
| -rw-r--r-- | src/features/concurrency/ui/ConcurrencyView.test.ts | 29 |
6 files changed, 126 insertions, 10 deletions
diff --git a/src/features/concurrency/index.ts b/src/features/concurrency/index.ts index 9499989..c0870e3 100644 --- a/src/features/concurrency/index.ts +++ b/src/features/concurrency/index.ts @@ -43,6 +43,7 @@ export { pauseLabel, providerFromModel, providerOptions, + statusLabel, summarizeLimits, summarizeStatus, viewAutoReduce, diff --git a/src/features/concurrency/logic/view-model.test.ts b/src/features/concurrency/logic/view-model.test.ts index c284ad0..6e6b770 100644 --- a/src/features/concurrency/logic/view-model.test.ts +++ b/src/features/concurrency/logic/view-model.test.ts @@ -14,6 +14,7 @@ import { pauseLabel, providerFromModel, providerOptions, + statusLabel, summarizeLimits, summarizeStatus, viewAutoReduce, @@ -262,6 +263,42 @@ describe("viewConcurrencyStatus", () => { }); }); +// ── statusLabel (the row's status badge word) ────────────────────────────────── + +describe("statusLabel", () => { + it("idle (no in-flight) → Idle", () => { + expect( + statusLabel(viewConcurrencyStatus(status({ inFlight: 0, limit: 4, queued: 0 }), 0)), + ).toBe("Idle"); + }); + + it("serving under capacity → Active", () => { + expect( + statusLabel(viewConcurrencyStatus(status({ inFlight: 2, limit: 4, queued: 0 }), 0)), + ).toBe("Active"); + }); + + it("at capacity with a queue → At capacity", () => { + expect( + statusLabel(viewConcurrencyStatus(status({ inFlight: 4, limit: 4, queued: 3 }), 0)), + ).toBe("At capacity"); + }); + + it("at capacity but no queue → Active (not At capacity)", () => { + expect( + statusLabel(viewConcurrencyStatus(status({ inFlight: 4, limit: 4, queued: 0 }), 0)), + ).toBe("Active"); + }); + + it("paused → Paused (regardless of in-flight/queue)", () => { + expect( + statusLabel( + viewConcurrencyStatus(status({ paused: true, inFlight: 4, limit: 4, queued: 3 }), 0), + ), + ).toBe("Paused"); + }); +}); + // ── viewAutoReduce / autoReduceNotices (the auto-reduce banner view) ─────────── describe("viewAutoReduce", () => { diff --git a/src/features/concurrency/logic/view-model.ts b/src/features/concurrency/logic/view-model.ts index e05423b..8a37198 100644 --- a/src/features/concurrency/logic/view-model.ts +++ b/src/features/concurrency/logic/view-model.ts @@ -261,6 +261,19 @@ export function viewConcurrencyStatus( } /** + * A short status word for a status view, for the row's status badge: + * "Paused" · "At capacity" (in-flight at the cap with a queue) · "Active" + * (in-flight > 0) · "Idle". Mirrors the badge-text branching so the template + * holds no branching logic. + */ +export function statusLabel(view: ConcurrencyStatusView): string { + if (view.paused) return "Paused"; + if (view.inFlight >= view.limit && view.queued > 0) return "At capacity"; + if (view.inFlight > 0) return "Active"; + return "Idle"; +} + +/** * The auto-reduce banner view for a status entry, or `null` when it is not * auto-reduced. `message` prefers the backend's `notice` (verbatim, when present * + non-empty); otherwise a synthesized fallback is built from diff --git a/src/features/concurrency/ui/ConcurrencyLimitRow.svelte b/src/features/concurrency/ui/ConcurrencyLimitRow.svelte index 666b769..505847a 100644 --- a/src/features/concurrency/ui/ConcurrencyLimitRow.svelte +++ b/src/features/concurrency/ui/ConcurrencyLimitRow.svelte @@ -1,9 +1,13 @@ <script lang="ts"> import { untrack } from "svelte"; import { + DEFAULT_COOLDOWN_MS, parseCooldownInput, parseLimitInput, + statusLabel, + type Badge, type ConcurrencyLimitView, + type ConcurrencyStatusView, } from "../logic/view-model"; import type { DeleteConcurrencyLimit, @@ -13,20 +17,33 @@ let { limit, - cooldownMs, + status, save, saveCooldown, remove, }: { /** The configured limit row (providerId + current limit). */ limit: ConcurrencyLimitView; - /** The current per-slot release cooldown (ms) from the live status poll. */ - cooldownMs: number; + /** The provider's live status view (in-flight/queue/badge), or null when no + * status entry exists yet. Drives the status line + seeds the cooldown input. */ + status: ConcurrencyStatusView | null; save: SaveConcurrencyLimit; saveCooldown: SaveConcurrencyCooldown; remove: DeleteConcurrencyLimit; } = $props(); + // The badge→color map (presentational). Mirrors the old status-card mapping. + const badgeClass: Record<Badge, string> = { + success: "badge-success", + warning: "badge-warning", + error: "badge-error", + neutral: "badge-ghost", + }; + + // The cooldown input seed: the live cooldown when a status entry exists, else + // the server default (350). + const cooldownMs = $derived(status?.cooldownMs ?? DEFAULT_COOLDOWN_MS); + // Inline-edit state for the limit + cooldown inputs. Each is seeded from its // canonical value, but only while untouched — so a save echo / status-poll // refresh re-syncs without clobbering an in-flight edit. Mirrors the @@ -111,6 +128,7 @@ </script> <div class="flex flex-col gap-1 rounded-box bg-base-200 p-2 text-sm"> + <!-- Line 1: provider + limit + cooldown + Set + ✕ (all on one line). --> <div class="flex flex-wrap items-center gap-2"> <span class="min-w-0 flex-1 truncate font-medium font-mono" title={limit.providerId} >{limit.providerId}</span @@ -161,6 +179,21 @@ {/if} </button> </div> + + <!-- Line 2: in-flight count (left) + status badge (right). Hidden until the + first status poll for this provider lands. --> + {#if status !== null} + <div class="flex items-center justify-between gap-2 text-xs opacity-70"> + <span title="In-flight slots held vs cap">{status.inFlightLabel} in flight</span> + <span class="badge badge-sm {badgeClass[status.badge]} gap-1"> + {#if status.busy} + <span class="loading loading-spinner loading-xs"></span> + {/if} + {statusLabel(status)} + </span> + </div> + {/if} + {#if error} <span class="font-mono text-xs text-error">{error}</span> {:else if justSaved && !dirty} diff --git a/src/features/concurrency/ui/ConcurrencyView.svelte b/src/features/concurrency/ui/ConcurrencyView.svelte index 2f7435a..05d178a 100644 --- a/src/features/concurrency/ui/ConcurrencyView.svelte +++ b/src/features/concurrency/ui/ConcurrencyView.svelte @@ -9,6 +9,8 @@ providerOptions, summarizeLimits, viewConcurrencyLimits, + viewConcurrencyStatus, + type ConcurrencyStatusView, } from "../logic/view-model"; import type { ConcurrencyLimitEntry, @@ -201,12 +203,13 @@ * INVISIBLE, mirroring the heartbeat runs list). */ let statusInFlight = false; - // Per-provider cooldown lookup (from the live status poll) so each saved limit - // row seeds its cooldown input. Defaults to the server default (350) when a - // provider has no status entry yet. - const cooldownByProvider = $derived.by(() => { - const map = new Map<string, number>(); - for (const s of statusEntries) map.set(s.providerId, s.cooldownMs); + // Per-provider status view (from the live status poll) so each saved limit row + // renders its in-flight count + status badge + seeds its cooldown input. Null + // when a provider has no status entry yet (the row falls back to Idle + the + // server-default cooldown of 350). + const statusByProvider = $derived.by(() => { + const map = new Map<string, ConcurrencyStatusView>(); + for (const e of statusEntries) map.set(e.providerId, viewConcurrencyStatus(e)); return map; }); @@ -346,7 +349,7 @@ <li> <ConcurrencyLimitRow {limit} - cooldownMs={cooldownByProvider.get(limit.providerId) ?? DEFAULT_COOLDOWN_MS} + status={statusByProvider.get(limit.providerId) ?? null} save={rowSave} saveCooldown={cooldownSave} remove={rowRemove} diff --git a/src/features/concurrency/ui/ConcurrencyView.test.ts b/src/features/concurrency/ui/ConcurrencyView.test.ts index 68856a1..a8163c2 100644 --- a/src/features/concurrency/ui/ConcurrencyView.test.ts +++ b/src/features/concurrency/ui/ConcurrencyView.test.ts @@ -151,6 +151,35 @@ describe("ConcurrencyView", () => { expect((cooldownInput as HTMLInputElement).value).toBe("350"); }); + it("renders a status line (in-flight count left + badge right) below the edit line", async () => { + // Default status: limit 4, inFlight 2, queued 1, not paused → Active, "2/4". + const fakes = makeFakes(); + render(ConcurrencyView, { props: props(fakes) }); + + expect(await screen.findByText("2/4 in flight")).toBeVisible(); + expect(await screen.findByText("Active")).toBeVisible(); + }); + + it("shows the At-capacity badge when in-flight is at the cap with a queue", async () => { + const fakes = makeFakes({ + status: [statusEntry({ inFlight: 4, limit: 4, queued: 3 })], + }); + render(ConcurrencyView, { props: props(fakes) }); + + expect(await screen.findByText("4/4 in flight")).toBeVisible(); + expect(await screen.findByText("At capacity")).toBeVisible(); + }); + + it("shows the Idle badge when no slots are in flight", async () => { + const fakes = makeFakes({ + status: [statusEntry({ inFlight: 0, limit: 4, queued: 0 })], + }); + render(ConcurrencyView, { props: props(fakes) }); + + expect(await screen.findByText("0/4 in flight")).toBeVisible(); + expect(await screen.findByText("Idle")).toBeVisible(); + }); + it("surfaces NO loading indicator during refresh (background poll is silent — no flicker)", async () => { // The 2s status poll + post-mutation reloads are SILENT: they never toggle a // visible loading state, so the Refresh button is plain-text (no spinner). |
