From 5da40698ad644dc335c04460637ac93a609c4de2a98aaee98d9287b1b6789fd3 Mon Sep 17 00:00:00 2001 From: Nils Blomgren Date: Thu, 11 Jun 2026 15:09:31 +0200 Subject: [PATCH] server management --- Dockerfile | 18 +- src/EntKube.Web/Components/App.razor | 2 + .../Components/Layout/NavMenu.razor | 5 + .../Admin/AdminNotificationSettings.razor | 499 ++ .../Pages/Admin/AdminUserDetail.razor | 115 +- .../Pages/Portal/AppGovernancePanel.razor | 148 +- .../Pages/Portal/CustomerPortal.razor | 807 ++- .../Pages/Portal/PortalAppRoutesPanel.razor | 628 ++ .../Pages/Portal/PortalDeploymentDetail.razor | 392 +- .../Pages/Portal/PortalIdentityDetail.razor | 86 +- .../Pages/Portal/PortalSecretsDetail.razor | 880 +-- .../Components/Pages/Shared/Breadcrumb.razor | 62 + .../Pages/Shared/ConfirmDialog.razor | 27 + .../Pages/Shared/ResourceTreePanel.razor | 319 +- .../Pages/Tenants/AlertRoutingTab.razor | 481 ++ .../Components/Pages/Tenants/AppDetail.razor | 2013 +------ .../Pages/Tenants/AppEnvironmentDetail.razor | 2910 +++++++++ .../Tenants/AppExternalAccessPanel.razor | 826 +++ .../Pages/Tenants/AppResourcesPanel.razor | 49 +- .../Pages/Tenants/ClusterDetail.razor | 9 + .../Pages/Tenants/ClusterNodes.razor | 848 +++ .../Pages/Tenants/CustomerTab.razor | 336 -- .../Pages/Tenants/GovernanceTab.razor | 692 ++- .../Pages/Tenants/IncidentManagement.razor | 460 +- .../Pages/Tenants/NotificationChannels.razor | 61 +- .../Components/Pages/Tenants/OnCallTab.razor | 342 ++ .../Pages/Tenants/ServerEditForm.razor | 91 + .../Pages/Tenants/TenantAlertRulesTab.razor | 80 + .../Pages/Tenants/TenantAlertsTab.razor | 679 +++ .../Pages/Tenants/TenantDetail.razor | 130 +- .../Components/Pages/Tenants/TenantList.razor | 48 +- .../Pages/Tenants/TenantLogsTab.razor | 97 + .../Tenants/TenantMonitoringOverview.razor | 243 +- .../Pages/Tenants/TenantOpsAnalyticsTab.razor | 301 + .../Components/Pages/Tenants/VaultTab.razor | 254 +- src/EntKube.Web/Data/AlertIncident.cs | 4 + src/EntKube.Web/Data/AlertRoutingRule.cs | 27 + src/EntKube.Web/Data/App.cs | 8 +- src/EntKube.Web/Data/AppAllowedCache.cs | 19 + src/EntKube.Web/Data/AppAllowedDatabase.cs | 25 + src/EntKube.Web/Data/AppAllowedStorage.cs | 19 + src/EntKube.Web/Data/AppDeployment.cs | 9 +- src/EntKube.Web/Data/AppEnvironment.cs | 6 + src/EntKube.Web/Data/AppNetworkPolicy.cs | 4 + src/EntKube.Web/Data/AppQuota.cs | 4 + src/EntKube.Web/Data/AppRbacPolicy.cs | 4 + src/EntKube.Web/Data/AppRoute.cs | 86 + src/EntKube.Web/Data/ApplicationDbContext.cs | 293 +- src/EntKube.Web/Data/ClusterServer.cs | 69 + src/EntKube.Web/Data/CustomerGitCredential.cs | 10 + src/EntKube.Web/Data/CustomerGitRepoPolicy.cs | 3 + src/EntKube.Web/Data/KubernetesCluster.cs | 1 + .../20260609100654_AddAppRoutes.Designer.cs | 4589 +++++++++++++++ .../Postgres/20260609100654_AddAppRoutes.cs | 100 + ...11925_GovernancePerEnvironment.Designer.cs | 4628 +++++++++++++++ ...20260609111925_GovernancePerEnvironment.cs | 179 + ...260609115713_GitPerEnvironment.Designer.cs | 4654 +++++++++++++++ .../20260609115713_GitPerEnvironment.cs | 126 + ...903_AddAppEnvironmentNamespace.Designer.cs | 4657 +++++++++++++++ ...260609130903_AddAppEnvironmentNamespace.cs | 28 + ...170945_AddGovernanceAllowlists.Designer.cs | 4861 +++++++++++++++ .../20260609170945_AddGovernanceAllowlists.cs | 196 + ...200000_AddCredentialUrlPattern.Designer.cs | 4661 +++++++++++++++ .../20260609200000_AddCredentialUrlPattern.cs | 29 + ...0260609210000_AddDeploymentRouteApplied.cs | 31 + .../20260610120000_AddVaultSecretVersions.cs | 54 + .../20260611000000_AddOpsTeamFeatures.cs | 117 + ...260611010000_AddAlertIncidentRunbookUrl.cs | 28 + ...260611020000_AddAlertIncidentEscalation.cs | 35 + .../20260611030000_AddDeploymentGitUrl.cs | 28 + ...611040000_AddNotificationProviderConfig.cs | 46 + ...260611060000_AddAlertRoutingSuppression.cs | 95 + ...260611122524_AddClusterServers.Designer.cs | 5238 ++++++++++++++++ .../20260611122524_AddClusterServers.cs | 236 + ...stgresApplicationDbContextModelSnapshot.cs | 809 ++- .../20260609100704_AddAppRoutes.Designer.cs | 4591 +++++++++++++++ .../SqlServer/20260609100704_AddAppRoutes.cs | 100 + ...11941_GovernancePerEnvironment.Designer.cs | 4630 +++++++++++++++ ...20260609111941_GovernancePerEnvironment.cs | 179 + ...260609115724_GitPerEnvironment.Designer.cs | 4656 +++++++++++++++ .../20260609115724_GitPerEnvironment.cs | 126 + ...943_AddAppEnvironmentNamespace.Designer.cs | 4659 +++++++++++++++ ...260609130943_AddAppEnvironmentNamespace.cs | 28 + ...171000_AddGovernanceAllowlists.Designer.cs | 4863 +++++++++++++++ .../20260609171000_AddGovernanceAllowlists.cs | 196 + ...200000_AddCredentialUrlPattern.Designer.cs | 4663 +++++++++++++++ .../20260609200000_AddCredentialUrlPattern.cs | 29 + ...0260609210000_AddDeploymentRouteApplied.cs | 29 + .../20260610120000_AddVaultSecretVersions.cs | 61 + .../20260611000000_AddOpsTeamFeatures.cs | 150 + ...260611010000_AddAlertIncidentRunbookUrl.cs | 31 + ...260611020000_AddAlertIncidentEscalation.cs | 39 + .../20260611030000_AddDeploymentGitUrl.cs | 26 + ...611040000_AddNotificationProviderConfig.cs | 41 + ...260611060000_AddAlertRoutingSuppression.cs | 100 + ...260611122526_AddClusterServers.Designer.cs | 5240 +++++++++++++++++ .../20260611122526_AddClusterServers.cs | 236 + ...ServerApplicationDbContextModelSnapshot.cs | 809 ++- .../20260609100644_AddAppRoutes.Designer.cs | 4580 ++++++++++++++ .../Sqlite/20260609100644_AddAppRoutes.cs | 100 + ...11905_GovernancePerEnvironment.Designer.cs | 4619 +++++++++++++++ ...20260609111905_GovernancePerEnvironment.cs | 179 + ...260609115702_GitPerEnvironment.Designer.cs | 4645 +++++++++++++++ .../20260609115702_GitPerEnvironment.cs | 126 + ...708_AddAppEnvironmentNamespace.Designer.cs | 4648 +++++++++++++++ ...260609125708_AddAppEnvironmentNamespace.cs | 28 + ...171012_AddGovernanceAllowlists.Designer.cs | 4852 +++++++++++++++ .../20260609171012_AddGovernanceAllowlists.cs | 196 + ...200000_AddCredentialUrlPattern.Designer.cs | 4652 +++++++++++++++ .../20260609200000_AddCredentialUrlPattern.cs | 29 + ...0260609210000_AddDeploymentRouteApplied.cs | 29 + .../20260610120000_AddVaultSecretVersions.cs | 61 + .../20260611000000_AddOpsTeamFeatures.cs | 150 + ...260611010000_AddAlertIncidentRunbookUrl.cs | 31 + ...260611020000_AddAlertIncidentEscalation.cs | 39 + .../20260611030000_AddDeploymentGitUrl.cs | 25 + ...611040000_AddNotificationProviderConfig.cs | 41 + ...260611060000_AddAlertRoutingSuppression.cs | 106 + ...260611122519_AddClusterServers.Designer.cs | 5229 ++++++++++++++++ .../20260611122519_AddClusterServers.cs | 216 + .../ApplicationDbContextModelSnapshot.cs | 809 ++- .../Data/NotificationProviderConfig.cs | 13 + src/EntKube.Web/Data/OnCallSchedule.cs | 14 + src/EntKube.Web/Data/OnCallShift.cs | 14 + src/EntKube.Web/Data/VaultSecret.cs | 8 + src/EntKube.Web/Data/VaultSecretVersion.cs | 40 + src/EntKube.Web/Program.cs | 58 + .../Services/AlertEscalationService.cs | 98 + .../Services/AlertRoutingService.cs | 116 + src/EntKube.Web/Services/AlertSyncService.cs | 142 +- .../Services/AppGovernanceService.cs | 453 +- src/EntKube.Web/Services/AppOfAppsService.cs | 31 +- src/EntKube.Web/Services/AppRouteService.cs | 304 + src/EntKube.Web/Services/BackupService.cs | 14 + .../Services/CustomerAccessService.cs | 2 +- .../Services/CustomerGitService.cs | 61 +- src/EntKube.Web/Services/DeploymentService.cs | 26 +- .../Services/DeploymentStatusNotifier.cs | 17 + .../Services/GitOperationsService.cs | 87 +- .../Services/GitRepositoryService.cs | 2 +- src/EntKube.Web/Services/GitSyncService.cs | 123 +- src/EntKube.Web/Services/GitWebhookService.cs | 51 +- src/EntKube.Web/Services/IncidentService.cs | 160 + .../Services/KubernetesOperationsService.cs | 569 +- .../Services/NodeManagementService.cs | 613 ++ .../NotificationProviderConfigService.cs | 123 + .../Services/NotificationService.cs | 194 +- src/EntKube.Web/Services/OnCallService.cs | 113 + src/EntKube.Web/Services/PrometheusService.cs | 3 + .../Services/RemediationService.cs | 177 + src/EntKube.Web/Services/TenantService.cs | 12 + src/EntKube.Web/Services/VaultService.cs | 167 +- 152 files changed, 121747 insertions(+), 3884 deletions(-) create mode 100644 src/EntKube.Web/Components/Pages/Admin/AdminNotificationSettings.razor create mode 100644 src/EntKube.Web/Components/Pages/Portal/PortalAppRoutesPanel.razor create mode 100644 src/EntKube.Web/Components/Pages/Shared/Breadcrumb.razor create mode 100644 src/EntKube.Web/Components/Pages/Shared/ConfirmDialog.razor create mode 100644 src/EntKube.Web/Components/Pages/Tenants/AlertRoutingTab.razor create mode 100644 src/EntKube.Web/Components/Pages/Tenants/AppEnvironmentDetail.razor create mode 100644 src/EntKube.Web/Components/Pages/Tenants/AppExternalAccessPanel.razor create mode 100644 src/EntKube.Web/Components/Pages/Tenants/ClusterNodes.razor create mode 100644 src/EntKube.Web/Components/Pages/Tenants/OnCallTab.razor create mode 100644 src/EntKube.Web/Components/Pages/Tenants/ServerEditForm.razor create mode 100644 src/EntKube.Web/Components/Pages/Tenants/TenantAlertRulesTab.razor create mode 100644 src/EntKube.Web/Components/Pages/Tenants/TenantAlertsTab.razor create mode 100644 src/EntKube.Web/Components/Pages/Tenants/TenantLogsTab.razor create mode 100644 src/EntKube.Web/Components/Pages/Tenants/TenantOpsAnalyticsTab.razor create mode 100644 src/EntKube.Web/Data/AlertRoutingRule.cs create mode 100644 src/EntKube.Web/Data/AppAllowedCache.cs create mode 100644 src/EntKube.Web/Data/AppAllowedDatabase.cs create mode 100644 src/EntKube.Web/Data/AppAllowedStorage.cs create mode 100644 src/EntKube.Web/Data/AppRoute.cs create mode 100644 src/EntKube.Web/Data/ClusterServer.cs create mode 100644 src/EntKube.Web/Data/Migrations/Postgres/20260609100654_AddAppRoutes.Designer.cs create mode 100644 src/EntKube.Web/Data/Migrations/Postgres/20260609100654_AddAppRoutes.cs create mode 100644 src/EntKube.Web/Data/Migrations/Postgres/20260609111925_GovernancePerEnvironment.Designer.cs create mode 100644 src/EntKube.Web/Data/Migrations/Postgres/20260609111925_GovernancePerEnvironment.cs create mode 100644 src/EntKube.Web/Data/Migrations/Postgres/20260609115713_GitPerEnvironment.Designer.cs create mode 100644 src/EntKube.Web/Data/Migrations/Postgres/20260609115713_GitPerEnvironment.cs create mode 100644 src/EntKube.Web/Data/Migrations/Postgres/20260609130903_AddAppEnvironmentNamespace.Designer.cs create mode 100644 src/EntKube.Web/Data/Migrations/Postgres/20260609130903_AddAppEnvironmentNamespace.cs create mode 100644 src/EntKube.Web/Data/Migrations/Postgres/20260609170945_AddGovernanceAllowlists.Designer.cs create mode 100644 src/EntKube.Web/Data/Migrations/Postgres/20260609170945_AddGovernanceAllowlists.cs create mode 100644 src/EntKube.Web/Data/Migrations/Postgres/20260609200000_AddCredentialUrlPattern.Designer.cs create mode 100644 src/EntKube.Web/Data/Migrations/Postgres/20260609200000_AddCredentialUrlPattern.cs create mode 100644 src/EntKube.Web/Data/Migrations/Postgres/20260609210000_AddDeploymentRouteApplied.cs create mode 100644 src/EntKube.Web/Data/Migrations/Postgres/20260610120000_AddVaultSecretVersions.cs create mode 100644 src/EntKube.Web/Data/Migrations/Postgres/20260611000000_AddOpsTeamFeatures.cs create mode 100644 src/EntKube.Web/Data/Migrations/Postgres/20260611010000_AddAlertIncidentRunbookUrl.cs create mode 100644 src/EntKube.Web/Data/Migrations/Postgres/20260611020000_AddAlertIncidentEscalation.cs create mode 100644 src/EntKube.Web/Data/Migrations/Postgres/20260611030000_AddDeploymentGitUrl.cs create mode 100644 src/EntKube.Web/Data/Migrations/Postgres/20260611040000_AddNotificationProviderConfig.cs create mode 100644 src/EntKube.Web/Data/Migrations/Postgres/20260611060000_AddAlertRoutingSuppression.cs create mode 100644 src/EntKube.Web/Data/Migrations/Postgres/20260611122524_AddClusterServers.Designer.cs create mode 100644 src/EntKube.Web/Data/Migrations/Postgres/20260611122524_AddClusterServers.cs create mode 100644 src/EntKube.Web/Data/Migrations/SqlServer/20260609100704_AddAppRoutes.Designer.cs create mode 100644 src/EntKube.Web/Data/Migrations/SqlServer/20260609100704_AddAppRoutes.cs create mode 100644 src/EntKube.Web/Data/Migrations/SqlServer/20260609111941_GovernancePerEnvironment.Designer.cs create mode 100644 src/EntKube.Web/Data/Migrations/SqlServer/20260609111941_GovernancePerEnvironment.cs create mode 100644 src/EntKube.Web/Data/Migrations/SqlServer/20260609115724_GitPerEnvironment.Designer.cs create mode 100644 src/EntKube.Web/Data/Migrations/SqlServer/20260609115724_GitPerEnvironment.cs create mode 100644 src/EntKube.Web/Data/Migrations/SqlServer/20260609130943_AddAppEnvironmentNamespace.Designer.cs create mode 100644 src/EntKube.Web/Data/Migrations/SqlServer/20260609130943_AddAppEnvironmentNamespace.cs create mode 100644 src/EntKube.Web/Data/Migrations/SqlServer/20260609171000_AddGovernanceAllowlists.Designer.cs create mode 100644 src/EntKube.Web/Data/Migrations/SqlServer/20260609171000_AddGovernanceAllowlists.cs create mode 100644 src/EntKube.Web/Data/Migrations/SqlServer/20260609200000_AddCredentialUrlPattern.Designer.cs create mode 100644 src/EntKube.Web/Data/Migrations/SqlServer/20260609200000_AddCredentialUrlPattern.cs create mode 100644 src/EntKube.Web/Data/Migrations/SqlServer/20260609210000_AddDeploymentRouteApplied.cs create mode 100644 src/EntKube.Web/Data/Migrations/SqlServer/20260610120000_AddVaultSecretVersions.cs create mode 100644 src/EntKube.Web/Data/Migrations/SqlServer/20260611000000_AddOpsTeamFeatures.cs create mode 100644 src/EntKube.Web/Data/Migrations/SqlServer/20260611010000_AddAlertIncidentRunbookUrl.cs create mode 100644 src/EntKube.Web/Data/Migrations/SqlServer/20260611020000_AddAlertIncidentEscalation.cs create mode 100644 src/EntKube.Web/Data/Migrations/SqlServer/20260611030000_AddDeploymentGitUrl.cs create mode 100644 src/EntKube.Web/Data/Migrations/SqlServer/20260611040000_AddNotificationProviderConfig.cs create mode 100644 src/EntKube.Web/Data/Migrations/SqlServer/20260611060000_AddAlertRoutingSuppression.cs create mode 100644 src/EntKube.Web/Data/Migrations/SqlServer/20260611122526_AddClusterServers.Designer.cs create mode 100644 src/EntKube.Web/Data/Migrations/SqlServer/20260611122526_AddClusterServers.cs create mode 100644 src/EntKube.Web/Data/Migrations/Sqlite/20260609100644_AddAppRoutes.Designer.cs create mode 100644 src/EntKube.Web/Data/Migrations/Sqlite/20260609100644_AddAppRoutes.cs create mode 100644 src/EntKube.Web/Data/Migrations/Sqlite/20260609111905_GovernancePerEnvironment.Designer.cs create mode 100644 src/EntKube.Web/Data/Migrations/Sqlite/20260609111905_GovernancePerEnvironment.cs create mode 100644 src/EntKube.Web/Data/Migrations/Sqlite/20260609115702_GitPerEnvironment.Designer.cs create mode 100644 src/EntKube.Web/Data/Migrations/Sqlite/20260609115702_GitPerEnvironment.cs create mode 100644 src/EntKube.Web/Data/Migrations/Sqlite/20260609125708_AddAppEnvironmentNamespace.Designer.cs create mode 100644 src/EntKube.Web/Data/Migrations/Sqlite/20260609125708_AddAppEnvironmentNamespace.cs create mode 100644 src/EntKube.Web/Data/Migrations/Sqlite/20260609171012_AddGovernanceAllowlists.Designer.cs create mode 100644 src/EntKube.Web/Data/Migrations/Sqlite/20260609171012_AddGovernanceAllowlists.cs create mode 100644 src/EntKube.Web/Data/Migrations/Sqlite/20260609200000_AddCredentialUrlPattern.Designer.cs create mode 100644 src/EntKube.Web/Data/Migrations/Sqlite/20260609200000_AddCredentialUrlPattern.cs create mode 100644 src/EntKube.Web/Data/Migrations/Sqlite/20260609210000_AddDeploymentRouteApplied.cs create mode 100644 src/EntKube.Web/Data/Migrations/Sqlite/20260610120000_AddVaultSecretVersions.cs create mode 100644 src/EntKube.Web/Data/Migrations/Sqlite/20260611000000_AddOpsTeamFeatures.cs create mode 100644 src/EntKube.Web/Data/Migrations/Sqlite/20260611010000_AddAlertIncidentRunbookUrl.cs create mode 100644 src/EntKube.Web/Data/Migrations/Sqlite/20260611020000_AddAlertIncidentEscalation.cs create mode 100644 src/EntKube.Web/Data/Migrations/Sqlite/20260611030000_AddDeploymentGitUrl.cs create mode 100644 src/EntKube.Web/Data/Migrations/Sqlite/20260611040000_AddNotificationProviderConfig.cs create mode 100644 src/EntKube.Web/Data/Migrations/Sqlite/20260611060000_AddAlertRoutingSuppression.cs create mode 100644 src/EntKube.Web/Data/Migrations/Sqlite/20260611122519_AddClusterServers.Designer.cs create mode 100644 src/EntKube.Web/Data/Migrations/Sqlite/20260611122519_AddClusterServers.cs create mode 100644 src/EntKube.Web/Data/NotificationProviderConfig.cs create mode 100644 src/EntKube.Web/Data/OnCallSchedule.cs create mode 100644 src/EntKube.Web/Data/OnCallShift.cs create mode 100644 src/EntKube.Web/Data/VaultSecretVersion.cs create mode 100644 src/EntKube.Web/Services/AlertEscalationService.cs create mode 100644 src/EntKube.Web/Services/AlertRoutingService.cs create mode 100644 src/EntKube.Web/Services/AppRouteService.cs create mode 100644 src/EntKube.Web/Services/DeploymentStatusNotifier.cs create mode 100644 src/EntKube.Web/Services/NodeManagementService.cs create mode 100644 src/EntKube.Web/Services/NotificationProviderConfigService.cs create mode 100644 src/EntKube.Web/Services/OnCallService.cs create mode 100644 src/EntKube.Web/Services/RemediationService.cs diff --git a/Dockerfile b/Dockerfile index d2dbdca..4446dcb 100644 --- a/Dockerfile +++ b/Dockerfile @@ -13,19 +13,33 @@ RUN dotnet restore src/EntKube.Web/EntKube.Web.csproj COPY src/ src/ RUN dotnet publish src/EntKube.Web/EntKube.Web.csproj \ - --no-restore \ -c Release \ -o /app/publish FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS runtime WORKDIR /app -# libgit2sharp bundles its native lib, but needs libssl/libcurl on Debian +# libgit2sharp needs libssl/libcurl; git is used by GitOperationsService; +# kubectl and helm are invoked by KubernetesOperationsService/ComponentLifecycleService. RUN apt-get update && apt-get install -y --no-install-recommends \ + ca-certificates \ + curl \ libssl3 \ libcurl4 \ + git \ + openssh-client \ && rm -rf /var/lib/apt/lists/* +# kubectl — latest stable, architecture-aware +RUN ARCH=$(uname -m | sed 's/x86_64/amd64/;s/aarch64/arm64/') && \ + KUBECTL_VERSION=$(curl -fsSL https://dl.k8s.io/release/stable.txt) && \ + curl -fsSL "https://dl.k8s.io/release/${KUBECTL_VERSION}/bin/linux/${ARCH}/kubectl" \ + -o /usr/local/bin/kubectl && \ + chmod +x /usr/local/bin/kubectl + +# helm — latest stable via official installer script +RUN curl -fsSL https://raw.githubusercontent.com/helm/helm/main/scripts/get-helm-3 | bash + # Run as non-root RUN groupadd --system appgroup && useradd --system --gid appgroup --no-create-home appuser RUN mkdir -p /app/Data && chown appuser:appgroup /app/Data diff --git a/src/EntKube.Web/Components/App.razor b/src/EntKube.Web/Components/App.razor index 5db165b..74d7fb9 100644 --- a/src/EntKube.Web/Components/App.razor +++ b/src/EntKube.Web/Components/App.razor @@ -18,8 +18,10 @@ + + diff --git a/src/EntKube.Web/Components/Layout/NavMenu.razor b/src/EntKube.Web/Components/Layout/NavMenu.razor index 67d2823..a2e5979 100644 --- a/src/EntKube.Web/Components/Layout/NavMenu.razor +++ b/src/EntKube.Web/Components/Layout/NavMenu.razor @@ -59,6 +59,11 @@ Backup + diff --git a/src/EntKube.Web/Components/Pages/Admin/AdminNotificationSettings.razor b/src/EntKube.Web/Components/Pages/Admin/AdminNotificationSettings.razor new file mode 100644 index 0000000..b2502dc --- /dev/null +++ b/src/EntKube.Web/Components/Pages/Admin/AdminNotificationSettings.razor @@ -0,0 +1,499 @@ +@page "/admin/notifications" +@using Microsoft.AspNetCore.Authorization +@using System.Text.Json +@using System.Security.Claims +@using EntKube.Web.Data +@using EntKube.Web.Services +@rendermode InteractiveServer +@attribute [Authorize(Roles = "Admin")] + +@inject NotificationProviderConfigService ProviderConfigService +@inject AuthenticationStateProvider AuthState + +Notification Settings + +
+
+

Notification Settings

+

Configure global notification providers used by tenant alert channels.

+
+
+ +@if (loading) +{ +
+
+
+} +else +{ +
+ + +
+
+
+ +
SMTP / Email
+ @if (smtpConfig is not null) + { + @(smtpConfig.IsEnabled ? "Enabled" : "Disabled") + } + else + { + Not configured + } +
+
+

Configure an SMTP server so email notification channels can deliver alerts.

+ +
+ + +
+
+
+ + +
+
+ + +
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ + @if (!string.IsNullOrEmpty(smtpError)) + { +
@smtpError
+ } + @if (!string.IsNullOrEmpty(smtpSuccess)) + { +
@smtpSuccess
+ } + +
+ +
+ + +
+ @if (smtpConfig is not null) + { + + } +
+
+
+
+ + +
+
+
+ +
Microsoft Teams (Graph API)
+ @if (teamsConfig is not null) + { + @(teamsConfig.IsEnabled ? "Enabled" : "Disabled") + } + else + { + Not configured + } +
+
+

+ Send alerts directly to Teams channels using the Microsoft Graph API. + Requires an Azure AD app registration with ChannelMessage.Send application permission. +

+
+ + Once configured, add a Teams notification channel to a tenant and supply a Team ID and Channel ID instead of a webhook URL. +
+ +
+ + +
+
+ + +
+
+ + +
+
+ + +
+ + @if (!string.IsNullOrEmpty(teamsError)) + { +
@teamsError
+ } + @if (!string.IsNullOrEmpty(teamsSuccess)) + { +
@teamsSuccess
+ } + +
+ + + @if (teamsConfig is not null) + { + + } +
+
+
+
+ + +
+
+
+
+ +
Slack
+ Ready +
+

+ Slack notifications use Incoming Webhooks — no global configuration needed. + Each tenant notification channel stores its own webhook URL. + Create webhooks at api.slack.com/apps. +

+
+
+
+ + +
+
+
+
+ +
Generic Webhook
+ Ready +
+

+ Webhook channels post a JSON payload to any URL and optionally authenticate with a Bearer token. + No global configuration required — configure per-channel in the tenant settings. +

+
+
+
+ +
+} + +@code { + private bool loading = true; + private string? currentUserId; + + // SMTP state + private NotificationProviderConfig? smtpConfig; + private string smtpHost = ""; + private int smtpPort = 587; + private string smtpFrom = ""; + private string smtpUsername = ""; + private string smtpPassword = ""; + private bool smtpEnableSsl = true; + private bool smtpEnabled = true; + private string smtpTestRecipient = ""; + private bool smtpBusy; + private string? smtpError; + private string? smtpSuccess; + + // Teams state + private NotificationProviderConfig? teamsConfig; + private string teamsTenantId = ""; + private string teamsClientId = ""; + private string teamsClientSecret = ""; + private bool teamsEnabled = true; + private bool teamsBusy; + private string? teamsError; + private string? teamsSuccess; + + protected override async Task OnInitializedAsync() + { + AuthenticationState authState = await AuthState.GetAuthenticationStateAsync(); + currentUserId = authState.User.FindFirst(ClaimTypes.NameIdentifier)?.Value; + + await LoadAsync(); + loading = false; + } + + private async Task LoadAsync() + { + smtpConfig = await ProviderConfigService.GetAsync(NotificationProviderType.Smtp); + teamsConfig = await ProviderConfigService.GetAsync(NotificationProviderType.MsTeamsGraph); + + if (smtpConfig is not null) + { + using JsonDocument doc = JsonDocument.Parse(smtpConfig.ConfigurationJson); + JsonElement root = doc.RootElement; + smtpHost = root.TryGetProperty("host", out var h) ? h.GetString() ?? "" : ""; + smtpPort = root.TryGetProperty("port", out var p) ? p.GetInt32() : 587; + smtpFrom = root.TryGetProperty("from", out var f) ? f.GetString() ?? "" : ""; + smtpUsername = root.TryGetProperty("username", out var u) ? u.GetString() ?? "" : ""; + smtpEnableSsl = !root.TryGetProperty("enableSsl", out var ssl) || ssl.GetBoolean(); + smtpEnabled = smtpConfig.IsEnabled; + smtpPassword = ""; + } + + if (teamsConfig is not null) + { + using JsonDocument doc = JsonDocument.Parse(teamsConfig.ConfigurationJson); + JsonElement root = doc.RootElement; + teamsTenantId = root.TryGetProperty("tenantId", out var t) ? t.GetString() ?? "" : ""; + teamsClientId = root.TryGetProperty("clientId", out var c) ? c.GetString() ?? "" : ""; + teamsEnabled = teamsConfig.IsEnabled; + teamsClientSecret = ""; + } + } + + private async Task SaveSmtp() + { + smtpError = null; + smtpSuccess = null; + if (string.IsNullOrWhiteSpace(smtpHost)) + { + smtpError = "Host is required."; + return; + } + + smtpBusy = true; + try + { + string password = smtpPassword; + if (string.IsNullOrEmpty(password) && smtpConfig is not null) + { + using JsonDocument existing = JsonDocument.Parse(smtpConfig.ConfigurationJson); + password = existing.RootElement.TryGetProperty("password", out var pw) ? pw.GetString() ?? "" : ""; + } + + string configJson = JsonSerializer.Serialize(new + { + host = smtpHost.Trim(), + port = smtpPort, + from = smtpFrom.Trim(), + username = smtpUsername.Trim(), + password, + enableSsl = smtpEnableSsl + }); + + await ProviderConfigService.SaveAsync(NotificationProviderType.Smtp, configJson, smtpEnabled, currentUserId); + smtpSuccess = "SMTP configuration saved."; + await LoadAsync(); + } + catch (Exception ex) + { + smtpError = ex.Message; + } + finally + { + smtpBusy = false; + } + } + + private async Task TestSmtp() + { + smtpError = null; + smtpSuccess = null; + if (string.IsNullOrWhiteSpace(smtpTestRecipient)) + { + smtpError = "Enter a test recipient email address."; + return; + } + + smtpBusy = true; + try + { + string password = smtpPassword; + if (string.IsNullOrEmpty(password) && smtpConfig is not null) + { + using JsonDocument existing = JsonDocument.Parse(smtpConfig.ConfigurationJson); + password = existing.RootElement.TryGetProperty("password", out var pw) ? pw.GetString() ?? "" : ""; + } + + string configJson = JsonSerializer.Serialize(new + { + host = smtpHost.Trim(), + port = smtpPort, + from = smtpFrom.Trim(), + username = smtpUsername.Trim(), + password, + enableSsl = smtpEnableSsl + }); + + await ProviderConfigService.TestSmtpAsync(configJson, smtpTestRecipient.Trim()); + smtpSuccess = $"Test email sent to {smtpTestRecipient}."; + } + catch (Exception ex) + { + smtpError = $"Test failed: {ex.Message}"; + } + finally + { + smtpBusy = false; + } + } + + private async Task DeleteSmtp() + { + smtpBusy = true; + smtpError = null; + smtpSuccess = null; + try + { + await ProviderConfigService.DeleteAsync(NotificationProviderType.Smtp); + smtpHost = ""; smtpPort = 587; smtpFrom = ""; smtpUsername = ""; smtpPassword = ""; smtpEnableSsl = true; smtpEnabled = true; + await LoadAsync(); + smtpSuccess = "SMTP configuration removed."; + } + finally + { + smtpBusy = false; + } + } + + private async Task SaveTeams() + { + teamsError = null; + teamsSuccess = null; + if (string.IsNullOrWhiteSpace(teamsTenantId) || string.IsNullOrWhiteSpace(teamsClientId)) + { + teamsError = "Tenant ID and Client ID are required."; + return; + } + + teamsBusy = true; + try + { + string secret = teamsClientSecret; + if (string.IsNullOrEmpty(secret) && teamsConfig is not null) + { + using JsonDocument existing = JsonDocument.Parse(teamsConfig.ConfigurationJson); + secret = existing.RootElement.TryGetProperty("clientSecret", out var s) ? s.GetString() ?? "" : ""; + } + + if (string.IsNullOrEmpty(secret)) + { + teamsError = "Client Secret is required."; + return; + } + + string configJson = JsonSerializer.Serialize(new + { + tenantId = teamsTenantId.Trim(), + clientId = teamsClientId.Trim(), + clientSecret = secret + }); + + await ProviderConfigService.SaveAsync(NotificationProviderType.MsTeamsGraph, configJson, teamsEnabled, currentUserId); + teamsSuccess = "Teams Graph configuration saved."; + await LoadAsync(); + } + catch (Exception ex) + { + teamsError = ex.Message; + } + finally + { + teamsBusy = false; + } + } + + private async Task TestTeams() + { + teamsError = null; + teamsSuccess = null; + if (string.IsNullOrWhiteSpace(teamsTenantId) || string.IsNullOrWhiteSpace(teamsClientId)) + { + teamsError = "Tenant ID and Client ID are required."; + return; + } + + teamsBusy = true; + try + { + string secret = teamsClientSecret; + if (string.IsNullOrEmpty(secret) && teamsConfig is not null) + { + using JsonDocument existing = JsonDocument.Parse(teamsConfig.ConfigurationJson); + secret = existing.RootElement.TryGetProperty("clientSecret", out var s) ? s.GetString() ?? "" : ""; + } + + if (string.IsNullOrEmpty(secret)) + { + teamsError = "Client Secret is required."; + return; + } + + string configJson = JsonSerializer.Serialize(new + { + tenantId = teamsTenantId.Trim(), + clientId = teamsClientId.Trim(), + clientSecret = secret + }); + + await ProviderConfigService.TestMsTeamsGraphAsync(configJson); + teamsSuccess = "Successfully authenticated with Azure AD. Credentials are valid."; + } + catch (Exception ex) + { + teamsError = $"Test failed: {ex.Message}"; + } + finally + { + teamsBusy = false; + } + } + + private async Task DeleteTeams() + { + teamsBusy = true; + teamsError = null; + teamsSuccess = null; + try + { + await ProviderConfigService.DeleteAsync(NotificationProviderType.MsTeamsGraph); + teamsTenantId = ""; teamsClientId = ""; teamsClientSecret = ""; teamsEnabled = true; + await LoadAsync(); + teamsSuccess = "Teams Graph configuration removed."; + } + finally + { + teamsBusy = false; + } + } +} diff --git a/src/EntKube.Web/Components/Pages/Admin/AdminUserDetail.razor b/src/EntKube.Web/Components/Pages/Admin/AdminUserDetail.razor index 5d27dd3..55620ed 100644 --- a/src/EntKube.Web/Components/Pages/Admin/AdminUserDetail.razor +++ b/src/EntKube.Web/Components/Pages/Admin/AdminUserDetail.razor @@ -40,6 +40,12 @@ else if (user is not null) } + @if (!string.IsNullOrEmpty(successMessage)) + { + + }
@@ -102,20 +108,25 @@ else if (user is not null) @m.JoinedAt.ToString("MMM d, yyyy") - @if (confirmRemoveTenantId == m.TenantId) - { - Remove? - - - } - else - { - - } + + @if (confirmRemoveTenantId == m.TenantId) + { + + + + + + } } @@ -196,20 +207,25 @@ else if (user is not null) @gm.Group.Tenant.Name @gm.JoinedAt.ToString("MMM d, yyyy") - @if (confirmRemoveGroupId == gm.GroupId) - { - Remove? - - - } - else - { - - } + + @if (confirmRemoveGroupId == gm.GroupId) + { + + + + + + } } @@ -260,20 +276,16 @@ else if (user is not null)
Danger Zone

Permanently delete this user account. All tenant and group memberships are removed. This cannot be undone.

