314 lines
13 KiB
Plaintext
314 lines
13 KiB
Plaintext
@using EntKube.Web.Data
|
|
@using EntKube.Web.Services
|
|
@inject IncidentService IncidentService
|
|
@inject AuthenticationStateProvider AuthStateProvider
|
|
|
|
@if (stats is not null)
|
|
{
|
|
<div class="row g-2 mb-3">
|
|
<div class="col-auto">
|
|
<div class="card border-0 bg-light text-center px-3 py-2">
|
|
<div class="fw-bold fs-5 @(stats.Active > 0 ? "text-danger" : "text-success")">@stats.Active</div>
|
|
<div class="text-muted small">Active</div>
|
|
</div>
|
|
</div>
|
|
<div class="col-auto">
|
|
<div class="card border-0 bg-light text-center px-3 py-2">
|
|
<div class="fw-bold fs-5 text-warning">@stats.Acknowledged</div>
|
|
<div class="text-muted small">Acknowledged</div>
|
|
</div>
|
|
</div>
|
|
<div class="col-auto">
|
|
<div class="card border-0 bg-light text-center px-3 py-2">
|
|
<div class="fw-bold fs-5">@stats.Total</div>
|
|
<div class="text-muted small">Total (@stats.WindowDays)d</div>
|
|
</div>
|
|
</div>
|
|
<div class="col-auto">
|
|
<div class="card border-0 bg-light text-center px-3 py-2">
|
|
<div class="fw-bold fs-5 text-danger">@stats.Critical</div>
|
|
<div class="text-muted small">Critical</div>
|
|
</div>
|
|
</div>
|
|
<div class="col-auto">
|
|
<div class="card border-0 bg-light text-center px-3 py-2">
|
|
<div class="fw-bold fs-5">@stats.FormatMttr()</div>
|
|
<div class="text-muted small">MTTR</div>
|
|
</div>
|
|
</div>
|
|
<div class="col-auto">
|
|
<div class="card border-0 bg-light text-center px-3 py-2">
|
|
<div class="fw-bold fs-5">@stats.FormatMtta()</div>
|
|
<div class="text-muted small">MTTA</div>
|
|
</div>
|
|
</div>
|
|
@if (stats.Daily.Count > 1)
|
|
{
|
|
int maxCount = stats.Daily.Max(d => d.Count);
|
|
<div class="col-auto align-self-center">
|
|
<div class="text-muted small mb-1">Alert volume (30d)</div>
|
|
<div class="d-flex align-items-end gap-px" style="height:32px;gap:2px">
|
|
@foreach (DailyAlertCount day in stats.Daily)
|
|
{
|
|
int h = maxCount > 0 ? (int)Math.Max(2, day.Count * 32.0 / maxCount) : 2;
|
|
string color = day.Count == 0 ? "#dee2e6" : "#dc3545";
|
|
<div style="width:5px;height:@(h)px;background:@color;border-radius:1px"
|
|
title="@day.Date.ToString("MMM d"): @day.Count alert@(day.Count == 1 ? "" : "s")"></div>
|
|
}
|
|
</div>
|
|
</div>
|
|
}
|
|
</div>
|
|
}
|
|
|
|
<div class="mb-3 d-flex flex-wrap gap-2 align-items-center">
|
|
<select class="form-select form-select-sm" style="width:auto" @bind="filterStatus" @bind:after="LoadIncidents">
|
|
<option value="">All statuses</option>
|
|
<option value="Active">Active</option>
|
|
<option value="Acknowledged">Acknowledged</option>
|
|
<option value="Resolved">Resolved</option>
|
|
</select>
|
|
<select class="form-select form-select-sm" style="width:auto" @bind="filterSeverity" @bind:after="LoadIncidents">
|
|
<option value="">All severities</option>
|
|
<option value="critical">Critical</option>
|
|
<option value="warning">Warning</option>
|
|
<option value="info">Info</option>
|
|
</select>
|
|
<select class="form-select form-select-sm" style="width:auto" @bind="filterClusterId" @bind:after="LoadIncidents">
|
|
<option value="">All clusters</option>
|
|
@foreach (var c in clusters)
|
|
{
|
|
<option value="@c.Id">@c.Name</option>
|
|
}
|
|
</select>
|
|
<select class="form-select form-select-sm" style="width:auto" @bind="filterDays" @bind:after="LoadIncidents">
|
|
<option value="1">Last 24 hours</option>
|
|
<option value="7">Last 7 days</option>
|
|
<option value="30">Last 30 days</option>
|
|
<option value="90">Last 90 days</option>
|
|
</select>
|
|
<button class="btn btn-sm btn-outline-secondary" @onclick="LoadIncidents">
|
|
<span class="bi bi-arrow-clockwise"></span> Refresh
|
|
</button>
|
|
<span class="ms-auto text-muted small">@incidents.Count incident@(incidents.Count == 1 ? "" : "s")</span>
|
|
</div>
|
|
|
|
@if (isLoading)
|
|
{
|
|
<div class="text-center py-4 text-muted"><span class="bi bi-hourglass-split"></span> Loading incidents…</div>
|
|
}
|
|
else if (incidents.Count == 0)
|
|
{
|
|
<div class="text-center py-5 text-muted">
|
|
<span class="bi bi-check-circle display-4 d-block mb-2"></span>
|
|
No incidents found for the selected filters.
|
|
</div>
|
|
}
|
|
else
|
|
{
|
|
<div class="table-responsive">
|
|
<table class="table table-hover align-middle">
|
|
<thead class="table-light">
|
|
<tr>
|
|
<th style="width:90px">Severity</th>
|
|
<th>Alert</th>
|
|
<th>Cluster</th>
|
|
<th>Started</th>
|
|
<th>Duration</th>
|
|
<th style="width:110px">Status</th>
|
|
<th style="width:180px">Actions</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
@foreach (AlertIncident incident in incidents)
|
|
{
|
|
bool expanded = expandedId == incident.Id;
|
|
<tr class="@(expanded ? "table-active" : "")" style="cursor:pointer" @onclick="() => ToggleExpand(incident.Id)">
|
|
<td>@SeverityBadge(incident.Severity)</td>
|
|
<td class="fw-semibold">@incident.AlertName</td>
|
|
<td class="text-muted small">@incident.Cluster?.Name</td>
|
|
<td class="text-muted small">@incident.StartsAt.ToLocalTime().ToString("MMM d HH:mm")</td>
|
|
<td class="text-muted small">@FormatDuration(incident)</td>
|
|
<td>@StatusBadge(incident.Status)</td>
|
|
<td @onclick:stopPropagation="true">
|
|
@if (incident.Status == IncidentStatus.Active)
|
|
{
|
|
<button class="btn btn-sm btn-outline-warning me-1" @onclick="() => Acknowledge(incident)">
|
|
Ack
|
|
</button>
|
|
}
|
|
@if (incident.Status != IncidentStatus.Resolved)
|
|
{
|
|
<button class="btn btn-sm btn-outline-success me-1" @onclick="() => Resolve(incident)">
|
|
Resolve
|
|
</button>
|
|
}
|
|
<button class="btn btn-sm btn-outline-secondary" @onclick="() => ToggleExpand(incident.Id)">
|
|
Notes @(incident.Notes.Count > 0 ? $"({incident.Notes.Count})" : "")
|
|
</button>
|
|
</td>
|
|
</tr>
|
|
@if (expanded)
|
|
{
|
|
<tr>
|
|
<td colspan="7" class="bg-light px-4 py-3">
|
|
@if (!string.IsNullOrEmpty(incident.Summary))
|
|
{
|
|
<p class="mb-1"><strong>Summary:</strong> @incident.Summary</p>
|
|
}
|
|
@if (!string.IsNullOrEmpty(incident.Description))
|
|
{
|
|
<p class="mb-2 text-muted">@incident.Description</p>
|
|
}
|
|
@if (incident.AcknowledgedBy is not null)
|
|
{
|
|
<p class="mb-2 small text-muted">
|
|
Acknowledged by <strong>@incident.AcknowledgedBy</strong>
|
|
at @incident.AcknowledgedAt?.ToLocalTime().ToString("MMM d HH:mm")
|
|
</p>
|
|
}
|
|
|
|
@if (incident.Notes.Count > 0)
|
|
{
|
|
<div class="mb-3">
|
|
<strong class="small">Notes</strong>
|
|
@foreach (IncidentNote note in incident.Notes)
|
|
{
|
|
<div class="border-start border-3 border-secondary ps-3 mb-2 mt-1">
|
|
<div class="small text-muted">@note.Author · @note.CreatedAt.ToLocalTime().ToString("MMM d HH:mm")</div>
|
|
<div>@note.Content</div>
|
|
</div>
|
|
}
|
|
</div>
|
|
}
|
|
|
|
<div class="d-flex gap-2">
|
|
<textarea class="form-control form-control-sm" rows="2" placeholder="Add a note…"
|
|
@bind="noteText" style="max-width:400px"></textarea>
|
|
<button class="btn btn-sm btn-primary align-self-end" @onclick="() => AddNote(incident)">
|
|
Add Note
|
|
</button>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
}
|
|
}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
}
|
|
|
|
@code {
|
|
[Parameter] public required Guid TenantId { get; set; }
|
|
|
|
private List<AlertIncident> incidents = [];
|
|
private List<KubernetesCluster> clusters = [];
|
|
private AlertStats? stats;
|
|
private Guid? expandedId;
|
|
private string filterStatus = "";
|
|
private string filterSeverity = "";
|
|
private string filterClusterId = "";
|
|
private int filterDays = 7;
|
|
private string noteText = "";
|
|
private bool isLoading = true;
|
|
private string? currentUser;
|
|
|
|
protected override async Task OnInitializedAsync()
|
|
{
|
|
AuthenticationState authState = await AuthStateProvider.GetAuthenticationStateAsync();
|
|
currentUser = authState.User.Identity?.Name ?? "Unknown";
|
|
await Task.WhenAll(LoadIncidents(), LoadStats());
|
|
}
|
|
|
|
private async Task LoadStats()
|
|
{
|
|
stats = await IncidentService.GetStatsForTenantAsync(TenantId, windowDays: 30);
|
|
}
|
|
|
|
private async Task LoadIncidents()
|
|
{
|
|
isLoading = true;
|
|
StateHasChanged();
|
|
|
|
IncidentStatus? status = filterStatus switch
|
|
{
|
|
"Active" => IncidentStatus.Active,
|
|
"Acknowledged" => IncidentStatus.Acknowledged,
|
|
"Resolved" => IncidentStatus.Resolved,
|
|
_ => null
|
|
};
|
|
|
|
Guid? clusterId = Guid.TryParse(filterClusterId, out Guid cid) ? cid : null;
|
|
|
|
incidents = await IncidentService.GetIncidentsForTenantAsync(
|
|
TenantId,
|
|
status: status,
|
|
severity: string.IsNullOrEmpty(filterSeverity) ? null : filterSeverity,
|
|
clusterId: clusterId,
|
|
from: DateTime.UtcNow.AddDays(-filterDays),
|
|
to: null);
|
|
|
|
// Collect unique clusters from results
|
|
clusters = incidents
|
|
.Where(i => i.Cluster is not null)
|
|
.Select(i => i.Cluster!)
|
|
.DistinctBy(c => c.Id)
|
|
.OrderBy(c => c.Name)
|
|
.ToList();
|
|
|
|
isLoading = false;
|
|
}
|
|
|
|
private void ToggleExpand(Guid id)
|
|
{
|
|
expandedId = expandedId == id ? null : id;
|
|
noteText = "";
|
|
}
|
|
|
|
private async Task Acknowledge(AlertIncident incident)
|
|
{
|
|
await IncidentService.AcknowledgeAsync(incident.Id, currentUser!);
|
|
await LoadIncidents();
|
|
}
|
|
|
|
private async Task Resolve(AlertIncident incident)
|
|
{
|
|
await IncidentService.ResolveAsync(incident.Id, currentUser!);
|
|
await LoadIncidents();
|
|
}
|
|
|
|
private async Task AddNote(AlertIncident incident)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(noteText)) return;
|
|
await IncidentService.AddNoteAsync(incident.Id, currentUser!, noteText.Trim());
|
|
noteText = "";
|
|
await LoadIncidents();
|
|
expandedId = incident.Id;
|
|
}
|
|
|
|
private static string FormatDuration(AlertIncident incident)
|
|
{
|
|
DateTime end = incident.EndsAt ?? DateTime.UtcNow;
|
|
TimeSpan duration = end - incident.StartsAt;
|
|
if (duration.TotalDays >= 1) return $"{(int)duration.TotalDays}d {duration.Hours}h";
|
|
if (duration.TotalHours >= 1) return $"{(int)duration.TotalHours}h {duration.Minutes}m";
|
|
return $"{(int)duration.TotalMinutes}m";
|
|
}
|
|
|
|
private static RenderFragment SeverityBadge(string severity) => severity switch
|
|
{
|
|
"critical" => @<span class="badge bg-danger">Critical</span>,
|
|
"warning" => @<span class="badge bg-warning text-dark">Warning</span>,
|
|
"info" => @<span class="badge bg-info text-dark">Info</span>,
|
|
_ => @<span class="badge bg-secondary">@severity</span>
|
|
};
|
|
|
|
private static RenderFragment StatusBadge(IncidentStatus status) => status switch
|
|
{
|
|
IncidentStatus.Active => @<span class="badge bg-danger">Active</span>,
|
|
IncidentStatus.Acknowledged => @<span class="badge bg-warning text-dark">Acknowledged</span>,
|
|
IncidentStatus.Resolved => @<span class="badge bg-success">Resolved</span>,
|
|
_ => @<span class="badge bg-secondary">@status</span>
|
|
};
|
|
}
|