Files
IT-Nexus/frontend/src/pages/PatchManagementPage.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

1013 lines
71 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, useCallback, useRef } from 'react';
import api from '../services/api';
import { toast } from 'react-toastify';
import { useAuth } from '../context/AuthContext';
const ANN_TYPES = { maintenance: { icon: '🔧', label: 'Wartung', color: '#f59e0b' }, warning: { icon: '⚠️', label: 'Warnung', color: '#ef4444' }, info: { icon: '', label: 'Info', color: '#6366f1' } };
const LATEST_AGENT_VERSION = '2.7.0';
const SEVERITY_LABELS = { critical: 'Kritisch', important: 'Wichtig', moderate: 'Moderat', low: 'Niedrig', all: 'Alle' };
const SEVERITY_COLORS = { critical: '#EF4444', important: '#F59E0B', moderate: '#3B82F6', low: '#6B7280', all: '#0D9488' };
const COMMAND_LABELS = { check_updates: '🔍 Update-Scan', install_updates: '⬇️ Installation', reboot: '🔄 Neustart', upgrade_win11: '🪟 Win 11 Upgrade', update_agent: '⬆️ Agent-Update' };
export default function PatchManagementPage() {
const { user } = useAuth();
const [tab, setTab] = useState('overview');
const [overview, setOverview] = useState(null);
const [groups, setGroups] = useState([]);
const [commands, setCommands] = useState([]);
const [loading, setLoading] = useState(true);
const [groupModal, setGroupModal] = useState(null);
const [policyModal, setPolicyModal] = useState(null);
const [assignModal, setAssignModal] = useState(null);
const [releaseModal, setReleaseModal] = useState(null);
const [announcements, setAnnouncements] = useState([]);
const [annModal, setAnnModal] = useState(null); // null | { id, title, message, type, target_roles, expires_at } | 'new'
const [liveCommands, setLiveCommands] = useState([]);
const pollRef = useRef(null);
const loadAll = useCallback(async () => {
setLoading(true);
try {
const [ov, gr, cm, ann] = await Promise.all([
api.get('/patch/overview').then(r => r.data),
api.get('/patch/groups').then(r => r.data),
api.get('/patch/commands').then(r => r.data),
api.get('/announcements').then(r => r.data).catch(() => []),
]);
setOverview(ov);
setGroups(gr);
setCommands(cm);
setAnnouncements(ann || []);
} catch { toast.error('Fehler beim Laden'); }
setLoading(false);
}, []);
useEffect(() => {
loadAll();
const t = setInterval(loadAll, 60000);
return () => clearInterval(t);
}, [loadAll]);
// Live-Polling für aktive Commands
const startPolling = useCallback(() => {
if (pollRef.current) return;
pollRef.current = setInterval(async () => {
try {
const cm = await api.get('/patch/commands').then(r => r.data);
setCommands(cm);
setLiveCommands(prev => {
const updated = prev.map(lc => {
const server = cm.find(c => c.id === lc.id);
return server ? { ...lc, ...server } : lc;
});
// Polling stoppen wenn alle done/failed
const allDone = updated.every(c => c.status === 'done' || c.status === 'failed') && updated.length > 0;
if (allDone && updated.length > 0) {
clearInterval(pollRef.current);
pollRef.current = null;
loadAll(); // finale Daten laden
}
return updated;
});
} catch {}
}, 4000);
}, [loadAll]);
const addLiveCommand = useCallback((cmd) => {
setLiveCommands(prev => {
const exists = prev.find(c => c.id === cmd.id);
if (exists) return prev;
return [{ ...cmd, _ts: Date.now() }, ...prev].slice(0, 20);
});
startPolling();
}, [startPolling]);
const clearLiveCommands = () => {
setLiveCommands([]);
if (pollRef.current) { clearInterval(pollRef.current); pollRef.current = null; }
};
useEffect(() => () => { if (pollRef.current) clearInterval(pollRef.current); }, []);
const triggerCommand = async (agentId, command, hostname) => {
try {
const r = await api.post('/patch/commands/trigger', { agent_id: agentId, command });
addLiveCommand({ ...r.data, hostname: hostname || agentId });
toast.success(`${COMMAND_LABELS[command]} gesendet — warte auf Agent...`);
} catch { toast.error('Fehler'); }
};
const triggerGroupCommand = async (groupId, command, agents) => {
try {
const r = await api.post('/patch/commands/trigger-group', { group_id: groupId, command });
toast.success(`${COMMAND_LABELS[command]} an ${r.data.triggered} Gerät(e) gesendet`);
// Live commands nachladen
const cm = await api.get('/patch/commands').then(res => res.data);
cm.filter(c => c.status === 'pending' || c.status === 'sent').forEach(c => addLiveCommand(c));
} catch { toast.error('Fehler'); }
};
const assignAgent = async (agentId, groupId) => {
try {
await api.post('/patch/assign', { agent_id: agentId, group_id: groupId });
toast.success('Gerät zugewiesen');
setAssignModal(null);
loadAll();
} catch { toast.error('Fehler'); }
};
const saveGroup = async (form) => {
try {
if (form.id) await api.put(`/patch/groups/${form.id}`, form);
else await api.post('/patch/groups', form);
toast.success('Gespeichert');
setGroupModal(null);
loadAll();
} catch (e) { toast.error(e.response?.data?.error || 'Fehler'); }
};
const deleteGroup = async (id) => {
if (!window.confirm('Gruppe löschen?')) return;
try { await api.delete(`/patch/groups/${id}`); loadAll(); } catch { toast.error('Fehler'); }
};
const openReleaseModal = (groupId, groupName, version, agents, sorted) => {
const idx = sorted.findIndex(g => g.id === groupId);
const nextGroup = sorted[idx + 1] || null;
const affectedCount = agents.filter(a => a.group_id === groupId).length;
setReleaseModal({ groupId, groupName, version, affectedCount, nextGroup, isFirst: idx === 0 });
};
const confirmRelease = async () => {
if (!releaseModal) return;
try {
await api.post(`/patch/groups/${releaseModal.groupId}/release`, { version: releaseModal.version });
toast.success(`v${releaseModal.version} für "${releaseModal.groupName}" freigegeben`);
setReleaseModal(null);
loadAll();
} catch (err) {
const msg = err?.response?.data?.error || 'Freigabe fehlgeschlagen';
toast.error(msg);
setReleaseModal(null);
}
};
const saveAnnouncement = async (form) => {
try {
const payload = { ...form, target_groups: form.target_groups || [] };
if (form.id) await api.put(`/announcements/${form.id}`, payload);
else await api.post('/announcements', payload);
toast.success('Ankündigung gespeichert');
setAnnModal(null);
loadAll();
} catch { toast.error('Fehler'); }
};
const deleteAnnouncement = async (id) => {
if (!window.confirm('Ankündigung löschen?')) return;
try { await api.delete(`/announcements/${id}`); loadAll(); } catch { toast.error('Fehler'); }
};
const toggleAnnouncement = async (ann) => {
try {
await api.put(`/announcements/${ann.id}`, { active: !ann.active });
loadAll();
} catch { toast.error('Fehler'); }
};
const savePolicy = async (form) => {
try {
await api.post('/patch/policies', form);
toast.success('Richtlinie gespeichert');
setPolicyModal(null);
loadAll();
} catch { toast.error('Fehler'); }
};
const deletePolicy = async (id) => {
try { await api.delete(`/patch/policies/${id}`); loadAll(); } catch { toast.error('Fehler'); }
};
if (loading) return <div style={{ padding: 40, textAlign: 'center', color: 'var(--text-muted)' }}>Laden...</div>;
const stats = overview?.stats || {};
const agents = overview?.agents || [];
return (
<div style={{ padding: '28px' }}>
{/* Header */}
<div className="page-header" style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: 24 }}>
<div>
<h1 style={{ margin: 0, color: 'var(--text-primary)', fontSize: 24, fontWeight: 800 }}>Patch Management</h1>
<p style={{ margin: '4px 0 0', color: 'var(--text-muted)', fontSize: 14 }}>Windows-Updates zentral verwalten und verteilen</p>
</div>
<div style={{ display: 'flex', gap: 8 }}>
<a href="/api/monitoring/agent-setup" download style={{ textDecoration: 'none' }}>
<button className="btn btn-secondary"> Agent v{LATEST_AGENT_VERSION} Setup</button>
</a>
<button className="btn btn-secondary" onClick={loadAll}>🔄 Aktualisieren</button>
</div>
</div>
{/* Live-Monitor */}
{liveCommands.length > 0 && (
<div style={{ background: 'var(--bg-card)', border: '1px solid var(--cereda-primary)', borderRadius: 14, padding: 20, marginBottom: 20 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 14 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<span style={{ display: 'inline-block', width: 10, height: 10, borderRadius: '50%', background: '#0D9488', boxShadow: '0 0 0 3px rgba(13,148,136,0.25)', animation: liveCommands.some(c => c.status === 'pending' || c.status === 'sent') ? 'pulse 1.5s infinite' : 'none' }} />
<span style={{ fontWeight: 700, color: 'var(--text-primary)', fontSize: 14 }}>Live-Monitor</span>
<span style={{ fontSize: 12, color: 'var(--text-muted)' }}> aktualisiert alle 4 Sekunden</span>
</div>
<button onClick={clearLiveCommands} style={{ background: 'none', border: 'none', color: 'var(--text-muted)', cursor: 'pointer', fontSize: 18, lineHeight: 1 }}></button>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{liveCommands.map(c => (
<div key={c.id} style={{ display: 'flex', alignItems: 'center', gap: 14, padding: '10px 14px', background: 'var(--bg-secondary)', borderRadius: 10, border: '1px solid var(--border-color)' }}>
<LiveStatusDot status={c.status} />
<div style={{ flex: 1 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<span style={{ fontWeight: 700, color: 'var(--text-primary)', fontSize: 13 }}>{c.hostname}</span>
<span style={{ fontSize: 12, color: 'var(--text-muted)' }}>{COMMAND_LABELS[c.command]}</span>
</div>
{c.result && <div style={{ fontSize: 12, color: '#10B981', marginTop: 2 }}> {c.result}</div>}
</div>
<LiveStatusBadge status={c.status} />
<span style={{ fontSize: 11, color: 'var(--text-muted)', whiteSpace: 'nowrap' }}>
{c.created_at ? new Date(c.created_at).toLocaleTimeString('de-DE') : ''}
</span>
</div>
))}
</div>
<style>{`@keyframes pulse { 0%,100%{box-shadow:0 0 0 3px rgba(13,148,136,0.25)} 50%{box-shadow:0 0 0 6px rgba(13,148,136,0.1)} }`}</style>
</div>
)}
{/* Stats */}
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(160px, 1fr))', gap: 12, marginBottom: 24 }}>
{[
{ label: 'Geräte gesamt', value: stats.total || 0, color: 'var(--cereda-primary)' },
{ label: 'Konform', value: stats.compliant || 0, color: '#10B981' },
{ label: 'Warnung', value: stats.warnings || 0, color: '#F59E0B' },
{ label: 'Offline', value: stats.offline || 0, color: '#6B7280' },
{ label: 'Ausstehende Updates', value: stats.total_pending_updates || 0, color: '#EF4444' },
].map(s => (
<div key={s.label} style={{ background: 'var(--bg-card)', border: '1px solid var(--border-color)', borderRadius: 12, padding: '16px 20px' }}>
<div style={{ fontSize: 28, fontWeight: 800, color: s.color }}>{s.value}</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginTop: 2 }}>{s.label}</div>
</div>
))}
</div>
{/* Tabs */}
<div style={{ display: 'flex', gap: 4, borderBottom: '1px solid var(--border-color)', marginBottom: 24 }}>
{[['overview', '📊 Übersicht'], ['groups', '🗂️ Gruppen'], ['commands', '📋 Verlauf'], ['announcements', `📢 Ankündigungen${announcements.filter(a=>a.active).length > 0 ? ` (${announcements.filter(a=>a.active).length})` : ''}`]].map(([k, l]) => (
<button key={k} onClick={() => setTab(k)} style={{
background: 'none', border: 'none', borderBottom: tab === k ? '2px solid var(--cereda-primary)' : '2px solid transparent',
color: tab === k ? 'var(--cereda-primary)' : 'var(--text-secondary)',
padding: '10px 18px', cursor: 'pointer', fontWeight: tab === k ? 700 : 400, fontSize: 14, marginBottom: -1,
}}>{l}</button>
))}
</div>
{/* ── Overview Tab ── */}
{tab === 'overview' && (
<div>
{groups.map(group => {
const groupAgents = agents.filter(a => a.group_id === group.id);
if (groupAgents.length === 0) return null;
const sorted = [...groups].sort((a, b) => (a.sort_order ?? 99) - (b.sort_order ?? 99));
const idx = sorted.findIndex(g => g.id === group.id);
const isLatest = group.target_agent_version === LATEST_AGENT_VERSION;
const prevDone = idx === 0 || sorted[idx - 1]?.target_agent_version === LATEST_AGENT_VERSION;
const canRelease = !isLatest && prevDone;
const updatedCount = groupAgents.filter(a => a.agent_version === LATEST_AGENT_VERSION).length;
return (
<div key={group.id} style={{ marginBottom: 24 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 12 }}>
<div style={{ width: 12, height: 12, borderRadius: '50%', background: group.color }} />
<h3 style={{ margin: 0, color: 'var(--text-primary)', fontSize: 16 }}>{group.name}</h3>
<span style={{ color: 'var(--text-muted)', fontSize: 13 }}>{groupAgents.length} Gerät(e)</span>
{/* ── Rollout-Status inline ── */}
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginLeft: 12, padding: '4px 12px', background: isLatest ? '#34d39912' : canRelease ? '#6366f112' : 'var(--bg-secondary)', border: `1px solid ${isLatest ? '#34d39940' : canRelease ? '#6366f140' : 'var(--border-color)'}`, borderRadius: 20 }}>
<span style={{ fontSize: 10, fontFamily: 'monospace', fontWeight: 700, color: isLatest ? '#34d399' : 'var(--text-muted)' }}>
{isLatest ? `✓ v${LATEST_AGENT_VERSION}` : group.target_agent_version ? `v${group.target_agent_version}` : `v${LATEST_AGENT_VERSION} verfügbar`}
</span>
{isLatest && groupAgents.length > 0 && (
<span style={{ fontSize: 10, color: updatedCount === groupAgents.length ? '#34d399' : '#f59e0b' }}>
· {updatedCount}/{groupAgents.length} aktualisiert
</span>
)}
{canRelease && (
<button
onClick={() => openReleaseModal(group.id, group.name, LATEST_AGENT_VERSION, agents, sorted)}
style={{ padding: '2px 10px', fontSize: 10, fontWeight: 700, borderRadius: 12, border: '1px solid #6366f1', background: 'rgba(99,102,241,0.2)', color: '#818cf8', cursor: 'pointer' }}
>
{idx === 0 ? '🚀 Rollout starten' : '✅ Freigeben'}
</button>
)}
{!isLatest && !canRelease && (
<span style={{ fontSize: 10, color: 'var(--text-muted)' }}> Wartet auf {sorted[idx - 1]?.name}</span>
)}
</div>
<div style={{ marginLeft: 'auto', display: 'flex', gap: 8 }}>
<button className="btn btn-secondary btn-sm" onClick={() => triggerGroupCommand(group.id, 'check_updates')}>🔍 Alle scannen</button>
<button className="btn btn-secondary btn-sm" onClick={() => triggerGroupCommand(group.id, 'install_updates')}> Alle installieren</button>
</div>
</div>
<AgentTable agents={groupAgents} onTrigger={triggerCommand} onAssign={a => setAssignModal(a)} groups={groups} />
</div>
);
})}
{/* Ungrouped */}
{(() => {
const ungrouped = agents.filter(a => !a.group_id);
if (!ungrouped.length) return null;
return (
<div style={{ marginBottom: 24 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 12 }}>
<div style={{ width: 12, height: 12, borderRadius: '50%', background: '#6B7280' }} />
<h3 style={{ margin: 0, color: 'var(--text-muted)', fontSize: 16 }}>Keine Gruppe</h3>
<span style={{ color: 'var(--text-muted)', fontSize: 13 }}>{ungrouped.length} Gerät(e)</span>
</div>
<AgentTable agents={ungrouped} onTrigger={triggerCommand} onAssign={a => setAssignModal(a)} groups={groups} />
</div>
);
})()}
</div>
)}
{/* ── Groups Tab ── */}
{tab === 'groups' && (() => {
const sorted = [...groups].sort((a, b) => (a.sort_order ?? 99) - (b.sort_order ?? 99));
const allDone = sorted.every(g => g.target_agent_version === LATEST_AGENT_VERSION);
return (
<div style={{ display: 'grid', gridTemplateColumns: '1fr 280px', gap: 20, alignItems: 'start' }}>
{/* ── Linke Spalte: Gruppen ── */}
<div>
<div style={{ display: 'flex', justifyContent: 'flex-end', marginBottom: 16 }}>
<button className="btn btn-primary" onClick={() => setGroupModal({ name: '', description: '', color: '#3B82F6' })}>+ Neue Gruppe</button>
</div>
{groups.map(g => (
<div key={g.id} style={{ background: 'var(--bg-card)', border: '1px solid var(--border-color)', borderRadius: 12, padding: 20, marginBottom: 12 }}>
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 16 }}>
<div style={{ width: 16, height: 16, borderRadius: '50%', background: g.color, flexShrink: 0, marginTop: 2 }} />
<div style={{ flex: 1 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 8 }}>
<span style={{ fontWeight: 700, color: 'var(--text-primary)', fontSize: 15 }}>{g.name}</span>
<span style={{ color: 'var(--text-muted)', fontSize: 12 }}>{g.agent_count} Gerät(e)</span>
{g.target_agent_version && (
<span style={{ fontSize: 11, fontFamily: 'monospace', fontWeight: 700, color: g.target_agent_version === LATEST_AGENT_VERSION ? '#34d399' : '#f59e0b', background: g.target_agent_version === LATEST_AGENT_VERSION ? '#34d39915' : '#f59e0b15', border: `1px solid ${g.target_agent_version === LATEST_AGENT_VERSION ? '#34d39940' : '#f59e0b40'}`, borderRadius: 10, padding: '1px 8px' }}>
v{g.target_agent_version}
</span>
)}
<div style={{ marginLeft: 'auto', display: 'flex', gap: 8 }}>
<button className="btn btn-secondary btn-sm" onClick={() => setGroupModal(g)}> Bearbeiten</button>
<button className="btn btn-secondary btn-sm" onClick={() => setPolicyModal({ group_id: g.id, severity: 'critical', max_days: 7, notify_email: '' })}>+ Richtlinie</button>
<button style={{ padding: '4px 10px', background: 'rgba(239,68,68,0.1)', color: '#ef4444', border: '1px solid rgba(239,68,68,0.3)', borderRadius: 6, cursor: 'pointer', fontSize: 12 }} onClick={() => deleteGroup(g.id)}>🗑</button>
</div>
</div>
{g.description && <div style={{ color: 'var(--text-muted)', fontSize: 13, marginBottom: 12 }}>{g.description}</div>}
{g.policies?.length > 0 && (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
{g.policies.map(p => (
<div key={p.id} style={{ display: 'flex', alignItems: 'center', gap: 8, background: 'var(--bg-secondary)', borderRadius: 8, padding: '6px 10px', border: `1px solid ${SEVERITY_COLORS[p.severity]}44` }}>
<span style={{ color: SEVERITY_COLORS[p.severity], fontWeight: 700, fontSize: 12 }}>{SEVERITY_LABELS[p.severity]}</span>
<span style={{ color: 'var(--text-secondary)', fontSize: 12 }}> {p.max_days} Tage</span>
{p.notify_email && <span style={{ color: 'var(--text-muted)', fontSize: 11 }}>📧</span>}
<button onClick={() => deletePolicy(p.id)} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-muted)', fontSize: 12, padding: 0 }}></button>
</div>
))}
</div>
)}
{(!g.policies || g.policies.length === 0) && (
<span style={{ color: 'var(--text-muted)', fontSize: 12 }}>Keine Richtlinien definiert</span>
)}
</div>
</div>
</div>
))}
</div>
{/* ── Rechte Spalte: Rollout Pipeline ── */}
<div style={{ position: 'sticky', top: 16 }}>
<div style={{ background: 'var(--bg-card)', border: '1px solid var(--border-color)', borderRadius: 12, padding: 16 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 16 }}>
<span style={{ fontSize: 16 }}>🚀</span>
<div>
<div style={{ fontWeight: 700, fontSize: 13 }}>Agent Rollout</div>
<div style={{ fontSize: 11, color: 'var(--text-muted)' }}>
Verfügbar: <span style={{ fontFamily: 'monospace', fontWeight: 700, color: 'var(--accent)' }}>v{LATEST_AGENT_VERSION}</span>
</div>
</div>
{allDone && (
<span style={{ marginLeft: 'auto', fontSize: 10, fontWeight: 700, color: '#34d399' }}> Alle aktuell</span>
)}
</div>
{/* Vertikale Pipeline */}
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
{sorted.map((g, idx) => {
const isLatest = g.target_agent_version === LATEST_AGENT_VERSION;
const prevDone = idx === 0 || sorted[idx - 1].target_agent_version === LATEST_AGENT_VERSION;
const canRelease = !isLatest && prevDone;
const isWaiting = !isLatest && !prevDone;
const borderColor = isLatest ? '#34d399' : canRelease ? '#6366f1' : '#6b7280';
const groupAgents = agents.filter(a => a.group_id === g.id);
const updatedCount = groupAgents.filter(a => a.agent_version === LATEST_AGENT_VERSION).length;
return (
<React.Fragment key={g.id}>
<div style={{ background: isLatest ? '#34d39910' : canRelease ? '#6366f110' : 'var(--bg-secondary)', border: `1px solid ${borderColor}44`, borderLeft: `3px solid ${borderColor}`, borderRadius: 7, padding: '10px 12px' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 6 }}>
<div style={{ width: 8, height: 8, borderRadius: '50%', background: g.color, flexShrink: 0 }} />
<span style={{ fontWeight: 700, fontSize: 12 }}>{g.name}</span>
<span style={{ fontFamily: 'monospace', fontSize: 11, color: isLatest ? '#34d399' : 'var(--text-muted)', marginLeft: 'auto' }}>
{g.target_agent_version ? `v${g.target_agent_version}` : '—'}
</span>
</div>
{isLatest && groupAgents.length > 0 && (
<div style={{ fontSize: 10, color: updatedCount === groupAgents.length ? '#34d399' : '#f59e0b', marginBottom: 4 }}>
{updatedCount}/{groupAgents.length} Geräte aktualisiert
</div>
)}
{isLatest && <div style={{ fontSize: 11, color: '#34d399', fontWeight: 600 }}> Freigegeben</div>}
{canRelease && (
<button
onClick={() => openReleaseModal(g.id, g.name, LATEST_AGENT_VERSION, agents, sorted)}
style={{ width: '100%', marginTop: 6, padding: '5px 0', fontSize: 11, fontWeight: 700, borderRadius: 5, border: '1px solid #6366f1', background: 'rgba(99,102,241,0.15)', color: '#818cf8', cursor: 'pointer' }}
>
{idx === 0 ? '🚀 Rollout starten' : `✅ Weiter zu ${g.name}`}
</button>
)}
{isWaiting && (
<div style={{ fontSize: 10, color: 'var(--text-muted)', marginTop: 4 }}> Wartet auf {sorted[idx - 1].name}</div>
)}
</div>
{idx < sorted.length - 1 && (
<div style={{ textAlign: 'center', color: isLatest ? '#34d399' : '#6b7280', fontSize: 14, lineHeight: '12px' }}></div>
)}
</React.Fragment>
);
})}
</div>
{false && (
<button
style={{ display: 'none' }}
>
hidden
</button>
)}
</div>
</div>
</div>
);
})()}
{/* ── Announcements Tab ── */}
{tab === 'announcements' && (
<div>
<div style={{ display: 'flex', justifyContent: 'flex-end', marginBottom: 16 }}>
<button className="btn btn-primary" onClick={() => setAnnModal({ title: '', message: '', type: 'maintenance', target_groups: [], expires_at: '' })}>
+ Neue Ankündigung
</button>
</div>
{announcements.length === 0 && (
<div style={{ textAlign: 'center', padding: '60px 0', color: 'var(--text-muted)', fontSize: 14 }}>
Noch keine Ankündigungen erstellt.
</div>
)}
{announcements.map(ann => {
const cfg = ANN_TYPES[ann.type] || ANN_TYPES.info;
const ackPct = ann.total_agents > 0 ? Math.round((ann.ack_count / ann.total_agents) * 100) : 0;
return (
<div key={ann.id} style={{ background: 'var(--bg-card)', border: `1px solid ${ann.active ? cfg.color + '40' : 'var(--border-color)'}`, borderLeft: `4px solid ${ann.active ? cfg.color : '#6b7280'}`, borderRadius: 12, padding: 20, marginBottom: 12, opacity: ann.active ? 1 : 0.6 }}>
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 14 }}>
<span style={{ fontSize: 26, flexShrink: 0 }}>{cfg.icon}</span>
<div style={{ flex: 1 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 6 }}>
<span style={{ fontWeight: 700, fontSize: 15 }}>{ann.title}</span>
<span style={{ fontSize: 11, fontWeight: 700, padding: '2px 8px', borderRadius: 10, background: `${cfg.color}20`, color: cfg.color, border: `1px solid ${cfg.color}40` }}>{cfg.label}</span>
{!ann.active && <span style={{ fontSize: 11, color: '#6b7280', fontWeight: 600 }}>Inaktiv</span>}
<span style={{ fontSize: 11, color: 'var(--text-muted)', marginLeft: 4 }}>von {ann.created_by_name || '—'} · {new Date(ann.created_at).toLocaleDateString('de-DE')}</span>
</div>
<div style={{ fontSize: 13, color: 'var(--text-secondary)', marginBottom: 12, whiteSpace: 'pre-wrap' }}>{ann.message}</div>
{/* Bestätigungs-Fortschritt */}
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<div style={{ flex: 1, height: 6, background: 'var(--bg-secondary)', borderRadius: 3, overflow: 'hidden' }}>
<div style={{ height: '100%', width: `${ackPct}%`, background: ackPct === 100 ? '#34d399' : cfg.color, borderRadius: 3, transition: 'width .5s' }} />
</div>
<span style={{ fontSize: 12, fontWeight: 700, color: ackPct === 100 ? '#34d399' : cfg.color, minWidth: 80, textAlign: 'right' }}>
{ann.ack_count}/{ann.total_agents} bestätigt
</span>
</div>
{ann.expires_at && (
<div style={{ fontSize: 11, color: 'var(--text-muted)', marginTop: 6 }}>Läuft ab: {new Date(ann.expires_at).toLocaleString('de-DE')}</div>
)}
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, flexShrink: 0 }}>
<button className="btn btn-secondary btn-sm" onClick={() => setAnnModal({ ...ann, target_groups: JSON.parse(ann.target_groups || '[]') })}></button>
<button
onClick={() => toggleAnnouncement(ann)}
style={{ padding: '4px 10px', borderRadius: 6, border: '1px solid var(--border-color)', background: 'none', color: 'var(--text-muted)', cursor: 'pointer', fontSize: 11, fontWeight: 600 }}
>
{ann.active ? '⏸ Pause' : '▶ Aktiv'}
</button>
<button style={{ padding: '4px 10px', background: 'rgba(239,68,68,0.1)', color: '#ef4444', border: '1px solid rgba(239,68,68,0.3)', borderRadius: 6, cursor: 'pointer', fontSize: 11 }} onClick={() => deleteAnnouncement(ann.id)}>🗑</button>
</div>
</div>
</div>
);
})}
</div>
)}
{/* ── Commands Tab ── */}
{tab === 'commands' && (
<div style={{ background: 'var(--bg-card)', border: '1px solid var(--border-color)', borderRadius: 12, overflow: 'hidden' }}>
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead>
<tr style={{ background: 'var(--bg-secondary)', borderBottom: '1px solid var(--border-color)' }}>
{['Gerät', 'Command', 'Status', 'Ausgelöst von', 'Erstellt', 'Ergebnis'].map(h => (
<th key={h} style={{ padding: '10px 14px', textAlign: 'left', fontSize: 12, color: 'var(--text-muted)', fontWeight: 600 }}>{h}</th>
))}
</tr>
</thead>
<tbody>
{commands.map(c => (
<tr key={c.id} style={{ borderBottom: '1px solid var(--border-color)' }}>
<td style={{ padding: '10px 14px', color: 'var(--text-primary)', fontSize: 13, fontWeight: 600 }}>{c.hostname}</td>
<td style={{ padding: '10px 14px', color: 'var(--text-secondary)', fontSize: 13 }}>{COMMAND_LABELS[c.command] || c.command}</td>
<td style={{ padding: '10px 14px' }}>
<StatusBadge status={c.status} />
</td>
<td style={{ padding: '10px 14px', color: 'var(--text-muted)', fontSize: 12 }}>{c.triggered_by_username || '—'}</td>
<td style={{ padding: '10px 14px', color: 'var(--text-muted)', fontSize: 12 }}>{c.created_at ? new Date(c.created_at).toLocaleString('de-DE') : '—'}</td>
<td style={{ padding: '10px 14px', color: 'var(--text-secondary)', fontSize: 12, maxWidth: 200 }}>{c.result || '—'}</td>
</tr>
))}
{commands.length === 0 && (
<tr><td colSpan={6} style={{ padding: 40, textAlign: 'center', color: 'var(--text-muted)' }}>Noch keine Commands ausgeführt</td></tr>
)}
</tbody>
</table>
</div>
)}
{/* ── Modals ── */}
{/* ── Announcement Create/Edit Modal ── */}
{annModal && (
<div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.6)', zIndex: 1000, display: 'flex', alignItems: 'center', justifyContent: 'center', backdropFilter: 'blur(4px)' }}
onClick={e => { if (e.target === e.currentTarget) setAnnModal(null); }}>
<div style={{ background: 'var(--bg-card)', border: '1px solid var(--border-color)', borderRadius: 14, padding: 28, width: 540, maxWidth: '90vw', boxShadow: '0 25px 60px rgba(0,0,0,0.5)' }}>
<h3 style={{ margin: '0 0 20px', fontSize: 16 }}>{annModal.id ? 'Ankündigung bearbeiten' : 'Neue Ankündigung'}</h3>
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
<div>
<label style={{ fontSize: 12, color: 'var(--text-muted)', display: 'block', marginBottom: 6 }}>Typ</label>
<div style={{ display: 'flex', gap: 8 }}>
{Object.entries(ANN_TYPES).map(([k, v]) => (
<button key={k} onClick={() => setAnnModal(p => ({ ...p, type: k }))}
style={{ flex: 1, padding: '8px 0', borderRadius: 8, border: `1px solid ${annModal.type === k ? v.color : 'var(--border-color)'}`, background: annModal.type === k ? `${v.color}20` : 'none', color: annModal.type === k ? v.color : 'var(--text-muted)', cursor: 'pointer', fontWeight: annModal.type === k ? 700 : 400, fontSize: 13 }}>
{v.icon} {v.label}
</button>
))}
</div>
</div>
<div>
<label style={{ fontSize: 12, color: 'var(--text-muted)', display: 'block', marginBottom: 6 }}>Titel *</label>
<input value={annModal.title} onChange={e => setAnnModal(p => ({ ...p, title: e.target.value }))}
placeholder="z.B. Wartungsfenster am 06.05.2026" style={{ width: '100%', padding: '9px 12px', borderRadius: 8, border: '1px solid var(--border-color)', background: 'var(--bg-secondary)', color: 'var(--text-primary)', fontSize: 13, boxSizing: 'border-box' }} />
</div>
<div>
<label style={{ fontSize: 12, color: 'var(--text-muted)', display: 'block', marginBottom: 6 }}>Nachricht *</label>
<textarea value={annModal.message} onChange={e => setAnnModal(p => ({ ...p, message: e.target.value }))}
rows={5} placeholder="Beschreibe was die Mitarbeiter wissen müssen..."
style={{ width: '100%', padding: '9px 12px', borderRadius: 8, border: '1px solid var(--border-color)', background: 'var(--bg-secondary)', color: 'var(--text-primary)', fontSize: 13, resize: 'vertical', boxSizing: 'border-box' }} />
</div>
<div>
<label style={{ fontSize: 12, color: 'var(--text-muted)', display: 'block', marginBottom: 6 }}>Zielgruppe (leer = alle Geräte)</label>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}>
{groups.map(g => {
const sel = (annModal.target_groups || []).includes(g.id);
return (
<button key={g.id} onClick={() => setAnnModal(p => ({ ...p, target_groups: sel ? p.target_groups.filter(id => id !== g.id) : [...(p.target_groups || []), g.id] }))}
style={{ padding: '6px 14px', borderRadius: 10, border: `1px solid ${sel ? g.color : 'var(--border-color)'}`, background: sel ? `${g.color}20` : 'none', color: sel ? g.color : 'var(--text-muted)', cursor: 'pointer', fontSize: 12, fontWeight: sel ? 700 : 400, display: 'flex', alignItems: 'center', gap: 6 }}>
<span style={{ width: 8, height: 8, borderRadius: '50%', background: g.color, display: 'inline-block' }} />
{g.name}
</button>
);
})}
</div>
</div>
<div>
<label style={{ fontSize: 12, color: 'var(--text-muted)', display: 'block', marginBottom: 6 }}>Ablaufdatum (optional)</label>
<input type="datetime-local" value={annModal.expires_at || ''} onChange={e => setAnnModal(p => ({ ...p, expires_at: e.target.value }))}
style={{ padding: '9px 12px', borderRadius: 8, border: '1px solid var(--border-color)', background: 'var(--bg-secondary)', color: 'var(--text-primary)', fontSize: 13 }} />
</div>
</div>
<div style={{ background: '#6366f110', border: '1px solid #6366f130', borderRadius: 8, padding: '10px 14px', marginTop: 16, fontSize: 12, color: 'var(--text-muted)' }}>
Die Ankündigung wird innerhalb von 15 Sekunden als Popup auf dem Desktop der Mitarbeiter angezeigt.
</div>
<div style={{ display: 'flex', gap: 10, justifyContent: 'flex-end', marginTop: 16 }}>
<button onClick={() => setAnnModal(null)} style={{ padding: '9px 20px', borderRadius: 8, border: '1px solid var(--border-color)', background: 'none', color: 'var(--text-secondary)', cursor: 'pointer', fontSize: 13 }}>Abbrechen</button>
<button onClick={() => saveAnnouncement(annModal)} style={{ padding: '9px 24px', borderRadius: 8, border: 'none', background: 'var(--cereda-primary)', color: '#fff', cursor: 'pointer', fontSize: 13, fontWeight: 700 }}>
📢 {annModal.id ? 'Speichern' : 'Senden'}
</button>
</div>
</div>
</div>
)}
{groupModal && <GroupModal initial={groupModal} onSave={saveGroup} onClose={() => setGroupModal(null)} />}
{policyModal && <PolicyModal initial={policyModal} groups={groups} onSave={savePolicy} onClose={() => setPolicyModal(null)} />}
{assignModal && <AssignModal agent={assignModal} groups={groups} onAssign={assignAgent} onClose={() => setAssignModal(null)} />}
{/* ── Release Confirmation Modal ── */}
{releaseModal && (
<div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.6)', zIndex: 1000, display: 'flex', alignItems: 'center', justifyContent: 'center', backdropFilter: 'blur(4px)' }}
onClick={e => { if (e.target === e.currentTarget) setReleaseModal(null); }}>
<div style={{ background: 'var(--bg-card)', border: '1px solid var(--border-color)', borderRadius: 14, padding: 28, width: 460, maxWidth: '90vw', boxShadow: '0 25px 60px rgba(0,0,0,0.5)' }}>
{/* Titel */}
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 20 }}>
<span style={{ fontSize: 28 }}>🚀</span>
<div>
<div style={{ fontWeight: 700, fontSize: 16, color: 'var(--text-primary)' }}>
Agent v{releaseModal.version} freigeben
</div>
<div style={{ fontSize: 13, color: 'var(--text-muted)', marginTop: 2 }}>
Gruppe: <strong style={{ color: 'var(--text-primary)' }}>{releaseModal.groupName}</strong>
</div>
</div>
</div>
{/* Was passiert */}
<div style={{ background: 'var(--bg-secondary)', borderRadius: 10, padding: 16, marginBottom: 16, borderLeft: '3px solid #6366f1' }}>
<div style={{ fontSize: 12, fontWeight: 700, color: 'var(--text-muted)', marginBottom: 10, textTransform: 'uppercase', letterSpacing: .5 }}>Was passiert als nächstes</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 10, fontSize: 13 }}>
<span style={{ color: '#6366f1', fontWeight: 700, marginTop: 1 }}>1.</span>
<span>Alle <strong>{releaseModal.affectedCount} Geräte</strong> in der Gruppe <strong>{releaseModal.groupName}</strong> erhalten beim nächsten Check-in (max. 1 Minute) den Update-Befehl.</span>
</div>
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 10, fontSize: 13 }}>
<span style={{ color: '#6366f1', fontWeight: 700, marginTop: 1 }}>2.</span>
<span>Der Agent lädt <strong>v{releaseModal.version}</strong> automatisch herunter und installiert sich selbst neu kein Eingriff nötig.</span>
</div>
{releaseModal.nextGroup && (
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 10, fontSize: 13 }}>
<span style={{ color: '#6366f1', fontWeight: 700, marginTop: 1 }}>3.</span>
<span>Nach dem Test kannst du <strong>{releaseModal.nextGroup.name}</strong> separat freigeben.</span>
</div>
)}
</div>
</div>
{/* Warnung */}
<div style={{ background: '#f59e0b10', border: '1px solid #f59e0b44', borderRadius: 8, padding: '10px 14px', marginBottom: 20, display: 'flex', gap: 10, alignItems: 'flex-start' }}>
<span style={{ fontSize: 16, flexShrink: 0 }}></span>
<span style={{ fontSize: 12, color: '#f59e0b' }}>
Der Update startet sofort beim nächsten Agent-Check-in. Stelle sicher, dass die neue Version in der Testgruppe stabil läuft bevor du weitere Gruppen freigibst.
</span>
</div>
{/* Freigegeben von */}
<div style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '10px 14px', background: 'var(--bg-secondary)', borderRadius: 8, marginBottom: 22, fontSize: 13 }}>
<span style={{ color: 'var(--text-muted)' }}>Freigegeben von:</span>
<strong style={{ color: 'var(--text-primary)' }}>{user?.name || user?.username || user?.email || 'Unbekannt'}</strong>
<span style={{ marginLeft: 'auto', color: 'var(--text-muted)', fontSize: 11 }}>{new Date().toLocaleString('de-DE')}</span>
</div>
{/* Buttons */}
<div style={{ display: 'flex', gap: 10, justifyContent: 'flex-end' }}>
<button
onClick={() => setReleaseModal(null)}
style={{ padding: '9px 20px', borderRadius: 8, border: '1px solid var(--border-color)', background: 'none', color: 'var(--text-secondary)', cursor: 'pointer', fontSize: 13, fontWeight: 600 }}
>
Abbrechen
</button>
<button
onClick={confirmRelease}
style={{ padding: '9px 24px', borderRadius: 8, border: 'none', background: 'linear-gradient(135deg, #6366f1, #818cf8)', color: '#fff', cursor: 'pointer', fontSize: 13, fontWeight: 700, boxShadow: '0 4px 14px rgba(99,102,241,0.4)' }}
>
🚀 Jetzt freigeben
</button>
</div>
</div>
</div>
)}
</div>
);
}
// ── Sub-Components ────────────────────────────────────────────────────────────
function AgentTable({ agents, onTrigger, onAssign, groups }) {
return (
<div style={{ background: 'var(--bg-card)', border: '1px solid var(--border-color)', borderRadius: 12, overflow: 'hidden' }}>
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead>
<tr style={{ background: 'var(--bg-secondary)', borderBottom: '1px solid var(--border-color)' }}>
{['Gerät', 'Benutzer', 'Status', 'Betriebssystem', 'Win 11', 'Agent-Version', 'Ausstehende Updates', 'Gruppe', 'Aktionen'].map(h => (
<th key={h} style={{ padding: '10px 14px', textAlign: 'left', fontSize: 12, color: 'var(--text-muted)', fontWeight: 600 }}>{h}</th>
))}
</tr>
</thead>
<tbody>
{agents.map(a => (
<tr key={a.id} style={{ borderBottom: '1px solid var(--border-color)' }}>
<td style={{ padding: '10px 14px', color: 'var(--text-primary)', fontSize: 13, fontWeight: 600 }}>{a.hostname}</td>
<td style={{ padding: '10px 14px', color: 'var(--text-muted)', fontSize: 12 }}>{a.last_user || '—'}</td>
<td style={{ padding: '10px 14px' }}>
<span style={{
display: 'inline-flex', alignItems: 'center', gap: 5,
padding: '3px 10px', borderRadius: 20, fontSize: 11, fontWeight: 700,
background: a.is_offline ? 'rgba(107,114,128,0.12)' : a.compliance === 'warn' ? 'rgba(245,158,11,0.12)' : 'rgba(16,185,129,0.12)',
color: a.is_offline ? '#6B7280' : a.compliance === 'warn' ? '#F59E0B' : '#10B981',
}}>
<span style={{ width: 6, height: 6, borderRadius: '50%', background: 'currentColor' }} />
{a.is_offline ? 'Offline' : a.compliance === 'warn' ? 'Updates ausstehend' : 'Konform'}
</span>
</td>
<td style={{ padding: '10px 14px' }}><WindowsBadge osName={a.os} osVersion={a.os_version} /></td>
<td style={{ padding: '10px 14px' }}><Win11Badge agent={a} /></td>
<td style={{ padding: '10px 14px' }}>
<VersionBadge version={a.agent_version} latest={LATEST_AGENT_VERSION} />
</td>
<td style={{ padding: '10px 14px' }}>
<span style={{ color: a.pending_updates > 0 ? '#EF4444' : '#10B981', fontWeight: 700, fontSize: 14 }}>
{a.pending_updates || 0}
</span>
</td>
<td style={{ padding: '10px 14px' }}>
<button onClick={() => onAssign(a)} style={{ background: 'none', border: '1px dashed var(--border-color)', borderRadius: 6, padding: '3px 8px', cursor: 'pointer', color: 'var(--text-muted)', fontSize: 12 }}>
{a.group_name !== 'Keine Gruppe' ? a.group_name : '+ Zuweisen'}
</button>
</td>
<td style={{ padding: '10px 14px' }}>
<div style={{ display: 'flex', gap: 6 }}>
<button className="btn btn-secondary btn-sm" disabled={a.is_offline} onClick={() => onTrigger(a.id, 'check_updates', a.hostname)} title="Update-Scan">🔍</button>
<button className="btn btn-secondary btn-sm" disabled={a.is_offline} onClick={() => onTrigger(a.id, 'install_updates', a.hostname)} title="Updates installieren"></button>
<button className="btn btn-secondary btn-sm" disabled={a.is_offline} onClick={() => {
if (window.confirm(`${a.hostname} neu starten?`)) onTrigger(a.id, 'reboot', a.hostname);
}} title="Neustart">🔄</button>
{a.agent_version !== LATEST_AGENT_VERSION && (
<button className="btn btn-secondary btn-sm" disabled={a.is_offline} onClick={() => onTrigger(a.id, 'update_agent', a.hostname)} title={`Agent aktualisieren auf v${LATEST_AGENT_VERSION}`} style={{ background: 'rgba(245,158,11,0.15)', borderColor: 'rgba(245,158,11,0.4)', color: '#F59E0B' }}></button>
)}
{a.win11_ready && !a.os?.includes('11') && (
<button className="btn btn-secondary btn-sm" disabled={a.is_offline} onClick={() => {
if (window.confirm(`Windows 11 Upgrade auf ${a.hostname} starten?\n\nDer Assistent wird heruntergeladen und läuft im Hintergrund. Das Gerät wird später neu gestartet.`)) onTrigger(a.id, 'upgrade_win11', a.hostname);
}} title="Windows 11 Upgrade" style={{ background: 'rgba(99,102,241,0.15)', borderColor: 'rgba(99,102,241,0.4)', color: '#818cf8' }}>🪟</button>
)}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
function VersionBadge({ version, latest }) {
const v = version || '?';
const parseVer = s => s.split('.').map(Number);
const [ma, mi, pa] = parseVer(v);
const [la, li, lp] = parseVer(latest);
const isLatest = ma > la || (ma === la && mi > li) || (ma === la && mi === li && pa >= lp);
return (
<span style={{
padding: '3px 9px', borderRadius: 20, fontSize: 11, fontWeight: 700,
color: isLatest ? '#10B981' : '#F59E0B',
background: isLatest ? 'rgba(16,185,129,0.12)' : 'rgba(245,158,11,0.12)',
border: `1px solid ${isLatest ? 'rgba(16,185,129,0.3)' : 'rgba(245,158,11,0.3)'}`,
}} title={isLatest ? 'Aktuell' : `Update verfügbar (${latest})`}>
v{v} {!isLatest && '⬆️'}
</span>
);
}
function StatusBadge({ status }) {
const cfg = {
pending: { label: 'Ausstehend', color: '#6B7280', bg: 'rgba(107,114,128,0.12)' },
sent: { label: 'Gesendet', color: '#3B82F6', bg: 'rgba(59,130,246,0.12)' },
running: { label: 'In Arbeit…', color: '#F59E0B', bg: 'rgba(245,158,11,0.12)' },
done: { label: 'Erledigt', color: '#10B981', bg: 'rgba(16,185,129,0.12)' },
failed: { label: 'Fehlgeschlagen', color: '#EF4444', bg: 'rgba(239,68,68,0.12)' },
}[status] || { label: status, color: '#6B7280', bg: 'rgba(107,114,128,0.12)' };
return <span style={{ padding: '3px 9px', borderRadius: 20, fontSize: 11, fontWeight: 700, color: cfg.color, background: cfg.bg }}>{cfg.label}</span>;
}
function GroupModal({ initial, onSave, onClose }) {
const [form, setForm] = useState(initial);
const s = (k) => (e) => setForm(f => ({ ...f, [k]: e.target.value }));
const COLORS = ['#3B82F6', '#10B981', '#8B5CF6', '#F59E0B', '#EF4444', '#0D9488', '#EC4899', '#6B7280'];
return (
<div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.6)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 1000 }}>
<div style={{ background: 'var(--bg-modal)', borderRadius: 16, padding: 28, width: 400, border: '1px solid var(--border-color)' }}>
<h3 style={{ margin: '0 0 20px', color: 'var(--text-primary)' }}>{form.id ? 'Gruppe bearbeiten' : 'Neue Gruppe'}</h3>
<label style={lbl}>Name *</label>
<input className="form-input" value={form.name} onChange={s('name')} style={{ marginBottom: 12 }} placeholder="z.B. Produktion" />
<label style={lbl}>Beschreibung</label>
<input className="form-input" value={form.description || ''} onChange={s('description')} style={{ marginBottom: 12 }} />
<label style={lbl}>Farbe</label>
<div style={{ display: 'flex', gap: 8, marginBottom: 20 }}>
{COLORS.map(c => (
<div key={c} onClick={() => setForm(f => ({ ...f, color: c }))} style={{ width: 28, height: 28, borderRadius: '50%', background: c, cursor: 'pointer', border: form.color === c ? '3px solid #fff' : '2px solid transparent', outline: form.color === c ? `2px solid ${c}` : 'none' }} />
))}
</div>
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 10 }}>
<button className="btn btn-secondary" onClick={onClose}>Abbrechen</button>
<button className="btn btn-primary" disabled={!form.name?.trim()} onClick={() => onSave(form)}>Speichern</button>
</div>
</div>
</div>
);
}
function PolicyModal({ initial, groups, onSave, onClose }) {
const [form, setForm] = useState(initial);
const s = (k) => (e) => setForm(f => ({ ...f, [k]: e.target.value }));
return (
<div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.6)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 1000 }}>
<div style={{ background: 'var(--bg-modal)', borderRadius: 16, padding: 28, width: 420, border: '1px solid var(--border-color)' }}>
<h3 style={{ margin: '0 0 20px', color: 'var(--text-primary)' }}>Patch-Richtlinie</h3>
<label style={lbl}>Gruppe</label>
<select className="form-select" value={form.group_id} onChange={s('group_id')} style={{ marginBottom: 12 }}>
{groups.map(g => <option key={g.id} value={g.id}>{g.name}</option>)}
</select>
<label style={lbl}>Schweregrad</label>
<select className="form-select" value={form.severity} onChange={s('severity')} style={{ marginBottom: 12 }}>
{Object.entries(SEVERITY_LABELS).map(([k, v]) => <option key={k} value={k}>{v}</option>)}
</select>
<label style={lbl}>Maximale Tage bis Patch *</label>
<input className="form-input" type="number" min={1} value={form.max_days} onChange={s('max_days')} style={{ marginBottom: 12 }} />
<label style={lbl}>Benachrichtigungs-E-Mail (optional)</label>
<input className="form-input" type="email" value={form.notify_email || ''} onChange={s('notify_email')} style={{ marginBottom: 20 }} placeholder="admin@firma.de" />
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 10 }}>
<button className="btn btn-secondary" onClick={onClose}>Abbrechen</button>
<button className="btn btn-primary" onClick={() => onSave(form)}>Speichern</button>
</div>
</div>
</div>
);
}
function AssignModal({ agent, groups, onAssign, onClose }) {
return (
<div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.6)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 1000 }}>
<div style={{ background: 'var(--bg-modal)', borderRadius: 16, padding: 28, width: 360, border: '1px solid var(--border-color)' }}>
<h3 style={{ margin: '0 0 8px', color: 'var(--text-primary)' }}>Gerät zuweisen</h3>
<p style={{ margin: '0 0 20px', color: 'var(--text-muted)', fontSize: 13 }}>{agent.hostname}</p>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
<div onClick={() => onAssign(agent.id, 'none')} style={{ padding: '10px 14px', border: '1px dashed var(--border-color)', borderRadius: 8, cursor: 'pointer', color: 'var(--text-muted)', fontSize: 13 }}>
Keine Gruppe
</div>
{groups.map(g => (
<div key={g.id} onClick={() => onAssign(agent.id, g.id)} style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '10px 14px', background: agent.group_id === g.id ? `${g.color}18` : 'var(--bg-secondary)', border: `1px solid ${agent.group_id === g.id ? g.color : 'var(--border-color)'}`, borderRadius: 8, cursor: 'pointer' }}>
<div style={{ width: 10, height: 10, borderRadius: '50%', background: g.color }} />
<span style={{ color: 'var(--text-primary)', fontSize: 14, fontWeight: 600 }}>{g.name}</span>
{agent.group_id === g.id && <span style={{ marginLeft: 'auto', color: g.color, fontSize: 12 }}> Aktuell</span>}
</div>
))}
</div>
<button className="btn btn-secondary" style={{ marginTop: 16, width: '100%' }} onClick={onClose}>Abbrechen</button>
</div>
</div>
);
}
function Win11Badge({ agent }) {
if (agent.agent_version === '?' || agent.agent_version === '1.0.0' || agent.agent_version === '1.1.0') {
return (
<span title="Agent v1.2.0+ erforderlich für TPM-Erkennung" style={{ padding: '3px 9px', borderRadius: 20, fontSize: 11, fontWeight: 700, color: '#6B7280', background: 'rgba(107,114,128,0.12)', border: '1px solid rgba(107,114,128,0.3)', cursor: 'default' }}>
? Update Agent
</span>
);
}
if (agent.win11_ready) {
const alreadyWin11 = agent.os?.includes('11');
const tpmHint = agent.tpm_version ? `TPM ${agent.tpm_version}` : 'TPM 2.0';
const sbHint = agent.secure_boot ? ' · Secure Boot ✓' : '';
return (
<span
title={alreadyWin11 ? 'Läuft bereits auf Windows 11' : `Win 11 fähig — ${tpmHint}${sbHint}`}
style={{ padding: '3px 9px', borderRadius: 20, fontSize: 11, fontWeight: 700,
color: alreadyWin11 ? '#6366F1' : '#10B981',
background: alreadyWin11 ? 'rgba(99,102,241,0.12)' : 'rgba(16,185,129,0.12)',
border: `1px solid ${alreadyWin11 ? 'rgba(99,102,241,0.3)' : 'rgba(16,185,129,0.3)'}`,
cursor: 'default' }}>
{alreadyWin11 ? '🪟 Win 11' : '✓ Fähig'}
</span>
);
}
const reasons = [];
if (!agent.tpm_v2) reasons.push(agent.tpm_present ? 'TPM 1.2 (zu alt)' : 'Kein TPM');
if (!agent.secure_boot) reasons.push('Kein Secure Boot');
return (
<span title={`Nicht Win 11 fähig: ${reasons.join(', ')}`} style={{ padding: '3px 9px', borderRadius: 20, fontSize: 11, fontWeight: 700, color: '#EF4444', background: 'rgba(239,68,68,0.12)', border: '1px solid rgba(239,68,68,0.3)', cursor: 'default' }}>
{reasons[0] || 'Nicht fähig'}
</span>
);
}
const WIN_BUILDS = {
// Windows 11
26100: { label: 'Win 11 24H2', eol: null },
22631: { label: 'Win 11 23H2', eol: '2026-11-10' },
22621: { label: 'Win 11 22H2', eol: '2024-10-14' },
22000: { label: 'Win 11 21H2', eol: '2023-10-10' },
// Windows 10
19045: { label: 'Win 10 22H2', eol: '2025-10-14' },
19044: { label: 'Win 10 21H2', eol: '2023-06-13' },
19043: { label: 'Win 10 21H1', eol: '2022-12-13' },
19042: { label: 'Win 10 20H2', eol: '2022-05-10' },
19041: { label: 'Win 10 2004', eol: '2021-12-14' },
// Windows Server
26080: { label: 'Server 25H2', eol: '2034-10-09' },
20348: { label: 'Server 2022', eol: '2031-10-14' },
17763: { label: 'Server 2019', eol: '2029-01-09' },
14393: { label: 'Server 2016', eol: '2027-01-12' },
};
function WindowsBadge({ osName, osVersion }) {
const build = parseInt((osVersion || '').split('.')[2] || '0', 10);
const info = WIN_BUILDS[build];
const now = new Date();
let color, bg, border, suffix = '';
if (!info) {
const label = osName?.includes('Server') ? 'Server' : osName?.includes('11') ? 'Win 11' : osName?.includes('10') ? 'Win 10' : (osName || '?');
return (
<span title={`${osName || '?'} — Build ${build || '?'}`} style={{ padding: '3px 9px', borderRadius: 20, fontSize: 11, fontWeight: 700, color: '#6B7280', background: 'rgba(107,114,128,0.12)', border: '1px solid rgba(107,114,128,0.3)' }}>
🪟 {label}
</span>
);
}
const eolDate = info.eol ? new Date(info.eol) : null;
const isEol = eolDate && eolDate < now;
const isSoonEol = eolDate && !isEol && (eolDate - now) < 180 * 24 * 3600 * 1000;
if (isEol) {
color = '#EF4444'; bg = 'rgba(239,68,68,0.12)'; border = 'rgba(239,68,68,0.3)'; suffix = ' ⚠️';
} else if (isSoonEol) {
color = '#F59E0B'; bg = 'rgba(245,158,11,0.12)'; border = 'rgba(245,158,11,0.3)'; suffix = ' ⬆️';
} else if (!info.eol) {
color = '#10B981'; bg = 'rgba(16,185,129,0.12)'; border = 'rgba(16,185,129,0.3)';
} else {
color = '#3B82F6'; bg = 'rgba(59,130,246,0.12)'; border = 'rgba(59,130,246,0.3)';
}
const eolHint = isEol ? `EOL seit ${info.eol}` : info.eol ? `Support bis ${info.eol}` : 'Aktuellste Version';
return (
<span title={`${osName || info.label} — Build ${build}${eolHint}`} style={{ padding: '3px 9px', borderRadius: 20, fontSize: 11, fontWeight: 700, color, background: bg, border: `1px solid ${border}`, cursor: 'default' }}>
🪟 {info.label}{suffix}
</span>
);
}
function LiveStatusDot({ status }) {
const cfg = {
pending: { color: '#3B82F6', anim: 'livePulse 1.2s ease-in-out infinite' },
sent: { color: '#F59E0B', anim: 'livePulse 1.2s ease-in-out infinite' },
done: { color: '#10B981', anim: 'none' },
failed: { color: '#EF4444', anim: 'none' },
}[status] || { color: '#6B7280', anim: 'none' };
return (
<>
<span style={{ display: 'inline-block', width: 10, height: 10, borderRadius: '50%', background: cfg.color, flexShrink: 0, animation: cfg.anim }} />
<style>{`@keyframes livePulse { 0%,100%{opacity:1;transform:scale(1)} 50%{opacity:.4;transform:scale(1.3)} }`}</style>
</>
);
}
function LiveStatusBadge({ status }) {
const cfg = {
pending: { label: 'Warten…', color: '#3B82F6', bg: 'rgba(59,130,246,0.12)' },
sent: { label: 'Läuft…', color: '#F59E0B', bg: 'rgba(245,158,11,0.12)' },
running: { label: 'In Arbeit…', color: '#8B5CF6', bg: 'rgba(139,92,246,0.12)' },
done: { label: 'Erledigt', color: '#10B981', bg: 'rgba(16,185,129,0.12)' },
failed: { label: 'Fehler', color: '#EF4444', bg: 'rgba(239,68,68,0.12)' },
}[status] || { label: status, color: '#6B7280', bg: 'rgba(107,114,128,0.12)' };
return (
<span style={{ padding: '3px 9px', borderRadius: 20, fontSize: 11, fontWeight: 700, color: cfg.color, background: cfg.bg, whiteSpace: 'nowrap' }}>
{cfg.label}
</span>
);
}
const lbl = { display: 'block', marginBottom: 6, fontSize: 13, color: 'var(--text-secondary)', fontWeight: 600 };