- 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>
1013 lines
71 KiB
JavaScript
1013 lines
71 KiB
JavaScript
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 };
|