Files
IT-Nexus/frontend/src/pages/AgentDetailPage.jsx
Simon Grüssing 81b1c326fc
Some checks failed
IT Nexus Deploy / Build Frontend (push) Has been cancelled
IT Nexus Deploy / Deploy to Production (push) Has been cancelled
Security: WS-Rollenprüfung, JWT-Cookie statt localStorage, XSS/SSRF-Fixes, RDP-Consent-Secret
- WebSocket Shell/RDP: Rollenprüfung statt nur JWT-Gültigkeit (war: jeder eingeloggte User konnte fremde Agents per Shell/RDP übernehmen)
- JWT_SECRET: Server bricht ab statt mit unsicherem Default weiterzulaufen
- Auth: Token läuft jetzt über httpOnly-Cookie statt localStorage (XSS-Schutz gegen Session-Diebstahl)
- WS-Auth: Token nicht mehr als URL-Query-Param (landete in nginx-Logs), sondern als erste Message bzw. automatisch via Cookie
- Frontend: toter Rollen-Check (isSuperAdmin/isAdmin ohne Funktionsaufruf) in AgentDetailPage gefixt
- XSS: DOMPurify-Sanitizing für alle marked.parse()-Renderstellen (KI-Antworten, Kommentare, Knowledge Base)
- E-Mail: HTML-Escaping für alle ticket-gesteuerten Felder (auch über öffentliche Ticket-Route erreichbar)
- SSRF-Schutz beim Knowledge-Base-URL-Import (blockt private/Loopback-Adressen)
- TV-Dashboard: Shared-Key statt komplett offenem Endpoint
- Striktes Rate-Limit auf /login, must_change_password serverseitig erzwungen
- Agent (C#) v2.7.0: RDP-Consent/Disconnect/Capture verlangen jetzt ein Pro-Session-Secret (war: jeder lokale Prozess konnte Consent vortäuschen), DataDir-ACL für agent.log/status.json
- FIDO-PINs AES-256-GCM-verschlüsselt statt Klartext, Retention-Job für alte patch_commands/audit_log

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-26 13:27:17 +02:00

836 lines
51 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { useState, useEffect, useRef } from 'react';
import { useParams, useNavigate, Link } from 'react-router-dom';
import monitoringService from '../services/monitoringService';
import assetService from '../services/assetService';
import api from '../services/api';
import { toast } from 'react-toastify';
import { useAuth } from '../context/AuthContext';
import RemoteDesktopPanel from '../components/common/RemoteDesktopPanel';
// ─── CSS Variables injected inline (design from Device Detail.html) ─────────────
const C = {
teal: '#14b8a8',
teal2: '#2dd2c2',
tealDim: 'rgba(20,184,168,0.14)',
success: '#22c55e',
warning: '#f59e0b',
danger: '#ef4444',
info: '#3b82f6',
purple: '#7c3aed',
};
// ─── Helpers ────────────────────────────────────────────────────────────────────
const timeAgo = (iso) => {
if (!iso) return '';
const m = Math.floor((Date.now() - new Date(iso).getTime()) / 60000);
if (m < 1) return 'gerade eben';
if (m < 60) return `vor ${m} Min`;
if (m < 1440) return `vor ${Math.floor(m / 60)} Std`;
return `vor ${Math.floor(m / 1440)} Tagen`;
};
const uptimeStr = (h) => {
if (!h) return '';
const d = Math.floor(h / 24), rh = Math.floor(h % 24), rm = Math.floor((h * 60) % 60);
if (d > 0) return `${d}d ${rh}h ${rm}min`;
return `${rh}h ${rm}min`;
};
const pct = (used, total) => (total > 0 ? Math.min(100, Math.round((used / total) * 100)) : 0);
const barColor = (p, warn = 70, crit = 90) => {
if (p >= crit) return C.danger;
if (p >= warn) return C.warning;
return C.teal;
};
const colorFor = (name) => {
let h = 0;
for (const c of name) h = (h * 31 + c.charCodeAt(0)) >>> 0;
const hues = [200, 220, 260, 280, 170, 150, 35, 20, 0, 320];
return `hsl(${hues[h % hues.length]}, 60%, 50%)`;
};
const copyToClipboard = (text) => {
navigator.clipboard.writeText(text).then(() => toast.success('Kopiert!', { autoClose: 1200 }));
};
// ─── Sub-components ─────────────────────────────────────────────────────────────
const MetricTile = ({ label, icon, value, unit, of: ofVal, footnote, pctVal, warnAt = 70, critAt = 90 }) => {
const p = pctVal ?? 0;
const col = barColor(p, warnAt, critAt);
return (
<div style={{
background: 'var(--bg-secondary)', border: '1px solid var(--border-color)',
borderRadius: 18, padding: '22px 24px', display: 'flex', flexDirection: 'column', gap: 14,
boxShadow: '0 1px 2px rgba(0,0,0,0.35)',
}}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 9, fontSize: 11.5, fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.10em', color: 'var(--text-muted)' }}>
<span style={{ width: 26, height: 26, borderRadius: 7, background: 'var(--bg-tertiary)', display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 13 }}>{icon}</span>
{label}
</div>
<span style={{ fontSize: 13.5, fontWeight: 600, color: col, fontVariantNumeric: 'tabular-nums' }}>{p} %</span>
</div>
<div style={{ display: 'flex', alignItems: 'baseline', gap: 8, fontVariantNumeric: 'tabular-nums' }}>
<span style={{ fontSize: 36, fontWeight: 600, letterSpacing: '-0.03em', lineHeight: 1, color: 'var(--text-primary)' }}>{value}</span>
{unit && <span style={{ fontSize: 14, color: 'var(--text-muted)', fontWeight: 500 }}>{unit}</span>}
{ofVal && <span style={{ fontSize: 14, color: 'var(--text-muted)' }}>{ofVal}</span>}
</div>
<div style={{ height: 6, borderRadius: 99, background: 'var(--bg-tertiary)', overflow: 'hidden' }}>
<div style={{ height: '100%', width: `${p}%`, borderRadius: 99, background: col, transition: 'width .4s ease' }} />
</div>
{footnote && (
<div style={{ fontSize: 12, color: 'var(--text-muted)', display: 'flex', alignItems: 'center', gap: 6 }}>
{footnote}
</div>
)}
</div>
);
};
const SecRow = ({ ok, warn: isWarn, name, desc, state }) => {
const bg = isWarn ? 'rgba(245,158,11,0.16)' : ok ? 'rgba(34,197,94,0.14)' : 'rgba(239,68,68,0.14)';
const color = isWarn ? C.warning : ok ? C.success : C.danger;
return (
<div style={{ display: 'flex', alignItems: 'center', gap: 14, padding: '12px 22px', borderTop: '1px solid var(--border-color)' }}>
<div style={{ width: 28, height: 28, borderRadius: 8, background: bg, color, display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0, fontSize: 14 }}>
{ok && !isWarn ? '✓' : isWarn ? '!' : '✗'}
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 13.5, fontWeight: 600, color: 'var(--text-primary)' }}>{name}</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginTop: 1 }}>{desc}</div>
</div>
<span style={{
fontSize: 11.5, fontWeight: 600, color,
padding: '3px 9px', borderRadius: 999,
background: bg,
}}>{state}</span>
</div>
);
};
const DlRow = ({ label, value, mono, copyVal }) => (
<div style={{ display: 'grid', gridTemplateColumns: '150px 1fr', gap: 16, padding: '11px 22px', alignItems: 'center', borderTop: '1px solid var(--border-color)' }}>
<div style={{ fontSize: 12.5, color: 'var(--text-muted)', fontWeight: 500 }}>{label}</div>
<div style={{ fontSize: 13.5, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', fontFamily: mono ? 'ui-monospace, monospace' : 'inherit', fontSize: mono ? 12.5 : 13.5 }}>
{value}
{copyVal && (
<button onClick={() => copyToClipboard(copyVal)} title="Kopieren" style={{ marginLeft: 6, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-muted)', padding: 0, fontSize: 11, opacity: 0.6 }}></button>
)}
</div>
</div>
);
const CardHead = ({ icon, title, meta }) => (
<div style={{ display: 'flex', alignItems: 'center', gap: 12, padding: '18px 22px', borderBottom: '1px solid var(--border-color)' }}>
<div style={{ width: 30, height: 30, borderRadius: 8, background: 'var(--bg-tertiary)', color: 'var(--text-muted)', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0, fontSize: 15 }}>
{icon}
</div>
<h2 style={{ margin: 0, fontSize: 14, fontWeight: 600, letterSpacing: '-0.005em', color: 'var(--text-primary)' }}>{title}</h2>
{meta && <span style={{ marginLeft: 'auto', fontSize: 12, color: 'var(--text-muted)' }}>{meta}</span>}
</div>
);
const Btn = ({ onClick, children, variant = 'default', disabled }) => {
const styles = {
default: { background: 'var(--bg-secondary)', color: 'var(--text-primary)', border: '1px solid var(--border-color)' },
primary: { background: C.teal, color: '#fff', border: `1px solid ${C.teal}` },
danger: { background: 'var(--bg-secondary)', color: C.danger, border: `1px solid rgba(239,68,68,0.30)` },
};
return (
<button onClick={onClick} disabled={disabled} style={{
...styles[variant],
borderRadius: 10, padding: '9px 14px', fontSize: 13, fontWeight: 500,
cursor: disabled ? 'not-allowed' : 'pointer', display: 'inline-flex', alignItems: 'center', gap: 7,
whiteSpace: 'nowrap', transition: 'background .14s', opacity: disabled ? 0.5 : 1,
fontFamily: 'inherit',
}}>
{children}
</button>
);
};
// ─── Remote Shell (WebSocket Live Terminal) ───────────────────────────────────
const API = process.env.REACT_APP_API_URL || '/api';
const authFetch = (url, opts = {}) => {
const token = localStorage.getItem('token');
return fetch(url, { ...opts, headers: { ...(opts.headers || {}), Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' } });
};
// Einfacher ANSI-Code-Stripper für die Anzeige ohne xterm.js
function stripAnsi(str) {
// eslint-disable-next-line no-control-regex
return str.replace(/\x1b\[[0-9;]*[mGKHF]/g, '').replace(/\x1b\[[0-9;]*[A-Z]/g, '');
}
function RemoteShell({ agentId, agentHostname }) {
const [input, setInput] = useState('');
const [output, setOutput] = useState('');
const [status, setStatus] = useState('disconnected'); // disconnected | connecting | connected
const outputRef = useRef(null);
const wsRef = useRef(null);
const inputRef = useRef(null);
const getWsUrl = () => {
const proto = window.location.protocol === 'https:' ? 'wss' : 'ws';
const host = window.location.host;
return `${proto}://${host}/ws?type=shell&agentId=${agentId}`;
};
const connect = () => {
if (wsRef.current && wsRef.current.readyState <= 1) return;
setStatus('connecting');
setOutput('');
const ws = new WebSocket(getWsUrl());
wsRef.current = ws;
ws.onopen = () => {
ws.send(JSON.stringify({ type: 'auth', token: localStorage.getItem('token') }));
setStatus('connected');
};
ws.onmessage = (e) => {
setOutput(prev => prev + stripAnsi(e.data));
setTimeout(() => {
if (outputRef.current) outputRef.current.scrollTop = outputRef.current.scrollHeight;
}, 10);
};
ws.onclose = () => {
setStatus('disconnected');
setOutput(prev => prev + '\r\n[Verbindung getrennt]\r\n');
};
ws.onerror = () => {
setStatus('disconnected');
};
};
const disconnect = () => {
wsRef.current?.close();
wsRef.current = null;
};
// Beim Unmount trennen
useEffect(() => { return () => disconnect(); }, []);
const send = () => {
if (!input.trim() || !wsRef.current || wsRef.current.readyState !== 1) return;
wsRef.current.send(input + '\n');
setInput('');
};
const onKey = (e) => {
if (e.key === 'Enter') { e.preventDefault(); send(); }
if (e.key === 'c' && e.ctrlKey) {
wsRef.current?.send('\x03'); // Ctrl+C
e.preventDefault();
}
};
const statusColor = status === 'connected' ? '#34d399' : status === 'connecting' ? '#f59e0b' : '#6b7280';
const statusLabel = status === 'connected' ? 'Verbunden' : status === 'connecting' ? 'Verbinde…' : 'Getrennt';
return (
<div style={{ marginTop: 24, borderRadius: 16, border: '1px solid var(--border-color)', overflow: 'hidden', background: 'var(--bg-secondary)' }}>
<div style={{ padding: '14px 20px', borderBottom: '1px solid var(--border-color)', display: 'flex', alignItems: 'center', gap: 10 }}>
<span style={{ fontSize: 15 }}>🖥</span>
<span style={{ fontWeight: 600, fontSize: 14, color: 'var(--text-primary)' }}>Remote Shell</span>
<span style={{ fontSize: 12, color: 'var(--text-muted)', marginLeft: 4 }}>{agentHostname} · PowerShell (SYSTEM)</span>
<span style={{ marginLeft: 8, display: 'flex', alignItems: 'center', gap: 5, fontSize: 12 }}>
<span style={{ width: 8, height: 8, borderRadius: '50%', background: statusColor, display: 'inline-block' }} />
<span style={{ color: statusColor }}>{statusLabel}</span>
</span>
<div style={{ marginLeft: 'auto', display: 'flex', gap: 8 }}>
{status === 'disconnected' ? (
<button onClick={connect} style={{ background: '#238636', border: 'none', borderRadius: 8, color: '#fff', padding: '5px 14px', fontSize: 12, fontWeight: 600, cursor: 'pointer' }}>
Verbinden
</button>
) : (
<button onClick={disconnect} style={{ background: '#21262d', border: '1px solid #30363d', borderRadius: 8, color: '#cdd9e5', padding: '5px 14px', fontSize: 12, cursor: 'pointer' }}>
Trennen
</button>
)}
<button onClick={() => setOutput('')} style={{ background: '#21262d', border: '1px solid #30363d', borderRadius: 8, color: '#8b949e', padding: '5px 10px', fontSize: 12, cursor: 'pointer' }}>
Leeren
</button>
</div>
</div>
<div
ref={outputRef}
onClick={() => inputRef.current?.focus()}
style={{ fontFamily: 'ui-monospace, Cascadia Code, Consolas, monospace', fontSize: 12.5, lineHeight: 1.6, padding: '16px 20px', minHeight: 240, maxHeight: 480, overflowY: 'auto', background: '#0d1117', color: '#e6edf3', cursor: 'text', whiteSpace: 'pre-wrap', wordBreak: 'break-all' }}
>
{output || <span style={{ color: '#8b949e' }}>{status === 'disconnected' ? 'Auf "Verbinden" klicken um eine Shell-Sitzung zu starten.' : 'Warte auf Agent…'}</span>}
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '10px 16px', borderTop: '1px solid #21262d', background: '#161b22' }}>
<span style={{ color: statusColor, fontFamily: 'monospace', fontSize: 13, whiteSpace: 'nowrap' }}>PS&gt;</span>
<input
ref={inputRef}
value={input}
onChange={e => setInput(e.target.value)}
onKeyDown={onKey}
disabled={status !== 'connected'}
placeholder={status === 'connected' ? 'Befehl eingeben… (Enter = Senden, Ctrl+C = Abbrechen)' : 'Nicht verbunden'}
style={{ flex: 1, background: 'transparent', border: 'none', outline: 'none', color: '#e6edf3', fontFamily: 'ui-monospace, monospace', fontSize: 13, opacity: status !== 'connected' ? 0.4 : 1 }}
/>
<button
onClick={send}
disabled={status !== 'connected' || !input.trim()}
style={{ background: C.teal, border: 'none', borderRadius: 8, color: '#fff', padding: '6px 14px', fontSize: 13, fontWeight: 600, cursor: status !== 'connected' ? 'not-allowed' : 'pointer', opacity: status !== 'connected' ? 0.4 : 1 }}
>
Senden
</button>
</div>
</div>
);
}
// ─── Remote Desktop (via RemoteDesktopPanel) ─────────────────────────────────────
function RemoteDesktop({ agentId, agentHostname }) {
return (
<RemoteDesktopPanel agentId={agentId} agentHostname={agentHostname} />
);
}
// ─── Main Page ───────────────────────────────────────────────────────────────────
export default function AgentDetailPage() {
const { id, hostname } = useParams();
const navigate = useNavigate();
const { isSuperAdmin, isAdmin } = useAuth();
const [agent, setAgent] = useState(null);
const [asset, setAsset] = useState(null);
const [assignedUser, setAssignedUser] = useState(null);
const [loading, setLoading] = useState(true);
const [swQuery, setSwQuery] = useState('');
const [sendingAnn, setSendingAnn] = useState(false);
const [annText, setAnnText] = useState('');
const [showAnnModal, setShowAnnModal] = useState(false);
const [patchHistory, setPatchHistory] = useState([]);
const [shellTab, setShellTab] = useState('shell'); // 'shell' | 'rdp'
const [countdown, setCountdown] = useState(60);
const agentRef = useRef(null);
const refreshTimer = useRef(null);
const countdownTimer = useRef(null);
useEffect(() => {
loadAgent();
return () => {
clearTimeout(refreshTimer.current);
clearInterval(countdownTimer.current);
};
}, [id, hostname]);
const loadAgent = async (silent = false) => {
try {
if (!silent) setLoading(true);
let data;
if (hostname) {
const all = await monitoringService.getAll();
data = all.find(a => a.hostname?.toLowerCase() === decodeURIComponent(hostname).toLowerCase());
if (!data) throw new Error('not found');
} else {
data = await monitoringService.getById(id);
}
setAgent(data);
agentRef.current = data;
// Countdown neu starten basierend auf last_checkin
clearInterval(countdownTimer.current);
const startCountdown = () => {
const secSince = data.last_checkin
? Math.floor((Date.now() - new Date(data.last_checkin).getTime()) / 1000)
: 60;
let remaining = Math.max(0, 60 - (secSince % 60));
setCountdown(remaining);
countdownTimer.current = setInterval(() => {
setCountdown(p => {
if (p <= 1) { loadAgent(true); return 60; }
return p - 1;
});
}, 1000);
};
startCountdown();
// Patch History laden
try {
const ph = await api.get(`/patch/commands?agent_id=${data.id}`);
setPatchHistory(ph.data || []);
} catch {}
// Asset + User nur beim ersten Laden
if (!silent) {
try {
const assets = await assetService.getAll();
const match = assets.find(a => a.name?.toLowerCase() === data.hostname?.toLowerCase());
if (match) {
setAsset(match);
if (match.assigned_to_user_id) {
const userRes = await api.get(`/users/${match.assigned_to_user_id}`);
setAssignedUser(userRes.data.data);
}
}
} catch {}
}
} catch {
if (!silent) {
toast.error('Agent nicht gefunden');
navigate('/monitoring');
}
} finally {
if (!silent) setLoading(false);
}
};
const handleSendCommand = async (command) => {
try {
await api.post('/patch/commands/trigger', { agent_id: agent.id, command });
toast.success('Befehl gesendet');
} catch {
toast.error('Fehler beim Senden');
}
};
const handleSendAnnouncement = async () => {
if (!annText.trim()) return;
setSendingAnn(true);
try {
await api.post('/announcements', {
title: 'Nachricht vom IT-Team',
message: annText,
type: 'info',
target_type: 'specific',
target_agent_ids: [agent.id],
});
toast.success('Ankündigung gesendet');
setShowAnnModal(false);
setAnnText('');
} catch {
toast.error('Fehler beim Senden');
} finally {
setSendingAnn(false);
}
};
if (loading) return (
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: '60vh', color: 'var(--text-muted)' }}>
Lade Gerätedaten
</div>
);
if (!agent) return null;
const sw = agent.installed_software || [];
const filteredSw = sw.filter(s => {
if (!swQuery) return true;
const q = swQuery.toLowerCase();
const nm = typeof s === 'string' ? s : (s.name || s.Name || s.DisplayName || '');
const pub = typeof s === 'string' ? '' : (s.publisher || s.Publisher || '');
return nm.toLowerCase().includes(q) || pub.toLowerCase().includes(q);
});
const cpuPct = agent.cpu_usage_percent ?? 0;
const ramPct = pct(agent.ram_used_gb, agent.ram_total_gb);
const diskUsed = (agent.disk_total_gb ?? 0) - (agent.disk_free_gb ?? 0);
const diskPct = pct(diskUsed, agent.disk_total_gb);
const bitlockerOk = agent.bitlocker_status === 'on' || agent.bitlocker_status === 'encrypted';
const defenderOk = agent.defender_enabled === 1;
const sigAge = agent.defender_signatures_age >= 0 ? agent.defender_signatures_age : null;
const defenderWarn = defenderOk && sigAge !== null && sigAge > 7;
const tpmOk = agent.tpm_present === 1;
const tpmVersion = agent.tpm_version ? agent.tpm_version.split(',')[0].trim() : null;
const secureBootOk = agent.secure_boot === 1;
const win11Ok = agent.win11_ready === 1;
const isWorkgroup = !agent.domain || agent.domain === 'WORKGROUP';
const allSecOk = bitlockerOk && defenderOk && !defenderWarn && tpmOk && secureBootOk;
const userInitials = assignedUser
? ((assignedUser.first_name?.[0] || '') + (assignedUser.last_name?.[0] || '')).toUpperCase() || assignedUser.username?.[0]?.toUpperCase()
: null;
return (
<div style={{ maxWidth: 1320, margin: '0 auto', padding: '24px 28px 80px' }}>
{/* Back link */}
<Link to={hostname ? '/assets' : '/monitoring'} style={{ display: 'inline-flex', alignItems: 'center', gap: 6, color: 'var(--text-muted)', fontSize: 13, textDecoration: 'none', marginBottom: 20 }}>
Zurück zu {hostname ? 'Assets' : 'Monitoring'}
</Link>
{/* Offline Banner */}
{agent.status === 'offline' && (
<div style={{ background: 'rgba(239,68,68,0.12)', border: '1px solid rgba(239,68,68,0.35)', borderRadius: 12, padding: '12px 18px', marginBottom: 20, display: 'flex', alignItems: 'center', gap: 10, color: C.danger, fontSize: 13.5, fontWeight: 500 }}>
Dieses Gerät ist offline Daten vom letzten Check-in ({timeAgo(agent.last_checkin)})
</div>
)}
{/* ── HERO ─────────────────────────────────────────────────────────── */}
<div style={{ display: 'grid', gridTemplateColumns: '1fr auto', gap: 24, alignItems: 'flex-end', marginBottom: 28, paddingBottom: 28, borderBottom: '1px solid var(--border-color)' }}>
<div>
<div style={{ display: 'inline-flex', alignItems: 'center', gap: 8, fontSize: 11, fontWeight: 600, color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: '0.10em', marginBottom: 6 }}>
<span style={{ color: C.purple }}>Workstation · Windows</span>
{agent.domain && <span>· {agent.domain}</span>}
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 14, flexWrap: 'wrap' }}>
<div style={{ width: 48, height: 48, borderRadius: 12, background: 'rgba(124,58,237,0.14)', color: C.purple, display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 22, flexShrink: 0 }}>
🖥
</div>
<h1 style={{ margin: 0, fontSize: 32, fontWeight: 600, letterSpacing: '-0.025em', lineHeight: 1.1, color: 'var(--text-primary)' }}>
{agent.hostname}
</h1>
<span style={{
display: 'inline-flex', alignItems: 'center', gap: 6,
padding: '4px 11px', borderRadius: 999, fontSize: 12, fontWeight: 600,
background: agent.status === 'online' ? 'rgba(34,197,94,0.12)' : 'rgba(239,68,68,0.14)',
color: agent.status === 'online' ? C.success : C.danger,
}}>
<span style={{
width: 7, height: 7, borderRadius: '50%', background: 'currentColor',
...(agent.status === 'online' ? { animation: 'agPulse 1.6s ease-out infinite' } : {}),
}} />
{agent.status === 'online' ? 'Online' : 'Offline'}
</span>
</div>
<div style={{ fontSize: 13.5, color: 'var(--text-muted)', marginTop: 8, display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
<span>Letzter Check-in <strong style={{ color: 'var(--text-secondary)' }}>{timeAgo(agent.last_checkin)}</strong></span>
<span>·</span>
<span>Nächster in <strong style={{ color: countdown <= 10 ? C.warning : 'var(--text-secondary)', fontFamily: 'ui-monospace, monospace' }}>{countdown}s</strong></span>
<span>·</span>
<span>Agent <strong style={{ color: 'var(--text-secondary)' }}>v{agent.agent_version || ''}</strong></span>
{agent.ip_address && <><span>·</span><span style={{ fontFamily: 'ui-monospace, monospace', fontSize: 12.5 }}>{agent.ip_address}</span></>}
</div>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
<Btn onClick={() => setShowAnnModal(true)}>📢 Ankündigung senden</Btn>
<Btn onClick={() => handleSendCommand('check_updates')}>🔄 Updates prüfen</Btn>
{agent.ip_address && (
<Btn onClick={() => window.open(`rdp://${agent.ip_address}`, '_blank') || (window.location.href = `ms-rd:openLocalSubnetRDP?computer=${agent.ip_address}`)}>
🖥 RDP
</Btn>
)}
<Btn variant="danger" onClick={() => {
if (window.confirm(`Reboot für ${agent.hostname} anfordern?`)) handleSendCommand('reboot');
}}> Reboot anfordern</Btn>
</div>
</div>
{/* ── METRIC TILES ─────────────────────────────────────────────────── */}
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 16, marginBottom: 22 }}>
<MetricTile
label="CPU" icon="⚙"
value={cpuPct.toFixed(0)} unit="%"
pctVal={cpuPct} warnAt={80} critAt={90}
footnote={<span style={{ fontFamily: 'ui-monospace, monospace', fontSize: 11.5, color: 'var(--text-secondary)' }}>{agent.cpu_model?.replace(/\(R\)|\(TM\)/g, '') || ''}{agent.cpu_cores ? ` · ${agent.cpu_cores} Kerne` : ''}</span>}
/>
<MetricTile
label="RAM" icon="💾"
value={agent.ram_used_gb?.toFixed(1) ?? ''} ofVal={`/ ${agent.ram_total_gb?.toFixed(0) ?? ''} GB`}
pctVal={ramPct} warnAt={85} critAt={95}
footnote={<span>{ramPct}% belegt · {((agent.ram_total_gb ?? 0) - (agent.ram_used_gb ?? 0)).toFixed(1)} GB frei</span>}
/>
<MetricTile
label="Festplatte C:" icon="🗄"
value={agent.disk_free_gb?.toFixed(0) ?? ''} ofVal={`GB frei von ${agent.disk_total_gb?.toFixed(0) ?? ''} GB`}
pctVal={diskPct} warnAt={75} critAt={90}
footnote={<span>{diskPct}% belegt</span>}
/>
</div>
{/* ── TWO COLUMNS: SYSTEM + SECURITY ───────────────────────────────── */}
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 16, marginBottom: 22 }}>
{/* System Info */}
<div style={{ background: 'var(--bg-secondary)', border: '1px solid var(--border-color)', borderRadius: 18, overflow: 'hidden', boxShadow: '0 1px 2px rgba(0,0,0,0.35)' }}>
<CardHead icon="🖥" title="Systeminformationen" meta="Hardware & OS" />
<div>
<DlRow label="Betriebssystem" value={agent.os_name || ''} />
<DlRow label="IP-Adresse" value={agent.ip_address || ''} mono copyVal={agent.ip_address} />
<DlRow label="MAC-Adresse" value={agent.mac_address || ''} mono copyVal={agent.mac_address} />
<DlRow label="Domain" value={agent.domain ? <>{agent.domain}{!isWorkgroup && <span style={{ color: 'var(--text-muted)', fontSize: 12 }}> (Active Directory)</span>}</> : ''} />
<DlRow label="Letzter Benutzer" value={agent.last_user ? <span style={{ fontFamily: 'ui-monospace, monospace', fontSize: 12.5 }}>{agent.last_user}</span> : ''} />
<DlRow label="Seriennummer" value={agent.hardware_serial || ''} mono copyVal={agent.hardware_serial} />
<DlRow label="Uptime" value={<strong style={{ fontVariantNumeric: 'tabular-nums' }}>{uptimeStr(agent.uptime_hours)}</strong>} />
</div>
</div>
{/* Security */}
<div style={{ background: 'var(--bg-secondary)', border: '1px solid var(--border-color)', borderRadius: 18, overflow: 'hidden', boxShadow: '0 1px 2px rgba(0,0,0,0.35)' }}>
<CardHead icon="🛡" title="Sicherheit & Compliance" meta={allSecOk ? 'Alle Checks bestanden' : 'Handlungsbedarf'} />
<div>
<SecRow
ok={bitlockerOk} warn={false}
name="BitLocker"
desc={bitlockerOk ? 'Systemlaufwerk C: verschlüsselt' : 'Laufwerk C: nicht verschlüsselt'}
state={bitlockerOk ? 'Verschlüsselt' : agent.bitlocker_status === 'unknown' ? 'Unbekannt' : 'Nicht verschlüsselt'}
/>
<SecRow
ok={defenderOk} warn={defenderWarn}
name="Windows Defender"
desc={defenderOk
? `Echtzeitschutz aktiv · Signaturen ${sigAge !== null ? `${sigAge} Tag${sigAge !== 1 ? 'e' : ''} alt` : 'aktuell'}`
: 'Echtzeitschutz inaktiv'}
state={defenderOk ? (defenderWarn ? 'Veraltet' : 'Aktiv') : 'Inaktiv'}
/>
<SecRow
ok={tpmOk} warn={false}
name="TPM"
desc={tpmOk ? `TPM ${tpmVersion || '2.0'} vorhanden` : 'Kein TPM gefunden'}
state={tpmOk ? 'Vorhanden' : 'Nicht gefunden'}
/>
<SecRow
ok={secureBootOk} warn={false}
name="Secure Boot"
desc={secureBootOk ? 'UEFI-Firmware konfiguriert' : 'Secure Boot deaktiviert'}
state={secureBootOk ? 'Aktiviert' : 'Deaktiviert'}
/>
<SecRow
ok={win11Ok} warn={false}
name="Windows 11 Kompatibilität"
desc={win11Ok ? 'Alle Hardware-Anforderungen erfüllt' : 'Nicht kompatibel'}
state={win11Ok ? 'Bereit' : 'Nicht bereit'}
/>
</div>
</div>
</div>
{/* ── BENUTZER CARD ────────────────────────────────────────────────── */}
<div style={{ display: 'grid', gridTemplateColumns: asset ? '1fr 1fr' : '1fr', gap: 16, marginBottom: 22 }}>
{/* Zugewiesener Benutzer */}
<div style={{ background: 'var(--bg-secondary)', border: '1px solid var(--border-color)', borderRadius: 18, overflow: 'hidden', boxShadow: '0 1px 2px rgba(0,0,0,0.35)' }}>
<CardHead icon="👤" title="Zugewiesener Benutzer" meta={assignedUser ? 'Aus Asset-Daten' : ''} />
{assignedUser ? (
<div style={{ padding: '20px 22px', display: 'flex', alignItems: 'center', gap: 16 }}>
<div style={{
width: 52, height: 52, borderRadius: '50%', flexShrink: 0,
background: `linear-gradient(135deg, ${C.teal}, #0a7a78)`,
color: '#fff', fontWeight: 700, fontSize: 18,
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}>
{userInitials}
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 16, fontWeight: 600, color: 'var(--text-primary)' }}>
{assignedUser.first_name} {assignedUser.last_name}
{(!assignedUser.first_name && !assignedUser.last_name) && assignedUser.username}
</div>
<div style={{ fontSize: 12.5, color: 'var(--text-muted)', marginTop: 2 }}>@{assignedUser.username}</div>
{assignedUser.email && <div style={{ fontSize: 12.5, color: 'var(--text-muted)' }}>{assignedUser.email}</div>}
{assignedUser.department && <div style={{ fontSize: 12, color: 'var(--text-muted)', marginTop: 4 }}>{assignedUser.department}</div>}
</div>
<Link to={`/users`} style={{ fontSize: 12.5, color: C.teal, textDecoration: 'none', fontWeight: 500 }}>Profil </Link>
</div>
) : (
<div style={{ padding: '20px 22px', display: 'flex', alignItems: 'center', gap: 12 }}>
<div style={{ width: 42, height: 42, borderRadius: '50%', background: 'var(--bg-tertiary)', display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 20 }}>?</div>
<div>
<div style={{ fontSize: 13.5, color: 'var(--text-secondary)', fontWeight: 500 }}>Nicht zugewiesen</div>
{agent.last_user && <div style={{ fontSize: 12, color: 'var(--text-muted)', marginTop: 2 }}>Zuletzt angemeldet: <span style={{ fontFamily: 'monospace' }}>{agent.last_user}</span></div>}
</div>
</div>
)}
</div>
{/* Asset-Verknüpfung */}
{asset && (
<div style={{ background: 'var(--bg-secondary)', border: '1px solid var(--border-color)', borderRadius: 18, overflow: 'hidden', boxShadow: '0 1px 2px rgba(0,0,0,0.35)' }}>
<CardHead icon="🏷" title="Verknüpftes Asset" meta="IT Nexus Asset-Verwaltung" />
<div style={{ padding: '16px 22px' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 12 }}>
<div style={{ fontSize: 16, fontWeight: 600, color: 'var(--text-primary)' }}>{asset.name}</div>
<span style={{ fontSize: 11.5, fontWeight: 600, padding: '2px 8px', borderRadius: 6, background: 'var(--bg-tertiary)', color: 'var(--text-muted)' }}>{asset.type || 'Notebook'}</span>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '6px 16px', fontSize: 12.5, color: 'var(--text-muted)' }}>
{asset.manufacturer && <div>Hersteller: <strong style={{ color: 'var(--text-secondary)' }}>{asset.manufacturer}</strong></div>}
{asset.model && <div>Modell: <strong style={{ color: 'var(--text-secondary)' }}>{asset.model}</strong></div>}
{asset.serial_number && <div>S/N: <span style={{ fontFamily: 'monospace', color: 'var(--text-secondary)' }}>{asset.serial_number}</span></div>}
{asset.status && <div>Status: <strong style={{ color: 'var(--text-secondary)' }}>{asset.status}</strong></div>}
</div>
<Link to="/assets" style={{ display: 'inline-block', marginTop: 12, fontSize: 12.5, color: C.teal, textDecoration: 'none', fontWeight: 500 }}>
Asset öffnen
</Link>
</div>
</div>
)}
</div>
{/* ── UPDATES BANNER ───────────────────────────────────────────────── */}
{(agent.windows_updates_pending ?? 0) > 0 && (
<div style={{
background: `linear-gradient(135deg, rgba(245,158,11,0.08), transparent 60%), var(--bg-secondary)`,
border: '1px solid rgba(245,158,11,0.28)',
borderRadius: 18, padding: '22px 24px', marginBottom: 22,
display: 'grid', gridTemplateColumns: 'auto 1fr auto', gap: 18, alignItems: 'center',
boxShadow: '0 1px 2px rgba(0,0,0,0.35)',
}}>
<div style={{ width: 44, height: 44, borderRadius: 12, background: 'rgba(245,158,11,0.16)', color: C.warning, display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 22, flexShrink: 0 }}>
🔄
</div>
<div>
<div style={{ fontSize: 15, fontWeight: 600, letterSpacing: '-0.01em', display: 'flex', alignItems: 'center', gap: 10, color: 'var(--text-primary)' }}>
Ausstehende Updates
<span style={{ background: C.warning, color: '#fff', padding: '2px 9px', borderRadius: 999, fontSize: 11, fontWeight: 700 }}>{agent.windows_updates_pending}</span>
</div>
<div style={{ fontSize: 12.5, color: 'var(--text-muted)', marginTop: 4 }}>Updates können über Patch Management installiert werden</div>
</div>
<div style={{ display: 'flex', gap: 8 }}>
<Btn onClick={() => handleSendCommand('install_updates')} variant="primary"> Updates installieren</Btn>
</div>
</div>
)}
{/* ── PATCH HISTORY ────────────────────────────────────────────────── */}
{patchHistory.length > 0 && (
<div style={{ background: 'var(--bg-secondary)', border: '1px solid var(--border-color)', borderRadius: 18, overflow: 'hidden', boxShadow: '0 1px 2px rgba(0,0,0,0.35)', marginBottom: 22 }}>
<CardHead icon="📋" title="Patch-Historie" meta={`${patchHistory.length} Einträge`} />
<div>
{patchHistory.slice(0, 10).map((cmd, i) => {
const statusColor = cmd.status === 'done' ? C.success : cmd.status === 'error' ? C.danger : cmd.status === 'running' ? C.warning : 'var(--text-muted)';
const statusLabel = { done: 'Erledigt', error: 'Fehler', running: 'Läuft', pending: 'Ausstehend', sent: 'Gesendet' }[cmd.status] || cmd.status;
const cmdLabel = { install_updates: 'Updates installieren', check_updates: 'Updates prüfen', reboot: 'Neustart', upgrade_win11: 'Win11 Upgrade', update_agent: 'Agent Update' }[cmd.command] || cmd.command;
return (
<div key={cmd.id} style={{ display: 'grid', gridTemplateColumns: '1fr auto auto', gap: 16, padding: '11px 22px', alignItems: 'center', borderTop: i === 0 ? 'none' : '1px solid var(--border-color)' }}>
<div>
<div style={{ fontSize: 13.5, fontWeight: 600, color: 'var(--text-primary)' }}>{cmdLabel}</div>
{cmd.result && <div style={{ fontSize: 12, color: 'var(--text-muted)', marginTop: 2 }}>{cmd.result}</div>}
</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)', textAlign: 'right', whiteSpace: 'nowrap' }}>
{cmd.triggered_by_username && <div>von {cmd.triggered_by_username}</div>}
<div>{new Date(cmd.created_at).toLocaleString('de-DE', { day: '2-digit', month: '2-digit', year: '2-digit', hour: '2-digit', minute: '2-digit' })}</div>
</div>
<span style={{ fontSize: 11.5, fontWeight: 600, padding: '3px 10px', borderRadius: 999, background: `${statusColor}18`, color: statusColor, whiteSpace: 'nowrap' }}>
{statusLabel}
</span>
</div>
);
})}
</div>
</div>
)}
{/* ── INSTALLED SOFTWARE ───────────────────────────────────────────── */}
<div style={{ background: 'var(--bg-secondary)', border: '1px solid var(--border-color)', borderRadius: 18, overflow: 'hidden', boxShadow: '0 1px 2px rgba(0,0,0,0.35)' }}>
<CardHead icon="📦" title="Installierte Software" meta={`${sw.length} Programme`} />
<div style={{ padding: '14px 22px', borderBottom: '1px solid var(--border-color)', background: 'var(--bg-tertiary)', display: 'flex', alignItems: 'center', gap: 10 }}>
<div style={{ position: 'relative', flex: 1, maxWidth: 380 }}>
<span style={{ position: 'absolute', left: 12, top: '50%', transform: 'translateY(-50%)', color: 'var(--text-muted)', pointerEvents: 'none' }}>🔍</span>
<input
value={swQuery}
onChange={e => setSwQuery(e.target.value)}
placeholder="Nach Name oder Hersteller suchen…"
style={{
width: '100%', boxSizing: 'border-box',
background: 'var(--bg-secondary)', color: 'var(--text-primary)',
border: '1px solid var(--border-color)', borderRadius: 10,
padding: '8px 14px 8px 36px', fontSize: 13, outline: 'none',
fontFamily: 'inherit',
}}
/>
</div>
<span style={{ marginLeft: 'auto', fontSize: 12.5, color: 'var(--text-muted)' }}>
{swQuery ? `${filteredSw.length} Treffer` : `${sw.length} Programme`}
</span>
</div>
{sw.length === 0 ? (
<div style={{ padding: '40px 20px', textAlign: 'center', color: 'var(--text-muted)' }}>
Keine Software-Daten verfügbar Check-in abwarten
</div>
) : filteredSw.length === 0 ? (
<div style={{ padding: '40px 20px', textAlign: 'center', color: 'var(--text-muted)' }}>Keine Programme gefunden.</div>
) : (
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr' }}>
{filteredSw.map((s, i) => {
const nm = typeof s === 'string' ? s : (s.name || s.Name || s.DisplayName || '?');
const ver = typeof s === 'string' ? '' : (s.version || s.Version || s.DisplayVersion || '');
const pub = typeof s === 'string' ? '' : (s.publisher || s.Publisher || '');
const col = colorFor(nm);
return (
<div key={i} style={{ display: 'flex', alignItems: 'center', gap: 12, padding: '11px 22px', borderBottom: '1px solid var(--border-color)', minWidth: 0 }}>
<div style={{ width: 30, height: 30, borderRadius: 7, background: `${col}22`, color: col, display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0, fontWeight: 700, fontSize: 13 }}>
{nm[0]}
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 13, fontWeight: 600, color: 'var(--text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{nm}</div>
<div style={{ fontFamily: 'ui-monospace, monospace', fontSize: 11, color: 'var(--text-muted)', marginTop: 2 }}>{ver}{pub ? ` · ${pub}` : ''}</div>
</div>
</div>
);
})}
</div>
)}
</div>
{/* ── REMOTE TOOLS ─────────────────────────────────────────────────── */}
{(isSuperAdmin() || isAdmin()) && (
<div style={{ marginTop: 24 }}>
<div style={{ display: 'flex', gap: 4, marginBottom: 0, borderBottom: '1px solid var(--border-color)' }}>
{[
{ id: 'shell', label: '⌨️ Remote Shell' },
{ id: 'rdp', label: '🖥️ Remote Desktop', badge: 'Beta' },
].map(t => (
<button key={t.id} onClick={() => setShellTab(t.id)} style={{
background: shellTab === t.id ? 'var(--bg-secondary)' : 'transparent',
border: '1px solid var(--border-color)',
borderBottom: shellTab === t.id ? '1px solid var(--bg-secondary)' : '1px solid var(--border-color)',
borderRadius: '10px 10px 0 0', color: shellTab === t.id ? 'var(--text-primary)' : 'var(--text-muted)',
padding: '8px 18px', fontSize: 13, fontWeight: 600, cursor: 'pointer',
display: 'flex', alignItems: 'center', gap: 6, marginBottom: -1,
}}>
{t.label}
{t.badge && <span style={{ fontSize: 10, background: C.tealDim, color: C.teal, borderRadius: 4, padding: '1px 5px', fontWeight: 700 }}>{t.badge}</span>}
</button>
))}
</div>
{shellTab === 'shell' && <RemoteShell agentId={agent.id} agentHostname={agent.hostname} />}
{shellTab === 'rdp' && <RemoteDesktop agentId={agent.id} agentHostname={agent.hostname} />}
</div>
)}
{/* ── ANKÜNDIGUNG MODAL ────────────────────────────────────────────── */}
{showAnnModal && (
<div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,.6)', zIndex: 1000, display: 'flex', alignItems: 'center', justifyContent: 'center' }}
onClick={e => e.target === e.currentTarget && setShowAnnModal(false)}>
<div style={{ background: 'var(--bg-primary)', borderRadius: 16, width: '100%', maxWidth: 500, border: '1px solid var(--border-color)', overflow: 'hidden' }}>
<div style={{ padding: '16px 20px', borderBottom: '1px solid var(--border-color)', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<h2 style={{ margin: 0, fontSize: 15, fontWeight: 600, color: 'var(--text-primary)' }}>📢 Ankündigung senden an {agent.hostname}</h2>
<button onClick={() => setShowAnnModal(false)} style={{ background: 'none', border: 'none', color: 'var(--text-muted)', fontSize: 22, cursor: 'pointer', lineHeight: 1 }}>×</button>
</div>
<div style={{ padding: 20, display: 'flex', flexDirection: 'column', gap: 14 }}>
<div style={{ background: 'rgba(20,184,168,0.10)', border: '1px solid rgba(20,184,168,0.3)', borderRadius: 8, padding: '8px 12px', fontSize: 12.5, color: '#2dd2c2' }}>
🎯 Wird <strong>nur</strong> an <strong>{agent?.hostname}</strong> gesendet (Agent-ID: {agent?.id})
</div>
<textarea
value={annText}
onChange={e => setAnnText(e.target.value)}
placeholder="Nachricht eingeben…"
rows={4}
style={{ width: '100%', boxSizing: 'border-box', background: 'var(--bg-secondary)', color: 'var(--text-primary)', border: '1px solid var(--border-color)', borderRadius: 10, padding: '10px 14px', fontSize: 13, fontFamily: 'inherit', outline: 'none', resize: 'vertical' }}
/>
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
<Btn onClick={() => setShowAnnModal(false)}>Abbrechen</Btn>
<Btn variant="primary" onClick={handleSendAnnouncement} disabled={sendingAnn || !annText.trim()}>
{sendingAnn ? 'Sende…' : '📢 Senden'}
</Btn>
</div>
</div>
</div>
</div>
)}
<style>{`
@keyframes agPulse {
0% { box-shadow: 0 0 0 0 rgba(34,197,94,0.5); }
70% { box-shadow: 0 0 0 6px rgba(34,197,94,0); }
100% { box-shadow: 0 0 0 0 rgba(34,197,94,0); }
}
`}</style>
</div>
);
}