From 76b7e55931db016a65d6e0f9c771f9c545363fd7724e7df4d2add1f4dcb475c2 Mon Sep 17 00:00:00 2001 From: Nils Blomgren Date: Sun, 7 Jun 2026 14:54:16 +0200 Subject: [PATCH] database and gitsync issues fixed --- .claude/settings.json | 7 + .gitignore | 3 + .../Components/Layout/MainLayout.razor | 14 +- src/EntKube.Web/Components/Pages/Error.razor | 41 +- src/EntKube.Web/Components/Pages/Home.razor | 120 +- .../Components/Pages/NotFound.razor | 17 +- .../Pages/Portal/CustomerPortal.razor | 282 +- .../Pages/Portal/CustomerStatusOverview.razor | 18 +- .../Pages/Portal/PortalDeploymentDetail.razor | 390 +- .../Pages/Portal/PortalIdentityDetail.razor | 32 +- .../Pages/Portal/PortalRegistryDetail.razor | 17 +- .../Pages/Portal/PortalSecretsDetail.razor | 22 +- .../Components/Pages/Shared/EmptyState.razor | 25 + .../Pages/Shared/LoadingPanel.razor | 27 + .../Pages/Shared/ResourceTreePanel.razor | 1004 ++-- .../Components/Pages/Tenants/AlertPanel.razor | 31 +- .../Components/Pages/Tenants/AppDetail.razor | 215 +- .../Pages/Tenants/AppResourcesPanel.razor | 373 +- .../Components/Pages/Tenants/CacheTab.razor | 11 +- .../Pages/Tenants/ClusterDetail.razor | 15 +- .../Pages/Tenants/CustomerTab.razor | 427 +- .../Pages/Tenants/DatabaseTab.razor | 94 +- .../Pages/Tenants/EnvironmentTab.razor | 39 +- .../Pages/Tenants/GitReposTab.razor | 20 +- .../Components/Pages/Tenants/GroupTab.razor | 20 +- .../Pages/Tenants/IdentityTab.razor | 11 +- .../Pages/Tenants/IncidentManagement.razor | 17 +- .../Pages/Tenants/MaintenanceWindows.razor | 7 +- .../Pages/Tenants/MessagingTab.razor | 11 +- .../Pages/Tenants/NetworkingTab.razor | 11 +- .../Pages/Tenants/NotificationChannels.razor | 7 +- .../Pages/Tenants/RegistryTab.razor | 11 +- .../Pages/Tenants/SlaTargetManager.razor | 7 +- .../Components/Pages/Tenants/StorageTab.razor | 11 +- .../Pages/Tenants/TenantDetail.razor | 202 +- .../Components/Pages/Tenants/TenantList.razor | 26 +- .../Components/Pages/Tenants/VaultTab.razor | 42 +- src/EntKube.Web/Components/_Imports.razor | 1 + src/EntKube.Web/Data/ApplicationDbContext.cs | 47 + src/EntKube.Web/Data/Customer.cs | 2 + src/EntKube.Web/Data/CustomerGitCredential.cs | 39 + src/EntKube.Web/Data/CustomerGitRepoPolicy.cs | 27 + src/EntKube.Web/Data/GitRepository.cs | 7 + ...tomerGitPoliciesAndCredentials.Designer.cs | 4436 ++++++++++++++++ ...14_AddCustomerGitPoliciesAndCredentials.cs | 122 + ...erGitCredentialToGitRepository.Designer.cs | 4448 ++++++++++++++++ ...AddCustomerGitCredentialToGitRepository.cs | 50 + ...stgresApplicationDbContextModelSnapshot.cs | 122 + ...tomerGitPoliciesAndCredentials.Designer.cs | 4438 ++++++++++++++++ ...27_AddCustomerGitPoliciesAndCredentials.cs | 122 + ...erGitCredentialToGitRepository.Designer.cs | 4450 +++++++++++++++++ ...AddCustomerGitCredentialToGitRepository.cs | 50 + ...ServerApplicationDbContextModelSnapshot.cs | 122 + ...tomerGitPoliciesAndCredentials.Designer.cs | 4427 ++++++++++++++++ ...47_AddCustomerGitPoliciesAndCredentials.cs | 122 + ...erGitCredentialToGitRepository.Designer.cs | 4439 ++++++++++++++++ ...AddCustomerGitCredentialToGitRepository.cs | 50 + .../ApplicationDbContextModelSnapshot.cs | 122 + src/EntKube.Web/Data/VaultSecret.cs | 7 + src/EntKube.Web/Program.cs | 1 + .../Services/CustomerGitService.cs | 258 + src/EntKube.Web/Services/DeploymentService.cs | 29 + .../Services/GitOperationsService.cs | 51 +- .../Services/KubernetesOperationsService.cs | 155 +- src/EntKube.Web/Services/KubernetesS3Proxy.cs | 244 +- src/EntKube.Web/Services/LokiService.cs | 45 +- src/EntKube.Web/Services/MongoService.cs | 330 +- .../Services/StorageBrowserService.cs | 10 +- src/EntKube.Web/Services/StorageService.cs | 72 +- src/EntKube.Web/Services/VaultService.cs | 66 + src/EntKube.Web/appsettings.json | 3 +- src/EntKube.Web/wwwroot/app.css | 34 + 72 files changed, 31067 insertions(+), 1508 deletions(-) create mode 100644 .claude/settings.json create mode 100644 src/EntKube.Web/Components/Pages/Shared/EmptyState.razor create mode 100644 src/EntKube.Web/Components/Pages/Shared/LoadingPanel.razor create mode 100644 src/EntKube.Web/Data/CustomerGitCredential.cs create mode 100644 src/EntKube.Web/Data/CustomerGitRepoPolicy.cs create mode 100644 src/EntKube.Web/Data/Migrations/Postgres/20260604152714_AddCustomerGitPoliciesAndCredentials.Designer.cs create mode 100644 src/EntKube.Web/Data/Migrations/Postgres/20260604152714_AddCustomerGitPoliciesAndCredentials.cs create mode 100644 src/EntKube.Web/Data/Migrations/Postgres/20260605075348_AddCustomerGitCredentialToGitRepository.Designer.cs create mode 100644 src/EntKube.Web/Data/Migrations/Postgres/20260605075348_AddCustomerGitCredentialToGitRepository.cs create mode 100644 src/EntKube.Web/Data/Migrations/SqlServer/20260604152727_AddCustomerGitPoliciesAndCredentials.Designer.cs create mode 100644 src/EntKube.Web/Data/Migrations/SqlServer/20260604152727_AddCustomerGitPoliciesAndCredentials.cs create mode 100644 src/EntKube.Web/Data/Migrations/SqlServer/20260605075405_AddCustomerGitCredentialToGitRepository.Designer.cs create mode 100644 src/EntKube.Web/Data/Migrations/SqlServer/20260605075405_AddCustomerGitCredentialToGitRepository.cs create mode 100644 src/EntKube.Web/Data/Migrations/Sqlite/20260604152747_AddCustomerGitPoliciesAndCredentials.Designer.cs create mode 100644 src/EntKube.Web/Data/Migrations/Sqlite/20260604152747_AddCustomerGitPoliciesAndCredentials.cs create mode 100644 src/EntKube.Web/Data/Migrations/Sqlite/20260605072428_AddCustomerGitCredentialToGitRepository.Designer.cs create mode 100644 src/EntKube.Web/Data/Migrations/Sqlite/20260605072428_AddCustomerGitCredentialToGitRepository.cs create mode 100644 src/EntKube.Web/Services/CustomerGitService.cs diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..2e2ddba --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,7 @@ +{ + "permissions": { + "allow": [ + "Bash(grep -E \"\\\\.\\(cs|razor\\)$\")" + ] + } +} diff --git a/.gitignore b/.gitignore index f9808d5..7910237 100644 --- a/.gitignore +++ b/.gitignore @@ -422,3 +422,6 @@ FodyWeavers.xsd *.db *.db-shm *.db-wal + +# Local reference infrastructure (not part of the project) +CMKS - Infra/ diff --git a/src/EntKube.Web/Components/Layout/MainLayout.razor b/src/EntKube.Web/Components/Layout/MainLayout.razor index c33853b..6d32849 100644 --- a/src/EntKube.Web/Components/Layout/MainLayout.razor +++ b/src/EntKube.Web/Components/Layout/MainLayout.razor @@ -1,4 +1,5 @@ @inherits LayoutComponentBase +@inject NavigationManager NavigationManager
-
- About +
+ + EntKube Platform + + + + + @context.User.Identity?.Name + + +
diff --git a/src/EntKube.Web/Components/Pages/Error.razor b/src/EntKube.Web/Components/Pages/Error.razor index 1ea9222..91e0e2a 100644 --- a/src/EntKube.Web/Components/Pages/Error.razor +++ b/src/EntKube.Web/Components/Pages/Error.razor @@ -1,30 +1,25 @@ -@page "/Error" +@page "/Error" @using System.Diagnostics -Error +Error — EntKube -

