Add: Agent v2.1.2, WebSocket Live Shell, WebRTC Remote Desktop (Beta), Announcement Push

- Agent v2.1.2: WebSocket ShellService, ACK nach WS-Ankündigungen, shown_announcements Fix
- Backend: shellServer.js mit WebSocket-Server (Shell + Announcement Push + RTC Signaling)
- Backend: patch.controller.js Fix (command_id=0 Falsy-Bug beim Auto-Update)
- Frontend: Remote Desktop Tab (WebRTC Beta) in AgentDetailPage für super_admin/admin
- Frontend: PatchManagementPage auf v2.1.2 aktualisiert
- WPF Notification: AllowsTransparency=False + kein DropShadowEffect (Dispatcher-Crash Fix)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-10 13:31:11 +02:00
parent d584e65226
commit c23091fa6e
27 changed files with 3481 additions and 556 deletions

View File

@@ -6,7 +6,7 @@ namespace ITNexusAgent;
public class AgentWorker public class AgentWorker
{ {
private const string Version = "2.0.0"; private const string Version = "2.1.2";
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";
@@ -39,6 +39,10 @@ public class AgentWorker
Log($"Agent v{Version} gestartet"); Log($"Agent v{Version} gestartet");
// WebSocket Shell-Service im Hintergrund starten
var shellService = new ShellService(_config.ServerUrl, _config.AgentKey, SystemInfoService.GetHostname());
_ = shellService.RunAsync(_ct);
while (!_ct.IsCancellationRequested) while (!_ct.IsCancellationRequested)
{ {
await RunCycleAsync(); await RunCycleAsync();
@@ -79,12 +83,16 @@ public class AgentWorker
await executor.ExecuteAsync(cmd); await executor.ExecuteAsync(cmd);
} }
// Ankündigungen anzeigen // Ankündigungen anzeigen + sofort ACKen damit Server sie nicht mehr schickt
if (response.Announcements.Count > 0) if (response.Announcements.Count > 0)
{ {
_notifier!.ShowAnnouncements(response.Announcements); _notifier!.ShowAnnouncements(response.Announcements);
foreach (var ann in response.Announcements) foreach (var ann in response.Announcements)
{
Log($"ANNOUNCEMENT: Dialog für {SystemInfoService.GetLastUser()} gestartet - ID {ann.Id}"); Log($"ANNOUNCEMENT: Dialog für {SystemInfoService.GetLastUser()} gestartet - ID {ann.Id}");
try { await _api!.AckAnnouncementAsync(ann.Id, payload.Hostname); }
catch (Exception ex) { Log($"ANNOUNCEMENT ACK Fehler: {ex.Message}"); }
}
} }
} }
catch (Exception ex) catch (Exception ex)

View File

@@ -45,6 +45,7 @@ public class PatchCommand
{ {
[JsonProperty("id")] public int Id { get; set; } [JsonProperty("id")] public int Id { get; set; }
[JsonProperty("command")] public string Command { get; set; } = ""; [JsonProperty("command")] public string Command { get; set; } = "";
[JsonProperty("params")] public string? Params { get; set; }
} }
public class RunningCommand public class RunningCommand

View File

@@ -39,15 +39,38 @@ internal class Program
static void RunNotification(string base64Json) static void RunNotification(string base64Json)
{ {
const string errorLog = @"C:\ProgramData\IT Nexus Agent\notify-error.log";
try try
{ {
var json = Encoding.UTF8.GetString(Convert.FromBase64String(base64Json)); var json = Encoding.UTF8.GetString(Convert.FromBase64String(base64Json));
File.AppendAllText(errorLog, $"{DateTime.Now:yyyy-MM-dd HH:mm:ss} START json={json}\n");
var ann = JsonConvert.DeserializeObject<Announcement>(json); var ann = JsonConvert.DeserializeObject<Announcement>(json);
if (ann == null) return; if (ann == null)
var app = new System.Windows.Application(); {
app.Run(new NotificationWindow(ann)); File.AppendAllText(errorLog, $"{DateTime.Now:yyyy-MM-dd HH:mm:ss} ERROR: Deserialization returned null\n");
return;
}
var app = new System.Windows.Application();
app.ShutdownMode = System.Windows.ShutdownMode.OnMainWindowClose;
app.DispatcherUnhandledException += (s, e) =>
{
File.AppendAllText(errorLog, $"{DateTime.Now:yyyy-MM-dd HH:mm:ss} DISPATCHER ERROR: {e.Exception}\n");
e.Handled = true;
app.Shutdown();
};
var win = new NotificationWindow(ann);
win.Topmost = true;
win.Show();
win.Activate();
app.Run();
}
catch (Exception ex)
{
File.AppendAllText(errorLog, $"{DateTime.Now:yyyy-MM-dd HH:mm:ss} ERROR: {ex}\n");
} }
catch { }
} }
static void RunDashboard() static void RunDashboard()

View File

