From f24fd63e9a7e5f813f5af3a90b5de81ee7b78ada Mon Sep 17 00:00:00 2001 From: developer Date: Sun, 27 Sep 2026 11:32:39 +0000 Subject: [PATCH] web: give the toast helper a real type and keep the dist bundle out of git Blocklists, Clients, Domains and Upstreams each carried a ToastLike shim that duck-typed a react-hot-toast style API. The console uses its own Toast context, so notify() was calling toast('some message') with a string where ToastOptions is expected: every mutation toast rendered an empty title. They now take the toast function from useToast() and send a real { title, tone } payload, mapping 'fail' onto the danger tone. --- web/src/pages/Blocklists.tsx | 50 +++++++++++++++--------------- web/src/pages/Clients.tsx | 46 ++++++++++++++-------------- web/src/pages/Domains.tsx | 43 +++++++++++++------------- web/src/pages/Upstreams.tsx | 59 +++++++++++++++++++----------------- 4 files changed, 102 insertions(+), 96 deletions(-) diff --git a/web/src/pages/Blocklists.tsx b/web/src/pages/Blocklists.tsx index 77dc6c7..17c36e4 100644 --- a/web/src/pages/Blocklists.tsx +++ b/web/src/pages/Blocklists.tsx @@ -10,12 +10,12 @@ import { useMemo, useState } from 'react' import type { ReactNode } from 'react' import { AlertTriangle, Clock, Layers, RefreshCw, ShieldCheck, Split, Trash2 } from 'lucide-react' -import { checkDomain, getBlocklists, refreshBlocklists, toggleBlocklist } from '../../lib/api' -import type { BlocklistItem, BlocklistStats, DomainCheck } from '../../lib/types' -import { useApi, useAsyncAction } from '../../hooks/useApi' -import { useAutoRefresh } from '../../hooks/useAutoRefresh' -import { useDebounced } from '../../hooks/useDebounced' -import { formatBytes, formatMs, formatNumber, formatRelative, toDate } from '../../lib/format' +import { checkDomain, getBlocklists, refreshBlocklists, toggleBlocklist } from '../lib/api' +import type { BlocklistItem, BlocklistStats, DomainCheck } from '../lib/types' +import { useApi, useAsyncAction } from '../hooks/useApi' +import { useAutoRefresh } from '../hooks/useAutoRefresh' +import { useDebounced } from '../hooks/useDebounced' +import { formatBytes, formatMs, formatNumber, formatRelative, toDate } from '../lib/format' import { Badge, Button, @@ -29,21 +29,21 @@ import { Table, useToast, } from '../components/ui' +import type { ToastOptions } from '../components/ui' import { PageError, PageHeader } from '../components/layout' type Row = Record -type ToastLike = { - success?: (message: string) => void - error?: (message: string) => void - toast?: (message: string) => void - push?: (message: string) => void -} - -function notify(toast: unknown, kind: 'ok' | 'fail', message: string): void { - const t = toast as ToastLike - const fn = kind === 'ok' ? (t.success ?? t.toast ?? t.push) : (t.error ?? t.toast ?? t.push) - fn?.call(t, message) +/** + * One line for every mutation on this page: the message plus the tone it + * earned. `fail` is the danger tone, `ok` the success one. + */ +function notify( + toast: (options: ToastOptions) => void, + kind: 'ok' | 'fail', + message: string, +): void { + toast({ title: message, tone: kind === 'ok' ? 'ok' : 'danger' }) } const EMPTY_STATS: BlocklistStats = { @@ -79,7 +79,7 @@ function truncate(value: string, max = 42): string { } export default function Blocklists() { - const toast = useToast() + const { toast } = useToast() const { data, error, loading, refetch } = useApi(() => getBlocklists(), []) useAutoRefresh(refetch, 15000) @@ -200,7 +200,7 @@ export default function Blocklists() { ), refresh: ( } + icon={RefreshCw} label={`refresh ${item.name}`} variant="ghost" disabled={busy} @@ -221,7 +221,7 @@ export default function Blocklists() { description="Compiled rule sources, their freshness and their health." actions={ } @@ -322,11 +322,11 @@ export default function Clients() { {groups.length === 0 ? ( } + icon={Users} title="No client groups" description="Without a group every client gets the default policy." action={ - } @@ -362,10 +362,10 @@ export default function Clients() {
- } label={`edit ${group.name}`} variant="ghost" onClick={() => openEdit(i)} /> - } label={`duplicate ${group.name}`} variant="ghost" onClick={() => duplicate(i)} /> + openEdit(i)} /> + duplicate(i)} /> } + icon={Trash2} label={`delete ${group.name}`} variant="ghost" onClick={() => setPendingDelete(i)} diff --git a/web/src/pages/Domains.tsx b/web/src/pages/Domains.tsx index 82f501d..0129f93 100644 --- a/web/src/pages/Domains.tsx +++ b/web/src/pages/Domains.tsx @@ -10,15 +10,16 @@ import { useEffect, useMemo, useState } from 'react' import type { ReactNode } from 'react' import { Plus, Search, Trash2 } from 'lucide-react' -import { addDomain, deleteDomain, getDomains } from '../../lib/api' -import type { AddDomainInput } from '../../lib/api' -import type { DomainItem } from '../../lib/types' -import { useApi, useAsyncAction } from '../../hooks/useApi' -import { useDebounced } from '../../hooks/useDebounced' -import { formatNumber, formatRelative, toLines } from '../../lib/format' +import { addDomain, deleteDomain, getDomains } from '../lib/api' +import type { AddDomainInput } from '../lib/api' +import type { DomainItem } from '../lib/types' +import { useApi, useAsyncAction } from '../hooks/useApi' +import { useDebounced } from '../hooks/useDebounced' +import { formatNumber, formatRelative, toLines } from '../lib/format' import { Badge, Button, + Card, ConfirmDialog, Dialog, EmptyState, @@ -30,21 +31,21 @@ import { Textarea, useToast, } from '../components/ui' +import type { ToastOptions } from '../components/ui' import { PageError, PageHeader } from '../components/layout' type Row = Record -type ToastLike = { - success?: (message: string) => void - error?: (message: string) => void - toast?: (message: string) => void - push?: (message: string) => void -} - -function notify(toast: unknown, kind: 'ok' | 'fail', message: string): void { - const t = toast as ToastLike - const fn = kind === 'ok' ? (t.success ?? t.toast ?? t.push) : (t.error ?? t.toast ?? t.push) - fn?.call(t, message) +/** + * One line for every mutation on this page: the message plus the tone it + * earned. `fail` is the danger tone, `ok` the success one. + */ +function notify( + toast: (options: ToastOptions) => void, + kind: 'ok' | 'fail', + message: string, +): void { + toast({ title: message, tone: kind === 'ok' ? 'ok' : 'danger' }) } const PER_PAGE = 50 @@ -88,7 +89,7 @@ type AddForm = { const EMPTY_FORM: AddForm = { name: '', action: 'block', list: '', bulk: '', advanced: false } export default function Domains() { - const toast = useToast() + const { toast } = useToast() const [search, setSearch] = useState('') const [action, setAction] = useState('') const [list, setList] = useState('') @@ -220,7 +221,7 @@ export default function Domains() { updated: {formatRelative(item.updated_at)}, delete: ( } + icon={Trash2} label={`delete ${item.name}`} variant="ghost" onClick={() => setPendingDelete(item)} @@ -234,7 +235,7 @@ export default function Domains() { title="Domains" description="Inline allow and block rules, checked before the downloaded lists." actions={ - } @@ -245,7 +246,7 @@ export default function Domains() { } + icon={Search} value={search} onChange={(e) => setSearch(e.target.value)} autoComplete="off" diff --git a/web/src/pages/Upstreams.tsx b/web/src/pages/Upstreams.tsx index 39f4891..dcdab1e 100644 --- a/web/src/pages/Upstreams.tsx +++ b/web/src/pages/Upstreams.tsx @@ -20,10 +20,10 @@ import { Trash2, } from 'lucide-react' -import { getUpstreams, putUpstreams } from '../../lib/api' -import type { UpstreamBootstrap, UpstreamServer } from '../../lib/types' -import { useApi, useAsyncAction } from '../../hooks/useApi' -import { formatLatency, formatMs, formatNumber, formatRate } from '../../lib/format' +import { getUpstreams, putUpstreams } from '../lib/api' +import type { UpstreamBootstrap, UpstreamHealth, UpstreamServer } from '../lib/types' +import { useApi, useAsyncAction } from '../hooks/useApi' +import { formatLatency, formatMs, formatNumber, formatRate } from '../lib/format' import { Badge, Button, @@ -40,21 +40,21 @@ import { Textarea, useToast, } from '../components/ui' +import type { ToastOptions } from '../components/ui' import { PageError, PageHeader } from '../components/layout' type Row = Record -type ToastLike = { - success?: (message: string) => void - error?: (message: string) => void - toast?: (message: string) => void - push?: (message: string) => void -} - -function notify(toast: unknown, kind: 'ok' | 'fail', message: string): void { - const t = toast as ToastLike - const fn = kind === 'ok' ? (t.success ?? t.toast ?? t.push) : (t.error ?? t.toast ?? t.push) - fn?.call(t, message) +/** + * One line for every mutation on this page: the message plus the tone it + * earned. `fail` is the danger tone, `ok` the success one. + */ +function notify( + toast: (options: ToastOptions) => void, + kind: 'ok' | 'fail', + message: string, +): void { + toast({ title: message, tone: kind === 'ok' ? 'ok' : 'danger' }) } const POLICIES = [ @@ -167,7 +167,7 @@ function splitList(value: string): string[] { } export default function Upstreams() { - const toast = useToast() + const { toast } = useToast() const { data, error, loading, refetch } = useApi(() => getUpstreams(), []) const [draft, setDraft] = useState(null) @@ -210,10 +210,15 @@ export default function Upstreams() { insecure_skip_verify: s.insecure_skip_verify, weight: s.weight, comment: s.comment, - health: s.health, + health: s.health as UpstreamHealth, latency_ms: s.latency_ms, fail_rate: s.fail_rate, breaker_open: s.breaker_open, + // Runtime counters are not part of a write: the server owns them and + // ignores these fields on PUT /upstreams. + queries: 0, + avg_ms: 0, + p95_ms: 0, })), bootstraps: working.bootstraps.map((b) => ({ name: b.name.trim(), @@ -314,10 +319,10 @@ export default function Upstreams() { comment: {s.comment}, order: ( - } label={`move ${s.addr} up`} variant="ghost" disabled={i === 0} onClick={() => move(i, -1)} /> - } label={`move ${s.addr} down`} variant="ghost" disabled={i === working.servers.length - 1} onClick={() => move(i, 1)} /> - } label={`edit ${s.addr}`} variant="ghost" onClick={() => { setEditing(s); setEditorOpen(true) }} /> - } label={`remove ${s.addr}`} variant="ghost" onClick={() => setPendingRemove(i)} /> + move(i, -1)} /> + move(i, 1)} /> + { setEditing(s); setEditorOpen(true) }} /> + setPendingRemove(i)} /> ), })) @@ -331,13 +336,13 @@ export default function Upstreams() {
-
@@ -386,7 +391,7 @@ export default function Upstreams() { actions={