diff options
| author | Adam Malczewski <[email protected]> | 2026-05-21 17:30:08 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-05-21 17:30:08 +0900 |
| commit | d6b208342edf97bafa5b1dcc986b782f9879d141 (patch) | |
| tree | c6d8f9bffa86f78e3b1369b811bef9642df788d0 /packages/frontend/src/lib/components | |
| parent | 1f309ccca20aabbd0ee3fb8fbb3c8192124edd95 (diff) | |
| download | dispatch-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')
3 files changed, 277 insertions, 66 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)} 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<Record<string, CredentialStatus>>({}); + let importingKey = $state<string | null>(null); + let importError = $state<string | null>(null); + let importSuccess = $state<string | null>(null); + + let apiKeyStatus = $state<Record<string, { keyId: string; provider: string; importedAt: number; updatedAt: number }>>({}); + let showKeyModal = $state<string | null>(null); // keyId or null + let keyModalValue = $state(""); + let keyModalError = $state<string | null>(null); + let keyModalSaving = $state(false); + + async function loadCredentialStatus(): Promise<void> { + try { + const res = await fetch(`${apiBase}/models/credentials-status`); + if (!res.ok) return; + const data = await res.json() as { credentials: CredentialStatus[] }; + const map: Record<string, CredentialStatus> = {}; + for (const cred of data.credentials) { + map[cred.keyId] = cred; + } + credentialStatus = map; + } catch { + // ignore + } + } + + async function importCredentials(keyId: string): Promise<void> { + 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<void> { + 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<string, typeof data.keys[0]> = {}; + for (const k of data.keys) { + map[k.keyId] = k; + } + apiKeyStatus = map; + } catch { + // ignore + } + } + + async function saveApiKey(): Promise<void> { + 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; } </script> @@ -96,12 +203,21 @@ </div> {/if} + <!-- Import error/success banners --> + {#if importError} + <div role="alert" class="text-xs text-error/80">{importError}</div> + {/if} + {#if importSuccess} + <div class="text-xs text-success/80">Imported credentials for {importSuccess}</div> + {/if} + <!-- Keys --> {#if keys.length > 0} <div class="flex flex-col gap-1"> <p class="text-xs text-base-content/50 uppercase tracking-wide">API Keys</p> <ul class="flex flex-col gap-1"> {#each keys as key (key.id)} + {@const cred = credentialStatus[key.id]} <li class="flex flex-col gap-0.5 rounded p-1 hover:bg-base-200 transition-colors"> <div class="flex items-center gap-1.5"> <span @@ -126,10 +242,88 @@ {/if} </div> {/if} - </li> - {/each} + <!-- Credential import for anthropic keys --> + {#if key.provider === "anthropic"} + <div class="pl-2 flex items-center gap-1.5 mt-0.5"> + {#if cred} + <span class="badge badge-xs {cred.expired ? 'badge-warning' : 'badge-info'}"> + {cred.expired ? "expired" : "imported"} + </span> + {#if cred.subscriptionType} + <span class="text-xs text-base-content/40">{cred.subscriptionType}</span> + {/if} + {/if} + <button + type="button" + class="btn btn-xs btn-ghost btn-outline" + disabled={importingKey === key.id} + onclick={() => importCredentials(key.id)} + > + {#if importingKey === key.id} + <span class="loading loading-spinner loading-xs"></span> + {:else} + {cred ? "Re-import" : "Import Credentials"} + {/if} + </button> + </div> + {/if} + <!-- API key import for env-based keys (opencode, copilot, etc) --> + {#if key.provider !== "anthropic"} + <div class="pl-2 flex items-center gap-1.5 mt-0.5"> + {#if apiKeyStatus[key.id]} + <span class="badge badge-xs badge-info">imported</span> + {/if} + <button + type="button" + class="btn btn-xs btn-ghost btn-outline" + onclick={() => { showKeyModal = key.id; keyModalValue = ""; keyModalError = null; }} + > + {apiKeyStatus[key.id] ? "Update Key" : "Import Key"} + </button> + </div> + {/if} + </li> + {/each} </ul> </div> {/if} {/if} +<!-- Import Key Modal --> +{#if showKeyModal} + <div class="fixed inset-0 bg-black/50 flex items-center justify-center z-50" role="dialog"> + <div class="bg-base-100 rounded-lg p-4 w-80 flex flex-col gap-3 shadow-xl"> + <h3 class="text-sm font-semibold">Import Key: {showKeyModal}</h3> + <input + type="password" + class="input input-bordered input-sm w-full" + placeholder="Paste API key..." + bind:value={keyModalValue} + /> + {#if keyModalError} + <p class="text-xs text-error">{keyModalError}</p> + {/if} + <div class="flex justify-end gap-2"> + <button + type="button" + class="btn btn-sm btn-ghost" + onclick={() => { showKeyModal = null; keyModalValue = ""; keyModalError = null; }} + > + Cancel + </button> + <button + type="button" + class="btn btn-sm btn-primary" + disabled={!keyModalValue.trim() || keyModalSaving} + onclick={saveApiKey} + > + {#if keyModalSaving} + <span class="loading loading-spinner loading-xs"></span> + {:else} + Save + {/if} + </button> + </div> + </div> + </div> +{/if} </div> 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"} <ClaudeReset {apiBase} /> {:else if panel.selected === "Model Status"} - <ModelStatus {models} {keys} {tags} /> + <ModelStatus {models} {keys} {tags} {apiBase} /> {:else if panel.selected === "Tasks"} <TaskListPanel {tasks} /> {:else if panel.selected === "Config"} |
