first commit ish
This commit is contained in:
32
src/EntKube.Web/Authorization/HasTenantAccessRequirement.cs
Normal file
32
src/EntKube.Web/Authorization/HasTenantAccessRequirement.cs
Normal file
@@ -0,0 +1,32 @@
|
||||
using System.Security.Claims;
|
||||
using EntKube.Web.Data;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace EntKube.Web.Authorization;
|
||||
|
||||
public class HasTenantAccessRequirement : IAuthorizationRequirement { }
|
||||
|
||||
public class HasTenantAccessHandler(IDbContextFactory<ApplicationDbContext> dbFactory)
|
||||
: AuthorizationHandler<HasTenantAccessRequirement>
|
||||
{
|
||||
protected override async Task HandleRequirementAsync(
|
||||
AuthorizationHandlerContext context,
|
||||
HasTenantAccessRequirement requirement)
|
||||
{
|
||||
if (context.User.Identity?.IsAuthenticated != true) return;
|
||||
|
||||
if (context.User.IsInRole("Admin"))
|
||||
{
|
||||
context.Succeed(requirement);
|
||||
return;
|
||||
}
|
||||
|
||||
string? userId = context.User.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||
if (userId is null) return;
|
||||
|
||||
using ApplicationDbContext db = dbFactory.CreateDbContext();
|
||||
if (await db.TenantMemberships.AnyAsync(m => m.UserId == userId))
|
||||
context.Succeed(requirement);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
@implements IDisposable
|
||||
@implements IDisposable
|
||||
|
||||
@inject NavigationManager NavigationManager
|
||||
@inject AuthenticationStateProvider AuthStateProvider
|
||||
@inject EntKube.Web.Services.UserAccessService UserAccessService
|
||||
|
||||
<div class="top-row ps-3 navbar navbar-dark">
|
||||
<div class="container-fluid">
|
||||
@@ -18,11 +20,14 @@
|
||||
</NavLink>
|
||||
</div>
|
||||
|
||||
<div class="nav-item px-3">
|
||||
<NavLink class="nav-link" href="tenants">
|
||||
<span class="bi bi-list-nested-nav-menu" aria-hidden="true"></span> Tenants
|
||||
</NavLink>
|
||||
</div>
|
||||
@if (showTenantsLink)
|
||||
{
|
||||
<div class="nav-item px-3">
|
||||
<NavLink class="nav-link" href="tenants">
|
||||
<span class="bi bi-list-nested-nav-menu" aria-hidden="true"></span> Tenants
|
||||
</NavLink>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="nav-item px-3">
|
||||
<NavLink class="nav-link" href="portal">
|
||||
@@ -30,6 +35,22 @@
|
||||
</NavLink>
|
||||
</div>
|
||||
|
||||
<div class="nav-item px-3">
|
||||
<NavLink class="nav-link" href="portal/status">
|
||||
<span class="bi bi-activity-nav-menu" aria-hidden="true"></span> Status
|
||||
</NavLink>
|
||||
</div>
|
||||
|
||||
<AuthorizeView Roles="Admin">
|
||||
<Authorized>
|
||||
<div class="nav-item px-3">
|
||||
<NavLink class="nav-link" href="admin/backup">
|
||||
<span class="bi bi-arrow-repeat-nav-menu" aria-hidden="true"></span> Backup
|
||||
</NavLink>
|
||||
</div>
|
||||
</Authorized>
|
||||
</AuthorizeView>
|
||||
|
||||
<AuthorizeView>
|
||||
<Authorized>
|
||||
<div class="nav-item px-3">
|
||||
@@ -65,11 +86,22 @@
|
||||
|
||||
@code {
|
||||
private string? currentUrl;
|
||||
private bool showTenantsLink;
|
||||
|
||||
protected override void OnInitialized()
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
currentUrl = NavigationManager.ToBaseRelativePath(NavigationManager.Uri);
|
||||
NavigationManager.LocationChanged += OnLocationChanged;
|
||||
|
||||
AuthenticationState authState = await AuthStateProvider.GetAuthenticationStateAsync();
|
||||
System.Security.Claims.ClaimsPrincipal user = authState.User;
|
||||
|
||||
if (user.Identity?.IsAuthenticated == true)
|
||||
{
|
||||
string? userId = user.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value;
|
||||
if (userId is not null)
|
||||
showTenantsLink = await UserAccessService.HasAnyAccessAsync(userId);
|
||||
}
|
||||
}
|
||||
|
||||
private void OnLocationChanged(object? sender, LocationChangedEventArgs e)
|
||||
@@ -83,4 +115,3 @@
|
||||
NavigationManager.LocationChanged -= OnLocationChanged;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -66,6 +66,14 @@
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' class='bi bi-arrow-bar-left' viewBox='0 0 16 16'%3E%3Cpath d='M12.5 15a.5.5 0 0 1-.5-.5v-13a.5.5 0 0 1 1 0v13a.5.5 0 0 1-.5.5ZM10 8a.5.5 0 0 1-.5.5H3.707l2.147 2.146a.5.5 0 0 1-.708.708l-3-3a.5.5 0 0 1 0-.708l3-3a.5.5 0 1 1 .708.708L3.707 7.5H9.5a.5.5 0 0 1 .5.5Z'/%3E%3C/svg%3E");
|
||||
}
|
||||
|
||||
.bi-activity-nav-menu {
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' class='bi bi-activity' viewBox='0 0 16 16'%3E%3Cpath fill-rule='evenodd' d='M6 2a.5.5 0 0 1 .47.33L10 12.036l1.53-4.208A.5.5 0 0 1 12 7.5h3.5a.5.5 0 0 1 0 1h-3.15l-1.88 5.17a.5.5 0 0 1-.94 0L6 3.964 4.47 8.171A.5.5 0 0 1 4 8.5H.5a.5.5 0 0 1 0-1h3.15l1.88-5.17A.5.5 0 0 1 6 2Z'/%3E%3C/svg%3E");
|
||||
}
|
||||
|
||||
.bi-grid-nav-menu {
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' class='bi bi-grid' viewBox='0 0 16 16'%3E%3Cpath d='M1 2.5A1.5 1.5 0 0 1 2.5 1h3A1.5 1.5 0 0 1 7 2.5v3A1.5 1.5 0 0 1 5.5 7h-3A1.5 1.5 0 0 1 1 5.5v-3zM2.5 2a.5.5 0 0 0-.5.5v3a.5.5 0 0 0 .5.5h3a.5.5 0 0 0 .5-.5v-3a.5.5 0 0 0-.5-.5h-3zm6.5.5A1.5 1.5 0 0 1 10.5 1h3A1.5 1.5 0 0 1 15 2.5v3A1.5 1.5 0 0 1 13.5 7h-3A1.5 1.5 0 0 1 9 5.5v-3zm1.5-.5a.5.5 0 0 0-.5.5v3a.5.5 0 0 0 .5.5h3a.5.5 0 0 0 .5-.5v-3a.5.5 0 0 0-.5-.5h-3zM1 10.5A1.5 1.5 0 0 1 2.5 9h3A1.5 1.5 0 0 1 7 10.5v3A1.5 1.5 0 0 1 5.5 15h-3A1.5 1.5 0 0 1 1 13.5v-3zm1.5-.5a.5.5 0 0 0-.5.5v3a.5.5 0 0 0 .5.5h3a.5.5 0 0 0 .5-.5v-3a.5.5 0 0 0-.5-.5h-3zm6.5.5A1.5 1.5 0 0 1 10.5 9h3a1.5 1.5 0 0 1 1.5 1.5v3a1.5 1.5 0 0 1-1.5 1.5h-3A1.5 1.5 0 0 1 9 13.5v-3z'/%3E%3C/svg%3E");
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
font-size: 0.9rem;
|
||||
padding-bottom: 0.5rem;
|
||||
|
||||
141
src/EntKube.Web/Components/Pages/Admin/AdminBackup.razor
Normal file
141
src/EntKube.Web/Components/Pages/Admin/AdminBackup.razor
Normal file
@@ -0,0 +1,141 @@
|
||||
@page "/admin/backup"
|
||||
@using Microsoft.AspNetCore.Authorization
|
||||
@using EntKube.Web.Services
|
||||
@rendermode InteractiveServer
|
||||
@attribute [Authorize(Roles = "Admin")]
|
||||
|
||||
@inject BackupService BackupService
|
||||
@inject AuthenticationStateProvider AuthStateProvider
|
||||
|
||||
<PageTitle>Backup & Restore</PageTitle>
|
||||
|
||||
<div class="d-flex align-items-center justify-content-between mb-4">
|
||||
<div>
|
||||
<h1 class="mb-0"><i class="bi bi-arrow-repeat me-2 text-primary"></i>Backup & Restore</h1>
|
||||
<p class="text-muted small mb-0 mt-1">Export the full app state or restore from a previous backup.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row g-4">
|
||||
|
||||
@* ── Export ── *@
|
||||
<div class="col-md-6">
|
||||
<div class="card border-0 shadow-sm h-100">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title mb-1"><i class="bi bi-download me-2 text-success"></i>Export Backup</h5>
|
||||
<p class="text-muted small mb-3">
|
||||
Downloads a <code>.json.gz</code> archive containing all tenants, apps,
|
||||
clusters, deployments, and decrypted secrets. Store it securely — it
|
||||
contains plaintext credentials.
|
||||
</p>
|
||||
<a href="/api/admin/backup" class="btn btn-success" target="_blank">
|
||||
<i class="bi bi-download me-1"></i>Download Backup
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@* ── Restore ── *@
|
||||
<div class="col-md-6">
|
||||
<div class="card border-0 shadow-sm h-100">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title mb-1"><i class="bi bi-upload me-2 text-warning"></i>Restore from Backup</h5>
|
||||
<p class="text-muted small mb-3">
|
||||
Upload a <code>.json.gz</code> backup file produced by this application.
|
||||
Secrets are re-encrypted with the current server's root key on restore.
|
||||
</p>
|
||||
|
||||
<div class="mb-3">
|
||||
<InputFile OnChange="OnFileSelected" accept=".gz" class="form-control" />
|
||||
</div>
|
||||
|
||||
<div class="form-check mb-3">
|
||||
<input class="form-check-input" type="checkbox" id="wipeCheck" @bind="wipeExisting" />
|
||||
<label class="form-check-label" for="wipeCheck">
|
||||
Wipe all existing data before restoring
|
||||
</label>
|
||||
</div>
|
||||
|
||||
@if (wipeExisting)
|
||||
{
|
||||
<div class="alert alert-danger py-2 small mb-3">
|
||||
<i class="bi bi-exclamation-triangle-fill me-1"></i>
|
||||
<strong>All current data will be permanently deleted</strong> before the
|
||||
backup is loaded. This cannot be undone. Only proceed on a fresh
|
||||
installation or when you intend a full replacement.
|
||||
</div>
|
||||
}
|
||||
|
||||
<button class="btn btn-warning" @onclick="RunRestore"
|
||||
disabled="@(selectedFile is null || restoring)">
|
||||
@if (restoring)
|
||||
{
|
||||
<span class="spinner-border spinner-border-sm me-1" role="status"></span>
|
||||
<span>Restoring…</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<i class="bi bi-upload me-1"></i>
|
||||
<span>Restore</span>
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
@if (!string.IsNullOrEmpty(successMessage))
|
||||
{
|
||||
<div class="alert alert-success mt-4">
|
||||
<i class="bi bi-check-circle-fill me-2"></i>@successMessage
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (!string.IsNullOrEmpty(errorMessage))
|
||||
{
|
||||
<div class="alert alert-danger mt-4">
|
||||
<i class="bi bi-exclamation-triangle-fill me-2"></i>@errorMessage
|
||||
</div>
|
||||
}
|
||||
|
||||
@code {
|
||||
private IBrowserFile? selectedFile;
|
||||
private bool wipeExisting;
|
||||
private bool restoring;
|
||||
private string? successMessage;
|
||||
private string? errorMessage;
|
||||
|
||||
private void OnFileSelected(InputFileChangeEventArgs e)
|
||||
{
|
||||
selectedFile = e.File;
|
||||
successMessage = null;
|
||||
errorMessage = null;
|
||||
}
|
||||
|
||||
private async Task RunRestore()
|
||||
{
|
||||
if (selectedFile is null) return;
|
||||
|
||||
restoring = true;
|
||||
successMessage = null;
|
||||
errorMessage = null;
|
||||
|
||||
try
|
||||
{
|
||||
// 50 MB ceiling — backups are gzip-compressed JSON so this is generous.
|
||||
await using Stream stream = selectedFile.OpenReadStream(maxAllowedSize: 50 * 1024 * 1024);
|
||||
await BackupService.ImportAsync(stream, wipeExisting);
|
||||
successMessage = $"Restore completed successfully from '{selectedFile.Name}'.";
|
||||
selectedFile = null;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
errorMessage = $"Restore failed: {ex.Message}";
|
||||
}
|
||||
finally
|
||||
{
|
||||
restoring = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,19 @@
|
||||
@page "/"
|
||||
@page "/"
|
||||
|
||||
<PageTitle>EntKube</PageTitle>
|
||||
|
||||
<h1>EntKube</h1>
|
||||
<p class="lead">Multi-tenant platform for managing shared Kubernetes applications and infrastructure services.</p>
|
||||
|
||||
<div class="mt-4">
|
||||
<a href="/tenants" class="btn btn-primary btn-lg">Manage Tenants</a>
|
||||
<div class="mt-4 d-flex gap-2">
|
||||
<AuthorizeView Policy="HasTenantAccess">
|
||||
<Authorized>
|
||||
<a href="/tenants" class="btn btn-primary btn-lg">Manage Tenants</a>
|
||||
</Authorized>
|
||||
</AuthorizeView>
|
||||
<AuthorizeView>
|
||||
<Authorized>
|
||||
<a href="/portal" class="btn btn-outline-secondary btn-lg">Customer Portal</a>
|
||||
</Authorized>
|
||||
</AuthorizeView>
|
||||
</div>
|
||||
|
||||
201
src/EntKube.Web/Components/Pages/Portal/AppGovernancePanel.razor
Normal file
201
src/EntKube.Web/Components/Pages/Portal/AppGovernancePanel.razor
Normal file
@@ -0,0 +1,201 @@
|
||||
@using EntKube.Web.Data
|
||||
@using EntKube.Web.Services
|
||||
@inject AppGovernanceService GovernanceService
|
||||
|
||||
@* ═══════════════════════════════════════════════════════════════════
|
||||
AppGovernancePanel — read-only portal view of an app's governance
|
||||
settings (namespace, quota, network policies, RBAC).
|
||||
Customers can see what limits apply to their app but cannot change them.
|
||||
═══════════════════════════════════════════════════════════════════ *@
|
||||
|
||||
@if (loading)
|
||||
{
|
||||
<div class="text-center py-4"><div class="spinner-border spinner-border-sm text-primary"></div></div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="d-flex align-items-center mb-3">
|
||||
<i class="bi bi-shield-check fs-5 me-2 text-primary"></i>
|
||||
<div>
|
||||
<strong>@AppName — Resource Policy</strong>
|
||||
<div class="text-muted small">Set by your platform administrator. Contact them to request changes.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@* Namespace *@
|
||||
<div class="mb-3">
|
||||
<label class="form-label small fw-medium">Kubernetes Namespace</label>
|
||||
@if (!string.IsNullOrWhiteSpace(data?.Namespace))
|
||||
{
|
||||
<input class="form-control form-control-sm font-monospace" value="@data.Namespace" disabled />
|
||||
}
|
||||
else
|
||||
{
|
||||
<input class="form-control form-control-sm text-muted fst-italic" value="Not assigned" disabled />
|
||||
}
|
||||
</div>
|
||||
|
||||
@* Quota *@
|
||||
<div class="card shadow-sm mb-3">
|
||||
<div class="card-header bg-white py-2">
|
||||
<i class="bi bi-speedometer me-2 text-warning"></i>
|
||||
<strong class="small">Resource Quota</strong>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
@if (data?.Quota is not { } q)
|
||||
{
|
||||
<p class="text-muted small mb-0">No quota restrictions configured.</p>
|
||||
}
|
||||
else
|
||||
{
|
||||
<table class="table table-sm mb-0">
|
||||
<tbody>
|
||||
@if (!string.IsNullOrWhiteSpace(q.CpuRequest) || !string.IsNullOrWhiteSpace(q.CpuLimit))
|
||||
{
|
||||
<tr>
|
||||
<td class="small text-muted w-50">CPU</td>
|
||||
<td class="small font-monospace">
|
||||
request @(q.CpuRequest ?? "—") / limit @(q.CpuLimit ?? "—")
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
@if (!string.IsNullOrWhiteSpace(q.MemoryRequest) || !string.IsNullOrWhiteSpace(q.MemoryLimit))
|
||||
{
|
||||
<tr>
|
||||
<td class="small text-muted">Memory</td>
|
||||
<td class="small font-monospace">
|
||||
request @(q.MemoryRequest ?? "—") / limit @(q.MemoryLimit ?? "—")
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
@if (q.MaxPods.HasValue)
|
||||
{
|
||||
<tr>
|
||||
<td class="small text-muted">Max pods</td>
|
||||
<td class="small font-monospace">@q.MaxPods</td>
|
||||
</tr>
|
||||
}
|
||||
@if (q.MaxPvcs.HasValue)
|
||||
{
|
||||
<tr>
|
||||
<td class="small text-muted">Max PVCs</td>
|
||||
<td class="small font-monospace">@q.MaxPvcs</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@* Network policies *@
|
||||
<div class="card shadow-sm mb-3">
|
||||
<div class="card-header bg-white py-2">
|
||||
<i class="bi bi-diagram-2 me-2 text-info"></i>
|
||||
<strong class="small">Network Policies</strong>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
@if (data?.NetworkPolicies is not { Count: > 0 } policies)
|
||||
{
|
||||
<p class="text-muted small mb-0">No network policies — namespace inherits cluster defaults.</p>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="d-flex flex-wrap gap-2">
|
||||
@foreach (AppNetworkPolicy np in policies)
|
||||
{
|
||||
<span class="badge border text-secondary d-flex align-items-center gap-1"
|
||||
style="background:white;font-size:.72rem;">
|
||||
<i class="bi @NpIcon(np.PolicyType)"></i>
|
||||
<span>@NpTypeLabel(np.PolicyType)</span>
|
||||
@if (np.PolicyType == AppNetworkPolicyType.AllowFromNamespace
|
||||
&& !string.IsNullOrWhiteSpace(np.AllowFromNamespace))
|
||||
{
|
||||
<code class="ms-1">@np.AllowFromNamespace</code>
|
||||
}
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@* RBAC *@
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-header bg-white py-2">
|
||||
<i class="bi bi-person-badge me-2 text-success"></i>
|
||||
<strong class="small">RBAC</strong>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
@if (data?.RbacPolicy is not { } rbac)
|
||||
{
|
||||
<p class="text-muted small mb-0">No service account or RBAC configured.</p>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="mb-2">
|
||||
<label class="form-label small fw-medium">Service account</label>
|
||||
<input class="form-control form-control-sm font-monospace" value="@rbac.ServiceAccountName" disabled />
|
||||
</div>
|
||||
@if (rbac.Rules.Count > 0)
|
||||
{
|
||||
<table class="table table-sm mb-0">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th class="small">API groups</th>
|
||||
<th class="small">Resources</th>
|
||||
<th class="small">Verbs</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (AppRbacRule rule in rbac.Rules)
|
||||
{
|
||||
<tr>
|
||||
<td class="small align-middle"><code>@(string.IsNullOrEmpty(rule.ApiGroups) ? "core" : rule.ApiGroups)</code></td>
|
||||
<td class="small align-middle"><code>@rule.Resources</code></td>
|
||||
<td class="small align-middle"><code>@rule.Verbs</code></td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
}
|
||||
else
|
||||
{
|
||||
<p class="text-muted small mb-0">Service account exists with no permissions (no-op role).</p>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@code {
|
||||
[Parameter, EditorRequired] public Guid AppId { get; set; }
|
||||
[Parameter, EditorRequired] public string AppName { get; set; } = "";
|
||||
|
||||
private AppGovernanceData? data;
|
||||
private bool loading = true;
|
||||
|
||||
protected override async Task OnParametersSetAsync()
|
||||
{
|
||||
loading = true;
|
||||
data = await GovernanceService.LoadAsync(AppId);
|
||||
loading = false;
|
||||
}
|
||||
|
||||
private static string NpTypeLabel(AppNetworkPolicyType t) => t switch
|
||||
{
|
||||
AppNetworkPolicyType.DenyAll => "Deny all",
|
||||
AppNetworkPolicyType.AllowFromIngress => "Allow ingress",
|
||||
AppNetworkPolicyType.AllowFromSameNamespace => "Allow same ns",
|
||||
AppNetworkPolicyType.AllowFromNamespace => "Allow from ns",
|
||||
AppNetworkPolicyType.Custom => "Custom",
|
||||
_ => t.ToString()
|
||||
};
|
||||
|
||||
private static string NpIcon(AppNetworkPolicyType t) => t switch
|
||||
{
|
||||
AppNetworkPolicyType.DenyAll => "bi-slash-circle",
|
||||
AppNetworkPolicyType.AllowFromIngress or AppNetworkPolicyType.AllowFromNamespace or AppNetworkPolicyType.AllowFromSameNamespace => "bi-check-circle",
|
||||
_ => "bi-code-slash"
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
@using EntKube.Web.Data
|
||||
@using EntKube.Web.Services
|
||||
@inject IncidentService IncidentService
|
||||
|
||||
@if (isLoading)
|
||||
{
|
||||
<div class="text-center py-3 text-muted small">
|
||||
<span class="bi bi-hourglass-split me-1"></span> Loading incident history…
|
||||
</div>
|
||||
}
|
||||
else if (incidents.Count == 0)
|
||||
{
|
||||
<div class="text-center py-4 text-muted">
|
||||
<span class="bi bi-check-circle-fill text-success d-block mb-1" style="font-size:1.5rem"></span>
|
||||
No incidents in the last @WindowDays days.
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="d-flex gap-2 mb-2 flex-wrap align-items-center">
|
||||
<select class="form-select form-select-sm" style="width:auto" @bind="filterSeverity" @bind:after="ApplyFilter">
|
||||
<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="filterStatus" @bind:after="ApplyFilter">
|
||||
<option value="">All statuses</option>
|
||||
<option value="Active">Active</option>
|
||||
<option value="Acknowledged">Acknowledged</option>
|
||||
<option value="Resolved">Resolved</option>
|
||||
</select>
|
||||
<span class="ms-auto text-muted small">@filtered.Count of @incidents.Count incident@(incidents.Count == 1 ? "" : "s")</span>
|
||||
</div>
|
||||
|
||||
<div class="list-group list-group-flush">
|
||||
@foreach (AlertIncident incident in filtered.Take(50))
|
||||
{
|
||||
bool expanded = expandedId == incident.Id;
|
||||
<div class="list-group-item list-group-item-action px-0 py-2"
|
||||
style="cursor:pointer" @onclick="() => Toggle(incident.Id)">
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
@SeverityDot(incident.Severity)
|
||||
<div class="flex-grow-1 min-width-0">
|
||||
<div class="fw-semibold small text-truncate">@incident.AlertName</div>
|
||||
@if (!string.IsNullOrEmpty(incident.Summary))
|
||||
{
|
||||
<div class="text-muted" style="font-size:0.78rem">@incident.Summary</div>
|
||||
}
|
||||
</div>
|
||||
<div class="text-end text-nowrap ms-2">
|
||||
@StatusBadge(incident.Status)
|
||||
<div class="text-muted mt-1" style="font-size:0.75rem">
|
||||
@incident.StartsAt.ToLocalTime().ToString("MMM d HH:mm")
|
||||
</div>
|
||||
</div>
|
||||
<span class="bi bi-chevron-@(expanded ? "up" : "down") text-muted small"></span>
|
||||
</div>
|
||||
|
||||
@if (expanded)
|
||||
{
|
||||
<div class="mt-2 ps-3 border-start border-2" @onclick:stopPropagation="true">
|
||||
@if (!string.IsNullOrEmpty(incident.Description))
|
||||
{
|
||||
<p class="small text-muted mb-1">@incident.Description</p>
|
||||
}
|
||||
<div class="small text-muted">
|
||||
<span class="me-3">
|
||||
<span class="bi bi-hdd-rack me-1"></span>@incident.Cluster?.Name
|
||||
</span>
|
||||
<span class="me-3">
|
||||
<span class="bi bi-clock me-1"></span>Started @incident.StartsAt.ToLocalTime().ToString("MMM d yyyy HH:mm")
|
||||
</span>
|
||||
@if (incident.EndsAt.HasValue || incident.ResolvedAt.HasValue)
|
||||
{
|
||||
DateTime resolved = incident.ResolvedAt ?? incident.EndsAt!.Value;
|
||||
TimeSpan dur = resolved - incident.StartsAt;
|
||||
<span>Duration: @FormatDuration(dur)</span>
|
||||
}
|
||||
</div>
|
||||
@if (incident.AcknowledgedBy is not null)
|
||||
{
|
||||
<div class="small text-muted mt-1">
|
||||
Acknowledged by <strong>@incident.AcknowledgedBy</strong>
|
||||
@if (incident.AcknowledgedAt.HasValue)
|
||||
{
|
||||
<span> at @incident.AcknowledgedAt.Value.ToLocalTime().ToString("HH:mm")</span>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
@if (incident.Notes.Count > 0)
|
||||
{
|
||||
<div class="mt-2">
|
||||
@foreach (IncidentNote note in incident.Notes)
|
||||
{
|
||||
<div class="small border-start border-2 ps-2 mb-1">
|
||||
<span class="text-muted">@note.Author · @note.CreatedAt.ToLocalTime().ToString("MMM d HH:mm")</span>
|
||||
<div>@note.Content</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
@if (incidents.Count > 50)
|
||||
{
|
||||
<div class="text-muted small text-center mt-2">Showing 50 of @incidents.Count incidents</div>
|
||||
}
|
||||
}
|
||||
|
||||
@code {
|
||||
[Parameter] public required List<AppDeployment> Deployments { get; set; }
|
||||
[Parameter] public int WindowDays { get; set; } = 30;
|
||||
|
||||
private List<AlertIncident> incidents = [];
|
||||
private List<AlertIncident> filtered = [];
|
||||
private Guid? expandedId;
|
||||
private string filterSeverity = "";
|
||||
private string filterStatus = "";
|
||||
private bool isLoading = true;
|
||||
|
||||
protected override async Task OnParametersSetAsync() => await Load();
|
||||
|
||||
private async Task Load()
|
||||
{
|
||||
if (Deployments.Count == 0) { isLoading = false; return; }
|
||||
isLoading = true;
|
||||
incidents = await IncidentService.GetIncidentsForDeploymentsAsync(Deployments, WindowDays);
|
||||
ApplyFilter();
|
||||
isLoading = false;
|
||||
}
|
||||
|
||||
private void ApplyFilter()
|
||||
{
|
||||
filtered = incidents.Where(i =>
|
||||
(string.IsNullOrEmpty(filterSeverity) || i.Severity == filterSeverity) &&
|
||||
(string.IsNullOrEmpty(filterStatus) || i.Status.ToString() == filterStatus)
|
||||
).ToList();
|
||||
}
|
||||
|
||||
private void Toggle(Guid id) => expandedId = expandedId == id ? null : id;
|
||||
|
||||
private static string FormatDuration(TimeSpan d) =>
|
||||
d.TotalDays >= 1 ? $"{(int)d.TotalDays}d {d.Hours}h"
|
||||
: d.TotalHours >= 1 ? $"{(int)d.TotalHours}h {d.Minutes}m"
|
||||
: $"{(int)d.TotalMinutes}m";
|
||||
|
||||
private static RenderFragment SeverityDot(string severity) => severity switch
|
||||
{
|
||||
"critical" => @<span style="width:10px;height:10px;border-radius:50%;background:#dc3545;flex-shrink:0;display:inline-block"></span>,
|
||||
"warning" => @<span style="width:10px;height:10px;border-radius:50%;background:#ffc107;flex-shrink:0;display:inline-block"></span>,
|
||||
_ => @<span style="width:10px;height:10px;border-radius:50%;background:#0dcaf0;flex-shrink:0;display:inline-block"></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">Acked</span>,
|
||||
IncidentStatus.Resolved => @<span class="badge bg-success">Resolved</span>,
|
||||
_ => @<span class="badge bg-secondary">@status</span>
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
@using EntKube.Web.Data
|
||||
@using EntKube.Web.Services
|
||||
@inject IncidentService IncidentService
|
||||
|
||||
@if (windows.Count > 0)
|
||||
{
|
||||
<div class="alert @(hasActive ? "alert-warning" : "alert-info") d-flex align-items-start gap-3 py-2 mb-3">
|
||||
<span class="bi bi-tools fs-5 mt-1 flex-shrink-0"></span>
|
||||
<div class="flex-grow-1">
|
||||
<strong>@(hasActive ? "Active Maintenance" : "Upcoming Maintenance")</strong>
|
||||
<div class="mt-1">
|
||||
@foreach (MaintenanceWindow w in windows)
|
||||
{
|
||||
bool active = w.StartsAt <= DateTime.UtcNow;
|
||||
<div class="small @(active ? "fw-semibold" : "text-muted")">
|
||||
@if (active)
|
||||
{
|
||||
<span class="badge bg-warning text-dark me-1">Now</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="badge bg-info text-dark me-1">@w.StartsAt.ToLocalTime().ToString("MMM d HH:mm")</span>
|
||||
}
|
||||
@w.Title
|
||||
@if (w.Cluster is not null)
|
||||
{
|
||||
<span class="text-muted ms-1">(@w.Cluster.Name)</span>
|
||||
}
|
||||
<span class="text-muted ms-1">— until @w.EndsAt.ToLocalTime().ToString("HH:mm")</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
@if (hasActive)
|
||||
{
|
||||
<div class="small text-muted mt-1">Alert notifications are suppressed during maintenance.</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@code {
|
||||
[Parameter] public required Guid TenantId { get; set; }
|
||||
[Parameter] public HashSet<Guid>? ClusterIds { get; set; }
|
||||
|
||||
private List<MaintenanceWindow> windows = [];
|
||||
private bool hasActive;
|
||||
|
||||
protected override async Task OnParametersSetAsync()
|
||||
{
|
||||
windows = await IncidentService.GetUpcomingMaintenanceAsync(TenantId, ClusterIds);
|
||||
DateTime now = DateTime.UtcNow;
|
||||
hasActive = windows.Any(w => w.StartsAt <= now && w.EndsAt >= now);
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,14 @@
|
||||
@rendermode InteractiveServer
|
||||
@inject CustomerAccessService CustomerAccessService
|
||||
@inject DeploymentService DeploymentService
|
||||
@inject TenantService TenantService
|
||||
@inject KubernetesOperationsService K8sOps
|
||||
@inject KeycloakService KeycloakService
|
||||
@inject HarborService HarborService
|
||||
@inject VaultService VaultService
|
||||
@inject PrometheusService PrometheusService
|
||||
@inject AuditService AuditService
|
||||
@inject IncidentService IncidentService
|
||||
@inject AuthenticationStateProvider AuthStateProvider
|
||||
@inject NavigationManager Navigation
|
||||
|
||||
@@ -42,7 +49,7 @@ else
|
||||
<nav aria-label="breadcrumb" class="mb-3">
|
||||
<ol class="breadcrumb small">
|
||||
<li class="breadcrumb-item">
|
||||
@if (selectedDeployment is not null)
|
||||
@if (selectedDeployment is not null || selectedIdentityRealm is not null || selectedSecretsApp is not null || selectedRegistryProject is not null || selectedGovernanceApp is not null)
|
||||
{
|
||||
<a href="javascript:void(0)" class="text-decoration-none" @onclick="BackToApps">
|
||||
<i class="bi bi-grid me-1"></i>Portal
|
||||
@@ -63,7 +70,7 @@ else
|
||||
@if (selectedCustomer is not null)
|
||||
{
|
||||
<li class="breadcrumb-item">
|
||||
@if (selectedDeployment is not null)
|
||||
@if (selectedDeployment is not null || selectedIdentityRealm is not null || selectedSecretsApp is not null || selectedRegistryProject is not null || selectedGovernanceApp is not null)
|
||||
{
|
||||
<a href="javascript:void(0)" class="text-decoration-none" @onclick="BackToApps">@selectedCustomer.Name</a>
|
||||
}
|
||||
@@ -78,6 +85,30 @@ else
|
||||
{
|
||||
<li class="breadcrumb-item active">@selectedDeployment.Name</li>
|
||||
}
|
||||
else if (selectedIdentityRealm is not null)
|
||||
{
|
||||
<li class="breadcrumb-item active">
|
||||
<i class="bi bi-shield-lock me-1"></i>@selectedIdentityRealm.DisplayName
|
||||
</li>
|
||||
}
|
||||
else if (selectedSecretsApp is not null)
|
||||
{
|
||||
<li class="breadcrumb-item active">
|
||||
<i class="bi bi-shield-lock me-1"></i>@selectedSecretsApp.Name — Secrets
|
||||
</li>
|
||||
}
|
||||
else if (selectedRegistryProject is not null)
|
||||
{
|
||||
<li class="breadcrumb-item active">
|
||||
<i class="bi bi-archive me-1"></i>@selectedRegistryProject.ProjectName — Registry
|
||||
</li>
|
||||
}
|
||||
else if (selectedGovernanceApp is not null)
|
||||
{
|
||||
<li class="breadcrumb-item active">
|
||||
<i class="bi bi-shield-check me-1"></i>@selectedGovernanceApp.Name — Policy
|
||||
</li>
|
||||
}
|
||||
</ol>
|
||||
</nav>
|
||||
|
||||
@@ -116,16 +147,61 @@ else
|
||||
@* ════════════════════════════════════════════════════════════════
|
||||
Level 2: Apps and deployments for a selected customer
|
||||
════════════════════════════════════════════════════════════════ *@
|
||||
else if (selectedDeployment is null)
|
||||
else if (selectedDeployment is null && selectedIdentityRealm is null && selectedSecretsApp is null && selectedRegistryProject is null)
|
||||
{
|
||||
<div class="d-flex align-items-center mb-4">
|
||||
<div class="d-flex align-items-center mb-3">
|
||||
<i class="bi bi-people fs-3 me-2 text-primary"></i>
|
||||
<div>
|
||||
<h2 class="mb-0">@selectedCustomer.Name</h2>
|
||||
<small class="text-muted">@selectedCustomer.Apps.Count app@(selectedCustomer.Apps.Count != 1 ? "s" : "")</small>
|
||||
</div>
|
||||
<div class="ms-auto d-flex gap-2">
|
||||
<button class="btn btn-sm @(showMonitoringPanel ? "btn-primary" : "btn-outline-secondary")"
|
||||
@onclick="() => showMonitoringPanel = !showMonitoringPanel">
|
||||
<span class="bi bi-heart-pulse me-1"></span>Monitoring
|
||||
</button>
|
||||
<a class="btn btn-sm btn-outline-secondary" href="/portal/status">
|
||||
<span class="bi bi-activity me-1"></span>Status Overview
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@* ── Maintenance notice ── *@
|
||||
<CustomerMaintenanceNotice TenantId="selectedCustomer.TenantId" ClusterIds="AllCustomerClusterIds" />
|
||||
|
||||
@* ── Monitoring panel (toggled) ── *@
|
||||
@if (showMonitoringPanel)
|
||||
{
|
||||
<div class="card shadow-sm mb-4">
|
||||
<div class="card-header d-flex align-items-center gap-2 py-2">
|
||||
<span class="bi bi-heart-pulse text-primary"></span>
|
||||
<strong>Monitoring — @selectedCustomer.Name</strong>
|
||||
<button class="btn-close btn-sm ms-auto" @onclick="() => showMonitoringPanel = false"></button>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<div class="p-3 border-bottom">
|
||||
<h6 class="text-muted small mb-2"><span class="bi bi-shield-check me-1"></span>SLA & Uptime</h6>
|
||||
@if (AllCustomerDeployments.Count > 0)
|
||||
{
|
||||
<CustomerSlaReport
|
||||
Deployments="AllCustomerDeployments"
|
||||
CustomerId="selectedCustomer.Id"
|
||||
TenantId="selectedCustomer.TenantId" />
|
||||
}
|
||||
else { <div class="text-muted small">No deployments yet.</div> }
|
||||
</div>
|
||||
<div class="p-3">
|
||||
<h6 class="text-muted small mb-2"><span class="bi bi-clock-history me-1"></span>Incident History (30 days)</h6>
|
||||
@if (AllCustomerDeployments.Count > 0)
|
||||
{
|
||||
<CustomerIncidentHistory Deployments="AllCustomerDeployments" WindowDays="30" />
|
||||
}
|
||||
else { <div class="text-muted small">No deployments yet.</div> }
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (selectedCustomer.Apps.Count == 0)
|
||||
{
|
||||
<div class="text-center py-4">
|
||||
@@ -137,14 +213,124 @@ else
|
||||
{
|
||||
@foreach (Data.App app in selectedCustomer.Apps.OrderBy(a => a.Name))
|
||||
{
|
||||
bool hasIdentity = appRealms.ContainsKey(app.Id);
|
||||
bool hasRegistry = appHarborProjects.ContainsKey(app.Id);
|
||||
bool showingCreateForm = createDeployAppId == app.Id;
|
||||
<div class="card shadow-sm mb-3">
|
||||
<div class="card-header bg-white py-2">
|
||||
<div class="d-flex align-items-center">
|
||||
<i class="bi bi-app-indicator me-2 text-primary"></i>
|
||||
<strong>@app.Name</strong>
|
||||
<div class="d-flex align-items-center justify-content-between">
|
||||
<div class="d-flex align-items-center">
|
||||
<i class="bi bi-app-indicator me-2 text-primary"></i>
|
||||
<strong>@app.Name</strong>
|
||||
</div>
|
||||
<div class="d-flex gap-2">
|
||||
@if (currentAccessRole >= CustomerAccessRole.Admin)
|
||||
{
|
||||
<button class="btn btn-sm btn-outline-primary"
|
||||
@onclick="() => ToggleCreateDeployment(app.Id)">
|
||||
<i class="bi bi-plus me-1"></i>New Deployment
|
||||
</button>
|
||||
}
|
||||
@if (hasIdentity)
|
||||
{
|
||||
<button class="btn btn-sm btn-outline-primary"
|
||||
@onclick="() => SelectIdentityRealm(app.Id)">
|
||||
<i class="bi bi-shield-lock me-1"></i>Identity
|
||||
</button>
|
||||
}
|
||||
@if (hasRegistry)
|
||||
{
|
||||
<button class="btn btn-sm btn-outline-primary"
|
||||
@onclick="() => SelectRegistryProject(app.Id)">
|
||||
<i class="bi bi-archive me-1"></i>Registry
|
||||
</button>
|
||||
}
|
||||
<button class="btn btn-sm btn-outline-secondary"
|
||||
@onclick="() => SelectSecretsApp(app)">
|
||||
<i class="bi bi-key me-1"></i>Secrets
|
||||
</button>
|
||||
<button class="btn btn-sm btn-outline-secondary"
|
||||
@onclick="() => SelectGovernanceApp(app)">
|
||||
<i class="bi bi-shield-check me-1"></i>Policy
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
@if (showingCreateForm)
|
||||
{
|
||||
<div class="card border-primary mb-3">
|
||||
<div class="card-body">
|
||||
<h6 class="mb-3"><i class="bi bi-plus-circle me-1"></i>Create Deployment</h6>
|
||||
@if (createDeployError is not null)
|
||||
{
|
||||
<div class="alert alert-danger py-1 small mb-2">@createDeployError</div>
|
||||
}
|
||||
<div class="row g-2 mb-2">
|
||||
<div class="col-md-6">
|
||||
<label class="form-label small">Name</label>
|
||||
<input class="form-control form-control-sm" @bind="newDeployName" placeholder="e.g. my-app-prod" />
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label small">Type</label>
|
||||
<select class="form-select form-select-sm" @bind="newDeployType">
|
||||
<option value="@DeploymentType.Manual">Manual</option>
|
||||
<option value="@DeploymentType.Yaml">YAML</option>
|
||||
<option value="@DeploymentType.HelmChart">Helm Chart</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label small">Environment</label>
|
||||
<select class="form-select form-select-sm" @bind="newDeployEnvId">
|
||||
<option value="@Guid.Empty">Select...</option>
|
||||
@foreach (Data.Environment env in tenantEnvironments)
|
||||
{
|
||||
<option value="@env.Id">@env.Name</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label small">Cluster</label>
|
||||
<select class="form-select form-select-sm" @bind="newDeployClusterId">
|
||||
<option value="@Guid.Empty">Select...</option>
|
||||
@foreach (KubernetesCluster cluster in tenantClusters.Where(c => newDeployEnvId == Guid.Empty || c.EnvironmentId == newDeployEnvId))
|
||||
{
|
||||
<option value="@cluster.Id">@cluster.Name</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label small">Namespace</label>
|
||||
<input class="form-control form-control-sm" @bind="newDeployNamespace" placeholder="e.g. my-app" />
|
||||
</div>
|
||||
@if (newDeployType == DeploymentType.HelmChart)
|
||||
{
|
||||
<div class="col-md-4">
|
||||
<label class="form-label small">Helm Repo URL</label>
|
||||
<input class="form-control form-control-sm" @bind="newHelmRepoUrl" placeholder="https://charts.example.com" />
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label small">Chart Name</label>
|
||||
<input class="form-control form-control-sm" @bind="newHelmChartName" />
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label small">Chart Version</label>
|
||||
<input class="form-control form-control-sm" @bind="newHelmChartVersion" placeholder="e.g. 1.0.0" />
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<div class="d-flex gap-1">
|
||||
<button class="btn btn-sm btn-primary" @onclick="() => CreateDeployment(app.Id)"
|
||||
disabled="@(string.IsNullOrWhiteSpace(newDeployName) || newDeployEnvId == Guid.Empty || newDeployClusterId == Guid.Empty || string.IsNullOrWhiteSpace(newDeployNamespace) || creatingDeployment)">
|
||||
@if (creatingDeployment) { <span class="spinner-border spinner-border-sm me-1"></span> }
|
||||
Create
|
||||
</button>
|
||||
<button class="btn btn-sm btn-outline-secondary" @onclick="() => createDeployAppId = null">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (appDeployments.TryGetValue(app.Id, out List<AppDeployment>? deploys) && deploys.Count > 0)
|
||||
{
|
||||
<div class="row row-cols-1 row-cols-lg-2 g-2">
|
||||
@@ -175,7 +361,7 @@ else
|
||||
}
|
||||
</div>
|
||||
}
|
||||
else
|
||||
else if (!showingCreateForm)
|
||||
{
|
||||
<p class="text-muted small mb-0">No deployments configured.</p>
|
||||
}
|
||||
@@ -188,14 +374,53 @@ else
|
||||
@* ════════════════════════════════════════════════════════════════
|
||||
Level 3: Deployment detail with operations
|
||||
════════════════════════════════════════════════════════════════ *@
|
||||
else
|
||||
else if (selectedDeployment is not null)
|
||||
{
|
||||
<PortalDeploymentDetail
|
||||
Deployment="selectedDeployment"
|
||||
AccessRole="currentAccessRole"
|
||||
K8sOps="K8sOps"
|
||||
DeploymentService="DeploymentService"
|
||||
OnBack="BackToApps" />
|
||||
PrometheusService="PrometheusService"
|
||||
AuditService="AuditService"
|
||||
OnBack="BackToApps"
|
||||
OnDeleted="OnDeploymentDeleted" />
|
||||
}
|
||||
|
||||
@* ════════════════════════════════════════════════════════════════
|
||||
Level 3: Identity (Keycloak realm) management
|
||||
════════════════════════════════════════════════════════════════ *@
|
||||
else if (selectedIdentityRealm is not null)
|
||||
{
|
||||
<PortalIdentityDetail
|
||||
Realm="selectedIdentityRealm"
|
||||
TenantId="selectedCustomer!.TenantId" />
|
||||
}
|
||||
|
||||
@* ════════════════════════════════════════════════════════════════
|
||||
Level 3: App secrets vault
|
||||
════════════════════════════════════════════════════════════════ *@
|
||||
else if (selectedSecretsApp is not null)
|
||||
{
|
||||
<PortalSecretsDetail
|
||||
App="selectedSecretsApp"
|
||||
TenantId="selectedCustomer!.TenantId"
|
||||
AccessRole="currentAccessRole" />
|
||||
}
|
||||
|
||||
@* ════════════════════════════════════════════════════════════════
|
||||
Level 3: Harbor registry project management
|
||||
════════════════════════════════════════════════════════════════ *@
|
||||
else if (selectedRegistryProject is not null)
|
||||
{
|
||||
<PortalRegistryDetail
|
||||
Project="selectedRegistryProject"
|
||||
TenantId="selectedCustomer!.TenantId"
|
||||
AccessRole="currentAccessRole" />
|
||||
}
|
||||
else if (selectedGovernanceApp is not null)
|
||||
{
|
||||
<AppGovernancePanel AppId="selectedGovernanceApp.Id" AppName="selectedGovernanceApp.Name" />
|
||||
}
|
||||
}
|
||||
|
||||
@@ -207,10 +432,42 @@ else
|
||||
private List<Customer>? customers;
|
||||
private Customer? selectedCustomer;
|
||||
private AppDeployment? selectedDeployment;
|
||||
private KeycloakRealm? selectedIdentityRealm;
|
||||
private Data.App? selectedSecretsApp;
|
||||
private HarborProject? selectedRegistryProject;
|
||||
private Data.App? selectedGovernanceApp;
|
||||
|
||||
// Deployments indexed by app ID for the selected customer.
|
||||
private Dictionary<Guid, List<AppDeployment>> appDeployments = new();
|
||||
|
||||
// Linked Keycloak realms indexed by app ID for the selected customer.
|
||||
private Dictionary<Guid, KeycloakRealm> appRealms = new();
|
||||
|
||||
// Linked Harbor projects indexed by app ID for the selected customer.
|
||||
private Dictionary<Guid, HarborProject> appHarborProjects = new();
|
||||
|
||||
// Environments and clusters for deployment creation (loaded when Admin selects a customer).
|
||||
private List<Data.Environment> tenantEnvironments = [];
|
||||
private List<KubernetesCluster> tenantClusters = [];
|
||||
|
||||
// Monitoring panel toggle in Level 2
|
||||
private bool showMonitoringPanel;
|
||||
|
||||
private List<AppDeployment> AllCustomerDeployments =>
|
||||
appDeployments.Values.SelectMany(d => d).ToList();
|
||||
|
||||
private HashSet<Guid> AllCustomerClusterIds =>
|
||||
AllCustomerDeployments.Select(d => d.ClusterId).ToHashSet();
|
||||
|
||||
// Create deployment form (portal Admin).
|
||||
private Guid? createDeployAppId;
|
||||
private string newDeployName = "", newDeployNamespace = "";
|
||||
private DeploymentType newDeployType = DeploymentType.Manual;
|
||||
private Guid newDeployEnvId, newDeployClusterId;
|
||||
private string newHelmRepoUrl = "", newHelmChartName = "", newHelmChartVersion = "";
|
||||
private bool creatingDeployment;
|
||||
private string? createDeployError;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
// Determine who the logged-in user is.
|
||||
@@ -231,18 +488,37 @@ else
|
||||
private async Task SelectCustomer(Customer customer)
|
||||
{
|
||||
selectedCustomer = customer;
|
||||
showMonitoringPanel = false;
|
||||
|
||||
// Look up the user's role for this customer.
|
||||
CustomerAccess? access = await CustomerAccessService.GetAccessAsync(currentUserId!, customer.Id);
|
||||
currentAccessRole = access?.Role ?? CustomerAccessRole.Viewer;
|
||||
|
||||
// Load deployments for all of the customer's apps.
|
||||
// Load deployments, linked Keycloak realms, and linked Harbor projects for all apps.
|
||||
appDeployments.Clear();
|
||||
appRealms.Clear();
|
||||
appHarborProjects.Clear();
|
||||
createDeployAppId = null;
|
||||
|
||||
foreach (Data.App app in customer.Apps)
|
||||
{
|
||||
List<AppDeployment> deploys = await DeploymentService.GetDeploymentsAsync(app.Id);
|
||||
appDeployments[app.Id] = deploys;
|
||||
|
||||
KeycloakRealm? realm = await KeycloakService.GetRealmForAppAsync(customer.TenantId, app.Id);
|
||||
if (realm is not null)
|
||||
appRealms[app.Id] = realm;
|
||||
|
||||
HarborProject? harborProject = await HarborService.GetProjectForAppAsync(customer.TenantId, app.Id);
|
||||
if (harborProject is not null)
|
||||
appHarborProjects[app.Id] = harborProject;
|
||||
}
|
||||
|
||||
// Load environments and clusters so Admin users can create deployments.
|
||||
if (currentAccessRole >= CustomerAccessRole.Admin)
|
||||
{
|
||||
tenantEnvironments = await TenantService.GetEnvironmentsAsync(customer.TenantId);
|
||||
tenantClusters = await TenantService.GetClustersAsync(customer.TenantId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -251,15 +527,98 @@ else
|
||||
selectedDeployment = deploy;
|
||||
}
|
||||
|
||||
private void SelectIdentityRealm(Guid appId)
|
||||
{
|
||||
if (appRealms.TryGetValue(appId, out KeycloakRealm? realm))
|
||||
selectedIdentityRealm = realm;
|
||||
}
|
||||
|
||||
private void SelectRegistryProject(Guid appId)
|
||||
{
|
||||
if (appHarborProjects.TryGetValue(appId, out HarborProject? project))
|
||||
selectedRegistryProject = project;
|
||||
}
|
||||
|
||||
private void BackToCustomers()
|
||||
{
|
||||
selectedCustomer = null;
|
||||
selectedDeployment = null;
|
||||
selectedIdentityRealm = null;
|
||||
selectedSecretsApp = null;
|
||||
selectedRegistryProject = null;
|
||||
}
|
||||
|
||||
private void SelectSecretsApp(Data.App app)
|
||||
{
|
||||
selectedSecretsApp = app;
|
||||
}
|
||||
|
||||
private void SelectGovernanceApp(Data.App app)
|
||||
{
|
||||
selectedGovernanceApp = app;
|
||||
}
|
||||
|
||||
private void BackToApps()
|
||||
{
|
||||
selectedDeployment = null;
|
||||
selectedIdentityRealm = null;
|
||||
selectedSecretsApp = null;
|
||||
selectedRegistryProject = null;
|
||||
selectedGovernanceApp = null;
|
||||
}
|
||||
|
||||
private async Task OnDeploymentDeleted()
|
||||
{
|
||||
if (selectedDeployment is not null)
|
||||
{
|
||||
Guid appId = selectedDeployment.AppId;
|
||||
selectedDeployment = null;
|
||||
// Reload the affected app's deployments.
|
||||
appDeployments[appId] = await DeploymentService.GetDeploymentsAsync(appId);
|
||||
}
|
||||
}
|
||||
|
||||
private void ToggleCreateDeployment(Guid appId)
|
||||
{
|
||||
if (createDeployAppId == appId)
|
||||
{
|
||||
createDeployAppId = null;
|
||||
}
|
||||
else
|
||||
{
|
||||
createDeployAppId = appId;
|
||||
newDeployName = newDeployNamespace = newHelmRepoUrl = newHelmChartName = newHelmChartVersion = "";
|
||||
newDeployType = DeploymentType.Manual;
|
||||
newDeployEnvId = newDeployClusterId = Guid.Empty;
|
||||
createDeployError = null;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task CreateDeployment(Guid appId)
|
||||
{
|
||||
creatingDeployment = true;
|
||||
createDeployError = null;
|
||||
|
||||
try
|
||||
{
|
||||
await DeploymentService.CreateDeploymentAsync(
|
||||
appId, newDeployName, newDeployType,
|
||||
newDeployEnvId, newDeployClusterId, newDeployNamespace,
|
||||
newDeployType == DeploymentType.HelmChart ? newHelmRepoUrl : null,
|
||||
newDeployType == DeploymentType.HelmChart ? newHelmChartName : null,
|
||||
newDeployType == DeploymentType.HelmChart ? newHelmChartVersion : null);
|
||||
|
||||
createDeployAppId = null;
|
||||
appDeployments[appId] = await DeploymentService.GetDeploymentsAsync(appId);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
createDeployError = ex.Message;
|
||||
}
|
||||
finally
|
||||
{
|
||||
creatingDeployment = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ──────── Badge helpers ────────
|
||||
|
||||
123
src/EntKube.Web/Components/Pages/Portal/CustomerSlaReport.razor
Normal file
123
src/EntKube.Web/Components/Pages/Portal/CustomerSlaReport.razor
Normal file
@@ -0,0 +1,123 @@
|
||||
@using EntKube.Web.Data
|
||||
@using EntKube.Web.Services
|
||||
@inject IncidentService IncidentService
|
||||
|
||||
@if (rows.Count == 0 && !isLoading)
|
||||
{
|
||||
<div class="text-muted small text-center py-2">No uptime data yet — health snapshots are collected every 5 minutes.</div>
|
||||
}
|
||||
else if (isLoading)
|
||||
{
|
||||
<div class="text-muted small text-center py-2"><span class="bi bi-hourglass-split me-1"></span> Loading…</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm align-middle mb-0">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>Deployment</th>
|
||||
<th>App</th>
|
||||
<th class="text-end">7-day uptime</th>
|
||||
<th class="text-end">30-day uptime</th>
|
||||
<th class="text-end">SLA target</th>
|
||||
<th class="text-end">Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (SlaRow row in rows)
|
||||
{
|
||||
bool met = !row.Uptime30d.HasValue || !row.SlaTarget.HasValue || row.Uptime30d.Value >= row.SlaTarget.Value;
|
||||
<tr>
|
||||
<td class="fw-semibold small">@row.DeploymentName</td>
|
||||
<td class="text-muted small">@row.AppName</td>
|
||||
<td class="text-end">
|
||||
@if (row.Uptime7d.HasValue)
|
||||
{
|
||||
<span class="@UptimeColor(row.Uptime7d.Value, row.SlaTarget)">@row.Uptime7d.Value.ToString("F2")%</span>
|
||||
}
|
||||
else { <span class="text-muted">—</span> }
|
||||
</td>
|
||||
<td class="text-end">
|
||||
@if (row.Uptime30d.HasValue)
|
||||
{
|
||||
<span class="@UptimeColor(row.Uptime30d.Value, row.SlaTarget)">@row.Uptime30d.Value.ToString("F2")%</span>
|
||||
}
|
||||
else { <span class="text-muted">—</span> }
|
||||
</td>
|
||||
<td class="text-end text-muted small">
|
||||
@(row.SlaTarget.HasValue ? $"{row.SlaTarget.Value:F1}%" : "—")
|
||||
</td>
|
||||
<td class="text-end">
|
||||
@if (!row.Uptime30d.HasValue)
|
||||
{
|
||||
<span class="text-muted small">No data</span>
|
||||
}
|
||||
else if (!row.SlaTarget.HasValue)
|
||||
{
|
||||
<span class="text-muted small">No target</span>
|
||||
}
|
||||
else if (met)
|
||||
{
|
||||
<span class="badge bg-success"><span class="bi bi-check me-1"></span>Meeting SLA</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="badge bg-danger"><span class="bi bi-exclamation-triangle me-1"></span>Below SLA</span>
|
||||
}
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
|
||||
@code {
|
||||
[Parameter] public required List<AppDeployment> Deployments { get; set; }
|
||||
[Parameter] public Guid CustomerId { get; set; }
|
||||
[Parameter] public Guid TenantId { get; set; }
|
||||
|
||||
private List<SlaRow> rows = [];
|
||||
private bool isLoading = true;
|
||||
|
||||
protected override async Task OnParametersSetAsync() => await Load();
|
||||
|
||||
private async Task Load()
|
||||
{
|
||||
isLoading = true;
|
||||
StateHasChanged();
|
||||
|
||||
List<SlaRow> result = [];
|
||||
foreach (AppDeployment dep in Deployments)
|
||||
{
|
||||
UptimeResult u7 = await IncidentService.GetUptimeAsync(dep.Id, 7);
|
||||
UptimeResult u30 = await IncidentService.GetUptimeAsync(dep.Id, 30);
|
||||
SlaTarget? sla = await IncidentService.GetSlaTargetAsync(TenantId, CustomerId, dep.AppId);
|
||||
|
||||
result.Add(new SlaRow(
|
||||
dep.Name,
|
||||
dep.App?.Name ?? dep.AppId.ToString(),
|
||||
u7.Percent,
|
||||
u30.Percent,
|
||||
sla?.TargetPercent));
|
||||
}
|
||||
rows = result.OrderBy(r => r.AppName).ThenBy(r => r.DeploymentName).ToList();
|
||||
isLoading = false;
|
||||
}
|
||||
|
||||
private static string UptimeColor(double pct, double? target)
|
||||
{
|
||||
double threshold = target ?? 99.0;
|
||||
if (pct >= threshold) return "text-success fw-semibold";
|
||||
if (pct >= threshold - 2) return "text-warning fw-semibold";
|
||||
return "text-danger fw-semibold";
|
||||
}
|
||||
|
||||
private record SlaRow(
|
||||
string DeploymentName,
|
||||
string AppName,
|
||||
double? Uptime7d,
|
||||
double? Uptime30d,
|
||||
double? SlaTarget);
|
||||
}
|
||||
@@ -0,0 +1,281 @@
|
||||
@page "/portal/status"
|
||||
@using EntKube.Web.Data
|
||||
@using EntKube.Web.Services
|
||||
@using Microsoft.EntityFrameworkCore
|
||||
@using Microsoft.AspNetCore.Authorization
|
||||
@using Microsoft.AspNetCore.Components.Authorization
|
||||
@attribute [Authorize]
|
||||
@rendermode InteractiveServer
|
||||
@inject CustomerAccessService CustomerAccessService
|
||||
@inject IncidentService IncidentService
|
||||
@inject IDbContextFactory<ApplicationDbContext> DbFactory
|
||||
@inject AuthenticationStateProvider AuthStateProvider
|
||||
@inject NavigationManager Navigation
|
||||
|
||||
<PageTitle>Status Overview</PageTitle>
|
||||
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<div>
|
||||
<h4 class="mb-0"><i class="bi bi-activity me-2"></i>Status Overview</h4>
|
||||
<div class="text-muted small">Health and uptime across all your applications</div>
|
||||
</div>
|
||||
<button class="btn btn-sm btn-outline-secondary" @onclick="LoadData" disabled="@isLoading">
|
||||
<span class="bi bi-arrow-clockwise @(isLoading ? "spin" : "")"></span> Refresh
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@if (isLoading)
|
||||
{
|
||||
<div class="text-center py-5">
|
||||
<div class="spinner-border text-primary" role="status"></div>
|
||||
<p class="text-muted mt-2">Loading status…</p>
|
||||
</div>
|
||||
}
|
||||
else if (rows.Count == 0)
|
||||
{
|
||||
<div class="text-center py-5 text-muted">
|
||||
<i class="bi bi-shield-lock display-4 d-block mb-2"></i>
|
||||
No applications to show. You may not have access to any customer yet.
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="card shadow-sm">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover align-middle mb-0">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>Application</th>
|
||||
<th>Customer</th>
|
||||
<th>Health</th>
|
||||
<th>Uptime (7d)</th>
|
||||
<th>Uptime (30d)</th>
|
||||
<th>SLA Target</th>
|
||||
<th>Active Alerts</th>
|
||||
<th>Last Sync</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (StatusRow row in rows)
|
||||
{
|
||||
<tr style="cursor:pointer" @onclick="() => GoToPortal(row)">
|
||||
<td class="fw-semibold">@row.AppName</td>
|
||||
<td class="text-muted">@row.CustomerName</td>
|
||||
<td>@HealthBadge(row.WorstHealth)</td>
|
||||
<td>
|
||||
@if (row.Uptime7d.HasValue)
|
||||
{
|
||||
<span class="fw-semibold @UptimeColor(row.Uptime7d.Value, row.SlaTarget)">@row.Uptime7d.Value.ToString("F2")%</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="text-muted">—</span>
|
||||
}
|
||||
</td>
|
||||
<td>
|
||||
@if (row.Uptime30d.HasValue)
|
||||
{
|
||||
<span class="fw-semibold @UptimeColor(row.Uptime30d.Value, row.SlaTarget)">@row.Uptime30d.Value.ToString("F2")%</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="text-muted">—</span>
|
||||
}
|
||||
</td>
|
||||
<td class="text-muted small">
|
||||
@if (row.SlaTarget.HasValue)
|
||||
{
|
||||
bool met30 = !row.Uptime30d.HasValue || row.Uptime30d.Value >= row.SlaTarget.Value;
|
||||
<span class="@(met30 ? "text-success" : "text-danger")">
|
||||
<i class="bi bi-@(met30 ? "check" : "exclamation-triangle") me-1"></i>@row.SlaTarget.Value.ToString("F1")%
|
||||
</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="text-muted">—</span>
|
||||
}
|
||||
</td>
|
||||
<td>
|
||||
@if (row.ActiveAlerts > 0)
|
||||
{
|
||||
<span class="badge bg-danger">@row.ActiveAlerts</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="text-success small"><i class="bi bi-check-circle me-1"></i>None</span>
|
||||
}
|
||||
</td>
|
||||
<td class="text-muted small">
|
||||
@(row.LastSync.HasValue ? row.LastSync.Value.ToLocalTime().ToString("MMM d HH:mm") : "—")
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="text-muted small mt-2 text-end">
|
||||
Refreshed @lastRefreshed.ToLocalTime().ToString("HH:mm:ss") · Health snapshots every 5 minutes
|
||||
</div>
|
||||
}
|
||||
|
||||
@code {
|
||||
private List<StatusRow> rows = [];
|
||||
private bool isLoading = true;
|
||||
private DateTime lastRefreshed;
|
||||
private string? currentUserId;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
AuthenticationState authState = await AuthStateProvider.GetAuthenticationStateAsync();
|
||||
currentUserId = authState.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value;
|
||||
await LoadData();
|
||||
}
|
||||
|
||||
private async Task LoadData()
|
||||
{
|
||||
if (currentUserId is null) return;
|
||||
|
||||
isLoading = true;
|
||||
StateHasChanged();
|
||||
|
||||
List<Customer> customers = await CustomerAccessService.GetAccessibleCustomersAsync(currentUserId);
|
||||
|
||||
using ApplicationDbContext db = DbFactory.CreateDbContext();
|
||||
|
||||
List<Guid> appIds = customers.SelectMany(c => c.Apps).Select(a => a.Id).ToList();
|
||||
|
||||
// Load all deployments for these apps
|
||||
List<AppDeployment> deployments = await db.AppDeployments
|
||||
.Where(d => appIds.Contains(d.AppId))
|
||||
.ToListAsync();
|
||||
|
||||
List<Guid> deploymentIds = deployments.Select(d => d.Id).ToList();
|
||||
|
||||
// Load uptime data for last 30 days
|
||||
DateTime from30 = DateTime.UtcNow.AddDays(-30);
|
||||
DateTime from7 = DateTime.UtcNow.AddDays(-7);
|
||||
|
||||
var snapshots = await db.DeploymentHealthSnapshots
|
||||
.Where(s => deploymentIds.Contains(s.DeploymentId) && s.SnapshotAt >= from30)
|
||||
.Select(s => new { s.DeploymentId, s.HealthStatus, s.SnapshotAt })
|
||||
.ToListAsync();
|
||||
|
||||
// Load active incidents for these clusters, fetching LabelsJson for namespace matching
|
||||
List<Guid> clusterIds = deployments.Select(d => d.ClusterId).Distinct().ToList();
|
||||
List<(Guid ClusterId, string LabelsJson)> activeIncidentLabels = await db.AlertIncidents
|
||||
.Where(i => clusterIds.Contains(i.ClusterId)
|
||||
&& (i.Status == IncidentStatus.Active || i.Status == IncidentStatus.Acknowledged))
|
||||
.Select(i => new ValueTuple<Guid, string>(i.ClusterId, i.LabelsJson))
|
||||
.ToListAsync();
|
||||
|
||||
// Build rows per app
|
||||
rows = [];
|
||||
foreach (Customer customer in customers.OrderBy(c => c.Name))
|
||||
{
|
||||
foreach (EntKube.Web.Data.App app in customer.Apps.OrderBy(a => a.Name))
|
||||
{
|
||||
List<AppDeployment> appDeployments = deployments.Where(d => d.AppId == app.Id).ToList();
|
||||
if (appDeployments.Count == 0) continue;
|
||||
|
||||
HealthStatus worstHealth = appDeployments
|
||||
.Select(d => d.HealthStatus)
|
||||
.OrderByDescending(h => (int)h)
|
||||
.FirstOrDefault();
|
||||
|
||||
DateTime? lastSync = appDeployments
|
||||
.Where(d => d.LastSyncedAt.HasValue)
|
||||
.Max(d => d.LastSyncedAt);
|
||||
|
||||
// Compute uptime per deployment then average
|
||||
double? uptime7d = null, uptime30d = null;
|
||||
foreach (AppDeployment dep in appDeployments)
|
||||
{
|
||||
var depSnaps = snapshots.Where(s => s.DeploymentId == dep.Id).ToList();
|
||||
if (depSnaps.Count == 0) continue;
|
||||
|
||||
var snaps7 = depSnaps.Where(s => s.SnapshotAt >= from7).ToList();
|
||||
var snaps30 = depSnaps;
|
||||
|
||||
if (snaps7.Count > 0)
|
||||
{
|
||||
double pct = (double)snaps7.Count(s => s.HealthStatus == HealthStatus.Healthy) / snaps7.Count * 100;
|
||||
uptime7d = uptime7d.HasValue ? (uptime7d + pct) / 2 : pct;
|
||||
}
|
||||
if (snaps30.Count > 0)
|
||||
{
|
||||
double pct = (double)snaps30.Count(s => s.HealthStatus == HealthStatus.Healthy) / snaps30.Count * 100;
|
||||
uptime30d = uptime30d.HasValue ? (uptime30d + pct) / 2 : pct;
|
||||
}
|
||||
}
|
||||
|
||||
// Count alerts by matching namespace label in LabelsJson
|
||||
HashSet<Guid> appClusterIds = appDeployments.Select(d => d.ClusterId).ToHashSet();
|
||||
HashSet<string> appNamespaces = appDeployments.Select(d => d.Namespace).ToHashSet();
|
||||
int activeAlerts = activeIncidentLabels
|
||||
.Where(x => appClusterIds.Contains(x.ClusterId))
|
||||
.Count(x =>
|
||||
{
|
||||
try
|
||||
{
|
||||
using var doc = System.Text.Json.JsonDocument.Parse(x.LabelsJson);
|
||||
return doc.RootElement.TryGetProperty("namespace", out var ns)
|
||||
&& appNamespaces.Contains(ns.GetString() ?? "");
|
||||
}
|
||||
catch { return false; }
|
||||
});
|
||||
|
||||
// Look up SLA target (app > customer > none)
|
||||
SlaTarget? sla = await IncidentService.GetSlaTargetAsync(
|
||||
customer.TenantId, customer.Id, app.Id);
|
||||
|
||||
rows.Add(new StatusRow(
|
||||
app.Name,
|
||||
customer.Name,
|
||||
customer.Id,
|
||||
worstHealth,
|
||||
uptime7d.HasValue ? Math.Round(uptime7d.Value, 2) : null,
|
||||
uptime30d.HasValue ? Math.Round(uptime30d.Value, 2) : null,
|
||||
sla?.TargetPercent,
|
||||
activeAlerts,
|
||||
lastSync));
|
||||
}
|
||||
}
|
||||
|
||||
lastRefreshed = DateTime.UtcNow;
|
||||
isLoading = false;
|
||||
}
|
||||
|
||||
private void GoToPortal(StatusRow row) =>
|
||||
Navigation.NavigateTo("/portal");
|
||||
|
||||
private static string UptimeColor(double pct, double? slaTarget)
|
||||
{
|
||||
double threshold = slaTarget ?? 99.0;
|
||||
if (pct >= threshold) return "text-success";
|
||||
if (pct >= threshold - 2) return "text-warning";
|
||||
return "text-danger";
|
||||
}
|
||||
|
||||
private static RenderFragment HealthBadge(HealthStatus health) => health switch
|
||||
{
|
||||
HealthStatus.Healthy => @<span class="badge bg-success">Healthy</span>,
|
||||
HealthStatus.Progressing => @<span class="badge bg-primary">Progressing</span>,
|
||||
HealthStatus.Degraded => @<span class="badge bg-warning text-dark">Degraded</span>,
|
||||
HealthStatus.Suspended => @<span class="badge bg-secondary">Suspended</span>,
|
||||
HealthStatus.Missing => @<span class="badge bg-danger">Missing</span>,
|
||||
_ => @<span class="badge bg-secondary">Unknown</span>
|
||||
};
|
||||
|
||||
private record StatusRow(
|
||||
string AppName,
|
||||
string CustomerName,
|
||||
Guid CustomerId,
|
||||
HealthStatus WorstHealth,
|
||||
double? Uptime7d,
|
||||
double? Uptime30d,
|
||||
double? SlaTarget,
|
||||
int ActiveAlerts,
|
||||
DateTime? LastSync);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,4 @@
|
||||
export function getElementValue(id) {
|
||||
const el = document.getElementById(id);
|
||||
return el ? el.value : '';
|
||||
}
|
||||
@@ -0,0 +1,506 @@
|
||||
@using EntKube.Web.Data
|
||||
@using EntKube.Web.Services
|
||||
@inject KeycloakService KeycloakService
|
||||
|
||||
@* Realm management for customer portal — same as IdentityTab realm detail
|
||||
but without the ability to create realms or manage themes. *@
|
||||
|
||||
<div class="d-flex align-items-center justify-content-between mb-3">
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<i class="bi bi-shield-lock fs-4 text-primary"></i>
|
||||
<div>
|
||||
<h5 class="mb-0">@Realm.DisplayName</h5>
|
||||
<small class="text-muted font-monospace">@Realm.RealmName</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ul class="nav nav-tabs mb-3 flex-wrap">
|
||||
@foreach (string tab in new[] { "users", "idp", "groups", "orgs" })
|
||||
{
|
||||
string label = tab switch
|
||||
{
|
||||
"users" => "Users",
|
||||
"idp" => "Identity Providers",
|
||||
"groups" => "Groups",
|
||||
"orgs" => "Organizations",
|
||||
_ => tab
|
||||
};
|
||||
<li class="nav-item">
|
||||
<button class="nav-link @(activeTab == tab ? "active" : "")"
|
||||
@onclick="() => SwitchTab(tab)">@label</button>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
|
||||
@* ── Users ── *@
|
||||
@if (activeTab == "users")
|
||||
{
|
||||
@if (loadingUsers)
|
||||
{
|
||||
<div class="spinner-border spinner-border-sm text-primary"></div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<span class="text-muted small">@(users?.Count ?? 0) users</span>
|
||||
<button class="btn btn-sm btn-primary" @onclick="() => showAddUser = !showAddUser">
|
||||
<i class="bi bi-person-plus me-1"></i>Add User
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@if (showAddUser)
|
||||
{
|
||||
<div class="card mb-3 border-primary-subtle">
|
||||
<div class="card-body">
|
||||
@if (userError is not null)
|
||||
{
|
||||
<div class="alert alert-danger py-2 small">@userError</div>
|
||||
}
|
||||
<div class="row g-2">
|
||||
<div class="col-md-4">
|
||||
<input class="form-control form-control-sm" placeholder="Username" @bind="newUserUsername" />
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<input class="form-control form-control-sm" placeholder="Email" @bind="newUserEmail" />
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<input class="form-control form-control-sm" placeholder="First name" @bind="newUserFirstName" />
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<input class="form-control form-control-sm" placeholder="Last name" @bind="newUserLastName" />
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<input type="password" class="form-control form-control-sm" placeholder="Password (optional)" @bind="newUserPassword" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-2 d-flex gap-2">
|
||||
<button class="btn btn-sm btn-primary" @onclick="AddUser" disabled="@savingUser">
|
||||
@if (savingUser) { <span class="spinner-border spinner-border-sm me-1"></span> }
|
||||
Create
|
||||
</button>
|
||||
<button class="btn btn-sm btn-outline-secondary" @onclick="() => showAddUser = false">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (users is not null)
|
||||
{
|
||||
<div class="list-group">
|
||||
@foreach (KeycloakUserInfo user in users)
|
||||
{
|
||||
<div class="list-group-item py-2">
|
||||
<div class="d-flex justify-content-between align-items-start">
|
||||
<div>
|
||||
<strong>@user.Username</strong>
|
||||
<span class="text-muted small ms-2">@user.Email</span>
|
||||
@if (!user.Enabled)
|
||||
{
|
||||
<span class="badge bg-secondary-subtle text-secondary ms-1 small">Disabled</span>
|
||||
}
|
||||
@if (!user.EmailVerified)
|
||||
{
|
||||
<span class="badge bg-warning-subtle text-warning ms-1 small">Unverified</span>
|
||||
}
|
||||
@if (user.Attributes.Count > 0)
|
||||
{
|
||||
<div class="mt-1 d-flex flex-wrap gap-1">
|
||||
@foreach (KeyValuePair<string, List<string>> attr in user.Attributes)
|
||||
{
|
||||
<span class="badge bg-light text-secondary border font-monospace"
|
||||
style="font-size:0.7rem">
|
||||
@attr.Key: @string.Join(", ", attr.Value)
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<button class="btn btn-sm btn-outline-danger border-0 ms-2"
|
||||
@onclick="() => DeleteUser(user.Id)">
|
||||
<i class="bi bi-trash"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@* ── Identity Providers ── *@
|
||||
@if (activeTab == "idp")
|
||||
{
|
||||
@if (loadingIdps)
|
||||
{
|
||||
<div class="spinner-border spinner-border-sm text-primary"></div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<span class="text-muted small">@(idps?.Count ?? 0) providers</span>
|
||||
<button class="btn btn-sm btn-primary" @onclick="() => showAddIdp = !showAddIdp">
|
||||
<i class="bi bi-plus-circle me-1"></i>Add Provider
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@if (showAddIdp)
|
||||
{
|
||||
<div class="card mb-3 border-primary-subtle">
|
||||
<div class="card-body">
|
||||
@if (idpError is not null)
|
||||
{
|
||||
<div class="alert alert-danger py-2 small">@idpError</div>
|
||||
}
|
||||
<div class="row g-2">
|
||||
<div class="col-md-3">
|
||||
<input class="form-control form-control-sm" placeholder="Alias (e.g. google)" @bind="newIdpAlias" />
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<select class="form-select form-select-sm" @bind="newIdpProviderId">
|
||||
<option value="oidc">OpenID Connect</option>
|
||||
<option value="saml">SAML</option>
|
||||
<option value="google">Google</option>
|
||||
<option value="github">GitHub</option>
|
||||
<option value="microsoft">Microsoft</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<input class="form-control form-control-sm" placeholder="Client ID" @bind="newIdpClientId" />
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<input type="password" class="form-control form-control-sm" placeholder="Client Secret" @bind="newIdpClientSecret" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-2 d-flex gap-2">
|
||||
<button class="btn btn-sm btn-primary" @onclick="AddIdp" disabled="@savingIdp">
|
||||
@if (savingIdp) { <span class="spinner-border spinner-border-sm me-1"></span> }
|
||||
Add
|
||||
</button>
|
||||
<button class="btn btn-sm btn-outline-secondary" @onclick="() => showAddIdp = false">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (idps is not null)
|
||||
{
|
||||
<div class="list-group">
|
||||
@foreach (KeycloakIdpInfo idp in idps)
|
||||
{
|
||||
<div class="list-group-item d-flex justify-content-between align-items-center py-2">
|
||||
<div>
|
||||
<i class="bi bi-box-arrow-in-right me-2 text-muted"></i>
|
||||
<strong>@idp.Alias</strong>
|
||||
<span class="badge bg-secondary-subtle text-secondary ms-2 small">@idp.ProviderId</span>
|
||||
@if (!idp.Enabled)
|
||||
{
|
||||
<span class="badge bg-secondary-subtle text-secondary ms-1 small">Disabled</span>
|
||||
}
|
||||
</div>
|
||||
<button class="btn btn-sm btn-outline-danger border-0"
|
||||
@onclick="() => DeleteIdp(idp.Alias)">
|
||||
<i class="bi bi-trash"></i>
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@* ── Groups ── *@
|
||||
@if (activeTab == "groups")
|
||||
{
|
||||
@if (loadingGroups)
|
||||
{
|
||||
<div class="spinner-border spinner-border-sm text-primary"></div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<span class="text-muted small">@(groups?.Count ?? 0) groups</span>
|
||||
<button class="btn btn-sm btn-primary" @onclick="() => showAddGroup = !showAddGroup">
|
||||
<i class="bi bi-people me-1"></i>Add Group
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@if (showAddGroup)
|
||||
{
|
||||
<div class="d-flex gap-2 mb-3">
|
||||
<input class="form-control form-control-sm" placeholder="Group name" @bind="newGroupName" />
|
||||
<button class="btn btn-sm btn-primary" @onclick="AddGroup" disabled="@savingGroup">
|
||||
@if (savingGroup) { <span class="spinner-border spinner-border-sm me-1"></span> }
|
||||
Create
|
||||
</button>
|
||||
<button class="btn btn-sm btn-outline-secondary" @onclick="() => showAddGroup = false">Cancel</button>
|
||||
</div>
|
||||
@if (groupError is not null)
|
||||
{
|
||||
<div class="alert alert-danger py-2 small mb-3">@groupError</div>
|
||||
}
|
||||
}
|
||||
|
||||
@if (groups is not null)
|
||||
{
|
||||
<div class="list-group">
|
||||
@foreach (KeycloakGroupInfo g in groups)
|
||||
{
|
||||
<div class="list-group-item d-flex justify-content-between align-items-center py-2">
|
||||
<span><i class="bi bi-people me-2 text-muted"></i>@g.Name <span class="text-muted small">@g.Path</span></span>
|
||||
<button class="btn btn-sm btn-outline-danger border-0"
|
||||
@onclick="() => DeleteGroup(g.Id)">
|
||||
<i class="bi bi-trash"></i>
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@* ── Organizations ── *@
|
||||
@if (activeTab == "orgs")
|
||||
{
|
||||
@if (loadingOrgs)
|
||||
{
|
||||
<div class="spinner-border spinner-border-sm text-primary"></div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<span class="text-muted small">@(orgs?.Count ?? 0) organizations (Keycloak 26+)</span>
|
||||
<button class="btn btn-sm btn-primary" @onclick="() => showAddOrg = !showAddOrg">
|
||||
<i class="bi bi-building me-1"></i>Add Organization
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@if (showAddOrg)
|
||||
{
|
||||
<div class="card mb-3 border-primary-subtle">
|
||||
<div class="card-body">
|
||||
@if (orgError is not null)
|
||||
{
|
||||
<div class="alert alert-danger py-2 small">@orgError</div>
|
||||
}
|
||||
<div class="row g-2">
|
||||
<div class="col-md-4">
|
||||
<input class="form-control form-control-sm" placeholder="Name" @bind="newOrgName" />
|
||||
</div>
|
||||
<div class="col-md-8">
|
||||
<input class="form-control form-control-sm" placeholder="Description (optional)" @bind="newOrgDescription" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-2 d-flex gap-2">
|
||||
<button class="btn btn-sm btn-primary" @onclick="AddOrg" disabled="@savingOrg">
|
||||
@if (savingOrg) { <span class="spinner-border spinner-border-sm me-1"></span> }
|
||||
Create
|
||||
</button>
|
||||
<button class="btn btn-sm btn-outline-secondary" @onclick="() => showAddOrg = false">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (orgs is not null)
|
||||
{
|
||||
<div class="list-group">
|
||||
@foreach (KeycloakOrganizationInfo org in orgs)
|
||||
{
|
||||
<div class="list-group-item py-2">
|
||||
<strong>@org.Name</strong>
|
||||
@if (!string.IsNullOrWhiteSpace(org.Description))
|
||||
{
|
||||
<span class="text-muted small ms-2">@org.Description</span>
|
||||
}
|
||||
@if (!org.Enabled)
|
||||
{
|
||||
<span class="badge bg-secondary-subtle text-secondary ms-1 small">Disabled</span>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@code {
|
||||
[Parameter] public required KeycloakRealm Realm { get; set; }
|
||||
[Parameter] public Guid TenantId { get; set; }
|
||||
|
||||
private string activeTab = "users";
|
||||
|
||||
private List<KeycloakUserInfo>? users;
|
||||
private List<KeycloakIdpInfo>? idps;
|
||||
private List<KeycloakGroupInfo>? groups;
|
||||
private List<KeycloakOrganizationInfo>? orgs;
|
||||
|
||||
private bool loadingUsers, loadingIdps, loadingGroups, loadingOrgs;
|
||||
|
||||
// User form
|
||||
private bool showAddUser, savingUser;
|
||||
private string? userError;
|
||||
private string newUserUsername = "", newUserEmail = "", newUserFirstName = "", newUserLastName = "", newUserPassword = "";
|
||||
|
||||
// IdP form
|
||||
private bool showAddIdp, savingIdp;
|
||||
private string? idpError;
|
||||
private string newIdpAlias = "", newIdpProviderId = "oidc", newIdpClientId = "", newIdpClientSecret = "";
|
||||
|
||||
// Group form
|
||||
private bool showAddGroup, savingGroup;
|
||||
private string? groupError;
|
||||
private string newGroupName = "";
|
||||
|
||||
// Org form
|
||||
private bool showAddOrg, savingOrg;
|
||||
private string? orgError;
|
||||
private string newOrgName = "", newOrgDescription = "";
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
await LoadTab("users");
|
||||
}
|
||||
|
||||
private async Task SwitchTab(string tab)
|
||||
{
|
||||
activeTab = tab;
|
||||
await LoadTab(tab);
|
||||
}
|
||||
|
||||
private async Task LoadTab(string tab)
|
||||
{
|
||||
switch (tab)
|
||||
{
|
||||
case "users" when users is null:
|
||||
loadingUsers = true;
|
||||
StateHasChanged();
|
||||
try { users = await KeycloakService.GetUsersAsync(TenantId, Realm.Id); }
|
||||
catch (Exception ex) { userError = ex.Message; }
|
||||
finally { loadingUsers = false; }
|
||||
break;
|
||||
|
||||
case "idp" when idps is null:
|
||||
loadingIdps = true;
|
||||
StateHasChanged();
|
||||
try { idps = await KeycloakService.GetIdpsAsync(TenantId, Realm.Id); }
|
||||
catch (Exception ex) { idpError = ex.Message; }
|
||||
finally { loadingIdps = false; }
|
||||
break;
|
||||
|
||||
case "groups" when groups is null:
|
||||
loadingGroups = true;
|
||||
StateHasChanged();
|
||||
try { groups = await KeycloakService.GetGroupsAsync(TenantId, Realm.Id); }
|
||||
catch (Exception ex) { groupError = ex.Message; }
|
||||
finally { loadingGroups = false; }
|
||||
break;
|
||||
|
||||
case "orgs" when orgs is null:
|
||||
loadingOrgs = true;
|
||||
StateHasChanged();
|
||||
try { orgs = await KeycloakService.GetOrganizationsAsync(TenantId, Realm.Id); }
|
||||
catch (Exception ex) { orgError = ex.Message; }
|
||||
finally { loadingOrgs = false; }
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task AddUser()
|
||||
{
|
||||
savingUser = true;
|
||||
userError = null;
|
||||
try
|
||||
{
|
||||
await KeycloakService.CreateUserAsync(
|
||||
TenantId, Realm.Id,
|
||||
newUserUsername, newUserEmail, newUserFirstName, newUserLastName,
|
||||
string.IsNullOrWhiteSpace(newUserPassword) ? null : newUserPassword);
|
||||
users = await KeycloakService.GetUsersAsync(TenantId, Realm.Id);
|
||||
showAddUser = false;
|
||||
newUserUsername = newUserEmail = newUserFirstName = newUserLastName = newUserPassword = "";
|
||||
}
|
||||
catch (Exception ex) { userError = ex.Message; }
|
||||
finally { savingUser = false; }
|
||||
}
|
||||
|
||||
private async Task DeleteUser(string userId)
|
||||
{
|
||||
try
|
||||
{
|
||||
await KeycloakService.DeleteUserAsync(TenantId, Realm.Id, userId);
|
||||
users = await KeycloakService.GetUsersAsync(TenantId, Realm.Id);
|
||||
}
|
||||
catch (Exception ex) { userError = ex.Message; }
|
||||
}
|
||||
|
||||
private async Task AddIdp()
|
||||
{
|
||||
savingIdp = true;
|
||||
idpError = null;
|
||||
try
|
||||
{
|
||||
await KeycloakService.CreateIdpAsync(
|
||||
TenantId, Realm.Id,
|
||||
newIdpAlias, newIdpProviderId, newIdpClientId, newIdpClientSecret);
|
||||
idps = await KeycloakService.GetIdpsAsync(TenantId, Realm.Id);
|
||||
showAddIdp = false;
|
||||
newIdpAlias = newIdpClientId = newIdpClientSecret = "";
|
||||
}
|
||||
catch (Exception ex) { idpError = ex.Message; }
|
||||
finally { savingIdp = false; }
|
||||
}
|
||||
|
||||
private async Task DeleteIdp(string alias)
|
||||
{
|
||||
try
|
||||
{
|
||||
await KeycloakService.DeleteIdpAsync(TenantId, Realm.Id, alias);
|
||||
idps = await KeycloakService.GetIdpsAsync(TenantId, Realm.Id);
|
||||
}
|
||||
catch (Exception ex) { idpError = ex.Message; }
|
||||
}
|
||||
|
||||
private async Task AddGroup()
|
||||
{
|
||||
savingGroup = true;
|
||||
groupError = null;
|
||||
try
|
||||
{
|
||||
await KeycloakService.CreateGroupAsync(TenantId, Realm.Id, newGroupName);
|
||||
groups = await KeycloakService.GetGroupsAsync(TenantId, Realm.Id);
|
||||
showAddGroup = false;
|
||||
newGroupName = "";
|
||||
}
|
||||
catch (Exception ex) { groupError = ex.Message; }
|
||||
finally { savingGroup = false; }
|
||||
}
|
||||
|
||||
private async Task DeleteGroup(string groupId)
|
||||
{
|
||||
try
|
||||
{
|
||||
await KeycloakService.DeleteGroupAsync(TenantId, Realm.Id, groupId);
|
||||
groups = await KeycloakService.GetGroupsAsync(TenantId, Realm.Id);
|
||||
}
|
||||
catch (Exception ex) { groupError = ex.Message; }
|
||||
}
|
||||
|
||||
private async Task AddOrg()
|
||||
{
|
||||
savingOrg = true;
|
||||
orgError = null;
|
||||
try
|
||||
{
|
||||
await KeycloakService.CreateOrganizationAsync(
|
||||
TenantId, Realm.Id, newOrgName,
|
||||
string.IsNullOrWhiteSpace(newOrgDescription) ? null : newOrgDescription);
|
||||
orgs = await KeycloakService.GetOrganizationsAsync(TenantId, Realm.Id);
|
||||
showAddOrg = false;
|
||||
newOrgName = newOrgDescription = "";
|
||||
}
|
||||
catch (Exception ex) { orgError = ex.Message; }
|
||||
finally { savingOrg = false; }
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,524 @@
|
||||
@using EntKube.Web.Data
|
||||
@using EntKube.Web.Services
|
||||
@inject HarborService HarborService
|
||||
|
||||
@if (loading)
|
||||
{
|
||||
<div class="text-center py-5">
|
||||
<div class="spinner-border text-primary" role="status"></div>
|
||||
<p class="text-muted mt-2">Loading registry...</p>
|
||||
</div>
|
||||
}
|
||||
else if (loadError is not null)
|
||||
{
|
||||
<div class="alert alert-danger">
|
||||
<i class="bi bi-exclamation-triangle me-2"></i>@loadError
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="d-flex align-items-center gap-2 mb-3">
|
||||
<i class="bi bi-archive fs-4 text-primary"></i>
|
||||
<div>
|
||||
<h5 class="mb-0">@Project.ProjectName</h5>
|
||||
@if (Project.HarborComponentConfig?.RegistryUrl is not null)
|
||||
{
|
||||
<small class="text-muted font-monospace">@Project.HarborComponentConfig.RegistryUrl/@Project.ProjectName</small>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ul class="nav nav-tabs mb-3">
|
||||
<li class="nav-item">
|
||||
<button class="nav-link @(activeTab == "repos" ? "active" : "")" @onclick='() => SwitchTab("repos")'>
|
||||
<i class="bi bi-box-seam me-1"></i>Repositories
|
||||
</button>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<button class="nav-link @(activeTab == "robots" ? "active" : "")" @onclick='() => SwitchTab("robots")'>
|
||||
<i class="bi bi-robot me-1"></i>Robot Accounts
|
||||
</button>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<button class="nav-link @(activeTab == "info" ? "active" : "")" @onclick='() => SwitchTab("info")'>
|
||||
<i class="bi bi-info-circle me-1"></i>Info
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
@* ── Repositories tab ── *@
|
||||
@if (activeTab == "repos")
|
||||
{
|
||||
@if (loadingRepos)
|
||||
{
|
||||
<div class="spinner-border spinner-border-sm text-primary"></div>
|
||||
}
|
||||
else if (repoError is not null)
|
||||
{
|
||||
<div class="alert alert-danger small">@repoError</div>
|
||||
}
|
||||
else if (repos is not null && repos.Count == 0)
|
||||
{
|
||||
<div class="alert alert-light border text-muted small">
|
||||
<i class="bi bi-inbox me-1"></i>No repositories in this project yet. Push an image to get started.
|
||||
@if (Project.HarborComponentConfig?.RegistryUrl is not null)
|
||||
{
|
||||
<div class="mt-2 font-monospace">docker push @Project.HarborComponentConfig.RegistryUrl.TrimEnd('/')/@Project.ProjectName/your-image:tag</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
else if (repos is not null)
|
||||
{
|
||||
<div class="list-group">
|
||||
@foreach (HarborRepositoryInfo repo in repos)
|
||||
{
|
||||
string shortName = repo.Name.Contains('/') ? repo.Name[(repo.Name.IndexOf('/') + 1)..] : repo.Name;
|
||||
bool isExpanded = expandedRepo == repo.Name;
|
||||
|
||||
<div class="list-group-item p-0">
|
||||
<button class="btn w-100 text-start py-2 px-3 d-flex align-items-center justify-content-between"
|
||||
@onclick="() => ToggleRepo(repo)">
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<i class="bi @(isExpanded ? "bi-chevron-down" : "bi-chevron-right") text-muted small"></i>
|
||||
<i class="bi bi-box-seam text-primary"></i>
|
||||
<span class="font-monospace small fw-medium">@shortName</span>
|
||||
</div>
|
||||
<div class="d-flex gap-3 text-muted small">
|
||||
<span>@repo.ArtifactCount artifacts</span>
|
||||
<span>@FormatSize(repo.SizeBytes)</span>
|
||||
@if (repo.UpdatedAt.HasValue)
|
||||
{
|
||||
<span>@repo.UpdatedAt.Value.ToString("yyyy-MM-dd")</span>
|
||||
}
|
||||
</div>
|
||||
</button>
|
||||
|
||||
@if (isExpanded)
|
||||
{
|
||||
<div class="border-top bg-light px-3 py-2">
|
||||
@if (loadingArtifacts)
|
||||
{
|
||||
<div class="spinner-border spinner-border-sm text-primary m-2"></div>
|
||||
}
|
||||
else if (artifacts is not null && artifacts.Count == 0)
|
||||
{
|
||||
<p class="text-muted small mb-0">No artifacts in this repository.</p>
|
||||
}
|
||||
else if (artifacts is not null)
|
||||
{
|
||||
<table class="table table-sm table-borderless mb-0 small">
|
||||
<thead>
|
||||
<tr class="text-muted">
|
||||
<th>Tags</th>
|
||||
<th>Digest</th>
|
||||
<th>Size</th>
|
||||
<th>Pushed</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (HarborArtifactInfo artifact in artifacts)
|
||||
{
|
||||
<tr>
|
||||
<td>
|
||||
@if (artifact.Tags.Count == 0)
|
||||
{
|
||||
<span class="text-muted fst-italic">untagged</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
@foreach (string tag in artifact.Tags)
|
||||
{
|
||||
<span class="badge bg-primary-subtle text-primary border border-primary-subtle me-1">@tag</span>
|
||||
}
|
||||
}
|
||||
</td>
|
||||
<td class="font-monospace text-muted">@artifact.Digest[..19]…</td>
|
||||
<td>@FormatSize(artifact.SizeBytes)</td>
|
||||
<td>@artifact.PushedAt.ToString("yyyy-MM-dd HH:mm")</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
@* ── Robot Accounts tab ── *@
|
||||
@if (activeTab == "robots")
|
||||
{
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<span class="text-muted small">@(robots?.Count ?? 0) robot accounts for this project</span>
|
||||
@if (AccessRole >= CustomerAccessRole.Admin)
|
||||
{
|
||||
<button class="btn btn-sm btn-primary" @onclick="() => showCreateRobot = !showCreateRobot">
|
||||
<i class="bi bi-robot me-1"></i>New Robot
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (showCreateRobot && AccessRole >= CustomerAccessRole.Admin)
|
||||
{
|
||||
<div class="card mb-3 border-primary-subtle">
|
||||
<div class="card-body">
|
||||
@if (robotError is not null)
|
||||
{
|
||||
<div class="alert alert-danger py-2 small">@robotError</div>
|
||||
}
|
||||
@if (newRobotSecret is not null)
|
||||
{
|
||||
<div class="alert alert-success py-2">
|
||||
<strong><i class="bi bi-key me-1"></i>Robot created!</strong> Copy the secret now — it will not be shown again.
|
||||
<div class="mt-2 font-monospace small bg-white border rounded p-2 user-select-all">@newRobotSecret</div>
|
||||
<button class="btn btn-sm btn-outline-secondary mt-2"
|
||||
@onclick="() => { showCreateRobot = false; newRobotSecret = null; }">Close</button>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="row g-2">
|
||||
<div class="col-md-4">
|
||||
<label class="form-label form-label-sm">Robot name</label>
|
||||
<input class="form-control form-control-sm font-monospace" placeholder="ci-runner"
|
||||
@bind="newRobotName" />
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label form-label-sm">Description (optional)</label>
|
||||
<input class="form-control form-control-sm" @bind="newRobotDescription" />
|
||||
</div>
|
||||
<div class="col-12 d-flex gap-3 align-items-center">
|
||||
<div class="form-check">
|
||||
<input type="checkbox" class="form-check-input" id="rPull" @bind="newRobotCanPull" />
|
||||
<label class="form-check-label small" for="rPull">Pull</label>
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input type="checkbox" class="form-check-input" id="rPush" @bind="newRobotCanPush" />
|
||||
<label class="form-check-label small" for="rPush">Push</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-2 d-flex gap-2">
|
||||
<button class="btn btn-sm btn-primary" @onclick="CreateRobot" disabled="@savingRobot">
|
||||
@if (savingRobot) { <span class="spinner-border spinner-border-sm me-1"></span> }
|
||||
Create
|
||||
</button>
|
||||
<button class="btn btn-sm btn-outline-secondary"
|
||||
@onclick="() => { showCreateRobot = false; robotError = null; }">Cancel</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (loadingRobots)
|
||||
{
|
||||
<div class="spinner-border spinner-border-sm text-primary"></div>
|
||||
}
|
||||
else if (robotListError is not null)
|
||||
{
|
||||
<div class="alert alert-danger small">@robotListError</div>
|
||||
}
|
||||
else if (robots is not null)
|
||||
{
|
||||
@if (robots.Count == 0)
|
||||
{
|
||||
<p class="text-muted small">No robot accounts yet.</p>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="list-group">
|
||||
@foreach (HarborRobotInfo robot in robots)
|
||||
{
|
||||
<div class="list-group-item py-2">
|
||||
<div class="d-flex justify-content-between align-items-start">
|
||||
<div>
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<i class="bi bi-robot text-primary"></i>
|
||||
<span class="font-monospace small fw-medium">@robot.Name</span>
|
||||
@if (robot.Disabled)
|
||||
{
|
||||
<span class="badge bg-danger-subtle text-danger border border-danger-subtle small">Disabled</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="badge bg-success-subtle text-success border border-success-subtle small">Active</span>
|
||||
}
|
||||
</div>
|
||||
@if (!string.IsNullOrWhiteSpace(robot.Description))
|
||||
{
|
||||
<div class="text-muted small mt-1">@robot.Description</div>
|
||||
}
|
||||
<div class="text-muted small mt-1">
|
||||
Created @robot.CreatedAt.ToString("yyyy-MM-dd")
|
||||
@if (robot.ExpiresAt == -1)
|
||||
{
|
||||
<span class="ms-2">· Never expires</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
DateTime expiry = DateTimeOffset.FromUnixTimeSeconds(robot.ExpiresAt).UtcDateTime;
|
||||
<span class="ms-2">· Expires @expiry.ToString("yyyy-MM-dd")</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (AccessRole >= CustomerAccessRole.Admin)
|
||||
{
|
||||
@if (confirmDeleteRobot == robot.Id)
|
||||
{
|
||||
<div class="d-flex gap-1 align-items-center">
|
||||
<span class="text-danger small me-2">Delete?</span>
|
||||
<button class="btn btn-sm btn-danger" @onclick="() => DeleteRobot(robot.Id)">Yes</button>
|
||||
<button class="btn btn-sm btn-outline-secondary"
|
||||
@onclick="() => confirmDeleteRobot = null">Cancel</button>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<button class="btn btn-sm btn-outline-danger"
|
||||
@onclick="() => confirmDeleteRobot = robot.Id"
|
||||
title="Delete robot">
|
||||
<i class="bi bi-trash"></i>
|
||||
</button>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@* ── Info tab ── *@
|
||||
@if (activeTab == "info")
|
||||
{
|
||||
<div class="card border-0 bg-light">
|
||||
<div class="card-body">
|
||||
<dl class="row mb-0 small">
|
||||
<dt class="col-sm-3 text-muted">Project</dt>
|
||||
<dd class="col-sm-9 font-monospace">@Project.ProjectName</dd>
|
||||
|
||||
@if (Project.HarborComponentConfig?.RegistryUrl is not null)
|
||||
{
|
||||
<dt class="col-sm-3 text-muted">Registry</dt>
|
||||
<dd class="col-sm-9"><code>@Project.HarborComponentConfig.RegistryUrl</code></dd>
|
||||
}
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (Project.HarborComponentConfig?.RegistryUrl is not null)
|
||||
{
|
||||
<div class="mt-3">
|
||||
<h6 class="text-muted mb-2">Quick start</h6>
|
||||
<div class="bg-dark text-white rounded p-3 font-monospace small">
|
||||
<div class="text-success mb-1"># Login</div>
|
||||
<div>docker login @Project.HarborComponentConfig.RegistryUrl.TrimEnd('/')</div>
|
||||
<div class="text-success mt-2 mb-1"># Pull an image</div>
|
||||
<div>docker pull @Project.HarborComponentConfig.RegistryUrl.TrimEnd('/')/@Project.ProjectName/<span class="text-warning">image:tag</span></div>
|
||||
<div class="text-success mt-2 mb-1"># Push an image</div>
|
||||
<div>docker tag <span class="text-warning">local-image:tag</span> @Project.HarborComponentConfig.RegistryUrl.TrimEnd('/')/@Project.ProjectName/<span class="text-warning">image:tag</span></div>
|
||||
<div>docker push @Project.HarborComponentConfig.RegistryUrl.TrimEnd('/')/@Project.ProjectName/<span class="text-warning">image:tag</span></div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@code {
|
||||
[Parameter] public required HarborProject Project { get; set; }
|
||||
[Parameter] public Guid TenantId { get; set; }
|
||||
[Parameter] public CustomerAccessRole AccessRole { get; set; }
|
||||
|
||||
private bool loading = true;
|
||||
private string? loadError;
|
||||
private string activeTab = "repos";
|
||||
|
||||
// Repos
|
||||
private List<HarborRepositoryInfo>? repos;
|
||||
private bool loadingRepos;
|
||||
private string? expandedRepo;
|
||||
private List<HarborArtifactInfo>? artifacts;
|
||||
private bool loadingArtifacts;
|
||||
private string? repoError;
|
||||
|
||||
// Robots
|
||||
private List<HarborRobotInfo>? robots;
|
||||
private bool loadingRobots;
|
||||
private bool showCreateRobot;
|
||||
private bool savingRobot;
|
||||
private string newRobotName = "";
|
||||
private string newRobotDescription = "";
|
||||
private bool newRobotCanPull = true;
|
||||
private bool newRobotCanPush;
|
||||
private string? newRobotSecret;
|
||||
private string? robotError;
|
||||
private string? robotListError;
|
||||
private long? confirmDeleteRobot;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
await LoadRepos();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
loadError = ex.Message;
|
||||
}
|
||||
finally
|
||||
{
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task SwitchTab(string tab)
|
||||
{
|
||||
activeTab = tab;
|
||||
repoError = null;
|
||||
robotListError = null;
|
||||
robotError = null;
|
||||
|
||||
if (tab == "repos" && repos is null)
|
||||
await LoadRepos();
|
||||
else if (tab == "robots" && robots is null)
|
||||
await LoadRobots();
|
||||
}
|
||||
|
||||
private async Task LoadRepos()
|
||||
{
|
||||
loadingRepos = true;
|
||||
repoError = null;
|
||||
StateHasChanged();
|
||||
|
||||
try
|
||||
{
|
||||
repos = await HarborService.GetRepositoriesAsync(TenantId, Project.HarborComponentConfig!, Project.ProjectName);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
repoError = ex.Message;
|
||||
}
|
||||
finally
|
||||
{
|
||||
loadingRepos = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task LoadRobots()
|
||||
{
|
||||
loadingRobots = true;
|
||||
robotListError = null;
|
||||
StateHasChanged();
|
||||
|
||||
try
|
||||
{
|
||||
List<HarborRobotInfo> all = await HarborService.GetRobotsAsync(TenantId, Project.HarborComponentConfig!);
|
||||
// Filter to robots scoped to this project (robot name contains project name after the $ prefix).
|
||||
string projectPrefix = $"robot${Project.ProjectName}+";
|
||||
robots = all.Where(r => r.Name.StartsWith(projectPrefix, StringComparison.OrdinalIgnoreCase)).ToList();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
robotListError = ex.Message;
|
||||
}
|
||||
finally
|
||||
{
|
||||
loadingRobots = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ToggleRepo(HarborRepositoryInfo repo)
|
||||
{
|
||||
if (expandedRepo == repo.Name)
|
||||
{
|
||||
expandedRepo = null;
|
||||
artifacts = null;
|
||||
return;
|
||||
}
|
||||
|
||||
expandedRepo = repo.Name;
|
||||
artifacts = null;
|
||||
loadingArtifacts = true;
|
||||
repoError = null;
|
||||
StateHasChanged();
|
||||
|
||||
try
|
||||
{
|
||||
string shortName = repo.Name.Contains('/') ? repo.Name[(repo.Name.IndexOf('/') + 1)..] : repo.Name;
|
||||
artifacts = await HarborService.GetArtifactsAsync(
|
||||
TenantId, Project.HarborComponentConfig!, Project.ProjectName, shortName);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
repoError = ex.Message;
|
||||
}
|
||||
finally
|
||||
{
|
||||
loadingArtifacts = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task CreateRobot()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(newRobotName)) return;
|
||||
|
||||
savingRobot = true;
|
||||
robotError = null;
|
||||
|
||||
try
|
||||
{
|
||||
string secret = await HarborService.CreateRobotAsync(
|
||||
TenantId, Project.HarborComponentConfig!,
|
||||
newRobotName.Trim(),
|
||||
string.IsNullOrWhiteSpace(newRobotDescription) ? null : newRobotDescription.Trim(),
|
||||
[Project.ProjectName],
|
||||
newRobotCanPush, newRobotCanPull);
|
||||
|
||||
newRobotSecret = secret;
|
||||
await LoadRobots();
|
||||
newRobotName = "";
|
||||
newRobotDescription = "";
|
||||
newRobotCanPull = true;
|
||||
newRobotCanPush = false;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
robotError = ex.Message;
|
||||
}
|
||||
finally
|
||||
{
|
||||
savingRobot = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task DeleteRobot(long robotId)
|
||||
{
|
||||
confirmDeleteRobot = null;
|
||||
robotListError = null;
|
||||
|
||||
try
|
||||
{
|
||||
await HarborService.DeleteRobotAsync(TenantId, Project.HarborComponentConfig!, robotId);
|
||||
await LoadRobots();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
robotListError = ex.Message;
|
||||
}
|
||||
}
|
||||
|
||||
private static string FormatSize(long bytes)
|
||||
{
|
||||
if (bytes <= 0) return "0 B";
|
||||
if (bytes < 1024) return $"{bytes} B";
|
||||
if (bytes < 1024 * 1024) return $"{bytes / 1024.0:F1} KB";
|
||||
if (bytes < 1024L * 1024 * 1024) return $"{bytes / (1024.0 * 1024):F1} MB";
|
||||
return $"{bytes / (1024.0 * 1024 * 1024):F1} GB";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,500 @@
|
||||
@using EntKube.Web.Data
|
||||
@using EntKube.Web.Services
|
||||
@inject VaultService VaultService
|
||||
@inject TenantService TenantService
|
||||
@using EntKube.Web.Components.Pages.Shared
|
||||
|
||||
@* ═══════════════════════════════════════════════════════════════════
|
||||
App Secrets — customer portal view for managing vault secrets
|
||||
scoped to a single app.
|
||||
|
||||
Access rules:
|
||||
Viewer+ — list secret names, reveal values on demand
|
||||
Admin — add / update / delete secrets, configure K8s sync
|
||||
═══════════════════════════════════════════════════════════════════ *@
|
||||
|
||||
<div class="d-flex align-items-center mb-3">
|
||||
<i class="bi bi-shield-lock fs-4 me-2 text-primary"></i>
|
||||
<div>
|
||||
<h5 class="mb-0">Secrets — @App.Name</h5>
|
||||
<small class="text-muted">
|
||||
Application secrets stored with AES-256-GCM envelope encryption.
|
||||
Values are never transmitted in plain text.
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (!vaultInitialized)
|
||||
{
|
||||
<div class="alert alert-warning py-2 small">
|
||||
<i class="bi bi-shield-exclamation me-1"></i>
|
||||
No vault has been set up for this tenant. Ask your platform administrator to initialize it.
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
@* ── Add Secret form (Admin only) ── *@
|
||||
@if (AccessRole >= CustomerAccessRole.Admin)
|
||||
{
|
||||
<div class="card shadow-sm mb-3">
|
||||
<div class="card-header bg-white py-2">
|
||||
<i class="bi bi-plus-circle me-1 text-primary"></i><strong>Add / Update Secret</strong>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row g-2 align-items-end">
|
||||
<div class="col-md-4">
|
||||
<label class="form-label small">Name</label>
|
||||
<input type="text" class="form-control form-control-sm"
|
||||
placeholder="e.g. API_KEY, DB_PASSWORD"
|
||||
@bind="newSecretName" @bind:event="oninput" />
|
||||
</div>
|
||||
<div class="col-md-5">
|
||||
<label class="form-label small">Value</label>
|
||||
<input type="password" class="form-control form-control-sm"
|
||||
placeholder="Secret value"
|
||||
@bind="newSecretValue" @bind:event="oninput" />
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<button class="btn btn-sm btn-primary" @onclick="AddSecret"
|
||||
disabled="@(string.IsNullOrWhiteSpace(newSecretName) || string.IsNullOrWhiteSpace(newSecretValue) || saving)">
|
||||
@if (saving) { <span class="spinner-border spinner-border-sm me-1"></span> }
|
||||
<i class="bi bi-plus-lg me-1"></i>Set Secret
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-text">
|
||||
If a secret with this name already exists it will be updated in place.
|
||||
</div>
|
||||
@if (errorMessage is not null)
|
||||
{
|
||||
<div class="alert alert-danger py-1 small mt-2 mb-0">
|
||||
<i class="bi bi-exclamation-triangle me-1"></i>@errorMessage
|
||||
</div>
|
||||
}
|
||||
@if (successMessage is not null)
|
||||
{
|
||||
<div class="alert alert-success py-1 small mt-2 mb-0">
|
||||
<i class="bi bi-check-circle me-1"></i>@successMessage
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@* ── Secrets list ── *@
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-header bg-white py-2 d-flex align-items-center justify-content-between">
|
||||
<div>
|
||||
<i class="bi bi-lock me-2 text-primary"></i>
|
||||
<strong>Stored Secrets</strong>
|
||||
@if (secrets is not null)
|
||||
{
|
||||
<span class="badge bg-secondary ms-1">@secrets.Count</span>
|
||||
}
|
||||
</div>
|
||||
@if (AccessRole >= CustomerAccessRole.Admin && secrets?.Any(s => s.SyncToKubernetes) == true)
|
||||
{
|
||||
<button class="btn btn-sm btn-outline-success" @onclick="SyncAllToKubernetes" disabled="@syncingSecrets">
|
||||
@if (syncingSecrets) { <span class="spinner-border spinner-border-sm me-1"></span> }
|
||||
else { <i class="bi bi-cloud-arrow-up me-1"></i> }
|
||||
Sync All to K8s
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
@if (secrets is null)
|
||||
{
|
||||
<div class="text-center py-4">
|
||||
<div class="spinner-border spinner-border-sm text-primary" role="status"></div>
|
||||
</div>
|
||||
}
|
||||
else if (secrets.Count == 0)
|
||||
{
|
||||
<div class="text-center py-5">
|
||||
<i class="bi bi-lock text-muted" style="font-size: 2rem;"></i>
|
||||
<p class="text-muted small mt-2 mb-0">No secrets stored for this app yet.</p>
|
||||
@if (AccessRole >= CustomerAccessRole.Admin)
|
||||
{
|
||||
<p class="text-muted small mb-0">Use the form above to add one.</p>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover align-middle mb-0">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>K8s Sync</th>
|
||||
<th>K8s Secret</th>
|
||||
<th>Namespace</th>
|
||||
<th>Updated</th>
|
||||
<th class="text-end" style="min-width: 120px;">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (VaultSecret secret in secrets)
|
||||
{
|
||||
<tr>
|
||||
<td><code>@secret.Name</code></td>
|
||||
<td>
|
||||
@if (secret.SyncToKubernetes)
|
||||
{
|
||||
<span class="badge bg-success"><i class="bi bi-cloud-check me-1"></i>Enabled</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="badge bg-secondary">Off</span>
|
||||
}
|
||||
</td>
|
||||
<td><small class="text-muted">@(secret.KubernetesSecretName ?? "—")</small></td>
|
||||
<td><small class="text-muted">@(secret.KubernetesNamespace ?? "—")</small></td>
|
||||
<td><small class="text-muted">@secret.UpdatedAt.ToString("MMM d, HH:mm")</small></td>
|
||||
<td class="text-end">
|
||||
@* Reveal — available to all roles *@
|
||||
<button class="btn btn-sm btn-outline-secondary me-1"
|
||||
@onclick="() => RevealSecret(secret)"
|
||||
title="@(revealedSecretId == secret.Id ? "Hide value" : "Reveal value")">
|
||||
<i class="bi @(revealedSecretId == secret.Id ? "bi-eye-slash" : "bi-eye")"></i>
|
||||
</button>
|
||||
|
||||
@* Admin-only: edit, K8s sync, delete *@
|
||||
@if (AccessRole >= CustomerAccessRole.Admin)
|
||||
{
|
||||
<button class="btn btn-sm btn-outline-warning me-1"
|
||||
@onclick="() => StartEdit(secret)" title="Update value">
|
||||
<i class="bi bi-pencil"></i>
|
||||
</button>
|
||||
<button class="btn btn-sm btn-outline-info me-1"
|
||||
@onclick="() => ToggleSync(secret)"
|
||||
title="@(secret.SyncToKubernetes ? "Disable K8s sync" : "Configure K8s sync")">
|
||||
<i class="bi @(secret.SyncToKubernetes ? "bi-cloud-slash" : "bi-cloud-arrow-up")"></i>
|
||||
</button>
|
||||
<button class="btn btn-sm btn-outline-danger"
|
||||
@onclick="() => DeleteSecret(secret.Id)" title="Delete secret">
|
||||
<i class="bi bi-trash"></i>
|
||||
</button>
|
||||
}
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@* ── Reveal value row ── *@
|
||||
@if (revealedSecretId == secret.Id)
|
||||
{
|
||||
<tr class="table-light">
|
||||
<td colspan="6">
|
||||
<div class="d-flex align-items-center px-2 py-1 gap-2">
|
||||
<span class="text-muted small">Value:</span>
|
||||
@if (revealLoading)
|
||||
{
|
||||
<div class="spinner-border spinner-border-sm text-primary" role="status"></div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<code class="user-select-all">@revealedValue</code>
|
||||
}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
|
||||
@* ── Edit value row ── *@
|
||||
@if (editSecretId == secret.Id)
|
||||
{
|
||||
<tr class="table-light">
|
||||
<td colspan="6">
|
||||
<div class="row g-2 p-2 align-items-center">
|
||||
<div class="col-md-6">
|
||||
<input type="password" class="form-control form-control-sm"
|
||||
placeholder="New secret value"
|
||||
@bind="editSecretValue" />
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<button class="btn btn-sm btn-success" @onclick="SaveEdit"
|
||||
disabled="@(string.IsNullOrWhiteSpace(editSecretValue) || saving)">
|
||||
@if (saving) { <span class="spinner-border spinner-border-sm me-1"></span> }
|
||||
<i class="bi bi-check-lg me-1"></i>Update
|
||||
</button>
|
||||
<button class="btn btn-sm btn-outline-secondary ms-1" @onclick="CancelEdit">
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
|
||||
@* ── K8s sync config row ── *@
|
||||
@if (syncConfigSecretId == secret.Id)
|
||||
{
|
||||
<tr class="table-light">
|
||||
<td colspan="6">
|
||||
<div class="row g-2 p-2 align-items-center">
|
||||
@if (clusters is not null)
|
||||
{
|
||||
<div class="col-md-3">
|
||||
<select class="form-select form-select-sm" @bind="syncClusterId">
|
||||
<option value="@Guid.Empty">Target cluster…</option>
|
||||
@foreach (KubernetesCluster cluster in clusters)
|
||||
{
|
||||
<option value="@cluster.Id">@cluster.Name</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
}
|
||||
<div class="col-md-3">
|
||||
<input type="text" class="form-control form-control-sm"
|
||||
placeholder="K8s Secret name (e.g. my-app-secrets)"
|
||||
@bind="syncSecretName" />
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<input type="text" class="form-control form-control-sm"
|
||||
placeholder="Namespace (e.g. my-app)"
|
||||
@bind="syncNamespace" />
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<button class="btn btn-sm btn-success" @onclick="SaveSync">
|
||||
<i class="bi bi-check-lg me-1"></i>Save
|
||||
</button>
|
||||
<button class="btn btn-sm btn-outline-secondary ms-1"
|
||||
@onclick="() => syncConfigSecretId = null">
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
@if (syncOutput is not null)
|
||||
{
|
||||
<div class="m-3 p-2 bg-dark text-white rounded font-monospace small" style="white-space: pre-wrap; max-height: 200px; overflow-y: auto;">@syncOutput</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@* ── Docker Registry Credentials ── *@
|
||||
<div class="mt-4">
|
||||
<div class="d-flex align-items-center mb-2">
|
||||
<i class="bi bi-box-seam fs-5 me-2 text-primary"></i>
|
||||
<h6 class="mb-0">Docker Registry Credentials</h6>
|
||||
<span class="ms-2 text-muted small">Pull secrets for private container registries</span>
|
||||
</div>
|
||||
<DockerRegistriesPanel
|
||||
TenantId="TenantId"
|
||||
AppId="App.Id"
|
||||
IsAdmin="AccessRole >= CustomerAccessRole.Admin" />
|
||||
</div>
|
||||
}
|
||||
|
||||
@code {
|
||||
[Parameter] public required Data.App App { get; set; }
|
||||
[Parameter] public Guid TenantId { get; set; }
|
||||
[Parameter] public CustomerAccessRole AccessRole { get; set; }
|
||||
|
||||
private bool vaultInitialized;
|
||||
private List<VaultSecret>? secrets;
|
||||
private List<KubernetesCluster>? clusters;
|
||||
|
||||
// Add form
|
||||
private string newSecretName = "";
|
||||
private string newSecretValue = "";
|
||||
private bool saving;
|
||||
private string? errorMessage;
|
||||
private string? successMessage;
|
||||
|
||||
// Reveal
|
||||
private Guid? revealedSecretId;
|
||||
private string? revealedValue;
|
||||
private bool revealLoading;
|
||||
|
||||
// Edit
|
||||
private Guid? editSecretId;
|
||||
private string editSecretValue = "";
|
||||
|
||||
// K8s sync config
|
||||
private Guid? syncConfigSecretId;
|
||||
private string syncSecretName = "";
|
||||
private string syncNamespace = "";
|
||||
private Guid syncClusterId;
|
||||
|
||||
// Sync-to-K8s state
|
||||
private bool syncingSecrets;
|
||||
private string? syncOutput;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
SecretVault? vault = await VaultService.GetVaultAsync(TenantId);
|
||||
vaultInitialized = vault is not null;
|
||||
|
||||
if (vaultInitialized)
|
||||
{
|
||||
await LoadSecrets();
|
||||
}
|
||||
|
||||
// Load clusters for the K8s sync config dropdown.
|
||||
clusters = await TenantService.GetClustersAsync(TenantId);
|
||||
}
|
||||
|
||||
private async Task LoadSecrets()
|
||||
{
|
||||
secrets = await VaultService.GetAppSecretsAsync(TenantId, App.Id);
|
||||
}
|
||||
|
||||
// ──────── Add ────────
|
||||
|
||||
private async Task AddSecret()
|
||||
{
|
||||
errorMessage = null;
|
||||
successMessage = null;
|
||||
saving = true;
|
||||
|
||||
try
|
||||
{
|
||||
await VaultService.SetAppSecretAsync(TenantId, App.Id, newSecretName.Trim(), newSecretValue.Trim());
|
||||
successMessage = $"Secret '{newSecretName.Trim()}' saved.";
|
||||
newSecretName = "";
|
||||
newSecretValue = "";
|
||||
await LoadSecrets();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
errorMessage = ex.Message;
|
||||
}
|
||||
finally { saving = false; }
|
||||
}
|
||||
|
||||
// ──────── Reveal ────────
|
||||
|
||||
private async Task RevealSecret(VaultSecret secret)
|
||||
{
|
||||
if (revealedSecretId == secret.Id)
|
||||
{
|
||||
revealedSecretId = null;
|
||||
revealedValue = null;
|
||||
return;
|
||||
}
|
||||
|
||||
revealLoading = true;
|
||||
revealedSecretId = secret.Id;
|
||||
revealedValue = null;
|
||||
revealedValue = await VaultService.GetSecretValueByIdAsync(secret.Id);
|
||||
revealLoading = false;
|
||||
}
|
||||
|
||||
// ──────── Edit ────────
|
||||
|
||||
private void StartEdit(VaultSecret secret)
|
||||
{
|
||||
editSecretId = secret.Id;
|
||||
editSecretValue = "";
|
||||
revealedSecretId = null;
|
||||
revealedValue = null;
|
||||
}
|
||||
|
||||
private void CancelEdit()
|
||||
{
|
||||
editSecretId = null;
|
||||
editSecretValue = "";
|
||||
}
|
||||
|
||||
private async Task SaveEdit()
|
||||
{
|
||||
if (editSecretId is null || string.IsNullOrWhiteSpace(editSecretValue)) return;
|
||||
|
||||
saving = true;
|
||||
errorMessage = null;
|
||||
|
||||
try
|
||||
{
|
||||
bool updated = await VaultService.UpdateSecretValueAsync(editSecretId.Value, editSecretValue.Trim());
|
||||
successMessage = updated ? "Secret value updated." : null;
|
||||
errorMessage = updated ? null : "Failed to update secret.";
|
||||
}
|
||||
finally { saving = false; }
|
||||
|
||||
editSecretId = null;
|
||||
editSecretValue = "";
|
||||
await LoadSecrets();
|
||||
}
|
||||
|
||||
// ──────── Delete ────────
|
||||
|
||||
private async Task DeleteSecret(Guid secretId)
|
||||
{
|
||||
errorMessage = null;
|
||||
(bool canDelete, string? reason) = await VaultService.CanDeleteSecretAsync(secretId);
|
||||
|
||||
if (!canDelete)
|
||||
{
|
||||
errorMessage = reason;
|
||||
return;
|
||||
}
|
||||
|
||||
await VaultService.DeleteSecretAsync(secretId);
|
||||
revealedSecretId = null;
|
||||
editSecretId = null;
|
||||
await LoadSecrets();
|
||||
}
|
||||
|
||||
// ──────── K8s Sync ────────
|
||||
|
||||
private void ToggleSync(VaultSecret secret)
|
||||
{
|
||||
if (secret.SyncToKubernetes)
|
||||
{
|
||||
_ = DisableSync(secret.Id);
|
||||
}
|
||||
else
|
||||
{
|
||||
syncConfigSecretId = secret.Id;
|
||||
syncSecretName = secret.KubernetesSecretName ?? "";
|
||||
syncNamespace = secret.KubernetesNamespace ?? "";
|
||||
syncClusterId = secret.KubernetesClusterId ?? Guid.Empty;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task DisableSync(Guid secretId)
|
||||
{
|
||||
await VaultService.ConfigureKubernetesSyncAsync(secretId, syncEnabled: false, secretName: null, ns: null);
|
||||
await LoadSecrets();
|
||||
}
|
||||
|
||||
private async Task SaveSync()
|
||||
{
|
||||
if (syncConfigSecretId is null) return;
|
||||
|
||||
Guid? clusterId = syncClusterId != Guid.Empty ? syncClusterId : (Guid?)null;
|
||||
|
||||
await VaultService.ConfigureKubernetesSyncAsync(
|
||||
syncConfigSecretId.Value,
|
||||
syncEnabled: true,
|
||||
secretName: syncSecretName.Trim(),
|
||||
ns: syncNamespace.Trim(),
|
||||
clusterId: clusterId);
|
||||
|
||||
syncConfigSecretId = null;
|
||||
await LoadSecrets();
|
||||
}
|
||||
|
||||
private async Task SyncAllToKubernetes()
|
||||
{
|
||||
syncingSecrets = true;
|
||||
syncOutput = null;
|
||||
|
||||
try
|
||||
{
|
||||
HelmExecutionResult result = await VaultService.SyncAppSecretsToKubernetesAsync(TenantId, App.Id);
|
||||
syncOutput = result.Output;
|
||||
}
|
||||
finally
|
||||
{
|
||||
syncingSecrets = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,615 @@
|
||||
@using EntKube.Web.Data
|
||||
@using EntKube.Web.Services
|
||||
@inject DockerRegistryService DockerRegistryService
|
||||
@inject TenantService TenantService
|
||||
@inject VaultService VaultService
|
||||
|
||||
@* ═══════════════════════════════════════════════════════════════════
|
||||
Docker / OCI Registry Credentials panel.
|
||||
|
||||
Reused in both the tenant admin Vault tab and the customer portal
|
||||
Secrets detail view. Pass AppId to scope credentials to one app;
|
||||
leave it as Guid.Empty for a tenant-wide view.
|
||||
|
||||
Access rules (controlled by IsAdmin parameter):
|
||||
Any user — list credentials, reveal password on demand
|
||||
Admin — add / edit / delete credentials, configure K8s sync, trigger sync
|
||||
═══════════════════════════════════════════════════════════════════ *@
|
||||
|
||||
@if (!vaultInitialized)
|
||||
{
|
||||
<div class="alert alert-warning py-2 small mb-0">
|
||||
<i class="bi bi-shield-exclamation me-1"></i>
|
||||
No vault has been set up for this tenant — initialize it first before storing credentials.
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
@* ── Add credential form (Admin only, collapsible) ── *@
|
||||
@if (IsAdmin)
|
||||
{
|
||||
<div class="card shadow-sm mb-3">
|
||||
<div class="card-header bg-white py-2 d-flex align-items-center justify-content-between"
|
||||
role="button" @onclick="() => showAddForm = !showAddForm" style="cursor: pointer;">
|
||||
<div>
|
||||
<i class="bi bi-plus-circle me-1 text-primary"></i>
|
||||
<strong>Add Registry Credential</strong>
|
||||
</div>
|
||||
<i class="bi @(showAddForm ? "bi-chevron-up" : "bi-chevron-down") text-muted"></i>
|
||||
</div>
|
||||
@if (showAddForm)
|
||||
{
|
||||
<div class="card-body">
|
||||
<div class="row g-2 mb-2">
|
||||
<div class="col-md-4">
|
||||
<label class="form-label small">Display Name</label>
|
||||
<input class="form-control form-control-sm" placeholder="e.g. Production ACR"
|
||||
@bind="addName" @bind:event="oninput" />
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label small">Registry Type</label>
|
||||
<select class="form-select form-select-sm" @bind="addType" @bind:after="OnAddTypeChanged">
|
||||
@foreach (DockerRegistryType t in Enum.GetValues<DockerRegistryType>())
|
||||
{
|
||||
<option value="@t">@DockerRegistryService.TypeLabel(t)</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label small">
|
||||
Registry Server
|
||||
@if (!string.IsNullOrEmpty(DockerRegistryService.DefaultServer(addType)))
|
||||
{
|
||||
<span class="text-muted ms-1">(auto-filled)</span>
|
||||
}
|
||||
</label>
|
||||
<input class="form-control form-control-sm"
|
||||
placeholder="@DockerRegistryService.ServerPlaceholder(addType)"
|
||||
@bind="addServer" @bind:event="oninput"
|
||||
readonly="@(!string.IsNullOrEmpty(DockerRegistryService.DefaultServer(addType)))" />
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label small">Username</label>
|
||||
<input class="form-control form-control-sm"
|
||||
placeholder="@(addType == DockerRegistryType.AzureContainerRegistry ? "Service principal ID" : "username")"
|
||||
@bind="addUsername" @bind:event="oninput" />
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label small">
|
||||
Password / Token
|
||||
@if (addType == DockerRegistryType.AzureContainerRegistry)
|
||||
{
|
||||
<span class="text-muted ms-1">or service principal secret</span>
|
||||
}
|
||||
</label>
|
||||
<input type="password" class="form-control form-control-sm"
|
||||
@bind="addPassword" @bind:event="oninput" />
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label small">Email <span class="text-muted">(optional)</span></label>
|
||||
<input class="form-control form-control-sm" placeholder="user@example.com"
|
||||
@bind="addEmail" @bind:event="oninput" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (addError is not null)
|
||||
{
|
||||
<div class="alert alert-danger py-1 small mb-2">
|
||||
<i class="bi bi-exclamation-triangle me-1"></i>@addError
|
||||
</div>
|
||||
}
|
||||
@if (addSuccess is not null)
|
||||
{
|
||||
<div class="alert alert-success py-1 small mb-2">
|
||||
<i class="bi bi-check-circle me-1"></i>@addSuccess
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="d-flex gap-1">
|
||||
<button class="btn btn-sm btn-primary" @onclick="AddCredential"
|
||||
disabled="@(string.IsNullOrWhiteSpace(addName)
|
||||
|| string.IsNullOrWhiteSpace(addServer)
|
||||
|| string.IsNullOrWhiteSpace(addUsername)
|
||||
|| string.IsNullOrWhiteSpace(addPassword)
|
||||
|| saving)">
|
||||
@if (saving) { <span class="spinner-border spinner-border-sm me-1"></span> }
|
||||
<i class="bi bi-plus-lg me-1"></i>Add Credential
|
||||
</button>
|
||||
<button class="btn btn-sm btn-outline-secondary" @onclick="ResetAddForm">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@* ── Credentials table ── *@
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-header bg-white py-2 d-flex align-items-center">
|
||||
<i class="bi bi-box-seam me-2 text-primary"></i>
|
||||
<strong>Registry Credentials</strong>
|
||||
@if (credentials is not null)
|
||||
{
|
||||
<span class="badge bg-secondary ms-1">@credentials.Count</span>
|
||||
}
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
@if (credentials is null)
|
||||
{
|
||||
<div class="text-center py-4">
|
||||
<div class="spinner-border spinner-border-sm text-primary" role="status"></div>
|
||||
</div>
|
||||
}
|
||||
else if (credentials.Count == 0)
|
||||
{
|
||||
<div class="text-center py-5">
|
||||
<i class="bi bi-box-seam text-muted" style="font-size: 2rem;"></i>
|
||||
<p class="text-muted small mt-2 mb-0">No registry credentials stored yet.</p>
|
||||
@if (IsAdmin)
|
||||
{
|
||||
<p class="text-muted small mb-0">Use the form above to add one.</p>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover align-middle mb-0">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Type</th>
|
||||
<th>Server</th>
|
||||
<th>Username</th>
|
||||
<th>K8s Sync</th>
|
||||
<th>Updated</th>
|
||||
<th class="text-end" style="min-width: 130px;">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (DockerRegistryCredential cred in credentials)
|
||||
{
|
||||
<tr>
|
||||
<td class="fw-medium small">@cred.Name</td>
|
||||
<td>
|
||||
<span class="badge @TypeBadgeClass(cred.RegistryType)">
|
||||
@DockerRegistryService.TypeLabel(cred.RegistryType)
|
||||
</span>
|
||||
</td>
|
||||
<td><small class="text-muted font-monospace">@cred.Server</small></td>
|
||||
<td><code class="small">@cred.Username</code></td>
|
||||
<td>
|
||||
@if (!string.IsNullOrEmpty(cred.KubernetesSecretName))
|
||||
{
|
||||
<span class="badge bg-success"><i class="bi bi-cloud-check me-1"></i>Configured</span>
|
||||
<br />
|
||||
<small class="text-muted">@cred.KubernetesCluster?.Name · @cred.KubernetesNamespace · @cred.KubernetesSecretName</small>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="badge bg-secondary">Not configured</span>
|
||||
}
|
||||
</td>
|
||||
<td><small class="text-muted">@cred.UpdatedAt.ToString("MMM d, HH:mm")</small></td>
|
||||
<td class="text-end">
|
||||
@* Reveal password — all roles *@
|
||||
<button class="btn btn-sm btn-outline-secondary me-1"
|
||||
title="@(revealedId == cred.Id ? "Hide password" : "Reveal password")"
|
||||
@onclick="() => RevealPassword(cred)">
|
||||
<i class="bi @(revealedId == cred.Id ? "bi-eye-slash" : "bi-eye")"></i>
|
||||
</button>
|
||||
|
||||
@if (IsAdmin)
|
||||
{
|
||||
@* Edit *@
|
||||
<button class="btn btn-sm btn-outline-warning me-1" title="Edit credentials"
|
||||
@onclick="() => StartEdit(cred)">
|
||||
<i class="bi bi-pencil"></i>
|
||||
</button>
|
||||
@* Configure K8s sync *@
|
||||
<button class="btn btn-sm btn-outline-info me-1" title="Configure K8s sync"
|
||||
@onclick="() => StartSyncConfig(cred)">
|
||||
<i class="bi bi-cloud-arrow-up"></i>
|
||||
</button>
|
||||
@* Sync to K8s (only if configured) *@
|
||||
@if (!string.IsNullOrEmpty(cred.KubernetesSecretName))
|
||||
{
|
||||
<button class="btn btn-sm btn-outline-success me-1"
|
||||
title="Apply to Kubernetes cluster"
|
||||
disabled="@(syncingId == cred.Id)"
|
||||
@onclick="() => SyncToKubernetes(cred)">
|
||||
@if (syncingId == cred.Id)
|
||||
{
|
||||
<span class="spinner-border spinner-border-sm"></span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<i class="bi bi-play-fill"></i>
|
||||
}
|
||||
</button>
|
||||
}
|
||||
@* Delete *@
|
||||
@if (confirmDeleteId == cred.Id)
|
||||
{
|
||||
<button class="btn btn-sm btn-danger me-1" @onclick="() => DeleteCredential(cred.Id)">
|
||||
Confirm
|
||||
</button>
|
||||
<button class="btn btn-sm btn-outline-secondary" @onclick="() => confirmDeleteId = null">
|
||||
Cancel
|
||||
</button>
|
||||
}
|
||||
else
|
||||
{
|
||||
<button class="btn btn-sm btn-outline-danger" title="Delete"
|
||||
@onclick="() => confirmDeleteId = cred.Id">
|
||||
<i class="bi bi-trash"></i>
|
||||
</button>
|
||||
}
|
||||
}
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@* ── Reveal password row ── *@
|
||||
@if (revealedId == cred.Id)
|
||||
{
|
||||
<tr class="table-light">
|
||||
<td colspan="7">
|
||||
<div class="d-flex align-items-center px-2 py-1 gap-2">
|
||||
<span class="text-muted small">Password:</span>
|
||||
@if (revealLoading)
|
||||
{
|
||||
<div class="spinner-border spinner-border-sm text-primary" role="status"></div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<code class="user-select-all">@revealedPassword</code>
|
||||
}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
|
||||
@* ── Edit row ── *@
|
||||
@if (editId == cred.Id)
|
||||
{
|
||||
<tr class="table-light">
|
||||
<td colspan="7">
|
||||
<div class="row g-2 p-2 align-items-end">
|
||||
<div class="col-md-4">
|
||||
<label class="form-label small mb-1">Username</label>
|
||||
<input class="form-control form-control-sm" @bind="editUsername" />
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label small mb-1">New password / token</label>
|
||||
<input type="password" class="form-control form-control-sm"
|
||||
placeholder="Leave blank to keep existing"
|
||||
@bind="editPassword" />
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label small mb-1">Email (optional)</label>
|
||||
<input class="form-control form-control-sm" @bind="editEmail" />
|
||||
</div>
|
||||
<div class="col-12 d-flex gap-1">
|
||||
<button class="btn btn-sm btn-success" @onclick="SaveEdit" disabled="@saving">
|
||||
@if (saving) { <span class="spinner-border spinner-border-sm me-1"></span> }
|
||||
<i class="bi bi-check-lg me-1"></i>Update
|
||||
</button>
|
||||
<button class="btn btn-sm btn-outline-secondary" @onclick="CancelEdit">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
|
||||
@* ── K8s sync config row ── *@
|
||||
@if (syncConfigId == cred.Id)
|
||||
{
|
||||
<tr class="table-light">
|
||||
<td colspan="7">
|
||||
<div class="row g-2 p-2 align-items-end">
|
||||
<div class="col-md-3">
|
||||
<label class="form-label small mb-1">Cluster</label>
|
||||
@if (clusters is null)
|
||||
{
|
||||
<div class="spinner-border spinner-border-sm text-primary" role="status"></div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<select class="form-select form-select-sm" @bind="syncClusterId">
|
||||
<option value="@Guid.Empty">Select cluster…</option>
|
||||
@foreach (KubernetesCluster cluster in clusters)
|
||||
{
|
||||
<option value="@cluster.Id">@cluster.Name</option>
|
||||
}
|
||||
</select>
|
||||
}
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label small mb-1">Namespace</label>
|
||||
<input class="form-control form-control-sm"
|
||||
placeholder="e.g. my-app" @bind="syncNamespace" />
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label small mb-1">K8s Secret name</label>
|
||||
<input class="form-control form-control-sm"
|
||||
placeholder="e.g. registry-creds" @bind="syncSecretName" />
|
||||
</div>
|
||||
<div class="col-md-3 d-flex gap-1 align-items-end">
|
||||
<button class="btn btn-sm btn-success" @onclick="() => SaveSyncConfig(cred.Id)"
|
||||
disabled="@(syncClusterId == Guid.Empty || string.IsNullOrWhiteSpace(syncNamespace) || string.IsNullOrWhiteSpace(syncSecretName))">
|
||||
<i class="bi bi-check-lg me-1"></i>Save
|
||||
</button>
|
||||
<button class="btn btn-sm btn-outline-secondary" @onclick="CancelSyncConfig">
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
|
||||
@* ── Sync output row ── *@
|
||||
@if (syncOutput.TryGetValue(cred.Id, out (bool ok, string msg) syncResult))
|
||||
{
|
||||
<tr class="table-light">
|
||||
<td colspan="7">
|
||||
<div class="d-flex align-items-center justify-content-between px-2 pt-1 mb-1">
|
||||
<span class="small fw-medium @(syncResult.ok ? "text-success" : "text-danger")">
|
||||
<i class="bi @(syncResult.ok ? "bi-check-circle" : "bi-x-circle") me-1"></i>
|
||||
@(syncResult.ok ? "Synced to cluster successfully" : "Sync failed")
|
||||
</span>
|
||||
<button class="btn btn-sm btn-link text-muted py-0"
|
||||
@onclick="() => syncOutput.Remove(cred.Id)">
|
||||
<i class="bi bi-x"></i>
|
||||
</button>
|
||||
</div>
|
||||
<pre class="bg-dark text-light p-2 rounded small mb-2 mx-2"
|
||||
style="max-height: 180px; overflow-y: auto; white-space: pre-wrap; word-break: break-all;">@syncResult.msg</pre>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@code {
|
||||
[Parameter] public Guid TenantId { get; set; }
|
||||
[Parameter] public Guid AppId { get; set; } // Guid.Empty = tenant-wide
|
||||
[Parameter] public bool IsAdmin { get; set; }
|
||||
|
||||
private bool vaultInitialized;
|
||||
private List<DockerRegistryCredential>? credentials;
|
||||
private List<KubernetesCluster>? clusters;
|
||||
|
||||
// Add form
|
||||
private bool showAddForm;
|
||||
private string addName = "";
|
||||
private DockerRegistryType addType = DockerRegistryType.Generic;
|
||||
private string addServer = "";
|
||||
private string addUsername = "";
|
||||
private string addPassword = "";
|
||||
private string addEmail = "";
|
||||
private bool saving;
|
||||
private string? addError;
|
||||
private string? addSuccess;
|
||||
|
||||
// Reveal
|
||||
private Guid? revealedId;
|
||||
private string? revealedPassword;
|
||||
private bool revealLoading;
|
||||
|
||||
// Edit
|
||||
private Guid? editId;
|
||||
private string editUsername = "";
|
||||
private string editPassword = "";
|
||||
private string editEmail = "";
|
||||
|
||||
// K8s sync config
|
||||
private Guid? syncConfigId;
|
||||
private Guid syncClusterId;
|
||||
private string syncNamespace = "";
|
||||
private string syncSecretName = "";
|
||||
|
||||
// Active sync operation
|
||||
private Guid? syncingId;
|
||||
private Dictionary<Guid, (bool Ok, string Msg)> syncOutput = new();
|
||||
|
||||
// Delete confirm
|
||||
private Guid? confirmDeleteId;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
await CheckVaultAndLoad();
|
||||
}
|
||||
|
||||
private async Task CheckVaultAndLoad()
|
||||
{
|
||||
SecretVault? vault = await VaultService.GetVaultAsync(TenantId);
|
||||
vaultInitialized = vault is not null;
|
||||
|
||||
if (vaultInitialized)
|
||||
{
|
||||
await LoadCredentials();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task LoadCredentials()
|
||||
{
|
||||
credentials = await DockerRegistryService.GetAsync(
|
||||
TenantId,
|
||||
AppId == Guid.Empty ? null : AppId);
|
||||
}
|
||||
|
||||
// ──────── Add ────────
|
||||
|
||||
private void OnAddTypeChanged()
|
||||
{
|
||||
string def = DockerRegistryService.DefaultServer(addType);
|
||||
addServer = def; // auto-fill or clear
|
||||
}
|
||||
|
||||
private void ResetAddForm()
|
||||
{
|
||||
showAddForm = false;
|
||||
addName = addServer = addUsername = addPassword = addEmail = "";
|
||||
addType = DockerRegistryType.Generic;
|
||||
addError = addSuccess = null;
|
||||
}
|
||||
|
||||
private async Task AddCredential()
|
||||
{
|
||||
addError = null;
|
||||
addSuccess = null;
|
||||
saving = true;
|
||||
|
||||
try
|
||||
{
|
||||
await DockerRegistryService.CreateAsync(
|
||||
TenantId,
|
||||
AppId == Guid.Empty ? null : AppId,
|
||||
addName.Trim(),
|
||||
addType,
|
||||
addServer.Trim(),
|
||||
addUsername.Trim(),
|
||||
addPassword,
|
||||
string.IsNullOrWhiteSpace(addEmail) ? null : addEmail.Trim());
|
||||
|
||||
addSuccess = $"Credential '{addName.Trim()}' added.";
|
||||
addName = addUsername = addPassword = addEmail = "";
|
||||
// Keep form open so user can add another, but clear fields.
|
||||
await LoadCredentials();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
addError = ex.Message;
|
||||
}
|
||||
finally { saving = false; }
|
||||
}
|
||||
|
||||
// ──────── Reveal ────────
|
||||
|
||||
private async Task RevealPassword(DockerRegistryCredential cred)
|
||||
{
|
||||
if (revealedId == cred.Id)
|
||||
{
|
||||
revealedId = null;
|
||||
revealedPassword = null;
|
||||
return;
|
||||
}
|
||||
|
||||
revealLoading = true;
|
||||
revealedId = cred.Id;
|
||||
revealedPassword = null;
|
||||
revealedPassword = await DockerRegistryService.GetPasswordAsync(cred.Id);
|
||||
revealLoading = false;
|
||||
}
|
||||
|
||||
// ──────── Edit ────────
|
||||
|
||||
private void StartEdit(DockerRegistryCredential cred)
|
||||
{
|
||||
editId = cred.Id;
|
||||
editUsername = cred.Username;
|
||||
editPassword = "";
|
||||
editEmail = cred.Email ?? "";
|
||||
revealedId = null;
|
||||
syncConfigId = null;
|
||||
}
|
||||
|
||||
private void CancelEdit()
|
||||
{
|
||||
editId = null;
|
||||
editUsername = editPassword = editEmail = "";
|
||||
}
|
||||
|
||||
private async Task SaveEdit()
|
||||
{
|
||||
if (editId is null) return;
|
||||
saving = true;
|
||||
try
|
||||
{
|
||||
await DockerRegistryService.UpdateAsync(
|
||||
editId.Value,
|
||||
string.IsNullOrWhiteSpace(editUsername) ? null : editUsername.Trim(),
|
||||
string.IsNullOrWhiteSpace(editPassword) ? null : editPassword,
|
||||
editEmail);
|
||||
}
|
||||
finally { saving = false; }
|
||||
|
||||
editId = null;
|
||||
await LoadCredentials();
|
||||
}
|
||||
|
||||
// ──────── K8s Sync Config ────────
|
||||
|
||||
private async Task StartSyncConfig(DockerRegistryCredential cred)
|
||||
{
|
||||
syncConfigId = cred.Id;
|
||||
syncClusterId = cred.KubernetesClusterId ?? Guid.Empty;
|
||||
syncNamespace = cred.KubernetesNamespace ?? "";
|
||||
syncSecretName = cred.KubernetesSecretName ?? "";
|
||||
editId = null;
|
||||
|
||||
// Load clusters lazily.
|
||||
if (clusters is null)
|
||||
{
|
||||
clusters = await TenantService.GetClustersAsync(TenantId);
|
||||
}
|
||||
}
|
||||
|
||||
private void CancelSyncConfig()
|
||||
{
|
||||
syncConfigId = null;
|
||||
}
|
||||
|
||||
private async Task SaveSyncConfig(Guid credId)
|
||||
{
|
||||
await DockerRegistryService.ConfigureSyncAsync(credId, syncClusterId, syncSecretName, syncNamespace);
|
||||
syncConfigId = null;
|
||||
await LoadCredentials();
|
||||
}
|
||||
|
||||
// ──────── Sync to K8s ────────
|
||||
|
||||
private async Task SyncToKubernetes(DockerRegistryCredential cred)
|
||||
{
|
||||
syncingId = cred.Id;
|
||||
syncOutput.Remove(cred.Id);
|
||||
|
||||
KubernetesOperationResult<string> result = await DockerRegistryService.SyncToKubernetesAsync(cred.Id);
|
||||
|
||||
syncOutput[cred.Id] = (result.IsSuccess, result.IsSuccess ? result.Data ?? "" : result.Error ?? "Unknown error");
|
||||
syncingId = null;
|
||||
|
||||
await LoadCredentials();
|
||||
}
|
||||
|
||||
// ──────── Delete ────────
|
||||
|
||||
private async Task DeleteCredential(Guid credId)
|
||||
{
|
||||
await DockerRegistryService.DeleteAsync(credId);
|
||||
confirmDeleteId = null;
|
||||
revealedId = null;
|
||||
editId = null;
|
||||
syncConfigId = null;
|
||||
syncOutput.Remove(credId);
|
||||
await LoadCredentials();
|
||||
}
|
||||
|
||||
// ──────── Rendering helpers ────────
|
||||
|
||||
private static string TypeBadgeClass(DockerRegistryType type) => type switch
|
||||
{
|
||||
DockerRegistryType.DockerHub => "bg-info text-dark",
|
||||
DockerRegistryType.AzureContainerRegistry => "bg-primary",
|
||||
DockerRegistryType.Harbor => "bg-success",
|
||||
DockerRegistryType.Quay => "bg-danger",
|
||||
DockerRegistryType.GitHubContainerRegistry => "bg-dark",
|
||||
_ => "bg-secondary"
|
||||
};
|
||||
}
|
||||
570
src/EntKube.Web/Components/Pages/Shared/ResourceTreePanel.razor
Normal file
570
src/EntKube.Web/Components/Pages/Shared/ResourceTreePanel.razor
Normal file
@@ -0,0 +1,570 @@
|
||||
@using EntKube.Web.Data
|
||||
@using EntKube.Web.Services
|
||||
@inject KubernetesOperationsService K8sOps
|
||||
|
||||
@* ═══════════════════════════════════════════════════════════════════
|
||||
ResourceTreePanel — ArgoCD-style resource ownership tree.
|
||||
|
||||
Each node renders as a card with a health-coloured left border,
|
||||
kind icon, kind badge, resource name, status and health label.
|
||||
Children are indented beneath a dashed vertical guide line,
|
||||
matching the ArgoCD Application detail layout.
|
||||
═══════════════════════════════════════════════════════════════════ *@
|
||||
|
||||
<style>
|
||||
.argo-tree { font-size: .84rem; }
|
||||
|
||||
.argo-node-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
border: 1px solid #e9ecef;
|
||||
border-left: 3px solid var(--hc, #adb5bd);
|
||||
border-radius: 6px;
|
||||
padding: 5px 10px;
|
||||
background: white;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,.06);
|
||||
margin-bottom: 4px;
|
||||
min-width: 0;
|
||||
transition: background .1s;
|
||||
}
|
||||
.argo-node-card:hover { background: #f8f9ff; }
|
||||
.argo-node-card.panel-open { background: #f0f4ff; border-left-color: #6ea8fe; }
|
||||
|
||||
.argo-children {
|
||||
margin-left: 22px;
|
||||
padding-left: 14px;
|
||||
border-left: 1px dashed #ced4da;
|
||||
padding-bottom: 2px;
|
||||
}
|
||||
|
||||
.argo-kind-badge {
|
||||
font-size: .65rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: .03em;
|
||||
border: 1px solid #dee2e6;
|
||||
border-radius: 3px;
|
||||
padding: 1px 5px;
|
||||
background: #f8f9fa;
|
||||
color: #495057;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.argo-action-panel {
|
||||
margin-left: 36px;
|
||||
margin-bottom: 6px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid #3a3a4e;
|
||||
background: #1e1e2e;
|
||||
overflow: hidden;
|
||||
}
|
||||
.argo-action-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 4px 10px;
|
||||
background: #2a2a3e;
|
||||
border-bottom: 1px solid #3a3a4e;
|
||||
}
|
||||
</style>
|
||||
|
||||
@if (Loading)
|
||||
{
|
||||
<div class="text-center py-4">
|
||||
<div class="spinner-border spinner-border-sm text-primary" role="status"></div>
|
||||
<p class="text-muted small mt-2 mb-0">Querying cluster…</p>
|
||||
</div>
|
||||
}
|
||||
else if (Error is not null)
|
||||
{
|
||||
<div class="alert alert-warning py-2 small mb-0">
|
||||
<i class="bi bi-exclamation-triangle me-1"></i>@Error
|
||||
</div>
|
||||
}
|
||||
else if (Resources is null || Resources.Count == 0)
|
||||
{
|
||||
<div class="text-center py-4">
|
||||
<i class="bi bi-diagram-3 text-muted" style="font-size: 2rem;"></i>
|
||||
<p class="text-muted small mt-2 mb-0">
|
||||
No resources found in namespace <code>@Namespace</code>.
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
@if (_actionFeedback is not null)
|
||||
{
|
||||
<div class="alert @(_actionFeedbackOk ? "alert-success" : "alert-danger") alert-dismissible py-2 small mb-3">
|
||||
<i class="bi @(_actionFeedbackOk ? "bi-check-circle" : "bi-exclamation-triangle") me-1"></i>
|
||||
@_actionFeedback
|
||||
<button type="button" class="btn-close btn-close-sm" @onclick="() => _actionFeedback = null"></button>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="argo-tree">
|
||||
@foreach (DeploymentResource root in Resources)
|
||||
{
|
||||
@RenderNode(root)
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@code {
|
||||
[Parameter] public bool Loading { get; set; }
|
||||
[Parameter] public string? Error { get; set; }
|
||||
[Parameter] public List<DeploymentResource>? Resources { get; set; }
|
||||
[Parameter] public string? Namespace { get; set; }
|
||||
[Parameter] public Guid DeploymentId { get; set; }
|
||||
/// <summary>Null = full admin access (tenant view).</summary>
|
||||
[Parameter] public CustomerAccessRole? AccessRole { get; set; }
|
||||
|
||||
private bool CanLogs => AccessRole is null || AccessRole >= CustomerAccessRole.Viewer;
|
||||
private bool CanEvents => AccessRole is null || AccessRole >= CustomerAccessRole.Viewer;
|
||||
private bool CanRestart => AccessRole is null || AccessRole >= CustomerAccessRole.Operator;
|
||||
private bool CanDelete => AccessRole is null || AccessRole >= CustomerAccessRole.Operator;
|
||||
private bool CanScale => AccessRole is null || AccessRole >= CustomerAccessRole.Admin;
|
||||
|
||||
private Guid? _activePanelId;
|
||||
private string? _panelMode;
|
||||
private bool _panelLoading;
|
||||
private string? _panelText;
|
||||
private string? _panelError;
|
||||
private List<string> _containers = [];
|
||||
private string? _selectedContainer;
|
||||
private int _scaleValue = 1;
|
||||
private bool _opBusy;
|
||||
private string? _actionFeedback;
|
||||
private bool _actionFeedbackOk;
|
||||
|
||||
// ── Static visual helpers ────────────────────────────────────────────────
|
||||
|
||||
private static string HealthColor(HealthStatus h) => h switch
|
||||
{
|
||||
HealthStatus.Healthy => "#198754",
|
||||
HealthStatus.Progressing => "#0d6efd",
|
||||
HealthStatus.Degraded => "#fd7e14",
|
||||
HealthStatus.Missing => "#dc3545",
|
||||
HealthStatus.Suspended => "#6c757d",
|
||||
_ => "#adb5bd",
|
||||
};
|
||||
|
||||
private static string HealthLabel(HealthStatus h) => h switch
|
||||
{
|
||||
HealthStatus.Healthy => "Healthy",
|
||||
HealthStatus.Progressing => "Progressing",
|
||||
HealthStatus.Degraded => "Degraded",
|
||||
HealthStatus.Missing => "Missing",
|
||||
HealthStatus.Suspended => "Suspended",
|
||||
_ => "Unknown",
|
||||
};
|
||||
|
||||
private static string KindIcon(string kind) => kind switch
|
||||
{
|
||||
"Deployment" => "bi-layers",
|
||||
"StatefulSet" => "bi-database",
|
||||
"DaemonSet" => "bi-broadcast",
|
||||
"ReplicaSet" => "bi-files",
|
||||
"Pod" => "bi-cpu",
|
||||
"Service" => "bi-share",
|
||||
"Ingress" => "bi-globe",
|
||||
"HTTPRoute" => "bi-signpost-split",
|
||||
"PersistentVolumeClaim" => "bi-hdd",
|
||||
"ConfigMap" => "bi-file-code",
|
||||
"Secret" => "bi-key",
|
||||
"Job" => "bi-lightning",
|
||||
"CronJob" => "bi-clock",
|
||||
_ => "bi-box",
|
||||
};
|
||||
|
||||
private static string KindColor(string kind) => kind switch
|
||||
{
|
||||
"Deployment" or "StatefulSet" or "DaemonSet" => "text-primary",
|
||||
"ReplicaSet" => "text-secondary",
|
||||
"Pod" => "text-success",
|
||||
"Service" or "Ingress" or "HTTPRoute" => "text-info",
|
||||
"Job" or "CronJob" => "text-warning",
|
||||
"PersistentVolumeClaim" => "text-danger",
|
||||
_ => "text-muted",
|
||||
};
|
||||
|
||||
// ── Panel open/close ─────────────────────────────────────────────────────
|
||||
|
||||
private void ClosePanel()
|
||||
{
|
||||
_activePanelId = null;
|
||||
_panelMode = null;
|
||||
_panelText = null;
|
||||
_panelError = null;
|
||||
_containers = [];
|
||||
_selectedContainer = null;
|
||||
}
|
||||
|
||||
private bool IsPanelOpen(Guid id, string mode) =>
|
||||
_activePanelId == id && _panelMode == mode;
|
||||
|
||||
// ── Action handlers ──────────────────────────────────────────────────────
|
||||
|
||||
private async Task OpenLogPanel(DeploymentResource node)
|
||||
{
|
||||
if (_activePanelId == node.Id && _panelMode == "logs") { ClosePanel(); return; }
|
||||
ClosePanel();
|
||||
_activePanelId = node.Id;
|
||||
_panelMode = "logs";
|
||||
_panelLoading = true;
|
||||
StateHasChanged();
|
||||
|
||||
var cr = await K8sOps.GetPodContainersAsync(DeploymentId, node.Name);
|
||||
if (!cr.IsSuccess) { _panelError = cr.Error; _panelLoading = false; return; }
|
||||
|
||||
_containers = cr.Data ?? [];
|
||||
_selectedContainer = _containers.Count > 0 ? _containers[0] : null;
|
||||
await FetchLogs(node.Name);
|
||||
}
|
||||
|
||||
private async Task FetchLogs(string podName)
|
||||
{
|
||||
_panelLoading = true;
|
||||
_panelText = null;
|
||||
_panelError = null;
|
||||
StateHasChanged();
|
||||
|
||||
var r = await K8sOps.GetPodLogsAsync(DeploymentId, podName, _selectedContainer);
|
||||
_panelLoading = false;
|
||||
if (r.IsSuccess) _panelText = r.Data ?? "(no output)";
|
||||
else _panelError = r.Error;
|
||||
}
|
||||
|
||||
private async Task OpenEventsPanel(DeploymentResource node)
|
||||
{
|
||||
if (_activePanelId == node.Id && _panelMode == "events") { ClosePanel(); return; }
|
||||
ClosePanel();
|
||||
_activePanelId = node.Id;
|
||||
_panelMode = "events";
|
||||
_panelLoading = true;
|
||||
StateHasChanged();
|
||||
|
||||
var r = await K8sOps.GetResourceEventsAsync(DeploymentId, node.Kind, node.Name);
|
||||
_panelLoading = false;
|
||||
if (r.IsSuccess)
|
||||
_panelText = r.Data is { Count: > 0 } lines ? string.Join("\n", lines) : "(no events)";
|
||||
else _panelError = r.Error;
|
||||
}
|
||||
|
||||
private void OpenScalePanel(DeploymentResource node)
|
||||
{
|
||||
if (_activePanelId == node.Id && _panelMode == "scale") { ClosePanel(); return; }
|
||||
ClosePanel();
|
||||
_activePanelId = node.Id;
|
||||
_panelMode = "scale";
|
||||
_scaleValue = ParseDesiredReplicas(node.StatusMessage) ?? 1;
|
||||
}
|
||||
|
||||
private async Task ApplyScale(DeploymentResource node)
|
||||
{
|
||||
_opBusy = true;
|
||||
StateHasChanged();
|
||||
var r = await K8sOps.ScaleWorkloadAsync(DeploymentId, node.Kind, node.Name, _scaleValue);
|
||||
_opBusy = false;
|
||||
ClosePanel();
|
||||
Feedback(r.IsSuccess, r.IsSuccess
|
||||
? $"{node.Kind} {node.Name} scaled to {_scaleValue}."
|
||||
: r.Error ?? "Scale failed.");
|
||||
}
|
||||
|
||||
private void OpenRestartConfirm(DeploymentResource node)
|
||||
{
|
||||
if (_activePanelId == node.Id && _panelMode == "restart-confirm") { ClosePanel(); return; }
|
||||
ClosePanel();
|
||||
_activePanelId = node.Id;
|
||||
_panelMode = "restart-confirm";
|
||||
}
|
||||
|
||||
private async Task ConfirmRestart(DeploymentResource node)
|
||||
{
|
||||
_opBusy = true;
|
||||
StateHasChanged();
|
||||
var r = await K8sOps.RestartWorkloadAsync(DeploymentId, node.Kind, node.Name);
|
||||
_opBusy = false;
|
||||
ClosePanel();
|
||||
Feedback(r.IsSuccess, r.IsSuccess
|
||||
? $"Rolling restart triggered for {node.Name}."
|
||||
: r.Error ?? "Restart failed.");
|
||||
}
|
||||
|
||||
private void OpenDeleteConfirm(DeploymentResource node)
|
||||
{
|
||||
if (_activePanelId == node.Id && _panelMode == "delete-confirm") { ClosePanel(); return; }
|
||||
ClosePanel();
|
||||
_activePanelId = node.Id;
|
||||
_panelMode = "delete-confirm";
|
||||
}
|
||||
|
||||
private async Task ConfirmDelete(DeploymentResource node)
|
||||
{
|
||||
_opBusy = true;
|
||||
StateHasChanged();
|
||||
var r = await K8sOps.DeleteResourceAsync(DeploymentId, node.Kind, node.Name);
|
||||
_opBusy = false;
|
||||
ClosePanel();
|
||||
Feedback(r.IsSuccess, r.IsSuccess
|
||||
? $"{node.Kind} {node.Name} deleted."
|
||||
: r.Error ?? "Delete failed.");
|
||||
}
|
||||
|
||||
private void Feedback(bool ok, string msg)
|
||||
{
|
||||
_actionFeedbackOk = ok;
|
||||
_actionFeedback = msg;
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
private static int? ParseDesiredReplicas(string? s)
|
||||
{
|
||||
if (string.IsNullOrEmpty(s)) return null;
|
||||
int slash = s.IndexOf('/');
|
||||
if (slash < 0) return null;
|
||||
string after = s[(slash + 1)..].Split(' ')[0];
|
||||
return int.TryParse(after, out int n) ? n : null;
|
||||
}
|
||||
|
||||
// ── Recursive card renderer ──────────────────────────────────────────────
|
||||
//
|
||||
// Each call emits one node card, then (if its panel is open) the action
|
||||
// panel, then recursively all children inside .argo-children.
|
||||
//
|
||||
// IMPORTANT: keep this method free of async lambdas and complex captures.
|
||||
// Only use simple @onclick="() => SyncMethod(node)" references here.
|
||||
|
||||
private RenderFragment RenderNode(DeploymentResource node) => __builder =>
|
||||
{
|
||||
string hColor = HealthColor(node.HealthStatus);
|
||||
bool panelOpen = _activePanelId == node.Id;
|
||||
bool hasKids = node.ChildResources is { Count: > 0 };
|
||||
|
||||
// ── Card ─────────────────────────────────────────────────────────────
|
||||
<div class="argo-node-card @(panelOpen ? "panel-open" : "")"
|
||||
style="--hc: @hColor">
|
||||
|
||||
<i class="bi @KindIcon(node.Kind) @KindColor(node.Kind)"
|
||||
style="font-size: .95rem; flex-shrink: 0;"></i>
|
||||
|
||||
<span class="argo-kind-badge">@node.Kind</span>
|
||||
|
||||
<span class="fw-semibold text-truncate" style="flex: 1 1 0; min-width: 0;"
|
||||
title="@node.Name">@node.Name</span>
|
||||
|
||||
@if (!string.IsNullOrEmpty(node.StatusMessage))
|
||||
{
|
||||
<span class="text-muted small text-nowrap">@node.StatusMessage</span>
|
||||
}
|
||||
|
||||
<span class="small text-nowrap" style="color: @hColor; flex-shrink: 0;">
|
||||
● @HealthLabel(node.HealthStatus)
|
||||
</span>
|
||||
|
||||
<div class="d-flex gap-1 flex-shrink-0">
|
||||
@if (node.Kind == "Pod" && CanLogs)
|
||||
{
|
||||
<button class="btn btn-sm py-0 px-1 @(IsPanelOpen(node.Id, "logs") ? "btn-dark" : "btn-outline-secondary")"
|
||||
title="Logs" @onclick="() => OpenLogPanel(node)">
|
||||
<i class="bi bi-terminal" style="font-size: .72rem;"></i>
|
||||
</button>
|
||||
}
|
||||
@if (CanEvents)
|
||||
{
|
||||
<button class="btn btn-sm py-0 px-1 @(IsPanelOpen(node.Id, "events") ? "btn-secondary" : "btn-outline-secondary")"
|
||||
title="Events" @onclick="() => OpenEventsPanel(node)">
|
||||
<i class="bi bi-list-ul" style="font-size: .72rem;"></i>
|
||||
</button>
|
||||
}
|
||||
@if (node.Kind is "Deployment" or "StatefulSet" or "DaemonSet" && CanRestart)
|
||||
{
|
||||
<button class="btn btn-sm py-0 px-1 @(IsPanelOpen(node.Id, "restart-confirm") ? "btn-warning" : "btn-outline-warning")"
|
||||
title="Restart" @onclick="() => OpenRestartConfirm(node)">
|
||||
<i class="bi bi-arrow-repeat" style="font-size: .72rem;"></i>
|
||||
</button>
|
||||
}
|
||||
@if (node.Kind is "Deployment" or "StatefulSet" or "ReplicaSet" && CanScale)
|
||||
{
|
||||
<button class="btn btn-sm py-0 px-1 @(IsPanelOpen(node.Id, "scale") ? "btn-primary" : "btn-outline-primary")"
|
||||
title="Scale" @onclick="() => OpenScalePanel(node)">
|
||||
<i class="bi bi-sliders" style="font-size: .72rem;"></i>
|
||||
</button>
|
||||
}
|
||||
@if (node.Kind == "Pod" && CanDelete)
|
||||
{
|
||||
<button class="btn btn-sm py-0 px-1 @(IsPanelOpen(node.Id, "delete-confirm") ? "btn-danger" : "btn-outline-danger")"
|
||||
title="Delete pod" @onclick="() => OpenDeleteConfirm(node)">
|
||||
<i class="bi bi-trash" style="font-size: .72rem;"></i>
|
||||
</button>
|
||||
}
|
||||
@if (node.Kind == "Job" && CanScale)
|
||||
{
|
||||
<button class="btn btn-sm py-0 px-1 @(IsPanelOpen(node.Id, "delete-confirm") ? "btn-danger" : "btn-outline-danger")"
|
||||
title="Delete job" @onclick="() => OpenDeleteConfirm(node)">
|
||||
<i class="bi bi-trash" style="font-size: .72rem;"></i>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@* ── Action panel (between card and children) ─────────────────────── *@
|
||||
@if (panelOpen && _panelMode is not null)
|
||||
{
|
||||
@RenderActionPanel(node)
|
||||
}
|
||||
|
||||
@* ── Children ─────────────────────────────────────────────────────── *@
|
||||
@if (hasKids)
|
||||
{
|
||||
<div class="argo-children">
|
||||
@foreach (DeploymentResource child in node.ChildResources!)
|
||||
{
|
||||
@RenderNode(child)
|
||||
}
|
||||
</div>
|
||||
}
|
||||
};
|
||||
|
||||
// Separate method so the heavy panel HTML is not inside the recursive lambda.
|
||||
private RenderFragment RenderActionPanel(DeploymentResource node) => __builder =>
|
||||
{
|
||||
<div class="argo-action-panel">
|
||||
<div class="argo-action-toolbar">
|
||||
<span class="text-white small fw-medium">
|
||||
@if (_panelMode == "logs")
|
||||
{
|
||||
<i class="bi bi-terminal me-1"></i>@:Logs — @node.Name
|
||||
}
|
||||
else if (_panelMode == "events")
|
||||
{
|
||||
<i class="bi bi-list-ul me-1"></i>@:Events — @node.Name
|
||||
}
|
||||
else if (_panelMode == "scale")
|
||||
{
|
||||
<i class="bi bi-sliders me-1"></i>@:Scale — @node.Name
|
||||
}
|
||||
else if (_panelMode == "restart-confirm")
|
||||
{
|
||||
<i class="bi bi-arrow-repeat me-1"></i>@:Restart — @node.Name
|
||||
}
|
||||
else if (_panelMode == "delete-confirm")
|
||||
{
|
||||
<i class="bi bi-trash me-1"></i>@:Delete — @node.Name
|
||||
}
|
||||
</span>
|
||||
<button class="btn btn-sm btn-link text-white py-0 px-1" @onclick="ClosePanel">
|
||||
<i class="bi bi-x"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@if (_panelMode == "logs")
|
||||
{
|
||||
@if (_containers.Count > 1)
|
||||
{
|
||||
<div class="px-2 py-1 d-flex gap-2 align-items-center"
|
||||
style="background:#2a2a3e;border-bottom:1px solid #333;">
|
||||
<span class="text-muted small">Container:</span>
|
||||
@foreach (string c in _containers)
|
||||
{
|
||||
string containerName = c;
|
||||
<button class="btn btn-sm py-0 px-2 @(_selectedContainer == containerName ? "btn-primary" : "btn-outline-secondary")"
|
||||
style="font-size:.7rem;"
|
||||
@onclick="() => SwitchContainer(node.Name, containerName)">@containerName</button>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
@if (_panelLoading)
|
||||
{
|
||||
<div class="text-center py-3">
|
||||
<div class="spinner-border spinner-border-sm text-light"></div>
|
||||
</div>
|
||||
}
|
||||
else if (_panelError is not null)
|
||||
{
|
||||
<div class="alert alert-warning m-2 py-1 small">@_panelError</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<pre class="m-0 p-2 small text-light"
|
||||
style="max-height:380px;overflow-y:auto;white-space:pre-wrap;word-break:break-all;font-size:.73rem;background:transparent;">@_panelText</pre>
|
||||
}
|
||||
}
|
||||
|
||||
@if (_panelMode == "events")
|
||||
{
|
||||
@if (_panelLoading)
|
||||
{
|
||||
<div class="text-center py-3">
|
||||
<div class="spinner-border spinner-border-sm text-light"></div>
|
||||
</div>
|
||||
}
|
||||
else if (_panelError is not null)
|
||||
{
|
||||
<div class="alert alert-warning m-2 py-1 small">@_panelError</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<pre class="m-0 p-2 small text-light"
|
||||
style="max-height:260px;overflow-y:auto;white-space:pre-wrap;word-break:break-all;font-size:.73rem;background:transparent;">@_panelText</pre>
|
||||
}
|
||||
}
|
||||
|
||||
@if (_panelMode == "scale")
|
||||
{
|
||||
<div class="p-3 d-flex align-items-center gap-2 flex-wrap">
|
||||
<span class="text-light small">Replicas for <strong>@node.Name</strong></span>
|
||||
<input type="number" class="form-control form-control-sm"
|
||||
style="width:70px;background:#2a2a3e;border-color:#555;color:white;"
|
||||
min="0" max="99" @bind="_scaleValue" />
|
||||
<button class="btn btn-sm btn-primary" disabled="@_opBusy"
|
||||
@onclick="() => ApplyScale(node)">
|
||||
@if (_opBusy) { <span class="spinner-border spinner-border-sm me-1"></span> }
|
||||
Apply
|
||||
</button>
|
||||
<button class="btn btn-sm btn-outline-secondary" @onclick="ClosePanel">Cancel</button>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (_panelMode == "restart-confirm")
|
||||
{
|
||||
<div class="p-3 d-flex align-items-center gap-2 flex-wrap">
|
||||
<span class="text-light small">Rolling restart <strong>@node.Name</strong>?</span>
|
||||
<button class="btn btn-sm btn-warning" disabled="@_opBusy"
|
||||
@onclick="() => ConfirmRestart(node)">
|
||||
@if (_opBusy) { <span class="spinner-border spinner-border-sm me-1"></span> }
|
||||
<i class="bi bi-arrow-repeat me-1"></i>Restart
|
||||
</button>
|
||||
<button class="btn btn-sm btn-outline-secondary" @onclick="ClosePanel">Cancel</button>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (_panelMode == "delete-confirm")
|
||||
{
|
||||
<div class="p-3 d-flex align-items-center gap-2 flex-wrap">
|
||||
<span class="text-light small">
|
||||
Delete <strong>@node.Kind @node.Name</strong>?
|
||||
@if (node.Kind == "Pod")
|
||||
{
|
||||
<span class="text-muted"> K8s will restart it automatically.</span>
|
||||
}
|
||||
</span>
|
||||
<button class="btn btn-sm btn-danger" disabled="@_opBusy"
|
||||
@onclick="() => ConfirmDelete(node)">
|
||||
@if (_opBusy) { <span class="spinner-border spinner-border-sm me-1"></span> }
|
||||
<i class="bi bi-trash me-1"></i>Delete
|
||||
</button>
|
||||
<button class="btn btn-sm btn-outline-secondary" @onclick="ClosePanel">Cancel</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
};
|
||||
|
||||
// Non-async wrapper to avoid async lambdas inside RenderFragment.
|
||||
private void SwitchContainer(string podName, string containerName)
|
||||
{
|
||||
_selectedContainer = containerName;
|
||||
_ = FetchLogs(podName);
|
||||
}
|
||||
}
|
||||
195
src/EntKube.Web/Components/Pages/Tenants/AlertRulesPanel.razor
Normal file
195
src/EntKube.Web/Components/Pages/Tenants/AlertRulesPanel.razor
Normal file
@@ -0,0 +1,195 @@
|
||||
@using EntKube.Web.Data
|
||||
@using EntKube.Web.Services
|
||||
@inject PrometheusService PrometheusService
|
||||
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<div class="d-flex gap-2 align-items-center flex-wrap">
|
||||
<select class="form-select form-select-sm" style="width:auto" @bind="filterState" @bind:after="ApplyFilter">
|
||||
<option value="">All states</option>
|
||||
<option value="firing">Firing</option>
|
||||
<option value="pending">Pending</option>
|
||||
<option value="inactive">Inactive</option>
|
||||
</select>
|
||||
<select class="form-select form-select-sm" style="width:auto" @bind="filterSeverity" @bind:after="ApplyFilter">
|
||||
<option value="">All severities</option>
|
||||
<option value="critical">Critical</option>
|
||||
<option value="warning">Warning</option>
|
||||
<option value="info">Info</option>
|
||||
<option value="none">None</option>
|
||||
</select>
|
||||
<input class="form-control form-control-sm" style="width:200px" placeholder="Search rules…"
|
||||
value="@filterSearch" @oninput="OnSearchInput" />
|
||||
</div>
|
||||
<div class="d-flex gap-2 align-items-center">
|
||||
<span class="text-muted small">@filtered.Count of @rules.Count rules</span>
|
||||
<button class="btn btn-sm btn-outline-secondary" @onclick="Load" disabled="@isLoading">
|
||||
<span class="bi bi-arrow-clockwise @(isLoading ? "spin" : "")"></span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (isLoading)
|
||||
{
|
||||
<div class="text-center py-4 text-muted">
|
||||
<span class="bi bi-hourglass-split me-1"></span> Loading rules…
|
||||
</div>
|
||||
}
|
||||
else if (errorMessage is not null)
|
||||
{
|
||||
<div class="alert alert-warning small py-2">
|
||||
<span class="bi bi-exclamation-triangle me-1"></span>@errorMessage
|
||||
</div>
|
||||
}
|
||||
else if (filtered.Count == 0)
|
||||
{
|
||||
<div class="text-center py-4 text-muted">No rules match the filter.</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
@* Summary counts *@
|
||||
<div class="d-flex gap-3 mb-3 flex-wrap">
|
||||
@{
|
||||
int firingCount = rules.Count(r => r.State == "firing");
|
||||
int pendingCount = rules.Count(r => r.State == "pending");
|
||||
}
|
||||
@if (firingCount > 0)
|
||||
{
|
||||
<span class="badge bg-danger px-2 py-1">@firingCount firing</span>
|
||||
}
|
||||
@if (pendingCount > 0)
|
||||
{
|
||||
<span class="badge bg-warning text-dark px-2 py-1">@pendingCount pending</span>
|
||||
}
|
||||
<span class="badge bg-success px-2 py-1">@rules.Count(r => r.State == "inactive") inactive</span>
|
||||
</div>
|
||||
|
||||
@foreach (IGrouping<string, AlertRule> group in filtered.GroupBy(r => r.GroupName).OrderBy(g => g.Key))
|
||||
{
|
||||
<div class="mb-3">
|
||||
<div class="text-muted small fw-semibold mb-1 text-uppercase" style="letter-spacing:.05em">
|
||||
<span class="bi bi-collection me-1"></span>@group.Key
|
||||
</div>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm align-middle mb-0">
|
||||
<tbody>
|
||||
@foreach (AlertRule rule in group.OrderBy(r => r.Name))
|
||||
{
|
||||
<tr>
|
||||
<td style="width:90px">
|
||||
@StateIndicator(rule.State)
|
||||
</td>
|
||||
<td>
|
||||
<div class="fw-semibold small">@rule.Name</div>
|
||||
@if (!string.IsNullOrEmpty(rule.Summary))
|
||||
{
|
||||
<div class="text-muted" style="font-size:0.77rem">@rule.Summary</div>
|
||||
}
|
||||
</td>
|
||||
<td style="width:80px">
|
||||
@if (!string.IsNullOrEmpty(rule.Severity))
|
||||
{
|
||||
@SeverityBadge(rule.Severity)
|
||||
}
|
||||
</td>
|
||||
<td style="width:90px" class="text-muted small text-end">
|
||||
@if (rule.DurationSeconds > 0)
|
||||
{
|
||||
<span title="Fires after this duration">@FormatDuration(rule.DurationSeconds)</span>
|
||||
}
|
||||
</td>
|
||||
<td style="width:36px" class="text-end">
|
||||
@if (!string.IsNullOrEmpty(rule.RunbookUrl))
|
||||
{
|
||||
<a href="@rule.RunbookUrl" target="_blank" class="btn btn-sm btn-link p-0" title="Runbook">
|
||||
<span class="bi bi-book"></span>
|
||||
</a>
|
||||
}
|
||||
</td>
|
||||
</tr>
|
||||
@if (expandedRule == rule.Name + rule.GroupName)
|
||||
{
|
||||
<tr>
|
||||
<td colspan="5" class="bg-light py-2 px-3">
|
||||
<div class="font-monospace small text-muted">@rule.Query</div>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
@code {
|
||||
[Parameter] public required Guid ClusterId { get; set; }
|
||||
|
||||
private List<AlertRule> rules = [];
|
||||
private List<AlertRule> filtered = [];
|
||||
private string filterState = "";
|
||||
private string filterSeverity = "";
|
||||
private string filterSearch = "";
|
||||
private string? expandedRule;
|
||||
private bool isLoading = true;
|
||||
private string? errorMessage;
|
||||
|
||||
protected override async Task OnParametersSetAsync() => await Load();
|
||||
|
||||
private async Task Load()
|
||||
{
|
||||
isLoading = true;
|
||||
errorMessage = null;
|
||||
StateHasChanged();
|
||||
|
||||
KubernetesOperationResult<List<AlertRule>> result = await PrometheusService.GetAlertRulesAsync(ClusterId);
|
||||
if (result.IsSuccess)
|
||||
{
|
||||
rules = result.Data ?? [];
|
||||
ApplyFilter();
|
||||
}
|
||||
else
|
||||
{
|
||||
errorMessage = result.Error;
|
||||
rules = [];
|
||||
filtered = [];
|
||||
}
|
||||
isLoading = false;
|
||||
}
|
||||
|
||||
private void OnSearchInput(ChangeEventArgs e)
|
||||
{
|
||||
filterSearch = e.Value?.ToString() ?? string.Empty;
|
||||
ApplyFilter();
|
||||
}
|
||||
|
||||
private void ApplyFilter()
|
||||
{
|
||||
filtered = rules.Where(r =>
|
||||
(string.IsNullOrEmpty(filterState) || r.State == filterState) &&
|
||||
(string.IsNullOrEmpty(filterSeverity) || r.Severity == filterSeverity || (filterSeverity == "none" && string.IsNullOrEmpty(r.Severity))) &&
|
||||
(string.IsNullOrEmpty(filterSearch) || r.Name.Contains(filterSearch, StringComparison.OrdinalIgnoreCase)
|
||||
|| r.Summary.Contains(filterSearch, StringComparison.OrdinalIgnoreCase))
|
||||
).ToList();
|
||||
}
|
||||
|
||||
private static string FormatDuration(double seconds) =>
|
||||
seconds >= 3600 ? $"{(int)(seconds / 3600)}h"
|
||||
: seconds >= 60 ? $"{(int)(seconds / 60)}m"
|
||||
: $"{(int)seconds}s";
|
||||
|
||||
private static RenderFragment StateIndicator(string state) => state switch
|
||||
{
|
||||
"firing" => @<span class="badge bg-danger"><span class="bi bi-fire me-1"></span>Firing</span>,
|
||||
"pending" => @<span class="badge bg-warning text-dark"><span class="bi bi-hourglass me-1"></span>Pending</span>,
|
||||
_ => @<span class="badge bg-success-subtle text-success border"><span class="bi bi-check me-1"></span>OK</span>
|
||||
};
|
||||
|
||||
private static RenderFragment SeverityBadge(string sev) => sev switch
|
||||
{
|
||||
"critical" => @<span class="badge bg-danger-subtle text-danger border">critical</span>,
|
||||
"warning" => @<span class="badge bg-warning-subtle text-warning border">warning</span>,
|
||||
"info" => @<span class="badge bg-info-subtle text-info border">info</span>,
|
||||
_ => @<span class="badge bg-secondary-subtle text-secondary border">@sev</span>
|
||||
};
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,4 @@
|
||||
export function getElementValue(id) {
|
||||
const el = document.getElementById(id);
|
||||
return el ? el.value : '';
|
||||
}
|
||||
334
src/EntKube.Web/Components/Pages/Tenants/AppResourcesPanel.razor
Normal file
334
src/EntKube.Web/Components/Pages/Tenants/AppResourcesPanel.razor
Normal file
@@ -0,0 +1,334 @@
|
||||
@using EntKube.Web.Data
|
||||
@using EntKube.Web.Services
|
||||
@inject DeploymentService DeploymentService
|
||||
@inject KubernetesOperationsService K8sOps
|
||||
|
||||
@* ═══════════════════════════════════════════════════════════════════
|
||||
AppResourcesPanel — app-level resource overview.
|
||||
|
||||
Shows each deployment as an ArgoCD-style Application card. Clicking
|
||||
a card expands its resource tree inline. Multiple cards can be open
|
||||
simultaneously.
|
||||
|
||||
Card layout mirrors the ArgoCD Application grid:
|
||||
┌──────────────────────────────────────┐
|
||||
│ [icon] deployment-name │
|
||||
│ [type] env · cluster · ns │
|
||||
│ ● Healthy ○ Synced │
|
||||
└──────────────────────────────────────┘
|
||||
═══════════════════════════════════════════════════════════════════ *@
|
||||
|
||||
<style>
|
||||
.app-argo-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
|
||||
gap: 12px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.app-card {
|
||||
border: 1px solid #e9ecef;
|
||||
border-top: 3px solid var(--hc, #adb5bd);
|
||||
border-radius: 8px;
|
||||
background: white;
|
||||
box-shadow: 0 1px 4px rgba(0,0,0,.07);
|
||||
cursor: pointer;
|
||||
transition: box-shadow .15s, border-color .15s;
|
||||
padding: 12px 14px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
user-select: none;
|
||||
}
|
||||
.app-card:hover { box-shadow: 0 3px 10px rgba(0,0,0,.12); }
|
||||
.app-card.expanded {
|
||||
border-color: #6ea8fe;
|
||||
box-shadow: 0 0 0 2px rgba(13,110,253,.15), 0 2px 8px rgba(0,0,0,.08);
|
||||
}
|
||||
|
||||
.app-card-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.app-card-icon {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 8px;
|
||||
background: #f0f4ff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.app-card-title {
|
||||
font-weight: 600;
|
||||
font-size: .88rem;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-width: 180px;
|
||||
}
|
||||
|
||||
.app-card-meta {
|
||||
font-size: .73rem;
|
||||
color: #6c757d;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.app-card-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.argo-badge-health, .argo-badge-sync {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
font-size: .7rem;
|
||||
font-weight: 500;
|
||||
padding: 2px 7px;
|
||||
border-radius: 20px;
|
||||
border: 1px solid;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.tree-section {
|
||||
background: #f8f9fb;
|
||||
border: 1px solid #e9ecef;
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.tree-section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
</style>
|
||||
|
||||
<div class="app-argo-grid">
|
||||
@if (deployments is null)
|
||||
{
|
||||
@for (int i = 0; i < 3; i++)
|
||||
{
|
||||
<div class="app-card" style="opacity:.5;pointer-events:none;">
|
||||
<div class="app-card-header">
|
||||
<div class="app-card-icon"><i class="bi bi-rocket-takeoff text-primary"></i></div>
|
||||
<div style="flex:1;">
|
||||
<div class="app-card-title text-muted">Loading…</div>
|
||||
<div class="app-card-meta">—</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
else if (deployments.Count == 0)
|
||||
{
|
||||
<div style="grid-column: 1/-1;" class="text-center py-5">
|
||||
<i class="bi bi-rocket-takeoff text-muted" style="font-size: 2.5rem;"></i>
|
||||
<p class="text-muted small mt-2 mb-0">No deployments yet. Create one in the Deployments tab.</p>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
@foreach (AppDeployment d in deployments)
|
||||
{
|
||||
string hc = HealthColor(d.HealthStatus);
|
||||
bool open = expandedIds.Contains(d.Id);
|
||||
|
||||
<div class="app-card @(open ? "expanded" : "")"
|
||||
style="--hc: @hc;"
|
||||
@onclick="() => ToggleDeployment(d.Id)">
|
||||
|
||||
<div class="app-card-header">
|
||||
<div class="app-card-icon">
|
||||
<i class="bi @TypeIcon(d.Type) text-primary"></i>
|
||||
</div>
|
||||
<div style="flex: 1; min-width: 0;">
|
||||
<div class="app-card-title" title="@d.Name">@d.Name</div>
|
||||
<div class="app-card-meta">
|
||||
@TypeLabel(d.Type) · @d.Environment?.Name · @d.Namespace
|
||||
</div>
|
||||
</div>
|
||||
<i class="bi @(open ? "bi-chevron-up" : "bi-chevron-down") text-muted small"></i>
|
||||
</div>
|
||||
|
||||
<div class="app-card-status">
|
||||
@{
|
||||
string hcol = HealthColor(d.HealthStatus);
|
||||
string hlbl = HealthLabel(d.HealthStatus);
|
||||
var (scol, sico, slbl) = SyncInfo(d.SyncStatus);
|
||||
}
|
||||
<span class="argo-badge-health" style="color:@hcol;border-color:@hcol;">
|
||||
● @hlbl
|
||||
</span>
|
||||
<span class="argo-badge-sync" style="color:@scol;border-color:@scol;">
|
||||
<i class="bi @sico"></i> @slbl
|
||||
</span>
|
||||
@if (d.GitRepositoryId is not null && d.GitLastSyncedCommit is not null)
|
||||
{
|
||||
<span class="badge bg-secondary" style="font-size:.65rem;">
|
||||
@d.GitLastSyncedCommit[..7]
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (d.Cluster is not null)
|
||||
{
|
||||
<div style="font-size:.72rem; color:#6c757d;">
|
||||
<i class="bi bi-hdd-network me-1"></i>@d.Cluster.Name
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
|
||||
@* ── Expanded resource trees ─────────────────────────────────────────────── *@
|
||||
@if (deployments is not null)
|
||||
{
|
||||
@foreach (AppDeployment d in deployments.Where(d => expandedIds.Contains(d.Id)))
|
||||
{
|
||||
<div class="tree-section">
|
||||
<div class="tree-section-header">
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<i class="bi bi-diagram-3 text-primary"></i>
|
||||
<strong class="small">@d.Name</strong>
|
||||
<span class="text-muted small">/ @d.Namespace</span>
|
||||
@if (loadingIds.Contains(d.Id))
|
||||
{
|
||||
<div class="spinner-border spinner-border-sm text-primary" style="width:.9rem;height:.9rem;"></div>
|
||||
}
|
||||
</div>
|
||||
<button class="btn btn-sm btn-link text-muted p-0" @onclick="() => expandedIds.Remove(d.Id)" @onclick:stopPropagation>
|
||||
<i class="bi bi-x-lg"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@if (treeErrors.TryGetValue(d.Id, out string? err))
|
||||
{
|
||||
<div class="alert alert-warning py-1 small mb-0">
|
||||
<i class="bi bi-exclamation-triangle me-1"></i>@err
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<ResourceTreePanel Loading="@loadingIds.Contains(d.Id)"
|
||||
Error="@(treeErrors.TryGetValue(d.Id, out var e2) ? e2 : null)"
|
||||
Resources="@(trees.TryGetValue(d.Id, out var t) ? t : null)"
|
||||
Namespace="@d.Namespace"
|
||||
DeploymentId="@d.Id"
|
||||
AccessRole="@null" />
|
||||
}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
@code {
|
||||
[Parameter, EditorRequired] public Guid AppId { get; set; }
|
||||
|
||||
private List<AppDeployment>? deployments;
|
||||
private HashSet<Guid> expandedIds = [];
|
||||
private HashSet<Guid> loadingIds = [];
|
||||
private Dictionary<Guid, List<DeploymentResource>> trees = [];
|
||||
private Dictionary<Guid, string> treeErrors = [];
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
deployments = await DeploymentService.GetDeploymentsAsync(AppId);
|
||||
}
|
||||
|
||||
private async Task ToggleDeployment(Guid id)
|
||||
{
|
||||
if (expandedIds.Contains(id))
|
||||
{
|
||||
expandedIds.Remove(id);
|
||||
return;
|
||||
}
|
||||
|
||||
expandedIds.Add(id);
|
||||
|
||||
if (trees.ContainsKey(id)) return;
|
||||
|
||||
loadingIds.Add(id);
|
||||
treeErrors.Remove(id);
|
||||
StateHasChanged();
|
||||
|
||||
KubernetesOperationResult<List<DeploymentResource>> result =
|
||||
await K8sOps.GetLiveResourcesAsync(id);
|
||||
|
||||
loadingIds.Remove(id);
|
||||
|
||||
if (result.IsSuccess && result.Data is not null)
|
||||
{
|
||||
trees[id] = result.Data;
|
||||
(SyncStatus sync, HealthStatus health) = KubernetesOperationsService.ComputeStatusFromResources(result.Data);
|
||||
AppDeployment? d = deployments?.FirstOrDefault(x => x.Id == id);
|
||||
if (d is not null) { d.SyncStatus = sync; d.HealthStatus = health; }
|
||||
}
|
||||
else
|
||||
{
|
||||
treeErrors[id] = result.Error ?? "Failed to fetch resources.";
|
||||
}
|
||||
}
|
||||
|
||||
// ── Badge / label helpers ─────────────────────────────────────────────────
|
||||
|
||||
private static string HealthColor(HealthStatus h) => h switch
|
||||
{
|
||||
HealthStatus.Healthy => "#198754",
|
||||
HealthStatus.Progressing => "#0d6efd",
|
||||
HealthStatus.Degraded => "#fd7e14",
|
||||
HealthStatus.Missing => "#dc3545",
|
||||
HealthStatus.Suspended => "#6c757d",
|
||||
_ => "#adb5bd",
|
||||
};
|
||||
|
||||
private static string HealthLabel(HealthStatus h) => h switch
|
||||
{
|
||||
HealthStatus.Healthy => "Healthy",
|
||||
HealthStatus.Progressing => "Progressing",
|
||||
HealthStatus.Degraded => "Degraded",
|
||||
HealthStatus.Missing => "Missing",
|
||||
HealthStatus.Suspended => "Suspended",
|
||||
_ => "Unknown",
|
||||
};
|
||||
|
||||
private static (string color, string icon, string label) SyncInfo(SyncStatus s) => s switch
|
||||
{
|
||||
SyncStatus.Synced => ("#198754", "bi-check-circle", "Synced"),
|
||||
SyncStatus.OutOfSync => ("#fd7e14", "bi-arrow-left-right", "OutOfSync"),
|
||||
SyncStatus.Syncing => ("#0d6efd", "bi-arrow-repeat", "Syncing"),
|
||||
SyncStatus.Failed => ("#dc3545", "bi-x-circle", "Failed"),
|
||||
_ => ("#adb5bd", "bi-question-circle", "Unknown"),
|
||||
};
|
||||
|
||||
private static string TypeIcon(DeploymentType t) => t switch
|
||||
{
|
||||
DeploymentType.HelmChart => "bi-box-seam",
|
||||
DeploymentType.GitYaml => "bi-git",
|
||||
DeploymentType.GitHelm => "bi-git",
|
||||
DeploymentType.GitAppOfApps => "bi-diagram-2",
|
||||
_ => "bi-rocket-takeoff",
|
||||
};
|
||||
|
||||
private static string TypeLabel(DeploymentType t) => t switch
|
||||
{
|
||||
DeploymentType.Manual => "Manual",
|
||||
DeploymentType.Yaml => "YAML",
|
||||
DeploymentType.HelmChart => "Helm",
|
||||
DeploymentType.GitYaml => "Git/YAML",
|
||||
DeploymentType.GitHelm => "Git/Helm",
|
||||
DeploymentType.GitAppOfApps => "App of Apps",
|
||||
_ => t.ToString(),
|
||||
};
|
||||
}
|
||||
689
src/EntKube.Web/Components/Pages/Tenants/CacheTab.razor
Normal file
689
src/EntKube.Web/Components/Pages/Tenants/CacheTab.razor
Normal file
@@ -0,0 +1,689 @@
|
||||
@using EntKube.Web.Data
|
||||
@using EntKube.Web.Services
|
||||
@inject RedisService RedisService
|
||||
@inject TenantService TenantService
|
||||
|
||||
@* ===========================================================================
|
||||
Cache Tab — Redis cluster management via OT-Container-Kit Redis Operator.
|
||||
Full lifecycle for RedisCluster CRDs (redis.redis.opstreelabs.in/v1beta2).
|
||||
EntKube generates the auth password, creates the K8s Secret, and applies
|
||||
the RedisCluster manifest. Credentials are surfaced from the tenant vault.
|
||||
=========================================================================== *@
|
||||
|
||||
@if (loading)
|
||||
{
|
||||
<div class="text-center py-5">
|
||||
<div class="spinner-border text-primary" role="status"></div>
|
||||
<p class="text-muted mt-2">Loading Redis clusters...</p>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
@* ── Operator availability badge ── *@
|
||||
<div class="d-flex gap-2 mb-4 flex-wrap">
|
||||
@if (operatorStatus?.OperatorAvailable == true)
|
||||
{
|
||||
<span class="badge bg-success-subtle text-success border border-success-subtle px-3 py-2">
|
||||
<i class="bi bi-lightning-charge me-1"></i>Redis Operator
|
||||
@if (operatorStatus.OperatorClusterName is not null)
|
||||
{
|
||||
<small class="ms-1 opacity-75">on @operatorStatus.OperatorClusterName</small>
|
||||
}
|
||||
</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="badge bg-secondary-subtle text-secondary border px-3 py-2">
|
||||
<i class="bi bi-lightning-charge me-1"></i>Redis Operator — not installed
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
|
||||
@* ── Cluster list ── *@
|
||||
<div class="mb-4">
|
||||
<h5 class="d-flex align-items-center gap-2 mb-3">
|
||||
<i class="bi bi-server text-warning"></i>Redis Clusters
|
||||
<span class="badge bg-secondary rounded-pill">@clusters.Count</span>
|
||||
<button class="btn btn-sm btn-secondary ms-auto" @onclick="ShowCreateForm"
|
||||
disabled="@(showCreateForm || operatorStatus?.OperatorAvailable != true)">
|
||||
<i class="bi bi-plus-lg me-1"></i>Create Cluster
|
||||
</button>
|
||||
</h5>
|
||||
|
||||
@if (showCreateForm)
|
||||
{
|
||||
<div class="card shadow-sm mb-3 border-secondary">
|
||||
<div class="card-header bg-secondary bg-opacity-10 d-flex align-items-center justify-content-between">
|
||||
<h6 class="mb-0"><i class="bi bi-plus-circle me-2"></i>New Redis Cluster</h6>
|
||||
<button class="btn btn-sm btn-outline-secondary border-0" @onclick="CancelCreate">
|
||||
<i class="bi bi-x-lg"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row g-3">
|
||||
<div class="col-md-6">
|
||||
<label class="form-label small">Cluster Name</label>
|
||||
<input class="form-control form-control-sm" @bind="createName"
|
||||
placeholder="redis-cache" />
|
||||
<div class="form-text small">DNS-safe, max 63 chars.</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label small">Kubernetes Cluster</label>
|
||||
<select class="form-select form-select-sm" @bind="createK8sClusterId">
|
||||
<option value="@Guid.Empty">Select cluster...</option>
|
||||
@foreach (KubernetesCluster k in allK8sClusters)
|
||||
{
|
||||
<option value="@k.Id">@k.Name (@k.Environment.Name)</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label small">Namespace</label>
|
||||
<input class="form-control form-control-sm" @bind="createNamespace"
|
||||
placeholder="ot-operators" />
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<label class="form-label small">Shards</label>
|
||||
<input type="number" class="form-control form-control-sm" @bind="createClusterSize"
|
||||
min="3" max="9" step="1" />
|
||||
<div class="form-text small">Min 3 for cluster mode</div>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<label class="form-label small">Redis Version</label>
|
||||
<input class="form-control form-control-sm" @bind="createRedisVersion"
|
||||
placeholder="v7.0.15" />
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<label class="form-label small">Storage / Node</label>
|
||||
<input class="form-control form-control-sm" @bind="createStorageSize"
|
||||
placeholder="1Gi" />
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label small">Storage Class (optional)</label>
|
||||
<input class="form-control form-control-sm" @bind="createStorageClass"
|
||||
placeholder="default" />
|
||||
</div>
|
||||
<div class="col-md-4 d-flex align-items-end">
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" @bind="createPersistenceEnabled"
|
||||
id="persistenceEnabled" />
|
||||
<label class="form-check-label small" for="persistenceEnabled">
|
||||
Persistence (PVC)
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (createError is not null)
|
||||
{
|
||||
<div class="alert alert-danger mt-3 py-2 small">@createError</div>
|
||||
}
|
||||
|
||||
<div class="mt-3 d-flex gap-2">
|
||||
<button class="btn btn-sm btn-primary" @onclick="CreateCluster" disabled="@creating">
|
||||
@if (creating)
|
||||
{
|
||||
<span class="spinner-border spinner-border-sm me-1"></span>
|
||||
}
|
||||
Create Cluster
|
||||
</button>
|
||||
<button class="btn btn-sm btn-outline-secondary" @onclick="CancelCreate">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@foreach (RedisCluster cluster in clusters)
|
||||
{
|
||||
bool expanded = expandedId == cluster.Id;
|
||||
|
||||
<div class="card shadow-sm mb-3">
|
||||
<div class="card-header d-flex align-items-center gap-2 py-2"
|
||||
style="cursor:pointer" @onclick="() => ToggleCluster(cluster.Id)">
|
||||
<i class="bi bi-lightning-charge text-warning"></i>
|
||||
<strong>@cluster.Name</strong>
|
||||
<small class="text-muted ms-1">@cluster.Namespace · @cluster.KubernetesCluster.Name</small>
|
||||
<span class="badge @StatusBadgeClass(cluster.Status) ms-1">@cluster.Status</span>
|
||||
<small class="text-muted ms-auto">@cluster.ClusterSize shards · @cluster.RedisVersion · @cluster.StorageSize/node</small>
|
||||
<i class="bi @(expanded ? "bi-chevron-up" : "bi-chevron-down") ms-2 text-muted"></i>
|
||||
</div>
|
||||
|
||||
@if (cluster.LastError is not null && cluster.Status == RedisClusterStatus.Failed)
|
||||
{
|
||||
<div class="alert alert-danger m-2 py-2 small mb-0">@cluster.LastError</div>
|
||||
}
|
||||
|
||||
@if (expanded)
|
||||
{
|
||||
<div class="card-body pt-2">
|
||||
<dl class="row small mb-3">
|
||||
<dt class="col-sm-3">Shards</dt>
|
||||
<dd class="col-sm-9">@cluster.ClusterSize leaders + @cluster.ClusterSize followers</dd>
|
||||
<dt class="col-sm-3">Redis Version</dt>
|
||||
<dd class="col-sm-9">@cluster.RedisVersion</dd>
|
||||
<dt class="col-sm-3">Persistence</dt>
|
||||
<dd class="col-sm-9">@(cluster.PersistenceEnabled ? "Enabled (PVC)" : "Disabled")</dd>
|
||||
<dt class="col-sm-3">Storage / Node</dt>
|
||||
<dd class="col-sm-9">@cluster.StorageSize</dd>
|
||||
@if (cluster.StorageClass is not null)
|
||||
{
|
||||
<dt class="col-sm-3">Storage Class</dt>
|
||||
<dd class="col-sm-9"><code>@cluster.StorageClass</code></dd>
|
||||
}
|
||||
<dt class="col-sm-3">Created</dt>
|
||||
<dd class="col-sm-9">@cluster.CreatedAt.ToString("yyyy-MM-dd HH:mm") UTC</dd>
|
||||
</dl>
|
||||
|
||||
@* ── Live pod status ── *@
|
||||
@if (loadingDetail && expandedId == cluster.Id)
|
||||
{
|
||||
<div class="d-flex align-items-center gap-2 mb-3 text-muted small">
|
||||
<span class="spinner-border spinner-border-sm"></span>
|
||||
Querying Kubernetes...
|
||||
</div>
|
||||
}
|
||||
else if (expandedDetail?.Cluster.Id == cluster.Id)
|
||||
{
|
||||
<div class="mb-3">
|
||||
<div class="d-flex align-items-center gap-2 mb-2">
|
||||
<small class="text-muted">Phase:</small>
|
||||
<small>@expandedDetail.Phase</small>
|
||||
<small class="text-muted ms-2">Leaders ready:</small>
|
||||
<small>@expandedDetail.ReadyLeaders / @cluster.ClusterSize</small>
|
||||
<small class="text-muted ms-2">Followers ready:</small>
|
||||
<small>@expandedDetail.ReadyFollowers / @cluster.ClusterSize</small>
|
||||
</div>
|
||||
@if (expandedDetail.Pods.Count > 0)
|
||||
{
|
||||
<table class="table table-sm table-borderless small mb-0">
|
||||
<thead class="text-muted">
|
||||
<tr>
|
||||
<th>Pod</th>
|
||||
<th>Role</th>
|
||||
<th>Status</th>
|
||||
<th>Ready</th>
|
||||
<th>Restarts</th>
|
||||
<th>Node</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (RedisPodInfo pod in expandedDetail.Pods)
|
||||
{
|
||||
<tr>
|
||||
<td><code>@pod.Name</code></td>
|
||||
<td>
|
||||
<span class="badge @(pod.Role == "leader" ? "bg-warning text-dark" : "bg-secondary")">
|
||||
@pod.Role
|
||||
</span>
|
||||
</td>
|
||||
<td>@pod.Status</td>
|
||||
<td>
|
||||
@if (pod.Ready)
|
||||
{
|
||||
<i class="bi bi-check-circle-fill text-success"></i>
|
||||
}
|
||||
else
|
||||
{
|
||||
<i class="bi bi-clock text-warning"></i>
|
||||
}
|
||||
</td>
|
||||
<td>@pod.Restarts</td>
|
||||
<td class="text-muted">@(pod.Node ?? "—")</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
}
|
||||
else
|
||||
{
|
||||
<p class="text-muted small">No pods found yet.</p>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@* ── Credentials panel ── *@
|
||||
@if (credClusterId == cluster.Id)
|
||||
{
|
||||
<div class="card border-secondary mb-3">
|
||||
<div class="card-header bg-secondary bg-opacity-10 d-flex align-items-center justify-content-between py-2">
|
||||
<span class="small fw-semibold"><i class="bi bi-key me-2"></i>Connection Details</span>
|
||||
<button class="btn btn-sm btn-outline-secondary border-0"
|
||||
@onclick="() => credClusterId = null">
|
||||
<i class="bi bi-x-lg"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
@if (loadingCreds)
|
||||
{
|
||||
<span class="spinner-border spinner-border-sm me-2"></span>
|
||||
<span class="small text-muted">Loading...</span>
|
||||
}
|
||||
else if (credError is not null)
|
||||
{
|
||||
<div class="alert alert-warning py-2 small">@credError</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="row g-2 small">
|
||||
<div class="col-md-5">
|
||||
<span class="text-muted d-block">Host (leader service)</span>
|
||||
<code>@(credHost ?? "—")</code>
|
||||
</div>
|
||||
<div class="col-md-1">
|
||||
<span class="text-muted d-block">Port</span>
|
||||
<code>@(credPort ?? "—")</code>
|
||||
</div>
|
||||
<div class="col-md-5">
|
||||
<span class="text-muted d-block">Password</span>
|
||||
@if (showCredPassword)
|
||||
{
|
||||
<code>@credPassword</code>
|
||||
}
|
||||
else
|
||||
{
|
||||
<code>••••••••</code>
|
||||
}
|
||||
<button class="btn btn-xs btn-sm btn-link p-0 ms-2"
|
||||
@onclick="() => showCredPassword = !showCredPassword">
|
||||
<i class="bi @(showCredPassword ? "bi-eye-slash" : "bi-eye")"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@* ── App bindings ── *@
|
||||
<div class="mb-3">
|
||||
<div class="d-flex align-items-center mb-2">
|
||||
<strong class="small">App Bindings</strong>
|
||||
<button class="btn btn-xs btn-sm btn-outline-primary ms-auto py-0 px-2"
|
||||
@onclick="() => ShowAddBinding(cluster.Id)">
|
||||
<i class="bi bi-plus-lg me-1"></i>Add
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@if (addBindingClusterId == cluster.Id)
|
||||
{
|
||||
<div class="card border-primary mb-2">
|
||||
<div class="card-body py-2">
|
||||
<div class="row g-2">
|
||||
<div class="col-md-5">
|
||||
<label class="form-label small mb-1">App Deployment</label>
|
||||
<select class="form-select form-select-sm" @bind="addBindingDeploymentId">
|
||||
<option value="@Guid.Empty">Select deployment...</option>
|
||||
@foreach (AppDeployment dep in allDeployments)
|
||||
{
|
||||
<option value="@dep.Id">@dep.App.Name / @dep.Name (@dep.Cluster.Name)</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label small mb-1">Secret Name</label>
|
||||
<input class="form-control form-control-sm" @bind="addBindingSecretName"
|
||||
placeholder="redis-cache" />
|
||||
</div>
|
||||
<div class="col-md-3 d-flex align-items-end gap-1">
|
||||
<button class="btn btn-sm btn-primary" @onclick="() => AddBinding(cluster.Id)"
|
||||
disabled="@(addBindingDeploymentId == Guid.Empty || string.IsNullOrWhiteSpace(addBindingSecretName))">
|
||||
Bind
|
||||
</button>
|
||||
<button class="btn btn-sm btn-outline-secondary" @onclick="CancelAddBinding">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
@if (addBindingError is not null)
|
||||
{
|
||||
<div class="alert alert-danger py-1 small mt-2 mb-0">@addBindingError</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@{
|
||||
List<CacheBinding> clusterBindings = allBindings.TryGetValue(cluster.Id, out List<CacheBinding>? b) ? b : [];
|
||||
}
|
||||
@if (clusterBindings.Count == 0)
|
||||
{
|
||||
<p class="text-muted small mb-0">No app bindings yet.</p>
|
||||
}
|
||||
else
|
||||
{
|
||||
<table class="table table-sm table-borderless small mb-0">
|
||||
<thead class="text-muted"><tr><th>App / Deployment</th><th>Secret</th><th>Last Synced</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
@foreach (CacheBinding binding in clusterBindings)
|
||||
{
|
||||
<tr>
|
||||
<td>@binding.AppDeployment.App.Name <span class="text-muted">/</span> @binding.AppDeployment.Name</td>
|
||||
<td><code>@binding.KubernetesSecretName</code></td>
|
||||
<td class="text-muted">@(binding.LastSyncedAt.HasValue ? binding.LastSyncedAt.Value.ToString("yyyy-MM-dd HH:mm") : "Never")</td>
|
||||
<td>
|
||||
<button class="btn btn-xs btn-sm btn-outline-danger border-0 py-0"
|
||||
@onclick="() => RemoveBinding(cluster.Id, binding.Id)">
|
||||
<i class="bi bi-trash"></i>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="d-flex gap-2 border-top pt-3">
|
||||
<button class="btn btn-sm btn-outline-secondary"
|
||||
@onclick="() => ShowCredentials(cluster)">
|
||||
<i class="bi bi-key me-1"></i>Connection Details
|
||||
</button>
|
||||
|
||||
@if (deleteTarget?.Id == cluster.Id)
|
||||
{
|
||||
<span class="small text-danger align-self-center ms-auto me-2">
|
||||
Delete cluster and all its data?
|
||||
</span>
|
||||
<button class="btn btn-sm btn-danger" @onclick="DeleteCluster"
|
||||
disabled="@deleting">
|
||||
@if (deleting)
|
||||
{
|
||||
<span class="spinner-border spinner-border-sm me-1"></span>
|
||||
}
|
||||
Confirm Delete
|
||||
</button>
|
||||
<button class="btn btn-sm btn-outline-secondary"
|
||||
@onclick="() => deleteTarget = null">Cancel</button>
|
||||
}
|
||||
else
|
||||
{
|
||||
<button class="btn btn-sm btn-outline-danger ms-auto"
|
||||
@onclick="() => ConfirmDelete(cluster)">
|
||||
<i class="bi bi-trash me-1"></i>Delete
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (clusters.Count == 0 && !showCreateForm)
|
||||
{
|
||||
<p class="text-muted">No Redis clusters. Install the Redis Operator from the Components tab, then create a cluster here.</p>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@code {
|
||||
[Parameter] public Guid TenantId { get; set; }
|
||||
|
||||
// ── State ──
|
||||
private bool loading = true;
|
||||
private List<RedisCluster> clusters = [];
|
||||
private List<KubernetesCluster> allK8sClusters = [];
|
||||
private RedisOperatorStatus? operatorStatus;
|
||||
|
||||
// ── Create form ──
|
||||
private bool showCreateForm;
|
||||
private string createName = "";
|
||||
private Guid createK8sClusterId;
|
||||
private string createNamespace = "ot-operators";
|
||||
private int createClusterSize = 3;
|
||||
private string createRedisVersion = "v7.0.15";
|
||||
private string createStorageSize = "1Gi";
|
||||
private string? createStorageClass;
|
||||
private bool createPersistenceEnabled = true;
|
||||
private bool creating;
|
||||
private string? createError;
|
||||
|
||||
// ── Expand / detail ──
|
||||
private Guid? expandedId;
|
||||
private RedisClusterDetail? expandedDetail;
|
||||
private bool loadingDetail;
|
||||
|
||||
// ── Delete ──
|
||||
private RedisCluster? deleteTarget;
|
||||
private bool deleting;
|
||||
|
||||
// ── Bindings ──
|
||||
private Dictionary<Guid, List<CacheBinding>> allBindings = [];
|
||||
private List<AppDeployment> allDeployments = [];
|
||||
private Guid? addBindingClusterId;
|
||||
private Guid addBindingDeploymentId;
|
||||
private string addBindingSecretName = "";
|
||||
private string? addBindingError;
|
||||
|
||||
// ── Credentials ──
|
||||
private Guid? credClusterId;
|
||||
private bool loadingCreds;
|
||||
private string? credHost;
|
||||
private string? credPort;
|
||||
private string? credPassword;
|
||||
private bool showCredPassword;
|
||||
private string? credError;
|
||||
|
||||
// ── Init ──
|
||||
|
||||
protected override async Task OnInitializedAsync() => await LoadAsync();
|
||||
|
||||
private async Task LoadAsync()
|
||||
{
|
||||
loading = true;
|
||||
|
||||
Task<List<RedisCluster>> clustersTask = RedisService.GetClustersAsync(TenantId);
|
||||
Task<List<KubernetesCluster>> k8sTask = TenantService.GetClustersAsync(TenantId);
|
||||
Task<RedisOperatorStatus> operatorTask = RedisService.GetOperatorStatusAsync(TenantId);
|
||||
Task<List<AppDeployment>> deploymentsTask = RedisService.GetTenantDeploymentsAsync(TenantId);
|
||||
|
||||
await Task.WhenAll(clustersTask, k8sTask, operatorTask, deploymentsTask);
|
||||
|
||||
clusters = clustersTask.Result;
|
||||
allK8sClusters = k8sTask.Result;
|
||||
operatorStatus = operatorTask.Result;
|
||||
allDeployments = deploymentsTask.Result;
|
||||
|
||||
// Load bindings per cluster.
|
||||
IEnumerable<Task<(Guid, List<CacheBinding>)>> bindingTasks = clusters.Select(async c =>
|
||||
(c.Id, await RedisService.GetCacheBindingsAsync(TenantId, c.Id)));
|
||||
|
||||
(Guid id, List<CacheBinding> bindings)[] bindingResults = await Task.WhenAll(bindingTasks);
|
||||
allBindings = bindingResults.ToDictionary(r => r.id, r => r.bindings);
|
||||
|
||||
loading = false;
|
||||
}
|
||||
|
||||
// ── Create ──
|
||||
|
||||
private void ShowCreateForm() { showCreateForm = true; createError = null; }
|
||||
private void CancelCreate() { showCreateForm = false; createError = null; }
|
||||
|
||||
private async Task CreateCluster()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(createName) || createK8sClusterId == Guid.Empty)
|
||||
{
|
||||
createError = "Cluster name and Kubernetes cluster are required.";
|
||||
return;
|
||||
}
|
||||
|
||||
creating = true;
|
||||
createError = null;
|
||||
|
||||
try
|
||||
{
|
||||
await RedisService.CreateClusterAsync(
|
||||
TenantId,
|
||||
createK8sClusterId,
|
||||
createName.Trim(),
|
||||
createNamespace.Trim(),
|
||||
createClusterSize,
|
||||
createRedisVersion.Trim(),
|
||||
createStorageSize.Trim(),
|
||||
string.IsNullOrWhiteSpace(createStorageClass) ? null : createStorageClass.Trim(),
|
||||
createPersistenceEnabled);
|
||||
|
||||
showCreateForm = false;
|
||||
createName = "";
|
||||
createNamespace = "ot-operators";
|
||||
createClusterSize = 3;
|
||||
createRedisVersion = "v7";
|
||||
createStorageSize = "1Gi";
|
||||
createStorageClass = null;
|
||||
|
||||
await LoadAsync();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
createError = ex.Message;
|
||||
}
|
||||
finally
|
||||
{
|
||||
creating = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Expand / detail ──
|
||||
|
||||
private async Task ToggleCluster(Guid id)
|
||||
{
|
||||
if (expandedId == id)
|
||||
{
|
||||
expandedId = null;
|
||||
expandedDetail = null;
|
||||
return;
|
||||
}
|
||||
|
||||
expandedId = id;
|
||||
expandedDetail = null;
|
||||
loadingDetail = true;
|
||||
StateHasChanged();
|
||||
|
||||
expandedDetail = await RedisService.GetClusterDetailAsync(TenantId, id);
|
||||
loadingDetail = false;
|
||||
}
|
||||
|
||||
// ── Delete ──
|
||||
|
||||
private void ConfirmDelete(RedisCluster cluster)
|
||||
{
|
||||
deleteTarget = cluster;
|
||||
credClusterId = null;
|
||||
}
|
||||
|
||||
private async Task DeleteCluster()
|
||||
{
|
||||
if (deleteTarget is null) return;
|
||||
|
||||
deleting = true;
|
||||
|
||||
try
|
||||
{
|
||||
await RedisService.DeleteClusterAsync(TenantId, deleteTarget.Id);
|
||||
deleteTarget = null;
|
||||
expandedId = null;
|
||||
await LoadAsync();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
deleteTarget.LastError = ex.Message;
|
||||
}
|
||||
finally
|
||||
{
|
||||
deleting = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Bindings ──
|
||||
|
||||
private void ShowAddBinding(Guid clusterId)
|
||||
{
|
||||
addBindingClusterId = clusterId;
|
||||
addBindingDeploymentId = Guid.Empty;
|
||||
addBindingSecretName = "redis-cache";
|
||||
addBindingError = null;
|
||||
}
|
||||
|
||||
private void CancelAddBinding()
|
||||
{
|
||||
addBindingClusterId = null;
|
||||
addBindingError = null;
|
||||
}
|
||||
|
||||
private async Task AddBinding(Guid clusterId)
|
||||
{
|
||||
addBindingError = null;
|
||||
|
||||
try
|
||||
{
|
||||
await RedisService.CreateCacheBindingAsync(
|
||||
TenantId, clusterId, addBindingDeploymentId, addBindingSecretName.Trim());
|
||||
|
||||
addBindingClusterId = null;
|
||||
addBindingSecretName = "";
|
||||
allBindings[clusterId] = await RedisService.GetCacheBindingsAsync(TenantId, clusterId);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
addBindingError = ex.Message;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task RemoveBinding(Guid clusterId, Guid bindingId)
|
||||
{
|
||||
try
|
||||
{
|
||||
await RedisService.DeleteCacheBindingAsync(TenantId, bindingId);
|
||||
allBindings[clusterId] = await RedisService.GetCacheBindingsAsync(TenantId, clusterId);
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
|
||||
// ── Credentials ──
|
||||
|
||||
private async Task ShowCredentials(RedisCluster cluster)
|
||||
{
|
||||
if (credClusterId == cluster.Id)
|
||||
{
|
||||
credClusterId = null;
|
||||
return;
|
||||
}
|
||||
|
||||
credClusterId = cluster.Id;
|
||||
loadingCreds = true;
|
||||
credError = null;
|
||||
credHost = null;
|
||||
credPort = null;
|
||||
credPassword = null;
|
||||
showCredPassword = false;
|
||||
|
||||
try
|
||||
{
|
||||
(string? password, string? host, string? port) =
|
||||
await RedisService.GetCredentialsAsync(TenantId, cluster.Id);
|
||||
|
||||
credPassword = password;
|
||||
credHost = host;
|
||||
credPort = port;
|
||||
|
||||
if (credPassword is null)
|
||||
credError = "No credentials found in vault.";
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
credError = ex.Message;
|
||||
}
|
||||
finally
|
||||
{
|
||||
loadingCreds = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Helpers ──
|
||||
|
||||
private static string StatusBadgeClass(RedisClusterStatus status) => status switch
|
||||
{
|
||||
RedisClusterStatus.Running => "badge bg-success",
|
||||
RedisClusterStatus.Creating => "badge bg-info text-dark",
|
||||
RedisClusterStatus.Failed => "badge bg-danger",
|
||||
RedisClusterStatus.Deleting => "badge bg-secondary",
|
||||
_ => "badge bg-secondary"
|
||||
};
|
||||
}
|
||||
@@ -7,6 +7,9 @@
|
||||
@inject ExternalRouteService RouteService
|
||||
@inject ComponentScanService ScanService
|
||||
@inject KeycloakService KeycloakService
|
||||
@inject HarborService HarborService
|
||||
@inject LokiService LokiService
|
||||
@inject StorageService StorageService
|
||||
|
||||
@* ═══════════════════════════════════════════════════════════════════
|
||||
Cluster Detail — a full-page view for a single cluster.
|
||||
@@ -75,12 +78,21 @@
|
||||
<i class="bi bi-puzzle me-1"></i>Components
|
||||
</button>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<button class="nav-link @(section == "logs" ? "active" : "")" @onclick='() => section = "logs"'>
|
||||
<i class="bi bi-journal-text me-1"></i>Logs
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
@if (section == "monitoring")
|
||||
{
|
||||
<ClusterMonitoring ClusterId="Cluster.Id" />
|
||||
}
|
||||
else if (section == "logs")
|
||||
{
|
||||
<LogBrowser ClusterId="Cluster.Id" />
|
||||
}
|
||||
else if (section == "components")
|
||||
{
|
||||
@* --- Register Component --- *@
|
||||
@@ -294,7 +306,7 @@ else if (section == "components")
|
||||
<div class="row g-2 mb-2">
|
||||
@foreach (ComponentFormField field in selectedCatalogEntry.FormFields)
|
||||
{
|
||||
@if (IsFieldVisible(field, regFormFieldValues))
|
||||
@if (!field.Hidden && IsFieldVisible(field, regFormFieldValues))
|
||||
{
|
||||
<div class="col-md-6">
|
||||
<label class="form-label small mb-0">@field.Label</label>
|
||||
@@ -321,6 +333,21 @@ else if (section == "components")
|
||||
}
|
||||
</select>
|
||||
}
|
||||
else if (field.Type == FormFieldType.StorageLink)
|
||||
{
|
||||
<select class="form-select form-select-sm"
|
||||
value="@GetFormFieldValue(field.Key)"
|
||||
@onchange="@(e => OnRegFieldChanged(field.Key, e))">
|
||||
<option value="">-- None (use local PVC) --</option>
|
||||
@if (harborStorageLinks is not null)
|
||||
{
|
||||
@foreach (StorageLink sl in harborStorageLinks)
|
||||
{
|
||||
<option value="@sl.Id">@sl.Name (@sl.BucketName)</option>
|
||||
}
|
||||
}
|
||||
</select>
|
||||
}
|
||||
else if (field.Type == FormFieldType.ClusterIssuer)
|
||||
{
|
||||
@if (keycloakAvailableIssuers is { Count: > 0 })
|
||||
@@ -342,6 +369,19 @@ else if (section == "components")
|
||||
@onchange="@(e => OnRegFieldChanged(field.Key, e))" />
|
||||
}
|
||||
}
|
||||
else if (field.Type == FormFieldType.GatewaySelector)
|
||||
{
|
||||
<select class="form-select form-select-sm"
|
||||
value="@GetFormFieldValue(field.Key)"
|
||||
@onchange="@(e => OnRegGatewaySelected(field.Key, e))">
|
||||
<option value="">-- Select a gateway --</option>
|
||||
@foreach (ClusterComponent gw in GetInstalledGateways())
|
||||
{
|
||||
string releaseName = gw.ReleaseName ?? gw.Name;
|
||||
<option value="@releaseName">@(ComponentCatalog.GetByKey(gw.Name)?.DisplayName ?? gw.Name) (@releaseName)</option>
|
||||
}
|
||||
</select>
|
||||
}
|
||||
else if (field.Type == FormFieldType.Select && field.Options is not null)
|
||||
{
|
||||
<select class="form-select form-select-sm"
|
||||
@@ -765,7 +805,7 @@ else if (section == "components")
|
||||
<div class="row g-2 mb-3">
|
||||
@foreach (ComponentFormField field in GetCatalogMatchForSelected()!.FormFields)
|
||||
{
|
||||
@if (IsFieldVisible(field, editFormFieldValues))
|
||||
@if (!field.Hidden && IsFieldVisible(field, editFormFieldValues))
|
||||
{
|
||||
<div class="col-md-6">
|
||||
<label class="form-label small mb-0 fw-semibold">@field.Label</label>
|
||||
@@ -792,6 +832,21 @@ else if (section == "components")
|
||||
}
|
||||
</select>
|
||||
}
|
||||
else if (field.Type == FormFieldType.StorageLink)
|
||||
{
|
||||
<select class="form-select form-select-sm"
|
||||
value="@GetEditFormFieldValue(field.Key)"
|
||||
@onchange="@(e => OnEditFieldChanged(field.Key, e))">
|
||||
<option value="">-- None (use local PVC) --</option>
|
||||
@if (harborStorageLinks is not null)
|
||||
{
|
||||
@foreach (StorageLink sl in harborStorageLinks)
|
||||
{
|
||||
<option value="@sl.Id">@sl.Name (@sl.BucketName)</option>
|
||||
}
|
||||
}
|
||||
</select>
|
||||
}
|
||||
else if (field.Type == FormFieldType.ClusterIssuer)
|
||||
{
|
||||
@if (keycloakAvailableIssuers is { Count: > 0 })
|
||||
@@ -813,6 +868,19 @@ else if (section == "components")
|
||||
@onchange="@(e => OnEditFieldChanged(field.Key, e))" />
|
||||
}
|
||||
}
|
||||
else if (field.Type == FormFieldType.GatewaySelector)
|
||||
{
|
||||
<select class="form-select form-select-sm"
|
||||
value="@GetEditFormFieldValue(field.Key)"
|
||||
@onchange="@(e => OnEditGatewaySelected(field.Key, e))">
|
||||
<option value="">-- Select a gateway --</option>
|
||||
@foreach (ClusterComponent gw in GetInstalledGateways())
|
||||
{
|
||||
string releaseName = gw.ReleaseName ?? gw.Name;
|
||||
<option value="@releaseName">@(ComponentCatalog.GetByKey(gw.Name)?.DisplayName ?? gw.Name) (@releaseName)</option>
|
||||
}
|
||||
</select>
|
||||
}
|
||||
else if (field.Type == FormFieldType.Select && field.Options is not null)
|
||||
{
|
||||
<select class="form-select form-select-sm"
|
||||
@@ -1173,6 +1241,7 @@ else if (section == "components")
|
||||
<th>Hostname</th>
|
||||
<th>Service</th>
|
||||
<th>TLS</th>
|
||||
<th>Health</th>
|
||||
<th class="text-end" style="width: 140px;">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -1200,6 +1269,28 @@ else if (section == "components")
|
||||
</span>
|
||||
}
|
||||
</td>
|
||||
<td>
|
||||
@if (route.LastHealthCheckAt is null)
|
||||
{
|
||||
<span class="text-muted small">—</span>
|
||||
}
|
||||
else if (route.IsReachable == true)
|
||||
{
|
||||
<span class="badge bg-success" title="Last checked @route.LastHealthCheckAt.Value.ToLocalTime().ToString("HH:mm")">
|
||||
<i class="bi bi-check-circle me-1"></i>@route.LastStatusCode
|
||||
</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="badge bg-danger" title="Last checked @route.LastHealthCheckAt.Value.ToLocalTime().ToString("HH:mm")">
|
||||
<i class="bi bi-x-circle me-1"></i>@(route.LastStatusCode?.ToString() ?? "Unreachable")
|
||||
</span>
|
||||
}
|
||||
@if (routeUptimes.TryGetValue(route.Id, out RouteUptimeSummary? uptimeSummary))
|
||||
{
|
||||
<div class="small text-muted mt-1">7d: @uptimeSummary.UptimeDisplay</div>
|
||||
}
|
||||
</td>
|
||||
<td class="text-end">
|
||||
<button class="btn btn-sm btn-outline-secondary me-1"
|
||||
@onclick="() => ShowRouteYaml(route.Id)"
|
||||
@@ -1255,6 +1346,7 @@ else if (section == "components")
|
||||
// Components
|
||||
private List<ClusterComponent>? components;
|
||||
private Dictionary<Guid, KeycloakComponentConfig> keycloakConfigsByComponentId = [];
|
||||
private Dictionary<Guid, HarborComponentConfig> harborConfigsByComponentId = [];
|
||||
private bool? gatewayApiCrdsPresent;
|
||||
private bool? certManagerGatewayApiEnabled;
|
||||
private string? componentError;
|
||||
@@ -1289,6 +1381,7 @@ else if (section == "components")
|
||||
private Dictionary<string, string> editFormFieldValues = new();
|
||||
private List<CnpgDatabase>? keycloakDatabases;
|
||||
private List<string>? keycloakAvailableIssuers;
|
||||
private List<StorageLink>? harborStorageLinks;
|
||||
|
||||
// Config editor
|
||||
private string editRepoUrl = "";
|
||||
@@ -1310,6 +1403,7 @@ else if (section == "components")
|
||||
|
||||
// External Routes
|
||||
private List<ExternalRoute>? componentRoutes;
|
||||
private Dictionary<Guid, RouteUptimeSummary> routeUptimes = new();
|
||||
private string routeHostname = "";
|
||||
private string routeServiceName = "";
|
||||
private int routeServicePort = 80;
|
||||
@@ -1339,7 +1433,11 @@ else if (section == "components")
|
||||
{
|
||||
components = await VaultService.GetComponentsAsync(Cluster.Id);
|
||||
List<KeycloakComponentConfig> kcConfigs = await KeycloakService.GetConfigsForClusterAsync(TenantId, Cluster.Id);
|
||||
keycloakConfigsByComponentId = kcConfigs.ToDictionary(c => c.ClusterComponentId);
|
||||
keycloakConfigsByComponentId = kcConfigs
|
||||
.Where(c => c.ClusterComponentId.HasValue)
|
||||
.ToDictionary(c => c.ClusterComponentId!.Value);
|
||||
List<HarborComponentConfig> harborConfigs = await HarborService.GetConfigsForClusterAsync(TenantId, Cluster.Id);
|
||||
harborConfigsByComponentId = harborConfigs.ToDictionary(c => c.ClusterComponentId);
|
||||
await CheckGatewayApiCrdsIfNeededAsync();
|
||||
await CheckCertManagerGatewayApiIfNeededAsync();
|
||||
}
|
||||
@@ -1519,20 +1617,17 @@ else if (section == "components")
|
||||
|
||||
// Load available CNPG databases and ClusterIssuers if this component type needs them.
|
||||
|
||||
if (entry.FormFields.Any(f => f.Type == FormFieldType.CnpgDatabase))
|
||||
{
|
||||
keycloakDatabases = await KeycloakService.GetDatabasesForClusterAsync(TenantId, Cluster.Id);
|
||||
keycloakAvailableIssuers = components?
|
||||
.Where(c => string.Equals(c.Name, "letsencrypt-issuer", StringComparison.OrdinalIgnoreCase)
|
||||
&& c.Status == ComponentStatus.Installed)
|
||||
.Select(c => c.ReleaseName ?? c.Name)
|
||||
.ToList() ?? [];
|
||||
}
|
||||
else
|
||||
{
|
||||
keycloakDatabases = null;
|
||||
keycloakAvailableIssuers = null;
|
||||
}
|
||||
keycloakDatabases = entry.FormFields.Any(f => f.Type == FormFieldType.CnpgDatabase)
|
||||
? await KeycloakService.GetDatabasesForClusterAsync(TenantId, Cluster.Id)
|
||||
: null;
|
||||
|
||||
keycloakAvailableIssuers = entry.FormFields.Any(f => f.Type == FormFieldType.ClusterIssuer)
|
||||
? await LifecycleService.ListClusterIssuersAsync(Cluster.Id)
|
||||
: null;
|
||||
|
||||
harborStorageLinks = entry.FormFields.Any(f => f.Type == FormFieldType.StorageLink)
|
||||
? await StorageService.GetStorageLinksAsync(TenantId)
|
||||
: null;
|
||||
}
|
||||
|
||||
private async Task RegisterCatalogComponent()
|
||||
@@ -1566,6 +1661,14 @@ else if (section == "components")
|
||||
|
||||
await SaveKeycloakConfigIfNeeded(created.Id, regFormFieldValues, selectedCatalogEntry);
|
||||
|
||||
// Configure Harbor (CNPG database, S3 storage, admin password) if this is Harbor.
|
||||
|
||||
await SaveHarborConfigIfNeeded(created.Id, regFormFieldValues, selectedCatalogEntry);
|
||||
|
||||
// Configure Loki S3 chunk storage if a storage link was selected.
|
||||
|
||||
await SaveLokiConfigIfNeeded(created.Id, regFormFieldValues, selectedCatalogEntry);
|
||||
|
||||
showRegisterForm = false;
|
||||
ResetRegisterForm();
|
||||
await LoadComponents();
|
||||
@@ -1641,14 +1744,21 @@ else if (section == "components")
|
||||
// databases and pre-populate config fields from the existing KeycloakComponentConfig.
|
||||
|
||||
CatalogEntry? catalogMatch = ComponentCatalog.GetByKey(comp.Name);
|
||||
if (catalogMatch is not null && catalogMatch.FormFields.Any(f => f.Type == FormFieldType.CnpgDatabase))
|
||||
bool hasCnpgField = catalogMatch?.FormFields.Any(f => f.Type == FormFieldType.CnpgDatabase) == true;
|
||||
bool hasIssuerField = catalogMatch?.FormFields.Any(f => f.Type == FormFieldType.ClusterIssuer) == true;
|
||||
bool hasStorageLinkField = catalogMatch?.FormFields.Any(f => f.Type == FormFieldType.StorageLink) == true;
|
||||
|
||||
if (catalogMatch is not null && (hasCnpgField || hasIssuerField || hasStorageLinkField))
|
||||
{
|
||||
keycloakDatabases = await KeycloakService.GetDatabasesForClusterAsync(TenantId, Cluster.Id);
|
||||
keycloakAvailableIssuers = components?
|
||||
.Where(c => string.Equals(c.Name, "letsencrypt-issuer", StringComparison.OrdinalIgnoreCase)
|
||||
&& c.Status == ComponentStatus.Installed)
|
||||
.Select(c => c.ReleaseName ?? c.Name)
|
||||
.ToList() ?? [];
|
||||
keycloakDatabases = hasCnpgField
|
||||
? await KeycloakService.GetDatabasesForClusterAsync(TenantId, Cluster.Id)
|
||||
: null;
|
||||
keycloakAvailableIssuers = hasIssuerField
|
||||
? await LifecycleService.ListClusterIssuersAsync(Cluster.Id)
|
||||
: null;
|
||||
harborStorageLinks = hasStorageLinkField
|
||||
? await StorageService.GetStorageLinksAsync(TenantId)
|
||||
: null;
|
||||
|
||||
KeycloakComponentConfig? kcConfig = await KeycloakService.GetConfigForComponentAsync(TenantId, componentId);
|
||||
if (kcConfig is not null)
|
||||
@@ -1664,6 +1774,20 @@ else if (section == "components")
|
||||
editFormFieldValues["admin-username"] = kcConfig.AdminUsername;
|
||||
}
|
||||
|
||||
HarborComponentConfig? harborConfig = await HarborService.GetConfigForComponentAsync(TenantId, componentId);
|
||||
if (harborConfig is not null)
|
||||
{
|
||||
if (harborConfig.CnpgDatabaseId.HasValue)
|
||||
{
|
||||
editFormFieldValues["cnpg-database"] = harborConfig.CnpgDatabaseId.Value.ToString();
|
||||
}
|
||||
if (harborConfig.StorageLinkId.HasValue)
|
||||
{
|
||||
editFormFieldValues["storage-link"] = harborConfig.StorageLinkId.Value.ToString();
|
||||
}
|
||||
editFormFieldValues["admin-username"] = harborConfig.AdminUsername;
|
||||
}
|
||||
|
||||
// Pre-populate hostname and TLS fields from the existing external route.
|
||||
List<ExternalRoute> routes = await RouteService.GetRoutesAsync(componentId);
|
||||
ExternalRoute? route = routes.FirstOrDefault();
|
||||
@@ -1681,6 +1805,7 @@ else if (section == "components")
|
||||
{
|
||||
keycloakDatabases = null;
|
||||
keycloakAvailableIssuers = null;
|
||||
harborStorageLinks = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1719,6 +1844,14 @@ else if (section == "components")
|
||||
|
||||
await SaveKeycloakConfigIfNeeded(componentId, editFormFieldValues, GetCatalogMatchForSelected());
|
||||
|
||||
// Configure Harbor if this is a Harbor component.
|
||||
|
||||
await SaveHarborConfigIfNeeded(componentId, editFormFieldValues, GetCatalogMatchForSelected());
|
||||
|
||||
// Configure Loki S3 chunk storage if a storage link was selected.
|
||||
|
||||
await SaveLokiConfigIfNeeded(componentId, editFormFieldValues, GetCatalogMatchForSelected());
|
||||
|
||||
await LoadComponents();
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
@@ -1739,6 +1872,14 @@ else if (section == "components")
|
||||
|
||||
// Refresh Keycloak DB config into HelmValues from the stored config (if any).
|
||||
await KeycloakService.RefreshDatabaseHelmValuesIfConfiguredAsync(TenantId, componentId);
|
||||
// Grant all schema privileges to the database owner so Liquibase can run on install.
|
||||
await KeycloakService.GrantDatabaseOwnerPermissionsIfConfiguredAsync(TenantId, componentId);
|
||||
// Release any stuck Liquibase lock so migrations can proceed on startup.
|
||||
await KeycloakService.ReleaseLiquibaseLockIfConfiguredAsync(TenantId, componentId);
|
||||
// Update realm frontendUrl to match the configured admin URL (fixes restored-database URL mismatch).
|
||||
await KeycloakService.FixRealmUrlsIfConfiguredAsync(TenantId, componentId);
|
||||
// Refresh Harbor CNPG + S3 credentials in Helm values before install/upgrade.
|
||||
await HarborService.RefreshHelmValuesIfConfiguredAsync(TenantId, componentId);
|
||||
|
||||
HelmCommand command = await LifecycleService.GetInstallCommandAsync(componentId);
|
||||
|
||||
@@ -1836,6 +1977,14 @@ else if (section == "components")
|
||||
|
||||
// Refresh Keycloak DB config into HelmValues from the stored config (if any).
|
||||
await KeycloakService.RefreshDatabaseHelmValuesIfConfiguredAsync(TenantId, componentId);
|
||||
// Grant all schema privileges to the database owner so Liquibase can run on install.
|
||||
await KeycloakService.GrantDatabaseOwnerPermissionsIfConfiguredAsync(TenantId, componentId);
|
||||
// Release any stuck Liquibase lock so migrations can proceed on startup.
|
||||
await KeycloakService.ReleaseLiquibaseLockIfConfiguredAsync(TenantId, componentId);
|
||||
// Update realm frontendUrl to match the configured admin URL (fixes restored-database URL mismatch).
|
||||
await KeycloakService.FixRealmUrlsIfConfiguredAsync(TenantId, componentId);
|
||||
// Refresh Harbor CNPG + S3 credentials in Helm values before install/upgrade.
|
||||
await HarborService.RefreshHelmValuesIfConfiguredAsync(TenantId, componentId);
|
||||
|
||||
HelmCommand command = await LifecycleService.GetInstallCommandAsync(componentId);
|
||||
command.NoWait = noWait;
|
||||
@@ -2108,6 +2257,48 @@ else if (section == "components")
|
||||
}
|
||||
}
|
||||
|
||||
// ──────── Gateway Selector ────────
|
||||
|
||||
private IEnumerable<ClusterComponent> GetInstalledGateways()
|
||||
=> components?.Where(c =>
|
||||
c.Name is "istio" or "istio-internal"
|
||||
&& c.Status == ComponentStatus.Installed)
|
||||
?? [];
|
||||
|
||||
private async Task OnRegGatewaySelected(string fieldKey, ChangeEventArgs e)
|
||||
{
|
||||
string releaseName = e.Value?.ToString() ?? "";
|
||||
regFormFieldValues[fieldKey] = releaseName;
|
||||
|
||||
if (!string.IsNullOrEmpty(releaseName))
|
||||
await AutoDetectAndFillHostReg(releaseName);
|
||||
}
|
||||
|
||||
private async Task OnEditGatewaySelected(string fieldKey, ChangeEventArgs e)
|
||||
{
|
||||
string releaseName = e.Value?.ToString() ?? "";
|
||||
editFormFieldValues[fieldKey] = releaseName;
|
||||
|
||||
if (!string.IsNullOrEmpty(releaseName))
|
||||
await AutoDetectAndFillHostEdit(releaseName);
|
||||
}
|
||||
|
||||
private async Task AutoDetectAndFillHostReg(string gatewayReleaseName)
|
||||
{
|
||||
string? ip = await LifecycleService.GetServiceExternalIpAsync(
|
||||
Cluster.Id, gatewayReleaseName, "istio-system");
|
||||
if (!string.IsNullOrEmpty(ip))
|
||||
regFormFieldValues["wg-host"] = ip;
|
||||
}
|
||||
|
||||
private async Task AutoDetectAndFillHostEdit(string gatewayReleaseName)
|
||||
{
|
||||
string? ip = await LifecycleService.GetServiceExternalIpAsync(
|
||||
Cluster.Id, gatewayReleaseName, "istio-system");
|
||||
if (!string.IsNullOrEmpty(ip))
|
||||
editFormFieldValues["wg-host"] = ip;
|
||||
}
|
||||
|
||||
// ──────── Form Field Helpers ────────
|
||||
|
||||
private string GetFormFieldValue(string key)
|
||||
@@ -2196,7 +2387,9 @@ else if (section == "components")
|
||||
continue;
|
||||
}
|
||||
|
||||
if (field.YamlPath.StartsWith("cnpg:", StringComparison.Ordinal))
|
||||
if (field.YamlPath.StartsWith("cnpg:", StringComparison.Ordinal)
|
||||
|| field.YamlPath.StartsWith("harbor:", StringComparison.Ordinal)
|
||||
|| field.YamlPath.StartsWith("loki:", StringComparison.Ordinal))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
@@ -2270,7 +2463,9 @@ else if (section == "components")
|
||||
continue;
|
||||
}
|
||||
|
||||
if (field.YamlPath.StartsWith("cnpg:", StringComparison.Ordinal))
|
||||
if (field.YamlPath.StartsWith("cnpg:", StringComparison.Ordinal)
|
||||
|| field.YamlPath.StartsWith("harbor:", StringComparison.Ordinal)
|
||||
|| field.YamlPath.StartsWith("loki:", StringComparison.Ordinal))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
@@ -2444,9 +2639,10 @@ else if (section == "components")
|
||||
continue;
|
||||
}
|
||||
|
||||
// cnpg: fields are not in YAML — loaded from KeycloakComponentConfig in ToggleComponentDetail.
|
||||
// cnpg:/harbor: fields are not in YAML — loaded from component configs in ToggleComponentDetail.
|
||||
|
||||
if (field.YamlPath.StartsWith("cnpg:", StringComparison.Ordinal))
|
||||
if (field.YamlPath.StartsWith("cnpg:", StringComparison.Ordinal)
|
||||
|| field.YamlPath.StartsWith("harbor:", StringComparison.Ordinal))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
@@ -2469,7 +2665,11 @@ else if (section == "components")
|
||||
private async Task SaveKeycloakConfigIfNeeded(
|
||||
Guid componentId, Dictionary<string, string> fieldValues, CatalogEntry? catalogEntry)
|
||||
{
|
||||
if (catalogEntry is null || !catalogEntry.FormFields.Any(f => f.Type == FormFieldType.CnpgDatabase))
|
||||
// Skip if no CnpgDatabase field, or if this is a Harbor component (has StorageLink field).
|
||||
// Harbor has its own SaveHarborConfigIfNeeded that handles the database and route.
|
||||
if (catalogEntry is null
|
||||
|| !catalogEntry.FormFields.Any(f => f.Type == FormFieldType.CnpgDatabase)
|
||||
|| catalogEntry.FormFields.Any(f => f.Type == FormFieldType.StorageLink))
|
||||
{
|
||||
return;
|
||||
}
|
||||
@@ -2534,9 +2734,15 @@ else if (section == "components")
|
||||
|
||||
bool isManual = string.Equals(tlsMode, "Manual", StringComparison.Ordinal);
|
||||
|
||||
// keycloakx chart creates {releaseName}-keycloakx-http (ClusterIP, ports 80/8443/9000).
|
||||
ClusterComponent? comp = components?.FirstOrDefault(c => c.Id == componentId);
|
||||
string releaseName = comp?.ReleaseName ?? comp?.Name ?? "keycloak";
|
||||
string serviceName = $"{releaseName}-keycloakx-http";
|
||||
|
||||
ExternalRouteRequest routeRequest = new()
|
||||
{
|
||||
Hostname = hostname,
|
||||
ServiceName = serviceName,
|
||||
ServicePort = 80,
|
||||
PathPrefix = "/",
|
||||
TlsMode = isManual ? TlsMode.Manual : TlsMode.ClusterIssuer,
|
||||
@@ -2593,6 +2799,125 @@ else if (section == "components")
|
||||
|| comp.HelmChartName == "keycloakx"
|
||||
|| comp.HelmChartName == "keycloak";
|
||||
|
||||
private static bool IsHarborComponent(ClusterComponent comp) =>
|
||||
comp.Name == "harbor"
|
||||
|| comp.HelmChartName == "harbor";
|
||||
|
||||
/// <summary>
|
||||
/// Saves Harbor config (CNPG database, S3 storage link, admin credentials) when the
|
||||
/// component has a StorageLink or CnpgDatabase form field (i.e. it is Harbor). Also
|
||||
/// creates an HTTPRoute for the configured hostname if one was provided.
|
||||
/// </summary>
|
||||
private async Task SaveLokiConfigIfNeeded(
|
||||
Guid componentId, Dictionary<string, string> fieldValues, CatalogEntry? catalogEntry)
|
||||
{
|
||||
if (catalogEntry?.Key != "loki") return;
|
||||
|
||||
if (!fieldValues.TryGetValue("storage-link", out string? slIdStr)
|
||||
|| !Guid.TryParse(slIdStr, out Guid storageLinkId)
|
||||
|| storageLinkId == Guid.Empty)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await LokiService.WriteStorageHelmValuesAsync(TenantId, componentId, storageLinkId);
|
||||
}
|
||||
|
||||
private async Task SaveHarborConfigIfNeeded(
|
||||
Guid componentId, Dictionary<string, string> fieldValues, CatalogEntry? catalogEntry)
|
||||
{
|
||||
if (catalogEntry?.Key != "harbor")
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Guid? cnpgDatabaseId = null;
|
||||
if (fieldValues.TryGetValue("cnpg-database", out string? dbIdStr)
|
||||
&& Guid.TryParse(dbIdStr, out Guid parsedDbId)
|
||||
&& parsedDbId != Guid.Empty)
|
||||
{
|
||||
cnpgDatabaseId = parsedDbId;
|
||||
}
|
||||
|
||||
Guid? storageLinkId = null;
|
||||
if (fieldValues.TryGetValue("storage-link", out string? slIdStr)
|
||||
&& Guid.TryParse(slIdStr, out Guid parsedSlId)
|
||||
&& parsedSlId != Guid.Empty)
|
||||
{
|
||||
storageLinkId = parsedSlId;
|
||||
}
|
||||
|
||||
fieldValues.TryGetValue("admin-password", out string? adminPassword);
|
||||
fieldValues.TryGetValue("admin-username", out string? adminUsername);
|
||||
string hostname = fieldValues.TryGetValue("hostname", out string? h) ? h.Trim() : "";
|
||||
string registryUrl = string.IsNullOrEmpty(hostname) ? "" : $"https://{hostname}";
|
||||
|
||||
await HarborService.ConfigureAsync(
|
||||
TenantId, componentId,
|
||||
cnpgDatabaseId, storageLinkId,
|
||||
string.IsNullOrWhiteSpace(adminUsername) ? "admin" : adminUsername,
|
||||
string.IsNullOrWhiteSpace(adminPassword) ? null : adminPassword,
|
||||
string.IsNullOrWhiteSpace(registryUrl) ? null : registryUrl);
|
||||
|
||||
// Inject externalURL and keep expose.clusterIP.name aligned with the release name so the
|
||||
// Gateway API route always targets the correct service (HarborService sets expose.clusterIP.name
|
||||
// to releaseName; EnsureHarborRouteAsync routes to releaseName as the backend service).
|
||||
if (!string.IsNullOrEmpty(registryUrl))
|
||||
{
|
||||
ClusterComponent? comp = components?.FirstOrDefault(c => c.Id == componentId);
|
||||
if (comp is not null)
|
||||
{
|
||||
string harborReleaseName = comp.ReleaseName ?? comp.Name;
|
||||
comp.HelmValues = YamlFormMerger.MergeFormValues(
|
||||
comp.HelmValues ?? "",
|
||||
new Dictionary<string, string>
|
||||
{
|
||||
["externalURL"] = registryUrl,
|
||||
["expose.clusterIP.name"] = harborReleaseName
|
||||
});
|
||||
await LifecycleService.UpdateConfigurationAsync(componentId, comp.HelmValues);
|
||||
}
|
||||
|
||||
await EnsureHarborRouteAsync(componentId, fieldValues, hostname);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task EnsureHarborRouteAsync(
|
||||
Guid componentId, Dictionary<string, string> fieldValues, string hostname)
|
||||
{
|
||||
List<ExternalRoute> existing = await RouteService.GetRoutesAsync(componentId);
|
||||
if (existing.Any(r => r.Hostname.Equals(hostname, StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
string tlsMode = fieldValues.TryGetValue("tls-mode", out string? t) ? t : "ClusterIssuer";
|
||||
string issuerName = fieldValues.TryGetValue("cluster-issuer", out string? i) && !string.IsNullOrWhiteSpace(i) ? i : "letsencrypt-prod";
|
||||
string tlsCert = fieldValues.TryGetValue("tls-cert", out string? c) ? c : "";
|
||||
string tlsKey = fieldValues.TryGetValue("tls-key", out string? k) ? k : "";
|
||||
bool isManual = string.Equals(tlsMode, "Manual", StringComparison.Ordinal);
|
||||
|
||||
// Harbor's expose ClusterIP service is named by expose.clusterIP.name.
|
||||
// HarborService sets this to the release name so they always align.
|
||||
ClusterComponent? comp = components?.FirstOrDefault(c => c.Id == componentId);
|
||||
string releaseName = comp?.ReleaseName ?? comp?.Name ?? "harbor";
|
||||
string serviceName = releaseName;
|
||||
|
||||
ExternalRouteRequest routeRequest = new()
|
||||
{
|
||||
Hostname = hostname,
|
||||
ServiceName = serviceName,
|
||||
ServicePort = 80,
|
||||
PathPrefix = "/",
|
||||
TlsMode = isManual ? TlsMode.Manual : TlsMode.ClusterIssuer,
|
||||
ClusterIssuerName = isManual ? null : issuerName,
|
||||
TlsCertificate = isManual ? tlsCert : null,
|
||||
TlsPrivateKey = isManual && !string.IsNullOrWhiteSpace(tlsKey) ? tlsKey : null
|
||||
};
|
||||
|
||||
await RouteService.AddRouteAsync(componentId, routeRequest);
|
||||
}
|
||||
|
||||
private bool IsFieldVisible(ComponentFormField field, Dictionary<string, string> fieldValues)
|
||||
{
|
||||
if (field.DependsOnKey is null)
|
||||
@@ -2619,6 +2944,18 @@ else if (section == "components")
|
||||
routeError = null;
|
||||
routeYamlPreview = null;
|
||||
componentRoutes = await RouteService.GetRoutesAsync(componentId);
|
||||
await LoadRouteUptimes();
|
||||
}
|
||||
|
||||
private async Task LoadRouteUptimes()
|
||||
{
|
||||
if (componentRoutes is null) return;
|
||||
routeUptimes = new();
|
||||
foreach (ExternalRoute route in componentRoutes)
|
||||
{
|
||||
RouteUptimeSummary summary = await RouteService.GetRouteUptimeAsync(route.Id);
|
||||
routeUptimes[route.Id] = summary;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task AddRoute(Guid componentId)
|
||||
|
||||
@@ -70,7 +70,7 @@
|
||||
<div class="card text-center @GetUsageBorderClass(health.CpuUsagePercent)">
|
||||
<div class="card-body py-2">
|
||||
<div class="fs-3 fw-bold @GetUsageTextClass(health.CpuUsagePercent)">
|
||||
@health.CpuUsagePercent%
|
||||
@health.CpuUsagePercent.ToString("F1")%
|
||||
</div>
|
||||
<small class="text-muted">CPU Usage</small>
|
||||
<div class="progress mt-1" style="height: 4px;">
|
||||
@@ -84,7 +84,7 @@
|
||||
<div class="card text-center @GetUsageBorderClass(health.MemoryUsagePercent)">
|
||||
<div class="card-body py-2">
|
||||
<div class="fs-3 fw-bold @GetUsageTextClass(health.MemoryUsagePercent)">
|
||||
@health.MemoryUsagePercent%
|
||||
@health.MemoryUsagePercent.ToString("F1")%
|
||||
</div>
|
||||
<small class="text-muted">Memory Usage</small>
|
||||
<div class="progress mt-1" style="height: 4px;">
|
||||
@@ -130,7 +130,7 @@
|
||||
<div class="progress-bar @GetProgressBarClass(node.CpuUsagePercent)"
|
||||
style="width: @node.CpuUsagePercent%"></div>
|
||||
</div>
|
||||
<small>@node.CpuUsagePercent%</small>
|
||||
<small>@node.CpuUsagePercent.ToString("F1")%</small>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
@@ -139,7 +139,7 @@
|
||||
<div class="progress-bar @GetProgressBarClass(node.MemoryUsagePercent)"
|
||||
style="width: @node.MemoryUsagePercent%"></div>
|
||||
</div>
|
||||
<small>@node.MemoryUsagePercent%</small>
|
||||
<small>@node.MemoryUsagePercent.ToString("F1")%</small>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -85,7 +85,7 @@
|
||||
<div class="col-md-3 col-6">
|
||||
<div class="card text-center h-100 @GetBorderClass(health.CpuUsagePercent)">
|
||||
<div class="card-body py-3">
|
||||
<div class="fs-2 fw-bold @GetTextClass(health.CpuUsagePercent)">@health.CpuUsagePercent<span class="fs-6">%</span></div>
|
||||
<div class="fs-2 fw-bold @GetTextClass(health.CpuUsagePercent)">@health.CpuUsagePercent.ToString("F1")<span class="fs-6">%</span></div>
|
||||
<small class="text-muted">CPU Usage</small>
|
||||
<div class="progress mt-2" style="height: 4px;">
|
||||
<div class="progress-bar @GetBarClass(health.CpuUsagePercent)" style="width: @health.CpuUsagePercent%"></div>
|
||||
@@ -96,7 +96,7 @@
|
||||
<div class="col-md-3 col-6">
|
||||
<div class="card text-center h-100 @GetBorderClass(health.MemoryUsagePercent)">
|
||||
<div class="card-body py-3">
|
||||
<div class="fs-2 fw-bold @GetTextClass(health.MemoryUsagePercent)">@health.MemoryUsagePercent<span class="fs-6">%</span></div>
|
||||
<div class="fs-2 fw-bold @GetTextClass(health.MemoryUsagePercent)">@health.MemoryUsagePercent.ToString("F1")<span class="fs-6">%</span></div>
|
||||
<small class="text-muted">Memory Usage</small>
|
||||
<div class="progress mt-2" style="height: 4px;">
|
||||
<div class="progress-bar @GetBarClass(health.MemoryUsagePercent)" style="width: @health.MemoryUsagePercent%"></div>
|
||||
@@ -123,13 +123,6 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="card shadow-sm h-100">
|
||||
<div class="card-body">
|
||||
<MetricChart Title="Network Receive" DataPoints="networkRxHistory" Color="#198754" Unit=" MB/s" HeightPx="80" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="card shadow-sm h-100">
|
||||
<div class="card-body">
|
||||
@@ -178,7 +171,7 @@
|
||||
<div class="progress-bar @GetBarClass(node.CpuUsagePercent)"
|
||||
style="width: @node.CpuUsagePercent%"></div>
|
||||
</div>
|
||||
<small class="fw-medium" style="min-width: 40px;">@node.CpuUsagePercent%</small>
|
||||
<small class="fw-medium" style="min-width: 40px;">@node.CpuUsagePercent.ToString("F1")%</small>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
@@ -187,7 +180,7 @@
|
||||
<div class="progress-bar @GetBarClass(node.MemoryUsagePercent)"
|
||||
style="width: @node.MemoryUsagePercent%"></div>
|
||||
</div>
|
||||
<small class="fw-medium" style="min-width: 40px;">@node.MemoryUsagePercent%</small>
|
||||
<small class="fw-medium" style="min-width: 40px;">@node.MemoryUsagePercent.ToString("F1")%</small>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -201,6 +194,24 @@
|
||||
|
||||
@* ── Alerts ── *@
|
||||
<AlertPanel ClusterId="ClusterId" PrometheusService="PrometheusService" />
|
||||
|
||||
@* ── Scrape Targets ── *@
|
||||
<div class="mt-4">
|
||||
<h6 class="text-muted mb-2"><span class="bi bi-radar me-1"></span>Scrape Targets</h6>
|
||||
<ScrapeTargetsPanel ClusterId="ClusterId" />
|
||||
</div>
|
||||
|
||||
@* ── Alert Rules ── *@
|
||||
<div class="mt-4">
|
||||
<h6 class="text-muted mb-2"><span class="bi bi-list-check me-1"></span>Alerting Rules</h6>
|
||||
<AlertRulesPanel ClusterId="ClusterId" />
|
||||
</div>
|
||||
|
||||
@* ── RabbitMQ Metrics ── *@
|
||||
<div class="mt-4">
|
||||
<h6 class="text-muted mb-2"><span class="bi bi-collection me-1"></span>RabbitMQ</h6>
|
||||
<RabbitMQMonitoringPanel ClusterId="ClusterId" />
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@@ -210,7 +221,6 @@
|
||||
private ClusterHealthSummary? health;
|
||||
private List<TimeSeriesDataPoint> cpuHistory = [];
|
||||
private List<TimeSeriesDataPoint> memHistory = [];
|
||||
private List<TimeSeriesDataPoint> networkRxHistory = [];
|
||||
private List<TimeSeriesDataPoint> podCountHistory = [];
|
||||
private bool loading;
|
||||
private string? errorMessage;
|
||||
@@ -253,46 +263,22 @@
|
||||
|
||||
private async Task LoadMetrics()
|
||||
{
|
||||
TimeSpan duration = TimeSpan.FromMinutes(timeRange);
|
||||
KubernetesOperationResult<ClusterMetricsHistory> result =
|
||||
await PrometheusService.GetClusterMetricsHistoryAsync(
|
||||
ClusterId, TimeSpan.FromMinutes(timeRange));
|
||||
|
||||
// Query multiple range metrics in parallel.
|
||||
|
||||
Task<KubernetesOperationResult<List<PrometheusTimeSeries>>> cpuTask =
|
||||
PrometheusService.GetMetricRangeAsync(ClusterId,
|
||||
"100 - (avg(rate(node_cpu_seconds_total{mode=\"idle\"}[5m])) * 100)", duration);
|
||||
|
||||
Task<KubernetesOperationResult<List<PrometheusTimeSeries>>> memTask =
|
||||
PrometheusService.GetMetricRangeAsync(ClusterId,
|
||||
"100 - (avg(node_memory_MemAvailable_bytes / node_memory_MemTotal_bytes) * 100)", duration);
|
||||
|
||||
Task<KubernetesOperationResult<List<PrometheusTimeSeries>>> netTask =
|
||||
PrometheusService.GetMetricRangeAsync(ClusterId,
|
||||
"sum(rate(node_network_receive_bytes_total[5m])) / 1024 / 1024", duration);
|
||||
|
||||
Task<KubernetesOperationResult<List<PrometheusTimeSeries>>> podTask =
|
||||
PrometheusService.GetMetricRangeAsync(ClusterId,
|
||||
"sum(kube_pod_status_phase{phase=\"Running\"})", duration);
|
||||
|
||||
await Task.WhenAll(cpuTask, memTask, netTask, podTask);
|
||||
|
||||
KubernetesOperationResult<List<PrometheusTimeSeries>> cpuResult = await cpuTask;
|
||||
KubernetesOperationResult<List<PrometheusTimeSeries>> memResult = await memTask;
|
||||
KubernetesOperationResult<List<PrometheusTimeSeries>> netResult = await netTask;
|
||||
KubernetesOperationResult<List<PrometheusTimeSeries>> podResult = await podTask;
|
||||
|
||||
// Extract the first series from each result (these are aggregated single-line metrics).
|
||||
|
||||
cpuHistory = cpuResult.IsSuccess && cpuResult.Data!.Count > 0
|
||||
? cpuResult.Data[0].DataPoints : [];
|
||||
|
||||
memHistory = memResult.IsSuccess && memResult.Data!.Count > 0
|
||||
? memResult.Data[0].DataPoints : [];
|
||||
|
||||
networkRxHistory = netResult.IsSuccess && netResult.Data!.Count > 0
|
||||
? netResult.Data[0].DataPoints : [];
|
||||
|
||||
podCountHistory = podResult.IsSuccess && podResult.Data!.Count > 0
|
||||
? podResult.Data[0].DataPoints : [];
|
||||
if (result.IsSuccess && result.Data is not null)
|
||||
{
|
||||
cpuHistory = result.Data.CpuPercent;
|
||||
memHistory = result.Data.MemoryPercent;
|
||||
podCountHistory = result.Data.PodCount;
|
||||
}
|
||||
else
|
||||
{
|
||||
cpuHistory = memHistory = podCountHistory = [];
|
||||
if (!string.IsNullOrEmpty(result.Error))
|
||||
errorMessage ??= result.Error;
|
||||
}
|
||||
}
|
||||
|
||||
private static string GetBorderClass(double percent) => percent switch
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
@using Microsoft.EntityFrameworkCore
|
||||
@inject TenantService TenantService
|
||||
@inject CustomerAccessService CustomerAccessService
|
||||
@inject PrometheusService PrometheusService
|
||||
|
||||
@* ===========================================================================
|
||||
Three-level drill-down: Customers ▸ Apps ▸ App Detail
|
||||
@@ -28,14 +29,82 @@ else if (selectedCustomer is not null)
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<div class="d-flex align-items-center mb-3">
|
||||
<i class="bi bi-person-circle fs-4 me-2 text-primary"></i>
|
||||
<div>
|
||||
<h4 class="mb-0">@selectedCustomer.Name</h4>
|
||||
<small class="text-muted">Applications owned by this customer</small>
|
||||
<div class="d-flex align-items-center justify-content-between mb-3">
|
||||
<div class="d-flex align-items-center">
|
||||
<i class="bi bi-person-circle fs-4 me-2 text-primary"></i>
|
||||
<div>
|
||||
<h4 class="mb-0">@selectedCustomer.Name</h4>
|
||||
<small class="text-muted">Applications owned by this customer</small>
|
||||
</div>
|
||||
</div>
|
||||
<button class="btn btn-sm @(customerView == "metrics" ? "btn-primary" : "btn-outline-secondary")"
|
||||
@onclick="OpenCustomerMetrics">
|
||||
<i class="bi bi-bar-chart-line me-1"></i>Metrics
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@if (customerView == "metrics")
|
||||
{
|
||||
<div class="card shadow-sm mb-4">
|
||||
<div class="card-header bg-white py-2 d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<i class="bi bi-bar-chart-line me-2 text-primary"></i>
|
||||
<strong>Customer Metrics</strong>
|
||||
<span class="text-muted small ms-2">aggregate across all apps and deployments</span>
|
||||
</div>
|
||||
<button class="btn btn-sm btn-outline-secondary" @onclick="OpenCustomerMetrics" disabled="@customerMetricsLoading">
|
||||
<i class="bi bi-arrow-clockwise me-1"></i>Refresh
|
||||
</button>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
@if (customerMetricsLoading)
|
||||
{
|
||||
<div class="text-center py-4"><div class="spinner-border spinner-border-sm text-primary"></div></div>
|
||||
}
|
||||
else if (customerMetricsError is not null)
|
||||
{
|
||||
<div class="alert alert-warning small py-2 mb-0">
|
||||
<i class="bi bi-exclamation-triangle me-1"></i>@customerMetricsError
|
||||
</div>
|
||||
}
|
||||
else if (customerMetrics is not null)
|
||||
{
|
||||
<div class="row g-3 mb-2">
|
||||
<div class="col-sm-6 col-lg-3">
|
||||
<div class="border rounded text-center p-3">
|
||||
<div class="fs-3 fw-bold text-primary">
|
||||
@(customerMetrics.CpuCores >= 1 ? $"{(int)Math.Round(customerMetrics.CpuCores)} cores" : $"{(int)Math.Round(customerMetrics.CpuCores * 1000)}m")
|
||||
</div>
|
||||
<div class="small text-muted">CPU (5 m avg)</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-sm-6 col-lg-3">
|
||||
<div class="border rounded text-center p-3">
|
||||
<div class="fs-3 fw-bold text-info">@customerMetrics.MemoryMiB.ToString("F0") <small class="fs-6">MiB</small></div>
|
||||
<div class="small text-muted">Memory (working set)</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-sm-6 col-lg-3">
|
||||
<div class="border rounded text-center p-3">
|
||||
<div class="fs-3 fw-bold text-success">@customerMetrics.PodCount</div>
|
||||
<div class="small text-muted">Pods running</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-sm-6 col-lg-3">
|
||||
<div class="border rounded text-center p-3">
|
||||
<div class="fs-3 fw-bold @(customerMetrics.RestartCount > 0 ? "text-warning" : "text-success")">@customerMetrics.RestartCount</div>
|
||||
<div class="small text-muted">Container restarts</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-muted small">
|
||||
<i class="bi bi-clock me-1"></i>Queried @customerMetrics.QueriedAt.ToLocalTime().ToString("HH:mm:ss")
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@* --- Add App --- *@
|
||||
<div class="card border-0 shadow-sm mb-4">
|
||||
<div class="card-body py-2">
|
||||
@@ -274,6 +343,12 @@ else
|
||||
private List<Data.App>? apps;
|
||||
private string newAppName = "";
|
||||
private string? appError;
|
||||
private string customerView = "apps"; // "apps" | "metrics"
|
||||
|
||||
// Customer metrics
|
||||
private DeploymentMetricsSummary? customerMetrics;
|
||||
private bool customerMetricsLoading;
|
||||
private string? customerMetricsError;
|
||||
|
||||
// --- Level 3: Selected app → detail ---
|
||||
private Data.App? selectedApp;
|
||||
@@ -337,10 +412,31 @@ else
|
||||
newAppName = "";
|
||||
accessError = null;
|
||||
accessSuccess = null;
|
||||
customerView = "apps";
|
||||
customerMetrics = null;
|
||||
await LoadApps();
|
||||
await LoadAccesses();
|
||||
}
|
||||
|
||||
private async Task OpenCustomerMetrics()
|
||||
{
|
||||
customerView = "metrics";
|
||||
customerMetricsLoading = true;
|
||||
customerMetricsError = null;
|
||||
customerMetrics = null;
|
||||
StateHasChanged();
|
||||
|
||||
KubernetesOperationResult<DeploymentMetricsSummary> result =
|
||||
await PrometheusService.GetCustomerMetricsAsync(selectedCustomer!.Id);
|
||||
|
||||
if (result.IsSuccess)
|
||||
customerMetrics = result.Data;
|
||||
else
|
||||
customerMetricsError = result.Error;
|
||||
|
||||
customerMetricsLoading = false;
|
||||
}
|
||||
|
||||
private async Task BackToCustomers()
|
||||
{
|
||||
selectedCustomer = null;
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
@using EntKube.Web.Data
|
||||
@using EntKube.Web.Services
|
||||
@using Microsoft.EntityFrameworkCore
|
||||
@inject PrometheusService PrometheusService
|
||||
@inject IDbContextFactory<ApplicationDbContext> DbFactory
|
||||
|
||||
@if (summaries.Count == 0 && !isLoading)
|
||||
{
|
||||
<div class="text-muted small text-center py-3">
|
||||
<span class="bi bi-bar-chart me-1"></span>
|
||||
No CNPG clusters found, or Prometheus is not available for this cluster.
|
||||
</div>
|
||||
}
|
||||
else if (isLoading)
|
||||
{
|
||||
<div class="text-muted small text-center py-2">
|
||||
<span class="bi bi-hourglass-split me-1"></span> Loading database metrics…
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
@foreach (CnpgMetricsSummary summary in summaries)
|
||||
{
|
||||
<div class="card mb-3">
|
||||
<div class="card-header py-2 d-flex align-items-center gap-2">
|
||||
<span class="bi bi-database-fill-gear text-info"></span>
|
||||
<strong>@summary.ClusterName</strong>
|
||||
<span class="ms-auto text-muted small">@summary.QueriedAt.ToLocalTime().ToString("HH:mm:ss")</span>
|
||||
</div>
|
||||
<div class="card-body py-2">
|
||||
<div class="row g-3 mb-3">
|
||||
<div class="col-auto">
|
||||
<div class="small text-muted">Replication Lag</div>
|
||||
<div class="fw-semibold @(summary.ReplicationLagSeconds > 5 ? "text-danger" : summary.ReplicationLagSeconds > 1 ? "text-warning" : "text-success")">
|
||||
@summary.ReplicationLagSeconds.ToString("F1")s
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<div class="small text-muted">Backends</div>
|
||||
<div class="fw-semibold">@summary.TotalBackends</div>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<div class="small text-muted">Active Queries</div>
|
||||
<div class="fw-semibold">@summary.ActiveQueries</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (summary.DatabaseSizes.Count > 0)
|
||||
{
|
||||
<div class="small text-muted mb-1">Database Sizes</div>
|
||||
<table class="table table-sm mb-0">
|
||||
<tbody>
|
||||
@foreach (CnpgDatabaseSize db in summary.DatabaseSizes)
|
||||
{
|
||||
<tr>
|
||||
<td class="py-1">@db.DatabaseName</td>
|
||||
<td class="py-1 text-end text-muted">@db.SizeMiB.ToString("F1") MiB</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
@code {
|
||||
[Parameter] public required Guid ClusterId { get; set; }
|
||||
|
||||
private List<CnpgMetricsSummary> summaries = [];
|
||||
private bool isLoading = true;
|
||||
|
||||
protected override async Task OnParametersSetAsync() => await LoadMetrics();
|
||||
|
||||
private async Task LoadMetrics()
|
||||
{
|
||||
isLoading = true;
|
||||
StateHasChanged();
|
||||
|
||||
List<string> cnpgNames;
|
||||
using (ApplicationDbContext db = DbFactory.CreateDbContext())
|
||||
{
|
||||
cnpgNames = await db.CnpgClusters
|
||||
.Where(c => c.KubernetesClusterId == ClusterId)
|
||||
.Select(c => c.Name)
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
List<CnpgMetricsSummary> results = [];
|
||||
foreach (string name in cnpgNames)
|
||||
{
|
||||
KubernetesOperationResult<CnpgMetricsSummary> result =
|
||||
await PrometheusService.GetCnpgClusterMetricsAsync(ClusterId, name);
|
||||
|
||||
if (result.IsSuccess && result.Data is not null)
|
||||
results.Add(result.Data);
|
||||
}
|
||||
|
||||
summaries = results;
|
||||
isLoading = false;
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
463
src/EntKube.Web/Components/Pages/Tenants/GitReposTab.razor
Normal file
463
src/EntKube.Web/Components/Pages/Tenants/GitReposTab.razor
Normal file
@@ -0,0 +1,463 @@
|
||||
@using EntKube.Web.Data
|
||||
@using EntKube.Web.Services
|
||||
@inject GitRepositoryService GitRepoService
|
||||
@inject VaultService VaultService
|
||||
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-header bg-white py-2 d-flex align-items-center justify-content-between">
|
||||
<div>
|
||||
<i class="bi bi-git me-2 text-primary"></i>
|
||||
<strong>Git Repositories</strong>
|
||||
</div>
|
||||
<button class="btn btn-sm btn-outline-primary" @onclick="() => showAddForm = !showAddForm">
|
||||
<i class="bi bi-plus me-1"></i>Add Repository
|
||||
</button>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
|
||||
@* ── Add repo form ── *@
|
||||
@if (showAddForm)
|
||||
{
|
||||
<div class="card border-primary mb-3">
|
||||
<div class="card-body">
|
||||
<h6 class="mb-3"><i class="bi bi-plus-circle me-1"></i>Register Git Repository</h6>
|
||||
|
||||
@if (addError is not null)
|
||||
{
|
||||
<div class="alert alert-danger py-1 small mb-2">
|
||||
<i class="bi bi-exclamation-triangle me-1"></i>@addError
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="row g-2 mb-2">
|
||||
<div class="col-md-4">
|
||||
<label class="form-label small">Name</label>
|
||||
<input class="form-control form-control-sm" @bind="newName" placeholder="e.g. prod-gitops" />
|
||||
</div>
|
||||
<div class="col-md-5">
|
||||
<label class="form-label small">URL</label>
|
||||
<input class="form-control form-control-sm" @bind="newUrl"
|
||||
placeholder="https://github.com/org/repo or git@github.com:org/repo.git" />
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label small">Default branch</label>
|
||||
<input class="form-control form-control-sm" @bind="newBranch" placeholder="main" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row g-2 mb-2">
|
||||
<div class="col-md-4">
|
||||
<label class="form-label small">Authentication</label>
|
||||
<select class="form-select form-select-sm" @bind="newAuthType">
|
||||
<option value="@GitAuthType.None">None (public)</option>
|
||||
<option value="@GitAuthType.HttpsPat">HTTPS — Personal Access Token</option>
|
||||
<option value="@GitAuthType.HttpsPassword">HTTPS — Username + Password</option>
|
||||
<option value="@GitAuthType.SshKey">SSH — Private Key</option>
|
||||
</select>
|
||||
</div>
|
||||
@if (newAuthType == GitAuthType.HttpsPassword)
|
||||
{
|
||||
<div class="col-md-4">
|
||||
<label class="form-label small">Username</label>
|
||||
<input class="form-control form-control-sm" @bind="newUsername" placeholder="e.g. git-user" />
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="d-flex gap-1">
|
||||
<button class="btn btn-sm btn-primary" @onclick="AddRepository"
|
||||
disabled="@(string.IsNullOrWhiteSpace(newName) || string.IsNullOrWhiteSpace(newUrl) || adding)">
|
||||
@if (adding) { <span class="spinner-border spinner-border-sm me-1"></span> }
|
||||
Save
|
||||
</button>
|
||||
<button class="btn btn-sm btn-outline-secondary" @onclick="CancelAdd">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@* ── Repo list ── *@
|
||||
@if (repos is null)
|
||||
{
|
||||
<div class="text-center py-3">
|
||||
<div class="spinner-border spinner-border-sm text-primary" role="status"></div>
|
||||
</div>
|
||||
}
|
||||
else if (repos.Count == 0 && !showAddForm)
|
||||
{
|
||||
<div class="text-center py-4">
|
||||
<i class="bi bi-git text-muted" style="font-size: 2rem;"></i>
|
||||
<p class="text-muted small mt-2 mb-0">No repositories registered. Add one to enable Git-sourced deployments.</p>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<table class="table table-sm mb-0">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th class="small">Name</th>
|
||||
<th class="small">URL</th>
|
||||
<th class="small">Auth</th>
|
||||
<th class="small">Branch</th>
|
||||
<th class="small">Connection</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (GitRepository repo in repos)
|
||||
{
|
||||
<tr>
|
||||
<td class="small fw-medium align-middle">@repo.Name</td>
|
||||
<td class="small align-middle">
|
||||
<code class="text-muted" style="font-size:0.75rem;">@TruncateUrl(repo.Url)</code>
|
||||
</td>
|
||||
<td class="small align-middle">@AuthLabel(repo.AuthType)</td>
|
||||
<td class="small align-middle text-muted">@repo.DefaultBranch</td>
|
||||
<td class="small align-middle">
|
||||
@if (testingRepoId == repo.Id)
|
||||
{
|
||||
<span class="spinner-border spinner-border-sm text-primary" style="width:0.8rem;height:0.8rem;"></span>
|
||||
}
|
||||
else if (testResults.TryGetValue(repo.Id, out var result))
|
||||
{
|
||||
@if (result.Success)
|
||||
{
|
||||
<span class="badge bg-success-subtle text-success border border-success-subtle">
|
||||
<i class="bi bi-check-circle me-1"></i>@result.Message
|
||||
</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="badge bg-danger-subtle text-danger border border-danger-subtle"
|
||||
title="@result.Message">
|
||||
<i class="bi bi-x-circle me-1"></i>Failed
|
||||
</span>
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="text-muted small">—</span>
|
||||
}
|
||||
</td>
|
||||
<td class="text-end align-middle">
|
||||
<div class="d-flex gap-1 justify-content-end">
|
||||
<button class="btn btn-sm btn-outline-secondary" title="Test connection"
|
||||
@onclick="() => TestConnection(repo.Id)"
|
||||
disabled="@(testingRepoId == repo.Id)">
|
||||
<i class="bi bi-plug"></i>
|
||||
</button>
|
||||
<button class="btn btn-sm btn-outline-primary" title="Manage credentials"
|
||||
@onclick="() => ToggleCredentials(repo.Id)">
|
||||
<i class="bi bi-key"></i>
|
||||
</button>
|
||||
@if (confirmDeleteRepoId != repo.Id)
|
||||
{
|
||||
<button class="btn btn-sm btn-outline-danger" title="Delete"
|
||||
@onclick="() => confirmDeleteRepoId = repo.Id">
|
||||
<i class="bi bi-trash"></i>
|
||||
</button>
|
||||
}
|
||||
else
|
||||
{
|
||||
<button class="btn btn-sm btn-danger" @onclick="() => DeleteRepo(repo.Id)">Yes</button>
|
||||
<button class="btn btn-sm btn-outline-secondary" @onclick="() => confirmDeleteRepoId = null">No</button>
|
||||
}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@* ── Credential entry panel (expanded inline) ── *@
|
||||
@if (credentialRepoId == repo.Id)
|
||||
{
|
||||
<tr>
|
||||
<td colspan="6" class="p-0">
|
||||
<div class="p-3 bg-light border-top">
|
||||
<h6 class="mb-2 small text-muted text-uppercase fw-bold">
|
||||
<i class="bi bi-shield-lock me-1"></i>Credentials — <em>@repo.Name</em>
|
||||
</h6>
|
||||
|
||||
@if (credError is not null)
|
||||
{
|
||||
<div class="alert alert-danger py-1 small mb-2">@credError</div>
|
||||
}
|
||||
@if (credSuccess is not null)
|
||||
{
|
||||
<div class="alert alert-success py-1 small mb-2">
|
||||
<i class="bi bi-check-circle me-1"></i>@credSuccess
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (repo.AuthType == GitAuthType.HttpsPat)
|
||||
{
|
||||
<div class="row g-2 mb-2">
|
||||
<div class="col-md-6">
|
||||
<label class="form-label small">Personal Access Token</label>
|
||||
<input type="password" class="form-control form-control-sm"
|
||||
@bind="credValue" placeholder="ghp_... or similar" autocomplete="new-password" />
|
||||
<div class="form-text">Stored encrypted in the tenant vault.</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
else if (repo.AuthType == GitAuthType.HttpsPassword)
|
||||
{
|
||||
<div class="row g-2 mb-2">
|
||||
<div class="col-md-6">
|
||||
<label class="form-label small">Password</label>
|
||||
<input type="password" class="form-control form-control-sm"
|
||||
@bind="credValue" placeholder="Password" autocomplete="new-password" />
|
||||
<div class="form-text">Username: <code>@repo.Username</code> — stored encrypted in the tenant vault.</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
else if (repo.AuthType == GitAuthType.SshKey)
|
||||
{
|
||||
<div class="row g-2 mb-2">
|
||||
<div class="col-12">
|
||||
<label class="form-label small">SSH Private Key (PEM format)</label>
|
||||
<textarea class="form-control form-control-sm font-monospace" rows="6"
|
||||
@bind="credValue"
|
||||
placeholder="-----BEGIN OPENSSH PRIVATE KEY----- ... -----END OPENSSH PRIVATE KEY-----" />
|
||||
<div class="form-text">Stored encrypted in the tenant vault. The public key for the deploy key must be added to the repository.</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<p class="text-muted small mb-2">This repository uses no authentication (public). No credentials needed.</p>
|
||||
}
|
||||
|
||||
@if (repo.AuthType != GitAuthType.None)
|
||||
{
|
||||
<div class="d-flex gap-1">
|
||||
<button class="btn btn-sm btn-primary" @onclick="() => SaveCredential(repo)"
|
||||
disabled="@(string.IsNullOrWhiteSpace(credValue) || savingCred)">
|
||||
@if (savingCred) { <span class="spinner-border spinner-border-sm me-1"></span> }
|
||||
Save to Vault
|
||||
</button>
|
||||
<button class="btn btn-sm btn-outline-secondary" @onclick="CloseCredentials">Close</button>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<button class="btn btn-sm btn-outline-secondary" @onclick="CloseCredentials">Close</button>
|
||||
}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@* ── Known Hosts section ── *@
|
||||
@if (knownHosts is not null && knownHosts.Count > 0)
|
||||
{
|
||||
<div class="card shadow-sm mt-3">
|
||||
<div class="card-header bg-white py-2">
|
||||
<i class="bi bi-shield-check me-2 text-success"></i>
|
||||
<strong>Trusted SSH Hosts</strong>
|
||||
<small class="text-muted ms-2">Auto-trusted on first connection. Remove to force re-verification.</small>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<table class="table table-sm mb-0">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th class="small px-3">Hostname</th>
|
||||
<th class="small">Key type</th>
|
||||
<th class="small">Fingerprint</th>
|
||||
<th class="small">Trusted</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (GitKnownHost host in knownHosts)
|
||||
{
|
||||
<tr>
|
||||
<td class="small px-3 align-middle"><code>@host.Hostname</code></td>
|
||||
<td class="small align-middle text-muted">@host.KeyType</td>
|
||||
<td class="small align-middle">
|
||||
<code style="font-size:0.7rem;">@host.Fingerprint</code>
|
||||
</td>
|
||||
<td class="small align-middle text-muted">@host.TrustedAt.ToString("yyyy-MM-dd")</td>
|
||||
<td class="text-end align-middle pe-3">
|
||||
@if (confirmDeleteHostId != host.Id)
|
||||
{
|
||||
<button class="btn btn-sm btn-outline-danger" title="Remove trust"
|
||||
@onclick="() => confirmDeleteHostId = host.Id">
|
||||
<i class="bi bi-trash"></i>
|
||||
</button>
|
||||
}
|
||||
else
|
||||
{
|
||||
<button class="btn btn-sm btn-danger" @onclick="() => DeleteKnownHost(host.Id)">Yes</button>
|
||||
<button class="btn btn-sm btn-outline-secondary ms-1" @onclick="() => confirmDeleteHostId = null">No</button>
|
||||
}
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@code {
|
||||
[Parameter, EditorRequired] public Guid TenantId { get; set; }
|
||||
|
||||
private List<GitRepository>? repos;
|
||||
private List<GitKnownHost>? knownHosts;
|
||||
|
||||
// Add form
|
||||
private bool showAddForm;
|
||||
private bool adding;
|
||||
private string newName = "";
|
||||
private string newUrl = "";
|
||||
private string newBranch = "main";
|
||||
private GitAuthType newAuthType = GitAuthType.None;
|
||||
private string newUsername = "";
|
||||
private string? addError;
|
||||
|
||||
// Test connection
|
||||
private Guid? testingRepoId;
|
||||
private Dictionary<Guid, (bool Success, string Message)> testResults = [];
|
||||
|
||||
// Credentials panel
|
||||
private Guid? credentialRepoId;
|
||||
private string credValue = "";
|
||||
private bool savingCred;
|
||||
private string? credError;
|
||||
private string? credSuccess;
|
||||
|
||||
// Delete repo
|
||||
private Guid? confirmDeleteRepoId;
|
||||
|
||||
// Delete known host
|
||||
private Guid? confirmDeleteHostId;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
await LoadAsync();
|
||||
}
|
||||
|
||||
private async Task LoadAsync()
|
||||
{
|
||||
repos = await GitRepoService.GetRepositoriesAsync(TenantId);
|
||||
knownHosts = await GitRepoService.GetKnownHostsAsync(TenantId);
|
||||
}
|
||||
|
||||
private async Task AddRepository()
|
||||
{
|
||||
addError = null;
|
||||
adding = true;
|
||||
try
|
||||
{
|
||||
await GitRepoService.CreateRepositoryAsync(
|
||||
TenantId, newName, newUrl, newAuthType,
|
||||
string.IsNullOrWhiteSpace(newUsername) ? null : newUsername,
|
||||
string.IsNullOrWhiteSpace(newBranch) ? "main" : newBranch);
|
||||
|
||||
showAddForm = false;
|
||||
newName = ""; newUrl = ""; newBranch = "main";
|
||||
newAuthType = GitAuthType.None; newUsername = "";
|
||||
await LoadAsync();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
addError = ex.Message;
|
||||
}
|
||||
finally { adding = false; }
|
||||
}
|
||||
|
||||
private void CancelAdd()
|
||||
{
|
||||
showAddForm = false;
|
||||
addError = null;
|
||||
newName = ""; newUrl = ""; newBranch = "main";
|
||||
newAuthType = GitAuthType.None; newUsername = "";
|
||||
}
|
||||
|
||||
private async Task TestConnection(Guid repoId)
|
||||
{
|
||||
testingRepoId = repoId;
|
||||
testResults.Remove(repoId);
|
||||
StateHasChanged();
|
||||
|
||||
var (success, message) = await GitRepoService.ValidateConnectionAsync(TenantId, repoId);
|
||||
testResults[repoId] = (success, message);
|
||||
testingRepoId = null;
|
||||
}
|
||||
|
||||
private void ToggleCredentials(Guid repoId)
|
||||
{
|
||||
if (credentialRepoId == repoId)
|
||||
{
|
||||
CloseCredentials();
|
||||
}
|
||||
else
|
||||
{
|
||||
credentialRepoId = repoId;
|
||||
credValue = ""; credError = null; credSuccess = null;
|
||||
}
|
||||
}
|
||||
|
||||
private void CloseCredentials()
|
||||
{
|
||||
credentialRepoId = null;
|
||||
credValue = ""; credError = null; credSuccess = null;
|
||||
}
|
||||
|
||||
private async Task SaveCredential(GitRepository repo)
|
||||
{
|
||||
credError = null; credSuccess = null;
|
||||
savingCred = true;
|
||||
try
|
||||
{
|
||||
switch (repo.AuthType)
|
||||
{
|
||||
case GitAuthType.HttpsPat:
|
||||
await GitRepoService.SetPatAsync(TenantId, repo.Id, credValue);
|
||||
break;
|
||||
case GitAuthType.HttpsPassword:
|
||||
await GitRepoService.SetPasswordAsync(TenantId, repo.Id, credValue);
|
||||
break;
|
||||
case GitAuthType.SshKey:
|
||||
await GitRepoService.SetSshKeyAsync(TenantId, repo.Id, credValue);
|
||||
break;
|
||||
}
|
||||
credSuccess = "Saved to vault.";
|
||||
credValue = "";
|
||||
}
|
||||
catch (Exception ex) { credError = ex.Message; }
|
||||
finally { savingCred = false; }
|
||||
}
|
||||
|
||||
private async Task DeleteRepo(Guid repoId)
|
||||
{
|
||||
await GitRepoService.DeleteRepositoryAsync(TenantId, repoId);
|
||||
confirmDeleteRepoId = null;
|
||||
testResults.Remove(repoId);
|
||||
if (credentialRepoId == repoId) CloseCredentials();
|
||||
await LoadAsync();
|
||||
}
|
||||
|
||||
private async Task DeleteKnownHost(Guid hostId)
|
||||
{
|
||||
await GitRepoService.RemoveKnownHostAsync(TenantId, hostId);
|
||||
confirmDeleteHostId = null;
|
||||
await LoadAsync();
|
||||
}
|
||||
|
||||
private static string AuthLabel(GitAuthType t) => t switch
|
||||
{
|
||||
GitAuthType.HttpsPat => "PAT",
|
||||
GitAuthType.HttpsPassword => "User/Pass",
|
||||
GitAuthType.SshKey => "SSH Key",
|
||||
_ => "None"
|
||||
};
|
||||
|
||||
private static string TruncateUrl(string url) =>
|
||||
url.Length > 55 ? url[..52] + "…" : url;
|
||||
}
|
||||
585
src/EntKube.Web/Components/Pages/Tenants/GovernanceTab.razor
Normal file
585
src/EntKube.Web/Components/Pages/Tenants/GovernanceTab.razor
Normal file
@@ -0,0 +1,585 @@
|
||||
@using EntKube.Web.Data
|
||||
@using EntKube.Web.Services
|
||||
@inject AppGovernanceService GovernanceService
|
||||
@inject TenantService TenantService
|
||||
|
||||
@* ═══════════════════════════════════════════════════════════════════
|
||||
GovernanceTab — tenant admin view for app-level namespace, resource
|
||||
quotas, network policies, and RBAC. All changes are saved to the
|
||||
database. "Apply to clusters" pushes to every cluster where this
|
||||
app has a deployment.
|
||||
═══════════════════════════════════════════════════════════════════ *@
|
||||
|
||||
<div class="d-flex align-items-center justify-content-between mb-3">
|
||||
<div>
|
||||
<h5 class="mb-0"><i class="bi bi-shield-check me-2 text-primary"></i>Governance</h5>
|
||||
<small class="text-muted">Namespace, quotas, network policies, and RBAC — read-only in the customer portal.</small>
|
||||
</div>
|
||||
<button class="btn btn-sm btn-success" @onclick="ApplyToAllClusters" disabled="@(applying || string.IsNullOrWhiteSpace(ns))">
|
||||
@if (applying) { <span class="spinner-border spinner-border-sm me-1"></span> }
|
||||
else { <i class="bi bi-cloud-upload me-1"></i> }
|
||||
Apply to clusters
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@if (applyOutput is not null)
|
||||
{
|
||||
<div class="alert @(applySuccess ? "alert-success" : "alert-danger") py-2 small mb-3 d-flex align-items-start justify-content-between">
|
||||
<div>
|
||||
<i class="bi @(applySuccess ? "bi-check-circle" : "bi-exclamation-triangle") me-1"></i>
|
||||
<strong>@(applySuccess ? "Applied successfully" : "Apply failed")</strong>
|
||||
<pre class="mb-0 mt-1 small" style="white-space:pre-wrap;max-height:200px;overflow-y:auto;">@applyOutput</pre>
|
||||
</div>
|
||||
<button class="btn-close btn-close-sm ms-2 flex-shrink-0" @onclick="() => applyOutput = null"></button>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (loading)
|
||||
{
|
||||
<div class="text-center py-4"><div class="spinner-border spinner-border-sm text-primary"></div></div>
|
||||
}
|
||||
else
|
||||
{
|
||||
@* ── 1. Namespace ─────────────────────────────────────────────── *@
|
||||
<div class="card shadow-sm mb-3">
|
||||
<div class="card-header bg-white py-2">
|
||||
<i class="bi bi-box me-2 text-primary"></i>
|
||||
<strong>Namespace</strong>
|
||||
<span class="text-muted small ms-2">The Kubernetes namespace where quotas and policies are enforced.</span>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row g-2 align-items-end">
|
||||
<div class="col-md-5">
|
||||
<label class="form-label small">Namespace</label>
|
||||
<input class="form-control form-control-sm font-monospace" @bind="ns"
|
||||
placeholder="e.g. my-app" />
|
||||
<div class="form-text">Must be a valid DNS label (lowercase, hyphens, max 63 chars).</div>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<button class="btn btn-sm btn-primary" @onclick="SaveNamespace"
|
||||
disabled="@savingNs">
|
||||
@if (savingNs) { <span class="spinner-border spinner-border-sm me-1"></span> }
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
@if (nsMessage is not null)
|
||||
{
|
||||
<div class="col-auto">
|
||||
<span class="small @(nsOk ? "text-success" : "text-danger")">
|
||||
<i class="bi @(nsOk ? "bi-check-circle" : "bi-x-circle") me-1"></i>@nsMessage
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@* ── 2. Resource Quota ─────────────────────────────────────────── *@
|
||||
<div class="card shadow-sm mb-3">
|
||||
<div class="card-header bg-white py-2 d-flex align-items-center justify-content-between">
|
||||
<div>
|
||||
<i class="bi bi-speedometer me-2 text-warning"></i>
|
||||
<strong>Resource Quota</strong>
|
||||
<span class="text-muted small ms-2">Limits applied as a Kubernetes ResourceQuota in the namespace.</span>
|
||||
</div>
|
||||
@if (quota is not null)
|
||||
{
|
||||
<button class="btn btn-sm btn-outline-danger" @onclick="DeleteQuota">
|
||||
<i class="bi bi-trash me-1"></i>Remove quota
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
<div class="card-body">
|
||||
@if (quotaMessage is not null)
|
||||
{
|
||||
<div class="alert @(quotaOk ? "alert-success" : "alert-danger") py-1 small mb-2">
|
||||
<i class="bi @(quotaOk ? "bi-check-circle" : "bi-x-circle") me-1"></i>@quotaMessage
|
||||
</div>
|
||||
}
|
||||
<div class="row g-2 mb-2">
|
||||
<div class="col-md-3">
|
||||
<label class="form-label small">CPU request</label>
|
||||
<input class="form-control form-control-sm font-monospace" @bind="qCpuReq" placeholder="e.g. 250m" />
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label small">CPU limit</label>
|
||||
<input class="form-control form-control-sm font-monospace" @bind="qCpuLim" placeholder="e.g. 2" />
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label small">Memory request</label>
|
||||
<input class="form-control form-control-sm font-monospace" @bind="qMemReq" placeholder="e.g. 128Mi" />
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label small">Memory limit</label>
|
||||
<input class="form-control form-control-sm font-monospace" @bind="qMemLim" placeholder="e.g. 512Mi" />
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label small">Max pods</label>
|
||||
<input type="number" class="form-control form-control-sm" @bind="qMaxPods" placeholder="e.g. 10" min="1" />
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label small">Max PVCs</label>
|
||||
<input type="number" class="form-control form-control-sm" @bind="qMaxPvcs" placeholder="e.g. 5" min="1" />
|
||||
</div>
|
||||
</div>
|
||||
<button class="btn btn-sm btn-primary" @onclick="SaveQuota" disabled="@savingQuota">
|
||||
@if (savingQuota) { <span class="spinner-border spinner-border-sm me-1"></span> }
|
||||
Save quota
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@* ── 3. Network Policies ───────────────────────────────────────── *@
|
||||
<div class="card shadow-sm mb-3">
|
||||
<div class="card-header bg-white py-2 d-flex align-items-center justify-content-between">
|
||||
<div>
|
||||
<i class="bi bi-diagram-2 me-2 text-info"></i>
|
||||
<strong>Network Policies</strong>
|
||||
<span class="text-muted small ms-2">Applied as Kubernetes NetworkPolicy resources.</span>
|
||||
</div>
|
||||
<button class="btn btn-sm btn-outline-info" @onclick="() => showAddNp = !showAddNp">
|
||||
<i class="bi bi-plus me-1"></i>Add policy
|
||||
</button>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
@if (npMessage is not null)
|
||||
{
|
||||
<div class="alert alert-danger py-1 small mb-2">@npMessage</div>
|
||||
}
|
||||
|
||||
@if (showAddNp)
|
||||
{
|
||||
<div class="card border-info mb-3">
|
||||
<div class="card-body">
|
||||
<div class="row g-2 mb-2">
|
||||
<div class="col-md-4">
|
||||
<label class="form-label small">Policy name (K8s resource name)</label>
|
||||
<input class="form-control form-control-sm font-monospace" @bind="newNpName"
|
||||
placeholder="e.g. deny-all" />
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label small">Type</label>
|
||||
<select class="form-select form-select-sm" @bind="newNpType">
|
||||
<option value="@AppNetworkPolicyType.DenyAll">Deny all (ingress + egress)</option>
|
||||
<option value="@AppNetworkPolicyType.AllowFromIngress">Allow from ingress controller</option>
|
||||
<option value="@AppNetworkPolicyType.AllowFromSameNamespace">Allow from same namespace</option>
|
||||
<option value="@AppNetworkPolicyType.AllowFromNamespace">Allow from specific namespace</option>
|
||||
<option value="@AppNetworkPolicyType.Custom">Custom YAML</option>
|
||||
</select>
|
||||
</div>
|
||||
@if (newNpType == AppNetworkPolicyType.AllowFromNamespace)
|
||||
{
|
||||
<div class="col-md-4">
|
||||
<label class="form-label small">Source namespace</label>
|
||||
<input class="form-control form-control-sm font-monospace" @bind="newNpAllowNs"
|
||||
placeholder="e.g. monitoring" />
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
@if (newNpType == AppNetworkPolicyType.Custom)
|
||||
{
|
||||
<div class="mb-2">
|
||||
<label class="form-label small">Custom NetworkPolicy YAML</label>
|
||||
<textarea class="form-control form-control-sm font-monospace" rows="8"
|
||||
@bind="newNpCustomYaml"
|
||||
placeholder="apiVersion: networking.k8s.io/v1 kind: NetworkPolicy ..." />
|
||||
</div>
|
||||
}
|
||||
<div class="d-flex gap-1">
|
||||
<button class="btn btn-sm btn-info" @onclick="AddNetworkPolicy"
|
||||
disabled="@(string.IsNullOrWhiteSpace(newNpName) || addingNp)">
|
||||
@if (addingNp) { <span class="spinner-border spinner-border-sm me-1"></span> }
|
||||
Add
|
||||
</button>
|
||||
<button class="btn btn-sm btn-outline-secondary" @onclick="() => showAddNp = false">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (networkPolicies.Count == 0 && !showAddNp)
|
||||
{
|
||||
<div class="text-center py-3">
|
||||
<i class="bi bi-diagram-2 text-muted" style="font-size: 1.5rem;"></i>
|
||||
<p class="text-muted small mt-2 mb-0">No network policies. The namespace inherits the cluster's defaults.</p>
|
||||
</div>
|
||||
}
|
||||
else if (networkPolicies.Count > 0)
|
||||
{
|
||||
<table class="table table-sm mb-0">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th class="small">Name</th>
|
||||
<th class="small">Type</th>
|
||||
<th class="small">Detail</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (AppNetworkPolicy np in networkPolicies)
|
||||
{
|
||||
<tr>
|
||||
<td class="small align-middle"><code>@np.Name</code></td>
|
||||
<td class="small align-middle">@NpTypeLabel(np.PolicyType)</td>
|
||||
<td class="small align-middle text-muted">
|
||||
@if (np.PolicyType == AppNetworkPolicyType.AllowFromNamespace)
|
||||
{
|
||||
<code>@np.AllowFromNamespace</code>
|
||||
}
|
||||
else if (np.PolicyType == AppNetworkPolicyType.Custom)
|
||||
{
|
||||
<span>Custom YAML</span>
|
||||
}
|
||||
</td>
|
||||
<td class="text-end align-middle">
|
||||
@if (confirmDeleteNpId != np.Id)
|
||||
{
|
||||
<button class="btn btn-sm btn-outline-danger"
|
||||
@onclick="() => confirmDeleteNpId = np.Id">
|
||||
<i class="bi bi-trash"></i>
|
||||
</button>
|
||||
}
|
||||
else
|
||||
{
|
||||
<button class="btn btn-sm btn-danger" @onclick="() => DeleteNetworkPolicy(np.Id)">Yes</button>
|
||||
<button class="btn btn-sm btn-outline-secondary ms-1" @onclick="() => confirmDeleteNpId = null">No</button>
|
||||
}
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@* ── 4. RBAC ───────────────────────────────────────────────────── *@
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-header bg-white py-2 d-flex align-items-center justify-content-between">
|
||||
<div>
|
||||
<i class="bi bi-person-badge me-2 text-success"></i>
|
||||
<strong>RBAC</strong>
|
||||
<span class="text-muted small ms-2">ServiceAccount, Role, and RoleBinding applied to the namespace.</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
@if (rbacMessage is not null)
|
||||
{
|
||||
<div class="alert @(rbacOk ? "alert-success" : "alert-danger") py-1 small mb-2">
|
||||
<i class="bi @(rbacOk ? "bi-check-circle" : "bi-x-circle") me-1"></i>@rbacMessage
|
||||
</div>
|
||||
}
|
||||
|
||||
@* Service account settings *@
|
||||
<div class="row g-2 mb-3 align-items-end">
|
||||
<div class="col-md-4">
|
||||
<label class="form-label small">Service account name</label>
|
||||
<input class="form-control form-control-sm font-monospace" @bind="rbacSaName"
|
||||
placeholder="e.g. my-app" />
|
||||
</div>
|
||||
<div class="col-auto d-flex align-items-center gap-2" style="padding-bottom: 0.25rem;">
|
||||
<div class="form-check mb-0">
|
||||
<input class="form-check-input" type="checkbox" id="rbacAutoMount" @bind="rbacAutoMount" />
|
||||
<label class="form-check-label small" for="rbacAutoMount">Auto-mount token</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<button class="btn btn-sm btn-success" @onclick="SaveRbac"
|
||||
disabled="@(string.IsNullOrWhiteSpace(rbacSaName) || savingRbac)">
|
||||
@if (savingRbac) { <span class="spinner-border spinner-border-sm me-1"></span> }
|
||||
Save
|
||||
</button>
|
||||
@if (rbacPolicy is not null)
|
||||
{
|
||||
<button class="btn btn-sm btn-outline-danger ms-1" @onclick="DeleteRbac">Remove RBAC</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@* Rules table *@
|
||||
@if (rbacPolicy is not null)
|
||||
{
|
||||
<div class="mb-2 d-flex align-items-center justify-content-between">
|
||||
<span class="small fw-medium text-muted">Role rules</span>
|
||||
<div class="d-flex gap-1">
|
||||
<button class="btn btn-sm btn-outline-secondary" @onclick='() => AddPreset("", "pods,services,configmaps,secrets", "get,list,watch")'>
|
||||
+ Read namespace
|
||||
</button>
|
||||
<button class="btn btn-sm btn-outline-secondary" @onclick='() => AddPreset("apps", "deployments", "get,list,watch,patch,update")'>
|
||||
+ Manage deployments
|
||||
</button>
|
||||
<button class="btn btn-sm btn-outline-primary" @onclick="() => showAddRule = !showAddRule">
|
||||
<i class="bi bi-plus"></i>Custom
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (showAddRule)
|
||||
{
|
||||
<div class="card border-success mb-2">
|
||||
<div class="card-body py-2">
|
||||
<div class="row g-2 mb-2">
|
||||
<div class="col-md-3">
|
||||
<label class="form-label small">API groups</label>
|
||||
<input class="form-control form-control-sm font-monospace" @bind="newRuleApiGroups"
|
||||
placeholder='e.g. "" or "apps"' />
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label small">Resources</label>
|
||||
<input class="form-control form-control-sm font-monospace" @bind="newRuleResources"
|
||||
placeholder="e.g. pods,services" />
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label small">Verbs</label>
|
||||
<input class="form-control form-control-sm font-monospace" @bind="newRuleVerbs"
|
||||
placeholder="e.g. get,list,watch" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="d-flex gap-1">
|
||||
<button class="btn btn-sm btn-success" @onclick="AddRbacRule"
|
||||
disabled="@(string.IsNullOrWhiteSpace(newRuleResources) || string.IsNullOrWhiteSpace(newRuleVerbs))">
|
||||
Add rule
|
||||
</button>
|
||||
<button class="btn btn-sm btn-outline-secondary" @onclick="() => showAddRule = false">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (rbacPolicy.Rules.Count == 0)
|
||||
{
|
||||
<p class="text-muted small mb-0">No rules yet. Add preset rules or a custom rule above.</p>
|
||||
}
|
||||
else
|
||||
{
|
||||
<table class="table table-sm mb-0">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th class="small">API groups</th>
|
||||
<th class="small">Resources</th>
|
||||
<th class="small">Verbs</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (AppRbacRule rule in rbacPolicy.Rules)
|
||||
{
|
||||
<tr>
|
||||
<td class="small align-middle"><code>@(string.IsNullOrEmpty(rule.ApiGroups) ? "\"\"" : rule.ApiGroups)</code></td>
|
||||
<td class="small align-middle"><code>@rule.Resources</code></td>
|
||||
<td class="small align-middle"><code>@rule.Verbs</code></td>
|
||||
<td class="text-end align-middle">
|
||||
<button class="btn btn-sm btn-outline-danger p-0 px-1"
|
||||
@onclick="() => DeleteRbacRule(rule.Id)">
|
||||
<i class="bi bi-x" style="font-size:.8rem;"></i>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@code {
|
||||
[Parameter, EditorRequired] public Guid AppId { get; set; }
|
||||
[Parameter, EditorRequired] public Guid TenantId { get; set; }
|
||||
|
||||
private bool loading = true;
|
||||
|
||||
// Namespace
|
||||
private string ns = "";
|
||||
private bool savingNs;
|
||||
private string? nsMessage;
|
||||
private bool nsOk;
|
||||
|
||||
// Quota
|
||||
private AppQuota? quota;
|
||||
private string qCpuReq = "", qCpuLim = "", qMemReq = "", qMemLim = "";
|
||||
private int? qMaxPods, qMaxPvcs;
|
||||
private bool savingQuota;
|
||||
private string? quotaMessage;
|
||||
private bool quotaOk;
|
||||
|
||||
// Network policies
|
||||
private List<AppNetworkPolicy> networkPolicies = [];
|
||||
private bool showAddNp;
|
||||
private bool addingNp;
|
||||
private string newNpName = "";
|
||||
private AppNetworkPolicyType newNpType = AppNetworkPolicyType.DenyAll;
|
||||
private string newNpAllowNs = "";
|
||||
private string newNpCustomYaml = "";
|
||||
private Guid? confirmDeleteNpId;
|
||||
private string? npMessage;
|
||||
|
||||
// RBAC
|
||||
private AppRbacPolicy? rbacPolicy;
|
||||
private string rbacSaName = "";
|
||||
private bool rbacAutoMount;
|
||||
private bool savingRbac;
|
||||
private bool showAddRule;
|
||||
private string newRuleApiGroups = "";
|
||||
private string newRuleResources = "";
|
||||
private string newRuleVerbs = "";
|
||||
private string? rbacMessage;
|
||||
private bool rbacOk;
|
||||
|
||||
// Apply
|
||||
private bool applying;
|
||||
private string? applyOutput;
|
||||
private bool applySuccess;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
await LoadAsync();
|
||||
loading = false;
|
||||
}
|
||||
|
||||
private async Task LoadAsync()
|
||||
{
|
||||
AppGovernanceData data = await GovernanceService.LoadAsync(AppId);
|
||||
ns = data.Namespace ?? "";
|
||||
|
||||
quota = data.Quota;
|
||||
if (quota is not null)
|
||||
{
|
||||
qCpuReq = quota.CpuRequest ?? "";
|
||||
qCpuLim = quota.CpuLimit ?? "";
|
||||
qMemReq = quota.MemoryRequest ?? "";
|
||||
qMemLim = quota.MemoryLimit ?? "";
|
||||
qMaxPods = quota.MaxPods;
|
||||
qMaxPvcs = quota.MaxPvcs;
|
||||
}
|
||||
|
||||
networkPolicies = data.NetworkPolicies;
|
||||
|
||||
rbacPolicy = data.RbacPolicy;
|
||||
if (rbacPolicy is not null)
|
||||
{
|
||||
rbacSaName = rbacPolicy.ServiceAccountName;
|
||||
rbacAutoMount = rbacPolicy.AutoMountToken;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task SaveNamespace()
|
||||
{
|
||||
savingNs = true; nsMessage = null;
|
||||
await GovernanceService.SetNamespaceAsync(AppId, ns);
|
||||
savingNs = false; nsOk = true;
|
||||
nsMessage = "Saved.";
|
||||
}
|
||||
|
||||
private async Task SaveQuota()
|
||||
{
|
||||
savingQuota = true; quotaMessage = null;
|
||||
quota = await GovernanceService.SaveQuotaAsync(AppId,
|
||||
qCpuReq, qCpuLim, qMemReq, qMemLim, qMaxPods, qMaxPvcs);
|
||||
savingQuota = false; quotaOk = true;
|
||||
quotaMessage = "Quota saved.";
|
||||
}
|
||||
|
||||
private async Task DeleteQuota()
|
||||
{
|
||||
await GovernanceService.DeleteQuotaAsync(AppId);
|
||||
quota = null;
|
||||
qCpuReq = qCpuLim = qMemReq = qMemLim = "";
|
||||
qMaxPods = qMaxPvcs = null;
|
||||
}
|
||||
|
||||
private async Task AddNetworkPolicy()
|
||||
{
|
||||
addingNp = true; npMessage = null;
|
||||
try
|
||||
{
|
||||
AppNetworkPolicy p = await GovernanceService.AddNetworkPolicyAsync(
|
||||
AppId, newNpName, newNpType,
|
||||
newNpAllowNs, newNpCustomYaml);
|
||||
networkPolicies.Add(p);
|
||||
showAddNp = false;
|
||||
newNpName = ""; newNpAllowNs = ""; newNpCustomYaml = "";
|
||||
newNpType = AppNetworkPolicyType.DenyAll;
|
||||
}
|
||||
catch (Exception ex) { npMessage = ex.Message; }
|
||||
finally { addingNp = false; }
|
||||
}
|
||||
|
||||
private async Task DeleteNetworkPolicy(Guid id)
|
||||
{
|
||||
await GovernanceService.DeleteNetworkPolicyAsync(id);
|
||||
networkPolicies.RemoveAll(p => p.Id == id);
|
||||
confirmDeleteNpId = null;
|
||||
}
|
||||
|
||||
private async Task SaveRbac()
|
||||
{
|
||||
savingRbac = true; rbacMessage = null;
|
||||
rbacPolicy = await GovernanceService.SaveRbacPolicyAsync(AppId, rbacSaName, rbacAutoMount);
|
||||
savingRbac = false; rbacOk = true;
|
||||
rbacMessage = "Saved.";
|
||||
}
|
||||
|
||||
private async Task DeleteRbac()
|
||||
{
|
||||
await GovernanceService.DeleteRbacPolicyAsync(AppId);
|
||||
rbacPolicy = null; rbacSaName = ""; rbacAutoMount = false;
|
||||
}
|
||||
|
||||
private async Task AddPreset(string apiGroups, string resources, string verbs)
|
||||
{
|
||||
if (rbacPolicy is null) return;
|
||||
AppRbacRule rule = await GovernanceService.AddRbacRuleAsync(
|
||||
rbacPolicy.Id, apiGroups, resources, verbs);
|
||||
rbacPolicy.Rules.Add(rule);
|
||||
}
|
||||
|
||||
private async Task AddRbacRule()
|
||||
{
|
||||
if (rbacPolicy is null) return;
|
||||
AppRbacRule rule = await GovernanceService.AddRbacRuleAsync(
|
||||
rbacPolicy.Id, newRuleApiGroups, newRuleResources, newRuleVerbs);
|
||||
rbacPolicy.Rules.Add(rule);
|
||||
showAddRule = false;
|
||||
newRuleApiGroups = newRuleResources = newRuleVerbs = "";
|
||||
}
|
||||
|
||||
private async Task DeleteRbacRule(Guid id)
|
||||
{
|
||||
await GovernanceService.DeleteRbacRuleAsync(id);
|
||||
rbacPolicy?.Rules.Remove(rbacPolicy.Rules.First(r => r.Id == id));
|
||||
}
|
||||
|
||||
private async Task ApplyToAllClusters()
|
||||
{
|
||||
applying = true; applyOutput = null;
|
||||
var sb = new System.Text.StringBuilder();
|
||||
|
||||
List<KubernetesCluster> clusters = await TenantService.GetClustersAsync(TenantId);
|
||||
|
||||
foreach (KubernetesCluster cluster in clusters)
|
||||
{
|
||||
var (ok, output) = await GovernanceService.ApplyToClusterAsync(AppId, cluster);
|
||||
sb.AppendLine($"── {cluster.Name} ──");
|
||||
sb.AppendLine(ok ? "✓ Applied" : $"✗ {output}");
|
||||
if (!ok && !string.IsNullOrWhiteSpace(output))
|
||||
sb.AppendLine(output);
|
||||
applySuccess = ok;
|
||||
}
|
||||
|
||||
applying = false;
|
||||
applyOutput = sb.ToString().TrimEnd();
|
||||
applySuccess = !applyOutput.Contains("✗");
|
||||
}
|
||||
|
||||
private static string NpTypeLabel(AppNetworkPolicyType t) => t switch
|
||||
{
|
||||
AppNetworkPolicyType.DenyAll => "Deny all",
|
||||
AppNetworkPolicyType.AllowFromIngress => "Allow from ingress",
|
||||
AppNetworkPolicyType.AllowFromSameNamespace => "Allow same namespace",
|
||||
AppNetworkPolicyType.AllowFromNamespace => "Allow from namespace",
|
||||
AppNetworkPolicyType.Custom => "Custom YAML",
|
||||
_ => t.ToString()
|
||||
};
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
};
|
||||
}
|
||||
222
src/EntKube.Web/Components/Pages/Tenants/LogBrowser.razor
Normal file
222
src/EntKube.Web/Components/Pages/Tenants/LogBrowser.razor
Normal file
@@ -0,0 +1,222 @@
|
||||
@using EntKube.Web.Data
|
||||
@using EntKube.Web.Services
|
||||
@inject LokiService LokiService
|
||||
|
||||
@if (!lokiAvailable && !isLoading)
|
||||
{
|
||||
<div class="alert alert-light border text-muted small py-2">
|
||||
<span class="bi bi-journal-x me-1"></span>
|
||||
Loki is not installed or configured on this cluster. Deploy a Loki component to enable log browsing.
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
@* ── Controls ── *@
|
||||
<div class="row g-2 mb-3 align-items-end">
|
||||
<div class="col-md-3">
|
||||
<label class="form-label small text-muted mb-1">Namespace</label>
|
||||
<select class="form-select form-select-sm" @bind="selectedNamespace" @bind:after="OnNamespaceChanged"
|
||||
disabled="@(namespaces.Count == 0)">
|
||||
<option value="">— select namespace —</option>
|
||||
@foreach (string ns in namespaces)
|
||||
{
|
||||
<option value="@ns">@ns</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label small text-muted mb-1">Pod</label>
|
||||
<select class="form-select form-select-sm" @bind="selectedPod"
|
||||
disabled="@(string.IsNullOrEmpty(selectedNamespace))">
|
||||
<option value="">All pods</option>
|
||||
@foreach (string pod in pods)
|
||||
{
|
||||
<option value="@pod">@pod</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label small text-muted mb-1">Time range</label>
|
||||
<select class="form-select form-select-sm" @bind="timeRangeMinutes">
|
||||
<option value="15">Last 15 minutes</option>
|
||||
<option value="60">Last 1 hour</option>
|
||||
<option value="180">Last 3 hours</option>
|
||||
<option value="720">Last 12 hours</option>
|
||||
<option value="1440">Last 24 hours</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label small text-muted mb-1">Lines</label>
|
||||
<select class="form-select form-select-sm" @bind="limit">
|
||||
<option value="100">100</option>
|
||||
<option value="200">200</option>
|
||||
<option value="500">500</option>
|
||||
<option value="1000">1000</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-12 col-md-9">
|
||||
<label class="form-label small text-muted mb-1">Filter text</label>
|
||||
<input class="form-control form-control-sm font-monospace" @bind="textFilter"
|
||||
placeholder="e.g. error, connection refused" />
|
||||
</div>
|
||||
<div class="col-12 col-md-3">
|
||||
<button class="btn btn-sm btn-primary w-100" @onclick="Search"
|
||||
disabled="@(isQuerying || string.IsNullOrEmpty(selectedNamespace))">
|
||||
@if (isQuerying) { <span class="spinner-border spinner-border-sm me-1"></span> }
|
||||
<span class="bi bi-search me-1"></span>Search
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (!string.IsNullOrEmpty(queryError))
|
||||
{
|
||||
<div class="alert alert-warning small py-2">
|
||||
<span class="bi bi-exclamation-triangle me-1"></span>@queryError
|
||||
</div>
|
||||
}
|
||||
|
||||
@* ── Log output ── *@
|
||||
@if (allEntries.Count > 0)
|
||||
{
|
||||
<div class="d-flex justify-content-between align-items-center mb-2">
|
||||
<span class="text-muted small">@allEntries.Count entries · @queried.ToLocalTime().ToString("HH:mm:ss")</span>
|
||||
<div class="d-flex gap-2 align-items-center">
|
||||
<select class="form-select form-select-sm" style="width:auto" @bind="levelFilter">
|
||||
<option value="">All levels</option>
|
||||
<option value="Error">Error + Fatal</option>
|
||||
<option value="Warn">Warn+</option>
|
||||
<option value="Info">Info+</option>
|
||||
</select>
|
||||
<button class="btn btn-sm btn-outline-secondary" @onclick="() => allEntries = []">
|
||||
<span class="bi bi-x-lg"></span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="border rounded font-monospace" style="background:#1e1e1e;max-height:500px;overflow-y:auto">
|
||||
@foreach (LokiLogEntry entry in FilteredEntries)
|
||||
{
|
||||
<div class="px-3 py-0" style="line-height:1.4;border-bottom:1px solid #2d2d2d">
|
||||
<span style="color:#888;font-size:0.72rem;user-select:none">
|
||||
@entry.Timestamp.ToLocalTime().ToString("HH:mm:ss.fff")
|
||||
</span>
|
||||
<span class="ms-2" style="@LineStyle(entry.DetectedLevel); font-size:0.8rem; white-space:pre-wrap; word-break:break-all">@entry.Line</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
else if (hasQueried && !isQuerying)
|
||||
{
|
||||
<div class="text-center py-4 text-muted">
|
||||
<span class="bi bi-journal display-4 d-block mb-2"></span>
|
||||
No logs found for the selected criteria.
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
@code {
|
||||
[Parameter] public required Guid ClusterId { get; set; }
|
||||
[Parameter] public string? PresetNamespace { get; set; }
|
||||
[Parameter] public string? PresetPod { get; set; }
|
||||
|
||||
private List<string> namespaces = [];
|
||||
private List<string> pods = [];
|
||||
private List<LokiLogEntry> allEntries = [];
|
||||
private string selectedNamespace = "";
|
||||
private string selectedPod = "";
|
||||
private string textFilter = "";
|
||||
private string levelFilter = "";
|
||||
private int timeRangeMinutes = 60;
|
||||
private int limit = 200;
|
||||
private bool lokiAvailable;
|
||||
private bool isLoading = true;
|
||||
private bool isQuerying;
|
||||
private bool hasQueried;
|
||||
private string? queryError;
|
||||
private DateTime queried;
|
||||
|
||||
private IEnumerable<LokiLogEntry> FilteredEntries => allEntries.Where(e => levelFilter switch
|
||||
{
|
||||
"Error" => e.DetectedLevel >= LogLevel.Error,
|
||||
"Warn" => e.DetectedLevel >= LogLevel.Warn,
|
||||
"Info" => e.DetectedLevel >= LogLevel.Info,
|
||||
_ => true
|
||||
});
|
||||
|
||||
protected override async Task OnParametersSetAsync()
|
||||
{
|
||||
isLoading = true;
|
||||
lokiAvailable = await LokiService.IsAvailableAsync(ClusterId);
|
||||
|
||||
if (lokiAvailable)
|
||||
{
|
||||
KubernetesOperationResult<List<string>> nsResult = await LokiService.GetNamespacesAsync(ClusterId);
|
||||
if (nsResult.IsSuccess) namespaces = nsResult.Data ?? [];
|
||||
|
||||
if (!string.IsNullOrEmpty(PresetNamespace))
|
||||
{
|
||||
selectedNamespace = PresetNamespace;
|
||||
await OnNamespaceChanged();
|
||||
}
|
||||
if (!string.IsNullOrEmpty(PresetPod))
|
||||
selectedPod = PresetPod;
|
||||
}
|
||||
isLoading = false;
|
||||
}
|
||||
|
||||
private async Task OnNamespaceChanged()
|
||||
{
|
||||
pods = [];
|
||||
selectedPod = "";
|
||||
if (string.IsNullOrEmpty(selectedNamespace)) return;
|
||||
|
||||
KubernetesOperationResult<List<string>> podResult =
|
||||
await LokiService.GetPodsAsync(ClusterId, selectedNamespace);
|
||||
if (podResult.IsSuccess) pods = podResult.Data ?? [];
|
||||
}
|
||||
|
||||
private async Task Search()
|
||||
{
|
||||
if (string.IsNullOrEmpty(selectedNamespace)) return;
|
||||
isQuerying = true;
|
||||
queryError = null;
|
||||
allEntries = [];
|
||||
StateHasChanged();
|
||||
|
||||
DateTime to = DateTime.UtcNow;
|
||||
DateTime from = to.AddMinutes(-timeRangeMinutes);
|
||||
|
||||
KubernetesOperationResult<List<LokiLogStream>> result =
|
||||
await LokiService.QueryRangeAsync(
|
||||
ClusterId, selectedNamespace,
|
||||
string.IsNullOrEmpty(selectedPod) ? null : selectedPod,
|
||||
null,
|
||||
string.IsNullOrEmpty(textFilter) ? null : textFilter,
|
||||
from, to, limit);
|
||||
|
||||
if (result.IsSuccess && result.Data is not null)
|
||||
{
|
||||
allEntries = result.Data
|
||||
.SelectMany(s => s.Entries)
|
||||
.OrderBy(e => e.Timestamp)
|
||||
.ToList();
|
||||
}
|
||||
else
|
||||
{
|
||||
queryError = result.Error;
|
||||
}
|
||||
|
||||
queried = DateTime.UtcNow;
|
||||
hasQueried = true;
|
||||
isQuerying = false;
|
||||
}
|
||||
|
||||
private static string LineStyle(LogLevel level) => level switch
|
||||
{
|
||||
LogLevel.Fatal or LogLevel.Error => "color:#f48771",
|
||||
LogLevel.Warn => "color:#cca700",
|
||||
LogLevel.Info => "color:#9cdcfe",
|
||||
LogLevel.Debug => "color:#808080",
|
||||
_ => "color:#d4d4d4"
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
@using EntKube.Web.Data
|
||||
@using EntKube.Web.Services
|
||||
@using Microsoft.EntityFrameworkCore
|
||||
@inject IncidentService IncidentService
|
||||
@inject IDbContextFactory<ApplicationDbContext> DbFactory
|
||||
@inject AuthenticationStateProvider AuthStateProvider
|
||||
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<h6 class="mb-0">Maintenance Windows</h6>
|
||||
<button class="btn btn-sm btn-primary" @onclick="ShowAdd">
|
||||
<span class="bi bi-plus-lg"></span> Schedule Window
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@if (activeWindows.Count > 0)
|
||||
{
|
||||
<div class="alert alert-warning d-flex align-items-center gap-2 mb-3 py-2">
|
||||
<span class="bi bi-tools fs-5"></span>
|
||||
<div>
|
||||
<strong>Active maintenance:</strong>
|
||||
@foreach (MaintenanceWindow w in activeWindows)
|
||||
{
|
||||
<span class="me-2">@w.Title (until @w.EndsAt.ToLocalTime().ToString("HH:mm"))</span>
|
||||
}
|
||||
<div class="small text-muted">Alert notifications are suppressed for covered clusters.</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (windows.Count == 0)
|
||||
{
|
||||
<div class="text-center py-4 text-muted">
|
||||
<span class="bi bi-calendar-x display-4 d-block mb-2"></span>
|
||||
No maintenance windows scheduled.
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm align-middle">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>Title</th>
|
||||
<th>Cluster</th>
|
||||
<th>Starts</th>
|
||||
<th>Ends</th>
|
||||
<th>Created By</th>
|
||||
<th>Status</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (MaintenanceWindow w in windows)
|
||||
{
|
||||
DateTime now = DateTime.UtcNow;
|
||||
bool active = w.StartsAt <= now && w.EndsAt >= now;
|
||||
bool past = w.EndsAt < now;
|
||||
<tr class="@(active ? "table-warning" : past ? "text-muted" : "")">
|
||||
<td class="fw-semibold">@w.Title</td>
|
||||
<td class="small">@(w.Cluster?.Name ?? "All clusters")</td>
|
||||
<td class="small">@w.StartsAt.ToLocalTime().ToString("MMM d HH:mm")</td>
|
||||
<td class="small">@w.EndsAt.ToLocalTime().ToString("MMM d HH:mm")</td>
|
||||
<td class="small text-muted">@w.CreatedBy</td>
|
||||
<td>
|
||||
@if (active)
|
||||
{
|
||||
<span class="badge bg-warning text-dark">Active</span>
|
||||
}
|
||||
else if (past)
|
||||
{
|
||||
<span class="badge bg-secondary">Completed</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="badge bg-info text-dark">Scheduled</span>
|
||||
}
|
||||
</td>
|
||||
<td>
|
||||
@if (!past)
|
||||
{
|
||||
<button class="btn btn-sm btn-outline-danger" @onclick="() => Delete(w.Id)">
|
||||
<span class="bi bi-trash"></span>
|
||||
</button>
|
||||
}
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (showAdd)
|
||||
{
|
||||
<div class="card mt-3 border-primary">
|
||||
<div class="card-header d-flex justify-content-between align-items-center py-2">
|
||||
<h6 class="mb-0">Schedule Maintenance Window</h6>
|
||||
<button class="btn-close btn-sm" @onclick="() => showAdd = false"></button>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row g-3">
|
||||
<div class="col-12">
|
||||
<label class="form-label small">Title <span class="text-danger">*</span></label>
|
||||
<input class="form-control form-control-sm" @bind="newTitle" placeholder="e.g. Scheduled database maintenance" />
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label class="form-label small">Description</label>
|
||||
<textarea class="form-control form-control-sm" rows="2" @bind="newDescription" placeholder="Optional details"></textarea>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label small">Cluster</label>
|
||||
<select class="form-select form-select-sm" @bind="newClusterId">
|
||||
<option value="">All clusters</option>
|
||||
@foreach (KubernetesCluster c in clusters)
|
||||
{
|
||||
<option value="@c.Id">@c.Name</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label small">Start (local)</label>
|
||||
<input class="form-control form-control-sm" type="datetime-local" @bind="newStartsAt" />
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label small">End (local)</label>
|
||||
<input class="form-control form-control-sm" type="datetime-local" @bind="newEndsAt" />
|
||||
</div>
|
||||
</div>
|
||||
@if (!string.IsNullOrEmpty(formError))
|
||||
{
|
||||
<div class="alert alert-danger py-1 small mt-2 mb-0">@formError</div>
|
||||
}
|
||||
<div class="mt-3 d-flex gap-2">
|
||||
<button class="btn btn-sm btn-primary" @onclick="Save">Save Window</button>
|
||||
<button class="btn btn-sm btn-outline-secondary" @onclick="() => showAdd = false">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@code {
|
||||
[Parameter] public required Guid TenantId { get; set; }
|
||||
|
||||
private List<MaintenanceWindow> windows = [];
|
||||
private List<MaintenanceWindow> activeWindows = [];
|
||||
private List<KubernetesCluster> clusters = [];
|
||||
private bool showAdd;
|
||||
private string newTitle = "";
|
||||
private string newDescription = "";
|
||||
private string newClusterId = "";
|
||||
private DateTime newStartsAt = DateTime.Now.AddMinutes(5);
|
||||
private DateTime newEndsAt = DateTime.Now.AddHours(2);
|
||||
private string? formError;
|
||||
private string? currentUser;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
AuthenticationState auth = await AuthStateProvider.GetAuthenticationStateAsync();
|
||||
currentUser = auth.User.Identity?.Name ?? "Unknown";
|
||||
await Load();
|
||||
}
|
||||
|
||||
private async Task Load()
|
||||
{
|
||||
windows = await IncidentService.GetMaintenanceWindowsAsync(TenantId);
|
||||
DateTime now = DateTime.UtcNow;
|
||||
activeWindows = windows.Where(w => w.StartsAt <= now && w.EndsAt >= now).ToList();
|
||||
|
||||
using ApplicationDbContext db = DbFactory.CreateDbContext();
|
||||
clusters = await db.KubernetesClusters
|
||||
.Where(c => c.TenantId == TenantId)
|
||||
.OrderBy(c => c.Name)
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
private void ShowAdd()
|
||||
{
|
||||
showAdd = true;
|
||||
newTitle = "";
|
||||
newDescription = "";
|
||||
newClusterId = "";
|
||||
newStartsAt = DateTime.Now.AddMinutes(5);
|
||||
newEndsAt = DateTime.Now.AddHours(2);
|
||||
formError = null;
|
||||
}
|
||||
|
||||
private async Task Save()
|
||||
{
|
||||
formError = null;
|
||||
if (string.IsNullOrWhiteSpace(newTitle)) { formError = "Title is required."; return; }
|
||||
if (newEndsAt <= newStartsAt) { formError = "End time must be after start time."; return; }
|
||||
|
||||
Guid? clusterId = Guid.TryParse(newClusterId, out Guid cid) ? cid : null;
|
||||
|
||||
await IncidentService.CreateMaintenanceWindowAsync(
|
||||
TenantId,
|
||||
newTitle.Trim(),
|
||||
string.IsNullOrWhiteSpace(newDescription) ? null : newDescription.Trim(),
|
||||
clusterId,
|
||||
newStartsAt.ToUniversalTime(),
|
||||
newEndsAt.ToUniversalTime(),
|
||||
currentUser!);
|
||||
|
||||
showAdd = false;
|
||||
await Load();
|
||||
}
|
||||
|
||||
private async Task Delete(Guid id)
|
||||
{
|
||||
await IncidentService.DeleteMaintenanceWindowAsync(id);
|
||||
await Load();
|
||||
}
|
||||
}
|
||||
1509
src/EntKube.Web/Components/Pages/Tenants/MessagingTab.razor
Normal file
1509
src/EntKube.Web/Components/Pages/Tenants/MessagingTab.razor
Normal file
File diff suppressed because it is too large
Load Diff
1284
src/EntKube.Web/Components/Pages/Tenants/NetworkingTab.razor
Normal file
1284
src/EntKube.Web/Components/Pages/Tenants/NetworkingTab.razor
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,354 @@
|
||||
@using EntKube.Web.Data
|
||||
@using EntKube.Web.Services
|
||||
@using Microsoft.EntityFrameworkCore
|
||||
@inject IDbContextFactory<ApplicationDbContext> DbFactory
|
||||
@inject NotificationService NotificationService
|
||||
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<h6 class="mb-0">Notification Channels</h6>
|
||||
<button class="btn btn-sm btn-primary" @onclick="ShowAddModal">
|
||||
<span class="bi bi-plus-lg"></span> Add Channel
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@if (channels.Count == 0)
|
||||
{
|
||||
<div class="text-center py-5 text-muted">
|
||||
<span class="bi bi-send display-4 d-block mb-2"></span>
|
||||
No notification channels configured. Add a channel to get alerted via Slack, Teams, email, or webhook.
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="row g-3">
|
||||
@foreach (NotificationChannel channel in channels)
|
||||
{
|
||||
<div class="col-12 col-md-6">
|
||||
<div class="card border-@(channel.IsEnabled ? "success" : "secondary")">
|
||||
<div class="card-body">
|
||||
<div class="d-flex align-items-center gap-2 mb-2">
|
||||
<span class="@TypeIcon(channel.Type) fs-5 text-@TypeColor(channel.Type)"></span>
|
||||
<strong>@channel.Name</strong>
|
||||
<span class="badge bg-secondary ms-1">@channel.Type</span>
|
||||
<div class="ms-auto form-check form-switch mb-0">
|
||||
<input class="form-check-input" type="checkbox" role="switch"
|
||||
checked="@channel.IsEnabled"
|
||||
@onchange="() => ToggleEnabled(channel)" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-muted small mb-2">
|
||||
Severity: <span class="badge bg-light text-dark">@FormatSeverityFilter(channel.SeverityFilter)</span>
|
||||
</div>
|
||||
|
||||
@if (deliveries.TryGetValue(channel.Id, out List<NotificationDelivery>? recent) && recent.Count > 0)
|
||||
{
|
||||
<div class="small mb-2">
|
||||
<span class="text-muted">Recent deliveries: </span>
|
||||
@foreach (NotificationDelivery d in recent.Take(5))
|
||||
{
|
||||
<span class="badge me-1 @(d.Success ? "bg-success" : "bg-danger")"
|
||||
title="@(d.IsFiring ? "Fired" : "Resolved") · @d.SentAt.ToLocalTime():g">
|
||||
@(d.Success ? "✓" : "✗")
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="d-flex gap-2">
|
||||
<button class="btn btn-sm btn-outline-primary" @onclick="() => TestChannel(channel)"
|
||||
disabled="@(testingId == channel.Id)">
|
||||
@if (testingId == channel.Id)
|
||||
{
|
||||
<span class="spinner-border spinner-border-sm me-1"></span>
|
||||
}
|
||||
Test
|
||||
</button>
|
||||
<button class="btn btn-sm btn-outline-danger ms-auto" @onclick="() => DeleteChannel(channel)">
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
@if (testResults.TryGetValue(channel.Id, out string? result))
|
||||
{
|
||||
<div class="alert @(result.StartsWith("✓") ? "alert-success" : "alert-danger") alert-sm mt-2 mb-0 py-1 px-2 small">
|
||||
@result
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (showModal)
|
||||
{
|
||||
<div class="modal fade show d-block" style="background:rgba(0,0,0,.5)">
|
||||
<div class="modal-dialog modal-dialog-centered">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">Add Notification Channel</h5>
|
||||
<button type="button" class="btn-close" @onclick="CloseModal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
@if (modalStep == 1)
|
||||
{
|
||||
<p class="text-muted mb-3">Select the channel type:</p>
|
||||
<div class="d-grid gap-2">
|
||||
@foreach (NotificationChannelType t in Enum.GetValues<NotificationChannelType>())
|
||||
{
|
||||
<button class="btn btn-outline-secondary text-start" @onclick="() => SelectType(t)">
|
||||
<span class="@TypeIcon(t) me-2"></span> @t
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
else if (modalStep == 2)
|
||||
{
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Channel Name</label>
|
||||
<input class="form-control" @bind="newName" placeholder="e.g. Ops Slack" />
|
||||
</div>
|
||||
|
||||
@if (newType == NotificationChannelType.Slack || newType == NotificationChannelType.Teams)
|
||||
{
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Webhook URL</label>
|
||||
<input class="form-control" @bind="newWebhookUrl" placeholder="https://hooks.slack.com/…" />
|
||||
</div>
|
||||
}
|
||||
else if (newType == NotificationChannelType.Email)
|
||||
{
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Recipient Email</label>
|
||||
<input class="form-control" type="email" @bind="newEmail" placeholder="ops@example.com" />
|
||||
</div>
|
||||
}
|
||||
else if (newType == NotificationChannelType.Webhook)
|
||||
{
|
||||
<div class="mb-3">
|
||||
<label class="form-label">URL</label>
|
||||
<input class="form-control" @bind="newWebhookUrl" placeholder="https://…" />
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Bearer Token (optional)</label>
|
||||
<input class="form-control" type="password" @bind="newBearerToken" />
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Alert Severity Filter</label>
|
||||
<select class="form-select" @bind="newSeverityFilter">
|
||||
<option value="@AlertSeverityFilter.All">All severities</option>
|
||||
<option value="@AlertSeverityFilter.WarningAndAbove">Warning and above</option>
|
||||
<option value="@AlertSeverityFilter.CriticalOnly">Critical only</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
@if (!string.IsNullOrEmpty(modalError))
|
||||
{
|
||||
<div class="alert alert-danger py-2 small">@modalError</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
@if (modalStep == 2)
|
||||
{
|
||||
<button class="btn btn-secondary" @onclick="() => modalStep = 1">Back</button>
|
||||
<button class="btn btn-primary" @onclick="SaveChannel">Save Channel</button>
|
||||
}
|
||||
else
|
||||
{
|
||||
<button class="btn btn-secondary" @onclick="CloseModal">Cancel</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@code {
|
||||
[Parameter] public required Guid TenantId { get; set; }
|
||||
|
||||
private List<NotificationChannel> channels = [];
|
||||
private Dictionary<Guid, List<NotificationDelivery>> deliveries = new();
|
||||
private bool showModal;
|
||||
private int modalStep = 1;
|
||||
private NotificationChannelType newType;
|
||||
private string newName = "";
|
||||
private string newWebhookUrl = "";
|
||||
private string newEmail = "";
|
||||
private string newBearerToken = "";
|
||||
private AlertSeverityFilter newSeverityFilter = AlertSeverityFilter.All;
|
||||
private string? modalError;
|
||||
private Guid? testingId;
|
||||
private Dictionary<Guid, string> testResults = new();
|
||||
|
||||
protected override async Task OnInitializedAsync() => await LoadChannels();
|
||||
|
||||
private async Task LoadChannels()
|
||||
{
|
||||
using ApplicationDbContext db = DbFactory.CreateDbContext();
|
||||
channels = await db.NotificationChannels
|
||||
.Where(c => c.TenantId == TenantId)
|
||||
.OrderBy(c => c.Name)
|
||||
.ToListAsync();
|
||||
|
||||
var ids = channels.Select(c => c.Id).ToList();
|
||||
List<NotificationDelivery> recent = await db.NotificationDeliveries
|
||||
.Where(d => ids.Contains(d.ChannelId))
|
||||
.OrderByDescending(d => d.SentAt)
|
||||
.ToListAsync();
|
||||
|
||||
deliveries = recent
|
||||
.GroupBy(d => d.ChannelId)
|
||||
.ToDictionary(g => g.Key, g => g.Take(5).ToList());
|
||||
}
|
||||
|
||||
private void ShowAddModal()
|
||||
{
|
||||
showModal = true;
|
||||
modalStep = 1;
|
||||
newName = "";
|
||||
newWebhookUrl = "";
|
||||
newEmail = "";
|
||||
newBearerToken = "";
|
||||
newSeverityFilter = AlertSeverityFilter.All;
|
||||
modalError = null;
|
||||
}
|
||||
|
||||
private void CloseModal() => showModal = false;
|
||||
|
||||
private void SelectType(NotificationChannelType type)
|
||||
{
|
||||
newType = type;
|
||||
modalStep = 2;
|
||||
}
|
||||
|
||||
private async Task SaveChannel()
|
||||
{
|
||||
modalError = null;
|
||||
if (string.IsNullOrWhiteSpace(newName))
|
||||
{
|
||||
modalError = "Channel name is required.";
|
||||
return;
|
||||
}
|
||||
|
||||
string configJson;
|
||||
try
|
||||
{
|
||||
configJson = BuildConfigJson();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
modalError = ex.Message;
|
||||
return;
|
||||
}
|
||||
|
||||
using ApplicationDbContext db = DbFactory.CreateDbContext();
|
||||
db.NotificationChannels.Add(new NotificationChannel
|
||||
{
|
||||
TenantId = TenantId,
|
||||
Name = newName.Trim(),
|
||||
Type = newType,
|
||||
ConfigurationJson = configJson,
|
||||
SeverityFilter = newSeverityFilter
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
CloseModal();
|
||||
await LoadChannels();
|
||||
}
|
||||
|
||||
private string BuildConfigJson()
|
||||
{
|
||||
return newType switch
|
||||
{
|
||||
NotificationChannelType.Slack or NotificationChannelType.Teams =>
|
||||
!string.IsNullOrWhiteSpace(newWebhookUrl)
|
||||
? System.Text.Json.JsonSerializer.Serialize(new { webhookUrl = newWebhookUrl.Trim() })
|
||||
: throw new InvalidOperationException("Webhook URL is required."),
|
||||
|
||||
NotificationChannelType.Email =>
|
||||
!string.IsNullOrWhiteSpace(newEmail)
|
||||
? System.Text.Json.JsonSerializer.Serialize(new { to = newEmail.Trim() })
|
||||
: throw new InvalidOperationException("Email address is required."),
|
||||
|
||||
NotificationChannelType.Webhook =>
|
||||
!string.IsNullOrWhiteSpace(newWebhookUrl)
|
||||
? System.Text.Json.JsonSerializer.Serialize(new
|
||||
{
|
||||
url = newWebhookUrl.Trim(),
|
||||
token = string.IsNullOrWhiteSpace(newBearerToken) ? null : newBearerToken.Trim()
|
||||
})
|
||||
: throw new InvalidOperationException("URL is required."),
|
||||
|
||||
_ => "{}"
|
||||
};
|
||||
}
|
||||
|
||||
private async Task ToggleEnabled(NotificationChannel channel)
|
||||
{
|
||||
using ApplicationDbContext db = DbFactory.CreateDbContext();
|
||||
NotificationChannel? ch = await db.NotificationChannels.FindAsync(channel.Id);
|
||||
if (ch is null) return;
|
||||
ch.IsEnabled = !ch.IsEnabled;
|
||||
await db.SaveChangesAsync();
|
||||
channel.IsEnabled = ch.IsEnabled;
|
||||
}
|
||||
|
||||
private async Task DeleteChannel(NotificationChannel channel)
|
||||
{
|
||||
using ApplicationDbContext db = DbFactory.CreateDbContext();
|
||||
NotificationChannel? ch = await db.NotificationChannels.FindAsync(channel.Id);
|
||||
if (ch is not null) db.NotificationChannels.Remove(ch);
|
||||
await db.SaveChangesAsync();
|
||||
await LoadChannels();
|
||||
}
|
||||
|
||||
private async Task TestChannel(NotificationChannel channel)
|
||||
{
|
||||
testingId = channel.Id;
|
||||
testResults.Remove(channel.Id);
|
||||
StateHasChanged();
|
||||
|
||||
try
|
||||
{
|
||||
await NotificationService.SendTestAsync(channel);
|
||||
testResults[channel.Id] = "✓ Test notification sent successfully";
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
testResults[channel.Id] = $"✗ {ex.Message}";
|
||||
}
|
||||
finally
|
||||
{
|
||||
testingId = null;
|
||||
StateHasChanged();
|
||||
}
|
||||
}
|
||||
|
||||
private static string TypeIcon(NotificationChannelType type) => type switch
|
||||
{
|
||||
NotificationChannelType.Slack => "bi bi-slack",
|
||||
NotificationChannelType.Teams => "bi bi-microsoft-teams",
|
||||
NotificationChannelType.Email => "bi bi-envelope-fill",
|
||||
NotificationChannelType.Webhook => "bi bi-link-45deg",
|
||||
_ => "bi bi-bell"
|
||||
};
|
||||
|
||||
private static string TypeColor(NotificationChannelType type) => type switch
|
||||
{
|
||||
NotificationChannelType.Slack => "success",
|
||||
NotificationChannelType.Teams => "primary",
|
||||
NotificationChannelType.Email => "warning",
|
||||
NotificationChannelType.Webhook => "secondary",
|
||||
_ => "secondary"
|
||||
};
|
||||
|
||||
private static string FormatSeverityFilter(AlertSeverityFilter filter) => filter switch
|
||||
{
|
||||
AlertSeverityFilter.All => "All",
|
||||
AlertSeverityFilter.WarningAndAbove => "Warning+",
|
||||
AlertSeverityFilter.CriticalOnly => "Critical",
|
||||
_ => filter.ToString()
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
@using EntKube.Web.Data
|
||||
@using EntKube.Web.Services
|
||||
@using Microsoft.EntityFrameworkCore
|
||||
@inject PrometheusService PrometheusService
|
||||
@inject IDbContextFactory<ApplicationDbContext> DbFactory
|
||||
|
||||
@if (isLoading)
|
||||
{
|
||||
<div class="text-muted small text-center py-2">
|
||||
<span class="bi bi-hourglass-split me-1"></span> Loading RabbitMQ metrics…
|
||||
</div>
|
||||
}
|
||||
else if (summaries.Count == 0)
|
||||
{
|
||||
<div class="text-muted small text-center py-3">
|
||||
<span class="bi bi-bar-chart me-1"></span>
|
||||
No RabbitMQ clusters found, or Prometheus metrics are unavailable for this cluster.
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
@foreach (RabbitMQMetricsSummary summary in summaries)
|
||||
{
|
||||
bool hasBacklog = summary.UnackedMessages > 0 || summary.ReadyMessages > 100;
|
||||
<div class="card mb-3 border-@(hasBacklog ? "warning" : "success")">
|
||||
<div class="card-header py-2 d-flex align-items-center gap-2">
|
||||
<span class="bi bi-collection-fill text-success"></span>
|
||||
<strong>@summary.ClusterName</strong>
|
||||
@if (hasBacklog)
|
||||
{
|
||||
<span class="badge bg-warning text-dark ms-1">Backlog</span>
|
||||
}
|
||||
<span class="ms-auto text-muted small">@summary.QueriedAt.ToLocalTime().ToString("HH:mm:ss")</span>
|
||||
</div>
|
||||
<div class="card-body py-2">
|
||||
<div class="row g-3 mb-2">
|
||||
<div class="col-auto">
|
||||
<div class="small text-muted">Connections</div>
|
||||
<div class="fw-semibold">@summary.Connections</div>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<div class="small text-muted">Channels</div>
|
||||
<div class="fw-semibold">@summary.Channels</div>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<div class="small text-muted">Ready Messages</div>
|
||||
<div class="fw-semibold @(summary.ReadyMessages > 1000 ? "text-warning" : "")">@summary.ReadyMessages.ToString("N0")</div>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<div class="small text-muted">Unacknowledged</div>
|
||||
<div class="fw-semibold @(summary.UnackedMessages > 0 ? "text-warning" : "text-success")">@summary.UnackedMessages.ToString("N0")</div>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<div class="small text-muted">Publish rate</div>
|
||||
<div class="fw-semibold">@summary.PublishRatePerSec.ToString("F1")/s</div>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<div class="small text-muted">Deliver rate</div>
|
||||
<div class="fw-semibold">@summary.DeliverRatePerSec.ToString("F1")/s</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
@code {
|
||||
[Parameter] public required Guid ClusterId { get; set; }
|
||||
|
||||
private List<RabbitMQMetricsSummary> summaries = [];
|
||||
private bool isLoading = true;
|
||||
|
||||
protected override async Task OnParametersSetAsync() => await LoadMetrics();
|
||||
|
||||
private async Task LoadMetrics()
|
||||
{
|
||||
isLoading = true;
|
||||
StateHasChanged();
|
||||
|
||||
List<string> rabbitNames;
|
||||
using (ApplicationDbContext db = DbFactory.CreateDbContext())
|
||||
{
|
||||
rabbitNames = await db.RabbitMQClusters
|
||||
.Where(c => c.KubernetesClusterId == ClusterId)
|
||||
.Select(c => c.Name)
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
List<RabbitMQMetricsSummary> results = [];
|
||||
foreach (string name in rabbitNames)
|
||||
{
|
||||
KubernetesOperationResult<RabbitMQMetricsSummary> result =
|
||||
await PrometheusService.GetRabbitMQMetricsAsync(ClusterId, name);
|
||||
|
||||
if (result.IsSuccess && result.Data is not null)
|
||||
results.Add(result.Data);
|
||||
}
|
||||
|
||||
summaries = results;
|
||||
isLoading = false;
|
||||
}
|
||||
}
|
||||
1062
src/EntKube.Web/Components/Pages/Tenants/RegistryTab.razor
Normal file
1062
src/EntKube.Web/Components/Pages/Tenants/RegistryTab.razor
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,155 @@
|
||||
@using EntKube.Web.Data
|
||||
@using EntKube.Web.Services
|
||||
@inject PrometheusService PrometheusService
|
||||
|
||||
<div class="d-flex gap-2 mb-3 align-items-center flex-wrap">
|
||||
<select class="form-select form-select-sm" style="width:auto" @bind="filterHealth" @bind:after="ApplyFilter">
|
||||
<option value="">All</option>
|
||||
<option value="up">Up</option>
|
||||
<option value="down">Down</option>
|
||||
</select>
|
||||
<input class="form-control form-control-sm" style="width:220px" placeholder="Filter by pool or label…"
|
||||
value="@filterText" @oninput="OnSearchInput" />
|
||||
<span class="ms-auto text-muted small">@filtered.Count of @targets.Count targets</span>
|
||||
<button class="btn btn-sm btn-outline-secondary" @onclick="Load" disabled="@isLoading">
|
||||
<span class="bi bi-arrow-clockwise @(isLoading ? "spin" : "")"></span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@if (isLoading)
|
||||
{
|
||||
<div class="text-center py-3 text-muted small">
|
||||
<span class="bi bi-hourglass-split me-1"></span> Loading scrape targets…
|
||||
</div>
|
||||
}
|
||||
else if (errorMessage is not null)
|
||||
{
|
||||
<div class="alert alert-warning small py-2">
|
||||
<span class="bi bi-exclamation-triangle me-1"></span>@errorMessage
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
@if (DownCount > 0)
|
||||
{
|
||||
<div class="alert alert-danger py-2 small mb-3 d-flex align-items-center gap-2">
|
||||
<span class="bi bi-exclamation-circle fs-5"></span>
|
||||
<strong>@DownCount target@(DownCount == 1 ? "" : "s") down</strong> — check the affected exporters.
|
||||
</div>
|
||||
}
|
||||
|
||||
@foreach (IGrouping<string, ScrapeTarget> group in filtered.GroupBy(t => t.Pool).OrderBy(g => g.Key))
|
||||
{
|
||||
bool anyDown = group.Any(t => t.Health == "down");
|
||||
<div class="mb-3">
|
||||
<div class="d-flex align-items-center gap-2 mb-1">
|
||||
<span class="text-muted small fw-semibold text-uppercase" style="letter-spacing:.05em">@group.Key</span>
|
||||
<span class="badge @(anyDown ? "bg-danger" : "bg-success") rounded-pill">@group.Count()</span>
|
||||
</div>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm align-middle mb-0 border">
|
||||
<tbody>
|
||||
@foreach (ScrapeTarget t in group.OrderBy(t => t.Health == "up" ? 1 : 0))
|
||||
{
|
||||
<tr class="@(t.Health == "down" ? "table-danger" : "")">
|
||||
<td style="width:50px">
|
||||
@if (t.Health == "up")
|
||||
{
|
||||
<span class="badge bg-success"><span class="bi bi-check me-1"></span>UP</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="badge bg-danger"><span class="bi bi-x me-1"></span>DOWN</span>
|
||||
}
|
||||
</td>
|
||||
<td class="font-monospace small text-truncate" style="max-width:300px"
|
||||
title="@t.ScrapeUrl">
|
||||
@t.Labels.GetValueOrDefault("instance", t.ScrapeUrl)
|
||||
</td>
|
||||
<td class="text-muted small">
|
||||
@if (t.Labels.TryGetValue("namespace", out string? ns))
|
||||
{
|
||||
<span class="badge bg-secondary-subtle text-secondary border me-1">@ns</span>
|
||||
}
|
||||
@if (t.Labels.TryGetValue("job", out string? job))
|
||||
{
|
||||
<span>@job</span>
|
||||
}
|
||||
</td>
|
||||
<td class="text-muted small text-end">
|
||||
@if (!string.IsNullOrEmpty(t.LastError))
|
||||
{
|
||||
<span class="text-danger" title="@t.LastError">
|
||||
<span class="bi bi-exclamation-circle me-1"></span>Error
|
||||
</span>
|
||||
}
|
||||
else if (t.LastScrape != default)
|
||||
{
|
||||
<span title="@t.LastScrape.ToLocalTime().ToString("u")">
|
||||
@t.LastScrapeDurationSeconds.ToString("F2")s ago
|
||||
</span>
|
||||
}
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
@code {
|
||||
[Parameter] public required Guid ClusterId { get; set; }
|
||||
|
||||
private List<ScrapeTarget> targets = [];
|
||||
private List<ScrapeTarget> filtered = [];
|
||||
private string filterHealth = "";
|
||||
private string filterText = "";
|
||||
private bool isLoading = true;
|
||||
private string? errorMessage;
|
||||
|
||||
protected override async Task OnParametersSetAsync() => await Load();
|
||||
|
||||
private async Task Load()
|
||||
{
|
||||
isLoading = true;
|
||||
errorMessage = null;
|
||||
StateHasChanged();
|
||||
|
||||
KubernetesOperationResult<List<ScrapeTarget>> result =
|
||||
await PrometheusService.GetScrapeTargetsAsync(ClusterId);
|
||||
|
||||
if (result.IsSuccess)
|
||||
{
|
||||
targets = result.Data ?? [];
|
||||
ApplyFilter();
|
||||
}
|
||||
else
|
||||
{
|
||||
errorMessage = result.Error;
|
||||
targets = [];
|
||||
filtered = [];
|
||||
}
|
||||
isLoading = false;
|
||||
}
|
||||
|
||||
private void OnSearchInput(ChangeEventArgs e)
|
||||
{
|
||||
filterText = e.Value?.ToString() ?? string.Empty;
|
||||
ApplyFilter();
|
||||
}
|
||||
|
||||
private int DownCount => targets.Count(t => t.Health == "down");
|
||||
|
||||
private void ApplyFilter()
|
||||
{
|
||||
filtered = targets.Where(t =>
|
||||
(string.IsNullOrEmpty(filterHealth) || t.Health == filterHealth) &&
|
||||
(string.IsNullOrEmpty(filterText) ||
|
||||
t.Pool.Contains(filterText, StringComparison.OrdinalIgnoreCase) ||
|
||||
t.ScrapeUrl.Contains(filterText, StringComparison.OrdinalIgnoreCase) ||
|
||||
t.Labels.Values.Any(v => v.Contains(filterText, StringComparison.OrdinalIgnoreCase)))
|
||||
).ToList();
|
||||
}
|
||||
}
|
||||
251
src/EntKube.Web/Components/Pages/Tenants/SlaTargetManager.razor
Normal file
251
src/EntKube.Web/Components/Pages/Tenants/SlaTargetManager.razor
Normal file
@@ -0,0 +1,251 @@
|
||||
@using EntKube.Web.Data
|
||||
@using EntKube.Web.Services
|
||||
@using Microsoft.EntityFrameworkCore
|
||||
@inject IncidentService IncidentService
|
||||
@inject IDbContextFactory<ApplicationDbContext> DbFactory
|
||||
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<h6 class="mb-0">SLA Targets</h6>
|
||||
<button class="btn btn-sm btn-primary" @onclick="ShowAdd">
|
||||
<span class="bi bi-plus-lg"></span> Add Target
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="text-muted small mb-3">
|
||||
Define uptime targets per customer or app. More specific targets (app > customer) take precedence.
|
||||
</div>
|
||||
|
||||
@if (targets.Count == 0)
|
||||
{
|
||||
<div class="text-center py-4 text-muted">
|
||||
<span class="bi bi-shield display-4 d-block mb-2"></span>
|
||||
No SLA targets configured. Add one to track uptime compliance.
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm align-middle">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>Scope</th>
|
||||
<th>Target</th>
|
||||
<th>Window</th>
|
||||
<th>Compliance (30d)</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (SlaTargetRow row in targets)
|
||||
{
|
||||
<tr>
|
||||
<td>
|
||||
@if (row.AppName is not null)
|
||||
{
|
||||
<span><span class="bi bi-app me-1 text-muted"></span>@row.AppName</span>
|
||||
<div class="text-muted small">@row.CustomerName</div>
|
||||
}
|
||||
else if (row.CustomerName is not null)
|
||||
{
|
||||
<span><span class="bi bi-person me-1 text-muted"></span>@row.CustomerName</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="text-muted">All customers (tenant default)</span>
|
||||
}
|
||||
</td>
|
||||
<td class="fw-semibold">@row.Target.TargetPercent.ToString("F1")%</td>
|
||||
<td class="text-muted small">@row.Target.MeasurementWindowDays days</td>
|
||||
<td>
|
||||
@if (row.CompliancePercent.HasValue)
|
||||
{
|
||||
bool met = row.CompliancePercent.Value >= row.Target.TargetPercent;
|
||||
<span class="@(met ? "text-success" : "text-danger") fw-semibold">
|
||||
<span class="bi bi-@(met ? "check-circle" : "exclamation-circle") me-1"></span>
|
||||
@row.CompliancePercent.Value.ToString("F2")%
|
||||
</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="text-muted small">No data</span>
|
||||
}
|
||||
</td>
|
||||
<td class="text-end">
|
||||
<button class="btn btn-sm btn-outline-danger" @onclick="() => Delete(row.Target)">
|
||||
<span class="bi bi-trash"></span>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (showAdd)
|
||||
{
|
||||
<div class="card mt-3 border-primary">
|
||||
<div class="card-header d-flex justify-content-between align-items-center py-2">
|
||||
<h6 class="mb-0">Add SLA Target</h6>
|
||||
<button class="btn-close btn-sm" @onclick="() => showAdd = false"></button>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row g-3">
|
||||
<div class="col-md-6">
|
||||
<label class="form-label small">Customer (optional)</label>
|
||||
<select class="form-select form-select-sm" @bind="newCustomerId" @bind:after="OnCustomerChanged">
|
||||
<option value="">All customers (tenant default)</option>
|
||||
@foreach (Customer c in customers)
|
||||
{
|
||||
<option value="@c.Id">@c.Name</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label small">App (optional)</label>
|
||||
<select class="form-select form-select-sm" @bind="newAppId" disabled="@(filteredApps.Count == 0)">
|
||||
<option value="">All apps for selected customer</option>
|
||||
@foreach (EntKube.Web.Data.App a in filteredApps)
|
||||
{
|
||||
<option value="@a.Id">@a.Name</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label small">Uptime Target (%)</label>
|
||||
<input class="form-control form-control-sm" type="number" min="90" max="100" step="0.1"
|
||||
@bind="newTargetPercent" />
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label small">Measurement Window (days)</label>
|
||||
<select class="form-select form-select-sm" @bind="newWindowDays">
|
||||
<option value="7">7 days</option>
|
||||
<option value="30">30 days</option>
|
||||
<option value="90">90 days</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
@if (!string.IsNullOrEmpty(formError))
|
||||
{
|
||||
<div class="alert alert-danger py-1 small mt-2 mb-0">@formError</div>
|
||||
}
|
||||
<div class="mt-3 d-flex gap-2">
|
||||
<button class="btn btn-sm btn-primary" @onclick="Save">Save Target</button>
|
||||
<button class="btn btn-sm btn-outline-secondary" @onclick="() => showAdd = false">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@code {
|
||||
[Parameter] public required Guid TenantId { get; set; }
|
||||
|
||||
private List<SlaTargetRow> targets = [];
|
||||
private List<Customer> customers = [];
|
||||
private List<EntKube.Web.Data.App> filteredApps = [];
|
||||
private bool showAdd;
|
||||
private string newCustomerId = "";
|
||||
private string newAppId = "";
|
||||
private double newTargetPercent = 99.9;
|
||||
private int newWindowDays = 30;
|
||||
private string? formError;
|
||||
|
||||
protected override async Task OnInitializedAsync() => await Load();
|
||||
|
||||
private async Task Load()
|
||||
{
|
||||
using ApplicationDbContext db = DbFactory.CreateDbContext();
|
||||
|
||||
customers = await db.Customers
|
||||
.Include(c => c.Apps)
|
||||
.Where(c => c.TenantId == TenantId)
|
||||
.OrderBy(c => c.Name)
|
||||
.ToListAsync();
|
||||
|
||||
List<SlaTarget> slaTargets = await db.SlaTargets
|
||||
.Include(s => s.Customer)
|
||||
.Include(s => s.App)
|
||||
.Where(s => s.TenantId == TenantId)
|
||||
.OrderBy(s => s.Customer!.Name).ThenBy(s => s.App!.Name)
|
||||
.ToListAsync();
|
||||
|
||||
// Compute compliance for each target
|
||||
targets = [];
|
||||
foreach (SlaTarget t in slaTargets)
|
||||
{
|
||||
// Find all deployments in scope
|
||||
List<Guid> deploymentIds = await db.AppDeployments
|
||||
.Where(d => (t.AppId == null || d.AppId == t.AppId)
|
||||
&& (t.CustomerId == null || d.App.CustomerId == t.CustomerId)
|
||||
&& d.App.Customer.TenantId == TenantId)
|
||||
.Select(d => d.Id)
|
||||
.ToListAsync();
|
||||
|
||||
double? compliance = null;
|
||||
if (deploymentIds.Count > 0)
|
||||
{
|
||||
DateTime from = DateTime.UtcNow.AddDays(-t.MeasurementWindowDays);
|
||||
var snaps = await db.DeploymentHealthSnapshots
|
||||
.Where(s => deploymentIds.Contains(s.DeploymentId) && s.SnapshotAt >= from)
|
||||
.Select(s => s.HealthStatus)
|
||||
.ToListAsync();
|
||||
|
||||
if (snaps.Count > 0)
|
||||
compliance = Math.Round((double)snaps.Count(s => s == HealthStatus.Healthy) / snaps.Count * 100, 2);
|
||||
}
|
||||
|
||||
targets.Add(new SlaTargetRow(t, t.Customer?.Name, t.App?.Name, compliance));
|
||||
}
|
||||
}
|
||||
|
||||
private async Task OnCustomerChanged()
|
||||
{
|
||||
filteredApps = [];
|
||||
newAppId = "";
|
||||
if (!Guid.TryParse(newCustomerId, out Guid cid)) return;
|
||||
Customer? customer = customers.FirstOrDefault(c => c.Id == cid);
|
||||
if (customer is not null)
|
||||
filteredApps = customer.Apps.OfType<EntKube.Web.Data.App>().OrderBy(a => a.Name).ToList();
|
||||
}
|
||||
|
||||
private void ShowAdd()
|
||||
{
|
||||
showAdd = true;
|
||||
newCustomerId = "";
|
||||
newAppId = "";
|
||||
newTargetPercent = 99.9;
|
||||
newWindowDays = 30;
|
||||
filteredApps = [];
|
||||
formError = null;
|
||||
}
|
||||
|
||||
private async Task Save()
|
||||
{
|
||||
formError = null;
|
||||
if (newTargetPercent < 50 || newTargetPercent > 100)
|
||||
{
|
||||
formError = "Target must be between 50% and 100%.";
|
||||
return;
|
||||
}
|
||||
|
||||
Guid? customerId = Guid.TryParse(newCustomerId, out Guid cid) ? cid : null;
|
||||
Guid? appId = Guid.TryParse(newAppId, out Guid aid) ? aid : null;
|
||||
|
||||
await IncidentService.UpsertSlaTargetAsync(
|
||||
TenantId, customerId, appId, newTargetPercent, newWindowDays);
|
||||
|
||||
showAdd = false;
|
||||
await Load();
|
||||
}
|
||||
|
||||
private async Task Delete(SlaTarget target)
|
||||
{
|
||||
using ApplicationDbContext db = DbFactory.CreateDbContext();
|
||||
SlaTarget? found = await db.SlaTargets.FindAsync(target.Id);
|
||||
if (found is not null) db.SlaTargets.Remove(found);
|
||||
await db.SaveChangesAsync();
|
||||
await Load();
|
||||
}
|
||||
|
||||
private record SlaTargetRow(SlaTarget Target, string? CustomerName, string? AppName, double? CompliancePercent);
|
||||
}
|
||||
632
src/EntKube.Web/Components/Pages/Tenants/StorageBrowser.razor
Normal file
632
src/EntKube.Web/Components/Pages/Tenants/StorageBrowser.razor
Normal file
@@ -0,0 +1,632 @@
|
||||
@using System.IO
|
||||
@using EntKube.Web.Data
|
||||
@using EntKube.Web.Services
|
||||
@using Microsoft.AspNetCore.Components.Forms
|
||||
@inject StorageBrowserService BrowserService
|
||||
@inject IJSRuntime JS
|
||||
@implements IAsyncDisposable
|
||||
|
||||
<div class="card shadow-sm mb-4 border-primary">
|
||||
<div class="card-header bg-primary bg-opacity-10 d-flex align-items-center justify-content-between">
|
||||
<h6 class="mb-0">
|
||||
<i class="bi bi-folder2-open me-2"></i>Browse:
|
||||
<strong>@Link.BucketName</strong>
|
||||
<span class="badge bg-secondary ms-2 small fw-normal">@GetProviderLabel()</span>
|
||||
</h6>
|
||||
<button class="btn btn-sm btn-outline-secondary border-0" @onclick="OnClose">
|
||||
<i class="bi bi-x-lg"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
|
||||
@if (!string.IsNullOrEmpty(error))
|
||||
{
|
||||
<div class="alert alert-danger small py-2 mb-3">
|
||||
<i class="bi bi-exclamation-triangle me-1"></i>@error
|
||||
</div>
|
||||
}
|
||||
|
||||
@* Breadcrumb + toolbar *@
|
||||
<div class="d-flex align-items-center gap-2 mb-3 flex-wrap">
|
||||
<nav aria-label="breadcrumb" class="flex-grow-1 overflow-hidden">
|
||||
<ol class="breadcrumb mb-0 small flex-nowrap text-nowrap overflow-auto">
|
||||
<li class="breadcrumb-item">
|
||||
<button type="button" class="btn btn-link btn-sm p-0 text-decoration-none" @onclick="NavigateToRoot">
|
||||
<i class="bi bi-bucket me-1"></i>@Link.BucketName
|
||||
</button>
|
||||
</li>
|
||||
@foreach (BreadcrumbItem crumb in breadcrumbs)
|
||||
{
|
||||
if (crumb.IsLast)
|
||||
{
|
||||
<li class="breadcrumb-item active">@crumb.Label</li>
|
||||
}
|
||||
else
|
||||
{
|
||||
<li class="breadcrumb-item">
|
||||
<button type="button" class="btn btn-link btn-sm p-0 text-decoration-none" @onclick="() => NavigateTo(crumb.Path)">@crumb.Label</button>
|
||||
</li>
|
||||
}
|
||||
}
|
||||
</ol>
|
||||
</nav>
|
||||
<div class="d-flex gap-1 flex-shrink-0">
|
||||
@if (!showNewFolderInput)
|
||||
{
|
||||
<button class="btn btn-sm btn-outline-secondary" @onclick="ShowNewFolderInput" title="New folder">
|
||||
<i class="bi bi-folder-plus me-1"></i><span class="d-none d-sm-inline">New Folder</span>
|
||||
</button>
|
||||
}
|
||||
<label class="btn btn-sm btn-outline-primary mb-0" title="Upload file(s)" style="cursor:pointer">
|
||||
<i class="bi bi-upload me-1"></i><span class="d-none d-sm-inline">Upload</span>
|
||||
<InputFile OnChange="HandleFileUpload" class="d-none" multiple />
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@* New folder input *@
|
||||
@if (showNewFolderInput)
|
||||
{
|
||||
<div class="d-flex gap-2 mb-3 align-items-center">
|
||||
<input type="text"
|
||||
class="form-control form-control-sm"
|
||||
style="max-width:280px"
|
||||
placeholder="Folder name"
|
||||
@bind="newFolderName"
|
||||
@bind:event="oninput"
|
||||
@onkeydown="OnFolderNameKeyDown" />
|
||||
<button class="btn btn-sm btn-primary"
|
||||
@onclick="CreateFolder"
|
||||
disabled="@(string.IsNullOrWhiteSpace(newFolderName) || creatingFolder)">
|
||||
@if (creatingFolder)
|
||||
{
|
||||
<span class="spinner-border spinner-border-sm me-1"></span>
|
||||
}
|
||||
Create
|
||||
</button>
|
||||
<button class="btn btn-sm btn-outline-secondary" @onclick="CancelNewFolder">Cancel</button>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (uploadingFile)
|
||||
{
|
||||
<div class="alert alert-info small py-2 mb-3">
|
||||
<span class="spinner-border spinner-border-sm me-2"></span>Uploading...
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (loading)
|
||||
{
|
||||
<div class="text-center py-4 text-muted">
|
||||
<span class="spinner-border spinner-border-sm me-2"></span>Loading...
|
||||
</div>
|
||||
}
|
||||
else if (listing is not null)
|
||||
{
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm table-hover align-middle mb-0">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th style="width:30px">
|
||||
<input type="checkbox"
|
||||
class="form-check-input"
|
||||
checked="@AllSelected"
|
||||
@onchange="ToggleSelectAll" />
|
||||
</th>
|
||||
<th>Name</th>
|
||||
<th class="d-none d-md-table-cell text-end" style="width:90px">Size</th>
|
||||
<th class="d-none d-lg-table-cell" style="width:145px">Modified</th>
|
||||
<th style="width:95px"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (string folder in listing.Folders)
|
||||
{
|
||||
string folderName = folder.TrimEnd('/').Split('/').Last();
|
||||
bool folderSelected = selectedKeys.Contains(folder);
|
||||
<tr>
|
||||
<td>
|
||||
<input type="checkbox"
|
||||
class="form-check-input"
|
||||
checked="@folderSelected"
|
||||
@onchange="e => ToggleKey(folder, (bool)e.Value!)" />
|
||||
</td>
|
||||
<td>
|
||||
<button type="button"
|
||||
class="btn btn-link p-0 text-dark fw-semibold text-decoration-none"
|
||||
@onclick="() => NavigateTo(folder)">
|
||||
<i class="bi bi-folder-fill text-warning me-2"></i>@folderName/
|
||||
</button>
|
||||
</td>
|
||||
<td class="d-none d-md-table-cell text-end text-muted small">—</td>
|
||||
<td class="d-none d-lg-table-cell text-muted small">—</td>
|
||||
<td class="text-end">
|
||||
<button class="btn btn-sm btn-outline-danger border-0 py-0"
|
||||
@onclick="() => DeleteFolder(folder)"
|
||||
title="Delete folder and all contents">
|
||||
<i class="bi bi-trash"></i>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
@foreach (StorageObject obj in listing.Objects)
|
||||
{
|
||||
string fileName = obj.Name;
|
||||
string fileKey = obj.Key;
|
||||
bool fileSelected = selectedKeys.Contains(fileKey);
|
||||
<tr>
|
||||
<td>
|
||||
<input type="checkbox"
|
||||
class="form-check-input"
|
||||
checked="@fileSelected"
|
||||
@onchange="e => ToggleKey(fileKey, (bool)e.Value!)" />
|
||||
</td>
|
||||
<td>
|
||||
<i class="@GetFileIcon(fileName) text-secondary me-2"></i>
|
||||
<span class="text-break">@fileName</span>
|
||||
</td>
|
||||
<td class="d-none d-md-table-cell text-end small text-muted">@FormatSize(obj.SizeBytes)</td>
|
||||
<td class="d-none d-lg-table-cell small text-muted">@obj.LastModified.ToString("yyyy-MM-dd HH:mm")</td>
|
||||
<td class="text-end">
|
||||
<button class="btn btn-sm btn-outline-secondary border-0 py-0"
|
||||
@onclick="() => DownloadFile(fileKey, fileName)"
|
||||
disabled="@(downloadingKey == fileKey)"
|
||||
title="Download">
|
||||
@if (downloadingKey == fileKey)
|
||||
{
|
||||
<span class="spinner-border spinner-border-sm"></span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<i class="bi bi-download"></i>
|
||||
}
|
||||
</button>
|
||||
<button class="btn btn-sm btn-outline-secondary border-0 py-0"
|
||||
@onclick="() => GenerateLink(fileKey, fileName)"
|
||||
disabled="@(generatingLinkKey == fileKey)"
|
||||
title="Get shareable link">
|
||||
@if (generatingLinkKey == fileKey)
|
||||
{
|
||||
<span class="spinner-border spinner-border-sm"></span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<i class="bi bi-link-45deg"></i>
|
||||
}
|
||||
</button>
|
||||
<button class="btn btn-sm btn-outline-danger border-0 py-0"
|
||||
@onclick="() => DeleteObject(fileKey)"
|
||||
title="Delete">
|
||||
<i class="bi bi-trash"></i>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
@if (listing.Folders.Count == 0 && listing.Objects.Count == 0)
|
||||
{
|
||||
<tr>
|
||||
<td colspan="5" class="text-center text-muted small py-4">
|
||||
<i class="bi bi-inbox me-2"></i>This folder is empty
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@if (selectedKeys.Count > 0)
|
||||
{
|
||||
<div class="mt-2">
|
||||
<button class="btn btn-sm btn-outline-danger"
|
||||
@onclick="DeleteSelected"
|
||||
disabled="@deleting">
|
||||
@if (deleting)
|
||||
{
|
||||
<span class="spinner-border spinner-border-sm me-1"></span>
|
||||
}
|
||||
<i class="bi bi-trash me-1"></i>Delete Selected (@selectedKeys.Count)
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
@if (sharedUrl is not null)
|
||||
{
|
||||
<div class="mt-3 p-3 rounded border border-success-subtle bg-success-subtle">
|
||||
<div class="d-flex align-items-start gap-2">
|
||||
<i class="bi bi-link-45deg text-success flex-shrink-0 mt-1"></i>
|
||||
<div class="flex-grow-1" style="min-width:0">
|
||||
<div class="small fw-semibold mb-1">
|
||||
Presigned link for <em>@sharedUrlFileName</em>
|
||||
<span class="text-muted fw-normal">— valid for 1 hour</span>
|
||||
</div>
|
||||
<div class="d-flex gap-2">
|
||||
<input type="text"
|
||||
class="form-control form-control-sm font-monospace"
|
||||
value="@sharedUrl"
|
||||
readonly />
|
||||
<button class="btn btn-sm @(linkCopied ? "btn-success" : "btn-outline-success") flex-shrink-0"
|
||||
@onclick="CopyLink"
|
||||
title="Copy to clipboard">
|
||||
@if (linkCopied)
|
||||
{
|
||||
<i class="bi bi-check-lg"></i>
|
||||
}
|
||||
else
|
||||
{
|
||||
<i class="bi bi-clipboard"></i>
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<button class="btn btn-sm btn-outline-secondary border-0 flex-shrink-0"
|
||||
@onclick="() => sharedUrl = null"
|
||||
title="Dismiss">
|
||||
<i class="bi bi-x-lg"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@code {
|
||||
[Parameter] public Guid TenantId { get; set; }
|
||||
[Parameter] public StorageLink Link { get; set; } = null!;
|
||||
[Parameter] public EventCallback OnClose { get; set; }
|
||||
|
||||
private string currentPrefix = "";
|
||||
private List<BreadcrumbItem> breadcrumbs = new();
|
||||
private StorageBrowseResult? listing;
|
||||
private bool loading = true;
|
||||
private string? error;
|
||||
private bool uploadingFile;
|
||||
private bool creatingFolder;
|
||||
private string newFolderName = "";
|
||||
private bool showNewFolderInput;
|
||||
private HashSet<string> selectedKeys = new();
|
||||
private bool deleting;
|
||||
private string? downloadingKey;
|
||||
private string? generatingLinkKey;
|
||||
private string? sharedUrl;
|
||||
private string? sharedUrlFileName;
|
||||
private bool linkCopied;
|
||||
private IJSObjectReference? jsModule;
|
||||
|
||||
private bool AllSelected =>
|
||||
listing is not null &&
|
||||
(listing.Folders.Count + listing.Objects.Count) > 0 &&
|
||||
listing.Folders.All(f => selectedKeys.Contains(f)) &&
|
||||
listing.Objects.All(o => selectedKeys.Contains(o.Key));
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
await LoadListing();
|
||||
}
|
||||
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||
{
|
||||
if (firstRender)
|
||||
{
|
||||
jsModule = await JS.InvokeAsync<IJSObjectReference>(
|
||||
"import", "./Components/Pages/Tenants/StorageBrowser.razor.js");
|
||||
}
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (jsModule is not null)
|
||||
await jsModule.DisposeAsync();
|
||||
}
|
||||
|
||||
private void UpdateBreadcrumbs()
|
||||
{
|
||||
breadcrumbs.Clear();
|
||||
string[] parts = currentPrefix.TrimEnd('/').Split('/', StringSplitOptions.RemoveEmptyEntries);
|
||||
string built = "";
|
||||
for (int i = 0; i < parts.Length; i++)
|
||||
{
|
||||
string part = parts[i];
|
||||
built += part + "/";
|
||||
breadcrumbs.Add(new BreadcrumbItem { Label = part, Path = built, IsLast = i == parts.Length - 1 });
|
||||
}
|
||||
}
|
||||
|
||||
private async Task LoadListing()
|
||||
{
|
||||
loading = true;
|
||||
error = null;
|
||||
selectedKeys.Clear();
|
||||
UpdateBreadcrumbs();
|
||||
StateHasChanged();
|
||||
try
|
||||
{
|
||||
listing = await BrowserService.ListAsync(TenantId, Link, currentPrefix);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
error = ex.Message;
|
||||
listing = null;
|
||||
}
|
||||
finally
|
||||
{
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task NavigateToRoot()
|
||||
{
|
||||
currentPrefix = "";
|
||||
await LoadListing();
|
||||
}
|
||||
|
||||
private async Task NavigateTo(string prefix)
|
||||
{
|
||||
currentPrefix = prefix;
|
||||
await LoadListing();
|
||||
}
|
||||
|
||||
private void ShowNewFolderInput()
|
||||
{
|
||||
showNewFolderInput = true;
|
||||
}
|
||||
|
||||
private async Task HandleFileUpload(InputFileChangeEventArgs e)
|
||||
{
|
||||
uploadingFile = true;
|
||||
error = null;
|
||||
StateHasChanged();
|
||||
try
|
||||
{
|
||||
foreach (IBrowserFile file in e.GetMultipleFiles(20))
|
||||
{
|
||||
string key = currentPrefix + file.Name;
|
||||
using Stream stream = file.OpenReadStream(maxAllowedSize: 100 * 1024 * 1024);
|
||||
await BrowserService.UploadAsync(TenantId, Link, key, stream, file.ContentType);
|
||||
}
|
||||
await LoadListing();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
error = ex.Message;
|
||||
}
|
||||
finally
|
||||
{
|
||||
uploadingFile = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task CreateFolder()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(newFolderName)) return;
|
||||
creatingFolder = true;
|
||||
error = null;
|
||||
try
|
||||
{
|
||||
string folderPath = currentPrefix + newFolderName.Trim('/');
|
||||
await BrowserService.CreateFolderAsync(TenantId, Link, folderPath);
|
||||
newFolderName = "";
|
||||
showNewFolderInput = false;
|
||||
await LoadListing();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
error = ex.Message;
|
||||
}
|
||||
finally
|
||||
{
|
||||
creatingFolder = false;
|
||||
}
|
||||
}
|
||||
|
||||
private void CancelNewFolder()
|
||||
{
|
||||
showNewFolderInput = false;
|
||||
newFolderName = "";
|
||||
}
|
||||
|
||||
private async Task OnFolderNameKeyDown(KeyboardEventArgs e)
|
||||
{
|
||||
if (e.Key == "Enter") await CreateFolder();
|
||||
else if (e.Key == "Escape") CancelNewFolder();
|
||||
}
|
||||
|
||||
private async Task DownloadFile(string key, string fileName)
|
||||
{
|
||||
downloadingKey = key;
|
||||
error = null;
|
||||
StateHasChanged();
|
||||
try
|
||||
{
|
||||
byte[] bytes = await BrowserService.DownloadAsync(TenantId, Link, key);
|
||||
if (jsModule is not null)
|
||||
await jsModule.InvokeVoidAsync("downloadFile", fileName, GetContentType(fileName), bytes);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
error = ex.Message;
|
||||
}
|
||||
finally
|
||||
{
|
||||
downloadingKey = null;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task GenerateLink(string key, string fileName)
|
||||
{
|
||||
generatingLinkKey = key;
|
||||
sharedUrl = null;
|
||||
linkCopied = false;
|
||||
error = null;
|
||||
StateHasChanged();
|
||||
try
|
||||
{
|
||||
sharedUrl = await BrowserService.GetPresignedUrlAsync(TenantId, Link, key);
|
||||
sharedUrlFileName = fileName;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
error = ex.Message;
|
||||
}
|
||||
finally
|
||||
{
|
||||
generatingLinkKey = null;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task CopyLink()
|
||||
{
|
||||
if (sharedUrl is null || jsModule is null) return;
|
||||
await jsModule.InvokeVoidAsync("copyToClipboard", sharedUrl);
|
||||
linkCopied = true;
|
||||
StateHasChanged();
|
||||
await Task.Delay(2000);
|
||||
linkCopied = false;
|
||||
}
|
||||
|
||||
private async Task DeleteObject(string key)
|
||||
{
|
||||
error = null;
|
||||
try
|
||||
{
|
||||
await BrowserService.DeleteAsync(TenantId, Link, key);
|
||||
await LoadListing();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
error = ex.Message;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task DeleteFolder(string prefix)
|
||||
{
|
||||
error = null;
|
||||
try
|
||||
{
|
||||
await BrowserService.DeleteFolderAsync(TenantId, Link, prefix);
|
||||
await LoadListing();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
error = ex.Message;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task DeleteSelected()
|
||||
{
|
||||
if (selectedKeys.Count == 0) return;
|
||||
deleting = true;
|
||||
error = null;
|
||||
try
|
||||
{
|
||||
List<string> folders = new(selectedKeys.Where(k => k.EndsWith('/')));
|
||||
List<string> files = new(selectedKeys.Where(k => !k.EndsWith('/')));
|
||||
|
||||
foreach (string folder in folders)
|
||||
await BrowserService.DeleteFolderAsync(TenantId, Link, folder);
|
||||
|
||||
if (files.Count > 0)
|
||||
await BrowserService.DeleteManyAsync(TenantId, Link, files);
|
||||
|
||||
selectedKeys.Clear();
|
||||
await LoadListing();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
error = ex.Message;
|
||||
}
|
||||
finally
|
||||
{
|
||||
deleting = false;
|
||||
}
|
||||
}
|
||||
|
||||
private void ToggleKey(string key, bool selected)
|
||||
{
|
||||
if (selected) selectedKeys.Add(key);
|
||||
else selectedKeys.Remove(key);
|
||||
}
|
||||
|
||||
private void ToggleSelectAll(ChangeEventArgs e)
|
||||
{
|
||||
if (listing is null) return;
|
||||
if ((bool)e.Value!)
|
||||
{
|
||||
selectedKeys.UnionWith(listing.Folders);
|
||||
selectedKeys.UnionWith(listing.Objects.Select(o => o.Key));
|
||||
}
|
||||
else
|
||||
{
|
||||
selectedKeys.Clear();
|
||||
}
|
||||
}
|
||||
|
||||
private string GetProviderLabel()
|
||||
{
|
||||
return Link.Provider switch
|
||||
{
|
||||
StorageProvider.AwsS3 => "AWS S3",
|
||||
StorageProvider.CleuraS3 => "Cleura S3",
|
||||
StorageProvider.MinIO => "MinIO",
|
||||
_ => Link.Provider.ToString()
|
||||
};
|
||||
}
|
||||
|
||||
private static string GetFileIcon(string fileName)
|
||||
{
|
||||
string ext = Path.GetExtension(fileName).ToLowerInvariant();
|
||||
if (ext == ".jpg" || ext == ".jpeg" || ext == ".png" || ext == ".gif" || ext == ".webp" || ext == ".svg" || ext == ".bmp")
|
||||
return "bi bi-file-image";
|
||||
if (ext == ".pdf")
|
||||
return "bi bi-file-pdf";
|
||||
if (ext == ".zip" || ext == ".tar" || ext == ".gz" || ext == ".7z" || ext == ".rar")
|
||||
return "bi bi-file-zip";
|
||||
if (ext == ".mp4" || ext == ".avi" || ext == ".mov" || ext == ".mkv" || ext == ".webm")
|
||||
return "bi bi-file-play";
|
||||
if (ext == ".mp3" || ext == ".wav" || ext == ".ogg" || ext == ".flac")
|
||||
return "bi bi-file-music";
|
||||
if (ext == ".json" || ext == ".xml" || ext == ".yaml" || ext == ".yml" || ext == ".toml")
|
||||
return "bi bi-file-code";
|
||||
if (ext == ".cs" || ext == ".js" || ext == ".ts" || ext == ".py" || ext == ".go" || ext == ".rs")
|
||||
return "bi bi-file-code";
|
||||
if (ext == ".txt" || ext == ".md" || ext == ".log")
|
||||
return "bi bi-file-text";
|
||||
if (ext == ".csv" || ext == ".xlsx" || ext == ".xls")
|
||||
return "bi bi-file-spreadsheet";
|
||||
return "bi bi-file-earmark";
|
||||
}
|
||||
|
||||
private static string FormatSize(long bytes)
|
||||
{
|
||||
if (bytes < 1024) return $"{bytes} B";
|
||||
if (bytes < 1024 * 1024) return $"{bytes / 1024.0:F1} KB";
|
||||
if (bytes < 1024L * 1024 * 1024) return $"{bytes / 1024.0 / 1024.0:F1} MB";
|
||||
return $"{bytes / 1024.0 / 1024.0 / 1024.0:F1} GB";
|
||||
}
|
||||
|
||||
private static string GetContentType(string fileName)
|
||||
{
|
||||
string ext = Path.GetExtension(fileName).ToLowerInvariant();
|
||||
if (ext == ".jpg" || ext == ".jpeg") return "image/jpeg";
|
||||
if (ext == ".png") return "image/png";
|
||||
if (ext == ".gif") return "image/gif";
|
||||
if (ext == ".webp") return "image/webp";
|
||||
if (ext == ".svg") return "image/svg+xml";
|
||||
if (ext == ".pdf") return "application/pdf";
|
||||
if (ext == ".json") return "application/json";
|
||||
if (ext == ".xml") return "application/xml";
|
||||
if (ext == ".txt" || ext == ".md" || ext == ".log") return "text/plain";
|
||||
if (ext == ".csv") return "text/csv";
|
||||
if (ext == ".html" || ext == ".htm") return "text/html";
|
||||
if (ext == ".css") return "text/css";
|
||||
if (ext == ".js") return "text/javascript";
|
||||
if (ext == ".zip") return "application/zip";
|
||||
if (ext == ".gz") return "application/gzip";
|
||||
return "application/octet-stream";
|
||||
}
|
||||
|
||||
private class BreadcrumbItem
|
||||
{
|
||||
public string Label { get; set; } = "";
|
||||
public string Path { get; set; } = "";
|
||||
public bool IsLast { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
export async function copyToClipboard(text) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function downloadFile(fileName, contentType, byteArray) {
|
||||
const blob = new Blob([new Uint8Array(byteArray)], { type: contentType });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const anchor = document.createElement('a');
|
||||
anchor.href = url;
|
||||
anchor.download = fileName;
|
||||
document.body.appendChild(anchor);
|
||||
anchor.click();
|
||||
document.body.removeChild(anchor);
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
@using EntKube.Web.Data
|
||||
@using EntKube.Web.Services
|
||||
@inject StorageService StorageService
|
||||
@inject StorageBrowserService BrowserService
|
||||
|
||||
@* ===========================================================================
|
||||
Storage Tab — shows MinIO instances discovered on clusters, plus external
|
||||
@@ -63,7 +64,7 @@ else
|
||||
<i class="bi bi-bucket text-warning"></i>
|
||||
<strong class="small">@instance.Name</strong>
|
||||
</div>
|
||||
@(GetMinioStatusBadge(instance.Status))
|
||||
@(GetMinioStatusBadge(instance))
|
||||
</div>
|
||||
<div class="card-body py-2">
|
||||
<div class="row small text-muted">
|
||||
@@ -89,13 +90,108 @@ else
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@* ── Pod status ── *@
|
||||
@if (instance.Pods.Count > 0)
|
||||
{
|
||||
<div class="mt-2 pt-2 border-top">
|
||||
<div class="small text-muted mb-1"><i class="bi bi-cpu me-1"></i>Pods</div>
|
||||
<div class="d-flex flex-wrap gap-1">
|
||||
@foreach (PodInfo pod in instance.Pods)
|
||||
{
|
||||
bool allReady = pod.ReadyContainers == pod.TotalContainers && pod.TotalContainers > 0;
|
||||
string podBadgeClass = pod.Status == "Running" && allReady
|
||||
? "bg-success-subtle text-success border border-success-subtle"
|
||||
: pod.Status is "Pending" or "ContainerCreating"
|
||||
? "bg-warning-subtle text-warning border border-warning-subtle"
|
||||
: "bg-danger-subtle text-danger border border-danger-subtle";
|
||||
<span class="badge @podBadgeClass px-2 py-1 small font-monospace"
|
||||
title="@pod.Status — @pod.ReadyContainers/@pod.TotalContainers ready, @pod.Restarts restarts">
|
||||
@pod.Name
|
||||
<span class="ms-1 opacity-75">@pod.ReadyContainers/@pod.TotalContainers</span>
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
else if (instance.HealthStatus is not null)
|
||||
{
|
||||
<div class="mt-2 pt-2 border-top small text-muted">
|
||||
<i class="bi bi-cpu me-1"></i>No pods found (label <code>v1.min.io/tenant=@instance.Name</code>)
|
||||
</div>
|
||||
}
|
||||
|
||||
@* ── Register as Storage Link ── *@
|
||||
@if (registeringInstance == instance)
|
||||
{
|
||||
<div class="mt-2 pt-2 border-top">
|
||||
<div class="small fw-semibold mb-2"><i class="bi bi-plug me-1"></i>Register as Storage Link</div>
|
||||
<div class="mb-2">
|
||||
<input type="text" class="form-control form-control-sm"
|
||||
placeholder="Display name (e.g. Production MinIO)"
|
||||
@bind="regFormName" />
|
||||
</div>
|
||||
<div class="mb-2">
|
||||
<label class="form-label small text-muted mb-1">
|
||||
S3 Endpoint URL
|
||||
<span class="text-danger">*</span>
|
||||
</label>
|
||||
<input type="text" class="form-control form-control-sm"
|
||||
placeholder="https://s3.minio.example.com"
|
||||
@bind="regFormEndpoint" />
|
||||
<div class="form-text small text-muted">
|
||||
Use an externally accessible URL — the internal cluster address
|
||||
(<code>@instance.Endpoint</code>) is blocked by network policy.
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-2">
|
||||
<input type="text" class="form-control form-control-sm"
|
||||
placeholder="Root User / Access Key"
|
||||
@bind="regFormAccessKey" />
|
||||
</div>
|
||||
<div class="mb-2">
|
||||
<input type="password" class="form-control form-control-sm"
|
||||
placeholder="Root Password / Secret Key"
|
||||
@bind="regFormSecretKey" />
|
||||
</div>
|
||||
@if (!string.IsNullOrEmpty(regFormError))
|
||||
{
|
||||
<div class="alert alert-danger small py-1 mb-2">
|
||||
<i class="bi bi-exclamation-triangle me-1"></i>@regFormError
|
||||
</div>
|
||||
}
|
||||
<div class="d-flex gap-1">
|
||||
<button class="btn btn-sm btn-primary py-0" @onclick="() => RegisterMinioTenant(instance)"
|
||||
disabled="@registering">
|
||||
@if (registering) { <span class="spinner-border spinner-border-sm me-1"></span> }
|
||||
Register
|
||||
</button>
|
||||
<button class="btn btn-sm btn-outline-secondary py-0" @onclick="CancelRegisterForm">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<div class="card-footer bg-white py-1 d-flex align-items-center justify-content-between">
|
||||
<small class="text-muted">
|
||||
@if (instance.CreatedAt.HasValue)
|
||||
{
|
||||
<text>Created @instance.CreatedAt.Value.ToString("yyyy-MM-dd HH:mm")</text>
|
||||
}
|
||||
</small>
|
||||
@if (IsMinioRegistered(instance))
|
||||
{
|
||||
<span class="badge bg-info-subtle text-info border border-info-subtle small">
|
||||
<i class="bi bi-plug-fill me-1"></i>Registered
|
||||
</span>
|
||||
}
|
||||
else if (registeringInstance != instance)
|
||||
{
|
||||
<button class="btn btn-sm btn-outline-primary border-0 py-0 small"
|
||||
@onclick="() => ShowRegisterForm(instance)">
|
||||
<i class="bi bi-plug me-1"></i>Register
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
@if (instance.CreatedAt.HasValue)
|
||||
{
|
||||
<div class="card-footer bg-white py-1">
|
||||
<small class="text-muted">Created @instance.CreatedAt.Value.ToString("yyyy-MM-dd HH:mm")</small>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@@ -185,6 +281,14 @@ else
|
||||
<i class="bi bi-list-ul"></i>
|
||||
}
|
||||
</button>
|
||||
@if (link.BucketName is not null)
|
||||
{
|
||||
<button class="btn btn-sm btn-outline-primary border-0 py-0"
|
||||
@onclick="() => OpenFileBrowser(link)"
|
||||
title="Browse files in bucket">
|
||||
<i class="bi bi-folder2-open"></i>
|
||||
</button>
|
||||
}
|
||||
<button class="btn btn-sm btn-outline-danger border-0 py-0"
|
||||
@onclick="() => DeleteCleuraS3Bucket(link.Id)"
|
||||
disabled="@deletingLinkId.HasValue"
|
||||
@@ -199,8 +303,49 @@ else
|
||||
}
|
||||
</button>
|
||||
}
|
||||
else if (link.Provider == StorageProvider.MinIO)
|
||||
{
|
||||
<button class="btn btn-sm btn-outline-secondary border-0 py-0"
|
||||
@onclick="() => ShowManageBucket(link)"
|
||||
title="Manage bucket (CORS, Policy)"
|
||||
disabled="@(link.BucketName is null)">
|
||||
<i class="bi bi-gear"></i>
|
||||
</button>
|
||||
<button class="btn btn-sm btn-outline-info border-0 py-0"
|
||||
@onclick="() => BrowseBuckets(link)"
|
||||
disabled="@(browsingLinkId.HasValue)"
|
||||
title="Browse all buckets">
|
||||
@if (browsingLinkId == link.Id)
|
||||
{
|
||||
<span class="spinner-border spinner-border-sm"></span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<i class="bi bi-list-ul"></i>
|
||||
}
|
||||
</button>
|
||||
@if (link.BucketName is not null)
|
||||
{
|
||||
<button class="btn btn-sm btn-outline-primary border-0 py-0"
|
||||
@onclick="() => OpenFileBrowser(link)"
|
||||
title="Browse files in bucket">
|
||||
<i class="bi bi-folder2-open"></i>
|
||||
</button>
|
||||
}
|
||||
<button class="btn btn-sm btn-outline-danger border-0 py-0" @onclick="() => DeleteLink(link.Id)" title="Remove registration">
|
||||
<i class="bi bi-trash"></i>
|
||||
</button>
|
||||
}
|
||||
else
|
||||
{
|
||||
@if (link.BucketName is not null)
|
||||
{
|
||||
<button class="btn btn-sm btn-outline-primary border-0 py-0"
|
||||
@onclick="() => OpenFileBrowser(link)"
|
||||
title="Browse files in bucket">
|
||||
<i class="bi bi-folder2-open"></i>
|
||||
</button>
|
||||
}
|
||||
<button class="btn btn-sm btn-outline-danger border-0 py-0" @onclick="() => DeleteLink(link.Id)" title="Delete">
|
||||
<i class="bi bi-trash"></i>
|
||||
</button>
|
||||
@@ -416,6 +561,14 @@ else
|
||||
</div>
|
||||
}
|
||||
|
||||
@* ── File Browser ── *@
|
||||
@if (fileBrowserLink is not null)
|
||||
{
|
||||
<StorageBrowser TenantId="TenantId"
|
||||
Link="fileBrowserLink"
|
||||
OnClose="CloseFileBrowser" />
|
||||
}
|
||||
|
||||
@* ── Add Storage Link Form ── *@
|
||||
@if (showForm)
|
||||
{
|
||||
@@ -697,6 +850,15 @@ else
|
||||
private bool minioAvailable;
|
||||
private List<MinioBucketInfo> minioInstances = [];
|
||||
|
||||
// MinIO registration form
|
||||
private MinioBucketInfo? registeringInstance;
|
||||
private string regFormName = "";
|
||||
private string regFormEndpoint = "";
|
||||
private string regFormAccessKey = "";
|
||||
private string regFormSecretKey = "";
|
||||
private bool registering;
|
||||
private string? regFormError;
|
||||
|
||||
// External storage links
|
||||
private List<StorageLink> storageLinks = [];
|
||||
private List<Data.Environment> environments = [];
|
||||
@@ -741,6 +903,9 @@ else
|
||||
private int corsMaxAge = 3600;
|
||||
private string bucketPolicyJson = "";
|
||||
|
||||
// File browser state
|
||||
private StorageLink? fileBrowserLink;
|
||||
|
||||
// Browse buckets state
|
||||
private StorageLink? browsedLink;
|
||||
private Guid? browsingLinkId;
|
||||
@@ -951,7 +1116,9 @@ else
|
||||
|
||||
try
|
||||
{
|
||||
browsedBuckets = await StorageService.ListCleuraS3BucketsAsync(TenantId, link.Id);
|
||||
browsedBuckets = link.Provider == StorageProvider.MinIO
|
||||
? await StorageService.ListS3BucketsAsync(TenantId, link.Id)
|
||||
: await StorageService.ListCleuraS3BucketsAsync(TenantId, link.Id);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -970,6 +1137,18 @@ else
|
||||
browseError = null;
|
||||
}
|
||||
|
||||
// ── File Browser ──
|
||||
|
||||
private void OpenFileBrowser(StorageLink link)
|
||||
{
|
||||
fileBrowserLink = link;
|
||||
}
|
||||
|
||||
private void CloseFileBrowser()
|
||||
{
|
||||
fileBrowserLink = null;
|
||||
}
|
||||
|
||||
private async Task LoadCors()
|
||||
{
|
||||
if (managedLink is null)
|
||||
@@ -1215,6 +1394,72 @@ else
|
||||
}
|
||||
}
|
||||
|
||||
// ── MinIO Registration ──
|
||||
|
||||
private void ShowRegisterForm(MinioBucketInfo instance)
|
||||
{
|
||||
registeringInstance = instance;
|
||||
regFormName = instance.Name;
|
||||
regFormEndpoint = ""; // leave blank so the user provides an externally accessible URL
|
||||
regFormAccessKey = "";
|
||||
regFormSecretKey = "";
|
||||
regFormError = null;
|
||||
}
|
||||
|
||||
private void CancelRegisterForm()
|
||||
{
|
||||
registeringInstance = null;
|
||||
regFormError = null;
|
||||
}
|
||||
|
||||
private async Task RegisterMinioTenant(MinioBucketInfo instance)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(regFormName) || string.IsNullOrWhiteSpace(regFormEndpoint)
|
||||
|| string.IsNullOrWhiteSpace(regFormAccessKey) || string.IsNullOrWhiteSpace(regFormSecretKey))
|
||||
{
|
||||
regFormError = "Name, endpoint URL, access key and secret key are all required.";
|
||||
return;
|
||||
}
|
||||
|
||||
registering = true;
|
||||
regFormError = null;
|
||||
StateHasChanged();
|
||||
|
||||
try
|
||||
{
|
||||
// Use the environment from the cluster if we can match it; fall back to the first environment.
|
||||
Guid envId = environments.FirstOrDefault(e => e.Name == instance.EnvironmentName)?.Id
|
||||
?? (environments.Count > 0 ? environments[0].Id : Guid.Empty);
|
||||
|
||||
await StorageService.RegisterMinioTenantAsync(
|
||||
TenantId,
|
||||
envId,
|
||||
instance.ClusterId,
|
||||
regFormName.Trim(),
|
||||
regFormEndpoint.Trim(),
|
||||
regFormAccessKey.Trim(),
|
||||
regFormSecretKey.Trim(),
|
||||
notes: null);
|
||||
|
||||
registeringInstance = null;
|
||||
storageLinks = await StorageService.GetStorageLinksAsync(TenantId);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
regFormError = ex.Message;
|
||||
}
|
||||
finally
|
||||
{
|
||||
registering = false;
|
||||
}
|
||||
}
|
||||
|
||||
private bool IsMinioRegistered(MinioBucketInfo instance) =>
|
||||
storageLinks.Any(l =>
|
||||
l.Provider == StorageProvider.MinIO &&
|
||||
l.Component?.ClusterId == instance.ClusterId &&
|
||||
string.Equals(l.Endpoint, instance.Endpoint, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
// ── Refresh + Helpers ──
|
||||
|
||||
private async Task RefreshAll()
|
||||
@@ -1293,23 +1538,28 @@ else
|
||||
_ => provider.ToString()
|
||||
};
|
||||
|
||||
private static RenderFragment GetMinioStatusBadge(string? status) => (status ?? "").ToLowerInvariant() switch
|
||||
private static RenderFragment GetMinioStatusBadge(MinioBucketInfo instance)
|
||||
{
|
||||
"initialized" or "running" or "ready" => __builder =>
|
||||
{
|
||||
<span class="badge bg-success"><i class="bi bi-check-circle me-1"></i>Ready</span>
|
||||
},
|
||||
"provisioning" or "waiting" => __builder =>
|
||||
{
|
||||
<span class="badge bg-warning text-dark"><i class="bi bi-hourglass me-1"></i>Provisioning</span>
|
||||
},
|
||||
"error" or "failed" => __builder =>
|
||||
{
|
||||
<span class="badge bg-danger"><i class="bi bi-x-circle me-1"></i>Failed</span>
|
||||
},
|
||||
_ => __builder =>
|
||||
{
|
||||
<span class="badge bg-secondary">@status</span>
|
||||
}
|
||||
};
|
||||
// healthStatus ("green"/"yellow"/"red") is the operator's authoritative health signal.
|
||||
// Fall back to Contains matching on currentState for operators that don't set it.
|
||||
string h = (instance.HealthStatus ?? "").ToLowerInvariant();
|
||||
string s = (instance.Status ?? "").ToLowerInvariant();
|
||||
|
||||
bool isReady = h == "green"
|
||||
|| (h == "" && (s.Contains("initialized") || s.Contains("ready") || s.Contains("running")));
|
||||
|
||||
bool isFailed = h == "red"
|
||||
|| (h == "" && (s.Contains("error") || s.Contains("failed")));
|
||||
|
||||
if (isReady)
|
||||
return __builder => { <span class="badge bg-success"><i class="bi bi-check-circle me-1"></i>Ready</span> };
|
||||
|
||||
if (isFailed)
|
||||
return __builder => { <span class="badge bg-danger"><i class="bi bi-x-circle me-1"></i>Failed</span> };
|
||||
|
||||
if (h == "yellow" || s.Contains("provisioning") || s.Contains("waiting") || s.Contains("certification"))
|
||||
return __builder => { <span class="badge bg-warning text-dark"><i class="bi bi-hourglass me-1"></i>Provisioning</span> };
|
||||
|
||||
return __builder => { <span class="badge bg-secondary">@instance.Status</span> };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
@using Microsoft.EntityFrameworkCore
|
||||
@rendermode InteractiveServer
|
||||
@inject TenantService TenantService
|
||||
@inject UserAccessService UserAccessService
|
||||
@inject AuthenticationStateProvider AuthStateProvider
|
||||
@inject NavigationManager Navigation
|
||||
|
||||
<PageTitle>@(tenant?.Name ?? "Tenant")</PageTitle>
|
||||
@@ -68,6 +70,56 @@ else
|
||||
<i class="bi bi-person-badge me-1"></i>Identity
|
||||
</button>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<button class="nav-link @(activeTab == "registry" ? "active" : "")" @onclick='() => activeTab = "registry"'>
|
||||
<i class="bi bi-archive me-1"></i>Registry
|
||||
</button>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<button class="nav-link @(activeTab == "messaging" ? "active" : "")" @onclick='() => activeTab = "messaging"'>
|
||||
<i class="bi bi-collection me-1"></i>Messaging
|
||||
</button>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<button class="nav-link @(activeTab == "cache" ? "active" : "")" @onclick='() => activeTab = "cache"'>
|
||||
<i class="bi bi-lightning-charge me-1"></i>Cache
|
||||
</button>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<button class="nav-link @(activeTab == "overview" ? "active" : "")" @onclick='() => activeTab = "overview"'>
|
||||
<i class="bi bi-speedometer2 me-1"></i>Overview
|
||||
</button>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<button class="nav-link @(activeTab == "incidents" ? "active" : "")" @onclick='() => activeTab = "incidents"'>
|
||||
<i class="bi bi-bell-fill me-1"></i>Incidents
|
||||
</button>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<button class="nav-link @(activeTab == "notifications" ? "active" : "")" @onclick='() => activeTab = "notifications"'>
|
||||
<i class="bi bi-send-fill me-1"></i>Notifications
|
||||
</button>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<button class="nav-link @(activeTab == "maintenance" ? "active" : "")" @onclick='() => activeTab = "maintenance"'>
|
||||
<i class="bi bi-tools me-1"></i>Maintenance
|
||||
</button>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<button class="nav-link @(activeTab == "sla" ? "active" : "")" @onclick='() => activeTab = "sla"'>
|
||||
<i class="bi bi-shield-check me-1"></i>SLA
|
||||
</button>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<button class="nav-link @(activeTab == "networking" ? "active" : "")" @onclick='() => activeTab = "networking"'>
|
||||
<i class="bi bi-shield-lock me-1"></i>Networking
|
||||
</button>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<button class="nav-link @(activeTab == "git" ? "active" : "")" @onclick='() => activeTab = "git"'>
|
||||
<i class="bi bi-git me-1"></i>Git
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div class="tab-content">
|
||||
@@ -94,6 +146,36 @@ else
|
||||
case "identity":
|
||||
<IdentityTab TenantId="tenant.Id" />
|
||||
break;
|
||||
case "registry":
|
||||
<RegistryTab TenantId="tenant.Id" />
|
||||
break;
|
||||
case "messaging":
|
||||
<MessagingTab TenantId="tenant.Id" />
|
||||
break;
|
||||
case "cache":
|
||||
<CacheTab TenantId="tenant.Id" />
|
||||
break;
|
||||
case "overview":
|
||||
<TenantMonitoringOverview TenantId="tenant.Id" TenantSlug="tenant.Slug" />
|
||||
break;
|
||||
case "incidents":
|
||||
<IncidentManagement TenantId="tenant.Id" />
|
||||
break;
|
||||
case "notifications":
|
||||
<NotificationChannels TenantId="tenant.Id" />
|
||||
break;
|
||||
case "maintenance":
|
||||
<MaintenanceWindows TenantId="tenant.Id" />
|
||||
break;
|
||||
case "sla":
|
||||
<SlaTargetManager TenantId="tenant.Id" />
|
||||
break;
|
||||
case "networking":
|
||||
<NetworkingTab TenantId="tenant.Id" />
|
||||
break;
|
||||
case "git":
|
||||
<GitReposTab TenantId="tenant.Id" />
|
||||
break;
|
||||
}
|
||||
</div>
|
||||
}
|
||||
@@ -106,11 +188,35 @@ else
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
AuthenticationState authState = await AuthStateProvider.GetAuthenticationStateAsync();
|
||||
System.Security.Claims.ClaimsPrincipal user = authState.User;
|
||||
|
||||
if (user.Identity?.IsAuthenticated != true)
|
||||
{
|
||||
Navigation.NavigateTo("/Account/Login", forceLoad: true);
|
||||
return;
|
||||
}
|
||||
|
||||
string? userId = user.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value;
|
||||
if (userId is null)
|
||||
{
|
||||
Navigation.NavigateTo("/Account/Login", forceLoad: true);
|
||||
return;
|
||||
}
|
||||
|
||||
tenant = await TenantService.GetTenantBySlugAsync(Slug);
|
||||
|
||||
if (tenant is null)
|
||||
{
|
||||
Navigation.NavigateTo("/tenants");
|
||||
return;
|
||||
}
|
||||
|
||||
bool canAccess = await UserAccessService.HasTenantAccessAsync(userId, tenant.Id);
|
||||
if (!canAccess)
|
||||
{
|
||||
bool hasMembership = await UserAccessService.HasAnyTenantMembershipAsync(userId);
|
||||
Navigation.NavigateTo(hasMembership ? "/tenants" : "/portal");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,96 +4,114 @@
|
||||
@using Microsoft.EntityFrameworkCore
|
||||
@rendermode InteractiveServer
|
||||
@inject TenantService TenantService
|
||||
@inject UserAccessService UserAccessService
|
||||
@inject AuthenticationStateProvider AuthStateProvider
|
||||
@inject NavigationManager Navigation
|
||||
|
||||
<PageTitle>Tenants</PageTitle>
|
||||
|
||||
<div class="d-flex align-items-center justify-content-between mb-4">
|
||||
<div>
|
||||
<h1 class="mb-0"><i class="bi bi-building me-2 text-primary"></i>Tenants</h1>
|
||||
<p class="text-muted small mb-0 mt-1">Organizations and workspaces in your platform.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card border-0 shadow-sm mb-4">
|
||||
<div class="card-body py-2">
|
||||
<div class="input-group">
|
||||
<span class="input-group-text bg-transparent border-end-0"><i class="bi bi-plus-circle"></i></span>
|
||||
<input type="text" class="form-control border-start-0" placeholder="New tenant name (e.g. Acme Corp)"
|
||||
@bind="newTenantName" @bind:event="oninput"
|
||||
@onkeydown="@(async (e) => { if (e.Key == "Enter" && !string.IsNullOrWhiteSpace(newTenantName)) await CreateTenant(); })" />
|
||||
<button class="btn btn-primary" @onclick="CreateTenant" disabled="@string.IsNullOrWhiteSpace(newTenantName)">
|
||||
<i class="bi bi-plus-lg me-1"></i>Create Tenant
|
||||
</button>
|
||||
</div>
|
||||
@if (!string.IsNullOrEmpty(errorMessage))
|
||||
{
|
||||
<div class="text-danger small mt-2"><i class="bi bi-exclamation-triangle me-1"></i>@errorMessage</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (tenants is null)
|
||||
@if (!loaded)
|
||||
{
|
||||
<div class="text-center py-5">
|
||||
<div class="spinner-border text-primary" role="status"></div>
|
||||
<p class="text-muted mt-2">Loading tenants...</p>
|
||||
</div>
|
||||
}
|
||||
else if (tenants.Count == 0)
|
||||
{
|
||||
<div class="text-center py-5">
|
||||
<i class="bi bi-building text-muted" style="font-size: 4rem;"></i>
|
||||
<h5 class="text-muted mt-3">No tenants yet</h5>
|
||||
<p class="text-muted">Create your first tenant above to get started.</p>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="row row-cols-1 row-cols-md-2 row-cols-lg-3 g-3">
|
||||
@foreach (Tenant tenant in tenants)
|
||||
{
|
||||
<div class="col">
|
||||
<div class="card h-100 shadow-sm border-0 tenant-card">
|
||||
<div class="card-body">
|
||||
<div class="d-flex align-items-start justify-content-between">
|
||||
<div>
|
||||
<h5 class="card-title mb-1">
|
||||
<i class="bi bi-building me-1 text-primary"></i>@tenant.Name
|
||||
</h5>
|
||||
<p class="card-text text-muted small mb-0">
|
||||
<code>@tenant.Slug</code>
|
||||
</p>
|
||||
</div>
|
||||
<button class="btn btn-sm btn-outline-danger" @onclick="() => confirmDeleteId = tenant.Id"
|
||||
title="Delete tenant">
|
||||
<i class="bi bi-trash"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="d-flex align-items-center justify-content-between mb-4">
|
||||
<div>
|
||||
<h1 class="mb-0"><i class="bi bi-building me-2 text-primary"></i>Tenants</h1>
|
||||
<p class="text-muted small mb-0 mt-1">Organizations and workspaces in your platform.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (confirmDeleteId == tenant.Id)
|
||||
{
|
||||
<div class="card-body py-2 border-top bg-danger bg-opacity-10">
|
||||
<div class="d-flex align-items-center justify-content-between">
|
||||
<small class="text-danger"><i class="bi bi-exclamation-triangle me-1"></i>Delete this tenant?</small>
|
||||
@if (isAdmin)
|
||||
{
|
||||
<div class="card border-0 shadow-sm mb-4">
|
||||
<div class="card-body py-2">
|
||||
<div class="input-group">
|
||||
<span class="input-group-text bg-transparent border-end-0"><i class="bi bi-plus-circle"></i></span>
|
||||
<input type="text" class="form-control border-start-0" placeholder="New tenant name (e.g. Acme Corp)"
|
||||
@bind="newTenantName" @bind:event="oninput"
|
||||
@onkeydown="@(async (e) => { if (e.Key == "Enter" && !string.IsNullOrWhiteSpace(newTenantName)) await CreateTenant(); })" />
|
||||
<button class="btn btn-primary" @onclick="CreateTenant" disabled="@string.IsNullOrWhiteSpace(newTenantName)">
|
||||
<i class="bi bi-plus-lg me-1"></i>Create Tenant
|
||||
</button>
|
||||
</div>
|
||||
@if (!string.IsNullOrEmpty(errorMessage))
|
||||
{
|
||||
<div class="text-danger small mt-2"><i class="bi bi-exclamation-triangle me-1"></i>@errorMessage</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (tenants is null || tenants.Count == 0)
|
||||
{
|
||||
<div class="text-center py-5">
|
||||
<i class="bi bi-building text-muted" style="font-size: 4rem;"></i>
|
||||
<h5 class="text-muted mt-3">No tenants yet</h5>
|
||||
@if (isAdmin)
|
||||
{
|
||||
<p class="text-muted">Create your first tenant above to get started.</p>
|
||||
}
|
||||
else
|
||||
{
|
||||
<p class="text-muted">You have not been added to any tenants yet.</p>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="row row-cols-1 row-cols-md-2 row-cols-lg-3 g-3">
|
||||
@foreach (Tenant tenant in tenants)
|
||||
{
|
||||
<div class="col">
|
||||
<div class="card h-100 shadow-sm border-0 tenant-card">
|
||||
<div class="card-body">
|
||||
<div class="d-flex align-items-start justify-content-between">
|
||||
<div>
|
||||
<button class="btn btn-sm btn-danger me-1" @onclick="() => DeleteTenant(tenant.Id)">Delete</button>
|
||||
<button class="btn btn-sm btn-outline-secondary" @onclick="() => confirmDeleteId = null">Cancel</button>
|
||||
<h5 class="card-title mb-1">
|
||||
<i class="bi bi-building me-1 text-primary"></i>@tenant.Name
|
||||
</h5>
|
||||
<p class="card-text text-muted small mb-0">
|
||||
<code>@tenant.Slug</code>
|
||||
</p>
|
||||
</div>
|
||||
@if (isAdmin)
|
||||
{
|
||||
<button class="btn btn-sm btn-outline-danger" @onclick="() => confirmDeleteId = tenant.Id"
|
||||
title="Delete tenant">
|
||||
<i class="bi bi-trash"></i>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (isAdmin && confirmDeleteId == tenant.Id)
|
||||
{
|
||||
<div class="card-body py-2 border-top bg-danger bg-opacity-10">
|
||||
<div class="d-flex align-items-center justify-content-between">
|
||||
<small class="text-danger"><i class="bi bi-exclamation-triangle me-1"></i>Delete this tenant?</small>
|
||||
<div>
|
||||
<button class="btn btn-sm btn-danger me-1" @onclick="() => DeleteTenant(tenant.Id)">Delete</button>
|
||||
<button class="btn btn-sm btn-outline-secondary" @onclick="() => confirmDeleteId = null">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
<div class="card-footer bg-transparent border-top-0 pt-0">
|
||||
<a href="/tenants/@tenant.Slug" class="btn btn-primary btn-sm w-100">
|
||||
<i class="bi bi-gear me-1"></i>Manage
|
||||
</a>
|
||||
<div class="card-footer bg-transparent border-top-0 pt-0">
|
||||
<a href="/tenants/@tenant.Slug" class="btn btn-primary btn-sm w-100">
|
||||
<i class="bi bi-gear me-1"></i>Manage
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
@code {
|
||||
@@ -101,19 +119,50 @@ else
|
||||
private string newTenantName = "";
|
||||
private string? errorMessage;
|
||||
private Guid? confirmDeleteId;
|
||||
private bool isAdmin;
|
||||
private bool loaded;
|
||||
private string? userId;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
AuthenticationState authState = await AuthStateProvider.GetAuthenticationStateAsync();
|
||||
System.Security.Claims.ClaimsPrincipal user = authState.User;
|
||||
|
||||
if (user.Identity?.IsAuthenticated != true)
|
||||
{
|
||||
Navigation.NavigateTo("/Account/Login", forceLoad: true);
|
||||
return;
|
||||
}
|
||||
|
||||
userId = user.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value;
|
||||
if (userId is null)
|
||||
{
|
||||
Navigation.NavigateTo("/Account/Login", forceLoad: true);
|
||||
return;
|
||||
}
|
||||
|
||||
isAdmin = await UserAccessService.IsGlobalAdminAsync(userId);
|
||||
bool hasMembership = isAdmin || await UserAccessService.HasAnyTenantMembershipAsync(userId);
|
||||
|
||||
if (!hasMembership)
|
||||
{
|
||||
Navigation.NavigateTo("/portal");
|
||||
return;
|
||||
}
|
||||
|
||||
await LoadTenants();
|
||||
loaded = true;
|
||||
}
|
||||
|
||||
private async Task LoadTenants()
|
||||
{
|
||||
tenants = await TenantService.GetAllTenantsAsync();
|
||||
if (userId is null) return;
|
||||
tenants = await UserAccessService.GetAccessibleTenantsAsync(userId);
|
||||
}
|
||||
|
||||
private async Task CreateTenant()
|
||||
{
|
||||
if (!isAdmin) return;
|
||||
errorMessage = null;
|
||||
|
||||
try
|
||||
@@ -130,8 +179,9 @@ else
|
||||
|
||||
private async Task DeleteTenant(Guid id)
|
||||
{
|
||||
if (!isAdmin) return;
|
||||
confirmDeleteId = null;
|
||||
await TenantService.DeleteTenantAsync(id);
|
||||
await LoadTenants();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
@using EntKube.Web.Data
|
||||
@using EntKube.Web.Services
|
||||
@using Microsoft.EntityFrameworkCore
|
||||
@inject PrometheusService PrometheusService
|
||||
@inject IncidentService IncidentService
|
||||
@inject IDbContextFactory<ApplicationDbContext> DbFactory
|
||||
@implements IAsyncDisposable
|
||||
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<div>
|
||||
@if (lastRefreshed.HasValue)
|
||||
{
|
||||
<span class="text-muted small">Last refreshed: @lastRefreshed.Value.ToLocalTime().ToString("HH:mm:ss")</span>
|
||||
}
|
||||
</div>
|
||||
<button class="btn btn-sm btn-outline-secondary" @onclick="Refresh" disabled="@isLoading">
|
||||
<span class="bi bi-arrow-clockwise @(isLoading ? "spin" : "")"></span> Refresh
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@if (clusterSummaries.Count > 0)
|
||||
{
|
||||
<div class="row g-2 mb-4">
|
||||
<div class="col-auto">
|
||||
<div class="card text-center border-0 bg-light px-3 py-2">
|
||||
<div class="fw-bold fs-4">@clusterSummaries.Count</div>
|
||||
<div class="text-muted small">Clusters</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<div class="card text-center border-0 bg-light px-3 py-2">
|
||||
<div class="fw-bold fs-4">@clusterSummaries.Sum(s => s.Health?.TotalNodes ?? 0)</div>
|
||||
<div class="text-muted small">Nodes</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<div class="card text-center border-0 bg-light px-3 py-2">
|
||||
<div class="fw-bold fs-4">@clusterSummaries.Sum(s => s.Health?.RunningPods ?? 0)</div>
|
||||
<div class="text-muted small">Running Pods</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<div class="card text-center border-0 bg-light px-3 py-2">
|
||||
<div class="fw-bold fs-4 @(tenantActiveAlerts > 0 ? "text-danger" : "text-success")">@tenantActiveAlerts</div>
|
||||
<div class="text-muted small">Active Alerts</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="row g-3">
|
||||
@foreach (ClusterSummaryVm vm in clusterSummaries)
|
||||
{
|
||||
<div class="col-12 col-md-6 col-xl-4">
|
||||
<div class="card h-100 border-@ClusterBorderColor(vm)">
|
||||
<div class="card-header d-flex align-items-center gap-2 py-2">
|
||||
<span class="bi bi-hdd-rack-fill text-@ClusterBorderColor(vm)"></span>
|
||||
<strong class="me-auto">@vm.Cluster.Name</strong>
|
||||
@if (vm.ActiveAlerts > 0)
|
||||
{
|
||||
<span class="badge bg-danger">@vm.ActiveAlerts alert@(vm.ActiveAlerts == 1 ? "" : "s")</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="badge bg-success">Healthy</span>
|
||||
}
|
||||
</div>
|
||||
<div class="card-body py-2">
|
||||
@if (vm.Health is null)
|
||||
{
|
||||
<div class="text-muted small text-center py-2">
|
||||
<span class="bi bi-exclamation-triangle me-1"></span>
|
||||
@(vm.Error ?? "Prometheus not available")
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="row g-2 mb-2">
|
||||
<div class="col-6">
|
||||
<div class="small text-muted">CPU</div>
|
||||
<div class="progress" style="height:8px">
|
||||
<div class="progress-bar bg-@UtilColor(vm.Health.CpuUsagePercent)"
|
||||
style="width:@((int)Math.Min(vm.Health.CpuUsagePercent,100))%"></div>
|
||||
</div>
|
||||
<div class="small fw-semibold">@vm.Health.CpuUsagePercent.ToString("F1")%</div>
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<div class="small text-muted">Memory</div>
|
||||
<div class="progress" style="height:8px">
|
||||
<div class="progress-bar bg-@UtilColor(vm.Health.MemoryUsagePercent)"
|
||||
style="width:@((int)Math.Min(vm.Health.MemoryUsagePercent,100))%"></div>
|
||||
</div>
|
||||
<div class="small fw-semibold">@vm.Health.MemoryUsagePercent.ToString("F1")%</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="small text-muted">
|
||||
<span class="bi bi-server me-1"></span>@vm.Health.ReadyNodes/@vm.Health.TotalNodes nodes ready
|
||||
·
|
||||
<span class="bi bi-box me-1"></span>@vm.Health.RunningPods pods running
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<div class="card-footer py-1 bg-transparent border-0">
|
||||
<a href="tenants/@TenantSlug/clusters/@vm.Cluster.Id/monitoring" class="btn btn-sm btn-link p-0 text-decoration-none">
|
||||
<span class="bi bi-graph-up me-1"></span>View monitoring
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@code {
|
||||
[Parameter] public required Guid TenantId { get; set; }
|
||||
[Parameter] public required string TenantSlug { get; set; }
|
||||
|
||||
private List<ClusterSummaryVm> clusterSummaries = [];
|
||||
private int tenantActiveAlerts;
|
||||
private DateTime? lastRefreshed;
|
||||
private bool isLoading;
|
||||
private Timer? refreshTimer;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
await Refresh();
|
||||
refreshTimer = new Timer(_ => InvokeAsync(async () =>
|
||||
{
|
||||
await Refresh();
|
||||
StateHasChanged();
|
||||
}), null, TimeSpan.FromMinutes(1), TimeSpan.FromMinutes(1));
|
||||
}
|
||||
|
||||
private async Task Refresh()
|
||||
{
|
||||
isLoading = true;
|
||||
StateHasChanged();
|
||||
|
||||
List<KubernetesCluster> clusters;
|
||||
using (ApplicationDbContext db = DbFactory.CreateDbContext())
|
||||
{
|
||||
clusters = await db.KubernetesClusters
|
||||
.Include(c => c.Components)
|
||||
.Where(c => c.TenantId == TenantId)
|
||||
.OrderBy(c => c.Name)
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
tenantActiveAlerts = await IncidentService.GetActiveAlertCountForTenantAsync(TenantId);
|
||||
|
||||
List<Task<ClusterSummaryVm>> tasks = clusters
|
||||
.Select(c => LoadClusterSummaryAsync(c))
|
||||
.ToList();
|
||||
|
||||
clusterSummaries = [.. await Task.WhenAll(tasks)];
|
||||
|
||||
lastRefreshed = DateTime.UtcNow;
|
||||
isLoading = false;
|
||||
}
|
||||
|
||||
private async Task<ClusterSummaryVm> LoadClusterSummaryAsync(KubernetesCluster cluster)
|
||||
{
|
||||
bool hasPrometheus = cluster.Components.Any(c =>
|
||||
c.HelmChartName == "kube-prometheus-stack" && c.Status == ComponentStatus.Installed);
|
||||
|
||||
if (!hasPrometheus)
|
||||
return new ClusterSummaryVm(cluster, null, null, 0);
|
||||
|
||||
KubernetesOperationResult<ClusterHealthSummary> result =
|
||||
await PrometheusService.GetClusterHealthAsync(cluster.Id);
|
||||
|
||||
return new ClusterSummaryVm(
|
||||
cluster,
|
||||
result.IsSuccess ? result.Data : null,
|
||||
result.IsSuccess ? null : result.Error,
|
||||
0);
|
||||
}
|
||||
|
||||
private static string UtilColor(double pct) =>
|
||||
pct >= 90 ? "danger" : pct >= 70 ? "warning" : "success";
|
||||
|
||||
private static string ClusterBorderColor(ClusterSummaryVm vm)
|
||||
{
|
||||
if (vm.ActiveAlerts > 0) return "danger";
|
||||
if (vm.Health is null) return "secondary";
|
||||
if (vm.Health.CpuUsagePercent >= 90 || vm.Health.MemoryUsagePercent >= 90) return "warning";
|
||||
return "success";
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (refreshTimer is not null)
|
||||
await refreshTimer.DisposeAsync();
|
||||
}
|
||||
|
||||
private record ClusterSummaryVm(
|
||||
KubernetesCluster Cluster,
|
||||
ClusterHealthSummary? Health,
|
||||
string? Error,
|
||||
int ActiveAlerts);
|
||||
}
|
||||
@@ -1,9 +1,11 @@
|
||||
@using EntKube.Web.Data
|
||||
@using EntKube.Web.Services
|
||||
@using Microsoft.EntityFrameworkCore
|
||||
@using EntKube.Web.Components.Pages.Shared
|
||||
@inject VaultService VaultService
|
||||
@inject TenantService TenantService
|
||||
@inject StorageService StorageService
|
||||
@inject CnpgService CnpgService
|
||||
|
||||
<div class="d-flex align-items-center mb-2">
|
||||
<i class="bi bi-shield-lock fs-4 me-2 text-primary"></i>
|
||||
@@ -39,6 +41,12 @@ else
|
||||
<button class="btn btn-sm @(scope == "storage" ? "btn-primary" : "btn-outline-primary")" @onclick='() => SwitchScope("storage")'>
|
||||
<i class="bi bi-hdd me-1"></i>Storage Secrets
|
||||
</button>
|
||||
<button class="btn btn-sm @(scope == "cnpg" ? "btn-primary" : "btn-outline-primary")" @onclick='() => SwitchScope("cnpg")'>
|
||||
<i class="bi bi-database me-1"></i>Database Secrets
|
||||
</button>
|
||||
<button class="btn btn-sm @(scope == "docker" ? "btn-primary" : "btn-outline-primary")" @onclick='() => SwitchScope("docker")'>
|
||||
<i class="bi bi-box-seam me-1"></i>Docker Registries
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -125,6 +133,43 @@ else
|
||||
}
|
||||
}
|
||||
|
||||
else if (scope == "cnpg")
|
||||
{
|
||||
@if (cnpgClusterOptions is not null && cnpgClusterOptions.Count > 0)
|
||||
{
|
||||
<div class="card border-0 shadow-sm mb-3">
|
||||
<div class="card-body py-2">
|
||||
<div class="input-group">
|
||||
<span class="input-group-text bg-transparent"><i class="bi bi-database"></i></span>
|
||||
<select class="form-select" @bind="selectedCnpgClusterId" @bind:after="LoadSecrets">
|
||||
<option value="@Guid.Empty">Select a CNPG cluster...</option>
|
||||
@foreach ((string label, Guid id) in cnpgClusterOptions)
|
||||
{
|
||||
<option value="@id">@label</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="text-center py-4">
|
||||
<i class="bi bi-database text-muted" style="font-size: 2rem;"></i>
|
||||
<p class="text-muted small mt-2 mb-0">No CNPG clusters found for this tenant.</p>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
@* --- Docker Registries Panel --- *@
|
||||
@if (scope == "docker")
|
||||
{
|
||||
<DockerRegistriesPanel
|
||||
TenantId="TenantId"
|
||||
AppId="Guid.Empty"
|
||||
IsAdmin="true" />
|
||||
}
|
||||
|
||||
@* --- Secrets Panel --- *@
|
||||
@if (HasSelection())
|
||||
{
|
||||
@@ -132,6 +177,14 @@ else
|
||||
<div class="card-header bg-white d-flex align-items-center py-2">
|
||||
<i class="bi bi-lock me-2 text-primary"></i>
|
||||
<strong>Secrets</strong>
|
||||
@if (scope == "app" && secrets?.Any(s => s.SyncToKubernetes) == true)
|
||||
{
|
||||
<button class="btn btn-sm btn-outline-success ms-auto" @onclick="SyncAllAppSecrets" disabled="@syncingSecrets">
|
||||
@if (syncingSecrets) { <span class="spinner-border spinner-border-sm me-1"></span> }
|
||||
else { <i class="bi bi-cloud-arrow-up me-1"></i> }
|
||||
Sync All to K8s
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
<div class="card-body">
|
||||
@* Add secret form *@
|
||||
@@ -193,6 +246,10 @@ else
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
@if (scope == "cnpg")
|
||||
{
|
||||
<th>Scope</th>
|
||||
}
|
||||
<th>K8s Sync</th>
|
||||
<th>K8s Secret</th>
|
||||
<th>Namespace</th>
|
||||
@@ -205,6 +262,19 @@ else
|
||||
{
|
||||
<tr>
|
||||
<td><code>@secret.Name</code></td>
|
||||
@if (scope == "cnpg")
|
||||
{
|
||||
<td>
|
||||
@if (secret.CnpgDatabase is not null)
|
||||
{
|
||||
<span class="badge bg-info-subtle text-info border border-info-subtle">@secret.CnpgDatabase.Name</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="badge bg-secondary-subtle text-secondary border border-secondary-subtle">cluster</span>
|
||||
}
|
||||
</td>
|
||||
}
|
||||
<td>
|
||||
@if (secret.SyncToKubernetes)
|
||||
{
|
||||
@@ -278,11 +348,23 @@ else
|
||||
<tr>
|
||||
<td colspan="6" class="bg-light">
|
||||
<div class="row g-2 p-2 align-items-center">
|
||||
<div class="col-md-4">
|
||||
@if (scope == "app" && clusterOptions is not null)
|
||||
{
|
||||
<div class="col-md-3">
|
||||
<select class="form-select form-select-sm" @bind="syncClusterId">
|
||||
<option value="@Guid.Empty">Target cluster…</option>
|
||||
@foreach ((string label, Guid id) in clusterOptions)
|
||||
{
|
||||
<option value="@id">@label</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
}
|
||||
<div class="col-md-3">
|
||||
<input type="text" class="form-control form-control-sm"
|
||||
placeholder="K8s Secret name (e.g. my-app-secrets)" @bind="syncSecretName" />
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="col-md-3">
|
||||
<input type="text" class="form-control form-control-sm"
|
||||
placeholder="Namespace (e.g. default)" @bind="syncNamespace" />
|
||||
</div>
|
||||
@@ -303,6 +385,10 @@ else
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
@if (syncOutput is not null)
|
||||
{
|
||||
<div class="mt-3 p-2 bg-dark text-white rounded font-monospace small" style="white-space: pre-wrap; max-height: 200px; overflow-y: auto;">@syncOutput</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@@ -326,6 +412,13 @@ else
|
||||
private List<(string Label, Guid Id)>? storageLinkOptions;
|
||||
private Guid selectedStorageLinkId;
|
||||
|
||||
// CNPG cluster scope
|
||||
private List<(string Label, Guid Id)>? cnpgClusterOptions;
|
||||
private Guid selectedCnpgClusterId;
|
||||
|
||||
// Cluster options (used in app-scope sync config)
|
||||
private List<(string Label, Guid Id)>? clusterOptions;
|
||||
|
||||
// Secrets
|
||||
private List<VaultSecret>? secrets;
|
||||
private string newSecretName = "";
|
||||
@@ -335,6 +428,11 @@ else
|
||||
private Guid? syncConfigSecretId;
|
||||
private string syncSecretName = "";
|
||||
private string syncNamespace = "";
|
||||
private Guid syncClusterId;
|
||||
|
||||
// Sync-to-K8s state (app scope)
|
||||
private bool syncingSecrets;
|
||||
private string? syncOutput;
|
||||
|
||||
// Reveal/Edit state
|
||||
private Guid? revealedSecretId;
|
||||
@@ -376,6 +474,7 @@ else
|
||||
}
|
||||
|
||||
List<KubernetesCluster> clusters = await TenantService.GetClustersAsync(TenantId);
|
||||
clusterOptions = clusters.Select(c => (c.Name, c.Id)).ToList();
|
||||
componentOptions = [];
|
||||
|
||||
foreach (KubernetesCluster cluster in clusters)
|
||||
@@ -394,6 +493,13 @@ else
|
||||
storageLinkOptions = links
|
||||
.Select(l => ($"{l.Environment.Name} / {l.Name} ({l.Provider})", l.Id))
|
||||
.ToList();
|
||||
|
||||
// Load CNPG clusters for the database secrets scope.
|
||||
|
||||
List<CnpgCluster> cnpgClusters = await CnpgService.GetClustersAsync(TenantId);
|
||||
cnpgClusterOptions = cnpgClusters
|
||||
.Select(c => ($"{c.KubernetesCluster.Name} / {c.Name}", c.Id))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private void SwitchScope(string newScope)
|
||||
@@ -403,15 +509,20 @@ else
|
||||
selectedAppId = Guid.Empty;
|
||||
selectedComponentId = Guid.Empty;
|
||||
selectedStorageLinkId = Guid.Empty;
|
||||
selectedCnpgClusterId = Guid.Empty;
|
||||
errorMessage = null;
|
||||
successMessage = null;
|
||||
syncClusterId = Guid.Empty;
|
||||
syncOutput = null;
|
||||
// Docker registries panel manages its own state; no reset needed here.
|
||||
}
|
||||
|
||||
private bool HasSelection()
|
||||
{
|
||||
return (scope == "app" && selectedAppId != Guid.Empty)
|
||||
|| (scope == "component" && selectedComponentId != Guid.Empty)
|
||||
|| (scope == "storage" && selectedStorageLinkId != Guid.Empty);
|
||||
|| (scope == "storage" && selectedStorageLinkId != Guid.Empty)
|
||||
|| (scope == "cnpg" && selectedCnpgClusterId != Guid.Empty);
|
||||
}
|
||||
|
||||
private async Task LoadSecrets()
|
||||
@@ -431,6 +542,10 @@ else
|
||||
{
|
||||
secrets = await VaultService.GetStorageLinkSecretsAsync(TenantId, selectedStorageLinkId);
|
||||
}
|
||||
else if (scope == "cnpg" && selectedCnpgClusterId != Guid.Empty)
|
||||
{
|
||||
secrets = await VaultService.GetAllCnpgSecretsForClusterAsync(TenantId, selectedCnpgClusterId);
|
||||
}
|
||||
else
|
||||
{
|
||||
secrets = null;
|
||||
@@ -456,6 +571,10 @@ else
|
||||
{
|
||||
await VaultService.SetStorageLinkSecretAsync(TenantId, selectedStorageLinkId, newSecretName.Trim(), newSecretValue.Trim());
|
||||
}
|
||||
else if (scope == "cnpg")
|
||||
{
|
||||
await VaultService.SetCnpgClusterSecretAsync(TenantId, selectedCnpgClusterId, newSecretName.Trim(), newSecretValue.Trim());
|
||||
}
|
||||
|
||||
successMessage = $"Secret '{newSecretName.Trim()}' saved.";
|
||||
newSecretName = "";
|
||||
@@ -555,6 +674,7 @@ else
|
||||
syncConfigSecretId = secret.Id;
|
||||
syncSecretName = secret.KubernetesSecretName ?? "";
|
||||
syncNamespace = secret.KubernetesNamespace ?? "";
|
||||
syncClusterId = secret.KubernetesClusterId ?? Guid.Empty;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -571,13 +691,34 @@ else
|
||||
return;
|
||||
}
|
||||
|
||||
Guid? clusterId = (scope == "app" && syncClusterId != Guid.Empty) ? syncClusterId : (Guid?)null;
|
||||
|
||||
await VaultService.ConfigureKubernetesSyncAsync(
|
||||
syncConfigSecretId.Value,
|
||||
syncEnabled: true,
|
||||
secretName: syncSecretName.Trim(),
|
||||
ns: syncNamespace.Trim());
|
||||
ns: syncNamespace.Trim(),
|
||||
clusterId: clusterId);
|
||||
|
||||
syncConfigSecretId = null;
|
||||
await LoadSecrets();
|
||||
}
|
||||
|
||||
private async Task SyncAllAppSecrets()
|
||||
{
|
||||
if (selectedAppId == Guid.Empty) return;
|
||||
|
||||
syncingSecrets = true;
|
||||
syncOutput = null;
|
||||
|
||||
try
|
||||
{
|
||||
HelmExecutionResult result = await VaultService.SyncAppSecretsToKubernetesAsync(TenantId, selectedAppId);
|
||||
syncOutput = result.Output;
|
||||
}
|
||||
finally
|
||||
{
|
||||
syncingSecrets = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -11,3 +11,4 @@
|
||||
@using EntKube.Web.Client
|
||||
@using EntKube.Web.Components
|
||||
@using EntKube.Web.Components.Layout
|
||||
@using EntKube.Web.Components.Pages.Shared
|
||||
|
||||
27
src/EntKube.Web/Data/AlertIncident.cs
Normal file
27
src/EntKube.Web/Data/AlertIncident.cs
Normal file
@@ -0,0 +1,27 @@
|
||||
namespace EntKube.Web.Data;
|
||||
|
||||
public enum IncidentStatus { Active, Acknowledged, Resolved }
|
||||
|
||||
public class AlertIncident
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public Guid ClusterId { get; set; }
|
||||
public required string Fingerprint { get; set; }
|
||||
public required string AlertName { get; set; }
|
||||
public required string Severity { get; set; }
|
||||
public string Summary { get; set; } = "";
|
||||
public string Description { get; set; } = "";
|
||||
public string LabelsJson { get; set; } = "{}";
|
||||
public DateTime StartsAt { get; set; }
|
||||
public DateTime? EndsAt { get; set; }
|
||||
public IncidentStatus Status { get; set; } = IncidentStatus.Active;
|
||||
public string? AcknowledgedBy { get; set; }
|
||||
public DateTime? AcknowledgedAt { get; set; }
|
||||
public DateTime? ResolvedAt { get; set; }
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
|
||||
|
||||
public KubernetesCluster Cluster { get; set; } = null!;
|
||||
public List<IncidentNote> Notes { get; set; } = [];
|
||||
public List<NotificationDelivery> Deliveries { get; set; } = [];
|
||||
}
|
||||
@@ -15,6 +15,13 @@ public class App
|
||||
/// </summary>
|
||||
public required string Name { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The primary Kubernetes namespace for this app. Used as the target for
|
||||
/// ResourceQuota, NetworkPolicy, and RBAC resources. Set by tenant admins;
|
||||
/// read-only in the customer portal. Null means no namespace has been assigned.
|
||||
/// </summary>
|
||||
public string? Namespace { get; set; }
|
||||
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
|
||||
// Navigation
|
||||
@@ -22,4 +29,7 @@ public class App
|
||||
public ICollection<AppEnvironment> AppEnvironments { get; set; } = [];
|
||||
public ICollection<VaultSecret> Secrets { get; set; } = [];
|
||||
public ICollection<AppDeployment> Deployments { get; set; } = [];
|
||||
public AppQuota? Quota { get; set; }
|
||||
public ICollection<AppNetworkPolicy> NetworkPolicies { get; set; } = [];
|
||||
public AppRbacPolicy? RbacPolicy { get; set; }
|
||||
}
|
||||
|
||||
@@ -79,12 +79,57 @@ public class AppDeployment
|
||||
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
|
||||
// ── Git source fields (only used when Type is GitYaml, GitHelm, or GitAppOfApps) ──
|
||||
|
||||
/// <summary>
|
||||
/// The registered GitRepository to sync from.
|
||||
/// </summary>
|
||||
public Guid? GitRepositoryId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Path within the repository to the manifest directory, Helm chart root,
|
||||
/// or app-of-apps directory. Use "." for the repo root.
|
||||
/// </summary>
|
||||
public string? GitPath { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Branch, tag, or commit SHA to check out. Defaults to the repository's
|
||||
/// DefaultBranch when null.
|
||||
/// </summary>
|
||||
public string? GitRevision { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// SHA of the commit that was last successfully synced from Git.
|
||||
/// </summary>
|
||||
public string? GitLastSyncedCommit { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// When the last successful Git sync completed.
|
||||
/// </summary>
|
||||
public DateTime? GitLastSyncedAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// When true the background GitSyncService will poll this deployment
|
||||
/// and sync whenever a new commit is detected.
|
||||
/// </summary>
|
||||
public bool GitAutoSync { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// For GitAppOfApps child deployments: the parent deployment that manages
|
||||
/// this deployment's lifecycle. Null for top-level deployments.
|
||||
/// </summary>
|
||||
public Guid? ParentDeploymentId { get; set; }
|
||||
|
||||
// Navigation
|
||||
public App App { get; set; } = null!;
|
||||
public Environment Environment { get; set; } = null!;
|
||||
public KubernetesCluster Cluster { get; set; } = null!;
|
||||
public GitRepository? GitRepository { get; set; }
|
||||
public AppDeployment? ParentDeployment { get; set; }
|
||||
public ICollection<AppDeployment> ChildDeployments { get; set; } = [];
|
||||
public ICollection<DeploymentManifest> Manifests { get; set; } = [];
|
||||
public ICollection<DeploymentResource> Resources { get; set; } = [];
|
||||
public ICollection<StorageBinding> StorageBindings { get; set; } = [];
|
||||
public ICollection<DatabaseBinding> DatabaseBindings { get; set; } = [];
|
||||
public ICollection<CacheBinding> CacheBindings { get; set; } = [];
|
||||
}
|
||||
|
||||
44
src/EntKube.Web/Data/AppNetworkPolicy.cs
Normal file
44
src/EntKube.Web/Data/AppNetworkPolicy.cs
Normal file
@@ -0,0 +1,44 @@
|
||||
namespace EntKube.Web.Data;
|
||||
|
||||
public enum AppNetworkPolicyType
|
||||
{
|
||||
/// <summary>Deny all ingress and egress traffic (security baseline).</summary>
|
||||
DenyAll,
|
||||
|
||||
/// <summary>Allow ingress from the ingress controller namespace.</summary>
|
||||
AllowFromIngress,
|
||||
|
||||
/// <summary>Allow ingress from pods in the same namespace.</summary>
|
||||
AllowFromSameNamespace,
|
||||
|
||||
/// <summary>Allow ingress from a specific namespace (see AllowFromNamespace).</summary>
|
||||
AllowFromNamespace,
|
||||
|
||||
/// <summary>Custom YAML policy — see CustomYaml.</summary>
|
||||
Custom
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A NetworkPolicy applied to the app's namespace. Multiple policies can
|
||||
/// coexist (e.g. DenyAll + AllowFromIngress is a common baseline).
|
||||
/// Applied via kubectl apply; managed by tenant admins and read-only in the portal.
|
||||
/// </summary>
|
||||
public class AppNetworkPolicy
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public Guid AppId { get; set; }
|
||||
|
||||
/// <summary>Human-readable name and also the K8s NetworkPolicy resource name.</summary>
|
||||
public required string Name { get; set; }
|
||||
|
||||
public AppNetworkPolicyType PolicyType { get; set; }
|
||||
|
||||
/// <summary>For AllowFromNamespace: the source namespace to allow.</summary>
|
||||
public string? AllowFromNamespace { get; set; }
|
||||
|
||||
/// <summary>For Custom: the complete NetworkPolicy YAML document.</summary>
|
||||
public string? CustomYaml { get; set; }
|
||||
|
||||
// Navigation
|
||||
public App App { get; set; } = null!;
|
||||
}
|
||||
29
src/EntKube.Web/Data/AppQuota.cs
Normal file
29
src/EntKube.Web/Data/AppQuota.cs
Normal file
@@ -0,0 +1,29 @@
|
||||
namespace EntKube.Web.Data;
|
||||
|
||||
/// <summary>
|
||||
/// Resource quota limits for an app's namespace, applied as a Kubernetes
|
||||
/// ResourceQuota. One record per app (1:1). Null fields are omitted from the
|
||||
/// generated manifest so only the desired constraints are enforced.
|
||||
/// </summary>
|
||||
public class AppQuota
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public Guid AppId { get; set; }
|
||||
|
||||
// CPU
|
||||
public string? CpuRequest { get; set; } // e.g. "500m"
|
||||
public string? CpuLimit { get; set; } // e.g. "2"
|
||||
|
||||
// Memory
|
||||
public string? MemoryRequest { get; set; } // e.g. "256Mi"
|
||||
public string? MemoryLimit { get; set; } // e.g. "1Gi"
|
||||
|
||||
// Count quotas
|
||||
public int? MaxPods { get; set; }
|
||||
public int? MaxPvcs { get; set; }
|
||||
|
||||
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
|
||||
|
||||
// Navigation
|
||||
public App App { get; set; } = null!;
|
||||
}
|
||||
52
src/EntKube.Web/Data/AppRbacPolicy.cs
Normal file
52
src/EntKube.Web/Data/AppRbacPolicy.cs
Normal file
@@ -0,0 +1,52 @@
|
||||
namespace EntKube.Web.Data;
|
||||
|
||||
/// <summary>
|
||||
/// RBAC configuration for an app: a ServiceAccount plus a Role/RoleBinding pair.
|
||||
/// One record per app (1:1). When applied, creates:
|
||||
/// - ServiceAccount (ServiceAccountName in the app's namespace)
|
||||
/// - Role (ServiceAccountName + "-role" with all Rules)
|
||||
/// - RoleBinding (ServiceAccountName + "-binding" → that Role)
|
||||
/// </summary>
|
||||
public class AppRbacPolicy
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public Guid AppId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The Kubernetes ServiceAccount name, e.g. "billing-api".
|
||||
/// Must be a valid DNS label.
|
||||
/// </summary>
|
||||
public required string ServiceAccountName { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether pods should auto-mount the service account token.
|
||||
/// Defaults to false (least-privilege).
|
||||
/// </summary>
|
||||
public bool AutoMountToken { get; set; } = false;
|
||||
|
||||
// Navigation
|
||||
public App App { get; set; } = null!;
|
||||
public ICollection<AppRbacRule> Rules { get; set; } = [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A single rule inside an AppRbacPolicy's Role, equivalent to one entry in
|
||||
/// rules[] of a Kubernetes Role manifest.
|
||||
/// </summary>
|
||||
public class AppRbacRule
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public Guid AppRbacPolicyId { get; set; }
|
||||
|
||||
/// <summary>API group, e.g. "" (core), "apps", "batch", or "*".</summary>
|
||||
public string ApiGroups { get; set; } = "";
|
||||
|
||||
/// <summary>Comma-separated resource types, e.g. "pods,services" or "*".</summary>
|
||||
public required string Resources { get; set; }
|
||||
|
||||
/// <summary>Comma-separated verbs, e.g. "get,list,watch" or "*".</summary>
|
||||
public required string Verbs { get; set; }
|
||||
|
||||
// Navigation
|
||||
public AppRbacPolicy Policy { get; set; } = null!;
|
||||
}
|
||||
@@ -42,7 +42,37 @@ public class ApplicationDbContext(DbContextOptions options) : IdentityDbContext<
|
||||
public DbSet<MongoBackup> MongoBackups => Set<MongoBackup>();
|
||||
public DbSet<KeycloakComponentConfig> KeycloakComponentConfigs => Set<KeycloakComponentConfig>();
|
||||
public DbSet<KeycloakRealm> KeycloakRealms => Set<KeycloakRealm>();
|
||||
public DbSet<KeycloakTheme> KeycloakThemes => Set<KeycloakTheme>();
|
||||
public DbSet<KeycloakBackup> KeycloakBackups => Set<KeycloakBackup>();
|
||||
public DbSet<RegisteredPostgresInstance> RegisteredPostgresInstances => Set<RegisteredPostgresInstance>();
|
||||
public DbSet<RegisteredPostgresDatabase> RegisteredPostgresDatabases => Set<RegisteredPostgresDatabase>();
|
||||
public DbSet<RegisteredPostgresDump> RegisteredPostgresDumps => Set<RegisteredPostgresDump>();
|
||||
public DbSet<HarborComponentConfig> HarborComponentConfigs => Set<HarborComponentConfig>();
|
||||
public DbSet<HarborProject> HarborProjects => Set<HarborProject>();
|
||||
public DbSet<DockerRegistryCredential> DockerRegistryCredentials => Set<DockerRegistryCredential>();
|
||||
public DbSet<AuditEvent> AuditEvents => Set<AuditEvent>();
|
||||
public DbSet<RabbitMQCluster> RabbitMQClusters => Set<RabbitMQCluster>();
|
||||
public DbSet<RabbitMQBackup> RabbitMQBackups => Set<RabbitMQBackup>();
|
||||
public DbSet<MessagingBinding> MessagingBindings => Set<MessagingBinding>();
|
||||
public DbSet<AlertIncident> AlertIncidents => Set<AlertIncident>();
|
||||
public DbSet<IncidentNote> IncidentNotes => Set<IncidentNote>();
|
||||
public DbSet<NotificationChannel> NotificationChannels => Set<NotificationChannel>();
|
||||
public DbSet<NotificationDelivery> NotificationDeliveries => Set<NotificationDelivery>();
|
||||
public DbSet<DeploymentHealthSnapshot> DeploymentHealthSnapshots => Set<DeploymentHealthSnapshot>();
|
||||
public DbSet<MaintenanceWindow> MaintenanceWindows => Set<MaintenanceWindow>();
|
||||
public DbSet<SlaTarget> SlaTargets => Set<SlaTarget>();
|
||||
public DbSet<ExternalRouteHealthHistory> ExternalRouteHealthHistories => Set<ExternalRouteHealthHistory>();
|
||||
public DbSet<VpnTunnel> VpnTunnels => Set<VpnTunnel>();
|
||||
public DbSet<VpnLocalEndpoint> VpnLocalEndpoints => Set<VpnLocalEndpoint>();
|
||||
public DbSet<VpnRemoteEndpoint> VpnRemoteEndpoints => Set<VpnRemoteEndpoint>();
|
||||
public DbSet<RedisCluster> RedisClusters => Set<RedisCluster>();
|
||||
public DbSet<CacheBinding> CacheBindings => Set<CacheBinding>();
|
||||
public DbSet<GitRepository> GitRepositories => Set<GitRepository>();
|
||||
public DbSet<GitKnownHost> GitKnownHosts => Set<GitKnownHost>();
|
||||
public DbSet<AppQuota> AppQuotas => Set<AppQuota>();
|
||||
public DbSet<AppNetworkPolicy> AppNetworkPolicies => Set<AppNetworkPolicy>();
|
||||
public DbSet<AppRbacPolicy> AppRbacPolicies => Set<AppRbacPolicy>();
|
||||
public DbSet<AppRbacRule> AppRbacRules => Set<AppRbacRule>();
|
||||
|
||||
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
|
||||
{
|
||||
@@ -268,6 +298,11 @@ public class ApplicationDbContext(DbContextOptions options) : IdentityDbContext<
|
||||
.HasForeignKey(s => s.ComponentId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
entity.HasOne(s => s.CnpgCluster)
|
||||
.WithMany()
|
||||
.HasForeignKey(s => s.CnpgClusterId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
entity.HasOne(s => s.CnpgDatabase)
|
||||
.WithMany()
|
||||
.HasForeignKey(s => s.CnpgDatabaseId)
|
||||
@@ -282,6 +317,57 @@ public class ApplicationDbContext(DbContextOptions options) : IdentityDbContext<
|
||||
.WithMany()
|
||||
.HasForeignKey(s => s.MongoClusterId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
entity.HasOne(s => s.RegisteredPostgresDatabase)
|
||||
.WithMany()
|
||||
.HasForeignKey(s => s.RegisteredPostgresDatabaseId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
// App-scoped secrets can optionally specify a target K8s cluster for sync.
|
||||
// When the cluster is deleted the FK is set to null (secrets are not removed).
|
||||
entity.HasOne(s => s.KubernetesCluster)
|
||||
.WithMany()
|
||||
.HasForeignKey(s => s.KubernetesClusterId)
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
|
||||
entity.HasOne(s => s.RabbitMQCluster)
|
||||
.WithMany()
|
||||
.HasForeignKey(s => s.RabbitMQClusterId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
entity.HasOne(s => s.RedisCluster)
|
||||
.WithMany()
|
||||
.HasForeignKey(s => s.RedisClusterId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
});
|
||||
|
||||
// DockerRegistryCredential — encrypted registry auth stored in the tenant vault.
|
||||
// Password is AES-256-GCM encrypted with the tenant DEK.
|
||||
|
||||
builder.Entity<DockerRegistryCredential>(entity =>
|
||||
{
|
||||
entity.HasKey(d => d.Id);
|
||||
entity.Property(d => d.Name).HasMaxLength(200).IsRequired();
|
||||
entity.Property(d => d.Server).HasMaxLength(500).IsRequired();
|
||||
entity.Property(d => d.Username).HasMaxLength(300).IsRequired();
|
||||
entity.Property(d => d.Email).HasMaxLength(254);
|
||||
entity.Property(d => d.KubernetesSecretName).HasMaxLength(253);
|
||||
entity.Property(d => d.KubernetesNamespace).HasMaxLength(63);
|
||||
|
||||
entity.HasOne(d => d.Vault)
|
||||
.WithMany()
|
||||
.HasForeignKey(d => d.VaultId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
entity.HasOne(d => d.App)
|
||||
.WithMany()
|
||||
.HasForeignKey(d => d.AppId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
entity.HasOne(d => d.KubernetesCluster)
|
||||
.WithMany()
|
||||
.HasForeignKey(d => d.KubernetesClusterId)
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
});
|
||||
|
||||
// ClusterComponent — a deployable unit (Helm chart, operator, etc.) on a cluster.
|
||||
@@ -448,6 +534,7 @@ public class ApplicationDbContext(DbContextOptions options) : IdentityDbContext<
|
||||
entity.Property(c => c.PostgresVersion).HasMaxLength(10).IsRequired();
|
||||
entity.Property(c => c.StorageSize).HasMaxLength(20).IsRequired();
|
||||
entity.Property(c => c.BackupSchedule).HasMaxLength(100);
|
||||
entity.Property(c => c.MaxBackups).HasDefaultValue(20);
|
||||
|
||||
entity.HasIndex(c => new { c.KubernetesClusterId, c.Name, c.Namespace }).IsUnique();
|
||||
|
||||
@@ -502,6 +589,7 @@ public class ApplicationDbContext(DbContextOptions options) : IdentityDbContext<
|
||||
entity.Property(c => c.MongoVersion).HasMaxLength(20).IsRequired();
|
||||
entity.Property(c => c.StorageSize).HasMaxLength(20).IsRequired();
|
||||
entity.Property(c => c.BackupSchedule).HasMaxLength(100);
|
||||
entity.Property(c => c.MaxBackups).HasDefaultValue(20);
|
||||
|
||||
entity.HasIndex(c => new { c.KubernetesClusterId, c.Name, c.Namespace }).IsUnique();
|
||||
|
||||
@@ -556,6 +644,7 @@ public class ApplicationDbContext(DbContextOptions options) : IdentityDbContext<
|
||||
entity.HasKey(c => c.Id);
|
||||
entity.Property(c => c.AdminUsername).HasMaxLength(100).IsRequired();
|
||||
entity.Property(c => c.AdminUrl).HasMaxLength(500);
|
||||
entity.Property(c => c.DisplayName).HasMaxLength(200);
|
||||
|
||||
entity.HasOne(c => c.Tenant)
|
||||
.WithMany()
|
||||
@@ -565,12 +654,37 @@ public class ApplicationDbContext(DbContextOptions options) : IdentityDbContext<
|
||||
entity.HasOne(c => c.ClusterComponent)
|
||||
.WithMany()
|
||||
.HasForeignKey(c => c.ClusterComponentId)
|
||||
.IsRequired(false)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
entity.HasOne(c => c.CnpgDatabase)
|
||||
.WithMany()
|
||||
.HasForeignKey(c => c.CnpgDatabaseId)
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
|
||||
entity.HasOne(c => c.RegisteredPostgresDatabase)
|
||||
.WithMany()
|
||||
.HasForeignKey(c => c.RegisteredPostgresDatabaseId)
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
});
|
||||
|
||||
// KeycloakTheme — a named CSS theme bundle for a Keycloak instance.
|
||||
// Name must be unique within the instance. CSS is stored separately in
|
||||
// the vault (keyed by theme Id). Multiple realms can reference the same theme.
|
||||
|
||||
builder.Entity<KeycloakTheme>(entity =>
|
||||
{
|
||||
entity.HasKey(t => t.Id);
|
||||
entity.Property(t => t.Name).HasMaxLength(200).IsRequired();
|
||||
entity.Property(t => t.LoginTheme).HasMaxLength(100);
|
||||
entity.Property(t => t.AccountTheme).HasMaxLength(100);
|
||||
|
||||
entity.HasIndex(t => new { t.KeycloakComponentConfigId, t.Name }).IsUnique();
|
||||
|
||||
entity.HasOne(t => t.ComponentConfig)
|
||||
.WithMany()
|
||||
.HasForeignKey(t => t.KeycloakComponentConfigId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
});
|
||||
|
||||
// KeycloakRealm — a realm managed via a component config. RealmName must be
|
||||
@@ -593,6 +707,11 @@ public class ApplicationDbContext(DbContextOptions options) : IdentityDbContext<
|
||||
.WithMany()
|
||||
.HasForeignKey(r => r.LinkedAppId)
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
|
||||
entity.HasOne(r => r.Theme)
|
||||
.WithMany(t => t.Realms)
|
||||
.HasForeignKey(r => r.KeycloakThemeId)
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
});
|
||||
|
||||
// KeycloakBackup — realm JSON snapshots stored in S3.
|
||||
@@ -611,12 +730,69 @@ public class ApplicationDbContext(DbContextOptions options) : IdentityDbContext<
|
||||
entity.HasOne(b => b.StorageLink)
|
||||
.WithMany()
|
||||
.HasForeignKey(b => b.StorageLinkId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
});
|
||||
|
||||
// DatabaseBinding — connects a managed database (CNPG or MongoDB) to an
|
||||
// AppDeployment. The platform syncs credentials into the app's namespace
|
||||
// automatically, including after password rotations.
|
||||
// RegisteredPostgresInstance — a vanilla Postgres server inside a K8s cluster
|
||||
// that is not managed by CNPG. EntKube registers it to manage databases and
|
||||
// credentials, but does not own the server lifecycle.
|
||||
|
||||
builder.Entity<RegisteredPostgresInstance>(entity =>
|
||||
{
|
||||
entity.HasKey(i => i.Id);
|
||||
entity.Property(i => i.Name).HasMaxLength(200).IsRequired();
|
||||
entity.Property(i => i.Namespace).HasMaxLength(63).IsRequired();
|
||||
entity.Property(i => i.ServiceName).HasMaxLength(253).IsRequired();
|
||||
entity.Property(i => i.AdminPodName).HasMaxLength(253).IsRequired();
|
||||
entity.Property(i => i.AdminUsername).HasMaxLength(63).IsRequired();
|
||||
entity.Property(i => i.Notes).HasMaxLength(1000);
|
||||
|
||||
entity.HasIndex(i => new { i.TenantId, i.Name }).IsUnique();
|
||||
|
||||
entity.HasOne(i => i.Tenant)
|
||||
.WithMany()
|
||||
.HasForeignKey(i => i.TenantId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
entity.HasOne(i => i.KubernetesCluster)
|
||||
.WithMany()
|
||||
.HasForeignKey(i => i.KubernetesClusterId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
});
|
||||
|
||||
builder.Entity<RegisteredPostgresDatabase>(entity =>
|
||||
{
|
||||
entity.HasKey(d => d.Id);
|
||||
entity.Property(d => d.Name).HasMaxLength(63).IsRequired();
|
||||
entity.Property(d => d.Owner).HasMaxLength(63).IsRequired();
|
||||
|
||||
entity.HasIndex(d => new { d.RegisteredPostgresInstanceId, d.Name }).IsUnique();
|
||||
|
||||
entity.HasOne(d => d.RegisteredPostgresInstance)
|
||||
.WithMany(i => i.Databases)
|
||||
.HasForeignKey(d => d.RegisteredPostgresInstanceId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
});
|
||||
|
||||
builder.Entity<RegisteredPostgresDump>(entity =>
|
||||
{
|
||||
entity.HasKey(d => d.Id);
|
||||
entity.Property(d => d.S3Key).HasMaxLength(1024).IsRequired();
|
||||
|
||||
entity.HasOne(d => d.RegisteredPostgresDatabase)
|
||||
.WithMany()
|
||||
.HasForeignKey(d => d.RegisteredPostgresDatabaseId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
entity.HasOne(d => d.StorageLink)
|
||||
.WithMany()
|
||||
.HasForeignKey(d => d.StorageLinkId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
});
|
||||
|
||||
// DatabaseBinding — connects a managed database (CNPG, MongoDB, or registered
|
||||
// Postgres) to an AppDeployment. The platform syncs credentials into the app's
|
||||
// namespace automatically, including after password rotations.
|
||||
|
||||
builder.Entity<DatabaseBinding>(entity =>
|
||||
{
|
||||
@@ -633,11 +809,521 @@ public class ApplicationDbContext(DbContextOptions options) : IdentityDbContext<
|
||||
.HasForeignKey(b => b.MongoDatabaseId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
entity.HasOne(b => b.RegisteredPostgresDatabase)
|
||||
.WithMany(d => d.DatabaseBindings)
|
||||
.HasForeignKey(b => b.RegisteredPostgresDatabaseId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
entity.HasOne(b => b.AppDeployment)
|
||||
.WithMany(d => d.DatabaseBindings)
|
||||
.HasForeignKey(b => b.AppDeploymentId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
});
|
||||
|
||||
// HarborComponentConfig — links an installed Harbor ClusterComponent to its
|
||||
// CNPG database and S3 storage link. One config per Harbor install.
|
||||
// Admin password and S3/DB credentials are stored in the vault (not here).
|
||||
|
||||
builder.Entity<AuditEvent>(entity =>
|
||||
{
|
||||
entity.HasKey(a => a.Id);
|
||||
entity.Property(a => a.Action).HasMaxLength(100).IsRequired();
|
||||
entity.Property(a => a.ResourceKind).HasMaxLength(100).IsRequired();
|
||||
entity.Property(a => a.ResourceName).HasMaxLength(253);
|
||||
entity.Property(a => a.PerformedBy).HasMaxLength(256);
|
||||
entity.Property(a => a.Details).HasMaxLength(1000);
|
||||
|
||||
entity.HasIndex(a => a.DeploymentId);
|
||||
entity.HasIndex(a => a.OccurredAt);
|
||||
|
||||
entity.HasOne(a => a.Deployment)
|
||||
.WithMany()
|
||||
.HasForeignKey(a => a.DeploymentId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
});
|
||||
|
||||
builder.Entity<HarborComponentConfig>(entity =>
|
||||
{
|
||||
entity.HasKey(c => c.Id);
|
||||
entity.Property(c => c.AdminUsername).HasMaxLength(100).IsRequired();
|
||||
entity.Property(c => c.RegistryUrl).HasMaxLength(500);
|
||||
|
||||
entity.HasOne(c => c.Tenant)
|
||||
.WithMany()
|
||||
.HasForeignKey(c => c.TenantId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
entity.HasOne(c => c.ClusterComponent)
|
||||
.WithMany()
|
||||
.HasForeignKey(c => c.ClusterComponentId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
entity.HasOne(c => c.CnpgDatabase)
|
||||
.WithMany()
|
||||
.HasForeignKey(c => c.CnpgDatabaseId)
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
|
||||
entity.HasOne(c => c.StorageLink)
|
||||
.WithMany()
|
||||
.HasForeignKey(c => c.StorageLinkId)
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
});
|
||||
|
||||
// HarborProject — tracks a Harbor project managed by EntKube.
|
||||
// Project name must be unique within a config (Harbor enforces this globally per server).
|
||||
// LinkedAppId links the project to a customer app for portal self-service.
|
||||
|
||||
builder.Entity<HarborProject>(entity =>
|
||||
{
|
||||
entity.HasKey(p => p.Id);
|
||||
entity.Property(p => p.ProjectName).HasMaxLength(255).IsRequired();
|
||||
|
||||
entity.HasIndex(p => new { p.HarborComponentConfigId, p.ProjectName }).IsUnique();
|
||||
|
||||
entity.HasOne(p => p.Tenant)
|
||||
.WithMany()
|
||||
.HasForeignKey(p => p.TenantId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
entity.HasOne(p => p.HarborComponentConfig)
|
||||
.WithMany()
|
||||
.HasForeignKey(p => p.HarborComponentConfigId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
entity.HasOne(p => p.LinkedApp)
|
||||
.WithMany()
|
||||
.HasForeignKey(p => p.LinkedAppId)
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
});
|
||||
|
||||
builder.Entity<RabbitMQCluster>(entity =>
|
||||
{
|
||||
entity.HasKey(c => c.Id);
|
||||
entity.Property(c => c.Name).HasMaxLength(63).IsRequired();
|
||||
entity.Property(c => c.Namespace).HasMaxLength(63).IsRequired();
|
||||
entity.Property(c => c.RabbitMQVersion).HasMaxLength(20).IsRequired();
|
||||
entity.Property(c => c.StorageSize).HasMaxLength(20).IsRequired();
|
||||
entity.Property(c => c.StorageClass).HasMaxLength(63);
|
||||
entity.Property(c => c.BackupSchedule).HasMaxLength(100);
|
||||
entity.Property(c => c.MaxBackups).HasDefaultValue(10);
|
||||
|
||||
entity.HasIndex(c => new { c.KubernetesClusterId, c.Name, c.Namespace }).IsUnique();
|
||||
|
||||
entity.HasOne(c => c.Tenant)
|
||||
.WithMany()
|
||||
.HasForeignKey(c => c.TenantId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
entity.HasOne(c => c.KubernetesCluster)
|
||||
.WithMany()
|
||||
.HasForeignKey(c => c.KubernetesClusterId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
entity.HasOne(c => c.StorageLink)
|
||||
.WithMany()
|
||||
.HasForeignKey(c => c.StorageLinkId)
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
});
|
||||
|
||||
builder.Entity<RabbitMQBackup>(entity =>
|
||||
{
|
||||
entity.HasKey(b => b.Id);
|
||||
entity.Property(b => b.ObjectKey).HasMaxLength(1024).IsRequired();
|
||||
entity.Property(b => b.ClusterName).HasMaxLength(63).IsRequired();
|
||||
|
||||
entity.HasOne(b => b.Cluster)
|
||||
.WithMany(c => c.Backups)
|
||||
.HasForeignKey(b => b.RabbitMQClusterId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
entity.HasOne(b => b.StorageLink)
|
||||
.WithMany()
|
||||
.HasForeignKey(b => b.StorageLinkId)
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
});
|
||||
|
||||
builder.Entity<RedisCluster>(entity =>
|
||||
{
|
||||
entity.HasKey(c => c.Id);
|
||||
entity.Property(c => c.Name).HasMaxLength(63).IsRequired();
|
||||
entity.Property(c => c.Namespace).HasMaxLength(63).IsRequired();
|
||||
entity.Property(c => c.RedisVersion).HasMaxLength(20).IsRequired();
|
||||
entity.Property(c => c.StorageSize).HasMaxLength(20).IsRequired();
|
||||
entity.Property(c => c.StorageClass).HasMaxLength(63);
|
||||
|
||||
entity.HasIndex(c => new { c.KubernetesClusterId, c.Name, c.Namespace }).IsUnique();
|
||||
|
||||
entity.HasOne(c => c.Tenant)
|
||||
.WithMany()
|
||||
.HasForeignKey(c => c.TenantId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
entity.HasOne(c => c.KubernetesCluster)
|
||||
.WithMany()
|
||||
.HasForeignKey(c => c.KubernetesClusterId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
});
|
||||
|
||||
builder.Entity<CacheBinding>(entity =>
|
||||
{
|
||||
entity.HasKey(b => b.Id);
|
||||
entity.Property(b => b.KubernetesSecretName).HasMaxLength(253).IsRequired();
|
||||
|
||||
entity.HasOne(b => b.RedisCluster)
|
||||
.WithMany()
|
||||
.HasForeignKey(b => b.RedisClusterId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
entity.HasOne(b => b.AppDeployment)
|
||||
.WithMany(d => d.CacheBindings)
|
||||
.HasForeignKey(b => b.AppDeploymentId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
});
|
||||
|
||||
builder.Entity<AlertIncident>(entity =>
|
||||
{
|
||||
entity.HasKey(i => i.Id);
|
||||
entity.HasIndex(i => new { i.ClusterId, i.Fingerprint }).IsUnique();
|
||||
entity.HasIndex(i => i.Status);
|
||||
entity.HasIndex(i => i.StartsAt);
|
||||
entity.Property(i => i.Fingerprint).HasMaxLength(100).IsRequired();
|
||||
entity.Property(i => i.AlertName).HasMaxLength(200).IsRequired();
|
||||
entity.Property(i => i.Severity).HasMaxLength(20).IsRequired();
|
||||
entity.Property(i => i.Summary).HasMaxLength(500);
|
||||
entity.Property(i => i.Description).HasMaxLength(2000);
|
||||
entity.Property(i => i.AcknowledgedBy).HasMaxLength(256);
|
||||
entity.Property(i => i.Status).HasConversion<string>().HasMaxLength(20);
|
||||
|
||||
entity.HasOne(i => i.Cluster)
|
||||
.WithMany()
|
||||
.HasForeignKey(i => i.ClusterId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
});
|
||||
|
||||
builder.Entity<IncidentNote>(entity =>
|
||||
{
|
||||
entity.HasKey(n => n.Id);
|
||||
entity.HasIndex(n => n.IncidentId);
|
||||
entity.Property(n => n.Author).HasMaxLength(256).IsRequired();
|
||||
entity.Property(n => n.Content).HasMaxLength(2000).IsRequired();
|
||||
|
||||
entity.HasOne(n => n.Incident)
|
||||
.WithMany(i => i.Notes)
|
||||
.HasForeignKey(n => n.IncidentId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
});
|
||||
|
||||
builder.Entity<NotificationChannel>(entity =>
|
||||
{
|
||||
entity.HasKey(c => c.Id);
|
||||
entity.HasIndex(c => new { c.TenantId, c.Name }).IsUnique();
|
||||
entity.Property(c => c.Name).HasMaxLength(200).IsRequired();
|
||||
entity.Property(c => c.Type).HasConversion<string>().HasMaxLength(20);
|
||||
entity.Property(c => c.SeverityFilter).HasConversion<string>().HasMaxLength(30);
|
||||
|
||||
entity.HasOne(c => c.Tenant)
|
||||
.WithMany()
|
||||
.HasForeignKey(c => c.TenantId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
});
|
||||
|
||||
builder.Entity<NotificationDelivery>(entity =>
|
||||
{
|
||||
entity.HasKey(d => d.Id);
|
||||
entity.HasIndex(d => new { d.IncidentId, d.ChannelId, d.IsFiring });
|
||||
entity.Property(d => d.Error).HasMaxLength(1000);
|
||||
|
||||
entity.HasOne(d => d.Incident)
|
||||
.WithMany(i => i.Deliveries)
|
||||
.HasForeignKey(d => d.IncidentId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
entity.HasOne(d => d.Channel)
|
||||
.WithMany(c => c.Deliveries)
|
||||
.HasForeignKey(d => d.ChannelId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
});
|
||||
|
||||
builder.Entity<DeploymentHealthSnapshot>(entity =>
|
||||
{
|
||||
entity.HasKey(s => s.Id);
|
||||
entity.HasIndex(s => new { s.DeploymentId, s.SnapshotAt });
|
||||
entity.Property(s => s.HealthStatus).HasConversion<string>().HasMaxLength(20);
|
||||
entity.Property(s => s.SyncStatus).HasConversion<string>().HasMaxLength(20);
|
||||
|
||||
entity.HasOne(s => s.Deployment)
|
||||
.WithMany()
|
||||
.HasForeignKey(s => s.DeploymentId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
});
|
||||
|
||||
builder.Entity<MaintenanceWindow>(entity =>
|
||||
{
|
||||
entity.HasKey(w => w.Id);
|
||||
entity.HasIndex(w => new { w.TenantId, w.StartsAt });
|
||||
entity.HasIndex(w => w.StartsAt);
|
||||
entity.Property(w => w.Title).HasMaxLength(200).IsRequired();
|
||||
entity.Property(w => w.Description).HasMaxLength(1000);
|
||||
entity.Property(w => w.CreatedBy).HasMaxLength(256).IsRequired();
|
||||
|
||||
entity.HasOne(w => w.Tenant)
|
||||
.WithMany()
|
||||
.HasForeignKey(w => w.TenantId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
entity.HasOne(w => w.Cluster)
|
||||
.WithMany()
|
||||
.HasForeignKey(w => w.ClusterId)
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
});
|
||||
|
||||
builder.Entity<SlaTarget>(entity =>
|
||||
{
|
||||
entity.HasKey(s => s.Id);
|
||||
entity.HasIndex(s => new { s.TenantId, s.CustomerId, s.AppId }).IsUnique().HasFilter(null);
|
||||
|
||||
entity.HasOne(s => s.Tenant)
|
||||
.WithMany()
|
||||
.HasForeignKey(s => s.TenantId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
entity.HasOne(s => s.Customer)
|
||||
.WithMany()
|
||||
.HasForeignKey(s => s.CustomerId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
entity.HasOne(s => s.App)
|
||||
.WithMany()
|
||||
.HasForeignKey(s => s.AppId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
});
|
||||
|
||||
builder.Entity<ExternalRouteHealthHistory>(entity =>
|
||||
{
|
||||
entity.HasKey(h => h.Id);
|
||||
entity.HasIndex(h => new { h.RouteId, h.CheckedAt });
|
||||
|
||||
entity.HasOne(h => h.Route)
|
||||
.WithMany()
|
||||
.HasForeignKey(h => h.RouteId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
});
|
||||
|
||||
builder.Entity<MessagingBinding>(entity =>
|
||||
{
|
||||
entity.HasKey(b => b.Id);
|
||||
entity.Property(b => b.Vhost).HasMaxLength(200).IsRequired();
|
||||
entity.Property(b => b.QueueName).HasMaxLength(255);
|
||||
entity.Property(b => b.ExchangeName).HasMaxLength(255);
|
||||
entity.Property(b => b.KubernetesSecretName).HasMaxLength(253).IsRequired();
|
||||
|
||||
entity.HasOne(b => b.Cluster)
|
||||
.WithMany()
|
||||
.HasForeignKey(b => b.RabbitMQClusterId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
entity.HasOne(b => b.AppDeployment)
|
||||
.WithMany()
|
||||
.HasForeignKey(b => b.AppDeploymentId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
});
|
||||
|
||||
// VpnTunnel — scoped to a tenant, name unique within a tenant.
|
||||
|
||||
builder.Entity<VpnTunnel>(entity =>
|
||||
{
|
||||
entity.HasKey(t => t.Id);
|
||||
entity.HasIndex(t => new { t.TenantId, t.Name }).IsUnique();
|
||||
entity.Property(t => t.Name).HasMaxLength(200).IsRequired();
|
||||
entity.Property(t => t.TunnelType).HasConversion<string>().HasMaxLength(20);
|
||||
entity.Property(t => t.Status).HasConversion<string>().HasMaxLength(20);
|
||||
entity.Property(t => t.IkeProposal).HasMaxLength(100).IsRequired();
|
||||
entity.Property(t => t.EspProposal).HasMaxLength(100).IsRequired();
|
||||
|
||||
entity.HasOne(t => t.Tenant)
|
||||
.WithMany()
|
||||
.HasForeignKey(t => t.TenantId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
});
|
||||
|
||||
// VpnLocalEndpoint — a platform cluster participating in a VPN tunnel.
|
||||
|
||||
builder.Entity<VpnLocalEndpoint>(entity =>
|
||||
{
|
||||
entity.HasKey(e => e.Id);
|
||||
entity.Property(e => e.Subnets).HasMaxLength(1000).IsRequired();
|
||||
entity.Property(e => e.PublicIp).HasMaxLength(45);
|
||||
entity.Property(e => e.Role).HasConversion<string>().HasMaxLength(20);
|
||||
entity.Property(e => e.Status).HasConversion<string>().HasMaxLength(20);
|
||||
|
||||
entity.HasOne(e => e.Tunnel)
|
||||
.WithMany(t => t.LocalEndpoints)
|
||||
.HasForeignKey(e => e.VpnTunnelId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
entity.HasOne(e => e.Cluster)
|
||||
.WithMany()
|
||||
.HasForeignKey(e => e.ClusterId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
entity.HasOne(e => e.Component)
|
||||
.WithMany()
|
||||
.HasForeignKey(e => e.ComponentId)
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
});
|
||||
|
||||
// VpnRemoteEndpoint — an external site for SiteToSite tunnels. PSK/cert stored
|
||||
// as VaultSecret entries scoped to this endpoint via VpnRemoteEndpointId.
|
||||
|
||||
builder.Entity<VpnRemoteEndpoint>(entity =>
|
||||
{
|
||||
entity.HasKey(e => e.Id);
|
||||
entity.Property(e => e.Name).HasMaxLength(200).IsRequired();
|
||||
entity.Property(e => e.PublicIp).HasMaxLength(45).IsRequired();
|
||||
entity.Property(e => e.Subnets).HasMaxLength(1000).IsRequired();
|
||||
entity.Property(e => e.AuthMode).HasConversion<string>().HasMaxLength(20);
|
||||
entity.Property(e => e.Status).HasConversion<string>().HasMaxLength(20);
|
||||
entity.Property(e => e.LocalId).HasMaxLength(500);
|
||||
entity.Property(e => e.RemoteId).HasMaxLength(500);
|
||||
|
||||
entity.HasOne(e => e.Tunnel)
|
||||
.WithMany(t => t.RemoteEndpoints)
|
||||
.HasForeignKey(e => e.VpnTunnelId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
});
|
||||
|
||||
// VaultSecret — VpnRemoteEndpoint scoping (PSK/cert for a remote site).
|
||||
// Registering this here keeps the VaultSecret entity config in one place
|
||||
// even though this relationship is declared in the VpnRemoteEndpoint section.
|
||||
builder.Entity<VaultSecret>()
|
||||
.HasOne(s => s.VpnRemoteEndpoint)
|
||||
.WithMany(e => e.Secrets)
|
||||
.HasForeignKey(s => s.VpnRemoteEndpointId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
// VaultSecret — GitRepository scoping (PAT / SSH key / password for a git repo).
|
||||
builder.Entity<VaultSecret>()
|
||||
.HasOne(s => s.GitRepository)
|
||||
.WithMany()
|
||||
.HasForeignKey(s => s.GitRepositoryId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
// App — add Namespace field constraint.
|
||||
builder.Entity<App>(entity =>
|
||||
{
|
||||
entity.Property(a => a.Namespace).HasMaxLength(63);
|
||||
});
|
||||
|
||||
// AppQuota — 1:1 with App, cascades on app delete.
|
||||
builder.Entity<AppQuota>(entity =>
|
||||
{
|
||||
entity.HasKey(q => q.Id);
|
||||
entity.HasIndex(q => q.AppId).IsUnique();
|
||||
entity.Property(q => q.CpuRequest).HasMaxLength(20);
|
||||
entity.Property(q => q.CpuLimit).HasMaxLength(20);
|
||||
entity.Property(q => q.MemoryRequest).HasMaxLength(20);
|
||||
entity.Property(q => q.MemoryLimit).HasMaxLength(20);
|
||||
|
||||
entity.HasOne(q => q.App)
|
||||
.WithOne(a => a.Quota)
|
||||
.HasForeignKey<AppQuota>(q => q.AppId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
});
|
||||
|
||||
// AppNetworkPolicy — many per app, name unique within app.
|
||||
builder.Entity<AppNetworkPolicy>(entity =>
|
||||
{
|
||||
entity.HasKey(p => p.Id);
|
||||
entity.HasIndex(p => new { p.AppId, p.Name }).IsUnique();
|
||||
entity.Property(p => p.Name).HasMaxLength(63).IsRequired();
|
||||
entity.Property(p => p.PolicyType).HasConversion<string>().HasMaxLength(30);
|
||||
entity.Property(p => p.AllowFromNamespace).HasMaxLength(63);
|
||||
|
||||
entity.HasOne(p => p.App)
|
||||
.WithMany(a => a.NetworkPolicies)
|
||||
.HasForeignKey(p => p.AppId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
});
|
||||
|
||||
// AppRbacPolicy — 1:1 with App.
|
||||
builder.Entity<AppRbacPolicy>(entity =>
|
||||
{
|
||||
entity.HasKey(p => p.Id);
|
||||
entity.HasIndex(p => p.AppId).IsUnique();
|
||||
entity.Property(p => p.ServiceAccountName).HasMaxLength(63).IsRequired();
|
||||
|
||||
entity.HasOne(p => p.App)
|
||||
.WithOne(a => a.RbacPolicy)
|
||||
.HasForeignKey<AppRbacPolicy>(p => p.AppId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
});
|
||||
|
||||
// AppRbacRule — many per AppRbacPolicy.
|
||||
builder.Entity<AppRbacRule>(entity =>
|
||||
{
|
||||
entity.HasKey(r => r.Id);
|
||||
entity.Property(r => r.ApiGroups).HasMaxLength(200).IsRequired();
|
||||
entity.Property(r => r.Resources).HasMaxLength(500).IsRequired();
|
||||
entity.Property(r => r.Verbs).HasMaxLength(200).IsRequired();
|
||||
|
||||
entity.HasOne(r => r.Policy)
|
||||
.WithMany(p => p.Rules)
|
||||
.HasForeignKey(r => r.AppRbacPolicyId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
});
|
||||
|
||||
// GitRepository — tenant-scoped, name unique within a tenant.
|
||||
|
||||
builder.Entity<GitRepository>(entity =>
|
||||
{
|
||||
entity.HasKey(r => r.Id);
|
||||
entity.HasIndex(r => new { r.TenantId, r.Name }).IsUnique();
|
||||
entity.Property(r => r.Name).HasMaxLength(200).IsRequired();
|
||||
entity.Property(r => r.Url).HasMaxLength(2000).IsRequired();
|
||||
entity.Property(r => r.AuthType).HasConversion<string>().HasMaxLength(20);
|
||||
entity.Property(r => r.Username).HasMaxLength(300);
|
||||
entity.Property(r => r.DefaultBranch).HasMaxLength(200).HasDefaultValue("main");
|
||||
|
||||
entity.HasOne(r => r.Tenant)
|
||||
.WithMany(t => t.GitRepositories)
|
||||
.HasForeignKey(r => r.TenantId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
});
|
||||
|
||||
// GitKnownHost — trusted SSH host fingerprints, unique per (TenantId, Hostname).
|
||||
|
||||
builder.Entity<GitKnownHost>(entity =>
|
||||
{
|
||||
entity.HasKey(h => h.Id);
|
||||
entity.HasIndex(h => new { h.TenantId, h.Hostname }).IsUnique();
|
||||
entity.Property(h => h.Hostname).HasMaxLength(253).IsRequired();
|
||||
entity.Property(h => h.Fingerprint).HasMaxLength(200).IsRequired();
|
||||
entity.Property(h => h.KeyType).HasMaxLength(50).IsRequired();
|
||||
|
||||
entity.HasOne(h => h.Tenant)
|
||||
.WithMany(t => t.GitKnownHosts)
|
||||
.HasForeignKey(h => h.TenantId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
});
|
||||
|
||||
// AppDeployment — git source FK and parent/child app-of-apps relationship.
|
||||
|
||||
builder.Entity<AppDeployment>(entity =>
|
||||
{
|
||||
entity.Property(d => d.GitPath).HasMaxLength(500);
|
||||
entity.Property(d => d.GitRevision).HasMaxLength(200);
|
||||
entity.Property(d => d.GitLastSyncedCommit).HasMaxLength(40);
|
||||
|
||||
entity.HasOne(d => d.GitRepository)
|
||||
.WithMany(r => r.Deployments)
|
||||
.HasForeignKey(d => d.GitRepositoryId)
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
|
||||
entity.HasOne(d => d.ParentDeployment)
|
||||
.WithMany(d => d.ChildDeployments)
|
||||
.HasForeignKey(d => d.ParentDeploymentId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
37
src/EntKube.Web/Data/AuditEvent.cs
Normal file
37
src/EntKube.Web/Data/AuditEvent.cs
Normal file
@@ -0,0 +1,37 @@
|
||||
namespace EntKube.Web.Data;
|
||||
|
||||
/// <summary>
|
||||
/// An immutable record of an action taken by a user or the system on a deployment
|
||||
/// resource. Written on every destructive or significant operation — restart, scale,
|
||||
/// delete, apply, install — so operators have a full activity timeline.
|
||||
/// </summary>
|
||||
public class AuditEvent
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
|
||||
/// <summary>The deployment the action was taken on, if applicable.</summary>
|
||||
public Guid? DeploymentId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The identity that performed the action. Contains the user's email/name
|
||||
/// from the authentication state. Null for system-initiated actions.
|
||||
/// </summary>
|
||||
public string? PerformedBy { get; set; }
|
||||
|
||||
/// <summary>Short verb describing what happened, e.g. "RestartWorkload", "ScalePod", "HelmInstall".</summary>
|
||||
public required string Action { get; set; }
|
||||
|
||||
/// <summary>The Kubernetes resource kind the action targeted, e.g. "Deployment", "Pod".</summary>
|
||||
public required string ResourceKind { get; set; }
|
||||
|
||||
/// <summary>The Kubernetes resource name that was affected.</summary>
|
||||
public string? ResourceName { get; set; }
|
||||
|
||||
/// <summary>Extra context: replica count for scale, chart version for install, etc.</summary>
|
||||
public string? Details { get; set; }
|
||||
|
||||
public DateTime OccurredAt { get; set; } = DateTime.UtcNow;
|
||||
|
||||
// Navigation
|
||||
public AppDeployment? Deployment { get; set; }
|
||||
}
|
||||
33
src/EntKube.Web/Data/CacheBinding.cs
Normal file
33
src/EntKube.Web/Data/CacheBinding.cs
Normal file
@@ -0,0 +1,33 @@
|
||||
namespace EntKube.Web.Data;
|
||||
|
||||
/// <summary>
|
||||
/// Links a Redis cluster to an AppDeployment. Many bindings can point to the
|
||||
/// same cluster — a shared Redis instance can serve multiple apps simultaneously.
|
||||
///
|
||||
/// On sync, REDIS_HOST, REDIS_PORT, REDIS_PASSWORD, and REDIS_URL are written
|
||||
/// as a Kubernetes Secret into the app deployment's namespace so the app can
|
||||
/// connect without knowing which Redis cluster backs it.
|
||||
/// </summary>
|
||||
public class CacheBinding
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
|
||||
public Guid RedisClusterId { get; set; }
|
||||
|
||||
public Guid AppDeploymentId { get; set; }
|
||||
|
||||
public Guid TenantId { get; set; }
|
||||
|
||||
/// <summary>Kubernetes Secret name created in the app deployment's namespace.</summary>
|
||||
public required string KubernetesSecretName { get; set; }
|
||||
|
||||
public bool SyncEnabled { get; set; } = true;
|
||||
|
||||
public DateTime? LastSyncedAt { get; set; }
|
||||
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
|
||||
// Navigation
|
||||
public RedisCluster RedisCluster { get; set; } = null!;
|
||||
public AppDeployment AppDeployment { get; set; } = null!;
|
||||
}
|
||||
@@ -83,6 +83,19 @@ public class CnpgCluster
|
||||
/// </summary>
|
||||
public int RetentionDays { get; set; } = 30;
|
||||
|
||||
/// <summary>
|
||||
/// Maximum number of completed Backup CRs to keep in Kubernetes. When exceeded
|
||||
/// during a cleanup, the oldest completed backups are deleted from K8s and the DB.
|
||||
/// Does not affect WAL archiving or Barman's own retention — use RetentionDays for that.
|
||||
/// </summary>
|
||||
public int MaxBackups { get; set; } = 20;
|
||||
|
||||
/// <summary>
|
||||
/// True when the cluster was registered from an existing CNPG deployment (not provisioned by EntKube).
|
||||
/// Delete should offer "Unregister" (remove from EntKube only) in addition to full deletion.
|
||||
/// </summary>
|
||||
public bool IsExternal { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Current lifecycle status of the cluster.
|
||||
/// </summary>
|
||||
|
||||
@@ -26,6 +26,12 @@ public class DatabaseBinding
|
||||
/// </summary>
|
||||
public Guid? MongoDatabaseId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The registered (non-CNPG) PostgreSQL database whose credentials should be synced.
|
||||
/// Mutually exclusive with CnpgDatabaseId and MongoDatabaseId.
|
||||
/// </summary>
|
||||
public Guid? RegisteredPostgresDatabaseId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The deployment that consumes the database credentials.
|
||||
/// The secret is written into this deployment's namespace on its cluster.
|
||||
@@ -56,5 +62,6 @@ public class DatabaseBinding
|
||||
// Navigation
|
||||
public CnpgDatabase? CnpgDatabase { get; set; }
|
||||
public MongoDatabase? MongoDatabase { get; set; }
|
||||
public RegisteredPostgresDatabase? RegisteredPostgresDatabase { get; set; }
|
||||
public AppDeployment AppDeployment { get; set; } = null!;
|
||||
}
|
||||
|
||||
@@ -22,7 +22,26 @@ public enum DeploymentType
|
||||
/// A Helm chart from any repository. The user provides the repo URL,
|
||||
/// chart name, version, and dynamic values.
|
||||
/// </summary>
|
||||
HelmChart
|
||||
HelmChart,
|
||||
|
||||
/// <summary>
|
||||
/// Raw YAML manifests sourced from a Git repository. The platform fetches
|
||||
/// the YAML files at the specified path and applies them to the cluster.
|
||||
/// </summary>
|
||||
GitYaml,
|
||||
|
||||
/// <summary>
|
||||
/// A Helm chart sourced from a Git repository (the chart lives in the repo,
|
||||
/// not in a chart museum). The chart directory is at GitPath.
|
||||
/// </summary>
|
||||
GitHelm,
|
||||
|
||||
/// <summary>
|
||||
/// App-of-apps sourced from a Git repository. The platform reads ArgoCD
|
||||
/// Application CRD YAML files at GitPath and creates or reconciles child
|
||||
/// AppDeployment rows automatically.
|
||||
/// </summary>
|
||||
GitAppOfApps
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
14
src/EntKube.Web/Data/DeploymentHealthSnapshot.cs
Normal file
14
src/EntKube.Web/Data/DeploymentHealthSnapshot.cs
Normal file
@@ -0,0 +1,14 @@
|
||||
namespace EntKube.Web.Data;
|
||||
|
||||
public class DeploymentHealthSnapshot
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public Guid DeploymentId { get; set; }
|
||||
public HealthStatus HealthStatus { get; set; }
|
||||
public SyncStatus SyncStatus { get; set; }
|
||||
public int? ReadyReplicas { get; set; }
|
||||
public int? TotalReplicas { get; set; }
|
||||
public DateTime SnapshotAt { get; set; } = DateTime.UtcNow;
|
||||
|
||||
public AppDeployment Deployment { get; set; } = null!;
|
||||
}
|
||||
@@ -35,6 +35,12 @@ public class DeploymentManifest
|
||||
/// </summary>
|
||||
public required string YamlContent { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// For Git-sourced manifests: the relative file path within the repository that
|
||||
/// produced this manifest. Null for manually created or pasted manifests.
|
||||
/// </summary>
|
||||
public string? SourceFile { get; set; }
|
||||
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
|
||||
|
||||
|
||||
68
src/EntKube.Web/Data/DockerRegistryCredential.cs
Normal file
68
src/EntKube.Web/Data/DockerRegistryCredential.cs
Normal file
@@ -0,0 +1,68 @@
|
||||
namespace EntKube.Web.Data;
|
||||
|
||||
/// <summary>
|
||||
/// Docker / OCI registry authentication credential stored in the tenant vault.
|
||||
/// The password is encrypted with the tenant's DEK (AES-256-GCM). When
|
||||
/// KubernetesClusterId + KubernetesSecretName + KubernetesNamespace are set the
|
||||
/// credential can be pushed to a cluster as a kubernetes.io/dockerconfigjson
|
||||
/// typed Secret so workloads can pull private images.
|
||||
/// </summary>
|
||||
public class DockerRegistryCredential
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The tenant vault that owns this credential. Used to look up the DEK
|
||||
/// for encryption/decryption.
|
||||
/// </summary>
|
||||
public Guid VaultId { get; set; }
|
||||
|
||||
/// <summary>Human-friendly display name, e.g. "Production ACR" or "Docker Hub pull".</summary>
|
||||
public required string Name { get; set; }
|
||||
|
||||
/// <summary>Registry type — drives the server URL default and UI display label.</summary>
|
||||
public DockerRegistryType RegistryType { get; set; } = DockerRegistryType.Generic;
|
||||
|
||||
/// <summary>
|
||||
/// Registry server URL, e.g. "https://index.docker.io/v1/", "myacr.azurecr.io",
|
||||
/// "registry.example.com". Stored in plain text.
|
||||
/// </summary>
|
||||
public required string Server { get; set; }
|
||||
|
||||
/// <summary>Registry username or service principal ID. Stored in plain text.</summary>
|
||||
public required string Username { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Password, personal access token, or service principal secret.
|
||||
/// Encrypted with the tenant DEK using AES-256-GCM. Combined ciphertext + tag.
|
||||
/// </summary>
|
||||
public required byte[] EncryptedPassword { get; set; }
|
||||
|
||||
/// <summary>GCM nonce used when encrypting EncryptedPassword. Unique per write.</summary>
|
||||
public required byte[] PasswordNonce { get; set; }
|
||||
|
||||
/// <summary>Optional contact email. Required by some registries for the auth entry.</summary>
|
||||
public string? Email { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// If set, this credential is scoped to a specific app. Null means tenant-wide.
|
||||
/// </summary>
|
||||
public Guid? AppId { get; set; }
|
||||
|
||||
/// <summary>Target Kubernetes cluster for the dockerconfigjson secret sync.</summary>
|
||||
public Guid? KubernetesClusterId { get; set; }
|
||||
|
||||
/// <summary>Name of the Kubernetes Secret to create/update on sync, e.g. "registry-creds".</summary>
|
||||
public string? KubernetesSecretName { get; set; }
|
||||
|
||||
/// <summary>Kubernetes namespace that receives the synced Secret.</summary>
|
||||
public string? KubernetesNamespace { get; set; }
|
||||
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
|
||||
|
||||
// Navigation properties
|
||||
public SecretVault Vault { get; set; } = null!;
|
||||
public App? App { get; set; }
|
||||
public KubernetesCluster? KubernetesCluster { get; set; }
|
||||
}
|
||||
16
src/EntKube.Web/Data/DockerRegistryType.cs
Normal file
16
src/EntKube.Web/Data/DockerRegistryType.cs
Normal file
@@ -0,0 +1,16 @@
|
||||
namespace EntKube.Web.Data;
|
||||
|
||||
/// <summary>
|
||||
/// Well-known OCI / Docker registry providers. Drives server URL defaults and
|
||||
/// display labels in the UI. Use <see cref="Generic"/> for any registry not
|
||||
/// listed here.
|
||||
/// </summary>
|
||||
public enum DockerRegistryType
|
||||
{
|
||||
Generic,
|
||||
DockerHub,
|
||||
AzureContainerRegistry,
|
||||
Harbor,
|
||||
Quay,
|
||||
GitHubContainerRegistry
|
||||
}
|
||||
@@ -83,6 +83,17 @@ public class ExternalRoute
|
||||
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
|
||||
// ── Health monitoring ──
|
||||
|
||||
/// <summary>When the last automated health check ran against this hostname.</summary>
|
||||
public DateTime? LastHealthCheckAt { get; set; }
|
||||
|
||||
/// <summary>HTTP status code from the last health check. Null if the request failed entirely.</summary>
|
||||
public int? LastStatusCode { get; set; }
|
||||
|
||||
/// <summary>True when the last health check returned a 2xx or redirect response.</summary>
|
||||
public bool? IsReachable { get; set; }
|
||||
|
||||
// Navigation
|
||||
public ClusterComponent Component { get; set; } = null!;
|
||||
}
|
||||
|
||||
13
src/EntKube.Web/Data/ExternalRouteHealthHistory.cs
Normal file
13
src/EntKube.Web/Data/ExternalRouteHealthHistory.cs
Normal file
@@ -0,0 +1,13 @@
|
||||
namespace EntKube.Web.Data;
|
||||
|
||||
public class ExternalRouteHealthHistory
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public Guid RouteId { get; set; }
|
||||
public bool IsReachable { get; set; }
|
||||
public int? StatusCode { get; set; }
|
||||
public int? ResponseMs { get; set; }
|
||||
public DateTime CheckedAt { get; set; } = DateTime.UtcNow;
|
||||
|
||||
public ExternalRoute Route { get; set; } = null!;
|
||||
}
|
||||
9
src/EntKube.Web/Data/GitAuthType.cs
Normal file
9
src/EntKube.Web/Data/GitAuthType.cs
Normal file
@@ -0,0 +1,9 @@
|
||||
namespace EntKube.Web.Data;
|
||||
|
||||
public enum GitAuthType
|
||||
{
|
||||
None,
|
||||
HttpsPat,
|
||||
HttpsPassword,
|
||||
SshKey
|
||||
}
|
||||
37
src/EntKube.Web/Data/GitKnownHost.cs
Normal file
37
src/EntKube.Web/Data/GitKnownHost.cs
Normal file
@@ -0,0 +1,37 @@
|
||||
namespace EntKube.Web.Data;
|
||||
|
||||
/// <summary>
|
||||
/// A trusted SSH host fingerprint for a tenant. When a GitRepository with SshKey
|
||||
/// auth connects for the first time, the server's fingerprint is recorded here
|
||||
/// (auto-trust on first connect, like ArgoCD). Subsequent connections that present
|
||||
/// a different fingerprint for the same hostname will fail.
|
||||
///
|
||||
/// Scoped to tenant rather than individual repositories so that multiple repos on
|
||||
/// the same host (e.g. github.com) share one trust entry.
|
||||
/// </summary>
|
||||
public class GitKnownHost
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
|
||||
public Guid TenantId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The hostname (e.g. "github.com", "dev.azure.com").
|
||||
/// </summary>
|
||||
public required string Hostname { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// SHA-256 fingerprint of the host key, base64-encoded (e.g. "SHA256:nThbg6...").
|
||||
/// </summary>
|
||||
public required string Fingerprint { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The key type reported by the server (e.g. "ssh-rsa", "ecdsa-sha2-nistp256", "ssh-ed25519").
|
||||
/// </summary>
|
||||
public required string KeyType { get; set; }
|
||||
|
||||
public DateTime TrustedAt { get; set; } = DateTime.UtcNow;
|
||||
|
||||
// Navigation
|
||||
public Tenant Tenant { get; set; } = null!;
|
||||
}
|
||||
52
src/EntKube.Web/Data/GitRepository.cs
Normal file
52
src/EntKube.Web/Data/GitRepository.cs
Normal file
@@ -0,0 +1,52 @@
|
||||
namespace EntKube.Web.Data;
|
||||
|
||||
/// <summary>
|
||||
/// A registered Git repository credential set, scoped to a tenant.
|
||||
/// Acts like ArgoCD's Repository — a reusable auth configuration that multiple
|
||||
/// deployments can reference. Credentials are stored encrypted in the tenant vault
|
||||
/// (VaultSecret rows with GitRepositoryId set), never in this table.
|
||||
///
|
||||
/// Supported auth types:
|
||||
/// - None: public repo, no credentials
|
||||
/// - HttpsPat: HTTPS with a single PAT (stored as vault secret "PAT")
|
||||
/// - HttpsPassword: HTTPS with username + password (stored as vault secret "PASSWORD"; username stored here)
|
||||
/// - SshKey: SSH private key (stored as vault secret "SSH_PRIVATE_KEY")
|
||||
/// </summary>
|
||||
public class GitRepository
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
|
||||
public Guid TenantId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Human-friendly label for this repository (e.g. "prod-gitops-repo").
|
||||
/// Must be unique within the tenant.
|
||||
/// </summary>
|
||||
public required string Name { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The clone URL. HTTPS (https://...) or SSH (git@github.com:...) depending on AuthType.
|
||||
/// </summary>
|
||||
public required string Url { get; set; }
|
||||
|
||||
public GitAuthType AuthType { get; set; } = GitAuthType.None;
|
||||
|
||||
/// <summary>
|
||||
/// For HttpsPassword auth: the username to use alongside the vault-stored password.
|
||||
/// For Azure DevOps PAT auth this is often just the string "x-auth-token" or empty.
|
||||
/// Not used for SshKey or HttpsPat auth.
|
||||
/// </summary>
|
||||
public string? Username { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The default branch/revision to check out when a deployment does not specify one.
|
||||
/// </summary>
|
||||
public string DefaultBranch { get; set; } = "main";
|
||||
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
|
||||
// Navigation
|
||||
public Tenant Tenant { get; set; } = null!;
|
||||
public ICollection<AppDeployment> Deployments { get; set; } = [];
|
||||
public ICollection<GitKnownHost> KnownHosts { get; set; } = [];
|
||||
}
|
||||
40
src/EntKube.Web/Data/HarborComponentConfig.cs
Normal file
40
src/EntKube.Web/Data/HarborComponentConfig.cs
Normal file
@@ -0,0 +1,40 @@
|
||||
namespace EntKube.Web.Data;
|
||||
|
||||
/// <summary>
|
||||
/// Harbor-specific configuration for a managed Harbor instance.
|
||||
/// Tracks the backing CNPG database, S3 storage bucket, admin credentials, and registry URL.
|
||||
///
|
||||
/// Admin password is stored as a component vault secret (key: HARBOR_ADMIN_PASSWORD).
|
||||
/// S3 credentials are stored as component vault secrets (keys: harbor-s3-access-key,
|
||||
/// harbor-s3-secret-key) and injected into Helm values at install time.
|
||||
/// CNPG database password is stored as a component vault secret (key: harbor-db-password).
|
||||
/// </summary>
|
||||
public class HarborComponentConfig
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
|
||||
public Guid TenantId { get; set; }
|
||||
|
||||
/// <summary>The installed Harbor ClusterComponent this config belongs to.</summary>
|
||||
public Guid ClusterComponentId { get; set; }
|
||||
|
||||
/// <summary>Managed CNPG database backing this Harbor instance. Null uses Harbor's built-in Postgres.</summary>
|
||||
public Guid? CnpgDatabaseId { get; set; }
|
||||
|
||||
/// <summary>S3-compatible storage link for Harbor artifact storage. Null uses local filesystem PVC.</summary>
|
||||
public Guid? StorageLinkId { get; set; }
|
||||
|
||||
/// <summary>Harbor admin username (default "admin").</summary>
|
||||
public string AdminUsername { get; set; } = "admin";
|
||||
|
||||
/// <summary>Public URL of the Harbor registry (e.g. "https://registry.example.com").</summary>
|
||||
public string? RegistryUrl { get; set; }
|
||||
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
|
||||
// Navigation
|
||||
public Tenant Tenant { get; set; } = null!;
|
||||
public ClusterComponent ClusterComponent { get; set; } = null!;
|
||||
public CnpgDatabase? CnpgDatabase { get; set; }
|
||||
public StorageLink? StorageLink { get; set; }
|
||||
}
|
||||
34
src/EntKube.Web/Data/HarborProject.cs
Normal file
34
src/EntKube.Web/Data/HarborProject.cs
Normal file
@@ -0,0 +1,34 @@
|
||||
namespace EntKube.Web.Data;
|
||||
|
||||
/// <summary>
|
||||
/// Tracks a Harbor project that EntKube is managing. Used to link a Harbor
|
||||
/// project to a customer app so the customer can manage it through the portal.
|
||||
///
|
||||
/// The project itself lives in Harbor; this record stores the EntKube-side
|
||||
/// association (tenant scope, optional app link) so the portal can surface it.
|
||||
/// </summary>
|
||||
public class HarborProject
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
|
||||
public Guid TenantId { get; set; }
|
||||
|
||||
/// <summary>The Harbor instance this project belongs to.</summary>
|
||||
public Guid HarborComponentConfigId { get; set; }
|
||||
|
||||
/// <summary>Harbor project name (used as the path segment in API calls and image references).</summary>
|
||||
public required string ProjectName { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// When set, this project is linked to a customer app and the customer can
|
||||
/// manage their project (repositories, robot accounts) through the portal.
|
||||
/// </summary>
|
||||
public Guid? LinkedAppId { get; set; }
|
||||
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
|
||||
// Navigation
|
||||
public Tenant Tenant { get; set; } = null!;
|
||||
public HarborComponentConfig HarborComponentConfig { get; set; } = null!;
|
||||
public App? LinkedApp { get; set; }
|
||||
}
|
||||
12
src/EntKube.Web/Data/IncidentNote.cs
Normal file
12
src/EntKube.Web/Data/IncidentNote.cs
Normal file
@@ -0,0 +1,12 @@
|
||||
namespace EntKube.Web.Data;
|
||||
|
||||
public class IncidentNote
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public Guid IncidentId { get; set; }
|
||||
public required string Author { get; set; }
|
||||
public required string Content { get; set; }
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
|
||||
public AlertIncident Incident { get; set; } = null!;
|
||||
}
|
||||
@@ -20,7 +20,7 @@ public class KeycloakBackup
|
||||
|
||||
public Guid TenantId { get; set; }
|
||||
|
||||
public Guid StorageLinkId { get; set; }
|
||||
public Guid? StorageLinkId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// S3 object key (e.g. "keycloak-backups/my-realm/2026-05-19T14-32-00.json").
|
||||
@@ -45,5 +45,5 @@ public class KeycloakBackup
|
||||
|
||||
// Navigation
|
||||
public KeycloakRealm Realm { get; set; } = null!;
|
||||
public StorageLink StorageLink { get; set; } = null!;
|
||||
public StorageLink? StorageLink { get; set; }
|
||||
}
|
||||
|
||||
@@ -1,16 +1,14 @@
|
||||
namespace EntKube.Web.Data;
|
||||
|
||||
/// <summary>
|
||||
/// Keycloak-specific configuration attached to an installed Keycloak ClusterComponent.
|
||||
/// Stores which CNPG database backs this Keycloak instance and the admin credentials
|
||||
/// needed for the Admin REST API.
|
||||
/// Keycloak-specific configuration for an instance tracked by EntKube.
|
||||
/// Covers both EntKube-managed installs (ClusterComponentId set) and externally
|
||||
/// deployed instances (ClusterComponentId null, DisplayName used instead).
|
||||
///
|
||||
/// DB credentials (KC_DB_URL, KC_DB_USERNAME, KC_DB_PASSWORD) are stored as
|
||||
/// component vault secrets and synced to a Kubernetes Secret so the Helm chart
|
||||
/// can consume them without any out-of-band secret management.
|
||||
///
|
||||
/// The admin password is stored as a component vault secret named
|
||||
/// "KEYCLOAK_ADMIN_PASSWORD" and synced to the same Kubernetes Secret.
|
||||
/// For managed instances: DB credentials and the admin password are stored as
|
||||
/// component vault secrets (keyed by ClusterComponentId) and synced to K8s.
|
||||
/// For external instances: only the admin password is stored in vault, keyed by
|
||||
/// this config's own Id.
|
||||
/// </summary>
|
||||
public class KeycloakComponentConfig
|
||||
{
|
||||
@@ -20,16 +18,29 @@ public class KeycloakComponentConfig
|
||||
|
||||
/// <summary>
|
||||
/// The installed Keycloak ClusterComponent this config belongs to.
|
||||
/// One config per Keycloak component instance.
|
||||
/// Null for externally deployed Keycloaks not managed by EntKube.
|
||||
/// </summary>
|
||||
public Guid ClusterComponentId { get; set; }
|
||||
public Guid? ClusterComponentId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Human-readable name for externally deployed Keycloaks (ClusterComponentId is null).
|
||||
/// Ignored when ClusterComponentId is set — the component name is used instead.
|
||||
/// </summary>
|
||||
public string? DisplayName { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The managed CNPG database backing this Keycloak instance.
|
||||
/// Null until the operator selects a database.
|
||||
/// Null when using a registered Postgres database or no database is linked yet.
|
||||
/// Mutually exclusive with RegisteredPostgresDatabaseId.
|
||||
/// </summary>
|
||||
public Guid? CnpgDatabaseId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// A registered (non-CNPG) PostgreSQL database backing this Keycloak instance.
|
||||
/// Mutually exclusive with CnpgDatabaseId.
|
||||
/// </summary>
|
||||
public Guid? RegisteredPostgresDatabaseId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Keycloak admin username (default "admin").
|
||||
/// </summary>
|
||||
@@ -46,7 +57,8 @@ public class KeycloakComponentConfig
|
||||
|
||||
// Navigation
|
||||
public Tenant Tenant { get; set; } = null!;
|
||||
public ClusterComponent ClusterComponent { get; set; } = null!;
|
||||
public ClusterComponent? ClusterComponent { get; set; }
|
||||
public CnpgDatabase? CnpgDatabase { get; set; }
|
||||
public RegisteredPostgresDatabase? RegisteredPostgresDatabase { get; set; }
|
||||
public ICollection<KeycloakRealm> Realms { get; set; } = [];
|
||||
}
|
||||
|
||||
@@ -42,10 +42,18 @@ public class KeycloakRealm
|
||||
/// </summary>
|
||||
public Guid? LinkedAppId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional named theme (from EntKube's theme library for this Keycloak instance).
|
||||
/// When set, applying the theme writes its LoginTheme/AccountTheme to Keycloak
|
||||
/// and associates its CSS overrides with this realm.
|
||||
/// </summary>
|
||||
public Guid? KeycloakThemeId { get; set; }
|
||||
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
|
||||
// Navigation
|
||||
public KeycloakComponentConfig ComponentConfig { get; set; } = null!;
|
||||
public App? LinkedApp { get; set; }
|
||||
public KeycloakTheme? Theme { get; set; }
|
||||
public ICollection<KeycloakBackup> Backups { get; set; } = [];
|
||||
}
|
||||
|
||||
31
src/EntKube.Web/Data/KeycloakTheme.cs
Normal file
31
src/EntKube.Web/Data/KeycloakTheme.cs
Normal file
@@ -0,0 +1,31 @@
|
||||
namespace EntKube.Web.Data;
|
||||
|
||||
/// <summary>
|
||||
/// A named CSS theme bundle for a Keycloak instance. Captures both the
|
||||
/// Keycloak-native theme to activate (loginTheme / accountTheme) and stores
|
||||
/// custom CSS overrides in the vault keyed by this theme's Id. Realms can
|
||||
/// reference a theme to inherit its settings in one operation.
|
||||
/// </summary>
|
||||
public class KeycloakTheme
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
|
||||
public Guid TenantId { get; set; }
|
||||
|
||||
public Guid KeycloakComponentConfigId { get; set; }
|
||||
|
||||
/// <summary>Human-readable name shown in the theme selector (e.g. "Capio Blue").</summary>
|
||||
public required string Name { get; set; }
|
||||
|
||||
/// <summary>Keycloak-native login theme provider name (e.g. "capio", "keycloak").</summary>
|
||||
public string? LoginTheme { get; set; }
|
||||
|
||||
/// <summary>Keycloak-native account console theme provider name.</summary>
|
||||
public string? AccountTheme { get; set; }
|
||||
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
|
||||
// Navigation
|
||||
public KeycloakComponentConfig ComponentConfig { get; set; } = null!;
|
||||
public ICollection<KeycloakRealm> Realms { get; set; } = [];
|
||||
}
|
||||
17
src/EntKube.Web/Data/MaintenanceWindow.cs
Normal file
17
src/EntKube.Web/Data/MaintenanceWindow.cs
Normal file
@@ -0,0 +1,17 @@
|
||||
namespace EntKube.Web.Data;
|
||||
|
||||
public class MaintenanceWindow
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public Guid TenantId { get; set; }
|
||||
public Guid? ClusterId { get; set; }
|
||||
public required string Title { get; set; }
|
||||
public string? Description { get; set; }
|
||||
public DateTime StartsAt { get; set; }
|
||||
public DateTime EndsAt { get; set; }
|
||||
public required string CreatedBy { get; set; }
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
|
||||
public Tenant Tenant { get; set; } = null!;
|
||||
public KubernetesCluster? Cluster { get; set; }
|
||||
}
|
||||
45
src/EntKube.Web/Data/MessagingBinding.cs
Normal file
45
src/EntKube.Web/Data/MessagingBinding.cs
Normal file
@@ -0,0 +1,45 @@
|
||||
namespace EntKube.Web.Data;
|
||||
|
||||
/// <summary>
|
||||
/// Links a RabbitMQ vhost/queue/exchange to an AppDeployment.
|
||||
/// The platform syncs AMQP connection details (host, port, vhost, credentials,
|
||||
/// queue/exchange name) into a Kubernetes Secret in the app's namespace,
|
||||
/// mirroring the DatabaseBinding pattern.
|
||||
/// </summary>
|
||||
public class MessagingBinding
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
|
||||
public Guid RabbitMQClusterId { get; set; }
|
||||
|
||||
public Guid AppDeploymentId { get; set; }
|
||||
|
||||
public Guid TenantId { get; set; }
|
||||
|
||||
/// <summary>The RabbitMQ virtual host the app connects to.</summary>
|
||||
public required string Vhost { get; set; }
|
||||
|
||||
/// <summary>Queue name the app consumes from, if this is a queue binding.</summary>
|
||||
public string? QueueName { get; set; }
|
||||
|
||||
/// <summary>Exchange name the app publishes to, if this is an exchange binding.</summary>
|
||||
public string? ExchangeName { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Target Kubernetes Secret name in the app deployment's namespace.
|
||||
/// The secret will contain RABBITMQ_HOST, RABBITMQ_PORT, RABBITMQ_VHOST,
|
||||
/// RABBITMQ_USERNAME, RABBITMQ_PASSWORD, RABBITMQ_URL, and RABBITMQ_QUEUE
|
||||
/// or RABBITMQ_EXCHANGE if applicable.
|
||||
/// </summary>
|
||||
public required string KubernetesSecretName { get; set; }
|
||||
|
||||
public bool SyncEnabled { get; set; } = true;
|
||||
|
||||
public DateTime? LastSyncedAt { get; set; }
|
||||
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
|
||||
// Navigation
|
||||
public RabbitMQCluster Cluster { get; set; } = null!;
|
||||
public AppDeployment AppDeployment { get; set; } = null!;
|
||||
}
|
||||
2224
src/EntKube.Web/Data/Migrations/Postgres/20260521201858_AddExternalKeycloak.Designer.cs
generated
Normal file
2224
src/EntKube.Web/Data/Migrations/Postgres/20260521201858_AddExternalKeycloak.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,48 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace EntKube.Web.Data.Migrations.Postgres
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddExternalKeycloak : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AlterColumn<Guid>(
|
||||
name: "ClusterComponentId",
|
||||
table: "KeycloakComponentConfigs",
|
||||
type: "uuid",
|
||||
nullable: true,
|
||||
oldClrType: typeof(Guid),
|
||||
oldType: "uuid");
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "DisplayName",
|
||||
table: "KeycloakComponentConfigs",
|
||||
type: "character varying(200)",
|
||||
maxLength: 200,
|
||||
nullable: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "DisplayName",
|
||||
table: "KeycloakComponentConfigs");
|
||||
|
||||
migrationBuilder.AlterColumn<Guid>(
|
||||
name: "ClusterComponentId",
|
||||
table: "KeycloakComponentConfigs",
|
||||
type: "uuid",
|
||||
nullable: false,
|
||||
defaultValue: new Guid("00000000-0000-0000-0000-000000000000"),
|
||||
oldClrType: typeof(Guid),
|
||||
oldType: "uuid",
|
||||
oldNullable: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
2288
src/EntKube.Web/Data/Migrations/Postgres/20260522065948_AddKeycloakTheme.Designer.cs
generated
Normal file
2288
src/EntKube.Web/Data/Migrations/Postgres/20260522065948_AddKeycloakTheme.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,82 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace EntKube.Web.Data.Migrations.Postgres
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddKeycloakTheme : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<Guid>(
|
||||
name: "KeycloakThemeId",
|
||||
table: "KeycloakRealms",
|
||||
type: "uuid",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "KeycloakThemes",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
TenantId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
KeycloakComponentConfigId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
Name = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
|
||||
LoginTheme = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: true),
|
||||
AccountTheme = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: true),
|
||||
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_KeycloakThemes", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_KeycloakThemes_KeycloakComponentConfigs_KeycloakComponentCo~",
|
||||
column: x => x.KeycloakComponentConfigId,
|
||||
principalTable: "KeycloakComponentConfigs",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_KeycloakRealms_KeycloakThemeId",
|
||||
table: "KeycloakRealms",
|
||||
column: "KeycloakThemeId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_KeycloakThemes_KeycloakComponentConfigId_Name",
|
||||
table: "KeycloakThemes",
|
||||
columns: new[] { "KeycloakComponentConfigId", "Name" },
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.AddForeignKey(
|
||||
name: "FK_KeycloakRealms_KeycloakThemes_KeycloakThemeId",
|
||||
table: "KeycloakRealms",
|
||||
column: "KeycloakThemeId",
|
||||
principalTable: "KeycloakThemes",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.SetNull);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropForeignKey(
|
||||
name: "FK_KeycloakRealms_KeycloakThemes_KeycloakThemeId",
|
||||
table: "KeycloakRealms");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "KeycloakThemes");
|
||||
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_KeycloakRealms_KeycloakThemeId",
|
||||
table: "KeycloakRealms");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "KeycloakThemeId",
|
||||
table: "KeycloakRealms");
|
||||
}
|
||||
}
|
||||
}
|
||||
2454
src/EntKube.Web/Data/Migrations/Postgres/20260522091248_AddRegisteredPostgres.Designer.cs
generated
Normal file
2454
src/EntKube.Web/Data/Migrations/Postgres/20260522091248_AddRegisteredPostgres.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,190 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace EntKube.Web.Data.Migrations.Postgres
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddRegisteredPostgres : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<Guid>(
|
||||
name: "RegisteredPostgresDatabaseId",
|
||||
table: "VaultSecrets",
|
||||
type: "uuid",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<Guid>(
|
||||
name: "RegisteredPostgresDatabaseId",
|
||||
table: "KeycloakComponentConfigs",
|
||||
type: "uuid",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<Guid>(
|
||||
name: "RegisteredPostgresDatabaseId",
|
||||
table: "DatabaseBindings",
|
||||
type: "uuid",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "RegisteredPostgresInstances",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
TenantId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
KubernetesClusterId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
Name = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
|
||||
Namespace = table.Column<string>(type: "character varying(63)", maxLength: 63, nullable: false),
|
||||
ServiceName = table.Column<string>(type: "character varying(253)", maxLength: 253, nullable: false),
|
||||
Port = table.Column<int>(type: "integer", nullable: false),
|
||||
AdminPodName = table.Column<string>(type: "character varying(253)", maxLength: 253, nullable: false),
|
||||
AdminUsername = table.Column<string>(type: "character varying(63)", maxLength: 63, nullable: false),
|
||||
Notes = table.Column<string>(type: "character varying(1000)", maxLength: 1000, nullable: true),
|
||||
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_RegisteredPostgresInstances", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_RegisteredPostgresInstances_KubernetesClusters_KubernetesCl~",
|
||||
column: x => x.KubernetesClusterId,
|
||||
principalTable: "KubernetesClusters",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
table.ForeignKey(
|
||||
name: "FK_RegisteredPostgresInstances_Tenants_TenantId",
|
||||
column: x => x.TenantId,
|
||||
principalTable: "Tenants",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "RegisteredPostgresDatabases",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
RegisteredPostgresInstanceId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
Name = table.Column<string>(type: "character varying(63)", maxLength: 63, nullable: false),
|
||||
Owner = table.Column<string>(type: "character varying(63)", maxLength: 63, nullable: false),
|
||||
Status = table.Column<int>(type: "integer", nullable: false),
|
||||
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_RegisteredPostgresDatabases", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_RegisteredPostgresDatabases_RegisteredPostgresInstances_Reg~",
|
||||
column: x => x.RegisteredPostgresInstanceId,
|
||||
principalTable: "RegisteredPostgresInstances",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_VaultSecrets_RegisteredPostgresDatabaseId",
|
||||
table: "VaultSecrets",
|
||||
column: "RegisteredPostgresDatabaseId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_KeycloakComponentConfigs_RegisteredPostgresDatabaseId",
|
||||
table: "KeycloakComponentConfigs",
|
||||
column: "RegisteredPostgresDatabaseId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_DatabaseBindings_RegisteredPostgresDatabaseId",
|
||||
table: "DatabaseBindings",
|
||||
column: "RegisteredPostgresDatabaseId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_RegisteredPostgresDatabases_RegisteredPostgresInstanceId_Na~",
|
||||
table: "RegisteredPostgresDatabases",
|
||||
columns: new[] { "RegisteredPostgresInstanceId", "Name" },
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_RegisteredPostgresInstances_KubernetesClusterId",
|
||||
table: "RegisteredPostgresInstances",
|
||||
column: "KubernetesClusterId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_RegisteredPostgresInstances_TenantId_Name",
|
||||
table: "RegisteredPostgresInstances",
|
||||
columns: new[] { "TenantId", "Name" },
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.AddForeignKey(
|
||||
name: "FK_DatabaseBindings_RegisteredPostgresDatabases_RegisteredPost~",
|
||||
table: "DatabaseBindings",
|
||||
column: "RegisteredPostgresDatabaseId",
|
||||
principalTable: "RegisteredPostgresDatabases",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
|
||||
migrationBuilder.AddForeignKey(
|
||||
name: "FK_KeycloakComponentConfigs_RegisteredPostgresDatabases_Regist~",
|
||||
table: "KeycloakComponentConfigs",
|
||||
column: "RegisteredPostgresDatabaseId",
|
||||
principalTable: "RegisteredPostgresDatabases",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.SetNull);
|
||||
|
||||
migrationBuilder.AddForeignKey(
|
||||
name: "FK_VaultSecrets_RegisteredPostgresDatabases_RegisteredPostgres~",
|
||||
table: "VaultSecrets",
|
||||
column: "RegisteredPostgresDatabaseId",
|
||||
principalTable: "RegisteredPostgresDatabases",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropForeignKey(
|
||||
name: "FK_DatabaseBindings_RegisteredPostgresDatabases_RegisteredPost~",
|
||||
table: "DatabaseBindings");
|
||||
|
||||
migrationBuilder.DropForeignKey(
|
||||
name: "FK_KeycloakComponentConfigs_RegisteredPostgresDatabases_Regist~",
|
||||
table: "KeycloakComponentConfigs");
|
||||
|
||||
migrationBuilder.DropForeignKey(
|
||||
name: "FK_VaultSecrets_RegisteredPostgresDatabases_RegisteredPostgres~",
|
||||
table: "VaultSecrets");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "RegisteredPostgresDatabases");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "RegisteredPostgresInstances");
|
||||
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_VaultSecrets_RegisteredPostgresDatabaseId",
|
||||
table: "VaultSecrets");
|
||||
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_KeycloakComponentConfigs_RegisteredPostgresDatabaseId",
|
||||
table: "KeycloakComponentConfigs");
|
||||
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_DatabaseBindings_RegisteredPostgresDatabaseId",
|
||||
table: "DatabaseBindings");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "RegisteredPostgresDatabaseId",
|
||||
table: "VaultSecrets");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "RegisteredPostgresDatabaseId",
|
||||
table: "KeycloakComponentConfigs");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "RegisteredPostgresDatabaseId",
|
||||
table: "DatabaseBindings");
|
||||
}
|
||||
}
|
||||
}
|
||||
2505
src/EntKube.Web/Data/Migrations/Postgres/20260522110451_AddRegisteredPostgresDump.Designer.cs
generated
Normal file
2505
src/EntKube.Web/Data/Migrations/Postgres/20260522110451_AddRegisteredPostgresDump.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,60 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace EntKube.Web.Data.Migrations.Postgres
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddRegisteredPostgresDump : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "RegisteredPostgresDumps",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
RegisteredPostgresDatabaseId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
StorageLinkId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
S3Key = table.Column<string>(type: "character varying(1024)", maxLength: 1024, nullable: false),
|
||||
SizeBytes = table.Column<long>(type: "bigint", nullable: false),
|
||||
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_RegisteredPostgresDumps", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_RegisteredPostgresDumps_RegisteredPostgresDatabases_Registe~",
|
||||
column: x => x.RegisteredPostgresDatabaseId,
|
||||
principalTable: "RegisteredPostgresDatabases",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
table.ForeignKey(
|
||||
name: "FK_RegisteredPostgresDumps_StorageLinks_StorageLinkId",
|
||||
column: x => x.StorageLinkId,
|
||||
principalTable: "StorageLinks",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_RegisteredPostgresDumps_RegisteredPostgresDatabaseId",
|
||||
table: "RegisteredPostgresDumps",
|
||||
column: "RegisteredPostgresDatabaseId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_RegisteredPostgresDumps_StorageLinkId",
|
||||
table: "RegisteredPostgresDumps",
|
||||
column: "StorageLinkId");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "RegisteredPostgresDumps");
|
||||
}
|
||||
}
|
||||
}
|
||||
2517
src/EntKube.Web/Data/Migrations/Postgres/20260524194104_AddCnpgClusterVaultSecret.Designer.cs
generated
Normal file
2517
src/EntKube.Web/Data/Migrations/Postgres/20260524194104_AddCnpgClusterVaultSecret.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,50 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace EntKube.Web.Data.Migrations.Postgres
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddCnpgClusterVaultSecret : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<Guid>(
|
||||
name: "CnpgClusterId",
|
||||
table: "VaultSecrets",
|
||||
type: "uuid",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_VaultSecrets_CnpgClusterId",
|
||||
table: "VaultSecrets",
|
||||
column: "CnpgClusterId");
|
||||
|
||||
migrationBuilder.AddForeignKey(
|
||||
name: "FK_VaultSecrets_CnpgClusters_CnpgClusterId",
|
||||
table: "VaultSecrets",
|
||||
column: "CnpgClusterId",
|
||||
principalTable: "CnpgClusters",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropForeignKey(
|
||||
name: "FK_VaultSecrets_CnpgClusters_CnpgClusterId",
|
||||
table: "VaultSecrets");
|
||||
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_VaultSecrets_CnpgClusterId",
|
||||
table: "VaultSecrets");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "CnpgClusterId",
|
||||
table: "VaultSecrets");
|
||||
}
|
||||
}
|
||||
}
|
||||
2516
src/EntKube.Web/Data/Migrations/Postgres/20260525203011_KeycloakBackupStorageLinkNullable.Designer.cs
generated
Normal file
2516
src/EntKube.Web/Data/Migrations/Postgres/20260525203011_KeycloakBackupStorageLinkNullable.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,61 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace EntKube.Web.Data.Migrations.Postgres
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class KeycloakBackupStorageLinkNullable : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropForeignKey(
|
||||
name: "FK_KeycloakBackups_StorageLinks_StorageLinkId",
|
||||
table: "KeycloakBackups");
|
||||
|
||||
migrationBuilder.AlterColumn<Guid>(
|
||||
name: "StorageLinkId",
|
||||
table: "KeycloakBackups",
|
||||
type: "uuid",
|
||||
nullable: true,
|
||||
oldClrType: typeof(Guid),
|
||||
oldType: "uuid");
|
||||
|
||||
migrationBuilder.AddForeignKey(
|
||||
name: "FK_KeycloakBackups_StorageLinks_StorageLinkId",
|
||||
table: "KeycloakBackups",
|
||||
column: "StorageLinkId",
|
||||
principalTable: "StorageLinks",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.SetNull);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropForeignKey(
|
||||
name: "FK_KeycloakBackups_StorageLinks_StorageLinkId",
|
||||
table: "KeycloakBackups");
|
||||
|
||||
migrationBuilder.AlterColumn<Guid>(
|
||||
name: "StorageLinkId",
|
||||
table: "KeycloakBackups",
|
||||
type: "uuid",
|
||||
nullable: false,
|
||||
defaultValue: new Guid("00000000-0000-0000-0000-000000000000"),
|
||||
oldClrType: typeof(Guid),
|
||||
oldType: "uuid",
|
||||
oldNullable: true);
|
||||
|
||||
migrationBuilder.AddForeignKey(
|
||||
name: "FK_KeycloakBackups_StorageLinks_StorageLinkId",
|
||||
table: "KeycloakBackups",
|
||||
column: "StorageLinkId",
|
||||
principalTable: "StorageLinks",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
}
|
||||
}
|
||||
}
|
||||
2592
src/EntKube.Web/Data/Migrations/Postgres/20260526050057_AddHarborComponentConfig.Designer.cs
generated
Normal file
2592
src/EntKube.Web/Data/Migrations/Postgres/20260526050057_AddHarborComponentConfig.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,84 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace EntKube.Web.Data.Migrations.Postgres
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddHarborComponentConfig : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "HarborComponentConfigs",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
TenantId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
ClusterComponentId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
CnpgDatabaseId = table.Column<Guid>(type: "uuid", nullable: true),
|
||||
StorageLinkId = table.Column<Guid>(type: "uuid", nullable: true),
|
||||
AdminUsername = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
|
||||
RegistryUrl = table.Column<string>(type: "character varying(500)", maxLength: 500, nullable: true),
|
||||
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_HarborComponentConfigs", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_HarborComponentConfigs_ClusterComponents_ClusterComponentId",
|
||||
column: x => x.ClusterComponentId,
|
||||
principalTable: "ClusterComponents",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
table.ForeignKey(
|
||||
name: "FK_HarborComponentConfigs_CnpgDatabases_CnpgDatabaseId",
|
||||
column: x => x.CnpgDatabaseId,
|
||||
principalTable: "CnpgDatabases",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.SetNull);
|
||||
table.ForeignKey(
|
||||
name: "FK_HarborComponentConfigs_StorageLinks_StorageLinkId",
|
||||
column: x => x.StorageLinkId,
|
||||
principalTable: "StorageLinks",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.SetNull);
|
||||
table.ForeignKey(
|
||||
name: "FK_HarborComponentConfigs_Tenants_TenantId",
|
||||
column: x => x.TenantId,
|
||||
principalTable: "Tenants",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_HarborComponentConfigs_ClusterComponentId",
|
||||
table: "HarborComponentConfigs",
|
||||
column: "ClusterComponentId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_HarborComponentConfigs_CnpgDatabaseId",
|
||||
table: "HarborComponentConfigs",
|
||||
column: "CnpgDatabaseId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_HarborComponentConfigs_StorageLinkId",
|
||||
table: "HarborComponentConfigs",
|
||||
column: "StorageLinkId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_HarborComponentConfigs_TenantId",
|
||||
table: "HarborComponentConfigs",
|
||||
column: "TenantId");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "HarborComponentConfigs");
|
||||
}
|
||||
}
|
||||
}
|
||||
2596
src/EntKube.Web/Data/Migrations/Postgres/20260526113535_AddCnpgMaxBackups.Designer.cs
generated
Normal file
2596
src/EntKube.Web/Data/Migrations/Postgres/20260526113535_AddCnpgMaxBackups.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,29 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace EntKube.Web.Data.Migrations.Postgres
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddCnpgMaxBackups : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "MaxBackups",
|
||||
table: "CnpgClusters",
|
||||
type: "integer",
|
||||
nullable: false,
|
||||
defaultValue: 20);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "MaxBackups",
|
||||
table: "CnpgClusters");
|
||||
}
|
||||
}
|
||||
}
|
||||
2595
src/EntKube.Web/Data/Migrations/Postgres/20260526114839_FixCnpgMaxBackupsDefault.Designer.cs
generated
Normal file
2595
src/EntKube.Web/Data/Migrations/Postgres/20260526114839_FixCnpgMaxBackupsDefault.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,22 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace EntKube.Web.Data.Migrations.Postgres
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class FixCnpgMaxBackupsDefault : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.Sql(
|
||||
"UPDATE \"CnpgClusters\" SET \"MaxBackups\" = 20 WHERE \"MaxBackups\" = 0");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
2599
src/EntKube.Web/Data/Migrations/Postgres/20260526121548_AddMongoMaxBackups.Designer.cs
generated
Normal file
2599
src/EntKube.Web/Data/Migrations/Postgres/20260526121548_AddMongoMaxBackups.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,29 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace EntKube.Web.Data.Migrations.Postgres
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddMongoMaxBackups : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "MaxBackups",
|
||||
table: "MongoClusters",
|
||||
type: "integer",
|
||||
nullable: false,
|
||||
defaultValue: 20);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "MaxBackups",
|
||||
table: "MongoClusters");
|
||||
}
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user