summaryrefslogtreecommitdiffhomepage
path: root/packages/frontend/src/lib/components
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
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')
-rw-r--r--packages/frontend/src/lib/components/KeyUsage.svelte141
-rw-r--r--packages/frontend/src/lib/components/ModelStatus.svelte200
-rw-r--r--packages/frontend/src/lib/components/SidebarPanel.svelte2
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"}