Files
IT-Nexus/frontend/src/pages/PatchManagementPage.jsx

1013 lines
71 KiB
React
Raw Normal View History

2026-06-01 20:49:07 +02:00
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';
2026-06-01 20:49:07 +02:00
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 };