summaryrefslogtreecommitdiffhomepage
path: root/src/features/concurrency/ui/ConcurrencyView.svelte
diff options
context:
space:
mode:
Diffstat (limited to 'src/features/concurrency/ui/ConcurrencyView.svelte')
-rw-r--r--src/features/concurrency/ui/ConcurrencyView.svelte433
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>