Add Remote Desktop (JPEG-over-WebSocket), Agent v2.2.0

- RtcService.cs: Screen capture via Graphics.CopyFromScreen, ~8fps JPEG stream over WebSocket
- shellServer.js: Separate rdp-agent/rdp types with independent socket maps (no shell collision)
- AgentDetailPage: WebSocket-based RemoteDesktop component replaces WebRTC attempt
- setup.iss: Fixed filename to include 'v' prefix (IT-Nexus-Agent-Setup-v2.2.0.exe)
- Removed SIPSorcery dependencies, added System.Drawing.Common

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-10 14:13:18 +02:00
parent c23091fa6e
commit 0a8b186585
7 changed files with 256 additions and 99 deletions

View File

@@ -6,7 +6,7 @@ namespace ITNexusAgent;
public class AgentWorker public class AgentWorker
{ {
private const string Version = "2.1.2"; private const string Version = "2.2.0";
private const string DataDir = @"C:\ProgramData\IT Nexus Agent"; private const string DataDir = @"C:\ProgramData\IT Nexus Agent";
private const string ConfigPath = @"C:\ProgramData\IT Nexus Agent\config.json"; private const string ConfigPath = @"C:\ProgramData\IT Nexus Agent\config.json";
private const string StatusPath = @"C:\ProgramData\IT Nexus Agent\status.json"; private const string StatusPath = @"C:\ProgramData\IT Nexus Agent\status.json";
@@ -43,6 +43,10 @@ public class AgentWorker
var shellService = new ShellService(_config.ServerUrl, _config.AgentKey, SystemInfoService.GetHostname()); var shellService = new ShellService(_config.ServerUrl, _config.AgentKey, SystemInfoService.GetHostname());
_ = shellService.RunAsync(_ct); _ = shellService.RunAsync(_ct);
// WebRTC Remote Desktop Service im Hintergrund starten
var rtcService = new RtcService(_config.ServerUrl, _config.AgentKey, SystemInfoService.GetHostname());
_ = rtcService.RunAsync(_ct);
while (!_ct.IsCancellationRequested) while (!_ct.IsCancellationRequested)
{ {
await RunCycleAsync(); await RunCycleAsync();

View File

@@ -2,7 +2,7 @@
<PropertyGroup> <PropertyGroup>
<OutputType>WinExe</OutputType> <OutputType>WinExe</OutputType>
<TargetFramework>net8.0-windows</TargetFramework> <TargetFramework>net8.0-windows10.0.17763.0</TargetFramework>
<UseWPF>true</UseWPF> <UseWPF>true</UseWPF>
<UseWindowsForms>true</UseWindowsForms> <UseWindowsForms>true</UseWindowsForms>
<AssemblyName>IT-Nexus-Agent</AssemblyName> <AssemblyName>IT-Nexus-Agent</AssemblyName>
@@ -28,6 +28,7 @@
<ItemGroup> <ItemGroup>
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" /> <PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
<PackageReference Include="System.Drawing.Common" Version="8.0.0" />
<PackageReference Include="System.Management" Version="8.0.0" /> <PackageReference Include="System.Management" Version="8.0.0" />
<PackageReference Include="System.ServiceProcess.ServiceController" Version="8.0.0" /> <PackageReference Include="System.ServiceProcess.ServiceController" Version="8.0.0" />
</ItemGroup> </ItemGroup>

View File

@@ -0,0 +1,127 @@
using System.Drawing;
using System.Drawing.Imaging;
using System.Net.WebSockets;
using System.Text;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
namespace ITNexusAgent.Services;
// Überträgt den Bildschirm via JPEG-Frames über WebSocket an den Browser
public class RtcService
{
private readonly string _serverUrl;
private readonly string _agentKey;
private readonly string _hostname;
private static readonly ImageCodecInfo JpegCodec =
ImageCodecInfo.GetImageEncoders().First(c => c.FormatID == ImageFormat.Jpeg.Guid);
public RtcService(string serverUrl, string agentKey, string hostname)
{
_serverUrl = serverUrl;
_agentKey = agentKey;
_hostname = hostname;
}
public async Task RunAsync(CancellationToken ct)
{
while (!ct.IsCancellationRequested)
{
try { await ConnectAsync(ct); }
catch (Exception ex) { AgentWorker.Log($"RDP: {ex.Message}"); }
if (!ct.IsCancellationRequested)
await Task.Delay(TimeSpan.FromSeconds(15), ct).ContinueWith(_ => { });
}
}
private async Task ConnectAsync(CancellationToken ct)
{
var wsUrl = _serverUrl
.Replace("https://", "wss://")
.Replace("http://", "ws://")
+ $"/ws?type=rdp-agent&key={Uri.EscapeDataString(_agentKey)}&hostname={Uri.EscapeDataString(_hostname)}";
using var ws = new ClientWebSocket();
await ws.ConnectAsync(new Uri(wsUrl), ct);
AgentWorker.Log("RDP: Bereit");
CancellationTokenSource? captureCts = null;
var buf = new byte[4096];
while (ws.State == WebSocketState.Open && !ct.IsCancellationRequested)
{
WebSocketReceiveResult res;
try { res = await ws.ReceiveAsync(new ArraySegment<byte>(buf), ct); }
catch { break; }
if (res.MessageType == WebSocketMessageType.Close) break;
var raw = Encoding.UTF8.GetString(buf, 0, res.Count);
JObject? obj;
try { obj = JObject.Parse(raw); } catch { continue; }
var type = obj["type"]?.ToString();
if (type == "rdp_start" && captureCts == null)
{
captureCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
_ = CaptureLoopAsync(ws, captureCts.Token);
AgentWorker.Log("RDP: Screen-Capture gestartet");
}
else if (type == "rdp_stop" && captureCts != null)
{
captureCts.Cancel();
captureCts = null;
AgentWorker.Log("RDP: Screen-Capture gestoppt");
}
}
captureCts?.Cancel();
AgentWorker.Log("RDP: Getrennt");
}
private static async Task CaptureLoopAsync(ClientWebSocket ws, CancellationToken ct)
{
var encParams = new EncoderParameters(1);
encParams.Param[0] = new EncoderParameter(System.Drawing.Imaging.Encoder.Quality, 60L);
while (!ct.IsCancellationRequested && ws.State == WebSocketState.Open)
{
try
{
var screen = System.Windows.Forms.Screen.PrimaryScreen;
if (screen == null) { await Task.Delay(500, ct).ContinueWith(_ => { }); continue; }
var bounds = screen.Bounds;
using var bmp = new Bitmap(bounds.Width, bounds.Height, System.Drawing.Imaging.PixelFormat.Format32bppArgb);
using (var g = Graphics.FromImage(bmp))
g.CopyFromScreen(bounds.X, bounds.Y, 0, 0, bounds.Size);
byte[] jpeg;
using (var ms = new MemoryStream())
{
bmp.Save(ms, JpegCodec, encParams);
jpeg = ms.ToArray();
}
var payload = Encoding.UTF8.GetBytes(JsonConvert.SerializeObject(new
{
type = "rdp_frame",
data = Convert.ToBase64String(jpeg),
w = bounds.Width,
h = bounds.Height
}));
if (ws.State == WebSocketState.Open)
await ws.SendAsync(new ArraySegment<byte>(payload), WebSocketMessageType.Text, true, CancellationToken.None);
}
catch (OperationCanceledException) { break; }
catch (Exception ex) { AgentWorker.Log($"RDP: Capture-Fehler: {ex.Message}"); }
// ~8 fps
await Task.Delay(125, ct).ContinueWith(_ => { });
}
}
}

View File

@@ -1,5 +1,5 @@
#define MyAppName "IT Nexus Agent" #define MyAppName "IT Nexus Agent"
#define MyAppVersion "2.1.2" #define MyAppVersion "2.2.0"
#define MyAppPublisher "Cereda Systems GmbH" #define MyAppPublisher "Cereda Systems GmbH"
#define MyAppURL "https://it-nexus.cereda-systems.de" #define MyAppURL "https://it-nexus.cereda-systems.de"
#define MyAppExeName "IT-Nexus-Agent.exe" #define MyAppExeName "IT-Nexus-Agent.exe"
@@ -19,7 +19,7 @@ DefaultGroupName={#MyAppName}
DisableProgramGroupPage=yes DisableProgramGroupPage=yes
DisableWelcomePage=no DisableWelcomePage=no
OutputDir=..\installer OutputDir=..\installer
OutputBaseFilename=IT-Nexus-Agent-Setup-{#MyAppVersion} OutputBaseFilename=IT-Nexus-Agent-Setup-v{#MyAppVersion}
SetupIconFile=icon.ico SetupIconFile=icon.ico
Compression=lzma2/max Compression=lzma2/max
SolidCompression=yes SolidCompression=yes

View File

@@ -7,6 +7,10 @@ const agentSockets = new Map();
// agentId -> WebSocket // agentId -> WebSocket
const browserSockets = new Map(); const browserSockets = new Map();
// RDP: separate Maps damit Shell nicht kollidiert
const rdpAgentSockets = new Map();
const rdpBrowserSockets = new Map();
function setupWebSocketServer(httpServer) { function setupWebSocketServer(httpServer) {
const wss = new WebSocket.Server({ server: httpServer, path: '/ws' }); const wss = new WebSocket.Server({ server: httpServer, path: '/ws' });
@@ -24,6 +28,10 @@ function setupWebSocketServer(httpServer) {
handleAgent(ws, url); handleAgent(ws, url);
} else if (type === 'shell') { } else if (type === 'shell') {
handleBrowser(ws, url); handleBrowser(ws, url);
} else if (type === 'rdp-agent') {
handleRdpAgent(ws, url);
} else if (type === 'rdp') {
handleRdpBrowser(ws, url);
} else { } else {
ws.close(1008, 'unknown type'); ws.close(1008, 'unknown type');
} }
@@ -124,6 +132,63 @@ function handleBrowser(ws, url) {
}); });
} }
function handleRdpAgent(ws, url) {
const key = url.searchParams.get('key');
if (key !== process.env.AGENT_API_KEY) { ws.close(1008, 'unauthorized'); return; }
const hostname = url.searchParams.get('hostname');
if (!hostname) { ws.close(1008, 'hostname required'); return; }
const db = getDatabase();
const agent = db.prepare('SELECT id FROM monitoring_agents WHERE hostname = ?').get(hostname);
if (!agent) { ws.close(1008, 'agent not found'); return; }
const agentId = agent.id;
const oldWs = rdpAgentSockets.get(agentId);
if (oldWs?.readyState === WebSocket.OPEN) oldWs.close();
rdpAgentSockets.set(agentId, ws);
console.log(`[WS-RDP] Agent verbunden: ${hostname}`);
ws.on('message', (data) => {
const bws = rdpBrowserSockets.get(agentId);
if (bws?.readyState === WebSocket.OPEN) bws.send(data.toString());
});
ws.on('close', () => {
rdpAgentSockets.delete(agentId);
const bws = rdpBrowserSockets.get(agentId);
if (bws?.readyState === WebSocket.OPEN) {
bws.send(JSON.stringify({ type: 'rdp_disconnected' }));
}
console.log(`[WS-RDP] Agent getrennt: ${hostname}`);
});
}
function handleRdpBrowser(ws, url) {
const token = url.searchParams.get('token');
try { jwt.verify(token, process.env.JWT_SECRET); }
catch { ws.close(1008, 'unauthorized'); return; }
const agentId = parseInt(url.searchParams.get('agentId'));
if (!agentId) { ws.close(1008, 'agentId required'); return; }
const oldBws = rdpBrowserSockets.get(agentId);
if (oldBws?.readyState === WebSocket.OPEN) oldBws.close();
rdpBrowserSockets.set(agentId, ws);
ws.on('message', (data) => {
const aws = rdpAgentSockets.get(agentId);
if (aws?.readyState === WebSocket.OPEN) aws.send(data.toString());
});
ws.on('close', () => {
rdpBrowserSockets.delete(agentId);
const aws = rdpAgentSockets.get(agentId);
if (aws?.readyState === WebSocket.OPEN) {
aws.send(JSON.stringify({ type: 'rdp_stop' }));
}
});
}
// Ankündigung an Agent pushen (für sofortige Zustellung) // Ankündigung an Agent pushen (für sofortige Zustellung)
function pushAnnouncementToAgent(agentId, announcements) { function pushAnnouncementToAgent(agentId, announcements) {
const aws = agentSockets.get(agentId); const aws = agentSockets.get(agentId);

View File

@@ -289,120 +289,91 @@ function RemoteShell({ agentId, agentHostname }) {
); );
} }
// ─── Remote Desktop (WebRTC) ───────────────────────────────────────────────────── // ─── Remote Desktop (JPEG-over-WebSocket) ────────────────────────────────────────
function RemoteDesktop({ agentId, agentHostname }) { function RemoteDesktop({ agentId, agentHostname }) {
const [status, setStatus] = useState('idle'); // idle | signaling | connected | error const [status, setStatus] = useState('idle'); // idle | connecting | connected | error
const [error, setError] = useState(''); const [error, setError] = useState('');
const videoRef = useRef(null); const [fps, setFps] = useState(0);
const pcRef = useRef(null); const [resolution, setResolution] = useState('');
const imgRef = useRef(null);
const wsRef = useRef(null); const wsRef = useRef(null);
const dcRef = useRef(null); const fpsCounterRef = useRef({ count: 0, last: Date.now() });
const canvasRef = useRef(null);
const getWsUrl = () => { const getWsUrl = () => {
const token = localStorage.getItem('token'); const token = localStorage.getItem('token');
const proto = window.location.protocol === 'https:' ? 'wss' : 'ws'; const proto = window.location.protocol === 'https:' ? 'wss' : 'ws';
return `${proto}://${window.location.host}/ws?type=shell&agentId=${agentId}&token=${token}`; return `${proto}://${window.location.host}/ws?type=rdp&agentId=${agentId}&token=${token}`;
}; };
const connect = async () => { const connect = () => {
setStatus('signaling'); setStatus('connecting');
setError(''); setError('');
const ws = new WebSocket(getWsUrl()); const ws = new WebSocket(getWsUrl());
wsRef.current = ws; wsRef.current = ws;
const pc = new RTCPeerConnection({ ws.onopen = () => {
iceServers: [{ urls: 'stun:stun.l.google.com:19302' }] ws.send(JSON.stringify({ type: 'rdp_start' }));
});
pcRef.current = pc;
// Video-Stream vom Agent empfangen
pc.ontrack = (e) => {
if (videoRef.current && e.streams[0]) {
videoRef.current.srcObject = e.streams[0];
setStatus('connected'); setStatus('connected');
}
}; };
// ICE Candidates an Agent schicken ws.onmessage = (e) => {
pc.onicecandidate = (e) => {
if (e.candidate && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'rtc_ice', candidate: e.candidate }));
}
};
pc.onconnectionstatechange = () => {
if (pc.connectionState === 'disconnected' || pc.connectionState === 'failed') {
setStatus('idle');
}
};
// DataChannel für Maus/Tastatur-Input
const dc = pc.createDataChannel('input');
dcRef.current = dc;
// Offer erstellen und an Agent senden
ws.onopen = async () => {
const offer = await pc.createOffer({ offerToReceiveVideo: true, offerToReceiveAudio: false });
await pc.setLocalDescription(offer);
ws.send(JSON.stringify({ type: 'rtc_offer', sdp: offer.sdp }));
};
ws.onmessage = async (e) => {
// Shell-Output ignorieren, nur RTC-Messages verarbeiten
try { try {
const msg = JSON.parse(e.data); const msg = JSON.parse(e.data);
if (msg.type === 'rtc_answer') { if (msg.type === 'rdp_frame' && imgRef.current) {
await pc.setRemoteDescription({ type: 'answer', sdp: msg.sdp }); imgRef.current.src = 'data:image/jpeg;base64,' + msg.data;
} else if (msg.type === 'rtc_ice' && msg.candidate) { if (msg.w && msg.h) setResolution(`${msg.w}×${msg.h}`);
await pc.addIceCandidate(msg.candidate); // FPS-Counter
const now = Date.now();
fpsCounterRef.current.count++;
if (now - fpsCounterRef.current.last >= 1000) {
setFps(fpsCounterRef.current.count);
fpsCounterRef.current = { count: 0, last: now };
} }
} catch { /* kein JSON / Shell-Output → ignorieren */ } } else if (msg.type === 'rdp_disconnected') {
setStatus('idle');
setError('Agent hat die Verbindung getrennt');
}
} catch { /* ignorieren */ }
}; };
ws.onerror = () => { setStatus('error'); setError('WebSocket-Fehler'); }; ws.onerror = () => { setStatus('error'); setError('WebSocket-Fehler'); };
ws.onclose = () => { if (status !== 'connected') setStatus('idle'); }; ws.onclose = () => { setStatus('idle'); };
}; };
const disconnect = () => { const disconnect = () => {
pcRef.current?.close(); if (wsRef.current?.readyState === WebSocket.OPEN) {
wsRef.current?.close(); wsRef.current.send(JSON.stringify({ type: 'rdp_stop' }));
pcRef.current = null; wsRef.current.close();
}
wsRef.current = null; wsRef.current = null;
if (videoRef.current) videoRef.current.srcObject = null; if (imgRef.current) imgRef.current.src = '';
setStatus('idle'); setStatus('idle');
setFps(0);
setResolution('');
}; };
useEffect(() => () => disconnect(), []); useEffect(() => () => { wsRef.current?.close(); }, []);
// Maus-Events auf Canvas → DataChannel const statusColor = status === 'connected' ? '#34d399' : status === 'connecting' ? '#f59e0b' : status === 'error' ? '#ef4444' : '#6b7280';
const sendMouseEvent = (type, e) => { const statusLabel = { idle: 'Getrennt', connecting: 'Verbinde…', connected: 'Verbunden', error: 'Fehler' }[status];
if (!dcRef.current || dcRef.current.readyState !== 'open') return;
const rect = canvasRef.current?.getBoundingClientRect();
if (!rect) return;
const scaleX = 1920 / rect.width;
const scaleY = 1080 / rect.height;
dcRef.current.send(JSON.stringify({
type, x: Math.round((e.clientX - rect.left) * scaleX),
y: Math.round((e.clientY - rect.top) * scaleY), button: e.button
}));
};
const statusColor = status === 'connected' ? '#34d399' : status === 'signaling' ? '#f59e0b' : status === 'error' ? '#ef4444' : '#6b7280';
const statusLabel = { idle: 'Getrennt', signaling: 'Verbinde…', connected: 'Verbunden', error: 'Fehler' }[status];
return ( return (
<div style={{ marginTop: 24, borderRadius: 16, border: '1px solid var(--border-color)', overflow: 'hidden', background: 'var(--bg-secondary)' }}> <div style={{ marginTop: 24, borderRadius: 16, border: '1px solid var(--border-color)', overflow: 'hidden', background: 'var(--bg-secondary)' }}>
<div style={{ padding: '14px 20px', borderBottom: '1px solid var(--border-color)', display: 'flex', alignItems: 'center', gap: 10 }}> <div style={{ padding: '14px 20px', borderBottom: '1px solid var(--border-color)', display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap' }}>
<span style={{ fontSize: 15 }}>🖥</span> <span style={{ fontSize: 15 }}>🖥</span>
<span style={{ fontWeight: 600, fontSize: 14, color: 'var(--text-primary)' }}>Remote Desktop</span> <span style={{ fontWeight: 600, fontSize: 14, color: 'var(--text-primary)' }}>Remote Desktop</span>
<span style={{ fontSize: 12, color: 'var(--text-muted)', marginLeft: 4 }}>{agentHostname} · WebRTC</span> <span style={{ fontSize: 12, color: 'var(--text-muted)', marginLeft: 4 }}>{agentHostname}</span>
<span style={{ marginLeft: 8, display: 'flex', alignItems: 'center', gap: 5, fontSize: 12 }}> <span style={{ display: 'flex', alignItems: 'center', gap: 5, fontSize: 12 }}>
<span style={{ width: 8, height: 8, borderRadius: '50%', background: statusColor, display: 'inline-block' }} /> <span style={{ width: 8, height: 8, borderRadius: '50%', background: statusColor, display: 'inline-block' }} />
<span style={{ color: statusColor }}>{statusLabel}</span> <span style={{ color: statusColor }}>{statusLabel}</span>
</span> </span>
{status === 'connected' && resolution && (
<span style={{ fontSize: 11, color: 'var(--text-muted)', background: 'var(--bg-tertiary)', borderRadius: 6, padding: '2px 8px' }}>
{resolution} · {fps} fps
</span>
)}
<div style={{ marginLeft: 'auto', display: 'flex', gap: 8 }}> <div style={{ marginLeft: 'auto', display: 'flex', gap: 8 }}>
{status === 'idle' || status === 'error' ? ( {status === 'idle' || status === 'error' ? (
<button onClick={connect} style={{ background: '#238636', border: 'none', borderRadius: 8, color: '#fff', padding: '5px 14px', fontSize: 12, fontWeight: 600, cursor: 'pointer' }}> <button onClick={connect} style={{ background: '#238636', border: 'none', borderRadius: 8, color: '#fff', padding: '5px 14px', fontSize: 12, fontWeight: 600, cursor: 'pointer' }}>
@@ -421,29 +392,18 @@ function RemoteDesktop({ agentId, agentHostname }) {
<span style={{ fontSize: 48 }}>🖥</span> <span style={{ fontSize: 48 }}>🖥</span>
<div style={{ textAlign: 'center' }}> <div style={{ textAlign: 'center' }}>
<div style={{ fontSize: 15, fontWeight: 600, color: 'var(--text-primary)', marginBottom: 6 }}>Remote Desktop (Beta)</div> <div style={{ fontSize: 15, fontWeight: 600, color: 'var(--text-primary)', marginBottom: 6 }}>Remote Desktop (Beta)</div>
<div style={{ fontSize: 13 }}>Direktübertragung via WebRTC Agent muss v2.2.0+ haben</div> <div style={{ fontSize: 13 }}>Bildschirmübertragung via WebSocket Agent muss v2.2.0+ haben</div>
{error && <div style={{ marginTop: 8, color: '#ef4444', fontSize: 12 }}>{error}</div>} {error && <div style={{ marginTop: 8, color: '#ef4444', fontSize: 12 }}>{error}</div>}
</div> </div>
</div> </div>
) : ( ) : (
<div style={{ background: '#000', position: 'relative', lineHeight: 0 }}> <div style={{ background: '#000', lineHeight: 0, position: 'relative' }}>
<video <img
ref={videoRef} ref={imgRef}
autoPlay alt="Remote Desktop"
playsInline style={{ width: '100%', display: 'block', maxHeight: 640, objectFit: 'contain' }}
style={{ width: '100%', display: 'block', maxHeight: 600, objectFit: 'contain' }}
/> />
{/* Unsichtbarer Canvas für Maus-Koordinaten */} {status === 'connecting' && (
<canvas
ref={canvasRef}
style={{ position: 'absolute', inset: 0, width: '100%', height: '100%', cursor: 'none', opacity: 0 }}
onMouseMove={e => sendMouseEvent('mousemove', e)}
onMouseDown={e => sendMouseEvent('mousedown', e)}
onMouseUp={e => sendMouseEvent('mouseup', e)}
onClick={e => sendMouseEvent('click', e)}
onContextMenu={e => { e.preventDefault(); sendMouseEvent('rightclick', e); }}
/>
{status === 'signaling' && (
<div style={{ position: 'absolute', inset: 0, display: 'flex', alignItems: 'center', justifyContent: 'center', background: 'rgba(0,0,0,0.7)', color: '#fff', fontSize: 14 }}> <div style={{ position: 'absolute', inset: 0, display: 'flex', alignItems: 'center', justifyContent: 'center', background: 'rgba(0,0,0,0.7)', color: '#fff', fontSize: 14 }}>
Verbinde Verbinde
</div> </div>

View File

@@ -5,7 +5,7 @@ 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 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.1.2'; const LATEST_AGENT_VERSION = '2.2.0';
const SEVERITY_LABELS = { critical: 'Kritisch', important: 'Wichtig', moderate: 'Moderat', low: 'Niedrig', all: 'Alle' }; 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 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' }; const COMMAND_LABELS = { check_updates: '🔍 Update-Scan', install_updates: '⬇️ Installation', reboot: '🔄 Neustart', upgrade_win11: '🪟 Win 11 Upgrade', update_agent: '⬆️ Agent-Update' };