diff options
Diffstat (limited to 'src/features/concurrency/ui')
| -rw-r--r-- | src/features/concurrency/ui/ConcurrencyLimitRow.svelte | 106 | ||||
| -rw-r--r-- | src/features/concurrency/ui/ConcurrencyView.svelte | 311 | ||||
| -rw-r--r-- | src/features/concurrency/ui/ConcurrencyView.test.ts | 160 |
3 files changed, 577 insertions, 0 deletions
diff --git a/src/features/concurrency/ui/ConcurrencyLimitRow.svelte b/src/features/concurrency/ui/ConcurrencyLimitRow.svelte new file mode 100644 index 0000000..411a3cd --- /dev/null +++ b/src/features/concurrency/ui/ConcurrencyLimitRow.svelte @@ -0,0 +1,106 @@ +<script lang="ts"> + import { untrack } from "svelte"; + import { parseLimitInput, type ConcurrencyLimitView } from "../logic/view-model"; + import type { DeleteConcurrencyLimit, SaveConcurrencyLimit } from "../logic/types"; + + let { + limit, + save, + remove, + }: { + /** The configured limit row (providerId + current limit). */ + limit: ConcurrencyLimitView; + save: SaveConcurrencyLimit; + remove: DeleteConcurrencyLimit; + } = $props(); + + // Inline-edit state: the raw text bound to the limit input. Seeded from the + // row's canonical limit, but only while the field is untouched — so a save + // echo / list refresh re-syncs it without clobbering an in-flight edit. Mirrors + // the ChatLimitField seed pattern (avoids reading the prop in the $state init). + let draft = $state(""); + let lastSeed = $state(""); + let saving = $state(false); + let removing = $state(false); + let error = $state<string | null>(null); + + $effect(() => { + const incoming = String(limit.limit); + untrack(() => { + if (draft === lastSeed) draft = incoming; + lastSeed = incoming; + }); + }); + + const parsed = $derived(parseLimitInput(draft)); + const dirty = $derived(parsed !== null && parsed !== limit.limit); + + async function handleSave(): Promise<void> { + if (parsed === null || parsed === limit.limit) return; + saving = true; + error = null; + const result = await save(limit.providerId, parsed); + saving = false; + if (result.ok) { + // Reflect the echoed limit back into the field immediately (the prop will + // also re-assert it via the seed effect above once the parent reloads). + draft = String(result.limit); + lastSeed = draft; + } else { + error = result.error; + } + } + + async function handleRemove(): Promise<void> { + removing = true; + error = null; + const result = await remove(limit.providerId); + removing = false; + if (!result.ok) { + error = result.error; + } + // On success the parent drops this row (re-loaded limits list). + } +</script> + +<div class="flex flex-col gap-1 rounded-box bg-base-200 p-2 text-sm"> + <div class="flex items-center gap-2"> + <span class="flex-1 truncate font-medium font-mono" title={limit.providerId}>{limit.providerId}</span> + <input + type="text" + inputmode="numeric" + class="input input-bordered input-xs w-20 font-mono" + aria-label={`Concurrency limit for ${limit.providerId}`} + bind:value={draft} + disabled={saving || removing} + /> + <button + type="button" + class="btn btn-primary btn-xs" + disabled={!dirty || saving || removing} + onclick={handleSave} + > + {#if saving} + <span class="loading loading-spinner loading-xs"></span> + {:else} + Save + {/if} + </button> + <button + type="button" + class="btn btn-ghost btn-xs text-error" + aria-label={`Remove concurrency limit for ${limit.providerId}`} + disabled={saving || removing} + onclick={handleRemove} + > + {#if removing} + <span class="loading loading-spinner loading-xs"></span> + {:else} + ✕ + {/if} + </button> + </div> + {#if error} + <span class="font-mono text-xs text-error">{error}</span> + {/if} +</div> diff --git a/src/features/concurrency/ui/ConcurrencyView.svelte b/src/features/concurrency/ui/ConcurrencyView.svelte new file mode 100644 index 0000000..078c003 --- /dev/null +++ b/src/features/concurrency/ui/ConcurrencyView.svelte @@ -0,0 +1,311 @@ +<script lang="ts"> + import { untrack } from "svelte"; + import type { ConcurrencyStatusEntry } from "@dispatch/transport-contract"; + import { + type Badge, + type ConcurrencyLimitView, + parseLimitInput, + summarizeLimits, + summarizeStatus, + viewConcurrencyLimits, + viewConcurrencyStatuses, + } from "../logic/view-model"; + import type { + ConcurrencyLimitEntry, + DeleteConcurrencyLimit, + LoadConcurrencyLimits, + LoadConcurrencyStatus, + SaveConcurrencyLimit, + } from "../logic/types"; + import ConcurrencyLimitRow from "./ConcurrencyLimitRow.svelte"; + + let { + loadLimits, + saveLimit, + deleteLimit, + loadStatus, + }: { + loadLimits: LoadConcurrencyLimits; + saveLimit: SaveConcurrencyLimit; + deleteLimit: DeleteConcurrencyLimit; + loadStatus: LoadConcurrencyStatus; + } = $props(); + + const badgeClass: Record<Badge, string> = { + success: "badge-success", + warning: "badge-warning", + error: "badge-error", + neutral: "badge-ghost", + }; + + // ── Limits (config: list / add / update / remove) ──────────────────────────── + let limits = $state<readonly ConcurrencyLimitEntry[]>([]); + let limitsLoading = $state(false); + let limitsError = $state<string | null>(null); + let hasLoadedLimits = $state(false); + + // Add-form state. + let newProviderId = $state(""); + let newLimitInput = $state(""); + let adding = $state(false); + let addError = $state<string | null>(null); + + const limitViews = $derived(viewConcurrencyLimits(limits)); + const limitsSummary = $derived(summarizeLimits(limits)); + const parsedNewLimit = $derived(parseLimitInput(newLimitInput)); + const trimmedProviderId = $derived(newProviderId.trim()); + const canAdd = $derived( + trimmedProviderId !== "" && + parsedNewLimit !== null && + !limits.some((l) => l.providerId === trimmedProviderId) && + !adding, + ); + + async function refreshLimits(): Promise<void> { + limitsLoading = true; + limitsError = null; + const result = await loadLimits(); + limitsLoading = false; + hasLoadedLimits = true; + if (result.ok) { + limits = result.limits; + } else { + limitsError = result.error; + } + } + + async function handleAdd(): Promise<void> { + if (parsedNewLimit === null || trimmedProviderId === "") return; + adding = true; + addError = null; + const result = await saveLimit(trimmedProviderId, parsedNewLimit); + adding = false; + if (result.ok) { + newProviderId = ""; + newLimitInput = ""; + void refreshLimits(); + void refreshStatus(); + } else { + addError = result.error; + } + } + + // Wrap the ports so a row's save/remove reloads the authoritative list + status + // on success (the row still gets the result to drive its own UI). + async function rowSave(providerId: string, limit: number) { + const result = await saveLimit(providerId, limit); + if (result.ok) { + void refreshLimits(); + void refreshStatus(); + } + return result; + } + + async function rowRemove(providerId: string) { + const result = await deleteLimit(providerId); + if (result.ok) { + void refreshLimits(); + void refreshStatus(); + } + return result; + } + + // ── Live status (polls while mounted) ─────────────────────────────────────── + let statusEntries = $state<readonly ConcurrencyStatusEntry[]>([]); + let statusLoading = $state(false); + let statusError = $state<string | null>(null); + let hasLoadedStatus = $state(false); + let now = $state(Date.now()); + + // A 1s clock so a `paused — resumes in Ns` countdown ticks live between polls. + $effect(() => { + const h = setInterval(() => { + now = Date.now(); + }, 1000); + return () => clearInterval(h); + }); + + const statusViews = $derived(viewConcurrencyStatuses(statusEntries, now)); + const statusSummary = $derived(summarizeStatus(statusEntries, now)); + + async function refreshStatus(): Promise<void> { + statusLoading = true; + statusError = null; + const result = await loadStatus(); + statusLoading = false; + hasLoadedStatus = true; + if (result.ok) { + statusEntries = result.providers; + } else { + statusError = result.error; + } + } + + const STATUS_POLL_MS = 2000; + + // Load limits + status on mount, and poll the live status while the view is + // alive (a running provider's in-flight/queued/paused transitions stay fresh + // without a manual refresh). Runs once — no reactive deps read inside. + $effect(() => { + untrack(() => { + void refreshLimits(); + void refreshStatus(); + }); + const h = setInterval(() => { + void refreshStatus(); + }, STATUS_POLL_MS); + return () => clearInterval(h); + }); +</script> + +<div class="flex flex-col gap-4"> + <!-- Limits (config) --> + <section class="flex flex-col gap-2"> + <div class="flex items-center justify-between gap-2"> + <h3 class="text-xs font-semibold uppercase opacity-60">Concurrency limits</h3> + <button + type="button" + class="btn btn-ghost btn-xs" + disabled={limitsLoading} + onclick={() => refreshLimits()} + aria-label="Refresh concurrency limits" + > + {#if limitsLoading} + <span class="loading loading-spinner loading-xs"></span> + {:else} + Refresh + {/if} + </button> + </div> + <p class="text-xs opacity-60"> + Cap how many concurrent token-generating requests a provider may run. At the cap, further + requests queue (oldest-agent-first); remove a limit to make a provider unlimited. Limits are + in-memory only. + </p> + + <!-- Add form --> + <form + class="flex flex-wrap items-end gap-2" + onsubmit={(e) => { + e.preventDefault(); + void handleAdd(); + }} + > + <label class="flex flex-col gap-1"> + <span class="text-[10px] uppercase opacity-60">Provider id</span> + <input + class="input input-bordered input-xs w-40 font-mono" + placeholder="umans" + autocomplete="off" + spellcheck="false" + bind:value={newProviderId} + disabled={adding} + /> + </label> + <label class="flex flex-col gap-1"> + <span class="text-[10px] uppercase opacity-60">Limit</span> + <input + type="text" + inputmode="numeric" + class="input input-bordered input-xs w-20 font-mono" + placeholder="4" + bind:value={newLimitInput} + disabled={adding} + /> + </label> + <button + type="submit" + class="btn btn-primary btn-xs" + disabled={!canAdd} + > + {#if adding} + <span class="loading loading-spinner loading-xs"></span> + {:else} + Add + {/if} + </button> + </form> + {#if addError} + <p class="font-mono text-xs text-error">{addError}</p> + {/if} + + <span class="text-xs opacity-70">{limitsSummary}</span> + + {#if limitsError} + <p class="text-xs text-error">{limitsError}</p> + {:else if hasLoadedLimits && limitViews.length === 0 && !limitsLoading} + <p class="text-xs opacity-60">No limits configured — providers run unlimited.</p> + {:else} + <ul class="flex flex-col gap-2"> + {#each limitViews as limit (limit.providerId)} + <li> + <ConcurrencyLimitRow {limit} save={rowSave} remove={rowRemove} /> + </li> + {/each} + </ul> + {/if} + </section> + + <!-- Live status --> + <section class="flex flex-col gap-2"> + <div class="flex items-center justify-between gap-2"> + <h3 class="text-xs font-semibold uppercase opacity-60">Live status</h3> + <button + type="button" + class="btn btn-ghost btn-xs" + disabled={statusLoading} + onclick={() => refreshStatus()} + aria-label="Refresh concurrency status" + > + {#if statusLoading} + <span class="loading loading-spinner loading-xs"></span> + {:else} + Refresh + {/if} + </button> + </div> + <p class="text-xs opacity-60"> + In-flight slots held vs the cap (e.g. 2/4), agents queued waiting, and a paused state when a + provider backs off after a 429. Polls every 2s. + </p> + + <span class="text-xs opacity-70">{statusSummary}</span> + + {#if statusError} + <p class="text-xs text-error">{statusError}</p> + {:else if hasLoadedStatus && statusViews.length === 0 && !statusLoading} + <p class="text-xs opacity-60">No limits configured — nothing to report.</p> + {:else} + <ul class="flex flex-col gap-2"> + {#each statusViews as s (s.providerId)} + <li class="flex flex-col gap-1 rounded-box bg-base-200 p-2 text-sm"> + <div class="flex items-center justify-between gap-2"> + <span class="font-medium font-mono" title={s.providerId}>{s.providerId}</span> + <span class="badge badge-sm {badgeClass[s.badge]} gap-1"> + {#if s.busy} + <span class="loading loading-spinner loading-xs"></span> + {/if} + {#if s.paused} + Paused + {:else if s.inFlight >= s.limit && s.queued > 0} + At capacity + {:else if s.inFlight > 0} + Active + {:else} + Idle + {/if} + </span> + </div> + <div class="flex flex-wrap items-center justify-between gap-2 text-xs opacity-70"> + <span title="In-flight slots held vs cap">{s.inFlightLabel} in flight</span> + <span>{s.queuedLabel}</span> + </div> + {#if s.pausedLabel} + <span class="text-xs text-warning">{s.pausedLabel}</span> + {/if} + </li> + {/each} + </ul> + {/if} + </section> +</div> diff --git a/src/features/concurrency/ui/ConcurrencyView.test.ts b/src/features/concurrency/ui/ConcurrencyView.test.ts new file mode 100644 index 0000000..ba458a5 --- /dev/null +++ b/src/features/concurrency/ui/ConcurrencyView.test.ts @@ -0,0 +1,160 @@ +import type { ConcurrencyStatusEntry } from "@dispatch/transport-contract"; +import { render, screen } from "@testing-library/svelte"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it } from "vitest"; +import type { + ConcurrencyDeleteResult, + ConcurrencyLimitResult, + ConcurrencyLimitsResult, + ConcurrencyStatusResult, +} from "../logic/types"; +import ConcurrencyView from "./ConcurrencyView.svelte"; + +// Fakes for the four injected ports. Each resolves immediately so the mount +// effect's initial load settles in a microtask (assertions await via findBy*). + +function makeFakes(opts?: { + limits?: readonly { providerId: string; limit: number }[]; + status?: readonly ConcurrencyStatusEntry[]; +}) { + let limits = opts?.limits ?? [{ providerId: "umans", limit: 4 }]; + const status = opts?.status ?? [ + { providerId: "umans", limit: 4, inFlight: 2, queued: 1, paused: false }, + ]; + + const calls = { + loadLimits: 0, + loadStatus: 0, + saves: [] as { providerId: string; limit: number }[], + deletes: [] as string[], + }; + + return { + calls, + loadLimits: async (): Promise<ConcurrencyLimitsResult> => { + calls.loadLimits++; + return { ok: true, limits }; + }, + saveLimit: async (providerId: string, limit: number): Promise<ConcurrencyLimitResult> => { + calls.saves.push({ providerId, limit }); + // Reflect the new limit into the list the next load returns. + limits = [...limits.filter((l) => l.providerId !== providerId), { providerId, limit }]; + return { ok: true, providerId, limit }; + }, + deleteLimit: async (providerId: string): Promise<ConcurrencyDeleteResult> => { + calls.deletes.push(providerId); + limits = limits.filter((l) => l.providerId !== providerId); + return { ok: true, providerId }; + }, + loadStatus: async (): Promise<ConcurrencyStatusResult> => { + calls.loadStatus++; + return { ok: true, providers: status }; + }, + }; +} + +describe("ConcurrencyView", () => { + it("loads + renders the configured limits and live status on mount", async () => { + const fakes = makeFakes(); + render(ConcurrencyView, { + props: { + loadLimits: fakes.loadLimits, + saveLimit: fakes.saveLimit, + deleteLimit: fakes.deleteLimit, + loadStatus: fakes.loadStatus, + }, + }); + + // Limits summary (unique to the limits section) + the row's remove control. + expect(await screen.findByText(/1 limit configured/)).toBeInTheDocument(); + expect(await screen.findByLabelText("Remove concurrency limit for umans")).toBeVisible(); + // Status summary (unique to the status section). + expect(await screen.findByText(/1 provider · 2\/4 in flight · 1 queued/)).toBeInTheDocument(); + expect(fakes.calls.loadLimits).toBeGreaterThanOrEqual(1); + expect(fakes.calls.loadStatus).toBeGreaterThanOrEqual(1); + }); + + it("adds a provider limit via the form (calls saveLimit + reloads)", async () => { + const user = userEvent.setup(); + const fakes = makeFakes({ limits: [], status: [] }); + render(ConcurrencyView, { + props: { + loadLimits: fakes.loadLimits, + saveLimit: fakes.saveLimit, + deleteLimit: fakes.deleteLimit, + loadStatus: fakes.loadStatus, + }, + }); + + await screen.findByPlaceholderText("umans"); + + await user.type(screen.getByPlaceholderText("umans"), "anthropic"); + await user.type(screen.getByPlaceholderText("4"), "8"); + await user.click(screen.getByRole("button", { name: "Add" })); + + expect(fakes.calls.saves).toEqual([{ providerId: "anthropic", limit: 8 }]); + // After save the component reloads the limits list (now showing the row). + expect(fakes.calls.loadLimits).toBeGreaterThanOrEqual(2); + expect(await screen.findByText(/1 limit configured/)).toBeInTheDocument(); + expect(await screen.findByLabelText("Remove concurrency limit for anthropic")).toBeVisible(); + }); + + it("disables Add when the provider id is empty or the limit is invalid", async () => { + const user = userEvent.setup(); + const fakes = makeFakes({ limits: [], status: [] }); + render(ConcurrencyView, { + props: { + loadLimits: fakes.loadLimits, + saveLimit: fakes.saveLimit, + deleteLimit: fakes.deleteLimit, + loadStatus: fakes.loadStatus, + }, + }); + + await screen.findByPlaceholderText("umans"); + const addBtn = screen.getByRole("button", { name: "Add" }); + expect(addBtn).toBeDisabled(); + + // Provider id set but invalid limit → still disabled. + await user.type(screen.getByPlaceholderText("umans"), "openai-compat"); + expect(addBtn).toBeDisabled(); + + // Now a valid limit → enabled. + await user.type(screen.getByPlaceholderText("4"), "5"); + expect(addBtn).toBeEnabled(); + }); + + it("removes a provider limit via the row ✕ (calls deleteLimit + reloads)", async () => { + const user = userEvent.setup(); + const fakes = makeFakes(); + render(ConcurrencyView, { + props: { + loadLimits: fakes.loadLimits, + saveLimit: fakes.saveLimit, + deleteLimit: fakes.deleteLimit, + loadStatus: fakes.loadStatus, + }, + }); + + // Wait for the limits to load (unique summary) before interacting. + await screen.findByText(/1 limit configured/); + await user.click(screen.getByLabelText("Remove concurrency limit for umans")); + + expect(fakes.calls.deletes).toEqual(["umans"]); + expect(fakes.calls.loadLimits).toBeGreaterThanOrEqual(2); + }); + + it("surfaces a load error from the limits endpoint", async () => { + const failing = { + loadLimits: async (): Promise<ConcurrencyLimitsResult> => ({ + ok: false, + error: "Concurrency service not available", + }), + saveLimit: async (): Promise<ConcurrencyLimitResult> => ({ ok: false, error: "noop" }), + deleteLimit: async (): Promise<ConcurrencyDeleteResult> => ({ ok: false, error: "noop" }), + loadStatus: async (): Promise<ConcurrencyStatusResult> => ({ ok: true, providers: [] }), + }; + render(ConcurrencyView, { props: failing }); + expect(await screen.findByText("Concurrency service not available")).toBeVisible(); + }); +}); |
