From ba68da660c9a02b90ff2134152abe650f004867e Mon Sep 17 00:00:00 2001 From: Adam Malczewski Date: Wed, 20 May 2026 22:44:42 +0900 Subject: feat: key usage shows all keys at once, removes dropdown, multi-account Claude - /key-usage route now returns all Claude accounts (not just first) - Added ClaudeAccountUsage type for per-account data - Frontend shows all keys stacked in scrollable cards - Each Claude account rendered separately with label + subscription badge - Removed key selector dropdown --- .../frontend/src/lib/components/KeyUsage.svelte | 411 +++++++++------------ packages/frontend/src/lib/types.ts | 10 + 2 files changed, 193 insertions(+), 228 deletions(-) (limited to 'packages/frontend/src') diff --git a/packages/frontend/src/lib/components/KeyUsage.svelte b/packages/frontend/src/lib/components/KeyUsage.svelte index 313c183..7f0453e 100644 --- a/packages/frontend/src/lib/components/KeyUsage.svelte +++ b/packages/frontend/src/lib/components/KeyUsage.svelte @@ -9,40 +9,57 @@ apiBase?: string; } = $props(); - let selectedKeyId = $state(null); - let usageData = $state(null); - let loading = $state(false); - let error = $state(null); + interface KeyUsageEntry { + keyId: string; + provider: string; + data: KeyUsageData | null; + error: string | null; + } - // Set default key when keys load asynchronously - $effect(() => { - if (selectedKeyId === null && keys.length > 0) { - selectedKeyId = keys[0]?.id ?? null; - } - }); + let entries = $state([]); + let loading = $state(true); - async function fetchUsage(keyId: string) { + async function fetchAll() { loading = true; - error = null; - usageData = null; - try { - const res = await fetch(`${apiBase}/models/key-usage?keyId=${encodeURIComponent(keyId)}`); - if (!res.ok) { - const data = await res.json().catch(() => ({})); - throw new Error(data.error ?? `HTTP ${res.status}: ${res.statusText}`); + const results: KeyUsageEntry[] = []; + + for (const key of keys) { + try { + const res = await fetch( + `${apiBase}/models/key-usage?keyId=${encodeURIComponent(key.id)}`, + ); + if (!res.ok) { + const data = await res.json().catch(() => ({})); + results.push({ + keyId: key.id, + provider: key.provider, + data: null, + error: data.error ?? `HTTP ${res.status}`, + }); + } else { + results.push({ + keyId: key.id, + provider: key.provider, + data: await res.json(), + error: null, + }); + } + } catch (e) { + results.push({ + keyId: key.id, + provider: key.provider, + data: null, + error: e instanceof Error ? e.message : "Failed to fetch", + }); } - usageData = await res.json(); - } catch (e) { - error = e instanceof Error ? e.message : "Failed to fetch usage data"; - } finally { - loading = false; } + + entries = results; + loading = false; } $effect(() => { - if (selectedKeyId) { - fetchUsage(selectedKeyId); - } + fetchAll(); }); function progressClass(utilization: number): string { @@ -58,225 +75,163 @@ function hasBucketData(bucket: UsageBucket | undefined): boolean { return bucket !== undefined && bucket.utilization !== undefined; } - - const hasAnyData = $derived.by(() => { - if (!usageData) return false; - if (usageData.provider === "anthropic") { - return hasBucketData(usageData.fiveHour) || hasBucketData(usageData.sevenDay); - } - if (usageData.provider === "opencode-go") { - // OpenCode has no API data but we always have something to show - return true; - } - if (usageData.provider === "github-copilot") { - return usageData.percentUsed !== undefined || usageData.tokensConsumed !== undefined; - } - return false; - });
- - {#if keys.length > 0} - - {:else} + {#if keys.length === 0}

No keys available.

- {/if} - - - {#if loading} + {:else if loading}
Loading usage data...
- {/if} - - - {#if error} - - {/if} - - - {#if !loading && usageData} - {#if !hasAnyData} -

No usage data available for this key.

+ {:else} +
+ {#each entries as entry (entry.keyId)} +
+
+ {entry.keyId} + {entry.provider} +
- {:else if usageData.provider === "anthropic"} -
-

Claude Usage

+ {#if entry.error} + + + {:else if !entry.data} +

No data.

+ + {:else if entry.data.provider === "anthropic"} + + {#if entry.data.accounts} + {#each entry.data.accounts as acct (acct.source)} +
+
+ {acct.label} + {#if acct.subscriptionType} + {acct.subscriptionType} + {/if} +
+ + {#if acct.error} +

{acct.error}

+ {/if} + + {#if hasBucketData(acct.fiveHour)} + {@const b = acct.fiveHour!} + {@const u = b.utilization ?? 0} + {@const p = Math.round(u * 100)} +
+
+ 5-Hour + {p}% +
+ + {#if b.resetsAt} + Resets: {formatDate(b.resetsAt)} + {/if} +
+ {/if} + + {#if hasBucketData(acct.sevenDay)} + {@const b = acct.sevenDay!} + {@const u = b.utilization ?? 0} + {@const p = Math.round(u * 100)} +
+
+ Weekly + {p}% +
+ + {#if b.resetsAt} + Resets: {formatDate(b.resetsAt)} + {/if} +
+ {/if} +
+ {/each} + {/if} - {#if hasBucketData(usageData.fiveHour)} - {@const bucket = usageData.fiveHour!} - {@const util = bucket.utilization ?? 0} - {@const pct = Math.round(util * 100)} -
-

5-Hour Window

- -
- {pct}% - {#if bucket.resetsAt} - Resets: {formatDate(bucket.resetsAt)} + {:else if entry.data.provider === "opencode-go"} + {#if entry.data.unavailable} +

Check console for usage.

+ {#if entry.data.consoleUrl} + + Open console + {/if} -
-
- {/if} - - {#if hasBucketData(usageData.sevenDay)} - {@const bucket = usageData.sevenDay!} - {@const util = bucket.utilization ?? 0} - {@const pct = Math.round(util * 100)} -
-

Weekly (7-Day)

- -
- {pct}% - {#if bucket.resetsAt} - Resets: {formatDate(bucket.resetsAt)} + {:else} + {#if hasBucketData(entry.data.fiveHour)} + {@const b = entry.data.fiveHour!} + {@const u = b.utilization ?? 0} + {@const p = Math.round(u * 100)} +
+
+ 5-Hour + {p}% +
+ + {#if b.resetsAt} + Resets: {formatDate(b.resetsAt)} + {/if} +
{/if} -
-
- {/if} -
- - {:else if usageData.provider === "opencode-go"} -
-

OpenCode Usage

- - {#if usageData.unavailable} -

- OpenCode does not expose usage data via API. -

- {#if usageData.limits} -
-

Rate Limits

-
    - {#if usageData.limits.fiveHour} -
  • 5-Hour: {usageData.limits.fiveHour}
  • - {/if} - {#if usageData.limits.weekly} -
  • Weekly: {usageData.limits.weekly}
  • - {/if} - {#if usageData.limits.monthly} -
  • Monthly: {usageData.limits.monthly}
  • - {/if} -
-
- {/if} - {#if usageData.consoleUrl} - - View usage in console - - {/if} - {:else} - - {#if hasBucketData(usageData.fiveHour)} - {@const bucket = usageData.fiveHour!} - {@const util = bucket.utilization ?? 0} - {@const pct = Math.round(util * 100)} -
-

5-Hour Window

- -
- {pct}% - {#if bucket.resetsAt} - Resets: {formatDate(bucket.resetsAt)} - {/if} -
-
- {/if} - - {#if hasBucketData(usageData.weekly)} - {@const bucket = usageData.weekly!} - {@const util = bucket.utilization ?? 0} - {@const pct = Math.round(util * 100)} -
-

Weekly

- -
- {pct}% - {#if bucket.resetsAt} - Resets: {formatDate(bucket.resetsAt)} - {/if} -
-
- {/if} + {#if hasBucketData(entry.data.weekly)} + {@const b = entry.data.weekly!} + {@const u = b.utilization ?? 0} + {@const p = Math.round(u * 100)} +
+
+ Weekly + {p}% +
+ + {#if b.resetsAt} + Resets: {formatDate(b.resetsAt)} + {/if} +
+ {/if} + {#if hasBucketData(entry.data.monthly)} + {@const b = entry.data.monthly!} + {@const u = b.utilization ?? 0} + {@const p = Math.round(u * 100)} +
+
+ Monthly + {p}% +
+ + {#if b.resetsAt} + Resets: {formatDate(b.resetsAt)} + {/if} +
+ {/if} + {/if} - {#if hasBucketData(usageData.monthly)} - {@const bucket = usageData.monthly!} - {@const util = bucket.utilization ?? 0} - {@const pct = Math.round(util * 100)} -
-

Monthly

- + {:else if entry.data.provider === "github-copilot"} + {@const p = Math.round(entry.data.percentUsed ?? 0)} +
- {pct}% - {#if bucket.resetsAt} - Resets: {formatDate(bucket.resetsAt)} - {/if} + + {#if entry.data.tokensConsumed !== undefined && entry.data.tokensRemaining !== undefined} + {entry.data.tokensConsumed.toLocaleString()} / {(entry.data.tokensConsumed + entry.data.tokensRemaining).toLocaleString()} tokens + {:else if entry.data.plan} + {entry.data.plan} + {:else} + Usage + {/if} + + {p}%
+ + {#if entry.data.resetAt} + Resets: {formatDate(entry.data.resetAt)} + {/if}
{/if} - {/if} -
- - {:else if usageData.provider === "github-copilot"} - {@const pct = Math.round(usageData.percentUsed ?? 0)} -
-

Copilot Usage

-
- -
- {#if usageData.tokensConsumed !== undefined && usageData.tokensRemaining !== undefined} - - {usageData.tokensConsumed.toLocaleString()} / {(usageData.tokensConsumed + usageData.tokensRemaining).toLocaleString()} ({pct}%) - - {:else} - {pct}% - {/if} - {#if usageData.resetAt} - Resets: {formatDate(usageData.resetAt)} - {/if} -
-
- {/if} + {/each} +
{/if}
diff --git a/packages/frontend/src/lib/types.ts b/packages/frontend/src/lib/types.ts index e7ff4ba..559742d 100644 --- a/packages/frontend/src/lib/types.ts +++ b/packages/frontend/src/lib/types.ts @@ -114,8 +114,18 @@ export interface UsageBucket { resetsAt?: number; } +export interface ClaudeAccountUsage { + label: string; + source: string; + subscriptionType?: string; + fiveHour?: UsageBucket; + sevenDay?: UsageBucket; + error?: string; +} + export interface ClaudeUsageData { provider: "anthropic"; + accounts?: ClaudeAccountUsage[]; fiveHour?: UsageBucket; sevenDay?: UsageBucket; } -- cgit v1.2.3