first commit ish

This commit is contained in:
Nils Blomgren
2026-06-04 08:51:22 +02:00
parent e0f967482e
commit 658f15d086
320 changed files with 307404 additions and 1187 deletions

View File

@@ -0,0 +1,313 @@
@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>
};
}