Mark subproject as dirty in CMKS - Infra
This commit is contained in:
325
src/EntKube.Web/Components/Pages/Tenants/AlertPanel.razor
Normal file
325
src/EntKube.Web/Components/Pages/Tenants/AlertPanel.razor
Normal file
@@ -0,0 +1,325 @@
|
||||
@using EntKube.Web.Services
|
||||
|
||||
@* ═══════════════════════════════════════════════════════════════════════════
|
||||
AlertPanel — shows active alerts from Alertmanager, allows creating silences,
|
||||
and displays existing silences. The central tool for operations teams to
|
||||
monitor and manage alert noise.
|
||||
═══════════════════════════════════════════════════════════════════════════ *@
|
||||
|
||||
<div class="card border-0 shadow-sm">
|
||||
<div class="card-header bg-white d-flex justify-content-between align-items-center">
|
||||
<span class="fw-medium">
|
||||
<i class="bi bi-bell me-2 text-warning"></i>Alerts
|
||||
@if (alerts is not null && alerts.Count > 0)
|
||||
{
|
||||
<span class="badge bg-danger ms-1">@alerts.Count</span>
|
||||
}
|
||||
</span>
|
||||
<div class="d-flex gap-1">
|
||||
<button class="btn btn-sm @(view == "alerts" ? "btn-primary" : "btn-outline-primary")"
|
||||
@onclick='() => view = "alerts"'>
|
||||
<i class="bi bi-bell me-1"></i>Active
|
||||
</button>
|
||||
<button class="btn btn-sm @(view == "silences" ? "btn-primary" : "btn-outline-primary")"
|
||||
@onclick="LoadSilences">
|
||||
<i class="bi bi-bell-slash me-1"></i>Silences
|
||||
</button>
|
||||
<button class="btn btn-sm btn-outline-secondary" @onclick="RefreshAlerts" disabled="@loading">
|
||||
<i class="bi bi-arrow-clockwise"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
@if (loading)
|
||||
{
|
||||
<div class="text-center py-3">
|
||||
<div class="spinner-border spinner-border-sm text-primary"></div>
|
||||
</div>
|
||||
}
|
||||
else if (!string.IsNullOrEmpty(errorMessage))
|
||||
{
|
||||
<div class="alert alert-warning m-3 mb-0">
|
||||
<i class="bi bi-exclamation-triangle me-1"></i>@errorMessage
|
||||
</div>
|
||||
}
|
||||
else if (view == "alerts")
|
||||
{
|
||||
@* ── Active Alerts ── *@
|
||||
@if (alerts is null || alerts.Count == 0)
|
||||
{
|
||||
<div class="text-center py-4">
|
||||
<i class="bi bi-check-circle text-success" style="font-size: 2rem;"></i>
|
||||
<p class="text-muted mt-2 mb-0">No active alerts. All clear!</p>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="list-group list-group-flush">
|
||||
@foreach (AlertInfo alert in alerts.OrderByDescending(a => a.Severity == "critical")
|
||||
.ThenByDescending(a => a.Severity == "warning"))
|
||||
{
|
||||
<div class="list-group-item px-3 py-2">
|
||||
<div class="d-flex align-items-start justify-content-between">
|
||||
<div class="flex-grow-1">
|
||||
<div class="d-flex align-items-center gap-2 mb-1">
|
||||
<span class="badge @GetSeverityBadgeClass(alert.Severity)">@alert.Severity</span>
|
||||
<strong class="small">@alert.Name</strong>
|
||||
<span class="badge bg-light text-dark border">@alert.State</span>
|
||||
</div>
|
||||
@if (!string.IsNullOrEmpty(alert.Summary))
|
||||
{
|
||||
<p class="mb-1 small text-muted">@alert.Summary</p>
|
||||
}
|
||||
<div class="d-flex flex-wrap gap-1">
|
||||
@foreach (KeyValuePair<string, string> label in alert.Labels
|
||||
.Where(l => l.Key != "alertname" && l.Key != "severity"))
|
||||
{
|
||||
<span class="badge bg-light text-dark border-0 small">
|
||||
<code>@label.Key</code>=@label.Value
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
<small class="text-muted">
|
||||
<i class="bi bi-clock me-1"></i>Started @FormatTimeAgo(alert.StartsAt)
|
||||
</small>
|
||||
</div>
|
||||
<button class="btn btn-sm btn-outline-warning" title="Silence this alert"
|
||||
@onclick="() => StartSilence(alert)">
|
||||
<i class="bi bi-bell-slash"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
else if (view == "silences")
|
||||
{
|
||||
@* ── Silences ── *@
|
||||
@if (silences is null || silences.Count == 0)
|
||||
{
|
||||
<div class="text-center py-4">
|
||||
<i class="bi bi-bell-slash text-muted" style="font-size: 2rem;"></i>
|
||||
<p class="text-muted mt-2 mb-0">No silences configured.</p>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="list-group list-group-flush">
|
||||
@foreach (SilenceInfo silence in silences.Where(s => s.State == "active"))
|
||||
{
|
||||
<div class="list-group-item px-3 py-2">
|
||||
<div class="d-flex align-items-start justify-content-between">
|
||||
<div>
|
||||
<div class="d-flex align-items-center gap-2 mb-1">
|
||||
<span class="badge bg-warning text-dark">active</span>
|
||||
<small class="fw-medium">@silence.Comment</small>
|
||||
</div>
|
||||
<div class="d-flex flex-wrap gap-1 mb-1">
|
||||
@foreach (SilenceMatcher matcher in silence.Matchers)
|
||||
{
|
||||
<span class="badge bg-light text-dark border small">
|
||||
@matcher.Name @(matcher.IsEqual ? "=" : "!=") @(matcher.IsRegex ? "~" : "")@matcher.Value
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
<small class="text-muted">
|
||||
By @silence.CreatedBy · Expires @silence.EndsAt.ToString("g")
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
@* ── Create Silence Form ── *@
|
||||
@if (showCreateSilence)
|
||||
{
|
||||
<div class="border-top p-3">
|
||||
<h6 class="small fw-bold mb-2"><i class="bi bi-bell-slash me-1"></i>Create Silence</h6>
|
||||
<div class="row g-2 mb-2">
|
||||
<div class="col-md-4">
|
||||
<label class="form-label small mb-0">Matcher (label=value)</label>
|
||||
<input type="text" class="form-control form-control-sm" placeholder="alertname=HighCPU"
|
||||
@bind="silenceMatcherInput" @bind:event="oninput" />
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label small mb-0">Duration</label>
|
||||
<select class="form-select form-select-sm" @bind="silenceDurationHours">
|
||||
<option value="1">1 hour</option>
|
||||
<option value="2">2 hours</option>
|
||||
<option value="4">4 hours</option>
|
||||
<option value="8">8 hours</option>
|
||||
<option value="24">24 hours</option>
|
||||
<option value="72">3 days</option>
|
||||
<option value="168">7 days</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-5">
|
||||
<label class="form-label small mb-0">Comment</label>
|
||||
<input type="text" class="form-control form-control-sm" placeholder="Reason for silence"
|
||||
@bind="silenceComment" @bind:event="oninput" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="d-flex gap-2">
|
||||
<button class="btn btn-sm btn-warning" @onclick="SubmitSilence"
|
||||
disabled="@(string.IsNullOrWhiteSpace(silenceMatcherInput) || string.IsNullOrWhiteSpace(silenceComment))">
|
||||
<i class="bi bi-bell-slash me-1"></i>Create Silence
|
||||
</button>
|
||||
<button class="btn btn-sm btn-outline-secondary" @onclick="CancelSilence">Cancel</button>
|
||||
</div>
|
||||
@if (!string.IsNullOrEmpty(silenceError))
|
||||
{
|
||||
<div class="text-danger small mt-1">@silenceError</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@code {
|
||||
[Parameter] public Guid ClusterId { get; set; }
|
||||
[Parameter] public PrometheusService PrometheusService { get; set; } = null!;
|
||||
|
||||
private List<AlertInfo>? alerts;
|
||||
private List<SilenceInfo>? silences;
|
||||
private string view = "alerts";
|
||||
private bool loading;
|
||||
private string? errorMessage;
|
||||
|
||||
// Silence form
|
||||
private bool showCreateSilence;
|
||||
private string silenceMatcherInput = "";
|
||||
private int silenceDurationHours = 2;
|
||||
private string silenceComment = "";
|
||||
private string? silenceError;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
await RefreshAlerts();
|
||||
}
|
||||
|
||||
private async Task RefreshAlerts()
|
||||
{
|
||||
loading = true;
|
||||
errorMessage = null;
|
||||
StateHasChanged();
|
||||
|
||||
KubernetesOperationResult<List<AlertInfo>> result =
|
||||
await PrometheusService.GetAlertsAsync(ClusterId);
|
||||
|
||||
if (result.IsSuccess)
|
||||
{
|
||||
alerts = result.Data;
|
||||
}
|
||||
else
|
||||
{
|
||||
errorMessage = result.Error;
|
||||
}
|
||||
|
||||
loading = false;
|
||||
}
|
||||
|
||||
private async Task LoadSilences()
|
||||
{
|
||||
view = "silences";
|
||||
loading = true;
|
||||
errorMessage = null;
|
||||
StateHasChanged();
|
||||
|
||||
KubernetesOperationResult<List<SilenceInfo>> result =
|
||||
await PrometheusService.GetSilencesAsync(ClusterId);
|
||||
|
||||
if (result.IsSuccess)
|
||||
{
|
||||
silences = result.Data;
|
||||
}
|
||||
else
|
||||
{
|
||||
errorMessage = result.Error;
|
||||
}
|
||||
|
||||
loading = false;
|
||||
}
|
||||
|
||||
private void StartSilence(AlertInfo alert)
|
||||
{
|
||||
showCreateSilence = true;
|
||||
silenceMatcherInput = $"alertname={alert.Name}";
|
||||
silenceComment = "";
|
||||
silenceError = null;
|
||||
}
|
||||
|
||||
private void CancelSilence()
|
||||
{
|
||||
showCreateSilence = false;
|
||||
silenceMatcherInput = "";
|
||||
silenceComment = "";
|
||||
silenceError = null;
|
||||
}
|
||||
|
||||
private async Task SubmitSilence()
|
||||
{
|
||||
silenceError = null;
|
||||
|
||||
// Parse matcher input "label=value" into a SilenceMatcher.
|
||||
|
||||
string[] parts = silenceMatcherInput.Split('=', 2);
|
||||
if (parts.Length != 2 || string.IsNullOrWhiteSpace(parts[0]))
|
||||
{
|
||||
silenceError = "Matcher format: label=value";
|
||||
return;
|
||||
}
|
||||
|
||||
List<SilenceMatcher> matchers =
|
||||
[
|
||||
new SilenceMatcher { Name = parts[0].Trim(), Value = parts[1].Trim(), IsEqual = true }
|
||||
];
|
||||
|
||||
KubernetesOperationResult result = await PrometheusService.CreateSilenceAsync(
|
||||
ClusterId,
|
||||
silenceComment,
|
||||
"entkube-user",
|
||||
TimeSpan.FromHours(silenceDurationHours),
|
||||
matchers);
|
||||
|
||||
if (result.IsSuccess)
|
||||
{
|
||||
showCreateSilence = false;
|
||||
silenceMatcherInput = "";
|
||||
silenceComment = "";
|
||||
await LoadSilences();
|
||||
}
|
||||
else
|
||||
{
|
||||
silenceError = result.Error;
|
||||
}
|
||||
}
|
||||
|
||||
private static string GetSeverityBadgeClass(string severity) => severity switch
|
||||
{
|
||||
"critical" => "bg-danger",
|
||||
"warning" => "bg-warning text-dark",
|
||||
"info" => "bg-info text-dark",
|
||||
_ => "bg-secondary"
|
||||
};
|
||||
|
||||
private static string FormatTimeAgo(DateTime time)
|
||||
{
|
||||
if (time == DateTime.MinValue)
|
||||
{
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
TimeSpan ago = DateTime.UtcNow - time;
|
||||
return ago switch
|
||||
{
|
||||
{ TotalMinutes: < 1 } => "just now",
|
||||
{ TotalMinutes: < 60 } => $"{(int)ago.TotalMinutes}m ago",
|
||||
{ TotalHours: < 24 } => $"{(int)ago.TotalHours}h ago",
|
||||
_ => $"{(int)ago.TotalDays}d ago"
|
||||
};
|
||||
}
|
||||
}
|
||||
840
src/EntKube.Web/Components/Pages/Tenants/AppDetail.razor
Normal file
840
src/EntKube.Web/Components/Pages/Tenants/AppDetail.razor
Normal file
@@ -0,0 +1,840 @@
|
||||
@using EntKube.Web.Data
|
||||
@using EntKube.Web.Services
|
||||
@using Microsoft.EntityFrameworkCore
|
||||
@inject TenantService TenantService
|
||||
@inject DeploymentService DeploymentService
|
||||
|
||||
@* ═══════════════════════════════════════════════════════════════════
|
||||
App Detail — a full-page view for a single application.
|
||||
Shows app info, environment links, and is the extension point
|
||||
for future app-level features (config, deployments, secrets, etc.).
|
||||
═══════════════════════════════════════════════════════════════════ *@
|
||||
|
||||
@* --- Back button --- *@
|
||||
<nav class="mb-3">
|
||||
<button class="btn btn-sm btn-link text-decoration-none p-0" @onclick="OnBack">
|
||||
<i class="bi bi-arrow-left me-1"></i>Back to @CustomerName
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
@* --- App header --- *@
|
||||
<div class="card shadow-sm mb-4">
|
||||
<div class="card-body">
|
||||
<div class="d-flex align-items-start justify-content-between">
|
||||
<div class="d-flex align-items-start">
|
||||
<i class="bi bi-app-indicator fs-3 me-3 text-primary"></i>
|
||||
<div>
|
||||
<h4 class="mb-1">@App.Name</h4>
|
||||
<div class="d-flex flex-wrap gap-2 text-muted small">
|
||||
<span><i class="bi bi-person me-1"></i>@CustomerName</span>
|
||||
<span><i class="bi bi-calendar me-1"></i>Created @App.CreatedAt.ToString("MMM d, yyyy")</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (!confirmDelete)
|
||||
{
|
||||
<button class="btn btn-sm btn-outline-danger" @onclick="() => confirmDelete = true" title="Delete app">
|
||||
<i class="bi bi-trash me-1"></i>Delete
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (confirmDelete)
|
||||
{
|
||||
<div class="mt-3 p-2 bg-danger bg-opacity-10 rounded d-flex align-items-center justify-content-between">
|
||||
<span class="text-danger small">
|
||||
<i class="bi bi-exclamation-triangle me-1"></i>
|
||||
Permanently delete <strong>@App.Name</strong> and all its data?
|
||||
</span>
|
||||
<div>
|
||||
<button class="btn btn-sm btn-danger me-1" @onclick="DeleteApp">Yes, delete</button>
|
||||
<button class="btn btn-sm btn-outline-secondary" @onclick="() => confirmDelete = false">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@* --- App sub-sections (tabs for future expansion) --- *@
|
||||
<ul class="nav nav-pills mb-3 gap-1">
|
||||
<li class="nav-item">
|
||||
<button class="nav-link @(section == "environments" ? "active" : "")" @onclick='() => section = "environments"'>
|
||||
<i class="bi bi-layers me-1"></i>Environments
|
||||
</button>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<button class="nav-link @(section == "deployments" ? "active" : "")" @onclick="SwitchToDeployments">
|
||||
<i class="bi bi-rocket-takeoff me-1"></i>Deployments
|
||||
@if (deployments is not null && deployments.Count > 0)
|
||||
{
|
||||
<span class="badge bg-primary ms-1">@deployments.Count</span>
|
||||
}
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
@if (section == "environments")
|
||||
{
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-header bg-white py-2">
|
||||
<div class="d-flex align-items-center">
|
||||
<i class="bi bi-layers me-2 text-primary"></i>
|
||||
<strong>Environment Links</strong>
|
||||
</div>
|
||||
<small class="text-muted">Select which environments this app is deployed to.</small>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
@if (environments is null)
|
||||
{
|
||||
<div class="text-center py-3">
|
||||
<div class="spinner-border spinner-border-sm text-primary" role="status"></div>
|
||||
</div>
|
||||
}
|
||||
else if (environments.Count == 0)
|
||||
{
|
||||
<div class="text-center py-4">
|
||||
<i class="bi bi-layers text-muted" style="font-size: 2rem;"></i>
|
||||
<p class="text-muted small mt-2 mb-0">No environments exist for this tenant yet. Create environments first.</p>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="row row-cols-1 row-cols-md-2 row-cols-lg-3 g-2">
|
||||
@foreach (Data.Environment env in environments)
|
||||
{
|
||||
bool isLinked = linkedEnvIds.Contains(env.Id);
|
||||
|
||||
<div class="col">
|
||||
<div class="card @(isLinked ? "border-success bg-success bg-opacity-10" : "border-light")"
|
||||
role="button" style="cursor: pointer;"
|
||||
@onclick="() => ToggleEnvironment(env.Id, isLinked)">
|
||||
<div class="card-body py-2 d-flex align-items-center">
|
||||
<i class="bi @(isLinked ? "bi-check-circle-fill text-success" : "bi-circle text-muted") me-2 fs-5"></i>
|
||||
<div class="flex-grow-1">
|
||||
<span class="fw-medium">@env.Name</span>
|
||||
</div>
|
||||
@if (isLinked)
|
||||
{
|
||||
<span class="badge bg-success">Active</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@* ═══════════════════════════════════════════════════════════════════
|
||||
Deployments Tab — manage deployments (Manual, YAML, Helm) targeting
|
||||
specific clusters. Shows ArgoCD-style sync/health status.
|
||||
═══════════════════════════════════════════════════════════════════ *@
|
||||
@if (section == "deployments")
|
||||
{
|
||||
@* --- Deployment detail view (drill-down into a single deployment) --- *@
|
||||
@if (selectedDeployment is not null)
|
||||
{
|
||||
<nav class="mb-3">
|
||||
<button class="btn btn-sm btn-link text-decoration-none p-0" @onclick="() => selectedDeployment = null">
|
||||
<i class="bi bi-arrow-left me-1"></i>Back to deployments
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<div class="card shadow-sm mb-3">
|
||||
<div class="card-body">
|
||||
<div class="d-flex align-items-start justify-content-between">
|
||||
<div>
|
||||
<h5 class="mb-1">
|
||||
<i class="bi bi-rocket-takeoff me-2 text-primary"></i>@selectedDeployment.Name
|
||||
</h5>
|
||||
<div class="d-flex flex-wrap gap-2 text-muted small">
|
||||
<span>@GetTypeBadge(selectedDeployment.Type)</span>
|
||||
<span><i class="bi bi-layers me-1"></i>@selectedDeployment.Environment?.Name</span>
|
||||
<span><i class="bi bi-hdd-network me-1"></i>@selectedDeployment.Cluster?.Name</span>
|
||||
<span><i class="bi bi-box me-1"></i>@selectedDeployment.Namespace</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="d-flex align-items-start gap-1">
|
||||
@GetSyncBadge(selectedDeployment.SyncStatus)
|
||||
@GetHealthBadge(selectedDeployment.HealthStatus)
|
||||
|
||||
@if (!confirmDeleteDeployment)
|
||||
{
|
||||
<button class="btn btn-sm btn-outline-danger ms-2" @onclick="() => confirmDeleteDeployment = true" title="Delete deployment">
|
||||
<i class="bi bi-trash"></i>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (confirmDeleteDeployment)
|
||||
{
|
||||
<div class="mt-3 p-2 bg-danger bg-opacity-10 rounded d-flex align-items-center justify-content-between">
|
||||
<span class="text-danger small">
|
||||
<i class="bi bi-exclamation-triangle me-1"></i>
|
||||
Permanently delete <strong>@selectedDeployment.Name</strong> and all its manifests and resources?
|
||||
</span>
|
||||
<div>
|
||||
<button class="btn btn-sm btn-danger me-1" @onclick="DeleteDeployment">Yes, delete</button>
|
||||
<button class="btn btn-sm btn-outline-secondary" @onclick="() => confirmDeleteDeployment = false">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (selectedDeployment.Type == DeploymentType.HelmChart)
|
||||
{
|
||||
<div class="mt-3 p-2 bg-light rounded">
|
||||
<small class="text-muted">
|
||||
<i class="bi bi-box-seam me-1"></i>
|
||||
@selectedDeployment.HelmChartName
|
||||
<span class="mx-1">@selectedDeployment.HelmChartVersion</span>
|
||||
<span class="text-muted">from @selectedDeployment.HelmRepoUrl</span>
|
||||
</small>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@* --- Sub-tabs: Manifests / Resources / Helm Values --- *@
|
||||
<ul class="nav nav-tabs mb-3">
|
||||
@if (selectedDeployment.Type != DeploymentType.HelmChart)
|
||||
{
|
||||
<li class="nav-item">
|
||||
<button class="nav-link @(deploymentSection == "manifests" ? "active" : "")"
|
||||
@onclick='() => deploymentSection = "manifests"'>
|
||||
<i class="bi bi-file-earmark-code me-1"></i>Manifests
|
||||
</button>
|
||||
</li>
|
||||
}
|
||||
|
||||
@if (selectedDeployment.Type == DeploymentType.HelmChart)
|
||||
{
|
||||
<li class="nav-item">
|
||||
<button class="nav-link @(deploymentSection == "helm-values" ? "active" : "")"
|
||||
@onclick='() => deploymentSection = "helm-values"'>
|
||||
<i class="bi bi-sliders me-1"></i>Values
|
||||
</button>
|
||||
</li>
|
||||
}
|
||||
|
||||
<li class="nav-item">
|
||||
<button class="nav-link @(deploymentSection == "resources" ? "active" : "")"
|
||||
@onclick="OpenResourcesTab">
|
||||
<i class="bi bi-diagram-3 me-1"></i>Resources
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
@* ── Manifests sub-tab ── *@
|
||||
@if (deploymentSection == "manifests")
|
||||
{
|
||||
<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-file-earmark-code me-2 text-primary"></i>
|
||||
<strong>Manifests</strong>
|
||||
</div>
|
||||
<button class="btn btn-sm btn-outline-primary" @onclick="() => showAddManifest = !showAddManifest">
|
||||
<i class="bi bi-plus me-1"></i>Add Manifest
|
||||
</button>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
@if (showAddManifest)
|
||||
{
|
||||
<div class="card border-primary mb-3">
|
||||
<div class="card-body">
|
||||
<h6 class="mb-3"><i class="bi bi-plus-circle me-1"></i>Add YAML Manifest</h6>
|
||||
<div class="row g-2 mb-2">
|
||||
<div class="col-md-4">
|
||||
<label class="form-label small">Kind</label>
|
||||
<input class="form-control form-control-sm" @bind="newManifestKind" placeholder="e.g. Deployment" />
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label small">Name</label>
|
||||
<input class="form-control form-control-sm" @bind="newManifestName" placeholder="e.g. billing-api" />
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label small">Sort Order</label>
|
||||
<input class="form-control form-control-sm" type="number" @bind="newManifestSortOrder" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-2">
|
||||
<label class="form-label small">YAML Content</label>
|
||||
<textarea class="form-control form-control-sm font-monospace" rows="8"
|
||||
@bind="newManifestYaml" placeholder="apiVersion: v1 kind: Service ..."></textarea>
|
||||
</div>
|
||||
<div class="d-flex gap-1">
|
||||
<button class="btn btn-sm btn-primary" @onclick="AddManifest"
|
||||
disabled="@(string.IsNullOrWhiteSpace(newManifestKind) || string.IsNullOrWhiteSpace(newManifestYaml))">
|
||||
Add
|
||||
</button>
|
||||
<button class="btn btn-sm btn-outline-secondary" @onclick="() => showAddManifest = false">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (manifests is null)
|
||||
{
|
||||
<div class="text-center py-3">
|
||||
<div class="spinner-border spinner-border-sm text-primary" role="status"></div>
|
||||
</div>
|
||||
}
|
||||
else if (manifests.Count == 0)
|
||||
{
|
||||
<div class="text-center py-4">
|
||||
<i class="bi bi-file-earmark-code text-muted" style="font-size: 2rem;"></i>
|
||||
<p class="text-muted small mt-2 mb-0">No manifests yet. Add YAML manifests to define what gets deployed.</p>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
@foreach (DeploymentManifest manifest in manifests)
|
||||
{
|
||||
<div class="card mb-2">
|
||||
<div class="card-header bg-white py-2 d-flex align-items-center justify-content-between">
|
||||
<div>
|
||||
<span class="badge bg-secondary me-1">@manifest.Kind</span>
|
||||
<span class="fw-medium">@manifest.Name</span>
|
||||
<span class="text-muted small ms-2">#@manifest.SortOrder</span>
|
||||
</div>
|
||||
<div class="d-flex gap-1">
|
||||
@if (editingManifestId == manifest.Id)
|
||||
{
|
||||
<button class="btn btn-sm btn-primary" @onclick="() => SaveManifest(manifest.Id)">Save</button>
|
||||
<button class="btn btn-sm btn-outline-secondary" @onclick="() => editingManifestId = null">Cancel</button>
|
||||
}
|
||||
else
|
||||
{
|
||||
<button class="btn btn-sm btn-outline-primary" @onclick="() => StartEditManifest(manifest)">
|
||||
<i class="bi bi-pencil"></i>
|
||||
</button>
|
||||
<button class="btn btn-sm btn-outline-danger" @onclick="() => DeleteManifest(manifest.Id)">
|
||||
<i class="bi bi-trash"></i>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (editingManifestId == manifest.Id)
|
||||
{
|
||||
<div class="card-body p-2">
|
||||
<textarea class="form-control form-control-sm font-monospace" rows="10"
|
||||
@bind="editManifestYaml"></textarea>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="card-body p-2">
|
||||
<pre class="mb-0 small" style="max-height: 200px; overflow-y: auto;">@manifest.YamlContent</pre>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@* ── Helm Values sub-tab ── *@
|
||||
@if (deploymentSection == "helm-values")
|
||||
{
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-header bg-white py-2">
|
||||
<i class="bi bi-sliders me-2 text-primary"></i>
|
||||
<strong>Helm Values</strong>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p class="text-muted small mb-2">Override the chart's default values (YAML format).</p>
|
||||
<textarea class="form-control font-monospace" rows="12"
|
||||
@bind="helmValuesYaml"
|
||||
placeholder="replicas: 3 persistence: size: 10Gi"></textarea>
|
||||
<button class="btn btn-sm btn-primary mt-2" @onclick="SaveHelmValues">
|
||||
<i class="bi bi-save me-1"></i>Save Values
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@* ── Resources sub-tab (ArgoCD-style tree) ── *@
|
||||
@if (deploymentSection == "resources")
|
||||
{
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-header bg-white py-2">
|
||||
<i class="bi bi-diagram-3 me-2 text-primary"></i>
|
||||
<strong>Resource Tree</strong>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
@if (resourceTree is null)
|
||||
{
|
||||
<div class="text-center py-3">
|
||||
<div class="spinner-border spinner-border-sm text-primary" role="status"></div>
|
||||
</div>
|
||||
}
|
||||
else if (resourceTree.Count == 0)
|
||||
{
|
||||
<div class="text-center py-4">
|
||||
<i class="bi bi-diagram-3 text-muted" style="font-size: 2rem;"></i>
|
||||
<p class="text-muted small mt-2 mb-0">No tracked resources yet. Resources appear here after the deployment syncs to the cluster.</p>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
@foreach (DeploymentResource root in resourceTree)
|
||||
{
|
||||
<div class="mb-2">
|
||||
@RenderResource(root, 0)
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
@* --- Deployment list view --- *@
|
||||
<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-rocket-takeoff me-2 text-primary"></i>
|
||||
<strong>Deployments</strong>
|
||||
</div>
|
||||
<button class="btn btn-sm btn-outline-primary" @onclick="() => showCreateDeployment = !showCreateDeployment">
|
||||
<i class="bi bi-plus me-1"></i>New Deployment
|
||||
</button>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
@* --- Create deployment form --- *@
|
||||
@if (showCreateDeployment)
|
||||
{
|
||||
<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>
|
||||
<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. billing-api-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>
|
||||
|
||||
<div class="row g-2 mb-2">
|
||||
<div class="col-md-4">
|
||||
<label class="form-label small">Environment</label>
|
||||
<select class="form-select form-select-sm" @bind="newDeployEnvId">
|
||||
<option value="">Select...</option>
|
||||
@if (environments is not null)
|
||||
{
|
||||
@foreach (Data.Environment env in environments)
|
||||
{
|
||||
<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="">Select...</option>
|
||||
@if (clusters is not null)
|
||||
{
|
||||
@foreach (KubernetesCluster cluster in clusters.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. billing" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (newDeployType == DeploymentType.HelmChart)
|
||||
{
|
||||
<div class="row g-2 mb-2">
|
||||
<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.bitnami.com/bitnami" />
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label small">Chart Name</label>
|
||||
<input class="form-control form-control-sm" @bind="newHelmChartName"
|
||||
placeholder="e.g. postgresql" />
|
||||
</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. 15.5.0" />
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (!string.IsNullOrEmpty(createDeployError))
|
||||
{
|
||||
<div class="alert alert-danger py-1 small mb-2">@createDeployError</div>
|
||||
}
|
||||
|
||||
<div class="d-flex gap-1">
|
||||
<button class="btn btn-sm btn-primary" @onclick="CreateDeployment"
|
||||
disabled="@(string.IsNullOrWhiteSpace(newDeployName) || newDeployEnvId == Guid.Empty || newDeployClusterId == Guid.Empty)">
|
||||
Create
|
||||
</button>
|
||||
<button class="btn btn-sm btn-outline-secondary" @onclick="() => showCreateDeployment = false">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@* --- Deployment list --- *@
|
||||
@if (deployments is null)
|
||||
{
|
||||
<div class="text-center py-3">
|
||||
<div class="spinner-border spinner-border-sm text-primary" role="status"></div>
|
||||
</div>
|
||||
}
|
||||
else if (deployments.Count == 0)
|
||||
{
|
||||
<div class="text-center py-4">
|
||||
<i class="bi bi-rocket-takeoff text-muted" style="font-size: 2rem;"></i>
|
||||
<p class="text-muted small mt-2 mb-0">No deployments yet. Create one to start deploying to Kubernetes.</p>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="row row-cols-1 row-cols-lg-2 g-2">
|
||||
@foreach (AppDeployment deploy in deployments)
|
||||
{
|
||||
<div class="col">
|
||||
<div class="card border-light h-100" role="button" style="cursor: pointer;"
|
||||
@onclick="() => SelectDeployment(deploy)">
|
||||
<div class="card-body py-2">
|
||||
<div class="d-flex align-items-center justify-content-between mb-1">
|
||||
<span class="fw-medium">
|
||||
<i class="bi bi-rocket-takeoff me-1 text-primary"></i>@deploy.Name
|
||||
</span>
|
||||
<div class="d-flex gap-1">
|
||||
@GetSyncBadge(deploy.SyncStatus)
|
||||
@GetHealthBadge(deploy.HealthStatus)
|
||||
</div>
|
||||
</div>
|
||||
<div class="d-flex flex-wrap gap-2 text-muted small">
|
||||
@GetTypeBadge(deploy.Type)
|
||||
<span><i class="bi bi-layers me-1"></i>@deploy.Environment?.Name</span>
|
||||
<span><i class="bi bi-hdd-network me-1"></i>@deploy.Cluster?.Name</span>
|
||||
<span><i class="bi bi-box me-1"></i>@deploy.Namespace</span>
|
||||
</div>
|
||||
|
||||
@if (!string.IsNullOrEmpty(deploy.StatusMessage))
|
||||
{
|
||||
<div class="text-muted small mt-1 text-truncate">@deploy.StatusMessage</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
@code {
|
||||
[Parameter] public required Data.App App { get; set; }
|
||||
[Parameter] public Guid TenantId { get; set; }
|
||||
[Parameter] public string CustomerName { get; set; } = "";
|
||||
[Parameter] public EventCallback OnBack { get; set; }
|
||||
[Parameter] public EventCallback OnDeleted { get; set; }
|
||||
|
||||
private string section = "environments";
|
||||
private bool confirmDelete;
|
||||
|
||||
// Environments
|
||||
private List<Data.Environment>? environments;
|
||||
private HashSet<Guid> linkedEnvIds = new();
|
||||
|
||||
// Deployments
|
||||
private List<AppDeployment>? deployments;
|
||||
private List<KubernetesCluster>? clusters;
|
||||
private AppDeployment? selectedDeployment;
|
||||
private string deploymentSection = "manifests";
|
||||
private bool confirmDeleteDeployment;
|
||||
|
||||
// Create deployment form
|
||||
private bool showCreateDeployment;
|
||||
private string newDeployName = "";
|
||||
private DeploymentType newDeployType = DeploymentType.Manual;
|
||||
private Guid newDeployEnvId;
|
||||
private Guid newDeployClusterId;
|
||||
private string newDeployNamespace = "";
|
||||
private string newHelmRepoUrl = "";
|
||||
private string newHelmChartName = "";
|
||||
private string newHelmChartVersion = "";
|
||||
private string? createDeployError;
|
||||
|
||||
// Manifest management
|
||||
private List<DeploymentManifest>? manifests;
|
||||
private bool showAddManifest;
|
||||
private string newManifestKind = "";
|
||||
private string newManifestName = "";
|
||||
private string newManifestYaml = "";
|
||||
private int newManifestSortOrder;
|
||||
private Guid? editingManifestId;
|
||||
private string editManifestYaml = "";
|
||||
|
||||
// Helm values
|
||||
private string helmValuesYaml = "";
|
||||
|
||||
// Resource tree
|
||||
private List<DeploymentResource>? resourceTree;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
await LoadEnvironments();
|
||||
}
|
||||
|
||||
// ──────── App ────────
|
||||
|
||||
private async Task DeleteApp()
|
||||
{
|
||||
await TenantService.DeleteAppAsync(App.Id);
|
||||
await OnDeleted.InvokeAsync();
|
||||
}
|
||||
|
||||
// ──────── Environments ────────
|
||||
|
||||
private async Task LoadEnvironments()
|
||||
{
|
||||
environments = await TenantService.GetEnvironmentsAsync(TenantId);
|
||||
|
||||
// Refresh the app to get current environment links.
|
||||
List<Data.App> apps = await TenantService.GetAppsAsync(App.CustomerId);
|
||||
Data.App? freshApp = apps.FirstOrDefault(a => a.Id == App.Id);
|
||||
|
||||
linkedEnvIds = freshApp?.AppEnvironments.Select(ae => ae.EnvironmentId).ToHashSet() ?? new();
|
||||
}
|
||||
|
||||
private async Task ToggleEnvironment(Guid envId, bool currentlyLinked)
|
||||
{
|
||||
if (currentlyLinked)
|
||||
{
|
||||
await TenantService.UnlinkAppFromEnvironmentAsync(App.Id, envId);
|
||||
}
|
||||
else
|
||||
{
|
||||
await TenantService.LinkAppToEnvironmentAsync(App.Id, envId);
|
||||
}
|
||||
|
||||
await LoadEnvironments();
|
||||
}
|
||||
|
||||
// ──────── Deployments ────────
|
||||
|
||||
private async Task SwitchToDeployments()
|
||||
{
|
||||
section = "deployments";
|
||||
|
||||
if (deployments is null)
|
||||
{
|
||||
await LoadDeployments();
|
||||
}
|
||||
|
||||
if (clusters is null)
|
||||
{
|
||||
clusters = await TenantService.GetClustersAsync(TenantId);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task LoadDeployments()
|
||||
{
|
||||
deployments = await DeploymentService.GetDeploymentsAsync(App.Id);
|
||||
}
|
||||
|
||||
private async Task CreateDeployment()
|
||||
{
|
||||
createDeployError = null;
|
||||
|
||||
try
|
||||
{
|
||||
await DeploymentService.CreateDeploymentAsync(
|
||||
App.Id, newDeployName, newDeployType,
|
||||
newDeployEnvId, newDeployClusterId, newDeployNamespace,
|
||||
newDeployType == DeploymentType.HelmChart ? newHelmRepoUrl : null,
|
||||
newDeployType == DeploymentType.HelmChart ? newHelmChartName : null,
|
||||
newDeployType == DeploymentType.HelmChart ? newHelmChartVersion : null);
|
||||
|
||||
// Reset form and reload.
|
||||
showCreateDeployment = false;
|
||||
newDeployName = "";
|
||||
newDeployNamespace = "";
|
||||
newHelmRepoUrl = "";
|
||||
newHelmChartName = "";
|
||||
newHelmChartVersion = "";
|
||||
await LoadDeployments();
|
||||
}
|
||||
catch (DbUpdateException)
|
||||
{
|
||||
createDeployError = $"A deployment named '{newDeployName}' already exists for this app.";
|
||||
}
|
||||
}
|
||||
|
||||
private async Task SelectDeployment(AppDeployment deploy)
|
||||
{
|
||||
selectedDeployment = deploy;
|
||||
confirmDeleteDeployment = false;
|
||||
|
||||
// Default to the appropriate sub-tab based on type.
|
||||
if (deploy.Type == DeploymentType.HelmChart)
|
||||
{
|
||||
deploymentSection = "helm-values";
|
||||
helmValuesYaml = deploy.HelmValues ?? "";
|
||||
}
|
||||
else
|
||||
{
|
||||
deploymentSection = "manifests";
|
||||
await LoadManifests();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task DeleteDeployment()
|
||||
{
|
||||
if (selectedDeployment is not null)
|
||||
{
|
||||
await DeploymentService.DeleteDeploymentAsync(selectedDeployment.Id);
|
||||
selectedDeployment = null;
|
||||
confirmDeleteDeployment = false;
|
||||
await LoadDeployments();
|
||||
}
|
||||
}
|
||||
|
||||
// ──────── Manifests ────────
|
||||
|
||||
private async Task LoadManifests()
|
||||
{
|
||||
if (selectedDeployment is not null)
|
||||
{
|
||||
manifests = await DeploymentService.GetManifestsAsync(selectedDeployment.Id);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task AddManifest()
|
||||
{
|
||||
if (selectedDeployment is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await DeploymentService.AddManifestAsync(
|
||||
selectedDeployment.Id, newManifestKind, newManifestName, newManifestYaml, newManifestSortOrder);
|
||||
|
||||
showAddManifest = false;
|
||||
newManifestKind = "";
|
||||
newManifestName = "";
|
||||
newManifestYaml = "";
|
||||
newManifestSortOrder = 0;
|
||||
await LoadManifests();
|
||||
}
|
||||
|
||||
private void StartEditManifest(DeploymentManifest manifest)
|
||||
{
|
||||
editingManifestId = manifest.Id;
|
||||
editManifestYaml = manifest.YamlContent;
|
||||
}
|
||||
|
||||
private async Task SaveManifest(Guid manifestId)
|
||||
{
|
||||
await DeploymentService.UpdateManifestAsync(manifestId, editManifestYaml);
|
||||
editingManifestId = null;
|
||||
await LoadManifests();
|
||||
}
|
||||
|
||||
private async Task DeleteManifest(Guid manifestId)
|
||||
{
|
||||
await DeploymentService.DeleteManifestAsync(manifestId);
|
||||
await LoadManifests();
|
||||
}
|
||||
|
||||
// ──────── Helm Values ────────
|
||||
|
||||
private async Task SaveHelmValues()
|
||||
{
|
||||
if (selectedDeployment is not null)
|
||||
{
|
||||
await DeploymentService.UpdateHelmValuesAsync(selectedDeployment.Id, helmValuesYaml);
|
||||
}
|
||||
}
|
||||
|
||||
// ──────── Resources ────────
|
||||
|
||||
private async Task OpenResourcesTab()
|
||||
{
|
||||
deploymentSection = "resources";
|
||||
|
||||
if (selectedDeployment is not null)
|
||||
{
|
||||
resourceTree = await DeploymentService.GetResourceTreeAsync(selectedDeployment.Id);
|
||||
}
|
||||
}
|
||||
|
||||
// ──────── Rendering helpers ────────
|
||||
|
||||
private RenderFragment GetTypeBadge(DeploymentType type) => type switch
|
||||
{
|
||||
DeploymentType.Manual => @<span class="badge bg-info">Manual</span>,
|
||||
DeploymentType.Yaml => @<span class="badge bg-warning text-dark">YAML</span>,
|
||||
DeploymentType.HelmChart => @<span class="badge bg-purple text-white" style="background-color: #6f42c1 !important;">Helm</span>,
|
||||
_ => @<span class="badge bg-secondary">Unknown</span>
|
||||
};
|
||||
|
||||
private RenderFragment GetSyncBadge(SyncStatus status) => status switch
|
||||
{
|
||||
SyncStatus.Synced => @<span class="badge bg-success"><i class="bi bi-check-circle me-1"></i>Synced</span>,
|
||||
SyncStatus.OutOfSync => @<span class="badge bg-warning text-dark"><i class="bi bi-exclamation-circle me-1"></i>OutOfSync</span>,
|
||||
SyncStatus.Syncing => @<span class="badge bg-info"><i class="bi bi-arrow-repeat me-1"></i>Syncing</span>,
|
||||
SyncStatus.Failed => @<span class="badge bg-danger"><i class="bi bi-x-circle me-1"></i>Failed</span>,
|
||||
_ => @<span class="badge bg-secondary">Unknown</span>
|
||||
};
|
||||
|
||||
private RenderFragment GetHealthBadge(HealthStatus status) => status switch
|
||||
{
|
||||
HealthStatus.Healthy => @<span class="badge bg-success"><i class="bi bi-heart-fill me-1"></i>Healthy</span>,
|
||||
HealthStatus.Progressing => @<span class="badge bg-info"><i class="bi bi-hourglass-split me-1"></i>Progressing</span>,
|
||||
HealthStatus.Degraded => @<span class="badge bg-warning text-dark"><i class="bi bi-exclamation-triangle me-1"></i>Degraded</span>,
|
||||
HealthStatus.Missing => @<span class="badge bg-danger"><i class="bi bi-question-circle me-1"></i>Missing</span>,
|
||||
HealthStatus.Suspended => @<span class="badge bg-secondary"><i class="bi bi-pause-circle me-1"></i>Suspended</span>,
|
||||
_ => @<span class="badge bg-secondary">Unknown</span>
|
||||
};
|
||||
|
||||
private RenderFragment RenderResource(DeploymentResource resource, int depth) => __builder =>
|
||||
{
|
||||
<div class="d-flex align-items-center py-1 border-bottom" style="padding-left: @(depth * 24)px;">
|
||||
<span class="badge bg-secondary me-2 small">@resource.Kind</span>
|
||||
<span class="fw-medium small flex-grow-1">@resource.Name</span>
|
||||
<div class="d-flex gap-1">
|
||||
@GetSyncBadge(resource.SyncStatus)
|
||||
@GetHealthBadge(resource.HealthStatus)
|
||||
</div>
|
||||
</div>
|
||||
@if (resource.ChildResources?.Count > 0)
|
||||
{
|
||||
@foreach (DeploymentResource child in resource.ChildResources)
|
||||
{
|
||||
@RenderResource(child, depth + 1)
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
148
src/EntKube.Web/Components/Pages/Tenants/AppPanel.razor
Normal file
148
src/EntKube.Web/Components/Pages/Tenants/AppPanel.razor
Normal file
@@ -0,0 +1,148 @@
|
||||
@using EntKube.Web.Data
|
||||
@using EntKube.Web.Services
|
||||
@using Microsoft.EntityFrameworkCore
|
||||
@inject TenantService TenantService
|
||||
|
||||
<div class="card mt-3">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<strong>Apps for @Customer.Name</strong>
|
||||
<button class="btn btn-sm btn-outline-secondary" @onclick="OnClose">Close</button>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="input-group mb-3" style="max-width: 400px;">
|
||||
<input type="text" class="form-control" placeholder="App name" @bind="newAppName" @bind:event="oninput" />
|
||||
<button class="btn btn-primary" @onclick="CreateApp" disabled="@string.IsNullOrWhiteSpace(newAppName)">Add App</button>
|
||||
</div>
|
||||
|
||||
@if (!string.IsNullOrEmpty(errorMessage))
|
||||
{
|
||||
<div class="text-danger mb-2">@errorMessage</div>
|
||||
}
|
||||
|
||||
@if (apps is null)
|
||||
{
|
||||
<p><em>Loading...</em></p>
|
||||
}
|
||||
else if (apps.Count == 0)
|
||||
{
|
||||
<p class="text-muted">No apps yet for this customer.</p>
|
||||
}
|
||||
else
|
||||
{
|
||||
<table class="table table-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>App Name</th>
|
||||
<th>Environments</th>
|
||||
<th style="width: 80px;"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (Data.App app in apps)
|
||||
{
|
||||
<tr>
|
||||
<td>@app.Name</td>
|
||||
<td>
|
||||
@if (app.AppEnvironments.Any())
|
||||
{
|
||||
@string.Join(", ", app.AppEnvironments.Select(ae => ae.Environment.Name))
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="text-muted">None</span>
|
||||
}
|
||||
|
||||
<button class="btn btn-sm btn-link p-0 ms-2" @onclick="() => ToggleEnvPicker(app.Id)">Edit</button>
|
||||
</td>
|
||||
<td>
|
||||
<button class="btn btn-sm btn-outline-danger" @onclick="() => DeleteApp(app.Id)">Delete</button>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@if (envPickerAppId == app.Id && environments is not null)
|
||||
{
|
||||
<tr>
|
||||
<td colspan="3">
|
||||
<div class="d-flex flex-wrap gap-2 p-2 bg-light rounded">
|
||||
@foreach (Data.Environment env in environments)
|
||||
{
|
||||
bool isLinked = app.AppEnvironments.Any(ae => ae.EnvironmentId == env.Id);
|
||||
<button class="btn btn-sm @(isLinked ? "btn-success" : "btn-outline-secondary")"
|
||||
@onclick="() => ToggleEnvironment(app.Id, env.Id, isLinked)">
|
||||
@env.Name
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@code {
|
||||
[Parameter] public Customer Customer { get; set; } = null!;
|
||||
[Parameter] public Guid TenantId { get; set; }
|
||||
[Parameter] public EventCallback OnClose { get; set; }
|
||||
|
||||
private List<Data.App>? apps;
|
||||
private List<Data.Environment>? environments;
|
||||
private string newAppName = "";
|
||||
private string? errorMessage;
|
||||
private Guid? envPickerAppId;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
await Load();
|
||||
environments = await TenantService.GetEnvironmentsAsync(TenantId);
|
||||
}
|
||||
|
||||
private async Task Load()
|
||||
{
|
||||
apps = await TenantService.GetAppsAsync(Customer.Id);
|
||||
}
|
||||
|
||||
private async Task CreateApp()
|
||||
{
|
||||
errorMessage = null;
|
||||
|
||||
try
|
||||
{
|
||||
await TenantService.CreateAppAsync(Customer.Id, newAppName.Trim());
|
||||
newAppName = "";
|
||||
await Load();
|
||||
}
|
||||
catch (DbUpdateException)
|
||||
{
|
||||
errorMessage = "An app with that name already exists for this customer.";
|
||||
}
|
||||
}
|
||||
|
||||
private async Task DeleteApp(Guid id)
|
||||
{
|
||||
await TenantService.DeleteAppAsync(id);
|
||||
await Load();
|
||||
}
|
||||
|
||||
private void ToggleEnvPicker(Guid appId)
|
||||
{
|
||||
envPickerAppId = envPickerAppId == appId ? null : appId;
|
||||
}
|
||||
|
||||
private async Task ToggleEnvironment(Guid appId, Guid envId, bool currentlyLinked)
|
||||
{
|
||||
if (currentlyLinked)
|
||||
{
|
||||
await TenantService.UnlinkAppFromEnvironmentAsync(appId, envId);
|
||||
}
|
||||
else
|
||||
{
|
||||
await TenantService.LinkAppToEnvironmentAsync(appId, envId);
|
||||
}
|
||||
|
||||
await Load();
|
||||
}
|
||||
}
|
||||
2043
src/EntKube.Web/Components/Pages/Tenants/ClusterDetail.razor
Normal file
2043
src/EntKube.Web/Components/Pages/Tenants/ClusterDetail.razor
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,216 @@
|
||||
@using EntKube.Web.Data
|
||||
@using EntKube.Web.Services
|
||||
@inject PrometheusService PrometheusService
|
||||
|
||||
<div class="card border-0 shadow-sm">
|
||||
<div class="card-header bg-dark text-white d-flex justify-content-between align-items-center">
|
||||
<span><i class="bi bi-heart-pulse me-2"></i>Cluster Health — Prometheus</span>
|
||||
<div>
|
||||
@if (loading)
|
||||
{
|
||||
<span class="spinner-border spinner-border-sm text-light me-2"></span>
|
||||
}
|
||||
|
||||
<button class="btn btn-sm btn-outline-light" @onclick="RefreshHealth" disabled="@loading">
|
||||
<i class="bi bi-arrow-clockwise"></i> Refresh
|
||||
</button>
|
||||
<button class="btn btn-sm btn-outline-secondary ms-1" @onclick="OnClose">
|
||||
<i class="bi bi-x-lg"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
@if (!string.IsNullOrEmpty(errorMessage))
|
||||
{
|
||||
<div class="alert alert-warning">
|
||||
<i class="bi bi-exclamation-triangle me-1"></i>@errorMessage
|
||||
</div>
|
||||
}
|
||||
else if (health is null && loading)
|
||||
{
|
||||
<p class="text-muted"><em>Querying Prometheus...</em></p>
|
||||
}
|
||||
else if (health is not null)
|
||||
{
|
||||
@* ── Overview cards ── *@
|
||||
<div class="row g-3 mb-4">
|
||||
<div class="col-md-3">
|
||||
<div class="card text-center @(health.ReadyNodes == health.TotalNodes ? "border-success" : "border-danger")">
|
||||
<div class="card-body py-2">
|
||||
<div class="fs-3 fw-bold @(health.ReadyNodes == health.TotalNodes ? "text-success" : "text-danger")">
|
||||
@health.ReadyNodes / @health.TotalNodes
|
||||
</div>
|
||||
<small class="text-muted">Nodes Ready</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="card text-center border-primary">
|
||||
<div class="card-body py-2">
|
||||
<div class="fs-3 fw-bold text-primary">@health.RunningPods</div>
|
||||
<small class="text-muted">Running Pods</small>
|
||||
@if (health.PendingPods > 0 || health.FailedPods > 0)
|
||||
{
|
||||
<div class="mt-1">
|
||||
@if (health.PendingPods > 0)
|
||||
{
|
||||
<span class="badge bg-warning text-dark me-1">@health.PendingPods pending</span>
|
||||
}
|
||||
|
||||
@if (health.FailedPods > 0)
|
||||
{
|
||||
<span class="badge bg-danger">@health.FailedPods failed</span>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="card text-center @GetUsageBorderClass(health.CpuUsagePercent)">
|
||||
<div class="card-body py-2">
|
||||
<div class="fs-3 fw-bold @GetUsageTextClass(health.CpuUsagePercent)">
|
||||
@health.CpuUsagePercent%
|
||||
</div>
|
||||
<small class="text-muted">CPU Usage</small>
|
||||
<div class="progress mt-1" style="height: 4px;">
|
||||
<div class="progress-bar @GetProgressBarClass(health.CpuUsagePercent)"
|
||||
style="width: @health.CpuUsagePercent%"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="card text-center @GetUsageBorderClass(health.MemoryUsagePercent)">
|
||||
<div class="card-body py-2">
|
||||
<div class="fs-3 fw-bold @GetUsageTextClass(health.MemoryUsagePercent)">
|
||||
@health.MemoryUsagePercent%
|
||||
</div>
|
||||
<small class="text-muted">Memory Usage</small>
|
||||
<div class="progress mt-1" style="height: 4px;">
|
||||
<div class="progress-bar @GetProgressBarClass(health.MemoryUsagePercent)"
|
||||
style="width: @health.MemoryUsagePercent%"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@* ── Per-node table ── *@
|
||||
@if (health.Nodes.Count > 0)
|
||||
{
|
||||
<h6 class="mt-3"><i class="bi bi-hdd-rack me-1"></i>Nodes</h6>
|
||||
<table class="table table-sm table-hover">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Node</th>
|
||||
<th>Status</th>
|
||||
<th>CPU</th>
|
||||
<th>Memory</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (NodeHealthInfo node in health.Nodes)
|
||||
{
|
||||
<tr>
|
||||
<td><code>@node.Name</code></td>
|
||||
<td>
|
||||
@if (node.Ready)
|
||||
{
|
||||
<span class="badge bg-success">Ready</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="badge bg-danger">NotReady</span>
|
||||
}
|
||||
</td>
|
||||
<td>
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<div class="progress flex-grow-1" style="height: 6px; min-width: 60px;">
|
||||
<div class="progress-bar @GetProgressBarClass(node.CpuUsagePercent)"
|
||||
style="width: @node.CpuUsagePercent%"></div>
|
||||
</div>
|
||||
<small>@node.CpuUsagePercent%</small>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<div class="progress flex-grow-1" style="height: 6px; min-width: 60px;">
|
||||
<div class="progress-bar @GetProgressBarClass(node.MemoryUsagePercent)"
|
||||
style="width: @node.MemoryUsagePercent%"></div>
|
||||
</div>
|
||||
<small>@node.MemoryUsagePercent%</small>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
}
|
||||
|
||||
<div class="text-muted small mt-2">
|
||||
<i class="bi bi-clock me-1"></i>Last queried: @health.QueriedAt.ToString("HH:mm:ss UTC")
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<p class="text-muted">No health data available. Click Refresh to query Prometheus.</p>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@code {
|
||||
[Parameter] public Guid ClusterId { get; set; }
|
||||
[Parameter] public EventCallback OnClose { get; set; }
|
||||
|
||||
private ClusterHealthSummary? health;
|
||||
private bool loading;
|
||||
private string? errorMessage;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
await RefreshHealth();
|
||||
}
|
||||
|
||||
private async Task RefreshHealth()
|
||||
{
|
||||
loading = true;
|
||||
errorMessage = null;
|
||||
StateHasChanged();
|
||||
|
||||
KubernetesOperationResult<ClusterHealthSummary> result =
|
||||
await PrometheusService.GetClusterHealthAsync(ClusterId);
|
||||
|
||||
if (result.IsSuccess)
|
||||
{
|
||||
health = result.Data;
|
||||
}
|
||||
else
|
||||
{
|
||||
errorMessage = result.Error;
|
||||
}
|
||||
|
||||
loading = false;
|
||||
}
|
||||
|
||||
private static string GetUsageBorderClass(double percent) => percent switch
|
||||
{
|
||||
>= 90 => "border-danger",
|
||||
>= 70 => "border-warning",
|
||||
_ => "border-success"
|
||||
};
|
||||
|
||||
private static string GetUsageTextClass(double percent) => percent switch
|
||||
{
|
||||
>= 90 => "text-danger",
|
||||
>= 70 => "text-warning",
|
||||
_ => "text-success"
|
||||
};
|
||||
|
||||
private static string GetProgressBarClass(double percent) => percent switch
|
||||
{
|
||||
>= 90 => "bg-danger",
|
||||
>= 70 => "bg-warning",
|
||||
_ => "bg-success"
|
||||
};
|
||||
}
|
||||
318
src/EntKube.Web/Components/Pages/Tenants/ClusterMonitoring.razor
Normal file
318
src/EntKube.Web/Components/Pages/Tenants/ClusterMonitoring.razor
Normal file
@@ -0,0 +1,318 @@
|
||||
@using EntKube.Web.Data
|
||||
@using EntKube.Web.Services
|
||||
@inject PrometheusService PrometheusService
|
||||
|
||||
@* ═══════════════════════════════════════════════════════════════════════════
|
||||
ClusterMonitoring — full monitoring dashboard for a single cluster.
|
||||
Shows real-time metrics from Prometheus, time-series graphs, and
|
||||
Alertmanager alerts/silences. The operations team's single pane of glass.
|
||||
═══════════════════════════════════════════════════════════════════════════ *@
|
||||
|
||||
<div class="mb-4">
|
||||
@if (loading && health is null)
|
||||
{
|
||||
<div class="text-center py-5">
|
||||
<div class="spinner-border text-primary" role="status"></div>
|
||||
<p class="text-muted mt-2">Connecting to Prometheus...</p>
|
||||
</div>
|
||||
}
|
||||
else if (!string.IsNullOrEmpty(errorMessage))
|
||||
{
|
||||
<div class="alert alert-warning">
|
||||
<i class="bi bi-exclamation-triangle me-2"></i>@errorMessage
|
||||
<button class="btn btn-sm btn-outline-warning ms-3" @onclick="LoadAll">Retry</button>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
@* ── Header with refresh ── *@
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<h5 class="mb-0"><i class="bi bi-heart-pulse me-2 text-success"></i>Monitoring</h5>
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<select class="form-select form-select-sm" style="width: auto;" @bind="timeRange" @bind:after="LoadMetrics">
|
||||
<option value="30">Last 30 min</option>
|
||||
<option value="60">Last 1 hour</option>
|
||||
<option value="180">Last 3 hours</option>
|
||||
<option value="360">Last 6 hours</option>
|
||||
<option value="720">Last 12 hours</option>
|
||||
<option value="1440">Last 24 hours</option>
|
||||
</select>
|
||||
@if (loading)
|
||||
{
|
||||
<span class="spinner-border spinner-border-sm text-primary"></span>
|
||||
}
|
||||
<button class="btn btn-sm btn-outline-primary" @onclick="LoadAll" disabled="@loading">
|
||||
<i class="bi bi-arrow-clockwise me-1"></i>Refresh
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@* ── Status Overview Cards ── *@
|
||||
@if (health is not null)
|
||||
{
|
||||
<div class="row g-3 mb-4">
|
||||
<div class="col-md-3 col-6">
|
||||
<div class="card text-center h-100 @(health.ReadyNodes == health.TotalNodes ? "border-success" : "border-danger")">
|
||||
<div class="card-body py-3">
|
||||
<div class="fs-2 fw-bold @(health.ReadyNodes == health.TotalNodes ? "text-success" : "text-danger")">
|
||||
@health.ReadyNodes<span class="fs-5 text-muted">/@health.TotalNodes</span>
|
||||
</div>
|
||||
<small class="text-muted">Nodes Ready</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3 col-6">
|
||||
<div class="card text-center h-100 border-primary">
|
||||
<div class="card-body py-3">
|
||||
<div class="fs-2 fw-bold text-primary">@health.RunningPods</div>
|
||||
<small class="text-muted">Running Pods</small>
|
||||
@if (health.PendingPods > 0 || health.FailedPods > 0)
|
||||
{
|
||||
<div class="mt-1">
|
||||
@if (health.PendingPods > 0)
|
||||
{
|
||||
<span class="badge bg-warning text-dark">@health.PendingPods pending</span>
|
||||
}
|
||||
@if (health.FailedPods > 0)
|
||||
{
|
||||
<span class="badge bg-danger">@health.FailedPods failed</span>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3 col-6">
|
||||
<div class="card text-center h-100 @GetBorderClass(health.CpuUsagePercent)">
|
||||
<div class="card-body py-3">
|
||||
<div class="fs-2 fw-bold @GetTextClass(health.CpuUsagePercent)">@health.CpuUsagePercent<span class="fs-6">%</span></div>
|
||||
<small class="text-muted">CPU Usage</small>
|
||||
<div class="progress mt-2" style="height: 4px;">
|
||||
<div class="progress-bar @GetBarClass(health.CpuUsagePercent)" style="width: @health.CpuUsagePercent%"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3 col-6">
|
||||
<div class="card text-center h-100 @GetBorderClass(health.MemoryUsagePercent)">
|
||||
<div class="card-body py-3">
|
||||
<div class="fs-2 fw-bold @GetTextClass(health.MemoryUsagePercent)">@health.MemoryUsagePercent<span class="fs-6">%</span></div>
|
||||
<small class="text-muted">Memory Usage</small>
|
||||
<div class="progress mt-2" style="height: 4px;">
|
||||
<div class="progress-bar @GetBarClass(health.MemoryUsagePercent)" style="width: @health.MemoryUsagePercent%"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@* ── Time-Series Graphs ── *@
|
||||
<div class="row g-3 mb-4">
|
||||
<div class="col-md-6">
|
||||
<div class="card shadow-sm h-100">
|
||||
<div class="card-body">
|
||||
<MetricChart Title="CPU Usage" DataPoints="cpuHistory" Color="#0d6efd" Unit="%" MaxValue="100" HeightPx="80" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="card shadow-sm h-100">
|
||||
<div class="card-body">
|
||||
<MetricChart Title="Memory Usage" DataPoints="memHistory" Color="#6f42c1" Unit="%" MaxValue="100" HeightPx="80" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="card shadow-sm h-100">
|
||||
<div class="card-body">
|
||||
<MetricChart Title="Network Receive" DataPoints="networkRxHistory" Color="#198754" Unit=" MB/s" HeightPx="80" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="card shadow-sm h-100">
|
||||
<div class="card-body">
|
||||
<MetricChart Title="Pod Count" DataPoints="podCountHistory" Color="#fd7e14" HeightPx="80" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@* ── Per-Node Status ── *@
|
||||
@if (health is not null && health.Nodes.Count > 0)
|
||||
{
|
||||
<div class="card shadow-sm mb-4">
|
||||
<div class="card-header bg-white">
|
||||
<i class="bi bi-hdd-rack me-2"></i><strong>Node Status</strong>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm table-hover align-middle mb-0">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>Node</th>
|
||||
<th>Status</th>
|
||||
<th style="min-width: 150px;">CPU</th>
|
||||
<th style="min-width: 150px;">Memory</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (NodeHealthInfo node in health.Nodes)
|
||||
{
|
||||
<tr>
|
||||
<td><code>@node.Name</code></td>
|
||||
<td>
|
||||
@if (node.Ready)
|
||||
{
|
||||
<span class="badge bg-success"><i class="bi bi-check-circle me-1"></i>Ready</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="badge bg-danger"><i class="bi bi-x-circle me-1"></i>NotReady</span>
|
||||
}
|
||||
</td>
|
||||
<td>
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<div class="progress flex-grow-1" style="height: 8px;">
|
||||
<div class="progress-bar @GetBarClass(node.CpuUsagePercent)"
|
||||
style="width: @node.CpuUsagePercent%"></div>
|
||||
</div>
|
||||
<small class="fw-medium" style="min-width: 40px;">@node.CpuUsagePercent%</small>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<div class="progress flex-grow-1" style="height: 8px;">
|
||||
<div class="progress-bar @GetBarClass(node.MemoryUsagePercent)"
|
||||
style="width: @node.MemoryUsagePercent%"></div>
|
||||
</div>
|
||||
<small class="fw-medium" style="min-width: 40px;">@node.MemoryUsagePercent%</small>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@* ── Alerts ── *@
|
||||
<AlertPanel ClusterId="ClusterId" PrometheusService="PrometheusService" />
|
||||
}
|
||||
</div>
|
||||
|
||||
@code {
|
||||
[Parameter] public Guid ClusterId { get; set; }
|
||||
|
||||
private ClusterHealthSummary? health;
|
||||
private List<TimeSeriesDataPoint> cpuHistory = [];
|
||||
private List<TimeSeriesDataPoint> memHistory = [];
|
||||
private List<TimeSeriesDataPoint> networkRxHistory = [];
|
||||
private List<TimeSeriesDataPoint> podCountHistory = [];
|
||||
private bool loading;
|
||||
private string? errorMessage;
|
||||
private int timeRange = 60; // minutes
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
await LoadAll();
|
||||
}
|
||||
|
||||
private async Task LoadAll()
|
||||
{
|
||||
loading = true;
|
||||
errorMessage = null;
|
||||
StateHasChanged();
|
||||
|
||||
// Load the instant health summary and time-series data in parallel.
|
||||
|
||||
Task healthTask = LoadHealth();
|
||||
Task metricsTask = LoadMetrics();
|
||||
await Task.WhenAll(healthTask, metricsTask);
|
||||
|
||||
loading = false;
|
||||
}
|
||||
|
||||
private async Task LoadHealth()
|
||||
{
|
||||
KubernetesOperationResult<ClusterHealthSummary> result =
|
||||
await PrometheusService.GetClusterHealthAsync(ClusterId);
|
||||
|
||||
if (result.IsSuccess)
|
||||
{
|
||||
health = result.Data;
|
||||
}
|
||||
else
|
||||
{
|
||||
errorMessage = result.Error;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task LoadMetrics()
|
||||
{
|
||||
TimeSpan duration = TimeSpan.FromMinutes(timeRange);
|
||||
|
||||
// Query multiple range metrics in parallel.
|
||||
|
||||
Task<KubernetesOperationResult<List<PrometheusTimeSeries>>> cpuTask =
|
||||
PrometheusService.GetMetricRangeAsync(ClusterId,
|
||||
"100 - (avg(rate(node_cpu_seconds_total{mode=\"idle\"}[5m])) * 100)", duration);
|
||||
|
||||
Task<KubernetesOperationResult<List<PrometheusTimeSeries>>> memTask =
|
||||
PrometheusService.GetMetricRangeAsync(ClusterId,
|
||||
"100 - (avg(node_memory_MemAvailable_bytes / node_memory_MemTotal_bytes) * 100)", duration);
|
||||
|
||||
Task<KubernetesOperationResult<List<PrometheusTimeSeries>>> netTask =
|
||||
PrometheusService.GetMetricRangeAsync(ClusterId,
|
||||
"sum(rate(node_network_receive_bytes_total[5m])) / 1024 / 1024", duration);
|
||||
|
||||
Task<KubernetesOperationResult<List<PrometheusTimeSeries>>> podTask =
|
||||
PrometheusService.GetMetricRangeAsync(ClusterId,
|
||||
"sum(kube_pod_status_phase{phase=\"Running\"})", duration);
|
||||
|
||||
await Task.WhenAll(cpuTask, memTask, netTask, podTask);
|
||||
|
||||
KubernetesOperationResult<List<PrometheusTimeSeries>> cpuResult = await cpuTask;
|
||||
KubernetesOperationResult<List<PrometheusTimeSeries>> memResult = await memTask;
|
||||
KubernetesOperationResult<List<PrometheusTimeSeries>> netResult = await netTask;
|
||||
KubernetesOperationResult<List<PrometheusTimeSeries>> podResult = await podTask;
|
||||
|
||||
// Extract the first series from each result (these are aggregated single-line metrics).
|
||||
|
||||
cpuHistory = cpuResult.IsSuccess && cpuResult.Data!.Count > 0
|
||||
? cpuResult.Data[0].DataPoints : [];
|
||||
|
||||
memHistory = memResult.IsSuccess && memResult.Data!.Count > 0
|
||||
? memResult.Data[0].DataPoints : [];
|
||||
|
||||
networkRxHistory = netResult.IsSuccess && netResult.Data!.Count > 0
|
||||
? netResult.Data[0].DataPoints : [];
|
||||
|
||||
podCountHistory = podResult.IsSuccess && podResult.Data!.Count > 0
|
||||
? podResult.Data[0].DataPoints : [];
|
||||
}
|
||||
|
||||
private static string GetBorderClass(double percent) => percent switch
|
||||
{
|
||||
>= 90 => "border-danger",
|
||||
>= 70 => "border-warning",
|
||||
_ => "border-success"
|
||||
};
|
||||
|
||||
private static string GetTextClass(double percent) => percent switch
|
||||
{
|
||||
>= 90 => "text-danger",
|
||||
>= 70 => "text-warning",
|
||||
_ => "text-success"
|
||||
};
|
||||
|
||||
private static string GetBarClass(double percent) => percent switch
|
||||
{
|
||||
>= 90 => "bg-danger",
|
||||
>= 70 => "bg-warning",
|
||||
_ => "bg-success"
|
||||
};
|
||||
}
|
||||
169
src/EntKube.Web/Components/Pages/Tenants/ClusterTab.razor
Normal file
169
src/EntKube.Web/Components/Pages/Tenants/ClusterTab.razor
Normal file
@@ -0,0 +1,169 @@
|
||||
@using EntKube.Web.Data
|
||||
@using EntKube.Web.Services
|
||||
@using Microsoft.EntityFrameworkCore
|
||||
@inject TenantService TenantService
|
||||
@inject VaultService VaultService
|
||||
|
||||
<h4>Kubernetes Clusters</h4>
|
||||
<p class="text-muted">Clusters registered in this tenant, each assigned to an environment.</p>
|
||||
|
||||
@if (environments is not null && environments.Count > 0)
|
||||
{
|
||||
<div class="row g-2 mb-3" style="max-width: 700px;">
|
||||
<div class="col">
|
||||
<input type="text" class="form-control" placeholder="Cluster name" @bind="newName" @bind:event="oninput" />
|
||||
</div>
|
||||
<div class="col">
|
||||
<input type="text" class="form-control" placeholder="API server URL" @bind="newApiUrl" @bind:event="oninput" />
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<select class="form-select" @bind="selectedEnvironmentId">
|
||||
<option value="">Environment...</option>
|
||||
@foreach (Data.Environment env in environments)
|
||||
{
|
||||
<option value="@env.Id">@env.Name</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<button class="btn btn-primary" @onclick="Create"
|
||||
disabled="@(string.IsNullOrWhiteSpace(newName) || string.IsNullOrWhiteSpace(newApiUrl) || selectedEnvironmentId == Guid.Empty)">
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="alert alert-info">Create at least one environment before adding clusters.</div>
|
||||
}
|
||||
|
||||
@if (!string.IsNullOrEmpty(errorMessage))
|
||||
{
|
||||
<div class="text-danger mb-2">@errorMessage</div>
|
||||
}
|
||||
|
||||
@if (clusters is null)
|
||||
{
|
||||
<p><em>Loading...</em></p>
|
||||
}
|
||||
else if (clusters.Count == 0)
|
||||
{
|
||||
<p class="text-muted">No clusters registered yet.</p>
|
||||
}
|
||||
else
|
||||
{
|
||||
<table class="table table-sm table-hover">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Environment</th>
|
||||
<th>API Server</th>
|
||||
<th style="width: 280px;"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (KubernetesCluster cluster in clusters)
|
||||
{
|
||||
<tr>
|
||||
<td>@cluster.Name</td>
|
||||
<td><span class="badge bg-secondary">@cluster.Environment.Name</span></td>
|
||||
<td class="text-truncate" style="max-width: 300px;">@cluster.ApiServerUrl</td>
|
||||
<td>
|
||||
<button class="btn btn-sm btn-outline-success me-1" @onclick="() => ToggleHealth(cluster.Id)">
|
||||
<i class="bi bi-heart-pulse"></i> Health
|
||||
</button>
|
||||
<button class="btn btn-sm btn-outline-primary me-1" @onclick="() => ToggleComponents(cluster.Id)">
|
||||
Components
|
||||
</button>
|
||||
<button class="btn btn-sm btn-outline-danger" @onclick="() => Delete(cluster.Id)">Delete</button>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@if (expandedClusterId == cluster.Id)
|
||||
{
|
||||
<tr>
|
||||
<td colspan="4">
|
||||
<ComponentPanel ClusterId="cluster.Id" TenantId="TenantId" OnClose="() => expandedClusterId = null" />
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
|
||||
@if (healthClusterId == cluster.Id)
|
||||
{
|
||||
<tr>
|
||||
<td colspan="4">
|
||||
<ClusterHealthPanel ClusterId="cluster.Id" OnClose="() => healthClusterId = null" />
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
}
|
||||
|
||||
@code {
|
||||
[Parameter] public Guid TenantId { get; set; }
|
||||
|
||||
private List<KubernetesCluster>? clusters;
|
||||
private List<Data.Environment>? environments;
|
||||
private string newName = "";
|
||||
private string newApiUrl = "";
|
||||
private Guid selectedEnvironmentId;
|
||||
private string? errorMessage;
|
||||
private Guid? expandedClusterId;
|
||||
private Guid? healthClusterId;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
await Load();
|
||||
environments = await TenantService.GetEnvironmentsAsync(TenantId);
|
||||
}
|
||||
|
||||
private async Task Load()
|
||||
{
|
||||
clusters = await TenantService.GetClustersAsync(TenantId);
|
||||
}
|
||||
|
||||
private async Task Create()
|
||||
{
|
||||
errorMessage = null;
|
||||
|
||||
try
|
||||
{
|
||||
await TenantService.CreateClusterAsync(TenantId, selectedEnvironmentId, newName.Trim(), newApiUrl.Trim());
|
||||
newName = "";
|
||||
newApiUrl = "";
|
||||
selectedEnvironmentId = Guid.Empty;
|
||||
await Load();
|
||||
}
|
||||
catch (DbUpdateException)
|
||||
{
|
||||
errorMessage = "A cluster with that name already exists in this tenant.";
|
||||
}
|
||||
}
|
||||
|
||||
private void ToggleComponents(Guid clusterId)
|
||||
{
|
||||
expandedClusterId = expandedClusterId == clusterId ? null : clusterId;
|
||||
if (expandedClusterId is not null)
|
||||
{
|
||||
healthClusterId = null;
|
||||
}
|
||||
}
|
||||
|
||||
private void ToggleHealth(Guid clusterId)
|
||||
{
|
||||
healthClusterId = healthClusterId == clusterId ? null : clusterId;
|
||||
if (healthClusterId is not null)
|
||||
{
|
||||
expandedClusterId = null;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task Delete(Guid id)
|
||||
{
|
||||
await TenantService.DeleteClusterAsync(id);
|
||||
await Load();
|
||||
}
|
||||
}
|
||||
293
src/EntKube.Web/Components/Pages/Tenants/ComponentPanel.razor
Normal file
293
src/EntKube.Web/Components/Pages/Tenants/ComponentPanel.razor
Normal file
@@ -0,0 +1,293 @@
|
||||
@using EntKube.Web.Data
|
||||
@using EntKube.Web.Services
|
||||
@using Microsoft.EntityFrameworkCore
|
||||
@inject VaultService VaultService
|
||||
|
||||
<div class="card mt-2 mb-2">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<strong>Components</strong>
|
||||
<button class="btn btn-sm btn-outline-secondary" @onclick="OnClose">Close</button>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row g-2 mb-3" style="max-width: 500px;">
|
||||
<div class="col">
|
||||
<input type="text" class="form-control form-control-sm" placeholder="Component name"
|
||||
@bind="newComponentName" @bind:event="oninput" />
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<select class="form-select form-select-sm" @bind="newComponentType">
|
||||
<option value="HelmChart">Helm Chart</option>
|
||||
<option value="Deployment">Deployment</option>
|
||||
<option value="StatefulSet">StatefulSet</option>
|
||||
<option value="Operator">Operator</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<button class="btn btn-sm btn-primary" @onclick="AddComponent"
|
||||
disabled="@string.IsNullOrWhiteSpace(newComponentName)">
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (!string.IsNullOrEmpty(errorMessage))
|
||||
{
|
||||
<div class="text-danger mb-2">@errorMessage</div>
|
||||
}
|
||||
|
||||
@if (components is null)
|
||||
{
|
||||
<p><em>Loading...</em></p>
|
||||
}
|
||||
else if (components.Count == 0)
|
||||
{
|
||||
<p class="text-muted">No components deployed to this cluster yet.</p>
|
||||
}
|
||||
else
|
||||
{
|
||||
<table class="table table-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Type</th>
|
||||
<th style="width: 200px;"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (ClusterComponent component in components)
|
||||
{
|
||||
<tr>
|
||||
<td><strong>@component.Name</strong></td>
|
||||
<td><span class="badge bg-info text-dark">@component.ComponentType</span></td>
|
||||
<td>
|
||||
<button class="btn btn-sm btn-outline-warning me-1" @onclick="() => ToggleSecrets(component.Id)">
|
||||
Secrets
|
||||
</button>
|
||||
<button class="btn btn-sm btn-outline-danger" @onclick="() => DeleteComponent(component.Id)">
|
||||
Delete
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@if (expandedComponentId == component.Id)
|
||||
{
|
||||
<tr>
|
||||
<td colspan="3">
|
||||
<div class="p-2 bg-light rounded">
|
||||
<div class="row g-2 mb-2" style="max-width: 600px;">
|
||||
<div class="col-4">
|
||||
<input type="text" class="form-control form-control-sm"
|
||||
placeholder="Secret name" @bind="newSecretName" @bind:event="oninput" />
|
||||
</div>
|
||||
<div class="col-5">
|
||||
<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(component.Id)"
|
||||
disabled="@(string.IsNullOrWhiteSpace(newSecretName) || string.IsNullOrWhiteSpace(newSecretValue))">
|
||||
Set
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (componentSecrets is not null && componentSecrets.Count > 0)
|
||||
{
|
||||
<table class="table table-sm table-bordered mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>K8s Sync</th>
|
||||
<th>Target Secret</th>
|
||||
<th style="width: 180px;"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (VaultSecret secret in componentSecrets)
|
||||
{
|
||||
<tr>
|
||||
<td><code>@secret.Name</code></td>
|
||||
<td>
|
||||
@if (secret.SyncToKubernetes)
|
||||
{
|
||||
<span class="badge bg-success">Synced</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="badge bg-secondary">Off</span>
|
||||
}
|
||||
</td>
|
||||
<td>@(secret.KubernetesSecretName ?? "—")</td>
|
||||
<td>
|
||||
<button class="btn btn-sm btn-outline-info me-1"
|
||||
@onclick="() => ToggleSecretSync(secret)">
|
||||
@(secret.SyncToKubernetes ? "Unsync" : "Sync")
|
||||
</button>
|
||||
<button class="btn btn-sm btn-outline-danger"
|
||||
@onclick="() => DeleteSecret(secret.Id, component.Id)">
|
||||
Del
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@if (syncConfigSecretId == secret.Id)
|
||||
{
|
||||
<tr>
|
||||
<td colspan="4">
|
||||
<div class="row g-2 p-1">
|
||||
<div class="col-4">
|
||||
<input type="text" class="form-control form-control-sm"
|
||||
placeholder="K8s Secret name" @bind="syncSecretName" />
|
||||
</div>
|
||||
<div class="col-4">
|
||||
<input type="text" class="form-control form-control-sm"
|
||||
placeholder="Namespace" @bind="syncNamespace" />
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<button class="btn btn-sm btn-success" @onclick="() => SaveSync(component.Id)">Save</button>
|
||||
<button class="btn btn-sm btn-outline-secondary" @onclick="() => syncConfigSecretId = null">X</button>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
}
|
||||
else
|
||||
{
|
||||
<p class="text-muted mb-0">No secrets for this component.</p>
|
||||
}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@code {
|
||||
[Parameter] public Guid ClusterId { get; set; }
|
||||
[Parameter] public Guid TenantId { get; set; }
|
||||
[Parameter] public EventCallback OnClose { get; set; }
|
||||
|
||||
private List<ClusterComponent>? components;
|
||||
private List<VaultSecret>? componentSecrets;
|
||||
private string newComponentName = "";
|
||||
private string newComponentType = "HelmChart";
|
||||
private string? errorMessage;
|
||||
private Guid? expandedComponentId;
|
||||
private string newSecretName = "";
|
||||
private string newSecretValue = "";
|
||||
private Guid? syncConfigSecretId;
|
||||
private string syncSecretName = "";
|
||||
private string syncNamespace = "";
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
await LoadComponents();
|
||||
}
|
||||
|
||||
private async Task LoadComponents()
|
||||
{
|
||||
components = await VaultService.GetComponentsAsync(ClusterId);
|
||||
}
|
||||
|
||||
private async Task AddComponent()
|
||||
{
|
||||
errorMessage = null;
|
||||
|
||||
try
|
||||
{
|
||||
await VaultService.CreateComponentAsync(ClusterId, newComponentName.Trim(), newComponentType);
|
||||
newComponentName = "";
|
||||
await LoadComponents();
|
||||
}
|
||||
catch (DbUpdateException)
|
||||
{
|
||||
errorMessage = "A component with that name already exists on this cluster.";
|
||||
}
|
||||
}
|
||||
|
||||
private async Task DeleteComponent(Guid componentId)
|
||||
{
|
||||
await VaultService.DeleteComponentAsync(componentId);
|
||||
|
||||
if (expandedComponentId == componentId)
|
||||
{
|
||||
expandedComponentId = null;
|
||||
}
|
||||
|
||||
await LoadComponents();
|
||||
}
|
||||
|
||||
private async Task ToggleSecrets(Guid componentId)
|
||||
{
|
||||
if (expandedComponentId == componentId)
|
||||
{
|
||||
expandedComponentId = null;
|
||||
componentSecrets = null;
|
||||
return;
|
||||
}
|
||||
|
||||
expandedComponentId = componentId;
|
||||
newSecretName = "";
|
||||
newSecretValue = "";
|
||||
|
||||
// Ensure vault is initialized for this tenant.
|
||||
await VaultService.InitializeVaultAsync(TenantId);
|
||||
componentSecrets = await VaultService.GetComponentSecretsAsync(TenantId, componentId);
|
||||
}
|
||||
|
||||
private async Task AddSecret(Guid componentId)
|
||||
{
|
||||
await VaultService.SetComponentSecretAsync(TenantId, componentId, newSecretName.Trim(), newSecretValue.Trim());
|
||||
newSecretName = "";
|
||||
newSecretValue = "";
|
||||
componentSecrets = await VaultService.GetComponentSecretsAsync(TenantId, componentId);
|
||||
}
|
||||
|
||||
private async Task DeleteSecret(Guid secretId, Guid componentId)
|
||||
{
|
||||
await VaultService.DeleteSecretAsync(secretId);
|
||||
componentSecrets = await VaultService.GetComponentSecretsAsync(TenantId, componentId);
|
||||
}
|
||||
|
||||
private void ToggleSecretSync(VaultSecret secret)
|
||||
{
|
||||
if (secret.SyncToKubernetes)
|
||||
{
|
||||
_ = DisableSync(secret);
|
||||
}
|
||||
else
|
||||
{
|
||||
syncConfigSecretId = secret.Id;
|
||||
syncSecretName = secret.KubernetesSecretName ?? "";
|
||||
syncNamespace = secret.KubernetesNamespace ?? "";
|
||||
}
|
||||
}
|
||||
|
||||
private async Task DisableSync(VaultSecret secret)
|
||||
{
|
||||
await VaultService.ConfigureKubernetesSyncAsync(secret.Id, syncEnabled: false, secretName: null, ns: null);
|
||||
componentSecrets = await VaultService.GetComponentSecretsAsync(TenantId, expandedComponentId!.Value);
|
||||
}
|
||||
|
||||
private async Task SaveSync(Guid componentId)
|
||||
{
|
||||
if (syncConfigSecretId is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await VaultService.ConfigureKubernetesSyncAsync(
|
||||
syncConfigSecretId.Value, syncEnabled: true, secretName: syncSecretName.Trim(), ns: syncNamespace.Trim());
|
||||
|
||||
syncConfigSecretId = null;
|
||||
componentSecrets = await VaultService.GetComponentSecretsAsync(TenantId, componentId);
|
||||
}
|
||||
}
|
||||
439
src/EntKube.Web/Components/Pages/Tenants/CustomerTab.razor
Normal file
439
src/EntKube.Web/Components/Pages/Tenants/CustomerTab.razor
Normal file
@@ -0,0 +1,439 @@
|
||||
@using EntKube.Web.Data
|
||||
@using EntKube.Web.Services
|
||||
@using Microsoft.EntityFrameworkCore
|
||||
@inject TenantService TenantService
|
||||
@inject CustomerAccessService CustomerAccessService
|
||||
|
||||
@* ===========================================================================
|
||||
Three-level drill-down: Customers ▸ Apps ▸ App Detail
|
||||
The user sees ONE level at a time — no nested panel clutter.
|
||||
=========================================================================== *@
|
||||
|
||||
@if (selectedApp is not null)
|
||||
{
|
||||
@* ───────────── Level 3: App Detail ───────────── *@
|
||||
<AppDetail App="selectedApp"
|
||||
TenantId="TenantId"
|
||||
CustomerName="@selectedCustomer!.Name"
|
||||
OnBack="BackToApps"
|
||||
OnDeleted="AppDeleted" />
|
||||
}
|
||||
else if (selectedCustomer is not null)
|
||||
{
|
||||
@* ───────────── Level 2: Apps for a customer ───────────── *@
|
||||
|
||||
<nav class="mb-3">
|
||||
<button class="btn btn-sm btn-link text-decoration-none p-0" @onclick="BackToCustomers">
|
||||
<i class="bi bi-arrow-left me-1"></i>All Customers
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<div class="d-flex align-items-center mb-3">
|
||||
<i class="bi bi-person-circle fs-4 me-2 text-primary"></i>
|
||||
<div>
|
||||
<h4 class="mb-0">@selectedCustomer.Name</h4>
|
||||
<small class="text-muted">Applications owned by this customer</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@* --- Add App --- *@
|
||||
<div class="card border-0 shadow-sm mb-4">
|
||||
<div class="card-body py-2">
|
||||
<div class="input-group">
|
||||
<span class="input-group-text bg-transparent border-end-0"><i class="bi bi-plus-circle"></i></span>
|
||||
<input type="text" class="form-control border-start-0" placeholder="New app name (e.g. billing-api)"
|
||||
@bind="newAppName" @bind:event="oninput"
|
||||
@onkeydown="@(async (e) => { if (e.Key == "Enter" && !string.IsNullOrWhiteSpace(newAppName)) await CreateApp(); })" />
|
||||
<button class="btn btn-primary" @onclick="CreateApp" disabled="@string.IsNullOrWhiteSpace(newAppName)">
|
||||
<i class="bi bi-plus-lg me-1"></i>Add App
|
||||
</button>
|
||||
</div>
|
||||
@if (!string.IsNullOrEmpty(appError))
|
||||
{
|
||||
<div class="text-danger small mt-2"><i class="bi bi-exclamation-triangle me-1"></i>@appError</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@* --- App cards --- *@
|
||||
@if (apps is null)
|
||||
{
|
||||
<div class="text-center py-3">
|
||||
<div class="spinner-border spinner-border-sm text-primary" role="status"></div>
|
||||
</div>
|
||||
}
|
||||
else if (apps.Count == 0)
|
||||
{
|
||||
<div class="text-center py-5">
|
||||
<i class="bi bi-app-indicator text-muted" style="font-size: 3rem;"></i>
|
||||
<p class="text-muted mt-2 mb-0">No apps yet. Create one above.</p>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="row row-cols-1 row-cols-md-2 g-3">
|
||||
@foreach (Data.App app in apps)
|
||||
{
|
||||
<div class="col">
|
||||
<div class="card shadow-sm h-100" role="button" @onclick="() => OpenApp(app)" style="cursor: pointer;">
|
||||
<div class="card-body py-3">
|
||||
<div class="d-flex align-items-start">
|
||||
<i class="bi bi-app-indicator fs-4 me-3 text-primary"></i>
|
||||
<div class="flex-grow-1 min-width-0">
|
||||
<h6 class="fw-semibold mb-1">@app.Name</h6>
|
||||
<div class="d-flex flex-wrap gap-1 mb-1">
|
||||
@if (app.AppEnvironments.Any())
|
||||
{
|
||||
@foreach (AppEnvironment ae in app.AppEnvironments)
|
||||
{
|
||||
<span class="badge bg-primary bg-opacity-75">@ae.Environment.Name</span>
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
<small class="text-muted">No environments linked</small>
|
||||
}
|
||||
</div>
|
||||
<small class="text-muted">Created @app.CreatedAt.ToString("MMM d, yyyy")</small>
|
||||
</div>
|
||||
<i class="bi bi-chevron-right text-muted"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@* --- Portal Access Management --- *@
|
||||
<div class="card shadow-sm mt-4">
|
||||
<div class="card-header bg-white py-2">
|
||||
<div class="d-flex align-items-center">
|
||||
<i class="bi bi-shield-lock me-2 text-primary"></i>
|
||||
<strong>Portal Access</strong>
|
||||
</div>
|
||||
<small class="text-muted">Grant users access to this customer's apps via the customer portal.</small>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
@* --- Grant access form --- *@
|
||||
<div class="input-group mb-3">
|
||||
<input type="email" class="form-control form-control-sm" placeholder="User email address"
|
||||
@bind="grantEmail" @bind:event="oninput" />
|
||||
<select class="form-select form-select-sm" style="max-width: 140px;" @bind="grantRole">
|
||||
<option value="@CustomerAccessRole.Viewer">Viewer</option>
|
||||
<option value="@CustomerAccessRole.Operator">Operator</option>
|
||||
<option value="@CustomerAccessRole.Admin">Admin</option>
|
||||
</select>
|
||||
<button class="btn btn-sm btn-outline-primary" @onclick="GrantAccess"
|
||||
disabled="@string.IsNullOrWhiteSpace(grantEmail)">
|
||||
<i class="bi bi-plus me-1"></i>Grant
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@if (!string.IsNullOrEmpty(accessError))
|
||||
{
|
||||
<div class="alert alert-danger py-1 small mb-2">@accessError</div>
|
||||
}
|
||||
|
||||
@if (!string.IsNullOrEmpty(accessSuccess))
|
||||
{
|
||||
<div class="alert alert-success py-1 small mb-2">@accessSuccess</div>
|
||||
}
|
||||
|
||||
@* --- Current access list --- *@
|
||||
@if (customerAccesses is null)
|
||||
{
|
||||
<div class="text-center py-2">
|
||||
<div class="spinner-border spinner-border-sm text-primary" role="status"></div>
|
||||
</div>
|
||||
}
|
||||
else if (customerAccesses.Count == 0)
|
||||
{
|
||||
<p class="text-muted small mb-0">No users have portal access to this customer yet.</p>
|
||||
}
|
||||
else
|
||||
{
|
||||
@foreach (CustomerAccess access in customerAccesses)
|
||||
{
|
||||
<div class="d-flex align-items-center justify-content-between py-1 border-bottom">
|
||||
<div class="small">
|
||||
<i class="bi bi-person me-1"></i>
|
||||
<span class="fw-medium">@access.User.Email</span>
|
||||
<span class="badge @GetRoleBadgeClass(access.Role) ms-1">@access.Role</span>
|
||||
</div>
|
||||
<button class="btn btn-sm btn-outline-danger" title="Revoke access"
|
||||
@onclick="() => RevokeAccess(access.UserId)">
|
||||
<i class="bi bi-x"></i>
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
@* ───────────── Level 1: Customer list ───────────── *@
|
||||
|
||||
<div class="d-flex align-items-center mb-2">
|
||||
<i class="bi bi-people fs-4 me-2 text-primary"></i>
|
||||
<h4 class="mb-0">Customers</h4>
|
||||
</div>
|
||||
<p class="text-muted small mb-3">End-clients or accounts served by this tenant. Click a customer to manage their apps.</p>
|
||||
|
||||
<div class="card border-0 shadow-sm mb-4">
|
||||
<div class="card-body py-2">
|
||||
<div class="input-group">
|
||||
<span class="input-group-text bg-transparent border-end-0"><i class="bi bi-plus-circle"></i></span>
|
||||
<input type="text" class="form-control border-start-0" placeholder="New customer name (e.g. Contoso Ltd)"
|
||||
@bind="newName" @bind:event="oninput"
|
||||
@onkeydown="@(async (e) => { if (e.Key == "Enter" && !string.IsNullOrWhiteSpace(newName)) await Create(); })" />
|
||||
<button class="btn btn-primary" @onclick="Create" disabled="@string.IsNullOrWhiteSpace(newName)">
|
||||
<i class="bi bi-plus-lg me-1"></i>Add
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (!string.IsNullOrEmpty(errorMessage))
|
||||
{
|
||||
<div class="alert alert-danger alert-dismissible fade show py-2" role="alert">
|
||||
<i class="bi bi-exclamation-triangle me-1"></i>@errorMessage
|
||||
<button type="button" class="btn-close btn-close-sm" @onclick="() => errorMessage = null"></button>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (customers is null)
|
||||
{
|
||||
<div class="text-center py-4">
|
||||
<div class="spinner-border spinner-border-sm text-primary" role="status"></div>
|
||||
<span class="ms-2 text-muted">Loading customers...</span>
|
||||
</div>
|
||||
}
|
||||
else if (customers.Count == 0)
|
||||
{
|
||||
<div class="text-center py-5">
|
||||
<i class="bi bi-people text-muted" style="font-size: 3rem;"></i>
|
||||
<p class="text-muted mt-2 mb-0">No customers yet. Create one above to get started.</p>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="list-group shadow-sm">
|
||||
@foreach (Customer customer in customers)
|
||||
{
|
||||
<div class="list-group-item p-0">
|
||||
@if (confirmDeleteId == customer.Id)
|
||||
{
|
||||
<div class="d-flex align-items-center justify-content-between px-3 py-2 bg-danger bg-opacity-10">
|
||||
<span class="text-danger small"><i class="bi bi-exclamation-triangle me-1"></i>Delete <strong>@customer.Name</strong> and all apps?</span>
|
||||
<div>
|
||||
<button class="btn btn-sm btn-danger me-1" @onclick="() => Delete(customer.Id)">Yes, delete</button>
|
||||
<button class="btn btn-sm btn-outline-secondary" @onclick="() => confirmDeleteId = null">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="d-flex align-items-center px-3 py-2">
|
||||
<div class="flex-grow-1 d-flex align-items-center" role="button" @onclick="() => OpenCustomer(customer)" style="cursor: pointer;">
|
||||
<i class="bi bi-person-circle me-2 text-primary"></i>
|
||||
<div>
|
||||
<span class="fw-semibold">@customer.Name</span>
|
||||
<br />
|
||||
<small class="text-muted">@(appCounts.GetValueOrDefault(customer.Id, 0)) app(s) · Created @customer.CreatedAt.ToString("MMM d, yyyy")</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="d-flex align-items-center gap-1">
|
||||
<button class="btn btn-sm btn-outline-danger" @onclick="() => confirmDeleteId = customer.Id"
|
||||
@onclick:stopPropagation="true" title="Delete customer">
|
||||
<i class="bi bi-trash"></i>
|
||||
</button>
|
||||
<i class="bi bi-chevron-right text-muted ms-2"></i>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
@code {
|
||||
[Parameter] public Guid TenantId { get; set; }
|
||||
|
||||
// --- Level 1: Customer list ---
|
||||
private List<Customer>? customers;
|
||||
private Dictionary<Guid, int> appCounts = new();
|
||||
private string newName = "";
|
||||
private string? errorMessage;
|
||||
private Guid? confirmDeleteId;
|
||||
|
||||
// --- Level 2: Selected customer → apps ---
|
||||
private Customer? selectedCustomer;
|
||||
private List<Data.App>? apps;
|
||||
private string newAppName = "";
|
||||
private string? appError;
|
||||
|
||||
// --- Level 3: Selected app → detail ---
|
||||
private Data.App? selectedApp;
|
||||
|
||||
// --- Portal access management ---
|
||||
private List<CustomerAccess>? customerAccesses;
|
||||
private string grantEmail = "";
|
||||
private CustomerAccessRole grantRole = CustomerAccessRole.Viewer;
|
||||
private string? accessError;
|
||||
private string? accessSuccess;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
await LoadCustomers();
|
||||
}
|
||||
|
||||
// ──────────────── Level 1 ────────────────
|
||||
|
||||
private async Task LoadCustomers()
|
||||
{
|
||||
customers = await TenantService.GetCustomersAsync(TenantId);
|
||||
|
||||
// Pre-load app counts for the list view.
|
||||
appCounts = new Dictionary<Guid, int>();
|
||||
|
||||
foreach (Customer customer in customers)
|
||||
{
|
||||
List<Data.App> customerApps = await TenantService.GetAppsAsync(customer.Id);
|
||||
appCounts[customer.Id] = customerApps.Count;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task Create()
|
||||
{
|
||||
errorMessage = null;
|
||||
|
||||
try
|
||||
{
|
||||
await TenantService.CreateCustomerAsync(TenantId, newName.Trim());
|
||||
newName = "";
|
||||
await LoadCustomers();
|
||||
}
|
||||
catch (DbUpdateException)
|
||||
{
|
||||
errorMessage = "A customer with that name already exists.";
|
||||
}
|
||||
}
|
||||
|
||||
private async Task Delete(Guid id)
|
||||
{
|
||||
confirmDeleteId = null;
|
||||
await TenantService.DeleteCustomerAsync(id);
|
||||
await LoadCustomers();
|
||||
}
|
||||
|
||||
private async Task OpenCustomer(Customer customer)
|
||||
{
|
||||
selectedCustomer = customer;
|
||||
selectedApp = null;
|
||||
appError = null;
|
||||
newAppName = "";
|
||||
accessError = null;
|
||||
accessSuccess = null;
|
||||
await LoadApps();
|
||||
await LoadAccesses();
|
||||
}
|
||||
|
||||
private async Task BackToCustomers()
|
||||
{
|
||||
selectedCustomer = null;
|
||||
selectedApp = null;
|
||||
apps = null;
|
||||
await LoadCustomers();
|
||||
}
|
||||
|
||||
// ──────────────── Level 2 ────────────────
|
||||
|
||||
private async Task LoadApps()
|
||||
{
|
||||
apps = await TenantService.GetAppsAsync(selectedCustomer!.Id);
|
||||
}
|
||||
|
||||
private async Task CreateApp()
|
||||
{
|
||||
appError = null;
|
||||
|
||||
try
|
||||
{
|
||||
await TenantService.CreateAppAsync(selectedCustomer!.Id, newAppName.Trim());
|
||||
newAppName = "";
|
||||
await LoadApps();
|
||||
}
|
||||
catch (DbUpdateException)
|
||||
{
|
||||
appError = "An app with that name already exists for this customer.";
|
||||
}
|
||||
}
|
||||
|
||||
private void OpenApp(Data.App app)
|
||||
{
|
||||
selectedApp = app;
|
||||
}
|
||||
|
||||
private async Task BackToApps()
|
||||
{
|
||||
selectedApp = null;
|
||||
await LoadApps();
|
||||
}
|
||||
|
||||
private async Task AppDeleted()
|
||||
{
|
||||
selectedApp = null;
|
||||
await LoadApps();
|
||||
}
|
||||
|
||||
// ──────────────── Portal Access ────────────────
|
||||
|
||||
private async Task LoadAccesses()
|
||||
{
|
||||
customerAccesses = await CustomerAccessService.GetCustomerUsersAsync(selectedCustomer!.Id);
|
||||
}
|
||||
|
||||
private async Task GrantAccess()
|
||||
{
|
||||
accessError = null;
|
||||
accessSuccess = null;
|
||||
|
||||
// Look up the user by email.
|
||||
ApplicationUser? user = await TenantService.FindUserByEmailAsync(grantEmail.Trim());
|
||||
|
||||
if (user is null)
|
||||
{
|
||||
accessError = $"No user found with email '{grantEmail}'.";
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await CustomerAccessService.GrantAccessAsync(user.Id, selectedCustomer!.Id, grantRole);
|
||||
accessSuccess = $"Access granted to {grantEmail}.";
|
||||
grantEmail = "";
|
||||
await LoadAccesses();
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
accessError = $"User '{grantEmail}' already has access to this customer.";
|
||||
}
|
||||
}
|
||||
|
||||
private async Task RevokeAccess(string userId)
|
||||
{
|
||||
await CustomerAccessService.RevokeAccessAsync(userId, selectedCustomer!.Id);
|
||||
await LoadAccesses();
|
||||
}
|
||||
|
||||
private static string GetRoleBadgeClass(CustomerAccessRole role) => role switch
|
||||
{
|
||||
CustomerAccessRole.Admin => "bg-danger",
|
||||
CustomerAccessRole.Operator => "bg-warning text-dark",
|
||||
CustomerAccessRole.Viewer => "bg-info",
|
||||
_ => "bg-secondary"
|
||||
};
|
||||
}
|
||||
1338
src/EntKube.Web/Components/Pages/Tenants/DatabaseTab.razor
Normal file
1338
src/EntKube.Web/Components/Pages/Tenants/DatabaseTab.razor
Normal file
File diff suppressed because it is too large
Load Diff
447
src/EntKube.Web/Components/Pages/Tenants/EnvironmentTab.razor
Normal file
447
src/EntKube.Web/Components/Pages/Tenants/EnvironmentTab.razor
Normal file
@@ -0,0 +1,447 @@
|
||||
@using EntKube.Web.Data
|
||||
@using EntKube.Web.Services
|
||||
@using Microsoft.AspNetCore.Components.Forms
|
||||
@using Microsoft.EntityFrameworkCore
|
||||
@inject TenantService TenantService
|
||||
@inject VaultService VaultService
|
||||
|
||||
@* ===========================================================================
|
||||
Three-level drill-down: Environments ▸ Clusters ▸ Cluster Detail
|
||||
The user sees ONE level at a time — no nested accordion clutter.
|
||||
=========================================================================== *@
|
||||
|
||||
@if (selectedCluster is not null)
|
||||
{
|
||||
@* ───────────── Level 3: Cluster Detail ───────────── *@
|
||||
<ClusterDetail Cluster="selectedCluster"
|
||||
TenantId="TenantId"
|
||||
OnBack="BackToClusters"
|
||||
OnDeleted="() => ClusterDeleted()" />
|
||||
}
|
||||
else if (selectedEnv is not null)
|
||||
{
|
||||
@* ───────────── Level 2: Clusters in an environment ───────────── *@
|
||||
|
||||
@* Breadcrumb back to environments *@
|
||||
<nav class="mb-3">
|
||||
<button class="btn btn-sm btn-link text-decoration-none p-0" @onclick="BackToEnvironments">
|
||||
<i class="bi bi-arrow-left me-1"></i>All Environments
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<div class="d-flex align-items-center mb-3">
|
||||
<i class="bi bi-box fs-4 me-2 text-primary"></i>
|
||||
<div>
|
||||
<h4 class="mb-0">@selectedEnv.Name</h4>
|
||||
<small class="text-muted">Clusters registered in this environment</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@* --- Add Cluster (kubeconfig) --- *@
|
||||
@if (showAddCluster)
|
||||
{
|
||||
<div class="card shadow-sm mb-4">
|
||||
<div class="card-header bg-white d-flex align-items-center justify-content-between py-2">
|
||||
<span><i class="bi bi-plus-circle me-2 text-success"></i><strong>Register a Cluster</strong></span>
|
||||
<button class="btn btn-sm btn-outline-secondary" @onclick="() => { showAddCluster = false; ResetClusterForm(); }">
|
||||
<i class="bi bi-x-lg"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
@if (parsedContexts is null || parsedContexts.Count == 0)
|
||||
{
|
||||
<p class="text-muted small mb-2">Paste your kubeconfig YAML or upload a file to auto-detect clusters and API server URLs.</p>
|
||||
|
||||
<textarea class="form-control mb-2" rows="5"
|
||||
placeholder="apiVersion: v1 kind: Config clusters: - cluster: server: https://..."
|
||||
@bind="kubeconfigInput" @bind:event="oninput"></textarea>
|
||||
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<label class="btn btn-sm btn-outline-secondary mb-0">
|
||||
<i class="bi bi-upload me-1"></i>Upload file
|
||||
<InputFile OnChange="HandleFileUpload" class="d-none" accept=".yaml,.yml,.conf,.config" />
|
||||
</label>
|
||||
<button class="btn btn-sm btn-primary"
|
||||
@onclick="ParseKubeconfig"
|
||||
disabled="@string.IsNullOrWhiteSpace(kubeconfigInput)">
|
||||
<i class="bi bi-search me-1"></i>Parse Contexts
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<p class="text-muted small mb-2">Found @parsedContexts.Count context(s). Select one to register:</p>
|
||||
|
||||
<div class="list-group mb-3">
|
||||
@foreach (KubeconfigContext ctx in parsedContexts)
|
||||
{
|
||||
<label class="list-group-item list-group-item-action d-flex align-items-center gap-2 py-2 @(selectedContext == ctx.Name ? "active" : "")">
|
||||
<input class="form-check-input mt-0" type="radio" name="contextSelect"
|
||||
checked="@(selectedContext == ctx.Name)"
|
||||
@onchange="() => SelectContext(ctx)" />
|
||||
<div class="flex-grow-1">
|
||||
<span class="fw-medium @(selectedContext == ctx.Name ? "text-white" : "")">@ctx.Name</span>
|
||||
<br />
|
||||
<small class="@(selectedContext == ctx.Name ? "text-white-50" : "text-muted")">@ctx.ClusterServer</small>
|
||||
</div>
|
||||
@if (ctx.IsCurrent)
|
||||
{
|
||||
<span class="badge @(selectedContext == ctx.Name ? "bg-white text-primary" : "bg-info")">current</span>
|
||||
}
|
||||
</label>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="row g-2 align-items-end">
|
||||
<div class="col" style="min-width: 200px; max-width: 300px;">
|
||||
<label class="form-label small mb-1">Cluster display name</label>
|
||||
<input type="text" class="form-control form-control-sm"
|
||||
placeholder="e.g. production-eu-west"
|
||||
@bind="newClusterName" @bind:event="oninput" />
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<button class="btn btn-sm btn-success" @onclick="CreateCluster"
|
||||
disabled="@(string.IsNullOrWhiteSpace(newClusterName) || string.IsNullOrWhiteSpace(selectedContext))">
|
||||
<i class="bi bi-check-lg me-1"></i>Register
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (!string.IsNullOrEmpty(clusterError))
|
||||
{
|
||||
<div class="alert alert-danger mt-2 mb-0 py-1 small">
|
||||
<i class="bi bi-exclamation-triangle me-1"></i>@clusterError
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<button class="btn btn-primary btn-sm mb-3" @onclick="() => showAddCluster = true">
|
||||
<i class="bi bi-plus-lg me-1"></i>Register Cluster
|
||||
</button>
|
||||
}
|
||||
|
||||
@* --- Cluster Cards --- *@
|
||||
@if (envClusters is null)
|
||||
{
|
||||
<div class="text-center py-3">
|
||||
<div class="spinner-border spinner-border-sm text-primary" role="status"></div>
|
||||
</div>
|
||||
}
|
||||
else if (envClusters.Count == 0)
|
||||
{
|
||||
<div class="text-center py-5">
|
||||
<i class="bi bi-hdd-rack text-muted" style="font-size: 3rem;"></i>
|
||||
<p class="text-muted mt-2 mb-0">No clusters registered yet.</p>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="row row-cols-1 row-cols-md-2 g-3">
|
||||
@foreach (KubernetesCluster cluster in envClusters)
|
||||
{
|
||||
<div class="col">
|
||||
<div class="card shadow-sm h-100 cluster-card" role="button" @onclick="() => OpenCluster(cluster)" style="cursor: pointer;">
|
||||
<div class="card-body py-3">
|
||||
<div class="d-flex align-items-start">
|
||||
<i class="bi bi-hdd-rack fs-4 me-3 text-primary"></i>
|
||||
<div class="flex-grow-1 min-width-0">
|
||||
<h6 class="fw-semibold mb-1">@cluster.Name</h6>
|
||||
@if (!string.IsNullOrEmpty(cluster.ContextName))
|
||||
{
|
||||
<div class="mb-1">
|
||||
<small class="text-muted"><i class="bi bi-terminal me-1"></i><code>@cluster.ContextName</code></small>
|
||||
</div>
|
||||
}
|
||||
<small class="text-muted text-truncate d-block" style="max-width: 280px;">
|
||||
<i class="bi bi-globe me-1"></i>@cluster.ApiServerUrl
|
||||
</small>
|
||||
</div>
|
||||
<i class="bi bi-chevron-right text-muted"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
@* ───────────── Level 1: Environment list ───────────── *@
|
||||
|
||||
<div class="d-flex align-items-center mb-2">
|
||||
<i class="bi bi-layers fs-4 me-2 text-primary"></i>
|
||||
<h4 class="mb-0">Environments</h4>
|
||||
</div>
|
||||
<p class="text-muted small mb-3">Deployment stages for this tenant. Click an environment to manage its clusters.</p>
|
||||
|
||||
<div class="card border-0 shadow-sm mb-4">
|
||||
<div class="card-body py-2">
|
||||
<div class="input-group">
|
||||
<span class="input-group-text bg-transparent border-end-0"><i class="bi bi-plus-circle"></i></span>
|
||||
<input type="text" class="form-control border-start-0" placeholder="New environment name (e.g. Production)"
|
||||
@bind="newName" @bind:event="oninput"
|
||||
@onkeydown="@(async (e) => { if (e.Key == "Enter" && !string.IsNullOrWhiteSpace(newName)) await Create(); })" />
|
||||
<button class="btn btn-primary" @onclick="Create" disabled="@string.IsNullOrWhiteSpace(newName)">
|
||||
<i class="bi bi-plus-lg me-1"></i>Add
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (!string.IsNullOrEmpty(errorMessage))
|
||||
{
|
||||
<div class="alert alert-danger alert-dismissible fade show py-2" role="alert">
|
||||
<i class="bi bi-exclamation-triangle me-1"></i>@errorMessage
|
||||
<button type="button" class="btn-close btn-close-sm" @onclick="() => errorMessage = null"></button>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (environments is null)
|
||||
{
|
||||
<div class="text-center py-4">
|
||||
<div class="spinner-border spinner-border-sm text-primary" role="status"></div>
|
||||
<span class="ms-2 text-muted">Loading environments...</span>
|
||||
</div>
|
||||
}
|
||||
else if (environments.Count == 0)
|
||||
{
|
||||
<div class="text-center py-5">
|
||||
<i class="bi bi-layers text-muted" style="font-size: 3rem;"></i>
|
||||
<p class="text-muted mt-2 mb-0">No environments yet. Create one above to get started.</p>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="list-group shadow-sm">
|
||||
@foreach (Data.Environment env in environments)
|
||||
{
|
||||
<div class="list-group-item p-0">
|
||||
@if (confirmDeleteEnvId == env.Id)
|
||||
{
|
||||
<div class="d-flex align-items-center justify-content-between px-3 py-2 bg-danger bg-opacity-10">
|
||||
<span class="text-danger small"><i class="bi bi-exclamation-triangle me-1"></i>Delete <strong>@env.Name</strong> and all its clusters?</span>
|
||||
<div>
|
||||
<button class="btn btn-sm btn-danger me-1" @onclick="() => Delete(env.Id)">Yes, delete</button>
|
||||
<button class="btn btn-sm btn-outline-secondary" @onclick="() => confirmDeleteEnvId = null">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="d-flex align-items-center px-3 py-2">
|
||||
<div class="flex-grow-1 d-flex align-items-center" role="button" @onclick="() => OpenEnvironment(env)" style="cursor: pointer;">
|
||||
<i class="bi bi-box me-2 text-primary"></i>
|
||||
<div>
|
||||
<span class="fw-semibold">@env.Name</span>
|
||||
<br />
|
||||
<small class="text-muted">@(clusterCounts.GetValueOrDefault(env.Id, 0)) cluster(s)</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="d-flex align-items-center gap-1">
|
||||
<button class="btn btn-sm btn-outline-danger" @onclick="() => confirmDeleteEnvId = env.Id"
|
||||
@onclick:stopPropagation="true" title="Delete environment">
|
||||
<i class="bi bi-trash"></i>
|
||||
</button>
|
||||
<i class="bi bi-chevron-right text-muted ms-2"></i>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
@code {
|
||||
[Parameter] public Guid TenantId { get; set; }
|
||||
|
||||
// --- Level 1: Environment list ---
|
||||
private List<Data.Environment>? environments;
|
||||
private Dictionary<Guid, int> clusterCounts = new();
|
||||
private string newName = "";
|
||||
private string? errorMessage;
|
||||
private Guid? confirmDeleteEnvId;
|
||||
|
||||
// --- Level 2: Selected environment → clusters ---
|
||||
private Data.Environment? selectedEnv;
|
||||
private List<KubernetesCluster>? envClusters;
|
||||
private bool showAddCluster;
|
||||
|
||||
// --- Level 3: Selected cluster → detail ---
|
||||
private KubernetesCluster? selectedCluster;
|
||||
|
||||
// --- Kubeconfig parsing ---
|
||||
private string kubeconfigInput = "";
|
||||
private List<KubeconfigContext>? parsedContexts;
|
||||
private string? selectedContext;
|
||||
private string? selectedServerUrl;
|
||||
private string newClusterName = "";
|
||||
private string? clusterError;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
await LoadEnvironments();
|
||||
}
|
||||
|
||||
// ──────────────── Level 1 ────────────────
|
||||
|
||||
private async Task LoadEnvironments()
|
||||
{
|
||||
environments = await TenantService.GetEnvironmentsAsync(TenantId);
|
||||
|
||||
// Pre-load cluster counts so the list shows "N cluster(s)" without expanding.
|
||||
List<KubernetesCluster> allClusters = await TenantService.GetClustersAsync(TenantId);
|
||||
clusterCounts = allClusters.GroupBy(c => c.EnvironmentId).ToDictionary(g => g.Key, g => g.Count());
|
||||
}
|
||||
|
||||
private async Task Create()
|
||||
{
|
||||
errorMessage = null;
|
||||
|
||||
try
|
||||
{
|
||||
await TenantService.CreateEnvironmentAsync(TenantId, newName.Trim());
|
||||
newName = "";
|
||||
await LoadEnvironments();
|
||||
}
|
||||
catch (DbUpdateException)
|
||||
{
|
||||
errorMessage = "An environment with that name already exists.";
|
||||
}
|
||||
}
|
||||
|
||||
private async Task Delete(Guid id)
|
||||
{
|
||||
confirmDeleteEnvId = null;
|
||||
await TenantService.DeleteEnvironmentAsync(id);
|
||||
await LoadEnvironments();
|
||||
}
|
||||
|
||||
private async Task OpenEnvironment(Data.Environment env)
|
||||
{
|
||||
selectedEnv = env;
|
||||
selectedCluster = null;
|
||||
showAddCluster = false;
|
||||
ResetClusterForm();
|
||||
await LoadClusters();
|
||||
}
|
||||
|
||||
private async Task BackToEnvironments()
|
||||
{
|
||||
selectedEnv = null;
|
||||
selectedCluster = null;
|
||||
envClusters = null;
|
||||
await LoadEnvironments();
|
||||
}
|
||||
|
||||
// ──────────────── Level 2 ────────────────
|
||||
|
||||
private async Task LoadClusters()
|
||||
{
|
||||
List<KubernetesCluster> allClusters = await TenantService.GetClustersAsync(TenantId);
|
||||
envClusters = allClusters.Where(c => c.EnvironmentId == selectedEnv!.Id).ToList();
|
||||
}
|
||||
|
||||
private void OpenCluster(KubernetesCluster cluster)
|
||||
{
|
||||
selectedCluster = cluster;
|
||||
}
|
||||
|
||||
private async Task BackToClusters()
|
||||
{
|
||||
selectedCluster = null;
|
||||
await LoadClusters();
|
||||
}
|
||||
|
||||
private async Task ClusterDeleted()
|
||||
{
|
||||
selectedCluster = null;
|
||||
await LoadClusters();
|
||||
}
|
||||
|
||||
// --- Kubeconfig ---
|
||||
|
||||
private void ParseKubeconfig()
|
||||
{
|
||||
clusterError = null;
|
||||
parsedContexts = KubeconfigParser.ParseContexts(kubeconfigInput);
|
||||
|
||||
if (parsedContexts.Count == 0)
|
||||
{
|
||||
clusterError = "No valid contexts found in the kubeconfig. Check the YAML format.";
|
||||
return;
|
||||
}
|
||||
|
||||
KubeconfigContext? defaultCtx = parsedContexts.FirstOrDefault(c => c.IsCurrent)
|
||||
?? parsedContexts.First();
|
||||
|
||||
SelectContext(defaultCtx);
|
||||
}
|
||||
|
||||
private async Task HandleFileUpload(InputFileChangeEventArgs e)
|
||||
{
|
||||
IBrowserFile file = e.File;
|
||||
|
||||
if (file.Size > 1_048_576)
|
||||
{
|
||||
clusterError = "File too large. Kubeconfig files should be under 1 MB.";
|
||||
return;
|
||||
}
|
||||
|
||||
using StreamReader reader = new(file.OpenReadStream(maxAllowedSize: 1_048_576));
|
||||
kubeconfigInput = await reader.ReadToEndAsync();
|
||||
ParseKubeconfig();
|
||||
}
|
||||
|
||||
private void SelectContext(KubeconfigContext ctx)
|
||||
{
|
||||
selectedContext = ctx.Name;
|
||||
selectedServerUrl = ctx.ClusterServer;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(newClusterName))
|
||||
{
|
||||
newClusterName = ctx.Name;
|
||||
}
|
||||
}
|
||||
|
||||
private void ResetClusterForm()
|
||||
{
|
||||
kubeconfigInput = "";
|
||||
parsedContexts = null;
|
||||
selectedContext = null;
|
||||
selectedServerUrl = null;
|
||||
newClusterName = "";
|
||||
clusterError = null;
|
||||
}
|
||||
|
||||
private async Task CreateCluster()
|
||||
{
|
||||
clusterError = null;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(selectedServerUrl))
|
||||
{
|
||||
clusterError = "No context selected.";
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await TenantService.CreateClusterAsync(
|
||||
TenantId, selectedEnv!.Id, newClusterName.Trim(), selectedServerUrl,
|
||||
contextName: selectedContext, kubeconfig: kubeconfigInput);
|
||||
|
||||
showAddCluster = false;
|
||||
ResetClusterForm();
|
||||
await LoadClusters();
|
||||
}
|
||||
catch (DbUpdateException)
|
||||
{
|
||||
clusterError = "A cluster with that name already exists.";
|
||||
}
|
||||
}
|
||||
}
|
||||
134
src/EntKube.Web/Components/Pages/Tenants/GroupTab.razor
Normal file
134
src/EntKube.Web/Components/Pages/Tenants/GroupTab.razor
Normal file
@@ -0,0 +1,134 @@
|
||||
@using EntKube.Web.Data
|
||||
@using EntKube.Web.Services
|
||||
@using Microsoft.EntityFrameworkCore
|
||||
@inject TenantService TenantService
|
||||
|
||||
<div class="d-flex align-items-center mb-2">
|
||||
<i class="bi bi-diagram-3 fs-4 me-2 text-primary"></i>
|
||||
<h4 class="mb-0">Groups</h4>
|
||||
</div>
|
||||
<p class="text-muted small mb-3">Organize users within this tenant into logical groups for access control and team management.</p>
|
||||
|
||||
<div class="card border-0 shadow-sm mb-4">
|
||||
<div class="card-body py-2">
|
||||
<div class="input-group">
|
||||
<span class="input-group-text bg-transparent border-end-0"><i class="bi bi-plus-circle"></i></span>
|
||||
<input type="text" class="form-control border-start-0" placeholder="New group name (e.g. Developers)"
|
||||
@bind="newName" @bind:event="oninput"
|
||||
@onkeydown="@(async (e) => { if (e.Key == "Enter" && !string.IsNullOrWhiteSpace(newName)) await Create(); })" />
|
||||
<button class="btn btn-primary" @onclick="Create" disabled="@string.IsNullOrWhiteSpace(newName)">
|
||||
<i class="bi bi-plus-lg me-1"></i>Add
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (!string.IsNullOrEmpty(errorMessage))
|
||||
{
|
||||
<div class="alert alert-danger alert-dismissible fade show py-2" role="alert">
|
||||
<i class="bi bi-exclamation-triangle me-1"></i>@errorMessage
|
||||
<button type="button" class="btn-close btn-close-sm" @onclick="() => errorMessage = null"></button>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (groups is null)
|
||||
{
|
||||
<div class="text-center py-4">
|
||||
<div class="spinner-border spinner-border-sm text-primary" role="status"></div>
|
||||
<span class="ms-2 text-muted">Loading groups...</span>
|
||||
</div>
|
||||
}
|
||||
else if (groups.Count == 0)
|
||||
{
|
||||
<div class="text-center py-5">
|
||||
<i class="bi bi-diagram-3 text-muted" style="font-size: 3rem;"></i>
|
||||
<p class="text-muted mt-2 mb-0">No groups yet. Create one above to get started.</p>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="row row-cols-1 row-cols-md-2 row-cols-lg-3 g-3">
|
||||
@foreach (Group group in groups)
|
||||
{
|
||||
<div class="col">
|
||||
<div class="card shadow-sm h-100">
|
||||
<div class="card-body py-3">
|
||||
<div class="d-flex align-items-start justify-content-between">
|
||||
<div>
|
||||
<h6 class="fw-semibold mb-1">
|
||||
<i class="bi bi-people-fill me-1 text-primary"></i>@group.Name
|
||||
</h6>
|
||||
<div class="d-flex align-items-center gap-3">
|
||||
<small class="text-muted">
|
||||
<i class="bi bi-person me-1"></i>@group.Memberships.Count member(s)
|
||||
</small>
|
||||
<small class="text-muted">
|
||||
<i class="bi bi-calendar me-1"></i>@group.CreatedAt.ToString("MMM d, yyyy")
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
<button class="btn btn-sm btn-outline-danger" @onclick="() => confirmDeleteId = group.Id" title="Delete group">
|
||||
<i class="bi bi-trash"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (confirmDeleteId == group.Id)
|
||||
{
|
||||
<div class="card-footer py-2 bg-danger bg-opacity-10">
|
||||
<div class="d-flex align-items-center justify-content-between">
|
||||
<small class="text-danger"><i class="bi bi-exclamation-triangle me-1"></i>Delete <strong>@group.Name</strong>?</small>
|
||||
<div>
|
||||
<button class="btn btn-sm btn-danger me-1" @onclick="() => Delete(group.Id)">Delete</button>
|
||||
<button class="btn btn-sm btn-outline-secondary" @onclick="() => confirmDeleteId = null">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@code {
|
||||
[Parameter] public Guid TenantId { get; set; }
|
||||
|
||||
private List<Group>? groups;
|
||||
private string newName = "";
|
||||
private string? errorMessage;
|
||||
private Guid? confirmDeleteId;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
await Load();
|
||||
}
|
||||
|
||||
private async Task Load()
|
||||
{
|
||||
groups = await TenantService.GetGroupsAsync(TenantId);
|
||||
}
|
||||
|
||||
private async Task Create()
|
||||
{
|
||||
errorMessage = null;
|
||||
|
||||
try
|
||||
{
|
||||
await TenantService.CreateGroupAsync(TenantId, newName.Trim());
|
||||
newName = "";
|
||||
await Load();
|
||||
}
|
||||
catch (DbUpdateException)
|
||||
{
|
||||
errorMessage = "A group with that name already exists.";
|
||||
}
|
||||
}
|
||||
|
||||
private async Task Delete(Guid id)
|
||||
{
|
||||
confirmDeleteId = null;
|
||||
await TenantService.DeleteGroupAsync(id);
|
||||
await Load();
|
||||
}
|
||||
}
|
||||
159
src/EntKube.Web/Components/Pages/Tenants/MetricChart.razor
Normal file
159
src/EntKube.Web/Components/Pages/Tenants/MetricChart.razor
Normal file
@@ -0,0 +1,159 @@
|
||||
@using EntKube.Web.Services
|
||||
|
||||
@* ═══════════════════════════════════════════════════════════════════════════
|
||||
MetricChart — renders a time-series as an SVG area chart with gradient fill.
|
||||
Purely server-rendered, no JS dependencies. Responsive via viewBox.
|
||||
═══════════════════════════════════════════════════════════════════════════ *@
|
||||
|
||||
<div class="metric-chart-container">
|
||||
@if (!string.IsNullOrEmpty(Title))
|
||||
{
|
||||
<div class="d-flex justify-content-between align-items-center mb-1">
|
||||
<small class="text-muted fw-medium">@Title</small>
|
||||
@if (DataPoints.Count > 0)
|
||||
{
|
||||
<small class="fw-bold @GetValueColorClass()">@CurrentValue</small>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (DataPoints.Count >= 2)
|
||||
{
|
||||
<svg viewBox="0 0 @Width @Height" preserveAspectRatio="none"
|
||||
style="width: 100%; height: @(HeightPx)px; display: block;">
|
||||
<defs>
|
||||
<linearGradient id="@GradientId" x1="0%" y1="0%" x2="0%" y2="100%">
|
||||
<stop offset="0%" style="stop-color: @Color; stop-opacity: 0.3;" />
|
||||
<stop offset="100%" style="stop-color: @Color; stop-opacity: 0.02;" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
@* Area fill below the line *@
|
||||
<path d="@AreaPath" fill="url(#@GradientId)" />
|
||||
|
||||
@* The line itself *@
|
||||
<polyline points="@LinePath" fill="none" stroke="@Color" stroke-width="1.5"
|
||||
stroke-linecap="round" stroke-linejoin="round" />
|
||||
</svg>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="text-center py-2">
|
||||
<small class="text-muted">Insufficient data</small>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@code {
|
||||
[Parameter] public string? Title { get; set; }
|
||||
[Parameter] public List<TimeSeriesDataPoint> DataPoints { get; set; } = [];
|
||||
[Parameter] public string Color { get; set; } = "#0d6efd";
|
||||
[Parameter] public string? Unit { get; set; }
|
||||
[Parameter] public int HeightPx { get; set; } = 60;
|
||||
[Parameter] public double? MaxValue { get; set; }
|
||||
|
||||
private const int Width = 300;
|
||||
private const int Height = 80;
|
||||
private const int Padding = 2;
|
||||
|
||||
private string GradientId => $"grad-{Title?.GetHashCode():x}";
|
||||
|
||||
private string CurrentValue
|
||||
{
|
||||
get
|
||||
{
|
||||
if (DataPoints.Count == 0)
|
||||
{
|
||||
return "";
|
||||
}
|
||||
|
||||
double last = DataPoints[^1].Value;
|
||||
string formatted = last >= 100 ? last.ToString("F0") : last.ToString("F1");
|
||||
return Unit is not null ? $"{formatted}{Unit}" : formatted;
|
||||
}
|
||||
}
|
||||
|
||||
private string LinePath
|
||||
{
|
||||
get
|
||||
{
|
||||
if (DataPoints.Count < 2)
|
||||
{
|
||||
return "";
|
||||
}
|
||||
|
||||
double minVal = 0;
|
||||
double maxVal = MaxValue ?? DataPoints.Max(d => d.Value);
|
||||
if (maxVal <= minVal)
|
||||
{
|
||||
maxVal = minVal + 1;
|
||||
}
|
||||
|
||||
double xStep = (double)(Width - 2 * Padding) / (DataPoints.Count - 1);
|
||||
|
||||
List<string> points = [];
|
||||
for (int i = 0; i < DataPoints.Count; i++)
|
||||
{
|
||||
double x = Padding + i * xStep;
|
||||
double y = Height - Padding - ((DataPoints[i].Value - minVal) / (maxVal - minVal) * (Height - 2 * Padding));
|
||||
points.Add($"{x:F1},{y:F1}");
|
||||
}
|
||||
|
||||
return string.Join(" ", points);
|
||||
}
|
||||
}
|
||||
|
||||
private string AreaPath
|
||||
{
|
||||
get
|
||||
{
|
||||
if (DataPoints.Count < 2)
|
||||
{
|
||||
return "";
|
||||
}
|
||||
|
||||
double minVal = 0;
|
||||
double maxVal = MaxValue ?? DataPoints.Max(d => d.Value);
|
||||
if (maxVal <= minVal)
|
||||
{
|
||||
maxVal = minVal + 1;
|
||||
}
|
||||
|
||||
double xStep = (double)(Width - 2 * Padding) / (DataPoints.Count - 1);
|
||||
|
||||
// Start at the bottom-left corner.
|
||||
List<string> pathParts = [$"M {Padding:F1},{Height - Padding:F1}"];
|
||||
|
||||
// Line to first data point.
|
||||
for (int i = 0; i < DataPoints.Count; i++)
|
||||
{
|
||||
double x = Padding + i * xStep;
|
||||
double y = Height - Padding - ((DataPoints[i].Value - minVal) / (maxVal - minVal) * (Height - 2 * Padding));
|
||||
pathParts.Add($"L {x:F1},{y:F1}");
|
||||
}
|
||||
|
||||
// Close back to bottom-right.
|
||||
double lastX = Padding + (DataPoints.Count - 1) * xStep;
|
||||
pathParts.Add($"L {lastX:F1},{Height - Padding:F1}");
|
||||
pathParts.Add("Z");
|
||||
|
||||
return string.Join(" ", pathParts);
|
||||
}
|
||||
}
|
||||
|
||||
private string GetValueColorClass()
|
||||
{
|
||||
if (DataPoints.Count == 0)
|
||||
{
|
||||
return "";
|
||||
}
|
||||
|
||||
double last = DataPoints[^1].Value;
|
||||
return last switch
|
||||
{
|
||||
>= 90 => "text-danger",
|
||||
>= 70 => "text-warning",
|
||||
_ => "text-success"
|
||||
};
|
||||
}
|
||||
}
|
||||
1191
src/EntKube.Web/Components/Pages/Tenants/StorageTab.razor
Normal file
1191
src/EntKube.Web/Components/Pages/Tenants/StorageTab.razor
Normal file
File diff suppressed because it is too large
Load Diff
108
src/EntKube.Web/Components/Pages/Tenants/TenantDetail.razor
Normal file
108
src/EntKube.Web/Components/Pages/Tenants/TenantDetail.razor
Normal file
@@ -0,0 +1,108 @@
|
||||
@page "/tenants/{Slug}"
|
||||
@using EntKube.Web.Data
|
||||
@using EntKube.Web.Services
|
||||
@using Microsoft.EntityFrameworkCore
|
||||
@rendermode InteractiveServer
|
||||
@inject TenantService TenantService
|
||||
@inject NavigationManager Navigation
|
||||
|
||||
<PageTitle>@(tenant?.Name ?? "Tenant")</PageTitle>
|
||||
|
||||
@if (tenant is null)
|
||||
{
|
||||
<div class="text-center py-5">
|
||||
<div class="spinner-border text-primary" role="status"></div>
|
||||
<p class="text-muted mt-2">Loading tenant...</p>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<nav aria-label="breadcrumb" class="mb-3">
|
||||
<ol class="breadcrumb small">
|
||||
<li class="breadcrumb-item"><a href="/tenants" class="text-decoration-none"><i class="bi bi-building me-1"></i>Tenants</a></li>
|
||||
<li class="breadcrumb-item active">@tenant.Name</li>
|
||||
</ol>
|
||||
</nav>
|
||||
|
||||
<div class="d-flex align-items-center mb-4">
|
||||
<i class="bi bi-building fs-3 me-2 text-primary"></i>
|
||||
<div>
|
||||
<h2 class="mb-0">@tenant.Name</h2>
|
||||
<small class="text-muted"><code>@tenant.Slug</code></small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ul class="nav nav-pills mb-4 gap-1">
|
||||
<li class="nav-item">
|
||||
<button class="nav-link @(activeTab == "environments" ? "active" : "")" @onclick='() => activeTab = "environments"'>
|
||||
<i class="bi bi-layers me-1"></i>Environments
|
||||
</button>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<button class="nav-link @(activeTab == "customers" ? "active" : "")" @onclick='() => activeTab = "customers"'>
|
||||
<i class="bi bi-people me-1"></i>Customers
|
||||
</button>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<button class="nav-link @(activeTab == "vault" ? "active" : "")" @onclick='() => activeTab = "vault"'>
|
||||
<i class="bi bi-shield-lock me-1"></i>Vault
|
||||
</button>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<button class="nav-link @(activeTab == "groups" ? "active" : "")" @onclick='() => activeTab = "groups"'>
|
||||
<i class="bi bi-diagram-3 me-1"></i>Groups
|
||||
</button>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<button class="nav-link @(activeTab == "databases" ? "active" : "")" @onclick='() => activeTab = "databases"'>
|
||||
<i class="bi bi-database me-1"></i>Databases
|
||||
</button>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<button class="nav-link @(activeTab == "storage" ? "active" : "")" @onclick='() => activeTab = "storage"'>
|
||||
<i class="bi bi-bucket me-1"></i>Storage
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div class="tab-content">
|
||||
@switch (activeTab)
|
||||
{
|
||||
case "environments":
|
||||
<EnvironmentTab TenantId="tenant.Id" />
|
||||
break;
|
||||
case "customers":
|
||||
<CustomerTab TenantId="tenant.Id" />
|
||||
break;
|
||||
case "vault":
|
||||
<VaultTab TenantId="tenant.Id" />
|
||||
break;
|
||||
case "groups":
|
||||
<GroupTab TenantId="tenant.Id" />
|
||||
break;
|
||||
case "databases":
|
||||
<DatabaseTab TenantId="tenant.Id" />
|
||||
break;
|
||||
case "storage":
|
||||
<StorageTab TenantId="tenant.Id" />
|
||||
break;
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@code {
|
||||
[Parameter] public string Slug { get; set; } = "";
|
||||
|
||||
private Tenant? tenant;
|
||||
private string activeTab = "environments";
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
tenant = await TenantService.GetTenantBySlugAsync(Slug);
|
||||
|
||||
if (tenant is null)
|
||||
{
|
||||
Navigation.NavigateTo("/tenants");
|
||||
}
|
||||
}
|
||||
}
|
||||
137
src/EntKube.Web/Components/Pages/Tenants/TenantList.razor
Normal file
137
src/EntKube.Web/Components/Pages/Tenants/TenantList.razor
Normal file
@@ -0,0 +1,137 @@
|
||||
@page "/tenants"
|
||||
@using EntKube.Web.Data
|
||||
@using EntKube.Web.Services
|
||||
@using Microsoft.EntityFrameworkCore
|
||||
@rendermode InteractiveServer
|
||||
@inject TenantService TenantService
|
||||
@inject NavigationManager Navigation
|
||||
|
||||
<PageTitle>Tenants</PageTitle>
|
||||
|
||||
<div class="d-flex align-items-center justify-content-between mb-4">
|
||||
<div>
|
||||
<h1 class="mb-0"><i class="bi bi-building me-2 text-primary"></i>Tenants</h1>
|
||||
<p class="text-muted small mb-0 mt-1">Organizations and workspaces in your platform.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card border-0 shadow-sm mb-4">
|
||||
<div class="card-body py-2">
|
||||
<div class="input-group">
|
||||
<span class="input-group-text bg-transparent border-end-0"><i class="bi bi-plus-circle"></i></span>
|
||||
<input type="text" class="form-control border-start-0" placeholder="New tenant name (e.g. Acme Corp)"
|
||||
@bind="newTenantName" @bind:event="oninput"
|
||||
@onkeydown="@(async (e) => { if (e.Key == "Enter" && !string.IsNullOrWhiteSpace(newTenantName)) await CreateTenant(); })" />
|
||||
<button class="btn btn-primary" @onclick="CreateTenant" disabled="@string.IsNullOrWhiteSpace(newTenantName)">
|
||||
<i class="bi bi-plus-lg me-1"></i>Create Tenant
|
||||
</button>
|
||||
</div>
|
||||
@if (!string.IsNullOrEmpty(errorMessage))
|
||||
{
|
||||
<div class="text-danger small mt-2"><i class="bi bi-exclamation-triangle me-1"></i>@errorMessage</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (tenants is null)
|
||||
{
|
||||
<div class="text-center py-5">
|
||||
<div class="spinner-border text-primary" role="status"></div>
|
||||
<p class="text-muted mt-2">Loading tenants...</p>
|
||||
</div>
|
||||
}
|
||||
else if (tenants.Count == 0)
|
||||
{
|
||||
<div class="text-center py-5">
|
||||
<i class="bi bi-building text-muted" style="font-size: 4rem;"></i>
|
||||
<h5 class="text-muted mt-3">No tenants yet</h5>
|
||||
<p class="text-muted">Create your first tenant above to get started.</p>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="row row-cols-1 row-cols-md-2 row-cols-lg-3 g-3">
|
||||
@foreach (Tenant tenant in tenants)
|
||||
{
|
||||
<div class="col">
|
||||
<div class="card h-100 shadow-sm border-0 tenant-card">
|
||||
<div class="card-body">
|
||||
<div class="d-flex align-items-start justify-content-between">
|
||||
<div>
|
||||
<h5 class="card-title mb-1">
|
||||
<i class="bi bi-building me-1 text-primary"></i>@tenant.Name
|
||||
</h5>
|
||||
<p class="card-text text-muted small mb-0">
|
||||
<code>@tenant.Slug</code>
|
||||
</p>
|
||||
</div>
|
||||
<button class="btn btn-sm btn-outline-danger" @onclick="() => confirmDeleteId = tenant.Id"
|
||||
title="Delete tenant">
|
||||
<i class="bi bi-trash"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (confirmDeleteId == tenant.Id)
|
||||
{
|
||||
<div class="card-body py-2 border-top bg-danger bg-opacity-10">
|
||||
<div class="d-flex align-items-center justify-content-between">
|
||||
<small class="text-danger"><i class="bi bi-exclamation-triangle me-1"></i>Delete this tenant?</small>
|
||||
<div>
|
||||
<button class="btn btn-sm btn-danger me-1" @onclick="() => DeleteTenant(tenant.Id)">Delete</button>
|
||||
<button class="btn btn-sm btn-outline-secondary" @onclick="() => confirmDeleteId = null">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="card-footer bg-transparent border-top-0 pt-0">
|
||||
<a href="/tenants/@tenant.Slug" class="btn btn-primary btn-sm w-100">
|
||||
<i class="bi bi-gear me-1"></i>Manage
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@code {
|
||||
private List<Tenant>? tenants;
|
||||
private string newTenantName = "";
|
||||
private string? errorMessage;
|
||||
private Guid? confirmDeleteId;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
await LoadTenants();
|
||||
}
|
||||
|
||||
private async Task LoadTenants()
|
||||
{
|
||||
tenants = await TenantService.GetAllTenantsAsync();
|
||||
}
|
||||
|
||||
private async Task CreateTenant()
|
||||
{
|
||||
errorMessage = null;
|
||||
|
||||
try
|
||||
{
|
||||
await TenantService.CreateTenantAsync(newTenantName.Trim());
|
||||
newTenantName = "";
|
||||
await LoadTenants();
|
||||
}
|
||||
catch (DbUpdateException)
|
||||
{
|
||||
errorMessage = "A tenant with that name already exists.";
|
||||
}
|
||||
}
|
||||
|
||||
private async Task DeleteTenant(Guid id)
|
||||
{
|
||||
confirmDeleteId = null;
|
||||
await TenantService.DeleteTenantAsync(id);
|
||||
await LoadTenants();
|
||||
}
|
||||
}
|
||||
583
src/EntKube.Web/Components/Pages/Tenants/VaultTab.razor
Normal file
583
src/EntKube.Web/Components/Pages/Tenants/VaultTab.razor
Normal file
@@ -0,0 +1,583 @@
|
||||
@using EntKube.Web.Data
|
||||
@using EntKube.Web.Services
|
||||
@using Microsoft.EntityFrameworkCore
|
||||
@inject VaultService VaultService
|
||||
@inject TenantService TenantService
|
||||
@inject StorageService StorageService
|
||||
|
||||
<div class="d-flex align-items-center mb-2">
|
||||
<i class="bi bi-shield-lock fs-4 me-2 text-primary"></i>
|
||||
<h4 class="mb-0">Secrets Vault</h4>
|
||||
</div>
|
||||
<p class="text-muted small mb-3">Encrypted secrets for apps and cluster components. Per-tenant AES-256-GCM envelope encryption with auto-unseal.</p>
|
||||
|
||||
@if (!vaultInitialized)
|
||||
{
|
||||
<div class="card border-warning shadow-sm">
|
||||
<div class="card-body text-center py-5">
|
||||
<i class="bi bi-shield-exclamation text-warning" style="font-size: 3rem;"></i>
|
||||
<h5 class="mt-3">Vault Not Initialized</h5>
|
||||
<p class="text-muted mb-3">A per-tenant encryption key will be generated and sealed with the platform root key.</p>
|
||||
<button class="btn btn-primary" @onclick="InitializeVault">
|
||||
<i class="bi bi-key me-1"></i>Initialize Vault
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
@* --- Scope Toggle --- *@
|
||||
<div class="d-flex align-items-center gap-3 mb-3">
|
||||
<span class="text-muted small fw-medium">Scope:</span>
|
||||
<div class="btn-group" role="group">
|
||||
<button class="btn btn-sm @(scope == "app" ? "btn-primary" : "btn-outline-primary")" @onclick='() => SwitchScope("app")'>
|
||||
<i class="bi bi-app-indicator me-1"></i>App Secrets
|
||||
</button>
|
||||
<button class="btn btn-sm @(scope == "component" ? "btn-primary" : "btn-outline-primary")" @onclick='() => SwitchScope("component")'>
|
||||
<i class="bi bi-puzzle me-1"></i>Component Secrets
|
||||
</button>
|
||||
<button class="btn btn-sm @(scope == "storage" ? "btn-primary" : "btn-outline-primary")" @onclick='() => SwitchScope("storage")'>
|
||||
<i class="bi bi-hdd me-1"></i>Storage Secrets
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@* --- Selector Dropdown --- *@
|
||||
@if (scope == "app")
|
||||
{
|
||||
@if (appOptions is not null && appOptions.Count > 0)
|
||||
{
|
||||
<div class="card border-0 shadow-sm mb-3">
|
||||
<div class="card-body py-2">
|
||||
<div class="input-group">
|
||||
<span class="input-group-text bg-transparent"><i class="bi bi-app"></i></span>
|
||||
<select class="form-select" @bind="selectedAppId" @bind:after="LoadSecrets">
|
||||
<option value="@Guid.Empty">Select an app...</option>
|
||||
@foreach ((string label, Guid id) in appOptions)
|
||||
{
|
||||
<option value="@id">@label</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="text-center py-4">
|
||||
<i class="bi bi-app text-muted" style="font-size: 2rem;"></i>
|
||||
<p class="text-muted small mt-2 mb-0">Create a customer and app first to store app secrets.</p>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
else if (scope == "component")
|
||||
{
|
||||
@if (componentOptions is not null && componentOptions.Count > 0)
|
||||
{
|
||||
<div class="card border-0 shadow-sm mb-3">
|
||||
<div class="card-body py-2">
|
||||
<div class="input-group">
|
||||
<span class="input-group-text bg-transparent"><i class="bi bi-puzzle"></i></span>
|
||||
<select class="form-select" @bind="selectedComponentId" @bind:after="LoadSecrets">
|
||||
<option value="@Guid.Empty">Select a component...</option>
|
||||
@foreach ((string label, Guid id) in componentOptions)
|
||||
{
|
||||
<option value="@id">@label</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="text-center py-4">
|
||||
<i class="bi bi-puzzle text-muted" style="font-size: 2rem;"></i>
|
||||
<p class="text-muted small mt-2 mb-0">Create an environment, cluster, and component first to store component secrets.</p>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
else if (scope == "storage")
|
||||
{
|
||||
@if (storageLinkOptions is not null && storageLinkOptions.Count > 0)
|
||||
{
|
||||
<div class="card border-0 shadow-sm mb-3">
|
||||
<div class="card-body py-2">
|
||||
<div class="input-group">
|
||||
<span class="input-group-text bg-transparent"><i class="bi bi-hdd"></i></span>
|
||||
<select class="form-select" @bind="selectedStorageLinkId" @bind:after="LoadSecrets">
|
||||
<option value="@Guid.Empty">Select a storage link...</option>
|
||||
@foreach ((string label, Guid id) in storageLinkOptions)
|
||||
{
|
||||
<option value="@id">@label</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="text-center py-4">
|
||||
<i class="bi bi-hdd text-muted" style="font-size: 2rem;"></i>
|
||||
<p class="text-muted small mt-2 mb-0">Create a storage link first to view its secrets.</p>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
@* --- Secrets Panel --- *@
|
||||
@if (HasSelection())
|
||||
{
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-header bg-white d-flex align-items-center py-2">
|
||||
<i class="bi bi-lock me-2 text-primary"></i>
|
||||
<strong>Secrets</strong>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
@* Add secret form *@
|
||||
<div class="row g-2 mb-3">
|
||||
<div class="col-md-4">
|
||||
<div class="input-group input-group-sm">
|
||||
<span class="input-group-text"><i class="bi bi-tag"></i></span>
|
||||
<input type="text" class="form-control" placeholder="SECRET_NAME"
|
||||
@bind="newSecretName" @bind:event="oninput" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-5">
|
||||
<div class="input-group input-group-sm">
|
||||
<span class="input-group-text"><i class="bi bi-key"></i></span>
|
||||
<input type="password" class="form-control" placeholder="Secret value"
|
||||
@bind="newSecretValue" @bind:event="oninput" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-auto">
|
||||
<button class="btn btn-sm btn-primary" @onclick="AddSecret"
|
||||
disabled="@(string.IsNullOrWhiteSpace(newSecretName) || string.IsNullOrWhiteSpace(newSecretValue))">
|
||||
<i class="bi bi-plus-lg me-1"></i>Set Secret
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (!string.IsNullOrEmpty(errorMessage))
|
||||
{
|
||||
<div class="alert alert-danger py-1 small mb-2">
|
||||
<i class="bi bi-exclamation-triangle me-1"></i>@errorMessage
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (!string.IsNullOrEmpty(successMessage))
|
||||
{
|
||||
<div class="alert alert-success py-1 small mb-2">
|
||||
<i class="bi bi-check-circle me-1"></i>@successMessage
|
||||
</div>
|
||||
}
|
||||
|
||||
@* Secrets table *@
|
||||
@if (secrets is null)
|
||||
{
|
||||
<div class="text-center py-3">
|
||||
<div class="spinner-border spinner-border-sm text-primary" role="status"></div>
|
||||
</div>
|
||||
}
|
||||
else if (secrets.Count == 0)
|
||||
{
|
||||
<div class="text-center py-3">
|
||||
<i class="bi bi-lock text-muted" style="font-size: 2rem;"></i>
|
||||
<p class="text-muted small mt-1 mb-0">No secrets stored yet. Add one above.</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="width: 150px;">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>Synced</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">
|
||||
<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>
|
||||
<button class="btn btn-sm btn-outline-warning me-1" @onclick="() => StartEdit(secret)" title="Edit 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" : "Enable 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>
|
||||
<td colspan="6" class="bg-light">
|
||||
<div class="p-2">
|
||||
<span class="text-muted small me-2">Value:</span>
|
||||
<code class="user-select-all">@revealedValue</code>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
|
||||
@* Edit value row *@
|
||||
@if (editSecretId == secret.Id)
|
||||
{
|
||||
<tr>
|
||||
<td colspan="6" class="bg-light">
|
||||
<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)">
|
||||
<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>
|
||||
}
|
||||
|
||||
@if (syncConfigSecretId == secret.Id)
|
||||
{
|
||||
<tr>
|
||||
<td colspan="6" class="bg-light">
|
||||
<div class="row g-2 p-2 align-items-center">
|
||||
<div class="col-md-4">
|
||||
<input type="text" class="form-control form-control-sm"
|
||||
placeholder="K8s Secret name (e.g. my-app-secrets)" @bind="syncSecretName" />
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<input type="text" class="form-control form-control-sm"
|
||||
placeholder="Namespace (e.g. default)" @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>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
@code {
|
||||
[Parameter] public Guid TenantId { get; set; }
|
||||
|
||||
private bool vaultInitialized;
|
||||
private string scope = "app";
|
||||
|
||||
// App scope
|
||||
private List<(string Label, Guid Id)>? appOptions;
|
||||
private Guid selectedAppId;
|
||||
|
||||
// Component scope
|
||||
private List<(string Label, Guid Id)>? componentOptions;
|
||||
private Guid selectedComponentId;
|
||||
|
||||
// Storage scope
|
||||
private List<(string Label, Guid Id)>? storageLinkOptions;
|
||||
private Guid selectedStorageLinkId;
|
||||
|
||||
// Secrets
|
||||
private List<VaultSecret>? secrets;
|
||||
private string newSecretName = "";
|
||||
private string newSecretValue = "";
|
||||
private string? errorMessage;
|
||||
private string? successMessage;
|
||||
private Guid? syncConfigSecretId;
|
||||
private string syncSecretName = "";
|
||||
private string syncNamespace = "";
|
||||
|
||||
// Reveal/Edit state
|
||||
private Guid? revealedSecretId;
|
||||
private string? revealedValue;
|
||||
private Guid? editSecretId;
|
||||
private string editSecretValue = "";
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
SecretVault? vault = await VaultService.GetVaultAsync(TenantId);
|
||||
vaultInitialized = vault is not null;
|
||||
|
||||
if (vaultInitialized)
|
||||
{
|
||||
await LoadOptions();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task InitializeVault()
|
||||
{
|
||||
await VaultService.InitializeVaultAsync(TenantId);
|
||||
vaultInitialized = true;
|
||||
await LoadOptions();
|
||||
}
|
||||
|
||||
private async Task LoadOptions()
|
||||
{
|
||||
List<Customer> customers = await TenantService.GetCustomersAsync(TenantId);
|
||||
appOptions = [];
|
||||
|
||||
foreach (Customer customer in customers)
|
||||
{
|
||||
List<Data.App> apps = await TenantService.GetAppsAsync(customer.Id);
|
||||
|
||||
foreach (Data.App app in apps)
|
||||
{
|
||||
appOptions.Add(($"{customer.Name} / {app.Name}", app.Id));
|
||||
}
|
||||
}
|
||||
|
||||
List<KubernetesCluster> clusters = await TenantService.GetClustersAsync(TenantId);
|
||||
componentOptions = [];
|
||||
|
||||
foreach (KubernetesCluster cluster in clusters)
|
||||
{
|
||||
List<ClusterComponent> components = await VaultService.GetComponentsAsync(cluster.Id);
|
||||
|
||||
foreach (ClusterComponent comp in components)
|
||||
{
|
||||
componentOptions.Add(($"{cluster.Name} / {comp.Name}", comp.Id));
|
||||
}
|
||||
}
|
||||
|
||||
// Load storage links for the storage secrets scope.
|
||||
|
||||
List<StorageLink> links = await StorageService.GetStorageLinksAsync(TenantId);
|
||||
storageLinkOptions = links
|
||||
.Select(l => ($"{l.Environment.Name} / {l.Name} ({l.Provider})", l.Id))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private void SwitchScope(string newScope)
|
||||
{
|
||||
scope = newScope;
|
||||
secrets = null;
|
||||
selectedAppId = Guid.Empty;
|
||||
selectedComponentId = Guid.Empty;
|
||||
selectedStorageLinkId = Guid.Empty;
|
||||
errorMessage = null;
|
||||
successMessage = null;
|
||||
}
|
||||
|
||||
private bool HasSelection()
|
||||
{
|
||||
return (scope == "app" && selectedAppId != Guid.Empty)
|
||||
|| (scope == "component" && selectedComponentId != Guid.Empty)
|
||||
|| (scope == "storage" && selectedStorageLinkId != Guid.Empty);
|
||||
}
|
||||
|
||||
private async Task LoadSecrets()
|
||||
{
|
||||
errorMessage = null;
|
||||
successMessage = null;
|
||||
|
||||
if (scope == "app" && selectedAppId != Guid.Empty)
|
||||
{
|
||||
secrets = await VaultService.GetAppSecretsAsync(TenantId, selectedAppId);
|
||||
}
|
||||
else if (scope == "component" && selectedComponentId != Guid.Empty)
|
||||
{
|
||||
secrets = await VaultService.GetComponentSecretsAsync(TenantId, selectedComponentId);
|
||||
}
|
||||
else if (scope == "storage" && selectedStorageLinkId != Guid.Empty)
|
||||
{
|
||||
secrets = await VaultService.GetStorageLinkSecretsAsync(TenantId, selectedStorageLinkId);
|
||||
}
|
||||
else
|
||||
{
|
||||
secrets = null;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task AddSecret()
|
||||
{
|
||||
errorMessage = null;
|
||||
successMessage = null;
|
||||
|
||||
try
|
||||
{
|
||||
if (scope == "app")
|
||||
{
|
||||
await VaultService.SetAppSecretAsync(TenantId, selectedAppId, newSecretName.Trim(), newSecretValue.Trim());
|
||||
}
|
||||
else if (scope == "component")
|
||||
{
|
||||
await VaultService.SetComponentSecretAsync(TenantId, selectedComponentId, newSecretName.Trim(), newSecretValue.Trim());
|
||||
}
|
||||
else if (scope == "storage")
|
||||
{
|
||||
await VaultService.SetStorageLinkSecretAsync(TenantId, selectedStorageLinkId, newSecretName.Trim(), newSecretValue.Trim());
|
||||
}
|
||||
|
||||
successMessage = $"Secret '{newSecretName.Trim()}' saved.";
|
||||
newSecretName = "";
|
||||
newSecretValue = "";
|
||||
await LoadSecrets();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
errorMessage = ex.Message;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task DeleteSecret(Guid secretId)
|
||||
{
|
||||
errorMessage = null;
|
||||
|
||||
// Check if deletion is blocked by active storage bindings.
|
||||
|
||||
(bool canDelete, string? reason) = await VaultService.CanDeleteSecretAsync(secretId);
|
||||
|
||||
if (!canDelete)
|
||||
{
|
||||
errorMessage = reason;
|
||||
return;
|
||||
}
|
||||
|
||||
await VaultService.DeleteSecretAsync(secretId);
|
||||
revealedSecretId = null;
|
||||
editSecretId = null;
|
||||
await LoadSecrets();
|
||||
}
|
||||
|
||||
private async Task RevealSecret(VaultSecret secret)
|
||||
{
|
||||
// Toggle off if already revealed.
|
||||
|
||||
if (revealedSecretId == secret.Id)
|
||||
{
|
||||
revealedSecretId = null;
|
||||
revealedValue = null;
|
||||
return;
|
||||
}
|
||||
|
||||
// Decrypt and reveal the value.
|
||||
|
||||
revealedValue = await VaultService.GetSecretValueByIdAsync(secret.Id);
|
||||
revealedSecretId = secret.Id;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
errorMessage = null;
|
||||
|
||||
bool updated = await VaultService.UpdateSecretValueAsync(editSecretId.Value, editSecretValue.Trim());
|
||||
|
||||
if (updated)
|
||||
{
|
||||
successMessage = "Secret value updated.";
|
||||
}
|
||||
else
|
||||
{
|
||||
errorMessage = "Failed to update secret.";
|
||||
}
|
||||
|
||||
editSecretId = null;
|
||||
editSecretValue = "";
|
||||
await LoadSecrets();
|
||||
}
|
||||
|
||||
private void ToggleSync(VaultSecret secret)
|
||||
{
|
||||
if (secret.SyncToKubernetes)
|
||||
{
|
||||
_ = DisableSync(secret.Id);
|
||||
}
|
||||
else
|
||||
{
|
||||
syncConfigSecretId = secret.Id;
|
||||
syncSecretName = secret.KubernetesSecretName ?? "";
|
||||
syncNamespace = secret.KubernetesNamespace ?? "";
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
await VaultService.ConfigureKubernetesSyncAsync(
|
||||
syncConfigSecretId.Value,
|
||||
syncEnabled: true,
|
||||
secretName: syncSecretName.Trim(),
|
||||
ns: syncNamespace.Trim());
|
||||
|
||||
syncConfigSecretId = null;
|
||||
await LoadSecrets();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user