fix(web): replace window.confirm with two-click arm/commit on swarm actions

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.
This commit is contained in:
2026-04-19 20:16:51 -04:00
parent df18cb44cc
commit a63301c7a3
2 changed files with 44 additions and 7 deletions

View File

@@ -23,6 +23,13 @@ const SwarmDeckies: React.FC = () => {
const [loading, setLoading] = useState(true);
const [tearingDown, setTearingDown] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
// Two-click arm/commit replaces window.confirm() — browsers silently
// suppress confirm() after the "prevent additional dialogs" opt-out.
const [armed, setArmed] = useState<string | null>(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"
>
<PowerOff size={14} /> {tearingDown === s.decky_name ? 'Tearing down…' : 'Teardown'}
<PowerOff size={14} />{' '}
{tearingDown === s.decky_name
? 'Tearing down…'
: armed === `td:${s.host_uuid}:${s.decky_name}`
? 'Click again to confirm'
: 'Teardown'}
</button>
</td>
</tr>

View File

@@ -25,6 +25,15 @@ const SwarmHosts: React.FC = () => {
const [decommissioning, setDecommissioning] = useState<string | null>(null);
const [tearingDown, setTearingDown] = useState<string | null>(null);
const [error, setError] = useState<string | null>(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: "<action>:<uuid>".
const [armed, setArmed] = useState<string | null>(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 = () => {
<td>{new Date(h.enrolled_at).toLocaleString()}</td>
<td>
<button
className="control-btn"
className={`control-btn${armed === `teardown:${h.uuid}` ? ' danger' : ''}`}
disabled={tearingDown === h.uuid || h.status !== 'active'}
onClick={() => handleTeardownAll(h)}
title="Stop all deckies on this host (keeps it enrolled)"
>
<PowerOff size={14} /> {tearingDown === h.uuid ? 'Tearing down…' : 'Teardown all'}
<PowerOff size={14} />{' '}
{tearingDown === h.uuid
? 'Tearing down…'
: armed === `teardown:${h.uuid}`
? 'Click again to confirm'
: 'Teardown all'}
</button>
<button
className="control-btn danger"
disabled={decommissioning === h.uuid}
onClick={() => handleDecommission(h)}
>
<Trash2 size={14} /> {decommissioning === h.uuid ? 'Decommissioning…' : 'Decommission'}
<Trash2 size={14} />{' '}
{decommissioning === h.uuid
? 'Decommissioning…'
: armed === `decom:${h.uuid}`
? 'Click again to confirm'
: 'Decommission'}
</button>
</td>
</tr>