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/ModelStatus.svelte | 200 ++++++++++++++++++++- 1 file changed, 197 insertions(+), 3 deletions(-) (limited to 'packages/frontend/src/lib/components/ModelStatus.svelte') 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} -- cgit v1.2.3