summaryrefslogtreecommitdiffhomepage
path: root/packages/frontend/src/lib
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-05-20 22:32:41 +0900
committerAdam Malczewski <[email protected]>2026-05-20 22:32:41 +0900
commitc0c55ed7c03188f264ff2c27b9982f2d57d092cc (patch)
tree04b871b83e6e1481c8b24b59aa3acc0e15b44f02 /packages/frontend/src/lib
parent8151447758e6826a578363758a755c6cebd1c05f (diff)
downloaddispatch-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.svelte282
-rw-r--r--packages/frontend/src/lib/components/SidebarPanel.svelte7
-rw-r--r--packages/frontend/src/lib/types.ts32
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;