- 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>
836 lines
51 KiB
JavaScript
836 lines
51 KiB
JavaScript
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></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>
|
||
);
|
||
}
|