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:
@@ -23,6 +23,13 @@ const SwarmDeckies: React.FC = () => {
|
|||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [tearingDown, setTearingDown] = useState<string | null>(null);
|
const [tearingDown, setTearingDown] = useState<string | null>(null);
|
||||||
const [error, setError] = 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 () => {
|
const fetch = async () => {
|
||||||
try {
|
try {
|
||||||
@@ -43,7 +50,9 @@ const SwarmDeckies: React.FC = () => {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleTeardown = async (s: DeckyShard) => {
|
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);
|
setTearingDown(s.decky_name);
|
||||||
try {
|
try {
|
||||||
await api.post(`/swarm/hosts/${s.host_uuid}/teardown`, { decky_id: s.decky_name });
|
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)}
|
onClick={() => handleTeardown(s)}
|
||||||
title="Stop this decky on its host"
|
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>
|
</button>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@@ -25,6 +25,15 @@ const SwarmHosts: React.FC = () => {
|
|||||||
const [decommissioning, setDecommissioning] = useState<string | null>(null);
|
const [decommissioning, setDecommissioning] = useState<string | null>(null);
|
||||||
const [tearingDown, setTearingDown] = useState<string | null>(null);
|
const [tearingDown, setTearingDown] = useState<string | null>(null);
|
||||||
const [error, setError] = 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 () => {
|
const fetchHosts = async () => {
|
||||||
try {
|
try {
|
||||||
@@ -45,7 +54,9 @@ const SwarmHosts: React.FC = () => {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleTeardownAll = async (host: SwarmHost) => {
|
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);
|
setTearingDown(host.uuid);
|
||||||
try {
|
try {
|
||||||
await api.post(`/swarm/hosts/${host.uuid}/teardown`, {});
|
await api.post(`/swarm/hosts/${host.uuid}/teardown`, {});
|
||||||
@@ -58,7 +69,9 @@ const SwarmHosts: React.FC = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleDecommission = async (host: SwarmHost) => {
|
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);
|
setDecommissioning(host.uuid);
|
||||||
try {
|
try {
|
||||||
await api.delete(`/swarm/hosts/${host.uuid}`);
|
await api.delete(`/swarm/hosts/${host.uuid}`);
|
||||||
@@ -112,19 +125,29 @@ const SwarmHosts: React.FC = () => {
|
|||||||
<td>{new Date(h.enrolled_at).toLocaleString()}</td>
|
<td>{new Date(h.enrolled_at).toLocaleString()}</td>
|
||||||
<td>
|
<td>
|
||||||
<button
|
<button
|
||||||
className="control-btn"
|
className={`control-btn${armed === `teardown:${h.uuid}` ? ' danger' : ''}`}
|
||||||
disabled={tearingDown === h.uuid || h.status !== 'active'}
|
disabled={tearingDown === h.uuid || h.status !== 'active'}
|
||||||
onClick={() => handleTeardownAll(h)}
|
onClick={() => handleTeardownAll(h)}
|
||||||
title="Stop all deckies on this host (keeps it enrolled)"
|
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>
|
||||||
<button
|
<button
|
||||||
className="control-btn danger"
|
className="control-btn danger"
|
||||||
disabled={decommissioning === h.uuid}
|
disabled={decommissioning === h.uuid}
|
||||||
onClick={() => handleDecommission(h)}
|
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>
|
</button>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
Reference in New Issue
Block a user