summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-06-01 09:28:37 +0900
committerAdam Malczewski <[email protected]>2026-06-01 09:28:37 +0900
commit786bc4336c9e4619385e9bce105f95727bcbb6ca (patch)
tree4b1e32dcf3fa1aaaab855a67396678d80aba2a10
parent21cdb1199599c4dc6e2a941e52713ba6511cd675 (diff)
downloaddispatch-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.svelte256
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>