- @if (confirmDelete) - { -
- Are you sure? This will permanently delete @user.Email. -
- - - } - else - { - - } + +
@@ -330,6 +342,9 @@ else if (user is not null) private Guid? confirmRemoveTenantId; private Guid? confirmRemoveGroupId; private bool confirmDelete; + private bool removingMembership; + private bool deletingUser; + private string? successMessage; protected override async Task OnInitializedAsync() { @@ -411,14 +426,27 @@ else if (user is not null) } } + private async Task ShowSuccessAsync(string message) + { + successMessage = message; + StateHasChanged(); + await Task.Delay(3500); + successMessage = null; + StateHasChanged(); + } + private async Task RemoveTenantMembership(Guid tenantId) { - confirmRemoveTenantId = null; + removingMembership = true; errorMessage = null; + string? tenantName = tenantMemberships.FirstOrDefault(m => m.TenantId == tenantId)?.Tenant.Name; await UserManagement.RemoveTenantMembershipAsync(UserId, tenantId); tenantMemberships = await UserManagement.GetUserTenantMembershipsAsync(UserId); RefreshAvailableTenants(); + removingMembership = false; + confirmRemoveTenantId = null; + if (tenantName is not null) _ = ShowSuccessAsync($"Removed from tenant '{tenantName}'."); } private async Task AddGroupMembership() @@ -440,17 +468,24 @@ else if (user is not null) private async Task RemoveGroupMembership(Guid groupId) { - confirmRemoveGroupId = null; + removingMembership = true; errorMessage = null; + string? groupName = groupMemberships.FirstOrDefault(gm => gm.GroupId == groupId)?.Group.Name; await UserManagement.RemoveGroupMembershipAsync(UserId, groupId); groupMemberships = await UserManagement.GetUserGroupMembershipsAsync(UserId); await LoadGroupsForSelectedTenant(); + removingMembership = false; + confirmRemoveGroupId = null; + if (groupName is not null) _ = ShowSuccessAsync($"Removed from group '{groupName}'."); } private async Task DeleteUser() { + deletingUser = true; IdentityResult result = await UserManagement.DeleteUserAsync(UserId); + deletingUser = false; + if (!result.Succeeded) { errorMessage = string.Join(" ", result.Errors.Select(e => e.Description)); diff --git a/src/EntKube.Web/Components/Pages/Portal/AppGovernancePanel.razor b/src/EntKube.Web/Components/Pages/Portal/AppGovernancePanel.razor index fbf8e89..8adad7b 100644 --- a/src/EntKube.Web/Components/Pages/Portal/AppGovernancePanel.razor +++ b/src/EntKube.Web/Components/Pages/Portal/AppGovernancePanel.razor @@ -1,6 +1,7 @@ @using EntKube.Web.Data @using EntKube.Web.Services @inject AppGovernanceService GovernanceService +@inject CustomerGitService CustomerGitService @* ═══════════════════════════════════════════════════════════════════ AppGovernancePanel — read-only portal view of an app's governance @@ -23,17 +24,21 @@ else @* Namespace *@ -
- - @if (!string.IsNullOrWhiteSpace(data?.Namespace)) - { - - } - else - { - - } -
+ @if (!string.IsNullOrWhiteSpace(data?.Namespace)) + { +
+
+ + Namespace +
+
+
+ + Your app runs in namespace @data.Namespace. +
+
+
+ } @* Quota *@
@@ -166,19 +171,122 @@ else }
+ + @* Git URL Policies *@ +
+
+ + Git URL Policies +
+
+ @if (gitPolicies.Count == 0) + { +

No git URL policies — all repositories are allowed.

+ } + else + { +
    + @foreach (CustomerGitRepoPolicy p in gitPolicies) + { +
  • + + @p.UrlPattern +
  • + } +
+ } +
+
+ + @* Allowed Databases *@ + @if (data?.AllowedDatabases is { Count: > 0 } allowedDbs) + { +
+
+ + Allowed Databases +
+
+
    + @foreach (AppAllowedDatabase db in allowedDbs) + { +
  • + + @AllowedDbTypeLabel(db) + @AllowedDbName(db) +
  • + } +
+
+
+ } + + @* Allowed Cache Services *@ + @if (data?.AllowedCaches is { Count: > 0 } allowedCaches) + { +
+
+ + Allowed Cache Services +
+
+
    + @foreach (AppAllowedCache cache in allowedCaches) + { +
  • + + @cache.RedisCluster.Name +
  • + } +
+
+
+ } + + @* Allowed S3 Storage *@ + @if (data?.AllowedStorages is { Count: > 0 } allowedStorages) + { +
+
+ + Allowed S3 Storage +
+
+
    + @foreach (AppAllowedStorage storage in allowedStorages) + { +
  • + + @storage.StorageLink.Provider + @storage.StorageLink.Name + @if (!string.IsNullOrWhiteSpace(storage.StorageLink.BucketName)) + { + @storage.StorageLink.BucketName + } +
  • + } +
+
+
+ } } @code { [Parameter, EditorRequired] public Guid AppId { get; set; } [Parameter, EditorRequired] public string AppName { get; set; } = ""; + [Parameter, EditorRequired] public Guid EnvironmentId { get; set; } + [Parameter] public Guid CustomerId { get; set; } private AppGovernanceData? data; + private List gitPolicies = []; private bool loading = true; protected override async Task OnParametersSetAsync() { loading = true; - data = await GovernanceService.LoadAsync(AppId); + data = await GovernanceService.LoadAsync(AppId, EnvironmentId); + if (CustomerId != Guid.Empty) + gitPolicies = await CustomerGitService.GetRepoPoliciesAsync(CustomerId, EnvironmentId); loading = false; } @@ -198,4 +306,20 @@ else AppNetworkPolicyType.AllowFromIngress or AppNetworkPolicyType.AllowFromNamespace or AppNetworkPolicyType.AllowFromSameNamespace => "bi-check-circle", _ => "bi-code-slash" }; + + private static string AllowedDbTypeLabel(AppAllowedDatabase entry) + { + if (entry.CnpgDatabase is not null) return "PostgreSQL"; + if (entry.MongoDatabase is not null) return "MongoDB"; + if (entry.RegisteredPostgresDatabase is not null) return "Postgres"; + return "Database"; + } + + private static string AllowedDbName(AppAllowedDatabase entry) + { + if (entry.CnpgDatabase is not null) return entry.CnpgDatabase.Name; + if (entry.MongoDatabase is not null) return entry.MongoDatabase.Name; + if (entry.RegisteredPostgresDatabase is not null) return entry.RegisteredPostgresDatabase.Name; + return "—"; + } } diff --git a/src/EntKube.Web/Components/Pages/Portal/CustomerPortal.razor b/src/EntKube.Web/Components/Pages/Portal/CustomerPortal.razor index 9cc0884..1a64fb3 100644 --- a/src/EntKube.Web/Components/Pages/Portal/CustomerPortal.razor +++ b/src/EntKube.Web/Components/Pages/Portal/CustomerPortal.razor @@ -16,6 +16,7 @@ @inject AuditService AuditService @inject IncidentService IncidentService @inject CustomerGitService CustomerGitService +@inject AppRouteService AppRouteService @inject AuthenticationStateProvider AuthStateProvider @inject NavigationManager Navigation @@ -45,83 +46,7 @@ else if (customers is null || customers.Count == 0) else { @* ── Level 0: Breadcrumb navigation ── *@ - + @* ════════════════════════════════════════════════════════════════ Level 1: Customer list @@ -156,45 +81,24 @@ else } @* ════════════════════════════════════════════════════════════════ - Level 3: Git URL policies (read-only) + Level 2 (loading): Apps data loading for selected customer ════════════════════════════════════════════════════════════════ *@ - else if (showGitPolicies) + else if (loadingCustomer) { -
-
- - 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 -
  • - } -
- } -
+
+ +

@selectedCustomer!.Name

+
+
+
+

