diff options
Diffstat (limited to 'src/features/concurrency/ui/ConcurrencyView.svelte')
| -rw-r--r-- | src/features/concurrency/ui/ConcurrencyView.svelte | 433 |
1 files changed, 433 insertions, 0 deletions
diff --git a/src/features/concurrency/ui/ConcurrencyView.svelte b/src/features/concurrency/ui/ConcurrencyView.svelte new file mode 100644 index 0000000..aadb8d1 --- /dev/null +++ b/src/features/concurrency/ui/ConcurrencyView.svelte @@ -0,0 +1,433 @@ +<script lang="ts"> + import { untrack } from "svelte"; + import type { ConcurrencyStatusEntry } from "@dispatch/transport-contract"; + import { + autoReduceNotices, + DEFAULT_COOLDOWN_MS, + parseCooldownInput, + parseLimitInput, + providerOptions, + summarizeLimits, + viewConcurrencyLimits, + viewConcurrencyStatus, + type ConcurrencyStatusView, + } from "../logic/view-model"; + import type { + ConcurrencyLimitEntry, + DeleteConcurrencyLimit, + LoadConcurrencyLimits, + LoadConcurrencyStatus, + RestoreOutcome, + SaveConcurrencyCooldown, + SaveConcurrencyLimit, + } from "../logic/types"; + import AutoReduceBanner from "./AutoReduceBanner.svelte"; + import ConcurrencyLimitRow from "./ConcurrencyLimitRow.svelte"; + + let { + models, + loadLimits, + saveLimit, + deleteLimit, + loadStatus, + saveCooldown, + }: { + /** Available models (`<provider>/<model>`) — the source of provider ids for the Add dropdown. */ + models: readonly string[]; + loadLimits: LoadConcurrencyLimits; + saveLimit: SaveConcurrencyLimit; + deleteLimit: DeleteConcurrencyLimit; + loadStatus: LoadConcurrencyStatus; + saveCooldown: SaveConcurrencyCooldown; + } = $props(); + + // ── Limits (config: list / add / update / remove) ──────────────────────────── + let limits = $state<readonly ConcurrencyLimitEntry[]>([]); + let limitsError = $state<string | null>(null); + /** True after the first load settles (gates the empty state). */ + let hasLoadedLimits = $state(false); + /** Re-entrancy guard for background/silent refreshes (no UI — prevents + * overlapping fetches). The refresh is near-instant, so a visible loading + * indicator would flicker every poll/reload; it stays INVISIBLE (mirrors the + * heartbeat runs list). */ + let limitsInFlight = false; + + // Add-row state. The provider id is chosen from a dropdown of known providers + // (derived from the available models + any already-configured limit providers). + // The row is revealed by the "Add" button; "Set" saves it, ✕ cancels. + let addOpen = $state(false); + let newProviderId = $state(""); + let newLimitInput = $state(""); + let newCooldownInput = $state(""); + let adding = $state(false); + let addError = $state<string | null>(null); + + const providerOpts = $derived(providerOptions(models, limits)); + const limitViews = $derived(viewConcurrencyLimits(limits)); + const limitsSummary = $derived(summarizeLimits(limits)); + const parsedNewLimit = $derived(parseLimitInput(newLimitInput)); + const parsedNewCooldown = $derived(parseCooldownInput(newCooldownInput)); + /** Only send a cooldown PUT when the user moved it off the server default. */ + const newCooldownChanged = $derived( + parsedNewCooldown !== null && parsedNewCooldown !== DEFAULT_COOLDOWN_MS, + ); + const canSet = $derived( + newProviderId !== "" && + parsedNewLimit !== null && + parsedNewCooldown !== null && + !limits.some((l) => l.providerId === newProviderId) && + !adding, + ); + + // Keep the dropdown selection valid: default to the first option, and if the + // selected provider is removed from the options (e.g. its limit was deleted and + // it has no models), fall back to the first remaining option. Runs untracked so + // it doesn't loop on its own assignment. + $effect(() => { + const opts = providerOpts; + untrack(() => { + if (opts.length === 0) { + if (newProviderId !== "") newProviderId = ""; + return; + } + if (!opts.includes(newProviderId)) newProviderId = opts[0] ?? ""; + }); + }); + + async function refreshLimits(): Promise<void> { + if (limitsInFlight) return; + limitsInFlight = true; + const result = await loadLimits(); + limitsInFlight = false; + hasLoadedLimits = true; + if (result.ok) { + limits = result.limits; + // Clear the error only on success so it stays visible (stable, no flicker) + // during an in-flight retry rather than vanishing mid-refresh. + limitsError = null; + } else { + limitsError = result.error; + } + } + + function startAdd(): void { + addOpen = true; + addError = null; + newLimitInput = ""; + newCooldownInput = String(DEFAULT_COOLDOWN_MS); + // newProviderId is kept valid (defaults to the first option) by the effect above. + } + + function cancelAdd(): void { + addOpen = false; + addError = null; + newLimitInput = ""; + newCooldownInput = ""; + } + + // "Set" on the add row: save the limit, then the cooldown (only when the user + // moved it off the server default of 350ms — the backend defaults to 350 when a + // limit is set, so an unchanged value needs no extra PUT). On full success the + // add row closes + the limits/status reload (the new limit appears as a row). + async function handleAdd(): Promise<void> { + if (parsedNewLimit === null || parsedNewCooldown === null || newProviderId === "") return; + adding = true; + addError = null; + const limitResult = await saveLimit(newProviderId, parsedNewLimit); + if (!limitResult.ok) { + adding = false; + addError = limitResult.error; + return; + } + if (newCooldownChanged) { + const cooldownResult = await saveCooldown(newProviderId, parsedNewCooldown); + adding = false; + if (!cooldownResult.ok) { + // The limit was saved (→ a row will appear after reload); the cooldown PUT + // failed. Surface the error but keep the add row open so it's visible. The + // user can edit the cooldown on the now-saved row. + addError = cooldownResult.error; + void refreshLimits(); + void refreshStatus(); + return; + } + } else { + adding = false; + } + addOpen = false; + newLimitInput = ""; + newCooldownInput = ""; + void refreshLimits(); + void refreshStatus(); + } + + // 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; + } + + // Wrap the cooldown save so a successful PUT refreshes the live status (which + // re-carries the new `cooldownMs`). The row still gets the result to drive its + // own UI. + async function cooldownSave(providerId: string, cooldownMs: number) { + const result = await saveCooldown(providerId, cooldownMs); + if (result.ok) { + void refreshStatus(); + } + return result; + } + + // ── Live status (polls while mounted — seeds cooldown inputs + drives the + // auto-reduce banners; the poll is silent, no status cards) ──────────────── + let statusEntries = $state<readonly ConcurrencyStatusEntry[]>([]); + let statusError = $state<string | null>(null); + /** True after the first load settles (gates the empty state). */ + let hasLoadedStatus = $state(false); + /** Re-entrancy guard for the 2s background poll (no UI — a visible loading + * indicator flickered every poll because the refresh is near-instant; it stays + * INVISIBLE, mirroring the heartbeat runs list). */ + let statusInFlight = false; + + // 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; + }); + + // ── Auto-reduce banners (persist while autoReduced===true; dismissible) ─────── + // + // When a provider's limit is auto-reduced by a 429, `GET /concurrency/status` + // carries `autoReduced: true` (+ `autoReducedFrom` + `notice`). We render a + // banner per such provider. The banner is DISMISSIBLE: a dismissed provider + // stays hidden while it remains auto-reduced (persist-while-true), and is + // UN-dismissed the moment a poll shows it no longer auto-reduced — so a future + // auto-reduce re-shows the banner. Restoring the limit (PUT) clears + // `autoReduced` server-side → the next poll drops the banner automatically. + // + // The dismissed set is intentionally COMPONENT-LOCAL (NOT persisted to + // localStorage / a module-global): it resets on remount (sidebar view switch / + // reload). This is correct — `autoReduced` is a REAL persisted degraded state, + // so re-showing the banner on a fresh mount reminds the user. Persisting a + // dismissal across reloads would risk HIDING an ongoing degradation (a + // footgun), and AGENTS.md forbids module-global ambient state. Mirrors the + // component-local `limitsError`/`statusError` pattern. + let dismissedAutoReduce = $state<ReadonlySet<string>>(new Set()); + + const allNotices = $derived(autoReduceNotices(statusEntries)); + const visibleNotices = $derived( + allNotices.filter((n) => !dismissedAutoReduce.has(n.providerId)), + ); + + // Reconcile the dismissed set against the live auto-reduced providers: keep a + // dismissed entry ONLY while its provider is still auto-reduced. A provider + // that has been restored (no longer in `allNotices`) is dropped from the + // dismissed set so a future auto-reduce re-shows its banner. + $effect(() => { + const autoReducedIds = new Set(allNotices.map((n) => n.providerId)); + untrack(() => { + let changed = false; + const next = new Set<string>(); + for (const id of dismissedAutoReduce) { + if (autoReducedIds.has(id)) next.add(id); + else changed = true; + } + if (changed) dismissedAutoReduce = next; + }); + }); + + function dismissAutoReduce(providerId: string): void { + if (dismissedAutoReduce.has(providerId)) return; + dismissedAutoReduce = new Set([...dismissedAutoReduce, providerId]); + } + + // "Restore to N" — PUT the limit back to `autoReducedFrom` via the limits + // endpoint (a manual PUT clears `autoReduced` server-side). Refreshes limits + + // status on success; the next status poll shows `autoReduced===false` and the + // banner drops (the dismissed-set effect above un-dismisses it too). The banner + // component owns its own restoring-spinner + inline error; on FAILURE the + // outcome is bubbled back so the banner shows the error inline (instead of + // silently re-enabling the button / surfacing it only in the limits section). + async function restoreLimit(providerId: string, limit: number): Promise<RestoreOutcome> { + const result = await saveLimit(providerId, limit); + if (result.ok) { + void refreshLimits(); + void refreshStatus(); + return { ok: true }; + } + return { ok: false, error: result.error }; + } + + async function refreshStatus(): Promise<void> { + if (statusInFlight) return; + statusInFlight = true; + const result = await loadStatus(); + statusInFlight = false; + hasLoadedStatus = true; + if (result.ok) { + statusEntries = result.providers; + // Clear the error only on success so it stays visible (stable, no flicker) + // during an in-flight retry rather than vanishing mid-poll. + statusError = null; + } else { + statusError = result.error; + } + } + + const STATUS_POLL_MS = 2000; + + // Load limits + status on mount, and poll the live status while the view is + // alive (so a saved limit's cooldown input re-seeds + auto-reduce banners 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"> + <!-- Auto-reduce banners (appear when a provider's limit was auto-reduced by a 429) --> + {#if visibleNotices.length > 0} + <section class="flex flex-col gap-2" aria-label="Concurrency auto-reduce notices"> + {#each visibleNotices as notice (notice.providerId)} + <AutoReduceBanner {notice} onRestore={restoreLimit} onDismiss={dismissAutoReduce} /> + {/each} + </section> + {/if} + + <!-- Limits (config) — a single list of editable rows. --> + <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" + onclick={() => { + void refreshLimits(); + void refreshStatus(); + }} + aria-label="Refresh concurrency limits" + > + Refresh + </button> + </div> + + <span class="text-xs opacity-70">{limitsSummary}</span> + + {#if limitsError} + <p class="text-xs text-error">{limitsError}</p> + {:else if hasLoadedLimits && limitViews.length === 0 && !addOpen} + <p class="text-xs opacity-60">No limits configured — providers run unlimited.</p> + {/if} + + <ul class="flex flex-col gap-2"> + {#each limitViews as limit (limit.providerId)} + <li> + <ConcurrencyLimitRow + {limit} + status={statusByProvider.get(limit.providerId) ?? null} + save={rowSave} + saveCooldown={cooldownSave} + remove={rowRemove} + /> + </li> + {/each} + </ul> + + <!-- Add row: an "Add" button reveals a new item (dropdown + limit + cooldown + + Set + ✕). Set saves the limit (+ cooldown when moved off the default); + ✕ cancels the draft. --> + {#if addOpen} + <div class="flex flex-col gap-1 rounded-box bg-base-200 p-2 text-sm"> + <!-- All on one line — nowrap so the buttons never wrap (tight gap + narrow + inputs keep the provider dropdown visible; it shrinks via flex-1 + min-w-0). --> + <div class="flex flex-nowrap items-center gap-1"> + <select + class="select select-bordered select-xs min-w-0 flex-1 font-mono" + aria-label="Provider" + bind:value={newProviderId} + disabled={adding || providerOpts.length === 0} + > + {#if providerOpts.length === 0} + <option value="" disabled>No providers available</option> + {:else} + {#each providerOpts as provider (provider)} + <option value={provider}>{provider}</option> + {/each} + {/if} + </select> + <input + type="text" + inputmode="numeric" + class="input input-bordered input-xs w-12 min-w-0 font-mono" + placeholder="4" + aria-label="New concurrency limit" + bind:value={newLimitInput} + disabled={adding} + /> + <input + type="text" + inputmode="numeric" + class="input input-bordered input-xs w-14 min-w-0 font-mono" + aria-label="New release cooldown (ms)" + bind:value={newCooldownInput} + disabled={adding} + /> + <span class="shrink-0 text-[10px] opacity-50">ms</span> + <button + type="button" + class="btn btn-primary btn-xs shrink-0" + disabled={!canSet} + onclick={handleAdd} + > + {#if adding} + <span class="loading loading-spinner loading-xs"></span> + {:else} + Set + {/if} + </button> + <button + type="button" + class="btn btn-ghost btn-xs shrink-0 text-error" + aria-label="Cancel add" + disabled={adding} + onclick={cancelAdd} + > + ✕ + </button> + </div> + {#if addError} + <p class="font-mono text-xs text-error">{addError}</p> + {/if} + </div> + {:else} + <button type="button" class="btn btn-ghost btn-xs w-fit" onclick={startAdd}> + + Add + </button> + {/if} + </section> +</div> |
