diff options
| author | Adam Malczewski <[email protected]> | 2026-05-20 22:32:41 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-05-20 22:32:41 +0900 |
| commit | c0c55ed7c03188f264ff2c27b9982f2d57d092cc (patch) | |
| tree | 04b871b83e6e1481c8b24b59aa3acc0e15b44f02 /packages/frontend/src/lib | |
| parent | 8151447758e6826a578363758a755c6cebd1c05f (diff) | |
| download | dispatch-c0c55ed7c03188f264ff2c27b9982f2d57d092cc.tar.gz dispatch-c0c55ed7c03188f264ff2c27b9982f2d57d092cc.zip | |
feat: key usage panel — display usage data for Claude, OpenCode, and Copilot
Adds 'Key Usage' to the sidebar dropdown with per-provider live usage:
- Claude: 5-hour and weekly utilization bars with reset timestamps
(normalizes Anthropic's 0-100% API response to 0-1 internally)
- OpenCode: Scrapes usage from workspace page via OPENCODE_COOKIE
session cookie, mapping key IDs to OPENCODE_WS1_ID/OPENCODE_WS2_ID
- Copilot: Fetches from api.github.com/copilot_internal/user with
entitlement/remaining/quota reset tracking
New files: opencode.ts, copilot.ts (usage fetchers), KeyUsage.svelte
New route: GET /models/key-usage?keyId=X dispatches by provider
Diffstat (limited to 'packages/frontend/src/lib')
| -rw-r--r-- | packages/frontend/src/lib/components/KeyUsage.svelte | 282 | ||||
| -rw-r--r-- | packages/frontend/src/lib/components/SidebarPanel.svelte | 7 | ||||
| -rw-r--r-- | packages/frontend/src/lib/types.ts | 32 |
3 files changed, 319 insertions, 2 deletions
diff --git a/packages/frontend/src/lib/components/KeyUsage.svelte b/packages/frontend/src/lib/components/KeyUsage.svelte new file mode 100644 index 0000000..313c183 --- /dev/null +++ b/packages/frontend/src/lib/components/KeyUsage.svelte @@ -0,0 +1,282 @@ +<script lang="ts"> + import type { KeyInfo, KeyUsageData, UsageBucket } from "../types.js"; + + const { + keys = [], + apiBase = "", + }: { + keys?: KeyInfo[]; + 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); + + // Set default key when keys load asynchronously + $effect(() => { + if (selectedKeyId === null && keys.length > 0) { + selectedKeyId = keys[0]?.id ?? null; + } + }); + + async function fetchUsage(keyId: string) { + 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}`); + } + usageData = await res.json(); + } catch (e) { + error = e instanceof Error ? e.message : "Failed to fetch usage data"; + } finally { + loading = false; + } + } + + $effect(() => { + if (selectedKeyId) { + fetchUsage(selectedKeyId); + } + }); + + function progressClass(utilization: number): string { + if (utilization > 0.8) return "progress-error"; + if (utilization >= 0.5) return "progress-warning"; + return "progress-success"; + } + + function formatDate(ts: number): string { + return new Date(ts).toLocaleString(); + } + + 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} + <p class="text-xs text-base-content/50">No keys available.</p> + {/if} + + <!-- Loading --> + {#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 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 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.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> + {/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(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> + <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} + </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} + {/if} +</div> diff --git a/packages/frontend/src/lib/components/SidebarPanel.svelte b/packages/frontend/src/lib/components/SidebarPanel.svelte index fe92486..2184818 100644 --- a/packages/frontend/src/lib/components/SidebarPanel.svelte +++ b/packages/frontend/src/lib/components/SidebarPanel.svelte @@ -4,6 +4,7 @@ import ConfigPanel from "./ConfigPanel.svelte"; import SkillsBrowser from "./SkillsBrowser.svelte"; import PermissionLog from "./PermissionLog.svelte"; + import KeyUsage from "./KeyUsage.svelte"; import type { TaskItem, LogEntry, KeyInfo, ModelInfo } from "../types.js"; const { @@ -24,7 +25,7 @@ let selected = $state("Tasks"); - const options = ["Model Status", "Tasks", "Config", "Skills", "Permission Log"]; + const options = ["Key Usage", "Model Status", "Tasks", "Config", "Skills", "Permission Log"]; </script> <div class="bg-base-200 rounded-lg p-3"> @@ -38,7 +39,9 @@ </select> <div class="mt-2"> - {#if selected === "Model Status"} + {#if selected === "Key Usage"} + <KeyUsage {keys} {apiBase} /> + {:else if selected === "Model Status"} <ModelStatus {models} {keys} {tags} /> {:else if selected === "Tasks"} <TaskListPanel {tasks} /> diff --git a/packages/frontend/src/lib/types.ts b/packages/frontend/src/lib/types.ts index 9c83eac..e7ff4ba 100644 --- a/packages/frontend/src/lib/types.ts +++ b/packages/frontend/src/lib/types.ts @@ -108,3 +108,35 @@ export interface LogEntry { timestamp: string; description: string; } + +export interface UsageBucket { + utilization?: number; + resetsAt?: number; +} + +export interface ClaudeUsageData { + provider: "anthropic"; + fiveHour?: UsageBucket; + sevenDay?: UsageBucket; +} + +export interface OpencodeUsageData { + provider: "opencode-go"; + unavailable?: boolean; + consoleUrl?: string; + limits?: { fiveHour?: string; weekly?: string; monthly?: string }; + fiveHour?: UsageBucket; + weekly?: UsageBucket; + monthly?: UsageBucket; +} + +export interface CopilotUsageData { + provider: "github-copilot"; + tokensConsumed?: number; + tokensRemaining?: number; + percentUsed?: number; + resetAt?: number; + plan?: string; +} + +export type KeyUsageData = ClaudeUsageData | OpencodeUsageData | CopilotUsageData; |
