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.
This commit is contained in:
2026-05-09 05:25:49 -04:00
parent be35228191
commit ccae1612bd
2 changed files with 244 additions and 0 deletions

View File

@@ -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(
<GlobalsTab
isAdmin
developerMode={false}
initialInterval="30m"
onSaveInterval={okSave}
onReinit={okReinit}
/>,
);
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(
<GlobalsTab
isAdmin
developerMode={false}
initialInterval="30m"
onSaveInterval={okSave}
onReinit={okReinit}
/>,
);
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(
<GlobalsTab
isAdmin
developerMode
initialInterval="30m"
onSaveInterval={okSave}
onReinit={okReinit}
/>,
);
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(
<GlobalsTab
isAdmin
developerMode
initialInterval="30m"
onSaveInterval={okSave}
onReinit={onReinit}
/>,
);
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(
<GlobalsTab
isAdmin={false}
developerMode={false}
initialInterval="30m"
onSaveInterval={okSave}
onReinit={okReinit}
/>,
);
expect(screen.getByText('30m')).toBeInTheDocument();
expect(screen.queryByText('SAVE')).not.toBeInTheDocument();
});
});

View File

@@ -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<MutationResult>;
onReinit: () => Promise<ReinitResult>;
}
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<Props> = ({
isAdmin, developerMode, initialInterval,
onSaveInterval, onReinit,
}) => {
const [intervalInput, setIntervalInput] = useState(initialInterval);
const [intervalSaving, setIntervalSaving] = useState(false);
const [intervalMsg, setIntervalMsg] = useState<FormMsg | null>(null);
const [confirmReinit, setConfirmReinit] = useState(false);
const [reiniting, setReiniting] = useState(false);
const [reinitMsg, setReinitMsg] = useState<FormMsg | null>(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 (
<>
<div className="config-panel">
<div className="config-field">
<span className="config-label">GLOBAL MUTATION INTERVAL</span>
{isAdmin ? (
<>
<div className="config-input-row">
<input
type="text"
value={intervalInput}
onChange={(e) => setIntervalInput(e.target.value)}
placeholder="30m"
/>
<button
className="save-btn"
onClick={handleSaveInterval}
disabled={intervalSaving}
>
<Save size={14} />
{intervalSaving ? 'SAVING...' : 'SAVE'}
</button>
</div>
<span className="interval-hint">
FORMAT: &lt;number&gt;&lt;unit&gt; m=minutes, d=days, M=months, y=years (e.g. 30m, 7d, 1M)
</span>
{intervalMsg && (
<span className={intervalMsg.type === 'success' ? 'config-success' : 'config-error'}>
{intervalMsg.text}
</span>
)}
</>
) : (
<span className="config-value">{initialInterval}</span>
)}
</div>
</div>
{developerMode && (
<div className="config-panel" style={{ borderColor: '#ff4141' }}>
<div className="config-field" style={{ marginBottom: 0 }}>
<span className="config-label" style={{ color: '#ff4141' }}>
<AlertTriangle size={12} style={{ display: 'inline', verticalAlign: 'middle', marginRight: '6px' }} />
DANGER ZONE DEVELOPER MODE
</span>
<p style={{ fontSize: '0.75rem', opacity: 0.5, margin: '4px 0 12px' }}>
Purge all logs, bounty vault entries, and attacker profiles. This action is irreversible.
</p>
{!confirmReinit ? (
<button
className="action-btn danger"
onClick={() => setConfirmReinit(true)}
style={{ padding: '8px 16px', fontSize: '0.8rem' }}
>
<Trash2 size={14} />
PURGE ALL DATA
</button>
) : (
<div className="confirm-dialog">
<span>THIS WILL DELETE ALL COLLECTED DATA. ARE YOU SURE?</span>
<button
className="action-btn danger"
onClick={handleReinit}
disabled={reiniting}
style={{ padding: '6px 16px' }}
>
{reiniting ? 'PURGING...' : 'YES, PURGE'}
</button>
<button
className="action-btn"
onClick={() => setConfirmReinit(false)}
style={{ padding: '6px 16px' }}
>
CANCEL
</button>
</div>
)}
{reinitMsg && (
<span className={reinitMsg.type === 'success' ? 'config-success' : 'config-error'} style={{ marginTop: '8px' }}>
{reinitMsg.text}
</span>
)}
</div>
</div>
)}
</>
);
};