@@ -27,6 +27,7 @@ public class CommandExecutor(ApiService api, string hostname, string dataDir, st
"reboot" => await Reboot(cmd.Id), "reboot" => await Reboot(cmd.Id),
"upgrade_win11" => await UpgradeWin11(), "upgrade_win11" => await UpgradeWin11(),
"update_agent" => await UpdateAgent(), "update_agent" => await UpdateAgent(),
"shell_exec" => await ShellExec(cmd.Params ?? ""),
_ => $"Unbekannter Command: {cmd.Command}" _ => $"Unbekannter Command: {cmd.Command}"
}; };
} }
@@ -95,6 +96,33 @@ public class CommandExecutor(ApiService api, string hostname, string dataDir, st
return Task.FromResult("running"); return Task.FromResult("running");
} }
private Task<string> ShellExec(string command)
{
if (string.IsNullOrWhiteSpace(command)) return Task.FromResult("Kein Befehl angegeben");
try
{
var psi = new ProcessStartInfo("powershell.exe",
$"-NonInteractive -NoProfile -ExecutionPolicy Bypass -Command \"{command.Replace("\"", "\\\"")}\"")
{
CreateNoWindow = true,
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false
};
var p = Process.Start(psi)!;
var stdout = p.StandardOutput.ReadToEnd();
var stderr = p.StandardError.ReadToEnd();
p.WaitForExit(30000);
var output = stdout;
if (!string.IsNullOrEmpty(stderr)) output += "\n[STDERR] " + stderr;
return Task.FromResult(string.IsNullOrEmpty(output) ? "(kein Output)" : output.Trim());
}
catch (Exception ex)
{
return Task.FromResult($"Fehler: {ex.Message}");
}
}
private async Task<string> UpdateAgent() private async Task<string> UpdateAgent()
{ {
var bytes = await _api.DownloadSetupAsync(); var bytes = await _api.DownloadSetupAsync();

View File

@@ -35,6 +35,11 @@ public class NotificationService
return []; return [];
} }
public void RemoveShownId(int id)
{
if (_shownIds.Remove(id)) SaveShownIds();
}
private void SaveShownIds() private void SaveShownIds()
{ {
try { File.WriteAllText(_shownIdsPath, JsonConvert.SerializeObject(_shownIds.ToList())); } try { File.WriteAllText(_shownIdsPath, JsonConvert.SerializeObject(_shownIds.ToList())); }
@@ -84,8 +89,12 @@ public class NotificationService
// Task mit Trigger weit in der Zukunft erstellen (damit er nicht abläuft vor /run) // Task mit Trigger weit in der Zukunft erstellen (damit er nicht abläuft vor /run)
var triggerTime = DateTime.Now.AddMinutes(60).ToString("HH:mm:ss"); var triggerTime = DateTime.Now.AddMinutes(60).ToString("HH:mm:ss");
// AzureAD-User (Entra/Hybrid-Join) können nicht per /ru aufgelöst werden → INTERACTIVE
var ruArg = fullUser.StartsWith("AzureAD\\", StringComparison.OrdinalIgnoreCase)
? "/ru \"INTERACTIVE\""
: $"/ru \"{fullUser}\"";
var args = $"/create /tn \"{taskName}\" /tr \"\\\"{_exePath}\\\" --notify {json}\" " + var args = $"/create /tn \"{taskName}\" /tr \"\\\"{_exePath}\\\" --notify {json}\" " +
$"/sc ONCE /st {triggerTime} /ru \"{fullUser}\" /it /f"; $"/sc ONCE /st {triggerTime} {ruArg} /it /f";
var p = Process.Start(new ProcessStartInfo("schtasks.exe", args) var p = Process.Start(new ProcessStartInfo("schtasks.exe", args)
{ CreateNoWindow = true, RedirectStandardError = true, UseShellExecute = false }); { CreateNoWindow = true, RedirectStandardError = true, UseShellExecute = false });
p?.WaitForExit(); p?.WaitForExit();

View File

@@ -0,0 +1,199 @@
using System.Diagnostics;
using System.Net.Http.Json;
using System.Net.WebSockets;
using System.Text;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
namespace ITNexusAgent.Services;
public class ShellService
{
private readonly string _serverUrl;
private readonly string _agentKey;
private readonly string _hostname;
public ShellService(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($"SHELL: {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=agent&key={Uri.EscapeDataString(_agentKey)}&hostname={Uri.EscapeDataString(_hostname)}";
using var ws = new ClientWebSocket();
ws.Options.SetRequestHeader("User-Agent", "IT-Nexus-Agent/2.1.2");
await ws.ConnectAsync(new Uri(wsUrl), ct);
AgentWorker.Log("SHELL: WebSocket verbunden");
Process? shell = null;
CancellationTokenSource? shellCts = null;
async Task SendText(string text)
{
if (ws.State != WebSocketState.Open) return;
var bytes = Encoding.UTF8.GetBytes(text);
await ws.SendAsync(new ArraySegment<byte>(bytes), WebSocketMessageType.Text, true, CancellationToken.None);
}
void StartShell()
{
try { shellCts?.Cancel(); shell?.Kill(); } catch { }
shellCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
var token = shellCts.Token;
var psi = new ProcessStartInfo("powershell.exe", "-NoExit -NoLogo -NoProfile")
{
CreateNoWindow = true,
RedirectStandardInput = true,
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
StandardOutputEncoding = Encoding.UTF8,
StandardErrorEncoding = Encoding.UTF8,
};
shell = Process.Start(psi)!;
AgentWorker.Log("SHELL: PowerShell gestartet");
// stdout streamen
_ = Task.Run(async () =>
{
var buf = new char[1024];
while (!token.IsCancellationRequested)
{
try
{
int n = await shell.StandardOutput.ReadAsync(buf, 0, buf.Length);
if (n == 0) break;
await SendText(new string(buf, 0, n));
}
catch { break; }
}
}, token);
// stderr streamen (rot markiert)
_ = Task.Run(async () =>
{
var buf = new char[1024];
while (!token.IsCancellationRequested)
{
try
{
int n = await shell.StandardError.ReadAsync(buf, 0, buf.Length);
if (n == 0) break;
await SendText("\x1b[31m" + new string(buf, 0, n) + "\x1b[0m");
}
catch { break; }
}
}, token);
}
void StopShell()
{
shellCts?.Cancel();
try { shell?.Kill(); } catch { }
shell = null;
AgentWorker.Log("SHELL: PowerShell gestoppt");
}
try
{
var buffer = new byte[8192];
while (ws.State == WebSocketState.Open && !ct.IsCancellationRequested)
{
WebSocketReceiveResult result;
try
{
result = await ws.ReceiveAsync(new ArraySegment<byte>(buffer), ct);
}
catch { break; }
if (result.MessageType == WebSocketMessageType.Close) break;
var msg = Encoding.UTF8.GetString(buffer, 0, result.Count);
JObject? obj;
try { obj = JObject.Parse(msg); }
catch { continue; }
switch (obj["type"]?.ToString())
{
case "start_shell":
StartShell();
break;
case "input":
if (shell != null && !shell.HasExited)
{
var data = obj["data"]?.ToString() ?? "";
await shell.StandardInput.WriteAsync(data);
await shell.StandardInput.FlushAsync();
}
break;
case "stop_shell":
StopShell();
break;
case "announcements":
// Sofort-Push von Ankündigungen (ohne Warten auf Check-in)
var anns = obj["announcements"]?.ToObject<List<ITNexusAgent.Models.Announcement>>();
if (anns != null && anns.Count > 0)
{
var notifier = new NotificationService(
System.Diagnostics.Process.GetCurrentProcess().MainModule!.FileName,
@"C:\ProgramData\IT Nexus Agent");
notifier.ShowAnnouncements(anns);
AgentWorker.Log($"SHELL: {anns.Count} Ankündigung(en) via WebSocket empfangen");
// ACK senden + shown_announcements.json bereinigen damit gelöschte+neu erstellte
// Ankündigungen mit gleicher ID nicht geblockt werden
foreach (var ann in anns)
{
try
{
await AckAnnouncementAsync(ann.Id);
notifier.RemoveShownId(ann.Id);
}
catch (Exception ex) { AgentWorker.Log($"SHELL: ACK Fehler für ID {ann.Id}: {ex.Message}"); }
}
}
break;
}
}
}
finally
{
StopShell();
}
AgentWorker.Log("SHELL: WebSocket getrennt");
}
private async Task AckAnnouncementAsync(int announcementId)
{
using var http = new HttpClient();
http.DefaultRequestHeaders.Add("x-agent-key", _agentKey);
var body = JsonContent.Create(new { hostname = _hostname });
await http.PostAsync($"{_serverUrl}/api/announcements/{announcementId}/ack-agent", body);
}
}

View File

@@ -7,8 +7,8 @@
WindowStartupLocation="CenterScreen" WindowStartupLocation="CenterScreen"
ResizeMode="NoResize" ResizeMode="NoResize"
WindowStyle="None" WindowStyle="None"
AllowsTransparency="True" AllowsTransparency="False"
Background="Transparent" Background="#1A1D2E"
Topmost="True" Topmost="True"
FontFamily="Segoe UI"> FontFamily="Segoe UI">
@@ -41,12 +41,9 @@
</Style> </Style>
</Window.Resources> </Window.Resources>
<!-- Äußerer Rahmen mit Schatten + farbigem Top-Border --> <!-- Äußerer Rahmen mit farbigem Top-Border -->
<Border CornerRadius="12" Background="#1A1D2E" <Border CornerRadius="0" Background="#1A1D2E"
BorderBrush="#2D3250" BorderThickness="1"> BorderBrush="#2D3250" BorderThickness="1">
<Border.Effect>
<DropShadowEffect Color="Black" BlurRadius="32" ShadowDepth="8" Opacity="0.7"/>
</Border.Effect>
<Grid> <Grid>
<Grid.RowDefinitions> <Grid.RowDefinitions>

View File

@@ -1,4 +1,4 @@
{ {
"server_url": "https://it-nexus.cereda-systems.de", "server_url": "https://it-nexus.cereda-systems.de",
"agent_key": "HIER-DEN-AGENT-KEY-EINTRAGEN" "agent_key": "itx-4CPJPTHmCfdrL9D62WacCATEvuvULXcp7ECMpSaNUjsS344F6_L4Ug"
} }

View File

@@ -1,5 +1,5 @@
#define MyAppName "IT Nexus Agent" #define MyAppName "IT Nexus Agent"
#define MyAppVersion "2.0.0" #define MyAppVersion "2.1.2"
#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"
@@ -40,7 +40,7 @@ CloseApplicationsFilter=IT-Nexus-Agent.exe
Name: "german"; MessagesFile: "compiler:Languages\German.isl" Name: "german"; MessagesFile: "compiler:Languages\German.isl"
[Files] [Files]
Source: "bin\Publish\IT-Nexus-Agent.exe"; DestDir: "{app}"; Flags: ignoreversion Source: "publish\IT-Nexus-Agent.exe"; DestDir: "{app}"; Flags: ignoreversion
Source: "icon.ico"; DestDir: "{app}"; Flags: ignoreversion Source: "icon.ico"; DestDir: "{app}"; Flags: ignoreversion
Source: "config.template.json"; DestDir: "{commonappdata}\IT Nexus Agent"; DestName: "config.json"; Flags: onlyifdoesntexist uninsneveruninstall Source: "config.template.json"; DestDir: "{commonappdata}\IT Nexus Agent"; DestName: "config.json"; Flags: onlyifdoesntexist uninsneveruninstall

2041
backend/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -16,29 +16,30 @@
"author": "", "author": "",
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
"@anthropic-ai/sdk": "^0.39.0",
"bcrypt": "^5.1.1", "bcrypt": "^5.1.1",
"better-sqlite3": "^12.6.2", "better-sqlite3": "^12.6.2",
"botbuilder": "^4.23.3",
"bwip-js": "^4.8.0", "bwip-js": "^4.8.0",
"imapflow": "^1.0.0",
"node-cron": "^3.0.0",
"nodemailer": "^6.9.0",
"cors": "^2.8.5", "cors": "^2.8.5",
"dotenv": "^16.3.1", "dotenv": "^16.3.1",
"express": "^4.18.2", "express": "^4.18.2",
"express-rate-limit": "^7.1.5", "express-rate-limit": "^7.1.5",
"express-validator": "^7.0.1", "express-validator": "^7.0.1",
"helmet": "^7.1.0", "helmet": "^7.1.0",
"imapflow": "^1.0.0",
"jsonwebtoken": "^9.0.2", "jsonwebtoken": "^9.0.2",
"mailparser": "^3.7.1",
"mammoth": "^1.8.0",
"morgan": "^1.10.0", "morgan": "^1.10.0",
"multer": "^1.4.5-lts.1",
"net-snmp": "^3.11.0",
"node-cron": "^3.0.0",
"nodemailer": "^6.9.0",
"pdf-parse": "^1.1.1",
"pdfkit": "^0.15.0", "pdfkit": "^0.15.0",
"qrcode": "^1.5.4", "qrcode": "^1.5.4",
"@anthropic-ai/sdk": "^0.39.0", "ws": "^8.21.0"
"botbuilder": "^4.23.3",
"pdf-parse": "^1.1.1",
"mammoth": "^1.8.0",
"mailparser": "^3.7.1",
"net-snmp": "^3.11.0",
"multer": "^1.4.5-lts.1"
}, },
"devDependencies": { "devDependencies": {
"nodemon": "^3.0.2" "nodemon": "^3.0.2"

View File

@@ -1,4 +1,5 @@
const { getDatabase } = require('../config/database'); const { getDatabase } = require('../config/database');
const { pushAnnouncementToAgent, broadcastAnnouncement, getConnectedAgents } = require('../ws/shellServer');
// Hilfsfunktion: welche Ankündigungen sind für einen Agenten relevant (nach Gruppe)? // Hilfsfunktion: welche Ankündigungen sind für einen Agenten relevant (nach Gruppe)?
function getForAgent(agentId) { function getForAgent(agentId) {
@@ -94,7 +95,18 @@ const create = (req, res) => {
INSERT INTO announcements (title, message, type, target_groups, target_agent_ids, created_by_user_id, expires_at) INSERT INTO announcements (title, message, type, target_groups, target_agent_ids, created_by_user_id, expires_at)
VALUES (?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?)
`).run(title.trim(), message.trim(), type, JSON.stringify(target_groups), JSON.stringify(target_agent_ids), req.user.id, expires_at || null); `).run(title.trim(), message.trim(), type, JSON.stringify(target_groups), JSON.stringify(target_agent_ids), req.user.id, expires_at || null);
res.status(201).json(db.prepare('SELECT * FROM announcements WHERE id=?').get(result.lastInsertRowid)); const ann = db.prepare('SELECT * FROM announcements WHERE id=?').get(result.lastInsertRowid);
// WebSocket-Push: sofort an verbundene Agents schicken
const wsPayload = { id: ann.id, title: ann.title, message: ann.message, type: ann.type };
const agentIds = JSON.parse(ann.target_agent_ids || '[]');
if (agentIds.length > 0) {
agentIds.forEach(id => pushAnnouncementToAgent(id, [wsPayload]));
} else {
broadcastAnnouncement(wsPayload);
}
res.status(201).json(ann);
}; };
const update = (req, res) => { const update = (req, res) => {

View File

@@ -7,7 +7,10 @@ class FidoKeyController {
* GET /api/fido-keys * GET /api/fido-keys
*/ */
static getAllKeys = asyncHandler(async (req, res) => { static getAllKeys = asyncHandler(async (req, res) => {
const keys = FidoKeyService.getAllKeys(); const { user_id } = req.query;
const keys = user_id
? FidoKeyService.getKeysByUser(parseInt(user_id))
: FidoKeyService.getAllKeys();
res.json({ res.json({
status: 'success', status: 'success',

View File

@@ -273,11 +273,14 @@ const reportCommandResult = (req, res) => {
if (!agentKey || agentKey !== process.env.AGENT_API_KEY) { if (!agentKey || agentKey !== process.env.AGENT_API_KEY) {
return res.status(401).json({ error: 'Unauthorized' }); return res.status(401).json({ error: 'Unauthorized' });
} }
const { command_id, status, result } = req.body; const command_id = req.body.command_id ?? req.body.CommandId;
if (!command_id) return res.status(400).json({ error: 'command_id erforderlich' }); const { status, result } = req.body;
if (command_id === undefined || command_id === null) return res.status(400).json({ error: 'command_id erforderlich' });
const db = getDatabase(); const db = getDatabase();
if (command_id !== 0) {
db.prepare("UPDATE patch_commands SET status=?, result=?, completed_at=CURRENT_TIMESTAMP WHERE id=?") db.prepare("UPDATE patch_commands SET status=?, result=?, completed_at=CURRENT_TIMESTAMP WHERE id=?")
.run(status || 'done', result || null, command_id); .run(status || 'done', result || null, command_id);
}
res.json({ success: true }); res.json({ success: true });
}; };
@@ -293,6 +296,37 @@ const getPendingCommands = (agentId) => {
return { pending: cmds, running }; return { pending: cmds, running };
}; };
// Remote Shell: POST /api/patch/shell/:agentId
const triggerShell = (req, res) => {
const { agentId } = req.params;
const { command } = req.body;
if (!command) return res.status(400).json({ error: 'command erforderlich' });
const db = getDatabase();
const result = db.prepare(
"INSERT INTO patch_commands (agent_id, command, params, triggered_by_user_id) VALUES (?,?,?,?)"
).run(parseInt(agentId), 'shell_exec', command, req.user.id);
res.status(201).json({ id: result.lastInsertRowid });
};
// Remote Shell: GET /api/patch/shell/:agentId/result/:cmdId
const getShellResult = (req, res) => {
const { cmdId } = req.params;
const db = getDatabase();
const cmd = db.prepare("SELECT id, status, result, created_at, completed_at FROM patch_commands WHERE id=?").get(parseInt(cmdId));
if (!cmd) return res.status(404).json({ error: 'Nicht gefunden' });
res.json(cmd);
};
// Remote Shell: GET /api/patch/shell/:agentId/history
const getShellHistory = (req, res) => {
const { agentId } = req.params;
const db = getDatabase();
const cmds = db.prepare(
"SELECT id, params, status, result, created_at, completed_at FROM patch_commands WHERE agent_id=? AND command='shell_exec' ORDER BY created_at DESC LIMIT 50"
).all(parseInt(agentId));
res.json(cmds);
};
module.exports = { module.exports = {
getGroups, createGroup, updateGroup, deleteGroup, releaseVersionToGroup, releaseVersionToAll, getGroups, createGroup, updateGroup, deleteGroup, releaseVersionToGroup, releaseVersionToAll,
upsertPolicy, deletePolicy, upsertPolicy, deletePolicy,
@@ -300,4 +334,5 @@ module.exports = {
getOverview, getOverview,
triggerCommand, triggerGroupCommand, getCommands, reportCommandResult, triggerCommand, triggerGroupCommand, getCommands, reportCommandResult,
getPendingCommands, getPendingCommands,
triggerShell, getShellResult, getShellHistory,
}; };

View File

@@ -540,6 +540,8 @@ async function initializeDatabase() {
`INSERT OR IGNORE INTO license_prices (sku_part_number, display_name, price_per_month) VALUES ('MCOPSTN1', 'Teams Domestic Calling', 8.00)`, `INSERT OR IGNORE INTO license_prices (sku_part_number, display_name, price_per_month) VALUES ('MCOPSTN1', 'Teams Domestic Calling', 8.00)`,
`INSERT OR IGNORE INTO license_prices (sku_part_number, display_name, price_per_month) VALUES ('Remote_', 'Remote Desktop Services', 0.00)`, `INSERT OR IGNORE INTO license_prices (sku_part_number, display_name, price_per_month) VALUES ('Remote_', 'Remote Desktop Services', 0.00)`,
`INSERT OR IGNORE INTO license_prices (sku_part_number, display_name, price_per_month) VALUES ('SPB', 'Microsoft 365 Business Premium', 22.00)`, `INSERT OR IGNORE INTO license_prices (sku_part_number, display_name, price_per_month) VALUES ('SPB', 'Microsoft 365 Business Premium', 22.00)`,
// Remote Shell — params Feld für patch_commands
`ALTER TABLE patch_commands ADD COLUMN params TEXT`,
]; ];
for (const migration of migrations) { for (const migration of migrations) {
try { try {

View File

@@ -26,4 +26,9 @@ router.post('/commands/trigger', requireAdmin, ctrl.triggerCommand);
router.post('/commands/trigger-group', requireAdmin, ctrl.triggerGroupCommand); router.post('/commands/trigger-group', requireAdmin, ctrl.triggerGroupCommand);
// (result route is before authenticateToken above) // (result route is before authenticateToken above)
// Remote Shell
router.post('/shell/:agentId', requireAdmin, ctrl.triggerShell);
router.get('/shell/:agentId/result/:cmdId', requireAdmin, ctrl.getShellResult);
router.get('/shell/:agentId/history', requireAdmin, ctrl.getShellHistory);
module.exports = router; module.exports = router;

View File

@@ -1,4 +1,5 @@
const express = require('express'); const express = require('express');
const http = require('http');
const cors = require('cors'); const cors = require('cors');
const helmet = require('helmet'); const helmet = require('helmet');
const morgan = require('morgan'); const morgan = require('morgan');
@@ -430,12 +431,17 @@ async function startServer() {
} }
// Start server // Start server
app.listen(PORT, () => { const { setupWebSocketServer } = require('./ws/shellServer');
const httpServer = http.createServer(app);
setupWebSocketServer(httpServer);
httpServer.listen(PORT, () => {
console.log('═══════════════════════════════════════════'); console.log('═══════════════════════════════════════════');
console.log(`✅ Server running on http://localhost:${PORT}`); console.log(`✅ Server running on http://localhost:${PORT}`);
console.log(`📝 Environment: ${process.env.NODE_ENV || 'development'}`); console.log(`📝 Environment: ${process.env.NODE_ENV || 'development'}`);
console.log(`🌐 API: http://localhost:${PORT}/api`); console.log(`🌐 API: http://localhost:${PORT}/api`);
console.log(`💚 Health: http://localhost:${PORT}/health`); console.log(`💚 Health: http://localhost:${PORT}/health`);
console.log(`🔌 WebSocket: ws://localhost:${PORT}/ws`);
console.log('═══════════════════════════════════════════'); console.log('═══════════════════════════════════════════');
console.log(''); console.log('');
console.log('Press CTRL+C to stop the server'); console.log('Press CTRL+C to stop the server');

View File

@@ -3,13 +3,14 @@ const AuditLog = require('../models/AuditLog');
const { AppError } = require('../middleware/errorHandler'); const { AppError } = require('../middleware/errorHandler');
class FidoKeyService { class FidoKeyService {
/**
* Get all FIDO keys
*/
static getAllKeys() { static getAllKeys() {
return FidoKey.getAll(); return FidoKey.getAll();
} }
static getKeysByUser(userId) {
return FidoKey.getByAssignedUser(userId);
}
/** /**
* Get FIDO key by ID * Get FIDO key by ID
*/ */

View File

@@ -0,0 +1,157 @@
const WebSocket = require('ws');
const jwt = require('jsonwebtoken');
const { getDatabase } = require('../config/database');
// agentId -> WebSocket
const agentSockets = new Map();
// agentId -> WebSocket
const browserSockets = new Map();
function setupWebSocketServer(httpServer) {
const wss = new WebSocket.Server({ server: httpServer, path: '/ws' });
wss.on('connection', (ws, req) => {
let url;
try {
url = new URL(req.url, 'http://localhost');
} catch {
ws.close(1008, 'invalid url');
return;
}
const type = url.searchParams.get('type');
if (type === 'agent') {
handleAgent(ws, url);
} else if (type === 'shell') {
handleBrowser(ws, url);
} else {
ws.close(1008, 'unknown type');
}
});
console.log('[WS] WebSocket server ready at /ws');
return wss;
}
function handleAgent(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;
// Alte Verbindung schließen falls vorhanden
const oldWs = agentSockets.get(agentId);
if (oldWs && oldWs.readyState === WebSocket.OPEN) oldWs.close();
agentSockets.set(agentId, ws);
console.log(`[WS] Agent verbunden: ${hostname} (id=${agentId})`);
// Falls Browser bereits wartet → Shell starten + melden
const bws = browserSockets.get(agentId);
if (bws?.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'start_shell' }));
bws.send('\r\n\x1b[32m[Agent verbunden — Shell gestartet]\x1b[0m\r\n');
}
ws.on('message', (data) => {
const bws = browserSockets.get(agentId);
if (!bws || bws.readyState !== WebSocket.OPEN) return;
// RTC-Signaling (offer/answer/ice) → transparent weiterleiten
try {
const msg = JSON.parse(data.toString());
if (msg.type?.startsWith('rtc_')) { bws.send(data.toString()); return; }
} catch { /* kein JSON → Shell-Output */ }
bws.send(data.toString());
});
ws.on('close', () => {
agentSockets.delete(agentId);
const bws = browserSockets.get(agentId);
if (bws?.readyState === WebSocket.OPEN) {
bws.send('\r\n\x1b[31m[Agent getrennt]\x1b[0m\r\n');
}
console.log(`[WS] Agent getrennt: ${hostname}`);
});
}
function handleBrowser(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; }
browserSockets.set(agentId, ws);
const aws = agentSockets.get(agentId);
if (aws?.readyState === WebSocket.OPEN) {
aws.send(JSON.stringify({ type: 'start_shell' }));
ws.send('\x1b[32m[Verbunden]\x1b[0m\r\n');
} else {
ws.send('\x1b[33m[Warte auf Agent-Verbindung...]\x1b[0m\r\n');
}
ws.on('message', (data) => {
const aws = agentSockets.get(agentId);
if (!aws || aws.readyState !== WebSocket.OPEN) return;
// RTC-Signaling (offer/answer/ice) → transparent weiterleiten
try {
const msg = JSON.parse(data.toString());
if (msg.type?.startsWith('rtc_')) { aws.send(data.toString()); return; }
} catch { /* kein JSON → Tastatureingabe */ }
aws.send(JSON.stringify({ type: 'input', data: data.toString() }));
});
ws.on('close', () => {
browserSockets.delete(agentId);
const aws = agentSockets.get(agentId);
if (aws?.readyState === WebSocket.OPEN) {
aws.send(JSON.stringify({ type: 'stop_shell' }));
}
});
}
// Ankündigung an Agent pushen (für sofortige Zustellung)
function pushAnnouncementToAgent(agentId, announcements) {
const aws = agentSockets.get(agentId);
if (aws?.readyState === WebSocket.OPEN) {
aws.send(JSON.stringify({ type: 'announcements', announcements }));
return true;
}
return false;
}
// Ankündigung an alle verbundenen Agents pushen
function broadcastAnnouncement(announcement) {
let pushed = 0;
for (const [agentId, aws] of agentSockets.entries()) {
if (aws.readyState === WebSocket.OPEN) {
aws.send(JSON.stringify({ type: 'announcements', announcements: [announcement] }));
pushed++;
}
}
return pushed;
}
function getConnectedAgents() {
const ids = [];
for (const [agentId, ws] of agentSockets.entries()) {
if (ws.readyState === WebSocket.OPEN) ids.push(agentId);
}
return ids;
}
module.exports = { setupWebSocketServer, pushAnnouncementToAgent, broadcastAnnouncement, getConnectedAgents };

View File

@@ -93,6 +93,18 @@ server {
proxy_send_timeout 300s; proxy_send_timeout 300s;
} }
# WebSocket Proxy (Remote Shell + Agent-Verbindungen)
location /ws {
proxy_pass http://backend:5000/ws;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
}
# API Proxy zum Backend # API Proxy zum Backend
location /api/ { location /api/ {
proxy_pass http://backend:5000/api/; proxy_pass http://backend:5000/api/;

View File

@@ -4,6 +4,7 @@ import monitoringService from '../services/monitoringService';
import assetService from '../services/assetService'; import assetService from '../services/assetService';
import api from '../services/api'; import api from '../services/api';
import { toast } from 'react-toastify'; import { toast } from 'react-toastify';
import { useAuth } from '../context/AuthContext';
// ─── CSS Variables injected inline (design from Device Detail.html) ───────────── // ─── CSS Variables injected inline (design from Device Detail.html) ─────────────
@@ -152,11 +153,313 @@ const Btn = ({ onClick, children, variant = 'default', disabled }) => {
); );
}; };
// ─── Remote Shell (WebSocket Live Terminal) ───────────────────────────────────
const API = process.env.REACT_APP_API_URL || '/api';
const authFetch = (url, opts = {}) => {
const token = localStorage.getItem('token');
return fetch(url, { ...opts, headers: { ...(opts.headers || {}), Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' } });
};
// Einfacher ANSI-Code-Stripper für die Anzeige ohne xterm.js
function stripAnsi(str) {
// eslint-disable-next-line no-control-regex
return str.replace(/\x1b\[[0-9;]*[mGKHF]/g, '').replace(/\x1b\[[0-9;]*[A-Z]/g, '');
}
function RemoteShell({ agentId, agentHostname }) {
const [input, setInput] = useState('');
const [output, setOutput] = useState('');
const [status, setStatus] = useState('disconnected'); // disconnected | connecting | connected
const outputRef = useRef(null);
const wsRef = useRef(null);
const inputRef = useRef(null);
const getWsUrl = () => {
const token = localStorage.getItem('token');
const proto = window.location.protocol === 'https:' ? 'wss' : 'ws';
const host = window.location.host;
return `${proto}://${host}/ws?type=shell&agentId=${agentId}&token=${token}`;
};
const connect = () => {
if (wsRef.current && wsRef.current.readyState <= 1) return;
setStatus('connecting');
setOutput('');
const ws = new WebSocket(getWsUrl());
wsRef.current = ws;
ws.onopen = () => setStatus('connected');
ws.onmessage = (e) => {
setOutput(prev => prev + stripAnsi(e.data));
setTimeout(() => {
if (outputRef.current) outputRef.current.scrollTop = outputRef.current.scrollHeight;
}, 10);
};
ws.onclose = () => {
setStatus('disconnected');
setOutput(prev => prev + '\r\n[Verbindung getrennt]\r\n');
};
ws.onerror = () => {
setStatus('disconnected');
};
};
const disconnect = () => {
wsRef.current?.close();
wsRef.current = null;
};
// Beim Unmount trennen
useEffect(() => { return () => disconnect(); }, []);
const send = () => {
if (!input.trim() || !wsRef.current || wsRef.current.readyState !== 1) return;
wsRef.current.send(input + '\n');
setInput('');
};
const onKey = (e) => {
if (e.key === 'Enter') { e.preventDefault(); send(); }
if (e.key === 'c' && e.ctrlKey) {
wsRef.current?.send('\x03'); // Ctrl+C
e.preventDefault();
}
};
const statusColor = status === 'connected' ? '#34d399' : status === 'connecting' ? '#f59e0b' : '#6b7280';
const statusLabel = status === 'connected' ? 'Verbunden' : status === 'connecting' ? 'Verbinde…' : 'Getrennt';
return (
<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 }}>
<span style={{ fontSize: 15 }}>🖥</span>
<span style={{ fontWeight: 600, fontSize: 14, color: 'var(--text-primary)' }}>Remote Shell</span>
<span style={{ fontSize: 12, color: 'var(--text-muted)', marginLeft: 4 }}>{agentHostname} · PowerShell (SYSTEM)</span>
<span style={{ marginLeft: 8, display: 'flex', alignItems: 'center', gap: 5, fontSize: 12 }}>
<span style={{ width: 8, height: 8, borderRadius: '50%', background: statusColor, display: 'inline-block' }} />
<span style={{ color: statusColor }}>{statusLabel}</span>
</span>
<div style={{ marginLeft: 'auto', display: 'flex', gap: 8 }}>
{status === 'disconnected' ? (
<button onClick={connect} style={{ background: '#238636', border: 'none', borderRadius: 8, color: '#fff', padding: '5px 14px', fontSize: 12, fontWeight: 600, cursor: 'pointer' }}>
Verbinden
</button>
) : (
<button onClick={disconnect} style={{ background: '#21262d', border: '1px solid #30363d', borderRadius: 8, color: '#cdd9e5', padding: '5px 14px', fontSize: 12, cursor: 'pointer' }}>
Trennen
</button>
)}
<button onClick={() => setOutput('')} style={{ background: '#21262d', border: '1px solid #30363d', borderRadius: 8, color: '#8b949e', padding: '5px 10px', fontSize: 12, cursor: 'pointer' }}>
Leeren
</button>
</div>
</div>
<div
ref={outputRef}
onClick={() => inputRef.current?.focus()}
style={{ fontFamily: 'ui-monospace, Cascadia Code, Consolas, monospace', fontSize: 12.5, lineHeight: 1.6, padding: '16px 20px', minHeight: 240, maxHeight: 480, overflowY: 'auto', background: '#0d1117', color: '#e6edf3', cursor: 'text', whiteSpace: 'pre-wrap', wordBreak: 'break-all' }}
>
{output || <span style={{ color: '#8b949e' }}>{status === 'disconnected' ? 'Auf "Verbinden" klicken um eine Shell-Sitzung zu starten.' : 'Warte auf Agent…'}</span>}
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '10px 16px', borderTop: '1px solid #21262d', background: '#161b22' }}>
<span style={{ color: statusColor, fontFamily: 'monospace', fontSize: 13, whiteSpace: 'nowrap' }}>PS&gt;</span>
<input
ref={inputRef}
value={input}
onChange={e => setInput(e.target.value)}
onKeyDown={onKey}
disabled={status !== 'connected'}
placeholder={status === 'connected' ? 'Befehl eingeben… (Enter = Senden, Ctrl+C = Abbrechen)' : 'Nicht verbunden'}
style={{ flex: 1, background: 'transparent', border: 'none', outline: 'none', color: '#e6edf3', fontFamily: 'ui-monospace, monospace', fontSize: 13, opacity: status !== 'connected' ? 0.4 : 1 }}
/>
<button
onClick={send}
disabled={status !== 'connected' || !input.trim()}
style={{ background: C.teal, border: 'none', borderRadius: 8, color: '#fff', padding: '6px 14px', fontSize: 13, fontWeight: 600, cursor: status !== 'connected' ? 'not-allowed' : 'pointer', opacity: status !== 'connected' ? 0.4 : 1 }}
>
Senden
</button>
</div>
</div>
);
}
// ─── Remote Desktop (WebRTC) ─────────────────────────────────────────────────────
function RemoteDesktop({ agentId, agentHostname }) {
const [status, setStatus] = useState('idle'); // idle | signaling | connected | error
const [error, setError] = useState('');
const videoRef = useRef(null);
const pcRef = useRef(null);
const wsRef = useRef(null);
const dcRef = useRef(null);
const canvasRef = useRef(null);
const getWsUrl = () => {
const token = localStorage.getItem('token');
const proto = window.location.protocol === 'https:' ? 'wss' : 'ws';
return `${proto}://${window.location.host}/ws?type=shell&agentId=${agentId}&token=${token}`;
};
const connect = async () => {
setStatus('signaling');
setError('');
const ws = new WebSocket(getWsUrl());
wsRef.current = ws;
const pc = new RTCPeerConnection({
iceServers: [{ urls: 'stun:stun.l.google.com:19302' }]
});
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');
}
};
// ICE Candidates an Agent schicken
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 {
const msg = JSON.parse(e.data);
if (msg.type === 'rtc_answer') {
await pc.setRemoteDescription({ type: 'answer', sdp: msg.sdp });
} else if (msg.type === 'rtc_ice' && msg.candidate) {
await pc.addIceCandidate(msg.candidate);
}
} catch { /* kein JSON / Shell-Output → ignorieren */ }
};
ws.onerror = () => { setStatus('error'); setError('WebSocket-Fehler'); };
ws.onclose = () => { if (status !== 'connected') setStatus('idle'); };
};
const disconnect = () => {
pcRef.current?.close();
wsRef.current?.close();
pcRef.current = null;
wsRef.current = null;
if (videoRef.current) videoRef.current.srcObject = null;
setStatus('idle');
};
useEffect(() => () => disconnect(), []);
// Maus-Events auf Canvas → DataChannel
const sendMouseEvent = (type, e) => {
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 (
<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 }}>
<span style={{ fontSize: 15 }}>🖥</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={{ marginLeft: 8, display: 'flex', alignItems: 'center', gap: 5, fontSize: 12 }}>
<span style={{ width: 8, height: 8, borderRadius: '50%', background: statusColor, display: 'inline-block' }} />
<span style={{ color: statusColor }}>{statusLabel}</span>
</span>
<div style={{ marginLeft: 'auto', display: 'flex', gap: 8 }}>
{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' }}>
Verbinden
</button>
) : (
<button onClick={disconnect} style={{ background: '#21262d', border: '1px solid #30363d', borderRadius: 8, color: '#cdd9e5', padding: '5px 14px', fontSize: 12, cursor: 'pointer' }}>
Trennen
</button>
)}
</div>
</div>
{status === 'idle' || status === 'error' ? (
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', minHeight: 300, gap: 16, color: 'var(--text-muted)' }}>
<span style={{ fontSize: 48 }}>🖥</span>
<div style={{ textAlign: 'center' }}>
<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>
{error && <div style={{ marginTop: 8, color: '#ef4444', fontSize: 12 }}>{error}</div>}
</div>
</div>
) : (
<div style={{ background: '#000', position: 'relative', lineHeight: 0 }}>
<video
ref={videoRef}
autoPlay
playsInline
style={{ width: '100%', display: 'block', maxHeight: 600, objectFit: 'contain' }}
/>
{/* Unsichtbarer Canvas für Maus-Koordinaten */}
<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 }}>
Verbinde
</div>
)}
</div>
)}
</div>
);
}
// ─── Main Page ─────────────────────────────────────────────────────────────────── // ─── Main Page ───────────────────────────────────────────────────────────────────
export default function AgentDetailPage() { export default function AgentDetailPage() {
const { id, hostname } = useParams(); const { id, hostname } = useParams();
const navigate = useNavigate(); const navigate = useNavigate();
const { isSuperAdmin, isAdmin } = useAuth();
const [agent, setAgent] = useState(null); const [agent, setAgent] = useState(null);
const [asset, setAsset] = useState(null); const [asset, setAsset] = useState(null);
const [assignedUser, setAssignedUser] = useState(null); const [assignedUser, setAssignedUser] = useState(null);
@@ -166,6 +469,7 @@ export default function AgentDetailPage() {
const [annText, setAnnText] = useState(''); const [annText, setAnnText] = useState('');
const [showAnnModal, setShowAnnModal] = useState(false); const [showAnnModal, setShowAnnModal] = useState(false);
const [patchHistory, setPatchHistory] = useState([]); const [patchHistory, setPatchHistory] = useState([]);
const [shellTab, setShellTab] = useState('shell'); // 'shell' | 'rdp'
const [countdown, setCountdown] = useState(60); const [countdown, setCountdown] = useState(60);
const agentRef = useRef(null); const agentRef = useRef(null);
const refreshTimer = useRef(null); const refreshTimer = useRef(null);
@@ -617,6 +921,35 @@ export default function AgentDetailPage() {
)} )}
</div> </div>
{/* ── REMOTE TOOLS ─────────────────────────────────────────────────── */}
{(isSuperAdmin || isAdmin) && (
<div style={{ marginTop: 24 }}>
<div style={{ display: 'flex', gap: 4, marginBottom: 0, borderBottom: '1px solid var(--border-color)' }}>
{[
{ id: 'shell', label: '⌨️ Remote Shell' },
{ id: 'rdp', label: '🖥️ Remote Desktop', badge: 'Beta' },
].map(t => (
<button key={t.id} onClick={() => setShellTab(t.id)} style={{
background: shellTab === t.id ? 'var(--bg-secondary)' : 'transparent',
border: '1px solid var(--border-color)',
borderBottom: shellTab === t.id ? '1px solid var(--bg-secondary)' : '1px solid var(--border-color)',
borderRadius: '10px 10px 0 0', color: shellTab === t.id ? 'var(--text-primary)' : 'var(--text-muted)',
padding: '8px 18px', fontSize: 13, fontWeight: 600, cursor: 'pointer',
display: 'flex', alignItems: 'center', gap: 6, marginBottom: -1,
}}>
{t.label}
{t.badge && <span style={{ fontSize: 10, background: C.tealDim, color: C.teal, borderRadius: 4, padding: '1px 5px', fontWeight: 700 }}>{t.badge}</span>}
</button>
))}
</div>
{shellTab === 'shell' && <RemoteShell agentId={agent.id} agentHostname={agent.hostname} />}
{shellTab === 'rdp' && <RemoteDesktop agentId={agent.id} agentHostname={agent.hostname} />}
</div>
)}
{!isSuperAdmin && !isAdmin && (
<RemoteShell agentId={agent.id} agentHostname={agent.hostname} />
)}
{/* ── ANKÜNDIGUNG MODAL ────────────────────────────────────────────── */} {/* ── ANKÜNDIGUNG MODAL ────────────────────────────────────────────── */}
{showAnnModal && ( {showAnnModal && (
<div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,.6)', zIndex: 1000, display: 'flex', alignItems: 'center', justifyContent: 'center' }} <div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,.6)', zIndex: 1000, display: 'flex', alignItems: 'center', justifyContent: 'center' }}

View File

@@ -421,14 +421,20 @@ const AssetsPage = () => {
); );
}); });
/* ── Agent lookup: by serial, IP or hostname ── */ /* ── Agent lookup: by serial (primary) or hostname (fallback) — NOT by IP (not unique with DHCP) ── */
const getAgent = (asset) => { const getAgent = (asset) => {
if (!asset || !agents.length) return null; if (!asset || !agents.length) return null;
return agents.find(ag => // Seriennummer ist eindeutig — erste Priorität
(asset.serial_number && ag.hardware_serial && ag.hardware_serial.toLowerCase() === asset.serial_number.toLowerCase()) || if (asset.serial_number) {
(asset.ip_address && ag.ip_address && ag.ip_address === asset.ip_address) || const bySerial = agents.find(ag => ag.hardware_serial && ag.hardware_serial.toLowerCase() === asset.serial_number.toLowerCase());
(asset.name && ag.hostname && ag.hostname.toLowerCase() === asset.name.toLowerCase()) if (bySerial) return bySerial;
) || null; }
// Hostname als Fallback
if (asset.name) {
const byHost = agents.find(ag => ag.hostname && ag.hostname.toLowerCase() === asset.name.toLowerCase());
if (byHost) return byHost;
}
return null;
}; };
/* ── PDF öffnen mit Auth-Token ── */ /* ── PDF öffnen mit Auth-Token ── */

View File

@@ -4,10 +4,17 @@ import fidoKeyService from '../services/fidoKeyService';
import LoadingSpinner from '../components/common/LoadingSpinner'; import LoadingSpinner from '../components/common/LoadingSpinner';
import { toast } from 'react-toastify'; import { toast } from 'react-toastify';
const API = process.env.REACT_APP_API_URL || '/api';
const authFetch = (url, opts = {}) => {
const token = localStorage.getItem('token');
return fetch(url, { ...opts, headers: { ...(opts.headers || {}), Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' } });
};
const FidoKeysPage = () => { const FidoKeysPage = () => {
const { canModifyFidoKeys, isAdmin } = useAuth(); const { canModifyFidoKeys, isAdmin } = useAuth();
const [keys, setKeys] = useState([]); const [keys, setKeys] = useState([]);
const [filteredKeys, setFilteredKeys] = useState([]); const [filteredKeys, setFilteredKeys] = useState([]);
const [users, setUsers] = useState([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [searchTerm, setSearchTerm] = useState(''); const [searchTerm, setSearchTerm] = useState('');
const [statusFilter, setStatusFilter] = useState('all'); const [statusFilter, setStatusFilter] = useState('all');
@@ -18,10 +25,15 @@ const FidoKeysPage = () => {
serial_number: '', serial_number: '',
status: 'aktiv', status: 'aktiv',
description: '', description: '',
assigned_to_user_id: '',
}); });
useEffect(() => { useEffect(() => {
loadKeys(); loadKeys();
authFetch(`${API}/users`)
.then(r => r.json())
.then(d => setUsers(d.data || d || []))
.catch(() => {});
}, []); }, []);
useEffect(() => { useEffect(() => {
@@ -41,32 +53,23 @@ const FidoKeysPage = () => {
const filterKeys = () => { const filterKeys = () => {
let filtered = [...keys]; let filtered = [...keys];
// Search filter
if (searchTerm) { if (searchTerm) {
filtered = filtered.filter( filtered = filtered.filter(
(key) => (key) =>
key.name.toLowerCase().includes(searchTerm.toLowerCase()) || key.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
key.serial_number.toLowerCase().includes(searchTerm.toLowerCase()) key.serial_number.toLowerCase().includes(searchTerm.toLowerCase()) ||
(key.assigned_to_username || '').toLowerCase().includes(searchTerm.toLowerCase())
); );
} }
// Status filter
if (statusFilter !== 'all') { if (statusFilter !== 'all') {
filtered = filtered.filter((key) => key.status === statusFilter); filtered = filtered.filter((key) => key.status === statusFilter);
} }
setFilteredKeys(filtered); setFilteredKeys(filtered);
}; };
const handleCreate = () => { const handleCreate = () => {
setEditingKey(null); setEditingKey(null);
setFormData({ setFormData({ name: '', serial_number: '', status: 'aktiv', description: '', assigned_to_user_id: '' });
name: '',
serial_number: '',
status: 'aktiv',
description: '',
});
setShowModal(true); setShowModal(true);
}; };
@@ -77,22 +80,25 @@ const FidoKeysPage = () => {
serial_number: key.serial_number, serial_number: key.serial_number,
status: key.status, status: key.status,
description: key.description || '', description: key.description || '',
assigned_to_user_id: key.assigned_to_user_id || '',
}); });
setShowModal(true); setShowModal(true);
}; };
const handleSubmit = async (e) => { const handleSubmit = async (e) => {
e.preventDefault(); e.preventDefault();
try { try {
const payload = {
...formData,
assigned_to_user_id: formData.assigned_to_user_id ? parseInt(formData.assigned_to_user_id) : null,
};
if (editingKey) { if (editingKey) {
await fidoKeyService.update(editingKey.id, formData); await fidoKeyService.update(editingKey.id, payload);
toast.success('FIDO-Key erfolgreich aktualisiert'); toast.success('FIDO-Key erfolgreich aktualisiert');
} else { } else {
await fidoKeyService.create(formData); await fidoKeyService.create(payload);
toast.success('FIDO-Key erfolgreich erstellt'); toast.success('FIDO-Key erfolgreich erstellt');
} }
setShowModal(false); setShowModal(false);
loadKeys(); loadKeys();
} catch (error) { } catch (error) {
@@ -101,10 +107,7 @@ const FidoKeysPage = () => {
}; };
const handleDelete = async (id) => { const handleDelete = async (id) => {
if (!window.confirm('Möchten Sie diesen FIDO-Key wirklich löschen?')) { if (!window.confirm('Möchten Sie diesen FIDO-Key wirklich löschen?')) return;
return;
}
try { try {
await fidoKeyService.delete(id); await fidoKeyService.delete(id);
toast.success('FIDO-Key erfolgreich gelöscht'); toast.success('FIDO-Key erfolgreich gelöscht');
@@ -116,7 +119,6 @@ const FidoKeysPage = () => {
const handleStatusToggle = async (key) => { const handleStatusToggle = async (key) => {
const newStatus = key.status === 'aktiv' ? 'inaktiv' : 'aktiv'; const newStatus = key.status === 'aktiv' ? 'inaktiv' : 'aktiv';
try { try {
await fidoKeyService.updateStatus(key.id, newStatus); await fidoKeyService.updateStatus(key.id, newStatus);
toast.success('Status erfolgreich geändert'); toast.success('Status erfolgreich geändert');
@@ -126,6 +128,16 @@ const FidoKeysPage = () => {
} }
}; };
const handleUnassign = async (key) => {
try {
await fidoKeyService.update(key.id, { ...key, assigned_to_user_id: null });
toast.success('Zuweisung aufgehoben');
loadKeys();
} catch (error) {
toast.error('Fehler beim Aufheben der Zuweisung');
}
};
if (loading) { if (loading) {
return ( return (
<div className="main-content"> <div className="main-content">
@@ -146,44 +158,21 @@ const FidoKeysPage = () => {
)} )}
</div> </div>
{/* Search and Filter */}
<div className="search-container"> <div className="search-container">
<input <input
type="text" type="text"
className="search-input" className="search-input"
placeholder="Suche nach Name oder Seriennummer..." placeholder="Suche nach Name, Seriennummer oder Benutzer..."
value={searchTerm} value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)} onChange={(e) => setSearchTerm(e.target.value)}
/> />
<div className="filter-buttons"> <div className="filter-buttons">
<button <button className={`btn ${statusFilter === 'all' ? 'btn-primary' : 'btn-secondary'} btn-small`} onClick={() => setStatusFilter('all')}>Alle</button>
className={`btn ${ <button className={`btn ${statusFilter === 'aktiv' ? 'btn-success' : 'btn-secondary'} btn-small`} onClick={() => setStatusFilter('aktiv')}>Aktiv</button>
statusFilter === 'all' ? 'btn-primary' : 'btn-secondary' <button className={`btn ${statusFilter === 'inaktiv' ? 'btn-danger' : 'btn-secondary'} btn-small`} onClick={() => setStatusFilter('inaktiv')}>Inaktiv</button>
} btn-small`}
onClick={() => setStatusFilter('all')}
>
Alle
</button>
<button
className={`btn ${
statusFilter === 'aktiv' ? 'btn-success' : 'btn-secondary'
} btn-small`}
onClick={() => setStatusFilter('aktiv')}
>
Aktiv
</button>
<button
className={`btn ${
statusFilter === 'inaktiv' ? 'btn-danger' : 'btn-secondary'
} btn-small`}
onClick={() => setStatusFilter('inaktiv')}
>
Inaktiv
</button>
</div> </div>
</div> </div>
{/* Table */}
<div className="card"> <div className="card">
<table className="table"> <table className="table">
<thead> <thead>
@@ -191,6 +180,7 @@ const FidoKeysPage = () => {
<th>Name</th> <th>Name</th>
<th>Seriennummer</th> <th>Seriennummer</th>
<th>Status</th> <th>Status</th>
<th>Zugewiesen an</th>
<th>Beschreibung</th> <th>Beschreibung</th>
<th>Erstellt von</th> <th>Erstellt von</th>
<th>Aktionen</th> <th>Aktionen</th>
@@ -199,19 +189,34 @@ const FidoKeysPage = () => {
<tbody> <tbody>
{filteredKeys.length === 0 ? ( {filteredKeys.length === 0 ? (
<tr> <tr>
<td colSpan="6" className="text-center"> <td colSpan="7" className="text-center">Keine FIDO-Keys gefunden</td>
Keine FIDO-Keys gefunden
</td>
</tr> </tr>
) : ( ) : (
filteredKeys.map((key) => ( filteredKeys.map((key) => (
<tr key={key.id}> <tr key={key.id}>
<td>{key.name}</td> <td>{key.name}</td>
<td>{key.serial_number}</td> <td><code style={{fontSize:12}}>{key.serial_number}</code></td>
<td> <td>
<span className={`status-badge status-${key.status}`}> <span className={`status-badge status-${key.status}`}>{key.status}</span>
{key.status} </td>
</span> <td>
{key.assigned_to_username ? (
<div style={{display:'flex',alignItems:'center',gap:6}}>
<div style={{width:24,height:24,borderRadius:'50%',background:'var(--cereda-primary)',color:'#fff',fontSize:10,display:'flex',alignItems:'center',justifyContent:'center',fontWeight:600,flexShrink:0}}>
{(key.assigned_to_username||'?').charAt(0).toUpperCase()}
</div>
<span style={{fontSize:13}}>{key.assigned_to_username}</span>
{canModifyFidoKeys() && (
<button
onClick={() => handleUnassign(key)}
title="Zuweisung aufheben"
style={{background:'none',border:'none',cursor:'pointer',color:'var(--text-muted)',padding:0,fontSize:14,lineHeight:1}}
>×</button>
)}
</div>
) : (
<span style={{color:'var(--text-muted)',fontSize:12}}> nicht zugewiesen</span>
)}
</td> </td>
<td>{key.description || '-'}</td> <td>{key.description || '-'}</td>
<td>{key.created_by_username}</td> <td>{key.created_by_username}</td>
@@ -219,29 +224,14 @@ const FidoKeysPage = () => {
<div className="table-actions"> <div className="table-actions">
{canModifyFidoKeys() && ( {canModifyFidoKeys() && (
<> <>
<button <button onClick={() => handleEdit(key)} className="btn btn-primary btn-small">Bearbeiten</button>
onClick={() => handleEdit(key)} <button onClick={() => handleStatusToggle(key)} className="btn btn-secondary btn-small">
className="btn btn-primary btn-small" {key.status === 'aktiv' ? 'Deaktivieren' : 'Aktivieren'}
>
Bearbeiten
</button>
<button
onClick={() => handleStatusToggle(key)}
className="btn btn-secondary btn-small"
>
{key.status === 'aktiv'
? 'Deaktivieren'
: 'Aktivieren'}
</button> </button>
</> </>
)} )}
{isAdmin() && ( {isAdmin() && (
<button <button onClick={() => handleDelete(key.id)} className="btn btn-danger btn-small">Löschen</button>
onClick={() => handleDelete(key.id)}
className="btn btn-danger btn-small"
>
Löschen
</button>
)} )}
</div> </div>
</td> </td>
@@ -252,93 +242,57 @@ const FidoKeysPage = () => {
</table> </table>
</div> </div>
{/* Modal */}
{showModal && ( {showModal && (
<div className="modal-overlay" onClick={() => setShowModal(false)}> <div className="modal-overlay" onClick={() => setShowModal(false)}>
<div className="modal-content" onClick={(e) => e.stopPropagation()}> <div className="modal-content" onClick={(e) => e.stopPropagation()}>
<div className="modal-header"> <div className="modal-header">
<h2 className="modal-title"> <h2 className="modal-title">{editingKey ? 'FIDO-Key bearbeiten' : 'Neuer FIDO-Key'}</h2>
{editingKey ? 'FIDO-Key bearbeiten' : 'Neuer FIDO-Key'} <button className="modal-close" onClick={() => setShowModal(false)}>×</button>
</h2>
<button
className="modal-close"
onClick={() => setShowModal(false)}
>
×
</button>
</div> </div>
<form onSubmit={handleSubmit}> <form onSubmit={handleSubmit}>
<div className="form-group"> <div className="form-group">
<label className="form-label">Name*</label> <label className="form-label">Name*</label>
<input <input type="text" className="form-input" value={formData.name} onChange={(e) => setFormData({ ...formData, name: e.target.value })} required />
type="text"
className="form-input"
value={formData.name}
onChange={(e) =>
setFormData({ ...formData, name: e.target.value })
}
required
/>
</div> </div>
<div className="form-group"> <div className="form-group">
<label className="form-label">Seriennummer*</label> <label className="form-label">Seriennummer*</label>
<input <input type="text" className="form-input" value={formData.serial_number} onChange={(e) => setFormData({ ...formData, serial_number: e.target.value })} required />
type="text"
className="form-input"
value={formData.serial_number}
onChange={(e) =>
setFormData({
...formData,
serial_number: e.target.value,
})
}
required
/>
</div> </div>
<div className="form-group"> <div className="form-group">
<label className="form-label">Status*</label> <label className="form-label">Status*</label>
<select <select className="form-select" value={formData.status} onChange={(e) => setFormData({ ...formData, status: e.target.value })} required>
className="form-select"
value={formData.status}
onChange={(e) =>
setFormData({ ...formData, status: e.target.value })
}
required
>
<option value="aktiv">Aktiv</option> <option value="aktiv">Aktiv</option>
<option value="inaktiv">Inaktiv</option> <option value="inaktiv">Inaktiv</option>
</select> </select>
</div> </div>
<div className="form-group">
<label className="form-label">Zugewiesen an</label>
<select
className="form-select"
value={formData.assigned_to_user_id}
onChange={(e) => setFormData({ ...formData, assigned_to_user_id: e.target.value })}
>
<option value=""> Nicht zugewiesen</option>
{users.map(u => (
<option key={u.id} value={u.id}>
{u.full_name || u.username} ({u.email})
</option>
))}
</select>
</div>
<div className="form-group"> <div className="form-group">
<label className="form-label">Beschreibung</label> <label className="form-label">Beschreibung</label>
<textarea <textarea className="form-textarea" rows="3" value={formData.description} onChange={(e) => setFormData({ ...formData, description: e.target.value })} />
className="form-textarea"
rows="3"
value={formData.description}
onChange={(e) =>
setFormData({
...formData,
description: e.target.value,
})
}
/>
</div> </div>
<div className="card-footer"> <div className="card-footer">
<button <button type="button" onClick={() => setShowModal(false)} className="btn btn-secondary">Abbrechen</button>
type="button" <button type="submit" className="btn btn-primary">Speichern</button>
onClick={() => setShowModal(false)}
className="btn btn-secondary"
>
Abbrechen
</button>
<button type="submit" className="btn btn-primary">
Speichern
</button>
</div> </div>
</form> </form>
</div> </div>

View File

@@ -241,7 +241,7 @@ const TimeSeriesChart = ({ data, key1, key2, height = 110 }) => {
); );
}; };
// ─── Checkmk-style Tactical Overview ───────────────────────────────────────── // ─── Tactical Overview — New KPI Card Design ─────────────────────────────────
const TacticalOverview = ({ devices, agents, problems, acknowledged, countdown, lastUpdated, onTabChange, extAlerts = [] }) => { const TacticalOverview = ({ devices, agents, problems, acknowledged, countdown, lastUpdated, onTabChange, extAlerts = [] }) => {
const openP = problems.filter(p => !acknowledged.has(p.id)); const openP = problems.filter(p => !acknowledged.has(p.id));
const critN = openP.filter(p => p.severity <= 1).length; const critN = openP.filter(p => p.severity <= 1).length;
@@ -253,100 +253,69 @@ const TacticalOverview = ({ devices, agents, problems, acknowledged, countdown,
const agOnline = agents.filter(a => a.status === 'online').length; const agOnline = agents.filter(a => a.status === 'online').length;
const agOffline = agents.filter(a => a.status === 'offline').length; const agOffline = agents.filter(a => a.status === 'offline').length;
const agWarn = agents.filter(a => warnings(a).some(w => w.type === 'warn') && a.status === 'online').length; const agWarn = agents.filter(a => warnings(a).some(w => w.type === 'warn') && a.status === 'online').length;
const pxAlerts = extAlerts.filter(a => a.source === 'proxmox' && !a.acknowledged); const pxAlerts = extAlerts.filter(a => a.source === 'proxmox' && !a.acknowledged);
const pxCrit = pxAlerts.filter(a => a.severity === 'CRIT').length; const pxCrit = pxAlerts.filter(a => a.severity === 'CRIT').length;
const pxWarn = pxAlerts.filter(a => a.severity === 'WARN').length; const pxWarn = pxAlerts.filter(a => a.severity === 'WARN').length;
const pxOk = pxCrit === 0 && pxWarn === 0;
const CountTile = ({ label, count, color, onClick, sub }) => { const KpiCard = ({ icon, name, accent, num, den, breaks, bar, onClick }) => (
const active = count > 0; <div className="mon-kpi" onClick={onClick} style={{ cursor: onClick ? 'pointer' : 'default' }}>
const W = 82, H = 72, cx = W / 2, cy = H / 2, r = 33; <div className="mon-kpi-head">
const pts = Array.from({ length: 6 }, (_, i) => { <div className="mon-kpi-ic" style={{ color: accent }}>{icon}</div>
const a = (Math.PI / 3) * i; <div className="mon-kpi-name">{name}</div>
return `${(cx + r * Math.cos(a)).toFixed(1)},${(cy + r * Math.sin(a)).toFixed(1)}`; </div>
}).join(' '); <div className="mon-kpi-row">
return ( <div style={{ display: 'flex', alignItems: 'baseline', gap: 6 }}>
<svg width={W} height={H} onClick={onClick} style={{ cursor: onClick ? 'pointer' : 'default', flexShrink: 0, transition: 'filter .15s' }} <span className="mon-kpi-num" style={{ color: accent }}>{num}</span>
onMouseEnter={e => { if (active && onClick) e.currentTarget.style.filter = `drop-shadow(0 0 7px ${color}99)`; }} {den && <span className="mon-kpi-den">/ {den}</span>}
onMouseLeave={e => { e.currentTarget.style.filter = ''; }}> </div>
<polygon points={pts} <div className="mon-kpi-breaks">
fill={active ? `${color}20` : 'rgba(255,255,255,0.03)'} {breaks.map((b, i) => (
stroke={active ? color : 'rgba(255,255,255,0.1)'} <div key={i} className="mon-kpi-break">
strokeWidth={active ? 1.5 : 1} /> <div className="mon-kpi-break-num" style={{ color: b[2] }}>{b[0]}</div>
<text x={cx} y={cy - (sub ? 8 : 3)} textAnchor="middle" dominantBaseline="middle" <div className="mon-kpi-break-lbl">{b[1]}</div>
fill={active ? color : 'rgba(255,255,255,0.2)'} </div>
fontSize="21" fontWeight="800" fontFamily="monospace,'Courier New'">{count}</text> ))}
<text x={cx} y={cy + (sub ? 9 : 12)} textAnchor="middle" dominantBaseline="middle" </div>
fill={active ? color : 'rgba(255,255,255,0.2)'} </div>
fontSize="7.5" fontWeight="700" letterSpacing="0.8">{label}</text> <div className="mon-kpi-bar">
{sub && <text x={cx} y={cy + 20} textAnchor="middle" dominantBaseline="middle" {bar.map((s, i) => <span key={i} style={{ width: `${s[0]}%`, background: s[1], height: '100%' }} />)}
fill="rgba(255,255,255,0.3)" fontSize="6.5">{sub}</text>} </div>
</svg> </div>
); );
};
const Divider = () => <div style={{ width: 1, alignSelf: 'stretch', background: 'var(--border-color)', margin: '0 8px' }} />; const total = hostUp + hostDown + hostUnk || 1;
const agTotal = agents.length || 1;
return ( return (
<div style={{ background: 'var(--bg-secondary)', border: '1px solid var(--border-color)', borderRadius: 10, padding: '14px 20px', marginBottom: 20 }}> <div className="mon-kpis">
<div style={{ display: 'flex', alignItems: 'center', gap: 0, flexWrap: 'wrap', rowGap: 10 }}> <KpiCard
{/* Section: Zustand */} icon="🖥️" name="Gesamtzustand" accent="#e8eef6"
<div style={{ display: 'flex', flexDirection: 'column', marginRight: 16 }}> num={hostUp + agOnline - agWarn} den={null}
<span style={{ fontSize: 9, fontWeight: 700, textTransform: 'uppercase', letterSpacing: .5, color: 'var(--text-muted)', marginBottom: 6, paddingLeft: 2 }}>Gesamtzustand</span> breaks={[[critN, 'Krit', SC.crit], [warnN, 'Warn', SC.warn], [hostUp + agOnline - agWarn - critN, 'OK', SC.ok]]}
<div style={{ display: 'flex', gap: 6 }}> bar={[[Math.round(critN / (devices.length + agTotal) * 100), SC.crit], [Math.round(warnN / (devices.length + agTotal) * 100), SC.warn], [Math.max(0, 100 - Math.round((critN + warnN) / (devices.length + agTotal) * 100)), SC.ok]]}
<CountTile label="KRITISCH" count={critN} color={SC.crit} onClick={critN > 0 ? () => onTabChange('problems') : null} /> onClick={critN > 0 || warnN > 0 ? () => onTabChange('problems') : null}
<CountTile label="WARNUNG" count={warnN} color={SC.warn} onClick={warnN > 0 ? () => onTabChange('problems') : null} /> />
<CountTile label="OK" count={hostUp + agOnline - agWarn} color={SC.ok} /> <KpiCard
</div> icon="🌐" name="Netzwerk-Geräte" accent={SC.ok}
</div> num={hostUp} den={devices.length}
breaks={[[hostDown, 'Down', SC.crit], [hostUnk, '?', SC.unknown]]}
<Divider /> bar={[[Math.round(hostDown / total * 100), SC.crit], [Math.round(hostUnk / total * 100), SC.unknown], [Math.round(hostUp / total * 100), SC.ok]]}
onClick={hostDown > 0 ? () => onTabChange('network') : null}
{/* Section: Netzwerk */} />
<div style={{ display: 'flex', flexDirection: 'column', marginRight: 16 }}> <KpiCard
<span style={{ fontSize: 9, fontWeight: 700, textTransform: 'uppercase', letterSpacing: .5, color: 'var(--text-muted)', marginBottom: 6, paddingLeft: 2 }}>Netzwerk-Geräte</span> icon="⚡" name="Windows Agents" accent={SC.ok}
<div style={{ display: 'flex', gap: 6 }}> num={agOnline} den={agents.length}
<CountTile label="UP" count={hostUp} color={SC.ok} sub={`von ${devices.length}`} /> breaks={[[agOffline, 'Off', SC.crit], [agWarn, 'Warn', SC.warn]]}
<CountTile label="DOWN" count={hostDown} color={SC.crit} onClick={hostDown > 0 ? () => onTabChange('network') : null} /> bar={[[Math.round(agOffline / agTotal * 100), SC.crit], [Math.round(agWarn / agTotal * 100), SC.warn], [Math.max(0, 100 - Math.round((agOffline + agWarn) / agTotal * 100)), SC.ok]]}
<CountTile label="?" count={hostUnk} color={SC.unknown} /> onClick={agOffline > 0 || agWarn > 0 ? () => onTabChange('agents') : null}
</div> />
</div> <KpiCard
icon="🔧" name="Proxmox" accent={pxCrit > 0 ? SC.crit : pxWarn > 0 ? SC.warn : SC.ok}
<Divider /> num={pxCrit > 0 ? pxCrit : pxWarn > 0 ? pxWarn : 1} den={null}
breaks={[[pxCrit, 'Krit', SC.crit], [pxWarn, 'Warn', SC.warn], [countdown + 's', 'Refresh', 'var(--text-muted)']]}
{/* Section: Agents */} bar={[[pxCrit > 0 ? 100 : pxWarn > 0 ? 60 : 0, pxCrit > 0 ? SC.crit : SC.warn], [pxCrit > 0 ? 0 : 100, SC.ok]]}
<div style={{ display: 'flex', flexDirection: 'column', marginRight: 16 }}> onClick={pxCrit > 0 || pxWarn > 0 ? () => onTabChange('overview') : null}
<span style={{ fontSize: 9, fontWeight: 700, textTransform: 'uppercase', letterSpacing: .5, color: 'var(--text-muted)', marginBottom: 6, paddingLeft: 2 }}>Windows Agents</span> />
<div style={{ display: 'flex', gap: 6 }}>
<CountTile label="ONLINE" count={agOnline} color={SC.ok} sub={`von ${agents.length}`} />
<CountTile label="OFFLINE" count={agOffline} color={SC.crit} onClick={agOffline > 0 ? () => onTabChange('agents') : null} />
<CountTile label="WARNUNG" count={agWarn} color={SC.warn} onClick={agWarn > 0 ? () => onTabChange('agents') : null} />
</div>
</div>
<Divider />
{/* Section: Proxmox */}
<div style={{ display: 'flex', flexDirection: 'column', marginRight: 16 }}>
<span style={{ fontSize: 9, fontWeight: 700, textTransform: 'uppercase', letterSpacing: .5, color: 'var(--text-muted)', marginBottom: 6, paddingLeft: 2 }}>Proxmox</span>
<div style={{ display: 'flex', gap: 6 }}>
<CountTile label="OK" count={pxOk ? 1 : 0} color={SC.ok} sub="Infrastruktur" />
<CountTile label="KRITISCH" count={pxCrit} color={SC.crit} onClick={pxCrit > 0 ? () => onTabChange('overview') : null} />
<CountTile label="WARNUNG" count={pxWarn} color={SC.warn} onClick={pxWarn > 0 ? () => onTabChange('overview') : null} />
</div>
</div>
{/* Right: status + timer */}
<div style={{ marginLeft: 'auto', display: 'flex', flexDirection: 'column', alignItems: 'flex-end', gap: 6 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 12 }}>
<span style={{ color: 'var(--text-muted)' }}>Nächster Refresh:</span>
<span style={{ fontWeight: 700, fontFamily: 'monospace', color: countdown <= 5 ? SC.warn : 'var(--text-primary)' }}>{countdown}s</span>
</div>
<div style={{ fontSize: 11, color: 'var(--text-muted)', fontFamily: 'monospace' }}>{lastUpdated}</div>
<div style={{ fontSize: 10, color: 'var(--text-muted)' }}>{devices.length + agents.length} Hosts gesamt</div>
</div>
</div>
</div> </div>
); );
}; };
@@ -748,6 +717,7 @@ const MonitoringPage = () => {
const [agentFilter, setAgentFilter] = useState('all'); const [agentFilter, setAgentFilter] = useState('all');
const [expandedAgents, setExpandedAgents] = useState(new Set()); const [expandedAgents, setExpandedAgents] = useState(new Set());
const [expandedHosts, setExpandedHosts] = useState(new Set()); const [expandedHosts, setExpandedHosts] = useState(new Set());
const [ovSelectedId, setOvSelectedId] = useState(null);
const [agentSort, setAgentSort] = useState('cpu'); const [agentSort, setAgentSort] = useState('cpu');
const [hostFilter, setHostFilter] = useState('all'); const [hostFilter, setHostFilter] = useState('all');
const [hostSearch, setHostSearch] = useState(''); const [hostSearch, setHostSearch] = useState('');
@@ -888,10 +858,8 @@ const MonitoringPage = () => {
const card = (ex = {}) => ({ background: 'var(--bg-secondary)', borderRadius: 10, border: '1px solid var(--border-color)', ...ex }); const card = (ex = {}) => ({ background: 'var(--bg-secondary)', borderRadius: 10, border: '1px solid var(--border-color)', ...ex });
// ─── Tab: Übersicht ─────────────────────────────────────────────────────── // ─── Tab: Übersicht — Master-Detail Layout ────────────────────────────────
const renderOverview = () => { const renderOverview = () => {
const toggleHost = (id) => setExpandedHosts(prev => { const n = new Set(prev); n.has(id) ? n.delete(id) : n.add(id); return n; });
const pxState = proxmoxData?.node ? (() => { const pxState = proxmoxData?.node ? (() => {
const cpu = (proxmoxData.node.cpu || 0) * 100; const cpu = (proxmoxData.node.cpu || 0) * 100;
const ram = proxmoxData.node.memory?.total > 0 ? (proxmoxData.node.memory.used / proxmoxData.node.memory.total) * 100 : 0; const ram = proxmoxData.node.memory?.total > 0 ? (proxmoxData.node.memory.used / proxmoxData.node.memory.total) * 100 : 0;
@@ -903,108 +871,103 @@ const MonitoringPage = () => {
})() : 'unknown'; })() : 'unknown';
const allHosts = [ const allHosts = [
...(proxmoxData ? [{ id: 'px-hve01', type: 'proxmox', name: proxmoxData.node_name || 'hve-01', ip: '192.168.0.184', icon: '🖥️', subLabel: 'Proxmox VE Node', state: pxState, services: proxmoxServices(proxmoxData), lastCheck: proxmoxData.fetched_at }] : []), ...(proxmoxData ? [{ id: 'px-hve01', name: proxmoxData.node_name || 'hve-01', ip: '192.168.0.184', icon: '🖥️', sub: 'Proxmox VE Node', state: pxState, services: proxmoxServices(proxmoxData), last: proxmoxData.fetched_at }] : []),
...devices.map(d => { ...devices.map(d => ({ id: `net-${d.id}`, name: d.name, ip: d.host, icon: TYPE_META[d.type]?.icon || '🌐', sub: TYPE_META[d.type]?.label || 'Netzwerkgerät', state: d.last_status === 'up' ? 'ok' : d.last_status === 'down' ? 'crit' : 'unknown', services: netDeviceServices(d, uptimeStats[d.id]), last: d.last_checked, raw: d })),
const ut = uptimeStats[d.id]; ...agents.map(a => { const w = warnings(a); const hasErr = w.some(x => x.type === 'error'); const state = a.status === 'offline' ? 'crit' : hasErr ? 'crit' : w.length ? 'warn' : 'ok'; return { id: `ag-${a.id}`, name: a.hostname, ip: a.ip_address, icon: '💻', sub: 'Windows Agent', state, services: agentServices(a), last: a.last_checkin, raw: a }; }),
const state = d.last_status === 'up' ? 'ok' : d.last_status === 'down' ? 'crit' : 'unknown';
return { id: `net-${d.id}`, type: 'network', name: d.name, ip: d.host, icon: TYPE_META[d.type]?.icon || '🌐', subLabel: TYPE_META[d.type]?.label || 'Netzwerkgerät', state, services: netDeviceServices(d, ut), lastCheck: d.last_checked, raw: d };
}),
...agents.map(a => {
const w = warnings(a);
const hasErr = w.some(x => x.type === 'error');
const state = a.status === 'offline' ? 'crit' : hasErr ? 'crit' : w.length ? 'warn' : 'ok';
return { id: `ag-${a.id}`, type: 'agent', name: a.hostname, ip: a.ip_address, icon: '💻', subLabel: 'Windows Agent', state, services: agentServices(a), lastCheck: a.last_checkin, raw: a };
}),
].sort((a, b) => { const o = { crit: 0, warn: 1, unknown: 2, ok: 3 }; return (o[a.state] ?? 3) - (o[b.state] ?? 3) || a.name.localeCompare(b.name); }); ].sort((a, b) => { const o = { crit: 0, warn: 1, unknown: 2, ok: 3 }; return (o[a.state] ?? 3) - (o[b.state] ?? 3) || a.name.localeCompare(b.name); });
const filtered = allHosts const filtered = allHosts.filter(h => (hostFilter === 'all' || h.state !== 'ok') && (!hostSearch || h.name.toLowerCase().includes(hostSearch.toLowerCase()) || (h.ip || '').includes(hostSearch)));
.filter(h => hostFilter === 'all' || h.state !== 'ok') const selId = ovSelectedId ?? filtered[0]?.id;
.filter(h => !hostSearch || h.name.toLowerCase().includes(hostSearch.toLowerCase()) || (h.ip || '').includes(hostSearch)); const selHost = filtered.find(h => h.id === selId) || filtered[0];
const C = { ok: SC.ok, warn: SC.warn, crit: SC.crit, unknown: SC.unknown };
const stateLabel = { ok: 'OK', warn: 'WARN', crit: 'CRIT', unknown: '?' };
const stateColor = { ok: SC.ok, warn: SC.warn, crit: SC.crit, unknown: SC.unknown };
return ( return (
<> <>
{/* Filter + Search */} <div style={{ display: 'flex', gap: 8, marginBottom: 16, alignItems: 'center' }}>
<div style={{ display: 'flex', gap: 8, marginBottom: 14, alignItems: 'center', flexWrap: 'wrap' }}> <div style={{ display: 'flex', padding: 3, borderRadius: 11, background: 'rgba(255,255,255,.04)', border: '1px solid rgba(255,255,255,.08)', gap: 2 }}>
{[['all', `Alle (${allHosts.length})`], ['problems', `Probleme (${allHosts.filter(h => h.state !== 'ok').length})`]].map(([v, l]) => ( {[['all', `Alle · ${allHosts.length}`], ['problems', `Nur Probleme · ${allHosts.filter(h => h.state !== 'ok').length}`]].map(([v, l]) => (
<button key={v} onClick={() => setHostFilter(v)} style={{ padding: '4px 14px', borderRadius: 20, border: `1px solid ${hostFilter === v ? 'var(--accent)' : 'var(--border-color)'}`, background: hostFilter === v ? 'var(--accent)' : 'none', color: hostFilter === v ? '#fff' : 'var(--text-muted)', fontSize: 12, cursor: 'pointer', fontWeight: hostFilter === v ? 700 : 400 }}>{l}</button> <button key={v} onClick={() => setHostFilter(v)} style={{ padding: '6px 14px', borderRadius: 8, border: 'none', background: hostFilter === v ? '#0d9488' : 'none', color: hostFilter === v ? '#fff' : 'var(--text-muted)', fontSize: 12.5, fontWeight: hostFilter === v ? 650 : 500, cursor: 'pointer', transition: '.14s' }}>{l}</button>
))} ))}
<button onClick={() => { setExpandedHosts(new Set(filtered.map(h => h.id))); }} style={{ padding: '4px 12px', borderRadius: 20, border: '1px solid var(--border-color)', background: 'none', color: 'var(--text-muted)', fontSize: 12, cursor: 'pointer' }}>Alle aufklappen</button> </div>
<button onClick={() => setExpandedHosts(new Set())} style={{ padding: '4px 12px', borderRadius: 20, border: '1px solid var(--border-color)', background: 'none', color: 'var(--text-muted)', fontSize: 12, cursor: 'pointer' }}>Alle zuklappen</button> <input value={hostSearch} onChange={e => setHostSearch(e.target.value)} placeholder="🔍 Host suchen..." style={{ marginLeft: 'auto', padding: '8px 12px', borderRadius: 10, border: '1px solid rgba(255,255,255,.08)', background: 'rgba(255,255,255,.04)', color: 'var(--text-primary)', fontSize: 13, width: 220 }} />
<input value={hostSearch} onChange={e => setHostSearch(e.target.value)} placeholder="🔍 Host suchen..." style={{ marginLeft: 'auto', padding: '5px 12px', borderRadius: 8, border: '1px solid var(--border-color)', background: 'var(--bg-secondary)', color: 'var(--text-primary)', fontSize: 13, minWidth: 200 }} />
</div> </div>
{/* CheckMK Host Table */} <div style={{ display: 'grid', gridTemplateColumns: 'minmax(0,1.15fr) minmax(0,1fr)', gap: 18, alignItems: 'start' }}>
<div style={{ border: '1px solid var(--border-color)', borderRadius: 10, overflow: 'hidden' }}> {/* HOST LIST */}
{/* Header */} <div style={{ background: 'rgba(255,255,255,.04)', border: '1px solid rgba(255,255,255,.08)', borderRadius: 14, overflow: 'hidden', backdropFilter: 'blur(10px)' }}>
<div style={{ display: 'grid', gridTemplateColumns: '32px 1fr 90px 60px 60px 60px 130px', background: 'var(--bg-secondary)', borderBottom: '2px solid var(--border-color)', padding: '8px 14px', fontSize: 10, color: 'var(--text-muted)', fontWeight: 800, textTransform: 'uppercase', letterSpacing: .5 }}> <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '14px 16px', borderBottom: '1px solid rgba(255,255,255,.08)' }}>
<div /> <span style={{ fontSize: 11, fontWeight: 700, textTransform: 'uppercase', letterSpacing: .6, color: 'var(--text-muted)' }}>Hosts</span>
<div>Host / IP</div> <span style={{ fontSize: 12, color: 'var(--text-muted)' }}>{filtered.length} von {allHosts.length}</span>
<div>Status</div>
<div style={{ color: SC.ok }}>OK</div>
<div style={{ color: SC.warn }}>WARN</div>
<div style={{ color: SC.crit }}>CRIT</div>
<div>Letzter Check</div>
</div> </div>
<div style={{ maxHeight: 'calc(100vh - 420px)', overflowY: 'auto' }}>
{filtered.map(host => { {filtered.length === 0 && <div style={{ padding: 40, textAlign: 'center', color: SC.ok }}> Alle Services in Ordnung</div>}
const expanded = expandedHosts.has(host.id); {filtered.map(h => {
const okC = host.services.filter(s => s.status === 'ok').length; const okC = h.services.filter(s => s.status === 'ok').length;
const warnC = host.services.filter(s => s.status === 'warn').length; const wC = h.services.filter(s => s.status === 'warn').length;
const critC = host.services.filter(s => s.status === 'crit').length; const cC = h.services.filter(s => s.status === 'crit').length;
const hColor = stateColor[host.state] || SC.unknown; const sel = h.id === selId;
return ( return (
<div key={host.id}> <div key={h.id} onClick={() => setOvSelectedId(h.id)} style={{ display: 'grid', gridTemplateColumns: '34px 1fr auto', gap: 12, alignItems: 'center', padding: '13px 16px', borderBottom: '1px solid rgba(255,255,255,.06)', cursor: 'pointer', borderLeft: `3px solid ${sel ? '#14b8a6' : C[h.state]}`, background: sel ? 'linear-gradient(90deg,rgba(13,148,136,.14),transparent)' : 'transparent', transition: 'background .12s' }}>
{/* Host row */} <div style={{ width: 34, height: 34, borderRadius: 10, display: 'grid', placeItems: 'center', background: 'rgba(255,255,255,.06)', border: '1px solid rgba(255,255,255,.08)', color: C[h.state], fontSize: 16 }}>{h.icon}</div>
<div onClick={() => toggleHost(host.id)} style={{ display: 'grid', gridTemplateColumns: '32px 1fr 90px 60px 60px 60px 130px', padding: '10px 14px', borderBottom: '1px solid var(--border-color)', cursor: 'pointer', background: host.state === 'crit' ? `${SC.crit}12` : host.state === 'warn' ? `${SC.warn}09` : 'transparent', borderLeft: `3px solid ${hColor}`, transition: 'background .1s' }}
onMouseEnter={e => e.currentTarget.style.filter = 'brightness(1.08)'}
onMouseLeave={e => e.currentTarget.style.filter = ''}>
<div style={{ color: 'var(--text-muted)', fontSize: 11, paddingTop: 2 }}>{expanded ? '▼' : '▶'}</div>
<div> <div>
<div style={{ display: 'flex', alignItems: 'center', gap: 7 }}> <div style={{ fontWeight: 650, fontSize: 13.5 }}>{h.name}</div>
<span style={{ fontSize: 14 }}>{host.icon}</span> <div style={{ fontSize: 11, color: 'var(--text-muted)', fontFamily: 'monospace', marginTop: 1 }}>{h.ip} · {h.sub}</div>
<span style={{ fontWeight: 700, fontSize: 13, color: 'var(--text-primary)' }}>{host.name}</span>
{host.ip && <span style={{ fontSize: 11, color: 'var(--text-muted)', fontFamily: 'monospace' }}>{host.ip}</span>}
</div> </div>
<div style={{ fontSize: 10, color: 'var(--text-muted)', marginTop: 1 }}>{host.subLabel}</div> <div style={{ display: 'flex', gap: 5 }}>
{[okC, wC, cC].map((n, i) => { const col = [SC.ok, SC.warn, SC.crit][i]; return <span key={i} style={{ fontFamily: 'monospace', fontSize: 11, fontWeight: 700, padding: '2px 7px', borderRadius: 6, background: n > 0 ? `${col}22` : 'rgba(255,255,255,.04)', color: n > 0 ? col : 'var(--text-muted)', border: `1px solid ${n > 0 ? col + '44' : 'rgba(255,255,255,.06)'}`, minWidth: 24, textAlign: 'center' }}>{n}</span>; })}
</div>
</div>
);
})}
</div> </div>
<div><span style={{ padding: '2px 8px', borderRadius: 3, fontSize: 11, fontWeight: 800, background: `${hColor}22`, color: hColor, border: `1px solid ${hColor}55`, fontFamily: 'monospace' }}>{stateLabel[host.state] || '?'}</span></div>
<div style={{ fontWeight: 700, fontSize: 13, color: SC.ok }}>{okC}</div>
<div style={{ fontWeight: warnC > 0 ? 700 : 400, fontSize: 13, color: warnC > 0 ? SC.warn : 'var(--text-muted)' }}>{warnC}</div>
<div style={{ fontWeight: critC > 0 ? 700 : 400, fontSize: 13, color: critC > 0 ? SC.crit : 'var(--text-muted)' }}>{critC}</div>
<div style={{ fontSize: 11, color: 'var(--text-muted)', fontFamily: 'monospace' }}>{timeAgo(host.lastCheck)}</div>
</div> </div>
{/* Service rows */} {/* SERVICE DETAIL */}
{expanded && host.services.map((svc, i) => { {selHost ? (
const sc = stateColor[svc.status] || SC.unknown; <div style={{ background: 'rgba(255,255,255,.04)', border: '1px solid rgba(255,255,255,.08)', borderRadius: 14, overflow: 'hidden', backdropFilter: 'blur(10px)', position: 'sticky', top: 0 }}>
<div style={{ padding: 20, borderBottom: '1px solid rgba(255,255,255,.08)', background: 'linear-gradient(180deg,rgba(255,255,255,.06),transparent)' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 13 }}>
<div style={{ width: 46, height: 46, borderRadius: 13, display: 'grid', placeItems: 'center', background: 'rgba(255,255,255,.06)', border: '1px solid rgba(255,255,255,.08)', color: C[selHost.state], fontSize: 22 }}>{selHost.icon}</div>
<div style={{ flex: 1 }}>
<div style={{ fontSize: 18, fontWeight: 730, letterSpacing: -.3 }}>{selHost.name}</div>
<div style={{ fontFamily: 'monospace', fontSize: 12, color: 'var(--text-muted)', marginTop: 2 }}>{selHost.ip}</div>
</div>
<span className={`mon-chip ${selHost.state} lg`}>{selHost.state.toUpperCase()}</span>
</div>
<div style={{ display: 'flex', gap: 8, marginTop: 14, flexWrap: 'wrap' }}>
<span style={{ fontSize: 11, color: 'var(--text-muted)', padding: '3px 9px', borderRadius: 7, background: 'rgba(255,255,255,.06)', border: '1px solid rgba(255,255,255,.08)' }}>{selHost.sub}</span>
<span style={{ fontSize: 11, color: 'var(--text-muted)', padding: '3px 9px', borderRadius: 7, background: 'rgba(255,255,255,.06)', border: '1px solid rgba(255,255,255,.08)' }}>Check {timeAgo(selHost.last)}</span>
</div>
</div>
<div style={{ maxHeight: 'calc(100vh - 520px)', overflowY: 'auto' }}>
{selHost.services.map((svc, i) => {
const sc = C[svc.status] || SC.unknown;
const p = svc.perf ? Math.min(svc.perf.val / svc.perf.max * 100, 100) : 0;
const pSt = svc.perf ? (p >= svc.perf.crit ? 'crit' : p >= svc.perf.warn ? 'warn' : 'ok') : 'ok';
return ( return (
<div key={i} style={{ display: 'grid', gridTemplateColumns: '32px 220px 80px 1fr 160px', gap: 8, padding: '6px 14px 6px 44px', borderBottom: '1px solid rgba(255,255,255,0.04)', background: svc.status === 'crit' ? `${SC.crit}09` : svc.status === 'warn' ? `${SC.warn}06` : i % 2 === 0 ? 'rgba(255,255,255,0.02)' : 'transparent', borderLeft: `3px solid ${i % 2 === 0 ? 'transparent' : 'transparent'}` }}> <div key={i} style={{ padding: '14px 20px', borderBottom: '1px solid rgba(255,255,255,.06)' }}>
<div /> <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, marginBottom: svc.perf ? 8 : 0 }}>
<div style={{ fontSize: 12, color: 'var(--text-secondary)', fontWeight: 500, display: 'flex', alignItems: 'center' }}>{svc.name}</div> <div>
<div style={{ display: 'flex', alignItems: 'center' }}> <div style={{ fontSize: 13, fontWeight: 600 }}>{svc.name}</div>
<span style={{ padding: '1px 7px', borderRadius: 3, fontSize: 10, fontWeight: 800, background: `${sc}22`, color: sc, border: `1px solid ${sc}44`, fontFamily: 'monospace' }}>{(svc.status || 'ok').toUpperCase()}</span> {svc.detail && <div style={{ fontSize: 11, color: 'var(--text-muted)', fontFamily: 'monospace', marginTop: 1 }}>{svc.detail}</div>}
</div> </div>
<div style={{ fontSize: 11, color: 'var(--text-muted)', display: 'flex', alignItems: 'center', gap: 8, overflow: 'hidden' }}> <span className={`mon-chip ${svc.status}`}>{(svc.status || 'ok').toUpperCase()}</span>
<span style={{ fontWeight: 700, color: svc.status === 'crit' ? SC.crit : svc.status === 'warn' ? SC.warn : 'var(--text-secondary)', whiteSpace: 'nowrap' }}>{svc.value}</span>
{svc.detail && <span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{svc.detail}</span>}
</div> </div>
<div style={{ display: 'flex', alignItems: 'center' }}> {svc.perf ? (
{svc.perf && <PerfBar {...svc.perf} />} <div className="mon-perf">
<div className="mon-perf-track"><div className={`mon-perf-fill ${pSt}`} style={{ width: `${p}%` }} /></div>
<span className="mon-perf-val" style={{ color: sc }}>{svc.perf.labelVal ?? `${p.toFixed(0)}%`}</span>
</div> </div>
) : (
<div style={{ fontSize: 13, fontWeight: 600, color: sc, fontFamily: 'monospace' }}>{svc.value}</div>
)}
</div> </div>
); );
})} })}
</div> </div>
);
})}
{filtered.length === 0 && (
<div style={{ padding: 48, textAlign: 'center', color: SC.ok, fontSize: 15 }}>
{hostFilter === 'problems' ? 'Alle Services in Ordnung — keine Probleme' : 'Keine Hosts gefunden'}
</div> </div>
) : (
<div style={{ background: 'rgba(255,255,255,.04)', border: '1px solid rgba(255,255,255,.08)', borderRadius: 14, padding: 60, textAlign: 'center', color: 'var(--text-muted)' }}>Host aus der Liste wählen</div>
)} )}
</div> </div>
</> </>
@@ -1220,17 +1183,31 @@ const MonitoringPage = () => {
return 0; return 0;
}); });
const MonPerf = ({ val, max, warn = 80, crit = 90, labelVal = null }) => {
if (val == null || !max) return <span style={{ color: 'var(--text-muted)', fontSize: 11 }}></span>;
const p = Math.min((val / max) * 100, 100);
const st = p >= crit ? 'crit' : p >= warn ? 'warn' : 'ok';
const display = labelVal ?? `${p.toFixed(0)}%`;
return (
<div className="mon-perf">
<div className="mon-perf-track">
<div className={`mon-perf-fill ${st}`} style={{ width: `${p}%` }} />
</div>
<span className="mon-perf-val" style={{ color: SC[st] }}>{display}</span>
</div>
);
};
return ( return (
<> <>
<div style={{ display: 'flex', gap: 10, marginBottom: 16, flexWrap: 'wrap', alignItems: 'center' }}> <div style={{ display: 'flex', gap: 10, marginBottom: 16, flexWrap: 'wrap', alignItems: 'center' }}>
<input value={agentSearch} onChange={e => setAgentSearch(e.target.value)} placeholder="Hostname / IP..." style={{ padding: '7px 12px', borderRadius: 7, border: '1px solid var(--border-color)', background: 'var(--bg-secondary)', color: 'var(--text-primary)', fontSize: 13, width: 220 }} /> <input value={agentSearch} onChange={e => setAgentSearch(e.target.value)} placeholder="Hostname / IP suchen…" style={{ padding: '8px 12px', borderRadius: 10, border: '1px solid rgba(255,255,255,.08)', background: 'rgba(255,255,255,.04)', color: 'var(--text-primary)', fontSize: 13, width: 230 }} />
<div style={{ display: 'flex', gap: 5 }}> <div style={{ display: 'flex', padding: 3, borderRadius: 11, background: 'rgba(255,255,255,.04)', border: '1px solid rgba(255,255,255,.08)', gap: 2 }}>
{pill(agentFilter === 'all', () => setAgentFilter('all'), 'Alle')} {[['all','Alle'],['online','Online'],['offline','Offline'],['warn','Warnung']].map(([v,l]) => (
{pill(agentFilter === 'online', () => setAgentFilter('online'), 'ONLINE')} <button key={v} onClick={() => setAgentFilter(v)} style={{ padding: '6px 14px', borderRadius: 8, border: 'none', background: agentFilter === v ? '#0d9488' : 'none', color: agentFilter === v ? '#fff' : 'var(--text-muted)', fontSize: 12.5, fontWeight: agentFilter === v ? 650 : 500, cursor: 'pointer', boxShadow: agentFilter === v ? '0 4px 12px -4px rgba(13,148,136,.45)' : 'none', transition: '.14s' }}>{l}</button>
{pill(agentFilter === 'offline', () => setAgentFilter('offline'), 'OFFLINE')} ))}
{pill(agentFilter === 'warn', () => setAgentFilter('warn'), 'WARNUNG')}
</div> </div>
<select value={agentSort} onChange={e => setAgentSort(e.target.value)} style={{ padding: '5px 10px', borderRadius: 7, border: '1px solid var(--border-color)', background: 'var(--bg-secondary)', color: 'var(--text-primary)', fontSize: 12, cursor: 'pointer' }}> <select value={agentSort} onChange={e => setAgentSort(e.target.value)} style={{ padding: '8px 12px', borderRadius: 10, border: '1px solid rgba(255,255,255,.08)', background: 'rgba(255,255,255,.04)', color: 'var(--text-primary)', fontSize: 12.5, outline: 'none' }}>
<option value="cpu">Sortierung: CPU-Auslastung</option> <option value="cpu">Sortierung: CPU-Auslastung</option>
<option value="ram">Sortierung: RAM-Auslastung</option> <option value="ram">Sortierung: RAM-Auslastung</option>
<option value="name">Sortierung: Name</option> <option value="name">Sortierung: Name</option>
@@ -1242,15 +1219,20 @@ const MonitoringPage = () => {
{sorted.length === 0 {sorted.length === 0
? <div style={{ textAlign: 'center', padding: 40, color: 'var(--text-muted)' }}>Keine Agents gefunden</div> ? <div style={{ textAlign: 'center', padding: 40, color: 'var(--text-muted)' }}>Keine Agents gefunden</div>
: ( : (
/* Checkmk-style service table */ <div style={{ background: 'rgba(255,255,255,.04)', border: '1px solid rgba(255,255,255,.08)', borderRadius: 14, overflow: 'hidden', backdropFilter: 'blur(10px)' }}>
<div style={card({ padding: 0, overflow: 'hidden' })}> <div className="mon-table-wrap">
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 12 }}> <table className="mon-table">
<thead> <thead>
<tr style={{ borderBottom: '1px solid var(--border-color)', background: 'var(--bg-primary)' }}> <tr>
<th style={{ width: 28 }} /> <th></th>
{['Host', 'Status', 'CPU', 'RAM', 'Disk', 'Uptime', 'Letzter Checkin', ''].map(h => ( <th>Host</th>
<th key={h} style={{ padding: '7px 12px', textAlign: 'left', fontSize: 10, fontWeight: 700, color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: .4 }}>{h}</th> <th>Status</th>
))} <th style={{ minWidth: 150 }}>CPU</th>
<th style={{ minWidth: 150 }}>RAM</th>
<th style={{ minWidth: 150 }}>Disk</th>
<th>Uptime</th>
<th>Letzter Checkin</th>
<th></th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -1261,45 +1243,28 @@ const MonitoringPage = () => {
const services = agentServices(a); const services = agentServices(a);
return ( return (
<React.Fragment key={a.id}> <React.Fragment key={a.id}>
{/* Agent row */} <tr className={`lrow-${state}`} style={{ cursor: 'pointer' }} onClick={() => toggleAgent(a.id)}>
<tr style={{ borderBottom: isExp ? 'none' : '1px solid var(--border-color)', background: rowBg(state), borderLeft: `3px solid ${rowBorder(state)}`, cursor: 'pointer' }} <td style={{ width: 28, textAlign: 'center', color: 'var(--text-muted)', fontSize: 11 }}>{isExp ? '▼' : '▶'}</td>
onClick={() => toggleAgent(a.id)}> <td style={{ fontWeight: 700, fontFamily: 'monospace' }}>
<td style={{ padding: '8px 6px', textAlign: 'center', color: 'var(--text-muted)', fontSize: 11 }}>{isExp ? '▼' : '▶'}</td> <Link to={`/monitoring/device/${a.id}`} onClick={e => e.stopPropagation()} style={{ color: 'inherit', textDecoration: 'none', borderBottom: '1px dotted rgba(255,255,255,.3)' }}>{a.hostname}</Link>
<td style={{ padding: '8px 12px', fontWeight: 700, whiteSpace: 'nowrap', fontFamily: 'monospace' }}>
<Link to={`/monitoring/device/${a.id}`} onClick={e => e.stopPropagation()} style={{ color: 'inherit', textDecoration: 'none', borderBottom: '1px dotted var(--text-muted)' }} title="Geräte-Details öffnen">
{a.hostname}
</Link>
</td> </td>
<td style={{ padding: '8px 12px' }}><StateSquare state={state} text={state.toUpperCase()} /></td> <td><span className={`mon-chip ${state}`}>{state.toUpperCase()}</span></td>
<td style={{ padding: '8px 12px', minWidth: 120 }}> <td><MonPerf val={a.cpu_usage_percent} max={100} warn={80} crit={90} /></td>
{a.cpu_usage_percent != null <td><MonPerf val={a.ram_used_gb} max={a.ram_total_gb} warn={85} crit={95} labelVal={a.ram_total_gb > 0 ? `${a.ram_used_gb?.toFixed(1)} / ${a.ram_total_gb?.toFixed(0)} GB` : null} /></td>
? <PerfBar val={a.cpu_usage_percent} max={100} warn={80} crit={90} /> <td><MonPerf val={a.disk_total_gb - a.disk_free_gb} max={a.disk_total_gb} warn={75} crit={90} labelVal={a.disk_total_gb > 0 ? `${a.disk_free_gb?.toFixed(0)} GB frei` : null} /></td>
: <span style={{ color: 'var(--text-muted)' }}></span>} <td style={{ fontFamily: 'monospace', fontSize: 12, color: 'var(--text-muted)' }}>{uptimeStr(a.uptime_hours)}</td>
</td> <td style={{ fontSize: 12, color: 'var(--text-muted)', whiteSpace: 'nowrap' }}>{timeAgo(a.last_checkin)}</td>
<td style={{ padding: '8px 12px', minWidth: 120 }}> <td onClick={e => e.stopPropagation()}>
{a.ram_total_gb > 0 <button onClick={() => setSelectedAgent(a)} style={{ padding: '5px 10px', borderRadius: 8, border: '1px solid rgba(255,255,255,.08)', background: 'rgba(255,255,255,.04)', color: 'var(--text-muted)', cursor: 'pointer', fontSize: 11.5, fontWeight: 550 }}>Details</button>
? <PerfBar val={a.ram_used_gb} max={a.ram_total_gb} warn={85} crit={95} />
: <span style={{ color: 'var(--text-muted)' }}></span>}
</td>
<td style={{ padding: '8px 12px', minWidth: 120 }}>
{a.disk_total_gb > 0
? <PerfBar val={a.disk_total_gb - a.disk_free_gb} max={a.disk_total_gb} warn={75} crit={90} labelVal={`${a.disk_free_gb?.toFixed(0)} GB`} />
: <span style={{ color: 'var(--text-muted)' }}></span>}
</td>
<td style={{ padding: '8px 12px', color: 'var(--text-muted)', fontFamily: 'monospace', fontSize: 11 }}>{uptimeStr(a.uptime_hours)}</td>
<td style={{ padding: '8px 12px', color: 'var(--text-muted)', fontSize: 11, whiteSpace: 'nowrap' }}>{timeAgo(a.last_checkin)}</td>
<td style={{ padding: '8px 12px' }} onClick={e => e.stopPropagation()}>
<button onClick={() => setSelectedAgent(a)} style={{ padding: '2px 9px', borderRadius: 4, border: '1px solid var(--border-color)', background: 'none', color: 'var(--text-muted)', cursor: 'pointer', fontSize: 11 }}>Details</button>
</td> </td>
</tr> </tr>
{/* Expanded: service checks (Checkmk-style sub-table) */}
{isExp && ( {isExp && (
<tr style={{ borderBottom: '1px solid var(--border-color)' }}> <tr style={{ borderBottom: '1px solid rgba(255,255,255,.06)' }}>
<td /> <td />
<td colSpan={8} style={{ padding: '0 0 0 16px', background: 'rgba(255,255,255,0.02)' }}> <td colSpan={8} style={{ padding: '0 0 0 16px', background: 'rgba(255,255,255,.02)' }}>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 12, marginBottom: 4 }}> <table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 12, marginBottom: 4 }}>
<thead> <thead>
<tr style={{ borderBottom: '1px solid var(--border-color)' }}> <tr style={{ borderBottom: '1px solid rgba(255,255,255,.06)' }}>
{['Status', 'Service', 'Perf-O-Meter', 'Wert', 'Details'].map(h => ( {['Status', 'Service', 'Perf-O-Meter', 'Wert', 'Details'].map(h => (
<th key={h} style={{ padding: '5px 10px', textAlign: 'left', fontSize: 10, fontWeight: 700, color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: .4 }}>{h}</th> <th key={h} style={{ padding: '5px 10px', textAlign: 'left', fontSize: 10, fontWeight: 700, color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: .4 }}>{h}</th>
))} ))}
@@ -1307,15 +1272,13 @@ const MonitoringPage = () => {
</thead> </thead>
<tbody> <tbody>
{services.map((svc, si) => ( {services.map((svc, si) => (
<tr key={si} style={{ borderBottom: '1px solid var(--border-color)', background: rowBg(svc.status) }}> <tr key={si} style={{ borderBottom: '1px solid rgba(255,255,255,.04)' }}>
<td style={{ padding: '6px 10px', borderLeft: `3px solid ${rowBorder(svc.status)}` }}> <td style={{ padding: '6px 10px', borderLeft: `3px solid ${SC[svc.status]}` }}>
<StateSquare state={svc.status} /> <span className={`mon-chip ${svc.status}`}>{svc.status.toUpperCase()}</span>
</td> </td>
<td style={{ padding: '6px 10px', fontWeight: 600 }}>{svc.name}</td> <td style={{ padding: '6px 10px', fontWeight: 600 }}>{svc.name}</td>
<td style={{ padding: '6px 10px', minWidth: 160 }}> <td style={{ padding: '6px 10px', minWidth: 160 }}>
{svc.perf {svc.perf ? <MonPerf val={svc.perf.val} max={svc.perf.max} warn={svc.perf.warn} crit={svc.perf.crit} labelVal={svc.perf.labelVal} /> : <span style={{ color: 'var(--text-muted)' }}></span>}
? <PerfBar val={svc.perf.val} max={svc.perf.max} warn={svc.perf.warn} crit={svc.perf.crit} unit={svc.perf.unit} labelVal={svc.perf.labelVal} />
: <span style={{ color: 'var(--text-muted)', fontSize: 11 }}></span>}
</td> </td>
<td style={{ padding: '6px 10px', fontFamily: 'monospace', fontSize: 11, color: SC[svc.status], fontWeight: 700 }}>{svc.value}</td> <td style={{ padding: '6px 10px', fontFamily: 'monospace', fontSize: 11, color: SC[svc.status], fontWeight: 700 }}>{svc.value}</td>
<td style={{ padding: '6px 10px', color: 'var(--text-muted)', fontSize: 11 }}>{svc.detail}</td> <td style={{ padding: '6px 10px', color: 'var(--text-muted)', fontSize: 11 }}>{svc.detail}</td>
@@ -1324,8 +1287,8 @@ const MonitoringPage = () => {
</tbody> </tbody>
</table> </table>
<div style={{ display: 'flex', gap: 8, padding: '8px 0 10px' }}> <div style={{ display: 'flex', gap: 8, padding: '8px 0 10px' }}>
<button onClick={() => setSelectedAgent(a)} style={{ padding: '4px 12px', borderRadius: 5, border: '1px solid var(--border-color)', background: 'none', color: 'var(--text-muted)', cursor: 'pointer', fontSize: 11 }}>Software &amp; Details</button> <button onClick={() => setSelectedAgent(a)} style={{ padding: '4px 12px', borderRadius: 8, border: '1px solid rgba(255,255,255,.08)', background: 'none', color: 'var(--text-muted)', cursor: 'pointer', fontSize: 11 }}>Software &amp; Details</button>
<button onClick={() => handleAgentDelete(a.id, a.hostname)} style={{ padding: '4px 12px', borderRadius: 5, border: `1px solid ${SC.crit}44`, background: 'none', color: SC.crit, cursor: 'pointer', fontSize: 11 }}>Entfernen</button> <button onClick={() => handleAgentDelete(a.id, a.hostname)} style={{ padding: '4px 12px', borderRadius: 8, border: `1px solid ${SC.crit}44`, background: 'none', color: SC.crit, cursor: 'pointer', fontSize: 11 }}>Entfernen</button>
</div> </div>
</td> </td>
</tr> </tr>
@@ -1336,6 +1299,7 @@ const MonitoringPage = () => {
</tbody> </tbody>
</table> </table>
</div> </div>
</div>
) )
} }
</> </>
@@ -1345,94 +1309,92 @@ const MonitoringPage = () => {
// ─── Tab: Probleme ──────────────────────────────────────────────────────── // ─── Tab: Probleme ────────────────────────────────────────────────────────
const renderProblems = () => { const renderProblems = () => {
const visible = showAcked ? problems : activeProblems; const visible = showAcked ? problems : activeProblems;
const renderTable = (rows) => (
<div style={card({ padding: 0, overflow: 'hidden' })}> const SevGroup = ({ severity, label, color, rows }) => {
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 12 }}> const items = rows.filter(p => (severity === 'crit' ? p.severity <= 1 : p.severity === 2));
<thead> if (!items.length) return null;
<tr style={{ borderBottom: '1px solid var(--border-color)', background: 'var(--bg-primary)' }}>
{['Schwere', 'Status', 'Host', 'Service', 'Seit', 'Bestätigen'].map(h => (
<th key={h} style={{ padding: '8px 14px', textAlign: 'left', fontSize: 10, fontWeight: 700, color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: .4 }}>{h}</th>
))}
</tr>
</thead>
<tbody>
{rows.map(p => {
const isAck = acknowledged.has(p.id);
const state = p.severity <= 1 ? 'crit' : 'warn';
return ( return (
<tr key={p.id} style={{ borderBottom: '1px solid var(--border-color)', background: isAck ? 'transparent' : rowBg(state), borderLeft: `3px solid ${isAck ? 'transparent' : rowBorder(state)}`, opacity: isAck ? 0.5 : 1 }}> <div className="mon-sev">
<td style={{ padding: '9px 14px' }}> <div className="mon-sev-head">
<StateSquare state={state} text={state.toUpperCase()} size="lg" /> <span className="mon-sev-dot" style={{ background: color, boxShadow: `0 0 10px ${color}` }} />
</td> <span className="mon-sev-name" style={{ color }}>{label}</span>
<td style={{ padding: '9px 14px' }}> <span className="mon-sev-cnt" style={{ color, background: `${color}22`, border: `1px solid ${color}55` }}>{items.length}</span>
<StateSquare state={p.severity <= 1 ? 'crit' : 'warn'} text={p.statusLabel} /> <span className="mon-sev-line" />
{isAck && <span style={{ marginLeft: 6, fontSize: 10, fontWeight: 700, color: SC.unknown, background: `${SC.unknown}20`, padding: '1px 6px', borderRadius: 3, border: `1px solid ${SC.unknown}44` }}>ACK</span>} </div>
</td> <div style={{ background: 'rgba(255,255,255,.04)', border: '1px solid rgba(255,255,255,.08)', borderRadius: 14, overflow: 'hidden' }}>
<td style={{ padding: '9px 14px' }}> {items.map(p => {
<div style={{ display: 'flex', alignItems: 'center', gap: 7 }}> const isAck = acknowledged.has(p.id);
<span style={{ fontSize: 15 }}>{p.typeIcon}</span> return (
<div> <div key={p.id} className="mon-prob" style={{ opacity: isAck ? 0.5 : 1 }}>
<div style={{ fontWeight: 700, fontFamily: 'monospace' }}>{p.name}</div> <div><span className={`mon-chip ${p.severity <= 1 ? 'crit' : 'warn'} lg`}>{p.statusLabel}</span></div>
{p.raw?.host && <div style={{ fontSize: 10, color: 'var(--text-muted)' }}>{p.raw.host}</div>} <div className="mon-prob-host">
<div className="mon-prob-icn" style={{ color }}>{p.typeIcon}</div>
<div style={{ minWidth: 0 }}>
<div className="mon-prob-name">{p.name}</div>
<div className="mon-prob-svc">{p.service}{p.raw?.host ? ` · ${p.raw.host}` : ''}</div>
</div> </div>
</div> </div>
</td> <div className="mon-prob-since">
<td style={{ padding: '9px 14px', color: 'var(--text-muted)' }}>{p.service}</td> <div className="mon-prob-since-lbl">seit</div>
<td style={{ padding: '9px 14px', fontFamily: 'monospace', color: 'var(--text-muted)', whiteSpace: 'nowrap', fontSize: 11 }}>{duration(p.lastCheck)}</td> {duration(p.lastCheck)}
<td style={{ padding: '9px 14px' }}> </div>
{!isAck {!isAck
? <button onClick={() => setAcknowledged(prev => new Set([...prev, p.id]))} style={{ padding: '3px 12px', borderRadius: 5, border: '1px solid var(--border-color)', background: 'none', color: 'var(--text-muted)', cursor: 'pointer', fontSize: 11, fontWeight: 600 }}> Bestätigen</button> ? <button onClick={() => setAcknowledged(prev => new Set([...prev, p.id]))} style={{ padding: '5px 13px', borderRadius: 8, border: '1px solid rgba(255,255,255,.08)', background: 'rgba(255,255,255,.04)', color: 'var(--text-muted)', cursor: 'pointer', fontSize: 12, fontWeight: 600, whiteSpace: 'nowrap' }}> Bestätigen</button>
: <button onClick={() => p.isDevice ? setSelectedDevice(p.raw) : setSelectedAgent(p.raw)} style={{ padding: '3px 12px', borderRadius: 5, border: '1px solid var(--border-color)', background: 'none', color: 'var(--text-muted)', cursor: 'pointer', fontSize: 11 }}>Details</button> : <button onClick={() => p.isDevice ? setSelectedDevice(p.raw) : setSelectedAgent(p.raw)} style={{ padding: '5px 13px', borderRadius: 8, border: '1px solid rgba(255,255,255,.08)', background: 'rgba(255,255,255,.04)', color: 'var(--text-muted)', cursor: 'pointer', fontSize: 12 }}>Details</button>
} }
</td> </div>
</tr>
); );
})} })}
</tbody> </div>
</table>
</div> </div>
); );
};
return ( return (
<> <>
<div style={{ display: 'flex', alignItems: 'center', gap: 16, marginBottom: 14 }}> <div style={{ display: 'flex', alignItems: 'center', gap: 16, marginBottom: 18 }}>
<label style={{ display: 'flex', alignItems: 'center', gap: 7, fontSize: 13, cursor: 'pointer', color: 'var(--text-muted)' }}> <label style={{ display: 'flex', alignItems: 'center', gap: 8, fontSize: 13, cursor: 'pointer', color: 'var(--text-muted)' }}>
<input type="checkbox" checked={showAcked} onChange={e => setShowAcked(e.target.checked)} style={{ accentColor: 'var(--accent)', width: 14, height: 14 }} /> <input type="checkbox" checked={showAcked} onChange={e => setShowAcked(e.target.checked)} style={{ accentColor: '#0d9488', width: 15, height: 15 }} />
Bestätigte anzeigen Bestätigte anzeigen
</label> </label>
<span style={{ fontSize: 12, color: 'var(--text-muted)' }}>{activeProblems.length} aktiv · {inactiveProblems.length} langfristig offline · {acknowledged.size} bestätigt</span> <span style={{ fontSize: 12, color: 'var(--text-muted)' }}>{activeProblems.length} aktiv · {inactiveProblems.length} langfristig offline · {acknowledged.size} bestätigt</span>
</div> </div>
{visible.length === 0 && inactiveProblems.length === 0 {visible.length === 0 && inactiveProblems.length === 0 ? (
? ( <div style={{ textAlign: 'center', padding: '60px 20px', background: 'rgba(52,211,153,.06)', borderRadius: 14, border: `1px solid ${SC.ok}30` }}>
<div style={{ textAlign: 'center', padding: '60px 20px', background: 'var(--bg-secondary)', borderRadius: 10, border: `1px solid ${SC.ok}30` }}>
<div style={{ fontSize: 40, marginBottom: 10 }}></div> <div style={{ fontSize: 40, marginBottom: 10 }}></div>
<div style={{ fontSize: 16, fontWeight: 700, color: SC.ok }}>Keine offenen Probleme</div> <div style={{ fontSize: 16, fontWeight: 700, color: SC.ok }}>Keine offenen Probleme</div>
<div style={{ fontSize: 13, color: 'var(--text-muted)', marginTop: 6 }}>Alle {devices.length + agents.length} Hosts sind in Ordnung</div> <div style={{ fontSize: 13, color: 'var(--text-muted)', marginTop: 6 }}>Alle {devices.length + agents.length} Hosts sind in Ordnung</div>
</div> </div>
) ) : (
: (
<> <>
{visible.length > 0 && renderTable(visible)} <SevGroup severity="crit" label="Kritisch" color={SC.crit} rows={visible} />
<SevGroup severity="warn" label="Warnung" color={SC.warn} rows={visible} />
{inactiveProblems.length > 0 && ( {inactiveProblems.length > 0 && (
<div style={{ marginTop: 16 }}> <div className="mon-sev">
<button onClick={() => setCollapsedGroups(prev => { const n = new Set(prev); n.has('inactive') ? n.delete('inactive') : n.add('inactive'); return n; })} style={{ display: 'flex', alignItems: 'center', gap: 8, width: '100%', padding: '8px 14px', background: 'var(--bg-secondary)', border: '1px solid var(--border-color)', borderRadius: 8, cursor: 'pointer', fontSize: 12, color: 'var(--text-muted)', textAlign: 'left' }}> <div className="mon-sev-head">
<span style={{ fontSize: 10 }}>{collapsedGroups.has('inactive') ? '▶' : '▼'}</span> <span className="mon-sev-dot" style={{ background: SC.unknown }} />
<span style={{ fontWeight: 600 }}>Langfristig offline (&gt; {INACTIVE_DAYS} Tage)</span> <span className="mon-sev-name" style={{ color: SC.unknown }}>Langfristig offline</span>
<span style={{ fontSize: 10, padding: '1px 6px', borderRadius: 8, background: `${SC.unknown}22`, color: SC.unknown, border: `1px solid ${SC.unknown}44` }}>{inactiveProblems.length}</span> <span className="mon-sev-cnt" style={{ color: SC.unknown, background: `${SC.unknown}22` }}>{inactiveProblems.length}</span>
<span style={{ marginLeft: 'auto', fontSize: 11 }}>Geräte die längere Zeit nicht gesehen wurden</span> <span className="mon-sev-line" />
</button> </div>
{!collapsedGroups.has('inactive') && ( <div style={{ opacity: 0.6, background: 'rgba(255,255,255,.04)', border: '1px solid rgba(255,255,255,.08)', borderRadius: 14, overflow: 'hidden' }}>
<div style={{ marginTop: 6, opacity: 0.6 }}> {inactiveProblems.map(p => (
{renderTable(inactiveProblems)} <div key={p.id} className="mon-prob">
<div><span className="mon-chip unk lg">OFFLINE</span></div>
<div className="mon-prob-host">
<div className="mon-prob-icn">{p.typeIcon}</div>
<div><div className="mon-prob-name">{p.name}</div><div className="mon-prob-svc">{p.service}</div></div>
</div>
<div className="mon-prob-since"><div className="mon-prob-since-lbl">seit</div>{duration(p.lastCheck)}</div>
<button onClick={() => p.isDevice ? setSelectedDevice(p.raw) : setSelectedAgent(p.raw)} style={{ padding: '5px 13px', borderRadius: 8, border: '1px solid rgba(255,255,255,.08)', background: 'none', color: 'var(--text-muted)', cursor: 'pointer', fontSize: 12 }}>Details</button>
</div>
))}
</div> </div>
)}
</div> </div>
)} )}
</> </>
) )}
}
</> </>
); );
}; };
@@ -1967,20 +1929,31 @@ const MonitoringPage = () => {
::-webkit-scrollbar-thumb:hover { background:rgba(255,255,255,0.22); } ::-webkit-scrollbar-thumb:hover { background:rgba(255,255,255,0.22); }
`}</style> `}</style>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 18 }}> <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 20 }}>
<h1 style={{ fontSize: 22, fontWeight: 700, margin: 0 }}>📡 Monitoring</h1> <div>
<h1 style={{ fontSize: 22, fontWeight: 750, margin: 0, letterSpacing: -.3 }}>📡 Monitoring</h1>
<div style={{ fontSize: 12, color: 'var(--text-muted)', marginTop: 3 }}>{devices.length + agents.length} Hosts · Letzter Scan {lastUpdated}</div>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 9, padding: '7px 13px', borderRadius: 11, background: 'rgba(255,255,255,.04)', border: '1px solid rgba(255,255,255,.08)' }}>
<div style={{ position: 'relative', width: 16, height: 16 }}>
<svg width="16" height="16" viewBox="0 0 16 16" style={{ transform: 'rotate(-90deg)' }}>
<circle cx="8" cy="8" r="6.5" fill="none" stroke="rgba(255,255,255,.12)" strokeWidth="2" />
<circle cx="8" cy="8" r="6.5" fill="none" stroke={countdown <= 5 ? '#f59e0b' : '#14b8a6'} strokeWidth="2" strokeLinecap="round" strokeDasharray="40.8" strokeDashoffset={(40.8 * (1 - countdown / 30)).toFixed(1)} />
</svg>
</div>
<span style={{ fontFamily: 'monospace', fontSize: 13, fontWeight: 700, color: 'var(--text-primary)' }}>{countdown}s</span>
</div>
</div> </div>
{/* Checkmk-style Tactical Overview */}
<TacticalOverview devices={devices} agents={agents} problems={problems} acknowledged={acknowledged} countdown={countdown} lastUpdated={lastUpdated} onTabChange={setActiveTab} extAlerts={extAlerts} /> <TacticalOverview devices={devices} agents={agents} problems={problems} acknowledged={acknowledged} countdown={countdown} lastUpdated={lastUpdated} onTabChange={setActiveTab} extAlerts={extAlerts} />
{/* Tab bar */} {/* Tab bar — neues Design */}
<div style={{ display: 'flex', gap: 0, marginBottom: 20, borderBottom: '1px solid var(--border-color)' }}> <div style={{ display: 'flex', gap: 0, marginBottom: 22, borderBottom: '1px solid rgba(255,255,255,.08)' }}>
{tabs.map(({ id, label, badge, red }) => ( {tabs.map(({ id, label, badge, red }) => (
<button key={id} onClick={() => setActiveTab(id)} style={{ padding: '10px 22px', border: 'none', background: 'none', cursor: 'pointer', fontSize: 13, fontWeight: activeTab === id ? 700 : 400, color: activeTab === id ? 'var(--accent)' : 'var(--text-muted)', borderBottom: activeTab === id ? '2px solid var(--accent)' : '2px solid transparent', marginBottom: -1, transition: 'all .15s', display: 'flex', alignItems: 'center', gap: 7 }}> <button key={id} onClick={() => setActiveTab(id)} style={{ padding: '10px 20px', border: 'none', background: 'none', cursor: 'pointer', fontSize: 13, fontWeight: activeTab === id ? 700 : 500, color: activeTab === id ? '#14b8a6' : 'var(--text-muted)', borderBottom: activeTab === id ? '2px solid #14b8a6' : '2px solid transparent', marginBottom: -1, transition: 'all .14s', display: 'flex', alignItems: 'center', gap: 7, whiteSpace: 'nowrap' }}>
{label} {label}
{badge != null && badge > 0 && ( {badge != null && badge > 0 && (
<span style={{ fontSize: 10, fontWeight: 700, padding: '1px 6px', borderRadius: 10, background: red ? `${SC.crit}22` : 'var(--bg-secondary)', color: red ? SC.crit : 'var(--text-muted)', border: `1px solid ${red ? SC.crit + '44' : 'var(--border-color)'}` }}>{badge}</span> <span style={{ fontSize: 10, fontWeight: 700, padding: '1px 7px', borderRadius: 20, background: red ? 'rgba(244,63,94,.16)' : 'rgba(255,255,255,.06)', color: red ? '#fda4af' : 'var(--text-muted)', border: `1px solid ${red ? 'rgba(244,63,94,.3)' : 'rgba(255,255,255,.08)'}`, minWidth: 20, textAlign: 'center' }}>{badge}</span>
)} )}
</button> </button>
))} ))}

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.0.0'; const LATEST_AGENT_VERSION = '2.1.2';
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' };

View File

@@ -707,31 +707,66 @@ const EntraTab = ({ user }) => {
══════════════════════════════════════════════════════════════════ */ ══════════════════════════════════════════════════════════════════ */
const FidoTab = ({ user }) => { const FidoTab = ({ user }) => {
const [keys, setKeys] = useState([]); const [keys, setKeys] = useState([]);
const [allKeys, setAllKeys] = useState([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [showAssign, setShowAssign] = useState(false);
const [assignId, setAssignId] = useState('');
const [saving, setSaving] = useState(false);
useEffect(() => { const loadKeys = () => {
authFetch(`${API}/fido-keys?user_id=${user.id}`) setLoading(true);
.then(r => r.json()) Promise.all([
.then(d => setKeys(d.data || [])) authFetch(`${API}/fido-keys?user_id=${user.id}`).then(r => r.json()).then(d => d.data || []),
.catch(() => setKeys([])) authFetch(`${API}/fido-keys`).then(r => r.json()).then(d => d.data || []),
.finally(() => setLoading(false)); ]).then(([userKeys, all]) => {
}, [user.id]); setKeys(userKeys);
setAllKeys(all.filter(k => !k.assigned_to_user_id));
}).catch(() => {}).finally(() => setLoading(false));
};
useEffect(() => { loadKeys(); }, [user.id]);
const handleAssign = async () => {
if (!assignId) return;
setSaving(true);
try {
const key = allKeys.find(k => k.id === parseInt(assignId));
if (!key) return;
await authFetch(`${API}/fido-keys/${assignId}`, {
method: 'PUT',
body: JSON.stringify({ ...key, assigned_to_user_id: user.id }),
});
setShowAssign(false);
setAssignId('');
loadKeys();
} catch {
} finally { setSaving(false); }
};
const handleUnassign = async (key) => {
await authFetch(`${API}/fido-keys/${key.id}`, {
method: 'PUT',
body: JSON.stringify({ ...key, assigned_to_user_id: null }),
});
loadKeys();
};
if (loading) return <div style={{padding:40,textAlign:'center',color:'var(--text-muted)'}}>Lade FIDO-Keys</div>; if (loading) return <div style={{padding:40,textAlign:'center',color:'var(--text-muted)'}}>Lade FIDO-Keys</div>;
const primaryKey = keys.find(k => k.key_type === 'primary') || keys[0];
const otherKeys = keys.filter(k => k !== primaryKey);
const hasEnoughKeys = keys.length >= 2; const hasEnoughKeys = keys.length >= 2;
return ( return (
<div className="bv-tab-content"> <div className="bv-tab-content">
<div className="bv-fido-hero"> <div className="bv-fido-hero">
{keys.map((key, i) => ( {keys.map((key, i) => (
<div key={key.id} className="bv-fkc-card"> <div key={key.id} className="bv-fkc-card" style={{position:'relative'}}>
<button
onClick={() => handleUnassign(key)}
title="Zuweisung aufheben"
style={{position:'absolute',top:8,right:8,background:'rgba(239,68,68,.12)',border:'1px solid rgba(239,68,68,.3)',color:'#ef4444',borderRadius:6,padding:'2px 7px',fontSize:11,cursor:'pointer'}}
> Entfernen</button>
<div className="bv-fkc-header"> <div className="bv-fkc-header">
<div className="bv-fkc-visual"> <div className="bv-fkc-visual"><KeyIcon /></div>
<KeyIcon />
</div>
<span className="bv-fkc-type-badge">{i === 0 ? 'PRIMÄR' : 'BACKUP'}</span> <span className="bv-fkc-type-badge">{i === 0 ? 'PRIMÄR' : 'BACKUP'}</span>
</div> </div>
<div className="bv-fkc-name">{key.name}</div> <div className="bv-fkc-name">{key.name}</div>
@@ -748,11 +783,35 @@ const FidoTab = ({ user }) => {
</div> </div>
</div> </div>
))} ))}
<Link to="/fido-keys" className="bv-add-key-card"> {showAssign ? (
<div className="bv-fkc-card" style={{justifyContent:'center',gap:10}}>
<div style={{fontSize:13,fontWeight:600,marginBottom:4}}>Key zuweisen</div>
<select
className="form-select"
value={assignId}
onChange={e => setAssignId(e.target.value)}
style={{fontSize:13}}
>
<option value=""> Key wählen </option>
{allKeys.map(k => (
<option key={k.id} value={k.id}>{k.name} (SN: {k.serial_number})</option>
))}
</select>
<div style={{display:'flex',gap:8,marginTop:4}}>
<button className="btn btn-primary btn-small" onClick={handleAssign} disabled={!assignId || saving}>
{saving ? '…' : 'Zuweisen'}
</button>
<button className="btn btn-secondary btn-small" onClick={() => setShowAssign(false)}>Abbrechen</button>
</div>
{allKeys.length === 0 && <div style={{fontSize:11,color:'var(--text-muted)',textAlign:'center'}}>Keine freien Keys im Bestand</div>}
</div>
) : (
<div className="bv-add-key-card" onClick={() => setShowAssign(true)} style={{cursor:'pointer'}}>
<div className="bv-akc-plus"><PlusIcon /></div> <div className="bv-akc-plus"><PlusIcon /></div>
<div className="bv-akc-title">Weiteren Schlüssel registrieren</div> <div className="bv-akc-title">Key zuweisen</div>
<div style={{fontSize:11.5,color:'var(--text-muted)'}}>YubiKey, Titan, Feitian WebAuthn</div> <div style={{fontSize:11.5,color:'var(--text-muted)'}}>Key aus Bestand zuweisen</div>
</Link> </div>
)}
</div> </div>
<div className="bv-fido-bottom"> <div className="bv-fido-bottom">

View File

@@ -4133,3 +4133,65 @@ html[data-theme="light"] .bv-modal-select {
color: #0f172a; color: #0f172a;
border-color: #e2e8f0; border-color: #e2e8f0;
} }
/* ═══════════════════════════════════════════════════════════
MONITORING PAGE — New Design (mon-* prefix)
═══════════════════════════════════════════════════════════ */
.mon-chip{display:inline-flex;align-items:center;gap:5px;font-family:monospace;font-size:10.5px;font-weight:700;letter-spacing:.4px;padding:2px 8px;border-radius:7px;white-space:nowrap;}
.mon-chip::before{content:"";width:6px;height:6px;border-radius:50%;background:currentColor;box-shadow:0 0 7px currentColor;}
.mon-chip.ok{color:#34d399;background:rgba(52,211,153,.12);border:1px solid rgba(52,211,153,.3);}
.mon-chip.warn{color:#f59e0b;background:rgba(245,158,11,.12);border:1px solid rgba(245,158,11,.3);}
.mon-chip.crit{color:#f43f5e;background:rgba(244,63,94,.12);border:1px solid rgba(244,63,94,.3);}
.mon-chip.unk{color:#64748b;background:rgba(100,116,139,.14);border:1px solid rgba(100,116,139,.3);}
.mon-chip.lg{font-size:11.5px;padding:4px 11px;}
.mon-kpis{display:grid;grid-template-columns:repeat(4,1fr);gap:16px;margin-bottom:24px;}
@media(max-width:1100px){.mon-kpis{grid-template-columns:repeat(2,1fr);}}
.mon-kpi{padding:18px;position:relative;overflow:hidden;background:rgba(255,255,255,.04);border:1px solid rgba(255,255,255,.08);border-radius:14px;backdrop-filter:blur(10px);}
.mon-kpi-head{display:flex;align-items:center;gap:9px;margin-bottom:14px;}
.mon-kpi-ic{width:30px;height:30px;border-radius:9px;display:grid;place-items:center;background:rgba(255,255,255,.06);border:1px solid rgba(255,255,255,.08);}
.mon-kpi-name{font-size:12px;font-weight:650;color:var(--text-muted);letter-spacing:.2px;}
.mon-kpi-row{display:flex;align-items:flex-end;gap:14px;}
.mon-kpi-num{font-family:monospace;font-size:34px;font-weight:750;line-height:1;letter-spacing:-1px;}
.mon-kpi-den{font-size:13px;color:var(--text-muted);font-family:monospace;}
.mon-kpi-breaks{display:flex;gap:14px;margin-left:auto;}
.mon-kpi-break{text-align:right;}
.mon-kpi-break-num{font-family:monospace;font-size:15px;font-weight:700;line-height:1;}
.mon-kpi-break-lbl{font-size:9.5px;text-transform:uppercase;letter-spacing:.5px;color:var(--text-muted);margin-top:3px;}
.mon-kpi-bar{display:flex;height:5px;border-radius:6px;overflow:hidden;margin-top:14px;gap:2px;background:rgba(255,255,255,.05);}
.mon-kpi-bar span{height:100%;}
.mon-perf{display:flex;align-items:center;gap:10px;min-width:0;}
.mon-perf-track{position:relative;flex:1;height:7px;min-width:60px;border-radius:6px;background:rgba(255,255,255,.07);overflow:hidden;}
.mon-perf-fill{position:absolute;left:0;top:0;bottom:0;border-radius:6px;}
.mon-perf-fill.ok{background:linear-gradient(90deg,#0d9488,#34d399);}
.mon-perf-fill.warn{background:linear-gradient(90deg,#d97706,#fbbf24);}
.mon-perf-fill.crit{background:linear-gradient(90deg,#e11d48,#fb7185);}
.mon-perf-val{font-family:monospace;font-size:12px;font-weight:700;min-width:48px;text-align:right;}
.mon-table-wrap{overflow-x:auto;}
.mon-table{width:100%;border-collapse:collapse;}
.mon-table thead th{text-align:left;font-size:10px;font-weight:700;letter-spacing:.6px;text-transform:uppercase;color:var(--text-muted);padding:11px 14px;border-bottom:1px solid rgba(255,255,255,.08);white-space:nowrap;background:rgba(255,255,255,.015);}
.mon-table tbody td{padding:11px 14px;border-bottom:1px solid rgba(255,255,255,.06);vertical-align:middle;font-size:13px;}
.mon-table tbody tr:last-child td{border-bottom:none;}
.mon-table tbody tr{transition:background .12s;}
.mon-table tbody tr:hover{background:rgba(255,255,255,.03);}
.mon-table tr.lrow-crit td:first-child{border-left:3px solid #f43f5e;padding-left:11px;}
.mon-table tr.lrow-warn td:first-child{border-left:3px solid #f59e0b;padding-left:11px;}
.mon-sev{margin-bottom:20px;}
.mon-sev-head{display:flex;align-items:center;gap:11px;margin-bottom:11px;padding:0 2px;}
.mon-sev-dot{width:10px;height:10px;border-radius:50%;}
.mon-sev-name{font-size:14px;font-weight:700;letter-spacing:.2px;}
.mon-sev-cnt{font-family:monospace;font-size:12px;font-weight:700;padding:2px 9px;border-radius:20px;}
.mon-sev-line{flex:1;height:1px;background:rgba(255,255,255,.08);}
.mon-prob{display:grid;grid-template-columns:auto 1fr auto auto;gap:16px;align-items:center;padding:14px 18px;border-bottom:1px solid rgba(255,255,255,.06);transition:background .12s;}
.mon-prob:last-child{border-bottom:none;}
.mon-prob:hover{background:rgba(255,255,255,.03);}
.mon-prob-host{display:flex;align-items:center;gap:11px;min-width:0;}
.mon-prob-icn{width:34px;height:34px;border-radius:10px;display:grid;place-items:center;background:rgba(255,255,255,.06);border:1px solid rgba(255,255,255,.08);flex-shrink:0;font-size:18px;}
.mon-prob-name{font-weight:650;font-size:13.5px;font-family:monospace;}
.mon-prob-svc{font-size:11.5px;color:var(--text-muted);margin-top:1px;}
.mon-prob-since{font-family:monospace;font-size:12px;color:var(--text-secondary);text-align:right;}
.mon-prob-since-lbl{font-size:10px;color:var(--text-muted);text-transform:uppercase;letter-spacing:.4px;}