diff options
| author | Adam Malczewski <[email protected]> | 2026-06-04 21:21:20 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-06-04 21:21:20 +0900 |
| commit | 394f1ed37ce860da6fdc385769bf29f9737105cd (patch) | |
| tree | 4b825dc642cb6eb9a060e54bf8d69288fbee4904 /packages/frontend/src/lib/components/KeyUsage.svelte | |
| parent | 81a9cdbadf8c9d940d4fe9a2a0de607dee1f5f1a (diff) | |
| download | dispatch-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.svelte | 477 |
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 · {entry.data.limits.weekly}/wk · {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> |
