database and gitsync issues fixed

This commit is contained in:
Nils Blomgren
2026-06-07 14:54:16 +02:00
parent 8dc46ef031
commit 76b7e55931
72 changed files with 31067 additions and 1508 deletions

View File

@@ -1,4 +1,5 @@
@inherits LayoutComponentBase
@inject NavigationManager NavigationManager
<div class="page">
<div class="sidebar">
@@ -6,8 +7,17 @@
</div>
<main>
<div class="top-row px-4">
<a href="https://learn.microsoft.com/aspnet/core/" target="_blank">About</a>
<div class="top-row px-4 d-flex align-items-center justify-content-between">
<span class="text-muted small">
<i class="bi bi-server me-1"></i>EntKube Platform
</span>
<AuthorizeView>
<Authorized>
<span class="text-muted small">
<i class="bi bi-person-circle me-1"></i>@context.User.Identity?.Name
</span>
</Authorized>
</AuthorizeView>
</div>
<article class="content px-4">

View File

@@ -1,30 +1,25 @@
@page "/Error"
@page "/Error"
@using System.Diagnostics
<PageTitle>Error</PageTitle>
<PageTitle>Error — EntKube</PageTitle>
<h1 class="text-danger">Error.</h1>
<h2 class="text-danger">An error occurred while processing your request.</h2>
<div class="text-center py-5">
<i class="bi bi-exclamation-triangle text-danger" style="font-size: 4rem;"></i>
<h2 class="mt-3 mb-1">Something went wrong</h2>
<p class="text-muted mb-1">An unexpected error occurred while processing your request.</p>
@if (ShowRequestId)
{
<p class="text-muted small mb-4">Request ID: <code>@RequestId</code></p>
}
<a href="/" class="btn btn-primary me-2">
<i class="bi bi-house me-1"></i>Go Home
</a>
<a href="javascript:location.reload()" class="btn btn-outline-secondary">
<i class="bi bi-arrow-clockwise me-1"></i>Retry
</a>
</div>
@if (ShowRequestId)
{
<p>
<strong>Request ID:</strong> <code>@RequestId</code>
</p>
}
<h3>Development Mode</h3>
<p>
Swapping to <strong>Development</strong> environment will display more detailed information about the error that occurred.
</p>
<p>
<strong>The Development environment shouldn't be enabled for deployed applications.</strong>
It can result in displaying sensitive information from exceptions to end users.
For local debugging, enable the <strong>Development</strong> environment by setting the <strong>ASPNETCORE_ENVIRONMENT</strong> environment variable to <strong>Development</strong>
and restarting the app.
</p>
@code{
@code {
[CascadingParameter]
private HttpContext? HttpContext { get; set; }

View File

@@ -1,19 +1,111 @@
@page "/"
@using Microsoft.AspNetCore.Components.Authorization
<PageTitle>EntKube</PageTitle>
<h1>EntKube</h1>
<p class="lead">Multi-tenant platform for managing shared Kubernetes applications and infrastructure services.</p>
<div class="mt-4 d-flex gap-2">
<AuthorizeView Policy="HasTenantAccess">
<Authorized>
<a href="/tenants" class="btn btn-primary btn-lg">Manage Tenants</a>
</Authorized>
</AuthorizeView>
<AuthorizeView>
<Authorized>
<a href="/portal" class="btn btn-outline-secondary btn-lg">Customer Portal</a>
</Authorized>
</AuthorizeView>
<div class="mb-4">
<h1 class="mb-1"><i class="bi bi-server me-2 text-primary"></i>EntKube</h1>
<p class="text-muted mb-0">Multi-tenant platform for managing shared Kubernetes applications and infrastructure services.</p>
</div>
<AuthorizeView>
<Authorized>
<div class="row row-cols-1 row-cols-md-2 row-cols-lg-3 g-3">
<AuthorizeView Policy="HasTenantAccess" Context="tenantCtx">
<Authorized>
<div class="col">
<a href="/tenants" class="text-decoration-none">
<div class="card h-100 shadow-sm border-0 home-card">
<div class="card-body">
<div class="d-flex align-items-center mb-2">
<div class="home-card-icon bg-primary bg-opacity-10 text-primary rounded me-3">
<i class="bi bi-building fs-4"></i>
</div>
<h5 class="mb-0">Tenants</h5>
</div>
<p class="text-muted small mb-0">Manage tenant organisations, infrastructure, services, and deployments.</p>
</div>
<div class="card-footer bg-transparent border-0 pt-0">
<span class="text-primary small fw-semibold">Manage Tenants <i class="bi bi-arrow-right ms-1"></i></span>
</div>
</div>
</a>
</div>
</Authorized>
</AuthorizeView>
<div class="col">
<a href="/portal" class="text-decoration-none">
<div class="card h-100 shadow-sm border-0 home-card">
<div class="card-body">
<div class="d-flex align-items-center mb-2">
<div class="home-card-icon bg-success bg-opacity-10 text-success rounded me-3">
<i class="bi bi-grid fs-4"></i>
</div>
<h5 class="mb-0">Customer Portal</h5>
</div>
<p class="text-muted small mb-0">Browse your customers, manage deployments, and access application resources.</p>
</div>
<div class="card-footer bg-transparent border-0 pt-0">
<span class="text-success small fw-semibold">Open Portal <i class="bi bi-arrow-right ms-1"></i></span>
</div>
</div>
</a>
</div>
<div class="col">
<a href="/portal/status" class="text-decoration-none">
<div class="card h-100 shadow-sm border-0 home-card">
<div class="card-body">
<div class="d-flex align-items-center mb-2">
<div class="home-card-icon bg-info bg-opacity-10 text-info rounded me-3">
<i class="bi bi-activity fs-4"></i>
</div>
<h5 class="mb-0">Status Overview</h5>
</div>
<p class="text-muted small mb-0">Cross-customer health, SLA targets, uptime, and active alerts at a glance.</p>
</div>
<div class="card-footer bg-transparent border-0 pt-0">
<span class="text-info small fw-semibold">View Status <i class="bi bi-arrow-right ms-1"></i></span>
</div>
</div>
</a>
</div>
<AuthorizeView Roles="Admin" Context="adminCtx">
<Authorized>
<div class="col">
<a href="/admin/backup" class="text-decoration-none">
<div class="card h-100 shadow-sm border-0 home-card">
<div class="card-body">
<div class="d-flex align-items-center mb-2">
<div class="home-card-icon bg-warning bg-opacity-10 text-warning rounded me-3">
<i class="bi bi-arrow-repeat fs-4"></i>
</div>
<h5 class="mb-0">Backup &amp; Restore</h5>
</div>
<p class="text-muted small mb-0">Export and restore platform state for migration or disaster recovery.</p>
</div>
<div class="card-footer bg-transparent border-0 pt-0">
<span class="text-warning small fw-semibold">Open Backup <i class="bi bi-arrow-right ms-1"></i></span>
</div>
</div>
</a>
</div>
</Authorized>
</AuthorizeView>
</div>
</Authorized>
<NotAuthorized>
<div class="d-flex gap-2 mt-3">
<a href="/Account/Login" class="btn btn-primary btn-lg">
<i class="bi bi-box-arrow-in-right me-2"></i>Sign In
</a>
<a href="/Account/Register" class="btn btn-outline-secondary btn-lg">
<i class="bi bi-person-plus me-2"></i>Register
</a>
</div>
</NotAuthorized>
</AuthorizeView>

View File

@@ -1,5 +1,16 @@
@page "/not-found"
@page "/not-found"
@layout MainLayout
<h3>Not Found</h3>
<p>Sorry, the content you are looking for does not exist.</p>
<PageTitle>Not Found — EntKube</PageTitle>
<div class="text-center py-5">
<i class="bi bi-map text-muted" style="font-size: 4rem;"></i>
<h2 class="mt-3 mb-1">Page not found</h2>
<p class="text-muted mb-4">Sorry, the page you are looking for does not exist or has been moved.</p>
<a href="/" class="btn btn-primary me-2">
<i class="bi bi-house me-1"></i>Go Home
</a>
<a href="javascript:history.back()" class="btn btn-outline-secondary">
<i class="bi bi-arrow-left me-1"></i>Go Back
</a>
</div>

View File

@@ -15,6 +15,7 @@
@inject PrometheusService PrometheusService
@inject AuditService AuditService
@inject IncidentService IncidentService
@inject CustomerGitService CustomerGitService
@inject AuthenticationStateProvider AuthStateProvider
@inject NavigationManager Navigation
@@ -37,11 +38,9 @@
}
else if (customers is null || customers.Count == 0)
{
<div class="text-center py-5">
<i class="bi bi-shield-lock text-muted" style="font-size: 3rem;"></i>
<h4 class="mt-3">No access granted</h4>
<p class="text-muted">You don't have access to any customers yet. Ask a tenant administrator to grant you access.</p>
</div>
<EmptyState Icon="bi-shield-lock"
Title="No access granted"
Message="You don't have access to any customers yet. Ask a tenant administrator to grant you access." />
}
else
{
@@ -49,7 +48,7 @@ else
<nav aria-label="breadcrumb" class="mb-3">
<ol class="breadcrumb small">
<li class="breadcrumb-item">
@if (selectedDeployment is not null || selectedIdentityRealm is not null || selectedSecretsApp is not null || selectedRegistryProject is not null || selectedGovernanceApp is not null)
@if (selectedDeployment is not null || selectedIdentityRealm is not null || selectedSecretsApp is not null || selectedRegistryProject is not null || selectedGovernanceApp is not null || selectedResourcesApp is not null || showGitPolicies)
{
<a href="javascript:void(0)" class="text-decoration-none" @onclick="BackToApps">
<i class="bi bi-grid me-1"></i>Portal
@@ -70,7 +69,7 @@ else
@if (selectedCustomer is not null)
{
<li class="breadcrumb-item">
@if (selectedDeployment is not null || selectedIdentityRealm is not null || selectedSecretsApp is not null || selectedRegistryProject is not null || selectedGovernanceApp is not null)
@if (selectedDeployment is not null || selectedIdentityRealm is not null || selectedSecretsApp is not null || selectedRegistryProject is not null || selectedGovernanceApp is not null || selectedResourcesApp is not null || showGitPolicies)
{
<a href="javascript:void(0)" class="text-decoration-none" @onclick="BackToApps">@selectedCustomer.Name</a>
}
@@ -109,6 +108,18 @@ else
<i class="bi bi-shield-check me-1"></i>@selectedGovernanceApp.Name — Policy
</li>
}
else if (selectedResourcesApp is not null)
{
<li class="breadcrumb-item active">
<i class="bi bi-diagram-3 me-1"></i>@selectedResourcesApp.Name — Resources
</li>
}
else if (showGitPolicies)
{
<li class="breadcrumb-item active">
<i class="bi bi-git me-1"></i>Git Policies
</li>
}
</ol>
</nav>
@@ -144,10 +155,46 @@ else
</div>
}
@* ════════════════════════════════════════════════════════════════
Level 3: Git URL policies (read-only)
════════════════════════════════════════════════════════════════ *@
else if (showGitPolicies)
{
<div class="card shadow-sm">
<div class="card-header bg-white py-2 d-flex align-items-center gap-2">
<span class="bi bi-git text-primary"></span>
<strong>Git URL Policies — @selectedCustomer!.Name</strong>
<button class="btn-close btn-sm ms-auto" @onclick="() => showGitPolicies = false"></button>
</div>
<div class="card-body">
<p class="text-muted small mb-3">
These URL patterns define which git repositories you may use in deployments.
Contact your administrator to change them.
</p>
@if (gitPolicies.Count == 0)
{
<div class="text-muted small">No git URL policies defined.</div>
}
else
{
<ul class="list-group list-group-flush">
@foreach (CustomerGitRepoPolicy policy in gitPolicies)
{
<li class="list-group-item d-flex align-items-center gap-2 px-0">
<i class="bi bi-check-circle text-success"></i>
<code class="small">@policy.UrlPattern</code>
</li>
}
</ul>
}
</div>
</div>
}
@* ════════════════════════════════════════════════════════════════
Level 2: Apps and deployments for a selected customer
════════════════════════════════════════════════════════════════ *@
else if (selectedDeployment is null && selectedIdentityRealm is null && selectedSecretsApp is null && selectedRegistryProject is null)
else if (selectedDeployment is null && selectedIdentityRealm is null && selectedSecretsApp is null && selectedRegistryProject is null && !showGitPolicies)
{
<div class="d-flex align-items-center mb-3">
<i class="bi bi-people fs-3 me-2 text-primary"></i>
@@ -160,6 +207,12 @@ else
@onclick="() => showMonitoringPanel = !showMonitoringPanel">
<span class="bi bi-heart-pulse me-1"></span>Monitoring
</button>
@if (gitPolicies.Count > 0)
{
<button class="btn btn-sm btn-outline-secondary" @onclick="() => showGitPolicies = true" title="Git URL Policies">
<span class="bi bi-git me-1"></span>Git Policies
</button>
}
<a class="btn btn-sm btn-outline-secondary" href="/portal/status">
<span class="bi bi-activity me-1"></span>Status Overview
</a>
@@ -173,7 +226,7 @@ else
@if (showMonitoringPanel)
{
<div class="card shadow-sm mb-4">
<div class="card-header d-flex align-items-center gap-2 py-2">
<div class="card-header bg-white d-flex align-items-center gap-2 py-2">
<span class="bi bi-heart-pulse text-primary"></span>
<strong>Monitoring — @selectedCustomer.Name</strong>
<button class="btn-close btn-sm ms-auto" @onclick="() => showMonitoringPanel = false"></button>
@@ -204,10 +257,8 @@ else
@if (selectedCustomer.Apps.Count == 0)
{
<div class="text-center py-4">
<i class="bi bi-app-indicator text-muted" style="font-size: 2rem;"></i>
<p class="text-muted mt-2">No apps configured for this customer yet.</p>
</div>
<EmptyState Icon="bi-app-indicator"
Message="No apps configured for this customer yet." />
}
else
{
@@ -223,7 +274,7 @@ else
<i class="bi bi-app-indicator me-2 text-primary"></i>
<strong>@app.Name</strong>
</div>
<div class="d-flex gap-2">
<div class="d-flex gap-2 align-items-center">
@if (currentAccessRole >= CustomerAccessRole.Admin)
{
<button class="btn btn-sm btn-outline-primary"
@@ -231,28 +282,45 @@ else
<i class="bi bi-plus me-1"></i>New Deployment
</button>
}
@if (hasIdentity)
{
<button class="btn btn-sm btn-outline-primary"
@onclick="() => SelectIdentityRealm(app.Id)">
<i class="bi bi-shield-lock me-1"></i>Identity
<div class="dropdown">
<button class="btn btn-sm btn-outline-secondary dropdown-toggle" type="button"
data-bs-toggle="dropdown" aria-expanded="false">
<i class="bi bi-three-dots me-1"></i>More
</button>
}
@if (hasRegistry)
{
<button class="btn btn-sm btn-outline-primary"
@onclick="() => SelectRegistryProject(app.Id)">
<i class="bi bi-archive me-1"></i>Registry
</button>
}
<button class="btn btn-sm btn-outline-secondary"
@onclick="() => SelectSecretsApp(app)">
<i class="bi bi-key me-1"></i>Secrets
</button>
<button class="btn btn-sm btn-outline-secondary"
@onclick="() => SelectGovernanceApp(app)">
<i class="bi bi-shield-check me-1"></i>Policy
</button>
<ul class="dropdown-menu dropdown-menu-end">
@if (hasIdentity)
{
<li>
<button class="dropdown-item" @onclick="() => SelectIdentityRealm(app.Id)">
<i class="bi bi-shield-lock me-2 text-primary"></i>Identity
</button>
</li>
}
@if (hasRegistry)
{
<li>
<button class="dropdown-item" @onclick="() => SelectRegistryProject(app.Id)">
<i class="bi bi-archive me-2 text-primary"></i>Registry
</button>
</li>
}
<li>
<button class="dropdown-item" @onclick="() => SelectSecretsApp(app)">
<i class="bi bi-key me-2"></i>Secrets
</button>
</li>
<li>
<button class="dropdown-item" @onclick="() => SelectGovernanceApp(app)">
<i class="bi bi-shield-check me-2"></i>Policy
</button>
</li>
<li>
<button class="dropdown-item" @onclick="() => SelectResourcesApp(app)">
<i class="bi bi-diagram-3 me-2"></i>Resources
</button>
</li>
</ul>
</div>
</div>
</div>
</div>
@@ -277,6 +345,12 @@ else
<option value="@DeploymentType.Manual">Manual</option>
<option value="@DeploymentType.Yaml">YAML</option>
<option value="@DeploymentType.HelmChart">Helm Chart</option>
@if (gitPolicies.Count > 0)
{
<option value="@DeploymentType.GitYaml">Git — YAML</option>
<option value="@DeploymentType.GitHelm">Git — Helm</option>
<option value="@DeploymentType.GitAppOfApps">Git — App of Apps</option>
}
</select>
</div>
<div class="col-md-4">
@@ -301,7 +375,15 @@ else
</div>
<div class="col-md-4">
<label class="form-label small">Namespace</label>
<input class="form-control form-control-sm" @bind="newDeployNamespace" placeholder="e.g. my-app" />
@if (!string.IsNullOrWhiteSpace(app.Namespace))
{
<input class="form-control form-control-sm font-monospace" value="@app.Namespace" disabled />
<div class="form-text small text-warning"><i class="bi bi-lock me-1"></i>Locked by governance policy.</div>
}
else
{
<input class="form-control form-control-sm" @bind="newDeployNamespace" placeholder="e.g. my-app" />
}
</div>
@if (newDeployType == DeploymentType.HelmChart)
{
@@ -318,10 +400,52 @@ else
<input class="form-control form-control-sm" @bind="newHelmChartVersion" placeholder="e.g. 1.0.0" />
</div>
}
@if (newDeployType is DeploymentType.GitYaml or DeploymentType.GitHelm or DeploymentType.GitAppOfApps)
{
<div class="col-12">
@if (customerGitCredentials.Count > 0)
{
<div class="alert alert-info py-2 small mb-2">
<i class="bi bi-key-fill me-1"></i>
<strong>@customerGitCredentials.Count stored credential@(customerGitCredentials.Count != 1 ? "s" : "") on file</strong>
(@string.Join(", ", customerGitCredentials.Select(c => c.Name))) — no need to enter credentials.
</div>
}
@if (gitPolicies.Count > 0)
{
<div class="alert alert-secondary py-2 small mb-2">
<i class="bi bi-shield-check me-1"></i>
Repository URL must match one of your allowed patterns:
@foreach (CustomerGitRepoPolicy p in gitPolicies)
{
<code class="ms-2">@p.UrlPattern</code>
}
</div>
}
</div>
<div class="col-12">
<label class="form-label small">Repository URL</label>
<input class="form-control form-control-sm font-monospace @(newGitUrlError is not null ? "is-invalid" : "")"
@bind="newGitUrl" @bind:event="oninput" @onchange="ValidateNewGitUrl"
placeholder="https://github.com/org/repo" />
@if (newGitUrlError is not null)
{
<div class="invalid-feedback">@newGitUrlError</div>
}
</div>
<div class="col-md-6">
<label class="form-label small">Path <span class="text-muted">(leave blank for repo root)</span></label>
<input class="form-control form-control-sm font-monospace" @bind="newGitPath" placeholder="." />
</div>
<div class="col-md-6">
<label class="form-label small">Branch / Revision <span class="text-muted">(leave blank for default)</span></label>
<input class="form-control form-control-sm font-monospace" @bind="newGitRevision" placeholder="main" />
</div>
}
</div>
<div class="d-flex gap-1">
<button class="btn btn-sm btn-primary" @onclick="() => CreateDeployment(app.Id)"
disabled="@(string.IsNullOrWhiteSpace(newDeployName) || newDeployEnvId == Guid.Empty || newDeployClusterId == Guid.Empty || string.IsNullOrWhiteSpace(newDeployNamespace) || creatingDeployment)">
disabled="@(string.IsNullOrWhiteSpace(newDeployName) || newDeployEnvId == Guid.Empty || newDeployClusterId == Guid.Empty || string.IsNullOrWhiteSpace(newDeployNamespace) || creatingDeployment || newGitUrlError is not null || (IsGitType(newDeployType) && string.IsNullOrWhiteSpace(newGitUrl)))">
@if (creatingDeployment) { <span class="spinner-border spinner-border-sm me-1"></span> }
Create
</button>
@@ -363,7 +487,8 @@ else
}
else if (!showingCreateForm)
{
<p class="text-muted small mb-0">No deployments configured.</p>
<EmptyState Icon="bi-rocket-takeoff"
Message="No deployments configured for this app yet." />
}
</div>
</div>
@@ -383,6 +508,9 @@ else
DeploymentService="DeploymentService"
PrometheusService="PrometheusService"
AuditService="AuditService"
CustomerId="selectedCustomer!.Id"
TenantId="selectedCustomer!.TenantId"
GovernanceNamespace="@(selectedCustomer?.Apps.FirstOrDefault(a => a.Id == selectedDeployment.AppId)?.Namespace)"
OnBack="BackToApps"
OnDeleted="OnDeploymentDeleted" />
}
@@ -422,6 +550,10 @@ else
{
<AppGovernancePanel AppId="selectedGovernanceApp.Id" AppName="selectedGovernanceApp.Name" />
}
else if (selectedResourcesApp is not null)
{
<AppResourcesPanel AppId="selectedResourcesApp.Id" />
}
}
@code {
@@ -436,6 +568,7 @@ else
private Data.App? selectedSecretsApp;
private HarborProject? selectedRegistryProject;
private Data.App? selectedGovernanceApp;
private Data.App? selectedResourcesApp;
// Deployments indexed by app ID for the selected customer.
private Dictionary<Guid, List<AppDeployment>> appDeployments = new();
@@ -459,12 +592,19 @@ else
private HashSet<Guid> AllCustomerClusterIds =>
AllCustomerDeployments.Select(d => d.ClusterId).ToHashSet();
// Git policies and credentials for the selected customer.
private List<CustomerGitRepoPolicy> gitPolicies = [];
private List<CustomerGitCredential> customerGitCredentials = [];
private bool showGitPolicies;
// Create deployment form (portal Admin).
private Guid? createDeployAppId;
private string newDeployName = "", newDeployNamespace = "";
private DeploymentType newDeployType = DeploymentType.Manual;
private Guid newDeployEnvId, newDeployClusterId;
private string newHelmRepoUrl = "", newHelmChartName = "", newHelmChartVersion = "";
private string newGitUrl = "", newGitPath = "", newGitRevision = "";
private string? newGitUrlError;
private bool creatingDeployment;
private string? createDeployError;
@@ -489,6 +629,7 @@ else
{
selectedCustomer = customer;
showMonitoringPanel = false;
showGitPolicies = false;
// Look up the user's role for this customer.
CustomerAccess? access = await CustomerAccessService.GetAccessAsync(currentUserId!, customer.Id);
@@ -500,6 +641,10 @@ else
appHarborProjects.Clear();
createDeployAppId = null;
// Load git policies and credentials for this customer (all roles need policies to validate URLs).
gitPolicies = await CustomerGitService.GetRepoPoliciesAsync(customer.Id);
customerGitCredentials = await CustomerGitService.GetCredentialsAsync(customer.Id);
foreach (Data.App app in customer.Apps)
{
List<AppDeployment> deploys = await DeploymentService.GetDeploymentsAsync(app.Id);
@@ -546,6 +691,9 @@ else
selectedIdentityRealm = null;
selectedSecretsApp = null;
selectedRegistryProject = null;
gitPolicies.Clear();
customerGitCredentials.Clear();
showGitPolicies = false;
}
private void SelectSecretsApp(Data.App app)
@@ -558,6 +706,11 @@ else
selectedGovernanceApp = app;
}
private void SelectResourcesApp(Data.App app)
{
selectedResourcesApp = app;
}
private void BackToApps()
{
selectedDeployment = null;
@@ -565,6 +718,8 @@ else
selectedSecretsApp = null;
selectedRegistryProject = null;
selectedGovernanceApp = null;
selectedResourcesApp = null;
showGitPolicies = false;
}
private async Task OnDeploymentDeleted()
@@ -587,7 +742,11 @@ else
else
{
createDeployAppId = appId;
newDeployName = newDeployNamespace = newHelmRepoUrl = newHelmChartName = newHelmChartVersion = "";
Data.App? app = selectedCustomer?.Apps.FirstOrDefault(a => a.Id == appId);
newDeployNamespace = app?.Namespace ?? "";
newDeployName = newHelmRepoUrl = newHelmChartName = newHelmChartVersion = "";
newGitUrl = newGitPath = newGitRevision = "";
newGitUrlError = null;
newDeployType = DeploymentType.Manual;
newDeployEnvId = newDeployClusterId = Guid.Empty;
createDeployError = null;
@@ -601,12 +760,36 @@ else
try
{
Guid? gitRepoId = null;
if (IsGitType(newDeployType) && !string.IsNullOrWhiteSpace(newGitUrl))
{
// Validate URL against policy one more time before creating.
if (!CustomerGitService.MatchesAnyPolicy(newGitUrl, gitPolicies))
{
createDeployError = "The repository URL does not match any allowed policy pattern.";
return;
}
// Find or create a GitRepository backed by the first available credential.
CustomerGitCredential? cred = customerGitCredentials.FirstOrDefault();
if (cred is not null && selectedCustomer is not null)
{
GitRepository repo = await CustomerGitService.FindOrCreateRepositoryForCustomerAsync(
selectedCustomer.TenantId, selectedCustomer.Id, newGitUrl, cred.Id);
gitRepoId = repo.Id;
}
}
await DeploymentService.CreateDeploymentAsync(
appId, newDeployName, newDeployType,
newDeployEnvId, newDeployClusterId, newDeployNamespace,
newDeployType == DeploymentType.HelmChart ? newHelmRepoUrl : null,
newDeployType == DeploymentType.HelmChart ? newHelmChartName : null,
newDeployType == DeploymentType.HelmChart ? newHelmChartVersion : null);
newDeployType == DeploymentType.HelmChart ? newHelmChartVersion : null,
gitRepositoryId: gitRepoId,
gitPath: string.IsNullOrWhiteSpace(newGitPath) ? null : newGitPath,
gitRevision: string.IsNullOrWhiteSpace(newGitRevision) ? null : newGitRevision);
createDeployAppId = null;
appDeployments[appId] = await DeploymentService.GetDeploymentsAsync(appId);
@@ -621,6 +804,22 @@ else
}
}
private void ValidateNewGitUrl(ChangeEventArgs e)
{
newGitUrl = e.Value?.ToString() ?? "";
if (string.IsNullOrWhiteSpace(newGitUrl))
{
newGitUrlError = null;
return;
}
newGitUrlError = CustomerGitService.MatchesAnyPolicy(newGitUrl, gitPolicies)
? null
: "URL does not match any allowed policy pattern.";
}
private static bool IsGitType(DeploymentType type)
=> type is DeploymentType.GitYaml or DeploymentType.GitHelm or DeploymentType.GitAppOfApps;
// ──────── Badge helpers ────────
private RenderFragment TypeBadge(DeploymentType type) => type switch
@@ -628,6 +827,9 @@ else
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>,
DeploymentType.GitYaml => @<span class="badge bg-success"><i class="bi bi-git me-1"></i>Git YAML</span>,
DeploymentType.GitHelm => @<span class="badge bg-success"><i class="bi bi-git me-1"></i>Git Helm</span>,
DeploymentType.GitAppOfApps => @<span class="badge bg-success"><i class="bi bi-git me-1"></i>App of Apps</span>,
_ => @<span class="badge bg-secondary">Unknown</span>
};

View File

@@ -24,19 +24,12 @@
</button>
</div>
@if (isLoading)
<LoadingPanel Loading="@isLoading" LoadingText="Loading status…">
@if (!isLoading && rows.Count == 0)
{
<div class="text-center py-5">
<div class="spinner-border text-primary" role="status"></div>
<p class="text-muted mt-2">Loading status…</p>
</div>
}
else if (rows.Count == 0)
{
<div class="text-center py-5 text-muted">
<i class="bi bi-shield-lock display-4 d-block mb-2"></i>
No applications to show. You may not have access to any customer yet.
</div>
<EmptyState Icon="bi-shield-lock"
Title="No applications to show"
Message="You may not have access to any customer yet." />
}
else
{
@@ -119,6 +112,7 @@ else
Refreshed @lastRefreshed.ToLocalTime().ToString("HH:mm:ss") · Health snapshots every 5 minutes
</div>
}
</LoadingPanel>
@code {
private List<StatusRow> rows = [];

View File

@@ -6,6 +6,7 @@
@inject IncidentService IncidentService
@inject LokiService LokiService
@inject GitSyncService GitSyncService
@inject CustomerGitService CustomerGitService
@inject IDbContextFactory<ApplicationDbContext> DbFactory
@* ═══════════════════════════════════════════════════════════════════
@@ -63,7 +64,15 @@
</div>
<div class="col-md-6">
<label class="form-label small">Namespace</label>
<input class="form-control form-control-sm" @bind="editDeployNamespace" />
@if (!string.IsNullOrWhiteSpace(GovernanceNamespace))
{
<input class="form-control form-control-sm font-monospace" value="@GovernanceNamespace" disabled />
<div class="form-text small text-warning"><i class="bi bi-lock me-1"></i>Locked by governance policy.</div>
}
else
{
<input class="form-control form-control-sm" @bind="editDeployNamespace" />
}
</div>
@if (Deployment.Type == DeploymentType.HelmChart)
{
@@ -94,17 +103,25 @@
@if (AccessRole >= CustomerAccessRole.Admin && 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">
<div class="mt-3 p-3 bg-danger bg-opacity-10 rounded">
<div class="text-danger small mb-2">
<i class="bi bi-exclamation-triangle me-1"></i>
Permanently delete <strong>@Deployment.Name</strong> and all its manifests?
</span>
<div class="d-flex gap-1">
<button class="btn btn-sm btn-danger" @onclick="DeleteDeployment" disabled="@deletingDeployment">
@if (deletingDeployment) { <span class="spinner-border spinner-border-sm me-1"></span> }
Yes, delete
What would you like to do with <strong>@Deployment.Name</strong>?
</div>
@if (deleteError is not null)
{
<div class="alert alert-danger py-1 small mb-2">@deleteError</div>
}
<div class="d-flex flex-wrap gap-2">
<button class="btn btn-sm btn-danger" @onclick="() => DeleteDeployment(true)" disabled="@deletingDeployment">
@if (deletingDeployment && deleteFromCluster) { <span class="spinner-border spinner-border-sm me-1"></span> }
<i class="bi bi-trash me-1"></i>Delete (remove cluster resources + DB)
</button>
<button class="btn btn-sm btn-outline-secondary" @onclick="() => confirmDeleteDeployment = false">Cancel</button>
<button class="btn btn-sm btn-outline-danger" @onclick="() => DeleteDeployment(false)" disabled="@deletingDeployment">
@if (deletingDeployment && !deleteFromCluster) { <span class="spinner-border spinner-border-sm me-1"></span> }
<i class="bi bi-database-x me-1"></i>Unregister (DB only, keep cluster resources)
</button>
<button class="btn btn-sm btn-outline-secondary" @onclick="() => { confirmDeleteDeployment = false; deleteError = null; }">Cancel</button>
</div>
</div>
}
@@ -221,6 +238,11 @@
<i class="bi bi-journal-text me-1"></i>Logs
</button>
</li>
<li class="nav-item">
<button class="nav-link @(activeTab == "git" ? "active" : "")" @onclick="LoadGitTab">
<i class="bi bi-git me-1"></i>Git
</button>
</li>
</ul>
@* ══════════════════════════════════════════════════════════════
@@ -246,27 +268,13 @@
</div>
}
@if (podsLoading)
<LoadingPanel Loading="@podsLoading" LoadingText="Loading pods from cluster…" Error="@podsError">
@if (!podsLoading && string.IsNullOrEmpty(podsError) && (pods is null || pods.Count == 0))
{
<div class="text-center py-4">
<div class="spinner-border spinner-border-sm text-primary" role="status"></div>
<p class="text-muted small mt-2">Loading pods from cluster...</p>
</div>
<EmptyState Icon="bi-cpu"
Message="@($"No pods found in namespace {Deployment.Namespace}.")" />
}
else if (!string.IsNullOrEmpty(podsError))
{
<div class="alert alert-warning py-2 small">
<i class="bi bi-exclamation-triangle me-1"></i>@podsError
</div>
}
else if (pods is null || pods.Count == 0)
{
<div class="text-center py-4">
<i class="bi bi-cpu text-muted" style="font-size: 2rem;"></i>
<p class="text-muted small mt-2">No pods found in namespace <code>@Deployment.Namespace</code>.</p>
</div>
}
else
else if (!podsLoading && string.IsNullOrEmpty(podsError) && pods is not null && pods.Count > 0)
{
@* --- Pod log viewer (shown above pods when active) --- *@
@if (showLogViewer)
@@ -409,6 +417,7 @@
</div>
}
}
</LoadingPanel>
}
@* ══════════════════════════════════════════════════════════════
@@ -475,20 +484,12 @@
}
}
@if (manifests is null)
<LoadingPanel Loading="@(manifests is null)">
@if (manifests is not null && manifests.Count == 0)
{
<div class="text-center py-3">
<div class="spinner-border spinner-border-sm text-primary" role="status"></div>
</div>
<EmptyState Icon="bi-file-earmark-code" Message="No manifests defined for this deployment." />
}
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">No manifests defined for this deployment.</p>
</div>
}
else
else if (manifests is not null)
{
@foreach (DeploymentManifest manifest in manifests)
{
@@ -536,6 +537,7 @@
</div>
}
}
</LoadingPanel>
@if (yamlApplyOutput is not null)
{
@@ -836,6 +838,163 @@
PresetNamespace="@Deployment.Namespace" />
}
@* ══════════════════════════════════════════════════════════════
Git Tab — per-deployment git source configuration
══════════════════════════════════════════════════════════════ *@
@if (activeTab == "git")
{
<div class="card shadow-sm mb-3">
<div class="card-header bg-white py-2 d-flex align-items-center gap-2">
<span class="bi bi-git text-primary"></span>
<strong>Git Source</strong>
@if (AccessRole >= CustomerAccessRole.Admin && !showGitEdit)
{
<button class="btn btn-sm btn-outline-secondary ms-auto" @onclick="StartGitEdit">
<i class="bi bi-pencil"></i> Edit
</button>
}
</div>
<div class="card-body">
@* ── Credential notice ── *@
@if (gitTabCredentials.Count > 0)
{
<div class="alert alert-info py-2 small mb-3">
<i class="bi bi-key-fill me-1"></i>
<strong>Stored credentials on file:</strong>
@string.Join(", ", gitTabCredentials.Select(c => c.Name)) —
no need to enter credentials when setting a repository URL.
</div>
}
@* ── Policies (read-only) ── *@
@if (gitTabPolicies.Count > 0)
{
<div class="mb-3">
<div class="text-muted small mb-1"><i class="bi bi-shield-check me-1"></i>Allowed repository URL patterns:</div>
<div class="d-flex flex-wrap gap-2">
@foreach (CustomerGitRepoPolicy p in gitTabPolicies)
{
<code class="small bg-light px-2 py-1 rounded">@p.UrlPattern</code>
}
</div>
</div>
}
@if (showGitEdit && AccessRole >= CustomerAccessRole.Admin)
{
@* ── Edit form ── *@
@if (gitEditError is not null)
{
<div class="alert alert-danger py-1 small mb-2">@gitEditError</div>
}
<div class="row g-2 mb-3">
<div class="col-12">
<label class="form-label small">Repository URL</label>
<input class="form-control form-control-sm font-monospace @(gitEditUrlError is not null ? "is-invalid" : "")"
@bind="gitEditUrl" @bind:event="oninput" @onchange="ValidateGitEditUrl"
placeholder="https://github.com/org/repo" />
@if (gitEditUrlError is not null)
{
<div class="invalid-feedback">@gitEditUrlError</div>
}
</div>
<div class="col-md-6">
<label class="form-label small">Path <span class="text-muted">(leave blank for root)</span></label>
<input class="form-control form-control-sm font-monospace" @bind="gitEditPath" placeholder="." />
</div>
<div class="col-md-6">
<label class="form-label small">Branch / Revision <span class="text-muted">(leave blank for default)</span></label>
<input class="form-control form-control-sm font-monospace" @bind="gitEditRevision" placeholder="main" />
</div>
<div class="col-12">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" id="gitAutoSync" @bind="gitEditAutoSync" />
<label class="form-check-label small" for="gitAutoSync">Auto-sync (poll for changes every 3 minutes)</label>
</div>
</div>
</div>
<div class="d-flex gap-1">
<button class="btn btn-sm btn-primary" @onclick="SaveGitSettings"
disabled="@(savingGitSettings || gitEditUrlError is not null || string.IsNullOrWhiteSpace(gitEditUrl))">
@if (savingGitSettings) { <span class="spinner-border spinner-border-sm me-1"></span> }
Save
</button>
<button class="btn btn-sm btn-outline-secondary" @onclick="() => showGitEdit = false">Cancel</button>
</div>
}
else if (Deployment.GitRepositoryId is not null)
{
@* ── Current settings (read) ── *@
<dl class="row small mb-0">
<dt class="col-sm-3">Repository</dt>
<dd class="col-sm-9 font-monospace">@(Deployment.GitRepository?.Url ?? "—")</dd>
<dt class="col-sm-3">Path</dt>
<dd class="col-sm-9 font-monospace">@(Deployment.GitPath ?? ".")</dd>
<dt class="col-sm-3">Revision</dt>
<dd class="col-sm-9 font-monospace">@(Deployment.GitRevision ?? Deployment.GitRepository?.DefaultBranch ?? "main")</dd>
<dt class="col-sm-3">Auto-sync</dt>
<dd class="col-sm-9">
@if (Deployment.GitAutoSync)
{
<span class="badge bg-success">Enabled</span>
}
else
{
<span class="badge bg-secondary">Disabled</span>
}
</dd>
@if (Deployment.GitLastSyncedCommit is not null)
{
<dt class="col-sm-3">Last synced</dt>
<dd class="col-sm-9">
<code>@Deployment.GitLastSyncedCommit[..Math.Min(7, Deployment.GitLastSyncedCommit.Length)]</code>
@if (Deployment.GitLastSyncedAt.HasValue)
{
<span class="text-muted ms-2">@Deployment.GitLastSyncedAt.Value.ToString("yyyy-MM-dd HH:mm")</span>
}
</dd>
}
</dl>
@if (AccessRole >= CustomerAccessRole.Operator)
{
<div class="mt-3">
<button class="btn btn-sm btn-outline-primary" @onclick="TriggerGitSync" disabled="@syncingGit">
@if (syncingGit) { <span class="spinner-border spinner-border-sm me-1"></span> }
else { <i class="bi bi-arrow-repeat me-1"></i> }
Sync now
</button>
@if (gitSyncMessage is not null)
{
<span class="ms-2 small @(gitSyncSuccess ? "text-success" : "text-danger")">
<i class="bi @(gitSyncSuccess ? "bi-check-circle" : "bi-exclamation-triangle") me-1"></i>@gitSyncMessage
</span>
}
</div>
}
}
else
{
@* ── Not configured ── *@
<div class="text-muted small">
No git source configured for this deployment.
@if (AccessRole >= CustomerAccessRole.Admin)
{
<button class="btn btn-sm btn-outline-primary ms-2" @onclick="StartGitEdit">
<i class="bi bi-plus me-1"></i>Configure Git
</button>
}
</div>
}
</div>
</div>
}
@code {
[Parameter] public required AppDeployment Deployment { get; set; }
[Parameter] public CustomerAccessRole AccessRole { get; set; }
@@ -843,11 +1002,24 @@
[Parameter] public required DeploymentService DeploymentService { get; set; }
[Parameter] public required PrometheusService PrometheusService { get; set; }
[Parameter] public required AuditService AuditService { get; set; }
[Parameter] public Guid CustomerId { get; set; }
[Parameter] public Guid TenantId { get; set; }
[Parameter] public string? GovernanceNamespace { get; set; }
[Parameter] public EventCallback OnBack { get; set; }
[Parameter] public EventCallback OnDeleted { get; set; }
private string activeTab = "pods";
// Git tab
private List<CustomerGitRepoPolicy> gitTabPolicies = [];
private List<CustomerGitCredential> gitTabCredentials = [];
private bool showGitEdit;
private bool savingGitSettings;
private string gitEditUrl = "", gitEditPath = "", gitEditRevision = "";
private bool gitEditAutoSync = true;
private string? gitEditUrlError;
private string? gitEditError;
// Git sync
private bool syncingGit;
private string? gitSyncMessage;
@@ -877,6 +1049,107 @@
finally { syncingGit = false; }
}
// ──────── Git tab ────────
private async Task LoadGitTab()
{
activeTab = "git";
if (gitTabPolicies.Count == 0 && CustomerId != Guid.Empty)
{
gitTabPolicies = await CustomerGitService.GetRepoPoliciesAsync(CustomerId);
gitTabCredentials = await CustomerGitService.GetCredentialsAsync(CustomerId);
}
}
private void StartGitEdit()
{
gitEditUrl = Deployment.GitRepository?.Url ?? "";
gitEditPath = Deployment.GitPath ?? "";
gitEditRevision = Deployment.GitRevision ?? "";
gitEditAutoSync = Deployment.GitAutoSync;
gitEditUrlError = null;
gitEditError = null;
showGitEdit = true;
}
private void ValidateGitEditUrl(ChangeEventArgs e)
{
gitEditUrl = e.Value?.ToString() ?? "";
if (string.IsNullOrWhiteSpace(gitEditUrl))
{
gitEditUrlError = null;
return;
}
gitEditUrlError = gitTabPolicies.Count == 0 || CustomerGitService.MatchesAnyPolicy(gitEditUrl, gitTabPolicies)
? null
: "URL does not match any allowed policy pattern.";
}
private async Task SaveGitSettings()
{
savingGitSettings = true;
gitEditError = null;
try
{
if (gitTabPolicies.Count > 0 && !CustomerGitService.MatchesAnyPolicy(gitEditUrl, gitTabPolicies))
{
gitEditError = "The repository URL does not match any allowed policy pattern.";
return;
}
Guid? repoId = null;
if (!string.IsNullOrWhiteSpace(gitEditUrl))
{
CustomerGitCredential? cred = gitTabCredentials.FirstOrDefault();
if (cred is not null)
{
GitRepository repo = await CustomerGitService.FindOrCreateRepositoryForCustomerAsync(
TenantId, CustomerId, gitEditUrl, cred.Id);
repoId = repo.Id;
}
else
{
// No customer credential — check if existing repo already has this URL.
using ApplicationDbContext db = DbFactory.CreateDbContext();
GitRepository? existing = await db.GitRepositories
.FirstOrDefaultAsync(r => r.TenantId == TenantId && r.Url == gitEditUrl);
repoId = existing?.Id;
if (repoId is null)
{
gitEditError = "No stored credential found. Ask your administrator to add a git credential for your account.";
return;
}
}
}
await DeploymentService.UpdateGitSettingsAsync(
Deployment.Id, repoId,
string.IsNullOrWhiteSpace(gitEditPath) ? null : gitEditPath,
string.IsNullOrWhiteSpace(gitEditRevision) ? null : gitEditRevision,
gitEditAutoSync);
// Update local state so the UI reflects the change without reload.
Deployment.GitRepositoryId = repoId;
Deployment.GitPath = string.IsNullOrWhiteSpace(gitEditPath) ? null : gitEditPath;
Deployment.GitRevision = string.IsNullOrWhiteSpace(gitEditRevision) ? null : gitEditRevision;
Deployment.GitAutoSync = gitEditAutoSync;
Deployment.GitLastSyncedCommit = null;
Deployment.GitLastSyncedAt = null;
showGitEdit = false;
}
catch (Exception ex)
{
gitEditError = ex.Message;
}
finally
{
savingGitSettings = false;
}
}
// Pods
private List<PodInfo>? pods;
private bool podsLoading;
@@ -934,6 +1207,8 @@
// Delete deployment
private bool confirmDeleteDeployment;
private bool deletingDeployment;
private bool deleteFromCluster;
private string? deleteError;
// Resources
private List<DeploymentResource>? resourceTree;
@@ -1125,7 +1400,7 @@
private void StartEditDeployment()
{
editDeployName = Deployment.Name;
editDeployNamespace = Deployment.Namespace;
editDeployNamespace = !string.IsNullOrWhiteSpace(GovernanceNamespace) ? GovernanceNamespace : Deployment.Namespace;
editHelmRepoUrl = Deployment.HelmRepoUrl ?? "";
editHelmChartName = Deployment.HelmChartName ?? "";
editHelmChartVersion = Deployment.HelmChartVersion ?? "";
@@ -1140,14 +1415,16 @@
editDeployError = null;
try
{
string ns = !string.IsNullOrWhiteSpace(GovernanceNamespace) ? GovernanceNamespace : editDeployNamespace;
await DeploymentService.UpdateDeploymentAsync(
Deployment.Id, editDeployName, editDeployNamespace,
Deployment.Id, editDeployName, ns,
Deployment.Type == DeploymentType.HelmChart ? editHelmRepoUrl : null,
Deployment.Type == DeploymentType.HelmChart ? editHelmChartName : null,
Deployment.Type == DeploymentType.HelmChart ? editHelmChartVersion : null);
Deployment.Name = editDeployName;
Deployment.Namespace = editDeployNamespace;
Deployment.Namespace = ns;
if (Deployment.Type == DeploymentType.HelmChart)
{
Deployment.HelmRepoUrl = editHelmRepoUrl;
@@ -1161,15 +1438,36 @@
finally { savingEdit = false; }
}
private async Task DeleteDeployment()
private async Task DeleteDeployment(bool fromCluster)
{
deletingDeployment = true;
deleteFromCluster = fromCluster;
deleteError = null;
StateHasChanged();
try
{
if (fromCluster)
{
KubernetesOperationResult<string> clusterResult =
await K8sOps.DeleteDeploymentFromClusterAsync(Deployment.Id);
if (!clusterResult.IsSuccess)
{
deleteError = clusterResult.Error;
deletingDeployment = false;
return;
}
}
await DeploymentService.DeleteDeploymentAsync(Deployment.Id);
await OnDeleted.InvokeAsync();
}
catch { deletingDeployment = false; confirmDeleteDeployment = false; }
catch (Exception ex)
{
deleteError = ex.Message;
deletingDeployment = false;
}
}
// ──────── Manifests ────────

View File

@@ -36,11 +36,8 @@
@* ── Users ── *@
@if (activeTab == "users")
{
@if (loadingUsers)
{
<div class="spinner-border spinner-border-sm text-primary"></div>
}
else
<LoadingPanel Loading="@loadingUsers">
@if (!loadingUsers)
{
<div class="d-flex justify-content-between align-items-center mb-3">
<span class="text-muted small">@(users?.Count ?? 0) users</span>
@@ -126,16 +123,14 @@
</div>
}
}
</LoadingPanel>
}
@* ── Identity Providers ── *@
@if (activeTab == "idp")
{
@if (loadingIdps)
{
<div class="spinner-border spinner-border-sm text-primary"></div>
}
else
<LoadingPanel Loading="@loadingIdps">
@if (!loadingIdps)
{
<div class="d-flex justify-content-between align-items-center mb-3">
<span class="text-muted small">@(idps?.Count ?? 0) providers</span>
@@ -207,16 +202,14 @@
</div>
}
}
</LoadingPanel>
}
@* ── Groups ── *@
@if (activeTab == "groups")
{
@if (loadingGroups)
{
<div class="spinner-border spinner-border-sm text-primary"></div>
}
else
<LoadingPanel Loading="@loadingGroups">
@if (!loadingGroups)
{
<div class="d-flex justify-content-between align-items-center mb-3">
<span class="text-muted small">@(groups?.Count ?? 0) groups</span>
@@ -257,16 +250,14 @@
</div>
}
}
</LoadingPanel>
}
@* ── Organizations ── *@
@if (activeTab == "orgs")
{
@if (loadingOrgs)
{
<div class="spinner-border spinner-border-sm text-primary"></div>
}
else
<LoadingPanel Loading="@loadingOrgs">
@if (!loadingOrgs)
{
<div class="d-flex justify-content-between align-items-center mb-3">
<span class="text-muted small">@(orgs?.Count ?? 0) organizations (Keycloak 26+)</span>
@@ -322,6 +313,7 @@
</div>
}
}
</LoadingPanel>
}
@code {

View File

@@ -2,20 +2,8 @@
@using EntKube.Web.Services
@inject HarborService HarborService
@if (loading)
{
<div class="text-center py-5">
<div class="spinner-border text-primary" role="status"></div>
<p class="text-muted mt-2">Loading registry...</p>
</div>
}
else if (loadError is not null)
{
<div class="alert alert-danger">
<i class="bi bi-exclamation-triangle me-2"></i>@loadError
</div>
}
else
<LoadingPanel Loading="@loading" LoadingText="Loading registry…" Error="@loadError">
@if (!loading && loadError is null)
{
<div class="d-flex align-items-center gap-2 mb-3">
<i class="bi bi-archive fs-4 text-primary"></i>
@@ -329,6 +317,7 @@ else
}
}
}
</LoadingPanel>
@code {
[Parameter] public required HarborProject Project { get; set; }

View File

@@ -102,24 +102,13 @@ else
}
</div>
<div class="card-body p-0">
@if (secrets is null)
<LoadingPanel Loading="@(secrets is null)">
@if (secrets is not null && secrets.Count == 0)
{
<div class="text-center py-4">
<div class="spinner-border spinner-border-sm text-primary" role="status"></div>
</div>
<EmptyState Icon="bi-lock"
Message="@(AccessRole >= CustomerAccessRole.Admin ? "No secrets stored yet. Use the form above to add one." : "No secrets stored for this app yet.")" />
}
else if (secrets.Count == 0)
{
<div class="text-center py-5">
<i class="bi bi-lock text-muted" style="font-size: 2rem;"></i>
<p class="text-muted small mt-2 mb-0">No secrets stored for this app yet.</p>
@if (AccessRole >= CustomerAccessRole.Admin)
{
<p class="text-muted small mb-0">Use the form above to add one.</p>
}
</div>
}
else
else if (secrets is not null)
{
<div class="table-responsive">
<table class="table table-hover align-middle mb-0">
@@ -271,6 +260,7 @@ else
</table>
</div>
}
</LoadingPanel>
@if (syncOutput is not null)
{
<div class="m-3 p-2 bg-dark text-white rounded font-monospace small" style="white-space: pre-wrap; max-height: 200px; overflow-y: auto;">@syncOutput</div>

View File

@@ -0,0 +1,25 @@
<div class="text-center py-4">
<i class="bi @Icon text-muted" style="font-size: 2.5rem;"></i>
@if (!string.IsNullOrEmpty(Title))
{
<h6 class="mt-3 mb-1 text-muted fw-semibold">@Title</h6>
}
@if (!string.IsNullOrEmpty(Message))
{
<p class="text-muted small mb-@(OnAction.HasDelegate ? 3 : 0)">@Message</p>
}
@if (OnAction.HasDelegate && !string.IsNullOrEmpty(ActionLabel))
{
<button class="btn btn-sm btn-primary" @onclick="OnAction">
<i class="bi bi-plus me-1"></i>@ActionLabel
</button>
}
</div>
@code {
[Parameter] public string Icon { get; set; } = "bi-inbox";
[Parameter] public string? Title { get; set; }
[Parameter] public string? Message { get; set; }
[Parameter] public string? ActionLabel { get; set; }
[Parameter] public EventCallback OnAction { get; set; }
}

View File

@@ -0,0 +1,27 @@
@if (Loading)
{
<div class="text-center py-4">
<div class="spinner-border spinner-border-sm text-primary" role="status"></div>
@if (!string.IsNullOrEmpty(LoadingText))
{
<p class="text-muted small mt-2 mb-0">@LoadingText</p>
}
</div>
}
else if (!string.IsNullOrEmpty(Error))
{
<div class="alert alert-warning small py-2 mb-0">
<i class="bi bi-exclamation-triangle me-1"></i>@Error
</div>
}
else
{
@ChildContent
}
@code {
[Parameter] public bool Loading { get; set; }
[Parameter] public string? Error { get; set; }
[Parameter] public string? LoadingText { get; set; }
[Parameter] public RenderFragment? ChildContent { get; set; }
}

File diff suppressed because it is too large Load Diff

View File

@@ -30,27 +30,15 @@
</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")
<LoadingPanel Loading="@loading" Error="@errorMessage">
@if (!loading && string.IsNullOrEmpty(errorMessage) && 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>
<EmptyState Icon="bi-check-circle"
Title="All clear!"
Message="No active alerts right now." />
}
else
{
@@ -93,15 +81,12 @@
</div>
}
}
else if (view == "silences")
else if (!loading && string.IsNullOrEmpty(errorMessage) && 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>
<EmptyState Icon="bi-bell-slash" Message="No silences configured." />
}
else
{
@@ -134,6 +119,8 @@
}
}
</LoadingPanel>
@* ── Create Silence Form ── *@
@if (showCreateSilence)
{

View File

@@ -5,6 +5,7 @@
@inject TenantService TenantService
@inject DeploymentService DeploymentService
@inject CnpgService CnpgService
@inject MongoService MongoService
@inject KubernetesOperationsService K8sOps
@inject PrometheusService PrometheusService
@inject AuditService AuditService
@@ -110,20 +111,13 @@
<small class="text-muted">Select which environments this app is deployed to.</small>
</div>
<div class="card-body">
@if (environments is null)
<LoadingPanel Loading="@(environments is null)">
@if (environments is not null && environments.Count == 0)
{
<div class="text-center py-3">
<div class="spinner-border spinner-border-sm text-primary" role="status"></div>
</div>
<EmptyState Icon="bi-layers"
Message="No environments exist for this tenant yet. Create environments first." />
}
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
else if (environments is not null)
{
<div class="row row-cols-1 row-cols-md-2 row-cols-lg-3 g-2">
@foreach (Data.Environment env in environments)
@@ -149,6 +143,7 @@
}
</div>
}
</LoadingPanel>
</div>
</div>
}
@@ -183,8 +178,14 @@
</div>
</div>
<div class="d-flex align-items-start gap-1">
@GetSyncBadge(selectedDeployment.SyncStatus)
@GetHealthBadge(selectedDeployment.HealthStatus)
<button class="btn btn-link p-0 border-0 bg-transparent" title="View resources"
@onclick="OpenResourcesTab">
@GetSyncBadge(selectedDeployment.SyncStatus)
</button>
<button class="btn btn-link p-0 border-0 bg-transparent" title="View activity"
@onclick="OpenActivityTab">
@GetHealthBadge(selectedDeployment.HealthStatus)
</button>
<button class="btn btn-sm btn-outline-secondary ms-2"
@onclick="StartEditDeployment" title="Edit deployment">
@@ -436,20 +437,13 @@
</div>
}
@if (manifests is null)
<LoadingPanel Loading="@(manifests is null)">
@if (manifests is not null && manifests.Count == 0)
{
<div class="text-center py-3">
<div class="spinner-border spinner-border-sm text-primary" role="status"></div>
</div>
<EmptyState Icon="bi-file-earmark-code"
Message="No manifests yet. Add YAML manifests to define what gets deployed." />
}
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
else if (manifests is not null)
{
@foreach (DeploymentManifest manifest in manifests)
{
@@ -469,12 +463,19 @@
</button>
<button class="btn btn-sm btn-outline-secondary" @onclick="() => editingManifestId = null" disabled="@savingManifest">Cancel</button>
}
else if (confirmDeleteManifestId == manifest.Id)
{
<button class="btn btn-sm btn-danger" @onclick="() => DeleteManifest(manifest.Id)">
<i class="bi bi-trash me-1"></i>Delete
</button>
<button class="btn btn-sm btn-outline-secondary" @onclick="() => confirmDeleteManifestId = 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)">
<button class="btn btn-sm btn-outline-danger" @onclick="() => confirmDeleteManifestId = manifest.Id">
<i class="bi bi-trash"></i>
</button>
}
@@ -497,6 +498,7 @@
</div>
}
}
</LoadingPanel>
@if (yamlApplyOutput is not null)
{
@@ -621,18 +623,29 @@
{
<div class="card border-primary mb-3">
<div class="card-body">
<h6 class="mb-3"><i class="bi bi-plus-circle me-1"></i>Attach a PostgreSQL Database</h6>
<h6 class="mb-3"><i class="bi bi-plus-circle me-1"></i>Attach a Database</h6>
<div class="row g-2 mb-2">
<div class="col-md-6">
<label class="form-label small">Database</label>
<select class="form-select form-select-sm" @bind="newBindingDatabaseId">
<option value="">Select database...</option>
@if (availableCnpgDatabases is not null)
@if (availableCnpgDatabases is not null && availableCnpgDatabases.Count > 0)
{
@foreach ((CnpgCluster cluster, CnpgDatabase database) in availableCnpgDatabases)
{
<option value="@database.Id">@cluster.Name / @database.Name</option>
}
<optgroup label="PostgreSQL (CNPG)">
@foreach ((CnpgCluster cluster, CnpgDatabase database) in availableCnpgDatabases)
{
<option value="@database.Id">@cluster.Name / @database.Name</option>
}
</optgroup>
}
@if (availableMongoDatabases is not null && availableMongoDatabases.Count > 0)
{
<optgroup label="MongoDB">
@foreach ((MongoCluster cluster, MongoDatabase database) in availableMongoDatabases)
{
<option value="@database.Id">@cluster.Name / @database.Name</option>
}
</optgroup>
}
</select>
</div>
@@ -655,21 +668,15 @@
}
@* --- Binding list --- *@
@if (databaseBindings is null)
<LoadingPanel Loading="@(databaseBindings is null)">
@if (databaseBindings is not null && databaseBindings.Count == 0)
{
<div class="text-center py-3">
<div class="spinner-border spinner-border-sm text-primary" role="status"></div>
</div>
<EmptyState Icon="bi-database"
Message="No databases attached. Attach a database to have its credentials automatically synced to this deployment's namespace." />
}
else if (databaseBindings.Count == 0)
{
<div class="text-center py-4">
<i class="bi bi-database text-muted" style="font-size: 2rem;"></i>
<p class="text-muted small mt-2 mb-0">No databases attached. Attach a database to have its credentials automatically synced to this deployment's namespace.</p>
</div>
}
else
else if (databaseBindings is not null)
{
<div class="table-responsive">
<table class="table table-sm mb-0">
<thead class="table-light">
<tr>
@@ -700,16 +707,26 @@
@(binding.LastSyncedAt.HasValue ? binding.LastSyncedAt.Value.ToString("yyyy-MM-dd HH:mm") : "Never")
</td>
<td>
<button class="btn btn-link btn-sm text-danger p-0" title="Detach"
@onclick="() => RemoveDatabaseBinding(binding.Id)">
<i class="bi bi-x-circle" style="font-size:0.85rem;"></i>
</button>
@if (confirmRemoveBindingId == binding.Id)
{
<button class="btn btn-sm btn-danger me-1" @onclick="() => RemoveDatabaseBinding(binding.Id)">Detach</button>
<button class="btn btn-sm btn-link p-0" @onclick="() => confirmRemoveBindingId = null">Cancel</button>
}
else
{
<button class="btn btn-link btn-sm text-danger p-0" title="Detach"
@onclick="() => confirmRemoveBindingId = binding.Id">
<i class="bi bi-x-circle" style="font-size:0.85rem;"></i>
</button>
}
</td>
</tr>
}
</tbody>
</table>
</div>
}
</LoadingPanel>
</div>
</div>
}
@@ -770,21 +787,15 @@
</div>
}
@if (cacheBindings is null)
<LoadingPanel Loading="@(cacheBindings is null)">
@if (cacheBindings is not null && cacheBindings.Count == 0)
{
<div class="text-center py-3">
<div class="spinner-border spinner-border-sm text-warning" role="status"></div>
</div>
<EmptyState Icon="bi-lightning-charge"
Message="No Redis clusters attached. Attach one to have REDIS_HOST, REDIS_PORT, and REDIS_PASSWORD synced into this deployment's namespace." />
}
else if (cacheBindings.Count == 0)
{
<div class="text-center py-4">
<i class="bi bi-lightning-charge text-muted" style="font-size: 2rem;"></i>
<p class="text-muted small mt-2 mb-0">No Redis clusters attached. Attach one to have REDIS_HOST, REDIS_PORT, and REDIS_PASSWORD synced into this deployment's namespace.</p>
</div>
}
else
else if (cacheBindings is not null)
{
<div class="table-responsive">
<table class="table table-sm mb-0">
<thead class="table-light">
<tr>
@@ -808,16 +819,26 @@
@(binding.LastSyncedAt.HasValue ? binding.LastSyncedAt.Value.ToString("yyyy-MM-dd HH:mm") : "Never")
</td>
<td>
<button class="btn btn-link btn-sm text-danger p-0" title="Detach"
@onclick="() => RemoveCacheBinding(binding.Id)">
<i class="bi bi-x-circle" style="font-size:0.85rem;"></i>
</button>
@if (confirmRemoveCacheBindingId == binding.Id)
{
<button class="btn btn-sm btn-danger me-1" @onclick="() => RemoveCacheBinding(binding.Id)">Detach</button>
<button class="btn btn-sm btn-link p-0" @onclick="() => confirmRemoveCacheBindingId = null">Cancel</button>
}
else
{
<button class="btn btn-link btn-sm text-danger p-0" title="Detach"
@onclick="() => confirmRemoveCacheBindingId = binding.Id">
<i class="bi bi-x-circle" style="font-size:0.85rem;"></i>
</button>
}
</td>
</tr>
}
</tbody>
</table>
</div>
}
</LoadingPanel>
</div>
</div>
}
@@ -1081,20 +1102,13 @@
}
@* --- Deployment list --- *@
@if (deployments is null)
<LoadingPanel Loading="@(deployments is null)">
@if (deployments is not null && deployments.Count == 0)
{
<div class="text-center py-3">
<div class="spinner-border spinner-border-sm text-primary" role="status"></div>
</div>
<EmptyState Icon="bi-rocket-takeoff"
Message="No deployments yet. Create one to start deploying to Kubernetes." />
}
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
else if (deployments is not null)
{
<div class="row row-cols-1 row-cols-lg-2 g-2">
@foreach (AppDeployment deploy in deployments)
@@ -1129,6 +1143,7 @@
}
</div>
}
</LoadingPanel>
</div>
</div>
}
@@ -1320,10 +1335,12 @@
// Database bindings
private List<DatabaseBinding>? databaseBindings;
private List<(CnpgCluster Cluster, CnpgDatabase Database)>? availableCnpgDatabases;
private List<(MongoCluster Cluster, MongoDatabase Database)>? availableMongoDatabases;
private bool showAddBinding;
private Guid newBindingDatabaseId;
private string newBindingSecretName = "";
private string? bindingError;
private Guid? confirmRemoveBindingId;
// Cache bindings
private List<CacheBinding>? cacheBindings;
@@ -1332,6 +1349,10 @@
private Guid newCacheBindingClusterId;
private string newCacheBindingSecretName = "redis-cache";
private string? cacheBindingError;
private Guid? confirmRemoveCacheBindingId;
// Manifest confirm delete
private Guid? confirmDeleteManifestId;
protected override async Task OnInitializedAsync()
{
@@ -1629,6 +1650,7 @@
private async Task DeleteManifest(Guid manifestId)
{
confirmDeleteManifestId = null;
await DeploymentService.DeleteManifestAsync(manifestId);
await LoadManifests();
}
@@ -1761,6 +1783,15 @@
.SelectMany(c => c.Databases.Select(d => (c, d)))
.ToList();
}
if (availableMongoDatabases is null)
{
List<MongoCluster> mongoClusters = await MongoService.GetClustersAsync(TenantId);
availableMongoDatabases = mongoClusters
.Where(c => !c.IsExternal)
.SelectMany(c => c.Databases.Select(d => (c, d)))
.ToList();
}
}
}
@@ -1828,17 +1859,29 @@
try
{
DatabaseBinding binding = await CnpgService.AddCnpgDatabaseBindingAsync(
selectedDeployment.Id, newBindingDatabaseId, newBindingSecretName);
// Find the cluster that owns this database so we can call sync.
(CnpgCluster cluster, CnpgDatabase _)? match = availableCnpgDatabases?
(MongoCluster cluster, MongoDatabase _)? mongoMatch = availableMongoDatabases?
.FirstOrDefault(x => x.Database.Id == newBindingDatabaseId);
if (match.HasValue)
if (mongoMatch.HasValue)
{
await CnpgService.SyncDatabaseCredentialsToK8sAsync(
TenantId, match.Value.cluster.Id, newBindingDatabaseId);
await MongoService.AddMongoDatabaseBindingAsync(
selectedDeployment.Id, newBindingDatabaseId, newBindingSecretName);
await MongoService.SyncDatabaseCredentialsToK8sAsync(
TenantId, mongoMatch.Value.cluster.Id, newBindingDatabaseId);
}
else
{
await CnpgService.AddCnpgDatabaseBindingAsync(
selectedDeployment.Id, newBindingDatabaseId, newBindingSecretName);
(CnpgCluster cluster, CnpgDatabase _)? cnpgMatch = availableCnpgDatabases?
.FirstOrDefault(x => x.Database.Id == newBindingDatabaseId);
if (cnpgMatch.HasValue)
{
await CnpgService.SyncDatabaseCredentialsToK8sAsync(
TenantId, cnpgMatch.Value.cluster.Id, newBindingDatabaseId);
}
}
showAddBinding = false;
@@ -1854,6 +1897,7 @@
private async Task RemoveDatabaseBinding(Guid bindingId)
{
confirmRemoveBindingId = null;
bindingError = null;
try
@@ -1909,6 +1953,7 @@
private async Task RemoveCacheBinding(Guid bindingId)
{
confirmRemoveCacheBindingId = null;
cacheBindingError = null;
try

View File

@@ -4,284 +4,215 @@
@inject KubernetesOperationsService K8sOps
@* ═══════════════════════════════════════════════════════════════════
AppResourcesPanel — app-level resource overview.
AppResourcesPanel — two-level ArgoCD-style navigation.
Shows each deployment as an ArgoCD-style Application card. Clicking
a card expands its resource tree inline. Multiple cards can be open
simultaneously.
Level 1 (grid): All deployments as Application cards showing
name, type, environment, health and sync status.
Clicking a card drills into level 2.
Card layout mirrors the ArgoCD Application grid:
┌──────────────────────────────────────┐
│ [icon] deployment-name │
│ [type] env · cluster · ns │
│ ● Healthy ○ Synced │
└──────────────────────────────────────┘
Level 2 (graph): Single deployment — full ResourceTreePanel graph
with a back button to return to the grid.
This matches ArgoCD's App List → App Detail flow.
═══════════════════════════════════════════════════════════════════ *@
<style>
.app-argo-grid {
.app-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
grid-template-columns: repeat(auto-fill, minmax(230px, 1fr));
gap: 12px;
margin-bottom: 24px;
}
.app-card {
border: 1px solid #e9ecef;
.app-app-card {
border: 1px solid #dee2e6;
border-top: 3px solid var(--hc, #adb5bd);
border-radius: 8px;
background: white;
box-shadow: 0 1px 4px rgba(0,0,0,.07);
padding: 12px 14px 10px;
cursor: pointer;
transition: box-shadow .15s, border-color .15s;
padding: 12px 14px;
display: flex;
flex-direction: column;
gap: 6px;
user-select: none;
transition: box-shadow .14s, transform .1s;
}
.app-card:hover { box-shadow: 0 3px 10px rgba(0,0,0,.12); }
.app-card.expanded {
border-color: #6ea8fe;
box-shadow: 0 0 0 2px rgba(13,110,253,.15), 0 2px 8px rgba(0,0,0,.08);
.app-app-card:hover {
box-shadow: 0 4px 14px rgba(0,0,0,.13);
transform: translateY(-1px);
}
.app-card-header {
display: flex;
align-items: flex-start;
gap: 10px;
}
.app-card-icon {
width: 36px;
height: 36px;
border-radius: 8px;
background: #f0f4ff;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
font-size: 1.1rem;
}
.app-card-title {
font-weight: 600;
font-size: .88rem;
.app-card-name {
font-weight: 700;
font-size: .9rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 180px;
margin-bottom: 3px;
}
.app-card-meta {
font-size: .73rem;
font-size: .71rem;
color: #6c757d;
margin-bottom: 7px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.app-card-status {
display: flex;
align-items: center;
gap: 8px;
margin-top: 2px;
}
.argo-badge-health, .argo-badge-sync {
.app-card-badges { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
.app-badge {
display: inline-flex;
align-items: center;
gap: 3px;
font-size: .7rem;
font-weight: 500;
font-size: .67rem;
font-weight: 600;
padding: 2px 7px;
border-radius: 20px;
border: 1px solid;
white-space: nowrap;
}
.tree-section {
background: #f8f9fb;
border: 1px solid #e9ecef;
border-radius: 8px;
padding: 16px;
margin-bottom: 12px;
}
.tree-section-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
}
</style>
<div class="app-argo-grid">
@if (deployments is null)
{
@for (int i = 0; i < 3; i++)
{
<div class="app-card" style="opacity:.5;pointer-events:none;">
<div class="app-card-header">
<div class="app-card-icon"><i class="bi bi-rocket-takeoff text-primary"></i></div>
<div style="flex:1;">
<div class="app-card-title text-muted">Loading…</div>
<div class="app-card-meta">—</div>
</div>
</div>
</div>
}
}
else if (deployments.Count == 0)
{
<div style="grid-column: 1/-1;" class="text-center py-5">
<i class="bi bi-rocket-takeoff text-muted" style="font-size: 2.5rem;"></i>
<p class="text-muted small mt-2 mb-0">No deployments yet. Create one in the Deployments tab.</p>
</div>
}
else
{
@foreach (AppDeployment d in deployments)
{
string hc = HealthColor(d.HealthStatus);
bool open = expandedIds.Contains(d.Id);
<div class="app-card @(open ? "expanded" : "")"
style="--hc: @hc;"
@onclick="() => ToggleDeployment(d.Id)">
<div class="app-card-header">
<div class="app-card-icon">
<i class="bi @TypeIcon(d.Type) text-primary"></i>
</div>
<div style="flex: 1; min-width: 0;">
<div class="app-card-title" title="@d.Name">@d.Name</div>
<div class="app-card-meta">
@TypeLabel(d.Type) · @d.Environment?.Name · @d.Namespace
</div>
</div>
<i class="bi @(open ? "bi-chevron-up" : "bi-chevron-down") text-muted small"></i>
</div>
<div class="app-card-status">
@{
string hcol = HealthColor(d.HealthStatus);
string hlbl = HealthLabel(d.HealthStatus);
var (scol, sico, slbl) = SyncInfo(d.SyncStatus);
}
<span class="argo-badge-health" style="color:@hcol;border-color:@hcol;">
● @hlbl
</span>
<span class="argo-badge-sync" style="color:@scol;border-color:@scol;">
<i class="bi @sico"></i> @slbl
</span>
@if (d.GitRepositoryId is not null && d.GitLastSyncedCommit is not null)
{
<span class="badge bg-secondary" style="font-size:.65rem;">
@d.GitLastSyncedCommit[..7]
</span>
}
</div>
@if (d.Cluster is not null)
{
<div style="font-size:.72rem; color:#6c757d;">
<i class="bi bi-hdd-network me-1"></i>@d.Cluster.Name
</div>
}
</div>
}
}
</div>
@* ── Expanded resource trees ─────────────────────────────────────────────── *@
@if (deployments is not null)
@if (selectedDeployment is null)
{
@foreach (AppDeployment d in deployments.Where(d => expandedIds.Contains(d.Id)))
@* ── Level 1: deployment card grid ── *@
<LoadingPanel Loading="@(deployments is null)" LoadingText="Loading deployments…">
@if (deployments is not null && deployments.Count == 0)
{
<div class="tree-section">
<div class="tree-section-header">
<div class="d-flex align-items-center gap-2">
<i class="bi bi-diagram-3 text-primary"></i>
<strong class="small">@d.Name</strong>
<span class="text-muted small">/ @d.Namespace</span>
@if (loadingIds.Contains(d.Id))
<EmptyState Icon="bi-rocket-takeoff" Message="No deployments yet." />
}
else if (deployments is not null)
{
<div class="app-grid">
@foreach (AppDeployment d in deployments)
{
string hc = HealthColor(d.HealthStatus);
string hlabel = HealthLabel(d.HealthStatus);
var (scol, sico, slabel) = SyncInfo(d.SyncStatus);
<div class="app-app-card" style="--hc:@hc;" @onclick="() => SelectDeployment(d)">
<div class="d-flex align-items-start gap-1 mb-1">
<div class="app-card-name" title="@d.Name">
<i class="bi @TypeIcon(d.Type) text-primary me-1" style="font-size:.78rem;"></i>@d.Name
</div>
<i class="bi bi-chevron-right text-muted ms-auto flex-shrink-0 mt-1 small"></i>
</div>
<div class="app-card-meta">
@TypeLabel(d.Type) · @d.Environment?.Name · <code style="font-size:.67rem;">@d.Namespace</code>
</div>
<div class="app-card-badges">
<span class="app-badge" style="color:@hc;border-color:@hc;">● @hlabel</span>
<span class="app-badge" style="color:@scol;border-color:@scol;">
<i class="bi @sico"></i> @slabel
</span>
@if (d.GitLastSyncedCommit is not null)
{
<span class="badge bg-secondary" style="font-size:.62rem;">
@d.GitLastSyncedCommit[..7]
</span>
}
</div>
@if (d.Cluster is not null)
{
<div class="spinner-border spinner-border-sm text-primary" style="width:.9rem;height:.9rem;"></div>
<div class="mt-1" style="font-size:.69rem;color:#6c757d;">
<i class="bi bi-hdd-network me-1"></i>@d.Cluster.Name
</div>
}
</div>
<button class="btn btn-sm btn-link text-muted p-0" @onclick="() => expandedIds.Remove(d.Id)" @onclick:stopPropagation>
<i class="bi bi-x-lg"></i>
</button>
</div>
@if (treeErrors.TryGetValue(d.Id, out string? err))
{
<div class="alert alert-warning py-1 small mb-0">
<i class="bi bi-exclamation-triangle me-1"></i>@err
</div>
}
else
{
<ResourceTreePanel Loading="@loadingIds.Contains(d.Id)"
Error="@(treeErrors.TryGetValue(d.Id, out var e2) ? e2 : null)"
Resources="@(trees.TryGetValue(d.Id, out var t) ? t : null)"
Namespace="@d.Namespace"
DeploymentId="@d.Id"
AccessRole="@null" />
}
</div>
}
</LoadingPanel>
}
else
{
@* ── Level 2: single deployment resource graph ── *@
<nav class="mb-3">
<button class="btn btn-sm btn-link text-decoration-none p-0 text-secondary"
@onclick="BackToGrid">
<i class="bi bi-arrow-left me-1"></i>All deployments
</button>
</nav>
<div class="d-flex align-items-center gap-3 mb-3 pb-2 border-bottom">
<div>
<span class="fw-semibold">@selectedDeployment.Name</span>
<span class="text-muted small ms-2">
@TypeLabel(selectedDeployment.Type) · @selectedDeployment.Environment?.Name · <code style="font-size:.75rem;">@selectedDeployment.Namespace</code>
</span>
</div>
@{
string shc = HealthColor(selectedDeployment.HealthStatus);
string shlabel = HealthLabel(selectedDeployment.HealthStatus);
var (sscol, ssico, sslabel) = SyncInfo(selectedDeployment.SyncStatus);
}
<span class="app-badge" style="color:@shc;border-color:@shc;">● @shlabel</span>
<span class="app-badge" style="color:@sscol;border-color:@sscol;">
<i class="bi @ssico"></i> @sslabel
</span>
@if (selectedDeployment.Cluster is not null)
{
<span class="text-muted small ms-auto">
<i class="bi bi-hdd-network me-1"></i>@selectedDeployment.Cluster.Name
</span>
}
</div>
<ResourceTreePanel Loading="@treeLoading"
Error="@treeError"
Resources="@tree"
Namespace="@selectedDeployment.Namespace"
DeploymentId="@selectedDeployment.Id"
AccessRole="@null" />
}
@code {
[Parameter, EditorRequired] public Guid AppId { get; set; }
private List<AppDeployment>? deployments;
private HashSet<Guid> expandedIds = [];
private HashSet<Guid> loadingIds = [];
private Dictionary<Guid, List<DeploymentResource>> trees = [];
private Dictionary<Guid, string> treeErrors = [];
// Level 2 state
private AppDeployment? selectedDeployment;
private bool treeLoading;
private string? treeError;
private List<DeploymentResource>? tree;
protected override async Task OnInitializedAsync()
{
deployments = await DeploymentService.GetDeploymentsAsync(AppId);
}
private async Task ToggleDeployment(Guid id)
private async Task SelectDeployment(AppDeployment d)
{
if (expandedIds.Contains(id))
{
expandedIds.Remove(id);
return;
}
expandedIds.Add(id);
if (trees.ContainsKey(id)) return;
loadingIds.Add(id);
treeErrors.Remove(id);
selectedDeployment = d;
treeLoading = true;
treeError = null;
tree = null;
StateHasChanged();
KubernetesOperationResult<List<DeploymentResource>> result =
await K8sOps.GetLiveResourcesAsync(id);
await K8sOps.GetLiveResourcesAsync(d.Id);
loadingIds.Remove(id);
treeLoading = false;
if (result.IsSuccess && result.Data is not null)
{
trees[id] = result.Data;
(SyncStatus sync, HealthStatus health) = KubernetesOperationsService.ComputeStatusFromResources(result.Data);
AppDeployment? d = deployments?.FirstOrDefault(x => x.Id == id);
if (d is not null) { d.SyncStatus = sync; d.HealthStatus = health; }
tree = result.Data;
(SyncStatus sync, HealthStatus health) =
KubernetesOperationsService.ComputeStatusFromResources(result.Data);
d.SyncStatus = sync;
d.HealthStatus = health;
}
else
{
treeErrors[id] = result.Error ?? "Failed to fetch resources.";
treeError = result.Error ?? "Failed to fetch resources.";
}
}
// ── Badge / label helpers ─────────────────────────────────────────────────
private void BackToGrid()
{
selectedDeployment = null;
tree = null;
treeError = null;
}
// ── Visual helpers ────────────────────────────────────────────────────────
private static string HealthColor(HealthStatus h) => h switch
{
@@ -305,30 +236,28 @@
private static (string color, string icon, string label) SyncInfo(SyncStatus s) => s switch
{
SyncStatus.Synced => ("#198754", "bi-check-circle", "Synced"),
SyncStatus.OutOfSync => ("#fd7e14", "bi-arrow-left-right", "OutOfSync"),
SyncStatus.Syncing => ("#0d6efd", "bi-arrow-repeat", "Syncing"),
SyncStatus.Failed => ("#dc3545", "bi-x-circle", "Failed"),
_ => ("#adb5bd", "bi-question-circle", "Unknown"),
SyncStatus.Synced => ("#198754", "bi-check-circle", "Synced"),
SyncStatus.OutOfSync => ("#fd7e14", "bi-arrow-left-right", "OutOfSync"),
SyncStatus.Syncing => ("#0d6efd", "bi-arrow-repeat", "Syncing"),
SyncStatus.Failed => ("#dc3545", "bi-x-circle", "Failed"),
_ => ("#adb5bd", "bi-question-circle", "Unknown"),
};
private static string TypeIcon(DeploymentType t) => t switch
{
DeploymentType.HelmChart => "bi-box-seam",
DeploymentType.GitYaml => "bi-git",
DeploymentType.GitHelm => "bi-git",
DeploymentType.GitAppOfApps => "bi-diagram-2",
_ => "bi-rocket-takeoff",
DeploymentType.HelmChart => "bi-box-seam",
DeploymentType.GitYaml or DeploymentType.GitHelm or DeploymentType.GitAppOfApps => "bi-git",
_ => "bi-rocket-takeoff",
};
private static string TypeLabel(DeploymentType t) => t switch
{
DeploymentType.Manual => "Manual",
DeploymentType.Yaml => "YAML",
DeploymentType.HelmChart => "Helm",
DeploymentType.GitYaml => "Git/YAML",
DeploymentType.GitHelm => "Git/Helm",
DeploymentType.Manual => "Manual",
DeploymentType.Yaml => "YAML",
DeploymentType.HelmChart => "Helm",
DeploymentType.GitYaml => "Git/YAML",
DeploymentType.GitHelm => "Git/Helm",
DeploymentType.GitAppOfApps => "App of Apps",
_ => t.ToString(),
_ => t.ToString(),
};
}

View File

@@ -10,14 +10,8 @@
the RedisCluster manifest. Credentials are surfaced from the tenant vault.
=========================================================================== *@
@if (loading)
{
<div class="text-center py-5">
<div class="spinner-border text-primary" role="status"></div>
<p class="text-muted mt-2">Loading Redis clusters...</p>
</div>
}
else
<LoadingPanel Loading="@loading" LoadingText="Loading Redis clusters…">
@if (!loading)
{
@* ── Operator availability badge ── *@
<div class="d-flex gap-2 mb-4 flex-wrap">
@@ -412,6 +406,7 @@ else
}
</div>
}
</LoadingPanel>
@code {
[Parameter] public Guid TenantId { get; set; }

View File

@@ -1788,6 +1788,16 @@ else if (section == "components")
editFormFieldValues["admin-username"] = harborConfig.AdminUsername;
}
// Load Loki storage link ID from the component's Configuration JSON.
if (catalogMatch?.Key == "loki")
{
Guid? lokiStorageLinkId = await LokiService.GetStorageLinkIdForComponentAsync(TenantId, componentId);
if (lokiStorageLinkId.HasValue)
{
editFormFieldValues["storage-link"] = lokiStorageLinkId.Value.ToString();
}
}
// Pre-populate hostname and TLS fields from the existing external route.
List<ExternalRoute> routes = await RouteService.GetRoutesAsync(componentId);
ExternalRoute? route = routes.FirstOrDefault();
@@ -2639,10 +2649,11 @@ else if (section == "components")
continue;
}
// cnpg:/harbor: fields are not in YAML — loaded from component configs in ToggleComponentDetail.
// cnpg:/harbor:/loki: fields are not in YAML — loaded from component configs in ToggleComponentDetail.
if (field.YamlPath.StartsWith("cnpg:", StringComparison.Ordinal)
|| field.YamlPath.StartsWith("harbor:", StringComparison.Ordinal))
|| field.YamlPath.StartsWith("harbor:", StringComparison.Ordinal)
|| field.YamlPath.StartsWith("loki:", StringComparison.Ordinal))
{
continue;
}

View File

@@ -4,6 +4,7 @@
@inject TenantService TenantService
@inject CustomerAccessService CustomerAccessService
@inject PrometheusService PrometheusService
@inject CustomerGitService CustomerGitService
@* ===========================================================================
Three-level drill-down: Customers ▸ Apps ▸ App Detail
@@ -57,17 +58,8 @@ else if (selectedCustomer is not null)
</button>
</div>
<div class="card-body">
@if (customerMetricsLoading)
{
<div class="text-center py-4"><div class="spinner-border spinner-border-sm text-primary"></div></div>
}
else if (customerMetricsError is not null)
{
<div class="alert alert-warning small py-2 mb-0">
<i class="bi bi-exclamation-triangle me-1"></i>@customerMetricsError
</div>
}
else if (customerMetrics is not null)
<LoadingPanel Loading="@customerMetricsLoading" Error="@customerMetricsError">
@if (!customerMetricsLoading && customerMetrics is not null)
{
<div class="row g-3 mb-2">
<div class="col-sm-6 col-lg-3">
@@ -101,6 +93,7 @@ else if (selectedCustomer is not null)
<i class="bi bi-clock me-1"></i>Queried @customerMetrics.QueriedAt.ToLocalTime().ToString("HH:mm:ss")
</div>
}
</LoadingPanel>
</div>
</div>
}
@@ -125,20 +118,14 @@ else if (selectedCustomer is not null)
</div>
@* --- App cards --- *@
@if (apps is null)
<LoadingPanel Loading="@(apps is null)">
@if (apps is not null && apps.Count == 0)
{
<div class="text-center py-3">
<div class="spinner-border spinner-border-sm text-primary" role="status"></div>
</div>
<EmptyState Icon="bi-app-indicator"
Title="No apps yet"
Message="Create one above to get started." />
}
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
else if (apps is not null)
{
<div class="row row-cols-1 row-cols-md-2 g-3">
@foreach (Data.App app in apps)
@@ -173,6 +160,204 @@ else if (selectedCustomer is not null)
}
</div>
}
</LoadingPanel>
@* --- Git Repo URL Policies --- *@
<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-git me-2 text-primary"></i>
<strong>Git Repo URL Policies</strong>
</div>
<small class="text-muted">Allowed git repository URL patterns for this customer. Supports <code>*</code> (within a path segment) and <code>**</code> (across segments).</small>
</div>
<div class="card-body">
<div class="input-group mb-3">
<input type="text" class="form-control form-control-sm font-monospace"
placeholder="e.g. https://github.com/acme/* or git@github.com:acme/**"
@bind="newRepoPolicyPattern" @bind:event="oninput"
@onkeydown="@(async (e) => { if (e.Key == "Enter" && !string.IsNullOrWhiteSpace(newRepoPolicyPattern)) await AddRepoPolicy(); })" />
<button class="btn btn-sm btn-outline-primary" @onclick="AddRepoPolicy"
disabled="@string.IsNullOrWhiteSpace(newRepoPolicyPattern)">
<i class="bi bi-plus me-1"></i>Add
</button>
</div>
@if (!string.IsNullOrEmpty(repoPolicyError))
{
<div class="alert alert-danger py-1 small mb-2">@repoPolicyError</div>
}
<LoadingPanel Loading="@(gitRepoPolicies is null)">
@if (gitRepoPolicies is not null && gitRepoPolicies.Count == 0)
{
<EmptyState Icon="bi-slash-circle"
Message="No URL patterns defined. All repo URLs are implicitly denied for this customer." />
}
else if (gitRepoPolicies is not null)
{
@foreach (CustomerGitRepoPolicy policy in gitRepoPolicies)
{
<div class="d-flex align-items-center justify-content-between py-1 border-bottom">
<code class="small">@policy.UrlPattern</code>
@if (confirmDeletePolicyId == policy.Id)
{
<div class="d-flex gap-1">
<button class="btn btn-sm btn-danger" @onclick="() => DeleteRepoPolicy(policy.Id)">Remove</button>
<button class="btn btn-sm btn-link p-0" @onclick="() => confirmDeletePolicyId = null">Cancel</button>
</div>
}
else
{
<button class="btn btn-sm btn-outline-danger" title="Remove pattern"
@onclick="() => confirmDeletePolicyId = policy.Id">
<i class="bi bi-x"></i>
</button>
}
</div>
}
}
</LoadingPanel>
</div>
</div>
@* --- Git Credentials --- *@
<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-key me-2 text-primary"></i>
<strong>Git Credentials</strong>
</div>
<small class="text-muted">Reusable credential sets for this customer's git repositories.</small>
</div>
<div class="card-body">
@* --- Add credential form --- *@
<div class="row g-2 align-items-end mb-3">
<div class="col-md-4">
<label class="form-label small mb-1">Name</label>
<input type="text" class="form-control form-control-sm" placeholder="e.g. GitHub PAT"
@bind="newCredName" @bind:event="oninput" />
</div>
<div class="col-md-3">
<label class="form-label small mb-1">Auth type</label>
<select class="form-select form-select-sm" @bind="newCredAuthType">
<option value="@GitAuthType.None">None (public)</option>
<option value="@GitAuthType.HttpsPat">HTTPS PAT</option>
<option value="@GitAuthType.HttpsPassword">HTTPS Password</option>
<option value="@GitAuthType.SshKey">SSH Key</option>
</select>
</div>
@if (newCredAuthType == GitAuthType.HttpsPassword)
{
<div class="col-md-3">
<label class="form-label small mb-1">Username</label>
<input type="text" class="form-control form-control-sm" placeholder="e.g. myuser"
@bind="newCredUsername" @bind:event="oninput" />
</div>
}
<div class="col-auto">
<button class="btn btn-sm btn-outline-primary" @onclick="AddCredential"
disabled="@string.IsNullOrWhiteSpace(newCredName)">
<i class="bi bi-plus me-1"></i>Add
</button>
</div>
</div>
@if (!string.IsNullOrEmpty(credentialError))
{
<div class="alert alert-danger py-1 small mb-2">@credentialError</div>
}
@if (!string.IsNullOrEmpty(credentialSuccess))
{
<div class="alert alert-success py-1 small mb-2">@credentialSuccess</div>
}
<LoadingPanel Loading="@(gitCredentials is null)">
@if (gitCredentials is not null && gitCredentials.Count == 0)
{
<EmptyState Icon="bi-key" Message="No credentials configured yet." />
}
else if (gitCredentials is not null)
{
@foreach (CustomerGitCredential cred in gitCredentials)
{
<div class="border rounded p-3 mb-2">
<div class="d-flex align-items-start justify-content-between">
<div>
<span class="fw-semibold"><i class="bi bi-key me-1 text-primary"></i>@cred.Name</span>
<span class="badge bg-secondary ms-2">@cred.AuthType</span>
@if (cred.AuthType == GitAuthType.HttpsPassword && cred.Username is not null)
{
<span class="text-muted small ms-2">(@cred.Username)</span>
}
</div>
@if (confirmDeleteCredentialId == cred.Id)
{
<div class="d-flex gap-1">
<button class="btn btn-sm btn-danger" @onclick="() => DeleteCredential(cred.Id)">Delete</button>
<button class="btn btn-sm btn-link p-0" @onclick="() => confirmDeleteCredentialId = null">Cancel</button>
</div>
}
else
{
<button class="btn btn-sm btn-outline-danger" title="Delete credential"
@onclick="() => confirmDeleteCredentialId = cred.Id">
<i class="bi bi-trash"></i>
</button>
}
</div>
@* --- Secret value form --- *@
@if (cred.AuthType != GitAuthType.None)
{
<div class="mt-2">
@if (cred.AuthType == GitAuthType.HttpsPat)
{
<div class="input-group input-group-sm">
<span class="input-group-text">PAT</span>
<input type="password" class="form-control form-control-sm"
placeholder="Personal Access Token"
@bind="credSecretValues[cred.Id]" @bind:event="oninput" />
<button class="btn btn-sm btn-outline-secondary"
@onclick="() => SaveCredentialSecret(cred)">
Save
</button>
</div>
}
else if (cred.AuthType == GitAuthType.HttpsPassword)
{
<div class="input-group input-group-sm">
<span class="input-group-text">Password</span>
<input type="password" class="form-control form-control-sm"
placeholder="Password"
@bind="credSecretValues[cred.Id]" @bind:event="oninput" />
<button class="btn btn-sm btn-outline-secondary"
@onclick="() => SaveCredentialSecret(cred)">
Save
</button>
</div>
}
else if (cred.AuthType == GitAuthType.SshKey)
{
<textarea class="form-control form-control-sm font-monospace mb-1"
rows="3" placeholder="-----BEGIN OPENSSH PRIVATE KEY-----"
@bind="credSecretValues[cred.Id]" @bind:event="oninput"></textarea>
<button class="btn btn-sm btn-outline-secondary"
@onclick="() => SaveCredentialSecret(cred)">
Save SSH Key
</button>
}
@if (credSaveMessages.TryGetValue(cred.Id, out string? msg) && msg is not null)
{
<div class="small mt-1 @(credSaveOk.GetValueOrDefault(cred.Id) ? "text-success" : "text-danger")">
<i class="bi @(credSaveOk.GetValueOrDefault(cred.Id) ? "bi-check-circle" : "bi-x-circle") me-1"></i>@msg
</div>
}
</div>
}
</div>
}
}
</LoadingPanel>
</div>
</div>
@* --- Portal Access Management --- *@
<div class="card shadow-sm mt-4">
@@ -210,17 +395,13 @@ else if (selectedCustomer is not null)
}
@* --- Current access list --- *@
@if (customerAccesses is null)
<LoadingPanel Loading="@(customerAccesses is null)">
@if (customerAccesses is not null && customerAccesses.Count == 0)
{
<div class="text-center py-2">
<div class="spinner-border spinner-border-sm text-primary" role="status"></div>
</div>
<EmptyState Icon="bi-person-x"
Message="No users have portal access to this customer yet." />
}
else if (customerAccesses.Count == 0)
{
<p class="text-muted small mb-0">No users have portal access to this customer yet.</p>
}
else
else if (customerAccesses is not null)
{
@foreach (CustomerAccess access in customerAccesses)
{
@@ -230,13 +411,24 @@ else if (selectedCustomer is not null)
<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>
@if (confirmRevokeUserId == access.UserId)
{
<div class="d-flex gap-1">
<button class="btn btn-sm btn-danger" @onclick="() => RevokeAccess(access.UserId)">Revoke</button>
<button class="btn btn-sm btn-link p-0" @onclick="() => confirmRevokeUserId = null">Cancel</button>
</div>
}
else
{
<button class="btn btn-sm btn-outline-danger" title="Revoke access"
@onclick="() => confirmRevokeUserId = access.UserId">
<i class="bi bi-x"></i>
</button>
}
</div>
}
}
</LoadingPanel>
</div>
</div>
}
@@ -272,21 +464,14 @@ else
</div>
}
@if (customers is null)
<LoadingPanel Loading="@(customers is null)">
@if (customers is not null && customers.Count == 0)
{
<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>
<EmptyState Icon="bi-people"
Title="No customers yet"
Message="Create one above to get started." />
}
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
else if (customers is not null)
{
<div class="list-group shadow-sm">
@foreach (Customer customer in customers)
@@ -326,6 +511,7 @@ else
}
</div>
}
</LoadingPanel>
}
@code {
@@ -360,6 +546,27 @@ else
private string? accessError;
private string? accessSuccess;
// --- Git repo policies ---
private List<CustomerGitRepoPolicy>? gitRepoPolicies;
private string newRepoPolicyPattern = "";
private string? repoPolicyError;
private Guid? confirmDeletePolicyId;
// --- Git credentials ---
private List<CustomerGitCredential>? gitCredentials;
private string newCredName = "";
private GitAuthType newCredAuthType = GitAuthType.HttpsPat;
private string newCredUsername = "";
private string? credentialError;
private string? credentialSuccess;
private Dictionary<Guid, string> credSecretValues = new();
private Dictionary<Guid, string?> credSaveMessages = new();
private Dictionary<Guid, bool> credSaveOk = new();
private Guid? confirmDeleteCredentialId;
// --- Portal access ---
private string? confirmRevokeUserId;
protected override async Task OnInitializedAsync()
{
await LoadCustomers();
@@ -414,8 +621,20 @@ else
accessSuccess = null;
customerView = "apps";
customerMetrics = null;
newRepoPolicyPattern = "";
repoPolicyError = null;
newCredName = "";
newCredAuthType = GitAuthType.HttpsPat;
newCredUsername = "";
credentialError = null;
credentialSuccess = null;
credSecretValues = new();
credSaveMessages = new();
credSaveOk = new();
await LoadApps();
await LoadAccesses();
await LoadGitRepoPolicies();
await LoadGitCredentials();
}
private async Task OpenCustomerMetrics()
@@ -521,10 +740,120 @@ else
private async Task RevokeAccess(string userId)
{
confirmRevokeUserId = null;
await CustomerAccessService.RevokeAccessAsync(userId, selectedCustomer!.Id);
await LoadAccesses();
}
// ──────────────── Git Repo Policies ────────────────
private async Task LoadGitRepoPolicies()
{
gitRepoPolicies = await CustomerGitService.GetRepoPoliciesAsync(selectedCustomer!.Id);
}
private async Task AddRepoPolicy()
{
repoPolicyError = null;
try
{
await CustomerGitService.AddRepoPolicyAsync(selectedCustomer!.Id, newRepoPolicyPattern.Trim());
newRepoPolicyPattern = "";
await LoadGitRepoPolicies();
}
catch (DbUpdateException)
{
repoPolicyError = "That URL pattern already exists for this customer.";
}
}
private async Task DeleteRepoPolicy(Guid policyId)
{
confirmDeletePolicyId = null;
await CustomerGitService.DeleteRepoPolicyAsync(selectedCustomer!.Id, policyId);
await LoadGitRepoPolicies();
}
// ──────────────── Git Credentials ────────────────
private async Task LoadGitCredentials()
{
gitCredentials = await CustomerGitService.GetCredentialsAsync(selectedCustomer!.Id);
foreach (CustomerGitCredential cred in gitCredentials)
credSecretValues.TryAdd(cred.Id, "");
}
private async Task AddCredential()
{
credentialError = null;
credentialSuccess = null;
try
{
await CustomerGitService.CreateCredentialAsync(
selectedCustomer!.Id, TenantId,
newCredName.Trim(), newCredAuthType,
newCredAuthType == GitAuthType.HttpsPassword ? newCredUsername : null);
newCredName = "";
newCredAuthType = GitAuthType.HttpsPat;
newCredUsername = "";
await LoadGitCredentials();
}
catch (DbUpdateException)
{
credentialError = "A credential with that name already exists for this customer.";
}
}
private async Task DeleteCredential(Guid credentialId)
{
confirmDeleteCredentialId = null;
credentialError = null;
await CustomerGitService.DeleteCredentialAsync(selectedCustomer!.Id, credentialId);
credSecretValues.Remove(credentialId);
credSaveMessages.Remove(credentialId);
credSaveOk.Remove(credentialId);
await LoadGitCredentials();
}
private async Task SaveCredentialSecret(CustomerGitCredential cred)
{
credSaveMessages[cred.Id] = null;
if (!credSecretValues.TryGetValue(cred.Id, out string? value) || string.IsNullOrWhiteSpace(value))
{
credSaveMessages[cred.Id] = "Value cannot be empty.";
credSaveOk[cred.Id] = false;
return;
}
try
{
switch (cred.AuthType)
{
case GitAuthType.HttpsPat:
await CustomerGitService.SetPatAsync(TenantId, cred.Id, value);
break;
case GitAuthType.HttpsPassword:
await CustomerGitService.SetPasswordAsync(TenantId, cred.Id, value);
break;
case GitAuthType.SshKey:
await CustomerGitService.SetSshKeyAsync(TenantId, cred.Id, value);
break;
}
credSecretValues[cred.Id] = "";
credSaveMessages[cred.Id] = "Saved.";
credSaveOk[cred.Id] = true;
}
catch (Exception ex)
{
credSaveMessages[cred.Id] = ex.Message;
credSaveOk[cred.Id] = false;
}
}
private static string GetRoleBadgeClass(CustomerAccessRole role) => role switch
{
CustomerAccessRole.Admin => "bg-danger",

View File

@@ -13,14 +13,8 @@
to be installed on at least one cluster.
=========================================================================== *@
@if (loading)
{
<div class="text-center py-5">
<div class="spinner-border text-primary" role="status"></div>
<p class="text-muted mt-2">Discovering database clusters...</p>
</div>
}
else
<LoadingPanel Loading="@loading" LoadingText="Discovering database clusters…">
@if (!loading)
{
@* ── Operator availability badges ── *@
<div class="d-flex gap-2 mb-4">
@@ -2190,6 +2184,19 @@ else
@onclick="() => ShowMongoResize(mc)">
<i class="bi bi-sliders"></i>
</button>
<button class="btn btn-sm btn-outline-secondary border-0"
title="Fetch the MongoDB admin password from the Kubernetes secret and store it in vault"
disabled="@(mongoAdminFetchingId == mc.Id)"
@onclick="() => FetchMongoAdminPassword(mc)">
@if (mongoAdminFetchingId == mc.Id)
{
<span class="spinner-border spinner-border-sm"></span>
}
else
{
<i class="bi bi-key"></i>
}
</button>
}
<button class="btn btn-sm btn-outline-danger border-0" title="Delete Cluster"
@onclick="() => InitiateDeleteMongoCluster(mc)" disabled="@(mongoDeletingId == mc.Id)">
@@ -2573,11 +2580,17 @@ else
}
@* ── Databases ── *@
@if (!string.IsNullOrEmpty(mongoClusterDetail.SyncError))
{
<div class="alert alert-warning small py-1 mb-2">
<i class="bi bi-exclamation-triangle me-1"></i>Database sync failed: @mongoClusterDetail.SyncError
</div>
}
<h6 class="small text-muted mb-2 d-flex align-items-center">
<i class="bi bi-table me-1"></i>Databases
<span class="badge bg-success rounded-pill ms-2">@mc.Databases.Count</span>
<span class="badge bg-success rounded-pill ms-2">@mongoClusterDetail.Cluster.Databases.Count</span>
</h6>
@if (mc.Databases.Count > 0)
@if (mongoClusterDetail.Cluster.Databases.Count > 0)
{
<div class="table-responsive mb-3">
<table class="table table-sm small mb-0">
@@ -2590,7 +2603,7 @@ else
</tr>
</thead>
<tbody>
@foreach (MongoDatabase mdb in mc.Databases)
@foreach (MongoDatabase mdb in mongoClusterDetail.Cluster.Databases)
{
<tr>
<td class="font-monospace">@mdb.Name</td>
@@ -2656,17 +2669,27 @@ else
<tr>
<td colspan="4" class="bg-light p-0">
<div class="p-2">
<table class="table table-sm table-borderless mb-1" style="font-size:0.75rem;">
<tbody>
@foreach (KeyValuePair<string, string> cred in mongoDbCredentials)
{
<tr>
<td class="text-muted fw-bold" style="width:140px;">@cred.Key</td>
<td><code class="user-select-all">@cred.Value</code></td>
</tr>
}
</tbody>
</table>
@if (mongoDbCredentials.Count == 0)
{
<div class="text-muted small">No credentials found in vault for this database.</div>
}
else
{
<table class="table table-sm table-borderless mb-1" style="font-size:0.75rem;">
<tbody>
@foreach (KeyValuePair<string, string> cred in mongoDbCredentials)
{
<tr>
<td class="text-muted fw-bold" style="width:140px;">@cred.Key</td>
<td><code class="user-select-all">@cred.Value</code></td>
</tr>
}
</tbody>
</table>
<div class="text-muted" style="font-size:0.7rem;">
<i class="bi bi-info-circle me-1"></i>K8s Secret: <code>@($"{mc.Name}-{mdb.Name}-credentials")</code> in namespace <code>@mc.Namespace</code>
</div>
}
</div>
</td>
</tr>
@@ -3253,6 +3276,7 @@ else
</button>
</div>
}
</LoadingPanel>
@code {
[Parameter] public Guid TenantId { get; set; }
@@ -3451,6 +3475,7 @@ else
private string? mongoScheduleSuccess;
private Guid? mongoCleaningUpBackupsId;
private Dictionary<Guid, string> mongoClusterMessages = [];
private Guid? mongoAdminFetchingId;
// Mongo cluster detail panel
private Guid? expandedMongoClusterId;
@@ -4320,6 +4345,28 @@ else
}
}
private async Task FetchMongoAdminPassword(MongoCluster cluster)
{
mongoAdminFetchingId = cluster.Id;
mongoClusterMessages.Remove(cluster.Id);
StateHasChanged();
try
{
await MongoService.FetchAdminPasswordAsync(TenantId, cluster.Id);
mongoClusterMessages[cluster.Id] = "Admin password fetched from Kubernetes and stored in vault.";
}
catch (Exception ex)
{
mongoClusterMessages[cluster.Id] = $"Error: {ex.Message}";
}
finally
{
mongoAdminFetchingId = null;
StateHasChanged();
}
}
private async Task FetchClusterSuperuserPassword(CnpgCluster cluster)
{
clusterSuperuserFetchingId = cluster.Id;
@@ -5238,6 +5285,9 @@ else
try
{
mongoClusterDetail = await MongoService.GetClusterDetailAsync(TenantId, cluster.Id);
// Sync the in-memory cluster object so the header badge shows the live count.
if (mongoClusterDetail is not null)
cluster.Databases = mongoClusterDetail.Cluster.Databases;
}
catch (Exception)
{

View File

@@ -125,20 +125,14 @@ else if (selectedEnv is not null)
}
@* --- Cluster Cards --- *@
@if (envClusters is null)
<LoadingPanel Loading="@(envClusters is null)">
@if (envClusters is not null && envClusters.Count == 0)
{
<div class="text-center py-3">
<div class="spinner-border spinner-border-sm text-primary" role="status"></div>
</div>
<EmptyState Icon="bi-hdd-rack"
Title="No clusters yet"
Message="Register a cluster above to start deploying." />
}
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
else if (envClusters is not null)
{
<div class="row row-cols-1 row-cols-md-2 g-3">
@foreach (KubernetesCluster cluster in envClusters)
@@ -168,6 +162,7 @@ else if (selectedEnv is not null)
}
</div>
}
</LoadingPanel>
}
else
{
@@ -201,21 +196,14 @@ else
</div>
}
@if (environments is null)
<LoadingPanel Loading="@(environments is null)">
@if (environments is not null && environments.Count == 0)
{
<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>
<EmptyState Icon="bi-layers"
Title="No environments yet"
Message="Create one above to get started." />
}
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
else if (environments is not null)
{
<div class="list-group shadow-sm">
@foreach (Data.Environment env in environments)
@@ -255,6 +243,7 @@ else
}
</div>
}
</LoadingPanel>
}
@code {

View File

@@ -77,21 +77,15 @@
}
@* ── Repo list ── *@
@if (repos is null)
<LoadingPanel Loading="@(repos is null)">
@if (repos is not null && repos.Count == 0 && !showAddForm)
{
<div class="text-center py-3">
<div class="spinner-border spinner-border-sm text-primary" role="status"></div>
</div>
<EmptyState Icon="bi-git"
Message="No repositories registered. Add one to enable Git-sourced deployments." />
}
else if (repos.Count == 0 && !showAddForm)
{
<div class="text-center py-4">
<i class="bi bi-git text-muted" style="font-size: 2rem;"></i>
<p class="text-muted small mt-2 mb-0">No repositories registered. Add one to enable Git-sourced deployments.</p>
</div>
}
else
else if (repos is not null && (repos.Count > 0 || showAddForm))
{
<div class="table-responsive">
<table class="table table-sm mb-0">
<thead class="table-light">
<tr>
@@ -248,7 +242,9 @@
}
</tbody>
</table>
</div>
}
</LoadingPanel>
</div>
</div>

