From ccae1612bd373b31769509dc65f7cd6246923417 Mon Sep 17 00:00:00 2001 From: anti Date: Sat, 9 May 2026 05:25:49 -0400 Subject: [PATCH] refactor(decnet_web/Config): extract GlobalsTab + DangerZone GLOBAL VALUES panel + the developer-mode-gated DANGER ZONE (reinit) into one tab file. Two stacked panels because they're the two pieces of UX you ever see together on the globals tab; splitting them into separate components would force the page shell to re-pick the gating predicate. - New Config/tabs/GlobalsTab.tsx (mutation-interval + DangerZone inline, since DangerZone is reinit-specific and won't be reused) - GlobalsTab.test.tsx covers interval-format validation, the DANGER ZONE gating on developer_mode, the two-step reinit confirm flow, the totals chip ("PURGED: N logs, N bounties, N attacker profiles") on success, and viewer-mode rendering. --- .../Config/tabs/GlobalsTab.test.tsx | 91 +++++++++++ .../src/components/Config/tabs/GlobalsTab.tsx | 153 ++++++++++++++++++ 2 files changed, 244 insertions(+) create mode 100644 decnet_web/src/components/Config/tabs/GlobalsTab.test.tsx create mode 100644 decnet_web/src/components/Config/tabs/GlobalsTab.tsx diff --git a/decnet_web/src/components/Config/tabs/GlobalsTab.test.tsx b/decnet_web/src/components/Config/tabs/GlobalsTab.test.tsx new file mode 100644 index 00000000..7ed4dd5f --- /dev/null +++ b/decnet_web/src/components/Config/tabs/GlobalsTab.test.tsx @@ -0,0 +1,91 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { GlobalsTab } from './GlobalsTab'; + +const okSave = async () => ({ ok: true } as const); +const okReinit = async () => + ({ ok: true, deleted: { logs: 12, bounties: 3, attackers: 4 } } as const); + +describe('GlobalsTab', () => { + it('rejects an invalid interval format with the inline error', async () => { + const user = userEvent.setup(); + render( + , + ); + const input = screen.getByPlaceholderText('30m'); + await user.clear(input); + await user.type(input, 'forever'); + await user.click(screen.getByText('SAVE')); + expect(screen.getByText(/INVALID FORMAT/)).toBeInTheDocument(); + }); + + it('hides the DANGER ZONE when developerMode is false', () => { + render( + , + ); + expect(screen.queryByText(/DANGER ZONE/)).not.toBeInTheDocument(); + }); + + it('shows the DANGER ZONE under developer mode and reveals confirm on first click', async () => { + const user = userEvent.setup(); + render( + , + ); + expect(screen.getByText(/DANGER ZONE/)).toBeInTheDocument(); + await user.click(screen.getByText('PURGE ALL DATA')); + expect(screen.getByText(/ARE YOU SURE/)).toBeInTheDocument(); + }); + + it('YES, PURGE fires onReinit and shows the totals chip on success', async () => { + const onReinit = vi.fn(okReinit); + const user = userEvent.setup(); + render( + , + ); + await user.click(screen.getByText('PURGE ALL DATA')); + await user.click(screen.getByText('YES, PURGE')); + expect(onReinit).toHaveBeenCalled(); + expect( + await screen.findByText(/PURGED: 12 logs, 3 bounties, 4 attacker profiles/), + ).toBeInTheDocument(); + }); + + it('viewers see the static interval value with no SAVE button', () => { + render( + , + ); + expect(screen.getByText('30m')).toBeInTheDocument(); + expect(screen.queryByText('SAVE')).not.toBeInTheDocument(); + }); +}); diff --git a/decnet_web/src/components/Config/tabs/GlobalsTab.tsx b/decnet_web/src/components/Config/tabs/GlobalsTab.tsx new file mode 100644 index 00000000..90de7a9d --- /dev/null +++ b/decnet_web/src/components/Config/tabs/GlobalsTab.tsx @@ -0,0 +1,153 @@ +import React, { useState } from 'react'; +import { AlertTriangle, Save, Trash2 } from '../../../icons'; +import type { FormMsg } from '../types'; + +type MutationResult = { ok: true } | { ok: false; reason: string }; +type ReinitTotals = { logs: number; bounties: number; attackers: number }; +type ReinitResult = + | { ok: true; deleted: ReinitTotals } + | { ok: false; reason: string }; + +interface Props { + isAdmin: boolean; + developerMode: boolean; + initialInterval: string; + onSaveInterval: (s: string) => Promise; + onReinit: () => Promise; +} + +const INTERVAL_RE = /^[1-9]\d*[mdMyY]$/; + +/** GLOBAL VALUES tab — global mutation interval form, plus the + * developer-mode-gated DANGER ZONE that purges all collected data. */ +export const GlobalsTab: React.FC = ({ + isAdmin, developerMode, initialInterval, + onSaveInterval, onReinit, +}) => { + const [intervalInput, setIntervalInput] = useState(initialInterval); + const [intervalSaving, setIntervalSaving] = useState(false); + const [intervalMsg, setIntervalMsg] = useState(null); + + const [confirmReinit, setConfirmReinit] = useState(false); + const [reiniting, setReiniting] = useState(false); + const [reinitMsg, setReinitMsg] = useState(null); + + const handleSaveInterval = async () => { + if (!INTERVAL_RE.test(intervalInput)) { + setIntervalMsg({ type: 'error', text: 'INVALID FORMAT (e.g. 30m, 1d, 6M)' }); + return; + } + setIntervalSaving(true); + setIntervalMsg(null); + const r = await onSaveInterval(intervalInput); + setIntervalMsg(r.ok + ? { type: 'success', text: 'MUTATION INTERVAL UPDATED' } + : { type: 'error', text: r.reason }); + setIntervalSaving(false); + }; + + const handleReinit = async () => { + setReiniting(true); + setReinitMsg(null); + const r = await onReinit(); + if (r.ok) { + const d = r.deleted; + setReinitMsg({ + type: 'success', + text: `PURGED: ${d.logs} logs, ${d.bounties} bounties, ${d.attackers} attacker profiles`, + }); + setConfirmReinit(false); + } else { + setReinitMsg({ type: 'error', text: r.reason }); + } + setReiniting(false); + }; + + return ( + <> +
+
+ GLOBAL MUTATION INTERVAL + {isAdmin ? ( + <> +
+ setIntervalInput(e.target.value)} + placeholder="30m" + /> + +
+ + FORMAT: <number><unit> — m=minutes, d=days, M=months, y=years (e.g. 30m, 7d, 1M) + + {intervalMsg && ( + + {intervalMsg.text} + + )} + + ) : ( + {initialInterval} + )} +
+
+ + {developerMode && ( +
+
+ + + DANGER ZONE — DEVELOPER MODE + +

+ Purge all logs, bounty vault entries, and attacker profiles. This action is irreversible. +

+ {!confirmReinit ? ( + + ) : ( +
+ THIS WILL DELETE ALL COLLECTED DATA. ARE YOU SURE? + + +
+ )} + {reinitMsg && ( + + {reinitMsg.text} + + )} +
+
+ )} + + ); +};