diff options
| author | Adam Malczewski <[email protected]> | 2026-05-20 22:44:42 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-05-20 22:44:42 +0900 |
| commit | ba68da660c9a02b90ff2134152abe650f004867e (patch) | |
| tree | 832dd87a2e211596b3ef5f3f6ed11774ff881fb7 /packages/frontend/src/lib/components | |
| parent | c0c55ed7c03188f264ff2c27b9982f2d57d092cc (diff) | |
| download | dispatch-ba68da660c9a02b90ff2134152abe650f004867e.tar.gz dispatch-ba68da660c9a02b90ff2134152abe650f004867e.zip | |
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
Diffstat (limited to 'packages/frontend/src/lib/components')
| -rw-r--r-- | packages/frontend/src/lib/components/KeyUsage.svelte | 411 |
1 files changed, 183 insertions, 228 deletions
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<string | null>(null); - let usageData = $state<KeyUsageData | null>(null); - let loading = $state(false); - let error = $state<string | null>(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<KeyUsageEntry[]>([]); + 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; - }); </script> <div class="flex flex-col gap-3"> - <!-- Key selector --> - {#if keys.length > 0} - <select - class="select select-bordered select-sm w-full" - bind:value={selectedKeyId} - > - {#each keys as key (key.id)} - <option value={key.id}>{key.id} ({key.provider})</option> - {/each} - </select> - {:else} + {#if keys.length === 0} <p class="text-xs text-base-content/50">No keys available.</p> - {/if} - - <!-- Loading --> - {#if loading} + {:else if loading} <div class="flex items-center gap-2 py-2"> <span class="loading loading-spinner loading-sm"></span> <span class="text-xs text-base-content/50">Loading usage data...</span> </div> - {/if} - - <!-- Error --> - {#if error} - <div role="alert" class="alert alert-error alert-soft py-2 px-3 text-xs"> - {error} - </div> - {/if} - - <!-- Usage data --> - {#if !loading && usageData} - {#if !hasAnyData} - <p class="text-xs text-base-content/50">No usage data available for this key.</p> + {:else} + <div class="flex flex-col gap-3 max-h-96 overflow-y-auto"> + {#each entries as entry (entry.keyId)} + <div + class="bg-base-200 rounded-lg p-2" + > + <div class="flex items-center gap-1.5 mb-1.5"> + <span class="text-xs font-semibold">{entry.keyId}</span> + <span class="badge badge-xs badge-ghost">{entry.provider}</span> + </div> - {:else if usageData.provider === "anthropic"} - <div class="flex flex-col gap-2"> - <p class="text-xs text-base-content/50 uppercase tracking-wide">Claude Usage</p> + {#if entry.error} + <div role="alert" class="text-xs text-error/80">{entry.error}</div> + + {:else if !entry.data} + <p class="text-xs text-base-content/50">No data.</p> + + {:else if entry.data.provider === "anthropic"} + <!-- Render each Claude account --> + {#if entry.data.accounts} + {#each entry.data.accounts as acct (acct.source)} + <div class="flex flex-col gap-1 pl-1"> + <div class="flex items-center gap-1"> + <span class="text-xs font-medium">{acct.label}</span> + {#if acct.subscriptionType} + <span class="badge badge-xs">{acct.subscriptionType}</span> + {/if} + </div> + + {#if acct.error} + <p class="text-xs text-error/70">{acct.error}</p> + {/if} + + {#if hasBucketData(acct.fiveHour)} + {@const b = acct.fiveHour!} + {@const u = b.utilization ?? 0} + {@const p = Math.round(u * 100)} + <div class="flex flex-col gap-0.5"> + <div class="flex items-center justify-between"> + <span class="text-xs text-base-content/50">5-Hour</span> + <span class="text-xs font-mono">{p}%</span> + </div> + <progress class="progress w-full h-2 {progressClass(u)}" value={p} max="100"></progress> + {#if b.resetsAt} + <span class="text-xs text-base-content/40">Resets: {formatDate(b.resetsAt)}</span> + {/if} + </div> + {/if} + + {#if hasBucketData(acct.sevenDay)} + {@const b = acct.sevenDay!} + {@const u = b.utilization ?? 0} + {@const p = Math.round(u * 100)} + <div class="flex flex-col gap-0.5"> + <div class="flex items-center justify-between"> + <span class="text-xs text-base-content/50">Weekly</span> + <span class="text-xs font-mono">{p}%</span> + </div> + <progress class="progress w-full h-2 {progressClass(u)}" value={p} max="100"></progress> + {#if b.resetsAt} + <span class="text-xs text-base-content/40">Resets: {formatDate(b.resetsAt)}</span> + {/if} + </div> + {/if} + </div> + {/each} + {/if} - {#if hasBucketData(usageData.fiveHour)} - {@const bucket = usageData.fiveHour!} - {@const util = bucket.utilization ?? 0} - {@const pct = Math.round(util * 100)} - <div class="flex flex-col gap-0.5"> - <p class="text-xs text-base-content/50">5-Hour Window</p> - <progress - class="progress w-full {progressClass(util)}" - value={pct} - max="100" - ></progress> - <div class="flex items-center justify-between"> - <span class="text-xs font-mono">{pct}%</span> - {#if bucket.resetsAt} - <span class="text-xs text-base-content/40">Resets: {formatDate(bucket.resetsAt)}</span> + {:else if entry.data.provider === "opencode-go"} + {#if entry.data.unavailable} + <p class="text-xs text-base-content/70">Check console for usage.</p> + {#if entry.data.consoleUrl} + <a href={entry.data.consoleUrl} target="_blank" rel="noopener noreferrer" class="link link-primary text-xs"> + Open console + </a> {/if} - </div> - </div> - {/if} - - {#if hasBucketData(usageData.sevenDay)} - {@const bucket = usageData.sevenDay!} - {@const util = bucket.utilization ?? 0} - {@const pct = Math.round(util * 100)} - <div class="flex flex-col gap-0.5"> - <p class="text-xs text-base-content/50">Weekly (7-Day)</p> - <progress - class="progress w-full {progressClass(util)}" - value={pct} - max="100" - ></progress> - <div class="flex items-center justify-between"> - <span class="text-xs font-mono">{pct}%</span> - {#if bucket.resetsAt} - <span class="text-xs text-base-content/40">Resets: {formatDate(bucket.resetsAt)}</span> + {:else} + {#if hasBucketData(entry.data.fiveHour)} + {@const b = entry.data.fiveHour!} + {@const u = b.utilization ?? 0} + {@const p = Math.round(u * 100)} + <div class="flex flex-col gap-0.5"> + <div class="flex items-center justify-between"> + <span class="text-xs text-base-content/50">5-Hour</span> + <span class="text-xs font-mono">{p}%</span> + </div> + <progress class="progress w-full h-2 {progressClass(u)}" value={p} max="100"></progress> + {#if b.resetsAt} + <span class="text-xs text-base-content/40">Resets: {formatDate(b.resetsAt)}</span> + {/if} + </div> {/if} - </div> - </div> - {/if} - </div> - - {:else if usageData.provider === "opencode-go"} - <div class="flex flex-col gap-2"> - <p class="text-xs text-base-content/50 uppercase tracking-wide">OpenCode Usage</p> - - {#if usageData.unavailable} - <p class="text-xs text-base-content/70"> - OpenCode does not expose usage data via API. - </p> - {#if usageData.limits} - <div class="flex flex-col gap-1"> - <p class="text-xs text-base-content/50">Rate Limits</p> - <ul class="text-xs text-base-content/70 list-disc pl-4 flex flex-col gap-0.5"> - {#if usageData.limits.fiveHour} - <li>5-Hour: {usageData.limits.fiveHour}</li> - {/if} - {#if usageData.limits.weekly} - <li>Weekly: {usageData.limits.weekly}</li> - {/if} - {#if usageData.limits.monthly} - <li>Monthly: {usageData.limits.monthly}</li> - {/if} - </ul> - </div> - {/if} - {#if usageData.consoleUrl} - <a - href={usageData.consoleUrl} - target="_blank" - rel="noopener noreferrer" - class="link link-primary text-xs" - > - View usage in console - </a> - {/if} - {:else} - <!-- If API data ever becomes available, render buckets --> - {#if hasBucketData(usageData.fiveHour)} - {@const bucket = usageData.fiveHour!} - {@const util = bucket.utilization ?? 0} - {@const pct = Math.round(util * 100)} - <div class="flex flex-col gap-0.5"> - <p class="text-xs text-base-content/50">5-Hour Window</p> - <progress - class="progress w-full {progressClass(util)}" - value={pct} - max="100" - ></progress> - <div class="flex items-center justify-between"> - <span class="text-xs font-mono">{pct}%</span> - {#if bucket.resetsAt} - <span class="text-xs text-base-content/40">Resets: {formatDate(bucket.resetsAt)}</span> - {/if} - </div> - </div> - {/if} - - {#if hasBucketData(usageData.weekly)} - {@const bucket = usageData.weekly!} - {@const util = bucket.utilization ?? 0} - {@const pct = Math.round(util * 100)} - <div class="flex flex-col gap-0.5"> - <p class="text-xs text-base-content/50">Weekly</p> - <progress - class="progress w-full {progressClass(util)}" - value={pct} - max="100" - ></progress> - <div class="flex items-center justify-between"> - <span class="text-xs font-mono">{pct}%</span> - {#if bucket.resetsAt} - <span class="text-xs text-base-content/40">Resets: {formatDate(bucket.resetsAt)}</span> - {/if} - </div> - </div> - {/if} + {#if hasBucketData(entry.data.weekly)} + {@const b = entry.data.weekly!} + {@const u = b.utilization ?? 0} + {@const p = Math.round(u * 100)} + <div class="flex flex-col gap-0.5"> + <div class="flex items-center justify-between"> + <span class="text-xs text-base-content/50">Weekly</span> + <span class="text-xs font-mono">{p}%</span> + </div> + <progress class="progress w-full h-2 {progressClass(u)}" value={p} max="100"></progress> + {#if b.resetsAt} + <span class="text-xs text-base-content/40">Resets: {formatDate(b.resetsAt)}</span> + {/if} + </div> + {/if} + {#if hasBucketData(entry.data.monthly)} + {@const b = entry.data.monthly!} + {@const u = b.utilization ?? 0} + {@const p = Math.round(u * 100)} + <div class="flex flex-col gap-0.5"> + <div class="flex items-center justify-between"> + <span class="text-xs text-base-content/50">Monthly</span> + <span class="text-xs font-mono">{p}%</span> + </div> + <progress class="progress w-full h-2 {progressClass(u)}" value={p} max="100"></progress> + {#if b.resetsAt} + <span class="text-xs text-base-content/40">Resets: {formatDate(b.resetsAt)}</span> + {/if} + </div> + {/if} + {/if} - {#if hasBucketData(usageData.monthly)} - {@const bucket = usageData.monthly!} - {@const util = bucket.utilization ?? 0} - {@const pct = Math.round(util * 100)} - <div class="flex flex-col gap-0.5"> - <p class="text-xs text-base-content/50">Monthly</p> - <progress - class="progress w-full {progressClass(util)}" - value={pct} - max="100" - ></progress> + {:else if entry.data.provider === "github-copilot"} + {@const p = Math.round(entry.data.percentUsed ?? 0)} + <div class="flex flex-col gap-0.5 pl-1"> <div class="flex items-center justify-between"> - <span class="text-xs font-mono">{pct}%</span> - {#if bucket.resetsAt} - <span class="text-xs text-base-content/40">Resets: {formatDate(bucket.resetsAt)}</span> - {/if} + <span class="text-xs text-base-content/50"> + {#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} + </span> + <span class="text-xs font-mono">{p}%</span> </div> + <progress class="progress w-full h-2 {progressClass(p / 100)}" value={p} max="100"></progress> + {#if entry.data.resetAt} + <span class="text-xs text-base-content/40">Resets: {formatDate(entry.data.resetAt)}</span> + {/if} </div> {/if} - {/if} - </div> - - {:else if usageData.provider === "github-copilot"} - {@const pct = Math.round(usageData.percentUsed ?? 0)} - <div class="flex flex-col gap-2"> - <p class="text-xs text-base-content/50 uppercase tracking-wide">Copilot Usage</p> - <div class="flex flex-col gap-0.5"> - <progress - class="progress w-full {progressClass(pct / 100)}" - value={pct} - max="100" - ></progress> - <div class="flex items-center justify-between"> - {#if usageData.tokensConsumed !== undefined && usageData.tokensRemaining !== undefined} - <span class="text-xs font-mono"> - {usageData.tokensConsumed.toLocaleString()} / {(usageData.tokensConsumed + usageData.tokensRemaining).toLocaleString()} ({pct}%) - </span> - {:else} - <span class="text-xs font-mono">{pct}%</span> - {/if} - {#if usageData.resetAt} - <span class="text-xs text-base-content/40">Resets: {formatDate(usageData.resetAt)}</span> - {/if} - </div> </div> - </div> - {/if} + {/each} + </div> {/if} </div> |
