From d6b208342edf97bafa5b1dcc986b782f9879d141 Mon Sep 17 00:00:00 2001 From: Adam Malczewski Date: Thu, 21 May 2026 17:30:08 +0900 Subject: feat: SQLite database for all credentials, keys, wake schedule, and usage cache MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .../frontend/src/lib/components/KeyUsage.svelte | 141 ++++++++------- .../frontend/src/lib/components/ModelStatus.svelte | 200 ++++++++++++++++++++- .../src/lib/components/SidebarPanel.svelte | 2 +- 3 files changed, 277 insertions(+), 66 deletions(-) (limited to 'packages/frontend/src/lib/components') 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 { - try { - const raw = localStorage.getItem(CACHE_STORAGE_KEY); - if (raw) { - const parsed = JSON.parse(raw) as Record; - return new Map(Object.entries(parsed)); - } - } catch { - // Ignore parse errors - } - return new Map(); - } - - function persistCache(cache: Map): 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(); 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)}
5-Hour {p}%
- - {#if b.resetsAt} - Resets: {formatDate(b.resetsAt)} - {/if} -
- {/if} - {#if hasBucketData(acct.sevenDay)} +
+ + {#if tp >= 0} +
+ {/if} +
+ {#if b.resetsAt} + Resets: {formatDate(b.resetsAt)} + {/if} + + {/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)}
Weekly {p}%
- - {#if b.resetsAt} - Resets: {formatDate(b.resetsAt)} - {/if} -
- {/if} - - {/each} +
+ + {#if tp >= 0} +
+ {/if} +
+ {#if b.resetsAt} + Resets: {formatDate(b.resetsAt)} + {/if} + + {/if} + + {/each} {/if} {/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)}
5-Hour {p}%
- - {#if b.resetsAt} - Resets: {formatDate(b.resetsAt)} - {/if} -
- {/if} - {#if hasBucketData(entry.data.weekly)} +
+ + {#if tp >= 0} +
+ {/if} +
+ {#if b.resetsAt} + Resets: {formatDate(b.resetsAt)} + {/if} + + {/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)}
Weekly {p}%
- - {#if b.resetsAt} - Resets: {formatDate(b.resetsAt)} - {/if} -
- {/if} - {#if hasBucketData(entry.data.monthly)} +
+ + {#if tp >= 0} +
+ {/if} +
+ {#if b.resetsAt} + Resets: {formatDate(b.resetsAt)} + {/if} + + {/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)}
Monthly {p}%
- - {#if b.resetsAt} - Resets: {formatDate(b.resetsAt)} - {/if} -
- {/if} +
+ + {#if tp >= 0} +
+ {/if} +
+ {#if b.resetsAt} + Resets: {formatDate(b.resetsAt)} + {/if} + {/if} + {/if} {:else if entry.data.provider === "github-copilot"} {@const p = Math.round(entry.data.percentUsed ?? 0)} diff --git a/packages/frontend/src/lib/components/ModelStatus.svelte b/packages/frontend/src/lib/components/ModelStatus.svelte index 34a0563..b2b6902 100644 --- a/packages/frontend/src/lib/components/ModelStatus.svelte +++ b/packages/frontend/src/lib/components/ModelStatus.svelte @@ -13,16 +13,27 @@ tags: string[]; } + interface CredentialStatus { + keyId: string; + provider: string; + subscriptionType: string | null; + importedAt: number; + updatedAt: number; + expired: boolean; + } + const { models = [], keys = [], tags = [], currentModel, + apiBase = "", }: { models?: ModelInfo[]; keys?: KeyInfo[]; tags?: string[]; currentModel?: string; + apiBase?: string; } = $props(); const activeKeys = $derived(keys.filter((k) => k.status === "active").length); @@ -33,6 +44,102 @@ const uniqueTags = $derived([...new Set(tags)]); + let credentialStatus = $state>({}); + let importingKey = $state(null); + let importError = $state(null); + let importSuccess = $state(null); + + let apiKeyStatus = $state>({}); + let showKeyModal = $state(null); // keyId or null + let keyModalValue = $state(""); + let keyModalError = $state(null); + let keyModalSaving = $state(false); + + async function loadCredentialStatus(): Promise { + try { + const res = await fetch(`${apiBase}/models/credentials-status`); + if (!res.ok) return; + const data = await res.json() as { credentials: CredentialStatus[] }; + const map: Record = {}; + for (const cred of data.credentials) { + map[cred.keyId] = cred; + } + credentialStatus = map; + } catch { + // ignore + } + } + + async function importCredentials(keyId: string): Promise { + importingKey = keyId; + importError = null; + importSuccess = null; + try { + const res = await fetch(`${apiBase}/models/import-credentials`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ keyId }), + }); + const data = await res.json() as { success?: boolean; error?: string }; + if (!res.ok || !data.success) { + importError = data.error ?? "Import failed"; + } else { + importSuccess = keyId; + await loadCredentialStatus(); + setTimeout(() => { importSuccess = null; }, 3000); + } + } catch (e) { + importError = e instanceof Error ? e.message : "Network error"; + } finally { + importingKey = null; + } + } + + async function loadApiKeyStatus(): Promise { + try { + const res = await fetch(`${apiBase}/models/api-keys-status`); + if (!res.ok) return; + const data = await res.json() as { keys: Array<{ keyId: string; provider: string; importedAt: number; updatedAt: number }> }; + const map: Record = {}; + for (const k of data.keys) { + map[k.keyId] = k; + } + apiKeyStatus = map; + } catch { + // ignore + } + } + + async function saveApiKey(): Promise { + if (!showKeyModal || !keyModalValue.trim()) return; + keyModalSaving = true; + keyModalError = null; + try { + const res = await fetch(`${apiBase}/models/set-api-key`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ keyId: showKeyModal, apiKey: keyModalValue.trim() }), + }); + const data = await res.json() as { success?: boolean; error?: string }; + if (!res.ok || !data.success) { + keyModalError = data.error ?? "Failed to save"; + } else { + showKeyModal = null; + keyModalValue = ""; + await loadApiKeyStatus(); + } + } catch (e) { + keyModalError = e instanceof Error ? e.message : "Network error"; + } finally { + keyModalSaving = false; + } + } + + $effect(() => { + void loadCredentialStatus(); + void loadApiKeyStatus(); + }); + function timeAgo(ts: number | null): string { if (ts === null) return ""; const diffMs = Date.now() - ts; @@ -46,7 +153,7 @@ function truncate(str: string | null, max: number): string { if (!str) return ""; - return str.length > max ? str.slice(0, max) + "…" : str; + return str.length > max ? str.slice(0, max) + "..." : str; } @@ -96,12 +203,21 @@ {/if} + + {#if importError} + + {/if} + {#if importSuccess} +
Imported credentials for {importSuccess}
+ {/if} + {#if keys.length > 0}

API Keys

    {#each keys as key (key.id)} + {@const cred = credentialStatus[key.id]}
  • {/if} -
  • - {/each} + + {#if key.provider === "anthropic"} +
    + {#if cred} + + {cred.expired ? "expired" : "imported"} + + {#if cred.subscriptionType} + {cred.subscriptionType} + {/if} + {/if} + +
    + {/if} + + {#if key.provider !== "anthropic"} +
    + {#if apiKeyStatus[key.id]} + imported + {/if} + +
    + {/if} + + {/each}
{/if} {/if} + +{#if showKeyModal} + +{/if} diff --git a/packages/frontend/src/lib/components/SidebarPanel.svelte b/packages/frontend/src/lib/components/SidebarPanel.svelte index 6edbdbf..79eb73b 100644 --- a/packages/frontend/src/lib/components/SidebarPanel.svelte +++ b/packages/frontend/src/lib/components/SidebarPanel.svelte @@ -109,7 +109,7 @@ {:else if panel.selected === "Claude Reset"} {:else if panel.selected === "Model Status"} - + {:else if panel.selected === "Tasks"} {:else if panel.selected === "Config"} -- cgit v1.2.3