summaryrefslogtreecommitdiffhomepage
path: root/packages/frontend/src/lib
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-05-21 15:44:00 +0900
committerAdam Malczewski <[email protected]>2026-05-21 15:44:00 +0900
commit1f309ccca20aabbd0ee3fb8fbb3c8192124edd95 (patch)
tree57aec6c0d039760aa37fab10e83e1cea7a23081e /packages/frontend/src/lib
parentc957e89e3ec46f1db64dcb1416f5ade7fb6e617e (diff)
downloaddispatch-1f309ccca20aabbd0ee3fb8fbb3c8192124edd95.tar.gz
dispatch-1f309ccca20aabbd0ee3fb8fbb3c8192124edd95.zip
fix: wake scheduler persistence/retry, credential filtering, usage cache and display names
- Wake scheduler: fix Bun timer leak, make recurring daily, persist to disk, retry failed wakes every 5min for 30min, start at boot - Key usage: localStorage cache survives page refresh, spinner during all refreshes, show cached data immediately - Credential filtering: key-usage and wake only use configured credentials_file, exclude unconfigured accounts - Display: remove counter suffix from Claude labels, format opencode/copilot key names
Diffstat (limited to 'packages/frontend/src/lib')
-rw-r--r--packages/frontend/src/lib/components/KeyUsage.svelte116
-rw-r--r--packages/frontend/src/lib/components/SidebarPanel.svelte15
2 files changed, 97 insertions, 34 deletions
diff --git a/packages/frontend/src/lib/components/KeyUsage.svelte b/packages/frontend/src/lib/components/KeyUsage.svelte
index 5e86f36..a2b735e 100644
--- a/packages/frontend/src/lib/components/KeyUsage.svelte
+++ b/packages/frontend/src/lib/components/KeyUsage.svelte
@@ -17,8 +17,45 @@
loading: boolean;
}
- // Module-level cache survives remounts during the same page session
- const usageCache = new Map<string, KeyUsageData>();
+ // 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();
+
+ function buildEntries(keyList: KeyInfo[]): KeyUsageEntry[] {
+ return keyList.map((k) => {
+ const cached = usageCache.get(k.id);
+ return {
+ keyId: k.id,
+ provider: k.provider,
+ data: cached ?? null,
+ error: null,
+ loading: true, // always show spinner during refresh
+ };
+ });
+ }
let entries = $state<KeyUsageEntry[]>([]);
@@ -36,6 +73,7 @@
} else {
const fresh = await res.json() as KeyUsageData;
usageCache.set(key.id, fresh);
+ persistCache(usageCache);
updateEntry(key.id, {
data: fresh,
error: null,
@@ -59,26 +97,23 @@
);
}
+ // Sync entries with keys reactively — runs before DOM update so
+ // cached data renders on first paint without a flash of empty state.
+ $effect.pre(() => {
+ entries = buildEntries(keys);
+ });
+
+ // Fetch data and set up 90s auto-refresh
$effect(() => {
- // Show cached data immediately if available, then refresh in background
- entries = keys.map((k) => {
- const cached = usageCache.get(k.id);
- return {
- keyId: k.id,
- provider: k.provider,
- data: cached ?? null,
- error: null,
- loading: true,
- };
- });
+ const currentKeys = keys;
// Fire all fetches in parallel
- for (const key of keys) {
+ for (const key of currentKeys) {
fetchOne(key);
}
// Refresh every 90s
const interval = setInterval(() => {
- for (const key of keys) {
+ for (const key of currentKeys) {
updateEntry(key.id, { loading: true });
fetchOne(key);
}
@@ -115,6 +150,9 @@
const claudeLoading = $derived(
entries.some((e) => e.provider === "anthropic" && e.loading),
);
+ const claudeError = $derived(
+ entries.find((e) => e.provider === "anthropic" && e.error)?.error ?? null,
+ );
const nonClaudeEntries = $derived(
entries.filter((e) => e.provider !== "anthropic"),
@@ -160,6 +198,14 @@
});
}
+ function formatKeyId(id: string): string {
+ if (/^github-copilot/i.test(id)) return "Copilot";
+ return id.split("-").map((part) => {
+ if (part.toLowerCase() === "opencode") return "OpenCode";
+ return part.charAt(0).toUpperCase() + part.slice(1);
+ }).join(" ");
+ }
+
function hasBucketData(bucket: UsageBucket | undefined): boolean {
return bucket !== undefined && bucket.utilization !== undefined;
}
@@ -171,7 +217,7 @@
{:else}
<div class="flex flex-col gap-3 flex-1 min-h-0 overflow-y-auto">
<!-- Claude (all accounts merged under one card) -->
- {#if claudeAccounts.length > 0 || claudeLoading}
+ {#if claudeAccounts.length > 0 || claudeLoading || claudeError}
<div class="bg-base-200 rounded-lg p-2">
<div class="flex items-center gap-1.5 mb-1.5">
<span class="text-xs font-semibold">Claude</span>
@@ -181,12 +227,17 @@
{/if}
</div>
- {#if claudeAccounts.length === 0 && claudeLoading}
- <div class="flex items-center gap-1.5 py-1">
- <span class="text-xs text-base-content/50">Loading...</span>
- </div>
- {:else}
- {#each claudeAccounts as acct, idx (acct.source)}
+ {#if claudeAccounts.length === 0 && claudeLoading}
+ <div class="flex items-center gap-1.5 py-1">
+ <span class="text-xs text-base-content/50">Loading...</span>
+ </div>
+ {:else if claudeAccounts.length === 0 && claudeError}
+ <div role="alert" class="text-xs text-error/80">{claudeError}</div>
+ {:else}
+ {#if claudeError}
+ <div role="alert" class="text-xs text-error/80 mb-1">{claudeError}</div>
+ {/if}
+ {#each claudeAccounts as acct, idx (acct.source)}
{#if idx > 0}
<div class="border-t border-base-300 my-1.5"></div>
{/if}
@@ -240,26 +291,26 @@
{#each nonClaudeEntries as entry (entry.keyId)}
<div class="bg-base-200 rounded-lg p-2">
<div class="flex items-center gap-1.5 mb-1.5">
- <span class="text-xs font-semibold">{entry.keyId}</span>
+ <span class="text-xs font-semibold">{formatKeyId(entry.keyId)}</span>
<span class="badge badge-xs badge-ghost">{entry.provider}</span>
{#if entry.loading}
<span class="loading loading-spinner loading-xs"></span>
{/if}
</div>
- {#if entry.loading}
+ {#if entry.loading && !entry.data}
<div class="flex items-center gap-1.5 py-1">
<span class="loading loading-spinner loading-xs"></span>
<span class="text-xs text-base-content/50">Loading...</span>
</div>
+ {:else}
+ {#if entry.error}
+ <div role="alert" class="text-xs text-error/80 mb-1">{entry.error}</div>
+ {/if}
+ {#if !entry.data}
+ <p class="text-xs text-base-content/50">No data.</p>
- {:else if entry.error}
- <div role="alert" class="text-xs text-error/80">{entry.error}</div>
-
- {:else if !entry.data}
- <p class="text-xs text-base-content/50">No data.</p>
-
- {:else if entry.data.provider === "opencode-go"}
+ {:else if entry.data.provider === "opencode-go"}
{#if entry.data.unavailable}
<p class="text-xs text-base-content/70">Check console for usage.</p>
{#if entry.data.consoleUrl}
@@ -335,7 +386,8 @@
<span class="text-xs text-base-content/40">Resets: {formatDate(entry.data.resetAt)}</span>
{/if}
</div>
- {/if}
+ {/if}
+ {/if}
</div>
{/each}
</div>
diff --git a/packages/frontend/src/lib/components/SidebarPanel.svelte b/packages/frontend/src/lib/components/SidebarPanel.svelte
index aebc74a..6edbdbf 100644
--- a/packages/frontend/src/lib/components/SidebarPanel.svelte
+++ b/packages/frontend/src/lib/components/SidebarPanel.svelte
@@ -50,11 +50,22 @@
function addPanel() {
panels = [...panels, { id: nextId++, selected: "Select a view" }];
}
+
+ function panelClass(selected: string): string {
+ const base = "bg-base-200 rounded-lg p-3 flex flex-col min-h-0";
+ const fill = selected === "Key Usage" || selected === "Claude Reset" || selected === "Tasks";
+ return fill ? base + " flex-1" : base;
+ }
+
+ function contentClass(selected: string): string {
+ const fill = selected === "Key Usage" || selected === "Claude Reset" || selected === "Tasks";
+ return fill ? "mt-2 flex-1 min-h-0" : "mt-2";
+ }
</script>
<div class="flex flex-col gap-2">
{#each panels as panel, idx (panel.id)}
- <div class="bg-base-200 rounded-lg p-3 flex flex-col">
+ <div class={panelClass(panel.selected)}>
<div class="flex items-center gap-1">
<select
class="select select-bordered select-sm flex-1"
@@ -82,7 +93,7 @@
{/if}
</div>
- <div class="mt-2">
+ <div class={contentClass(panel.selected)}>
{#if panel.selected === "Current Model"}
<ModelSelector
{keys}