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:
@@ -6,7 +6,7 @@ namespace ITNexusAgent;
|
||||
|
||||
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 ConfigPath = @"C:\ProgramData\IT Nexus Agent\config.json";
|
||||
private const string StatusPath = @"C:\ProgramData\IT Nexus Agent\status.json";
|
||||
@@ -39,6 +39,10 @@ public class AgentWorker
|
||||
|
||||
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)
|
||||
{
|
||||
await RunCycleAsync();
|
||||
@@ -79,12 +83,16 @@ public class AgentWorker
|
||||
await executor.ExecuteAsync(cmd);
|
||||
}
|
||||
|
||||
// Ankündigungen anzeigen
|
||||
// Ankündigungen anzeigen + sofort ACKen damit Server sie nicht mehr schickt
|
||||
if (response.Announcements.Count > 0)
|
||||
{
|
||||
_notifier!.ShowAnnouncements(response.Announcements);
|
||||
foreach (var ann in response.Announcements)
|
||||
{
|
||||
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)
|
||||
|
||||
@@ -45,6 +45,7 @@ public class PatchCommand
|
||||
{
|
||||
[JsonProperty("id")] public int Id { get; set; }
|
||||
[JsonProperty("command")] public string Command { get; set; } = "";
|
||||
[JsonProperty("params")] public string? Params { get; set; }
|
||||
}
|
||||
|
||||
public class RunningCommand
|
||||
|
||||
@@ -39,15 +39,38 @@ internal class Program
|
||||
|
||||
static void RunNotification(string base64Json)
|
||||
{
|
||||
const string errorLog = @"C:\ProgramData\IT Nexus Agent\notify-error.log";
|
||||
try
|
||||
{
|
||||
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);
|
||||
if (ann == null) return;
|
||||
var app = new System.Windows.Application();
|
||||
app.Run(new NotificationWindow(ann));
|
||||
if (ann == null)
|
||||
{
|
||||
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()
|
||||
|
||||
@@ -27,6 +27,7 @@ public class CommandExecutor(ApiService api, string hostname, string dataDir, st
|
||||
"reboot" => await Reboot(cmd.Id),
|
||||
"upgrade_win11" => await UpgradeWin11(),
|
||||
"update_agent" => await UpdateAgent(),
|
||||
"shell_exec" => await ShellExec(cmd.Params ?? ""),
|
||||
_ => $"Unbekannter Command: {cmd.Command}"
|
||||
};
|
||||
}
|
||||
@@ -95,6 +96,33 @@ public class CommandExecutor(ApiService api, string hostname, string dataDir, st
|
||||
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()
|
||||
{
|
||||
var bytes = await _api.DownloadSetupAsync();
|
||||
|
||||
@@ -35,6 +35,11 @@ public class NotificationService
|
||||
return [];
|
||||
}
|
||||
|
||||
public void RemoveShownId(int id)
|
||||
{
|
||||
if (_shownIds.Remove(id)) SaveShownIds();
|
||||
}
|
||||
|
||||
private void SaveShownIds()
|
||||
{
|
||||
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)
|
||||
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}\" " +
|
||||
$"/sc ONCE /st {triggerTime} /ru \"{fullUser}\" /it /f";
|
||||
$"/sc ONCE /st {triggerTime} {ruArg} /it /f";
|
||||
var p = Process.Start(new ProcessStartInfo("schtasks.exe", args)
|
||||
{ CreateNoWindow = true, RedirectStandardError = true, UseShellExecute = false });
|
||||
p?.WaitForExit();
|
||||
|
||||
199
agent-cs/Services/ShellService.cs
Normal file
199
agent-cs/Services/ShellService.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -7,8 +7,8 @@
|
||||
WindowStartupLocation="CenterScreen"
|
||||
ResizeMode="NoResize"
|
||||
WindowStyle="None"
|
||||
AllowsTransparency="True"
|
||||
Background="Transparent"
|
||||
AllowsTransparency="False"
|
||||
Background="#1A1D2E"
|
||||
Topmost="True"
|
||||
FontFamily="Segoe UI">
|
||||
|
||||
@@ -41,12 +41,9 @@
|
||||
</Style>
|
||||
</Window.Resources>
|
||||
|
||||
<!-- Äußerer Rahmen mit Schatten + farbigem Top-Border -->
|
||||
<Border CornerRadius="12" Background="#1A1D2E"
|
||||
<!-- Äußerer Rahmen mit farbigem Top-Border -->
|
||||
<Border CornerRadius="0" Background="#1A1D2E"
|
||||
BorderBrush="#2D3250" BorderThickness="1">
|
||||
<Border.Effect>
|
||||
<DropShadowEffect Color="Black" BlurRadius="32" ShadowDepth="8" Opacity="0.7"/>
|
||||
</Border.Effect>
|
||||
|
||||
<Grid>
|
||||
<Grid.RowDefinitions>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{
|
||||
"server_url": "https://it-nexus.cereda-systems.de",
|
||||
"agent_key": "HIER-DEN-AGENT-KEY-EINTRAGEN"
|
||||
"agent_key": "itx-4CPJPTHmCfdrL9D62WacCATEvuvULXcp7ECMpSaNUjsS344F6_L4Ug"
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
#define MyAppName "IT Nexus Agent"
|
||||
#define MyAppVersion "2.0.0"
|
||||
#define MyAppVersion "2.1.2"
|
||||
#define MyAppPublisher "Cereda Systems GmbH"
|
||||
#define MyAppURL "https://it-nexus.cereda-systems.de"
|
||||
#define MyAppExeName "IT-Nexus-Agent.exe"
|
||||
@@ -40,7 +40,7 @@ CloseApplicationsFilter=IT-Nexus-Agent.exe
|
||||
Name: "german"; MessagesFile: "compiler:Languages\German.isl"
|
||||
|
||||
[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: "config.template.json"; DestDir: "{commonappdata}\IT Nexus Agent"; DestName: "config.json"; Flags: onlyifdoesntexist uninsneveruninstall
|
||||
|
||||
|
||||
2041
backend/package-lock.json
generated
2041
backend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -16,29 +16,30 @@
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@anthropic-ai/sdk": "^0.39.0",
|
||||
"bcrypt": "^5.1.1",
|
||||
"better-sqlite3": "^12.6.2",
|
||||
"botbuilder": "^4.23.3",
|
||||
"bwip-js": "^4.8.0",
|
||||
"imapflow": "^1.0.0",
|
||||
"node-cron": "^3.0.0",
|
||||
"nodemailer": "^6.9.0",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^16.3.1",
|
||||
"express": "^4.18.2",
|
||||
"express-rate-limit": "^7.1.5",
|
||||
"express-validator": "^7.0.1",
|
||||
"helmet": "^7.1.0",
|
||||
"imapflow": "^1.0.0",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"mailparser": "^3.7.1",
|
||||
"mammoth": "^1.8.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",
|
||||
"qrcode": "^1.5.4",
|
||||
"@anthropic-ai/sdk": "^0.39.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"
|
||||
"ws": "^8.21.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"nodemon": "^3.0.2"
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
const { getDatabase } = require('../config/database');
|
||||
const { pushAnnouncementToAgent, broadcastAnnouncement, getConnectedAgents } = require('../ws/shellServer');
|
||||
|
||||
// Hilfsfunktion: welche Ankündigungen sind für einen Agenten relevant (nach Gruppe)?
|
||||
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)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
`).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) => {
|
||||
|
||||
@@ -7,7 +7,10 @@ class FidoKeyController {
|
||||
* GET /api/fido-keys
|
||||
*/
|
||||
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({
|
||||
status: 'success',
|
||||
|
||||
@@ -273,11 +273,14 @@ const reportCommandResult = (req, res) => {
|
||||
if (!agentKey || agentKey !== process.env.AGENT_API_KEY) {
|
||||
return res.status(401).json({ error: 'Unauthorized' });
|
||||
}
|
||||
const { command_id, status, result } = req.body;
|
||||
if (!command_id) return res.status(400).json({ error: 'command_id erforderlich' });
|
||||
const command_id = req.body.command_id ?? req.body.CommandId;
|
||||
const { status, result } = req.body;
|
||||
if (command_id === undefined || command_id === null) return res.status(400).json({ error: 'command_id erforderlich' });
|
||||
const db = getDatabase();
|
||||
if (command_id !== 0) {
|
||||
db.prepare("UPDATE patch_commands SET status=?, result=?, completed_at=CURRENT_TIMESTAMP WHERE id=?")
|
||||
.run(status || 'done', result || null, command_id);
|
||||
}
|
||||
res.json({ success: true });
|
||||
};
|
||||
|
||||
@@ -293,6 +296,37 @@ const getPendingCommands = (agentId) => {
|
||||
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 = {
|
||||
getGroups, createGroup, updateGroup, deleteGroup, releaseVersionToGroup, releaseVersionToAll,
|
||||
upsertPolicy, deletePolicy,
|
||||
@@ -300,4 +334,5 @@ module.exports = {
|
||||
getOverview,
|
||||
triggerCommand, triggerGroupCommand, getCommands, reportCommandResult,
|
||||
getPendingCommands,
|
||||
triggerShell, getShellResult, getShellHistory,
|
||||
};
|
||||
|
||||
@@ -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 ('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)`,
|
||||
// Remote Shell — params Feld für patch_commands
|
||||
`ALTER TABLE patch_commands ADD COLUMN params TEXT`,
|
||||
];
|
||||
for (const migration of migrations) {
|
||||
try {
|
||||
|
||||
@@ -26,4 +26,9 @@ router.post('/commands/trigger', requireAdmin, ctrl.triggerCommand);
|
||||
router.post('/commands/trigger-group', requireAdmin, ctrl.triggerGroupCommand);
|
||||
// (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;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
const express = require('express');
|
||||
const http = require('http');
|
||||
const cors = require('cors');
|
||||
const helmet = require('helmet');
|
||||
const morgan = require('morgan');
|
||||
@@ -430,12 +431,17 @@ async function startServer() {
|
||||
}
|
||||
|
||||
// Start server
|
||||
app.listen(PORT, () => {
|
||||
const { setupWebSocketServer } = require('./ws/shellServer');
|
||||
const httpServer = http.createServer(app);
|
||||
setupWebSocketServer(httpServer);
|
||||
|
||||
httpServer.listen(PORT, () => {
|
||||
console.log('═══════════════════════════════════════════');
|
||||
console.log(`✅ Server running on http://localhost:${PORT}`);
|
||||
console.log(`📝 Environment: ${process.env.NODE_ENV || 'development'}`);
|
||||
console.log(`🌐 API: http://localhost:${PORT}/api`);
|
||||
console.log(`💚 Health: http://localhost:${PORT}/health`);
|
||||
console.log(`🔌 WebSocket: ws://localhost:${PORT}/ws`);
|
||||
console.log('═══════════════════════════════════════════');
|
||||
console.log('');
|
||||
console.log('Press CTRL+C to stop the server');
|
||||
|
||||
@@ -3,13 +3,14 @@ const AuditLog = require('../models/AuditLog');
|
||||
const { AppError } = require('../middleware/errorHandler');
|
||||
|
||||
class FidoKeyService {
|
||||
/**
|
||||
* Get all FIDO keys
|
||||
*/
|
||||
static getAllKeys() {
|
||||
return FidoKey.getAll();
|
||||
}
|
||||
|
||||
static getKeysByUser(userId) {
|
||||
return FidoKey.getByAssignedUser(userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get FIDO key by ID
|
||||
*/
|
||||
|
||||
157
backend/src/ws/shellServer.js
Normal file
157
backend/src/ws/shellServer.js
Normal 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 };
|
||||
@@ -93,6 +93,18 @@ server {
|
||||
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
|
||||
location /api/ {
|
||||
proxy_pass http://backend:5000/api/;
|
||||
|
||||
@@ -4,6 +4,7 @@ import monitoringService from '../services/monitoringService';
|
||||
import assetService from '../services/assetService';
|
||||
import api from '../services/api';
|
||||
import { toast } from 'react-toastify';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
|
||||
// ─── 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></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 ───────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function AgentDetailPage() {
|
||||
const { id, hostname } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const { isSuperAdmin, isAdmin } = useAuth();
|
||||
const [agent, setAgent] = useState(null);
|
||||
const [asset, setAsset] = useState(null);
|
||||
const [assignedUser, setAssignedUser] = useState(null);
|
||||
@@ -166,6 +469,7 @@ export default function AgentDetailPage() {
|
||||
const [annText, setAnnText] = useState('');
|
||||
const [showAnnModal, setShowAnnModal] = useState(false);
|
||||
const [patchHistory, setPatchHistory] = useState([]);
|
||||
const [shellTab, setShellTab] = useState('shell'); // 'shell' | 'rdp'
|
||||
const [countdown, setCountdown] = useState(60);
|
||||
const agentRef = useRef(null);
|
||||
const refreshTimer = useRef(null);
|
||||
@@ -617,6 +921,35 @@ export default function AgentDetailPage() {
|
||||
)}
|
||||
</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 ────────────────────────────────────────────── */}
|
||||
{showAnnModal && (
|
||||
<div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,.6)', zIndex: 1000, display: 'flex', alignItems: 'center', justifyContent: 'center' }}
|
||||
|
||||
@@ -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) => {
|
||||
if (!asset || !agents.length) return null;
|
||||
return agents.find(ag =>
|
||||
(asset.serial_number && ag.hardware_serial && ag.hardware_serial.toLowerCase() === asset.serial_number.toLowerCase()) ||
|
||||
(asset.ip_address && ag.ip_address && ag.ip_address === asset.ip_address) ||
|
||||
(asset.name && ag.hostname && ag.hostname.toLowerCase() === asset.name.toLowerCase())
|
||||
) || null;
|
||||
// Seriennummer ist eindeutig — erste Priorität
|
||||
if (asset.serial_number) {
|
||||
const bySerial = agents.find(ag => ag.hardware_serial && ag.hardware_serial.toLowerCase() === asset.serial_number.toLowerCase());
|
||||
if (bySerial) return bySerial;
|
||||
}
|
||||
// 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 ── */
|
||||
|
||||
@@ -4,10 +4,17 @@ import fidoKeyService from '../services/fidoKeyService';
|
||||
import LoadingSpinner from '../components/common/LoadingSpinner';
|
||||
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 { canModifyFidoKeys, isAdmin } = useAuth();
|
||||
const [keys, setKeys] = useState([]);
|
||||
const [filteredKeys, setFilteredKeys] = useState([]);
|
||||
const [users, setUsers] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [statusFilter, setStatusFilter] = useState('all');
|
||||
@@ -18,10 +25,15 @@ const FidoKeysPage = () => {
|
||||
serial_number: '',
|
||||
status: 'aktiv',
|
||||
description: '',
|
||||
assigned_to_user_id: '',
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
loadKeys();
|
||||
authFetch(`${API}/users`)
|
||||
.then(r => r.json())
|
||||
.then(d => setUsers(d.data || d || []))
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -41,32 +53,23 @@ const FidoKeysPage = () => {
|
||||
|
||||
const filterKeys = () => {
|
||||
let filtered = [...keys];
|
||||
|
||||
// Search filter
|
||||
if (searchTerm) {
|
||||
filtered = filtered.filter(
|
||||
(key) =>
|
||||
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') {
|
||||
filtered = filtered.filter((key) => key.status === statusFilter);
|
||||
}
|
||||
|
||||
setFilteredKeys(filtered);
|
||||
};
|
||||
|
||||
const handleCreate = () => {
|
||||
setEditingKey(null);
|
||||
setFormData({
|
||||
name: '',
|
||||
serial_number: '',
|
||||
status: 'aktiv',
|
||||
description: '',
|
||||
});
|
||||
setFormData({ name: '', serial_number: '', status: 'aktiv', description: '', assigned_to_user_id: '' });
|
||||
setShowModal(true);
|
||||
};
|
||||
|
||||
@@ -77,22 +80,25 @@ const FidoKeysPage = () => {
|
||||
serial_number: key.serial_number,
|
||||
status: key.status,
|
||||
description: key.description || '',
|
||||
assigned_to_user_id: key.assigned_to_user_id || '',
|
||||
});
|
||||
setShowModal(true);
|
||||
};
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
try {
|
||||
const payload = {
|
||||
...formData,
|
||||
assigned_to_user_id: formData.assigned_to_user_id ? parseInt(formData.assigned_to_user_id) : null,
|
||||
};
|
||||
if (editingKey) {
|
||||
await fidoKeyService.update(editingKey.id, formData);
|
||||
await fidoKeyService.update(editingKey.id, payload);
|
||||
toast.success('FIDO-Key erfolgreich aktualisiert');
|
||||
} else {
|
||||
await fidoKeyService.create(formData);
|
||||
await fidoKeyService.create(payload);
|
||||
toast.success('FIDO-Key erfolgreich erstellt');
|
||||
}
|
||||
|
||||
setShowModal(false);
|
||||
loadKeys();
|
||||
} catch (error) {
|
||||
@@ -101,10 +107,7 @@ const FidoKeysPage = () => {
|
||||
};
|
||||
|
||||
const handleDelete = async (id) => {
|
||||
if (!window.confirm('Möchten Sie diesen FIDO-Key wirklich löschen?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!window.confirm('Möchten Sie diesen FIDO-Key wirklich löschen?')) return;
|
||||
try {
|
||||
await fidoKeyService.delete(id);
|
||||
toast.success('FIDO-Key erfolgreich gelöscht');
|
||||
@@ -116,7 +119,6 @@ const FidoKeysPage = () => {
|
||||
|
||||
const handleStatusToggle = async (key) => {
|
||||
const newStatus = key.status === 'aktiv' ? 'inaktiv' : 'aktiv';
|
||||
|
||||
try {
|
||||
await fidoKeyService.updateStatus(key.id, newStatus);
|
||||
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) {
|
||||
return (
|
||||
<div className="main-content">
|
||||
@@ -146,44 +158,21 @@ const FidoKeysPage = () => {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Search and Filter */}
|
||||
<div className="search-container">
|
||||
<input
|
||||
type="text"
|
||||
className="search-input"
|
||||
placeholder="Suche nach Name oder Seriennummer..."
|
||||
placeholder="Suche nach Name, Seriennummer oder Benutzer..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
/>
|
||||
<div className="filter-buttons">
|
||||
<button
|
||||
className={`btn ${
|
||||
statusFilter === 'all' ? 'btn-primary' : 'btn-secondary'
|
||||
} 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>
|
||||
<button className={`btn ${statusFilter === 'all' ? 'btn-primary' : 'btn-secondary'} 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>
|
||||
|
||||
{/* Table */}
|
||||
<div className="card">
|
||||
<table className="table">
|
||||
<thead>
|
||||
@@ -191,6 +180,7 @@ const FidoKeysPage = () => {
|
||||
<th>Name</th>
|
||||
<th>Seriennummer</th>
|
||||
<th>Status</th>
|
||||
<th>Zugewiesen an</th>
|
||||
<th>Beschreibung</th>
|
||||
<th>Erstellt von</th>
|
||||
<th>Aktionen</th>
|
||||
@@ -199,19 +189,34 @@ const FidoKeysPage = () => {
|
||||
<tbody>
|
||||
{filteredKeys.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan="6" className="text-center">
|
||||
Keine FIDO-Keys gefunden
|
||||
</td>
|
||||
<td colSpan="7" className="text-center">Keine FIDO-Keys gefunden</td>
|
||||
</tr>
|
||||
) : (
|
||||
filteredKeys.map((key) => (
|
||||
<tr key={key.id}>
|
||||
<td>{key.name}</td>
|
||||
<td>{key.serial_number}</td>
|
||||
<td><code style={{fontSize:12}}>{key.serial_number}</code></td>
|
||||
<td>
|
||||
<span className={`status-badge status-${key.status}`}>
|
||||
{key.status}
|
||||
</span>
|
||||
<span className={`status-badge status-${key.status}`}>{key.status}</span>
|
||||
</td>
|
||||
<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>{key.description || '-'}</td>
|
||||
<td>{key.created_by_username}</td>
|
||||
@@ -219,29 +224,14 @@ const FidoKeysPage = () => {
|
||||
<div className="table-actions">
|
||||
{canModifyFidoKeys() && (
|
||||
<>
|
||||
<button
|
||||
onClick={() => handleEdit(key)}
|
||||
className="btn btn-primary btn-small"
|
||||
>
|
||||
Bearbeiten
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleStatusToggle(key)}
|
||||
className="btn btn-secondary btn-small"
|
||||
>
|
||||
{key.status === 'aktiv'
|
||||
? 'Deaktivieren'
|
||||
: 'Aktivieren'}
|
||||
<button onClick={() => handleEdit(key)} className="btn btn-primary btn-small">Bearbeiten</button>
|
||||
<button onClick={() => handleStatusToggle(key)} className="btn btn-secondary btn-small">
|
||||
{key.status === 'aktiv' ? 'Deaktivieren' : 'Aktivieren'}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{isAdmin() && (
|
||||
<button
|
||||
onClick={() => handleDelete(key.id)}
|
||||
className="btn btn-danger btn-small"
|
||||
>
|
||||
Löschen
|
||||
</button>
|
||||
<button onClick={() => handleDelete(key.id)} className="btn btn-danger btn-small">Löschen</button>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
@@ -252,93 +242,57 @@ const FidoKeysPage = () => {
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Modal */}
|
||||
{showModal && (
|
||||
<div className="modal-overlay" onClick={() => setShowModal(false)}>
|
||||
<div className="modal-content" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="modal-header">
|
||||
<h2 className="modal-title">
|
||||
{editingKey ? 'FIDO-Key bearbeiten' : 'Neuer FIDO-Key'}
|
||||
</h2>
|
||||
<button
|
||||
className="modal-close"
|
||||
onClick={() => setShowModal(false)}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
<h2 className="modal-title">{editingKey ? 'FIDO-Key bearbeiten' : 'Neuer FIDO-Key'}</h2>
|
||||
<button className="modal-close" onClick={() => setShowModal(false)}>×</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="form-group">
|
||||
<label className="form-label">Name*</label>
|
||||
<input
|
||||
type="text"
|
||||
className="form-input"
|
||||
value={formData.name}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, name: e.target.value })
|
||||
}
|
||||
required
|
||||
/>
|
||||
<input type="text" className="form-input" value={formData.name} onChange={(e) => setFormData({ ...formData, name: e.target.value })} required />
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label className="form-label">Seriennummer*</label>
|
||||
<input
|
||||
type="text"
|
||||
className="form-input"
|
||||
value={formData.serial_number}
|
||||
onChange={(e) =>
|
||||
setFormData({
|
||||
...formData,
|
||||
serial_number: e.target.value,
|
||||
})
|
||||
}
|
||||
required
|
||||
/>
|
||||
<input type="text" className="form-input" value={formData.serial_number} onChange={(e) => setFormData({ ...formData, serial_number: e.target.value })} required />
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label className="form-label">Status*</label>
|
||||
<select
|
||||
className="form-select"
|
||||
value={formData.status}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, status: e.target.value })
|
||||
}
|
||||
required
|
||||
>
|
||||
<select className="form-select" value={formData.status} onChange={(e) => setFormData({ ...formData, status: e.target.value })} required>
|
||||
<option value="aktiv">Aktiv</option>
|
||||
<option value="inaktiv">Inaktiv</option>
|
||||
</select>
|
||||
</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">
|
||||
<label className="form-label">Beschreibung</label>
|
||||
<textarea
|
||||
className="form-textarea"
|
||||
rows="3"
|
||||
value={formData.description}
|
||||
onChange={(e) =>
|
||||
setFormData({
|
||||
...formData,
|
||||
description: e.target.value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
<textarea className="form-textarea" rows="3" value={formData.description} onChange={(e) => setFormData({ ...formData, description: e.target.value })} />
|
||||
</div>
|
||||
|
||||
<div className="card-footer">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowModal(false)}
|
||||
className="btn btn-secondary"
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
<button type="submit" className="btn btn-primary">
|
||||
Speichern
|
||||
</button>
|
||||
<button type="button" onClick={() => setShowModal(false)} className="btn btn-secondary">Abbrechen</button>
|
||||
<button type="submit" className="btn btn-primary">Speichern</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@@ -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 openP = problems.filter(p => !acknowledged.has(p.id));
|
||||
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 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 pxAlerts = extAlerts.filter(a => a.source === 'proxmox' && !a.acknowledged);
|
||||
const pxCrit = pxAlerts.filter(a => a.severity === 'CRIT').length;
|
||||
const pxWarn = pxAlerts.filter(a => a.severity === 'WARN').length;
|
||||
const pxOk = pxCrit === 0 && pxWarn === 0;
|
||||
|
||||
const CountTile = ({ label, count, color, onClick, sub }) => {
|
||||
const active = count > 0;
|
||||
const W = 82, H = 72, cx = W / 2, cy = H / 2, r = 33;
|
||||
const pts = Array.from({ length: 6 }, (_, i) => {
|
||||
const a = (Math.PI / 3) * i;
|
||||
return `${(cx + r * Math.cos(a)).toFixed(1)},${(cy + r * Math.sin(a)).toFixed(1)}`;
|
||||
}).join(' ');
|
||||
return (
|
||||
<svg width={W} height={H} onClick={onClick} style={{ cursor: onClick ? 'pointer' : 'default', flexShrink: 0, transition: 'filter .15s' }}
|
||||
onMouseEnter={e => { if (active && onClick) e.currentTarget.style.filter = `drop-shadow(0 0 7px ${color}99)`; }}
|
||||
onMouseLeave={e => { e.currentTarget.style.filter = ''; }}>
|
||||
<polygon points={pts}
|
||||
fill={active ? `${color}20` : 'rgba(255,255,255,0.03)'}
|
||||
stroke={active ? color : 'rgba(255,255,255,0.1)'}
|
||||
strokeWidth={active ? 1.5 : 1} />
|
||||
<text x={cx} y={cy - (sub ? 8 : 3)} textAnchor="middle" dominantBaseline="middle"
|
||||
fill={active ? color : 'rgba(255,255,255,0.2)'}
|
||||
fontSize="21" fontWeight="800" fontFamily="monospace,'Courier New'">{count}</text>
|
||||
<text x={cx} y={cy + (sub ? 9 : 12)} textAnchor="middle" dominantBaseline="middle"
|
||||
fill={active ? color : 'rgba(255,255,255,0.2)'}
|
||||
fontSize="7.5" fontWeight="700" letterSpacing="0.8">{label}</text>
|
||||
{sub && <text x={cx} y={cy + 20} textAnchor="middle" dominantBaseline="middle"
|
||||
fill="rgba(255,255,255,0.3)" fontSize="6.5">{sub}</text>}
|
||||
</svg>
|
||||
const KpiCard = ({ icon, name, accent, num, den, breaks, bar, onClick }) => (
|
||||
<div className="mon-kpi" onClick={onClick} style={{ cursor: onClick ? 'pointer' : 'default' }}>
|
||||
<div className="mon-kpi-head">
|
||||
<div className="mon-kpi-ic" style={{ color: accent }}>{icon}</div>
|
||||
<div className="mon-kpi-name">{name}</div>
|
||||
</div>
|
||||
<div className="mon-kpi-row">
|
||||
<div style={{ display: 'flex', alignItems: 'baseline', gap: 6 }}>
|
||||
<span className="mon-kpi-num" style={{ color: accent }}>{num}</span>
|
||||
{den && <span className="mon-kpi-den">/ {den}</span>}
|
||||
</div>
|
||||
<div className="mon-kpi-breaks">
|
||||
{breaks.map((b, i) => (
|
||||
<div key={i} className="mon-kpi-break">
|
||||
<div className="mon-kpi-break-num" style={{ color: b[2] }}>{b[0]}</div>
|
||||
<div className="mon-kpi-break-lbl">{b[1]}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mon-kpi-bar">
|
||||
{bar.map((s, i) => <span key={i} style={{ width: `${s[0]}%`, background: s[1], height: '100%' }} />)}
|
||||
</div>
|
||||
</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 (
|
||||
<div style={{ background: 'var(--bg-secondary)', border: '1px solid var(--border-color)', borderRadius: 10, padding: '14px 20px', marginBottom: 20 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 0, flexWrap: 'wrap', rowGap: 10 }}>
|
||||
{/* Section: Zustand */}
|
||||
<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 }}>Gesamtzustand</span>
|
||||
<div style={{ display: 'flex', gap: 6 }}>
|
||||
<CountTile label="KRITISCH" count={critN} color={SC.crit} onClick={critN > 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} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Divider />
|
||||
|
||||
{/* Section: Netzwerk */}
|
||||
<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 }}>Netzwerk-Geräte</span>
|
||||
<div style={{ display: 'flex', gap: 6 }}>
|
||||
<CountTile label="UP" count={hostUp} color={SC.ok} sub={`von ${devices.length}`} />
|
||||
<CountTile label="DOWN" count={hostDown} color={SC.crit} onClick={hostDown > 0 ? () => onTabChange('network') : null} />
|
||||
<CountTile label="?" count={hostUnk} color={SC.unknown} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Divider />
|
||||
|
||||
{/* Section: Agents */}
|
||||
<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 }}>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 className="mon-kpis">
|
||||
<KpiCard
|
||||
icon="🖥️" name="Gesamtzustand" accent="#e8eef6"
|
||||
num={hostUp + agOnline - agWarn} den={null}
|
||||
breaks={[[critN, 'Krit', SC.crit], [warnN, 'Warn', SC.warn], [hostUp + agOnline - agWarn - critN, 'OK', SC.ok]]}
|
||||
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]]}
|
||||
onClick={critN > 0 || warnN > 0 ? () => onTabChange('problems') : null}
|
||||
/>
|
||||
<KpiCard
|
||||
icon="🌐" name="Netzwerk-Geräte" accent={SC.ok}
|
||||
num={hostUp} den={devices.length}
|
||||
breaks={[[hostDown, 'Down', SC.crit], [hostUnk, '?', SC.unknown]]}
|
||||
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}
|
||||
/>
|
||||
<KpiCard
|
||||
icon="⚡" name="Windows Agents" accent={SC.ok}
|
||||
num={agOnline} den={agents.length}
|
||||
breaks={[[agOffline, 'Off', SC.crit], [agWarn, 'Warn', SC.warn]]}
|
||||
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]]}
|
||||
onClick={agOffline > 0 || agWarn > 0 ? () => onTabChange('agents') : null}
|
||||
/>
|
||||
<KpiCard
|
||||
icon="🔧" name="Proxmox" accent={pxCrit > 0 ? SC.crit : pxWarn > 0 ? SC.warn : SC.ok}
|
||||
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)']]}
|
||||
bar={[[pxCrit > 0 ? 100 : pxWarn > 0 ? 60 : 0, pxCrit > 0 ? SC.crit : SC.warn], [pxCrit > 0 ? 0 : 100, SC.ok]]}
|
||||
onClick={pxCrit > 0 || pxWarn > 0 ? () => onTabChange('overview') : null}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -748,6 +717,7 @@ const MonitoringPage = () => {
|
||||
const [agentFilter, setAgentFilter] = useState('all');
|
||||
const [expandedAgents, setExpandedAgents] = useState(new Set());
|
||||
const [expandedHosts, setExpandedHosts] = useState(new Set());
|
||||
const [ovSelectedId, setOvSelectedId] = useState(null);
|
||||
const [agentSort, setAgentSort] = useState('cpu');
|
||||
const [hostFilter, setHostFilter] = useState('all');
|
||||
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 });
|
||||
|
||||
// ─── Tab: Übersicht ───────────────────────────────────────────────────────
|
||||
// ─── Tab: Übersicht — Master-Detail Layout ────────────────────────────────
|
||||
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 cpu = (proxmoxData.node.cpu || 0) * 100;
|
||||
const ram = proxmoxData.node.memory?.total > 0 ? (proxmoxData.node.memory.used / proxmoxData.node.memory.total) * 100 : 0;
|
||||
@@ -903,108 +871,103 @@ const MonitoringPage = () => {
|
||||
})() : 'unknown';
|
||||
|
||||
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 }] : []),
|
||||
...devices.map(d => {
|
||||
const ut = uptimeStats[d.id];
|
||||
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 };
|
||||
}),
|
||||
...(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 => ({ 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 })),
|
||||
...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 }; }),
|
||||
].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
|
||||
.filter(h => hostFilter === 'all' || h.state !== 'ok')
|
||||
.filter(h => !hostSearch || h.name.toLowerCase().includes(hostSearch.toLowerCase()) || (h.ip || '').includes(hostSearch));
|
||||
|
||||
const stateLabel = { ok: 'OK', warn: 'WARN', crit: 'CRIT', unknown: '?' };
|
||||
const stateColor = { ok: SC.ok, warn: SC.warn, crit: SC.crit, unknown: SC.unknown };
|
||||
const filtered = allHosts.filter(h => (hostFilter === 'all' || h.state !== 'ok') && (!hostSearch || h.name.toLowerCase().includes(hostSearch.toLowerCase()) || (h.ip || '').includes(hostSearch)));
|
||||
const selId = ovSelectedId ?? filtered[0]?.id;
|
||||
const selHost = filtered.find(h => h.id === selId) || filtered[0];
|
||||
const C = { ok: SC.ok, warn: SC.warn, crit: SC.crit, unknown: SC.unknown };
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Filter + Search */}
|
||||
<div style={{ display: 'flex', gap: 8, marginBottom: 14, alignItems: 'center', flexWrap: 'wrap' }}>
|
||||
{[['all', `Alle (${allHosts.length})`], ['problems', `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>
|
||||
<div style={{ display: 'flex', gap: 8, marginBottom: 16, alignItems: 'center' }}>
|
||||
<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', `Nur Probleme · ${allHosts.filter(h => h.state !== 'ok').length}`]].map(([v, l]) => (
|
||||
<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>
|
||||
<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: '5px 12px', borderRadius: 8, border: '1px solid var(--border-color)', background: 'var(--bg-secondary)', color: 'var(--text-primary)', fontSize: 13, minWidth: 200 }} />
|
||||
</div>
|
||||
<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 }} />
|
||||
</div>
|
||||
|
||||
{/* CheckMK Host Table */}
|
||||
<div style={{ border: '1px solid var(--border-color)', borderRadius: 10, overflow: 'hidden' }}>
|
||||
{/* Header */}
|
||||
<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 />
|
||||
<div>Host / IP</div>
|
||||
<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 style={{ display: 'grid', gridTemplateColumns: 'minmax(0,1.15fr) minmax(0,1fr)', gap: 18, alignItems: 'start' }}>
|
||||
{/* HOST LIST */}
|
||||
<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: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '14px 16px', borderBottom: '1px solid rgba(255,255,255,.08)' }}>
|
||||
<span style={{ fontSize: 11, fontWeight: 700, textTransform: 'uppercase', letterSpacing: .6, color: 'var(--text-muted)' }}>Hosts</span>
|
||||
<span style={{ fontSize: 12, color: 'var(--text-muted)' }}>{filtered.length} von {allHosts.length}</span>
|
||||
</div>
|
||||
|
||||
{filtered.map(host => {
|
||||
const expanded = expandedHosts.has(host.id);
|
||||
const okC = host.services.filter(s => s.status === 'ok').length;
|
||||
const warnC = host.services.filter(s => s.status === 'warn').length;
|
||||
const critC = host.services.filter(s => s.status === 'crit').length;
|
||||
const hColor = stateColor[host.state] || SC.unknown;
|
||||
<div style={{ maxHeight: 'calc(100vh - 420px)', overflowY: 'auto' }}>
|
||||
{filtered.length === 0 && <div style={{ padding: 40, textAlign: 'center', color: SC.ok }}>✅ Alle Services in Ordnung</div>}
|
||||
{filtered.map(h => {
|
||||
const okC = h.services.filter(s => s.status === 'ok').length;
|
||||
const wC = h.services.filter(s => s.status === 'warn').length;
|
||||
const cC = h.services.filter(s => s.status === 'crit').length;
|
||||
const sel = h.id === selId;
|
||||
return (
|
||||
<div key={host.id}>
|
||||
{/* Host row */}
|
||||
<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 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' }}>
|
||||
<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>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 7 }}>
|
||||
<span style={{ fontSize: 14 }}>{host.icon}</span>
|
||||
<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 style={{ fontWeight: 650, fontSize: 13.5 }}>{h.name}</div>
|
||||
<div style={{ fontSize: 11, color: 'var(--text-muted)', fontFamily: 'monospace', marginTop: 1 }}>{h.ip} · {h.sub}</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><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>
|
||||
|
||||
{/* Service rows */}
|
||||
{expanded && host.services.map((svc, i) => {
|
||||
const sc = stateColor[svc.status] || SC.unknown;
|
||||
{/* SERVICE DETAIL */}
|
||||
{selHost ? (
|
||||
<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 (
|
||||
<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 />
|
||||
<div style={{ fontSize: 12, color: 'var(--text-secondary)', fontWeight: 500, display: 'flex', alignItems: 'center' }}>{svc.name}</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center' }}>
|
||||
<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>
|
||||
<div key={i} style={{ padding: '14px 20px', borderBottom: '1px solid rgba(255,255,255,.06)' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, marginBottom: svc.perf ? 8 : 0 }}>
|
||||
<div>
|
||||
<div style={{ fontSize: 13, fontWeight: 600 }}>{svc.name}</div>
|
||||
{svc.detail && <div style={{ fontSize: 11, color: 'var(--text-muted)', fontFamily: 'monospace', marginTop: 1 }}>{svc.detail}</div>}
|
||||
</div>
|
||||
<div style={{ fontSize: 11, color: 'var(--text-muted)', display: 'flex', alignItems: 'center', gap: 8, overflow: 'hidden' }}>
|
||||
<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>}
|
||||
<span className={`mon-chip ${svc.status}`}>{(svc.status || 'ok').toUpperCase()}</span>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center' }}>
|
||||
{svc.perf && <PerfBar {...svc.perf} />}
|
||||
{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 style={{ fontSize: 13, fontWeight: 600, color: sc, fontFamily: 'monospace' }}>{svc.value}</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 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>
|
||||
</>
|
||||
@@ -1220,17 +1183,31 @@ const MonitoringPage = () => {
|
||||
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 (
|
||||
<>
|
||||
<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 }} />
|
||||
<div style={{ display: 'flex', gap: 5 }}>
|
||||
{pill(agentFilter === 'all', () => setAgentFilter('all'), 'Alle')}
|
||||
{pill(agentFilter === 'online', () => setAgentFilter('online'), 'ONLINE')}
|
||||
{pill(agentFilter === 'offline', () => setAgentFilter('offline'), 'OFFLINE')}
|
||||
{pill(agentFilter === 'warn', () => setAgentFilter('warn'), 'WARNUNG')}
|
||||
<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', padding: 3, borderRadius: 11, background: 'rgba(255,255,255,.04)', border: '1px solid rgba(255,255,255,.08)', gap: 2 }}>
|
||||
{[['all','Alle'],['online','Online'],['offline','Offline'],['warn','Warnung']].map(([v,l]) => (
|
||||
<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>
|
||||
))}
|
||||
</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="ram">Sortierung: RAM-Auslastung</option>
|
||||
<option value="name">Sortierung: Name</option>
|
||||
@@ -1242,15 +1219,20 @@ const MonitoringPage = () => {
|
||||
{sorted.length === 0
|
||||
? <div style={{ textAlign: 'center', padding: 40, color: 'var(--text-muted)' }}>Keine Agents gefunden</div>
|
||||
: (
|
||||
/* Checkmk-style service table */
|
||||
<div style={card({ padding: 0, overflow: 'hidden' })}>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 12 }}>
|
||||
<div style={{ background: 'rgba(255,255,255,.04)', border: '1px solid rgba(255,255,255,.08)', borderRadius: 14, overflow: 'hidden', backdropFilter: 'blur(10px)' }}>
|
||||
<div className="mon-table-wrap">
|
||||
<table className="mon-table">
|
||||
<thead>
|
||||
<tr style={{ borderBottom: '1px solid var(--border-color)', background: 'var(--bg-primary)' }}>
|
||||
<th style={{ width: 28 }} />
|
||||
{['Host', 'Status', 'CPU', 'RAM', 'Disk', 'Uptime', 'Letzter Checkin', ''].map(h => (
|
||||
<th key={h} style={{ padding: '7px 12px', textAlign: 'left', fontSize: 10, fontWeight: 700, color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: .4 }}>{h}</th>
|
||||
))}
|
||||
<tr>
|
||||
<th></th>
|
||||
<th>Host</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>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -1261,45 +1243,28 @@ const MonitoringPage = () => {
|
||||
const services = agentServices(a);
|
||||
return (
|
||||
<React.Fragment key={a.id}>
|
||||
{/* Agent row */}
|
||||
<tr style={{ borderBottom: isExp ? 'none' : '1px solid var(--border-color)', background: rowBg(state), borderLeft: `3px solid ${rowBorder(state)}`, cursor: 'pointer' }}
|
||||
onClick={() => toggleAgent(a.id)}>
|
||||
<td style={{ padding: '8px 6px', textAlign: 'center', color: 'var(--text-muted)', fontSize: 11 }}>{isExp ? '▼' : '▶'}</td>
|
||||
<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>
|
||||
<tr className={`lrow-${state}`} style={{ cursor: 'pointer' }} onClick={() => toggleAgent(a.id)}>
|
||||
<td style={{ width: 28, textAlign: 'center', color: 'var(--text-muted)', fontSize: 11 }}>{isExp ? '▼' : '▶'}</td>
|
||||
<td style={{ fontWeight: 700, fontFamily: 'monospace' }}>
|
||||
<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>
|
||||
<td style={{ padding: '8px 12px' }}><StateSquare state={state} text={state.toUpperCase()} /></td>
|
||||
<td style={{ padding: '8px 12px', minWidth: 120 }}>
|
||||
{a.cpu_usage_percent != null
|
||||
? <PerfBar val={a.cpu_usage_percent} max={100} warn={80} crit={90} />
|
||||
: <span style={{ color: 'var(--text-muted)' }}>–</span>}
|
||||
</td>
|
||||
<td style={{ padding: '8px 12px', minWidth: 120 }}>
|
||||
{a.ram_total_gb > 0
|
||||
? <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><span className={`mon-chip ${state}`}>{state.toUpperCase()}</span></td>
|
||||
<td><MonPerf val={a.cpu_usage_percent} max={100} warn={80} crit={90} /></td>
|
||||
<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>
|
||||
<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>
|
||||
<td style={{ fontFamily: 'monospace', fontSize: 12, color: 'var(--text-muted)' }}>{uptimeStr(a.uptime_hours)}</td>
|
||||
<td style={{ fontSize: 12, color: 'var(--text-muted)', whiteSpace: 'nowrap' }}>{timeAgo(a.last_checkin)}</td>
|
||||
<td onClick={e => e.stopPropagation()}>
|
||||
<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>
|
||||
</td>
|
||||
</tr>
|
||||
{/* Expanded: service checks (Checkmk-style sub-table) */}
|
||||
{isExp && (
|
||||
<tr style={{ borderBottom: '1px solid var(--border-color)' }}>
|
||||
<tr style={{ borderBottom: '1px solid rgba(255,255,255,.06)' }}>
|
||||
<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 }}>
|
||||
<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 => (
|
||||
<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>
|
||||
<tbody>
|
||||
{services.map((svc, si) => (
|
||||
<tr key={si} style={{ borderBottom: '1px solid var(--border-color)', background: rowBg(svc.status) }}>
|
||||
<td style={{ padding: '6px 10px', borderLeft: `3px solid ${rowBorder(svc.status)}` }}>
|
||||
<StateSquare state={svc.status} />
|
||||
<tr key={si} style={{ borderBottom: '1px solid rgba(255,255,255,.04)' }}>
|
||||
<td style={{ padding: '6px 10px', borderLeft: `3px solid ${SC[svc.status]}` }}>
|
||||
<span className={`mon-chip ${svc.status}`}>{svc.status.toUpperCase()}</span>
|
||||
</td>
|
||||
<td style={{ padding: '6px 10px', fontWeight: 600 }}>{svc.name}</td>
|
||||
<td style={{ padding: '6px 10px', minWidth: 160 }}>
|
||||
{svc.perf
|
||||
? <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>}
|
||||
{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>}
|
||||
</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>
|
||||
@@ -1324,8 +1287,8 @@ const MonitoringPage = () => {
|
||||
</tbody>
|
||||
</table>
|
||||
<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 & 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={() => 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 & Details</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>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -1336,6 +1299,7 @@ const MonitoringPage = () => {
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</>
|
||||
@@ -1345,94 +1309,92 @@ const MonitoringPage = () => {
|
||||
// ─── Tab: Probleme ────────────────────────────────────────────────────────
|
||||
const renderProblems = () => {
|
||||
const visible = showAcked ? problems : activeProblems;
|
||||
const renderTable = (rows) => (
|
||||
<div style={card({ padding: 0, overflow: 'hidden' })}>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 12 }}>
|
||||
<thead>
|
||||
<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';
|
||||
|
||||
const SevGroup = ({ severity, label, color, rows }) => {
|
||||
const items = rows.filter(p => (severity === 'crit' ? p.severity <= 1 : p.severity === 2));
|
||||
if (!items.length) return null;
|
||||
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 }}>
|
||||
<td style={{ padding: '9px 14px' }}>
|
||||
<StateSquare state={state} text={state.toUpperCase()} size="lg" />
|
||||
</td>
|
||||
<td style={{ padding: '9px 14px' }}>
|
||||
<StateSquare state={p.severity <= 1 ? 'crit' : 'warn'} text={p.statusLabel} />
|
||||
{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>}
|
||||
</td>
|
||||
<td style={{ padding: '9px 14px' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 7 }}>
|
||||
<span style={{ fontSize: 15 }}>{p.typeIcon}</span>
|
||||
<div>
|
||||
<div style={{ fontWeight: 700, fontFamily: 'monospace' }}>{p.name}</div>
|
||||
{p.raw?.host && <div style={{ fontSize: 10, color: 'var(--text-muted)' }}>{p.raw.host}</div>}
|
||||
<div className="mon-sev">
|
||||
<div className="mon-sev-head">
|
||||
<span className="mon-sev-dot" style={{ background: color, boxShadow: `0 0 10px ${color}` }} />
|
||||
<span className="mon-sev-name" style={{ color }}>{label}</span>
|
||||
<span className="mon-sev-cnt" style={{ color, background: `${color}22`, border: `1px solid ${color}55` }}>{items.length}</span>
|
||||
<span className="mon-sev-line" />
|
||||
</div>
|
||||
<div style={{ background: 'rgba(255,255,255,.04)', border: '1px solid rgba(255,255,255,.08)', borderRadius: 14, overflow: 'hidden' }}>
|
||||
{items.map(p => {
|
||||
const isAck = acknowledged.has(p.id);
|
||||
return (
|
||||
<div key={p.id} className="mon-prob" style={{ opacity: isAck ? 0.5 : 1 }}>
|
||||
<div><span className={`mon-chip ${p.severity <= 1 ? 'crit' : 'warn'} lg`}>{p.statusLabel}</span></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>
|
||||
</td>
|
||||
<td style={{ padding: '9px 14px', color: 'var(--text-muted)' }}>{p.service}</td>
|
||||
<td style={{ padding: '9px 14px', fontFamily: 'monospace', color: 'var(--text-muted)', whiteSpace: 'nowrap', fontSize: 11 }}>{duration(p.lastCheck)}</td>
|
||||
<td style={{ padding: '9px 14px' }}>
|
||||
<div className="mon-prob-since">
|
||||
<div className="mon-prob-since-lbl">seit</div>
|
||||
{duration(p.lastCheck)}
|
||||
</div>
|
||||
{!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={() => 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={() => 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: '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>
|
||||
</tr>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 16, marginBottom: 14 }}>
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: 7, 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 }} />
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 16, marginBottom: 18 }}>
|
||||
<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: '#0d9488', width: 15, height: 15 }} />
|
||||
Bestätigte anzeigen
|
||||
</label>
|
||||
<span style={{ fontSize: 12, color: 'var(--text-muted)' }}>{activeProblems.length} aktiv · {inactiveProblems.length} langfristig offline · {acknowledged.size} bestätigt</span>
|
||||
</div>
|
||||
|
||||
{visible.length === 0 && inactiveProblems.length === 0
|
||||
? (
|
||||
<div style={{ textAlign: 'center', padding: '60px 20px', background: 'var(--bg-secondary)', borderRadius: 10, border: `1px solid ${SC.ok}30` }}>
|
||||
{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={{ fontSize: 40, marginBottom: 10 }}>✓</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>
|
||||
)
|
||||
: (
|
||||
) : (
|
||||
<>
|
||||
{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 && (
|
||||
<div style={{ marginTop: 16 }}>
|
||||
<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' }}>
|
||||
<span style={{ fontSize: 10 }}>{collapsedGroups.has('inactive') ? '▶' : '▼'}</span>
|
||||
<span style={{ fontWeight: 600 }}>Langfristig offline (> {INACTIVE_DAYS} Tage)</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 style={{ marginLeft: 'auto', fontSize: 11 }}>Geräte die längere Zeit nicht gesehen wurden</span>
|
||||
</button>
|
||||
{!collapsedGroups.has('inactive') && (
|
||||
<div style={{ marginTop: 6, opacity: 0.6 }}>
|
||||
{renderTable(inactiveProblems)}
|
||||
<div className="mon-sev">
|
||||
<div className="mon-sev-head">
|
||||
<span className="mon-sev-dot" style={{ background: SC.unknown }} />
|
||||
<span className="mon-sev-name" style={{ color: SC.unknown }}>Langfristig offline</span>
|
||||
<span className="mon-sev-cnt" style={{ color: SC.unknown, background: `${SC.unknown}22` }}>{inactiveProblems.length}</span>
|
||||
<span className="mon-sev-line" />
|
||||
</div>
|
||||
<div style={{ opacity: 0.6, background: 'rgba(255,255,255,.04)', border: '1px solid rgba(255,255,255,.08)', borderRadius: 14, overflow: 'hidden' }}>
|
||||
{inactiveProblems.map(p => (
|
||||
<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>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -1967,20 +1929,31 @@ const MonitoringPage = () => {
|
||||
::-webkit-scrollbar-thumb:hover { background:rgba(255,255,255,0.22); }
|
||||
`}</style>
|
||||
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 18 }}>
|
||||
<h1 style={{ fontSize: 22, fontWeight: 700, margin: 0 }}>📡 Monitoring</h1>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 20 }}>
|
||||
<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>
|
||||
|
||||
{/* Checkmk-style Tactical Overview */}
|
||||
<TacticalOverview devices={devices} agents={agents} problems={problems} acknowledged={acknowledged} countdown={countdown} lastUpdated={lastUpdated} onTabChange={setActiveTab} extAlerts={extAlerts} />
|
||||
|
||||
{/* Tab bar */}
|
||||
<div style={{ display: 'flex', gap: 0, marginBottom: 20, borderBottom: '1px solid var(--border-color)' }}>
|
||||
{/* Tab bar — neues Design */}
|
||||
<div style={{ display: 'flex', gap: 0, marginBottom: 22, borderBottom: '1px solid rgba(255,255,255,.08)' }}>
|
||||
{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}
|
||||
{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>
|
||||
))}
|
||||
|
||||
@@ -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 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_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' };
|
||||
|
||||
@@ -707,31 +707,66 @@ const EntraTab = ({ user }) => {
|
||||
══════════════════════════════════════════════════════════════════ */
|
||||
const FidoTab = ({ user }) => {
|
||||
const [keys, setKeys] = useState([]);
|
||||
const [allKeys, setAllKeys] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showAssign, setShowAssign] = useState(false);
|
||||
const [assignId, setAssignId] = useState('');
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
authFetch(`${API}/fido-keys?user_id=${user.id}`)
|
||||
.then(r => r.json())
|
||||
.then(d => setKeys(d.data || []))
|
||||
.catch(() => setKeys([]))
|
||||
.finally(() => setLoading(false));
|
||||
}, [user.id]);
|
||||
const loadKeys = () => {
|
||||
setLoading(true);
|
||||
Promise.all([
|
||||
authFetch(`${API}/fido-keys?user_id=${user.id}`).then(r => r.json()).then(d => d.data || []),
|
||||
authFetch(`${API}/fido-keys`).then(r => r.json()).then(d => d.data || []),
|
||||
]).then(([userKeys, all]) => {
|
||||
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>;
|
||||
|
||||
const primaryKey = keys.find(k => k.key_type === 'primary') || keys[0];
|
||||
const otherKeys = keys.filter(k => k !== primaryKey);
|
||||
const hasEnoughKeys = keys.length >= 2;
|
||||
|
||||
return (
|
||||
<div className="bv-tab-content">
|
||||
<div className="bv-fido-hero">
|
||||
{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-visual">
|
||||
<KeyIcon />
|
||||
</div>
|
||||
<div className="bv-fkc-visual"><KeyIcon /></div>
|
||||
<span className="bv-fkc-type-badge">{i === 0 ? 'PRIMÄR' : 'BACKUP'}</span>
|
||||
</div>
|
||||
<div className="bv-fkc-name">{key.name}</div>
|
||||
@@ -748,11 +783,35 @@ const FidoTab = ({ user }) => {
|
||||
</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-title">Weiteren Schlüssel registrieren</div>
|
||||
<div style={{fontSize:11.5,color:'var(--text-muted)'}}>YubiKey, Titan, Feitian — WebAuthn</div>
|
||||
</Link>
|
||||
<div className="bv-akc-title">Key zuweisen</div>
|
||||
<div style={{fontSize:11.5,color:'var(--text-muted)'}}>Key aus Bestand zuweisen</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="bv-fido-bottom">
|
||||
|
||||
@@ -4133,3 +4133,65 @@ html[data-theme="light"] .bv-modal-select {
|
||||
color: #0f172a;
|
||||
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;}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user