first commit ish
This commit is contained in:
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user