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' } } ;
2026-06-26 13:27:17 +02:00
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 } ;