summaryrefslogtreecommitdiffhomepage
path: root/src/features
diff options
context:
space:
mode:
Diffstat (limited to 'src/features')
-rw-r--r--src/features/concurrency/index.ts1
-rw-r--r--src/features/concurrency/logic/view-model.test.ts37
-rw-r--r--src/features/concurrency/logic/view-model.ts13
-rw-r--r--src/features/concurrency/ui/ConcurrencyLimitRow.svelte39
-rw-r--r--src/features/concurrency/ui/ConcurrencyView.svelte17
-rw-r--r--src/features/concurrency/ui/ConcurrencyView.test.ts29
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).