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/ModelStatus.svelte | |
| 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/ModelStatus.svelte')
| -rw-r--r-- | packages/frontend/src/lib/components/ModelStatus.svelte | 200 |
1 files changed, 197 insertions, 3 deletions
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> |
