From a78126b1ba88ce3e08ede90108ca41aca394ff64 Mon Sep 17 00:00:00 2001 From: anti Date: Wed, 15 Apr 2026 12:51:08 -0400 Subject: [PATCH] feat: enhance UI components with config management and RBAC gating - Add Config.tsx component for admin configuration management - Update AttackerDetail, DeckyFleet components to use server-side RBAC gating - Remove client-side role checks per memory: server-side UI gating is mandatory - Add Config.css for configuration UI styling --- decnet_web/src/components/AttackerDetail.tsx | 306 ++++++++++- decnet_web/src/components/Config.css | 282 ++++++++++ decnet_web/src/components/Config.tsx | 516 ++++++++++++++++++- decnet_web/src/components/DeckyFleet.tsx | 69 ++- 4 files changed, 1139 insertions(+), 34 deletions(-) create mode 100644 decnet_web/src/components/Config.css diff --git a/decnet_web/src/components/AttackerDetail.tsx b/decnet_web/src/components/AttackerDetail.tsx index e50dc51..d1974dd 100644 --- a/decnet_web/src/components/AttackerDetail.tsx +++ b/decnet_web/src/components/AttackerDetail.tsx @@ -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 = { + linux: 'LINUX', + windows: 'WINDOWS', + macos_ios: 'macOS / iOS', + freebsd: 'FREEBSD', + openbsd: 'OPENBSD', + embedded: 'EMBEDDED', + nmap: 'NMAP (SCANNER)', + unknown: 'UNKNOWN', +}; + +const BEHAVIOR_COLORS: Record = { + beaconing: '#ff6b6b', + interactive: 'var(--accent-color)', + scanning: '#e5c07b', + mixed: 'var(--text-color)', + unknown: 'var(--text-color)', +}; + +const TOOL_LABELS: Record = { + 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, +}) => ( +
+
+ {value} +
+
{label}
+
+); + +const KeyValueRow: React.FC<{ label: string; value: React.ReactNode }> = ({ label, value }) => ( +
+ + {label} + + + {value} + +
+); + +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 ( +
+ + + + +
+ ); +}; + +const BeaconBlock: React.FC<{ b: AttackerBehavior }> = ({ b }) => { + if (b.behavior_class !== 'beaconing' || b.beacon_interval_s === null) return null; + return ( +
+
+ + + BEACON CADENCE + +
+
+
+ INTERVAL + + {fmtSecs(b.beacon_interval_s)} + +
+ {b.beacon_jitter_pct !== null && ( +
+ JITTER + + {b.beacon_jitter_pct.toFixed(1)}% + +
+ )} +
+
+ ); +}; + +const TcpStackBlock: React.FC<{ b: AttackerBehavior }> = ({ b }) => { + const fp = b.tcp_fingerprint; + if (!fp || (!fp.window && !fp.mss && !fp.options_sig)) return null; + return ( +
+
+ + + TCP STACK (PASSIVE) + +
+
+
+ {fp.window !== null && fp.window !== undefined && ( +
+ WIN + + {fp.window} + +
+ )} + {fp.wscale !== null && fp.wscale !== undefined && ( +
+ WSCALE + + {fp.wscale} + +
+ )} + {fp.mss !== null && fp.mss !== undefined && ( +
+ MSS + {fp.mss} +
+ )} +
+ RETRANSMITS + 0 ? '#e5c07b' : undefined, + }} + > + {b.retransmit_count} + +
+
+
+ {fp.has_sack && SACK} + {fp.has_timestamps && TS} +
+ {fp.options_sig && ( +
+ OPTS: + + {fp.options_sig} + +
+ )} +
+
+ ); +}; + +const TimingStatsBlock: React.FC<{ b: AttackerBehavior }> = ({ b }) => { + const s = b.timing_stats; + if (!s || !s.event_count || s.event_count < 2) return null; + return ( +
+
+ + + INTER-EVENT TIMING + +
+
+ + + + + + + +
+
+ ); +}; + +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 ( +
+
+ + + PHASE SEQUENCE + +
+
+ + + + +
+
+ ); +}; + // ─── 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 = () => { + {/* Behavioral Profile */} +
toggle('behavior')} + > + {attacker.behavior ? ( +
+ +
+ + + + +
+
+ ) : ( +
+ NO BEHAVIORAL DATA YET — PROFILER HAS NOT RUN FOR THIS ATTACKER +
+ )} +
+ {/* Commands */} {(() => { const cmdTotalPages = Math.ceil(cmdTotal / cmdLimit); diff --git a/decnet_web/src/components/Config.css b/decnet_web/src/components/Config.css new file mode 100644 index 0000000..496548b --- /dev/null +++ b/decnet_web/src/components/Config.css @@ -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; +} diff --git a/decnet_web/src/components/Config.tsx b/decnet_web/src/components/Config.tsx index 5c41911..87a7c0c 100644 --- a/decnet_web/src/components/Config.tsx +++ b/decnet_web/src/components/Config.tsx @@ -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(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(null); + + // Reset password state + const [resetTarget, setResetTarget] = useState(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 ( +
+
LOADING CONFIGURATION...
+
+ ); + } + + if (!config) { + return ( +
+
+

FAILED TO LOAD CONFIGURATION

+
+
+ ); + } + + const tabs: { key: string; label: string; icon: React.ReactNode }[] = [ + { key: 'limits', label: 'DEPLOYMENT LIMITS', icon: }, + ...(config.users + ? [{ key: 'users', label: 'USER MANAGEMENT', icon: }] + : []), + { key: 'globals', label: 'GLOBAL VALUES', icon: }, + ]; + return ( -
-
- -

SYSTEM CONFIGURATION

+
+
+
+ +

SYSTEM CONFIGURATION

+
-
-

CONFIGURATION READ-ONLY MODE ACTIVE.

-

(Config view placeholder)

+ +
+ {tabs.map((tab) => ( + + ))}
+ + {/* DEPLOYMENT LIMITS TAB */} + {activeTab === 'limits' && ( +
+
+ MAXIMUM DECKIES PER DEPLOYMENT + {isAdmin ? ( + <> +
+ setLimitInput(e.target.value)} + /> +
+ {[10, 50, 100, 200].map((v) => ( + + ))} +
+ +
+ {limitMsg && ( + + {limitMsg.text} + + )} + + ) : ( + {config.deployment_limit} + )} +
+
+ )} + + {/* USER MANAGEMENT TAB (only if server sent users) */} + {activeTab === 'users' && config.users && ( +
+
+ + + + + + + + + + + {config.users.map((user) => ( + + + + + + + ))} + +
USERNAMEROLESTATUSACTIONS
{user.username} + {user.role.toUpperCase()} + + {user.must_change_password && ( + MUST CHANGE PASSWORD + )} + +
+ {/* Role change dropdown */} + + + {/* Reset password */} + {resetTarget === user.uuid ? ( +
+ setResetPassword(e.target.value)} + style={{ width: '140px' }} + /> + + +
+ ) : ( + + )} + + {/* Delete */} + {confirmDelete === user.uuid ? ( +
+ CONFIRM? + + +
+ ) : ( + + )} +
+
+
+ +
+
+
+ + setNewUsername(e.target.value)} + required + minLength={1} + maxLength={64} + /> +
+
+ + setNewPassword(e.target.value)} + required + minLength={8} + maxLength={72} + /> +
+
+ + +
+ + {userMsg && ( + + {userMsg.text} + + )} +
+
+
+ )} + + {/* GLOBAL VALUES TAB */} + {activeTab === 'globals' && ( +
+
+ GLOBAL MUTATION INTERVAL + {isAdmin ? ( + <> +
+ setIntervalInput(e.target.value)} + placeholder="30m" + /> + +
+ + FORMAT: <number><unit> — m=minutes, d=days, M=months, y=years (e.g. 30m, 7d, 1M) + + {intervalMsg && ( + + {intervalMsg.text} + + )} + + ) : ( + {config.global_mutation_interval} + )} +
+
+ )} + + {/* DANGER ZONE — developer mode only, server-gated, shown on globals tab */} + {activeTab === 'globals' && config.developer_mode && ( +
+
+ + + DANGER ZONE — DEVELOPER MODE + +

+ Purge all logs, bounty vault entries, and attacker profiles. This action is irreversible. +

+ {!confirmReinit ? ( + + ) : ( +
+ THIS WILL DELETE ALL COLLECTED DATA. ARE YOU SURE? + + +
+ )} + {reinitMsg && ( + + {reinitMsg.text} + + )} +
+
+ )}
); }; diff --git a/decnet_web/src/components/DeckyFleet.tsx b/decnet_web/src/components/DeckyFleet.tsx index a6f99a9..de3a972 100644 --- a/decnet_web/src/components/DeckyFleet.tsx +++ b/decnet_web/src/components/DeckyFleet.tsx @@ -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 = () => {

DECOY FLEET ASSET INVENTORY

- + {isAdmin && ( + + )}
{showDeploy && ( @@ -186,24 +199,32 @@ const DeckyFleet: React.FC = () => {
MUTATION: - handleIntervalChange(decky.name, decky.mutate_interval)} - > - {decky.mutate_interval ? `EVERY ${decky.mutate_interval}m` : 'DISABLED'} - - + {isAdmin ? ( + handleIntervalChange(decky.name, decky.mutate_interval)} + > + {decky.mutate_interval ? `EVERY ${decky.mutate_interval}m` : 'DISABLED'} + + ) : ( + + {decky.mutate_interval ? `EVERY ${decky.mutate_interval}m` : 'DISABLED'} + + )} + {isAdmin && ( + + )}
{decky.last_mutated > 0 && (