From a63301c7a397c18d0c6ae377b214be1db98a97d0 Mon Sep 17 00:00:00 2001 From: anti Date: Sun, 19 Apr 2026 20:16:51 -0400 Subject: [PATCH] fix(web): replace window.confirm with two-click arm/commit on swarm actions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Teardown and Decommission buttons were silently dead in the browser. Root cause: every handler started with 'if (!window.confirm(...)) return;' and browsers permanently disable confirm() for a tab once the user ticks 'Prevent this page from creating additional dialogs'. That returns false with no UI, the handler early-exits, and no request is ever fired — no network traffic, no console error, no backend activity. Swap to an inline two-click pattern: first click arms the button (label flips to 'Click again to confirm', resets after 4s); second click within the window commits. Same safety against misclicks, zero dependency on browser-native dialog primitives. --- decnet_web/src/components/SwarmDeckies.tsx | 18 ++++++++++-- decnet_web/src/components/SwarmHosts.tsx | 33 ++++++++++++++++++---- 2 files changed, 44 insertions(+), 7 deletions(-) diff --git a/decnet_web/src/components/SwarmDeckies.tsx b/decnet_web/src/components/SwarmDeckies.tsx index 4b49181..ce3b2a3 100644 --- a/decnet_web/src/components/SwarmDeckies.tsx +++ b/decnet_web/src/components/SwarmDeckies.tsx @@ -23,6 +23,13 @@ const SwarmDeckies: React.FC = () => { const [loading, setLoading] = useState(true); const [tearingDown, setTearingDown] = useState(null); const [error, setError] = useState(null); + // Two-click arm/commit replaces window.confirm() — browsers silently + // suppress confirm() after the "prevent additional dialogs" opt-out. + const [armed, setArmed] = useState(null); + const arm = (key: string) => { + setArmed(key); + setTimeout(() => setArmed((prev) => (prev === key ? null : prev)), 4000); + }; const fetch = async () => { try { @@ -43,7 +50,9 @@ const SwarmDeckies: React.FC = () => { }, []); const handleTeardown = async (s: DeckyShard) => { - if (!window.confirm(`Tear down decky ${s.decky_name} on ${s.host_name}?`)) return; + const key = `td:${s.host_uuid}:${s.decky_name}`; + if (armed !== key) { arm(key); return; } + setArmed(null); setTearingDown(s.decky_name); try { await api.post(`/swarm/hosts/${s.host_uuid}/teardown`, { decky_id: s.decky_name }); @@ -110,7 +119,12 @@ const SwarmDeckies: React.FC = () => { onClick={() => handleTeardown(s)} title="Stop this decky on its host" > - {tearingDown === s.decky_name ? 'Tearing down…' : 'Teardown'} + {' '} + {tearingDown === s.decky_name + ? 'Tearing down…' + : armed === `td:${s.host_uuid}:${s.decky_name}` + ? 'Click again to confirm' + : 'Teardown'} diff --git a/decnet_web/src/components/SwarmHosts.tsx b/decnet_web/src/components/SwarmHosts.tsx index a56fa61..068b9bb 100644 --- a/decnet_web/src/components/SwarmHosts.tsx +++ b/decnet_web/src/components/SwarmHosts.tsx @@ -25,6 +25,15 @@ const SwarmHosts: React.FC = () => { const [decommissioning, setDecommissioning] = useState(null); const [tearingDown, setTearingDown] = useState(null); const [error, setError] = useState(null); + // Two-click arm/commit replaces window.confirm(). Browsers silently + // suppress confirm() after the "prevent additional dialogs" opt-out, + // which manifests as a dead button — no network request, no console + // error. Key format: ":". + const [armed, setArmed] = useState(null); + const arm = (key: string) => { + setArmed(key); + setTimeout(() => setArmed((prev) => (prev === key ? null : prev)), 4000); + }; const fetchHosts = async () => { try { @@ -45,7 +54,9 @@ const SwarmHosts: React.FC = () => { }, []); const handleTeardownAll = async (host: SwarmHost) => { - if (!window.confirm(`Tear down ALL deckies on ${host.name}? The host stays enrolled.`)) return; + const key = `teardown:${host.uuid}`; + if (armed !== key) { arm(key); return; } + setArmed(null); setTearingDown(host.uuid); try { await api.post(`/swarm/hosts/${host.uuid}/teardown`, {}); @@ -58,7 +69,9 @@ const SwarmHosts: React.FC = () => { }; const handleDecommission = async (host: SwarmHost) => { - if (!window.confirm(`Decommission ${host.name} (${host.address})? This removes certs and decky mappings.`)) return; + const key = `decom:${host.uuid}`; + if (armed !== key) { arm(key); return; } + setArmed(null); setDecommissioning(host.uuid); try { await api.delete(`/swarm/hosts/${host.uuid}`); @@ -112,19 +125,29 @@ const SwarmHosts: React.FC = () => { {new Date(h.enrolled_at).toLocaleString()}