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