Error.

-

An error occurred while processing your request.

+
+ +

Something went wrong

+

An unexpected error occurred while processing your request.

+ @if (ShowRequestId) + { +

Request ID: @RequestId

+ } + + Go Home + + + Retry + +
-@if (ShowRequestId) -{ -

- Request ID: @RequestId -

-} - -

Development Mode

-

- Swapping to Development environment will display more detailed information about the error that occurred. -

-

- The Development environment shouldn't be enabled for deployed applications. - It can result in displaying sensitive information from exceptions to end users. - For local debugging, enable the Development environment by setting the ASPNETCORE_ENVIRONMENT environment variable to Development - and restarting the app. -

- -@code{ +@code { [CascadingParameter] private HttpContext? HttpContext { get; set; } diff --git a/src/EntKube.Web/Components/Pages/Home.razor b/src/EntKube.Web/Components/Pages/Home.razor index 8d69564..e2484a4 100644 --- a/src/EntKube.Web/Components/Pages/Home.razor +++ b/src/EntKube.Web/Components/Pages/Home.razor @@ -1,19 +1,111 @@ @page "/" +@using Microsoft.AspNetCore.Components.Authorization EntKube -

EntKube

-

Multi-tenant platform for managing shared Kubernetes applications and infrastructure services.

- -
- - - Manage Tenants - - - - - Customer Portal - - +
+

EntKube

+

Multi-tenant platform for managing shared Kubernetes applications and infrastructure services.

+ + + + + + + + + diff --git a/src/EntKube.Web/Components/Pages/NotFound.razor b/src/EntKube.Web/Components/Pages/NotFound.razor index 962bdc4..1c56043 100644 --- a/src/EntKube.Web/Components/Pages/NotFound.razor +++ b/src/EntKube.Web/Components/Pages/NotFound.razor @@ -1,5 +1,16 @@ -@page "/not-found" +@page "/not-found" @layout MainLayout -

Not Found

-

Sorry, the content you are looking for does not exist.

\ No newline at end of file +Not Found — EntKube + +
+ +

Page not found

+

Sorry, the page you are looking for does not exist or has been moved.

+ + Go Home + + + Go Back + +
diff --git a/src/EntKube.Web/Components/Pages/Portal/CustomerPortal.razor b/src/EntKube.Web/Components/Pages/Portal/CustomerPortal.razor index d07ab78..2b145e8 100644 --- a/src/EntKube.Web/Components/Pages/Portal/CustomerPortal.razor +++ b/src/EntKube.Web/Components/Pages/Portal/CustomerPortal.razor @@ -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) { -
- -

No access granted

-

You don't have access to any customers yet. Ask a tenant administrator to grant you access.

-
+ } else { @@ -49,7 +48,7 @@ else @@ -144,10 +155,46 @@ else
} + @* ════════════════════════════════════════════════════════════════ + Level 3: Git URL policies (read-only) + ════════════════════════════════════════════════════════════════ *@ + else if (showGitPolicies) + { +
+
+ + Git URL Policies — @selectedCustomer!.Name + +
+
+

+ These URL patterns define which git repositories you may use in deployments. + Contact your administrator to change them. +

+ @if (gitPolicies.Count == 0) + { +
No git URL policies defined.
+ } + else + { +
    + @foreach (CustomerGitRepoPolicy policy in gitPolicies) + { +
  • + + @policy.UrlPattern +
  • + } +
+ } +
+
+ } + @* ════════════════════════════════════════════════════════════════ 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) {
@@ -160,6 +207,12 @@ else @onclick="() => showMonitoringPanel = !showMonitoringPanel"> Monitoring + @if (gitPolicies.Count > 0) + { + + } Status Overview @@ -173,7 +226,7 @@ else @if (showMonitoringPanel) {
-
+
Monitoring — @selectedCustomer.Name @@ -204,10 +257,8 @@ else @if (selectedCustomer.Apps.Count == 0) { -
- -

No apps configured for this customer yet.

-
+ } else { @@ -223,7 +274,7 @@ else @app.Name
-
+
@if (currentAccessRole >= CustomerAccessRole.Admin) { } - @if (hasIdentity) - { - - } - @if (hasRegistry) - { - - } - - + +
@@ -277,6 +345,12 @@ else + @if (gitPolicies.Count > 0) + { + + + + }
@@ -301,7 +375,15 @@ else
- + @if (!string.IsNullOrWhiteSpace(app.Namespace)) + { + +
Locked by governance policy.
+ } + else + { + + }
@if (newDeployType == DeploymentType.HelmChart) { @@ -318,10 +400,52 @@ else
} + @if (newDeployType is DeploymentType.GitYaml or DeploymentType.GitHelm or DeploymentType.GitAppOfApps) + { +
+ @if (customerGitCredentials.Count > 0) + { +
+ + @customerGitCredentials.Count stored credential@(customerGitCredentials.Count != 1 ? "s" : "") on file + (@string.Join(", ", customerGitCredentials.Select(c => c.Name))) — no need to enter credentials. +
+ } + @if (gitPolicies.Count > 0) + { +
+ + Repository URL must match one of your allowed patterns: + @foreach (CustomerGitRepoPolicy p in gitPolicies) + { + @p.UrlPattern + } +
+ } +
+
+ + + @if (newGitUrlError is not null) + { +
@newGitUrlError
+ } +
+
+ + +
+
+ + +
+ }
@@ -363,7 +487,8 @@ else } else if (!showingCreateForm) { -

No deployments configured.

+ }
@@ -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 { } + else if (selectedResourcesApp is not null) + { + + } } @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> appDeployments = new(); @@ -459,12 +592,19 @@ else private HashSet AllCustomerClusterIds => AllCustomerDeployments.Select(d => d.ClusterId).ToHashSet(); + // Git policies and credentials for the selected customer. + private List gitPolicies = []; + private List 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 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 => @Manual, DeploymentType.Yaml => @YAML, DeploymentType.HelmChart => @Helm, + DeploymentType.GitYaml => @Git YAML, + DeploymentType.GitHelm => @Git Helm, + DeploymentType.GitAppOfApps => @App of Apps, _ => @Unknown }; diff --git a/src/EntKube.Web/Components/Pages/Portal/CustomerStatusOverview.razor b/src/EntKube.Web/Components/Pages/Portal/CustomerStatusOverview.razor index 48ebdd6..2459bcd 100644 --- a/src/EntKube.Web/Components/Pages/Portal/CustomerStatusOverview.razor +++ b/src/EntKube.Web/Components/Pages/Portal/CustomerStatusOverview.razor @@ -24,19 +24,12 @@ -@if (isLoading) + +@if (!isLoading && rows.Count == 0) { -
-
-

Loading status…

-
-} -else if (rows.Count == 0) -{ -
- - No applications to show. 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 } +
@code { private List rows = []; diff --git a/src/EntKube.Web/Components/Pages/Portal/PortalDeploymentDetail.razor b/src/EntKube.Web/Components/Pages/Portal/PortalDeploymentDetail.razor index d316534..a9ad137 100644 --- a/src/EntKube.Web/Components/Pages/Portal/PortalDeploymentDetail.razor +++ b/src/EntKube.Web/Components/Pages/Portal/PortalDeploymentDetail.razor @@ -6,6 +6,7 @@ @inject IncidentService IncidentService @inject LokiService LokiService @inject GitSyncService GitSyncService +@inject CustomerGitService CustomerGitService @inject IDbContextFactory DbFactory @* ═══════════════════════════════════════════════════════════════════ @@ -63,7 +64,15 @@
- + @if (!string.IsNullOrWhiteSpace(GovernanceNamespace)) + { + +
Locked by governance policy.
+ } + else + { + + }
@if (Deployment.Type == DeploymentType.HelmChart) { @@ -94,17 +103,25 @@ @if (AccessRole >= CustomerAccessRole.Admin && confirmDeleteDeployment) { -
- +
+
- Permanently delete @Deployment.Name and all its manifests? - -
-
+ @if (deleteError is not null) + { +
@deleteError
+ } +
+ - + +
} @@ -221,6 +238,11 @@ Logs + @* ══════════════════════════════════════════════════════════════ @@ -246,27 +268,13 @@
} - @if (podsLoading) + + @if (!podsLoading && string.IsNullOrEmpty(podsError) && (pods is null || pods.Count == 0)) { -
-
-

Loading pods from cluster...

-
+ } - else if (!string.IsNullOrEmpty(podsError)) - { -
- @podsError -
- } - else if (pods is null || pods.Count == 0) - { -
- -

No pods found in namespace @Deployment.Namespace.

-
- } - 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 @@
} } + } @* ══════════════════════════════════════════════════════════════ @@ -475,20 +484,12 @@ } } - @if (manifests is null) + + @if (manifests is not null && manifests.Count == 0) { -
-
-
+ } - else if (manifests.Count == 0) - { -
- -

No manifests defined for this deployment.

-
- } - else + else if (manifests is not null) { @foreach (DeploymentManifest manifest in manifests) { @@ -536,6 +537,7 @@ } } +
@if (yamlApplyOutput is not null) { @@ -836,6 +838,163 @@ PresetNamespace="@Deployment.Namespace" /> } +@* ══════════════════════════════════════════════════════════════ + Git Tab — per-deployment git source configuration + ══════════════════════════════════════════════════════════════ *@ +@if (activeTab == "git") +{ +
+
+ + Git Source + @if (AccessRole >= CustomerAccessRole.Admin && !showGitEdit) + { + + } +
+
+ + @* ── Credential notice ── *@ + @if (gitTabCredentials.Count > 0) + { +
+ + Stored credentials on file: + @string.Join(", ", gitTabCredentials.Select(c => c.Name)) — + no need to enter credentials when setting a repository URL. +
+ } + + @* ── Policies (read-only) ── *@ + @if (gitTabPolicies.Count > 0) + { +
+
Allowed repository URL patterns:
+
+ @foreach (CustomerGitRepoPolicy p in gitTabPolicies) + { + @p.UrlPattern + } +
+
+ } + + @if (showGitEdit && AccessRole >= CustomerAccessRole.Admin) + { + @* ── Edit form ── *@ + @if (gitEditError is not null) + { +
@gitEditError
+ } +
+
+ + + @if (gitEditUrlError is not null) + { +
@gitEditUrlError
+ } +
+
+ + +
+
+ + +
+
+
+ + +
+
+
+
+ + +
+ } + else if (Deployment.GitRepositoryId is not null) + { + @* ── Current settings (read) ── *@ +
+
Repository
+
@(Deployment.GitRepository?.Url ?? "—")
+ +
Path
+
@(Deployment.GitPath ?? ".")
+ +
Revision
+
@(Deployment.GitRevision ?? Deployment.GitRepository?.DefaultBranch ?? "main")
+ +
Auto-sync
+
+ @if (Deployment.GitAutoSync) + { + Enabled + } + else + { + Disabled + } +
+ + @if (Deployment.GitLastSyncedCommit is not null) + { +
Last synced
+
+ @Deployment.GitLastSyncedCommit[..Math.Min(7, Deployment.GitLastSyncedCommit.Length)] + @if (Deployment.GitLastSyncedAt.HasValue) + { + @Deployment.GitLastSyncedAt.Value.ToString("yyyy-MM-dd HH:mm") + } +
+ } +
+ + @if (AccessRole >= CustomerAccessRole.Operator) + { +
+ + @if (gitSyncMessage is not null) + { + + @gitSyncMessage + + } +
+ } + } + else + { + @* ── Not configured ── *@ +
+ No git source configured for this deployment. + @if (AccessRole >= CustomerAccessRole.Admin) + { + + } +
+ } +
+
+} + @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 gitTabPolicies = []; + private List 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? 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? 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 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 ──────── diff --git a/src/EntKube.Web/Components/Pages/Portal/PortalIdentityDetail.razor b/src/EntKube.Web/Components/Pages/Portal/PortalIdentityDetail.razor index db9487f..cf11fde 100644 --- a/src/EntKube.Web/Components/Pages/Portal/PortalIdentityDetail.razor +++ b/src/EntKube.Web/Components/Pages/Portal/PortalIdentityDetail.razor @@ -36,11 +36,8 @@ @* ── Users ── *@ @if (activeTab == "users") { - @if (loadingUsers) - { -
- } - else + + @if (!loadingUsers) {
@(users?.Count ?? 0) users @@ -126,16 +123,14 @@
} } +
} @* ── Identity Providers ── *@ @if (activeTab == "idp") { - @if (loadingIdps) - { -
- } - else + + @if (!loadingIdps) {
@(idps?.Count ?? 0) providers @@ -207,16 +202,14 @@
} } +
} @* ── Groups ── *@ @if (activeTab == "groups") { - @if (loadingGroups) - { -
- } - else + + @if (!loadingGroups) {
@(groups?.Count ?? 0) groups @@ -257,16 +250,14 @@
} } +
} @* ── Organizations ── *@ @if (activeTab == "orgs") { - @if (loadingOrgs) - { -
- } - else + + @if (!loadingOrgs) {
@(orgs?.Count ?? 0) organizations (Keycloak 26+) @@ -322,6 +313,7 @@
} } +
} @code { diff --git a/src/EntKube.Web/Components/Pages/Portal/PortalRegistryDetail.razor b/src/EntKube.Web/Components/Pages/Portal/PortalRegistryDetail.razor index 4d61f17..c56618a 100644 --- a/src/EntKube.Web/Components/Pages/Portal/PortalRegistryDetail.razor +++ b/src/EntKube.Web/Components/Pages/Portal/PortalRegistryDetail.razor @@ -2,20 +2,8 @@ @using EntKube.Web.Services @inject HarborService HarborService -@if (loading) -{ -
-
-

Loading registry...

-
-} -else if (loadError is not null) -{ -
- @loadError -
-} -else + +@if (!loading && loadError is null) {
@@ -329,6 +317,7 @@ else } } } + @code { [Parameter] public required HarborProject Project { get; set; } diff --git a/src/EntKube.Web/Components/Pages/Portal/PortalSecretsDetail.razor b/src/EntKube.Web/Components/Pages/Portal/PortalSecretsDetail.razor index c3da689..9fe5784 100644 --- a/src/EntKube.Web/Components/Pages/Portal/PortalSecretsDetail.razor +++ b/src/EntKube.Web/Components/Pages/Portal/PortalSecretsDetail.razor @@ -102,24 +102,13 @@ else }
- @if (secrets is null) + + @if (secrets is not null && secrets.Count == 0) { -
-
-
+ } - else if (secrets.Count == 0) - { -
- -

No secrets stored for this app yet.

- @if (AccessRole >= CustomerAccessRole.Admin) - { -

Use the form above to add one.

- } -
- } - else + else if (secrets is not null) {
@@ -271,6 +260,7 @@ else
} +
@if (syncOutput is not null) {
@syncOutput
diff --git a/src/EntKube.Web/Components/Pages/Shared/EmptyState.razor b/src/EntKube.Web/Components/Pages/Shared/EmptyState.razor new file mode 100644 index 0000000..e68c8d9 --- /dev/null +++ b/src/EntKube.Web/Components/Pages/Shared/EmptyState.razor @@ -0,0 +1,25 @@ +
+ + @if (!string.IsNullOrEmpty(Title)) + { +
@Title
+ } + @if (!string.IsNullOrEmpty(Message)) + { +

@Message

+ } + @if (OnAction.HasDelegate && !string.IsNullOrEmpty(ActionLabel)) + { + + } +
+ +@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; } +} diff --git a/src/EntKube.Web/Components/Pages/Shared/LoadingPanel.razor b/src/EntKube.Web/Components/Pages/Shared/LoadingPanel.razor new file mode 100644 index 0000000..0ae555f --- /dev/null +++ b/src/EntKube.Web/Components/Pages/Shared/LoadingPanel.razor @@ -0,0 +1,27 @@ +@if (Loading) +{ +
+
+ @if (!string.IsNullOrEmpty(LoadingText)) + { +

@LoadingText

+ } +
+} +else if (!string.IsNullOrEmpty(Error)) +{ +
+ @Error +
+} +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; } +} diff --git a/src/EntKube.Web/Components/Pages/Shared/ResourceTreePanel.razor b/src/EntKube.Web/Components/Pages/Shared/ResourceTreePanel.razor index a43b401..d896a3d 100644 --- a/src/EntKube.Web/Components/Pages/Shared/ResourceTreePanel.razor +++ b/src/EntKube.Web/Components/Pages/Shared/ResourceTreePanel.razor @@ -3,112 +3,265 @@ @inject KubernetesOperationsService K8sOps @* ═══════════════════════════════════════════════════════════════════ - ResourceTreePanel — ArgoCD-style resource ownership tree. + ResourceTreePanel — proper ArgoCD-style graph. - Each node renders as a card with a health-coloured left border, - kind icon, kind badge, resource name, status and health label. - Children are indented beneath a dashed vertical guide line, - matching the ArgoCD Application detail layout. + Layout: horizontal tree, each depth level is a column. + Subtrees are centred over their children using a two-pass + layout algorithm. + Lines: SVG cubic bezier curves drawn on an absolute-positioned + overlay that covers the whole graph container. + Cards: absolute-positioned; left-border colour = health status; + kind icon, name, status, health badge and action buttons. + Actions: floating panel positioned under (or above) the active card. ═══════════════════════════════════════════════════════════════════ *@ -@if (Loading) + +@if (!Loading && Error is null && (Resources is null || Resources.Count == 0)) { -
-
-

Querying cluster…

-
+ } -else if (Error is not null) +else if (!Loading && Error is null && Resources is not null && Resources.Count > 0) { -
- @Error -
-} -else if (Resources is null || Resources.Count == 0) -{ -
- -

- No resources found in namespace @Namespace. -

-
-} -else -{ - @if (_actionFeedback is not null) + @if (_feedback is not null) { -
- - @_actionFeedback - +
+ @_feedback +
} -
- @foreach (DeploymentResource root in Resources) - { - @RenderNode(root) - } +
+
+ + @* ── SVG bezier connectors ── *@ + + @foreach (SvgLine ln in _lines) + { + + } + + + @* ── Node cards ── *@ + @foreach (NodeLayout nd in _nodes) + { + var cx = nd.Col * ColW + 12; + var cy = nd.Y + 12; + var hc = HealthColor(nd.Node.HealthStatus); + var isActive = _activePanelNodeId == nd.Node.Id; + +
+ +
+
+ + @nd.Node.Kind +
+
+ +
@nd.Node.Name
+ + @if (!string.IsNullOrEmpty(nd.Node.StatusMessage)) + { +
@nd.Node.StatusMessage
+ } + else + { +
@HealthLabel(nd.Node.HealthStatus)
+ } +
+ + @* ── Action buttons ── *@ +
+ @if (nd.Node.Kind == "Pod" && CanLogs) + { + + } + @if (CanEvents) + { + + } + @if ((nd.Node.Kind is "Deployment" or "StatefulSet" or "DaemonSet") && CanRestart) + { + + } + @if ((nd.Node.Kind is "Deployment" or "StatefulSet" or "ReplicaSet") && CanScale) + { + + } + @if ((nd.Node.Kind == "Pod" || nd.Node.Kind == "Job") && CanDelete) + { + + } +
+
+ + @* ── Floating action panel for this node ── *@ + @if (isActive && _panelMode is not null) + { + @RenderPanel(nd.Node, cx, cy + CardH + 6) + } + } +
} + @code { [Parameter] public bool Loading { get; set; } @@ -116,17 +269,41 @@ else [Parameter] public List? Resources { get; set; } [Parameter] public string? Namespace { get; set; } [Parameter] public Guid DeploymentId { get; set; } - /// Null = full admin access (tenant view). [Parameter] public CustomerAccessRole? AccessRole { get; set; } + // ── Access gates ──────────────────────────────────────────────────────── + private bool CanLogs => AccessRole is null || AccessRole >= CustomerAccessRole.Viewer; private bool CanEvents => AccessRole is null || AccessRole >= CustomerAccessRole.Viewer; private bool CanRestart => AccessRole is null || AccessRole >= CustomerAccessRole.Operator; private bool CanDelete => AccessRole is null || AccessRole >= CustomerAccessRole.Operator; private bool CanScale => AccessRole is null || AccessRole >= CustomerAccessRole.Admin; - private Guid? _activePanelId; + // ── Layout constants ───────────────────────────────────────────────────── + + private const double CardW = 204; + private const double CardH = 86; // body + action bar + private const double ColGap = 72; // horizontal gap between columns (lines travel here) + private const double RowGap = 28; + private const double ColW = CardW + ColGap; + private const double RowH = CardH + RowGap; + + // ── Layout state ───────────────────────────────────────────────────────── + + private record NodeLayout(DeploymentResource Node, int Col, double Y, Guid? ParentId); + private record SvgLine(double X1, double Y1, double X2, double Y2); + + private List _nodes = []; + private List _lines = []; + private double _graphW; + private double _graphH; + + // ── Panel state ────────────────────────────────────────────────────────── + + private Guid? _activePanelNodeId; + private DeploymentResource? _activePanelNode; private string? _panelMode; + private bool _panelLoading; private string? _panelText; private string? _panelError; @@ -134,10 +311,309 @@ else private string? _selectedContainer; private int _scaleValue = 1; private bool _opBusy; - private string? _actionFeedback; - private bool _actionFeedbackOk; - // ── Static visual helpers ──────────────────────────────────────────────── + private string? _feedback; + private bool _feedbackOk; + + // ── Lifecycle ──────────────────────────────────────────────────────────── + + protected override void OnParametersSet() + { + ComputeLayout(); + } + + // ── Layout engine ──────────────────────────────────────────────────────── + + private void ComputeLayout() + { + _nodes.Clear(); + _lines.Clear(); + if (Resources is null || Resources.Count == 0) return; + + double y = 0; + + void Layout(DeploymentResource node, int col, Guid? parentId) + { + double top = y; + + if (node.ChildResources is { Count: > 0 } kids) + foreach (DeploymentResource kid in kids) + Layout(kid, col + 1, node.Id); + else + y += RowH; + + double centerY = (top + y) / 2 - CardH / 2; + _nodes.Add(new NodeLayout(node, col, centerY, parentId)); + } + + foreach (DeploymentResource root in Resources) + Layout(root, 0, null); + + Dictionary byId = _nodes.ToDictionary(n => n.Node.Id); + + foreach (NodeLayout n in _nodes.Where(n => n.ParentId.HasValue)) + { + if (byId.TryGetValue(n.ParentId!.Value, out NodeLayout? p)) + { + _lines.Add(new SvgLine( + p.Col * ColW + CardW + 12, // parent right edge (+ canvas padding) + p.Y + CardH / 2 + 12, // parent vertical centre + n.Col * ColW + 12, // child left edge + n.Y + CardH / 2 + 12 // child vertical centre + )); + } + } + + int maxCol = _nodes.Count > 0 ? _nodes.Max(n => n.Col) : 0; + _graphW = (maxCol + 1) * ColW; + _graphH = y; + } + + private static string BezierPath(SvgLine l) + { + double dx = Math.Abs(l.X2 - l.X1) * 0.55; + return FormattableString.Invariant( + $"M {l.X1:F1} {l.Y1:F1} C {l.X1 + dx:F1} {l.Y1:F1} {l.X2 - dx:F1} {l.Y2:F1} {l.X2:F1} {l.Y2:F1}"); + } + + // ── Panel helpers ──────────────────────────────────────────────────────── + + private bool IsPanel(Guid id, string mode) => + _activePanelNodeId == id && _panelMode == mode; + + // String-literal-safe wrappers — avoids embedding "string" inside @onclick="..." attributes. + private void PanelLogs(DeploymentResource n) => TogglePanel(n.Id, n, "logs"); + private void PanelEvents(DeploymentResource n) => TogglePanel(n.Id, n, "events"); + private void PanelRestart(DeploymentResource n) => TogglePanel(n.Id, n, "restart"); + private void PanelScale(DeploymentResource n) => TogglePanel(n.Id, n, "scale"); + private void PanelDelete(DeploymentResource n) => TogglePanel(n.Id, n, "delete"); + + private void TogglePanel(Guid id, DeploymentResource node, string mode) + { + if (_activePanelNodeId == id && _panelMode == mode) + { + ClosePanel(); return; + } + ClosePanel(); + _activePanelNodeId = id; + _activePanelNode = node; + _panelMode = mode; + + if (mode == "logs") _ = LoadLogsAsync(node); + if (mode == "events") _ = LoadEventsAsync(node); + if (mode == "scale") _scaleValue = ParseDesiredReplicas(node.StatusMessage) ?? 1; + } + + private void ClosePanel() + { + _activePanelNodeId = null; + _activePanelNode = null; + _panelMode = null; + _panelText = null; + _panelError = null; + _containers = []; + _selectedContainer = null; + } + + // ── Async operations (fire-and-forget from sync context) ───────────────── + + private async Task LoadLogsAsync(DeploymentResource node) + { + _panelLoading = true; _panelText = null; _panelError = null; + StateHasChanged(); + + var cr = await K8sOps.GetPodContainersAsync(DeploymentId, node.Name); + if (!cr.IsSuccess) { _panelError = cr.Error; _panelLoading = false; StateHasChanged(); return; } + + _containers = cr.Data ?? []; + _selectedContainer = _containers.FirstOrDefault(); + await FetchLogsAsync(node.Name); + } + + private async Task FetchLogsAsync(string podName) + { + _panelLoading = true; _panelText = null; _panelError = null; + StateHasChanged(); + var r = await K8sOps.GetPodLogsAsync(DeploymentId, podName, _selectedContainer); + _panelLoading = false; + if (r.IsSuccess) _panelText = r.Data ?? "(no output)"; else _panelError = r.Error; + StateHasChanged(); + } + + private async Task LoadEventsAsync(DeploymentResource node) + { + _panelLoading = true; _panelText = null; _panelError = null; + StateHasChanged(); + var r = await K8sOps.GetResourceEventsAsync(DeploymentId, node.Kind, node.Name); + _panelLoading = false; + if (r.IsSuccess) + _panelText = r.Data is { Count: > 0 } l ? string.Join("\n", l) : "(no events)"; + else _panelError = r.Error; + StateHasChanged(); + } + + private void SwitchContainer(string podName, string container) + { + _selectedContainer = container; + _ = FetchLogsAsync(podName); + } + + private async Task ApplyScaleAsync(DeploymentResource node) + { + _opBusy = true; StateHasChanged(); + var r = await K8sOps.ScaleWorkloadAsync(DeploymentId, node.Kind, node.Name, _scaleValue); + _opBusy = false; ClosePanel(); + SetFeedback(r.IsSuccess, r.IsSuccess + ? $"{node.Kind} {node.Name} scaled to {_scaleValue}." + : r.Error ?? "Scale failed."); + } + + private async Task ApplyRestartAsync(DeploymentResource node) + { + _opBusy = true; StateHasChanged(); + var r = await K8sOps.RestartWorkloadAsync(DeploymentId, node.Kind, node.Name); + _opBusy = false; ClosePanel(); + SetFeedback(r.IsSuccess, r.IsSuccess + ? $"Rolling restart triggered for {node.Name}." + : r.Error ?? "Restart failed."); + } + + private async Task ApplyDeleteAsync(DeploymentResource node) + { + _opBusy = true; StateHasChanged(); + var r = await K8sOps.DeleteResourceAsync(DeploymentId, node.Kind, node.Name); + _opBusy = false; ClosePanel(); + SetFeedback(r.IsSuccess, r.IsSuccess + ? $"{node.Kind} {node.Name} deleted." + : r.Error ?? "Delete failed."); + } + + private void SetFeedback(bool ok, string msg) + { + _feedbackOk = ok; _feedback = msg; StateHasChanged(); + } + + // ── Floating panel renderer ────────────────────────────────────────────── + + private RenderFragment RenderPanel(DeploymentResource node, double px, double py) => __builder => + { +
+
+ + @if (_panelMode == "logs") + { + Logs — @node.Name + } + else if (_panelMode == "events") + { + Events — @node.Name + } + else if (_panelMode == "scale") + { + Scale — @node.Name + } + else if (_panelMode == "restart") + { + Restart — @node.Name + } + else if (_panelMode == "delete") + { + Delete — @node.Name + } + + +
+ + @if (_panelMode == "logs") + { + @if (_containers.Count > 1) + { +
+ Container: + @foreach (string c in _containers) + { + + } +
+ } + @if (_panelLoading) + { +
+ } + else if (_panelError is not null) + { +
@_panelError
+ } + else + { +
@_panelText
+ } + } + + @if (_panelMode == "events") + { + @if (_panelLoading) + { +
+ } + else if (_panelError is not null) + { +
@_panelError
+ } + else + { +
@_panelText
+ } + } + + @if (_panelMode == "scale") + { +
+ Replicas for @node.Name + + + +
+ } + + @if (_panelMode == "restart") + { +
+ Rolling restart @node.Name? + + +
+ } + + @if (_panelMode == "delete") + { +
+ + Delete @node.Kind @node.Name? + @if (node.Kind == "Pod") { K8s will restart it. } + + + +
+ } +
+ }; + + // ── Visual helpers ─────────────────────────────────────────────────────── private static string HealthColor(HealthStatus h) => h switch { @@ -188,383 +664,11 @@ else _ => "text-muted", }; - // ── Panel open/close ───────────────────────────────────────────────────── - - private void ClosePanel() - { - _activePanelId = null; - _panelMode = null; - _panelText = null; - _panelError = null; - _containers = []; - _selectedContainer = null; - } - - private bool IsPanelOpen(Guid id, string mode) => - _activePanelId == id && _panelMode == mode; - - // ── Action handlers ────────────────────────────────────────────────────── - - private async Task OpenLogPanel(DeploymentResource node) - { - if (_activePanelId == node.Id && _panelMode == "logs") { ClosePanel(); return; } - ClosePanel(); - _activePanelId = node.Id; - _panelMode = "logs"; - _panelLoading = true; - StateHasChanged(); - - var cr = await K8sOps.GetPodContainersAsync(DeploymentId, node.Name); - if (!cr.IsSuccess) { _panelError = cr.Error; _panelLoading = false; return; } - - _containers = cr.Data ?? []; - _selectedContainer = _containers.Count > 0 ? _containers[0] : null; - await FetchLogs(node.Name); - } - - private async Task FetchLogs(string podName) - { - _panelLoading = true; - _panelText = null; - _panelError = null; - StateHasChanged(); - - var r = await K8sOps.GetPodLogsAsync(DeploymentId, podName, _selectedContainer); - _panelLoading = false; - if (r.IsSuccess) _panelText = r.Data ?? "(no output)"; - else _panelError = r.Error; - } - - private async Task OpenEventsPanel(DeploymentResource node) - { - if (_activePanelId == node.Id && _panelMode == "events") { ClosePanel(); return; } - ClosePanel(); - _activePanelId = node.Id; - _panelMode = "events"; - _panelLoading = true; - StateHasChanged(); - - var r = await K8sOps.GetResourceEventsAsync(DeploymentId, node.Kind, node.Name); - _panelLoading = false; - if (r.IsSuccess) - _panelText = r.Data is { Count: > 0 } lines ? string.Join("\n", lines) : "(no events)"; - else _panelError = r.Error; - } - - private void OpenScalePanel(DeploymentResource node) - { - if (_activePanelId == node.Id && _panelMode == "scale") { ClosePanel(); return; } - ClosePanel(); - _activePanelId = node.Id; - _panelMode = "scale"; - _scaleValue = ParseDesiredReplicas(node.StatusMessage) ?? 1; - } - - private async Task ApplyScale(DeploymentResource node) - { - _opBusy = true; - StateHasChanged(); - var r = await K8sOps.ScaleWorkloadAsync(DeploymentId, node.Kind, node.Name, _scaleValue); - _opBusy = false; - ClosePanel(); - Feedback(r.IsSuccess, r.IsSuccess - ? $"{node.Kind} {node.Name} scaled to {_scaleValue}." - : r.Error ?? "Scale failed."); - } - - private void OpenRestartConfirm(DeploymentResource node) - { - if (_activePanelId == node.Id && _panelMode == "restart-confirm") { ClosePanel(); return; } - ClosePanel(); - _activePanelId = node.Id; - _panelMode = "restart-confirm"; - } - - private async Task ConfirmRestart(DeploymentResource node) - { - _opBusy = true; - StateHasChanged(); - var r = await K8sOps.RestartWorkloadAsync(DeploymentId, node.Kind, node.Name); - _opBusy = false; - ClosePanel(); - Feedback(r.IsSuccess, r.IsSuccess - ? $"Rolling restart triggered for {node.Name}." - : r.Error ?? "Restart failed."); - } - - private void OpenDeleteConfirm(DeploymentResource node) - { - if (_activePanelId == node.Id && _panelMode == "delete-confirm") { ClosePanel(); return; } - ClosePanel(); - _activePanelId = node.Id; - _panelMode = "delete-confirm"; - } - - private async Task ConfirmDelete(DeploymentResource node) - { - _opBusy = true; - StateHasChanged(); - var r = await K8sOps.DeleteResourceAsync(DeploymentId, node.Kind, node.Name); - _opBusy = false; - ClosePanel(); - Feedback(r.IsSuccess, r.IsSuccess - ? $"{node.Kind} {node.Name} deleted." - : r.Error ?? "Delete failed."); - } - - private void Feedback(bool ok, string msg) - { - _actionFeedbackOk = ok; - _actionFeedback = msg; - StateHasChanged(); - } - private static int? ParseDesiredReplicas(string? s) { if (string.IsNullOrEmpty(s)) return null; int slash = s.IndexOf('/'); if (slash < 0) return null; - string after = s[(slash + 1)..].Split(' ')[0]; - return int.TryParse(after, out int n) ? n : null; - } - - // ── Recursive card renderer ────────────────────────────────────────────── - // - // Each call emits one node card, then (if its panel is open) the action - // panel, then recursively all children inside .argo-children. - // - // IMPORTANT: keep this method free of async lambdas and complex captures. - // Only use simple @onclick="() => SyncMethod(node)" references here. - - private RenderFragment RenderNode(DeploymentResource node) => __builder => - { - string hColor = HealthColor(node.HealthStatus); - bool panelOpen = _activePanelId == node.Id; - bool hasKids = node.ChildResources is { Count: > 0 }; - - // ── Card ───────────────────────────────────────────────────────────── -
- - - - @node.Kind - - @node.Name - - @if (!string.IsNullOrEmpty(node.StatusMessage)) - { - @node.StatusMessage - } - - - ● @HealthLabel(node.HealthStatus) - - -
- @if (node.Kind == "Pod" && CanLogs) - { - - } - @if (CanEvents) - { - - } - @if (node.Kind is "Deployment" or "StatefulSet" or "DaemonSet" && CanRestart) - { - - } - @if (node.Kind is "Deployment" or "StatefulSet" or "ReplicaSet" && CanScale) - { - - } - @if (node.Kind == "Pod" && CanDelete) - { - - } - @if (node.Kind == "Job" && CanScale) - { - - } -
-
- - @* ── Action panel (between card and children) ─────────────────────── *@ - @if (panelOpen && _panelMode is not null) - { - @RenderActionPanel(node) - } - - @* ── Children ─────────────────────────────────────────────────────── *@ - @if (hasKids) - { -
- @foreach (DeploymentResource child in node.ChildResources!) - { - @RenderNode(child) - } -
- } - }; - - // Separate method so the heavy panel HTML is not inside the recursive lambda. - private RenderFragment RenderActionPanel(DeploymentResource node) => __builder => - { -
-
- - @if (_panelMode == "logs") - { - @:Logs — @node.Name - } - else if (_panelMode == "events") - { - @:Events — @node.Name - } - else if (_panelMode == "scale") - { - @:Scale — @node.Name - } - else if (_panelMode == "restart-confirm") - { - @:Restart — @node.Name - } - else if (_panelMode == "delete-confirm") - { - @:Delete — @node.Name - } - - -
- - @if (_panelMode == "logs") - { - @if (_containers.Count > 1) - { -
- Container: - @foreach (string c in _containers) - { - string containerName = c; - - } -
- } - @if (_panelLoading) - { -
-
-
- } - else if (_panelError is not null) - { -
@_panelError
- } - else - { -
@_panelText
- } - } - - @if (_panelMode == "events") - { - @if (_panelLoading) - { -
-
-
- } - else if (_panelError is not null) - { -
@_panelError
- } - else - { -
@_panelText
- } - } - - @if (_panelMode == "scale") - { -
- Replicas for @node.Name - - - -
- } - - @if (_panelMode == "restart-confirm") - { -
- Rolling restart @node.Name? - - -
- } - - @if (_panelMode == "delete-confirm") - { -
- - Delete @node.Kind @node.Name? - @if (node.Kind == "Pod") - { - K8s will restart it automatically. - } - - - -
- } -
- }; - - // Non-async wrapper to avoid async lambdas inside RenderFragment. - private void SwitchContainer(string podName, string containerName) - { - _selectedContainer = containerName; - _ = FetchLogs(podName); + return int.TryParse(s[(slash + 1)..].Split(' ')[0], out int n) ? n : null; } } diff --git a/src/EntKube.Web/Components/Pages/Tenants/AlertPanel.razor b/src/EntKube.Web/Components/Pages/Tenants/AlertPanel.razor index 95aa7af..6569730 100644 --- a/src/EntKube.Web/Components/Pages/Tenants/AlertPanel.razor +++ b/src/EntKube.Web/Components/Pages/Tenants/AlertPanel.razor @@ -30,27 +30,15 @@
- @if (loading) - { -
-
-
- } - else if (!string.IsNullOrEmpty(errorMessage)) - { -
- @errorMessage -
- } - else if (view == "alerts") + + @if (!loading && string.IsNullOrEmpty(errorMessage) && view == "alerts") { @* ── Active Alerts ── *@ @if (alerts is null || alerts.Count == 0) { -
- -

No active alerts. All clear!

-
+ } else { @@ -93,15 +81,12 @@
} } - else if (view == "silences") + else if (!loading && string.IsNullOrEmpty(errorMessage) && view == "silences") { @* ── Silences ── *@ @if (silences is null || silences.Count == 0) { -
- -

No silences configured.

-
+ } else { @@ -134,6 +119,8 @@ } } +
+ @* ── Create Silence Form ── *@ @if (showCreateSilence) { diff --git a/src/EntKube.Web/Components/Pages/Tenants/AppDetail.razor b/src/EntKube.Web/Components/Pages/Tenants/AppDetail.razor index f9f2e0c..a2f804c 100644 --- a/src/EntKube.Web/Components/Pages/Tenants/AppDetail.razor +++ b/src/EntKube.Web/Components/Pages/Tenants/AppDetail.razor @@ -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 @@ Select which environments this app is deployed to.
- @if (environments is null) + + @if (environments is not null && environments.Count == 0) { -
-
-
+ } - else if (environments.Count == 0) - { -
- -

No environments exist for this tenant yet. Create environments first.

-
- } - else + else if (environments is not null) {
@foreach (Data.Environment env in environments) @@ -149,6 +143,7 @@ }
} +
} @@ -183,8 +178,14 @@
- @GetSyncBadge(selectedDeployment.SyncStatus) - @GetHealthBadge(selectedDeployment.HealthStatus) + +
} - @if (manifests is null) + + @if (manifests is not null && manifests.Count == 0) { -
-
-
+ } - else if (manifests.Count == 0) - { -
- -

No manifests yet. Add YAML manifests to define what gets deployed.

-
- } - else + else if (manifests is not null) { @foreach (DeploymentManifest manifest in manifests) { @@ -469,12 +463,19 @@ } + else if (confirmDeleteManifestId == manifest.Id) + { + + + } else { - } @@ -497,6 +498,7 @@ } } +
@if (yamlApplyOutput is not null) { @@ -621,18 +623,29 @@ {
-
Attach a PostgreSQL Database
+
Attach a Database
@@ -655,21 +668,15 @@ } @* --- Binding list --- *@ - @if (databaseBindings is null) + + @if (databaseBindings is not null && databaseBindings.Count == 0) { -
-
-
+ } - else if (databaseBindings.Count == 0) - { -
- -

No databases attached. Attach a database to have its credentials automatically synced to this deployment's namespace.

-
- } - else + else if (databaseBindings is not null) { +
@@ -700,16 +707,26 @@ @(binding.LastSyncedAt.HasValue ? binding.LastSyncedAt.Value.ToString("yyyy-MM-dd HH:mm") : "Never") }
- + @if (confirmRemoveBindingId == binding.Id) + { + + + } + else + { + + }
+
} +
} @@ -770,21 +787,15 @@
} - @if (cacheBindings is null) + + @if (cacheBindings is not null && cacheBindings.Count == 0) { -
-
-
+ } - else if (cacheBindings.Count == 0) - { -
- -

No Redis clusters attached. Attach one to have REDIS_HOST, REDIS_PORT, and REDIS_PASSWORD synced into this deployment's namespace.

-
- } - else + else if (cacheBindings is not null) { +
@@ -808,16 +819,26 @@ @(binding.LastSyncedAt.HasValue ? binding.LastSyncedAt.Value.ToString("yyyy-MM-dd HH:mm") : "Never") }
- + @if (confirmRemoveCacheBindingId == binding.Id) + { + + + } + else + { + + }
+
} +
} @@ -1081,20 +1102,13 @@ } @* --- Deployment list --- *@ - @if (deployments is null) + + @if (deployments is not null && deployments.Count == 0) { -
-
-
+ } - else if (deployments.Count == 0) - { -
- -

No deployments yet. Create one to start deploying to Kubernetes.

-
- } - else + else if (deployments is not null) {
@foreach (AppDeployment deploy in deployments) @@ -1129,6 +1143,7 @@ }
} +
} @@ -1320,10 +1335,12 @@ // Database bindings private List? 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? 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 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 diff --git a/src/EntKube.Web/Components/Pages/Tenants/AppResourcesPanel.razor b/src/EntKube.Web/Components/Pages/Tenants/AppResourcesPanel.razor index c918c9d..506a12d 100644 --- a/src/EntKube.Web/Components/Pages/Tenants/AppResourcesPanel.razor +++ b/src/EntKube.Web/Components/Pages/Tenants/AppResourcesPanel.razor @@ -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. ═══════════════════════════════════════════════════════════════════ *@ -
- @if (deployments is null) - { - @for (int i = 0; i < 3; i++) - { -
-
-
-
-
Loading…
-
-
-
-
- } - } - else if (deployments.Count == 0) - { -
- -

No deployments yet. Create one in the Deployments tab.

-
- } - else - { - @foreach (AppDeployment d in deployments) - { - string hc = HealthColor(d.HealthStatus); - bool open = expandedIds.Contains(d.Id); - -
- -
-
- -
-
-
@d.Name
-
- @TypeLabel(d.Type) · @d.Environment?.Name · @d.Namespace -
-
- -
- -
- @{ - string hcol = HealthColor(d.HealthStatus); - string hlbl = HealthLabel(d.HealthStatus); - var (scol, sico, slbl) = SyncInfo(d.SyncStatus); - } - - ● @hlbl - - - @slbl - - @if (d.GitRepositoryId is not null && d.GitLastSyncedCommit is not null) - { - - @d.GitLastSyncedCommit[..7] - - } -
- - @if (d.Cluster is not null) - { -
- @d.Cluster.Name -
- } -
- } - } -
- -@* ── 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 ── *@ + + @if (deployments is not null && deployments.Count == 0) { -
-
-
- - @d.Name - / @d.Namespace - @if (loadingIds.Contains(d.Id)) + + } + else if (deployments is not null) + { +
+ @foreach (AppDeployment d in deployments) + { + string hc = HealthColor(d.HealthStatus); + string hlabel = HealthLabel(d.HealthStatus); + var (scol, sico, slabel) = SyncInfo(d.SyncStatus); + +
+
+
+ @d.Name +
+ +
+
+ @TypeLabel(d.Type) · @d.Environment?.Name · @d.Namespace +
+
+ ● @hlabel + + @slabel + + @if (d.GitLastSyncedCommit is not null) + { + + @d.GitLastSyncedCommit[..7] + + } +
+ @if (d.Cluster is not null) { -
+
+ @d.Cluster.Name +
}
- -
- - @if (treeErrors.TryGetValue(d.Id, out string? err)) - { -
- @err -
- } - else - { - }
} + +} +else +{ + @* ── Level 2: single deployment resource graph ── *@ + + +
+
+ @selectedDeployment.Name + + @TypeLabel(selectedDeployment.Type) · @selectedDeployment.Environment?.Name · @selectedDeployment.Namespace + +
+ @{ + string shc = HealthColor(selectedDeployment.HealthStatus); + string shlabel = HealthLabel(selectedDeployment.HealthStatus); + var (sscol, ssico, sslabel) = SyncInfo(selectedDeployment.SyncStatus); + } + ● @shlabel + + @sslabel + + @if (selectedDeployment.Cluster is not null) + { + + @selectedDeployment.Cluster.Name + + } +
+ + } @code { [Parameter, EditorRequired] public Guid AppId { get; set; } private List? deployments; - private HashSet expandedIds = []; - private HashSet loadingIds = []; - private Dictionary> trees = []; - private Dictionary treeErrors = []; + + // Level 2 state + private AppDeployment? selectedDeployment; + private bool treeLoading; + private string? treeError; + private List? 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> 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(), }; } diff --git a/src/EntKube.Web/Components/Pages/Tenants/CacheTab.razor b/src/EntKube.Web/Components/Pages/Tenants/CacheTab.razor index 2f09bcf..90dbe0a 100644 --- a/src/EntKube.Web/Components/Pages/Tenants/CacheTab.razor +++ b/src/EntKube.Web/Components/Pages/Tenants/CacheTab.razor @@ -10,14 +10,8 @@ the RedisCluster manifest. Credentials are surfaced from the tenant vault. =========================================================================== *@ -@if (loading) -{ -
-
-

Loading Redis clusters...

-
-} -else + +@if (!loading) { @* ── Operator availability badge ── *@
@@ -412,6 +406,7 @@ else }
} +
@code { [Parameter] public Guid TenantId { get; set; } diff --git a/src/EntKube.Web/Components/Pages/Tenants/ClusterDetail.razor b/src/EntKube.Web/Components/Pages/Tenants/ClusterDetail.razor index 7af0781..3aa0c62 100644 --- a/src/EntKube.Web/Components/Pages/Tenants/ClusterDetail.razor +++ b/src/EntKube.Web/Components/Pages/Tenants/ClusterDetail.razor @@ -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 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; } diff --git a/src/EntKube.Web/Components/Pages/Tenants/CustomerTab.razor b/src/EntKube.Web/Components/Pages/Tenants/CustomerTab.razor index 37d5fae..2fb9234 100644 --- a/src/EntKube.Web/Components/Pages/Tenants/CustomerTab.razor +++ b/src/EntKube.Web/Components/Pages/Tenants/CustomerTab.razor @@ -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)
- @if (customerMetricsLoading) - { -
- } - else if (customerMetricsError is not null) - { -
- @customerMetricsError -
- } - else if (customerMetrics is not null) + + @if (!customerMetricsLoading && customerMetrics is not null) {
@@ -101,6 +93,7 @@ else if (selectedCustomer is not null) Queried @customerMetrics.QueriedAt.ToLocalTime().ToString("HH:mm:ss")
} +
} @@ -125,20 +118,14 @@ else if (selectedCustomer is not null)
@* --- App cards --- *@ - @if (apps is null) + + @if (apps is not null && apps.Count == 0) { -
-
-
+ } - else if (apps.Count == 0) - { -
- -

No apps yet. Create one above.

-
- } - else + else if (apps is not null) {
@foreach (Data.App app in apps) @@ -173,6 +160,204 @@ else if (selectedCustomer is not null) }
} +
+ + @* --- Git Repo URL Policies --- *@ +
+
+
+ + Git Repo URL Policies +
+ Allowed git repository URL patterns for this customer. Supports * (within a path segment) and ** (across segments). +
+
+
+ + +
+ @if (!string.IsNullOrEmpty(repoPolicyError)) + { +
@repoPolicyError
+ } + + @if (gitRepoPolicies is not null && gitRepoPolicies.Count == 0) + { + + } + else if (gitRepoPolicies is not null) + { + @foreach (CustomerGitRepoPolicy policy in gitRepoPolicies) + { +
+ @policy.UrlPattern + @if (confirmDeletePolicyId == policy.Id) + { +
+ + +
+ } + else + { + + } +
+ } + } +
+
+
+ + @* --- Git Credentials --- *@ +
+
+
+ + Git Credentials +
+ Reusable credential sets for this customer's git repositories. +
+
+ @* --- Add credential form --- *@ +
+
+ + +
+
+ + +
+ @if (newCredAuthType == GitAuthType.HttpsPassword) + { +
+ + +
+ } +
+ +
+
+ @if (!string.IsNullOrEmpty(credentialError)) + { +
@credentialError
+ } + @if (!string.IsNullOrEmpty(credentialSuccess)) + { +
@credentialSuccess
+ } + + + @if (gitCredentials is not null && gitCredentials.Count == 0) + { + + } + else if (gitCredentials is not null) + { + @foreach (CustomerGitCredential cred in gitCredentials) + { +
+
+
+ @cred.Name + @cred.AuthType + @if (cred.AuthType == GitAuthType.HttpsPassword && cred.Username is not null) + { + (@cred.Username) + } +
+ @if (confirmDeleteCredentialId == cred.Id) + { +
+ + +
+ } + else + { + + } +
+ + @* --- Secret value form --- *@ + @if (cred.AuthType != GitAuthType.None) + { +
+ @if (cred.AuthType == GitAuthType.HttpsPat) + { +
+ PAT + + +
+ } + else if (cred.AuthType == GitAuthType.HttpsPassword) + { +
+ Password + + +
+ } + else if (cred.AuthType == GitAuthType.SshKey) + { + + + } + @if (credSaveMessages.TryGetValue(cred.Id, out string? msg) && msg is not null) + { +
+ @msg +
+ } +
+ } +
+ } + } +
+
+
@* --- Portal Access Management --- *@
@@ -210,17 +395,13 @@ else if (selectedCustomer is not null) } @* --- Current access list --- *@ - @if (customerAccesses is null) + + @if (customerAccesses is not null && customerAccesses.Count == 0) { -
-
-
+ } - else if (customerAccesses.Count == 0) - { -

No users have portal access to this customer yet.

- } - else + else if (customerAccesses is not null) { @foreach (CustomerAccess access in customerAccesses) { @@ -230,13 +411,24 @@ else if (selectedCustomer is not null) @access.User.Email @access.Role
- + @if (confirmRevokeUserId == access.UserId) + { +
+ + +
+ } + else + { + + } } } +
} @@ -272,21 +464,14 @@ else } - @if (customers is null) + + @if (customers is not null && customers.Count == 0) { -
-
- Loading customers... -
+ } - else if (customers.Count == 0) - { -
- -

No customers yet. Create one above to get started.

-
- } - else + else if (customers is not null) {
@foreach (Customer customer in customers) @@ -326,6 +511,7 @@ else }
} +
} @code { @@ -360,6 +546,27 @@ else private string? accessError; private string? accessSuccess; + // --- Git repo policies --- + private List? gitRepoPolicies; + private string newRepoPolicyPattern = ""; + private string? repoPolicyError; + private Guid? confirmDeletePolicyId; + + // --- Git credentials --- + private List? gitCredentials; + private string newCredName = ""; + private GitAuthType newCredAuthType = GitAuthType.HttpsPat; + private string newCredUsername = ""; + private string? credentialError; + private string? credentialSuccess; + private Dictionary credSecretValues = new(); + private Dictionary credSaveMessages = new(); + private Dictionary 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", diff --git a/src/EntKube.Web/Components/Pages/Tenants/DatabaseTab.razor b/src/EntKube.Web/Components/Pages/Tenants/DatabaseTab.razor index 3e2ca4b..3bdadb4 100644 --- a/src/EntKube.Web/Components/Pages/Tenants/DatabaseTab.razor +++ b/src/EntKube.Web/Components/Pages/Tenants/DatabaseTab.razor @@ -13,14 +13,8 @@ to be installed on at least one cluster. =========================================================================== *@ -@if (loading) -{ -
-
-

Discovering database clusters...

-
-} -else + +@if (!loading) { @* ── Operator availability badges ── *@
@@ -2190,6 +2184,19 @@ else @onclick="() => ShowMongoResize(mc)"> + }
diff --git a/src/EntKube.Web/Components/Pages/Tenants/GroupTab.razor b/src/EntKube.Web/Components/Pages/Tenants/GroupTab.razor index bf9f3a9..f1594a0 100644 --- a/src/EntKube.Web/Components/Pages/Tenants/GroupTab.razor +++ b/src/EntKube.Web/Components/Pages/Tenants/GroupTab.razor @@ -31,21 +31,14 @@ } -@if (groups is null) + +@if (groups is not null && groups.Count == 0) { -
-
- Loading groups... -
+ } -else if (groups.Count == 0) -{ -
- -

No groups yet. Create one above to get started.

-
-} -else +else if (groups is not null) {
@foreach (Group group in groups) @@ -90,6 +83,7 @@ else }
} +
@code { [Parameter] public Guid TenantId { get; set; } diff --git a/src/EntKube.Web/Components/Pages/Tenants/IdentityTab.razor b/src/EntKube.Web/Components/Pages/Tenants/IdentityTab.razor index 42d0f1a..7dc0d4a 100644 --- a/src/EntKube.Web/Components/Pages/Tenants/IdentityTab.razor +++ b/src/EntKube.Web/Components/Pages/Tenants/IdentityTab.razor @@ -5,14 +5,8 @@ @inject ComponentLifecycleService LifecycleService @inject RegisteredPostgresService RegisteredPostgresService -@if (loading) -{ -
-
-

Scanning for Keycloak instances...

-
-} -else if (selectedRealm is not null && selectedKeycloak is not null) + +@if (selectedRealm is not null && selectedKeycloak is not null) { @* ── Level 3: Realm detail ── *@