diff --git a/frontend/src/pages/Windows.jsx b/frontend/src/pages/Windows.jsx index 21bdba5..197b311 100644 --- a/frontend/src/pages/Windows.jsx +++ b/frontend/src/pages/Windows.jsx @@ -1,149 +1,300 @@ -import { useEffect, useState, useMemo } from 'react' +import { useEffect, useState, useMemo, useRef } from 'react' import api from '../api/client' import StatusBadge from '../components/StatusBadge' import WindowsPanel from '../components/WindowsPanel' -import { Search, Monitor } from 'lucide-react' +import { Search, ChevronUp, ChevronDown, Settings2, X } from 'lucide-react' -function fmtPct(v) { - if (v === null || v === undefined) return '—' - return `${Number(v).toFixed(0)}%` +const ALL_COLUMNS = [ + { id: 'customer', label: 'Kunde', default: true }, + { id: 'name', label: 'Name', default: true, locked: true }, + { id: 'hostname', label: 'Hostname', default: false }, + { id: 'os', label: 'OS', default: true }, + { id: 'version', label: 'Agent Version', default: true }, + { id: 'ip', label: 'IP', default: true }, + { id: 'uptime', label: 'Uptime', default: true }, + { id: 'cpu', label: 'CPU', default: true }, + { id: 'ram', label: 'RAM', default: true }, + { id: 'disk', label: 'Disk (C:)', default: true }, + { id: 'lastresponse', label: 'Letzter Kontakt', default: false }, +] + +const STORAGE_KEY = 'rmm-windows-columns' + +function loadColumns() { + try { + const saved = localStorage.getItem(STORAGE_KEY) + if (saved) return JSON.parse(saved) + } catch {} + return ALL_COLUMNS.filter(c => c.default).map(c => c.id) } -function fmtUptime(s) { - if (!s) return '—' - const d = Math.floor(s / 86400) - const h = Math.floor((s % 86400) / 3600) - return d > 0 ? `${d}d ${h}h` : `${h}h` +function saveColumns(cols) { + localStorage.setItem(STORAGE_KEY, JSON.stringify(cols)) } -function fmtBytes(b) { - if (!b) return '—' - if (b >= 1073741824) return `${(b / 1073741824).toFixed(1)} GB` - return `${(b / 1048576).toFixed(0)} MB` +function formatUptime(seconds) { + if (!seconds) return '—' + const d = Math.floor(seconds / 86400) + const h = Math.floor((seconds % 86400) / 3600) + const m = Math.floor((seconds % 3600) / 60) + if (d > 0) return `${d}d ${h}h` + return `${h}h ${m}m` } export default function Windows() { - const [agents, setAgents] = useState([]) - const [customers, setCustomers] = useState([]) - const [details, setDetails] = useState({}) - const [search, setSearch] = useState('') - const [selectedId, setSelectedId] = useState(null) - const [loading, setLoading] = useState(true) + const [agents, setAgents] = useState([]) + const [customers, setCustomers] = useState([]) + const [agentDetails, setAgentDetails] = useState({}) + const [search, setSearch] = useState('') + const [loading, setLoading] = useState(true) + const [sortKey, setSortKey] = useState('customer') + const [sortDir, setSortDir] = useState('asc') + const [selectedId, setSelectedId] = useState(null) + const [visibleCols, setVisibleCols] = useState(loadColumns) + const [showColMenu, setShowColMenu] = useState(false) + const colMenuRef = useRef(null) const reload = () => { - Promise.all([api.getAgents(), api.getCustomers()]).then(([a, c]) => { - const win = a.filter(ag => ag.platform === 'windows') - setAgents(win) - setCustomers(c) - setLoading(false) - win.forEach(ag => { - api.getAgent(ag.id).then(d => setDetails(prev => ({ ...prev, [ag.id]: d }))) + Promise.all([api.getAgents(), api.getCustomers()]) + .then(([a, c]) => { + setAgents((a || []).filter(ag => ag.platform === 'windows')) + setCustomers(c || []) }) - }) + .finally(() => setLoading(false)) } - useEffect(() => { reload() }, []) + useEffect(() => { + reload() + const iv = setInterval(reload, 30000) + return () => clearInterval(iv) + }, []) + + useEffect(() => { + agents.forEach(a => { + if (!agentDetails[a.id]) { + api.getAgent(a.id).then(d => + setAgentDetails(prev => ({ ...prev, [a.id]: d })) + ) + } + }) + }, [agents]) + + // Close column menu on click outside + useEffect(() => { + const handler = (e) => { + if (colMenuRef.current && !colMenuRef.current.contains(e.target)) setShowColMenu(false) + } + document.addEventListener('mousedown', handler) + return () => document.removeEventListener('mousedown', handler) + }, []) const customerMap = useMemo(() => - Object.fromEntries(customers.map(c => [c.id, c])), [customers]) + Object.fromEntries((customers || []).map(c => [c.id, c])), [customers]) - const rows = useMemo(() => agents.map(a => { - const d = details[a.id] - const sys = d?.system_data - const win = sys?.windows + const toggleSort = (key) => { + if (sortKey === key) setSortDir(d => d === 'asc' ? 'desc' : 'asc') + else { setSortKey(key); setSortDir('asc') } + } + + const toggleCol = (id) => { + const col = ALL_COLUMNS.find(c => c.id === id) + if (col?.locked) return + const next = visibleCols.includes(id) + ? visibleCols.filter(c => c !== id) + : [...visibleCols, id] + setVisibleCols(next) + saveColumns(next) + } + + const isColVisible = (id) => visibleCols.includes(id) + + const enriched = useMemo(() => agents.map(a => { + const d = agentDetails[a.id] + const win = d?.system_data?.windows const cust = a.customer_id ? customerMap[a.customer_id] : null - const memUsed = win?.memory?.total_bytes > 0 - ? ((win.memory.total_bytes - win.memory.available_bytes) / win.memory.total_bytes * 100) + + const memPct = win?.memory?.total_bytes > 0 + ? ((win.memory.total_bytes - (win.memory.available_bytes ?? 0)) / win.memory.total_bytes * 100) : null + + // Ersten FIXED-Disk nehmen (meistens C:) const disk = win?.disks?.[0] + const diskPct = disk?.used_percent ?? null + return { - id: a.id, name: a.name, status: a.status, - version: a.agent_version, - customer: cust ? `${cust.number} — ${cust.name}` : '—', - customer_id: a.customer_id, - os: win?.os_version?.replace('Windows ', 'Win ') || '—', - cpu: win?.cpu?.load_percent != null ? fmtPct(win.cpu.load_percent) : '—', - ram: memUsed != null ? fmtPct(memUsed) : '—', - disk: disk?.used_percent != null ? fmtPct(disk.used_percent) : '—', - uptime: fmtUptime(win?.uptime_seconds), - lastHB: a.last_heartbeat - ? new Date(a.last_heartbeat).toLocaleString('de-DE', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' }) - : '—', + ...a, + customer: cust, + customerName: cust?.name || '', + customerNumber: cust?.number || '', + os: win?.os_version || '—', + cpuPct: win?.cpu?.load_percent ?? null, + memPct, + diskPct, + uptime: win?.uptime_seconds || 0, } - }), [agents, details, customerMap]) + }), [agents, agentDetails, customerMap]) const filtered = useMemo(() => { - if (!search) return rows - const q = search.toLowerCase() - return rows.filter(r => - r.name.toLowerCase().includes(q) || - r.customer.toLowerCase().includes(q) || - r.os.toLowerCase().includes(q) - ) - }, [rows, search]) + let list = enriched + if (search) { + const q = search.toLowerCase() + list = list.filter(a => + a.name.toLowerCase().includes(q) || + (a.ip || '').toLowerCase().includes(q) || + (a.hostname || '').toLowerCase().includes(q) || + a.customerName.toLowerCase().includes(q) || + a.customerNumber.toLowerCase().includes(q) + ) + } + list = [...list].sort((a, b) => { + let va, vb + switch (sortKey) { + case 'customer': va = a.customerNumber; vb = b.customerNumber; break + case 'name': va = a.name; vb = b.name; break + case 'os': va = a.os; vb = b.os; break + case 'version': va = a.agent_version || ''; vb = b.agent_version || ''; break + case 'uptime': return sortDir === 'asc' ? a.uptime - b.uptime : b.uptime - a.uptime + case 'cpu': return sortDir === 'asc' ? (a.cpuPct ?? 999) - (b.cpuPct ?? 999) : (b.cpuPct ?? -1) - (a.cpuPct ?? -1) + case 'ram': return sortDir === 'asc' ? (a.memPct ?? 999) - (b.memPct ?? 999) : (b.memPct ?? -1) - (a.memPct ?? -1) + case 'disk': return sortDir === 'asc' ? (a.diskPct ?? 999) - (b.diskPct ?? 999) : (b.diskPct ?? -1) - (a.diskPct ?? -1) + case 'status': va = a.status; vb = b.status; break + default: va = a.name; vb = b.name + } + if (typeof va === 'string') { + const cmp = va.localeCompare(vb) + return sortDir === 'asc' ? cmp : -cmp + } + return 0 + }) + return list + }, [enriched, search, sortKey, sortDir]) - if (loading) return
Laden...
+ const SortHeader = ({ label, k, className = '' }) => ( + toggleSort(k)} + > + + {label} + {sortKey === k && (sortDir === 'asc' + ? + : + )} + + + ) return ( -
- {/* Toolbar */} -
- - Windows Agents - ({agents.length}) -
-
- +
+
+

Windows Agents ({agents.length})

+
+ +
+
+ setSearch(e.target.value)} - placeholder="Suchen..." - className="pl-8 pr-3 py-1.5 bg-gray-800 border border-gray-700 rounded text-sm text-white focus:outline-none focus:border-sky-500 w-56" + type="text" + placeholder="Suchen (Name, IP, Kunde)..." + value={search} + onChange={e => setSearch(e.target.value)} + className="w-full bg-gray-900 border border-gray-800 rounded pl-10 pr-3 py-2 text-sm text-white placeholder-gray-600 focus:outline-none focus:border-sky-500" />
+ {/* Spalten-Toggle */} +
+ + {showColMenu && ( +
+
Sichtbare Spalten
+ {ALL_COLUMNS.map(col => ( + + ))} +
+ )} +
- {/* Tabelle */} -
- {filtered.length === 0 ? ( -
- {agents.length === 0 ? 'Keine Windows Agents registriert' : 'Keine Ergebnisse'} -
- ) : ( - + {loading ? ( +
Laden...
+ ) : ( +
+
- - {['Status', 'Name', 'Kunde', 'OS', 'Version', 'CPU', 'RAM', 'Disk', 'Uptime', 'Letzter Kontakt'].map(h => ( - - ))} + + + {isColVisible('customer') && } + {isColVisible('name') && } + {isColVisible('hostname') && } + {isColVisible('os') && } + {isColVisible('version') && } + {isColVisible('ip') && } + {isColVisible('uptime') && } + {isColVisible('cpu') && } + {isColVisible('ram') && } + {isColVisible('disk') && } + {isColVisible('lastresponse') && } - - {filtered.map(r => ( + + {filtered.map(a => ( setSelectedId(r.id)} - className={`hover:bg-gray-800/50 cursor-pointer transition-colors ${selectedId === r.id ? 'bg-gray-800/70' : ''}`} + key={a.id} + onClick={() => setSelectedId(a.id)} + className={`hover:bg-gray-800/50 transition-colors cursor-pointer ${selectedId === a.id ? 'bg-gray-800/70' : ''}`} > - - - - - - - - - - + + {isColVisible('customer') && ( + + )} + {isColVisible('name') && } + {isColVisible('hostname') && } + {isColVisible('os') && } + {isColVisible('version') && } + {isColVisible('ip') && } + {isColVisible('uptime') && } + {isColVisible('cpu') && } + {isColVisible('ram') && } + {isColVisible('disk') && } + {isColVisible('lastresponse') && ( + + )} ))}
{h}
HOSTNAMEIPLETZTER KONTAKT
- - {r.name}{r.customer}{r.os}{r.version ? `v${r.version}` : '—'}{r.cpu}{r.ram}{r.disk}{r.uptime}{r.lastHB} + {a.customer + ? {a.customerNumber} + : } + {a.name}{a.hostname || '—'}{a.os}{a.agent_version ? `v${a.agent_version}` : '—'}{a.ip || '—'}{formatUptime(a.uptime)} + {a.last_heartbeat ? new Date(a.last_heartbeat).toLocaleString('de-DE') : '—'} +
- )} -
+ {filtered.length === 0 && ( +
+ {search ? 'Keine Treffer' : 'Keine Windows Agents registriert'} +
+ )} +
+ )} - {/* Panel */} {selectedId && ( ) } + +function MiniBar({ value }) { + if (value === null || value === undefined) return + const color = value > 90 ? 'bg-red-500' : value > 70 ? 'bg-yellow-500' : value > 50 ? 'bg-blue-500' : 'bg-green-500' + return ( +
+
+
+
+ {value.toFixed(0)}% +
+ ) +}