Loading apps...

} @* ════════════════════════════════════════════════════════════════ Level 2: Apps and deployments for a selected customer ════════════════════════════════════════════════════════════════ *@ - else if (selectedDeployment is null && selectedIdentityRealm is null && selectedSecretsApp is null && selectedRegistryProject is null && !showGitPolicies) + else if (selectedDeployment is null && selectedIdentityRealm is null && selectedSecrets is null && selectedRegistryProject is null && selectedGovernanceApp is null && selectedResourcesApp is null && selectedRoutesApp is null && selectedPortalEnvironment is null) {
@@ -207,12 +111,6 @@ else @onclick="() => showMonitoringPanel = !showMonitoringPanel"> Monitoring - @if (gitPolicies.Count > 0) - { - - } Status Overview @@ -266,7 +164,7 @@ else { bool hasIdentity = appRealms.ContainsKey(app.Id); bool hasRegistry = appHarborProjects.ContainsKey(app.Id); - bool showingCreateForm = createDeployAppId == app.Id; +
@@ -275,24 +173,39 @@ else @app.Name
- @if (currentAccessRole >= CustomerAccessRole.Admin) - { - - } -
-
- @if (showingCreateForm) - { -
-
-
Create Deployment
- @if (createDeployError is not null) - { -
@createDeployError
- } -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - @if (!string.IsNullOrWhiteSpace(app.Namespace)) - { - -
Locked by governance policy.
- } - else - { - - } -
- @if (newDeployType == DeploymentType.HelmChart) - { -
- - -
-
- - -
-
- - -
- } - @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
- } -
-
- - -
-
- - -
- } -
-
- - -
-
+ @if (editingAppId == app.Id) + { +
+
+ + +
- } - - @if (appDeployments.TryGetValue(app.Id, out List? deploys) && deploys.Count > 0) +
+ } + @if (deletingAppId == app.Id) + { +
+ @if (deleteAppError is not null) + { +
+ @deleteAppError +
+ } + +
+ } +
+ @* ── Environment cards ── *@ + @if (appLinkedEnvironments.TryGetValue(app.Id, out List? linkedEnvs) && linkedEnvs.Count > 0) { -
- @foreach (AppDeployment deploy in deploys) +
+ @foreach (Data.Environment env in linkedEnvs) { + int deployCount = appDeployments.TryGetValue(app.Id, out List? deploys2) + ? deploys2.Count(d => d.EnvironmentId == env.Id) + : 0;
-
+
- @deploy.Name + @env.Name -
- @SyncBadge(deploy.SyncStatus) - @HealthBadge(deploy.HealthStatus) -
+
-
- @TypeBadge(deploy.Type) - @deploy.Environment?.Name - @deploy.Cluster?.Name - @deploy.Namespace +
+ @if (deployCount > 0) + { + @deployCount deployment@(deployCount == 1 ? "" : "s") + } + else + { + No deployments + }
@@ -485,10 +314,64 @@ else }
} - else if (!showingCreateForm) + else { - + + } + + @* ── External URLs ── *@ + @if (appRoutes.TryGetValue(app.Id, out List? routes) && routes.Count > 0) + { +
+
+ External URLs +
+
+ @foreach (AppRoute route in routes.Where(r => r.IsEnabled)) + { + string href = $"https://{route.Hostname}"; + bool allUp = route.DeploymentRoutes.Any() && route.DeploymentRoutes.All(dr => dr.IsReachable == true); + bool anyDown = route.DeploymentRoutes.Any(dr => dr.IsReachable == false); + string statusClass = allUp ? "text-success" : anyDown ? "text-danger" : "text-muted"; + string statusIcon = allUp ? "bi-check-circle-fill" : anyDown ? "bi-x-circle-fill" : "bi-circle"; + string tlsTitle = route.TlsMode == TlsMode.ClusterIssuer + ? $"TLS: auto ({route.ClusterIssuerName})" + : "TLS: manual certificate"; + +
+ + + @route.Hostname + + + @if (route.DeploymentRoutes.Count(dr => dr.IsEnabled) > 1) + { + + @route.DeploymentRoutes.Count(dr => dr.IsEnabled) + + } +
+ } +
+ @if (routes.Any(r => r.IsEnabled && r.DeploymentRoutes.Any(dr => dr.IsEnabled && dr.PathPrefix != "/"))) + { +
+ @foreach (AppRoute route in routes.Where(r => r.IsEnabled)) + { + @foreach (AppDeploymentRoute dr in route.DeploymentRoutes.Where(dr => dr.IsEnabled && dr.PathPrefix != "/").OrderBy(dr => dr.PathPrefix)) + { + + @dr.PathPrefix + (@(dr.AppDeployment?.Environment?.Name ?? "unknown")) + + } + } +
+ } +
}
@@ -496,6 +379,19 @@ else } } + @* ════════════════════════════════════════════════════════════════ + Level 3: Per-environment view (deployments, resources, external access) + ════════════════════════════════════════════════════════════════ *@ + else if (selectedPortalEnvironment is not null && selectedPortalApp is not null) + { + + } + @* ════════════════════════════════════════════════════════════════ Level 3: Deployment detail with operations ════════════════════════════════════════════════════════════════ *@ @@ -510,6 +406,7 @@ else AuditService="AuditService" CustomerId="selectedCustomer!.Id" TenantId="selectedCustomer!.TenantId" + EnvironmentId="selectedDeployment.EnvironmentId" GovernanceNamespace="@(selectedCustomer?.Apps.FirstOrDefault(a => a.Id == selectedDeployment.AppId)?.Namespace)" OnBack="BackToApps" OnDeleted="OnDeploymentDeleted" /> @@ -528,12 +425,14 @@ else @* ════════════════════════════════════════════════════════════════ Level 3: App secrets vault ════════════════════════════════════════════════════════════════ *@ - else if (selectedSecretsApp is not null) + else if (selectedSecrets is not null) { + AccessRole="currentAccessRole" + InitialScope="@selectedSecrets.Scope" /> } @* ════════════════════════════════════════════════════════════════ @@ -546,29 +445,110 @@ else TenantId="selectedCustomer!.TenantId" AccessRole="currentAccessRole" /> } - else if (selectedGovernanceApp is not null) - { - - } + @* Kept for reference — governance is now accessed via environment cards *@ else if (selectedResourcesApp is not null) { } + else if (selectedRoutesApp is not null) + { + + } } + + @code { private bool loading = true; + private bool loadingCustomer; private string? currentUserId; private CustomerAccessRole currentAccessRole; + private bool InDetailView => + selectedDeployment is not null || selectedIdentityRealm is not null || + selectedSecrets is not null || selectedRegistryProject is not null || + selectedGovernanceApp is not null || selectedResourcesApp is not null || + selectedRoutesApp is not null || selectedPortalEnvironment is not null; + + private IReadOnlyList PortalBreadcrumbs + { + get + { + var items = new List(); + + if (InDetailView) + items.Add(new("Portal", "bi-grid", OnClick: EventCallback.Factory.Create(this, BackToApps))); + else if (selectedCustomer is not null) + items.Add(new("Portal", "bi-grid", OnClick: EventCallback.Factory.Create(this, BackToCustomers))); + else + items.Add(new("Portal", "bi-grid", IsActive: true)); + + if (selectedCustomer is not null) + { + if (InDetailView) + items.Add(new(selectedCustomer.Name, OnClick: EventCallback.Factory.Create(this, BackToApps))); + else + items.Add(new(selectedCustomer.Name, IsActive: true)); + } + + if (selectedDeployment is not null) + items.Add(new(selectedDeployment.Name, IsActive: true)); + else if (selectedIdentityRealm is not null) + items.Add(new(selectedIdentityRealm.DisplayName, "bi-shield-lock", IsActive: true)); + else if (selectedSecrets is not null) + items.Add(new($"{selectedSecrets.App.Name} — {SecretsScopeLabel}", SecretsScopeIcon, IsActive: true)); + else if (selectedRegistryProject is not null) + items.Add(new($"{selectedRegistryProject.ProjectName} — Registry", "bi-archive", IsActive: true)); + else if (selectedGovernanceApp is not null) + items.Add(new($"{selectedGovernanceApp.Name} — Policy", "bi-shield-check", IsActive: true)); + else if (selectedResourcesApp is not null) + items.Add(new($"{selectedResourcesApp.Name} — Resources", "bi-diagram-3", IsActive: true)); + else if (selectedRoutesApp is not null) + items.Add(new($"{selectedRoutesApp.Name} — URLs", "bi-globe", IsActive: true)); + else if (selectedPortalEnvironment is not null && selectedPortalApp is not null) + items.Add(new($"{selectedPortalApp.Name} › {selectedPortalEnvironment.Name}", "bi-app-indicator", IsActive: true)); + + return items; + } + } + private List? customers; private Customer? selectedCustomer; private AppDeployment? selectedDeployment; private KeycloakRealm? selectedIdentityRealm; - private Data.App? selectedSecretsApp; + + private sealed record SecretsNav(Data.App App, string Scope); + private SecretsNav? selectedSecrets; + + private string SecretsScopeIcon => selectedSecrets?.Scope switch + { + "database" => "bi-database", + "messaging" => "bi-send", + "cache" => "bi-lightning", + "docker" => "bi-box-seam", + _ => "bi-key" + }; + + private string SecretsScopeLabel => selectedSecrets?.Scope switch + { + "database" => "Databases", + "messaging" => "Messaging", + "cache" => "Cache", + "docker" => "Registries", + _ => "App Secrets" + }; private HarborProject? selectedRegistryProject; private Data.App? selectedGovernanceApp; private Data.App? selectedResourcesApp; + private Data.App? selectedRoutesApp; + + // Per-environment navigation in portal (environment-first view) + private Data.App? selectedPortalApp; + private Data.Environment? selectedPortalEnvironment; + private Dictionary> appLinkedEnvironments = new(); // Deployments indexed by app ID for the selected customer. private Dictionary> appDeployments = new(); @@ -579,6 +559,9 @@ else // Linked Harbor projects indexed by app ID for the selected customer. private Dictionary appHarborProjects = new(); + // External routes indexed by app ID for the selected customer. + private Dictionary> appRoutes = new(); + // Environments and clusters for deployment creation (loaded when Admin selects a customer). private List tenantEnvironments = []; private List tenantClusters = []; @@ -592,21 +575,15 @@ 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; + // App edit/delete state + private Guid? editingAppId; + private string editAppName = ""; + private bool savingAppName; + private Guid? deletingAppId; + private bool deletingApp; + private string? deleteAppError; + protected override async Task OnInitializedAsync() { @@ -628,22 +605,22 @@ else private async Task SelectCustomer(Customer customer) { selectedCustomer = customer; + loadingCustomer = true; showMonitoringPanel = false; - showGitPolicies = false; + StateHasChanged(); // Look up the user's role for this customer. CustomerAccess? access = await CustomerAccessService.GetAccessAsync(currentUserId!, customer.Id); currentAccessRole = access?.Role ?? CustomerAccessRole.Viewer; - // Load deployments, linked Keycloak realms, and linked Harbor projects for all apps. + // Load deployments, linked Keycloak realms, Harbor projects, and external routes for all apps. appDeployments.Clear(); appRealms.Clear(); 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); + appRoutes.Clear(); + appLinkedEnvironments.Clear(); + selectedPortalApp = null; + selectedPortalEnvironment = null; foreach (Data.App app in customer.Apps) { @@ -657,14 +634,42 @@ else HarborProject? harborProject = await HarborService.GetProjectForAppAsync(customer.TenantId, app.Id); if (harborProject is not null) appHarborProjects[app.Id] = harborProject; + + try + { + List routes = await AppRouteService.GetRoutesForAppAsync(app.Id); + if (routes.Count > 0) + appRoutes[app.Id] = routes; + } + catch + { + // Routes table may not exist if a migration is pending; skip silently. + } + } + + // Always load environments — needed to show environment cards for all roles. + tenantEnvironments = await TenantService.GetEnvironmentsAsync(customer.TenantId); + + // Compute per-app linked environments from AppEnvironments navigation property. + foreach (Data.App app in customer.Apps) + { + appLinkedEnvironments[app.Id] = tenantEnvironments + .Where(e => app.AppEnvironments.Any(ae => ae.EnvironmentId == e.Id)) + .ToList(); } - // Load environments and clusters so Admin users can create deployments. if (currentAccessRole >= CustomerAccessRole.Admin) { - tenantEnvironments = await TenantService.GetEnvironmentsAsync(customer.TenantId); tenantClusters = await TenantService.GetClustersAsync(customer.TenantId); } + + loadingCustomer = false; + } + + private void SelectPortalEnvironment(Data.App app, Data.Environment env) + { + selectedPortalApp = app; + selectedPortalEnvironment = env; } private void SelectDeployment(AppDeployment deploy) @@ -689,16 +694,84 @@ else selectedCustomer = null; selectedDeployment = null; selectedIdentityRealm = null; - selectedSecretsApp = null; + selectedSecrets = null; selectedRegistryProject = null; - gitPolicies.Clear(); - customerGitCredentials.Clear(); - showGitPolicies = false; + + } - private void SelectSecretsApp(Data.App app) + private void OpenAppSecrets(Data.App app) => selectedSecrets = new(app, "app"); + private void OpenDatabases(Data.App app) => selectedSecrets = new(app, "database"); + private void OpenMessaging(Data.App app) => selectedSecrets = new(app, "messaging"); + private void OpenCache(Data.App app) => selectedSecrets = new(app, "cache"); + private void OpenDockerRegistries(Data.App app) => selectedSecrets = new(app, "docker"); + + // ──────── App edit ──────── + + private void StartEditApp(Data.App app) { - selectedSecretsApp = app; + editingAppId = app.Id; + editAppName = app.Name; + deletingAppId = null; + } + + private void CancelEditApp() + { + editingAppId = null; + editAppName = ""; + } + + private async Task OnEditAppNameKeyUp(KeyboardEventArgs e) + { + if (e.Key == "Enter") await SaveAppName(); + } + + private async Task SaveAppName() + { + if (editingAppId is null || string.IsNullOrWhiteSpace(editAppName)) return; + + savingAppName = true; + await TenantService.RenameAppAsync(editingAppId.Value, editAppName); + + Data.App? appInList = selectedCustomer!.Apps.FirstOrDefault(a => a.Id == editingAppId.Value); + if (appInList is not null) appInList.Name = editAppName.Trim(); + + editingAppId = null; + editAppName = ""; + savingAppName = false; + } + + // ──────── App delete ──────── + + private void StartDeleteApp(Data.App app) + { + deletingAppId = app.Id; + deleteAppError = null; + editingAppId = null; + } + + private void CancelDeleteApp() + { + deletingAppId = null; + deleteAppError = null; + } + + private async Task ConfirmDeleteApp(Guid appId) + { + deletingApp = true; + deleteAppError = null; + + await K8sOps.DeleteAppFromClusterAsync(appId, currentUserId); + await TenantService.DeleteAppAsync(appId); + + Data.App? appToRemove = selectedCustomer!.Apps.FirstOrDefault(a => a.Id == appId); + if (appToRemove is not null) selectedCustomer.Apps.Remove(appToRemove); + appDeployments.Remove(appId); + appRealms.Remove(appId); + appHarborProjects.Remove(appId); + + deletingApp = false; + deletingAppId = null; } private void SelectGovernanceApp(Data.App app) @@ -711,15 +784,23 @@ else selectedResourcesApp = app; } + private void SelectRoutesApp(Data.App app) + { + selectedRoutesApp = app; + } + private void BackToApps() { selectedDeployment = null; selectedIdentityRealm = null; - selectedSecretsApp = null; + selectedSecrets = null; selectedRegistryProject = null; selectedGovernanceApp = null; selectedResourcesApp = null; - showGitPolicies = false; + selectedRoutesApp = null; + selectedPortalApp = null; + selectedPortalEnvironment = null; + } private async Task OnDeploymentDeleted() @@ -733,90 +814,6 @@ else } } - private void ToggleCreateDeployment(Guid appId) - { - if (createDeployAppId == appId) - { - createDeployAppId = null; - } - else - { - createDeployAppId = appId; - 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; - } - } - - private async Task CreateDeployment(Guid appId) - { - creatingDeployment = true; - createDeployError = null; - - 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, - gitRepositoryId: gitRepoId, - gitPath: string.IsNullOrWhiteSpace(newGitPath) ? null : newGitPath, - gitRevision: string.IsNullOrWhiteSpace(newGitRevision) ? null : newGitRevision); - - createDeployAppId = null; - appDeployments[appId] = await DeploymentService.GetDeploymentsAsync(appId); - } - catch (Exception ex) - { - createDeployError = ex.Message; - } - finally - { - creatingDeployment = false; - } - } - - 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; diff --git a/src/EntKube.Web/Components/Pages/Portal/PortalAppRoutesPanel.razor b/src/EntKube.Web/Components/Pages/Portal/PortalAppRoutesPanel.razor new file mode 100644 index 0000000..169063e --- /dev/null +++ b/src/EntKube.Web/Components/Pages/Portal/PortalAppRoutesPanel.razor @@ -0,0 +1,628 @@ +@using EntKube.Web.Data +@using EntKube.Web.Services +@inject AppRouteService AppRouteService +@inject DeploymentService DeploymentService +@inject KubernetesOperationsService K8sOps + +@* ═══════════════════════════════════════════════════════════════════ + Portal URL Management — customers configure which paths under + their ops-assigned hostnames route to which services. + + Ops owns: hostname + TLS (AppRoute) + Customer owns: path prefixes + target services (AppDeploymentRoute) + ═══════════════════════════════════════════════════════════════════ *@ + +
+ +
+
URL Configuration — @App.Name
+ Manage which paths route to which services for each hostname. +
+
+ +@if (loadError is not null) +{ +
+ + Failed to load URL configuration: @loadError +
+} +else if (routes is null) +{ +
+
+
+} +else if (routes.Count == 0) +{ + +} +else +{ + @foreach (AppRoute route in routes) + { +
+ @* ── Hostname header (ops-managed, read-only) ── *@ +
+
+ + + @route.Hostname + + @TlsBadge(route) + @if (!route.IsEnabled) + { + Disabled by ops + } + ops-managed +
+
+ +
+ @* ── Existing deployment routes ── *@ + @if (route.DeploymentRoutes.Count > 0) + { + + + + + + + + + @if (AccessRole >= CustomerAccessRole.Operator) + { + + } + + + + @foreach (AppDeploymentRoute dr in route.DeploymentRoutes.OrderBy(r => r.PathPrefix)) + { + bool isEditing = editingDrId == dr.Id; + + + @if (isEditing) + { + + } + else + { + + + + + + @if (AccessRole >= CustomerAccessRole.Operator) + { + + } + } + + } + +
PathDeploymentEnvServiceStatus
+ @if (editError is not null) + { +
@editError
+ } +
+
+ + +
+
+ + @if (editAvailableServices.Count > 0) + { + + } + else + { + + } +
+
+ + @if (editAvailablePorts.Count > 0) + { + + } + else + { + + } +
+
+ @* Endpoint health for edit *@ + @if (editEndpointSummary is not null && editEndpointSummary.HasEndpoints) + { +
+ + + @editEndpointSummary.TotalReady ready + + @if (editEndpointSummary.TotalNotReady > 0) + { + + + @editEndpointSummary.TotalNotReady not ready + + } +
+ } + else if (editEndpointSummary is not null && !editEndpointSummary.HasEndpoints && !string.IsNullOrEmpty(editService)) + { +
+ No endpoints — service has no ready pods. +
+ } +
+ + +
+
@dr.PathPrefix@dr.AppDeployment?.Name@dr.AppDeployment?.Environment?.Name@dr.ServiceName:@dr.ServicePort@HealthBadge(dr) + + @if (deleteConfirmId == dr.Id) + { + + + } + else + { + + } +
+ } + else if (AccessRole < CustomerAccessRole.Operator) + { +
No paths configured yet.
+ } + + @* ── Add path form ── *@ + @if (AccessRole >= CustomerAccessRole.Operator) + { + @if (addingToRouteId == route.Id) + { +
+ @if (addError is not null) + { +
@addError
+ } +
+
+ + +
+
+ + +
+
+ + @if (addAvailableServices.Count > 0) + { + + } + else + { + + } +
+
+ + @if (addAvailablePorts.Count > 0) + { + + } + else + { + + } +
+
+ + @* Endpoint health preview *@ + @if (addEndpointSummary is not null && addEndpointSummary.HasEndpoints) + { +
+ + + @addEndpointSummary.TotalReady ready + + @if (addEndpointSummary.TotalNotReady > 0) + { + + + @addEndpointSummary.TotalNotReady not ready + + } +
+ } + else if (addEndpointSummary is not null && !addEndpointSummary.HasEndpoints && !string.IsNullOrEmpty(newDrService)) + { +
+ No endpoints — service has no ready pods. +
+ } + +
+ + +
+
+ } + else + { +
+ +
+ } + } +
+
+ } + +
+ + Path changes update the routing configuration. Your platform team will apply the updated HTTPRoute to the cluster. +
+} + +@code { + [Parameter] public required Data.App App { get; set; } + [Parameter] public CustomerAccessRole AccessRole { get; set; } + + private List? routes; + private List? deployments; + + // Add form + private Guid? addingToRouteId; + private Guid newDrDeploymentId; + private string newDrPath = "/"; + private string newDrService = ""; + private int newDrPort = 80; + private string? addError; + private bool adding; + private bool addLoadingServices; + private List addAvailableServices = []; + private List addAvailablePorts = []; + private KubeEndpointSummary? addEndpointSummary; + + // Edit form + private Guid? editingDrId; + private AppDeployment? editingDeployment; + private string editPath = "/"; + private string editService = ""; + private int editPort = 80; + private string? editError; + private bool saving; + private bool editLoadingServices; + private List editAvailableServices = []; + private List editAvailablePorts = []; + private KubeEndpointSummary? editEndpointSummary; + + // Delete confirm + private Guid? deleteConfirmId; + private string? loadError; + + protected override async Task OnParametersSetAsync() + { + loadError = null; + try + { + routes = await AppRouteService.GetRoutesForAppAsync(App.Id); + deployments = await DeploymentService.GetDeploymentsAsync(App.Id); + } + catch (Exception ex) + { + loadError = ex.Message; + routes = []; + } + } + + // ── Add flow ── + + private async Task StartAdd(Guid routeId) + { + addingToRouteId = routeId; + newDrDeploymentId = Guid.Empty; + newDrPath = "/"; + newDrService = ""; + newDrPort = 80; + addError = null; + addAvailableServices = []; + addAvailablePorts = []; + addEndpointSummary = null; + editingDrId = null; + + if (deployments?.Count == 1) + await LoadAddServices(deployments[0]); + } + + private void CancelAdd() + { + addingToRouteId = null; + addError = null; + addAvailableServices = []; + addAvailablePorts = []; + addEndpointSummary = null; + } + + private async Task OnAddDeploymentSelected(ChangeEventArgs e) + { + addAvailableServices = []; + addAvailablePorts = []; + addEndpointSummary = null; + newDrService = ""; + + if (!Guid.TryParse(e.Value?.ToString(), out Guid id) || id == Guid.Empty) + { + newDrDeploymentId = Guid.Empty; + return; + } + + newDrDeploymentId = id; + AppDeployment? d = deployments?.FirstOrDefault(d => d.Id == id); + if (d is not null) await LoadAddServices(d); + } + + private async Task LoadAddServices(AppDeployment d) + { + if (d.Cluster is null) return; + newDrDeploymentId = d.Id; + addLoadingServices = true; + addAvailableServices = await K8sOps.GetServicesInNamespaceAsync(d.Cluster.Id, d.Namespace); + addLoadingServices = false; + if (addAvailableServices.Count == 1) + await SelectAddService(addAvailableServices[0].Name, d); + } + + private async Task OnAddServiceSelected(ChangeEventArgs e) + { + string name = e.Value?.ToString() ?? ""; + newDrService = name; + addAvailablePorts = []; + addEndpointSummary = null; + if (string.IsNullOrEmpty(name)) return; + AppDeployment? d = deployments?.FirstOrDefault(d => d.Id == newDrDeploymentId); + if (d is not null) await SelectAddService(name, d); + } + + private async Task SelectAddService(string name, AppDeployment d) + { + newDrService = name; + KubeServiceInfo? svc = addAvailableServices.FirstOrDefault(s => s.Name == name); + addAvailablePorts = svc?.Ports ?? []; + if (addAvailablePorts.Count > 0) newDrPort = addAvailablePorts[0].Port; + if (d.Cluster is not null) + addEndpointSummary = await K8sOps.GetEndpointsForServiceAsync(d.Cluster.Id, d.Namespace, name); + } + + private async Task AddPath(Guid routeId) + { + addError = null; + adding = true; + try + { + await AppRouteService.AddDeploymentRouteAsync(routeId, newDrDeploymentId, new AppDeploymentRouteRequest + { + ServiceName = newDrService, + ServicePort = newDrPort, + PathPrefix = newDrPath + }); + CancelAdd(); + routes = await AppRouteService.GetRoutesForAppAsync(App.Id); + } + catch (Exception ex) + { + addError = ex.Message; + } + finally + { + adding = false; + } + } + + // ── Edit flow ── + + private async Task StartEdit(AppDeploymentRoute dr) + { + editingDrId = dr.Id; + editPath = dr.PathPrefix; + editService = dr.ServiceName; + editPort = dr.ServicePort; + editError = null; + editAvailableServices = []; + editAvailablePorts = []; + editEndpointSummary = null; + addingToRouteId = null; + + editingDeployment = deployments?.FirstOrDefault(d => d.Id == dr.AppDeploymentId); + if (editingDeployment?.Cluster is not null) + { + editLoadingServices = true; + editAvailableServices = await K8sOps.GetServicesInNamespaceAsync( + editingDeployment.Cluster.Id, editingDeployment.Namespace); + editLoadingServices = false; + + KubeServiceInfo? svc = editAvailableServices.FirstOrDefault(s => s.Name == editService); + editAvailablePorts = svc?.Ports ?? []; + editEndpointSummary = await K8sOps.GetEndpointsForServiceAsync( + editingDeployment.Cluster.Id, editingDeployment.Namespace, editService); + } + } + + private void CancelEdit() + { + editingDrId = null; + editingDeployment = null; + editError = null; + editAvailableServices = []; + editAvailablePorts = []; + editEndpointSummary = null; + } + + private async Task OnEditServiceSelected(ChangeEventArgs e) + { + string name = e.Value?.ToString() ?? ""; + editService = name; + editAvailablePorts = []; + editEndpointSummary = null; + if (string.IsNullOrEmpty(name) || editingDeployment?.Cluster is null) return; + + KubeServiceInfo? svc = editAvailableServices.FirstOrDefault(s => s.Name == name); + editAvailablePorts = svc?.Ports ?? []; + if (editAvailablePorts.Count > 0) editPort = editAvailablePorts[0].Port; + editEndpointSummary = await K8sOps.GetEndpointsForServiceAsync( + editingDeployment.Cluster.Id, editingDeployment.Namespace, name); + } + + private async Task SaveEdit(Guid drId) + { + editError = null; + saving = true; + try + { + await AppRouteService.UpdateDeploymentRouteAsync(drId, new AppDeploymentRouteRequest + { + ServiceName = editService, + ServicePort = editPort, + PathPrefix = editPath + }); + CancelEdit(); + routes = await AppRouteService.GetRoutesForAppAsync(App.Id); + } + catch (Exception ex) + { + editError = ex.Message; + } + finally + { + saving = false; + } + } + + private async Task DeleteDeploymentRoute(Guid drId) + { + await AppRouteService.DeleteDeploymentRouteAsync(drId); + deleteConfirmId = null; + routes = await AppRouteService.GetRoutesForAppAsync(App.Id); + } + + private static RenderFragment TlsBadge(AppRoute route) => __builder => + { + if (route.TlsMode == TlsMode.ClusterIssuer) + { + __builder.OpenElement(0, "span"); + __builder.AddAttribute(1, "class", "badge bg-success bg-opacity-75 small"); + __builder.AddAttribute(2, "title", $"Auto TLS — {route.ClusterIssuerName}"); + __builder.AddContent(3, $"TLS auto · {route.ClusterIssuerName}"); + __builder.CloseElement(); + } + else + { + __builder.OpenElement(0, "span"); + __builder.AddAttribute(1, "class", "badge bg-warning text-dark small"); + __builder.AddAttribute(2, "title", "Manual TLS certificate"); + __builder.AddContent(3, "TLS manual"); + __builder.CloseElement(); + } + }; + + private static RenderFragment HealthBadge(AppDeploymentRoute dr) => __builder => + { + if (dr.IsReachable == true) + { + __builder.OpenElement(0, "span"); + __builder.AddAttribute(1, "class", "badge bg-success bg-opacity-75 small"); + __builder.AddContent(2, $"✓ {dr.LastStatusCode}"); + __builder.CloseElement(); + } + else if (dr.IsReachable == false) + { + __builder.OpenElement(0, "span"); + __builder.AddAttribute(1, "class", "badge bg-danger bg-opacity-75 small"); + __builder.AddContent(2, dr.LastStatusCode.HasValue ? $"✗ {dr.LastStatusCode}" : "✗ unreachable"); + __builder.CloseElement(); + } + else + { + __builder.OpenElement(0, "span"); + __builder.AddAttribute(1, "class", "badge bg-secondary bg-opacity-50 small"); + __builder.AddContent(2, "not checked"); + __builder.CloseElement(); + } + }; +} diff --git a/src/EntKube.Web/Components/Pages/Portal/PortalDeploymentDetail.razor b/src/EntKube.Web/Components/Pages/Portal/PortalDeploymentDetail.razor index 1d048c9..b298f0a 100644 --- a/src/EntKube.Web/Components/Pages/Portal/PortalDeploymentDetail.razor +++ b/src/EntKube.Web/Components/Pages/Portal/PortalDeploymentDetail.razor @@ -2,11 +2,13 @@ @using EntKube.Web.Services @using Microsoft.EntityFrameworkCore @implements IAsyncDisposable +@inject IJSRuntime JS @inject IncidentService IncidentService @inject LokiService LokiService @inject GitSyncService GitSyncService @inject CustomerGitService CustomerGitService @inject IDbContextFactory DbFactory +@inject DeploymentStatusNotifier StatusNotifier @* ═══════════════════════════════════════════════════════════════════ Deployment operations view — the heart of the customer portal. @@ -134,13 +136,13 @@
} - @if (!showEditDeployment && Deployment.GitRepositoryId is not null) + @if (!showEditDeployment && Deployment.GitUrl is not null) {
- @Deployment.GitRepository?.Name - @(Deployment.GitRevision ?? Deployment.GitRepository?.DefaultBranch) + @Deployment.GitUrl + @(Deployment.GitRevision ?? "main") @if (!string.IsNullOrEmpty(Deployment.GitPath) && Deployment.GitPath != ".") { @Deployment.GitPath @@ -257,15 +259,49 @@
} - @* --- Operation buttons (role-gated) --- *@ - @if (AccessRole >= CustomerAccessRole.Operator) - { -
+ @* --- Operation bar (refresh always visible; destructive ops gated to Operator+) --- *@ +
+ @if (AccessRole >= CustomerAccessRole.Operator) + { -
- } + @if (AccessRole >= CustomerAccessRole.Admin) + { +
+ + + + + @if (pods is not null) + { + int running = pods.Count(p => p.Status == "Running"); + + (@running running) + + } +
+ } + } + +
@if (!podsLoading && string.IsNullOrEmpty(podsError) && (pods is null || pods.Count == 0)) @@ -279,7 +315,7 @@ @if (showLogViewer) {
-
+
Logs: @logPodName @@ -288,9 +324,24 @@ (@logContainerName) }
- +
+ @foreach (int tl in new[] { 100, 500, 1000 }) + { + int tlCopy = tl; + + } +
+ + +
@if (logLoading) @@ -305,14 +356,42 @@ } else { -
@logContent
+
@logContent
}
} + @* --- Pod filter --- *@ +
+
+ + + @if (!string.IsNullOrWhiteSpace(_podFilter)) + { + + } +
+ @foreach (string st in new[] { "All", "Running", "Pending", "Failed" }) + { + string stCopy = st; + int cnt = stCopy == "All" ? pods.Count : pods.Count(p => p.Status == stCopy); + + } +
+ @* --- Pod cards --- *@ - @foreach (PodInfo pod in pods) + @if (!FilteredPods.Any()) + { + + } + @foreach (PodInfo pod in FilteredPods) {
@@ -325,7 +404,14 @@ @pod.ReadyContainers/@pod.TotalContainers ready @if (pod.Restarts > 0) { - @pod.Restarts restarts + @pod.Restarts restarts + } + @if (pod.StartTime.HasValue) + { + + @PodAge(pod.StartTime.Value) + }
@@ -393,7 +479,7 @@
@container.Name - @container.Image + @TruncateImage(container.Image) @container.State @@ -406,12 +492,6 @@
} - @if (pod.StartTime.HasValue) - { -
- Started @pod.StartTime.Value.ToString("g") -
- }
} @@ -686,7 +766,7 @@ } else if (auditEvents is not null && auditEvents.Count == 0) { -
No activity recorded yet.
+ } else if (auditEvents is not null) { @@ -921,18 +1001,18 @@
} - else if (Deployment.GitRepositoryId is not null) + else if (Deployment.GitUrl is not null) { @* ── Current settings (read) ── *@
Repository
-
@(Deployment.GitRepository?.Url ?? "—")
+
@Deployment.GitUrl
Path
@(Deployment.GitPath ?? ".")
Revision
-
@(Deployment.GitRevision ?? Deployment.GitRepository?.DefaultBranch ?? "main")
+
@(Deployment.GitRevision ?? "main")
Auto-sync
@@ -994,6 +1074,11 @@ } + + @code { [Parameter] public required AppDeployment Deployment { get; set; } [Parameter] public CustomerAccessRole AccessRole { get; set; } @@ -1003,6 +1088,7 @@ [Parameter] public required AuditService AuditService { get; set; } [Parameter] public Guid CustomerId { get; set; } [Parameter] public Guid TenantId { get; set; } + [Parameter] public Guid EnvironmentId { get; set; } [Parameter] public string? GovernanceNamespace { get; set; } [Parameter] public EventCallback OnBack { get; set; } [Parameter] public EventCallback OnDeleted { get; set; } @@ -1035,16 +1121,21 @@ try { GitSyncResult result = await GitSyncService.SyncDeploymentAsync(Deployment.Id); - gitSyncSuccess = result.IsSuccess; - gitSyncMessage = result.AlreadyCurrent - ? "Already up to date." - : result.IsSuccess - ? $"Synced to {result.CommitSha[..Math.Min(7, result.CommitSha.Length)]} — {result.CommitMessage}" - : result.Error ?? "Sync failed."; + gitSyncSuccess = result.IsSuccess && result.ApplySuccess; + if (result.AlreadyCurrent) + gitSyncMessage = "Already up to date."; + else if (!result.IsSuccess) + gitSyncMessage = result.Error ?? "Sync failed."; + else if (!result.ApplySuccess) + gitSyncMessage = $"Synced to {result.CommitSha[..Math.Min(7, result.CommitSha.Length)]} but cluster apply failed: {result.ApplyOutput}"; + else + gitSyncMessage = $"Synced and applied to cluster — {result.CommitSha[..Math.Min(7, result.CommitSha.Length)]} {result.CommitMessage}"; if (result.IsSuccess) { Deployment.GitLastSyncedCommit = result.CommitSha; Deployment.GitLastSyncedAt = DateTime.UtcNow; + if (manifests is not null) + manifests = await DeploymentService.GetManifestsAsync(Deployment.Id); } } catch (Exception ex) { gitSyncSuccess = false; gitSyncMessage = ex.Message; } @@ -1056,16 +1147,16 @@ private async Task LoadGitTab() { activeTab = "git"; - if (gitTabPolicies.Count == 0 && CustomerId != Guid.Empty) + if (gitTabPolicies.Count == 0 && CustomerId != Guid.Empty && EnvironmentId != Guid.Empty) { - gitTabPolicies = await CustomerGitService.GetRepoPoliciesAsync(CustomerId); - gitTabCredentials = await CustomerGitService.GetCredentialsAsync(CustomerId); + gitTabPolicies = await CustomerGitService.GetRepoPoliciesAsync(CustomerId, EnvironmentId); + gitTabCredentials = await CustomerGitService.GetCredentialsAsync(CustomerId, EnvironmentId); } } private void StartGitEdit() { - gitEditUrl = Deployment.GitRepository?.Url ?? ""; + gitEditUrl = Deployment.GitUrl ?? ""; gitEditPath = Deployment.GitPath ?? ""; gitEditRevision = Deployment.GitRevision ?? ""; gitEditAutoSync = Deployment.GitAutoSync; @@ -1100,40 +1191,16 @@ 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; - } - } - } + string? resolvedUrl = string.IsNullOrWhiteSpace(gitEditUrl) ? null : gitEditUrl.Trim(); await DeploymentService.UpdateGitSettingsAsync( - Deployment.Id, repoId, + Deployment.Id, resolvedUrl, 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.GitUrl = resolvedUrl; Deployment.GitPath = string.IsNullOrWhiteSpace(gitEditPath) ? null : gitEditPath; Deployment.GitRevision = string.IsNullOrWhiteSpace(gitEditRevision) ? null : gitEditRevision; Deployment.GitAutoSync = gitEditAutoSync; @@ -1162,6 +1229,8 @@ private string? operationMessage; private bool operationSuccess; private string? confirmDeletePod; + private int _scaleReplicas = 1; + private bool _scaleInitialized; // Logs private bool showLogViewer; @@ -1170,6 +1239,25 @@ private string? logContainerName; private string logContent = ""; private string? logError; + private int _logTailLines = 500; + private ElementReference _logPreRef; + private bool _pendingLogScroll; + + private string _podFilter = ""; + private string _podStatusFilter = "All"; + + private IEnumerable FilteredPods + { + get + { + IEnumerable result = pods ?? []; + if (!string.IsNullOrWhiteSpace(_podFilter)) + result = result.Where(p => p.Name.Contains(_podFilter, StringComparison.OrdinalIgnoreCase)); + if (_podStatusFilter != "All") + result = result.Where(p => p.Status == _podStatusFilter); + return result; + } + } // Manifests private List? manifests; @@ -1213,6 +1301,8 @@ private List? resourceTree; private string? resourcesError; private DateTime? _resourcesRefreshedAt; + private PeriodicTimer? _resourceTimer; + private CancellationTokenSource? _resourceTimerCts; // Metrics private DeploymentMetricsSummary? metrics; @@ -1233,15 +1323,39 @@ protected override async Task OnInitializedAsync() { + StatusNotifier.OnStatusChanged += HandleDeploymentStatusChanged; await LoadPodsInternal(); } - public ValueTask DisposeAsync() => ValueTask.CompletedTask; + private void HandleDeploymentStatusChanged(Guid deploymentId, SyncStatus sync, HealthStatus health) + { + if (deploymentId != Deployment.Id) return; + Deployment.SyncStatus = sync; + Deployment.HealthStatus = health; + InvokeAsync(StateHasChanged); + } + + public ValueTask DisposeAsync() + { + StatusNotifier.OnStatusChanged -= HandleDeploymentStatusChanged; + StopResourceRefresh(); + return ValueTask.CompletedTask; + } + + private void StopResourceRefresh() + { + _resourceTimerCts?.Cancel(); + _resourceTimerCts?.Dispose(); + _resourceTimerCts = null; + _resourceTimer?.Dispose(); + _resourceTimer = null; + } // ──────── Pod operations ──────── private async Task LoadPods() { + StopResourceRefresh(); activeTab = "pods"; await LoadPodsInternal(); } @@ -1259,6 +1373,12 @@ { pods = result.Data ?? []; + if (!_scaleInitialized && pods.Count > 0) + { + _scaleReplicas = pods.Count; + _scaleInitialized = true; + } + // Derive and persist live status from the pod list so the badges // reflect actual cluster state rather than the DB default (Unknown). (SyncStatus sync, HealthStatus health) = @@ -1287,11 +1407,12 @@ StateHasChanged(); KubernetesOperationResult result = await K8sOps.GetPodLogsAsync( - Deployment.Id, podName, containerName); + Deployment.Id, podName, containerName, _logTailLines); if (result.IsSuccess) { logContent = result.Data ?? "(empty)"; + _pendingLogScroll = true; } else { @@ -1301,6 +1422,15 @@ logLoading = false; } + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (_pendingLogScroll) + { + _pendingLogScroll = false; + try { await JS.InvokeVoidAsync("scrollToBottom", _logPreRef); } catch { } + } + } + private void CloseLogViewer() { showLogViewer = false; @@ -1340,13 +1470,11 @@ if (failures.Count == 0) { - operationMessage = $"Rolling restart triggered for {deploymentNames.Count} deployment(s)."; - operationSuccess = true; + SetOperationMessage($"Rolling restart triggered for {deploymentNames.Count} deployment(s).", true); } else { - operationMessage = $"Some restarts failed: {string.Join("; ", failures)}"; - operationSuccess = false; + SetOperationMessage($"Some restarts failed: {string.Join("; ", failures)}", false); } operationInProgress = false; @@ -1356,6 +1484,41 @@ await LoadPodsInternal(); } + private async Task ScaleMainDeployment() + { + operationInProgress = true; + operationMessage = null; + StateHasChanged(); + + List tree = await DeploymentService.GetResourceTreeAsync(Deployment.Id); + List<(string Kind, string Name)> targets = tree + .Where(r => r.Kind is "Deployment" or "StatefulSet" or "ReplicaSet") + .Select(r => (r.Kind, r.Name)) + .ToList(); + + if (targets.Count == 0) + targets.Add(("Deployment", Deployment.Name)); + + List failures = []; + + foreach ((string kind, string name) in targets) + { + KubernetesOperationResult result = await K8sOps.ScaleWorkloadAsync( + Deployment.Id, kind, name, _scaleReplicas); + if (!result.IsSuccess) + failures.Add($"{name}: {result.Error}"); + } + + if (failures.Count == 0) + SetOperationMessage($"Scaled to {_scaleReplicas} replica{(_scaleReplicas == 1 ? "" : "s")}.", true); + else + SetOperationMessage($"Scale failed: {string.Join("; ", failures)}", false); + + operationInProgress = false; + await Task.Delay(2000); + await LoadPodsInternal(); + } + private async Task DeletePod(string podName) { operationInProgress = true; @@ -1364,16 +1527,11 @@ KubernetesOperationResult result = await K8sOps.DeletePodAsync(Deployment.Id, podName); - if (result.IsSuccess) - { - operationMessage = $"Pod '{podName}' deleted. Kubernetes will create a replacement."; - operationSuccess = true; - } - else - { - operationMessage = result.Error; - operationSuccess = false; - } + SetOperationMessage( + result.IsSuccess + ? $"Pod '{podName}' deleted. Kubernetes will create a replacement." + : result.Error, + result.IsSuccess); operationInProgress = false; @@ -1461,6 +1619,7 @@ private async Task LoadManifests() { + StopResourceRefresh(); activeTab = "manifests"; yamlApplyOutput = null; yamlApplySuccess = null; @@ -1575,11 +1734,20 @@ private async Task LoadResources() { + StopResourceRefresh(); activeTab = "resources"; - resourceTree = null; // trigger spinner + resourceTree = null; resourcesError = null; StateHasChanged(); + await RefreshResourcesAsync(); + + _resourceTimerCts = new CancellationTokenSource(); + _ = RunResourceAutoRefreshAsync(_resourceTimerCts.Token); + } + + private async Task RefreshResourcesAsync() + { KubernetesOperationResult> result = await K8sOps.GetLiveResourcesAsync(Deployment.Id); @@ -1588,7 +1756,6 @@ resourceTree = result.Data ?? []; _resourcesRefreshedAt = DateTime.UtcNow; - // Persist the live status so the header badges are accurate. (SyncStatus sync, HealthStatus health) = KubernetesOperationsService.ComputeStatusFromResources(resourceTree); @@ -1603,10 +1770,28 @@ } } + private async Task RunResourceAutoRefreshAsync(CancellationToken ct) + { + _resourceTimer = new PeriodicTimer(TimeSpan.FromSeconds(15)); + try + { + while (await _resourceTimer.WaitForNextTickAsync(ct)) + { + await InvokeAsync(async () => + { + await RefreshResourcesAsync(); + StateHasChanged(); + }); + } + } + catch (OperationCanceledException) { } + } + // ──────── Metrics ──────── private async Task LoadMetrics() { + StopResourceRefresh(); activeTab = "metrics"; metricsLoading = true; metricsError = null; @@ -1628,6 +1813,7 @@ private async Task LoadActivity() { + StopResourceRefresh(); activeTab = "activity"; activityLoading = true; auditEvents = null; @@ -1639,6 +1825,7 @@ private async Task LoadMonitoring() { + StopResourceRefresh(); activeTab = "monitoring"; monitoringLoading = true; StateHasChanged(); @@ -1677,8 +1864,41 @@ monitoringLoading = false; } + // ──────── Operation message helpers ──────── + + private void SetOperationMessage(string? msg, bool success) + { + operationMessage = msg; + operationSuccess = success; + if (msg is not null) + _ = AutoDismissMessageAsync(msg); + } + + private async Task AutoDismissMessageAsync(string snapshot) + { + await Task.Delay(6000); + // Only clear if the message hasn't been replaced by a newer one. + if (operationMessage == snapshot) + { + operationMessage = null; + await InvokeAsync(StateHasChanged); + } + } + // ──────── Rendering helpers ──────── + private static string PodAge(DateTime startTime) + { + TimeSpan age = DateTime.UtcNow - startTime; + if (age.TotalDays >= 1) return $"{(int)age.TotalDays}d"; + if (age.TotalHours >= 1) return $"{(int)age.TotalHours}h"; + if (age.TotalMinutes >= 1) return $"{(int)age.TotalMinutes}m"; + return "<1m"; + } + + private static string RestartBadgeClass(int restarts) => + restarts >= 5 ? "bg-danger" : "bg-warning text-dark"; + private static string GetPodStatusColor(string status) => status switch { "Running" => "text-success", @@ -1697,6 +1917,12 @@ _ => "bg-secondary" }; + private static string TruncateImage(string image) + { + int slash = image.LastIndexOf('/'); + return slash >= 0 ? image[(slash + 1)..] : image; + } + private RenderFragment TypeBadge(DeploymentType type) => type switch { DeploymentType.Manual => @Manual, diff --git a/src/EntKube.Web/Components/Pages/Portal/PortalIdentityDetail.razor b/src/EntKube.Web/Components/Pages/Portal/PortalIdentityDetail.razor index cf11fde..da18d6b 100644 --- a/src/EntKube.Web/Components/Pages/Portal/PortalIdentityDetail.razor +++ b/src/EntKube.Web/Components/Pages/Portal/PortalIdentityDetail.razor @@ -114,10 +114,19 @@ }
+ @if (confirmDeleteUserId == user.Id) + { + + }
}
@@ -183,20 +192,31 @@
@foreach (KeycloakIdpInfo idp in idps) { -
-
- - @idp.Alias - @idp.ProviderId - @if (!idp.Enabled) - { - Disabled - } +
+
+
+ + @idp.Alias + @idp.ProviderId + @if (!idp.Enabled) + { + Disabled + } +
+
- + @if (confirmDeleteIdpAlias == idp.Alias) + { + + }
}
@@ -239,12 +259,23 @@
@foreach (KeycloakGroupInfo g in groups) { -
- @g.Name @g.Path - +
+
+ @g.Name @g.Path + +
+ @if (confirmDeleteGroupId == g.Id) + { + + }
}
@@ -329,6 +360,12 @@ private bool loadingUsers, loadingIdps, loadingGroups, loadingOrgs; + // Delete confirm state + private string? confirmDeleteUserId; + private string? confirmDeleteIdpAlias; + private string? confirmDeleteGroupId; + private bool deletingItem; + // User form private bool showAddUser, savingUser; private string? userError; @@ -418,12 +455,15 @@ private async Task DeleteUser(string userId) { + deletingItem = true; try { await KeycloakService.DeleteUserAsync(TenantId, Realm.Id, userId); + confirmDeleteUserId = null; users = await KeycloakService.GetUsersAsync(TenantId, Realm.Id); } catch (Exception ex) { userError = ex.Message; } + finally { deletingItem = false; } } private async Task AddIdp() @@ -445,12 +485,15 @@ private async Task DeleteIdp(string alias) { + deletingItem = true; try { await KeycloakService.DeleteIdpAsync(TenantId, Realm.Id, alias); + confirmDeleteIdpAlias = null; idps = await KeycloakService.GetIdpsAsync(TenantId, Realm.Id); } catch (Exception ex) { idpError = ex.Message; } + finally { deletingItem = false; } } private async Task AddGroup() @@ -470,12 +513,15 @@ private async Task DeleteGroup(string groupId) { + deletingItem = true; try { await KeycloakService.DeleteGroupAsync(TenantId, Realm.Id, groupId); + confirmDeleteGroupId = null; groups = await KeycloakService.GetGroupsAsync(TenantId, Realm.Id); } catch (Exception ex) { groupError = ex.Message; } + finally { deletingItem = false; } } private async Task AddOrg() diff --git a/src/EntKube.Web/Components/Pages/Portal/PortalSecretsDetail.razor b/src/EntKube.Web/Components/Pages/Portal/PortalSecretsDetail.razor index 7b511b5..803a32f 100644 --- a/src/EntKube.Web/Components/Pages/Portal/PortalSecretsDetail.razor +++ b/src/EntKube.Web/Components/Pages/Portal/PortalSecretsDetail.razor @@ -1,37 +1,47 @@ @using EntKube.Web.Data @using EntKube.Web.Services +@using Microsoft.AspNetCore.Components.Authorization @inject VaultService VaultService +@inject AppGovernanceService AppGovernanceService @inject TenantService TenantService @inject DeploymentService DeploymentService @inject CnpgService CnpgService @inject MongoService MongoService -@inject RabbitMQService RabbitMQService @inject RedisService RedisService +@inject AuthenticationStateProvider AuthStateProvider @using EntKube.Web.Components.Pages.Shared @* ═══════════════════════════════════════════════════════════════════ App Secrets — customer portal view for managing vault secrets and - connected infrastructure credentials scoped to a single app. + Docker registry credentials scoped to a single app. Tabs: App Secrets — vault secrets tied directly to the app - Databases — CNPG / MongoDB / Registered Postgres bindings - Messaging — RabbitMQ bindings (vhost / queue / exchange) - Cache — Redis bindings Registries — Docker pull secrets + Note: Database, Messaging, Cache, and Storage bindings are now + top-level tabs in AppEnvironmentDetail. + Access rules: Viewer+ — list and reveal values Admin — add / update / delete secrets, trigger syncs ═══════════════════════════════════════════════════════════════════ *@ -
- -
-
Secrets — @App.Name
- Application secrets and connected infrastructure credentials. -
-
+@* ── Scope tab navigation ── *@ + @if (!vaultInitialized) { @@ -42,39 +52,10 @@ } else { - @* ── Tab navigation ── *@ - - @* ════════════════════════════════════════════════════════════════ Tab: App Secrets ════════════════════════════════════════════════════════════════ *@ - @if (scope == "app") + @if (activeScope == "app" || activeScope == "") { @* ── Add Secret form (Admin only) ── *@ @if (AccessRole >= CustomerAccessRole.Admin) @@ -196,19 +177,37 @@ else @onclick="() => StartEdit(secret)" title="Update value"> + } + @if (confirmDeleteSecretId == secret.Id) + { + + + + + + } + @if (revealedSecretId == secret.Id) { @@ -255,21 +254,21 @@ else @if (syncConfigSecretId == secret.Id) { + bool nsLocked = syncClusterId != Guid.Empty && GetLockedNamespaceForCluster(syncClusterId) is not null;
- @if (clusters is not null) - { -
- -
- } +
+ +
+ @bind="syncNamespace" + disabled="@nsLocked" + title="@(nsLocked ? "Namespace is locked by governance policy" : null)" />
+ @if (nsLocked) + { +
+ Namespace locked by governance policy. +
+ } + + + } + + @* Version history row *@ + @if (historySecretId == secret.Id) + { + + +
+
+ + Version History + @(secretVersions?.Count ?? 0) +
+ @if (secretVersions is null) + { +
+ } + else if (secretVersions.Count == 0) + { +

No previous versions recorded.

+ } + else + { + + + + + + + + + + + @foreach (VaultSecretVersion ver in secretVersions) + { + + + + + + + @if (revealedVersionId == ver.Id) + { + + + + } + } + +
#Set byDateActions
v@ver.VersionNumber@(ver.CreatedBy ?? "—")@ver.CreatedAt.ToString("MMM d, HH:mm") + + +
+ Value: + @if (versionRevealLoading) + { + + } + else + { + @revealedVersionValue + } +
+ } +
} @@ -307,364 +389,10 @@ else
} - @* ════════════════════════════════════════════════════════════════ - Tab: Databases - ════════════════════════════════════════════════════════════════ *@ - else if (scope == "database") - { - - @if (dbLoaded) - { - bool anyDbBindings = dbBindingsByDeployment.Values.Any(b => b.Count > 0); - - @if (!anyDbBindings) - { - - } - else - { - @foreach (AppDeployment deployment in deployments!) - { - List bindings = dbBindingsByDeployment.GetValueOrDefault(deployment.Id, []); - if (bindings.Count == 0) continue; - -
-
-
- - @deployment.Name - - @deployment.Cluster?.Name - @deployment.Namespace - -
-
-
-
- - - - - - - - @if (AccessRole >= CustomerAccessRole.Admin) - { - - } - - - - @foreach (DatabaseBinding binding in bindings) - { - - - - - - @if (AccessRole >= CustomerAccessRole.Admin) - { - - } - - } - -
TypeDatabaseK8s SecretLast SyncedActions
@DbTypeBadge(binding) - @DbDisplayName(binding) - @if (binding.CnpgDatabase?.CnpgCluster is not null) - { -
@binding.CnpgDatabase.CnpgCluster.Name - } - else if (binding.MongoDatabase?.MongoCluster is not null) - { -
@binding.MongoDatabase.MongoCluster.Name - } -
- @binding.KubernetesSecretName -
- @deployment.Namespace -
- @if (binding.LastSyncedAt is not null) - { - @binding.LastSyncedAt.Value.ToString("MMM d, HH:mm") - } - else - { - Never synced - } - - @if (binding.CnpgDatabaseId.HasValue || binding.MongoDatabaseId.HasValue) - { - - } - @if (bindingSyncErrors.TryGetValue(binding.Id, out string? dbErr) && dbErr is not null) - { -
@dbErr
- } -
-
-
-
- } - } - } -
- } - - @* ════════════════════════════════════════════════════════════════ - Tab: Messaging (RabbitMQ) - ════════════════════════════════════════════════════════════════ *@ - else if (scope == "messaging") - { - - @if (msgLoaded) - { - bool anyMsgBindings = msgBindingsByDeployment.Values.Any(b => b.Count > 0); - - @if (!anyMsgBindings) - { - - } - else - { - @foreach (AppDeployment deployment in deployments!) - { - List bindings = msgBindingsByDeployment.GetValueOrDefault(deployment.Id, []); - if (bindings.Count == 0) continue; - -
-
-
- - @deployment.Name - - @deployment.Cluster?.Name - @deployment.Namespace - -
-
-
-
- - - - - - - - - @if (AccessRole >= CustomerAccessRole.Admin) - { - - } - - - - @foreach (MessagingBinding binding in bindings) - { - - - - - - - @if (AccessRole >= CustomerAccessRole.Admin) - { - - } - - } - -
ClusterVhostQueue / ExchangeK8s SecretLast SyncedActions
@binding.Cluster?.Name@binding.Vhost - @if (binding.QueueName is not null) - { - - Queue - - @binding.QueueName - } - @if (binding.ExchangeName is not null) - { - - Exchange - - @binding.ExchangeName - } - @if (binding.QueueName is null && binding.ExchangeName is null) - { - vhost only - } - - @binding.KubernetesSecretName -
- @deployment.Namespace -
- @if (binding.LastSyncedAt is not null) - { - @binding.LastSyncedAt.Value.ToString("MMM d, HH:mm") - } - else - { - Never synced - } - - - @if (bindingSyncErrors.TryGetValue(binding.Id, out string? msgErr) && msgErr is not null) - { -
@msgErr
- } -
-
-
-
- } - } - } -
- } - - @* ════════════════════════════════════════════════════════════════ - Tab: Cache (Redis) - ════════════════════════════════════════════════════════════════ *@ - else if (scope == "cache") - { - - @if (cacheLoaded) - { - bool anyCacheBindings = cacheBindingsByDeployment.Values.Any(b => b.Count > 0); - - @if (!anyCacheBindings) - { - - } - else - { - @foreach (AppDeployment deployment in deployments!) - { - List bindings = cacheBindingsByDeployment.GetValueOrDefault(deployment.Id, []); - if (bindings.Count == 0) continue; - -
-
-
- - @deployment.Name - - @deployment.Cluster?.Name - @deployment.Namespace - -
-
-
-
- - - - - - - @if (AccessRole >= CustomerAccessRole.Admin) - { - - } - - - - @foreach (CacheBinding binding in bindings) - { - - - - - @if (AccessRole >= CustomerAccessRole.Admin) - { - - } - - } - -
Redis ClusterK8s SecretLast SyncedActions
- - @binding.RedisCluster.Name - @if (binding.RedisCluster.KubernetesCluster is not null) - { -
@binding.RedisCluster.KubernetesCluster.Name - } -
- @binding.KubernetesSecretName -
- @deployment.Namespace -
- @if (binding.LastSyncedAt is not null) - { - @binding.LastSyncedAt.Value.ToString("MMM d, HH:mm") - } - else - { - Never synced - } - - - @if (bindingSyncErrors.TryGetValue(binding.Id, out string? cacheErr) && cacheErr is not null) - { -
@cacheErr
- } -
-
-
-
- } - } - } -
- } - @* ════════════════════════════════════════════════════════════════ Tab: Docker Registries ════════════════════════════════════════════════════════════════ *@ - else if (scope == "docker") + else if (activeScope == "docker") { ? secrets; - private List? clusters; + private Dictionary environmentNamespaceLock = new(); + private List appEnvironmentClusters = []; private string newSecretName = ""; private string newSecretValue = ""; @@ -698,6 +428,9 @@ else private Guid? editSecretId; private string editSecretValue = ""; + private Guid? confirmDeleteSecretId; + private bool deletingSecret; + private Guid? syncConfigSecretId; private string syncSecretName = ""; private string syncNamespace = ""; @@ -706,50 +439,58 @@ else private bool syncingSecrets; private string? syncOutput; + // ── Version history state ──────────────────────────────────────── + private Guid? historySecretId; + private List? secretVersions; + private Guid? revealedVersionId; + private string? revealedVersionValue; + private bool versionRevealLoading; + // ── Shared binding state ───────────────────────────────────────── private List? deployments; - private HashSet syncingBindingIds = []; - private Dictionary bindingSyncErrors = new(); - // ── Databases ──────────────────────────────────────────────────── - private Dictionary> dbBindingsByDeployment = new(); - private bool dbLoaded; - - // ── Messaging ──────────────────────────────────────────────────── - private Dictionary> msgBindingsByDeployment = new(); - private bool msgLoaded; - - // ── Cache ──────────────────────────────────────────────────────── - private Dictionary> cacheBindingsByDeployment = new(); - private bool cacheLoaded; + private string? currentUserEmail; protected override async Task OnInitializedAsync() { + activeScope = InitialScope; + + AuthenticationState authState = await AuthStateProvider.GetAuthenticationStateAsync(); + currentUserEmail = authState.User.Identity?.Name; + SecretVault? vault = await VaultService.GetVaultAsync(TenantId); vaultInitialized = vault is not null; - if (vaultInitialized) - { - await LoadSecrets(); - } + await EnsureDeployments(); + List appEnvs = await AppGovernanceService.GetAppEnvironmentsAsync(App.Id); + environmentNamespaceLock = appEnvs.ToDictionary(ae => ae.EnvironmentId, ae => ae.Namespace); - clusters = await TenantService.GetClustersAsync(TenantId); + HashSet appEnvIds = appEnvs.Select(ae => ae.EnvironmentId).ToHashSet(); + appEnvironmentClusters = (await TenantService.GetClustersAsync(TenantId)) + .Where(c => appEnvIds.Contains(c.EnvironmentId)) + .ToList(); + + if (!vaultInitialized) return; + + await LoadScopeData(activeScope); } - // ── Scope switching ────────────────────────────────────────────── - - private async Task SetScope(string newScope) + private async Task SwitchScope(string scope) { - scope = newScope; - errorMessage = null; - successMessage = null; + activeScope = scope; + await LoadScopeData(scope); + } - if (newScope == "database" && !dbLoaded) - await LoadDatabaseBindings(); - else if (newScope == "messaging" && !msgLoaded) - await LoadMessagingBindings(); - else if (newScope == "cache" && !cacheLoaded) - await LoadCacheBindings(); + private async Task LoadScopeData(string scope) + { + switch (scope) + { + case "docker": + break; + default: + if (secrets is null) await LoadSecrets(); + break; + } } private async Task EnsureDeployments() @@ -758,48 +499,6 @@ else deployments = await DeploymentService.GetDeploymentsAsync(App.Id); } - private async Task LoadDatabaseBindings() - { - await EnsureDeployments(); - dbBindingsByDeployment.Clear(); - - foreach (AppDeployment deployment in deployments!) - { - List bindings = await CnpgService.GetDatabaseBindingsAsync(deployment.Id); - dbBindingsByDeployment[deployment.Id] = bindings; - } - - dbLoaded = true; - } - - private async Task LoadMessagingBindings() - { - await EnsureDeployments(); - msgBindingsByDeployment.Clear(); - - foreach (AppDeployment deployment in deployments!) - { - List bindings = await RabbitMQService.GetMessagingBindingsForDeploymentAsync(deployment.Id); - msgBindingsByDeployment[deployment.Id] = bindings; - } - - msgLoaded = true; - } - - private async Task LoadCacheBindings() - { - await EnsureDeployments(); - cacheBindingsByDeployment.Clear(); - - foreach (AppDeployment deployment in deployments!) - { - List bindings = await RedisService.GetCacheBindingsForDeploymentAsync(deployment.Id); - cacheBindingsByDeployment[deployment.Id] = bindings; - } - - cacheLoaded = true; - } - // ── App Secrets: Add ───────────────────────────────────────────── private async Task LoadSecrets() @@ -807,6 +506,15 @@ else secrets = await VaultService.GetAppSecretsAsync(TenantId, App.Id); } + private async Task ShowSuccessAsync(string message) + { + successMessage = message; + StateHasChanged(); + await Task.Delay(3500); + successMessage = null; + StateHasChanged(); + } + private async Task AddSecret() { errorMessage = null; @@ -815,11 +523,12 @@ else try { - await VaultService.SetAppSecretAsync(TenantId, App.Id, newSecretName.Trim(), newSecretValue.Trim()); - successMessage = $"Secret '{newSecretName.Trim()}' saved."; + string name = newSecretName.Trim(); + await VaultService.SetAppSecretAsync(TenantId, App.Id, name, newSecretValue.Trim()); newSecretName = ""; newSecretValue = ""; await LoadSecrets(); + _ = ShowSuccessAsync($"Secret '{name}' saved."); } catch (Exception ex) { @@ -871,9 +580,11 @@ else try { - bool updated = await VaultService.UpdateSecretValueAsync(editSecretId.Value, editSecretValue.Trim()); - successMessage = updated ? "Secret value updated." : null; - errorMessage = updated ? null : "Failed to update secret."; + bool updated = await VaultService.UpdateSecretValueAsync(editSecretId.Value, editSecretValue.Trim(), currentUserEmail); + if (updated) + _ = ShowSuccessAsync("Secret value updated."); + else + errorMessage = "Failed to update secret."; } finally { saving = false; } @@ -887,15 +598,20 @@ else private async Task DeleteSecret(Guid secretId) { errorMessage = null; - (bool canDelete, string? reason) = await VaultService.CanDeleteSecretAsync(secretId); + deletingSecret = true; + (bool canDelete, string? reason) = await VaultService.CanDeleteSecretAsync(secretId); if (!canDelete) { errorMessage = reason; + deletingSecret = false; + confirmDeleteSecretId = null; return; } await VaultService.DeleteSecretAsync(secretId); + deletingSecret = false; + confirmDeleteSecretId = null; revealedSecretId = null; editSecretId = null; await LoadSecrets(); @@ -915,9 +631,49 @@ else syncSecretName = secret.KubernetesSecretName ?? ""; syncNamespace = secret.KubernetesNamespace ?? ""; syncClusterId = secret.KubernetesClusterId ?? Guid.Empty; + + // Auto-select cluster when only one exists across the app's environments + if (syncClusterId == Guid.Empty && appEnvironmentClusters.Count == 1) + syncClusterId = appEnvironmentClusters[0].Id; + + // Pre-fill namespace: governance lock takes precedence, then deployment namespace + if (syncClusterId != Guid.Empty) + { + string? locked = GetLockedNamespaceForCluster(syncClusterId); + if (locked is not null) + syncNamespace = locked; + else if (string.IsNullOrEmpty(syncNamespace)) + syncNamespace = GetDeploymentNamespaceForCluster(syncClusterId) ?? ""; + } } } + private void OnSyncClusterChanged(ChangeEventArgs e) + { + if (Guid.TryParse(e.Value?.ToString(), out Guid id)) + { + syncClusterId = id; + if (id != Guid.Empty) + { + string? locked = GetLockedNamespaceForCluster(id); + if (locked is not null) + syncNamespace = locked; + else + syncNamespace = GetDeploymentNamespaceForCluster(id) ?? syncNamespace; + } + } + } + + private string? GetLockedNamespaceForCluster(Guid clusterId) + { + KubernetesCluster? cluster = appEnvironmentClusters.FirstOrDefault(c => c.Id == clusterId); + if (cluster is null) return null; + return environmentNamespaceLock.GetValueOrDefault(cluster.EnvironmentId); + } + + private string? GetDeploymentNamespaceForCluster(Guid clusterId) => + deployments?.FirstOrDefault(d => d.ClusterId == clusterId)?.Namespace; + private async Task DisableSync(Guid secretId) { await VaultService.ConfigureKubernetesSyncAsync(secretId, syncEnabled: false, secretName: null, ns: null); @@ -957,110 +713,60 @@ else } } - // ── Database binding sync ──────────────────────────────────────── + // ── Version history ────────────────────────────────────────────── - private async Task SyncDatabaseBinding(DatabaseBinding binding) + private async Task ToggleHistory(Guid secretId) { - syncingBindingIds.Add(binding.Id); - bindingSyncErrors.Remove(binding.Id); - - try + if (historySecretId == secretId) { - if (binding.CnpgDatabaseId.HasValue && binding.CnpgDatabase is not null) - { - await CnpgService.SyncDatabaseCredentialsToK8sAsync( - TenantId, binding.CnpgDatabase.CnpgClusterId, binding.CnpgDatabase.Id); - } - else if (binding.MongoDatabaseId.HasValue && binding.MongoDatabase is not null) - { - await MongoService.SyncDatabaseCredentialsToK8sAsync( - TenantId, binding.MongoDatabase.MongoClusterId, binding.MongoDatabase.Id); - } - - // Refresh to show updated LastSyncedAt. - await LoadDatabaseBindings(); + historySecretId = null; + secretVersions = null; + revealedVersionId = null; + revealedVersionValue = null; + return; } - catch (Exception ex) + + historySecretId = secretId; + revealedVersionId = null; + revealedVersionValue = null; + secretVersions = null; + secretVersions = await VaultService.GetSecretVersionsAsync(secretId); + } + + private async Task RevealVersion(Guid secretId, VaultSecretVersion version) + { + if (revealedVersionId == version.Id) { - bindingSyncErrors[binding.Id] = ex.Message; + revealedVersionId = null; + revealedVersionValue = null; + return; } - finally + + versionRevealLoading = true; + revealedVersionId = version.Id; + revealedVersionValue = null; + revealedVersionValue = await VaultService.GetSecretVersionValueAsync(secretId, version.Id); + versionRevealLoading = false; + } + + private async Task RollbackToVersion(Guid secretId, Guid versionId) + { + errorMessage = null; + bool ok = await VaultService.RollbackToVersionAsync(secretId, versionId, currentUserEmail); + + if (ok) { - syncingBindingIds.Remove(binding.Id); + historySecretId = null; + secretVersions = null; + revealedVersionId = null; + revealedVersionValue = null; + await LoadSecrets(); + _ = ShowSuccessAsync("Secret restored to selected version."); + } + else + { + errorMessage = "Failed to restore version."; } } - // ── Messaging binding sync ─────────────────────────────────────── - - private async Task SyncMessagingBinding(MessagingBinding binding) - { - syncingBindingIds.Add(binding.Id); - bindingSyncErrors.Remove(binding.Id); - - try - { - await RabbitMQService.SyncMessagingBindingAsync(TenantId, binding.Id); - await LoadMessagingBindings(); - } - catch (Exception ex) - { - bindingSyncErrors[binding.Id] = ex.Message; - } - finally - { - syncingBindingIds.Remove(binding.Id); - } - } - - // ── Cache binding sync ─────────────────────────────────────────── - - private async Task SyncCacheBinding(CacheBinding binding) - { - syncingBindingIds.Add(binding.Id); - bindingSyncErrors.Remove(binding.Id); - - try - { - await RedisService.SyncCacheBindingAsync(TenantId, binding.Id); - await LoadCacheBindings(); - } - catch (Exception ex) - { - bindingSyncErrors[binding.Id] = ex.Message; - } - finally - { - syncingBindingIds.Remove(binding.Id); - } - } - - // ── Display helpers ────────────────────────────────────────────── - - private RenderFragment DbTypeBadge(DatabaseBinding binding) - { - if (binding.CnpgDatabaseId.HasValue) - return @ - PostgreSQL - ; - - if (binding.MongoDatabaseId.HasValue) - return @ - MongoDB - ; - - if (binding.RegisteredPostgresDatabaseId.HasValue) - return @ - Postgres (registered) - ; - - return @Unknown; - } - - private static string DbDisplayName(DatabaseBinding binding) - { - if (binding.CnpgDatabase is not null) return binding.CnpgDatabase.Name; - if (binding.MongoDatabase is not null) return binding.MongoDatabase.Name; - if (binding.RegisteredPostgresDatabase is not null) return binding.RegisteredPostgresDatabase.Name; - return "—"; - } } diff --git a/src/EntKube.Web/Components/Pages/Shared/Breadcrumb.razor b/src/EntKube.Web/Components/Pages/Shared/Breadcrumb.razor new file mode 100644 index 0000000..3662ecf --- /dev/null +++ b/src/EntKube.Web/Components/Pages/Shared/Breadcrumb.razor @@ -0,0 +1,62 @@ + + +@code { + [Parameter] public IReadOnlyList Items { get; set; } = []; + + public record BreadcrumbItem( + string Label, + string? Icon = null, + string? Href = null, + EventCallback OnClick = default, + bool IsActive = false); +} diff --git a/src/EntKube.Web/Components/Pages/Shared/ConfirmDialog.razor b/src/EntKube.Web/Components/Pages/Shared/ConfirmDialog.razor new file mode 100644 index 0000000..7a81fbf --- /dev/null +++ b/src/EntKube.Web/Components/Pages/Shared/ConfirmDialog.razor @@ -0,0 +1,27 @@ +@if (Visible) +{ +
+ + + @Message + +
+ + +
+
+} + +@code { + [Parameter] public bool Visible { get; set; } + [Parameter] public string Message { get; set; } = "Are you sure?"; + [Parameter] public string ConfirmText { get; set; } = "Confirm"; + [Parameter] public string CancelText { get; set; } = "Cancel"; + [Parameter] public string Variant { get; set; } = "danger"; + [Parameter] public bool IsBusy { get; set; } + [Parameter] public EventCallback OnConfirm { get; set; } + [Parameter] public EventCallback OnCancel { get; set; } +} diff --git a/src/EntKube.Web/Components/Pages/Shared/ResourceTreePanel.razor b/src/EntKube.Web/Components/Pages/Shared/ResourceTreePanel.razor index 6b83531..d01e1ff 100644 --- a/src/EntKube.Web/Components/Pages/Shared/ResourceTreePanel.razor +++ b/src/EntKube.Web/Components/Pages/Shared/ResourceTreePanel.razor @@ -1,6 +1,7 @@ @using EntKube.Web.Data @using EntKube.Web.Services @inject KubernetesOperationsService K8sOps +@inject IJSRuntime JS @* ═══════════════════════════════════════════════════════════════════ ResourceTreePanel — proper ArgoCD-style graph. @@ -138,7 +139,7 @@ .rg-panel { position: absolute; z-index: 200; - width: 340px; + width: 360px; background: #1e1e2e; border: 1px solid #3a3a4e; border-radius: 8px; @@ -180,7 +181,7 @@ } @if (!Loading && Resources is not null) { - @CountAllResources(Resources) resources + @_nodes.Count resources } @if (LastRefreshed.HasValue && !Loading) { @@ -195,6 +196,11 @@ Refresh } + } + @if (CanEvents) + { + + } + @if (nd.Node.Kind == "CronJob" && CanRestart) + { + bool cronSuspended = nd.Node.HealthStatus == HealthStatus.Suspended; + + + } @if ((nd.Node.Kind is "Deployment" or "StatefulSet" or "DaemonSet") && CanRestart) { } + @if (nd.Node.Kind == "ReplicaSet" && ParseDesiredReplicas(nd.Node.StatusMessage) == 0 && CanDelete) + { + + } @if ((nd.Node.Kind == "Pod" || nd.Node.Kind == "Job") && CanDelete) { } + @if (_hideCompleted && _hiddenJobCounts.TryGetValue(nd.Node.Id, out int hj) && hj > 0) + { + + @hj + + } + @if (_hideCompleted && _hiddenRsCounts.TryGetValue(nd.Node.Id, out int hr) && hr > 0) + { + + @hr + + }
@* ── Floating action panel for this node ── *@ @if (isActive && _panelMode is not null) { - @RenderPanel(nd.Node, cx, cy + CardH + 6) + @RenderPanel(nd.Node, ClampPanelX(cx), PanelY(cy)) } }
@@ -323,18 +371,6 @@ else if (!Loading && Error is null && Resources is not null && Resources.Count > [Parameter] public EventCallback OnRefresh { get; set; } [Parameter] public DateTime? LastRefreshed { get; set; } - private static int CountAllResources(IEnumerable? list) - { - if (list is null) return 0; - int n = 0; - foreach (DeploymentResource r in list) - { - n++; - n += CountAllResources(r.ChildResources); - } - return n; - } - // ── Access gates ──────────────────────────────────────────────────────── private bool CanLogs => AccessRole is null || AccessRole >= CustomerAccessRole.Viewer; @@ -379,6 +415,9 @@ else if (!Loading && Error is null && Resources is not null && Resources.Count > private string? _feedback; private bool _feedbackOk; private bool _fullscreen; + private bool _hideCompleted = true; + private Dictionary _hiddenJobCounts = new(); + private Dictionary _hiddenRsCounts = new(); // ── Lifecycle ──────────────────────────────────────────────────────────── @@ -393,6 +432,8 @@ else if (!Loading && Error is null && Resources is not null && Resources.Count > { _nodes.Clear(); _lines.Clear(); + _hiddenJobCounts.Clear(); + _hiddenRsCounts.Clear(); if (Resources is null || Resources.Count == 0) return; double y = 0; @@ -401,7 +442,39 @@ else if (!Loading && Error is null && Resources is not null && Resources.Count > { double top = y; - if (node.ChildResources is { Count: > 0 } kids) + List kids = node.ChildResources is { Count: > 0 } c ? new(c) : []; + + if (_hideCompleted) + { + // Completed Jobs owned by a CronJob are history — hide them, show count on parent + if (node.Kind == "CronJob") + { + List done = kids + .Where(k => k.Kind == "Job" && k.HealthStatus != HealthStatus.Progressing) + .ToList(); + if (done.Count > 0) + { + _hiddenJobCounts[node.Id] = done.Count; + kids = kids.Except(done).ToList(); + } + } + + // Completed standalone Jobs: keep the Job card but collapse its pod children + if (node.Kind == "Job" && node.HealthStatus != HealthStatus.Progressing) + kids = []; + + // Zero-replica ReplicaSets are inactive rollback history — hide them + List zeroRs = kids + .Where(k => k.Kind == "ReplicaSet" && ParseDesiredReplicas(k.StatusMessage) == 0) + .ToList(); + if (zeroRs.Count > 0) + { + _hiddenRsCounts[node.Id] = zeroRs.Count; + kids = kids.Except(zeroRs).ToList(); + } + } + + if (kids.Count > 0) foreach (DeploymentResource kid in kids) Layout(kid, col + 1, node.Id); else @@ -449,6 +522,8 @@ else if (!Loading && Error is null && Resources is not null && Resources.Count > // 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 PanelYaml(DeploymentResource n) => TogglePanel(n.Id, n, "yaml"); + private void PanelTrigger(DeploymentResource n) => TogglePanel(n.Id, n, "trigger"); 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"); @@ -464,9 +539,10 @@ else if (!Loading && Error is null && Resources is not null && Resources.Count > _activePanelNode = node; _panelMode = mode; - if (mode == "logs") _ = LoadLogsAsync(node); - if (mode == "events") _ = LoadEventsAsync(node); - if (mode == "scale") _scaleValue = ParseDesiredReplicas(node.StatusMessage) ?? 1; + if (mode == "logs") _ = LoadLogsAsync(node); + if (mode == "events") _ = LoadEventsAsync(node); + if (mode == "yaml") _ = LoadYamlAsync(node); + if (mode == "scale") _scaleValue = ParseDesiredReplicas(node.StatusMessage) ?? 1; } private void ClosePanel() @@ -495,13 +571,37 @@ else if (!Loading && Error is null && Resources is not null && Resources.Count > await FetchLogsAsync(node.Name); } + private int _panelTailLines = 500; + private ElementReference _logPreRef; + private bool _pendingLogScroll; + private async Task FetchLogsAsync(string podName) { _panelLoading = true; _panelText = null; _panelError = null; StateHasChanged(); - var r = await K8sOps.GetPodLogsAsync(DeploymentId, podName, _selectedContainer); + var r = await K8sOps.GetPodLogsAsync(DeploymentId, podName, _selectedContainer, _panelTailLines); _panelLoading = false; - if (r.IsSuccess) _panelText = r.Data ?? "(no output)"; else _panelError = r.Error; + if (r.IsSuccess) { _panelText = r.Data ?? "(no output)"; _pendingLogScroll = true; } + else _panelError = r.Error; + StateHasChanged(); + } + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (_pendingLogScroll) + { + _pendingLogScroll = false; + try { await JS.InvokeVoidAsync("scrollToBottom", _logPreRef); } catch { } + } + } + + private async Task LoadYamlAsync(DeploymentResource node) + { + _panelLoading = true; _panelText = null; _panelError = null; + StateHasChanged(); + var r = await K8sOps.GetResourceYamlAsync(DeploymentId, node.Kind, node.Name); + _panelLoading = false; + if (r.IsSuccess) _panelText = r.Data ?? "(empty)"; else _panelError = r.Error; StateHasChanged(); } @@ -543,6 +643,33 @@ else if (!Loading && Error is null && Resources is not null && Resources.Count > : r.Error ?? "Restart failed."); } + private async Task ApplyTriggerAsync(DeploymentResource node) + { + _opBusy = true; StateHasChanged(); + var r = await K8sOps.TriggerCronJobAsync(DeploymentId, node.Name); + _opBusy = false; ClosePanel(); + SetFeedback(r.IsSuccess, r.IsSuccess + ? r.Data ?? "Job created." + : r.Error ?? "Trigger failed."); + if (r.IsSuccess && OnRefresh.HasDelegate) + await OnRefresh.InvokeAsync(); + } + + private async Task ToggleSuspendCronJobAsync(DeploymentResource node) + { + if (_opBusy) return; + _opBusy = true; + StateHasChanged(); + bool wasSuspended = node.HealthStatus == HealthStatus.Suspended; + var r = await K8sOps.SetCronJobSuspendedAsync(DeploymentId, node.Name, !wasSuspended); + _opBusy = false; + SetFeedback(r.IsSuccess, r.IsSuccess + ? $"CronJob {node.Name} {(!wasSuspended ? "suspended" : "resumed")}." + : r.Error ?? "Operation failed."); + if (r.IsSuccess && OnRefresh.HasDelegate) + await OnRefresh.InvokeAsync(); + } + private async Task ApplyDeleteAsync(DeploymentResource node) { _opBusy = true; StateHasChanged(); @@ -551,8 +678,18 @@ else if (!Loading && Error is null && Resources is not null && Resources.Count > SetFeedback(r.IsSuccess, r.IsSuccess ? $"{node.Kind} {node.Name} deleted." : r.Error ?? "Delete failed."); + if (r.IsSuccess && OnRefresh.HasDelegate) + await OnRefresh.InvokeAsync(); } + private Task RefreshPanel(DeploymentResource node) => _panelMode switch + { + "logs" => FetchLogsAsync(node.Name), + "events" => LoadEventsAsync(node), + "yaml" => LoadYamlAsync(node), + _ => Task.CompletedTask + }; + private void SetFeedback(bool ok, string msg) { _feedbackOk = ok; _feedback = msg; StateHasChanged(); @@ -560,6 +697,37 @@ else if (!Loading && Error is null && Resources is not null && Resources.Count > private void ToggleFullscreen() => _fullscreen = !_fullscreen; + private void ToggleHideCompleted() + { + _hideCompleted = !_hideCompleted; + ComputeLayout(); + } + + private double ClampPanelX(double cardX) + { + const double PanelW = 360; + double canvasW = _graphW + 24; + return Math.Max(0, Math.Min(cardX, canvasW - PanelW)); + } + + private static double EstimatedPanelHeight(string? mode) => mode switch + { + "logs" => 430, + "events" => 310, + "yaml" => 410, + "scale" => 120, + "trigger" => 90, + _ => 70 // restart / delete + }; + + private double PanelY(double cardY) + { + double below = cardY + CardH + 6; + double panelH = EstimatedPanelHeight(_panelMode); + // Flip above the card when the panel would extend past the canvas bottom. + return below + panelH > _graphH ? Math.Max(0, cardY - panelH - 6) : below; + } + // ── Floating panel renderer ────────────────────────────────────────────── private RenderFragment RenderPanel(DeploymentResource node, double px, double py) => __builder => @@ -583,14 +751,32 @@ else if (!Loading && Error is null && Resources is not null && Resources.Count > { Restart — @node.Name } + else if (_panelMode == "yaml") + { + YAML — @node.Kind/@node.Name + } + else if (_panelMode == "trigger") + { + Trigger — @node.Name + } else if (_panelMode == "delete") { Delete — @node.Name } - +
+ @if (_panelMode is "logs" or "events" or "yaml") + { + + } + +
@if (_panelMode == "logs") @@ -607,6 +793,16 @@ else if (!Loading && Error is null && Resources is not null && Resources.Count > }
} +
+ Lines: + @foreach (int tl in new[] { 100, 500, 1000 }) + { + int tlCopy = tl; + + } +
@if (_panelLoading) {
@@ -617,7 +813,7 @@ else if (!Loading && Error is null && Resources is not null && Resources.Count > } else { -
@_panelText
+
@_panelText
} } @@ -637,17 +833,51 @@ else if (!Loading && Error is null && Resources is not null && Resources.Count > } } + @if (_panelMode == "yaml") + { + @if (_panelLoading) + { +
+ } + else if (_panelError is not null) + { +
@_panelError
+ } + else + { +
@_panelText
+ } + } + @if (_panelMode == "scale") { -
- Replicas for @node.Name - - - + var current = ParseDesiredReplicas(node.StatusMessage); +
+ @if (current.HasValue) + { +
+ Current: @current replica@(current == 1 ? "" : "s") +
+ } +
+
+ + + +
+ + +
} @@ -669,6 +899,7 @@ else if (!Loading && Error is null && Resources is not null && Resources.Count > Delete @node.Kind @node.Name? @if (node.Kind == "Pod") { K8s will restart it. } + @if (node.Kind == "ReplicaSet") { Old revision, safe to remove. }
} + + @if (_panelMode == "trigger") + { +
+
+ Create a manual Job from @node.Name? + @if (!string.IsNullOrEmpty(node.StatusMessage)) + { + (@node.StatusMessage) + } +
+
+ + +
+
+ } }; diff --git a/src/EntKube.Web/Components/Pages/Tenants/AlertRoutingTab.razor b/src/EntKube.Web/Components/Pages/Tenants/AlertRoutingTab.razor new file mode 100644 index 0000000..286a72d --- /dev/null +++ b/src/EntKube.Web/Components/Pages/Tenants/AlertRoutingTab.razor @@ -0,0 +1,481 @@ +@using EntKube.Web.Data +@using EntKube.Web.Services +@using Microsoft.EntityFrameworkCore +@inject AlertRoutingService AlertRoutingService +@inject IDbContextFactory DbFactory + +
+

