summaryrefslogtreecommitdiffhomepage
path: root/packages/frontend/src/lib/components/KeyUsage.svelte
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-06-04 21:21:20 +0900
committerAdam Malczewski <[email protected]>2026-06-04 21:21:20 +0900
commit394f1ed37ce860da6fdc385769bf29f9737105cd (patch)
tree4b825dc642cb6eb9a060e54bf8d69288fbee4904 /packages/frontend/src/lib/components/KeyUsage.svelte
parent81a9cdbadf8c9d940d4fe9a2a0de607dee1f5f1a (diff)
downloaddispatch-394f1ed37ce860da6fdc385769bf29f9737105cd.tar.gz
dispatch-394f1ed37ce860da6fdc385769bf29f9737105cd.zip
chore: genesis — remove all files to rebuild from scratch (arch rewrite)
Diffstat (limited to 'packages/frontend/src/lib/components/KeyUsage.svelte')
-rw-r--r--packages/frontend/src/lib/components/KeyUsage.svelte477
1 files changed, 0 insertions, 477 deletions
diff --git a/packages/frontend/src/lib/components/KeyUsage.svelte b/packages/frontend/src/lib/components/KeyUsage.svelte
deleted file mode 100644
index 7c0cadc..0000000
--- a/packages/frontend/src/lib/components/KeyUsage.svelte
+++ /dev/null
@@ -1,477 +0,0 @@
-<script lang="ts">
-import type { KeyInfo, KeyUsageData, UsageBucket } from "../types.js";
-
-const {
- keys = [],
- apiBase = "",
-}: {
- keys?: KeyInfo[];
- apiBase?: string;
-} = $props();
-
-interface KeyUsageEntry {
- keyId: string;
- provider: string;
- data: KeyUsageData | null;
- error: string | null;
- loading: boolean;
-}
-
-// In-memory cache for the current session (backend DB is the persistent cache)
-const usageCache = new Map<string, KeyUsageData>();
-
-function buildEntries(keyList: KeyInfo[]): KeyUsageEntry[] {
- return keyList.map((k) => {
- const cached = usageCache.get(k.id);
- return {
- keyId: k.id,
- provider: k.provider,
- data: cached ?? null,
- error: null,
- loading: true, // always show spinner during refresh
- };
- });
-}
-
-let entries = $state<KeyUsageEntry[]>([]);
-
-async function fetchOne(key: KeyInfo) {
- try {
- const res = await fetch(`${apiBase}/models/key-usage?keyId=${encodeURIComponent(key.id)}`);
- if (!res.ok) {
- const data = await res.json().catch(() => ({}));
- updateEntry(key.id, {
- error: data.error ?? `HTTP ${res.status}`,
- loading: false,
- });
- } else {
- const fresh = (await res.json()) as KeyUsageData;
- usageCache.set(key.id, fresh);
- updateEntry(key.id, {
- data: fresh,
- error: null,
- loading: false,
- });
- }
- } catch (e) {
- updateEntry(key.id, {
- error: e instanceof Error ? e.message : "Failed to fetch",
- loading: false,
- });
- }
-}
-
-function updateEntry(
- keyId: string,
- patch: { data?: KeyUsageData | null; error?: string | null; loading?: boolean },
-) {
- entries = entries.map((e) => (e.keyId === keyId ? { ...e, ...patch } : e));
-}
-
-// Sync entries with keys reactively — runs before DOM update so
-// cached data renders on first paint without a flash of empty state.
-$effect.pre(() => {
- entries = buildEntries(keys);
-});
-
-// Fetch data and set up 90s auto-refresh
-$effect(() => {
- const currentKeys = keys;
- // Fire all fetches in parallel
- for (const key of currentKeys) {
- fetchOne(key);
- }
-
- // Refresh every 90s
- const interval = setInterval(() => {
- for (const key of currentKeys) {
- updateEntry(key.id, { loading: true });
- fetchOne(key);
- }
- }, 90_000);
-
- return () => clearInterval(interval);
-});
-
-// Merge duplicate Claude entries — all anthropic keys return the same
-// set of accounts, so collect and deduplicate under one "Claude" card.
-const claudeAccounts = $derived.by(() => {
- const seen = new Set<string>();
- const accounts: Array<{
- label: string;
- source: string;
- subscriptionType?: string;
- fiveHour?: UsageBucket;
- sevenDay?: UsageBucket;
- error?: string;
- }> = [];
- const claudeEntries = entries.filter((e) => e.provider === "anthropic");
- for (const e of claudeEntries) {
- if (!e.data || e.data.provider !== "anthropic" || !e.data.accounts) continue;
- for (const acct of e.data.accounts) {
- if (!seen.has(acct.source)) {
- seen.add(acct.source);
- accounts.push(acct);
- }
- }
- }
- return accounts;
-});
-
-const claudeLoading = $derived(entries.some((e) => e.provider === "anthropic" && e.loading));
-const claudeError = $derived(
- entries.find((e) => e.provider === "anthropic" && e.error)?.error ?? null,
-);
-
-const nonClaudeEntries = $derived(entries.filter((e) => e.provider !== "anthropic"));
-
-function progressClass(utilization: number): string {
- if (utilization > 0.8) return "progress-error";
- if (utilization >= 0.5) return "progress-warning";
- return "progress-success";
-}
-
-// Pace-aware coloring for cycle bars that show a "time dot" (elapsed % of the
-// reset window). Red once usage hits 90%, otherwise green when usage is at or
-// behind the dot and orange when it has run ahead of it. Falls back to the
-// plain threshold coloring when no dot is present (elapsedPct < 0).
-function pacedProgressClass(percentUsed: number, elapsedPct: number): string {
- if (percentUsed >= 90) return "progress-error";
- if (elapsedPct < 0) return progressClass(percentUsed / 100);
- if (percentUsed <= elapsedPct) return "progress-success";
- return "progress-warning";
-}
-
-function formatDate(ts: number): string {
- const diff = ts - Date.now();
- const days = Math.floor(diff / 86400000);
- const d = new Date(ts);
- const dateStr =
- d.toLocaleDateString("en-US", { month: "2-digit", day: "2-digit" }) +
- " " +
- d.toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit", hour12: true });
-
- if (diff <= 0) {
- return d.toLocaleString();
- }
- if (diff < 48 * 60 * 60 * 1000) {
- const hours = Math.floor(diff / 3600000);
- const minutes = Math.floor((diff % 3600000) / 60000);
- return `in ${hours}:${String(minutes).padStart(2, "0")}`;
- }
- if (days <= 30) {
- const weeks = Math.floor(days / 7);
- const remDays = days % 7;
- if (weeks > 0 && remDays > 0) {
- return `in ${weeks}w ${remDays}d (${dateStr})`;
- }
- if (weeks > 0) {
- return `in ${weeks} week${weeks > 1 ? "s" : ""} (${dateStr})`;
- }
- return `in ${days} day${days > 1 ? "s" : ""} (${dateStr})`;
- }
- return d.toLocaleDateString("en-US", {
- month: "2-digit",
- day: "2-digit",
- year: "numeric",
- });
-}
-
-function formatKeyId(id: string): string {
- if (/^github-copilot/i.test(id)) return "Copilot";
- return id
- .split("-")
- .map((part) => {
- if (part.toLowerCase() === "opencode") return "OpenCode";
- return part.charAt(0).toUpperCase() + part.slice(1);
- })
- .join(" ");
-}
-
-// Cycle durations in ms
-const FIVE_HOUR_MS = 5 * 60 * 60 * 1000;
-const SEVEN_DAY_MS = 7 * 24 * 60 * 60 * 1000;
-const THIRTY_DAY_MS = 30 * 24 * 60 * 60 * 1000;
-
-function cycleElapsedPct(resetsAt: number | undefined, cycleDurationMs: number): number {
- if (!resetsAt) return -1;
- const timeRemaining = resetsAt - Date.now();
- const elapsed = cycleDurationMs - timeRemaining;
- return Math.max(0, Math.min(100, Math.round((elapsed / cycleDurationMs) * 100)));
-}
-
-function hasBucketData(bucket: UsageBucket | undefined): boolean {
- return bucket !== undefined && bucket.utilization !== undefined;
-}
-</script>
-
-<div class="flex flex-col gap-3 flex-1 min-h-0">
- {#if keys.length === 0}
- <p class="text-xs text-base-content/50">No keys available.</p>
- {:else}
- <div class="flex flex-col gap-3 flex-1 min-h-0 overflow-y-auto">
- <!-- Claude (all accounts merged under one card) -->
- {#if claudeAccounts.length > 0 || claudeLoading || claudeError}
- <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">Claude</span>
- <span class="badge badge-xs badge-ghost">anthropic</span>
- {#if claudeLoading}
- <span class="loading loading-spinner loading-xs"></span>
- {/if}
- </div>
-
- {#if claudeAccounts.length === 0 && claudeLoading}
- <div class="flex items-center gap-1.5 py-1">
- <span class="text-xs text-base-content/50">Loading...</span>
- </div>
- {:else if claudeAccounts.length === 0 && claudeError}
- <div role="alert" class="text-xs text-error/80">{claudeError}</div>
- {:else}
- {#if claudeError}
- <div role="alert" class="text-xs text-error/80 mb-1">{claudeError}</div>
- {/if}
- {#each claudeAccounts as acct, idx (acct.source)}
- {#if idx > 0}
- <div class="border-t border-base-300 my-1.5"></div>
- {/if}
- <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)}
- {@const tp = cycleElapsedPct(b.resetsAt, FIVE_HOUR_MS)}
- <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>
- <div class="relative w-full h-2">
- <progress class="progress w-full h-2 {pacedProgressClass(p, tp)} absolute inset-0" value={p} max="100"></progress>
- {#if tp >= 0}
- <div class="absolute top-1/2 -translate-y-1/2 -translate-x-1/2 w-2 h-2 rounded-full border border-info bg-info-content pointer-events-none box-border" style="left: {tp}%"></div>
- {/if}
- </div>
- {#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)}
- {@const tp = cycleElapsedPct(b.resetsAt, SEVEN_DAY_MS)}
- <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>
- <div class="relative w-full h-2">
- <progress class="progress w-full h-2 {pacedProgressClass(p, tp)} absolute inset-0" value={p} max="100"></progress>
- {#if tp >= 0}
- <div class="absolute top-1/2 -translate-y-1/2 -translate-x-1/2 w-2 h-2 rounded-full border border-info bg-info-content pointer-events-none box-border" style="left: {tp}%"></div>
- {/if}
- </div>
- {#if b.resetsAt}
- <span class="text-xs text-base-content/40">Resets: {formatDate(b.resetsAt)}</span>
- {/if}
- </div>
- {/if}
- </div>
- {/each}
- {/if}
- </div>
- {/if}
-
- <!-- Non-Claude keys -->
- {#each nonClaudeEntries 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">{formatKeyId(entry.keyId)}</span>
- <span class="badge badge-xs badge-ghost">{entry.provider}</span>
- {#if entry.loading}
- <span class="loading loading-spinner loading-xs"></span>
- {/if}
- </div>
-
- {#if entry.loading && !entry.data}
- <div class="flex items-center gap-1.5 py-1">
- <span class="loading loading-spinner loading-xs"></span>
- <span class="text-xs text-base-content/50">Loading...</span>
- </div>
- {:else}
- {#if entry.error}
- <div role="alert" class="text-xs text-error/80 mb-1">{entry.error}</div>
- {/if}
- {#if !entry.data}
- <p class="text-xs text-base-content/50">No data.</p>
-
- {:else if entry.data.provider === "opencode-go"}
- {#if entry.data.unavailable}
- <p class="text-xs text-base-content/70">Usage data not available. Set OPENCODE_COOKIE env var to enable.</p>
- {#if entry.data.limits}
- <div class="text-xs text-base-content/50 mt-1">
- Limits: {entry.data.limits.fiveHour}/5h &middot; {entry.data.limits.weekly}/wk &middot; {entry.data.limits.monthly}/mo
- </div>
- {/if}
- {#if entry.data.consoleUrl}
- <a href={entry.data.consoleUrl} target="_blank" rel="noopener noreferrer" class="link link-primary text-xs mt-1">
- View usage on opencode.ai
- </a>
- {/if}
- {:else}
- {#if hasBucketData(entry.data.fiveHour)}
- {@const b = entry.data.fiveHour!}
- {@const u = b.utilization ?? 0}
- {@const p = Math.round(u * 100)}
- {@const tp = cycleElapsedPct(b.resetsAt, FIVE_HOUR_MS)}
- <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>
- <div class="relative w-full h-2">
- <progress class="progress w-full h-2 {pacedProgressClass(p, tp)} absolute inset-0" value={p} max="100"></progress>
- {#if tp >= 0}
- <div class="absolute top-1/2 -translate-y-1/2 -translate-x-1/2 w-2 h-2 rounded-full border border-info bg-info-content pointer-events-none box-border" style="left: {tp}%"></div>
- {/if}
- </div>
- {#if b.resetsAt}
- <span class="text-xs text-base-content/40">Resets: {formatDate(b.resetsAt)}</span>
- {/if}
- </div>
- {/if}
- {#if hasBucketData(entry.data.weekly)}
- {@const b = entry.data.weekly!}
- {@const u = b.utilization ?? 0}
- {@const p = Math.round(u * 100)}
- {@const tp = cycleElapsedPct(b.resetsAt, SEVEN_DAY_MS)}
- <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>
- <div class="relative w-full h-2">
- <progress class="progress w-full h-2 {pacedProgressClass(p, tp)} absolute inset-0" value={p} max="100"></progress>
- {#if tp >= 0}
- <div class="absolute top-1/2 -translate-y-1/2 -translate-x-1/2 w-2 h-2 rounded-full border border-info bg-info-content pointer-events-none box-border" style="left: {tp}%"></div>
- {/if}
- </div>
- {#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)}
- {@const tp = cycleElapsedPct(b.resetsAt, THIRTY_DAY_MS)}
- <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>
- <div class="relative w-full h-2">
- <progress class="progress w-full h-2 {pacedProgressClass(p, tp)} absolute inset-0" value={p} max="100"></progress>
- {#if tp >= 0}
- <div class="absolute top-1/2 -translate-y-1/2 -translate-x-1/2 w-2 h-2 rounded-full border border-info bg-info-content pointer-events-none box-border" style="left: {tp}%"></div>
- {/if}
- </div>
- {#if b.resetsAt}
- <span class="text-xs text-base-content/40">Resets: {formatDate(b.resetsAt)}</span>
- {/if}
- </div>
- {/if}
- {/if}
-
- {: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 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>
- {:else if entry.data.provider === "google"}
- <div class="flex flex-col gap-0.5 pl-1">
- <!-- Cookie-scraped usage from gemini.google.com -->
- {#if entry.data.currentUsage}
- {@const u = entry.data.currentUsage}
- <div class="flex flex-col gap-0.5">
- <div class="flex items-center justify-between">
- <span class="text-xs text-base-content/50">Current</span>
- <span class="text-xs font-mono">{u.percentUsed}%</span>
- </div>
- <progress class="progress w-full h-2 {progressClass(u.percentUsed / 100)}" value={u.percentUsed} max="100"></progress>
- {#if u.resetsAt}
- <span class="text-xs text-base-content/40">Resets: {u.resetsAt}</span>
- {/if}
- </div>
- {/if}
- {#if entry.data.weeklyUsage}
- {@const w = entry.data.weeklyUsage}
- <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">{w.percentUsed}%</span>
- </div>
- <progress class="progress w-full h-2 {progressClass(w.percentUsed / 100)}" value={w.percentUsed} max="100"></progress>
- {#if w.resetsAt}
- <span class="text-xs text-base-content/40">Resets: {w.resetsAt}</span>
- {/if}
- </div>
- {/if}
- <!-- API key rate limits -->
- {#if !entry.data.currentUsage && entry.data.models && entry.data.models.length > 0}
- {@const m = entry.data.models[0]}
- <div class="flex items-center justify-between">
- <span class="text-xs text-base-content/50">Models</span>
- <span class="text-xs font-mono">{entry.data.models.length} available</span>
- </div>
- {#if m && m.rpm > 0}
- <div class="flex items-center justify-between">
- <span class="text-xs text-base-content/50">RPM</span>
- <span class="text-xs font-mono">{m.rpm}</span>
- </div>
- {/if}
- {#if m && m.requestsPerDay > 0}
- <div class="flex items-center justify-between">
- <span class="text-xs text-base-content/50">RPD</span>
- <span class="text-xs font-mono">{m.requestsPerDay.toLocaleString()}</span>
- </div>
- {/if}
- {#if !entry.data.currentUsage}
- <p class="text-xs text-base-content/40 mt-0.5">Set GEMINI_COOKIE (__Secure-1PSID) for usage %</p>
- {/if}
- {/if}
- </div>
- {/if}
- {/if}
- </div>
- {/each}
- </div>
- {/if}
-</div>