summaryrefslogtreecommitdiffhomepage
path: root/packages/frontend/src/lib/components/ModelStatus.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/ModelStatus.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/ModelStatus.svelte')
-rw-r--r--packages/frontend/src/lib/components/ModelStatus.svelte200
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>