feat: implement Phase 5 — Alerting & Monitoring
Backend: - AlertEvaluationService: evaluates metrics against AlertRules after each heartbeat - Supports cpu_usage_percent and memory_usage_percent metric paths - Operators: >, >=, <, <=, == - 15-minute dedup window to prevent alert spam - AlertRulesController: full CRUD for alert rules (GET/POST/PUT/DELETE) - AlertsController: list with acknowledged filter + POST acknowledge endpoint - IRmmHubClient: added AlertTriggered push method - Program.cs: AlertEvaluationService registered as Scoped Frontend: - AlertsPage: two-tab layout (active alerts + rules) - Alerts tab: severity badges, acknowledge button, all/unack/ack filter - Rules tab: condition display, enabled toggle, delete with confirm - Create rule modal with MetricPath/Operator/Threshold/Severity selects - api/types.ts: AlertRule, AlertItem, CreateAlertRuleRequest types - api/client.ts: alertRulesApi and alertsApi - useAgentSignalR: handles AlertTriggered → invalidates alerts query - App.tsx: Alerts nav item with Bell icon Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
84
Backend/src/NexusRMM.Api/Controllers/AlertRulesController.cs
Normal file
84
Backend/src/NexusRMM.Api/Controllers/AlertRulesController.cs
Normal file
@@ -0,0 +1,84 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using NexusRMM.Core.Models;
|
||||
using NexusRMM.Infrastructure.Data;
|
||||
|
||||
namespace NexusRMM.Api.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/v1/alert-rules")]
|
||||
public class AlertRulesController : ControllerBase
|
||||
{
|
||||
private readonly RmmDbContext _db;
|
||||
public AlertRulesController(RmmDbContext db) => _db = db;
|
||||
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> GetAll() =>
|
||||
Ok(await _db.AlertRules.OrderBy(r => r.Name).ToListAsync());
|
||||
|
||||
[HttpGet("{id:int}")]
|
||||
public async Task<IActionResult> GetById(int id)
|
||||
{
|
||||
var rule = await _db.AlertRules.FindAsync(id);
|
||||
return rule is null ? NotFound() : Ok(rule);
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
public async Task<IActionResult> Create([FromBody] CreateAlertRuleRequest req)
|
||||
{
|
||||
var rule = new AlertRule
|
||||
{
|
||||
Name = req.Name,
|
||||
MetricPath = req.MetricPath,
|
||||
Operator = req.Operator,
|
||||
Threshold = req.Threshold,
|
||||
Severity = req.Severity,
|
||||
Enabled = true,
|
||||
};
|
||||
_db.AlertRules.Add(rule);
|
||||
await _db.SaveChangesAsync();
|
||||
return CreatedAtAction(nameof(GetById), new { id = rule.Id }, rule);
|
||||
}
|
||||
|
||||
[HttpPut("{id:int}")]
|
||||
public async Task<IActionResult> Update(int id, [FromBody] UpdateAlertRuleRequest req)
|
||||
{
|
||||
var rule = await _db.AlertRules.FindAsync(id);
|
||||
if (rule is null) return NotFound();
|
||||
|
||||
if (req.Name is not null) rule.Name = req.Name;
|
||||
if (req.MetricPath is not null) rule.MetricPath = req.MetricPath;
|
||||
if (req.Operator is not null) rule.Operator = req.Operator;
|
||||
if (req.Threshold.HasValue) rule.Threshold = req.Threshold.Value;
|
||||
if (req.Severity.HasValue) rule.Severity = req.Severity.Value;
|
||||
if (req.Enabled.HasValue) rule.Enabled = req.Enabled.Value;
|
||||
|
||||
await _db.SaveChangesAsync();
|
||||
return Ok(rule);
|
||||
}
|
||||
|
||||
[HttpDelete("{id:int}")]
|
||||
public async Task<IActionResult> Delete(int id)
|
||||
{
|
||||
var rule = await _db.AlertRules.FindAsync(id);
|
||||
if (rule is null) return NotFound();
|
||||
_db.AlertRules.Remove(rule);
|
||||
await _db.SaveChangesAsync();
|
||||
return NoContent();
|
||||
}
|
||||
}
|
||||
|
||||
public record CreateAlertRuleRequest(
|
||||
string Name,
|
||||
string MetricPath,
|
||||
string Operator,
|
||||
double Threshold,
|
||||
AlertSeverity Severity);
|
||||
|
||||
public record UpdateAlertRuleRequest(
|
||||
string? Name,
|
||||
string? MetricPath,
|
||||
string? Operator,
|
||||
double? Threshold,
|
||||
AlertSeverity? Severity,
|
||||
bool? Enabled);
|
||||
50
Backend/src/NexusRMM.Api/Controllers/AlertsController.cs
Normal file
50
Backend/src/NexusRMM.Api/Controllers/AlertsController.cs
Normal file
@@ -0,0 +1,50 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using NexusRMM.Infrastructure.Data;
|
||||
|
||||
namespace NexusRMM.Api.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/v1/alerts")]
|
||||
public class AlertsController : ControllerBase
|
||||
{
|
||||
private readonly RmmDbContext _db;
|
||||
public AlertsController(RmmDbContext db) => _db = db;
|
||||
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> GetAll([FromQuery] bool? acknowledged = null)
|
||||
{
|
||||
var query = _db.Alerts
|
||||
.Include(a => a.Rule)
|
||||
.Include(a => a.Agent)
|
||||
.AsQueryable();
|
||||
|
||||
if (acknowledged.HasValue)
|
||||
query = query.Where(a => a.Acknowledged == acknowledged.Value);
|
||||
|
||||
var alerts = await query
|
||||
.OrderByDescending(a => a.CreatedAt)
|
||||
.Take(200)
|
||||
.Select(a => new
|
||||
{
|
||||
a.Id, a.Message, a.Severity, a.Acknowledged, a.CreatedAt,
|
||||
AgentId = a.AgentId.ToString(),
|
||||
AgentHostname = a.Agent.Hostname,
|
||||
RuleId = a.RuleId,
|
||||
RuleName = a.Rule.Name,
|
||||
})
|
||||
.ToListAsync();
|
||||
|
||||
return Ok(alerts);
|
||||
}
|
||||
|
||||
[HttpPost("{id:long}/acknowledge")]
|
||||
public async Task<IActionResult> Acknowledge(long id)
|
||||
{
|
||||
var alert = await _db.Alerts.FindAsync(id);
|
||||
if (alert is null) return NotFound();
|
||||
alert.Acknowledged = true;
|
||||
await _db.SaveChangesAsync();
|
||||
return Ok(new { alert.Id, alert.Acknowledged });
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ using Grpc.Core;
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using NexusRMM.Api.Hubs;
|
||||
using NexusRMM.Api.Services;
|
||||
using NexusRMM.Core.Models;
|
||||
using NexusRMM.Infrastructure.Data;
|
||||
using NexusRMM.Protos;
|
||||
@@ -16,12 +17,14 @@ public class AgentGrpcService : AgentService.AgentServiceBase
|
||||
private readonly RmmDbContext _db;
|
||||
private readonly ILogger<AgentGrpcService> _logger;
|
||||
private readonly IHubContext<RmmHub, IRmmHubClient> _hub;
|
||||
private readonly AlertEvaluationService _alertService;
|
||||
|
||||
public AgentGrpcService(RmmDbContext db, ILogger<AgentGrpcService> logger, IHubContext<RmmHub, IRmmHubClient> hub)
|
||||
public AgentGrpcService(RmmDbContext db, ILogger<AgentGrpcService> logger, IHubContext<RmmHub, IRmmHubClient> hub, AlertEvaluationService alertService)
|
||||
{
|
||||
_db = db;
|
||||
_logger = logger;
|
||||
_hub = hub;
|
||||
_alertService = alertService;
|
||||
}
|
||||
|
||||
public override async Task<EnrollResponse> Enroll(EnrollRequest request, ServerCallContext context)
|
||||
@@ -102,6 +105,9 @@ public class AgentGrpcService : AgentService.AgentServiceBase
|
||||
await _hub.Clients.All
|
||||
.AgentStatusChanged(request.AgentId, "Online", DateTime.UtcNow.ToString("O"));
|
||||
|
||||
// Alert-Engine: Metriken gegen Regeln auswerten
|
||||
await _alertService.EvaluateAsync(agentId, agent.Hostname, request.Metrics);
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
|
||||
@@ -13,4 +13,7 @@ public interface IRmmHubClient
|
||||
|
||||
/// <summary>Command-Ergebnis verfügbar (an agent-Gruppe gepusht)</summary>
|
||||
Task CommandResultUpdated(string taskId, string agentId, bool success, int exitCode);
|
||||
|
||||
/// <summary>Neuer Alert ausgelöst (an alle Clients gepusht)</summary>
|
||||
Task AlertTriggered(string agentId, string agentHostname, string ruleName, string message, string severity);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ using Microsoft.AspNetCore.Server.Kestrel.Core;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using NexusRMM.Api.GrpcServices;
|
||||
using NexusRMM.Api.Hubs;
|
||||
using NexusRMM.Api.Services;
|
||||
using NexusRMM.Infrastructure.Data;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
@@ -22,6 +23,8 @@ builder.Services.AddControllers();
|
||||
builder.Services.AddEndpointsApiExplorer();
|
||||
builder.Services.AddSwaggerGen();
|
||||
|
||||
builder.Services.AddScoped<AlertEvaluationService>();
|
||||
|
||||
builder.Services.AddCors(options =>
|
||||
{
|
||||
options.AddDefaultPolicy(policy =>
|
||||
|
||||
96
Backend/src/NexusRMM.Api/Services/AlertEvaluationService.cs
Normal file
96
Backend/src/NexusRMM.Api/Services/AlertEvaluationService.cs
Normal file
@@ -0,0 +1,96 @@
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using NexusRMM.Api.Hubs;
|
||||
using NexusRMM.Core.Models;
|
||||
using NexusRMM.Infrastructure.Data;
|
||||
using NexusRMM.Protos;
|
||||
|
||||
namespace NexusRMM.Api.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Wertet HeartbeatRequest-Metriken gegen alle aktiven AlertRules aus.
|
||||
/// Wird vom AgentGrpcService nach jedem Heartbeat aufgerufen.
|
||||
/// </summary>
|
||||
public class AlertEvaluationService
|
||||
{
|
||||
private readonly RmmDbContext _db;
|
||||
private readonly IHubContext<RmmHub, IRmmHubClient> _hub;
|
||||
private readonly ILogger<AlertEvaluationService> _logger;
|
||||
|
||||
public AlertEvaluationService(
|
||||
RmmDbContext db,
|
||||
IHubContext<RmmHub, IRmmHubClient> hub,
|
||||
ILogger<AlertEvaluationService> logger)
|
||||
{
|
||||
_db = db;
|
||||
_hub = hub;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task EvaluateAsync(Guid agentId, string agentHostname, SystemMetrics metrics)
|
||||
{
|
||||
var rules = await _db.AlertRules
|
||||
.Where(r => r.Enabled)
|
||||
.ToListAsync();
|
||||
|
||||
foreach (var rule in rules)
|
||||
{
|
||||
var metricValue = ExtractMetricValue(metrics, rule.MetricPath);
|
||||
if (metricValue is null) continue;
|
||||
|
||||
if (!EvaluateCondition(metricValue.Value, rule.Operator, rule.Threshold)) continue;
|
||||
|
||||
// Duplikat-Schutz: kein neuer Alert wenn eines innerhalb der letzten 15 Min. existiert
|
||||
var recentAlert = await _db.Alerts
|
||||
.AnyAsync(a => a.AgentId == agentId
|
||||
&& a.RuleId == rule.Id
|
||||
&& a.CreatedAt > DateTime.UtcNow.AddMinutes(-15));
|
||||
|
||||
if (recentAlert) continue;
|
||||
|
||||
var message = $"{rule.Name}: {rule.MetricPath} {rule.Operator} {rule.Threshold} " +
|
||||
$"(aktuell: {metricValue.Value:F1})";
|
||||
|
||||
var alert = new Alert
|
||||
{
|
||||
RuleId = rule.Id,
|
||||
AgentId = agentId,
|
||||
Message = message,
|
||||
Severity = rule.Severity,
|
||||
Acknowledged = false,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
};
|
||||
|
||||
_db.Alerts.Add(alert);
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
_logger.LogWarning("Alert ausgelöst: {Message} für Agent {AgentId}", message, agentId);
|
||||
|
||||
await _hub.Clients.All.AlertTriggered(
|
||||
agentId.ToString(),
|
||||
agentHostname,
|
||||
rule.Name,
|
||||
message,
|
||||
rule.Severity.ToString());
|
||||
}
|
||||
}
|
||||
|
||||
private static double? ExtractMetricValue(SystemMetrics metrics, string metricPath) =>
|
||||
metricPath switch
|
||||
{
|
||||
"cpu_usage_percent" => metrics.CpuUsagePercent,
|
||||
"memory_usage_percent" => metrics.MemoryUsagePercent,
|
||||
_ => null
|
||||
};
|
||||
|
||||
private static bool EvaluateCondition(double value, string op, double threshold) =>
|
||||
op switch
|
||||
{
|
||||
">" => value > threshold,
|
||||
">=" => value >= threshold,
|
||||
"<" => value < threshold,
|
||||
"<=" => value <= threshold,
|
||||
"==" => Math.Abs(value - threshold) < 0.001,
|
||||
_ => false
|
||||
};
|
||||
}
|
||||
@@ -1,9 +1,10 @@
|
||||
import { useState } from 'react'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { LayoutDashboard, Ticket, Menu, X } from 'lucide-react'
|
||||
import { LayoutDashboard, Ticket, Bell, Menu, X } from 'lucide-react'
|
||||
import { DashboardPage } from './pages/DashboardPage'
|
||||
import { AgentDetailPage } from './pages/AgentDetailPage'
|
||||
import TicketsPage from './pages/TicketsPage'
|
||||
import AlertsPage from './pages/AlertsPage'
|
||||
import { cn } from './lib/utils'
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
@@ -15,7 +16,7 @@ const queryClient = new QueryClient({
|
||||
},
|
||||
})
|
||||
|
||||
type Page = 'dashboard' | 'agent-detail' | 'tickets'
|
||||
type Page = 'dashboard' | 'agent-detail' | 'tickets' | 'alerts'
|
||||
|
||||
interface NavItem {
|
||||
id: Page
|
||||
@@ -26,6 +27,7 @@ interface NavItem {
|
||||
const navItems: NavItem[] = [
|
||||
{ id: 'dashboard', label: 'Dashboard', icon: <LayoutDashboard size={18} /> },
|
||||
{ id: 'tickets', label: 'Tickets', icon: <Ticket size={18} /> },
|
||||
{ id: 'alerts', label: 'Alerts', icon: <Bell size={18} /> },
|
||||
]
|
||||
|
||||
function AppContent() {
|
||||
@@ -107,6 +109,7 @@ function AppContent() {
|
||||
<AgentDetailPage agentId={selectedAgentId} onBack={handleBack} />
|
||||
)}
|
||||
{page === 'tickets' && <TicketsPage />}
|
||||
{page === 'alerts' && <AlertsPage />}
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -6,6 +6,10 @@ import type {
|
||||
CreateTaskRequest,
|
||||
CreateTicketRequest,
|
||||
UpdateTicketRequest,
|
||||
AlertRule,
|
||||
AlertItem,
|
||||
CreateAlertRuleRequest,
|
||||
UpdateAlertRuleRequest,
|
||||
} from './types'
|
||||
|
||||
const BASE_URL = '/api/v1'
|
||||
@@ -48,3 +52,24 @@ export const ticketsApi = {
|
||||
update: (id: number, data: UpdateTicketRequest) =>
|
||||
request<Ticket>(`/tickets/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
|
||||
}
|
||||
|
||||
// Alert Rules
|
||||
export const alertRulesApi = {
|
||||
list: () => request<AlertRule[]>('/alert-rules'),
|
||||
create: (data: CreateAlertRuleRequest) =>
|
||||
request<AlertRule>('/alert-rules', { method: 'POST', body: JSON.stringify(data) }),
|
||||
update: (id: number, data: UpdateAlertRuleRequest) =>
|
||||
request<AlertRule>(`/alert-rules/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
|
||||
delete: (id: number) =>
|
||||
request<void>(`/alert-rules/${id}`, { method: 'DELETE' }),
|
||||
}
|
||||
|
||||
// Alerts
|
||||
export const alertsApi = {
|
||||
list: (acknowledged?: boolean) => {
|
||||
const param = acknowledged !== undefined ? `?acknowledged=${acknowledged}` : ''
|
||||
return request<AlertItem[]>(`/alerts${param}`)
|
||||
},
|
||||
acknowledge: (id: number) =>
|
||||
request<{ id: number; acknowledged: boolean }>(`/alerts/${id}/acknowledge`, { method: 'POST' }),
|
||||
}
|
||||
|
||||
@@ -93,3 +93,42 @@ export interface UpdateTicketRequest {
|
||||
status?: TicketStatus
|
||||
priority?: TicketPriority
|
||||
}
|
||||
|
||||
export interface AlertRule {
|
||||
id: number
|
||||
name: string
|
||||
metricPath: string
|
||||
operator: string
|
||||
threshold: number
|
||||
severity: AlertSeverity
|
||||
enabled: boolean
|
||||
}
|
||||
|
||||
export interface AlertItem {
|
||||
id: number
|
||||
message: string
|
||||
severity: AlertSeverity
|
||||
acknowledged: boolean
|
||||
createdAt: string
|
||||
agentId: string
|
||||
agentHostname: string
|
||||
ruleId: number
|
||||
ruleName: string
|
||||
}
|
||||
|
||||
export interface CreateAlertRuleRequest {
|
||||
name: string
|
||||
metricPath: string
|
||||
operator: string
|
||||
threshold: number
|
||||
severity: AlertSeverity
|
||||
}
|
||||
|
||||
export interface UpdateAlertRuleRequest {
|
||||
name?: string
|
||||
metricPath?: string
|
||||
operator?: string
|
||||
threshold?: number
|
||||
severity?: AlertSeverity
|
||||
enabled?: boolean
|
||||
}
|
||||
|
||||
@@ -22,6 +22,12 @@ export function useGlobalSignalR() {
|
||||
// Einzelnen Agent-Cache aktualisieren
|
||||
queryClient.invalidateQueries({ queryKey: ['agent', agentId] })
|
||||
})
|
||||
|
||||
connection.on('AlertTriggered', (_agentId: string, agentHostname: string, ruleName: string, _message: string, _severity: string) => {
|
||||
console.debug('[SignalR] AlertTriggered', agentHostname, ruleName)
|
||||
// Alerts-Liste invalidieren
|
||||
queryClient.invalidateQueries({ queryKey: ['alerts'] })
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
497
Frontend/src/pages/AlertsPage.tsx
Normal file
497
Frontend/src/pages/AlertsPage.tsx
Normal file
@@ -0,0 +1,497 @@
|
||||
import { useState } from 'react'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { Bell, Plus, Trash2, Check, AlertTriangle, Info, AlertCircle } from 'lucide-react'
|
||||
import { alertsApi, alertRulesApi } from '../api/client'
|
||||
import type { AlertSeverity, AlertRule, CreateAlertRuleRequest } from '../api/types'
|
||||
import { cn } from '../lib/utils'
|
||||
|
||||
function getSeverityStyle(severity: AlertSeverity) {
|
||||
return {
|
||||
Critical: 'bg-red-500/20 text-red-400 border border-red-500/30',
|
||||
Warning: 'bg-yellow-500/20 text-yellow-400 border border-yellow-500/30',
|
||||
Info: 'bg-blue-500/20 text-blue-400 border border-blue-500/30',
|
||||
}[severity]
|
||||
}
|
||||
|
||||
function getSeverityIcon(severity: AlertSeverity) {
|
||||
return {
|
||||
Critical: <AlertCircle size={14} />,
|
||||
Warning: <AlertTriangle size={14} />,
|
||||
Info: <Info size={14} />,
|
||||
}[severity]
|
||||
}
|
||||
|
||||
const metricPathOptions = [
|
||||
{ value: 'cpu_usage_percent', label: 'CPU-Auslastung (%)' },
|
||||
{ value: 'memory_usage_percent', label: 'RAM-Auslastung (%)' },
|
||||
]
|
||||
|
||||
const operatorOptions = [
|
||||
{ value: '>', label: '>' },
|
||||
{ value: '>=', label: '>=' },
|
||||
{ value: '<', label: '<' },
|
||||
{ value: '<=', label: '<=' },
|
||||
{ value: '==', label: '==' },
|
||||
]
|
||||
|
||||
const severityOptions = [
|
||||
{ value: 'Info' as AlertSeverity, label: 'Info' },
|
||||
{ value: 'Warning' as AlertSeverity, label: 'Warning' },
|
||||
{ value: 'Critical' as AlertSeverity, label: 'Critical' },
|
||||
]
|
||||
|
||||
interface CreateRuleForm {
|
||||
name: string
|
||||
metricPath: string
|
||||
operator: string
|
||||
threshold: string
|
||||
severity: AlertSeverity
|
||||
}
|
||||
|
||||
export default function AlertsPage() {
|
||||
const [activeTab, setActiveTab] = useState<'alerts' | 'rules'>('alerts')
|
||||
const [alertFilter, setAlertFilter] = useState<'all' | 'unacknowledged' | 'acknowledged'>('unacknowledged')
|
||||
const [showCreateRuleModal, setShowCreateRuleModal] = useState(false)
|
||||
const [formData, setFormData] = useState<CreateRuleForm>({
|
||||
name: '',
|
||||
metricPath: 'cpu_usage_percent',
|
||||
operator: '>',
|
||||
threshold: '',
|
||||
severity: 'Warning',
|
||||
})
|
||||
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
// Queries
|
||||
const alertsQuery = useQuery({
|
||||
queryKey: ['alerts'],
|
||||
queryFn: () => alertsApi.list(),
|
||||
})
|
||||
|
||||
const rulesQuery = useQuery({
|
||||
queryKey: ['alert-rules'],
|
||||
queryFn: () => alertRulesApi.list(),
|
||||
})
|
||||
|
||||
// Mutations
|
||||
const acknowledgeMutation = useMutation({
|
||||
mutationFn: (id: number) => alertsApi.acknowledge(id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['alerts'] })
|
||||
},
|
||||
})
|
||||
|
||||
const createRuleMutation = useMutation({
|
||||
mutationFn: (data: CreateAlertRuleRequest) => alertRulesApi.create(data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['alert-rules'] })
|
||||
setShowCreateRuleModal(false)
|
||||
setFormData({
|
||||
name: '',
|
||||
metricPath: 'cpu_usage_percent',
|
||||
operator: '>',
|
||||
threshold: '',
|
||||
severity: 'Warning',
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
const updateRuleMutation = useMutation({
|
||||
mutationFn: ({ id, enabled }: { id: number; enabled: boolean }) =>
|
||||
alertRulesApi.update(id, { enabled }),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['alert-rules'] })
|
||||
},
|
||||
})
|
||||
|
||||
const deleteRuleMutation = useMutation({
|
||||
mutationFn: (id: number) => alertRulesApi.delete(id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['alert-rules'] })
|
||||
},
|
||||
})
|
||||
|
||||
// Filter alerts
|
||||
const filteredAlerts = alertsQuery.data?.filter((alert) => {
|
||||
if (alertFilter === 'acknowledged') return alert.acknowledged
|
||||
if (alertFilter === 'unacknowledged') return !alert.acknowledged
|
||||
return true
|
||||
}) || []
|
||||
|
||||
// Handlers
|
||||
const handleCreateRule = async () => {
|
||||
if (!formData.name.trim() || !formData.threshold) {
|
||||
alert('Bitte alle erforderlichen Felder ausfüllen')
|
||||
return
|
||||
}
|
||||
try {
|
||||
await createRuleMutation.mutateAsync({
|
||||
name: formData.name,
|
||||
metricPath: formData.metricPath,
|
||||
operator: formData.operator,
|
||||
threshold: parseFloat(formData.threshold),
|
||||
severity: formData.severity,
|
||||
})
|
||||
} catch (err) {
|
||||
console.error('Fehler beim Erstellen der Regel:', err)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDeleteRule = (id: number) => {
|
||||
if (window.confirm('Möchten Sie diese Regel wirklich löschen?')) {
|
||||
deleteRuleMutation.mutate(id)
|
||||
}
|
||||
}
|
||||
|
||||
const handleToggleRuleEnabled = (rule: AlertRule) => {
|
||||
updateRuleMutation.mutate({ id: rule.id, enabled: !rule.enabled })
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6 max-w-7xl mx-auto">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<Bell size={24} className="text-primary" />
|
||||
<h1 className="text-3xl font-bold text-foreground">Alerts</h1>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex gap-2 border-b border-border mb-6">
|
||||
<button
|
||||
onClick={() => setActiveTab('alerts')}
|
||||
className={cn(
|
||||
'px-4 py-2 font-medium transition-colors',
|
||||
activeTab === 'alerts'
|
||||
? 'border-b-2 border-primary text-primary'
|
||||
: 'text-muted-foreground hover:text-foreground',
|
||||
)}
|
||||
>
|
||||
Aktive Alerts
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('rules')}
|
||||
className={cn(
|
||||
'px-4 py-2 font-medium transition-colors',
|
||||
activeTab === 'rules'
|
||||
? 'border-b-2 border-primary text-primary'
|
||||
: 'text-muted-foreground hover:text-foreground',
|
||||
)}
|
||||
>
|
||||
Regeln
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Tab 1: Aktive Alerts */}
|
||||
{activeTab === 'alerts' && (
|
||||
<div className="space-y-4">
|
||||
{/* Filter */}
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => setAlertFilter('all')}
|
||||
className={cn(
|
||||
'px-3 py-1 rounded text-sm transition-colors',
|
||||
alertFilter === 'all'
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'bg-accent text-foreground hover:bg-accent/80',
|
||||
)}
|
||||
>
|
||||
Alle
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setAlertFilter('unacknowledged')}
|
||||
className={cn(
|
||||
'px-3 py-1 rounded text-sm transition-colors',
|
||||
alertFilter === 'unacknowledged'
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'bg-accent text-foreground hover:bg-accent/80',
|
||||
)}
|
||||
>
|
||||
Unbestätigt
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setAlertFilter('acknowledged')}
|
||||
className={cn(
|
||||
'px-3 py-1 rounded text-sm transition-colors',
|
||||
alertFilter === 'acknowledged'
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'bg-accent text-foreground hover:bg-accent/80',
|
||||
)}
|
||||
>
|
||||
Bestätigt
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Loading / Error */}
|
||||
{alertsQuery.isLoading && <p className="text-muted-foreground">Lädt...</p>}
|
||||
{alertsQuery.isError && (
|
||||
<p className="text-red-400">Fehler beim Laden der Alerts</p>
|
||||
)}
|
||||
|
||||
{/* Empty State */}
|
||||
{alertsQuery.isSuccess && filteredAlerts.length === 0 && (
|
||||
<p className="text-muted-foreground text-center py-8">
|
||||
Keine aktiven Alerts
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Table */}
|
||||
{alertsQuery.isSuccess && filteredAlerts.length > 0 && (
|
||||
<div className="overflow-x-auto border border-border rounded-lg">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="bg-accent border-b border-border">
|
||||
<th className="px-4 py-2 text-left font-medium">Erstellt</th>
|
||||
<th className="px-4 py-2 text-left font-medium">Agent</th>
|
||||
<th className="px-4 py-2 text-left font-medium">Regel</th>
|
||||
<th className="px-4 py-2 text-left font-medium">Nachricht</th>
|
||||
<th className="px-4 py-2 text-left font-medium">Schweregrad</th>
|
||||
<th className="px-4 py-2 text-left font-medium">Aktionen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filteredAlerts.map((alert) => (
|
||||
<tr key={alert.id} className="border-b border-border hover:bg-accent/50">
|
||||
<td className="px-4 py-2 text-xs text-muted-foreground">
|
||||
{new Date(alert.createdAt).toLocaleString('de-DE')}
|
||||
</td>
|
||||
<td className="px-4 py-2">
|
||||
<div>
|
||||
<p className="font-medium">{alert.agentHostname}</p>
|
||||
<p className="text-xs text-muted-foreground">{alert.agentId}</p>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-2">{alert.ruleName}</td>
|
||||
<td className="px-4 py-2 text-xs">{alert.message}</td>
|
||||
<td className="px-4 py-2">
|
||||
<div className={cn('flex items-center gap-1 px-2 py-1 rounded w-fit text-xs font-medium', getSeverityStyle(alert.severity))}>
|
||||
{getSeverityIcon(alert.severity)}
|
||||
{alert.severity}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-2">
|
||||
<button
|
||||
onClick={() => acknowledgeMutation.mutate(alert.id)}
|
||||
disabled={alert.acknowledged || acknowledgeMutation.isPending}
|
||||
className={cn(
|
||||
'flex items-center gap-1 px-2 py-1 rounded text-xs transition-colors',
|
||||
alert.acknowledged
|
||||
? 'bg-green-500/20 text-green-400 cursor-default'
|
||||
: 'bg-blue-500/20 text-blue-400 hover:bg-blue-500/30 disabled:opacity-50',
|
||||
)}
|
||||
>
|
||||
<Check size={12} />
|
||||
{alert.acknowledged ? 'Bestätigt' : 'Bestätigen'}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tab 2: Regeln */}
|
||||
{activeTab === 'rules' && (
|
||||
<div className="space-y-4">
|
||||
{/* Create Button */}
|
||||
<button
|
||||
onClick={() => setShowCreateRuleModal(true)}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-primary text-primary-foreground rounded-md hover:bg-primary/90 transition-colors"
|
||||
>
|
||||
<Plus size={16} />
|
||||
Neue Regel
|
||||
</button>
|
||||
|
||||
{/* Loading / Error */}
|
||||
{rulesQuery.isLoading && <p className="text-muted-foreground">Lädt...</p>}
|
||||
{rulesQuery.isError && (
|
||||
<p className="text-red-400">Fehler beim Laden der Regeln</p>
|
||||
)}
|
||||
|
||||
{/* Empty State */}
|
||||
{rulesQuery.isSuccess && rulesQuery.data.length === 0 && (
|
||||
<p className="text-muted-foreground text-center py-8">
|
||||
Keine Regeln vorhanden
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Table */}
|
||||
{rulesQuery.isSuccess && rulesQuery.data.length > 0 && (
|
||||
<div className="overflow-x-auto border border-border rounded-lg">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="bg-accent border-b border-border">
|
||||
<th className="px-4 py-2 text-left font-medium">Name</th>
|
||||
<th className="px-4 py-2 text-left font-medium">Metrik</th>
|
||||
<th className="px-4 py-2 text-left font-medium">Bedingung</th>
|
||||
<th className="px-4 py-2 text-left font-medium">Schweregrad</th>
|
||||
<th className="px-4 py-2 text-center font-medium">Aktiv</th>
|
||||
<th className="px-4 py-2 text-left font-medium">Aktionen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rulesQuery.data.map((rule) => {
|
||||
const metricLabel = metricPathOptions.find(
|
||||
(opt) => opt.value === rule.metricPath,
|
||||
)?.label || rule.metricPath
|
||||
return (
|
||||
<tr key={rule.id} className="border-b border-border hover:bg-accent/50">
|
||||
<td className="px-4 py-2 font-medium">{rule.name}</td>
|
||||
<td className="px-4 py-2 text-xs">{metricLabel}</td>
|
||||
<td className="px-4 py-2 text-xs font-mono">
|
||||
{metricLabel} {rule.operator} {rule.threshold}
|
||||
</td>
|
||||
<td className="px-4 py-2">
|
||||
<div className={cn('flex items-center gap-1 px-2 py-1 rounded w-fit text-xs font-medium', getSeverityStyle(rule.severity))}>
|
||||
{getSeverityIcon(rule.severity)}
|
||||
{rule.severity}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-2 text-center">
|
||||
<button
|
||||
onClick={() => handleToggleRuleEnabled(rule)}
|
||||
disabled={updateRuleMutation.isPending}
|
||||
className={cn(
|
||||
'px-2 py-1 rounded text-xs font-medium transition-colors',
|
||||
rule.enabled
|
||||
? 'bg-green-500/20 text-green-400 hover:bg-green-500/30'
|
||||
: 'bg-red-500/20 text-red-400 hover:bg-red-500/30',
|
||||
)}
|
||||
>
|
||||
{rule.enabled ? 'Ja' : 'Nein'}
|
||||
</button>
|
||||
</td>
|
||||
<td className="px-4 py-2">
|
||||
<button
|
||||
onClick={() => handleDeleteRule(rule.id)}
|
||||
disabled={deleteRuleMutation.isPending}
|
||||
className="flex items-center gap-1 px-2 py-1 rounded text-xs bg-red-500/20 text-red-400 hover:bg-red-500/30 transition-colors disabled:opacity-50"
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
Löschen
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Create Rule Modal */}
|
||||
{showCreateRuleModal && (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center p-4 z-50">
|
||||
<div className="bg-card border border-border rounded-lg p-6 max-w-md w-full">
|
||||
<h2 className="text-xl font-bold mb-4">Neue Regel erstellen</h2>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* Name */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">
|
||||
Name <span className="text-red-400">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||
className="w-full px-3 py-2 bg-background border border-border rounded-md text-foreground focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
placeholder="z.B. CPU über 90%"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* MetricPath */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">
|
||||
Metrik <span className="text-red-400">*</span>
|
||||
</label>
|
||||
<select
|
||||
value={formData.metricPath}
|
||||
onChange={(e) => setFormData({ ...formData, metricPath: e.target.value })}
|
||||
className="w-full px-3 py-2 bg-background border border-border rounded-md text-foreground focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
>
|
||||
{metricPathOptions.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Operator */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">
|
||||
Operator <span className="text-red-400">*</span>
|
||||
</label>
|
||||
<select
|
||||
value={formData.operator}
|
||||
onChange={(e) => setFormData({ ...formData, operator: e.target.value })}
|
||||
className="w-full px-3 py-2 bg-background border border-border rounded-md text-foreground focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
>
|
||||
{operatorOptions.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Threshold */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">
|
||||
Schwellenwert <span className="text-red-400">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={formData.threshold}
|
||||
onChange={(e) => setFormData({ ...formData, threshold: e.target.value })}
|
||||
className="w-full px-3 py-2 bg-background border border-border rounded-md text-foreground focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
placeholder="z.B. 90"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Severity */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">
|
||||
Schweregrad <span className="text-red-400">*</span>
|
||||
</label>
|
||||
<select
|
||||
value={formData.severity}
|
||||
onChange={(e) => setFormData({ ...formData, severity: e.target.value as AlertSeverity })}
|
||||
className="w-full px-3 py-2 bg-background border border-border rounded-md text-foreground focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
>
|
||||
{severityOptions.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Buttons */}
|
||||
<div className="flex gap-3 mt-6">
|
||||
<button
|
||||
onClick={() => setShowCreateRuleModal(false)}
|
||||
className="flex-1 px-4 py-2 bg-accent text-foreground rounded-md hover:bg-accent/80 transition-colors"
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
<button
|
||||
onClick={handleCreateRule}
|
||||
disabled={createRuleMutation.isPending}
|
||||
className="flex-1 px-4 py-2 bg-primary text-primary-foreground rounded-md hover:bg-primary/90 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{createRuleMutation.isPending ? 'Wird erstellt...' : 'Erstellen'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user