View File

@@ -31,21 +31,14 @@
</div>
}
@if (groups is null)
<LoadingPanel Loading="@(groups is null)" LoadingText="Loading groups…">
@if (groups is not null && groups.Count == 0)
{
<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>
<EmptyState Icon="bi-diagram-3"
Title="No groups yet"
Message="Create one above to get started." />
}
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
else if (groups is not null)
{
<div class="row row-cols-1 row-cols-md-2 row-cols-lg-3 g-3">
@foreach (Group group in groups)
@@ -90,6 +83,7 @@ else
}
</div>
}
</LoadingPanel>
@code {
[Parameter] public Guid TenantId { get; set; }

View File

@@ -5,14 +5,8 @@
@inject ComponentLifecycleService LifecycleService
@inject RegisteredPostgresService RegisteredPostgresService
@if (loading)
{
<div class="text-center py-5">
<div class="spinner-border text-primary" role="status"></div>
<p class="text-muted mt-2">Scanning for Keycloak instances...</p>
</div>
}
else if (selectedRealm is not null && selectedKeycloak is not null)
<LoadingPanel Loading="@loading" LoadingText="Scanning for Keycloak instances…">
@if (selectedRealm is not null && selectedKeycloak is not null)
{
@* ── Level 3: Realm detail ── *@
<nav class="mb-3">
@@ -1340,6 +1334,7 @@ else
</div>
}
}
</LoadingPanel>
@code {
[Parameter] public Guid TenantId { get; set; }

View File

@@ -93,18 +93,14 @@
<span class="ms-auto text-muted small">@incidents.Count incident@(incidents.Count == 1 ? "" : "s")</span>
</div>
@if (isLoading)
<LoadingPanel Loading="@isLoading" LoadingText="Loading incidents…">
@if (!isLoading && incidents.Count == 0)
{
<div class="text-center py-4 text-muted"><span class="bi bi-hourglass-split"></span> Loading incidents…</div>
<EmptyState Icon="bi-check-circle"
Title="No incidents found"
Message="No incidents match the selected filters." />
}
else if (incidents.Count == 0)
{
<div class="text-center py-5 text-muted">
<span class="bi bi-check-circle display-4 d-block mb-2"></span>
No incidents found for the selected filters.
</div>
}
else
else if (!isLoading)
{
<div class="table-responsive">
<table class="table table-hover align-middle">
@@ -197,6 +193,7 @@ else
</table>
</div>
}
</LoadingPanel>
@code {
[Parameter] public required Guid TenantId { get; set; }

View File

@@ -29,10 +29,9 @@
@if (windows.Count == 0)
{
<div class="text-center py-4 text-muted">
<span class="bi bi-calendar-x display-4 d-block mb-2"></span>
No maintenance windows scheduled.
</div>
<EmptyState Icon="bi-calendar-x"
Title="No maintenance windows"
Message="No windows scheduled." />
}
else
{

View File

@@ -11,14 +11,8 @@
App linkage syncs AMQP connection details into app namespace K8s Secrets.
=========================================================================== *@
@if (loading)
{
<div class="text-center py-5">
<div class="spinner-border text-primary" role="status"></div>
<p class="text-muted mt-2">Loading RabbitMQ clusters...</p>
</div>
}
else
<LoadingPanel Loading="@loading" LoadingText="Loading RabbitMQ clusters…">
@if (!loading)
{
@* ── Operator availability badges ── *@
<div class="d-flex gap-2 mb-4 flex-wrap">
@@ -908,6 +902,7 @@ else
</div>
}
}
</LoadingPanel>
@code {
[Parameter] public Guid TenantId { get; set; }

View File

@@ -10,14 +10,8 @@
Helm component flow — no separate ops process needed.
============================================================================ *@
@if (loading)
{
<div class="text-center py-5">
<div class="spinner-border text-primary" role="status"></div>
<p class="text-muted mt-2">Loading VPN tunnels...</p>
</div>
}
else
<LoadingPanel Loading="@loading" LoadingText="Loading VPN tunnels…">
@if (!loading)
{
@* ── Header ── *@
<div class="d-flex align-items-center justify-content-between mb-4">
@@ -753,6 +747,7 @@ else
}
}
}
</LoadingPanel>
@code {
[Parameter] public Guid TenantId { get; set; }

View File

@@ -13,10 +13,9 @@
@if (channels.Count == 0)
{
<div class="text-center py-5 text-muted">
<span class="bi bi-send display-4 d-block mb-2"></span>
No notification channels configured. Add a channel to get alerted via Slack, Teams, email, or webhook.
</div>
<EmptyState Icon="bi-send"
Title="No notification channels"
Message="Add a channel to get alerted via Slack, Teams, email, or webhook." />
}
else
{

View File

@@ -3,14 +3,8 @@
@inject HarborService HarborService
@inject TenantService TenantService
@if (loading)
{
<div class="text-center py-5">
<div class="spinner-border text-primary" role="status"></div>
<p class="text-muted mt-2">Scanning for Harbor instances...</p>
</div>
}
else if (selectedProject is not null && selectedHarbor is not null)
<LoadingPanel Loading="@loading" LoadingText="Scanning for Harbor instances…">
@if (selectedProject is not null && selectedHarbor is not null)
{
@* ── Level 3: Project repositories ── *@
<nav class="mb-3">
@@ -646,6 +640,7 @@ else
</div>
}
}
</LoadingPanel>
@code {
[Parameter] public Guid TenantId { get; set; }

View File

@@ -17,10 +17,9 @@
@if (targets.Count == 0)
{
<div class="text-center py-4 text-muted">
<span class="bi bi-shield display-4 d-block mb-2"></span>
No SLA targets configured. Add one to track uptime compliance.
</div>
<EmptyState Icon="bi-shield"
Title="No SLA targets"
Message="Add one to track uptime compliance." />
}
else
{

View File

@@ -10,14 +10,8 @@
External providers are managed as vault-backed links.
=========================================================================== *@
@if (loading)
{
<div class="text-center py-5">
<div class="spinner-border text-primary" role="status"></div>
<p class="text-muted mt-2">Discovering storage resources...</p>
</div>
}
else
<LoadingPanel Loading="@loading" LoadingText="Discovering storage resources…">
@if (!loading)
{
@* ── MinIO Section ── *@
<div class="mb-5">
@@ -834,6 +828,7 @@ else
</button>
</div>
}
</LoadingPanel>
@code {
[Parameter] public Guid TenantId { get; set; }

View File

@@ -34,94 +34,131 @@ else
</div>
</div>
<ul class="nav nav-pills mb-4 gap-1">
@* ── Category (top-level) nav ── *@
<ul class="nav nav-pills mb-2 gap-1 tenant-category-nav">
<li class="nav-item">
<button class="nav-link @(activeTab == "environments" ? "active" : "")" @onclick='() => activeTab = "environments"'>
<i class="bi bi-layers me-1"></i>Environments
<button class="nav-link @(activeCategory == "apps" ? "active" : "")" @onclick='() => SetCategory("apps")'>
<i class="bi bi-people me-1"></i>Applications
</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 class="nav-link @(activeCategory == "infra" ? "active" : "")" @onclick='() => SetCategory("infra")'>
<i class="bi bi-layers me-1"></i>Infrastructure
</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 class="nav-link @(activeCategory == "services" ? "active" : "")" @onclick='() => SetCategory("services")'>
<i class="bi bi-box-seam me-1"></i>Services
</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>
<li class="nav-item">
<button class="nav-link @(activeTab == "identity" ? "active" : "")" @onclick='() => activeTab = "identity"'>
<i class="bi bi-person-badge me-1"></i>Identity
</button>
</li>
<li class="nav-item">
<button class="nav-link @(activeTab == "registry" ? "active" : "")" @onclick='() => activeTab = "registry"'>
<i class="bi bi-archive me-1"></i>Registry
</button>
</li>
<li class="nav-item">
<button class="nav-link @(activeTab == "messaging" ? "active" : "")" @onclick='() => activeTab = "messaging"'>
<i class="bi bi-collection me-1"></i>Messaging
</button>
</li>
<li class="nav-item">
<button class="nav-link @(activeTab == "cache" ? "active" : "")" @onclick='() => activeTab = "cache"'>
<i class="bi bi-lightning-charge me-1"></i>Cache
</button>
</li>
<li class="nav-item">
<button class="nav-link @(activeTab == "overview" ? "active" : "")" @onclick='() => activeTab = "overview"'>
<i class="bi bi-speedometer2 me-1"></i>Overview
</button>
</li>
<li class="nav-item">
<button class="nav-link @(activeTab == "incidents" ? "active" : "")" @onclick='() => activeTab = "incidents"'>
<i class="bi bi-bell-fill me-1"></i>Incidents
</button>
</li>
<li class="nav-item">
<button class="nav-link @(activeTab == "notifications" ? "active" : "")" @onclick='() => activeTab = "notifications"'>
<i class="bi bi-send-fill me-1"></i>Notifications
</button>
</li>
<li class="nav-item">
<button class="nav-link @(activeTab == "maintenance" ? "active" : "")" @onclick='() => activeTab = "maintenance"'>
<i class="bi bi-tools me-1"></i>Maintenance
</button>
</li>
<li class="nav-item">
<button class="nav-link @(activeTab == "sla" ? "active" : "")" @onclick='() => activeTab = "sla"'>
<i class="bi bi-shield-check me-1"></i>SLA
</button>
</li>
<li class="nav-item">
<button class="nav-link @(activeTab == "networking" ? "active" : "")" @onclick='() => activeTab = "networking"'>
<i class="bi bi-shield-lock me-1"></i>Networking
</button>
</li>
<li class="nav-item">
<button class="nav-link @(activeTab == "git" ? "active" : "")" @onclick='() => activeTab = "git"'>
<i class="bi bi-git me-1"></i>Git
<button class="nav-link @(activeCategory == "ops" ? "active" : "")" @onclick='() => SetCategory("ops")'>
<i class="bi bi-speedometer2 me-1"></i>Operations
</button>
</li>
</ul>
@* ── Sub-tab strip for the active category ── *@
<ul class="nav nav-tabs mb-4">
@if (activeCategory == "apps")
{
<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 == "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 == "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 == "git" ? "active" : "")" @onclick='() => activeTab = "git"'>
<i class="bi bi-git me-1"></i>Git
</button>
</li>
}
else if (activeCategory == "infra")
{
<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 == "networking" ? "active" : "")" @onclick='() => activeTab = "networking"'>
<i class="bi bi-diagram-2 me-1"></i>Networking
</button>
</li>
}
else if (activeCategory == "services")
{
<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>
<li class="nav-item">
<button class="nav-link @(activeTab == "messaging" ? "active" : "")" @onclick='() => activeTab = "messaging"'>
<i class="bi bi-collection me-1"></i>Messaging
</button>
</li>
<li class="nav-item">
<button class="nav-link @(activeTab == "cache" ? "active" : "")" @onclick='() => activeTab = "cache"'>
<i class="bi bi-lightning-charge me-1"></i>Cache
</button>
</li>
<li class="nav-item">
<button class="nav-link @(activeTab == "identity" ? "active" : "")" @onclick='() => activeTab = "identity"'>
<i class="bi bi-person-badge me-1"></i>Identity
</button>
</li>
<li class="nav-item">
<button class="nav-link @(activeTab == "registry" ? "active" : "")" @onclick='() => activeTab = "registry"'>
<i class="bi bi-archive me-1"></i>Registry
</button>
</li>
}
else if (activeCategory == "ops")
{
<li class="nav-item">
<button class="nav-link @(activeTab == "overview" ? "active" : "")" @onclick='() => activeTab = "overview"'>
<i class="bi bi-speedometer2 me-1"></i>Overview
</button>
</li>
<li class="nav-item">
<button class="nav-link @(activeTab == "incidents" ? "active" : "")" @onclick='() => activeTab = "incidents"'>
<i class="bi bi-bell-fill me-1"></i>Incidents
</button>
</li>
<li class="nav-item">
<button class="nav-link @(activeTab == "notifications" ? "active" : "")" @onclick='() => activeTab = "notifications"'>
<i class="bi bi-send-fill me-1"></i>Notifications
</button>
</li>
<li class="nav-item">
<button class="nav-link @(activeTab == "maintenance" ? "active" : "")" @onclick='() => activeTab = "maintenance"'>
<i class="bi bi-tools me-1"></i>Maintenance
</button>
</li>
<li class="nav-item">
<button class="nav-link @(activeTab == "sla" ? "active" : "")" @onclick='() => activeTab = "sla"'>
<i class="bi bi-shield-check me-1"></i>SLA
</button>
</li>
}
</ul>
<div class="tab-content">
@switch (activeTab)
{
@@ -184,7 +221,22 @@ else
[Parameter] public string Slug { get; set; } = "";
private Tenant? tenant;
private string activeTab = "environments";
private string activeCategory = "apps";
private string activeTab = "customers";
private static readonly Dictionary<string, string> CategoryDefaults = new()
{
["apps"] = "customers",
["infra"] = "environments",
["services"] = "databases",
["ops"] = "overview",
};
private void SetCategory(string category)
{
activeCategory = category;
activeTab = CategoryDefaults[category];
}
protected override async Task OnInitializedAsync()
{

View File

@@ -10,14 +10,8 @@
<PageTitle>Tenants</PageTitle>
@if (!loaded)
{
<div class="text-center py-5">
<div class="spinner-border text-primary" role="status"></div>
<p class="text-muted mt-2">Loading tenants...</p>
</div>
}
else
<LoadingPanel Loading="@(!loaded)" LoadingText="Loading tenants…">
@if (loaded)
{
<div class="d-flex align-items-center justify-content-between mb-4">
<div>
@@ -49,18 +43,9 @@ else
@if (tenants is null || tenants.Count == 0)
{
<div class="text-center py-5">
<i class="bi bi-building text-muted" style="font-size: 4rem;"></i>
<h5 class="text-muted mt-3">No tenants yet</h5>
@if (isAdmin)
{
<p class="text-muted">Create your first tenant above to get started.</p>
}
else
{
<p class="text-muted">You have not been added to any tenants yet.</p>
}
</div>
<EmptyState Icon="bi-building"
Title="No tenants yet"
Message="@(isAdmin ? "Create your first tenant above to get started." : "You have not been added to any tenants yet.")" />
}
else
{
@@ -113,6 +98,7 @@ else
</div>
}
}
</LoadingPanel>
@code {
private List<Tenant>? tenants;

View File

@@ -72,10 +72,8 @@ else
}
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>
<EmptyState Icon="bi-app"
Message="Create a customer and app first to store app secrets." />
}
}
else if (scope == "component")
@@ -99,10 +97,8 @@ else
}
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>
<EmptyState Icon="bi-puzzle"
Message="Create an environment, cluster, and component first to store component secrets." />
}
}
else if (scope == "storage")
@@ -126,10 +122,8 @@ else
}
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>
<EmptyState Icon="bi-hdd"
Message="Create a storage link first to view its secrets." />
}
}
@@ -154,10 +148,8 @@ else
}
else
{
<div class="text-center py-4">
<i class="bi bi-database text-muted" style="font-size: 2rem;"></i>
<p class="text-muted small mt-2 mb-0">No CNPG clusters found for this tenant.</p>
</div>
<EmptyState Icon="bi-database"
Message="No CNPG clusters found for this tenant." />
}
}
@@ -226,20 +218,13 @@ else
}
@* Secrets table *@
@if (secrets is null)
<LoadingPanel Loading="@(secrets is null)">
@if (secrets is not null && secrets.Count == 0)
{
<div class="text-center py-3">
<div class="spinner-border spinner-border-sm text-primary" role="status"></div>
</div>
<EmptyState Icon="bi-lock"
Message="No secrets stored yet. Add one above." />
}
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
else if (secrets is not null)
{
<div class="table-responsive">
<table class="table table-hover align-middle mb-0">
@@ -385,6 +370,7 @@ else
</table>
</div>
}
</LoadingPanel>
@if (syncOutput is not null)
{
<div class="mt-3 p-2 bg-dark text-white rounded font-monospace small" style="white-space: pre-wrap; max-height: 200px; overflow-y: auto;">@syncOutput</div>

View File

@@ -12,3 +12,4 @@
@using EntKube.Web.Components
@using EntKube.Web.Components.Layout
@using EntKube.Web.Components.Pages.Shared
@using EntKube.Web.Components.Pages.Tenants