refactor(decnet_web/Config): wire hook + bump coverage floor
Final integration. The page shell is now a thin composition of useConfig + the previously-extracted children: - Config.tsx: 989 -> 131 LOC. Page owns only the activeTab state (and the "drop the users tab if the server didn't send users" effect). Every form lives inside its tab; toast wiring lives in AppearanceTab; window.alert calls live inside UsersTab. - Tabs receive their `onSave* / onAddUser / ...` callbacks directly from the hook — no intermediate wrapper handlers. Coverage floor bumped after the split: lines 14 -> 17 functions 13 -> 15 branches 11 -> 13 statements 13 -> 16 Phase 4 final scoreboard: 34 test files, 156 tests, all green.
This commit is contained in:
@@ -1,206 +1,37 @@
|
|||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import api from '../utils/api';
|
import {
|
||||||
import { Settings, Users, Sliders, Trash2, UserPlus, Key, Save, Shield, AlertTriangle, Palette, Activity } from '../icons';
|
Settings, Users, Sliders, Shield, Palette, Activity,
|
||||||
|
} from '../icons';
|
||||||
import { useToast } from './Toasts/useToast';
|
import { useToast } from './Toasts/useToast';
|
||||||
import RuleStateControls from './RuleStateControls';
|
import RuleStateControls from './RuleStateControls';
|
||||||
import './Dashboard.css';
|
import './Dashboard.css';
|
||||||
import './Config.css';
|
import './Config.css';
|
||||||
|
import type { ConfigTab } from './Config/types';
|
||||||
import type { ConfigData, ConfigTab } from './Config/types';
|
import { useConfig } from './Config/useConfig';
|
||||||
import { WorkersPanel } from './Config/WorkersPanel';
|
import { WorkersPanel } from './Config/WorkersPanel';
|
||||||
|
import { LimitsTab } from './Config/tabs/LimitsTab';
|
||||||
|
import { UsersTab } from './Config/tabs/UsersTab';
|
||||||
|
import { GlobalsTab } from './Config/tabs/GlobalsTab';
|
||||||
|
import { AppearanceTab } from './Config/tabs/AppearanceTab';
|
||||||
|
|
||||||
const Config: React.FC = () => {
|
const Config: React.FC = () => {
|
||||||
const [config, setConfig] = useState<ConfigData | null>(null);
|
const {
|
||||||
const [loading, setLoading] = useState(true);
|
config, loading, isAdmin,
|
||||||
const [activeTab, setActiveTab] = useState<ConfigTab>('limits');
|
setDeploymentLimit, setGlobalMutationInterval,
|
||||||
const [accent, setAccent] = useState<'matrix' | 'violet'>(() => {
|
addUser, deleteUser, setUserRole, resetUserPassword,
|
||||||
try {
|
reinit,
|
||||||
const raw = localStorage.getItem('decnet_tweaks');
|
} = useConfig();
|
||||||
if (raw) {
|
|
||||||
const parsed = JSON.parse(raw);
|
|
||||||
if (parsed?.accent === 'violet') return 'violet';
|
|
||||||
}
|
|
||||||
} catch { /* noop */ }
|
|
||||||
return 'matrix';
|
|
||||||
});
|
|
||||||
const { push: pushToast } = useToast();
|
const { push: pushToast } = useToast();
|
||||||
|
|
||||||
const handleAccentChange = (value: 'matrix' | 'violet') => {
|
const [activeTab, setActiveTab] = useState<ConfigTab>('limits');
|
||||||
setAccent(value);
|
|
||||||
let existing: Record<string, unknown> = {};
|
|
||||||
try {
|
|
||||||
const raw = localStorage.getItem('decnet_tweaks');
|
|
||||||
if (raw) existing = JSON.parse(raw) ?? {};
|
|
||||||
} catch { existing = {}; }
|
|
||||||
localStorage.setItem('decnet_tweaks', JSON.stringify({ ...existing, accent: value }));
|
|
||||||
document.documentElement.setAttribute('data-accent', value);
|
|
||||||
pushToast({ text: `ACCENT · ${value.toUpperCase()}`, icon: 'check-circle', tone: 'violet' });
|
|
||||||
};
|
|
||||||
|
|
||||||
// Deployment limit state
|
// If server didn't send users, force tab away from users.
|
||||||
const [limitInput, setLimitInput] = useState('');
|
|
||||||
const [limitSaving, setLimitSaving] = useState(false);
|
|
||||||
const [limitMsg, setLimitMsg] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
|
|
||||||
|
|
||||||
// Global mutation interval state
|
|
||||||
const [intervalInput, setIntervalInput] = useState('');
|
|
||||||
const [intervalSaving, setIntervalSaving] = useState(false);
|
|
||||||
const [intervalMsg, setIntervalMsg] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
|
|
||||||
|
|
||||||
// Add user form state
|
|
||||||
const [newUsername, setNewUsername] = useState('');
|
|
||||||
const [newPassword, setNewPassword] = useState('');
|
|
||||||
const [newRole, setNewRole] = useState<'admin' | 'viewer'>('viewer');
|
|
||||||
const [addingUser, setAddingUser] = useState(false);
|
|
||||||
const [userMsg, setUserMsg] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
|
|
||||||
|
|
||||||
// Confirm delete state
|
|
||||||
const [confirmDelete, setConfirmDelete] = useState<string | null>(null);
|
|
||||||
|
|
||||||
// Reset password state
|
|
||||||
const [resetTarget, setResetTarget] = useState<string | null>(null);
|
|
||||||
const [resetPassword, setResetPassword] = useState('');
|
|
||||||
|
|
||||||
// Reinit state
|
|
||||||
const [confirmReinit, setConfirmReinit] = useState(false);
|
|
||||||
const [reiniting, setReiniting] = useState(false);
|
|
||||||
const [reinitMsg, setReinitMsg] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
|
|
||||||
|
|
||||||
const isAdmin = config?.role === 'admin';
|
|
||||||
|
|
||||||
const fetchConfig = async () => {
|
|
||||||
try {
|
|
||||||
const res = await api.get('/config');
|
|
||||||
setConfig(res.data);
|
|
||||||
setLimitInput(String(res.data.deployment_limit));
|
|
||||||
setIntervalInput(res.data.global_mutation_interval);
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Failed to fetch config', err);
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
fetchConfig();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// If server didn't send users, force tab away from users
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (config && !config.users && activeTab === 'users') {
|
if (config && !config.users && activeTab === 'users') {
|
||||||
setActiveTab('limits');
|
setActiveTab('limits');
|
||||||
}
|
}
|
||||||
}, [config, activeTab]);
|
}, [config, activeTab]);
|
||||||
|
|
||||||
const handleSaveLimit = async () => {
|
|
||||||
const val = parseInt(limitInput);
|
|
||||||
if (isNaN(val) || val < 1 || val > 500) {
|
|
||||||
setLimitMsg({ type: 'error', text: 'VALUE MUST BE 1-500' });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setLimitSaving(true);
|
|
||||||
setLimitMsg(null);
|
|
||||||
try {
|
|
||||||
await api.put('/config/deployment-limit', { deployment_limit: val });
|
|
||||||
setLimitMsg({ type: 'success', text: 'DEPLOYMENT LIMIT UPDATED' });
|
|
||||||
fetchConfig();
|
|
||||||
} catch (err: any) {
|
|
||||||
setLimitMsg({ type: 'error', text: err.response?.data?.detail || 'UPDATE FAILED' });
|
|
||||||
} finally {
|
|
||||||
setLimitSaving(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSaveInterval = async () => {
|
|
||||||
if (!/^[1-9]\d*[mdMyY]$/.test(intervalInput)) {
|
|
||||||
setIntervalMsg({ type: 'error', text: 'INVALID FORMAT (e.g. 30m, 1d, 6M)' });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setIntervalSaving(true);
|
|
||||||
setIntervalMsg(null);
|
|
||||||
try {
|
|
||||||
await api.put('/config/global-mutation-interval', { global_mutation_interval: intervalInput });
|
|
||||||
setIntervalMsg({ type: 'success', text: 'MUTATION INTERVAL UPDATED' });
|
|
||||||
fetchConfig();
|
|
||||||
} catch (err: any) {
|
|
||||||
setIntervalMsg({ type: 'error', text: err.response?.data?.detail || 'UPDATE FAILED' });
|
|
||||||
} finally {
|
|
||||||
setIntervalSaving(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleAddUser = async (e: React.FormEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
if (!newUsername.trim() || !newPassword.trim()) return;
|
|
||||||
setAddingUser(true);
|
|
||||||
setUserMsg(null);
|
|
||||||
try {
|
|
||||||
await api.post('/config/users', {
|
|
||||||
username: newUsername.trim(),
|
|
||||||
password: newPassword,
|
|
||||||
role: newRole,
|
|
||||||
});
|
|
||||||
setNewUsername('');
|
|
||||||
setNewPassword('');
|
|
||||||
setNewRole('viewer');
|
|
||||||
setUserMsg({ type: 'success', text: 'USER CREATED' });
|
|
||||||
fetchConfig();
|
|
||||||
} catch (err: any) {
|
|
||||||
setUserMsg({ type: 'error', text: err.response?.data?.detail || 'CREATE FAILED' });
|
|
||||||
} finally {
|
|
||||||
setAddingUser(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDeleteUser = async (uuid: string) => {
|
|
||||||
try {
|
|
||||||
await api.delete(`/config/users/${uuid}`);
|
|
||||||
setConfirmDelete(null);
|
|
||||||
fetchConfig();
|
|
||||||
} catch (err: any) {
|
|
||||||
alert(err.response?.data?.detail || 'Delete failed');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleRoleChange = async (uuid: string, role: string) => {
|
|
||||||
try {
|
|
||||||
await api.put(`/config/users/${uuid}/role`, { role });
|
|
||||||
fetchConfig();
|
|
||||||
} catch (err: any) {
|
|
||||||
alert(err.response?.data?.detail || 'Role update failed');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleResetPassword = async (uuid: string) => {
|
|
||||||
if (!resetPassword.trim() || resetPassword.length < 8) {
|
|
||||||
alert('Password must be at least 8 characters');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
await api.put(`/config/users/${uuid}/reset-password`, { new_password: resetPassword });
|
|
||||||
setResetTarget(null);
|
|
||||||
setResetPassword('');
|
|
||||||
fetchConfig();
|
|
||||||
} catch (err: any) {
|
|
||||||
alert(err.response?.data?.detail || 'Password reset failed');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleReinit = async () => {
|
|
||||||
setReiniting(true);
|
|
||||||
setReinitMsg(null);
|
|
||||||
try {
|
|
||||||
const res = await api.delete('/config/reinit');
|
|
||||||
const d = res.data.deleted;
|
|
||||||
setReinitMsg({ type: 'success', text: `PURGED: ${d.logs} logs, ${d.bounties} bounties, ${d.attackers} attacker profiles` });
|
|
||||||
setConfirmReinit(false);
|
|
||||||
} catch (err: any) {
|
|
||||||
setReinitMsg({ type: 'error', text: err.response?.data?.detail || 'REINIT FAILED' });
|
|
||||||
} finally {
|
|
||||||
setReiniting(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="logs-section">
|
<div className="logs-section">
|
||||||
@@ -219,15 +50,19 @@ const Config: React.FC = () => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const tabs: { key: string; label: string; icon: React.ReactNode }[] = [
|
const tabs: { key: ConfigTab; label: string; icon: React.ReactNode }[] = [
|
||||||
{ key: 'limits', label: 'DEPLOYMENT LIMITS', icon: <Sliders size={14} /> },
|
{ key: 'limits', label: 'DEPLOYMENT LIMITS', icon: <Sliders size={14} /> },
|
||||||
...(config.users
|
...(config.users
|
||||||
? [{ key: 'users', label: 'USER MANAGEMENT', icon: <Users size={14} /> }]
|
? [{ key: 'users' as const, label: 'USER MANAGEMENT', icon: <Users size={14} /> }]
|
||||||
: []),
|
: []),
|
||||||
{ key: 'globals', label: 'GLOBAL VALUES', icon: <Settings size={14} /> },
|
{ key: 'globals', label: 'GLOBAL VALUES', icon: <Settings size={14} /> },
|
||||||
{ key: 'appearance', label: 'APPEARANCE', icon: <Palette size={14} /> },
|
{ key: 'appearance', label: 'APPEARANCE', icon: <Palette size={14} /> },
|
||||||
...(isAdmin ? [{ key: 'workers', label: 'WORKERS', icon: <Activity size={14} /> }] : []),
|
...(isAdmin
|
||||||
...(isAdmin ? [{ key: 'ttp', label: 'TTP RULES', icon: <Shield size={14} /> }] : []),
|
? [{ key: 'workers' as const, label: 'WORKERS', icon: <Activity size={14} /> }]
|
||||||
|
: []),
|
||||||
|
...(isAdmin
|
||||||
|
? [{ key: 'ttp' as const, label: 'TTP RULES', icon: <Shield size={14} /> }]
|
||||||
|
: []),
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -244,7 +79,7 @@ const Config: React.FC = () => {
|
|||||||
<button
|
<button
|
||||||
key={tab.key}
|
key={tab.key}
|
||||||
className={`config-tab ${activeTab === tab.key ? 'active' : ''}`}
|
className={`config-tab ${activeTab === tab.key ? 'active' : ''}`}
|
||||||
onClick={() => setActiveTab(tab.key as ConfigTab)}
|
onClick={() => setActiveTab(tab.key)}
|
||||||
>
|
>
|
||||||
{tab.icon}
|
{tab.icon}
|
||||||
{tab.label}
|
{tab.label}
|
||||||
@@ -252,333 +87,45 @@ const Config: React.FC = () => {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* DEPLOYMENT LIMITS TAB */}
|
|
||||||
{activeTab === 'limits' && (
|
{activeTab === 'limits' && (
|
||||||
<div className="config-panel">
|
<LimitsTab
|
||||||
<div className="config-field">
|
isAdmin={isAdmin}
|
||||||
<span className="config-label">MAXIMUM DECKIES PER DEPLOYMENT</span>
|
initialValue={config.deployment_limit}
|
||||||
{isAdmin ? (
|
onSave={setDeploymentLimit}
|
||||||
<>
|
/>
|
||||||
<div className="config-input-row">
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
min={1}
|
|
||||||
max={500}
|
|
||||||
value={limitInput}
|
|
||||||
onChange={(e) => setLimitInput(e.target.value)}
|
|
||||||
/>
|
|
||||||
<div className="preset-buttons">
|
|
||||||
{[10, 50, 100, 200].map((v) => (
|
|
||||||
<button
|
|
||||||
key={v}
|
|
||||||
className={`preset-btn ${limitInput === String(v) ? 'active' : ''}`}
|
|
||||||
onClick={() => setLimitInput(String(v))}
|
|
||||||
>
|
|
||||||
{v}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
className="save-btn"
|
|
||||||
onClick={handleSaveLimit}
|
|
||||||
disabled={limitSaving}
|
|
||||||
>
|
|
||||||
<Save size={14} />
|
|
||||||
{limitSaving ? 'SAVING...' : 'SAVE'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{limitMsg && (
|
|
||||||
<span className={limitMsg.type === 'success' ? 'config-success' : 'config-error'}>
|
|
||||||
{limitMsg.text}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<span className="config-value">{config.deployment_limit}</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* USER MANAGEMENT TAB (only if server sent users) */}
|
|
||||||
{activeTab === 'users' && config.users && (
|
{activeTab === 'users' && config.users && (
|
||||||
<div className="config-panel">
|
<UsersTab
|
||||||
<div className="users-table-container">
|
users={config.users}
|
||||||
<table className="users-table">
|
onDeleteUser={deleteUser}
|
||||||
<thead>
|
onSetUserRole={setUserRole}
|
||||||
<tr>
|
onResetUserPassword={resetUserPassword}
|
||||||
<th>USERNAME</th>
|
onAddUser={addUser}
|
||||||
<th>ROLE</th>
|
/>
|
||||||
<th>STATUS</th>
|
|
||||||
<th>ACTIONS</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{config.users.map((user) => (
|
|
||||||
<tr key={user.uuid}>
|
|
||||||
<td>{user.username}</td>
|
|
||||||
<td>
|
|
||||||
<span className={`role-badge ${user.role}`}>{user.role.toUpperCase()}</span>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
{user.must_change_password && (
|
|
||||||
<span className="must-change-badge">MUST CHANGE PASSWORD</span>
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<div className="user-actions">
|
|
||||||
{/* Role change dropdown */}
|
|
||||||
<select
|
|
||||||
className="role-select"
|
|
||||||
value={user.role}
|
|
||||||
onChange={(e) => handleRoleChange(user.uuid, e.target.value)}
|
|
||||||
>
|
|
||||||
<option value="admin">admin</option>
|
|
||||||
<option value="viewer">viewer</option>
|
|
||||||
</select>
|
|
||||||
|
|
||||||
{/* Reset password */}
|
|
||||||
{resetTarget === user.uuid ? (
|
|
||||||
<div className="confirm-dialog">
|
|
||||||
<input
|
|
||||||
type="password"
|
|
||||||
placeholder="New password"
|
|
||||||
value={resetPassword}
|
|
||||||
onChange={(e) => setResetPassword(e.target.value)}
|
|
||||||
style={{ width: '140px' }}
|
|
||||||
/>
|
|
||||||
<button className="action-btn" onClick={() => handleResetPassword(user.uuid)}>
|
|
||||||
SET
|
|
||||||
</button>
|
|
||||||
<button className="action-btn" onClick={() => { setResetTarget(null); setResetPassword(''); }}>
|
|
||||||
CANCEL
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<button
|
|
||||||
className="action-btn"
|
|
||||||
onClick={() => setResetTarget(user.uuid)}
|
|
||||||
>
|
|
||||||
<Key size={12} />
|
|
||||||
RESET
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Delete */}
|
|
||||||
{confirmDelete === user.uuid ? (
|
|
||||||
<div className="confirm-dialog">
|
|
||||||
<span>CONFIRM?</span>
|
|
||||||
<button className="action-btn danger" onClick={() => handleDeleteUser(user.uuid)}>
|
|
||||||
YES
|
|
||||||
</button>
|
|
||||||
<button className="action-btn" onClick={() => setConfirmDelete(null)}>
|
|
||||||
NO
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<button
|
|
||||||
className="action-btn danger"
|
|
||||||
onClick={() => setConfirmDelete(user.uuid)}
|
|
||||||
>
|
|
||||||
<Trash2 size={12} />
|
|
||||||
DELETE
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="add-user-section">
|
|
||||||
<form className="add-user-form" onSubmit={handleAddUser}>
|
|
||||||
<div className="form-group">
|
|
||||||
<label>USERNAME</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={newUsername}
|
|
||||||
onChange={(e) => setNewUsername(e.target.value)}
|
|
||||||
required
|
|
||||||
minLength={1}
|
|
||||||
maxLength={64}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="form-group">
|
|
||||||
<label>PASSWORD</label>
|
|
||||||
<input
|
|
||||||
type="password"
|
|
||||||
value={newPassword}
|
|
||||||
onChange={(e) => setNewPassword(e.target.value)}
|
|
||||||
required
|
|
||||||
minLength={8}
|
|
||||||
maxLength={72}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="form-group">
|
|
||||||
<label>ROLE</label>
|
|
||||||
<select
|
|
||||||
value={newRole}
|
|
||||||
onChange={(e) => setNewRole(e.target.value as 'admin' | 'viewer')}
|
|
||||||
>
|
|
||||||
<option value="viewer">viewer</option>
|
|
||||||
<option value="admin">admin</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<button type="submit" className="save-btn" disabled={addingUser}>
|
|
||||||
<UserPlus size={14} />
|
|
||||||
{addingUser ? 'CREATING...' : 'ADD USER'}
|
|
||||||
</button>
|
|
||||||
{userMsg && (
|
|
||||||
<span className={userMsg.type === 'success' ? 'config-success' : 'config-error'}>
|
|
||||||
{userMsg.text}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* GLOBAL VALUES TAB */}
|
|
||||||
{activeTab === 'globals' && (
|
{activeTab === 'globals' && (
|
||||||
<div className="config-panel">
|
<GlobalsTab
|
||||||
<div className="config-field">
|
isAdmin={isAdmin}
|
||||||
<span className="config-label">GLOBAL MUTATION INTERVAL</span>
|
developerMode={config.developer_mode === true}
|
||||||
{isAdmin ? (
|
initialInterval={config.global_mutation_interval}
|
||||||
<>
|
onSaveInterval={setGlobalMutationInterval}
|
||||||
<div className="config-input-row">
|
onReinit={reinit}
|
||||||
<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">{config.global_mutation_interval}</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* WORKERS TAB (admin only, server-gated too) */}
|
{activeTab === 'appearance' && <AppearanceTab />}
|
||||||
|
|
||||||
{activeTab === 'workers' && isAdmin && (
|
{activeTab === 'workers' && isAdmin && (
|
||||||
<WorkersPanel pushToast={pushToast} />
|
<WorkersPanel pushToast={pushToast} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* TTP RULES TAB — admin only. RuleStateControls also self-gates
|
{/* RuleStateControls also self-gates on /config?.role so a state
|
||||||
on /config?.role so a state leak can't render it. */}
|
leak can't render it. */}
|
||||||
{activeTab === 'ttp' && isAdmin && (
|
{activeTab === 'ttp' && isAdmin && <RuleStateControls />}
|
||||||
<RuleStateControls />
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* APPEARANCE TAB */}
|
|
||||||
{activeTab === 'appearance' && (
|
|
||||||
<div className="config-panel">
|
|
||||||
<div className="config-field">
|
|
||||||
<span className="config-label">ACCENT COLOR</span>
|
|
||||||
<p style={{ fontSize: '0.75rem', opacity: 0.5, margin: '4px 0 12px' }}>
|
|
||||||
Swaps the UI accent (nav bars, hover glows, chip borders) between matrix-green and electric-violet. Persists per-browser.
|
|
||||||
</p>
|
|
||||||
<div style={{ display: 'flex', gap: '8px' }}>
|
|
||||||
{(['matrix', 'violet'] as const).map((value) => (
|
|
||||||
<button
|
|
||||||
key={value}
|
|
||||||
type="button"
|
|
||||||
onClick={() => handleAccentChange(value)}
|
|
||||||
className="save-btn"
|
|
||||||
style={{
|
|
||||||
padding: '8px 16px',
|
|
||||||
fontSize: '0.75rem',
|
|
||||||
letterSpacing: '1.5px',
|
|
||||||
borderColor: accent === value
|
|
||||||
? (value === 'violet' ? 'var(--violet)' : 'var(--matrix)')
|
|
||||||
: 'var(--border)',
|
|
||||||
color: accent === value
|
|
||||||
? (value === 'violet' ? 'var(--violet)' : 'var(--matrix)')
|
|
||||||
: 'var(--matrix)',
|
|
||||||
opacity: accent === value ? 1 : 0.6,
|
|
||||||
background: 'transparent',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{accent === value ? '● ' : '○ '}
|
|
||||||
{value.toUpperCase()}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* DANGER ZONE — developer mode only, server-gated, shown on globals tab */}
|
|
||||||
{activeTab === 'globals' && config.developer_mode && (
|
|
||||||
<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>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
export default Config;
|
export default Config;
|
||||||
|
|||||||
@@ -15,14 +15,14 @@ export default defineConfig({
|
|||||||
include: ['src/**/*.{ts,tsx}'],
|
include: ['src/**/*.{ts,tsx}'],
|
||||||
exclude: ['src/**/*.d.ts', 'src/test/**', 'src/main.tsx'],
|
exclude: ['src/**/*.d.ts', 'src/test/**', 'src/main.tsx'],
|
||||||
// Baseline floors. Each refactor PR raises these; never lower.
|
// Baseline floors. Each refactor PR raises these; never lower.
|
||||||
// Phase 3 (CanaryTokens split): page shell down from 1,334 to 210
|
// Phase 4 (Config split): page shell down from 989 to 131 LOC;
|
||||||
// LOC; hook + 3 modals + 3 list views + ui/types/helpers, 33 new
|
// hook + WorkersPanel + 4 tab files, 25 new tests. Suite:
|
||||||
// tests. Suite: 28 files, 131 tests, 14.51% lines / 11.43% branches.
|
// 34 files, 156 tests, 17.73% lines / 13.85% branches.
|
||||||
thresholds: {
|
thresholds: {
|
||||||
lines: 14,
|
lines: 17,
|
||||||
functions: 13,
|
functions: 15,
|
||||||
branches: 11,
|
branches: 13,
|
||||||
statements: 13,
|
statements: 16,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user