summaryrefslogtreecommitdiffhomepage
path: root/packages/frontend/src/lib/components/KeyUsage.svelte
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-05-21 17:30:08 +0900
committerAdam Malczewski <[email protected]>2026-05-21 17:30:08 +0900
commitd6b208342edf97bafa5b1dcc986b782f9879d141 (patch)
treec6d8f9bffa86f78e3b1369b811bef9642df788d0 /packages/frontend/src/lib/components/KeyUsage.svelte
parent1f309ccca20aabbd0ee3fb8fbb3c8192124edd95 (diff)
downloaddispatch-d6b208342edf97bafa5b1dcc986b782f9879d141.tar.gz
dispatch-d6b208342edf97bafa5b1dcc986b782f9879d141.zip
feat: SQLite database for all credentials, keys, wake schedule, and usage cache
- Add SQLite database at ~/.local/share/dispatch/dispatch.db with tables: credentials, api_keys, wake_schedule, usage_cache - Store Claude OAuth credentials in DB with import button in Model Status UI - Store OpenCode/Copilot API keys in DB with paste-to-import modal - Store OpenCode cookie and workspace IDs in DB - Migrate wake schedule from .wake-schedule.json to DB - Migrate usage cache from in-memory Map + localStorage to DB - Remove all env var and file fallbacks — DB is the single source of truth - Add seed scripts: bin/import-credentials.ts, bin/seed-opencode-keys.ts - Docker: container runs as host UID/GID with matching home directory - Clean up dispatch.toml: remove env fields, update comments - Progress bar time markers for usage cycle tracking
Diffstat (limited to 'packages/frontend/src/lib/components/KeyUsage.svelte')
-rw-r--r--packages/frontend/src/lib/components/KeyUsage.svelte141
1 files changed, 79 insertions, 62 deletions
diff --git a/packages/frontend/src/lib/components/KeyUsage.svelte b/packages/frontend/src/lib/components/KeyUsage.svelte
index a2b735e..f5f7d6d 100644
--- a/packages/frontend/src/lib/components/KeyUsage.svelte
+++ b/packages/frontend/src/lib/components/KeyUsage.svelte
@@ -17,32 +17,8 @@
loading: boolean;
}
- // localStorage-backed cache: survives page refreshes
- const CACHE_STORAGE_KEY = "dispatch-key-usage-cache";
-
- function loadPersistedCache(): Map<string, KeyUsageData> {
- try {
- const raw = localStorage.getItem(CACHE_STORAGE_KEY);
- if (raw) {
- const parsed = JSON.parse(raw) as Record<string, KeyUsageData>;
- return new Map(Object.entries(parsed));
- }
- } catch {
- // Ignore parse errors
- }
- return new Map();
- }
-
- function persistCache(cache: Map<string, KeyUsageData>): void {
- try {
- const obj = Object.fromEntries(cache.entries());
- localStorage.setItem(CACHE_STORAGE_KEY, JSON.stringify(obj));
- } catch {
- // Ignore storage errors (e.g. quota exceeded)
- }
- }
-
- const usageCache = loadPersistedCache();
+ // 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) => {
@@ -73,7 +49,6 @@
} else {
const fresh = await res.json() as KeyUsageData;
usageCache.set(key.id, fresh);
- persistCache(usageCache);
updateEntry(key.id, {
data: fresh,
error: null,
@@ -206,6 +181,18 @@
}).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;
}
@@ -255,34 +242,46 @@
{@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>
- <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)}
+ <div class="relative w-full h-2">
+ <progress class="progress w-full h-2 {progressClass(u)} 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>
- <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}
+ <div class="relative w-full h-2">
+ <progress class="progress w-full h-2 {progressClass(u)} 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}
@@ -323,48 +322,66 @@
{@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>
- <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.weekly)}
+ <div class="relative w-full h-2">
+ <progress class="progress w-full h-2 {progressClass(u)} 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>
- <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)}
+ <div class="relative w-full h-2">
+ <progress class="progress w-full h-2 {progressClass(u)} 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>
- <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 class="relative w-full h-2">
+ <progress class="progress w-full h-2 {progressClass(u)} 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)}