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:
91
decnet_web/src/components/Config/tabs/GlobalsTab.test.tsx
Normal file
91
decnet_web/src/components/Config/tabs/GlobalsTab.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
153
decnet_web/src/components/Config/tabs/GlobalsTab.tsx
Normal file
153
decnet_web/src/components/Config/tabs/GlobalsTab.tsx
Normal 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: <number><unit> — 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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user