+ Routing rules match incoming alerts and direct them to a notification channel — or suppress them so no incident is created at all. + Rules are evaluated in priority order (lower number = higher priority). The first matching rule wins. +

+
+ +
+
Routing Rules
+ +
+ +@if (showForm) +{ +
+
+ @(editId.HasValue ? "Edit Rule" : "New Rule") +
+
+
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+
+ + +
+
+
+ + @if (formSuppressIncident) + { +
+ + Matching alerts will be silently dropped — no incident is created and no notification is sent. + No channel needed. +
+ } + else + { +
+
+ + +
+
+ } + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + @if (!string.IsNullOrEmpty(formError)) + { +
@formError
+ } +
+ + +
+
+
+} + +@if (rules.Count == 0 && !isLoading) +{ + +} +else +{ +
+ + + + + + + + + + + + + @foreach (AlertRoutingRule rule in rules) + { + + + + + + + + + } + +
PriorityNameActionMatchesStatus
@rule.Priority@rule.Name + @if (rule.SuppressIncident) + { + Suppress + } + else + { + @rule.Channel?.Name + } + +
+ @if (rule.MatchClusterId.HasValue) + { + string clusterName = clusters.FirstOrDefault(c => c.Id == rule.MatchClusterId)?.Name ?? rule.MatchClusterId.Value.ToString("D")[..8]; + cluster=@clusterName + } + @if (!string.IsNullOrEmpty(rule.MatchAlertName)) + { + alert~@rule.MatchAlertName + } + @if (!string.IsNullOrEmpty(rule.MatchSeverity)) + { + string sevColor = rule.MatchSeverity == "critical" ? "danger" : rule.MatchSeverity == "warning" ? "warning" : "info"; + @rule.MatchSeverity + } + @if (!string.IsNullOrEmpty(rule.MatchNamespace)) + { + ns=@rule.MatchNamespace + } + @if (!string.IsNullOrEmpty(rule.MatchLabelKey)) + { + + @rule.MatchLabelKey@(!string.IsNullOrEmpty(rule.MatchLabelValue) ? $"={rule.MatchLabelValue}" : "") + + } + @if (!rule.MatchClusterId.HasValue && string.IsNullOrEmpty(rule.MatchAlertName) && string.IsNullOrEmpty(rule.MatchSeverity) + && string.IsNullOrEmpty(rule.MatchNamespace) && string.IsNullOrEmpty(rule.MatchLabelKey)) + { + catch-all + } +
+
+ @if (rule.IsEnabled) + { + Enabled + } + else + { + Disabled + } + +
+ + +
+
+
+} + +@* ── Test routing rules ── *@ +@if (rules.Count > 0) +{ +
+
+ Test Routing + +
+ @if (showTest) + { +
+

Enter alert attributes to see which rule would match.

+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + @if (testRan) + { + @if (testMatchedRule is not null && testMatchedRule.SuppressIncident) + { +
+ + Matched suppression rule @testMatchedRule.Name + (priority @testMatchedRule.Priority) — no incident would be created. +
+ } + else if (testMatchedRule is not null) + { +
+ + Matched rule @testMatchedRule.Name + (priority @testMatchedRule.Priority) + → channel @testMatchedRule.Channel?.Name +
+ } + else + { +
+ + No rule matched — alert would be sent to all enabled channels + (filtered by each channel's severity setting). +
+ } + } +
+ } +
+} + +@code { + [Parameter] public required Guid TenantId { get; set; } + + private List rules = []; + private List channels = []; + private List clusters = []; + private bool isLoading = true; + private bool showForm; + private Guid? editId; + + private string formName = ""; + private int formPriority; + private string formChannelId = ""; + private string formMatchClusterId = ""; + private bool formEnabled = true; + private bool formSuppressIncident; + private string formAlertName = ""; + private string formSeverity = ""; + private string formNamespace = ""; + private string formLabelKey = ""; + private string formLabelValue = ""; + private string? formError; + + // Test panel + private bool showTest; + private string testClusterId = ""; + private string testAlertName = ""; + private string testSeverity = ""; + private string testNamespace = ""; + private string testLabelKey = ""; + private string testLabelValue = ""; + private bool testRan; + private AlertRoutingRule? testMatchedRule; + + protected override async Task OnInitializedAsync() + { + await Load(); + } + + private async Task Load() + { + isLoading = true; + + using ApplicationDbContext db = DbFactory.CreateDbContext(); + channels = await db.NotificationChannels + .Where(c => c.TenantId == TenantId) + .OrderBy(c => c.Name) + .ToListAsync(); + + clusters = await db.KubernetesClusters + .Where(c => c.TenantId == TenantId) + .OrderBy(c => c.Name) + .ToListAsync(); + + rules = await AlertRoutingService.GetRulesAsync(TenantId); + isLoading = false; + } + + private void ResetForm() + { + formName = ""; + formPriority = rules.Count > 0 ? (rules.Max(r => r.Priority) + 10) : 0; + formChannelId = ""; + formMatchClusterId = ""; + formEnabled = true; + formSuppressIncident = false; + formAlertName = ""; + formSeverity = ""; + formNamespace = ""; + formLabelKey = ""; + formLabelValue = ""; + formError = null; + } + + private void StartEdit(AlertRoutingRule rule) + { + editId = rule.Id; + formName = rule.Name; + formPriority = rule.Priority; + formChannelId = rule.ChannelId?.ToString() ?? ""; + formMatchClusterId = rule.MatchClusterId?.ToString() ?? ""; + formEnabled = rule.IsEnabled; + formSuppressIncident = rule.SuppressIncident; + formAlertName = rule.MatchAlertName ?? ""; + formSeverity = rule.MatchSeverity ?? ""; + formNamespace = rule.MatchNamespace ?? ""; + formLabelKey = rule.MatchLabelKey ?? ""; + formLabelValue = rule.MatchLabelValue ?? ""; + formError = null; + showForm = true; + } + + private async Task SaveRule() + { + formError = null; + if (string.IsNullOrWhiteSpace(formName)) return; + + Guid? channelGuid = null; + if (!formSuppressIncident) + { + if (string.IsNullOrWhiteSpace(formChannelId)) return; + if (!Guid.TryParse(formChannelId, out Guid cg)) + { + formError = "Invalid channel."; + return; + } + channelGuid = cg; + } + + Guid? matchClusterGuid = null; + if (!string.IsNullOrWhiteSpace(formMatchClusterId) && Guid.TryParse(formMatchClusterId, out Guid mcg)) + matchClusterGuid = mcg; + + if (editId.HasValue) + { + await AlertRoutingService.UpdateRuleAsync(editId.Value, formName.Trim(), formPriority, channelGuid, + formAlertName, formNamespace, formSeverity, formLabelKey, formLabelValue, formEnabled, + formSuppressIncident, matchClusterGuid); + } + else + { + await AlertRoutingService.CreateRuleAsync(TenantId, formName.Trim(), formPriority, channelGuid, + formAlertName, formNamespace, formSeverity, formLabelKey, formLabelValue, + formSuppressIncident, matchClusterGuid); + } + + showForm = false; + editId = null; + await Load(); + } + + private void CancelForm() + { + showForm = false; + editId = null; + formError = null; + } + + private async Task DeleteRule(AlertRoutingRule rule) + { + await AlertRoutingService.DeleteRuleAsync(rule.Id); + await Load(); + } + + private async Task RunTest() + { + Dictionary? labels = null; + if (!string.IsNullOrWhiteSpace(testLabelKey)) + labels = new() { [testLabelKey.Trim()] = testLabelValue.Trim() }; + + Guid? clusterId = null; + if (Guid.TryParse(testClusterId, out Guid cid)) + clusterId = cid; + + testMatchedRule = await AlertRoutingService.MatchAsync( + TenantId, + clusterId, + testAlertName, + testSeverity, + string.IsNullOrWhiteSpace(testNamespace) ? null : testNamespace.Trim(), + labels); + + // Reload the channel nav property from the already-loaded rules list + if (testMatchedRule is not null) + testMatchedRule = rules.FirstOrDefault(r => r.Id == testMatchedRule.Id) ?? testMatchedRule; + + testRan = true; + } +} diff --git a/src/EntKube.Web/Components/Pages/Tenants/AppDetail.razor b/src/EntKube.Web/Components/Pages/Tenants/AppDetail.razor index 8c3b360..8e00130 100644 --- a/src/EntKube.Web/Components/Pages/Tenants/AppDetail.razor +++ b/src/EntKube.Web/Components/Pages/Tenants/AppDetail.razor @@ -1,1144 +1,100 @@ @using EntKube.Web.Data @using EntKube.Web.Services -@using Microsoft.EntityFrameworkCore -@implements IAsyncDisposable @inject TenantService TenantService -@inject DeploymentService DeploymentService -@inject CnpgService CnpgService -@inject MongoService MongoService -@inject KubernetesOperationsService K8sOps -@inject PrometheusService PrometheusService -@inject AuditService AuditService -@inject RedisService RedisService -@inject GitRepositoryService GitRepoService -@inject GitSyncService GitSyncService -@inject IDbContextFactory DbFactory @* ═══════════════════════════════════════════════════════════════════ - App Detail — a full-page view for a single application. - Shows app info, environment links, and is the extension point - for future app-level features (config, deployments, secrets, etc.). + App Detail — app-level header + environment cards. + All per-environment content (deployments, resources, governance, + metrics, external access) is handled by AppEnvironmentDetail. ═══════════════════════════════════════════════════════════════════ *@ -@* --- Back button --- *@ - - -@* --- App header --- *@ -
-
-
-
- -
-

@App.Name

-
- @CustomerName - Created @App.CreatedAt.ToString("MMM d, yyyy") -
-
-
- - @if (!confirmDelete) - { - - } -
- - @if (confirmDelete) - { -
- - - Permanently delete @App.Name and all its data? - -
- - -
-
- } -
-
- -@* --- App sub-sections (tabs for future expansion) --- *@ - - -@if (section == "environments") +@if (selectedEnvironment is not null) { -
-
-
- - Environment Links -
- Select which environments this app is deployed to. -
-
- - @if (environments is not null && environments.Count == 0) - { - - } - else if (environments is not null) - { -
- @foreach (Data.Environment env in environments) - { - bool isLinked = linkedEnvIds.Contains(env.Id); - -
-
-
- -
- @env.Name -
- @if (isLinked) - { - Active - } -
-
-
- } -
- } -
-
-
+ } - -@* ═══════════════════════════════════════════════════════════════════ - Deployments Tab — manage deployments (Manual, YAML, Helm) targeting - specific clusters. Shows ArgoCD-style sync/health status. - ═══════════════════════════════════════════════════════════════════ *@ -@if (section == "deployments") +else { - @* --- Deployment detail view (drill-down into a single deployment) --- *@ - @if (selectedDeployment is not null) - { - + @* --- Back button --- *@ + -
-
-
+ @* --- App header --- *@ +
+
+
+
+
-
- @selectedDeployment.Name -
+

@App.Name

- @GetTypeBadge(selectedDeployment.Type) - @selectedDeployment.Environment?.Name - @selectedDeployment.Cluster?.Name - @selectedDeployment.Namespace + @CustomerName + Created @App.CreatedAt.ToString("MMM d, yyyy")
-
- - - - - - @if (!confirmDeleteDeployment) - { - - } -
- @if (showEditDeployment) + @if (!confirmDelete) { -
- @if (editDeployError is not null) - { -
@editDeployError
- } -
-
- - -
-
- - -
- @if (selectedDeployment.Type == DeploymentType.HelmChart) - { -
- - -
-
- - -
-
- - -
- } -
-
- - -
-
- } - - @if (confirmDeleteDeployment) - { -
- - - Permanently delete @selectedDeployment.Name and all its manifests and resources? - -
- - -
-
- } - - @if (!showEditDeployment && selectedDeployment.Type == DeploymentType.HelmChart) - { -
- - - @selectedDeployment.HelmChartName - @selectedDeployment.HelmChartVersion - from @selectedDeployment.HelmRepoUrl - -
- } - - @if (!showEditDeployment && selectedDeployment.GitRepositoryId is not null) - { -
-
- - @selectedDeployment.GitRepository?.Name - @(selectedDeployment.GitRevision ?? selectedDeployment.GitRepository?.DefaultBranch) - @if (!string.IsNullOrEmpty(selectedDeployment.GitPath) && selectedDeployment.GitPath != ".") - { - @selectedDeployment.GitPath - } - @if (selectedDeployment.GitLastSyncedCommit is not null) - { - @selectedDeployment.GitLastSyncedCommit[..7] - } - @if (selectedDeployment.GitLastSyncedAt is not null) - { - · synced @selectedDeployment.GitLastSyncedAt.Value.ToString("yyyy-MM-dd HH:mm") - } - @if (selectedDeployment.GitAutoSync) - { - auto-sync - } -
- -
- @if (gitSyncResult is not null) - { -
- @gitSyncResult -
- } + }
+ +
+
- @* --- Sub-tabs: Manifests / Resources / Helm Values --- *@ - - - @* ── Manifests sub-tab ── *@ - @if (deploymentSection == "manifests") - { -
-
-
- - Manifests -
-
- - @if (!IsGitManaged) - { - - } -
-
-
- @if (showAddManifest && !IsGitManaged) - { -
-
-
Add YAML Manifest
-
-
- - -
-
- - -
-
- - -
-
-
- - -
-
- - -
-
-
- } - - - @if (manifests is not null && manifests.Count == 0) - { - - } - else if (manifests is not null) - { - @foreach (DeploymentManifest manifest in manifests) - { -
-
-
- @manifest.Kind - @manifest.Name - #@manifest.SortOrder -
- @if (!IsGitManaged) - { -
- @if (editingManifestId == manifest.Id) - { - - - } - else if (confirmDeleteManifestId == manifest.Id) - { - - - } - else - { - - - } -
- } -
- - @if (editingManifestId == manifest.Id) - { -
- -
- } - else - { -
- -
- } -
- } - } -
- - @if (yamlApplyOutput is not null) - { -
-
- - - @(yamlApplySuccess == true ? "Applied to cluster successfully" : "Apply failed") - - -
-
@yamlApplyOutput
-
- } -
-
- } - - @* ── Helm Values sub-tab ── *@ - @if (deploymentSection == "helm-values") - { -
-
- - Helm Values -
-
-

Override the chart's default values (YAML format).

- @if (helmValuesError is not null) - { -
@helmValuesError
- } - -
- - - @if (helmValuesSaved) - { - Saved - } -
- - @if (helmInstallOutput is not null) - { -
-
- - - @(helmInstallSuccess == true ? "Release applied successfully" : "Helm operation failed") - - -
-
@helmInstallOutput
-
- } -
-
- } - - @* ── Resources sub-tab (ArgoCD-style ownership tree) ── *@ - @if (deploymentSection == "resources") - { -
-
- - Resource Tree -
-
- -
-
- } - - @* ── Databases sub-tab ── *@ - @if (deploymentSection == "databases") - { -
-
-
- - Database Bindings -
- -
-
- @if (!string.IsNullOrEmpty(bindingError)) - { -
@bindingError
- } - - @* --- Add binding form --- *@ - @if (showAddBinding) - { -
-
-
Attach a Database
-
-
- - -
-
- - -
The K8s Secret created in @selectedDeployment?.Namespace.
-
-
-
- - -
-
-
- } - - @* --- Binding list --- *@ - - @if (databaseBindings is not null && databaseBindings.Count == 0) - { - - } - else if (databaseBindings is not null) - { -
- - - - - - - - - - - @foreach (DatabaseBinding binding in databaseBindings) - { - - - - - - - } - -
DatabaseSecret nameLast synced
- @if (binding.CnpgDatabase is not null) - { - - @binding.CnpgDatabase.CnpgCluster.Name / @binding.CnpgDatabase.Name - } - else if (binding.MongoDatabase is not null) - { - - @binding.MongoDatabase.MongoCluster.Name / @binding.MongoDatabase.Name - } - @binding.KubernetesSecretName - @(binding.LastSyncedAt.HasValue ? binding.LastSyncedAt.Value.ToString("yyyy-MM-dd HH:mm") : "Never") - - @if (confirmRemoveBindingId == binding.Id) - { - - - } - else - { - - } -
-
- } -
-
-
- } - - @* ── Cache bindings sub-tab ── *@ - @if (deploymentSection == "cache") - { -
-
-
- - Cache Bindings -
- -
-
- @if (!string.IsNullOrEmpty(cacheBindingError)) - { -
@cacheBindingError
- } - - @if (showAddCacheBinding) - { -
-
-
Attach a Redis Cluster
-
-
- - -
-
- - -
The K8s Secret created in @selectedDeployment?.Namespace.
-
-
-
- - -
-
-
- } - - - @if (cacheBindings is not null && cacheBindings.Count == 0) - { - - } - else if (cacheBindings is not null) - { -
- - - - - - - - - - - @foreach (CacheBinding binding in cacheBindings) - { - - - - - - - } - -
Redis ClusterSecret nameLast synced
- - @binding.RedisCluster.Name - @binding.RedisCluster.KubernetesCluster.Name - @binding.KubernetesSecretName - @(binding.LastSyncedAt.HasValue ? binding.LastSyncedAt.Value.ToString("yyyy-MM-dd HH:mm") : "Never") - - @if (confirmRemoveCacheBindingId == binding.Id) - { - - - } - else - { - - } -
-
- } -
-
-
- } - - @* ── Metrics sub-tab ── *@ - @if (deploymentSection == "metrics") - { -
-
- Namespace Metrics - @selectedDeployment.Namespace -
-
- @if (metricsLoading) - { -
- } - else if (metricsError is not null) - { -
- @metricsError -
- } - else if (deploymentMetrics is not null) - { -
-
-
-
@(deploymentMetrics.CpuCores >= 1 ? $"{(int)Math.Round(deploymentMetrics.CpuCores)} cores" : $"{(int)Math.Round(deploymentMetrics.CpuCores * 1000)}m")
-
CPU cores (5 m avg)
-
-
-
-
-
@deploymentMetrics.MemoryMiB.ToString("F0") MiB
-
Memory (working set)
-
-
-
-
-
@deploymentMetrics.PodCount
-
Pods in namespace
-
-
-
-
-
@deploymentMetrics.RestartCount
-
Container restarts (total)
-
-
-
-
- Queried @deploymentMetrics.QueriedAt.ToLocalTime().ToString("HH:mm:ss") -
- } -
-
- } - - @* ── Activity sub-tab ── *@ - @if (deploymentSection == "activity") - { -
-
- Activity Log -
-
- @if (activityLoading) - { -
- } - else if (deploymentAuditEvents is not null && deploymentAuditEvents.Count == 0) - { -
No activity recorded yet.
- } - else if (deploymentAuditEvents is not null) - { -
- @foreach (AuditEvent ev in deploymentAuditEvents) - { -
-
-
- @ev.ResourceKind - @ev.Action - @if (!string.IsNullOrEmpty(ev.ResourceName)) - { - @ev.ResourceName - } - @if (!string.IsNullOrEmpty(ev.Details)) - { - — @ev.Details - } -
-
-
@ev.OccurredAt.ToLocalTime().ToString("MMM d HH:mm")
- @if (!string.IsNullOrEmpty(ev.PerformedBy)) - { -
@ev.PerformedBy
- } -
-
-
- } -
- } -
-
- } - } - else + @if (showLinkPanel) { - @* --- Deployment list view --- *@ -
-
-
- - Deployments -
- +
+
+ Select which environments this app is deployed in.
-
- @* --- Create deployment form --- *@ - @if (showCreateDeployment) +
+ + @if (allEnvironments is not null && allEnvironments.Count == 0) { -
-
-
Create Deployment
-
-
- - -
-
- - -
-
- -
-
- - -
-
- - -
-
- - -
-
- - @if (newDeployType == DeploymentType.HelmChart) - { -
-
- - -
-
- - -
-
- - -
-
- } - - @if (newDeployType is DeploymentType.GitYaml or DeploymentType.GitHelm or DeploymentType.GitAppOfApps) - { -
-
- - - @if (gitRepos is not null && gitRepos.Count == 0) - { -
- No repositories registered. Add one in the Git tab first. -
- } -
-
- - -
Path inside the repo (use . for root).
-
-
- - -
-
-
-
- - -
-
- } - - @if (!string.IsNullOrEmpty(createDeployError)) - { -
@createDeployError
- } - -
- - -
-
-
+ } - - @* --- Deployment list --- *@ - - @if (deployments is not null && deployments.Count == 0) + else if (allEnvironments is not null) { - - } - else if (deployments is not null) - { -
- @foreach (AppDeployment deploy in deployments) +
+ @foreach (Data.Environment env in allEnvironments) { + bool isLinked = linkedEnvIds.Contains(env.Id);
-
-
-
- - @deploy.Name - -
- @GetSyncBadge(deploy.SyncStatus) - @GetHealthBadge(deploy.HealthStatus) -
-
-
- @GetTypeBadge(deploy.Type) - @deploy.Environment?.Name - @deploy.Cluster?.Name - @deploy.Namespace -
- - @if (!string.IsNullOrEmpty(deploy.StatusMessage)) - { -
@deploy.StatusMessage
- } +
+
+ + @env.Name + @if (isLinked) { Active }
@@ -1149,89 +105,61 @@
} -} -@* ═══════════════════════════════════════════════════════════════════ - App Resources Tab — ArgoCD-style grid of all deployments with live trees. - ═══════════════════════════════════════════════════════════════════ *@ -@if (section == "app-resources") -{ - -} - -@* ═══════════════════════════════════════════════════════════════════ - Governance Tab — namespace, quotas, network policies, RBAC. - ═══════════════════════════════════════════════════════════════════ *@ -@if (section == "governance") -{ - -} - -@* ═══════════════════════════════════════════════════════════════════ - App Metrics Tab — aggregate CPU, memory, and pods across all deployments. - ═══════════════════════════════════════════════════════════════════ *@ -@if (section == "app-metrics") -{ -
-
-
- - App Metrics - aggregate across all deployments + + @if (linkedEnvironments is not null && linkedEnvironments.Count == 0) + { +
+
+
-
-
- @if (appMetricsLoading) + } + else if (linkedEnvironments is not null) + { +
+ @foreach (Data.Environment env in linkedEnvironments) { -
- } - else if (appMetricsError is not null) - { -
- @appMetricsError -
- } - else if (appMetrics is not null) - { -
-
-
-
- @(appMetrics.CpuCores >= 1 ? $"{(int)Math.Round(appMetrics.CpuCores)} cores" : $"{(int)Math.Round(appMetrics.CpuCores * 1000)}m") + int deployCount = deployCountByEnv.GetValueOrDefault(env.Id, 0); + +
+
+
+
+
+ +
@env.Name
+
+ +
+
+ @if (deployCount > 0) + { + @deployCount deployment@(deployCount == 1 ? "" : "s") + } + else + { + No deployments + }
-
CPU (5 m avg)
-
-
-
@appMetrics.MemoryMiB.ToString("F0") MiB
-
Memory (working set)
-
-
-
-
-
@appMetrics.PodCount
-
Pods running
-
-
-
-
-
@appMetrics.RestartCount
-
Container restarts
-
-
-
-
- Queried @appMetrics.QueriedAt.ToLocalTime().ToString("HH:mm:ss")
}
-
+ } + } + + @code { [Parameter] public required Data.App App { get; set; } [Parameter] public Guid TenantId { get; set; } @@ -1239,742 +167,53 @@ [Parameter] public EventCallback OnBack { get; set; } [Parameter] public EventCallback OnDeleted { get; set; } - private string section = "environments"; private bool confirmDelete; + private bool deleting; + private bool showLinkPanel; - // Environments - private List? environments; + private List? allEnvironments; + private List? linkedEnvironments; private HashSet linkedEnvIds = new(); + private Dictionary deployCountByEnv = new(); - // Deployments - private List? deployments; - private List? clusters; - private AppDeployment? selectedDeployment; - private string deploymentSection = "manifests"; - private bool IsGitManaged => selectedDeployment?.Type is - DeploymentType.GitYaml or DeploymentType.GitHelm or DeploymentType.GitAppOfApps; - private bool confirmDeleteDeployment; - - // Create deployment form - private bool showCreateDeployment; - private string newDeployName = ""; - private DeploymentType newDeployType = DeploymentType.Manual; - private Guid newDeployEnvId; - private Guid newDeployClusterId; - private string newDeployNamespace = ""; - private string newHelmRepoUrl = ""; - private string newHelmChartName = ""; - private string newHelmChartVersion = ""; - private string? createDeployError; - - // Git fields for create form - private List? gitRepos; - private Guid newGitRepoId; - private string newGitPath = "."; - private string newGitRevision = ""; - private bool newGitAutoSync = true; - - // Git sync panel on deployment detail - private bool syncingGit; - private string? gitSyncResult; - private bool gitSyncSuccess; - - // Edit deployment form - private bool showEditDeployment; - private string editDeployName = ""; - private string editDeployNamespace = ""; - private string editHelmRepoUrl = ""; - private string editHelmChartName = ""; - private string editHelmChartVersion = ""; - private string? editDeployError; - - // Manifest management - private List? manifests; - private bool showAddManifest; - private bool savingManifest; - private string newManifestKind = ""; - private string newManifestName = ""; - private string newManifestYaml = ""; - private int newManifestSortOrder; - private Guid? editingManifestId; - private string editManifestYaml = ""; - - // Helm values - private string helmValuesYaml = ""; - private bool savingHelmValues; - private bool helmValuesSaved; - private string? helmValuesError; - - // Helm install / upgrade - private bool installingHelm; - private bool? helmInstallSuccess; - private string? helmInstallOutput; - - // YAML apply - private bool applyingYaml; - private bool? yamlApplySuccess; - private string? yamlApplyOutput; - - // Resource tree - private List? resourceTree; - private string? resourcesError; - private DateTime? resourceTreeRefreshedAt; - - // Metrics - private DeploymentMetricsSummary? deploymentMetrics; - private bool metricsLoading; - private string? metricsError; - - // Activity - private List? deploymentAuditEvents; - private bool activityLoading; - - // App-level aggregate metrics - private DeploymentMetricsSummary? appMetrics; - private bool appMetricsLoading; - private string? appMetricsError; - - // 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; - private List? availableRedisClusters; - private bool showAddCacheBinding; - private Guid newCacheBindingClusterId; - private string newCacheBindingSecretName = "redis-cache"; - private string? cacheBindingError; - private Guid? confirmRemoveCacheBindingId; - - // Manifest confirm delete - private Guid? confirmDeleteManifestId; + private Data.Environment? selectedEnvironment; protected override async Task OnInitializedAsync() { await LoadEnvironments(); } - public ValueTask DisposeAsync() => ValueTask.CompletedTask; - - // ──────── App ──────── - private async Task DeleteApp() { + deleting = true; await TenantService.DeleteAppAsync(App.Id); + deleting = false; + confirmDelete = false; await OnDeleted.InvokeAsync(); } - // ──────── Environments ──────── - private async Task LoadEnvironments() { - environments = await TenantService.GetEnvironmentsAsync(TenantId); + allEnvironments = await TenantService.GetEnvironmentsAsync(TenantId); - // Refresh the app to get current environment links. List apps = await TenantService.GetAppsAsync(App.CustomerId); Data.App? freshApp = apps.FirstOrDefault(a => a.Id == App.Id); - linkedEnvIds = freshApp?.AppEnvironments.Select(ae => ae.EnvironmentId).ToHashSet() ?? new(); + + linkedEnvironments = allEnvironments.Where(e => linkedEnvIds.Contains(e.Id)).ToList(); + + deployCountByEnv = (freshApp?.Deployments ?? []) + .GroupBy(d => d.EnvironmentId) + .ToDictionary(g => g.Key, g => g.Count()); } private async Task ToggleEnvironment(Guid envId, bool currentlyLinked) { if (currentlyLinked) - { await TenantService.UnlinkAppFromEnvironmentAsync(App.Id, envId); - } else - { await TenantService.LinkAppToEnvironmentAsync(App.Id, envId); - } await LoadEnvironments(); } - - // ──────── Deployments ──────── - - private async Task SwitchToDeployments() - { - section = "deployments"; - - if (deployments is null) - { - await LoadDeployments(); - } - - if (clusters is null) - { - clusters = await TenantService.GetClustersAsync(TenantId); - } - } - - private async Task LoadDeployments() - { - deployments = await DeploymentService.GetDeploymentsAsync(App.Id); - } - - private async Task OnNewDeployTypeChanged() - { - bool isGit = newDeployType is DeploymentType.GitYaml - or DeploymentType.GitHelm or DeploymentType.GitAppOfApps; - - if (isGit && gitRepos is null) - gitRepos = await GitRepoService.GetRepositoriesAsync(TenantId); - } - - private async Task CreateDeployment() - { - createDeployError = null; - - bool isGit = newDeployType is DeploymentType.GitYaml - or DeploymentType.GitHelm or DeploymentType.GitAppOfApps; - - if (isGit && newGitRepoId == Guid.Empty) - { - createDeployError = "Select a Git repository."; - return; - } - - try - { - await DeploymentService.CreateDeploymentAsync( - App.Id, newDeployName, newDeployType, - newDeployEnvId, newDeployClusterId, newDeployNamespace, - newDeployType == DeploymentType.HelmChart ? newHelmRepoUrl : null, - newDeployType == DeploymentType.HelmChart ? newHelmChartName : null, - newDeployType == DeploymentType.HelmChart ? newHelmChartVersion : null, - gitRepositoryId: isGit ? newGitRepoId : null, - gitPath: isGit ? newGitPath : null, - gitRevision: isGit && !string.IsNullOrWhiteSpace(newGitRevision) ? newGitRevision : null, - gitAutoSync: isGit && newGitAutoSync); - - showCreateDeployment = false; - newDeployName = ""; newDeployNamespace = ""; - newHelmRepoUrl = ""; newHelmChartName = ""; newHelmChartVersion = ""; - newGitRepoId = Guid.Empty; newGitPath = "."; newGitRevision = ""; newGitAutoSync = true; - await LoadDeployments(); - } - catch (DbUpdateException) - { - createDeployError = $"A deployment named '{newDeployName}' already exists for this app."; - } - } - - private async Task TriggerGitSync() - { - if (selectedDeployment is null) return; - syncingGit = true; - gitSyncResult = null; - StateHasChanged(); - try - { - GitSyncResult result = await GitSyncService.SyncDeploymentAsync(selectedDeployment.Id); - gitSyncSuccess = result.IsSuccess; - gitSyncResult = result.AlreadyCurrent - ? "Already up to date." - : result.IsSuccess - ? $"Synced to {result.CommitSha[..Math.Min(7, result.CommitSha.Length)]} — {result.CommitMessage}" - : result.Error ?? "Sync failed."; - - if (result.IsSuccess) - { - // Reload to get updated LastSyncedAt / commit - await LoadDeployments(); - selectedDeployment = deployments?.FirstOrDefault(d => d.Id == selectedDeployment.Id) - ?? selectedDeployment; - } - } - catch (Exception ex) { gitSyncSuccess = false; gitSyncResult = ex.Message; } - finally { syncingGit = false; } - } - - private async Task SelectDeployment(AppDeployment deploy) - { - selectedDeployment = deploy; - confirmDeleteDeployment = false; - helmValuesSaved = false; - helmValuesError = null; - helmInstallOutput = null; - helmInstallSuccess = null; - yamlApplyOutput = null; - yamlApplySuccess = null; - savingManifest = false; - editingManifestId = null; - resourceTree = null; - resourcesError = null; - deploymentMetrics = null; - metricsError = null; - deploymentAuditEvents = null; - - gitSyncResult = null; - syncingGit = false; - - // Default to the appropriate sub-tab based on type. - if (deploy.Type == DeploymentType.HelmChart || deploy.Type == DeploymentType.GitHelm) - { - deploymentSection = "helm-values"; - helmValuesYaml = deploy.HelmValues ?? ""; - } - else if (deploy.Type == DeploymentType.GitAppOfApps) - { - deploymentSection = "resources"; - await OpenResourcesTab(); - } - else - { - deploymentSection = "manifests"; - await LoadManifests(); - } - } - - private async Task DeleteDeployment() - { - if (selectedDeployment is not null) - { - await DeploymentService.DeleteDeploymentAsync(selectedDeployment.Id); - selectedDeployment = null; - confirmDeleteDeployment = false; - await LoadDeployments(); - } - } - - private void StartEditDeployment() - { - if (selectedDeployment is null) return; - editDeployName = selectedDeployment.Name; - editDeployNamespace = selectedDeployment.Namespace; - editHelmRepoUrl = selectedDeployment.HelmRepoUrl ?? ""; - editHelmChartName = selectedDeployment.HelmChartName ?? ""; - editHelmChartVersion = selectedDeployment.HelmChartVersion ?? ""; - editDeployError = null; - showEditDeployment = true; - confirmDeleteDeployment = false; - } - - private async Task SaveDeployment() - { - if (selectedDeployment is null) return; - - editDeployError = null; - - try - { - await DeploymentService.UpdateDeploymentAsync( - selectedDeployment.Id, - editDeployName, - editDeployNamespace, - selectedDeployment.Type == DeploymentType.HelmChart ? editHelmRepoUrl : null, - selectedDeployment.Type == DeploymentType.HelmChart ? editHelmChartName : null, - selectedDeployment.Type == DeploymentType.HelmChart ? editHelmChartVersion : null); - - await LoadDeployments(); - selectedDeployment = deployments?.FirstOrDefault(d => d.Id == selectedDeployment.Id) ?? selectedDeployment; - showEditDeployment = false; - } - catch (Exception ex) - { - editDeployError = ex.Message; - } - } - - // ──────── Manifests ──────── - - private async Task LoadManifests() - { - if (selectedDeployment is not null) - { - manifests = await DeploymentService.GetManifestsAsync(selectedDeployment.Id); - } - } - - private async Task AddManifest() - { - if (selectedDeployment is null) return; - - savingManifest = true; - try - { - await DeploymentService.AddManifestAsync( - selectedDeployment.Id, newManifestKind, newManifestName, newManifestYaml, newManifestSortOrder); - showAddManifest = false; - newManifestKind = ""; - newManifestName = ""; - newManifestYaml = ""; - newManifestSortOrder = 0; - await LoadManifests(); - } - finally { savingManifest = false; } - } - - private void StartEditManifest(DeploymentManifest manifest) - { - editingManifestId = manifest.Id; - editManifestYaml = manifest.YamlContent; - } - - private async Task SaveManifest(Guid manifestId) - { - savingManifest = true; - try - { - await DeploymentService.UpdateManifestAsync(manifestId, editManifestYaml); - editingManifestId = null; - await LoadManifests(); - } - finally { savingManifest = false; } - } - - private async Task DeleteManifest(Guid manifestId) - { - confirmDeleteManifestId = null; - await DeploymentService.DeleteManifestAsync(manifestId); - await LoadManifests(); - } - - // ──────── YAML Apply ──────── - - private async Task ApplyYamlDeployment() - { - if (selectedDeployment is null) return; - - applyingYaml = true; - yamlApplyOutput = null; - yamlApplySuccess = null; - - try - { - KubernetesOperationResult result = - await K8sOps.ApplyYamlDeploymentAsync(selectedDeployment.Id); - - yamlApplySuccess = result.IsSuccess; - yamlApplyOutput = result.IsSuccess ? result.Data : result.Error; - } - finally { applyingYaml = false; } - } - - // ──────── Helm Install / Upgrade ──────── - - private async Task InstallOrUpgradeHelm() - { - if (selectedDeployment is null) return; - - installingHelm = true; - helmInstallOutput = null; - helmInstallSuccess = null; - - try - { - KubernetesOperationResult result = - await K8sOps.HelmInstallOrUpgradeAsync(selectedDeployment.Id); - - helmInstallSuccess = result.IsSuccess; - helmInstallOutput = result.IsSuccess ? result.Data : result.Error; - } - finally { installingHelm = false; } - } - - // ──────── Helm Values ──────── - - private async Task SaveHelmValues() - { - savingHelmValues = true; - helmValuesSaved = false; - helmValuesError = null; - try - { - if (selectedDeployment is not null) - { - await DeploymentService.UpdateHelmValuesAsync(selectedDeployment.Id, helmValuesYaml); - selectedDeployment.HelmValues = helmValuesYaml; - } - helmValuesSaved = true; - } - catch (Exception ex) { helmValuesError = ex.Message; } - finally { savingHelmValues = false; } - } - - // ──────── Resources ──────── - - private async Task OpenResourcesTab() - { - deploymentSection = "resources"; - resourceTree = null; // trigger spinner - resourcesError = null; - - if (selectedDeployment is not null) - { - KubernetesOperationResult> result = - await K8sOps.GetLiveResourcesAsync(selectedDeployment.Id); - - if (result.IsSuccess) - { - resourceTree = result.Data ?? []; - resourceTreeRefreshedAt = DateTime.UtcNow; - - // Derive live status from the resource tree and persist it so - // the header badges (and the deployment list card) show real values. - (SyncStatus sync, HealthStatus health) = - KubernetesOperationsService.ComputeStatusFromResources(resourceTree); - - await DeploymentService.UpdateDeploymentStatusAsync( - selectedDeployment.Id, sync, health); - - // Update in-memory objects so UI refreshes immediately without a reload. - selectedDeployment.SyncStatus = sync; - selectedDeployment.HealthStatus = health; - - AppDeployment? listEntry = deployments?.FirstOrDefault(d => d.Id == selectedDeployment.Id); - if (listEntry is not null) - { - listEntry.SyncStatus = sync; - listEntry.HealthStatus = health; - } - } - else - { - resourcesError = result.Error; - resourceTree = []; - } - } - } - - // ──────── Database bindings ──────── - - private async Task OpenDatabasesTab() - { - deploymentSection = "databases"; - - if (selectedDeployment is not null) - { - databaseBindings = await CnpgService.GetDatabaseBindingsAsync(selectedDeployment.Id); - - if (availableCnpgDatabases is null) - { - List clusters = await CnpgService.GetClustersAsync(TenantId); - availableCnpgDatabases = clusters - .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(); - } - } - } - - private async Task OpenMetricsTab() - { - deploymentSection = "metrics"; - if (selectedDeployment is null) return; - - metricsLoading = true; - metricsError = null; - deploymentMetrics = null; - StateHasChanged(); - - KubernetesOperationResult result = - await PrometheusService.GetDeploymentMetricsAsync(selectedDeployment.Id); - - if (result.IsSuccess) - deploymentMetrics = result.Data; - else - metricsError = result.Error; - - metricsLoading = false; - } - - private async Task OpenActivityTab() - { - deploymentSection = "activity"; - if (selectedDeployment is null) return; - - activityLoading = true; - deploymentAuditEvents = null; - StateHasChanged(); - - deploymentAuditEvents = await AuditService.GetDeploymentEventsAsync(selectedDeployment.Id); - activityLoading = false; - } - - private async Task OpenAppMetricsSection() - { - section = "app-metrics"; - appMetricsLoading = true; - appMetricsError = null; - appMetrics = null; - StateHasChanged(); - - KubernetesOperationResult result = - await PrometheusService.GetAppMetricsAsync(App.Id); - - if (result.IsSuccess) - appMetrics = result.Data; - else - appMetricsError = result.Error; - - appMetricsLoading = false; - } - - private async Task AddDatabaseBinding() - { - if (selectedDeployment is null || newBindingDatabaseId == Guid.Empty) - { - return; - } - - bindingError = null; - - try - { - (MongoCluster cluster, MongoDatabase _)? mongoMatch = availableMongoDatabases? - .FirstOrDefault(x => x.Database.Id == newBindingDatabaseId); - - if (mongoMatch.HasValue) - { - 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; - newBindingDatabaseId = Guid.Empty; - newBindingSecretName = ""; - databaseBindings = await CnpgService.GetDatabaseBindingsAsync(selectedDeployment.Id); - } - catch (Exception ex) - { - bindingError = $"Failed to attach database: {ex.Message}"; - } - } - - private async Task RemoveDatabaseBinding(Guid bindingId) - { - confirmRemoveBindingId = null; - bindingError = null; - - try - { - await CnpgService.RemoveDatabaseBindingAsync(bindingId); - - if (selectedDeployment is not null) - { - databaseBindings = await CnpgService.GetDatabaseBindingsAsync(selectedDeployment.Id); - } - } - catch (Exception ex) - { - bindingError = $"Failed to detach database: {ex.Message}"; - } - } - - // ──────── Cache bindings ──────── - - private async Task OpenCacheTab() - { - deploymentSection = "cache"; - - if (cacheBindings is null && selectedDeployment is not null) - { - cacheBindings = await RedisService.GetCacheBindingsForDeploymentAsync(selectedDeployment.Id); - availableRedisClusters = await RedisService.GetClustersAsync(TenantId); - } - } - - private async Task AddCacheBinding() - { - if (selectedDeployment is null || newCacheBindingClusterId == Guid.Empty) return; - - cacheBindingError = null; - - try - { - await RedisService.CreateCacheBindingAsync( - TenantId, newCacheBindingClusterId, selectedDeployment.Id, - newCacheBindingSecretName.Trim()); - - showAddCacheBinding = false; - newCacheBindingClusterId = Guid.Empty; - newCacheBindingSecretName = "redis-cache"; - cacheBindings = await RedisService.GetCacheBindingsForDeploymentAsync(selectedDeployment.Id); - } - catch (Exception ex) - { - cacheBindingError = $"Failed to attach Redis cluster: {ex.Message}"; - } - } - - private async Task RemoveCacheBinding(Guid bindingId) - { - confirmRemoveCacheBindingId = null; - cacheBindingError = null; - - try - { - await RedisService.DeleteCacheBindingAsync(TenantId, bindingId); - - if (selectedDeployment is not null) - cacheBindings = await RedisService.GetCacheBindingsForDeploymentAsync(selectedDeployment.Id); - } - catch (Exception ex) - { - cacheBindingError = $"Failed to detach: {ex.Message}"; - } - } - - // ──────── Rendering helpers ──────── - - private RenderFragment GetTypeBadge(DeploymentType type) => type switch - { - DeploymentType.Manual => @Manual, - DeploymentType.Yaml => @YAML, - DeploymentType.HelmChart => @Helm, - _ => @Unknown - }; - - private RenderFragment GetSyncBadge(SyncStatus status) => status switch - { - SyncStatus.Synced => @Synced, - SyncStatus.OutOfSync => @OutOfSync, - SyncStatus.Syncing => @Syncing, - SyncStatus.Failed => @Failed, - _ => @Unknown - }; - - private RenderFragment GetHealthBadge(HealthStatus status) => status switch - { - HealthStatus.Healthy => @Healthy, - HealthStatus.Progressing => @Progressing, - HealthStatus.Degraded => @Degraded, - HealthStatus.Missing => @Missing, - HealthStatus.Suspended => @Suspended, - _ => @Unknown - }; - } diff --git a/src/EntKube.Web/Components/Pages/Tenants/AppEnvironmentDetail.razor b/src/EntKube.Web/Components/Pages/Tenants/AppEnvironmentDetail.razor new file mode 100644 index 0000000..d524df3 --- /dev/null +++ b/src/EntKube.Web/Components/Pages/Tenants/AppEnvironmentDetail.razor @@ -0,0 +1,2910 @@ +@using EntKube.Web.Data +@using EntKube.Web.Services +@using EntKube.Web.Components.Pages.Portal +@using Microsoft.EntityFrameworkCore +@implements IAsyncDisposable +@inject DeploymentService DeploymentService +@inject CnpgService CnpgService +@inject MongoService MongoService +@inject KubernetesOperationsService K8sOps +@inject PrometheusService PrometheusService +@inject AuditService AuditService +@inject RedisService RedisService +@inject GitSyncService GitSyncService +@inject TenantService TenantService +@inject AppGovernanceService AppGovernanceService +@inject CustomerGitService CustomerGitService +@inject IDbContextFactory DbFactory +@inject RabbitMQService RabbitMQService +@inject StorageService StorageService +@inject RegisteredPostgresService RegisteredPostgresService +@inject VaultService VaultService +@inject DeploymentStatusNotifier StatusNotifier + +@* ═══════════════════════════════════════════════════════════════════ + Per-environment detail view. All tabs — deployments, resources, + governance, metrics, and external access — are scoped to this + single environment. Used in both tenant-admin and portal contexts. + ═══════════════════════════════════════════════════════════════════ *@ + +@* --- Back button --- *@ + + +@* --- Environment header --- *@ +
+
+
+ +
+
@Environment.Name
+ @App.Name +
+ @if (deployments is not null) + { +
+ @foreach (AppDeployment d in deployments) + { + + @GetSyncBadge(d.SyncStatus) + @GetHealthBadge(d.HealthStatus) + + } +
+ } +
+
+
+ +@* --- Per-environment tabs --- *@ + + +@* ══════════════════════════════════════════════════════ + Deployments tab + ══════════════════════════════════════════════════════ *@ +@if (section == "deployments") +{ + @if (selectedDeployment is not null) + { + @* ── Deployment detail drill-down ── *@ + + +
+
+
+
+
+ @selectedDeployment.Name +
+
+ @GetTypeBadge(selectedDeployment.Type) + @selectedDeployment.Cluster?.Name + @selectedDeployment.Namespace +
+
+
+ + + + @if (!confirmDeleteDeployment) + { + + } +
+
+ + @if (showEditDeployment) + { +
+ @if (editDeployError is not null) + { +
@editDeployError
+ } +
+
+ + +
+
+ + @if (!string.IsNullOrWhiteSpace(governanceNamespace)) + { + +
Locked by governance policy.
+ } + else + { + + } +
+ @if (selectedDeployment.Type == DeploymentType.HelmChart) + { +
+ + +
+
+ + +
+
+ + +
+ } +
+
+ + +
+
+ } + + @if (confirmDeleteDeployment) + { +
+ + + Permanently delete @selectedDeployment.Name? + +
+ + +
+
+ } + + @if (!showEditDeployment && selectedDeployment.Type == DeploymentType.HelmChart) + { +
+ + + @selectedDeployment.HelmChartName + @selectedDeployment.HelmChartVersion + from @selectedDeployment.HelmRepoUrl + +
+ } + + @if (!showEditDeployment && IsGitManaged) + { + @if (showEditGitSettings) + { +
+
Git Source
+ @if (editGitError is not null) + { +
@editGitError
+ } + @if (gitRepoPolicies is { Count: > 0 }) + { +
+ + Repo URL policy active. + Allowed patterns: + @foreach (CustomerGitRepoPolicy p in gitRepoPolicies) + { + @p.UrlPattern + } +
+ } +
+
+ + + @if (editGitUrlError is not null) + { +
@editGitUrlError
+ } +
+
+ + +
+
+ + +
+
+
+
+ + +
+
+
+ + +
+
+ } + else if (selectedDeployment.GitUrl is not null) + { +
+
+ + @selectedDeployment.GitUrl + @(selectedDeployment.GitRevision ?? "main") + @if (!string.IsNullOrEmpty(selectedDeployment.GitPath) && selectedDeployment.GitPath != ".") + { + @selectedDeployment.GitPath + } + @if (selectedDeployment.GitLastSyncedCommit is not null) + { + @selectedDeployment.GitLastSyncedCommit[..7] + } + @if (selectedDeployment.GitAutoSync) + { + auto-sync + } +
+
+ + +
+
+ @if (gitSyncResult is not null) + { +
+ @gitSyncResult +
+ } + } + else + { +
+ No git source configured. + +
+ } + } +
+
+ + @* ── Deployment sub-tabs ── *@ + + + @* Manifests *@ + @if (deploymentSection == "manifests") + { +
+
+
Manifests
+
+ + @if (!IsGitManaged) + { + + } +
+
+
+ @if (showAddManifest && !IsGitManaged) + { +
+
+
Add YAML Manifest
+
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+
+ + +
+
+
+ } + + @if (manifests is not null && manifests.Count == 0) + { + + } + else if (manifests is not null) + { + @foreach (DeploymentManifest manifest in manifests) + { +
+
+
+ @manifest.Kind + @manifest.Name + #@manifest.SortOrder +
+ @if (!IsGitManaged) + { +
+ @if (editingManifestId == manifest.Id) + { + + + } + else if (confirmDeleteManifestId == manifest.Id) + { + + + } + else + { + + + } +
+ } +
+ @if (editingManifestId == manifest.Id) + { +
+ } + else + { +
+ +
+ } +
+ } + } +
+ @if (yamlApplyOutput is not null) + { +
+
+ + + @(yamlApplySuccess == true ? "Applied successfully" : "Apply failed") + + +
+
@yamlApplyOutput
+
+ } +
+
+ } + + @* Helm Values *@ + @if (deploymentSection == "helm-values") + { +
+
+ Helm Values +
+
+

Override the chart's default values (YAML format).

+ @if (helmValuesError is not null) {
@helmValuesError
} + +
+ + + @if (helmValuesSaved) { Saved } +
+ @if (helmInstallOutput is not null) + { +
+
+ + + @(helmInstallSuccess == true ? "Release applied successfully" : "Helm operation failed") + + +
+
@helmInstallOutput
+
+ } +
+
+ } + + @* Deployment Resources *@ + @if (deploymentSection == "resources") + { +
+
+ Resource Tree +
+
+ +
+
+ } + + @* Deployment Metrics *@ + @if (deploymentSection == "deployment-metrics") + { +
+
+ Namespace Metrics + @selectedDeployment.Namespace +
+
+ @if (deploymentMetricsLoading) {
} + else if (deploymentMetricsError is not null) + { +
+ @deploymentMetricsError +
+ } + else if (deploymentMetrics is not null) + { +
+
+
+
@(deploymentMetrics.CpuCores >= 1 ? $"{(int)Math.Round(deploymentMetrics.CpuCores)} cores" : $"{(int)Math.Round(deploymentMetrics.CpuCores * 1000)}m")
+
CPU cores (5 m avg)
+
+
+
+
+
@deploymentMetrics.MemoryMiB.ToString("F0") MiB
+
Memory (working set)
+
+
+
+
+
@deploymentMetrics.PodCount
+
Pods in namespace
+
+
+
+
+
@deploymentMetrics.RestartCount
+
Container restarts
+
+
+
+
Queried @deploymentMetrics.QueriedAt.ToLocalTime().ToString("HH:mm:ss")
+ } +
+
+ } + + @* Activity *@ + @if (deploymentSection == "activity") + { +
+
+ Activity Log +
+
+ @if (activityLoading) {
} + else if (deploymentAuditEvents is not null && deploymentAuditEvents.Count == 0) + { +
No activity recorded yet.
+ } + else if (deploymentAuditEvents is not null) + { +
+ @foreach (AuditEvent ev in deploymentAuditEvents) + { +
+
+
+ @ev.ResourceKind + @ev.Action + @if (!string.IsNullOrEmpty(ev.ResourceName)) { @ev.ResourceName } + @if (!string.IsNullOrEmpty(ev.Details)) { — @ev.Details } +
+
+
@ev.OccurredAt.ToLocalTime().ToString("MMM d HH:mm")
+ @if (!string.IsNullOrEmpty(ev.PerformedBy)) {
@ev.PerformedBy
} +
+
+
+ } +
+ } +
+
+ } + } + else + { + @* ── Deployment list ── *@ +
+
+
Deployments
+ +
+
+ @if (showCreateDeployment) + { +
+
+
Create Deployment
+
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ + @if (!string.IsNullOrWhiteSpace(governanceNamespace)) + { + +
Locked by governance policy.
+ } + else + { + + } +
+
+ @if (newDeployType == DeploymentType.HelmChart) + { +
+
+ + +
+
+ + +
+
+ + +
+
+ } + @if (newDeployType is DeploymentType.GitYaml or DeploymentType.GitHelm or DeploymentType.GitAppOfApps) + { +
+
+ + + @if (newGitUrlError is not null) + { +
@newGitUrlError
+ } + @if (gitRepoPolicies is { Count: > 0 }) + { +
+ Allowed: + @foreach (CustomerGitRepoPolicy p in gitRepoPolicies) + { + @p.UrlPattern + } +
+ } +
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ } + @if (!string.IsNullOrEmpty(createDeployError)) {
@createDeployError
} +
+ + +
+
+
+ } + + + @if (deployments is not null && deployments.Count == 0) + { + + } + else if (deployments is not null) + { +
+ @foreach (AppDeployment deploy in deployments) + { +
+
+
+
+ + @deploy.Name + +
+ @GetSyncBadge(deploy.SyncStatus) + @GetHealthBadge(deploy.HealthStatus) +
+
+
+ @GetTypeBadge(deploy.Type) + @deploy.Cluster?.Name + @deploy.Namespace +
+
+
+
+ } +
+ } +
+
+
+ } +} + +@* ══════════════════════════════════════════════════════ + Resources tab + ══════════════════════════════════════════════════════ *@ +@if (section == "resources") +{ + +} + +@* ══════════════════════════════════════════════════════ + Governance tab — full editor for tenant admins, read-only for portal + ══════════════════════════════════════════════════════ *@ +@if (section == "governance") +{ + @if (IsPortalView) + { + + } + else + { + + } +} + +@* ══════════════════════════════════════════════════════ + Env-aggregate metrics tab + ══════════════════════════════════════════════════════ *@ +@if (section == "metrics") +{ +
+
+
+ + @Environment.Name Metrics + aggregate across all deployments in this environment +
+ +
+
+ @if (envMetricsLoading) {
} + else if (envMetricsError is not null) + { +
+ @envMetricsError +
+ } + else if (envMetrics is not null) + { +
+
+
+
+ @(envMetrics.CpuCores >= 1 ? $"{(int)Math.Round(envMetrics.CpuCores)} cores" : $"{(int)Math.Round(envMetrics.CpuCores * 1000)}m") +
+
CPU (5 m avg)
+
+
+
+
+
@envMetrics.MemoryMiB.ToString("F0") MiB
+
Memory (working set)
+
+
+
+
+
@envMetrics.PodCount
+
Pods running
+
+
+
+
+
@envMetrics.RestartCount
+
Container restarts
+
+
+
+
Queried @envMetrics.QueriedAt.ToLocalTime().ToString("HH:mm:ss")
+ } +
+
+} + +@* ══════════════════════════════════════════════════════ + External Access tab + ══════════════════════════════════════════════════════ *@ +@if (section == "external-access") +{ + +} + +@* ══════════════════════════════════════════════════════ + Git Access tab — URL policies + credentials for this customer/environment + ══════════════════════════════════════════════════════ *@ +@if (section == "git-access") +{ +
+ @* --- URL Policies --- *@ +
+
+
+ + Git Repo URL Policies +
Allowed repository URL patterns. Supports * (within 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 + { + + } +
+ } + } +
+
+
+
+ + @* --- Credentials --- *@ +
+
+
+ + Git Credentials +
Reusable credential sets for this customer's repositories in this environment.
+
+
+
+
+ + +
+
+ + +
+
+ + +
+ @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 (cred.UrlPattern is not null) + { + @cred.UrlPattern + } +
+ @if (confirmDeleteCredentialId == cred.Id) + { +
+ + +
+ } + else + { + + } +
+ @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 +
+ } +
+ } +
+ } + } +
+
+
+
+
+} + +@* ══════════════════════════════════════════════════════ + Vault tab — app secrets scoped to this environment + ══════════════════════════════════════════════════════ *@ +@if (section == "vault") +{ + +} + +@* ══════════════════════════════════════════════════════ + Databases tab + ══════════════════════════════════════════════════════ *@ +@if (section == "databases") +{ + @if (!dbBindingsLoaded) + { +
+ } + else + { + @if (!IsPortalView) + { +
+
+
Attach Database
+ +
+ @if (showDbAttachForm) + { +
+
+
+ + +
+
+ + +
+
+ + +
+
+ @if (bindingError is not null) {
@bindingError
} + @if (bindingWarning is not null) {
@bindingWarning
} +
+ + +
+
+ } +
+ } + + @if (!dbBindingsByDeployment.Values.Any(b => b.Count > 0)) + { + + } + else + { + @foreach (AppDeployment deployment in deployments ?? []) + { + List bindings = dbBindingsByDeployment.GetValueOrDefault(deployment.Id, []); + if (bindings.Count == 0) continue; +
+
+
+ + @deployment.Name + + @deployment.Cluster?.Name + @deployment.Namespace + +
+
+
+
+ + + + + + + + + + + + @foreach (DatabaseBinding binding in bindings) + { + + + + + + + + @if (revealedDbBindingId == binding.Id) + { + + + + } + } + +
TypeDatabaseK8s SecretLast SyncedActions
@DbTypeBadge(binding) + @DbDisplayName(binding) + @if (binding.CnpgDatabase?.CnpgCluster is not null) + { +
@binding.CnpgDatabase.CnpgCluster.Name + } + else if (binding.MongoDatabase?.MongoCluster is not null) + { +
@binding.MongoDatabase.MongoCluster.Name + } +
+ @binding.KubernetesSecretName +
@deployment.Namespace +
+ @if (binding.LastSyncedAt is not null) + { @binding.LastSyncedAt.Value.ToString("MMM d, HH:mm") } + else + { Never synced } + + + + @if (!IsPortalView) + { + @if (confirmRemoveBindingId == binding.Id) + { + + + } + else + { + + } + } + @if (bindingSyncErrors.TryGetValue(binding.Id, out string? dbErr) && dbErr is not null) + {
@dbErr
} +
+ @if (dbCredLoading) + { +
+ } + else if (dbCredSecrets is { Count: > 0 }) + { +
+ @foreach (VaultSecret sec in dbCredSecrets) + { +
+ @sec.Name: + @if (sec.Name == "PASSWORD") + { + @if (revealedDbPasswordSecretId == sec.Id) + { + @dbCredValues.GetValueOrDefault(sec.Id, "•••") + + } + else + { + •••••••• + + } + } + else + { + @(dbCredValues.GetValueOrDefault(sec.Id, "…")) + } +
+ } +
+ } + else + {
No credentials found in vault for this database.
} +
+
+
+
+ } + } + } +} + +@* ══════════════════════════════════════════════════════ + Messaging tab + ══════════════════════════════════════════════════════ *@ +@if (section == "messaging") +{ + @if (!msgBindingsLoaded) + { +
+ } + else + { + @if (!IsPortalView) + { +
+
+
Attach Messaging
+ +
+ @if (showMsgAttachForm) + { +
+ @if (msgAttachError is not null) {
@msgAttachError
} +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+
+ } +
+ } + + @if (!msgBindingsByDeployment.Values.Any(b => b.Count > 0)) + { + + } + else + { + @foreach (AppDeployment deployment in deployments ?? []) + { + List bindings = msgBindingsByDeployment.GetValueOrDefault(deployment.Id, []); + if (bindings.Count == 0) continue; +
+
+
+ + @deployment.Name + + @deployment.Cluster?.Name + @deployment.Namespace + +
+
+
+
+ + + + + + + + + + + + + @foreach (MessagingBinding binding in bindings) + { + + + + + + + + + } + +
ClusterVhostQueue / ExchangeK8s SecretLast SyncedActions
@binding.Cluster?.Name@binding.Vhost + @if (binding.QueueName is not null) + { + + Queue + + @binding.QueueName + } + @if (binding.ExchangeName is not null) + { + + Exchange + + @binding.ExchangeName + } + @if (binding.QueueName is null && binding.ExchangeName is null) + { + vhost only + } + + @binding.KubernetesSecretName +
@deployment.Namespace +
+ @if (binding.LastSyncedAt is not null) + { @binding.LastSyncedAt.Value.ToString("MMM d, HH:mm") } + else + { Never synced } + + + @if (!IsPortalView) + { + @if (confirmDetachMsgId == binding.Id) + { + + + } + else + { + + } + } + @if (bindingSyncErrors.TryGetValue(binding.Id, out string? msgErr) && msgErr is not null) + {
@msgErr
} +
+
+
+
+ } + } + } +} + +@* ══════════════════════════════════════════════════════ + Cache tab + ══════════════════════════════════════════════════════ *@ +@if (section == "cache") +{ + @if (!cacheBindingsLoaded) + { +
+ } + else + { + @if (!IsPortalView) + { +
+
+
Attach Redis Cluster
+ +
+ @if (showCacheAttachForm) + { +
+ @if (!string.IsNullOrEmpty(cacheBindingError)) {
@cacheBindingError
} +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+
+ } +
+ } + + @if (!cacheBindingsByDeployment.Values.Any(b => b.Count > 0)) + { + + } + else + { + @foreach (AppDeployment deployment in deployments ?? []) + { + List bindings = cacheBindingsByDeployment.GetValueOrDefault(deployment.Id, []); + if (bindings.Count == 0) continue; +
+
+
+ + @deployment.Name + + @deployment.Cluster?.Name + @deployment.Namespace + +
+
+
+
+ + + + + + + + + + + @foreach (CacheBinding binding in bindings) + { + + + + + + + } + +
Redis ClusterK8s SecretLast SyncedActions
+ + @binding.RedisCluster.Name + @if (binding.RedisCluster.KubernetesCluster is not null) + { +
@binding.RedisCluster.KubernetesCluster.Name + } +
+ @binding.KubernetesSecretName +
@deployment.Namespace +
+ @if (binding.LastSyncedAt is not null) + { @binding.LastSyncedAt.Value.ToString("MMM d, HH:mm") } + else + { Never synced } + + + @if (!IsPortalView) + { + @if (confirmRemoveCacheBindingId == binding.Id) + { + + + } + else + { + + } + } + @if (bindingSyncErrors.TryGetValue(binding.Id, out string? cacheErr) && cacheErr is not null) + {
@cacheErr
} +
+
+
+
+ } + } + } +} + +@* ══════════════════════════════════════════════════════ + Storage tab + ══════════════════════════════════════════════════════ *@ +@if (section == "storage") +{ + @if (!storageBindingsLoaded) + { +
+ } + else + { + @if (!IsPortalView) + { +
+
+ Attach Storage +
+
+ @if (storageBindingError is not null) {
@storageBindingError
} +
+
+ + +
+
+ + +
+
+ + +
+
+
+ +
+
+
+ } + + @if (!storageBindingsByDeployment.Values.Any(b => b.Count > 0)) + { + + } + else + { + @foreach (AppDeployment deployment in deployments ?? []) + { + List bindings = storageBindingsByDeployment.GetValueOrDefault(deployment.Id, []); + if (bindings.Count == 0) continue; +
+
+
+ + @deployment.Name + + @deployment.Cluster?.Name + @deployment.Namespace + +
+
+
+
+ + + + + + + + @if (!IsPortalView) { } + + + + @foreach (StorageBinding binding in bindings) + { + + + + + + @if (!IsPortalView) + { + + } + + } + +
ProviderBucketK8s SecretLinkedActions
+ + @binding.StorageLink.Provider + + + @binding.StorageLink.Name + @if (!string.IsNullOrWhiteSpace(binding.StorageLink.BucketName)) + { +
@binding.StorageLink.BucketName + } +
+ @binding.KubernetesSecretName +
@deployment.Namespace +
+ @binding.CreatedAt.ToString("MMM d, HH:mm") + + @if (confirmUnbindStorageId == binding.Id) + { + + + } + else + { + + } + @if (storageBindingSyncErrors.TryGetValue(binding.Id, out string? stErr) && stErr is not null) + {
@stErr
} +
+
+
+
+ } + } + } +} + +@code { + [Parameter] public required Data.App App { get; set; } + [Parameter] public required Data.Environment Environment { get; set; } + [Parameter] public Guid TenantId { get; set; } + [Parameter] public bool IsPortalView { get; set; } + [Parameter] public CustomerAccessRole AccessRole { get; set; } = CustomerAccessRole.Admin; + [Parameter] public EventCallback OnBack { get; set; } + + private string section = "deployments"; + + // Governance + private string? governanceNamespace; + + // Deployments + private List? deployments; + private List? envClusters; + private AppDeployment? selectedDeployment; + private string deploymentSection = "manifests"; + private bool IsGitManaged => selectedDeployment?.Type is + DeploymentType.GitYaml or DeploymentType.GitHelm or DeploymentType.GitAppOfApps; + private bool confirmDeleteDeployment; + + // Create deployment form + private bool showCreateDeployment; + private string newDeployName = ""; + private DeploymentType newDeployType = DeploymentType.Manual; + private Guid newDeployClusterId; + private string newDeployNamespace = ""; + private string newHelmRepoUrl = "", newHelmChartName = "", newHelmChartVersion = ""; + private string? createDeployError; + private string newGitUrl = ""; + private string? newGitUrlError; + private string newGitPath = ".", newGitRevision = ""; + private bool newGitAutoSync = true; + + // Git sync + private bool syncingGit; + private string? gitSyncResult; + private bool gitSyncSuccess; + + // Edit deployment + private bool showEditDeployment; + private string editDeployName = "", editDeployNamespace = ""; + private string editHelmRepoUrl = "", editHelmChartName = "", editHelmChartVersion = ""; + private string? editDeployError; + + // Edit git settings + private bool showEditGitSettings; + private string editGitUrl = ""; + private string? editGitUrlError; + private string editGitPath = "."; + private string editGitRevision = ""; + private bool editGitAutoSync = true; + private string? editGitError; + private bool savingGitSettings; + + // Manifests + private List? manifests; + private bool showAddManifest, savingManifest; + private string newManifestKind = "", newManifestName = "", newManifestYaml = ""; + private int newManifestSortOrder; + private Guid? editingManifestId, confirmDeleteManifestId; + private string editManifestYaml = ""; + + // Helm + private string helmValuesYaml = ""; + private bool savingHelmValues, helmValuesSaved, installingHelm; + private bool? helmInstallSuccess; + private string? helmInstallOutput, helmValuesError; + + // YAML apply + private bool applyingYaml; + private bool? yamlApplySuccess; + private string? yamlApplyOutput; + + // Resource tree (per-deployment) + private List? resourceTree; + private string? resourcesError; + private DateTime? resourceTreeRefreshedAt; + + // Deployment metrics + private DeploymentMetricsSummary? deploymentMetrics; + private bool deploymentMetricsLoading; + private string? deploymentMetricsError; + + // Activity + private List? deploymentAuditEvents; + private bool activityLoading; + + // Env-aggregate metrics + private DeploymentMetricsSummary? envMetrics; + private bool envMetricsLoading; + private string? envMetricsError; + + // Database bindings (form state shared between app-level tab and legacy methods) + private List<(CnpgCluster Cluster, CnpgDatabase Database)>? availableCnpgDatabases; + private List<(MongoCluster Cluster, MongoDatabase Database)>? availableMongoDatabases; + private Guid newBindingDatabaseId; + private string newBindingSecretName = ""; + private string? bindingError; + private string? bindingWarning; + private Guid? confirmRemoveBindingId; + + // Cache bindings (form state shared with app-level tab) + private List? availableRedisClusters; + private Guid newCacheBindingClusterId; + private string newCacheBindingSecretName = "redis-cache"; + private string? cacheBindingError; + private Guid? confirmRemoveCacheBindingId; + + // ── App-level database bindings ────────────────────────────────── + private Dictionary> dbBindingsByDeployment = new(); + private bool dbBindingsLoaded; + private Guid? revealedDbBindingId; + private bool dbCredLoading; + private List? dbCredSecrets; + private Dictionary dbCredValues = new(); + private Guid? revealedDbPasswordSecretId; + private bool dbPasswordRevealing; + private Guid newDbAttachDeploymentId; + private bool showDbAttachForm; + + // Shared sync state for app-level binding tabs + private HashSet syncingBindingIds = []; + private Dictionary bindingSyncErrors = new(); + + // ── App-level messaging bindings ───────────────────────────────── + private Dictionary> msgBindingsByDeployment = new(); + private bool msgBindingsLoaded; + private List? availableRabbitClusters; + private Guid newMsgDeploymentId; + private Guid newMsgClusterId; + private string newMsgVhost = "/"; + private string newMsgQueueName = ""; + private string newMsgExchangeName = ""; + private string newMsgSecretName = ""; + private bool showMsgAttachForm; + private string? msgAttachError; + private Guid? confirmDetachMsgId; + + // ── App-level cache bindings ───────────────────────────────────── + private Dictionary> cacheBindingsByDeployment = new(); + private bool cacheBindingsLoaded; + private Guid newCacheAttachDeploymentId; + private bool showCacheAttachForm; + + // ── App-level storage bindings ─────────────────────────────────── + private Dictionary> storageBindingsByDeployment = new(); + private bool storageBindingsLoaded; + private List availableStorageLinks = []; + private Guid newStorageDeploymentId; + private Guid newStorageLinkId; + private string newStorageSecretName = ""; + private bool addingStorageBinding; + private string? storageBindingError; + private Guid? confirmUnbindStorageId; + private Dictionary storageBindingSyncErrors = new(); + + // Git Access tab + private List? gitRepoPolicies; + private string newRepoPolicyPattern = ""; + private string? repoPolicyError; + private Guid? confirmDeletePolicyId; + + private List? gitCredentials; + private string newCredName = ""; + private GitAuthType newCredAuthType = GitAuthType.HttpsPat; + private string newCredUsername = ""; + private string newCredUrlPattern = ""; + private string? credentialError; + private string? credentialSuccess; + private Dictionary credSecretValues = new(); + private Dictionary credSaveMessages = new(); + private Dictionary credSaveOk = new(); + private Guid? confirmDeleteCredentialId; + + private void HandleDeploymentStatusChanged(Guid deploymentId, SyncStatus sync, HealthStatus health) + { + bool changed = false; + if (deployments is not null) + { + AppDeployment? entry = deployments.FirstOrDefault(d => d.Id == deploymentId); + if (entry is not null) { entry.SyncStatus = sync; entry.HealthStatus = health; changed = true; } + } + if (selectedDeployment?.Id == deploymentId) + { + selectedDeployment.SyncStatus = sync; + selectedDeployment.HealthStatus = health; + changed = true; + } + if (changed) InvokeAsync(StateHasChanged); + } + + public ValueTask DisposeAsync() + { + StatusNotifier.OnStatusChanged -= HandleDeploymentStatusChanged; + return ValueTask.CompletedTask; + } + + protected override async Task OnInitializedAsync() + { + StatusNotifier.OnStatusChanged += HandleDeploymentStatusChanged; + await LoadDeployments(); + envClusters = (await TenantService.GetClustersAsync(TenantId)) + .Where(c => c.EnvironmentId == Environment.Id) + .ToList(); + if (envClusters.Count == 1) + newDeployClusterId = envClusters[0].Id; + try + { + governanceNamespace = await AppGovernanceService.GetNamespaceAsync(App.Id, Environment.Id); + } + catch + { + // Namespace column may be absent on databases that haven't been migrated yet. + } + } + + // ── Deployments ──────────────────────────────────────────────── + + private async Task SwitchToDeployments() + { + section = "deployments"; + if (deployments is null) await LoadDeployments(); + try + { + governanceNamespace = await AppGovernanceService.GetNamespaceAsync(App.Id, Environment.Id); + } + catch { } + } + + private async Task LoadDeployments() + { + List all = await DeploymentService.GetDeploymentsAsync(App.Id); + deployments = all.Where(d => d.EnvironmentId == Environment.Id).ToList(); + } + + private void ValidateNewGitUrl(ChangeEventArgs e) + { + newGitUrl = e.Value?.ToString() ?? ""; + newGitUrlError = !string.IsNullOrWhiteSpace(newGitUrl) + && gitRepoPolicies is { Count: > 0 } + && !CustomerGitService.MatchesAnyPolicy(newGitUrl, gitRepoPolicies) + ? "URL does not match any allowed policy pattern." + : null; + } + + private void ValidateEditGitUrl(ChangeEventArgs e) + { + editGitUrl = e.Value?.ToString() ?? ""; + editGitUrlError = !string.IsNullOrWhiteSpace(editGitUrl) + && gitRepoPolicies is { Count: > 0 } + && !CustomerGitService.MatchesAnyPolicy(editGitUrl, gitRepoPolicies) + ? "URL does not match any allowed policy pattern." + : null; + } + + private async Task OnNewDeployTypeChanged() + { + if (newDeployType is DeploymentType.GitYaml or DeploymentType.GitHelm or DeploymentType.GitAppOfApps) + { + gitRepoPolicies ??= await CustomerGitService.GetRepoPoliciesAsync(App.CustomerId, Environment.Id); + } + } + + private async Task CreateDeployment() + { + createDeployError = null; + bool isGit = newDeployType is DeploymentType.GitYaml or DeploymentType.GitHelm or DeploymentType.GitAppOfApps; + + string? resolvedGitUrl = null; + if (isGit) + { + if (string.IsNullOrWhiteSpace(newGitUrl)) { createDeployError = "Enter a Git repository URL."; return; } + if (newGitUrlError is not null) { createDeployError = newGitUrlError; return; } + resolvedGitUrl = newGitUrl.Trim(); + } + + try + { + string effectiveNs = !string.IsNullOrWhiteSpace(governanceNamespace) ? governanceNamespace : newDeployNamespace; + await DeploymentService.CreateDeploymentAsync( + App.Id, newDeployName, newDeployType, + Environment.Id, newDeployClusterId, effectiveNs, + newDeployType == DeploymentType.HelmChart ? newHelmRepoUrl : null, + newDeployType == DeploymentType.HelmChart ? newHelmChartName : null, + newDeployType == DeploymentType.HelmChart ? newHelmChartVersion : null, + gitUrl: resolvedGitUrl, + gitPath: isGit ? newGitPath : null, + gitRevision: isGit && !string.IsNullOrWhiteSpace(newGitRevision) ? newGitRevision : null, + gitAutoSync: isGit && newGitAutoSync); + + showCreateDeployment = false; + newDeployName = newDeployNamespace = newHelmRepoUrl = newHelmChartName = newHelmChartVersion = ""; + newGitUrl = ""; newGitUrlError = null; newGitPath = "."; newGitRevision = ""; newGitAutoSync = true; + await LoadDeployments(); + } + catch (Exception ex) { createDeployError = ex.Message; } + } + + private async Task SelectDeployment(AppDeployment deploy) + { + selectedDeployment = deploy; + confirmDeleteDeployment = false; + helmValuesSaved = false; helmValuesError = null; helmInstallOutput = null; helmInstallSuccess = null; + yamlApplyOutput = null; yamlApplySuccess = null; savingManifest = false; editingManifestId = null; + resourceTree = null; resourcesError = null; deploymentMetrics = null; deploymentMetricsError = null; + deploymentAuditEvents = null; gitSyncResult = null; syncingGit = false; + + if (deploy.Type == DeploymentType.HelmChart || deploy.Type == DeploymentType.GitHelm) + { + deploymentSection = "helm-values"; + helmValuesYaml = deploy.HelmValues ?? ""; + } + else if (deploy.Type == DeploymentType.GitAppOfApps) + { + deploymentSection = "resources"; + await OpenDeploymentResourcesTab(); + } + else + { + deploymentSection = "manifests"; + await LoadManifests(); + } + } + + private async Task DeleteDeployment() + { + if (selectedDeployment is not null) + { + await DeploymentService.DeleteDeploymentAsync(selectedDeployment.Id); + selectedDeployment = null; + confirmDeleteDeployment = false; + await LoadDeployments(); + } + } + + private void StartEditDeployment() + { + if (selectedDeployment is null) return; + editDeployName = selectedDeployment.Name; + editDeployNamespace = selectedDeployment.Namespace; + editHelmRepoUrl = selectedDeployment.HelmRepoUrl ?? ""; + editHelmChartName = selectedDeployment.HelmChartName ?? ""; + editHelmChartVersion = selectedDeployment.HelmChartVersion ?? ""; + editDeployError = null; + showEditDeployment = true; + showEditGitSettings = false; + confirmDeleteDeployment = false; + } + + private async Task StartEditGitSettings() + { + if (selectedDeployment is null) return; + editGitUrl = selectedDeployment.GitUrl ?? ""; + editGitPath = selectedDeployment.GitPath ?? "."; + editGitRevision = selectedDeployment.GitRevision ?? ""; + editGitAutoSync = selectedDeployment.GitAutoSync; + editGitError = null; + editGitUrlError = null; + showEditGitSettings = true; + showEditDeployment = false; + + gitRepoPolicies ??= await CustomerGitService.GetRepoPoliciesAsync(App.CustomerId, Environment.Id); + } + + private async Task SaveGitSettings() + { + if (selectedDeployment is null) return; + editGitError = null; + savingGitSettings = true; + try + { + if (editGitUrlError is not null) { editGitError = editGitUrlError; return; } + + await DeploymentService.UpdateGitSettingsAsync( + selectedDeployment.Id, + string.IsNullOrWhiteSpace(editGitUrl) ? null : editGitUrl.Trim(), + editGitPath, editGitRevision, editGitAutoSync); + await LoadDeployments(); + selectedDeployment = deployments?.FirstOrDefault(d => d.Id == selectedDeployment.Id) ?? selectedDeployment; + showEditGitSettings = false; + } + catch (Exception ex) { editGitError = ex.Message; } + finally { savingGitSettings = false; } + } + + private async Task SaveDeployment() + { + if (selectedDeployment is null) return; + editDeployError = null; + try + { + string effectiveNs = !string.IsNullOrWhiteSpace(governanceNamespace) ? governanceNamespace : editDeployNamespace; + await DeploymentService.UpdateDeploymentAsync( + selectedDeployment.Id, editDeployName, effectiveNs, + selectedDeployment.Type == DeploymentType.HelmChart ? editHelmRepoUrl : null, + selectedDeployment.Type == DeploymentType.HelmChart ? editHelmChartName : null, + selectedDeployment.Type == DeploymentType.HelmChart ? editHelmChartVersion : null); + await LoadDeployments(); + selectedDeployment = deployments?.FirstOrDefault(d => d.Id == selectedDeployment.Id) ?? selectedDeployment; + showEditDeployment = false; + } + catch (Exception ex) { editDeployError = ex.Message; } + } + + private async Task TriggerGitSync() + { + if (selectedDeployment is null) return; + syncingGit = true; gitSyncResult = null; StateHasChanged(); + try + { + GitSyncResult result = await GitSyncService.SyncDeploymentAsync(selectedDeployment.Id); + gitSyncSuccess = result.IsSuccess; + gitSyncResult = result.AlreadyCurrent + ? "Already up to date." + : result.IsSuccess + ? $"Synced to {result.CommitSha[..Math.Min(7, result.CommitSha.Length)]} — {result.CommitMessage}" + : result.Error ?? "Sync failed."; + if (result.IsSuccess) + { + await LoadDeployments(); + selectedDeployment = deployments?.FirstOrDefault(d => d.Id == selectedDeployment.Id) ?? selectedDeployment; + } + } + catch (Exception ex) { gitSyncSuccess = false; gitSyncResult = ex.Message; } + finally { syncingGit = false; } + } + + // ── Manifests ──────────────────────────────────────────────── + + private async Task LoadManifests() + { + if (selectedDeployment is not null) + manifests = await DeploymentService.GetManifestsAsync(selectedDeployment.Id); + } + + private async Task AddManifest() + { + if (selectedDeployment is null) return; + savingManifest = true; + try + { + await DeploymentService.AddManifestAsync( + selectedDeployment.Id, newManifestKind, newManifestName, newManifestYaml, newManifestSortOrder); + showAddManifest = false; newManifestKind = newManifestName = newManifestYaml = ""; newManifestSortOrder = 0; + await LoadManifests(); + } + finally { savingManifest = false; } + } + + private void StartEditManifest(DeploymentManifest m) { editingManifestId = m.Id; editManifestYaml = m.YamlContent; } + + private async Task SaveManifest(Guid id) + { + savingManifest = true; + try { await DeploymentService.UpdateManifestAsync(id, editManifestYaml); editingManifestId = null; await LoadManifests(); } + finally { savingManifest = false; } + } + + private async Task DeleteManifest(Guid id) + { + confirmDeleteManifestId = null; + await DeploymentService.DeleteManifestAsync(id); + await LoadManifests(); + } + + // ── YAML / Helm apply ──────────────────────────────────────── + + private async Task ApplyYamlDeployment() + { + if (selectedDeployment is null) return; + applyingYaml = true; yamlApplyOutput = null; yamlApplySuccess = null; + try + { + KubernetesOperationResult result = await K8sOps.ApplyYamlDeploymentAsync(selectedDeployment.Id); + yamlApplySuccess = result.IsSuccess; + yamlApplyOutput = result.IsSuccess ? result.Data : result.Error; + } + finally { applyingYaml = false; } + } + + private async Task InstallOrUpgradeHelm() + { + if (selectedDeployment is null) return; + installingHelm = true; helmInstallOutput = null; helmInstallSuccess = null; + try + { + KubernetesOperationResult result = await K8sOps.HelmInstallOrUpgradeAsync(selectedDeployment.Id); + helmInstallSuccess = result.IsSuccess; + helmInstallOutput = result.IsSuccess ? result.Data : result.Error; + } + finally { installingHelm = false; } + } + + private async Task SaveHelmValues() + { + savingHelmValues = true; helmValuesSaved = false; helmValuesError = null; + try + { + if (selectedDeployment is not null) + { + await DeploymentService.UpdateHelmValuesAsync(selectedDeployment.Id, helmValuesYaml); + selectedDeployment.HelmValues = helmValuesYaml; + } + helmValuesSaved = true; + } + catch (Exception ex) { helmValuesError = ex.Message; } + finally { savingHelmValues = false; } + } + + // ── Resource tree ──────────────────────────────────────────── + + private async Task OpenDeploymentResourcesTab() + { + deploymentSection = "resources"; + resourceTree = null; resourcesError = null; + if (selectedDeployment is not null) + { + KubernetesOperationResult> result = + await K8sOps.GetLiveResourcesAsync(selectedDeployment.Id); + if (result.IsSuccess) + { + resourceTree = result.Data ?? []; + resourceTreeRefreshedAt = DateTime.UtcNow; + (SyncStatus sync, HealthStatus health) = + KubernetesOperationsService.ComputeStatusFromResources(resourceTree); + await DeploymentService.UpdateDeploymentStatusAsync(selectedDeployment.Id, sync, health); + selectedDeployment.SyncStatus = sync; + selectedDeployment.HealthStatus = health; + AppDeployment? listEntry = deployments?.FirstOrDefault(d => d.Id == selectedDeployment.Id); + if (listEntry is not null) { listEntry.SyncStatus = sync; listEntry.HealthStatus = health; } + } + else { resourcesError = result.Error; resourceTree = []; } + } + } + + + // ── App-level section open/load methods ────────────────────────── + + private async Task OpenDatabasesSection() + { + section = "databases"; + if (!dbBindingsLoaded) await LoadAppDatabaseBindings(); + } + + private async Task LoadAppDatabaseBindings() + { + dbBindingsByDeployment.Clear(); + foreach (AppDeployment dep in deployments ?? []) + dbBindingsByDeployment[dep.Id] = await CnpgService.GetDatabaseBindingsAsync(dep.Id); + dbBindingsLoaded = true; + if (availableCnpgDatabases is null) + { + List clusters = await CnpgService.GetClustersAsync(TenantId); + availableCnpgDatabases = clusters.SelectMany(c => c.Databases.Select(d => (c, d))).ToList(); + } + if (availableMongoDatabases is null) + { + List mongos = await MongoService.GetClustersAsync(TenantId); + availableMongoDatabases = mongos.Where(c => !c.IsExternal) + .SelectMany(c => c.Databases.Select(d => (c, d))).ToList(); + } + } + + private async Task OpenMessagingSection() + { + section = "messaging"; + if (!msgBindingsLoaded) await LoadAppMessagingBindings(); + } + + private async Task LoadAppMessagingBindings() + { + msgBindingsByDeployment.Clear(); + foreach (AppDeployment dep in deployments ?? []) + msgBindingsByDeployment[dep.Id] = await RabbitMQService.GetMessagingBindingsForDeploymentAsync(dep.Id); + msgBindingsLoaded = true; + if (availableRabbitClusters is null) + availableRabbitClusters = await RabbitMQService.GetClustersAsync(TenantId); + } + + private async Task OpenCacheSection() + { + section = "cache"; + if (!cacheBindingsLoaded) await LoadAppCacheBindings(); + } + + private async Task LoadAppCacheBindings() + { + cacheBindingsByDeployment.Clear(); + foreach (AppDeployment dep in deployments ?? []) + cacheBindingsByDeployment[dep.Id] = await RedisService.GetCacheBindingsForDeploymentAsync(dep.Id); + cacheBindingsLoaded = true; + if (availableRedisClusters is null) + availableRedisClusters = await RedisService.GetClustersAsync(TenantId); + } + + private async Task OpenStorageSection() + { + section = "storage"; + if (!storageBindingsLoaded) await LoadAppStorageBindings(); + } + + private async Task LoadAppStorageBindings() + { + storageBindingsByDeployment.Clear(); + foreach (AppDeployment dep in deployments ?? []) + storageBindingsByDeployment[dep.Id] = await StorageService.GetBindingsForDeploymentAsync(dep.Id); + availableStorageLinks = await StorageService.GetStorageLinksAsync(TenantId); + storageBindingsLoaded = true; + } + + // ── App-level database actions ──────────────────────────────────── + + private async Task AddDatabaseBindingAppLevel() + { + if (newDbAttachDeploymentId == Guid.Empty || newBindingDatabaseId == Guid.Empty) return; + bindingError = null; + bindingWarning = null; + try + { + var mongoMatch = availableMongoDatabases?.FirstOrDefault(x => x.Database.Id == newBindingDatabaseId); + if (mongoMatch is { Database: not null }) + { + await MongoService.AddMongoDatabaseBindingAsync(newDbAttachDeploymentId, newBindingDatabaseId, newBindingSecretName); + try { await MongoService.SyncDatabaseCredentialsToK8sAsync(TenantId, mongoMatch.Value.Cluster.Id, newBindingDatabaseId); } + catch (Exception syncEx) { bindingWarning = $"Database attached, but credential sync failed: {syncEx.Message}"; } + } + else + { + await CnpgService.AddCnpgDatabaseBindingAsync(newDbAttachDeploymentId, newBindingDatabaseId, newBindingSecretName); + var cnpgMatch = availableCnpgDatabases?.FirstOrDefault(x => x.Database.Id == newBindingDatabaseId); + if (cnpgMatch is { Database: not null }) + { + try { await CnpgService.SyncDatabaseCredentialsToK8sAsync(TenantId, cnpgMatch.Value.Cluster.Id, newBindingDatabaseId); } + catch (Exception syncEx) { bindingWarning = $"Database attached, but credential sync failed: {syncEx.Message}"; } + } + } + showDbAttachForm = false; newDbAttachDeploymentId = Guid.Empty; newBindingDatabaseId = Guid.Empty; newBindingSecretName = ""; + await LoadAppDatabaseBindings(); + } + catch (Exception ex) + { + Exception inner = ex; + while (inner.InnerException is not null) inner = inner.InnerException; + bindingError = $"Failed to attach database: {ex.Message}{(inner != ex ? $" ({inner.Message})" : "")}"; + } + } + + private async Task RemoveDatabaseBindingAppLevel(Guid id) + { + confirmRemoveBindingId = null; bindingError = null; + try + { + await CnpgService.RemoveDatabaseBindingAsync(id); + await LoadAppDatabaseBindings(); + } + catch (Exception ex) { bindingError = $"Failed to detach: {ex.Message}"; } + } + + private async Task SyncDatabaseBinding(DatabaseBinding binding) + { + syncingBindingIds.Add(binding.Id); + bindingSyncErrors.Remove(binding.Id); + try + { + if (binding.CnpgDatabaseId.HasValue && binding.CnpgDatabase is not null) + await CnpgService.SyncDatabaseCredentialsToK8sAsync(TenantId, binding.CnpgDatabase.CnpgClusterId, binding.CnpgDatabase.Id); + else if (binding.MongoDatabaseId.HasValue && binding.MongoDatabase is not null) + await MongoService.SyncDatabaseCredentialsToK8sAsync(TenantId, binding.MongoDatabase.MongoClusterId, binding.MongoDatabase.Id); + else if (binding.RegisteredPostgresDatabaseId.HasValue && binding.RegisteredPostgresDatabase is not null) + await RegisteredPostgresService.SyncCredentialsToK8sAsync(TenantId, binding.RegisteredPostgresDatabase.RegisteredPostgresInstanceId, binding.RegisteredPostgresDatabaseId.Value); + await LoadAppDatabaseBindings(); + } + catch (Exception ex) { bindingSyncErrors[binding.Id] = ex.Message; } + finally { syncingBindingIds.Remove(binding.Id); } + } + + private async Task ToggleDbCredentials(DatabaseBinding binding) + { + if (revealedDbBindingId == binding.Id) + { + revealedDbBindingId = null; dbCredSecrets = null; dbCredValues.Clear(); revealedDbPasswordSecretId = null; return; + } + revealedDbBindingId = binding.Id; dbCredLoading = true; dbCredSecrets = null; dbCredValues.Clear(); revealedDbPasswordSecretId = null; + try + { + if (binding.CnpgDatabaseId.HasValue) + dbCredSecrets = await VaultService.GetCnpgDatabaseSecretsAsync(TenantId, binding.CnpgDatabaseId.Value); + else if (binding.MongoDatabaseId.HasValue) + dbCredSecrets = await VaultService.GetMongoDatabaseSecretsAsync(TenantId, binding.MongoDatabaseId.Value); + else if (binding.RegisteredPostgresDatabaseId.HasValue) + dbCredSecrets = await VaultService.GetRegisteredPostgresDatabaseSecretsAsync(TenantId, binding.RegisteredPostgresDatabaseId.Value); + foreach (VaultSecret sec in dbCredSecrets ?? []) + if (sec.Name != "PASSWORD") + dbCredValues[sec.Id] = await VaultService.GetSecretValueByIdAsync(sec.Id); + } + finally { dbCredLoading = false; } + } + + private async Task RevealDbPassword(VaultSecret secret) + { + dbPasswordRevealing = true; revealedDbPasswordSecretId = secret.Id; + try { dbCredValues[secret.Id] = await VaultService.GetSecretValueByIdAsync(secret.Id); } + finally { dbPasswordRevealing = false; } + } + + private RenderFragment DbTypeBadge(DatabaseBinding binding) + { + if (binding.CnpgDatabaseId.HasValue) + return @PostgreSQL; + if (binding.MongoDatabaseId.HasValue) + return @MongoDB; + if (binding.RegisteredPostgresDatabaseId.HasValue) + return @Postgres (ext); + return @Unknown; + } + + private static string DbDisplayName(DatabaseBinding binding) + { + if (binding.CnpgDatabase is not null) return binding.CnpgDatabase.Name; + if (binding.MongoDatabase is not null) return binding.MongoDatabase.Name; + if (binding.RegisteredPostgresDatabase is not null) return binding.RegisteredPostgresDatabase.Name; + return "—"; + } + + // ── App-level messaging actions ─────────────────────────────────── + + private async Task AddMessagingBinding() + { + if (newMsgDeploymentId == Guid.Empty || newMsgClusterId == Guid.Empty) return; + msgAttachError = null; + try + { + await RabbitMQService.CreateMessagingBindingAsync( + TenantId, newMsgClusterId, newMsgDeploymentId, + newMsgVhost.Trim(), + string.IsNullOrWhiteSpace(newMsgQueueName) ? null : newMsgQueueName.Trim(), + string.IsNullOrWhiteSpace(newMsgExchangeName) ? null : newMsgExchangeName.Trim(), + newMsgSecretName.Trim()); + showMsgAttachForm = false; + newMsgDeploymentId = Guid.Empty; newMsgClusterId = Guid.Empty; + newMsgVhost = "/"; newMsgSecretName = ""; newMsgQueueName = ""; newMsgExchangeName = ""; + await LoadAppMessagingBindings(); + } + catch (Exception ex) { msgAttachError = ex.Message; } + } + + private async Task RemoveMessagingBinding(Guid id) + { + confirmDetachMsgId = null; + try + { + await RabbitMQService.DeleteMessagingBindingAsync(TenantId, id); + await LoadAppMessagingBindings(); + } + catch { } + } + + private async Task SyncMessagingBinding(MessagingBinding binding) + { + syncingBindingIds.Add(binding.Id); + bindingSyncErrors.Remove(binding.Id); + try + { + await RabbitMQService.SyncMessagingBindingAsync(TenantId, binding.Id); + await LoadAppMessagingBindings(); + } + catch (Exception ex) { bindingSyncErrors[binding.Id] = ex.Message; } + finally { syncingBindingIds.Remove(binding.Id); } + } + + // ── App-level cache actions ──────────────────────────────────────── + + private async Task AddCacheBindingAppLevel() + { + if (newCacheAttachDeploymentId == Guid.Empty || newCacheBindingClusterId == Guid.Empty) return; + cacheBindingError = null; + try + { + await RedisService.CreateCacheBindingAsync(TenantId, newCacheBindingClusterId, newCacheAttachDeploymentId, newCacheBindingSecretName.Trim()); + showCacheAttachForm = false; newCacheAttachDeploymentId = Guid.Empty; newCacheBindingClusterId = Guid.Empty; newCacheBindingSecretName = "redis-cache"; + await LoadAppCacheBindings(); + } + catch (Exception ex) { cacheBindingError = $"Failed to attach Redis: {ex.Message}"; } + } + + private async Task RemoveCacheBindingAppLevel(Guid id) + { + confirmRemoveCacheBindingId = null; cacheBindingError = null; + try + { + await RedisService.DeleteCacheBindingAsync(TenantId, id); + await LoadAppCacheBindings(); + } + catch (Exception ex) { cacheBindingError = $"Failed to detach: {ex.Message}"; } + } + + private async Task SyncCacheBinding(CacheBinding binding) + { + syncingBindingIds.Add(binding.Id); + bindingSyncErrors.Remove(binding.Id); + try + { + await RedisService.SyncCacheBindingAsync(TenantId, binding.Id); + await LoadAppCacheBindings(); + } + catch (Exception ex) { bindingSyncErrors[binding.Id] = ex.Message; } + finally { syncingBindingIds.Remove(binding.Id); } + } + + // ── App-level storage actions ───────────────────────────────────── + + private async Task AddStorageBinding() + { + storageBindingError = null; addingStorageBinding = true; + try + { + await StorageService.BindStorageToDeploymentAsync(newStorageLinkId, newStorageDeploymentId, newStorageSecretName.Trim()); + newStorageDeploymentId = Guid.Empty; newStorageLinkId = Guid.Empty; newStorageSecretName = ""; + await LoadAppStorageBindings(); + } + catch (Exception ex) { storageBindingError = ex.Message; } + finally { addingStorageBinding = false; } + } + + private async Task UnbindStorage(Guid bindingId) + { + confirmUnbindStorageId = null; + storageBindingSyncErrors.Remove(bindingId); + await StorageService.UnbindStorageAsync(bindingId); + await LoadAppStorageBindings(); + } + + // ── Metrics ─────────────────────────────────────────────────── + + private async Task OpenDeploymentMetricsTab() + { + deploymentSection = "deployment-metrics"; + if (selectedDeployment is null) return; + deploymentMetricsLoading = true; deploymentMetricsError = null; deploymentMetrics = null; StateHasChanged(); + KubernetesOperationResult result = + await PrometheusService.GetDeploymentMetricsAsync(selectedDeployment.Id); + if (result.IsSuccess) deploymentMetrics = result.Data; + else deploymentMetricsError = result.Error; + deploymentMetricsLoading = false; + } + + private async Task OpenMetricsSection() + { + section = "metrics"; + envMetricsLoading = true; envMetricsError = null; envMetrics = null; StateHasChanged(); + KubernetesOperationResult result = + await PrometheusService.GetAppMetricsAsync(App.Id); + if (result.IsSuccess) envMetrics = result.Data; + else envMetricsError = result.Error; + envMetricsLoading = false; + } + + // ── Activity ───────────────────────────────────────────────── + + private async Task OpenActivityTab() + { + deploymentSection = "activity"; + if (selectedDeployment is null) return; + activityLoading = true; deploymentAuditEvents = null; StateHasChanged(); + deploymentAuditEvents = await AuditService.GetDeploymentEventsAsync(selectedDeployment.Id); + activityLoading = false; + } + + // ── Git Access ──────────────────────────────────────────────── + + private async Task SwitchToGitAccess() + { + section = "git-access"; + if (gitRepoPolicies is null) + gitRepoPolicies = await CustomerGitService.GetRepoPoliciesAsync(App.CustomerId, Environment.Id); + if (gitCredentials is null) + { + gitCredentials = await CustomerGitService.GetCredentialsAsync(App.CustomerId, Environment.Id); + foreach (CustomerGitCredential c in gitCredentials) + credSecretValues.TryAdd(c.Id, ""); + } + } + + private async Task AddRepoPolicy() + { + repoPolicyError = null; + try + { + await CustomerGitService.AddRepoPolicyAsync(App.CustomerId, Environment.Id, newRepoPolicyPattern.Trim()); + newRepoPolicyPattern = ""; + gitRepoPolicies = await CustomerGitService.GetRepoPoliciesAsync(App.CustomerId, Environment.Id); + } + catch (Microsoft.EntityFrameworkCore.DbUpdateException) + { + repoPolicyError = "That URL pattern already exists for this customer in this environment."; + } + } + + private async Task DeleteRepoPolicy(Guid policyId) + { + confirmDeletePolicyId = null; + await CustomerGitService.DeleteRepoPolicyAsync(App.CustomerId, policyId); + gitRepoPolicies = await CustomerGitService.GetRepoPoliciesAsync(App.CustomerId, Environment.Id); + } + + private async Task AddGitCredential() + { + credentialError = null; + credentialSuccess = null; + try + { + await CustomerGitService.CreateCredentialAsync( + App.CustomerId, Environment.Id, TenantId, + newCredName.Trim(), newCredAuthType, + newCredAuthType == GitAuthType.HttpsPassword ? newCredUsername : null, + string.IsNullOrWhiteSpace(newCredUrlPattern) ? null : newCredUrlPattern.Trim()); + newCredName = ""; + newCredAuthType = GitAuthType.HttpsPat; + newCredUsername = ""; + newCredUrlPattern = ""; + gitCredentials = await CustomerGitService.GetCredentialsAsync(App.CustomerId, Environment.Id); + foreach (CustomerGitCredential c in gitCredentials) + credSecretValues.TryAdd(c.Id, ""); + credentialSuccess = "Credential added."; + } + catch (Microsoft.EntityFrameworkCore.DbUpdateException) + { + credentialError = "A credential with that name already exists for this customer in this environment."; + } + } + + private async Task DeleteGitCredential(Guid credentialId) + { + confirmDeleteCredentialId = null; + credentialError = null; + await CustomerGitService.DeleteCredentialAsync(App.CustomerId, credentialId); + credSecretValues.Remove(credentialId); + credSaveMessages.Remove(credentialId); + credSaveOk.Remove(credentialId); + gitCredentials = await CustomerGitService.GetCredentialsAsync(App.CustomerId, Environment.Id); + } + + 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; + } + } + + // ── Badge helpers ───────────────────────────────────────────── + + private RenderFragment GetTypeBadge(DeploymentType type) => type switch + { + DeploymentType.Manual => @Manual, + DeploymentType.Yaml => @YAML, + DeploymentType.HelmChart => @Helm, + DeploymentType.GitYaml => @Git YAML, + DeploymentType.GitHelm => @Git Helm, + _ => @Git + }; + + private RenderFragment GetSyncBadge(SyncStatus status) => status switch + { + SyncStatus.Synced => @Synced, + SyncStatus.OutOfSync => @OutOfSync, + SyncStatus.Syncing => @Syncing, + SyncStatus.Failed => @Failed, + _ => @Unknown + }; + + private RenderFragment GetHealthBadge(HealthStatus status) => status switch + { + HealthStatus.Healthy => @Healthy, + HealthStatus.Progressing => @Progressing, + HealthStatus.Degraded => @Degraded, + HealthStatus.Missing => @Missing, + HealthStatus.Suspended => @Suspended, + _ => @Unknown + }; +} diff --git a/src/EntKube.Web/Components/Pages/Tenants/AppExternalAccessPanel.razor b/src/EntKube.Web/Components/Pages/Tenants/AppExternalAccessPanel.razor new file mode 100644 index 0000000..fe8f2e2 --- /dev/null +++ b/src/EntKube.Web/Components/Pages/Tenants/AppExternalAccessPanel.razor @@ -0,0 +1,826 @@ +@using EntKube.Web.Data +@using EntKube.Web.Services +@inject AppRouteService AppRouteService +@inject DeploymentService DeploymentService +@inject TenantService TenantService +@inject ComponentLifecycleService ComponentLifecycleService +@inject KubernetesOperationsService K8sOps + +@* ═══════════════════════════════════════════════════════════════════ + App External Access — ops panel for managing Gateway API HTTPRoutes. + Ops configures hostname + TLS (AppRoute); each deployment environment + attaches under a path prefix with target service (AppDeploymentRoute). + ═══════════════════════════════════════════════════════════════════ *@ + +
+
+
+ + External Access +
+ @if (!IsPortalView) + { + + } +
+ + @* ── Add new AppRoute form (ops only) ── *@ + @if (!IsPortalView && showAddRoute) + { +
+ @if (addRouteError is not null) + { +
@addRouteError
+ } +
+
+ + +
+
+ + +
+ + @if (newTlsMode == TlsMode.ClusterIssuer) + { + @* Cluster selector drives the ClusterIssuer dropdown *@ +
+ + +
+
+ + @if (loadingIssuers) + { +
+ Loading… +
+ } + else if (availableIssuers.Count > 0) + { + + } + else + { + +
+ @(issuerClusterId == Guid.Empty ? "Pick a cluster above to load available issuers." : "No ClusterIssuers found — enter manually.") +
+ } +
+ } + else + { +
+ + - - } - @if (credSaveMessages.TryGetValue(cred.Id, out string? msg) && msg is not null) - { -
- @msg -
- } -
- } -
- } - } - -
-
- @* --- Portal Access Management --- *@
@@ -546,23 +348,6 @@ 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; @@ -621,20 +406,8 @@ 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() @@ -745,115 +518,6 @@ else 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/GovernanceTab.razor b/src/EntKube.Web/Components/Pages/Tenants/GovernanceTab.razor index e8fabf0..bddf1a4 100644 --- a/src/EntKube.Web/Components/Pages/Tenants/GovernanceTab.razor +++ b/src/EntKube.Web/Components/Pages/Tenants/GovernanceTab.razor @@ -2,32 +2,86 @@ @using EntKube.Web.Services @inject AppGovernanceService GovernanceService @inject TenantService TenantService +@inject DeploymentService DeploymentService +@inject StorageService StorageService @* ═══════════════════════════════════════════════════════════════════ - GovernanceTab — tenant admin view for app-level namespace, resource - quotas, network policies, and RBAC. All changes are saved to the - database. "Apply to clusters" pushes to every cluster where this - app has a deployment. + GovernanceTab — per-environment governance: resource quotas, + network policies, and RBAC for this app in this specific environment. + Namespace is derived from each deployment (not stored here). ═══════════════════════════════════════════════════════════════════ *@
Governance
- Namespace, quotas, network policies, and RBAC — read-only in the customer portal. + Quota, network policies, and RBAC for this environment. Applied to each deployment's namespace on its cluster. +
+
+ +
-
+@* ── Copy from environment ── *@ +@if (showCopyFrom) +{ +
+
+
+ + Copy governance from: + + @if (!confirmCopy) + { + + } + else + { + This will overwrite all governance in this environment. + + + } + +
+ @if (copyMessage is not null) + { +
+ @copyMessage +
+ } +
+
+} + @if (applyOutput is not null) {
- @(applySuccess ? "Applied successfully" : "Apply failed") + @(applySuccess ? "Applied successfully" : "Apply had errors")
@applyOutput
@@ -40,47 +94,55 @@ } else { - @* ── 1. Namespace ─────────────────────────────────────────────── *@ + @* ── 0. Namespace ─────────────────────────────────────────────── *@
Namespace - The Kubernetes namespace where quotas and policies are enforced. + Locks the Kubernetes namespace for all deployments of this app in this environment.
-
-
- - -
Must be a valid DNS label (lowercase, hyphens, max 63 chars).
+ @if (nsMessage is not null) + { +
+ @nsMessage
-
- -
- @if (nsMessage is not null) + } +
+ + + @if (!string.IsNullOrWhiteSpace(nsValue)) { -
- - @nsMessage - -
+ }
+ @if (!string.IsNullOrWhiteSpace(nsValue)) + { +
+ + Deployments in this environment will be locked to namespace @nsValue. +
+ } + else + { +
No namespace lock — deployments may use any namespace.
+ }
- @* ── 2. Resource Quota ─────────────────────────────────────────── *@ + @* ── 1. Resource Quota ─────────────────────────────────────────── *@
Resource Quota - Limits applied as a Kubernetes ResourceQuota in the namespace. + Applied as a Kubernetes ResourceQuota in each deployment's namespace.
@if (quota is not null) { @@ -96,6 +158,19 @@ else @quotaMessage
} +
+ Presets: + + + + + +
@@ -129,7 +204,7 @@ else
- @* ── 3. Network Policies ───────────────────────────────────────── *@ + @* ── 2. Network Policies ───────────────────────────────────────── *@
@@ -251,14 +326,12 @@ else
- @* ── 4. RBAC ───────────────────────────────────────────────────── *@ -
-
-
- - RBAC - ServiceAccount, Role, and RoleBinding applied to the namespace. -
+ @* ── 3. RBAC ───────────────────────────────────────────────────── *@ +
+
+ + RBAC + ServiceAccount, Role, and RoleBinding applied to the namespace.
@if (rbacMessage is not null) @@ -268,7 +341,6 @@ else
} - @* Service account settings *@
@@ -277,8 +349,8 @@ else
- - + +
@@ -294,7 +366,6 @@ else
- @* Rules table *@ @if (rbacPolicy is not null) {
@@ -380,20 +451,279 @@ else }
+ + @* ── 4. Allowed Services ───────────────────────────────────────── *@ +
+
+ + Allowed Services + Restrict which databases, cache clusters, and storage buckets this app may link to. Empty = no restriction. +
+
+ + @* ── Allowed Databases ── *@ +
+
+ Allowed Databases + +
+ @if (allowedDbMessage is not null) + { +
@allowedDbMessage
+ } + @if (showAddDb) + { +
+
+ @if (availableDbs is null) + { +
+ Loading databases… + } + else if (availableDbs.Count == 0) + { + No databases found in this environment. + } + else + { +
+ + + +
+ } +
+
+ } + @if (allowedDatabases.Count == 0 && !showAddDb) + { +

No restrictions — all databases are available.

+ } + else if (allowedDatabases.Count > 0) + { +
+ @foreach (AppAllowedDatabase entry in allowedDatabases) + { +
+
+ @DbTypeLabel(entry) + @DbAllowedName(entry) +
+ @if (confirmDeleteDbId != entry.Id) + { + + } + else + { + + + } +
+ } +
+ } +
+ + @* ── Allowed Cache Services ── *@ +
+
+ Allowed Cache Services + +
+ @if (allowedCacheMessage is not null) + { +
@allowedCacheMessage
+ } + @if (showAddCache) + { +
+
+ @if (availableCaches is null) + { +
+ Loading Redis clusters… + } + else if (availableCaches.Count == 0) + { + No Redis clusters found for this tenant. + } + else + { +
+ + + +
+ } +
+
+ } + @if (allowedCaches.Count == 0 && !showAddCache) + { +

No restrictions — all Redis clusters are available.

+ } + else if (allowedCaches.Count > 0) + { +
+ @foreach (AppAllowedCache entry in allowedCaches) + { +
+
+ + @entry.RedisCluster.Name +
+ @if (confirmDeleteCacheId != entry.Id) + { + + } + else + { + + + } +
+ } +
+ } +
+ + @* ── Allowed S3 Storage ── *@ +
+
+ Allowed S3 Storage + +
+ @if (allowedStorageMessage is not null) + { +
@allowedStorageMessage
+ } + @if (showAddStorage) + { +
+
+ @if (availableStorages is null) + { +
+ Loading storage buckets… + } + else if (availableStorages.Count == 0) + { + No storage buckets found in this environment. + } + else + { +
+ + + +
+ } +
+
+ } + @if (allowedStorages.Count == 0 && !showAddStorage) + { +

No restrictions — all storage buckets are available.

+ } + else if (allowedStorages.Count > 0) + { +
+ @foreach (AppAllowedStorage entry in allowedStorages) + { +
+
+ @entry.StorageLink.Provider + @entry.StorageLink.Name + @if (!string.IsNullOrWhiteSpace(entry.StorageLink.BucketName)) + { + @entry.StorageLink.BucketName + } +
+ @if (confirmDeleteStorageId != entry.Id) + { + + } + else + { + + + } +
+ } +
+ } +
+ +
+
} @code { [Parameter, EditorRequired] public Guid AppId { get; set; } [Parameter, EditorRequired] public Guid TenantId { get; set; } + [Parameter, EditorRequired] public Guid EnvironmentId { get; set; } private bool loading = true; // Namespace - private string ns = ""; + private string nsValue = ""; private bool savingNs; private string? nsMessage; private bool nsOk; + // Copy from environment + private bool showCopyFrom; + private List? otherEnvironments; + private Guid copyFromEnvId; + private bool confirmCopy, copying; + private string? copyMessage; + private bool copyOk; + // Quota private AppQuota? quota; private string qCpuReq = "", qCpuLim = "", qMemReq = "", qMemLim = ""; @@ -436,10 +766,17 @@ else loading = false; } + protected override async Task OnParametersSetAsync() + { + if (showCopyFrom && otherEnvironments is null) + await LoadOtherEnvironments(); + } + private async Task LoadAsync() { - AppGovernanceData data = await GovernanceService.LoadAsync(AppId); - ns = data.Namespace ?? ""; + AppGovernanceData data = await GovernanceService.LoadAsync(AppId, EnvironmentId); + + nsValue = data.Namespace ?? ""; quota = data.Quota; if (quota is not null) @@ -460,20 +797,66 @@ else rbacSaName = rbacPolicy.ServiceAccountName; rbacAutoMount = rbacPolicy.AutoMountToken; } + + allowedDatabases = data.AllowedDatabases; + allowedCaches = data.AllowedCaches; + allowedStorages = data.AllowedStorages; } + private async Task LoadOtherEnvironments() + { + List all = await TenantService.GetEnvironmentsAsync(TenantId); + otherEnvironments = all.Where(e => e.Id != EnvironmentId).ToList(); + } + + // ── Copy from environment ────────────────────────────────────────────────── + + private async Task DoCopyFrom() + { + copying = true; copyMessage = null; + try + { + await GovernanceService.CopyFromEnvironmentAsync(AppId, copyFromEnvId, EnvironmentId); + await LoadAsync(); + copyOk = true; + copyMessage = "Governance copied and loaded."; + showCopyFrom = false; + confirmCopy = false; + } + catch (Exception ex) { copyOk = false; copyMessage = ex.Message; } + finally { copying = false; } + } + + // ── Namespace ────────────────────────────────────────────────────────────── + private async Task SaveNamespace() { savingNs = true; nsMessage = null; - await GovernanceService.SetNamespaceAsync(AppId, ns); - savingNs = false; nsOk = true; - nsMessage = "Saved."; + await GovernanceService.SaveNamespaceAsync(AppId, EnvironmentId, nsValue); + nsValue = nsValue.Trim().ToLowerInvariant(); + savingNs = false; nsOk = true; nsMessage = "Namespace saved."; + } + + private async Task ClearNamespace() + { + await GovernanceService.SaveNamespaceAsync(AppId, EnvironmentId, null); + nsValue = ""; nsMessage = null; + } + + // ── Quota ────────────────────────────────────────────────────────────────── + + private void ApplyQuotaPreset(string cpuReq, string cpuLim, string memReq, string memLim, int maxPods, int maxPvcs) + { + qCpuReq = cpuReq; qCpuLim = cpuLim; + qMemReq = memReq; qMemLim = memLim; + qMaxPods = maxPods; qMaxPvcs = maxPvcs; + quotaMessage = null; } private async Task SaveQuota() { savingQuota = true; quotaMessage = null; - quota = await GovernanceService.SaveQuotaAsync(AppId, + quota = await GovernanceService.SaveQuotaAsync(AppId, EnvironmentId, qCpuReq, qCpuLim, qMemReq, qMemLim, qMaxPods, qMaxPvcs); savingQuota = false; quotaOk = true; quotaMessage = "Quota saved."; @@ -481,19 +864,21 @@ else private async Task DeleteQuota() { - await GovernanceService.DeleteQuotaAsync(AppId); + await GovernanceService.DeleteQuotaAsync(AppId, EnvironmentId); quota = null; qCpuReq = qCpuLim = qMemReq = qMemLim = ""; qMaxPods = qMaxPvcs = null; } + // ── Network policies ─────────────────────────────────────────────────────── + private async Task AddNetworkPolicy() { addingNp = true; npMessage = null; try { AppNetworkPolicy p = await GovernanceService.AddNetworkPolicyAsync( - AppId, newNpName, newNpType, + AppId, EnvironmentId, newNpName, newNpType, newNpAllowNs, newNpCustomYaml); networkPolicies.Add(p); showAddNp = false; @@ -511,17 +896,19 @@ else confirmDeleteNpId = null; } + // ── RBAC ─────────────────────────────────────────────────────────────────── + private async Task SaveRbac() { savingRbac = true; rbacMessage = null; - rbacPolicy = await GovernanceService.SaveRbacPolicyAsync(AppId, rbacSaName, rbacAutoMount); + rbacPolicy = await GovernanceService.SaveRbacPolicyAsync(AppId, EnvironmentId, rbacSaName, rbacAutoMount); savingRbac = false; rbacOk = true; rbacMessage = "Saved."; } private async Task DeleteRbac() { - await GovernanceService.DeleteRbacPolicyAsync(AppId); + await GovernanceService.DeleteRbacPolicyAsync(AppId, EnvironmentId); rbacPolicy = null; rbacSaName = ""; rbacAutoMount = false; } @@ -549,26 +936,49 @@ else rbacPolicy?.Rules.Remove(rbacPolicy.Rules.First(r => r.Id == id)); } - private async Task ApplyToAllClusters() + // ── Apply ────────────────────────────────────────────────────────────────── + + private async Task ApplyToEnvironmentClusters() { applying = true; applyOutput = null; var sb = new System.Text.StringBuilder(); - List clusters = await TenantService.GetClustersAsync(TenantId); + // Only apply to clusters that have a deployment for this app+environment. + List envDeployments = (await DeploymentService.GetDeploymentsAsync(AppId)) + .Where(d => d.EnvironmentId == EnvironmentId && d.Cluster is not null) + .DistinctBy(d => d.ClusterId) + .ToList(); - foreach (KubernetesCluster cluster in clusters) + if (envDeployments.Count == 0) { - var (ok, output) = await GovernanceService.ApplyToClusterAsync(AppId, cluster); - sb.AppendLine($"── {cluster.Name} ──"); - sb.AppendLine(ok ? "✓ Applied" : $"✗ {output}"); - if (!ok && !string.IsNullOrWhiteSpace(output)) - sb.AppendLine(output); - applySuccess = ok; + applyOutput = "No deployments found for this environment. Create a deployment first."; + applySuccess = false; + applying = false; + return; } - applying = false; + bool anyFail = false; + foreach (AppDeployment deploy in envDeployments) + { + (bool ok, string output) = await GovernanceService.ApplyToClusterAsync( + AppId, EnvironmentId, deploy.Cluster!); + sb.AppendLine($"── {deploy.Cluster!.Name} ({deploy.Namespace}) ──"); + sb.AppendLine(ok ? "✓ Applied" : $"✗ {output}"); + if (!ok) anyFail = true; + } + + applySuccess = !anyFail; applyOutput = sb.ToString().TrimEnd(); - applySuccess = !applyOutput.Contains("✗"); + applying = false; + } + + // ── Lazy load copy panel ─────────────────────────────────────────────────── + + private async Task OpenCopyFrom() + { + showCopyFrom = !showCopyFrom; + if (showCopyFrom && otherEnvironments is null) + await LoadOtherEnvironments(); } private static string NpTypeLabel(AppNetworkPolicyType t) => t switch @@ -580,4 +990,148 @@ else AppNetworkPolicyType.Custom => "Custom YAML", _ => t.ToString() }; + + // ── Allowed databases ────────────────────────────────────────────────────── + + private List allowedDatabases = []; + private List? availableDbs; + private bool showAddDb; + private bool addingDb; + private Guid newDbOptionId; + private Guid? confirmDeleteDbId; + private string? allowedDbMessage; + + private async Task OpenAddDb() + { + showAddDb = true; + if (availableDbs is null) + availableDbs = await GovernanceService.GetAvailableDatabasesAsync(TenantId, EnvironmentId); + } + + private async Task AddAllowedDatabase() + { + allowedDbMessage = null; + AvailableDatabaseOption? opt = availableDbs?.FirstOrDefault(o => o.Id == newDbOptionId); + if (opt is null) return; + + addingDb = true; + try + { + AppAllowedDatabase entry = await GovernanceService.AddAllowedDatabaseAsync( + AppId, EnvironmentId, opt.CnpgId, opt.MongoId, opt.RegPostgresId); + allowedDatabases.Add(entry); + showAddDb = false; + newDbOptionId = Guid.Empty; + } + catch (Exception ex) { allowedDbMessage = ex.Message; } + finally { addingDb = false; } + } + + private async Task DeleteAllowedDatabase(Guid id) + { + await GovernanceService.DeleteAllowedDatabaseAsync(id); + allowedDatabases.RemoveAll(a => a.Id == id); + confirmDeleteDbId = null; + } + + private static string DbTypeLabel(AppAllowedDatabase entry) + { + if (entry.CnpgDatabase is not null) return "PostgreSQL"; + if (entry.MongoDatabase is not null) return "MongoDB"; + if (entry.RegisteredPostgresDatabase is not null) return "Postgres (registered)"; + return "Database"; + } + + private static string DbAllowedName(AppAllowedDatabase entry) + { + if (entry.CnpgDatabase is not null) + return entry.CnpgDatabase.CnpgCluster is not null + ? $"{entry.CnpgDatabase.Name} ({entry.CnpgDatabase.CnpgCluster.Name})" + : entry.CnpgDatabase.Name; + if (entry.MongoDatabase is not null) + return entry.MongoDatabase.MongoCluster is not null + ? $"{entry.MongoDatabase.Name} ({entry.MongoDatabase.MongoCluster.Name})" + : entry.MongoDatabase.Name; + if (entry.RegisteredPostgresDatabase is not null) + return entry.RegisteredPostgresDatabase.Name; + return "—"; + } + + // ── Allowed caches ───────────────────────────────────────────────────────── + + private List allowedCaches = []; + private List? availableCaches; + private bool showAddCache; + private bool addingCache; + private Guid newCacheId; + private Guid? confirmDeleteCacheId; + private string? allowedCacheMessage; + + private async Task OpenAddCache() + { + showAddCache = true; + if (availableCaches is null) + availableCaches = await GovernanceService.GetAvailableCachesAsync(TenantId); + } + + private async Task AddAllowedCache() + { + allowedCacheMessage = null; + addingCache = true; + try + { + AppAllowedCache entry = await GovernanceService.AddAllowedCacheAsync(AppId, EnvironmentId, newCacheId); + allowedCaches.Add(entry); + showAddCache = false; + newCacheId = Guid.Empty; + } + catch (Exception ex) { allowedCacheMessage = ex.Message; } + finally { addingCache = false; } + } + + private async Task DeleteAllowedCache(Guid id) + { + await GovernanceService.DeleteAllowedCacheAsync(id); + allowedCaches.RemoveAll(a => a.Id == id); + confirmDeleteCacheId = null; + } + + // ── Allowed storages ─────────────────────────────────────────────────────── + + private List allowedStorages = []; + private List? availableStorages; + private bool showAddStorage; + private bool addingStorage; + private Guid newStorageId; + private Guid? confirmDeleteStorageId; + private string? allowedStorageMessage; + + private async Task OpenAddStorage() + { + showAddStorage = true; + if (availableStorages is null) + availableStorages = await GovernanceService.GetAvailableStoragesAsync(TenantId, EnvironmentId); + } + + private async Task AddAllowedStorage() + { + allowedStorageMessage = null; + addingStorage = true; + try + { + AppAllowedStorage entry = await GovernanceService.AddAllowedStorageAsync(AppId, EnvironmentId, newStorageId); + allowedStorages.Add(entry); + showAddStorage = false; + newStorageId = Guid.Empty; + } + catch (Exception ex) { allowedStorageMessage = ex.Message; } + finally { addingStorage = false; } + } + + private async Task DeleteAllowedStorage(Guid id) + { + await GovernanceService.DeleteAllowedStorageAsync(id); + allowedStorages.RemoveAll(a => a.Id == id); + confirmDeleteStorageId = null; + } } diff --git a/src/EntKube.Web/Components/Pages/Tenants/IncidentManagement.razor b/src/EntKube.Web/Components/Pages/Tenants/IncidentManagement.razor index 6d836dd..8304a55 100644 --- a/src/EntKube.Web/Components/Pages/Tenants/IncidentManagement.razor +++ b/src/EntKube.Web/Components/Pages/Tenants/IncidentManagement.razor @@ -1,8 +1,26 @@ @using EntKube.Web.Data @using EntKube.Web.Services @inject IncidentService IncidentService +@inject OnCallService OnCallService +@inject NotificationService NotificationService @inject AuthenticationStateProvider AuthStateProvider +@* ── On-call banner ── *@ +@if (currentOnCall is not null) +{ +
+ + + On call now: @currentOnCall.AssigneeName + @if (!string.IsNullOrEmpty(currentOnCall.AssigneeEmail)) + { + @currentOnCall.AssigneeEmail + } + — until @currentOnCall.EndsAt.ToLocalTime().ToString("MMM d HH:mm") + +
+} + @if (stats is not null) {
@@ -42,6 +60,20 @@
MTTA
+ @{ + int unassignedActive = incidents.Count(i => + i.AssignedTo is null && i.Status != IncidentStatus.Resolved); + } + @if (unassignedActive > 0) + { +
+
+
@unassignedActive
+
Unassigned
+
+
+ } @if (stats.Daily.Count > 1) { int maxCount = stats.Daily.Max(d => d.Count); @@ -87,6 +119,23 @@ +
+ + +
+ + @if (selectedIds.Count > 0) + { + @selectedIds.Count selected + + + + } @@ -106,22 +155,46 @@ else if (!isLoading) + - + @foreach (AlertIncident incident in incidents) { bool expanded = expandedId == incident.Id; - - - + bool selected = selectedIds.Contains(incident.Id); + + + + @@ -140,50 +213,162 @@ else if (!isLoading) } @if (expanded) { - @@ -210,11 +395,26 @@ else if (!isLoading) private bool isLoading = true; private string? currentUser; + private HashSet selectedIds = []; + private Guid? assigningId; + private string assigneeInput = ""; + private bool filterUnassigned; + private string searchText = ""; + private OnCallShift? currentOnCall; + private Dictionary> deliveriesByIncident = new(); + private Guid? reNotifyingId; + private Dictionary reNotifyResults = new(); + protected override async Task OnInitializedAsync() { AuthenticationState authState = await AuthStateProvider.GetAuthenticationStateAsync(); currentUser = authState.User.Identity?.Name ?? "Unknown"; - await Task.WhenAll(LoadIncidents(), LoadStats()); + await Task.WhenAll(LoadIncidents(), LoadStats(), LoadOnCall()); + } + + private async Task LoadOnCall() + { + currentOnCall = await OnCallService.GetCurrentOnCallAsync(TenantId); } private async Task LoadStats() @@ -227,39 +427,147 @@ else if (!isLoading) isLoading = true; StateHasChanged(); - IncidentStatus? status = filterStatus switch + try { - "Active" => IncidentStatus.Active, - "Acknowledged" => IncidentStatus.Acknowledged, - "Resolved" => IncidentStatus.Resolved, - _ => null - }; + IncidentStatus? status = filterStatus switch + { + "Active" => IncidentStatus.Active, + "Acknowledged" => IncidentStatus.Acknowledged, + "Resolved" => IncidentStatus.Resolved, + _ => null + }; - Guid? clusterId = Guid.TryParse(filterClusterId, out Guid cid) ? cid : null; + Guid? clusterId = Guid.TryParse(filterClusterId, out Guid cid) ? cid : null; - incidents = await IncidentService.GetIncidentsForTenantAsync( - TenantId, - status: status, - severity: string.IsNullOrEmpty(filterSeverity) ? null : filterSeverity, - clusterId: clusterId, - from: DateTime.UtcNow.AddDays(-filterDays), - to: null); + incidents = await IncidentService.GetIncidentsForTenantAsync( + TenantId, + status: status, + severity: string.IsNullOrEmpty(filterSeverity) ? null : filterSeverity, + clusterId: clusterId, + from: DateTime.UtcNow.AddDays(-filterDays), + to: null); - // Collect unique clusters from results - clusters = incidents - .Where(i => i.Cluster is not null) - .Select(i => i.Cluster!) - .DistinctBy(c => c.Id) - .OrderBy(c => c.Name) - .ToList(); + if (filterUnassigned) + incidents = incidents.Where(i => i.AssignedTo is null).ToList(); - isLoading = false; + if (!string.IsNullOrWhiteSpace(searchText)) + { + string q = searchText.Trim(); + incidents = incidents.Where(i => + i.AlertName.Contains(q, StringComparison.OrdinalIgnoreCase) || + i.Summary.Contains(q, StringComparison.OrdinalIgnoreCase)).ToList(); + } + + deliveriesByIncident.Clear(); + + clusters = incidents + .Where(i => i.Cluster is not null) + .Select(i => i.Cluster!) + .DistinctBy(c => c.Id) + .OrderBy(c => c.Name) + .ToList(); + } + catch + { + incidents = []; + } + finally + { + isLoading = false; + } } private void ToggleExpand(Guid id) { - expandedId = expandedId == id ? null : id; + if (expandedId == id) + { + expandedId = null; + } + else + { + expandedId = id; + _ = LoadDeliveriesAsync(id); + } noteText = ""; + if (assigningId != id) assigningId = null; + } + + private async Task LoadDeliveriesAsync(Guid incidentId) + { + List deliveries = await IncidentService.GetDeliveriesAsync(incidentId); + deliveriesByIncident[incidentId] = deliveries; + StateHasChanged(); + } + + private async Task ReNotify(AlertIncident incident) + { + reNotifyingId = incident.Id; + reNotifyResults.Remove(incident.Id); + StateHasChanged(); + + try + { + int count = await NotificationService.ReNotifyAsync(incident.Id, incident.Cluster!.TenantId); + reNotifyResults[incident.Id] = count > 0 + ? $"✓ Notified {count} channel{(count == 1 ? "" : "s")}" + : "✓ No channels to notify"; + + // Refresh deliveries so the new entry appears + await LoadDeliveriesAsync(incident.Id); + } + catch (Exception ex) + { + reNotifyResults[incident.Id] = $"✗ {ex.Message}"; + } + finally + { + reNotifyingId = null; + } + } + + private void ToggleSelect(Guid id) + { + if (!selectedIds.Remove(id)) + selectedIds.Add(id); + } + + private void ToggleSelectAll(bool select) + { + selectedIds.Clear(); + if (select) + { + foreach (AlertIncident incident in incidents) + selectedIds.Add(incident.Id); + } + } + + private async Task BulkAcknowledge() + { + await IncidentService.BulkAcknowledgeAsync(selectedIds, currentUser!); + selectedIds.Clear(); + await LoadIncidents(); + } + + private async Task BulkResolve() + { + await IncidentService.BulkResolveAsync(selectedIds); + selectedIds.Clear(); + await LoadIncidents(); + } + + private void StartAssign(AlertIncident incident) + { + assigningId = incident.Id; + assigneeInput = incident.AssignedTo ?? ""; + } + + private async Task SubmitAssign(AlertIncident incident) + { + if (string.IsNullOrWhiteSpace(assigneeInput)) return; + await IncidentService.AssignAsync(incident.Id, assigneeInput.Trim()); + assigningId = null; + await LoadIncidents(); + expandedId = incident.Id; } private async Task Acknowledge(AlertIncident incident) @@ -283,6 +591,36 @@ else if (!isLoading) expandedId = incident.Id; } + private record TimelineEvent(DateTime At, string Icon, string IconColor, string Label, string Detail); + + private static List BuildTimeline(AlertIncident incident) + { + List events = []; + + events.Add(new(incident.StartsAt, "bi-bell-fill", "text-danger", "Alert fired", "")); + + if (incident.EscalatedAt.HasValue) + events.Add(new(incident.EscalatedAt.Value, "bi-exclamation-triangle-fill", "text-danger", + "Escalated", "No acknowledgement within threshold")); + + if (incident.AssignedAt.HasValue && incident.AssignedTo is not null) + events.Add(new(incident.AssignedAt.Value, "bi-person-fill", "text-primary", + $"Assigned to {incident.AssignedTo}", "")); + + if (incident.AcknowledgedAt.HasValue) + events.Add(new(incident.AcknowledgedAt.Value, "bi-check-circle-fill", "text-warning", + $"Acknowledged{(incident.AcknowledgedBy is not null ? $" by {incident.AcknowledgedBy}" : "")}", "")); + + foreach (IncidentNote note in incident.Notes) + events.Add(new(note.CreatedAt, "bi-chat-left-text-fill", "text-secondary", + $"Note by {note.Author}", note.Content)); + + if (incident.ResolvedAt.HasValue) + events.Add(new(incident.ResolvedAt.Value, "bi-check2-circle", "text-success", "Resolved", "")); + + return [.. events.OrderBy(e => e.At)]; + } + private static string FormatDuration(AlertIncident incident) { DateTime end = incident.EndsAt ?? DateTime.UtcNow; diff --git a/src/EntKube.Web/Components/Pages/Tenants/NotificationChannels.razor b/src/EntKube.Web/Components/Pages/Tenants/NotificationChannels.razor index 17b2f9a..fcbc61d 100644 --- a/src/EntKube.Web/Components/Pages/Tenants/NotificationChannels.razor +++ b/src/EntKube.Web/Components/Pages/Tenants/NotificationChannels.razor @@ -3,6 +3,7 @@ @using Microsoft.EntityFrameworkCore @inject IDbContextFactory DbFactory @inject NotificationService NotificationService +@inject NotificationProviderConfigService ProviderConfigService
Notification Channels
@@ -108,13 +109,45 @@ else
- @if (newType == NotificationChannelType.Slack || newType == NotificationChannelType.Teams) + @if (newType == NotificationChannelType.Slack) {
- +
} + else if (newType == NotificationChannelType.Teams) + { + @if (teamsGraphConfigured) + { +
+ + Teams Graph API is configured. Messages will be sent via Microsoft Graph. +
+
+ + +
Found in Teams → right-click team → Get link to team
+
+
+ + +
Found in Teams → right-click channel → Get link to channel
+
+ } + else + { +
+ + Teams Graph API is not configured. Using legacy incoming webhook. + Configure it here. +
+
+ + +
+ } + } else if (newType == NotificationChannelType.Email) {
@@ -177,12 +210,20 @@ else private string newWebhookUrl = ""; private string newEmail = ""; private string newBearerToken = ""; + private string newTeamId = ""; + private string newChannelId = ""; private AlertSeverityFilter newSeverityFilter = AlertSeverityFilter.All; private string? modalError; private Guid? testingId; private Dictionary testResults = new(); + private bool teamsGraphConfigured; - protected override async Task OnInitializedAsync() => await LoadChannels(); + protected override async Task OnInitializedAsync() + { + NotificationProviderConfig? graphConfig = await ProviderConfigService.GetAsync(NotificationProviderType.MsTeamsGraph); + teamsGraphConfigured = graphConfig?.IsEnabled == true; + await LoadChannels(); + } private async Task LoadChannels() { @@ -211,6 +252,8 @@ else newWebhookUrl = ""; newEmail = ""; newBearerToken = ""; + newTeamId = ""; + newChannelId = ""; newSeverityFilter = AlertSeverityFilter.All; modalError = null; } @@ -261,7 +304,17 @@ else { return newType switch { - NotificationChannelType.Slack or NotificationChannelType.Teams => + NotificationChannelType.Slack => + !string.IsNullOrWhiteSpace(newWebhookUrl) + ? System.Text.Json.JsonSerializer.Serialize(new { webhookUrl = newWebhookUrl.Trim() }) + : throw new InvalidOperationException("Webhook URL is required."), + + NotificationChannelType.Teams when teamsGraphConfigured => + !string.IsNullOrWhiteSpace(newTeamId) && !string.IsNullOrWhiteSpace(newChannelId) + ? System.Text.Json.JsonSerializer.Serialize(new { teamId = newTeamId.Trim(), channelId = newChannelId.Trim() }) + : throw new InvalidOperationException("Team ID and Channel ID are required."), + + NotificationChannelType.Teams => !string.IsNullOrWhiteSpace(newWebhookUrl) ? System.Text.Json.JsonSerializer.Serialize(new { webhookUrl = newWebhookUrl.Trim() }) : throw new InvalidOperationException("Webhook URL is required."), diff --git a/src/EntKube.Web/Components/Pages/Tenants/OnCallTab.razor b/src/EntKube.Web/Components/Pages/Tenants/OnCallTab.razor new file mode 100644 index 0000000..a53455b --- /dev/null +++ b/src/EntKube.Web/Components/Pages/Tenants/OnCallTab.razor @@ -0,0 +1,342 @@ +@using EntKube.Web.Data +@using EntKube.Web.Services +@inject OnCallService OnCallService +@inject AuthenticationStateProvider AuthStateProvider + +@* ── Current on-call banner ── *@ +@if (currentShift is not null) +{ +
+ +
+ @currentShift.AssigneeName is currently on call + @if (!string.IsNullOrEmpty(currentShift.AssigneeEmail)) + { + @currentShift.AssigneeEmail + } + until @currentShift.EndsAt.ToLocalTime().ToString("MMM d HH:mm") + @currentShift.Schedule.Name +
+
+} +else +{ +
+ + No one is currently on call. Add a shift to cover now. +
+} + +@* ── Schedules + Create ── *@ +
+
Schedules
+ +
+ +@if (showCreateSchedule) +{ +
+
+
+
+ + +
+
+ + +
+
+ @if (!string.IsNullOrEmpty(scheduleError)) + { +
@scheduleError
+ } +
+ + +
+
+
+} + +@if (schedules.Count == 0 && !isLoading) +{ + +} +else +{ + @foreach (OnCallSchedule schedule in schedules) + { + bool isExpanded = expandedScheduleId == schedule.Id; +
+
+ + @schedule.Name + @if (!string.IsNullOrEmpty(schedule.Description)) + { + @schedule.Description + } + @if (!schedule.IsEnabled) + { + Disabled + } + else + { + int activeCount = schedule.Shifts.Count(sh => sh.StartsAt <= DateTime.UtcNow && sh.EndsAt >= DateTime.UtcNow); + int upcomingCount = schedule.Shifts.Count(sh => sh.StartsAt > DateTime.UtcNow); + @if (activeCount > 0) + { + Active now + } + @schedule.Shifts.Count shift@(schedule.Shifts.Count == 1 ? "" : "s") + } + + +
+ + @if (isExpanded) + { +
+ @* ── Shifts list ── *@ + @if (schedule.Shifts.Count == 0) + { +

No shifts yet. Add the first shift below.

+ } + else + { +
+
+ + Severity Alert Cluster Started Duration StatusActionsActions
@SeverityBadge(incident.Severity)@incident.AlertName
+ + + @SeverityBadge(incident.Severity) + @if (incident.EscalatedAt.HasValue) + { +
Escalated
+ } +
+
@incident.AlertName
+ @if (incident.AssignedTo is not null) + { +
+ @incident.AssignedTo +
+ } +
@incident.Cluster?.Name @incident.StartsAt.ToLocalTime().ToString("MMM d HH:mm") @FormatDuration(incident)
- @if (!string.IsNullOrEmpty(incident.Summary)) - { -

Summary: @incident.Summary

- } - @if (!string.IsNullOrEmpty(incident.Description)) - { -

@incident.Description

- } - @if (incident.AcknowledgedBy is not null) - { -

- Acknowledged by @incident.AcknowledgedBy - at @incident.AcknowledgedAt?.ToLocalTime().ToString("MMM d HH:mm") -

- } +
+
- @if (incident.Notes.Count > 0) - { -
- Notes - @foreach (IncidentNote note in incident.Notes) + @* ── Left: Timeline ── *@ +
+ @if (!string.IsNullOrEmpty(incident.Summary)) { -
-
@note.Author · @note.CreatedAt.ToLocalTime().ToString("MMM d HH:mm")
-
@note.Content
+

Summary: @incident.Summary

+ } + @if (!string.IsNullOrEmpty(incident.Description)) + { +

@incident.Description

+ } + @if (!string.IsNullOrEmpty(incident.RunbookUrl)) + { +

+ + Runbook + +

+ } + + Timeline +
+
+ @foreach (TimelineEvent ev in BuildTimeline(incident)) + { +
+ + + +
+ @ev.Label + @ev.At.ToLocalTime().ToString("MMM d HH:mm") +
+ @if (!string.IsNullOrEmpty(ev.Detail)) + { +
@ev.Detail
+ } +
+ } +
+
+ + @* ── Right: Actions ── *@ +
+ + @* Assignment *@ +
+ Assignment + @if (incident.AssignedTo is not null) + { +

+ + Assigned to @incident.AssignedTo + @if (incident.AssignedAt.HasValue) + { + at @incident.AssignedAt.Value.ToLocalTime().ToString("MMM d HH:mm") + } +

+ } + @if (assigningId == incident.Id) + { +
+ + + +
+ } + else + { + + } +
+ + @* Notification delivery history *@ + @if (deliveriesByIncident.TryGetValue(incident.Id, out List? deliveries) && deliveries.Count > 0) + { +
+ Notification history +
+ + + @foreach (NotificationDelivery d in deliveries) + { + + + + + + + } + +
@d.SentAt.ToLocalTime().ToString("MMM d HH:mm")@d.Channel?.Name@(d.IsFiring ? "FIRING" : "RESOLVED") + @if (d.Success) + { + Sent + } + else + { + Failed + } +
+
} -
- } -
- - + @* Re-notify *@ +
+ + @if (reNotifyResults.TryGetValue(incident.Id, out string? reNotifyMsg)) + { + @reNotifyMsg + } +
+ + @* Add note *@ + Add note +
+ + +
+
+
+ + + + + + + + + + + @foreach (OnCallShift shift in schedule.Shifts.OrderBy(sh => sh.StartsAt)) + { + bool active = shift.StartsAt <= DateTime.UtcNow && shift.EndsAt >= DateTime.UtcNow; + bool past = shift.EndsAt < DateTime.UtcNow; + + + + + + + + } + +
AssigneeStartEndStatus
+
@shift.AssigneeName
+ @if (!string.IsNullOrEmpty(shift.AssigneeEmail)) + { +
@shift.AssigneeEmail
+ } +
@shift.StartsAt.ToLocalTime().ToString("MMM d HH:mm")@shift.EndsAt.ToLocalTime().ToString("MMM d HH:mm") + @if (active) + { + Active + } + else if (past) + { + Past + } + else + { + Upcoming + } + + +
+
+ } + + @* ── Add shift form ── *@ + @if (addShiftScheduleId == schedule.Id) + { +
+
Add Shift
+
+
+ + +
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ + +
+
+ @if (!string.IsNullOrEmpty(shiftError)) + { +
@shiftError
+ } +
+ + +
+
+ } + else + { + + } +
+ } +
+ } +} + +@code { + [Parameter] public required Guid TenantId { get; set; } + + private List schedules = []; + private OnCallShift? currentShift; + private bool isLoading = true; + private Guid? expandedScheduleId; + + // Create schedule form + private bool showCreateSchedule; + private string newScheduleName = ""; + private string? newScheduleDescription; + private string? scheduleError; + + // Add shift form + private Guid? addShiftScheduleId; + private string newShiftName = ""; + private string? newShiftEmail; + private string? newShiftNotes; + private DateTime newShiftStartsAt = DateTime.Now.Date.AddHours(DateTime.Now.Hour + 1); + private DateTime newShiftEndsAt = DateTime.Now.Date.AddHours(DateTime.Now.Hour + 9); + private string? shiftError; + + protected override async Task OnInitializedAsync() + { + await Load(); + } + + private async Task Load() + { + isLoading = true; + Task> schedulesTask = OnCallService.GetSchedulesAsync(TenantId); + Task currentTask = OnCallService.GetCurrentOnCallAsync(TenantId); + await Task.WhenAll(schedulesTask, currentTask); + schedules = schedulesTask.Result; + currentShift = currentTask.Result; + isLoading = false; + } + + private void ToggleSchedule(Guid id) + { + expandedScheduleId = expandedScheduleId == id ? null : id; + addShiftScheduleId = null; + } + + private async Task CreateSchedule() + { + scheduleError = null; + if (string.IsNullOrWhiteSpace(newScheduleName)) return; + + try + { + await OnCallService.CreateScheduleAsync(TenantId, newScheduleName.Trim(), newScheduleDescription?.Trim()); + newScheduleName = ""; + newScheduleDescription = null; + showCreateSchedule = false; + await Load(); + } + catch (Exception ex) + { + scheduleError = ex.Message; + } + } + + private async Task DeleteSchedule(OnCallSchedule schedule) + { + await OnCallService.DeleteScheduleAsync(schedule.Id); + await Load(); + } + + private void StartAddShift(Guid scheduleId) + { + addShiftScheduleId = scheduleId; + newShiftName = ""; + newShiftEmail = null; + newShiftNotes = null; + newShiftStartsAt = DateTime.Now.Date.AddHours(DateTime.Now.Hour + 1); + newShiftEndsAt = DateTime.Now.Date.AddHours(DateTime.Now.Hour + 9); + shiftError = null; + } + + private void CancelAddShift() + { + addShiftScheduleId = null; + shiftError = null; + } + + private async Task AddShift(Guid scheduleId) + { + shiftError = null; + if (string.IsNullOrWhiteSpace(newShiftName)) return; + if (newShiftEndsAt <= newShiftStartsAt) + { + shiftError = "End time must be after start time."; + return; + } + + await OnCallService.AddShiftAsync( + scheduleId, + newShiftName.Trim(), + string.IsNullOrWhiteSpace(newShiftEmail) ? null : newShiftEmail.Trim(), + newShiftStartsAt.ToUniversalTime(), + newShiftEndsAt.ToUniversalTime(), + string.IsNullOrWhiteSpace(newShiftNotes) ? null : newShiftNotes.Trim()); + + addShiftScheduleId = null; + await Load(); + expandedScheduleId = scheduleId; + } + + private async Task DeleteShift(OnCallShift shift) + { + await OnCallService.DeleteShiftAsync(shift.Id); + await Load(); + expandedScheduleId = shift.ScheduleId; + } +} diff --git a/src/EntKube.Web/Components/Pages/Tenants/ServerEditForm.razor b/src/EntKube.Web/Components/Pages/Tenants/ServerEditForm.razor new file mode 100644 index 0000000..5afda60 --- /dev/null +++ b/src/EntKube.Web/Components/Pages/Tenants/ServerEditForm.razor @@ -0,0 +1,91 @@ +@using EntKube.Web.Data + +@* ── Server record edit / create form ── *@ +
+
+ + +
+
+ + +
+
+ + +
+ +
+ + +
+
+ + +
+
+ + +
+ +
+ + +
+
+ + +
+
+ + +
+
+ + +
+ +
+ + +
+
+ + +
+
+ + +
+ +
+ + +
+ +
+ + +
+
+ +@code { + [Parameter, EditorRequired] public ClusterServer Server { get; set; } = null!; + [Parameter, EditorRequired] public EventCallback OnSave { get; set; } + [Parameter, EditorRequired] public EventCallback OnCancel { get; set; } + + private Task HandleSave() => OnSave.InvokeAsync(Server); +} diff --git a/src/EntKube.Web/Components/Pages/Tenants/TenantAlertRulesTab.razor b/src/EntKube.Web/Components/Pages/Tenants/TenantAlertRulesTab.razor new file mode 100644 index 0000000..844ca4d --- /dev/null +++ b/src/EntKube.Web/Components/Pages/Tenants/TenantAlertRulesTab.razor @@ -0,0 +1,80 @@ +@using EntKube.Web.Data +@using EntKube.Web.Services +@using Microsoft.EntityFrameworkCore +@inject IDbContextFactory DbFactory + +@* Cluster picker — AlertRulesPanel is cluster-scoped *@ +
+ + + @if (clusterOptions.Any(c => c.HasPrometheus) && string.IsNullOrEmpty(selectedClusterId)) + { + Select a cluster to view its alert rules. + } +
+ +@if (string.IsNullOrEmpty(selectedClusterId)) +{ + @if (!isLoading && clusterOptions.Count == 0) + { + + } + else if (!isLoading && !clusterOptions.Any(c => c.HasPrometheus)) + { + + } +} +else +{ + +} + +@code { + [Parameter] public required Guid TenantId { get; set; } + + private List clusterOptions = []; + private string selectedClusterId = ""; + private bool isLoading = true; + + protected override async Task OnInitializedAsync() + { + using ApplicationDbContext db = DbFactory.CreateDbContext(); + List clusters = await db.KubernetesClusters + .Include(c => c.Components) + .Where(c => c.TenantId == TenantId) + .OrderBy(c => c.Name) + .ToListAsync(); + + clusterOptions = clusters.Select(c => new ClusterOption( + c.Id.ToString(), + c.Name, + HasPrometheus: c.Components.Any(comp => + comp.HelmChartName == "kube-prometheus-stack" && + comp.Status == ComponentStatus.Installed) + )).ToList(); + + // Auto-select first cluster with Prometheus + ClusterOption? best = clusterOptions.FirstOrDefault(c => c.HasPrometheus); + if (best is not null) + selectedClusterId = best.Id; + + isLoading = false; + } + + private void OnClusterChanged() { } + + private record ClusterOption(string Id, string Name, bool HasPrometheus); +} diff --git a/src/EntKube.Web/Components/Pages/Tenants/TenantAlertsTab.razor b/src/EntKube.Web/Components/Pages/Tenants/TenantAlertsTab.razor new file mode 100644 index 0000000..be27273 --- /dev/null +++ b/src/EntKube.Web/Components/Pages/Tenants/TenantAlertsTab.razor @@ -0,0 +1,679 @@ +@using EntKube.Web.Data +@using EntKube.Web.Services +@using Microsoft.EntityFrameworkCore +@inject PrometheusService PrometheusService +@inject RemediationService RemediationService +@inject IncidentService IncidentService +@inject IDbContextFactory DbFactory +@inject AuthenticationStateProvider AuthStateProvider +@implements IAsyncDisposable + +@* ── Toolbar ── *@ +
+ + + @if (selectedKeys.Count > 0) + { + @selectedKeys.Count selected + + + + } +
+ @if (lastRefreshed.HasValue) + { + Refreshed @lastRefreshed.Value.ToLocalTime().ToString("HH:mm:ss") + } + +
+
+ +@* ── Summary strip ── *@ +@{ + int totalFiring = Filtered.Count(a => a.Alert.State == "active"); + int totalWarning = Filtered.Count(a => a.Alert.Severity == "warning"); + int withRemediation = Filtered.Count(a => a.Remediation is not null); +} +@if (clusterAlerts.Any(vm => vm.Alerts is not null)) +{ +
+
+
@totalFiring
+
Firing
+
+
+
@totalWarning
+
Warning
+
+
+
@withRemediation
+
Auto-fixable
+
+ @foreach (ClusterAlertVm vm in clusterAlerts.Where(v => v.Error is not null)) + { +
+
@vm.Cluster.Name unavailable
+
+ } +
+} + +@if (isLoading && !clusterAlerts.Any()) +{ +
+
+

Loading alerts from clusters…

+
+} +else if (!Filtered.Any()) +{ + +} +else +{ +
+ + + + + + + + + + + + + + @foreach (AlertRow row in Filtered.OrderByDescending(r => r.Alert.Severity == "critical") + .ThenByDescending(r => r.Alert.Severity == "warning") + .ThenBy(r => r.Alert.StartsAt)) + { + bool expanded = expandedKey == row.Key; + bool confirming = confirmKey == row.Key; + bool selected = selectedKeys.Contains(row.Key); + + + + + + + + + + + + @* ── Confirm remediation ── *@ + @if (confirming && row.Remediation is not null) + { + + + + } + + @* ── Labels / details expand ── *@ + @if (expanded) + { + + + + } + } + +
+ + SeverityAlertClusterNamespaceStartedActions
+ + @SeverityBadge(row.Alert.Severity) +
@row.Alert.Name
+ @if (!string.IsNullOrEmpty(row.Alert.Summary)) + { +
@row.Alert.Summary
+ } + @if (!string.IsNullOrEmpty(row.Alert.RunbookUrl)) + { + + Runbook + + } +
@row.ClusterName + @row.Alert.Labels.GetValueOrDefault("namespace", "—") + @FormatAge(row.Alert.StartsAt) +
+ @* DB incident status badge *@ + @if (row.Incident is not null) + { + @if (row.Incident.Status == IncidentStatus.Active) + { + + } + else if (row.Incident.Status == IncidentStatus.Acknowledged) + { + Acked + + } + } + @if (row.Remediation is not null) + { + + } + + +
+
+
+ +
+
Auto-fix: @row.Remediation.ResourceKind/@row.Remediation.ResourceName
+
@row.Remediation.Description
+ @if (remediationResults.TryGetValue(row.Key, out string? result)) + { +
+ @result +
+ } +
+ + +
+
+
+
+ @if (!string.IsNullOrEmpty(row.Alert.Description)) + { +

@row.Alert.Description

+ } +
+ @foreach (KeyValuePair label in row.Alert.Labels + .Where(l => l.Key != "alertname" && l.Key != "severity")) + { + + @label.Key=@label.Value + + } +
+
+ Fingerprint: @row.Alert.Fingerprint +
+
+
+} + +@* ── Bulk silence form ── *@ +@if (showBulkSilenceForm) +{ +
+
+
Silence @selectedKeys.Count selected alert@(selectedKeys.Count == 1 ? "" : "s")
+ +
+
+
+
+ + +
+
+ + +
+
+ @if (!string.IsNullOrEmpty(bulkSilenceError)) + { +
@bulkSilenceError
+ } + @if (!string.IsNullOrEmpty(bulkSilenceResult)) + { +
@bulkSilenceResult
+ } +
+ + +
+
+
+} + +@* ── Create silence form ── *@ +@if (showSilenceForm && silenceTarget is not null) +{ +
+
+
Silence — @silenceTarget.Alert.Name
+ +
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ @if (!string.IsNullOrEmpty(silenceError)) + { +
@silenceError
+ } +
+ + +
+
+
+} + +@code { + [Parameter] public required Guid TenantId { get; set; } + + private List clusterAlerts = []; + private Dictionary incidentsByFingerprint = new(); + private string filterClusterId = ""; + private string filterSeverity = ""; + private string? expandedKey; + private string? confirmKey; + private string? executingKey; + private Dictionary remediationResults = new(); + private bool isLoading = true; + private DateTime? lastRefreshed; + private string? currentUser; + private Timer? refreshTimer; + + // Silence form + private bool showSilenceForm; + private AlertRow? silenceTarget; + private string silenceMatcher = ""; + private int silenceHours = 2; + private string silenceComment = ""; + private string? silenceError; + private bool isSilencing; + + // Bulk silence form + private bool showBulkSilenceForm; + private int bulkSilenceHours = 2; + private string bulkSilenceComment = ""; + private string? bulkSilenceError; + private string? bulkSilenceResult; + private bool isBulkSilencing; + + private HashSet selectedKeys = []; + + private IEnumerable Filtered => clusterAlerts + .Where(vm => vm.Alerts is not null) + .SelectMany(vm => vm.Alerts!.Select(a => + { + string key = $"{vm.Cluster.Id}:{a.Fingerprint}"; + incidentsByFingerprint.TryGetValue(key, out AlertIncident? incident); + return new AlertRow( + Key: key, + ClusterId: vm.Cluster.Id, + ClusterName: vm.Cluster.Name, + Alert: a, + Remediation: RemediationService.TryGetRemediation(a), + Incident: incident); + })) + .Where(r => + (string.IsNullOrEmpty(filterClusterId) || r.ClusterId.ToString() == filterClusterId) && + (string.IsNullOrEmpty(filterSeverity) || r.Alert.Severity == filterSeverity)); + + protected override async Task OnInitializedAsync() + { + AuthenticationState auth = await AuthStateProvider.GetAuthenticationStateAsync(); + currentUser = auth.User.Identity?.Name ?? "Unknown"; + await Refresh(); + refreshTimer = new Timer(_ => InvokeAsync(async () => + { + await Refresh(); + StateHasChanged(); + }), null, TimeSpan.FromMinutes(2), TimeSpan.FromMinutes(2)); + } + + private async Task Refresh() + { + isLoading = true; + StateHasChanged(); + + List clusters; + using (ApplicationDbContext db = DbFactory.CreateDbContext()) + { + clusters = await db.KubernetesClusters + .Include(c => c.Components) + .Where(c => c.TenantId == TenantId) + .OrderBy(c => c.Name) + .ToListAsync(); + } + + // Only query clusters that have Prometheus installed + List prometheusEnabled = clusters + .Where(c => c.Components.Any(comp => + comp.HelmChartName == "kube-prometheus-stack" && + comp.Status == ComponentStatus.Installed)) + .ToList(); + + List> tasks = prometheusEnabled + .Select(c => LoadClusterAlertsAsync(c)) + .ToList(); + + clusterAlerts = [.. await Task.WhenAll(tasks)]; + + // Build fingerprint → incident lookup keyed by "clusterId:fingerprint" + List activeIncidents = + await IncidentService.GetActiveIncidentsForTenantAsync(TenantId); + incidentsByFingerprint = activeIncidents + .Where(i => !string.IsNullOrEmpty(i.Fingerprint)) + .ToDictionary(i => $"{i.ClusterId}:{i.Fingerprint}"); + + lastRefreshed = DateTime.UtcNow; + isLoading = false; + } + + private async Task LoadClusterAlertsAsync(KubernetesCluster cluster) + { + KubernetesOperationResult> result = + await PrometheusService.GetAlertsAsync(cluster.Id); + + return result.IsSuccess + ? new ClusterAlertVm(cluster, result.Data ?? [], null) + : new ClusterAlertVm(cluster, null, result.Error); + } + + private void Toggle(string key) + { + if (expandedKey == key) expandedKey = null; + else { expandedKey = key; confirmKey = null; } + } + + private void StartConfirm(string key) + { + confirmKey = confirmKey == key ? null : key; + expandedKey = null; + remediationResults.Remove(key); + } + + private async Task ExecuteRemediation(AlertRow row) + { + if (row.Remediation is null) return; + executingKey = row.Key; + StateHasChanged(); + + KubernetesOperationResult result = + await RemediationService.ExecuteAsync(row.ClusterId, row.Remediation, currentUser); + + remediationResults[row.Key] = result.IsSuccess + ? "✓ Remediation executed successfully. The alert should clear shortly." + : $"✗ {result.Error}"; + + executingKey = null; + + if (result.IsSuccess) + { + // Wait a moment then refresh alerts + await Task.Delay(2000); + await Refresh(); + } + } + + private void StartSilence(AlertRow row) + { + silenceTarget = row; + silenceMatcher = $"alertname={row.Alert.Name}"; + silenceComment = ""; + silenceError = null; + showSilenceForm = true; + expandedKey = null; + confirmKey = null; + } + + private async Task AcknowledgeIncident(AlertIncident incident) + { + await IncidentService.AcknowledgeAsync(incident.Id, currentUser!); + await Refresh(); + } + + private async Task ResolveIncident(AlertIncident incident) + { + await IncidentService.ResolveAsync(incident.Id, currentUser!); + await Refresh(); + } + + private void ToggleSelect(string key) + { + if (!selectedKeys.Remove(key)) + selectedKeys.Add(key); + } + + private void ToggleSelectAll(bool select) + { + selectedKeys.Clear(); + if (select) + { + foreach (AlertRow row in Filtered) + selectedKeys.Add(row.Key); + } + } + + private async Task BulkAcknowledge() + { + List incidentIds = Filtered + .Where(r => selectedKeys.Contains(r.Key) + && r.Incident?.Status == IncidentStatus.Active) + .Select(r => r.Incident!.Id) + .ToList(); + + if (incidentIds.Count > 0) + await IncidentService.BulkAcknowledgeAsync(incidentIds, currentUser!); + + selectedKeys.Clear(); + await Refresh(); + } + + private void StartBulkSilence() + { + bulkSilenceComment = ""; + bulkSilenceError = null; + bulkSilenceResult = null; + showBulkSilenceForm = true; + } + + private async Task SubmitBulkSilence() + { + if (string.IsNullOrWhiteSpace(bulkSilenceComment)) return; + + bulkSilenceError = null; + bulkSilenceResult = null; + isBulkSilencing = true; + StateHasChanged(); + + List selected = Filtered + .Where(r => selectedKeys.Contains(r.Key)) + .ToList(); + + int ok = 0, fail = 0; + foreach (AlertRow row in selected) + { + KubernetesOperationResult result = await PrometheusService.CreateSilenceAsync( + row.ClusterId, + bulkSilenceComment, + currentUser ?? "entkube-user", + TimeSpan.FromHours(bulkSilenceHours), + [new SilenceMatcher { Name = "alertname", Value = row.Alert.Name, IsEqual = true }]); + + if (result.IsSuccess) ok++; else fail++; + } + + isBulkSilencing = false; + selectedKeys.Clear(); + + if (fail == 0) + { + bulkSilenceResult = $"✓ Created {ok} silence{(ok == 1 ? "" : "s")} successfully."; + await Task.Delay(1500); + showBulkSilenceForm = false; + await Refresh(); + } + else + { + bulkSilenceError = $"{ok} silence{(ok == 1 ? "" : "s")} created, {fail} failed."; + } + } + + private void CancelSilence() + { + showSilenceForm = false; + silenceTarget = null; + } + + private async Task SubmitSilence() + { + if (silenceTarget is null) return; + silenceError = null; + + string[] parts = silenceMatcher.Split('=', 2); + if (parts.Length != 2 || string.IsNullOrWhiteSpace(parts[0])) + { + silenceError = "Format: label=value"; + return; + } + + isSilencing = true; + StateHasChanged(); + + KubernetesOperationResult result = await PrometheusService.CreateSilenceAsync( + silenceTarget.ClusterId, + silenceComment, + currentUser ?? "entkube-user", + TimeSpan.FromHours(silenceHours), + [new SilenceMatcher { Name = parts[0].Trim(), Value = parts[1].Trim(), IsEqual = true }]); + + isSilencing = false; + + if (result.IsSuccess) + { + showSilenceForm = false; + silenceTarget = null; + await Refresh(); + } + else + { + silenceError = result.Error; + } + } + + private static string FormatAge(DateTime startsAt) + { + TimeSpan age = DateTime.UtcNow - startsAt; + return age switch + { + { TotalMinutes: < 1 } => "just now", + { TotalMinutes: < 60 } => $"{(int)age.TotalMinutes}m ago", + { TotalHours: < 24 } => $"{(int)age.TotalHours}h ago", + _ => $"{(int)age.TotalDays}d ago" + }; + } + + private static RenderFragment SeverityBadge(string severity) => severity switch + { + "critical" => @Critical, + "warning" => @Warning, + "info" => @Info, + _ => @@severity + }; + + public async ValueTask DisposeAsync() + { + if (refreshTimer is not null) + await refreshTimer.DisposeAsync(); + } + + private record ClusterAlertVm(KubernetesCluster Cluster, List? Alerts, string? Error); + private record AlertRow(string Key, Guid ClusterId, string ClusterName, AlertInfo Alert, AlertRemediation? Remediation, AlertIncident? Incident); +} diff --git a/src/EntKube.Web/Components/Pages/Tenants/TenantDetail.razor b/src/EntKube.Web/Components/Pages/Tenants/TenantDetail.razor index 8ab2940..f734315 100644 --- a/src/EntKube.Web/Components/Pages/Tenants/TenantDetail.razor +++ b/src/EntKube.Web/Components/Pages/Tenants/TenantDetail.razor @@ -37,22 +37,22 @@ else @* ── Category (top-level) nav ── *@
- @switch (activeTab) + @if (tabLoading) + { +
+
+
+ } + else @switch (activeTab) { case "environments": @@ -193,7 +224,16 @@ else break; case "overview": - + + break; + case "alerts": + + break; + case "alert-rules": + + break; + case "logs": + break; case "incidents": @@ -204,15 +244,21 @@ else case "maintenance": break; + case "on-call": + + break; + case "alert-routing": + + break; + case "analytics": + + break; case "sla": break; case "networking": break; - case "git": - - break; }
} @@ -223,6 +269,7 @@ else private Tenant? tenant; private string activeCategory = "apps"; private string activeTab = "customers"; + private bool tabLoading; private static readonly Dictionary CategoryDefaults = new() { @@ -232,10 +279,23 @@ else ["ops"] = "overview", }; - private void SetCategory(string category) + private async Task SetCategory(string category) { + tabLoading = true; activeCategory = category; activeTab = CategoryDefaults[category]; + StateHasChanged(); + await Task.Yield(); + tabLoading = false; + } + + private async Task SetTab(string tab) + { + tabLoading = true; + activeTab = tab; + StateHasChanged(); + await Task.Yield(); + tabLoading = false; } protected override async Task OnInitializedAsync() diff --git a/src/EntKube.Web/Components/Pages/Tenants/TenantList.razor b/src/EntKube.Web/Components/Pages/Tenants/TenantList.razor index 95d623d..a0d4945 100644 --- a/src/EntKube.Web/Components/Pages/Tenants/TenantList.razor +++ b/src/EntKube.Web/Components/Pages/Tenants/TenantList.razor @@ -29,14 +29,21 @@ -
@if (!string.IsNullOrEmpty(errorMessage)) {
@errorMessage
} + @if (!string.IsNullOrEmpty(successMessage)) + { +
@successMessage
+ }
} @@ -76,14 +83,13 @@ @if (isAdmin && confirmDeleteId == tenant.Id) { -
-
- Delete this tenant? -
- - -
-
+
+
} @@ -104,9 +110,12 @@ private List? tenants; private string newTenantName = ""; private string? errorMessage; + private string? successMessage; private Guid? confirmDeleteId; private bool isAdmin; private bool loaded; + private bool creating; + private bool deleting; private string? userId; protected override async Task OnInitializedAsync() @@ -146,28 +155,43 @@ tenants = await UserAccessService.GetAccessibleTenantsAsync(userId); } + private async Task ShowSuccessAsync(string message) + { + successMessage = message; + StateHasChanged(); + await Task.Delay(3500); + successMessage = null; + StateHasChanged(); + } + private async Task CreateTenant() { if (!isAdmin) return; errorMessage = null; + creating = true; try { - await TenantService.CreateTenantAsync(newTenantName.Trim()); + string name = newTenantName.Trim(); + await TenantService.CreateTenantAsync(name); newTenantName = ""; await LoadTenants(); + _ = ShowSuccessAsync($"Tenant '{name}' created."); } catch (DbUpdateException) { errorMessage = "A tenant with that name already exists."; } + finally { creating = false; } } private async Task DeleteTenant(Guid id) { if (!isAdmin) return; - confirmDeleteId = null; + deleting = true; await TenantService.DeleteTenantAsync(id); + deleting = false; + confirmDeleteId = null; await LoadTenants(); } } diff --git a/src/EntKube.Web/Components/Pages/Tenants/TenantLogsTab.razor b/src/EntKube.Web/Components/Pages/Tenants/TenantLogsTab.razor new file mode 100644 index 0000000..e6f76f4 --- /dev/null +++ b/src/EntKube.Web/Components/Pages/Tenants/TenantLogsTab.razor @@ -0,0 +1,97 @@ +@using EntKube.Web.Data +@using EntKube.Web.Services +@using Microsoft.EntityFrameworkCore +@inject IDbContextFactory DbFactory + +@* Cluster picker — required because LogBrowser is cluster-scoped *@ +
+
+ + +
+ + @if (selectedCluster is not null) + { +
+ + Loki @(selectedCluster.HasLoki ? "installed" : "not installed") + +
+ } +
+ +@if (string.IsNullOrEmpty(selectedClusterId) && clusterOptions.Count == 0 && !isLoading) +{ + +} +else if (string.IsNullOrEmpty(selectedClusterId)) +{ + @if (clusterOptions.Count > 0) + { +
+ + Select a cluster above to browse logs. +
+ } +} +else +{ + +} + +@code { + [Parameter] public required Guid TenantId { get; set; } + + private List clusterOptions = []; + private ClusterOption? selectedCluster; + private string selectedClusterId = ""; + private bool isLoading = true; + + protected override async Task OnInitializedAsync() + { + using ApplicationDbContext db = DbFactory.CreateDbContext(); + List clusters = await db.KubernetesClusters + .Include(c => c.Components) + .Where(c => c.TenantId == TenantId) + .OrderBy(c => c.Name) + .ToListAsync(); + + clusterOptions = clusters.Select(c => new ClusterOption( + c.Id.ToString(), + c.Name, + HasLoki: c.Components.Any(comp => comp.HelmChartName == "loki-stack" && comp.Status == ComponentStatus.Installed) + || c.Components.Any(comp => comp.HelmChartName == "grafana-loki" && comp.Status == ComponentStatus.Installed), + HasPrometheus: c.Components.Any(comp => comp.HelmChartName == "kube-prometheus-stack" && comp.Status == ComponentStatus.Installed) + )).ToList(); + + // Auto-select the first cluster that has Loki, then any cluster + ClusterOption? best = clusterOptions.FirstOrDefault(c => c.HasLoki) + ?? clusterOptions.FirstOrDefault(); + if (best is not null) + { + selectedClusterId = best.Id; + selectedCluster = best; + } + + isLoading = false; + } + + private void OnClusterChanged() + { + selectedCluster = clusterOptions.FirstOrDefault(c => c.Id == selectedClusterId); + } + + private record ClusterOption(string Id, string Name, bool HasLoki, bool HasPrometheus); +} diff --git a/src/EntKube.Web/Components/Pages/Tenants/TenantMonitoringOverview.razor b/src/EntKube.Web/Components/Pages/Tenants/TenantMonitoringOverview.razor index 6cf6de3..ebae2c6 100644 --- a/src/EntKube.Web/Components/Pages/Tenants/TenantMonitoringOverview.razor +++ b/src/EntKube.Web/Components/Pages/Tenants/TenantMonitoringOverview.razor @@ -3,6 +3,7 @@ @using Microsoft.EntityFrameworkCore @inject PrometheusService PrometheusService @inject IncidentService IncidentService +@inject OnCallService OnCallService @inject IDbContextFactory DbFactory @implements IAsyncDisposable @@ -18,37 +19,54 @@
+@* ── Tenant-level summary strip ── *@ @if (clusterSummaries.Count > 0) { -
-
-
-
@clusterSummaries.Count
-
Clusters
+
+
+
@clusterSummaries.Count
+
Clusters
+
+
+
@clusterSummaries.Sum(s => s.Health?.TotalNodes ?? 0)
+
Nodes
+
+
+
@clusterSummaries.Sum(s => s.Health?.RunningPods ?? 0)
+
Running Pods
+
+
+
@tenantActiveAlerts
+
+ Active Alerts + @if (OnNavigateTab.HasDelegate) + { + + }
-
-
-
@clusterSummaries.Sum(s => s.Health?.TotalNodes ?? 0)
-
Nodes
+ @if (criticalIncidents > 0) + { +
+
@criticalIncidents
+
+ Critical + @if (OnNavigateTab.HasDelegate) + { + + } +
-
-
-
-
@clusterSummaries.Sum(s => s.Health?.RunningPods ?? 0)
-
Running Pods
-
-
-
-
-
@tenantActiveAlerts
-
Active Alerts
-
-
+ }
} -
+@* ── Cluster cards ── *@ +
@foreach (ClusterSummaryVm vm in clusterSummaries) {
@@ -97,25 +115,146 @@ @vm.Health.ReadyNodes/@vm.Health.TotalNodes nodes ready  ·  @vm.Health.RunningPods pods running + @if (vm.Health.FailedPods > 0) + { +  · @vm.Health.FailedPods failed + } + @if (vm.Health.PendingPods > 0) + { +  · @vm.Health.PendingPods pending + }
}
-
}
+@* ── On-call banner ── *@ +@if (currentOnCall is not null) +{ +
+ + + On call: @currentOnCall.AssigneeName + @if (!string.IsNullOrEmpty(currentOnCall.AssigneeEmail)) + { + @currentOnCall.AssigneeEmail + } + — until @currentOnCall.EndsAt.ToLocalTime().ToString("MMM d HH:mm") + + @if (OnNavigateTab.HasDelegate) + { + + } +
+} + +@* ── Active incidents ── *@ +@if (activeIncidents.Count > 0) +{ +
+
+
Active Incidents
+ @if (OnNavigateTab.HasDelegate) + { + + } +
+
+ @foreach (AlertIncident incident in activeIncidents.Take(5)) + { +
+ @SeverityDot(incident.Severity) +
+
@incident.AlertName
+ @if (!string.IsNullOrEmpty(incident.Summary)) + { +
@incident.Summary
+ } +
+
+
@incident.Cluster?.Name
+
@FormatAge(incident.StartsAt)
+
+ @if (incident.Status == IncidentStatus.Acknowledged) + { + Acked + } +
+ } +
+ @if (activeIncidents.Count > 5) + { +
+ +
+ } +
+} + +@* ── Upcoming maintenance ── *@ +@if (upcomingMaintenance.Count > 0) +{ +
+ +
+ Upcoming maintenance +
+ @foreach (MaintenanceWindow w in upcomingMaintenance) + { + bool active = w.StartsAt <= DateTime.UtcNow && w.EndsAt >= DateTime.UtcNow; +
+ @if (active) + { + Active now + } + @w.Title + — @(w.Cluster?.Name ?? "All clusters") + + @w.StartsAt.ToLocalTime().ToString("MMM d HH:mm") – @w.EndsAt.ToLocalTime().ToString("HH:mm") + +
+ } +
+
+
+} + @code { [Parameter] public required Guid TenantId { get; set; } [Parameter] public required string TenantSlug { get; set; } + [Parameter] public EventCallback OnNavigateTab { get; set; } private List clusterSummaries = []; + private List activeIncidents = []; + private List upcomingMaintenance = []; + private OnCallShift? currentOnCall; private int tenantActiveAlerts; + private int criticalIncidents; private DateTime? lastRefreshed; private bool isLoading; private Timer? refreshTimer; @@ -145,10 +284,28 @@ .ToListAsync(); } - tenantActiveAlerts = await IncidentService.GetActiveAlertCountForTenantAsync(TenantId); + // Load per-cluster active alert counts and tenant-level stats in parallel + Dictionary perClusterCounts = await IncidentService + .GetActiveCountsPerClusterAsync(clusters.Select(c => c.Id)); + + tenantActiveAlerts = perClusterCounts.Values.Sum(); + + Task> incidentTask = + IncidentService.GetActiveIncidentsForTenantAsync(TenantId); + Task> maintenanceTask = + IncidentService.GetUpcomingMaintenanceAsync(TenantId); + Task onCallTask = + OnCallService.GetCurrentOnCallAsync(TenantId); + + await Task.WhenAll(incidentTask, maintenanceTask, onCallTask); + + activeIncidents = incidentTask.Result; + criticalIncidents = activeIncidents.Count(i => i.Severity == "critical"); + upcomingMaintenance = maintenanceTask.Result; + currentOnCall = onCallTask.Result; List> tasks = clusters - .Select(c => LoadClusterSummaryAsync(c)) + .Select(c => LoadClusterSummaryAsync(c, perClusterCounts.GetValueOrDefault(c.Id))) .ToList(); clusterSummaries = [.. await Task.WhenAll(tasks)]; @@ -157,13 +314,13 @@ isLoading = false; } - private async Task LoadClusterSummaryAsync(KubernetesCluster cluster) + private async Task LoadClusterSummaryAsync(KubernetesCluster cluster, int activeAlerts) { bool hasPrometheus = cluster.Components.Any(c => c.HelmChartName == "kube-prometheus-stack" && c.Status == ComponentStatus.Installed); if (!hasPrometheus) - return new ClusterSummaryVm(cluster, null, null, 0); + return new ClusterSummaryVm(cluster, null, null, activeAlerts); KubernetesOperationResult result = await PrometheusService.GetClusterHealthAsync(cluster.Id); @@ -172,7 +329,13 @@ cluster, result.IsSuccess ? result.Data : null, result.IsSuccess ? null : result.Error, - 0); + activeAlerts); + } + + private async Task NavigateTo(string tab) + { + if (OnNavigateTab.HasDelegate) + await OnNavigateTab.InvokeAsync(tab); } private static string UtilColor(double pct) => @@ -186,6 +349,24 @@ return "success"; } + private static string FormatAge(DateTime t) + { + TimeSpan ago = DateTime.UtcNow - t; + return ago switch + { + { TotalMinutes: < 60 } => $"{(int)ago.TotalMinutes}m", + { TotalHours: < 24 } => $"{(int)ago.TotalHours}h", + _ => $"{(int)ago.TotalDays}d" + }; + } + + private static RenderFragment SeverityDot(string severity) => severity switch + { + "critical" => @, + "warning" => @, + _ => @ + }; + public async ValueTask DisposeAsync() { if (refreshTimer is not null) diff --git a/src/EntKube.Web/Components/Pages/Tenants/TenantOpsAnalyticsTab.razor b/src/EntKube.Web/Components/Pages/Tenants/TenantOpsAnalyticsTab.razor new file mode 100644 index 0000000..a51a9da --- /dev/null +++ b/src/EntKube.Web/Components/Pages/Tenants/TenantOpsAnalyticsTab.razor @@ -0,0 +1,301 @@ +@using EntKube.Web.Data +@using EntKube.Web.Services +@inject IncidentService IncidentService + +@if (isLoading) +{ +
+
+

Loading analytics…

+
+} +else if (loadError is not null) +{ +
+ Failed to load analytics. @loadError +
+} +else if (stats is null) +{ + +} +else +{ +
+
+ + +
+ +
+ + @* ── Summary strip ── *@ +
+
+
@stats.Total
+
Total incidents
+
+
+
@stats.Critical
+
Critical
+
+
+
@stats.Warning
+
Warning
+
+
+
@stats.FormatMttr()
+
Avg MTTR
+
+
+
@stats.FormatMtta()
+
Avg MTTA
+
+
+
@stats.Active
+
Still active
+
+
+ +
+ @* ── Daily volume sparkline ── *@ +
+
+
+ Alert volume — last @windowDays days +
+
+ @{ + int maxDaily = stats.Daily.Count > 0 ? Math.Max(1, stats.Daily.Max(d => d.Count)) : 1; + } +
+ @foreach (DailyAlertCount day in stats.Daily) + { + int h = (int)Math.Max(4, day.Count * 76.0 / maxDaily); + string color = day.Count == 0 ? "#dee2e6" + : day.Count >= maxDaily * 0.8 ? "#dc3545" + : day.Count >= maxDaily * 0.5 ? "#fd7e14" + : "#0d6efd"; +
+
+ } +
+
+ @if (stats.Daily.Count > 0) + { + @stats.Daily.First().Date.ToString("MMM d") + @stats.Daily.Last().Date.ToString("MMM d") + } +
+
+
+
+ + @* ── Top alerts ── *@ +
+
+
+ Most frequent alerts +
+
+ @if (topAlerts.Count == 0) + { +

No data.

+ } + else + { + int maxCount = topAlerts.Max(a => a.Count); + @foreach (AlertFrequency alert in topAlerts) + { + int pct = maxCount > 0 ? (int)(alert.Count * 100.0 / maxCount) : 0; + string barColor = alert.Severity == "critical" ? "#dc3545" + : alert.Severity == "warning" ? "#fd7e14" + : "#0d6efd"; +
+
+ @alert.Name + @alert.Count +
+
+
+
+
+ } + } +
+
+
+ + @* ── Top namespaces ── *@ +
+
+
+ Alerts by namespace +
+
+ @if (topNamespaces.Count == 0) + { +

No namespace data.

+ } + else + { + int maxCount = topNamespaces.Max(n => n.Count); + @foreach (AlertFrequency ns in topNamespaces) + { + int pct = maxCount > 0 ? (int)(ns.Count * 100.0 / maxCount) : 0; +
+
+ @ns.Name + @ns.Count +
+
+
+
+
+ } + } +
+
+
+ + @* ── Alerts by cluster ── *@ + @if (byCluster.Count > 1) + { +
+
+
+ Alerts by cluster +
+
+ @{ + int maxCluster = byCluster.Max(c => c.Count); + } + @foreach (AlertFrequency cluster in byCluster) + { + int pct = maxCluster > 0 ? (int)(cluster.Count * 100.0 / maxCluster) : 0; +
+
+ @cluster.Name + @cluster.Count +
+
+
+
+
+ } +
+
+
+ } + + @* ── Severity breakdown ── *@ +
+
+
+ Severity breakdown +
+
+ @if (stats.Total > 0) + { + int critPct = (int)(stats.Critical * 100.0 / stats.Total); + int warnPct = (int)(stats.Warning * 100.0 / stats.Total); + int otherPct = 100 - critPct - warnPct; +
+ @if (critPct > 0) + { +
+ @if (critPct > 10) { @(critPct)% } +
+ } + @if (warnPct > 0) + { +
+ @if (warnPct > 10) { @(warnPct)% } +
+ } + @if (otherPct > 0) + { +
+ @if (otherPct > 10) { @(otherPct)% } +
+ } +
+
+ @stats.CriticalCritical + @stats.WarningWarning + @(stats.Total - stats.Critical - stats.Warning)Other +
+ } + else + { +

No incidents in window.

+ } +
+
+
+
+} + +@code { + [Parameter] public required Guid TenantId { get; set; } + + private AlertStats? stats; + private List topAlerts = []; + private List topNamespaces = []; + private List byCluster = []; + private int windowDays = 30; + private bool isLoading = true; + private string? loadError; + + protected override async Task OnInitializedAsync() => await Load(); + + private async Task Load() + { + isLoading = true; + loadError = null; + StateHasChanged(); + + try + { + await Task.WhenAll( + LoadStats(), + LoadTopAlerts(), + LoadTopNamespaces(), + LoadByCluster()); + } + catch (Exception ex) + { + loadError = ex.Message; + } + finally + { + isLoading = false; + } + } + + private async Task LoadStats() + => stats = await IncidentService.GetStatsForTenantAsync(TenantId, windowDays); + + private async Task LoadTopAlerts() + => topAlerts = await IncidentService.GetTopAlertsAsync(TenantId, windowDays); + + private async Task LoadTopNamespaces() + => topNamespaces = await IncidentService.GetTopNamespacesAsync(TenantId, windowDays); + + private async Task LoadByCluster() + => byCluster = await IncidentService.GetAlertsByClusterAsync(TenantId, windowDays); +} diff --git a/src/EntKube.Web/Components/Pages/Tenants/VaultTab.razor b/src/EntKube.Web/Components/Pages/Tenants/VaultTab.razor index 86714aa..8bf8141 100644 --- a/src/EntKube.Web/Components/Pages/Tenants/VaultTab.razor +++ b/src/EntKube.Web/Components/Pages/Tenants/VaultTab.razor @@ -6,6 +6,8 @@ @inject TenantService TenantService @inject StorageService StorageService @inject CnpgService CnpgService +@inject MongoService MongoService +@inject RegisteredPostgresService RegisteredPostgresService
@@ -42,7 +44,13 @@ else Storage Secrets + +
- @* Add secret form *@ + @* Add secret form — hidden for read-only DB scopes *@ + @if (scope is not "mongodb" and not "regpostgres") + {
@@ -202,6 +264,7 @@ else
+ } @if (!string.IsNullOrEmpty(errorMessage)) { @@ -231,9 +294,9 @@ else Name - @if (scope == "cnpg") + @if (scope is "cnpg" or "mongodb" or "regpostgres") { - Scope + Database } K8s Sync K8s Secret @@ -247,13 +310,21 @@ else { @secret.Name - @if (scope == "cnpg") + @if (scope is "cnpg" or "mongodb" or "regpostgres") { @if (secret.CnpgDatabase is not null) { @secret.CnpgDatabase.Name } + else if (secret.MongoDatabase is not null) + { + @secret.MongoDatabase.Name + } + else if (secret.RegisteredPostgresDatabase is not null) + { + @secret.RegisteredPostgresDatabase.Name + } else { cluster @@ -281,6 +352,9 @@ else +