diff options
| author | Adam Malczewski <[email protected]> | 2026-06-01 09:28:37 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-06-01 09:28:37 +0900 |
| commit | 786bc4336c9e4619385e9bce105f95727bcbb6ca (patch) | |
| tree | 4b1e32dcf3fa1aaaab855a67396678d80aba2a10 | |
| parent | 21cdb1199599c4dc6e2a941e52713ba6511cd675 (diff) | |
| download | dispatch-786bc4336c9e4619385e9bce105f95727bcbb6ca.tar.gz dispatch-786bc4336c9e4619385e9bce105f95727bcbb6ca.zip | |
feat(frontend): ntfy.sh settings block in SettingsPanel
Adds a 'Notifications (ntfy.sh)' section below 'Backend URL' with:
- Enable toggle (master switch)
- Topic URL field (with security hint: anyone with the URL can read)
- Optional auth token (password input; placeholder reflects whether one
is already stored, and a 'Clear stored token' button surfaces only when
hasAuthToken=true)
- Per-event-type checkboxes driven by the eventTypes catalog returned
from GET /notifications (so adding a new event type in core doesn't
require a frontend change)
- Save + Send test buttons, with inline success/error feedback
The component hand-mirrors the NtfyConfig shape rather than importing it
from @dispatch/core — matching the existing pattern (lib/types.ts mirrors
a few core types) to keep node-only barrels out of the browser bundle.
| -rw-r--r-- | packages/frontend/src/lib/components/SettingsPanel.svelte | 256 |
1 files changed, 256 insertions, 0 deletions
diff --git a/packages/frontend/src/lib/components/SettingsPanel.svelte b/packages/frontend/src/lib/components/SettingsPanel.svelte index 392852a..1d9ebf7 100644 --- a/packages/frontend/src/lib/components/SettingsPanel.svelte +++ b/packages/frontend/src/lib/components/SettingsPanel.svelte @@ -20,6 +20,59 @@ let localChunkLimit = $state(appSettings.chunkLimit); let backendUrl = $state(config.apiBase); let backendUrlSaved = $state(false); +// ─── ntfy.sh push notifications ────────────────────────────────── +// Server-side schema mirror — kept inline rather than imported to avoid +// pulling a node-only barrel into the browser bundle (frontend already +// hand-mirrors a few core types in lib/types.ts for the same reason). +type NotificationEventType = + | "turn-completed" + | "turn-error" + | "permission-required" + | "agent-spawned"; + +interface NtfyConfigView { + enabled: boolean; + topicUrl: string; + authToken: string; + hasAuthToken?: boolean; + events: Record<NotificationEventType, boolean>; +} + +const NTFY_EVENT_LABELS: Record<NotificationEventType, string> = { + "turn-completed": "Turn completed", + "turn-error": "Turn error", + "permission-required": "Permission requested", + "agent-spawned": "User agent spawned", +}; + +const DEFAULT_NTFY: NtfyConfigView = { + enabled: false, + topicUrl: "", + authToken: "", + hasAuthToken: false, + events: { + "turn-completed": true, + "turn-error": true, + "permission-required": true, + "agent-spawned": false, + }, +}; + +let ntfy = $state<NtfyConfigView>({ ...DEFAULT_NTFY, events: { ...DEFAULT_NTFY.events } }); +let ntfyAuthTokenInput = $state(""); // empty == leave unchanged on save +let ntfyEventOrder = $state<NotificationEventType[]>([ + "turn-completed", + "turn-error", + "permission-required", + "agent-spawned", +]); +let ntfySaving = $state(false); +let ntfySaveError = $state<string | null>(null); +let ntfySaveOk = $state(false); +let ntfyTesting = $state(false); +let ntfyTestResult = $state<string | null>(null); +let ntfyTestOk = $state(false); + function onChunkLimitChange(e: Event): void { const input = e.target as HTMLInputElement; const val = parseInt(input.value, 10); @@ -73,6 +126,108 @@ async function loadSettings(): Promise<void> { } catch { // ignore } + await loadNtfy(); +} + +async function loadNtfy(): Promise<void> { + try { + const res = await fetch(`${apiBase}/notifications`); + if (!res.ok) return; + const data = (await res.json()) as { + config: NtfyConfigView; + eventTypes?: NotificationEventType[]; + }; + ntfy = { + ...DEFAULT_NTFY, + ...data.config, + events: { ...DEFAULT_NTFY.events, ...(data.config.events ?? {}) }, + }; + if (Array.isArray(data.eventTypes) && data.eventTypes.length > 0) { + ntfyEventOrder = data.eventTypes; + } + } catch { + // ignore + } +} + +async function saveNtfy(): Promise<void> { + ntfySaving = true; + ntfySaveError = null; + ntfySaveOk = false; + try { + // `authToken: undefined` ⇒ server keeps the existing token. + // `authToken: ""` ⇒ explicit clear (the user typed and cleared). + const payload: Partial<NtfyConfigView> & { authToken?: string } = { + enabled: ntfy.enabled, + topicUrl: ntfy.topicUrl, + events: ntfy.events, + }; + if (ntfyAuthTokenInput !== "") payload.authToken = ntfyAuthTokenInput; + const res = await fetch(`${apiBase}/notifications`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }); + const data = (await res.json()) as { config?: NtfyConfigView; error?: string }; + if (!res.ok) { + ntfySaveError = data.error ?? `Save failed (HTTP ${res.status})`; + return; + } + if (data.config) { + ntfy = { + ...DEFAULT_NTFY, + ...data.config, + events: { ...DEFAULT_NTFY.events, ...(data.config.events ?? {}) }, + }; + } + ntfyAuthTokenInput = ""; + ntfySaveOk = true; + setTimeout(() => { + ntfySaveOk = false; + }, 2000); + } catch (e) { + ntfySaveError = e instanceof Error ? e.message : "Network error"; + } finally { + ntfySaving = false; + } +} + +async function sendNtfyTest(): Promise<void> { + ntfyTesting = true; + ntfyTestResult = null; + ntfyTestOk = false; + try { + const res = await fetch(`${apiBase}/notifications/test`, { method: "POST" }); + const data = (await res.json()) as { ok?: boolean; error?: string; status?: number }; + if (!res.ok || !data.ok) { + ntfyTestResult = data.error ?? `Test failed (HTTP ${res.status})`; + return; + } + ntfyTestOk = true; + ntfyTestResult = "Sent — check your ntfy client."; + } catch (e) { + ntfyTestResult = e instanceof Error ? e.message : "Network error"; + } finally { + ntfyTesting = false; + } +} + +function clearNtfyAuthToken(): void { + // `""` ⇒ explicit clear on save (vs. `undefined` which keeps existing). + ntfyAuthTokenInput = ""; + ntfy = { ...ntfy, hasAuthToken: false }; + // Send a save with explicit empty string to clear server-side. + void (async () => { + try { + await fetch(`${apiBase}/notifications`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ authToken: "" }), + }); + } catch { + // ignore + } + })(); } async function toggleAutoExpand(): Promise<void> { @@ -222,5 +377,106 @@ $effect(() => { {#if backendUrlSaved} <p class="text-xs text-success">Saved. Reload the page to apply.</p> {/if} + + <div class="divider my-0"></div> + + <p class="text-xs text-base-content/70">Notifications (ntfy.sh)</p> + <p class="text-xs text-base-content/40"> + Push notifications to your phone when things happen here. Subscribe to your topic in the + <a href="https://ntfy.sh/" target="_blank" rel="noopener" class="link">ntfy.sh</a> app to receive them. + </p> + + <label class="flex items-center gap-2 cursor-pointer"> + <input + type="checkbox" + class="checkbox checkbox-sm rounded-sm" + bind:checked={ntfy.enabled} + /> + <span class="text-xs text-base-content/70">Enable notifications</span> + </label> + + <label class="text-xs text-base-content/60 flex flex-col gap-1"> + Topic URL + <input + type="text" + class="input input-bordered input-sm w-full" + placeholder="https://ntfy.sh/your-secret-topic" + bind:value={ntfy.topicUrl} + /> + <span class="text-[10px] text-base-content/40"> + Pick something unguessable — anyone with the URL can read your notifications. + </span> + </label> + + <label class="text-xs text-base-content/60 flex flex-col gap-1"> + Auth token (optional, for private ntfy servers) + <input + type="password" + class="input input-bordered input-sm w-full" + placeholder={ntfy.hasAuthToken ? "•••• (stored — type to replace)" : "Leave blank for public ntfy.sh"} + bind:value={ntfyAuthTokenInput} + autocomplete="off" + /> + {#if ntfy.hasAuthToken} + <button + type="button" + class="btn btn-xs btn-ghost btn-outline self-start" + onclick={clearNtfyAuthToken} + > + Clear stored token + </button> + {/if} + </label> + + <div class="flex flex-col gap-1 mt-1"> + <span class="text-xs text-base-content/60">Notify me on:</span> + {#each ntfyEventOrder as evType (evType)} + <label class="flex items-center gap-2 cursor-pointer"> + <input + type="checkbox" + class="checkbox checkbox-sm rounded-sm" + bind:checked={ntfy.events[evType]} + /> + <span class="text-xs text-base-content/70">{NTFY_EVENT_LABELS[evType] ?? evType}</span> + </label> + {/each} + </div> + + <div class="flex gap-1 mt-1"> + <button + type="button" + class="btn btn-sm btn-primary flex-1" + disabled={ntfySaving} + onclick={saveNtfy} + > + {#if ntfySaving} + <span class="loading loading-spinner loading-xs"></span> + {:else} + Save + {/if} + </button> + <button + type="button" + class="btn btn-sm btn-outline" + disabled={ntfyTesting || !ntfy.enabled} + onclick={sendNtfyTest} + title={ntfy.enabled ? "Send a test notification with current settings" : "Enable notifications first"} + > + {#if ntfyTesting} + <span class="loading loading-spinner loading-xs"></span> + {:else} + Send test + {/if} + </button> + </div> + {#if ntfySaveOk} + <p class="text-xs text-success">Saved.</p> + {/if} + {#if ntfySaveError} + <p class="text-xs text-error">{ntfySaveError}</p> + {/if} + {#if ntfyTestResult} + <p class="text-xs {ntfyTestOk ? 'text-success' : 'text-error'}">{ntfyTestResult}</p> + {/if} </div> </div> |
