merge testing->tomerge/main #7
@@ -1,9 +1,44 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { ArrowLeft, ChevronDown, ChevronLeft, ChevronRight, ChevronUp, Crosshair, Fingerprint, Shield, Clock, Wifi, Lock, FileKey } from 'lucide-react';
|
||||
import { Activity, ArrowLeft, ChevronDown, ChevronLeft, ChevronRight, ChevronUp, Crosshair, Fingerprint, Shield, Clock, Wifi, Lock, FileKey, Radio, Timer } from 'lucide-react';
|
||||
import api from '../utils/api';
|
||||
import './Dashboard.css';
|
||||
|
||||
interface AttackerBehavior {
|
||||
os_guess: string | null;
|
||||
hop_distance: number | null;
|
||||
tcp_fingerprint: {
|
||||
window?: number | null;
|
||||
wscale?: number | null;
|
||||
mss?: number | null;
|
||||
options_sig?: string;
|
||||
has_sack?: boolean;
|
||||
has_timestamps?: boolean;
|
||||
} | null;
|
||||
retransmit_count: number;
|
||||
behavior_class: string | null;
|
||||
beacon_interval_s: number | null;
|
||||
beacon_jitter_pct: number | null;
|
||||
tool_guess: string | null;
|
||||
timing_stats: {
|
||||
event_count?: number;
|
||||
duration_s?: number;
|
||||
mean_iat_s?: number | null;
|
||||
median_iat_s?: number | null;
|
||||
stdev_iat_s?: number | null;
|
||||
min_iat_s?: number | null;
|
||||
max_iat_s?: number | null;
|
||||
cv?: number | null;
|
||||
} | null;
|
||||
phase_sequence: {
|
||||
recon_end_ts?: string | null;
|
||||
exfil_start_ts?: string | null;
|
||||
exfil_latency_s?: number | null;
|
||||
large_payload_count?: number;
|
||||
} | null;
|
||||
updated_at?: string;
|
||||
}
|
||||
|
||||
interface AttackerData {
|
||||
uuid: string;
|
||||
ip: string;
|
||||
@@ -21,6 +56,7 @@ interface AttackerData {
|
||||
fingerprints: any[];
|
||||
commands: { service: string; decky: string; command: string; timestamp: string }[];
|
||||
updated_at: string;
|
||||
behavior: AttackerBehavior | null;
|
||||
}
|
||||
|
||||
// ─── Fingerprint rendering ───────────────────────────────────────────────────
|
||||
@@ -312,6 +348,250 @@ const FingerprintGroup: React.FC<{ fpType: string; items: any[] }> = ({ fpType,
|
||||
);
|
||||
};
|
||||
|
||||
// ─── Behavioral profile blocks ──────────────────────────────────────────────
|
||||
|
||||
const OS_LABELS: Record<string, string> = {
|
||||
linux: 'LINUX',
|
||||
windows: 'WINDOWS',
|
||||
macos_ios: 'macOS / iOS',
|
||||
freebsd: 'FREEBSD',
|
||||
openbsd: 'OPENBSD',
|
||||
embedded: 'EMBEDDED',
|
||||
nmap: 'NMAP (SCANNER)',
|
||||
unknown: 'UNKNOWN',
|
||||
};
|
||||
|
||||
const BEHAVIOR_COLORS: Record<string, string> = {
|
||||
beaconing: '#ff6b6b',
|
||||
interactive: 'var(--accent-color)',
|
||||
scanning: '#e5c07b',
|
||||
mixed: 'var(--text-color)',
|
||||
unknown: 'var(--text-color)',
|
||||
};
|
||||
|
||||
const TOOL_LABELS: Record<string, string> = {
|
||||
cobalt_strike: 'COBALT STRIKE',
|
||||
sliver: 'SLIVER',
|
||||
havoc: 'HAVOC',
|
||||
mythic: 'MYTHIC',
|
||||
};
|
||||
|
||||
const fmtOpt = (v: number | null | undefined): string =>
|
||||
v === null || v === undefined ? '—' : String(v);
|
||||
|
||||
const fmtSecs = (v: number | null | undefined): string => {
|
||||
if (v === null || v === undefined) return '—';
|
||||
if (v < 1) return `${(v * 1000).toFixed(0)} ms`;
|
||||
if (v < 60) return `${v.toFixed(2)} s`;
|
||||
if (v < 3600) return `${(v / 60).toFixed(2)} m`;
|
||||
return `${(v / 3600).toFixed(2)} h`;
|
||||
};
|
||||
|
||||
const StatBlock: React.FC<{ label: string; value: React.ReactNode; color?: string }> = ({
|
||||
label, value, color,
|
||||
}) => (
|
||||
<div className="stat-card">
|
||||
<div className="stat-value" style={{ color: color || 'var(--text-color)' }}>
|
||||
{value}
|
||||
</div>
|
||||
<div className="stat-label">{label}</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const KeyValueRow: React.FC<{ label: string; value: React.ReactNode }> = ({ label, value }) => (
|
||||
<div style={{ display: 'flex', gap: '12px', alignItems: 'baseline' }}>
|
||||
<span className="dim" style={{ fontSize: '0.7rem', letterSpacing: '1px', minWidth: '120px' }}>
|
||||
{label}
|
||||
</span>
|
||||
<span className="matrix-text" style={{ fontFamily: 'monospace', fontSize: '0.85rem' }}>
|
||||
{value}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
const BehaviorHeadline: React.FC<{ b: AttackerBehavior }> = ({ b }) => {
|
||||
const osLabel = b.os_guess ? (OS_LABELS[b.os_guess] || b.os_guess.toUpperCase()) : '—';
|
||||
const behaviorLabel = b.behavior_class ? b.behavior_class.toUpperCase() : 'UNKNOWN';
|
||||
const behaviorColor = b.behavior_class ? BEHAVIOR_COLORS[b.behavior_class] : undefined;
|
||||
const toolLabel = b.tool_guess ? (TOOL_LABELS[b.tool_guess] || b.tool_guess.toUpperCase()) : '—';
|
||||
return (
|
||||
<div className="stats-grid" style={{ gridTemplateColumns: 'repeat(4, 1fr)' }}>
|
||||
<StatBlock label="OS GUESS" value={osLabel} />
|
||||
<StatBlock label="HOP DISTANCE" value={fmtOpt(b.hop_distance)} />
|
||||
<StatBlock label="BEHAVIOR" value={behaviorLabel} color={behaviorColor} />
|
||||
<StatBlock
|
||||
label="TOOL ATTRIBUTION"
|
||||
value={toolLabel}
|
||||
color={b.tool_guess ? '#ff6b6b' : undefined}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const BeaconBlock: React.FC<{ b: AttackerBehavior }> = ({ b }) => {
|
||||
if (b.behavior_class !== 'beaconing' || b.beacon_interval_s === null) return null;
|
||||
return (
|
||||
<div style={{
|
||||
border: '1px solid var(--border-color)', padding: '12px 16px',
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '10px' }}>
|
||||
<Radio size={14} style={{ opacity: 0.6 }} />
|
||||
<span style={{ fontSize: '0.75rem', letterSpacing: '2px', fontWeight: 'bold' }}>
|
||||
BEACON CADENCE
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '32px', alignItems: 'baseline' }}>
|
||||
<div>
|
||||
<span className="dim" style={{ fontSize: '0.7rem' }}>INTERVAL </span>
|
||||
<span className="matrix-text" style={{ fontSize: '1.3rem', fontWeight: 'bold' }}>
|
||||
{fmtSecs(b.beacon_interval_s)}
|
||||
</span>
|
||||
</div>
|
||||
{b.beacon_jitter_pct !== null && (
|
||||
<div>
|
||||
<span className="dim" style={{ fontSize: '0.7rem' }}>JITTER </span>
|
||||
<span className="matrix-text" style={{ fontSize: '1.3rem', fontWeight: 'bold' }}>
|
||||
{b.beacon_jitter_pct.toFixed(1)}%
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const TcpStackBlock: React.FC<{ b: AttackerBehavior }> = ({ b }) => {
|
||||
const fp = b.tcp_fingerprint;
|
||||
if (!fp || (!fp.window && !fp.mss && !fp.options_sig)) return null;
|
||||
return (
|
||||
<div style={{
|
||||
border: '1px solid var(--border-color)', padding: '12px 16px',
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '10px' }}>
|
||||
<Wifi size={14} style={{ opacity: 0.6 }} />
|
||||
<span style={{ fontSize: '0.75rem', letterSpacing: '2px', fontWeight: 'bold' }}>
|
||||
TCP STACK (PASSIVE)
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '6px' }}>
|
||||
<div style={{ display: 'flex', gap: '24px', flexWrap: 'wrap' }}>
|
||||
{fp.window !== null && fp.window !== undefined && (
|
||||
<div>
|
||||
<span className="dim" style={{ fontSize: '0.7rem' }}>WIN </span>
|
||||
<span className="matrix-text" style={{ fontSize: '1.1rem', fontWeight: 'bold' }}>
|
||||
{fp.window}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{fp.wscale !== null && fp.wscale !== undefined && (
|
||||
<div>
|
||||
<span className="dim" style={{ fontSize: '0.7rem' }}>WSCALE </span>
|
||||
<span className="matrix-text" style={{ fontSize: '1.1rem', fontWeight: 'bold' }}>
|
||||
{fp.wscale}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{fp.mss !== null && fp.mss !== undefined && (
|
||||
<div>
|
||||
<span className="dim" style={{ fontSize: '0.7rem' }}>MSS </span>
|
||||
<span className="matrix-text" style={{ fontSize: '1.1rem' }}>{fp.mss}</span>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<span className="dim" style={{ fontSize: '0.7rem' }}>RETRANSMITS </span>
|
||||
<span
|
||||
className="matrix-text"
|
||||
style={{
|
||||
fontSize: '1.1rem',
|
||||
fontWeight: 'bold',
|
||||
color: b.retransmit_count > 0 ? '#e5c07b' : undefined,
|
||||
}}
|
||||
>
|
||||
{b.retransmit_count}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '8px', flexWrap: 'wrap' }}>
|
||||
{fp.has_sack && <Tag>SACK</Tag>}
|
||||
{fp.has_timestamps && <Tag>TS</Tag>}
|
||||
</div>
|
||||
{fp.options_sig && (
|
||||
<div>
|
||||
<span className="dim" style={{ fontSize: '0.7rem' }}>OPTS: </span>
|
||||
<span className="matrix-text" style={{ fontFamily: 'monospace', fontSize: '0.8rem' }}>
|
||||
{fp.options_sig}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const TimingStatsBlock: React.FC<{ b: AttackerBehavior }> = ({ b }) => {
|
||||
const s = b.timing_stats;
|
||||
if (!s || !s.event_count || s.event_count < 2) return null;
|
||||
return (
|
||||
<div style={{
|
||||
border: '1px solid var(--border-color)', padding: '12px 16px',
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '10px' }}>
|
||||
<Timer size={14} style={{ opacity: 0.6 }} />
|
||||
<span style={{ fontSize: '0.75rem', letterSpacing: '2px', fontWeight: 'bold' }}>
|
||||
INTER-EVENT TIMING
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '4px' }}>
|
||||
<KeyValueRow label="EVENT COUNT" value={s.event_count ?? '—'} />
|
||||
<KeyValueRow label="DURATION" value={fmtSecs(s.duration_s)} />
|
||||
<KeyValueRow label="MEAN IAT" value={fmtSecs(s.mean_iat_s)} />
|
||||
<KeyValueRow label="MEDIAN IAT" value={fmtSecs(s.median_iat_s)} />
|
||||
<KeyValueRow label="STDEV IAT" value={fmtSecs(s.stdev_iat_s)} />
|
||||
<KeyValueRow
|
||||
label="MIN / MAX IAT"
|
||||
value={`${fmtSecs(s.min_iat_s)} / ${fmtSecs(s.max_iat_s)}`}
|
||||
/>
|
||||
<KeyValueRow
|
||||
label="CV (JITTER)"
|
||||
value={s.cv !== null && s.cv !== undefined ? s.cv.toFixed(3) : '—'}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const PhaseSequenceBlock: React.FC<{ b: AttackerBehavior }> = ({ b }) => {
|
||||
const p = b.phase_sequence;
|
||||
if (!p || (!p.recon_end_ts && !p.exfil_start_ts && !p.large_payload_count)) return null;
|
||||
return (
|
||||
<div style={{
|
||||
border: '1px solid var(--border-color)', padding: '12px 16px',
|
||||
}}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '10px' }}>
|
||||
<Activity size={14} style={{ opacity: 0.6 }} />
|
||||
<span style={{ fontSize: '0.75rem', letterSpacing: '2px', fontWeight: 'bold' }}>
|
||||
PHASE SEQUENCE
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '4px' }}>
|
||||
<KeyValueRow
|
||||
label="RECON END"
|
||||
value={p.recon_end_ts ? new Date(p.recon_end_ts).toLocaleString() : '—'}
|
||||
/>
|
||||
<KeyValueRow
|
||||
label="EXFIL START"
|
||||
value={p.exfil_start_ts ? new Date(p.exfil_start_ts).toLocaleString() : '—'}
|
||||
/>
|
||||
<KeyValueRow label="RECON→EXFIL LATENCY" value={fmtSecs(p.exfil_latency_s)} />
|
||||
<KeyValueRow
|
||||
label="LARGE PAYLOADS"
|
||||
value={p.large_payload_count ?? 0}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ─── Collapsible section ────────────────────────────────────────────────────
|
||||
|
||||
const Section: React.FC<{
|
||||
@@ -352,6 +632,7 @@ const AttackerDetail: React.FC = () => {
|
||||
timeline: true,
|
||||
services: true,
|
||||
deckies: true,
|
||||
behavior: true,
|
||||
commands: true,
|
||||
fingerprints: true,
|
||||
});
|
||||
@@ -543,6 +824,29 @@ const AttackerDetail: React.FC = () => {
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{/* Behavioral Profile */}
|
||||
<Section
|
||||
title="BEHAVIORAL PROFILE"
|
||||
open={openSections.behavior}
|
||||
onToggle={() => toggle('behavior')}
|
||||
>
|
||||
{attacker.behavior ? (
|
||||
<div style={{ padding: '16px', display: 'flex', flexDirection: 'column', gap: '16px' }}>
|
||||
<BehaviorHeadline b={attacker.behavior} />
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
|
||||
<BeaconBlock b={attacker.behavior} />
|
||||
<TcpStackBlock b={attacker.behavior} />
|
||||
<TimingStatsBlock b={attacker.behavior} />
|
||||
<PhaseSequenceBlock b={attacker.behavior} />
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ padding: '24px', textAlign: 'center', opacity: 0.5 }}>
|
||||
NO BEHAVIORAL DATA YET — PROFILER HAS NOT RUN FOR THIS ATTACKER
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
{/* Commands */}
|
||||
{(() => {
|
||||
const cmdTotalPages = Math.ceil(cmdTotal / cmdLimit);
|
||||
|
||||
282
decnet_web/src/components/Config.css
Normal file
282
decnet_web/src/components/Config.css
Normal file
@@ -0,0 +1,282 @@
|
||||
.config-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.config-tabs {
|
||||
display: flex;
|
||||
gap: 0;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
background-color: var(--secondary-color);
|
||||
}
|
||||
|
||||
.config-tab {
|
||||
padding: 12px 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 0.75rem;
|
||||
letter-spacing: 1.5px;
|
||||
border: none;
|
||||
border-bottom: 2px solid transparent;
|
||||
background: transparent;
|
||||
color: var(--text-color);
|
||||
opacity: 0.5;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.config-tab:hover {
|
||||
opacity: 0.8;
|
||||
background: rgba(0, 255, 65, 0.03);
|
||||
box-shadow: none;
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.config-tab.active {
|
||||
opacity: 1;
|
||||
border-bottom-color: var(--accent-color);
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.config-panel {
|
||||
background-color: var(--secondary-color);
|
||||
border: 1px solid var(--border-color);
|
||||
padding: 32px;
|
||||
}
|
||||
|
||||
.config-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.config-field:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.config-label {
|
||||
font-size: 0.7rem;
|
||||
letter-spacing: 1px;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.config-value {
|
||||
font-size: 1.1rem;
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.config-input-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.config-input-row input {
|
||||
width: 120px;
|
||||
}
|
||||
|
||||
.config-input-row input[type="text"] {
|
||||
width: 160px;
|
||||
}
|
||||
|
||||
.preset-buttons {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.preset-btn {
|
||||
padding: 6px 14px;
|
||||
font-size: 0.75rem;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.preset-btn.active {
|
||||
opacity: 1;
|
||||
border-color: var(--accent-color);
|
||||
color: var(--accent-color);
|
||||
}
|
||||
|
||||
.save-btn {
|
||||
padding: 8px 20px;
|
||||
font-weight: bold;
|
||||
letter-spacing: 1px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.save-btn:disabled {
|
||||
opacity: 0.3;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* User Management Table */
|
||||
.users-table-container {
|
||||
overflow-x: auto;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.users-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.8rem;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.users-table th {
|
||||
padding: 12px 24px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
opacity: 0.5;
|
||||
font-weight: normal;
|
||||
font-size: 0.7rem;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
.users-table td {
|
||||
padding: 12px 24px;
|
||||
border-bottom: 1px solid rgba(48, 54, 61, 0.5);
|
||||
}
|
||||
|
||||
.users-table tr:hover {
|
||||
background-color: rgba(0, 255, 65, 0.03);
|
||||
}
|
||||
|
||||
.user-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
padding: 4px 10px;
|
||||
font-size: 0.7rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.action-btn.danger {
|
||||
border-color: #ff4141;
|
||||
color: #ff4141;
|
||||
}
|
||||
|
||||
.action-btn.danger:hover {
|
||||
background: #ff4141;
|
||||
color: var(--background-color);
|
||||
box-shadow: 0 0 10px rgba(255, 65, 65, 0.5);
|
||||
}
|
||||
|
||||
/* Add User Form */
|
||||
.add-user-section {
|
||||
border-top: 1px solid var(--border-color);
|
||||
padding-top: 24px;
|
||||
}
|
||||
|
||||
.add-user-form {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 16px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.add-user-form .form-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.add-user-form label {
|
||||
font-size: 0.65rem;
|
||||
letter-spacing: 1px;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.add-user-form input {
|
||||
width: 180px;
|
||||
}
|
||||
|
||||
.add-user-form select {
|
||||
background: #0d1117;
|
||||
border: 1px solid var(--border-color);
|
||||
color: var(--text-color);
|
||||
padding: 8px 12px;
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.add-user-form select:focus {
|
||||
outline: none;
|
||||
border-color: var(--text-color);
|
||||
box-shadow: var(--matrix-green-glow);
|
||||
}
|
||||
|
||||
.role-select {
|
||||
background: #0d1117;
|
||||
border: 1px solid var(--border-color);
|
||||
color: var(--text-color);
|
||||
padding: 4px 8px;
|
||||
font-family: inherit;
|
||||
font-size: 0.75rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.role-badge {
|
||||
font-size: 0.7rem;
|
||||
padding: 2px 8px;
|
||||
border: 1px solid;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.role-badge.admin {
|
||||
border-color: var(--accent-color);
|
||||
color: var(--accent-color);
|
||||
}
|
||||
|
||||
.role-badge.viewer {
|
||||
border-color: var(--border-color);
|
||||
color: var(--text-color);
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.must-change-badge {
|
||||
font-size: 0.65rem;
|
||||
color: #ffaa00;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.config-success {
|
||||
color: var(--text-color);
|
||||
font-size: 0.75rem;
|
||||
padding: 6px 12px;
|
||||
border: 1px solid var(--text-color);
|
||||
background: rgba(0, 255, 65, 0.1);
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.config-error {
|
||||
color: #ff4141;
|
||||
font-size: 0.75rem;
|
||||
padding: 6px 12px;
|
||||
border: 1px solid #ff4141;
|
||||
background: rgba(255, 65, 65, 0.1);
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.confirm-dialog {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.confirm-dialog span {
|
||||
color: #ff4141;
|
||||
}
|
||||
|
||||
.interval-hint {
|
||||
font-size: 0.65rem;
|
||||
opacity: 0.4;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
@@ -1,18 +1,516 @@
|
||||
import React from 'react';
|
||||
import { Settings } from 'lucide-react';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import api from '../utils/api';
|
||||
import { Settings, Users, Sliders, Trash2, UserPlus, Key, Save, Shield, AlertTriangle } from 'lucide-react';
|
||||
import './Dashboard.css';
|
||||
import './Config.css';
|
||||
|
||||
interface UserEntry {
|
||||
uuid: string;
|
||||
username: string;
|
||||
role: string;
|
||||
must_change_password: boolean;
|
||||
}
|
||||
|
||||
interface ConfigData {
|
||||
role: string;
|
||||
deployment_limit: number;
|
||||
global_mutation_interval: string;
|
||||
users?: UserEntry[];
|
||||
developer_mode?: boolean;
|
||||
}
|
||||
|
||||
const Config: React.FC = () => {
|
||||
const [config, setConfig] = useState<ConfigData | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [activeTab, setActiveTab] = useState<'limits' | 'users' | 'globals'>('limits');
|
||||
|
||||
// Deployment limit state
|
||||
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(() => {
|
||||
if (config && !config.users && activeTab === 'users') {
|
||||
setActiveTab('limits');
|
||||
}
|
||||
}, [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) {
|
||||
return (
|
||||
<div className="logs-section">
|
||||
<div className="loader">LOADING CONFIGURATION...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!config) {
|
||||
return (
|
||||
<div className="logs-section">
|
||||
<div style={{ padding: '40px', textAlign: 'center', opacity: 0.5 }}>
|
||||
<p>FAILED TO LOAD CONFIGURATION</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const tabs: { key: string; label: string; icon: React.ReactNode }[] = [
|
||||
{ key: 'limits', label: 'DEPLOYMENT LIMITS', icon: <Sliders size={14} /> },
|
||||
...(config.users
|
||||
? [{ key: 'users', label: 'USER MANAGEMENT', icon: <Users size={14} /> }]
|
||||
: []),
|
||||
{ key: 'globals', label: 'GLOBAL VALUES', icon: <Settings size={14} /> },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="logs-section">
|
||||
<div className="section-header">
|
||||
<Settings size={20} />
|
||||
<h2>SYSTEM CONFIGURATION</h2>
|
||||
<div className="config-page">
|
||||
<div className="logs-section">
|
||||
<div className="section-header">
|
||||
<Shield size={20} />
|
||||
<h2>SYSTEM CONFIGURATION</h2>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ padding: '40px', textAlign: 'center', opacity: 0.5 }}>
|
||||
<p>CONFIGURATION READ-ONLY MODE ACTIVE.</p>
|
||||
<p style={{ marginTop: '10px', fontSize: '0.8rem' }}>(Config view placeholder)</p>
|
||||
|
||||
<div className="config-tabs">
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
key={tab.key}
|
||||
className={`config-tab ${activeTab === tab.key ? 'active' : ''}`}
|
||||
onClick={() => setActiveTab(tab.key as any)}
|
||||
>
|
||||
{tab.icon}
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* DEPLOYMENT LIMITS TAB */}
|
||||
{activeTab === 'limits' && (
|
||||
<div className="config-panel">
|
||||
<div className="config-field">
|
||||
<span className="config-label">MAXIMUM DECKIES PER DEPLOYMENT</span>
|
||||
{isAdmin ? (
|
||||
<>
|
||||
<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 && (
|
||||
<div className="config-panel">
|
||||
<div className="users-table-container">
|
||||
<table className="users-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>USERNAME</th>
|
||||
<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' && (
|
||||
<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">{config.global_mutation_interval}</span>
|
||||
)}
|
||||
</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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -22,6 +22,7 @@ const DeckyFleet: React.FC = () => {
|
||||
const [showDeploy, setShowDeploy] = useState(false);
|
||||
const [iniContent, setIniContent] = useState('');
|
||||
const [deploying, setDeploying] = useState(false);
|
||||
const [isAdmin, setIsAdmin] = useState(false);
|
||||
|
||||
const fetchDeckies = async () => {
|
||||
try {
|
||||
@@ -34,6 +35,15 @@ const DeckyFleet: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const fetchRole = async () => {
|
||||
try {
|
||||
const res = await api.get('/config');
|
||||
setIsAdmin(res.data.role === 'admin');
|
||||
} catch {
|
||||
setIsAdmin(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMutate = async (name: string) => {
|
||||
setMutating(name);
|
||||
try {
|
||||
@@ -94,6 +104,7 @@ const DeckyFleet: React.FC = () => {
|
||||
|
||||
useEffect(() => {
|
||||
fetchDeckies();
|
||||
fetchRole();
|
||||
const _interval = setInterval(fetchDeckies, 10000); // Fleet state updates less frequently than logs
|
||||
return () => clearInterval(_interval);
|
||||
}, []);
|
||||
@@ -107,12 +118,14 @@ const DeckyFleet: React.FC = () => {
|
||||
<Server size={20} />
|
||||
<h2 style={{ margin: 0 }}>DECOY FLEET ASSET INVENTORY</h2>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowDeploy(!showDeploy)}
|
||||
style={{ display: 'flex', alignItems: 'center', gap: '8px', border: '1px solid var(--accent-color)', color: 'var(--accent-color)' }}
|
||||
>
|
||||
+ DEPLOY DECKIES
|
||||
</button>
|
||||
{isAdmin && (
|
||||
<button
|
||||
onClick={() => setShowDeploy(!showDeploy)}
|
||||
style={{ display: 'flex', alignItems: 'center', gap: '8px', border: '1px solid var(--accent-color)', color: 'var(--accent-color)' }}
|
||||
>
|
||||
+ DEPLOY DECKIES
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showDeploy && (
|
||||
@@ -186,24 +199,32 @@ const DeckyFleet: React.FC = () => {
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', fontSize: '0.85rem', marginTop: '8px' }}>
|
||||
<Clock size={14} className="dim" />
|
||||
<span className="dim">MUTATION:</span>
|
||||
<span
|
||||
style={{ color: 'var(--accent-color)', cursor: 'pointer', textDecoration: 'underline' }}
|
||||
onClick={() => handleIntervalChange(decky.name, decky.mutate_interval)}
|
||||
>
|
||||
{decky.mutate_interval ? `EVERY ${decky.mutate_interval}m` : 'DISABLED'}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => handleMutate(decky.name)}
|
||||
disabled={!!mutating}
|
||||
style={{
|
||||
background: 'transparent', border: '1px solid var(--accent-color)',
|
||||
color: 'var(--accent-color)', padding: '2px 8px', fontSize: '0.7rem',
|
||||
cursor: mutating ? 'not-allowed' : 'pointer', display: 'flex', alignItems: 'center', gap: '4px', marginLeft: 'auto',
|
||||
opacity: mutating ? 0.5 : 1
|
||||
}}
|
||||
>
|
||||
<RefreshCw size={10} className={mutating === decky.name ? "spin" : ""} /> {mutating === decky.name ? 'MUTATING...' : 'FORCE'}
|
||||
</button>
|
||||
{isAdmin ? (
|
||||
<span
|
||||
style={{ color: 'var(--accent-color)', cursor: 'pointer', textDecoration: 'underline' }}
|
||||
onClick={() => handleIntervalChange(decky.name, decky.mutate_interval)}
|
||||
>
|
||||
{decky.mutate_interval ? `EVERY ${decky.mutate_interval}m` : 'DISABLED'}
|
||||
</span>
|
||||
) : (
|
||||
<span style={{ color: 'var(--accent-color)' }}>
|
||||
{decky.mutate_interval ? `EVERY ${decky.mutate_interval}m` : 'DISABLED'}
|
||||
</span>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<button
|
||||
onClick={() => handleMutate(decky.name)}
|
||||
disabled={!!mutating}
|
||||
style={{
|
||||
background: 'transparent', border: '1px solid var(--accent-color)',
|
||||
color: 'var(--accent-color)', padding: '2px 8px', fontSize: '0.7rem',
|
||||
cursor: mutating ? 'not-allowed' : 'pointer', display: 'flex', alignItems: 'center', gap: '4px', marginLeft: 'auto',
|
||||
opacity: mutating ? 0.5 : 1
|
||||
}}
|
||||
>
|
||||
<RefreshCw size={10} className={mutating === decky.name ? "spin" : ""} /> {mutating === decky.name ? 'MUTATING...' : 'FORCE'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{decky.last_mutated > 0 && (
|
||||
<div style={{ fontSize: '0.7rem', color: 'var(--dim-color)', fontStyle: 'italic', marginTop: '4px' }}>
|
||||
|
||||
Reference in New Issue
Block a user