Compare commits

...

5 Commits

Author SHA256 Message Date
Nils Blomgren
5da40698ad server management
Some checks failed
Build and Deploy / Build and push image (push) Failing after 5m50s
Build and Deploy / Deploy to server (push) Has been skipped
2026-06-11 15:09:31 +02:00
Nils Blomgren
343de77810 blazor.web.js issues
Some checks failed
Build and Deploy / Deploy to server (push) Has been skipped
Build and Deploy / Build and push image (push) Failing after 6m0s
2026-06-08 16:48:36 +02:00
Nils Blomgren
ffe41b4413 publish1 2026-06-07 21:09:37 +02:00
Nils Blomgren
76b7e55931 database and gitsync issues fixed 2026-06-07 14:54:16 +02:00
Nils Blomgren
8dc46ef031 ignoring reference project 2026-06-04 08:53:05 +02:00
230 changed files with 154735 additions and 4654 deletions

7
.claude/settings.json Normal file
View File

@@ -0,0 +1,7 @@
{
"permissions": {
"allow": [
"Bash(grep -E \"\\\\.\\(cs|razor\\)$\")"
]
}
}

View File

@@ -11,6 +11,7 @@
**/*.user **/*.user
**/*.dbmdl **/*.dbmdl
**/*.jfm **/*.jfm
**/*.db
**/secrets.dev.yaml **/secrets.dev.yaml
**/values.dev.yaml **/values.dev.yaml
**/Tiltfile **/Tiltfile

26
.env.example Normal file
View File

@@ -0,0 +1,26 @@
# Copy this file to .env and fill in the values before running docker compose.
# --- Domain / TLS ---
# Public domain pointing at this server. Caddy will obtain a Let's Encrypt
# certificate automatically on first start. Port 80 and 443 must be reachable.
DOMAIN=entkube.example.com
# Email used for Let's Encrypt account registration and expiry notices.
ACME_EMAIL=admin@example.com
# --- Registry ---
# Registry to push/pull the entkube image.
# Authenticate before running compose: docker login ${REGISTRY} -u ${REGISTRY_USERNAME} -p ${REGISTRY_PASSWORD}
REGISTRY=entit.azurecr.io
IMAGE_TAG=latest
REGISTRY_USERNAME=<acr-username-or-service-principal-id>
REGISTRY_PASSWORD=<acr-password-or-service-principal-secret>
# --- Postgres ---
# Password used by both the postgres service and the app connection string.
POSTGRES_PASSWORD=changeme
# --- Vault encryption ---
# 32-byte base64 key for secret encryption.
# Generate with: openssl rand -base64 32
VAULT__ROOTKEY=REPLACE_WITH_BASE64_32_BYTE_KEY

69
.github/workflows/deploy.yml vendored Normal file
View File

@@ -0,0 +1,69 @@
name: Build and Deploy
on:
push:
branches: [main]
workflow_dispatch:
env:
REGISTRY: entit.azurecr.io
IMAGE: entit.azurecr.io/entkube
jobs:
build-and-push:
name: Build and push image
runs-on: ubuntu-latest
outputs:
image-tag: ${{ steps.meta.outputs.version }}
steps:
- uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to ACR
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ secrets.REGISTRY_USERNAME }}
password: ${{ secrets.REGISTRY_PASSWORD }}
- name: Extract image metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.IMAGE }}
tags: |
type=sha,prefix=,format=short
type=raw,value=latest,enable={{is_default_branch}}
- name: Build and push
uses: docker/build-push-action@v6
with:
context: .
platforms: linux/amd64
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
deploy:
name: Deploy to server
needs: build-and-push
runs-on: ubuntu-latest
steps:
- name: Pull and restart via SSH
uses: appleboy/ssh-action@v1
with:
host: ${{ secrets.SERVER_HOST }}
username: ${{ secrets.SERVER_USER }}
key: ${{ secrets.SSH_PRIVATE_KEY }}
script: |
cd ${{ secrets.COMPOSE_DIR }}
echo "${{ secrets.REGISTRY_PASSWORD }}" | docker login ${{ secrets.REGISTRY_HOST }} -u "${{ secrets.REGISTRY_USERNAME }}" --password-stdin
docker compose pull
docker compose up -d --remove-orphans
docker image prune -f

6
.gitignore vendored
View File

@@ -422,3 +422,9 @@ FodyWeavers.xsd
*.db *.db
*.db-shm *.db-shm
*.db-wal *.db-wal
# Local secrets — never commit the real .env, only .env.example
.env
# Local reference infrastructure (not part of the project)
CMKS - Infra/

Submodule CMKS - Infra deleted from ac8bf53d54

7
Caddyfile Normal file
View File

@@ -0,0 +1,7 @@
{
email {env.ACME_EMAIL}
}
{env.DOMAIN} {
reverse_proxy entkube:8080
}

View File

@@ -1,6 +1,11 @@
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build FROM mcr.microsoft.com/dotnet/sdk:10.0.101 AS build
WORKDIR /src WORKDIR /src
# wasm-tools provides Microsoft.AspNetCore.App.Internal.Assets, which contains
# blazor.web.js, blazor.server.js, and the other Blazor framework JS files.
# Without this workload the publish output silently omits those files.
RUN dotnet workload install wasm-tools
COPY Directory.Build.props ./ COPY Directory.Build.props ./
COPY src/EntKube.Web/EntKube.Web.csproj src/EntKube.Web/ COPY src/EntKube.Web/EntKube.Web.csproj src/EntKube.Web/
COPY src/EntKube.Web.Client/EntKube.Web.Client.csproj src/EntKube.Web.Client/ COPY src/EntKube.Web.Client/EntKube.Web.Client.csproj src/EntKube.Web.Client/
@@ -8,22 +13,48 @@ RUN dotnet restore src/EntKube.Web/EntKube.Web.csproj
COPY src/ src/ COPY src/ src/
RUN dotnet publish src/EntKube.Web/EntKube.Web.csproj \ RUN dotnet publish src/EntKube.Web/EntKube.Web.csproj \
--no-restore \
-c Release \ -c Release \
-o /app/publish -o /app/publish
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS runtime FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS runtime
WORKDIR /app 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 \ RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates \
curl \
libssl3 \ libssl3 \
libcurl4 \ libcurl4 \
git \
openssh-client \
&& rm -rf /var/lib/apt/lists/* && 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
COPY --from=build /app/publish . COPY --from=build /app/publish .
RUN chown -R appuser:appgroup /app
USER appuser
ENV ASPNETCORE_URLS=http://+:8080 ENV ASPNETCORE_URLS=http://+:8080
ENV ASPNETCORE_ENVIRONMENT=Production
# SQLite database lives here — mount a persistent volume at /app/Data
VOLUME ["/app/Data"]
EXPOSE 8080 EXPOSE 8080
ENTRYPOINT ["dotnet", "EntKube.Web.dll"] ENTRYPOINT ["dotnet", "EntKube.Web.dll"]

View File

@@ -42,6 +42,59 @@ EntKube/
└── EntKube.Client/ # WebAssembly client project └── EntKube.Client/ # WebAssembly client project
``` ```
## Docker
### Build and push
> **Apple Silicon (M-series) Mac:** servers are typically `linux/amd64`. Use `docker buildx` to cross-compile and push in one step — the `--push` flag is required because multi-platform images cannot be loaded into the local Docker daemon.
```bash
# One-time setup
docker buildx create --use --name multiarch
# Build for amd64 and push directly to the registry
docker buildx build --platform linux/amd64 \
-t entit.azurecr.io/entkube:latest \
--push .
# Tag a versioned release
docker buildx build --platform linux/amd64 \
-t entit.azurecr.io/entkube:latest \
-t entit.azurecr.io/entkube:1.0.0 \
--push .
```
Replace `entit.azurecr.io/entkube` with your actual registry and image path.
### Run with Docker Compose
Copy `.env.example` to `.env` and fill in the required values, then authenticate with the registry and start the stack:
```bash
# Authenticate with the container registry (once per machine / token expiry)
docker login entit.azurecr.io -u $REGISTRY_USERNAME -p $REGISTRY_PASSWORD
docker compose up -d
```
The app will be available at `http://localhost:8080`.
> **Data safety** — `docker compose down` does **not** delete the database volume.
> Only `docker compose down -v` removes volumes.
### Required configuration
| Variable | Description |
|---|---|
| `DOMAIN` | Public domain pointing at the server (e.g. `entkube.example.com`). |
| `ACME_EMAIL` | Email for Let's Encrypt account and expiry notices. |
| `Vault__RootKey` | 32-byte base64 key for secret encryption. Generate with `openssl rand -base64 32`. |
| `POSTGRES_PASSWORD` | Password for the Postgres database (set in `.env`). |
### TLS / HTTPS
Caddy is the reverse proxy and handles TLS automatically. On first start it obtains a Let's Encrypt certificate for `DOMAIN` and renews it automatically before it expires. Ports **80** and **443** must be open and your DNS must point at the server before starting the stack.
## License ## License
See [LICENSE](LICENSE). See [LICENSE](LICENSE).

81
docker-compose.yml Normal file
View File

@@ -0,0 +1,81 @@
services:
caddy:
image: caddy:2
ports:
- "80:80"
- "443:443"
- "443:443/udp"
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- caddy-data:/data
- caddy-config:/config
environment:
DOMAIN: "${DOMAIN}"
ACME_EMAIL: "${ACME_EMAIL}"
depends_on:
- entkube
restart: unless-stopped
postgres:
image: postgres:17
environment:
POSTGRES_DB: entkube
POSTGRES_USER: entkube
POSTGRES_PASSWORD: "${POSTGRES_PASSWORD:-changeme}"
volumes:
- postgres-data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U entkube -d entkube"]
interval: 5s
timeout: 5s
retries: 10
start_period: 10s
restart: unless-stopped
entkube:
image: ${REGISTRY:-entit.azurecr.io}/entkube:${IMAGE_TAG:-latest}
volumes:
- entkube-data:/app/Data
depends_on:
postgres:
condition: service_healthy
environment:
# --- Database ---
DatabaseProvider: Postgres
ConnectionStrings__DefaultConnection: "Host=postgres;Port=5432;Database=entkube;Username=entkube;Password=${POSTGRES_PASSWORD:-changeme}"
# --- Vault encryption ---
# Required — set VAULT__ROOTKEY in your .env file (openssl rand -base64 32)
Vault__RootKey: "${VAULT__ROOTKEY}"
# --- Reverse proxy ---
# Tells ASP.NET Core to trust X-Forwarded-For / X-Forwarded-Proto from Caddy.
# Required for Blazor Server SignalR (WebSocket) to work correctly behind a proxy.
ASPNETCORE_FORWARDEDHEADERS_ENABLED: "true"
# --- Auth ---
# Set to false after the first admin account is created.
Auth__AllowRegistration: "true"
# --- Temporary: log all HTTP requests so we can see 404s ---
Logging__LogLevel__Microsoft__AspNetCore__Hosting__Diagnostics: "Information"
# --- Bootstrap ---
# Optional. If set, this user is granted the Admin role on every startup
# (no-op if already admin). Useful for recovering access.
# Seed__AdminEmail: "admin@example.com"
# --- Email / SMTP (optional) ---
# Smtp__Host: "smtp.example.com"
# Smtp__Username: "user"
# Smtp__Password: "password"
# Smtp__FromAddress: "alerts@entkube.io"
restart: unless-stopped
volumes:
# Named volumes are NOT removed by "docker compose down".
# Data is only deleted if you explicitly run "docker compose down -v".
postgres-data:
entkube-data:
caddy-data:
caddy-config:

View File

@@ -7,11 +7,14 @@
<NoDefaultLaunchSettingsFile>true</NoDefaultLaunchSettingsFile> <NoDefaultLaunchSettingsFile>true</NoDefaultLaunchSettingsFile>
<StaticWebAssetProjectMode>Default</StaticWebAssetProjectMode> <StaticWebAssetProjectMode>Default</StaticWebAssetProjectMode>
<BlazorDisableThrowNavigationException>true</BlazorDisableThrowNavigationException> <BlazorDisableThrowNavigationException>true</BlazorDisableThrowNavigationException>
<!-- Skip Emscripten native relink — no WASM rendering is active on the server,
so dotnet.wasm is never loaded. Use the pre-built runtime from the workload. -->
<WasmBuildNative>false</WasmBuildNative>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly" Version="10.0.1" /> <PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly" Version="10.0.1" />
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.Authentication" Version="10.0.1" /> <PackageReference Include="Microsoft.AspNetCore.Components.Authorization" Version="10.0.1" />
</ItemGroup> </ItemGroup>
</Project> </Project>

View File

@@ -3,7 +3,7 @@
@using Microsoft.AspNetCore.Authorization @using Microsoft.AspNetCore.Authorization
@attribute [Authorize] @attribute [Authorize]
@rendermode InteractiveAuto @rendermode InteractiveServer
<PageTitle>Auth</PageTitle> <PageTitle>Auth</PageTitle>

View File

@@ -1,5 +1,5 @@
@page "/counter" @page "/counter"
@rendermode InteractiveAuto @rendermode InteractiveServer
<PageTitle>Counter</PageTitle> <PageTitle>Counter</PageTitle>

View File

@@ -1,17 +1,8 @@
using Microsoft.AspNetCore.Components.WebAssembly.Hosting; using Microsoft.AspNetCore.Components.WebAssembly.Hosting;
namespace EntKube.Web.Client; var builder = WebAssemblyHostBuilder.CreateDefault(args);
class Program builder.Services.AddAuthorizationCore();
{ builder.Services.AddCascadingAuthenticationState();
static async Task Main(string[] args)
{
var builder = WebAssemblyHostBuilder.CreateDefault(args);
builder.Services.AddAuthorizationCore(); await builder.Build().RunAsync();
builder.Services.AddCascadingAuthenticationState();
builder.Services.AddAuthenticationStateDeserialization();
await builder.Build().RunAsync();
}
}

View File

@@ -14,12 +14,20 @@
@inject ILogger<Register> Logger @inject ILogger<Register> Logger
@inject NavigationManager NavigationManager @inject NavigationManager NavigationManager
@inject IdentityRedirectManager RedirectManager @inject IdentityRedirectManager RedirectManager
@inject IConfiguration Configuration
<PageTitle>Register</PageTitle> <PageTitle>Register</PageTitle>
<h1>Register</h1> @if (!allowRegistration)
{
<h1>Registration Closed</h1>
<p class="text-muted">Self-registration is currently disabled. Please contact an administrator to get access.</p>
}
else
{
<h1>Register</h1>
<div class="row"> <div class="row">
<div class="col-lg-6"> <div class="col-lg-6">
<StatusMessage Message="@Message" /> <StatusMessage Message="@Message" />
<EditForm Model="Input" method="post" OnValidSubmit="RegisterUser" FormName="register"> <EditForm Model="Input" method="post" OnValidSubmit="RegisterUser" FormName="register">
@@ -52,10 +60,12 @@
<ExternalLoginPicker /> <ExternalLoginPicker />
</section> </section>
</div> </div>
</div> </div>
}
@code { @code {
private IEnumerable<IdentityError>? identityErrors; private IEnumerable<IdentityError>? identityErrors;
private bool allowRegistration = true;
[SupplyParameterFromForm] [SupplyParameterFromForm]
private InputModel Input { get; set; } = default!; private InputModel Input { get; set; } = default!;
@@ -68,10 +78,17 @@
protected override void OnInitialized() protected override void OnInitialized()
{ {
Input ??= new(); Input ??= new();
allowRegistration = Configuration.GetValue<bool>("Auth:AllowRegistration", true);
} }
public async Task RegisterUser(EditContext editContext) public async Task RegisterUser(EditContext editContext)
{ {
if (!allowRegistration)
{
RedirectManager.RedirectTo("Account/Register");
return;
}
var user = CreateUser(); var user = CreateUser();
await UserStore.SetUserNameAsync(user, Input.Email, CancellationToken.None); await UserStore.SetUserNameAsync(user, Input.Email, CancellationToken.None);

View File

@@ -18,8 +18,10 @@
<body> <body>
<Routes /> <Routes />
<ReconnectModal /> <ReconnectModal />
<script src="@Assets["lib/bootstrap/dist/js/bootstrap.bundle.min.js"]"></script>
<script src="@Assets["_framework/blazor.web.js"]"></script> <script src="@Assets["_framework/blazor.web.js"]"></script>
<script src="@Assets["Components/Account/Shared/PasskeySubmit.razor.js"]" type="module"></script> <script src="@Assets["Components/Account/Shared/PasskeySubmit.razor.js"]" type="module"></script>
<script>window.scrollToBottom = (el) => { if (el) el.scrollTop = el.scrollHeight; };</script>
</body> </body>
</html> </html>

View File

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

View File

@@ -3,6 +3,7 @@
@inject NavigationManager NavigationManager @inject NavigationManager NavigationManager
@inject AuthenticationStateProvider AuthStateProvider @inject AuthenticationStateProvider AuthStateProvider
@inject EntKube.Web.Services.UserAccessService UserAccessService @inject EntKube.Web.Services.UserAccessService UserAccessService
@inject IConfiguration Configuration
<div class="top-row ps-3 navbar navbar-dark"> <div class="top-row ps-3 navbar navbar-dark">
<div class="container-fluid"> <div class="container-fluid">
@@ -43,11 +44,26 @@
<AuthorizeView Roles="Admin"> <AuthorizeView Roles="Admin">
<Authorized> <Authorized>
<div class="nav-item px-3">
<NavLink class="nav-link" href="admin/users">
<span class="bi bi-people-nav-menu" aria-hidden="true"></span> Users
</NavLink>
</div>
<div class="nav-item px-3">
<NavLink class="nav-link" href="admin/roles">
<span class="bi bi-shield-lock-nav-menu" aria-hidden="true"></span> Roles
</NavLink>
</div>
<div class="nav-item px-3"> <div class="nav-item px-3">
<NavLink class="nav-link" href="admin/backup"> <NavLink class="nav-link" href="admin/backup">
<span class="bi bi-arrow-repeat-nav-menu" aria-hidden="true"></span> Backup <span class="bi bi-arrow-repeat-nav-menu" aria-hidden="true"></span> Backup
</NavLink> </NavLink>
</div> </div>
<div class="nav-item px-3">
<NavLink class="nav-link" href="admin/notifications">
<span class="bi bi-bell-nav-menu" aria-hidden="true"></span> Notifications
</NavLink>
</div>
</Authorized> </Authorized>
</AuthorizeView> </AuthorizeView>
@@ -69,11 +85,14 @@
</div> </div>
</Authorized> </Authorized>
<NotAuthorized> <NotAuthorized>
@if (allowRegistration)
{
<div class="nav-item px-3"> <div class="nav-item px-3">
<NavLink class="nav-link" href="Account/Register"> <NavLink class="nav-link" href="Account/Register">
<span class="bi bi-person-nav-menu" aria-hidden="true"></span> Register <span class="bi bi-person-nav-menu" aria-hidden="true"></span> Register
</NavLink> </NavLink>
</div> </div>
}
<div class="nav-item px-3"> <div class="nav-item px-3">
<NavLink class="nav-link" href="Account/Login"> <NavLink class="nav-link" href="Account/Login">
<span class="bi bi-person-badge-nav-menu" aria-hidden="true"></span> Login <span class="bi bi-person-badge-nav-menu" aria-hidden="true"></span> Login
@@ -87,12 +106,15 @@
@code { @code {
private string? currentUrl; private string? currentUrl;
private bool showTenantsLink; private bool showTenantsLink;
private bool allowRegistration = true;
protected override async Task OnInitializedAsync() protected override async Task OnInitializedAsync()
{ {
currentUrl = NavigationManager.ToBaseRelativePath(NavigationManager.Uri); currentUrl = NavigationManager.ToBaseRelativePath(NavigationManager.Uri);
NavigationManager.LocationChanged += OnLocationChanged; NavigationManager.LocationChanged += OnLocationChanged;
allowRegistration = Configuration.GetValue<bool>("Auth:AllowRegistration", true);
AuthenticationState authState = await AuthStateProvider.GetAuthenticationStateAsync(); AuthenticationState authState = await AuthStateProvider.GetAuthenticationStateAsync();
System.Security.Claims.ClaimsPrincipal user = authState.User; System.Security.Claims.ClaimsPrincipal user = authState.User;

View File

@@ -4,8 +4,7 @@
@rendermode InteractiveServer @rendermode InteractiveServer
@attribute [Authorize(Roles = "Admin")] @attribute [Authorize(Roles = "Admin")]
@inject BackupService BackupService @inject NavigationManager Nav
@inject AuthenticationStateProvider AuthStateProvider
<PageTitle>Backup & Restore</PageTitle> <PageTitle>Backup & Restore</PageTitle>
@@ -41,16 +40,19 @@
<div class="card-body"> <div class="card-body">
<h5 class="card-title mb-1"><i class="bi bi-upload me-2 text-warning"></i>Restore from Backup</h5> <h5 class="card-title mb-1"><i class="bi bi-upload me-2 text-warning"></i>Restore from Backup</h5>
<p class="text-muted small mb-3"> <p class="text-muted small mb-3">
Upload a <code>.json.gz</code> backup file produced by this application. Upload a <code>.json.gz</code> or <code>.json</code> backup file produced by this application.
Secrets are re-encrypted with the current server's root key on restore. Secrets are re-encrypted with the current server's root key on restore.
</p> </p>
<form action="/api/admin/restore" method="post" enctype="multipart/form-data">
<div class="mb-3"> <div class="mb-3">
<InputFile OnChange="OnFileSelected" accept=".gz" class="form-control" /> <input type="file" name="bundle" class="form-control" required />
</div> </div>
<div class="form-check mb-3"> <div class="form-check mb-3">
<input class="form-check-input" type="checkbox" id="wipeCheck" @bind="wipeExisting" /> @* name+value on the checkbox so the browser submits "wipe=true" when it is checked *@
<input class="form-check-input" type="checkbox" name="wipe" value="true" id="wipeCheck"
@onchange="e => wipeExisting = (bool)(e.Value ?? false)" />
<label class="form-check-label" for="wipeCheck"> <label class="form-check-label" for="wipeCheck">
Wipe all existing data before restoring Wipe all existing data before restoring
</label> </label>
@@ -66,19 +68,10 @@
</div> </div>
} }
<button class="btn btn-warning" @onclick="RunRestore" <button type="submit" class="btn btn-warning">
disabled="@(selectedFile is null || restoring)"> <i class="bi bi-upload me-1"></i>Restore
@if (restoring)
{
<span class="spinner-border spinner-border-sm me-1" role="status"></span>
<span>Restoring…</span>
}
else
{
<i class="bi bi-upload me-1"></i>
<span>Restore</span>
}
</button> </button>
</form>
</div> </div>
</div> </div>
</div> </div>
@@ -100,42 +93,15 @@
} }
@code { @code {
private IBrowserFile? selectedFile;
private bool wipeExisting; private bool wipeExisting;
private bool restoring;
private string? successMessage; private string? successMessage;
private string? errorMessage; private string? errorMessage;
private void OnFileSelected(InputFileChangeEventArgs e) protected override void OnInitialized()
{ {
selectedFile = e.File; var uri = new Uri(Nav.Uri);
successMessage = null; var query = Microsoft.AspNetCore.WebUtilities.QueryHelpers.ParseQuery(uri.Query);
errorMessage = null; if (query.TryGetValue("success", out var s)) successMessage = s;
} if (query.TryGetValue("error", out var e)) errorMessage = e;
private async Task RunRestore()
{
if (selectedFile is null) return;
restoring = true;
successMessage = null;
errorMessage = null;
try
{
// 50 MB ceiling — backups are gzip-compressed JSON so this is generous.
await using Stream stream = selectedFile.OpenReadStream(maxAllowedSize: 50 * 1024 * 1024);
await BackupService.ImportAsync(stream, wipeExisting);
successMessage = $"Restore completed successfully from '{selectedFile.Name}'.";
selectedFile = null;
}
catch (Exception ex)
{
errorMessage = $"Restore failed: {ex.Message}";
}
finally
{
restoring = false;
}
} }
} }

View File

@@ -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
<PageTitle>Notification Settings</PageTitle>
<div class="d-flex align-items-center justify-content-between mb-4">
<div>
<h1 class="mb-0"><i class="bi bi-bell-fill me-2 text-primary"></i>Notification Settings</h1>
<p class="text-muted small mb-0 mt-1">Configure global notification providers used by tenant alert channels.</p>
</div>
</div>
@if (loading)
{
<div class="d-flex justify-content-center py-5">
<div class="spinner-border text-primary" role="status"></div>
</div>
}
else
{
<div class="row g-4">
<!-- SMTP -->
<div class="col-12 col-xl-6">
<div class="card border-0 shadow-sm h-100">
<div class="card-header bg-transparent d-flex align-items-center gap-2">
<i class="bi bi-envelope-fill text-warning fs-5"></i>
<h5 class="mb-0">SMTP / Email</h5>
@if (smtpConfig is not null)
{
<span class="badge @(smtpConfig.IsEnabled ? "bg-success" : "bg-secondary") ms-auto">@(smtpConfig.IsEnabled ? "Enabled" : "Disabled")</span>
}
else
{
<span class="badge bg-light text-dark ms-auto">Not configured</span>
}
</div>
<div class="card-body">
<p class="text-muted small">Configure an SMTP server so email notification channels can deliver alerts.</p>
<div class="mb-3">
<label class="form-label fw-semibold">Host</label>
<input class="form-control" @bind="smtpHost" placeholder="smtp.example.com" />
</div>
<div class="row g-2 mb-3">
<div class="col-4">
<label class="form-label fw-semibold">Port</label>
<input class="form-control" type="number" @bind="smtpPort" />
</div>
<div class="col-8">
<label class="form-label fw-semibold">From Address</label>
<input class="form-control" type="email" @bind="smtpFrom" placeholder="alerts@example.com" />
</div>
</div>
<div class="mb-3">
<label class="form-label fw-semibold">Username</label>
<input class="form-control" @bind="smtpUsername" placeholder="(optional)" autocomplete="username" />
</div>
<div class="mb-3">
<label class="form-label fw-semibold">Password</label>
<input class="form-control" type="password" @bind="smtpPassword" placeholder="@(smtpConfig is not null ? "•••••••• (leave blank to keep current)" : "(optional)")" autocomplete="current-password" />
</div>
<div class="mb-3 form-check form-switch">
<input class="form-check-input" type="checkbox" role="switch" id="smtpSsl" @bind="smtpEnableSsl" />
<label class="form-check-label" for="smtpSsl">Enable SSL/TLS</label>
</div>
<div class="mb-3 form-check form-switch">
<input class="form-check-input" type="checkbox" role="switch" id="smtpEnabled" @bind="smtpEnabled" />
<label class="form-check-label" for="smtpEnabled">Enabled</label>
</div>
@if (!string.IsNullOrEmpty(smtpError))
{
<div class="alert alert-danger py-2 small">@smtpError</div>
}
@if (!string.IsNullOrEmpty(smtpSuccess))
{
<div class="alert alert-success py-2 small">@smtpSuccess</div>
}
<div class="d-flex gap-2 mt-3">
<button class="btn btn-primary" @onclick="SaveSmtp" disabled="@smtpBusy">
@if (smtpBusy) { <span class="spinner-border spinner-border-sm me-1"></span> }
Save
</button>
<div class="input-group" style="max-width:260px">
<input class="form-control form-control-sm" @bind="smtpTestRecipient" placeholder="test@example.com" />
<button class="btn btn-outline-secondary btn-sm" @onclick="TestSmtp" disabled="@smtpBusy">
Test
</button>
</div>
@if (smtpConfig is not null)
{
<button class="btn btn-outline-danger ms-auto" @onclick="DeleteSmtp" disabled="@smtpBusy">Remove</button>
}
</div>
</div>
</div>
</div>
<!-- MS Teams via Microsoft Graph -->
<div class="col-12 col-xl-6">
<div class="card border-0 shadow-sm h-100">
<div class="card-header bg-transparent d-flex align-items-center gap-2">
<i class="bi bi-microsoft-teams text-primary fs-5"></i>
<h5 class="mb-0">Microsoft Teams (Graph API)</h5>
@if (teamsConfig is not null)
{
<span class="badge @(teamsConfig.IsEnabled ? "bg-success" : "bg-secondary") ms-auto">@(teamsConfig.IsEnabled ? "Enabled" : "Disabled")</span>
}
else
{
<span class="badge bg-light text-dark ms-auto">Not configured</span>
}
</div>
<div class="card-body">
<p class="text-muted small">
Send alerts directly to Teams channels using the Microsoft Graph API.
Requires an Azure AD app registration with <code>ChannelMessage.Send</code> application permission.
</p>
<div class="alert alert-info py-2 small mb-3">
<i class="bi bi-info-circle me-1"></i>
Once configured, add a Teams notification channel to a tenant and supply a <strong>Team ID</strong> and <strong>Channel ID</strong> instead of a webhook URL.
</div>
<div class="mb-3">
<label class="form-label fw-semibold">Tenant ID (Directory ID)</label>
<input class="form-control font-monospace" @bind="teamsTenantId" placeholder="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" />
</div>
<div class="mb-3">
<label class="form-label fw-semibold">Client ID (Application ID)</label>
<input class="form-control font-monospace" @bind="teamsClientId" placeholder="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" />
</div>
<div class="mb-3">
<label class="form-label fw-semibold">Client Secret</label>
<input class="form-control" type="password" @bind="teamsClientSecret"
placeholder="@(teamsConfig is not null ? "•••••••• (leave blank to keep current)" : "")"
autocomplete="current-password" />
</div>
<div class="mb-3 form-check form-switch">
<input class="form-check-input" type="checkbox" role="switch" id="teamsEnabled" @bind="teamsEnabled" />
<label class="form-check-label" for="teamsEnabled">Enabled</label>
</div>
@if (!string.IsNullOrEmpty(teamsError))
{
<div class="alert alert-danger py-2 small">@teamsError</div>
}
@if (!string.IsNullOrEmpty(teamsSuccess))
{
<div class="alert alert-success py-2 small">@teamsSuccess</div>
}
<div class="d-flex gap-2 mt-3">
<button class="btn btn-primary" @onclick="SaveTeams" disabled="@teamsBusy">
@if (teamsBusy) { <span class="spinner-border spinner-border-sm me-1"></span> }
Save
</button>
<button class="btn btn-outline-secondary" @onclick="TestTeams" disabled="@teamsBusy">
Test Credentials
</button>
@if (teamsConfig is not null)
{
<button class="btn btn-outline-danger ms-auto" @onclick="DeleteTeams" disabled="@teamsBusy">Remove</button>
}
</div>
</div>
</div>
</div>
<!-- Slack info card -->
<div class="col-12 col-xl-6">
<div class="card border-0 shadow-sm bg-light">
<div class="card-body">
<div class="d-flex align-items-center gap-2 mb-2">
<i class="bi bi-slack text-success fs-5"></i>
<h6 class="mb-0">Slack</h6>
<span class="badge bg-success ms-auto">Ready</span>
</div>
<p class="text-muted small mb-0">
Slack notifications use <strong>Incoming Webhooks</strong> — no global configuration needed.
Each tenant notification channel stores its own webhook URL.
Create webhooks at <strong>api.slack.com/apps</strong>.
</p>
</div>
</div>
</div>
<!-- Webhook info card -->
<div class="col-12 col-xl-6">
<div class="card border-0 shadow-sm bg-light">
<div class="card-body">
<div class="d-flex align-items-center gap-2 mb-2">
<i class="bi bi-link-45deg text-secondary fs-5"></i>
<h6 class="mb-0">Generic Webhook</h6>
<span class="badge bg-success ms-auto">Ready</span>
</div>
<p class="text-muted small mb-0">
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.
</p>
</div>
</div>
</div>
</div>
}
@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;
}
}
}

View File

@@ -0,0 +1,365 @@
@page "/admin/roles"
@using Microsoft.AspNetCore.Authorization
@using EntKube.Web.Data
@using EntKube.Web.Services
@rendermode InteractiveServer
@attribute [Authorize(Roles = "Admin")]
@inject TenantRoleService RoleService
@inject TenantService TenantService
<PageTitle>Role Management</PageTitle>
<div class="d-flex align-items-center justify-content-between mb-4">
<div>
<h1 class="mb-0"><i class="bi bi-shield-lock me-2 text-primary"></i>Role Management</h1>
<p class="text-muted small mb-0 mt-1">Define roles and their feature permissions per tenant.</p>
</div>
</div>
@* ── Tenant selector ── *@
<div class="card border-0 shadow-sm mb-4">
<div class="card-body py-2">
<div class="d-flex align-items-center gap-3">
<label class="text-muted small fw-semibold text-nowrap mb-0">Tenant</label>
<select class="form-select" @onchange="OnTenantChanged">
<option value="">Select a tenant…</option>
@foreach (Tenant t in allTenants)
{
<option value="@t.Id" selected="@(t.Id == selectedTenantId)">@t.Name</option>
}
</select>
</div>
</div>
</div>
@if (selectedTenantId != Guid.Empty)
{
@* ── Create role ── *@
<div class="card border-0 shadow-sm mb-4">
<div class="card-body py-2">
<div class="input-group">
<span class="input-group-text bg-transparent border-end-0"><i class="bi bi-plus-circle"></i></span>
<input type="text" class="form-control border-start-0" placeholder="New role name (e.g. DevOps)"
@bind="newRoleName" @bind:event="oninput"
@onkeydown="@(async e => { if (e.Key == "Enter" && !string.IsNullOrWhiteSpace(newRoleName)) await CreateRole(); })" />
<button class="btn btn-primary" @onclick="CreateRole" disabled="@string.IsNullOrWhiteSpace(newRoleName)">
<i class="bi bi-plus-lg me-1"></i>Add Role
</button>
</div>
</div>
</div>
@if (!string.IsNullOrEmpty(errorMessage))
{
<div class="alert alert-danger alert-dismissible py-2 mb-3">
<i class="bi bi-exclamation-triangle me-1"></i>@errorMessage
<button type="button" class="btn-close btn-close-sm" @onclick="() => errorMessage = null"></button>
</div>
}
<LoadingPanel Loading="@(roles is null)" LoadingText="Loading roles…">
@if (roles is not null && roles.Count == 0)
{
<EmptyState Icon="bi-shield-lock"
Title="No roles yet"
Message="Create a role above to define what users in this tenant can access." />
}
else if (roles is not null)
{
<div class="d-flex flex-column gap-3">
@foreach (TenantRole role in roles)
{
bool isExpanded = expandedRoleId == role.Id;
Dictionary<TenantFeature, AccessLevel> perms = TenantRoleService.DecodePermissions(role.PermissionsJson);
<div class="card border-0 shadow-sm">
@* ── Role header ── *@
<div class="card-body py-3 d-flex align-items-center justify-content-between"
style="cursor:pointer" @onclick="() => ToggleExpand(role.Id)">
<div class="d-flex align-items-center gap-2">
<i class="bi bi-chevron-@(isExpanded ? "up" : "down") text-muted"></i>
@if (editingNameId == role.Id)
{
<input type="text" class="form-control form-control-sm" style="width:220px"
value="@editingName"
@oninput="e => editingName = e.Value?.ToString() ?? string.Empty"
@onkeydown="@(async e => { if (e.Key == "Enter") await SaveRename(role.Id); else if (e.Key == "Escape") CancelRename(); })"
@onclick:stopPropagation="true" />
<button class="btn btn-sm btn-success" @onclick:stopPropagation="true" @onclick="() => SaveRename(role.Id)">
<i class="bi bi-check"></i>
</button>
<button class="btn btn-sm btn-outline-secondary" @onclick:stopPropagation="true" @onclick="CancelRename">
<i class="bi bi-x"></i>
</button>
}
else
{
<span class="fw-semibold">@role.Name</span>
<span class="badge bg-secondary-subtle text-secondary border border-secondary-subtle">
@role.Memberships.Count members
</span>
}
</div>
<div class="d-flex gap-2" @onclick:stopPropagation="true">
<button class="btn btn-sm btn-outline-secondary" title="Rename"
@onclick="() => StartRename(role.Id, role.Name)">
<i class="bi bi-pencil"></i>
</button>
@if (confirmDeleteId == role.Id)
{
<span class="text-danger small align-self-center me-1">Delete?</span>
<button class="btn btn-sm btn-danger" @onclick="() => DeleteRole(role.Id)">Yes</button>
<button class="btn btn-sm btn-outline-secondary" @onclick="() => confirmDeleteId = null">No</button>
}
else
{
<button class="btn btn-sm btn-outline-danger" title="Delete role"
@onclick="() => confirmDeleteId = role.Id">
<i class="bi bi-trash"></i>
</button>
}
</div>
</div>
@* ── Permission matrix ── *@
@if (isExpanded)
{
Dictionary<TenantFeature, AccessLevel> draft = GetDraft(role.Id, perms);
bool isDirty = savedMessages.ContainsKey(role.Id) || IsDirty(role.Id, perms);
<div class="card-body border-top pt-3 pb-3">
<div class="table-responsive">
<table class="table table-sm table-borderless mb-3" style="max-width:700px">
<thead>
<tr class="text-muted small">
<th style="width:40%">Feature</th>
<th class="text-center">None</th>
<th class="text-center">View</th>
<th class="text-center">Edit</th>
<th class="text-center">Manage</th>
</tr>
</thead>
<tbody>
@foreach ((TenantFeature feature, string label, string icon) in TenantRoleService.Features)
{
AccessLevel current = TenantRoleService.GetPermission(draft, feature);
<tr>
<td class="align-middle">
<i class="bi @icon me-2 text-muted"></i>@label
</td>
@foreach (AccessLevel level in Enum.GetValues<AccessLevel>())
{
<td class="text-center align-middle">
<input type="radio"
class="form-check-input"
name="@($"perm-{role.Id}-{feature}")"
checked="@(current == level)"
@onchange="() => SetDraft(role.Id, feature, level)" />
</td>
}
</tr>
}
</tbody>
</table>
</div>
<div class="d-flex align-items-center gap-3">
<button class="btn btn-primary btn-sm" @onclick="() => SavePermissions(role.Id)">
<i class="bi bi-floppy me-1"></i>Save Permissions
</button>
<button class="btn btn-outline-secondary btn-sm" @onclick="() => ResetDraft(role.Id, perms)">
Reset
</button>
@if (savedMessages.TryGetValue(role.Id, out string? msg))
{
<span class="text-success small"><i class="bi bi-check-circle me-1"></i>@msg</span>
}
</div>
</div>
}
</div>
}
</div>
}
</LoadingPanel>
}
@code {
private List<Tenant> allTenants = [];
private Guid selectedTenantId;
private List<TenantRole>? roles;
private string newRoleName = "";
private string? errorMessage;
// Expand/collapse
private Guid? expandedRoleId;
// Inline rename
private Guid? editingNameId;
private string editingName = "";
// Delete confirm
private Guid? confirmDeleteId;
// Permission drafts: roleId → working copy of permissions
private Dictionary<Guid, Dictionary<TenantFeature, AccessLevel>> drafts = [];
// Save confirmation messages
private Dictionary<Guid, string> savedMessages = [];
protected override async Task OnInitializedAsync()
{
allTenants = await TenantService.GetAllTenantsAsync();
}
private async Task OnTenantChanged(ChangeEventArgs e)
{
selectedTenantId = Guid.TryParse(e.Value?.ToString(), out Guid id) ? id : Guid.Empty;
roles = null;
drafts.Clear();
savedMessages.Clear();
expandedRoleId = null;
errorMessage = null;
if (selectedTenantId != Guid.Empty)
await LoadRoles();
}
private async Task LoadRoles()
{
roles = await RoleService.GetRolesAsync(selectedTenantId);
// Prune drafts for roles that no longer exist.
HashSet<Guid> ids = roles.Select(r => r.Id).ToHashSet();
foreach (Guid gone in drafts.Keys.Except(ids).ToList())
drafts.Remove(gone);
}
private async Task CreateRole()
{
if (string.IsNullOrWhiteSpace(newRoleName)) return;
errorMessage = null;
try
{
TenantRole created = await RoleService.CreateRoleAsync(selectedTenantId, newRoleName.Trim());
newRoleName = "";
await LoadRoles();
expandedRoleId = created.Id;
}
catch (Exception ex)
{
errorMessage = $"Could not create role: {ex.Message}";
}
}
private async Task DeleteRole(Guid roleId)
{
confirmDeleteId = null;
errorMessage = null;
bool ok = await RoleService.DeleteRoleAsync(roleId);
if (!ok) errorMessage = "Role not found.";
drafts.Remove(roleId);
savedMessages.Remove(roleId);
if (expandedRoleId == roleId) expandedRoleId = null;
await LoadRoles();
}
private void ToggleExpand(Guid roleId)
{
expandedRoleId = expandedRoleId == roleId ? null : roleId;
}
// ── Rename ──
private void StartRename(Guid roleId, string currentName)
{
editingNameId = roleId;
editingName = currentName;
}
private void CancelRename()
{
editingNameId = null;
editingName = "";
}
private async Task SaveRename(Guid roleId)
{
if (string.IsNullOrWhiteSpace(editingName)) return;
errorMessage = null;
try
{
await RoleService.RenameRoleAsync(roleId, editingName.Trim());
CancelRename();
await LoadRoles();
}
catch (Exception ex)
{
errorMessage = $"Could not rename role: {ex.Message}";
}
}
// ── Permission draft management ──
private Dictionary<TenantFeature, AccessLevel> GetDraft(Guid roleId, Dictionary<TenantFeature, AccessLevel> saved)
{
if (!drafts.TryGetValue(roleId, out Dictionary<TenantFeature, AccessLevel>? draft))
{
draft = new Dictionary<TenantFeature, AccessLevel>(saved);
drafts[roleId] = draft;
}
return draft;
}
private void SetDraft(Guid roleId, TenantFeature feature, AccessLevel level)
{
if (!drafts.TryGetValue(roleId, out Dictionary<TenantFeature, AccessLevel>? draft))
{
draft = [];
drafts[roleId] = draft;
}
draft[feature] = level;
savedMessages.Remove(roleId);
}
private void ResetDraft(Guid roleId, Dictionary<TenantFeature, AccessLevel> saved)
{
drafts[roleId] = new Dictionary<TenantFeature, AccessLevel>(saved);
savedMessages.Remove(roleId);
}
private bool IsDirty(Guid roleId, Dictionary<TenantFeature, AccessLevel> saved)
{
if (!drafts.TryGetValue(roleId, out Dictionary<TenantFeature, AccessLevel>? draft)) return false;
foreach (TenantFeature f in Enum.GetValues<TenantFeature>())
{
AccessLevel savedLevel = TenantRoleService.GetPermission(saved, f);
AccessLevel draftLevel = TenantRoleService.GetPermission(draft, f);
if (savedLevel != draftLevel) return true;
}
return false;
}
private async Task SavePermissions(Guid roleId)
{
if (!drafts.TryGetValue(roleId, out Dictionary<TenantFeature, AccessLevel>? draft)) return;
errorMessage = null;
await RoleService.SavePermissionsAsync(roleId, draft);
savedMessages[roleId] = "Saved";
await LoadRoles();
// Clear the saved message after 3 seconds.
_ = Task.Delay(3000).ContinueWith(_ =>
{
savedMessages.Remove(roleId);
InvokeAsync(StateHasChanged);
});
}
}

View File

@@ -0,0 +1,497 @@
@page "/admin/users/{UserId}"
@using Microsoft.AspNetCore.Authorization
@using Microsoft.AspNetCore.Identity
@using EntKube.Web.Data
@using EntKube.Web.Services
@rendermode InteractiveServer
@attribute [Authorize(Roles = "Admin")]
@inject UserManagementService UserManagement
@inject NavigationManager NavigationManager
<PageTitle>@(user?.Email ?? "User")</PageTitle>
@if (user is null && loaded)
{
<div class="alert alert-danger"><i class="bi bi-exclamation-triangle me-2"></i>User not found.</div>
}
else if (user is not null)
{
<div class="d-flex align-items-center mb-1">
<a href="/admin/users" class="btn btn-sm btn-outline-secondary me-3">
<i class="bi bi-arrow-left me-1"></i>Users
</a>
<h1 class="mb-0 fs-3"><i class="bi bi-person-circle me-2 text-primary"></i>@user.Email</h1>
@if (user.EmailConfirmed)
{
<span class="badge bg-success-subtle text-success border border-success-subtle ms-3">Confirmed</span>
}
else
{
<span class="badge bg-warning-subtle text-warning border border-warning-subtle ms-3">Unconfirmed</span>
}
</div>
<p class="text-muted small mb-4">ID: @user.Id</p>
@if (!string.IsNullOrEmpty(errorMessage))
{
<div class="alert alert-danger alert-dismissible py-2" role="alert">
<i class="bi bi-exclamation-triangle me-1"></i>@errorMessage
<button type="button" class="btn-close btn-close-sm" @onclick="() => errorMessage = null"></button>
</div>
}
@if (!string.IsNullOrEmpty(successMessage))
{
<div class="alert alert-success py-2 small" role="alert">
<i class="bi bi-check-circle me-1"></i>@successMessage
</div>
}
<div class="row g-4">
@* ── Global Roles ── *@
<div class="col-12">
<div class="card border-0 shadow-sm">
<div class="card-body">
<h5 class="card-title mb-3"><i class="bi bi-shield-lock me-2 text-primary"></i>Global Roles</h5>
@if (allRoles.Count == 0)
{
<p class="text-muted small mb-0">No global roles defined.</p>
}
else
{
<div class="d-flex flex-wrap gap-3">
@foreach (IdentityRole role in allRoles)
{
bool hasRole = userRoles.Contains(role.Name ?? "");
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" role="switch"
id="role-@role.Id"
checked="@hasRole"
@onchange="e => ToggleRole(role.Name!, (bool)(e.Value ?? false))" />
<label class="form-check-label fw-semibold" for="role-@role.Id">
@role.Name
</label>
</div>
}
</div>
}
</div>
</div>
</div>
@* ── Tenant Memberships ── *@
<div class="col-12">
<div class="card border-0 shadow-sm">
<div class="card-body">
<h5 class="card-title mb-3"><i class="bi bi-building me-2 text-primary"></i>Tenant Memberships</h5>
@if (tenantMemberships.Count > 0)
{
<div class="table-responsive mb-3">
<table class="table table-sm mb-0">
<thead class="table-light">
<tr>
<th>Tenant</th>
<th>Role</th>
<th>Joined</th>
<th></th>
</tr>
</thead>
<tbody>
@foreach (TenantMembership m in tenantMemberships)
{
<tr>
<td class="align-middle">@m.Tenant.Name</td>
<td class="align-middle">
<span class="badge bg-primary-subtle text-primary border border-primary-subtle">@m.Role.Name</span>
</td>
<td class="align-middle text-muted small">@m.JoinedAt.ToString("MMM d, yyyy")</td>
<td class="align-middle text-end">
<button class="btn btn-sm btn-outline-danger"
@onclick="() => confirmRemoveTenantId = m.TenantId">
<i class="bi bi-trash"></i>
</button>
</td>
</tr>
@if (confirmRemoveTenantId == m.TenantId)
{
<tr class="table-danger table-sm">
<td colspan="4" class="px-3 py-2">
<ConfirmDialog Visible="true"
Message="@($"Remove {user!.Email} from tenant '{m.Tenant.Name}'?")"
ConfirmText="Remove"
IsBusy="removingMembership"
OnConfirm="() => RemoveTenantMembership(m.TenantId)"
OnCancel="() => confirmRemoveTenantId = null" />
</td>
</tr>
}
}
</tbody>
</table>
</div>
}
@* Add tenant form *@
@if (availableTenants.Count > 0)
{
<div class="d-flex gap-2 align-items-end flex-wrap">
<div>
<label class="form-label small text-muted mb-1">Tenant</label>
<select class="form-select form-select-sm" @bind="addTenantId" style="min-width:180px">
<option value="">Select tenant…</option>
@foreach (Tenant t in availableTenants)
{
<option value="@t.Id">@t.Name</option>
}
</select>
</div>
<div>
<label class="form-label small text-muted mb-1">Role</label>
@if (addTenantId != Guid.Empty && !rolesForSelectedTenant.Any())
{
<div class="text-muted small">
<i class="bi bi-exclamation-circle me-1"></i>No roles defined for this tenant.
<a href="/admin/roles">Create roles first</a>.
</div>
}
else
{
<select class="form-select form-select-sm" @bind="addTenantRoleId" style="min-width:150px">
<option value="">Select role…</option>
@foreach (TenantRole r in rolesForSelectedTenant)
{
<option value="@r.Id">@r.Name</option>
}
</select>
}
</div>
<button class="btn btn-sm btn-primary" @onclick="AddTenantMembership"
disabled="@(addTenantId == Guid.Empty || addTenantRoleId == Guid.Empty)">
<i class="bi bi-plus-lg me-1"></i>Add
</button>
</div>
}
else if (tenantMemberships.Count == 0)
{
<p class="text-muted small mb-0">No tenants available to assign.</p>
}
</div>
</div>
</div>
@* ── Group Memberships ── *@
<div class="col-12">
<div class="card border-0 shadow-sm">
<div class="card-body">
<h5 class="card-title mb-3"><i class="bi bi-diagram-3 me-2 text-primary"></i>Group Memberships</h5>
@if (groupMemberships.Count > 0)
{
<div class="table-responsive mb-3">
<table class="table table-sm mb-0">
<thead class="table-light">
<tr>
<th>Group</th>
<th>Tenant</th>
<th>Joined</th>
<th></th>
</tr>
</thead>
<tbody>
@foreach (GroupMembership gm in groupMemberships)
{
<tr>
<td class="align-middle">@gm.Group.Name</td>
<td class="align-middle text-muted small">@gm.Group.Tenant.Name</td>
<td class="align-middle text-muted small">@gm.JoinedAt.ToString("MMM d, yyyy")</td>
<td class="align-middle text-end">
<button class="btn btn-sm btn-outline-danger"
@onclick="() => confirmRemoveGroupId = gm.GroupId">
<i class="bi bi-trash"></i>
</button>
</td>
</tr>
@if (confirmRemoveGroupId == gm.GroupId)
{
<tr class="table-danger table-sm">
<td colspan="4" class="px-3 py-2">
<ConfirmDialog Visible="true"
Message="@($"Remove {user!.Email} from group '{gm.Group.Name}'?")"
ConfirmText="Remove"
IsBusy="removingMembership"
OnConfirm="() => RemoveGroupMembership(gm.GroupId)"
OnCancel="() => confirmRemoveGroupId = null" />
</td>
</tr>
}
}
</tbody>
</table>
</div>
}
@* Add group form — only tenants this user is a member of have groups *@
@if (tenantMemberships.Count > 0)
{
<div class="d-flex gap-2 align-items-end flex-wrap">
<div>
<label class="form-label small text-muted mb-1">Tenant</label>
<select class="form-select form-select-sm" @bind="addGroupTenantId" style="min-width:180px">
<option value="">Select tenant…</option>
@foreach (TenantMembership m in tenantMemberships)
{
<option value="@m.TenantId">@m.Tenant.Name</option>
}
</select>
</div>
<div>
<label class="form-label small text-muted mb-1">Group</label>
<select class="form-select form-select-sm" @bind="addGroupId" style="min-width:180px">
<option value="">Select group…</option>
@foreach (Group g in availableGroupsForTenant)
{
<option value="@g.Id">@g.Name</option>
}
</select>
</div>
<button class="btn btn-sm btn-primary" @onclick="AddGroupMembership"
disabled="@(addGroupId == Guid.Empty)">
<i class="bi bi-plus-lg me-1"></i>Add
</button>
</div>
}
else if (groupMemberships.Count == 0)
{
<p class="text-muted small mb-0">Add the user to a tenant first, then assign groups.</p>
}
</div>
</div>
</div>
@* ── Danger Zone ── *@
<div class="col-12">
<div class="card border-danger border-opacity-50 shadow-sm">
<div class="card-body">
<h5 class="card-title text-danger mb-1"><i class="bi bi-exclamation-triangle me-2"></i>Danger Zone</h5>
<p class="text-muted small mb-3">Permanently delete this user account. All tenant and group memberships are removed. This cannot be undone.</p>
<button class="btn btn-outline-danger" @onclick="() => confirmDelete = true"
hidden="@confirmDelete">
<i class="bi bi-trash me-1"></i>Delete User
</button>
<ConfirmDialog Visible="confirmDelete"
Message="@($"Permanently delete {user.Email}? All memberships are removed and this cannot be undone.")"
ConfirmText="Delete Permanently"
IsBusy="deletingUser"
OnConfirm="DeleteUser"
OnCancel="() => confirmDelete = false" />
</div>
</div>
</div>
</div>
}
@code {
[Parameter] public string UserId { get; set; } = null!;
private ApplicationUser? user;
private bool loaded;
private string? errorMessage;
private List<IdentityRole> allRoles = [];
private IList<string> userRoles = [];
private List<TenantMembership> tenantMemberships = [];
private List<Tenant> allTenants = [];
private List<Tenant> availableTenants = [];
// Add-tenant form state
private Guid _addTenantId;
private Guid addTenantId
{
get => _addTenantId;
set
{
_addTenantId = value;
addTenantRoleId = Guid.Empty;
}
}
private Guid addTenantRoleId;
private IEnumerable<TenantRole> rolesForSelectedTenant =>
allTenants.FirstOrDefault(t => t.Id == addTenantId)?.Roles ?? [];
private List<GroupMembership> groupMemberships = [];
// Add-group form state
private Guid _addGroupTenantId;
private Guid addGroupTenantId
{
get => _addGroupTenantId;
set
{
_addGroupTenantId = value;
addGroupId = Guid.Empty;
_ = LoadGroupsForSelectedTenant();
}
}
private Guid addGroupId;
private List<Group> availableGroupsForTenant = [];
private Guid? confirmRemoveTenantId;
private Guid? confirmRemoveGroupId;
private bool confirmDelete;
private bool removingMembership;
private bool deletingUser;
private string? successMessage;
protected override async Task OnInitializedAsync()
{
await Load();
}
private async Task Load()
{
user = await UserManagement.GetUserByIdAsync(UserId);
loaded = true;
if (user is null) return;
// UserManager and RoleManager share the scoped DbContext — run sequentially.
// The IDbContextFactory-based calls each create their own instance and are safe.
allRoles = await UserManagement.GetAllRolesAsync();
userRoles = await UserManagement.GetUserRolesAsync(UserId);
tenantMemberships = await UserManagement.GetUserTenantMembershipsAsync(UserId);
groupMemberships = await UserManagement.GetUserGroupMembershipsAsync(UserId);
allTenants = await UserManagement.GetAllTenantsAsync();
RefreshAvailableTenants();
}
private void RefreshAvailableTenants()
{
HashSet<Guid> joined = tenantMemberships.Select(m => m.TenantId).ToHashSet();
availableTenants = allTenants.Where(t => !joined.Contains(t.Id)).ToList();
if (!availableTenants.Any(t => t.Id == addTenantId))
{
addTenantId = Guid.Empty;
addTenantRoleId = Guid.Empty;
}
}
private async Task LoadGroupsForSelectedTenant()
{
if (_addGroupTenantId == Guid.Empty)
{
availableGroupsForTenant = [];
return;
}
List<Group> all = await UserManagement.GetGroupsForTenantAsync(_addGroupTenantId);
HashSet<Guid> joined = groupMemberships.Select(gm => gm.GroupId).ToHashSet();
availableGroupsForTenant = all.Where(g => !joined.Contains(g.Id)).ToList();
StateHasChanged();
}
private async Task ToggleRole(string role, bool grant)
{
errorMessage = null;
IdentityResult result = grant
? await UserManagement.AddToRoleAsync(UserId, role)
: await UserManagement.RemoveFromRoleAsync(UserId, role);
if (!result.Succeeded)
{
errorMessage = string.Join(" ", result.Errors.Select(e => e.Description));
return;
}
userRoles = await UserManagement.GetUserRolesAsync(UserId);
}
private async Task AddTenantMembership()
{
if (addTenantId == Guid.Empty || addTenantRoleId == Guid.Empty) return;
errorMessage = null;
try
{
await UserManagement.AddTenantMembershipAsync(UserId, addTenantId, addTenantRoleId);
addTenantId = Guid.Empty;
tenantMemberships = await UserManagement.GetUserTenantMembershipsAsync(UserId);
RefreshAvailableTenants();
}
catch (Exception ex)
{
errorMessage = $"Failed to add tenant membership: {ex.Message}";
}
}
private async Task ShowSuccessAsync(string message)
{
successMessage = message;
StateHasChanged();
await Task.Delay(3500);
successMessage = null;
StateHasChanged();
}
private async Task RemoveTenantMembership(Guid tenantId)
{
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()
{
if (addGroupId == Guid.Empty) return;
errorMessage = null;
try
{
await UserManagement.AddGroupMembershipAsync(UserId, addGroupId);
groupMemberships = await UserManagement.GetUserGroupMembershipsAsync(UserId);
await LoadGroupsForSelectedTenant();
}
catch (Exception ex)
{
errorMessage = $"Failed to add group membership: {ex.Message}";
}
}
private async Task RemoveGroupMembership(Guid groupId)
{
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));
confirmDelete = false;
return;
}
NavigationManager.NavigateTo("/admin/users");
}
}

View File

@@ -0,0 +1,153 @@
@page "/admin/users"
@using Microsoft.AspNetCore.Authorization
@using Microsoft.AspNetCore.Identity
@using EntKube.Web.Data
@using EntKube.Web.Services
@rendermode InteractiveServer
@attribute [Authorize(Roles = "Admin")]
@inject UserManagementService UserManagement
@inject NavigationManager NavigationManager
<PageTitle>User Management</PageTitle>
<div class="d-flex align-items-center justify-content-between mb-4">
<div>
<h1 class="mb-0"><i class="bi bi-people me-2 text-primary"></i>Users</h1>
<p class="text-muted small mb-0 mt-1">Manage system users, roles, and tenant access.</p>
</div>
<button class="btn btn-primary" @onclick="() => showAddForm = !showAddForm">
<i class="bi bi-person-plus me-1"></i>Add User
</button>
</div>
@if (showAddForm)
{
<div class="card border-0 shadow-sm mb-4">
<div class="card-body">
<h5 class="card-title mb-3"><i class="bi bi-person-plus me-2 text-primary"></i>New User</h5>
@if (!string.IsNullOrEmpty(addError))
{
<div class="alert alert-danger py-2 small">
<i class="bi bi-exclamation-triangle me-1"></i>@addError
</div>
}
<div class="row g-3">
<div class="col-md-5">
<input type="email" class="form-control" placeholder="Email address"
@bind="newEmail" @bind:event="oninput" />
</div>
<div class="col-md-5">
<input type="password" class="form-control" placeholder="Password"
@bind="newPassword" @bind:event="oninput" />
</div>
<div class="col-md-2">
<button class="btn btn-primary w-100" @onclick="CreateUser"
disabled="@(string.IsNullOrWhiteSpace(newEmail) || string.IsNullOrWhiteSpace(newPassword) || creating)">
@if (creating)
{
<span class="spinner-border spinner-border-sm" role="status"></span>
}
else
{
<span>Create</span>
}
</button>
</div>
</div>
<small class="text-muted mt-2 d-block">The account will be created with email pre-confirmed — no email verification needed.</small>
</div>
</div>
}
<LoadingPanel Loading="@(users is null)" LoadingText="Loading users…">
@if (users is not null && users.Count == 0)
{
<EmptyState Icon="bi-people"
Title="No users yet"
Message="Add the first user above." />
}
else if (users is not null)
{
<div class="card border-0 shadow-sm">
<div class="table-responsive">
<table class="table table-hover mb-0">
<thead class="table-light">
<tr>
<th>Email</th>
<th>Status</th>
<th></th>
</tr>
</thead>
<tbody>
@foreach (ApplicationUser user in users)
{
<tr style="cursor:pointer" @onclick='() => NavigationManager.NavigateTo($"/admin/users/{user.Id}")'>
<td class="align-middle">
<i class="bi bi-person-circle me-2 text-muted"></i>@user.Email
</td>
<td class="align-middle">
@if (user.EmailConfirmed)
{
<span class="badge bg-success-subtle text-success border border-success-subtle">Confirmed</span>
}
else
{
<span class="badge bg-warning-subtle text-warning border border-warning-subtle">Unconfirmed</span>
}
</td>
<td class="align-middle text-end">
<i class="bi bi-chevron-right text-muted"></i>
</td>
</tr>
}
</tbody>
</table>
</div>
</div>
}
</LoadingPanel>
@code {
private List<ApplicationUser>? users;
private bool showAddForm;
private bool creating;
private string newEmail = "";
private string newPassword = "";
private string? addError;
protected override async Task OnInitializedAsync()
{
await Load();
}
private async Task Load()
{
users = await UserManagement.GetAllUsersAsync();
}
private async Task CreateUser()
{
addError = null;
creating = true;
try
{
IdentityResult result = await UserManagement.CreateUserAsync(newEmail.Trim(), newPassword);
if (!result.Succeeded)
{
addError = string.Join(" ", result.Errors.Select(e => e.Description));
return;
}
newEmail = "";
newPassword = "";
showAddForm = false;
await Load();
}
finally
{
creating = false;
}
}
}

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,7 @@
@using EntKube.Web.Data @using EntKube.Web.Data
@using EntKube.Web.Services @using EntKube.Web.Services
@inject AppGovernanceService GovernanceService @inject AppGovernanceService GovernanceService
@inject CustomerGitService CustomerGitService
@* ═══════════════════════════════════════════════════════════════════ @* ═══════════════════════════════════════════════════════════════════
AppGovernancePanel — read-only portal view of an app's governance AppGovernancePanel — read-only portal view of an app's governance
@@ -23,17 +24,21 @@ else
</div> </div>
@* Namespace *@ @* Namespace *@
<div class="mb-3">
<label class="form-label small fw-medium">Kubernetes Namespace</label>
@if (!string.IsNullOrWhiteSpace(data?.Namespace)) @if (!string.IsNullOrWhiteSpace(data?.Namespace))
{ {
<input class="form-control form-control-sm font-monospace" value="@data.Namespace" disabled /> <div class="card shadow-sm mb-3">
} <div class="card-header bg-white py-2">
else <i class="bi bi-box me-2 text-primary"></i>
{ <strong class="small">Namespace</strong>
<input class="form-control form-control-sm text-muted fst-italic" value="Not assigned" disabled />
}
</div> </div>
<div class="card-body py-2">
<div class="d-flex align-items-center gap-2">
<i class="bi bi-lock-fill text-warning small"></i>
<span class="small">Your app runs in namespace <code>@data.Namespace</code>.</span>
</div>
</div>
</div>
}
@* Quota *@ @* Quota *@
<div class="card shadow-sm mb-3"> <div class="card shadow-sm mb-3">
@@ -166,19 +171,122 @@ else
} }
</div> </div>
</div> </div>
@* Git URL Policies *@
<div class="card shadow-sm mt-3">
<div class="card-header bg-white py-2">
<i class="bi bi-git me-2 text-secondary"></i>
<strong class="small">Git URL Policies</strong>
</div>
<div class="card-body">
@if (gitPolicies.Count == 0)
{
<p class="text-muted small mb-0">No git URL policies — all repositories are allowed.</p>
}
else
{
<ul class="list-group list-group-flush mb-0">
@foreach (CustomerGitRepoPolicy p in gitPolicies)
{
<li class="list-group-item d-flex align-items-center gap-2 px-0 py-1">
<i class="bi bi-check-circle text-success small"></i>
<code class="small">@p.UrlPattern</code>
</li>
}
</ul>
}
</div>
</div>
@* Allowed Databases *@
@if (data?.AllowedDatabases is { Count: > 0 } allowedDbs)
{
<div class="card shadow-sm mt-3">
<div class="card-header bg-white py-2">
<i class="bi bi-database me-2 text-primary"></i>
<strong class="small">Allowed Databases</strong>
</div>
<div class="card-body">
<ul class="list-group list-group-flush mb-0">
@foreach (AppAllowedDatabase db in allowedDbs)
{
<li class="list-group-item d-flex align-items-center gap-2 px-0 py-1">
<i class="bi bi-check-circle text-success small"></i>
<span class="badge bg-primary-subtle text-primary border border-primary-subtle small">@AllowedDbTypeLabel(db)</span>
<span class="small">@AllowedDbName(db)</span>
</li>
}
</ul>
</div>
</div>
}
@* Allowed Cache Services *@
@if (data?.AllowedCaches is { Count: > 0 } allowedCaches)
{
<div class="card shadow-sm mt-3">
<div class="card-header bg-white py-2">
<i class="bi bi-lightning me-2 text-warning"></i>
<strong class="small">Allowed Cache Services</strong>
</div>
<div class="card-body">
<ul class="list-group list-group-flush mb-0">
@foreach (AppAllowedCache cache in allowedCaches)
{
<li class="list-group-item d-flex align-items-center gap-2 px-0 py-1">
<i class="bi bi-check-circle text-success small"></i>
<span class="small fw-medium">@cache.RedisCluster.Name</span>
</li>
}
</ul>
</div>
</div>
}
@* Allowed S3 Storage *@
@if (data?.AllowedStorages is { Count: > 0 } allowedStorages)
{
<div class="card shadow-sm mt-3">
<div class="card-header bg-white py-2">
<i class="bi bi-bucket me-2 text-info"></i>
<strong class="small">Allowed S3 Storage</strong>
</div>
<div class="card-body">
<ul class="list-group list-group-flush mb-0">
@foreach (AppAllowedStorage storage in allowedStorages)
{
<li class="list-group-item d-flex align-items-center gap-2 px-0 py-1">
<i class="bi bi-check-circle text-success small"></i>
<span class="badge bg-info-subtle text-info border border-info-subtle small">@storage.StorageLink.Provider</span>
<span class="small fw-medium">@storage.StorageLink.Name</span>
@if (!string.IsNullOrWhiteSpace(storage.StorageLink.BucketName))
{
<code class="small text-muted">@storage.StorageLink.BucketName</code>
}
</li>
}
</ul>
</div>
</div>
}
} }
@code { @code {
[Parameter, EditorRequired] public Guid AppId { get; set; } [Parameter, EditorRequired] public Guid AppId { get; set; }
[Parameter, EditorRequired] public string AppName { 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 AppGovernanceData? data;
private List<CustomerGitRepoPolicy> gitPolicies = [];
private bool loading = true; private bool loading = true;
protected override async Task OnParametersSetAsync() protected override async Task OnParametersSetAsync()
{ {
loading = true; 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; loading = false;
} }
@@ -198,4 +306,20 @@ else
AppNetworkPolicyType.AllowFromIngress or AppNetworkPolicyType.AllowFromNamespace or AppNetworkPolicyType.AllowFromSameNamespace => "bi-check-circle", AppNetworkPolicyType.AllowFromIngress or AppNetworkPolicyType.AllowFromNamespace or AppNetworkPolicyType.AllowFromSameNamespace => "bi-check-circle",
_ => "bi-code-slash" _ => "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 "—";
}
} }

View File

@@ -15,6 +15,8 @@
@inject PrometheusService PrometheusService @inject PrometheusService PrometheusService
@inject AuditService AuditService @inject AuditService AuditService
@inject IncidentService IncidentService @inject IncidentService IncidentService
@inject CustomerGitService CustomerGitService
@inject AppRouteService AppRouteService
@inject AuthenticationStateProvider AuthStateProvider @inject AuthenticationStateProvider AuthStateProvider
@inject NavigationManager Navigation @inject NavigationManager Navigation
@@ -37,80 +39,14 @@
} }
else if (customers is null || customers.Count == 0) else if (customers is null || customers.Count == 0)
{ {
<div class="text-center py-5"> <EmptyState Icon="bi-shield-lock"
<i class="bi bi-shield-lock text-muted" style="font-size: 3rem;"></i> Title="No access granted"
<h4 class="mt-3">No access granted</h4> Message="You don't have access to any customers yet. Ask a tenant administrator to grant you access." />
<p class="text-muted">You don't have access to any customers yet. Ask a tenant administrator to grant you access.</p>
</div>
} }
else else
{ {
@* ── Level 0: Breadcrumb navigation ── *@ @* ── Level 0: Breadcrumb navigation ── *@
<nav aria-label="breadcrumb" class="mb-3"> <Breadcrumb Items="PortalBreadcrumbs" />
<ol class="breadcrumb small">
<li class="breadcrumb-item">
@if (selectedDeployment is not null || selectedIdentityRealm is not null || selectedSecretsApp is not null || selectedRegistryProject is not null || selectedGovernanceApp is not null)
{
<a href="javascript:void(0)" class="text-decoration-none" @onclick="BackToApps">
<i class="bi bi-grid me-1"></i>Portal
</a>
}
else if (selectedCustomer is not null)
{
<a href="javascript:void(0)" class="text-decoration-none" @onclick="BackToCustomers">
<i class="bi bi-grid me-1"></i>Portal
</a>
}
else
{
<span class="text-muted"><i class="bi bi-grid me-1"></i>Portal</span>
}
</li>
@if (selectedCustomer is not null)
{
<li class="breadcrumb-item">
@if (selectedDeployment is not null || selectedIdentityRealm is not null || selectedSecretsApp is not null || selectedRegistryProject is not null || selectedGovernanceApp is not null)
{
<a href="javascript:void(0)" class="text-decoration-none" @onclick="BackToApps">@selectedCustomer.Name</a>
}
else
{
<span class="text-muted">@selectedCustomer.Name</span>
}
</li>
}
@if (selectedDeployment is not null)
{
<li class="breadcrumb-item active">@selectedDeployment.Name</li>
}
else if (selectedIdentityRealm is not null)
{
<li class="breadcrumb-item active">
<i class="bi bi-shield-lock me-1"></i>@selectedIdentityRealm.DisplayName
</li>
}
else if (selectedSecretsApp is not null)
{
<li class="breadcrumb-item active">
<i class="bi bi-shield-lock me-1"></i>@selectedSecretsApp.Name — Secrets
</li>
}
else if (selectedRegistryProject is not null)
{
<li class="breadcrumb-item active">
<i class="bi bi-archive me-1"></i>@selectedRegistryProject.ProjectName — Registry
</li>
}
else if (selectedGovernanceApp is not null)
{
<li class="breadcrumb-item active">
<i class="bi bi-shield-check me-1"></i>@selectedGovernanceApp.Name — Policy
</li>
}
</ol>
</nav>
@* ════════════════════════════════════════════════════════════════ @* ════════════════════════════════════════════════════════════════
Level 1: Customer list Level 1: Customer list
@@ -144,10 +80,25 @@ else
</div> </div>
} }
@* ════════════════════════════════════════════════════════════════
Level 2 (loading): Apps data loading for selected customer
════════════════════════════════════════════════════════════════ *@
else if (loadingCustomer)
{
<div class="d-flex align-items-center mb-3">
<i class="bi bi-people fs-3 me-2 text-primary"></i>
<h2 class="mb-0">@selectedCustomer!.Name</h2>
</div>
<div class="text-center py-5">
<div class="spinner-border text-primary" role="status"></div>
<p class="text-muted small mt-2 mb-0">Loading apps...</p>
</div>
}
@* ════════════════════════════════════════════════════════════════ @* ════════════════════════════════════════════════════════════════
Level 2: Apps and deployments for a selected customer Level 2: Apps and deployments for a selected customer
════════════════════════════════════════════════════════════════ *@ ════════════════════════════════════════════════════════════════ *@
else if (selectedDeployment is null && selectedIdentityRealm is null && selectedSecretsApp is null && selectedRegistryProject is null) else if (selectedDeployment is null && selectedIdentityRealm is null && selectedSecrets is null && selectedRegistryProject is null && selectedGovernanceApp is null && selectedResourcesApp is null && selectedRoutesApp is null && selectedPortalEnvironment is null)
{ {
<div class="d-flex align-items-center mb-3"> <div class="d-flex align-items-center mb-3">
<i class="bi bi-people fs-3 me-2 text-primary"></i> <i class="bi bi-people fs-3 me-2 text-primary"></i>
@@ -173,7 +124,7 @@ else
@if (showMonitoringPanel) @if (showMonitoringPanel)
{ {
<div class="card shadow-sm mb-4"> <div class="card shadow-sm mb-4">
<div class="card-header d-flex align-items-center gap-2 py-2"> <div class="card-header bg-white d-flex align-items-center gap-2 py-2">
<span class="bi bi-heart-pulse text-primary"></span> <span class="bi bi-heart-pulse text-primary"></span>
<strong>Monitoring — @selectedCustomer.Name</strong> <strong>Monitoring — @selectedCustomer.Name</strong>
<button class="btn-close btn-sm ms-auto" @onclick="() => showMonitoringPanel = false"></button> <button class="btn-close btn-sm ms-auto" @onclick="() => showMonitoringPanel = false"></button>
@@ -204,10 +155,8 @@ else
@if (selectedCustomer.Apps.Count == 0) @if (selectedCustomer.Apps.Count == 0)
{ {
<div class="text-center py-4"> <EmptyState Icon="bi-app-indicator"
<i class="bi bi-app-indicator text-muted" style="font-size: 2rem;"></i> Message="No apps configured for this customer yet." />
<p class="text-muted mt-2">No apps configured for this customer yet.</p>
</div>
} }
else else
{ {
@@ -215,7 +164,7 @@ else
{ {
bool hasIdentity = appRealms.ContainsKey(app.Id); bool hasIdentity = appRealms.ContainsKey(app.Id);
bool hasRegistry = appHarborProjects.ContainsKey(app.Id); bool hasRegistry = appHarborProjects.ContainsKey(app.Id);
bool showingCreateForm = createDeployAppId == app.Id;
<div class="card shadow-sm mb-3"> <div class="card shadow-sm mb-3">
<div class="card-header bg-white py-2"> <div class="card-header bg-white py-2">
<div class="d-flex align-items-center justify-content-between"> <div class="d-flex align-items-center justify-content-between">
@@ -223,147 +172,206 @@ else
<i class="bi bi-app-indicator me-2 text-primary"></i> <i class="bi bi-app-indicator me-2 text-primary"></i>
<strong>@app.Name</strong> <strong>@app.Name</strong>
</div> </div>
<div class="d-flex gap-2"> <div class="d-flex gap-2 align-items-center">
@if (currentAccessRole >= CustomerAccessRole.Admin) <div class="dropdown">
{ <button class="btn btn-sm btn-outline-secondary dropdown-toggle" type="button"
<button class="btn btn-sm btn-outline-primary" data-bs-toggle="dropdown" aria-expanded="false">
@onclick="() => ToggleCreateDeployment(app.Id)"> <i class="bi bi-three-dots me-1"></i>More
<i class="bi bi-plus me-1"></i>New Deployment
</button> </button>
} <ul class="dropdown-menu dropdown-menu-end">
<li><h6 class="dropdown-header">Secrets</h6></li>
<li>
<button class="dropdown-item" @onclick="() => OpenAppSecrets(app)">
<i class="bi bi-key me-2 text-primary"></i>App Secrets
</button>
</li>
<li>
<button class="dropdown-item" @onclick="() => OpenDatabases(app)">
<i class="bi bi-database me-2 text-primary"></i>Databases
</button>
</li>
<li>
<button class="dropdown-item" @onclick="() => OpenMessaging(app)">
<i class="bi bi-send me-2 text-primary"></i>Messaging
</button>
</li>
<li>
<button class="dropdown-item" @onclick="() => OpenCache(app)">
<i class="bi bi-lightning me-2 text-primary"></i>Cache
</button>
</li>
<li>
<button class="dropdown-item" @onclick="() => OpenDockerRegistries(app)">
<i class="bi bi-box-seam me-2 text-primary"></i>Registries
</button>
</li>
<li><hr class="dropdown-divider" /></li>
@if (hasIdentity) @if (hasIdentity)
{ {
<button class="btn btn-sm btn-outline-primary" <li>
@onclick="() => SelectIdentityRealm(app.Id)"> <button class="dropdown-item" @onclick="() => SelectIdentityRealm(app.Id)">
<i class="bi bi-shield-lock me-1"></i>Identity <i class="bi bi-shield-lock me-2 text-primary"></i>Identity
</button> </button>
</li>
} }
@if (hasRegistry) @if (hasRegistry)
{ {
<button class="btn btn-sm btn-outline-primary" <li>
@onclick="() => SelectRegistryProject(app.Id)"> <button class="dropdown-item" @onclick="() => SelectRegistryProject(app.Id)">
<i class="bi bi-archive me-1"></i>Registry <i class="bi bi-archive me-2 text-primary"></i>Registry
</button> </button>
</li>
} }
<button class="btn btn-sm btn-outline-secondary" <li>
@onclick="() => SelectSecretsApp(app)"> <button class="dropdown-item" @onclick="() => SelectRoutesApp(app)">
<i class="bi bi-key me-1"></i>Secrets <i class="bi bi-globe me-2 text-primary"></i>URL Configuration
</button> </button>
<button class="btn btn-sm btn-outline-secondary" </li>
@onclick="() => SelectGovernanceApp(app)"> @if (currentAccessRole >= CustomerAccessRole.Admin)
<i class="bi bi-shield-check me-1"></i>Policy {
<li><hr class="dropdown-divider" /></li>
<li>
<button class="dropdown-item" @onclick="() => StartEditApp(app)">
<i class="bi bi-pencil me-2"></i>Rename
</button> </button>
</li>
<li>
<button class="dropdown-item text-danger" @onclick="() => StartDeleteApp(app)">
<i class="bi bi-trash me-2"></i>Delete App
</button>
</li>
}
</ul>
</div> </div>
</div> </div>
</div> </div>
</div>
@if (editingAppId == app.Id)
{
<div class="px-3 py-2 border-top">
<div class="d-flex gap-2 align-items-center flex-wrap">
<input class="form-control form-control-sm" style="max-width:280px"
@bind="editAppName" @bind:event="oninput"
@onkeyup="OnEditAppNameKeyUp" />
<button class="btn btn-sm btn-primary" @onclick="SaveAppName" disabled="@savingAppName">
@if (savingAppName) { <span class="spinner-border spinner-border-sm me-1"></span> }
Save
</button>
<button class="btn btn-sm btn-outline-secondary" @onclick="CancelEditApp" disabled="@savingAppName">Cancel</button>
</div>
</div>
}
@if (deletingAppId == app.Id)
{
<div class="px-3 py-2 border-top">
@if (deleteAppError is not null)
{
<div class="alert alert-danger py-1 small mb-2">
<i class="bi bi-exclamation-triangle me-1"></i>@deleteAppError
</div>
}
<ConfirmDialog Visible="true"
Message="@($"Permanently delete {app.Name} and all its deployments and cluster resources?")"
ConfirmText="Yes, delete"
IsBusy="deletingApp"
OnConfirm="() => ConfirmDeleteApp(app.Id)"
OnCancel="CancelDeleteApp" />
</div>
}
<div class="card-body"> <div class="card-body">
@if (showingCreateForm) @* ── Environment cards ── *@
@if (appLinkedEnvironments.TryGetValue(app.Id, out List<Data.Environment>? linkedEnvs) && linkedEnvs.Count > 0)
{ {
<div class="card border-primary mb-3"> <div class="row row-cols-1 row-cols-md-2 row-cols-lg-3 g-2">
<div class="card-body"> @foreach (Data.Environment env in linkedEnvs)
<h6 class="mb-3"><i class="bi bi-plus-circle me-1"></i>Create Deployment</h6>
@if (createDeployError is not null)
{
<div class="alert alert-danger py-1 small mb-2">@createDeployError</div>
}
<div class="row g-2 mb-2">
<div class="col-md-6">
<label class="form-label small">Name</label>
<input class="form-control form-control-sm" @bind="newDeployName" placeholder="e.g. my-app-prod" />
</div>
<div class="col-md-6">
<label class="form-label small">Type</label>
<select class="form-select form-select-sm" @bind="newDeployType">
<option value="@DeploymentType.Manual">Manual</option>
<option value="@DeploymentType.Yaml">YAML</option>
<option value="@DeploymentType.HelmChart">Helm Chart</option>
</select>
</div>
<div class="col-md-4">
<label class="form-label small">Environment</label>
<select class="form-select form-select-sm" @bind="newDeployEnvId">
<option value="@Guid.Empty">Select...</option>
@foreach (Data.Environment env in tenantEnvironments)
{
<option value="@env.Id">@env.Name</option>
}
</select>
</div>
<div class="col-md-4">
<label class="form-label small">Cluster</label>
<select class="form-select form-select-sm" @bind="newDeployClusterId">
<option value="@Guid.Empty">Select...</option>
@foreach (KubernetesCluster cluster in tenantClusters.Where(c => newDeployEnvId == Guid.Empty || c.EnvironmentId == newDeployEnvId))
{
<option value="@cluster.Id">@cluster.Name</option>
}
</select>
</div>
<div class="col-md-4">
<label class="form-label small">Namespace</label>
<input class="form-control form-control-sm" @bind="newDeployNamespace" placeholder="e.g. my-app" />
</div>
@if (newDeployType == DeploymentType.HelmChart)
{
<div class="col-md-4">
<label class="form-label small">Helm Repo URL</label>
<input class="form-control form-control-sm" @bind="newHelmRepoUrl" placeholder="https://charts.example.com" />
</div>
<div class="col-md-4">
<label class="form-label small">Chart Name</label>
<input class="form-control form-control-sm" @bind="newHelmChartName" />
</div>
<div class="col-md-4">
<label class="form-label small">Chart Version</label>
<input class="form-control form-control-sm" @bind="newHelmChartVersion" placeholder="e.g. 1.0.0" />
</div>
}
</div>
<div class="d-flex gap-1">
<button class="btn btn-sm btn-primary" @onclick="() => CreateDeployment(app.Id)"
disabled="@(string.IsNullOrWhiteSpace(newDeployName) || newDeployEnvId == Guid.Empty || newDeployClusterId == Guid.Empty || string.IsNullOrWhiteSpace(newDeployNamespace) || creatingDeployment)">
@if (creatingDeployment) { <span class="spinner-border spinner-border-sm me-1"></span> }
Create
</button>
<button class="btn btn-sm btn-outline-secondary" @onclick="() => createDeployAppId = null">Cancel</button>
</div>
</div>
</div>
}
@if (appDeployments.TryGetValue(app.Id, out List<AppDeployment>? deploys) && deploys.Count > 0)
{
<div class="row row-cols-1 row-cols-lg-2 g-2">
@foreach (AppDeployment deploy in deploys)
{ {
int deployCount = appDeployments.TryGetValue(app.Id, out List<AppDeployment>? deploys2)
? deploys2.Count(d => d.EnvironmentId == env.Id)
: 0;
<div class="col"> <div class="col">
<div class="card border-light" role="button" style="cursor: pointer;" <div class="card border-light h-100 portal-env-card" role="button" style="cursor: pointer;"
@onclick="() => SelectDeployment(deploy)"> @onclick="() => SelectPortalEnvironment(app, env)">
<div class="card-body py-2"> <div class="card-body py-2">
<div class="d-flex align-items-center justify-content-between mb-1"> <div class="d-flex align-items-center justify-content-between mb-1">
<span class="fw-medium"> <span class="fw-medium">
<i class="bi bi-rocket-takeoff me-1 text-primary"></i>@deploy.Name <i class="bi bi-layers me-1 text-primary"></i>@env.Name
</span> </span>
<div class="d-flex gap-1"> <i class="bi bi-chevron-right text-muted small"></i>
@SyncBadge(deploy.SyncStatus)
@HealthBadge(deploy.HealthStatus)
</div> </div>
</div> <div class="small text-muted">
<div class="d-flex flex-wrap gap-2 text-muted small"> @if (deployCount > 0)
@TypeBadge(deploy.Type)
<span><i class="bi bi-layers me-1"></i>@deploy.Environment?.Name</span>
<span><i class="bi bi-hdd-network me-1"></i>@deploy.Cluster?.Name</span>
<span><i class="bi bi-box me-1"></i>@deploy.Namespace</span>
</div>
</div>
</div>
</div>
}
</div>
}
else if (!showingCreateForm)
{ {
<p class="text-muted small mb-0">No deployments configured.</p> <span><i class="bi bi-rocket-takeoff me-1"></i>@deployCount deployment@(deployCount == 1 ? "" : "s")</span>
}
else
{
<span class="fst-italic">No deployments</span>
}
</div>
</div>
</div>
</div>
}
</div>
}
else
{
<EmptyState Icon="bi-layers"
Message="No environments linked to this app yet." />
}
@* ── External URLs ── *@
@if (appRoutes.TryGetValue(app.Id, out List<AppRoute>? routes) && routes.Count > 0)
{
<div class="mt-3 pt-2 border-top">
<div class="small text-muted mb-1">
<i class="bi bi-globe me-1"></i>External URLs
</div>
<div class="d-flex flex-wrap gap-2">
@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";
<div class="d-flex align-items-center gap-1 border rounded px-2 py-1 bg-light"
title="@tlsTitle">
<i class="bi @statusIcon @statusClass small"></i>
<a href="@href" target="_blank" rel="noopener noreferrer"
class="small text-decoration-none fw-medium">
@route.Hostname
</a>
<i class="bi bi-lock-fill text-success small" title="@tlsTitle"></i>
@if (route.DeploymentRoutes.Count(dr => dr.IsEnabled) > 1)
{
<span class="badge bg-secondary bg-opacity-50 small" title="Multiple deployment paths">
@route.DeploymentRoutes.Count(dr => dr.IsEnabled)
</span>
}
</div>
}
</div>
@if (routes.Any(r => r.IsEnabled && r.DeploymentRoutes.Any(dr => dr.IsEnabled && dr.PathPrefix != "/")))
{
<div class="mt-1">
@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))
{
<span class="badge bg-light text-dark border me-1 small" title="@(dr.AppDeployment?.Environment?.Name)">
<i class="bi bi-link-45deg me-1"></i>@dr.PathPrefix
<span class="text-muted ms-1">(@(dr.AppDeployment?.Environment?.Name ?? "unknown"))</span>
</span>
}
}
</div>
}
</div>
} }
</div> </div>
</div> </div>
@@ -371,6 +379,19 @@ else
} }
} }
@* ════════════════════════════════════════════════════════════════
Level 3: Per-environment view (deployments, resources, external access)
════════════════════════════════════════════════════════════════ *@
else if (selectedPortalEnvironment is not null && selectedPortalApp is not null)
{
<AppEnvironmentDetail App="selectedPortalApp"
Environment="selectedPortalEnvironment"
TenantId="selectedCustomer!.TenantId"
IsPortalView="true"
AccessRole="currentAccessRole"
OnBack="() => { selectedPortalApp = null; selectedPortalEnvironment = null; }" />
}
@* ════════════════════════════════════════════════════════════════ @* ════════════════════════════════════════════════════════════════
Level 3: Deployment detail with operations Level 3: Deployment detail with operations
════════════════════════════════════════════════════════════════ *@ ════════════════════════════════════════════════════════════════ *@
@@ -383,6 +404,10 @@ else
DeploymentService="DeploymentService" DeploymentService="DeploymentService"
PrometheusService="PrometheusService" PrometheusService="PrometheusService"
AuditService="AuditService" AuditService="AuditService"
CustomerId="selectedCustomer!.Id"
TenantId="selectedCustomer!.TenantId"
EnvironmentId="selectedDeployment.EnvironmentId"
GovernanceNamespace="@(selectedCustomer?.Apps.FirstOrDefault(a => a.Id == selectedDeployment.AppId)?.Namespace)"
OnBack="BackToApps" OnBack="BackToApps"
OnDeleted="OnDeploymentDeleted" /> OnDeleted="OnDeploymentDeleted" />
} }
@@ -400,12 +425,14 @@ else
@* ════════════════════════════════════════════════════════════════ @* ════════════════════════════════════════════════════════════════
Level 3: App secrets vault Level 3: App secrets vault
════════════════════════════════════════════════════════════════ *@ ════════════════════════════════════════════════════════════════ *@
else if (selectedSecretsApp is not null) else if (selectedSecrets is not null)
{ {
<PortalSecretsDetail <PortalSecretsDetail
App="selectedSecretsApp" @key="(selectedSecrets.App.Id, selectedSecrets.Scope)"
App="selectedSecrets.App"
TenantId="selectedCustomer!.TenantId" TenantId="selectedCustomer!.TenantId"
AccessRole="currentAccessRole" /> AccessRole="currentAccessRole"
InitialScope="@selectedSecrets.Scope" />
} }
@* ════════════════════════════════════════════════════════════════ @* ════════════════════════════════════════════════════════════════
@@ -418,24 +445,110 @@ else
TenantId="selectedCustomer!.TenantId" TenantId="selectedCustomer!.TenantId"
AccessRole="currentAccessRole" /> AccessRole="currentAccessRole" />
} }
else if (selectedGovernanceApp is not null) @* Kept for reference — governance is now accessed via environment cards *@
else if (selectedResourcesApp is not null)
{ {
<AppGovernancePanel AppId="selectedGovernanceApp.Id" AppName="selectedGovernanceApp.Name" /> <AppResourcesPanel AppId="selectedResourcesApp.Id" />
}
else if (selectedRoutesApp is not null)
{
<PortalAppRoutesPanel App="selectedRoutesApp" AccessRole="currentAccessRole" />
} }
} }
<style>
.portal-env-card { transition: box-shadow .14s, transform .1s; }
.portal-env-card:hover { box-shadow: 0 4px 14px rgba(0,0,0,.13) !important; transform: translateY(-1px); }
</style>
@code { @code {
private bool loading = true; private bool loading = true;
private bool loadingCustomer;
private string? currentUserId; private string? currentUserId;
private CustomerAccessRole currentAccessRole; 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<Breadcrumb.BreadcrumbItem> PortalBreadcrumbs
{
get
{
var items = new List<Breadcrumb.BreadcrumbItem>();
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<Customer>? customers; private List<Customer>? customers;
private Customer? selectedCustomer; private Customer? selectedCustomer;
private AppDeployment? selectedDeployment; private AppDeployment? selectedDeployment;
private KeycloakRealm? selectedIdentityRealm; 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 HarborProject? selectedRegistryProject;
private Data.App? selectedGovernanceApp; 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<Guid, List<Data.Environment>> appLinkedEnvironments = new();
// Deployments indexed by app ID for the selected customer. // Deployments indexed by app ID for the selected customer.
private Dictionary<Guid, List<AppDeployment>> appDeployments = new(); private Dictionary<Guid, List<AppDeployment>> appDeployments = new();
@@ -446,6 +559,9 @@ else
// Linked Harbor projects indexed by app ID for the selected customer. // Linked Harbor projects indexed by app ID for the selected customer.
private Dictionary<Guid, HarborProject> appHarborProjects = new(); private Dictionary<Guid, HarborProject> appHarborProjects = new();
// External routes indexed by app ID for the selected customer.
private Dictionary<Guid, List<AppRoute>> appRoutes = new();
// Environments and clusters for deployment creation (loaded when Admin selects a customer). // Environments and clusters for deployment creation (loaded when Admin selects a customer).
private List<Data.Environment> tenantEnvironments = []; private List<Data.Environment> tenantEnvironments = [];
private List<KubernetesCluster> tenantClusters = []; private List<KubernetesCluster> tenantClusters = [];
@@ -459,14 +575,15 @@ else
private HashSet<Guid> AllCustomerClusterIds => private HashSet<Guid> AllCustomerClusterIds =>
AllCustomerDeployments.Select(d => d.ClusterId).ToHashSet(); AllCustomerDeployments.Select(d => d.ClusterId).ToHashSet();
// Create deployment form (portal Admin).
private Guid? createDeployAppId; // App edit/delete state
private string newDeployName = "", newDeployNamespace = ""; private Guid? editingAppId;
private DeploymentType newDeployType = DeploymentType.Manual; private string editAppName = "";
private Guid newDeployEnvId, newDeployClusterId; private bool savingAppName;
private string newHelmRepoUrl = "", newHelmChartName = "", newHelmChartVersion = ""; private Guid? deletingAppId;
private bool creatingDeployment; private bool deletingApp;
private string? createDeployError; private string? deleteAppError;
protected override async Task OnInitializedAsync() protected override async Task OnInitializedAsync()
{ {
@@ -488,17 +605,22 @@ else
private async Task SelectCustomer(Customer customer) private async Task SelectCustomer(Customer customer)
{ {
selectedCustomer = customer; selectedCustomer = customer;
loadingCustomer = true;
showMonitoringPanel = false; showMonitoringPanel = false;
StateHasChanged();
// Look up the user's role for this customer. // Look up the user's role for this customer.
CustomerAccess? access = await CustomerAccessService.GetAccessAsync(currentUserId!, customer.Id); CustomerAccess? access = await CustomerAccessService.GetAccessAsync(currentUserId!, customer.Id);
currentAccessRole = access?.Role ?? CustomerAccessRole.Viewer; 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(); appDeployments.Clear();
appRealms.Clear(); appRealms.Clear();
appHarborProjects.Clear(); appHarborProjects.Clear();
createDeployAppId = null; appRoutes.Clear();
appLinkedEnvironments.Clear();
selectedPortalApp = null;
selectedPortalEnvironment = null;
foreach (Data.App app in customer.Apps) foreach (Data.App app in customer.Apps)
{ {
@@ -512,14 +634,42 @@ else
HarborProject? harborProject = await HarborService.GetProjectForAppAsync(customer.TenantId, app.Id); HarborProject? harborProject = await HarborService.GetProjectForAppAsync(customer.TenantId, app.Id);
if (harborProject is not null) if (harborProject is not null)
appHarborProjects[app.Id] = harborProject; appHarborProjects[app.Id] = harborProject;
try
{
List<AppRoute> 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) if (currentAccessRole >= CustomerAccessRole.Admin)
{ {
tenantEnvironments = await TenantService.GetEnvironmentsAsync(customer.TenantId);
tenantClusters = await TenantService.GetClustersAsync(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) private void SelectDeployment(AppDeployment deploy)
@@ -544,13 +694,84 @@ else
selectedCustomer = null; selectedCustomer = null;
selectedDeployment = null; selectedDeployment = null;
selectedIdentityRealm = null; selectedIdentityRealm = null;
selectedSecretsApp = null; selectedSecrets = null;
selectedRegistryProject = null; selectedRegistryProject = null;
} }
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) private void SelectGovernanceApp(Data.App app)
@@ -558,13 +779,28 @@ else
selectedGovernanceApp = app; selectedGovernanceApp = app;
} }
private void SelectResourcesApp(Data.App app)
{
selectedResourcesApp = app;
}
private void SelectRoutesApp(Data.App app)
{
selectedRoutesApp = app;
}
private void BackToApps() private void BackToApps()
{ {
selectedDeployment = null; selectedDeployment = null;
selectedIdentityRealm = null; selectedIdentityRealm = null;
selectedSecretsApp = null; selectedSecrets = null;
selectedRegistryProject = null; selectedRegistryProject = null;
selectedGovernanceApp = null; selectedGovernanceApp = null;
selectedResourcesApp = null;
selectedRoutesApp = null;
selectedPortalApp = null;
selectedPortalEnvironment = null;
} }
private async Task OnDeploymentDeleted() private async Task OnDeploymentDeleted()
@@ -578,48 +814,8 @@ else
} }
} }
private void ToggleCreateDeployment(Guid appId) private static bool IsGitType(DeploymentType type)
{ => type is DeploymentType.GitYaml or DeploymentType.GitHelm or DeploymentType.GitAppOfApps;
if (createDeployAppId == appId)
{
createDeployAppId = null;
}
else
{
createDeployAppId = appId;
newDeployName = newDeployNamespace = newHelmRepoUrl = newHelmChartName = newHelmChartVersion = "";
newDeployType = DeploymentType.Manual;
newDeployEnvId = newDeployClusterId = Guid.Empty;
createDeployError = null;
}
}
private async Task CreateDeployment(Guid appId)
{
creatingDeployment = true;
createDeployError = null;
try
{
await DeploymentService.CreateDeploymentAsync(
appId, newDeployName, newDeployType,
newDeployEnvId, newDeployClusterId, newDeployNamespace,
newDeployType == DeploymentType.HelmChart ? newHelmRepoUrl : null,
newDeployType == DeploymentType.HelmChart ? newHelmChartName : null,
newDeployType == DeploymentType.HelmChart ? newHelmChartVersion : null);
createDeployAppId = null;
appDeployments[appId] = await DeploymentService.GetDeploymentsAsync(appId);
}
catch (Exception ex)
{
createDeployError = ex.Message;
}
finally
{
creatingDeployment = false;
}
}
// ──────── Badge helpers ──────── // ──────── Badge helpers ────────
@@ -628,6 +824,9 @@ else
DeploymentType.Manual => @<span class="badge bg-info">Manual</span>, DeploymentType.Manual => @<span class="badge bg-info">Manual</span>,
DeploymentType.Yaml => @<span class="badge bg-warning text-dark">YAML</span>, DeploymentType.Yaml => @<span class="badge bg-warning text-dark">YAML</span>,
DeploymentType.HelmChart => @<span class="badge bg-purple text-white" style="background-color: #6f42c1 !important;">Helm</span>, DeploymentType.HelmChart => @<span class="badge bg-purple text-white" style="background-color: #6f42c1 !important;">Helm</span>,
DeploymentType.GitYaml => @<span class="badge bg-success"><i class="bi bi-git me-1"></i>Git YAML</span>,
DeploymentType.GitHelm => @<span class="badge bg-success"><i class="bi bi-git me-1"></i>Git Helm</span>,
DeploymentType.GitAppOfApps => @<span class="badge bg-success"><i class="bi bi-git me-1"></i>App of Apps</span>,
_ => @<span class="badge bg-secondary">Unknown</span> _ => @<span class="badge bg-secondary">Unknown</span>
}; };

View File

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

View File

@@ -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)
═══════════════════════════════════════════════════════════════════ *@
<div class="d-flex align-items-center mb-3">
<i class="bi bi-globe fs-4 me-2 text-primary"></i>
<div>
<h5 class="mb-0">URL Configuration — @App.Name</h5>
<small class="text-muted">Manage which paths route to which services for each hostname.</small>
</div>
</div>
@if (loadError is not null)
{
<div class="alert alert-danger py-2 small">
<i class="bi bi-exclamation-triangle me-1"></i>
Failed to load URL configuration: @loadError
</div>
}
else if (routes is null)
{
<div class="text-center py-4">
<div class="spinner-border spinner-border-sm text-primary"></div>
</div>
}
else if (routes.Count == 0)
{
<EmptyState Icon="bi-globe2"
Title="No URLs configured"
Message="No hostnames have been configured for this app yet. Contact your operations team to set up a hostname and TLS certificate." />
}
else
{
@foreach (AppRoute route in routes)
{
<div class="card shadow-sm mb-3">
@* ── Hostname header (ops-managed, read-only) ── *@
<div class="card-header bg-white py-2">
<div class="d-flex align-items-center gap-2">
<i class="bi bi-globe2 text-primary"></i>
<a href="https://@route.Hostname" target="_blank" rel="noopener noreferrer"
class="fw-medium text-decoration-none">
@route.Hostname
</a>
@TlsBadge(route)
@if (!route.IsEnabled)
{
<span class="badge bg-secondary">Disabled by ops</span>
}
<span class="badge bg-light text-muted border ms-auto small">ops-managed</span>
</div>
</div>
<div class="card-body p-0">
@* ── Existing deployment routes ── *@
@if (route.DeploymentRoutes.Count > 0)
{
<table class="table table-sm mb-0">
<thead class="table-light">
<tr>
<th class="small ps-3">Path</th>
<th class="small">Deployment</th>
<th class="small">Env</th>
<th class="small">Service</th>
<th class="small">Status</th>
@if (AccessRole >= CustomerAccessRole.Operator)
{
<th></th>
}
</tr>
</thead>
<tbody>
@foreach (AppDeploymentRoute dr in route.DeploymentRoutes.OrderBy(r => r.PathPrefix))
{
bool isEditing = editingDrId == dr.Id;
<tr>
@if (isEditing)
{
<td colspan="@(AccessRole >= CustomerAccessRole.Operator ? 6 : 5)" class="px-3 py-2">
@if (editError is not null)
{
<div class="alert alert-danger py-1 small mb-2">@editError</div>
}
<div class="row g-2">
<div class="col-md-2">
<label class="form-label small">Path Prefix</label>
<input class="form-control form-control-sm" @bind="editPath" placeholder="/" />
</div>
<div class="col-md-4">
<label class="form-label small">
Service
@if (editLoadingServices)
{
<span class="spinner-border spinner-border-sm ms-1" style="width:.7rem;height:.7rem;"></span>
}
</label>
@if (editAvailableServices.Count > 0)
{
<select class="form-select form-select-sm"
value="@editService"
@onchange="OnEditServiceSelected">
<option value="">— select —</option>
@foreach (KubeServiceInfo svc in editAvailableServices)
{
<option value="@svc.Name">@svc.Name (@svc.Type)</option>
}
</select>
}
else
{
<input class="form-control form-control-sm" @bind="editService" />
}
</div>
<div class="col-md-2">
<label class="form-label small">Port</label>
@if (editAvailablePorts.Count > 0)
{
<select class="form-select form-select-sm" @bind="editPort">
@foreach (KubeServicePort p in editAvailablePorts)
{
<option value="@p.Port">@p.Port@(p.Name is not null ? $" ({p.Name})" : "")</option>
}
</select>
}
else
{
<input type="number" class="form-control form-control-sm" @bind="editPort" />
}
</div>
</div>
@* Endpoint health for edit *@
@if (editEndpointSummary is not null && editEndpointSummary.HasEndpoints)
{
<div class="mt-1 small">
<span class="text-success me-2">
<i class="bi bi-circle-fill me-1" style="font-size:.5rem;vertical-align:middle;"></i>
@editEndpointSummary.TotalReady ready
</span>
@if (editEndpointSummary.TotalNotReady > 0)
{
<span class="text-warning">
<i class="bi bi-circle-fill me-1" style="font-size:.5rem;vertical-align:middle;"></i>
@editEndpointSummary.TotalNotReady not ready
</span>
}
</div>
}
else if (editEndpointSummary is not null && !editEndpointSummary.HasEndpoints && !string.IsNullOrEmpty(editService))
{
<div class="mt-1 small text-warning">
<i class="bi bi-exclamation-triangle me-1"></i>No endpoints — service has no ready pods.
</div>
}
<div class="d-flex gap-1 mt-2">
<button class="btn btn-sm btn-primary" @onclick="() => SaveEdit(dr.Id)"
disabled="@(string.IsNullOrWhiteSpace(editService) || saving)">
@if (saving) { <span class="spinner-border spinner-border-sm me-1"></span> }
Save
</button>
<button class="btn btn-sm btn-outline-secondary" @onclick="CancelEdit">Cancel</button>
</div>
</td>
}
else
{
<td class="small align-middle ps-3"><code class="small">@dr.PathPrefix</code></td>
<td class="small align-middle">@dr.AppDeployment?.Name</td>
<td class="small align-middle text-muted">@dr.AppDeployment?.Environment?.Name</td>
<td class="small align-middle text-muted">@dr.ServiceName<span class="text-muted">:@dr.ServicePort</span></td>
<td class="small align-middle">@HealthBadge(dr)</td>
@if (AccessRole >= CustomerAccessRole.Operator)
{
<td class="text-end align-middle pe-2" style="white-space: nowrap;">
<button class="btn btn-sm btn-outline-secondary py-0"
@onclick="() => StartEdit(dr)">
<i class="bi bi-pencil"></i>
</button>
@if (deleteConfirmId == dr.Id)
{
<button class="btn btn-sm btn-danger py-0 ms-1"
@onclick="() => DeleteDeploymentRoute(dr.Id)">Confirm</button>
<button class="btn btn-sm btn-outline-secondary py-0 ms-1"
@onclick="() => deleteConfirmId = null">Cancel</button>
}
else
{
<button class="btn btn-sm btn-outline-danger py-0 ms-1"
title="Remove this path"
@onclick="() => deleteConfirmId = dr.Id">
<i class="bi bi-x-lg"></i>
</button>
}
</td>
}
}
</tr>
}
</tbody>
</table>
}
else if (AccessRole < CustomerAccessRole.Operator)
{
<div class="p-3 text-muted small">No paths configured yet.</div>
}
@* ── Add path form ── *@
@if (AccessRole >= CustomerAccessRole.Operator)
{
@if (addingToRouteId == route.Id)
{
<div class="px-3 py-2 border-top bg-light">
@if (addError is not null)
{
<div class="alert alert-danger py-1 small mb-2">@addError</div>
}
<div class="row g-2">
<div class="col-md-3">
<label class="form-label small">Deployment</label>
<select class="form-select form-select-sm"
value="@newDrDeploymentId"
@onchange="OnAddDeploymentSelected">
<option value="">— select —</option>
@foreach (AppDeployment d in (deployments ?? []))
{
<option value="@d.Id">@d.Name (@d.Environment?.Name)</option>
}
</select>
</div>
<div class="col-md-2">
<label class="form-label small">Path Prefix</label>
<input class="form-control form-control-sm" placeholder="/" @bind="newDrPath" />
</div>
<div class="col-md-4">
<label class="form-label small">
Service
@if (addLoadingServices)
{
<span class="spinner-border spinner-border-sm ms-1" style="width:.7rem;height:.7rem;"></span>
}
</label>
@if (addAvailableServices.Count > 0)
{
<select class="form-select form-select-sm"
value="@newDrService"
@onchange="OnAddServiceSelected">
<option value="">— select —</option>
@foreach (KubeServiceInfo svc in addAvailableServices)
{
<option value="@svc.Name">@svc.Name (@svc.Type)</option>
}
</select>
}
else
{
<input class="form-control form-control-sm" placeholder="my-service"
@bind="newDrService" />
}
</div>
<div class="col-md-2">
<label class="form-label small">Port</label>
@if (addAvailablePorts.Count > 0)
{
<select class="form-select form-select-sm" @bind="newDrPort">
@foreach (KubeServicePort p in addAvailablePorts)
{
<option value="@p.Port">@p.Port@(p.Name is not null ? $" ({p.Name})" : "")</option>
}
</select>
}
else
{
<input type="number" class="form-control form-control-sm" @bind="newDrPort" />
}
</div>
</div>
@* Endpoint health preview *@
@if (addEndpointSummary is not null && addEndpointSummary.HasEndpoints)
{
<div class="mt-1 small">
<span class="text-success me-2">
<i class="bi bi-circle-fill me-1" style="font-size:.5rem;vertical-align:middle;"></i>
@addEndpointSummary.TotalReady ready
</span>
@if (addEndpointSummary.TotalNotReady > 0)
{
<span class="text-warning">
<i class="bi bi-circle-fill me-1" style="font-size:.5rem;vertical-align:middle;"></i>
@addEndpointSummary.TotalNotReady not ready
</span>
}
</div>
}
else if (addEndpointSummary is not null && !addEndpointSummary.HasEndpoints && !string.IsNullOrEmpty(newDrService))
{
<div class="mt-1 small text-warning">
<i class="bi bi-exclamation-triangle me-1"></i>No endpoints — service has no ready pods.
</div>
}
<div class="d-flex gap-1 mt-2">
<button class="btn btn-sm btn-primary"
disabled="@(newDrDeploymentId == Guid.Empty || string.IsNullOrWhiteSpace(newDrService) || adding)"
@onclick="() => AddPath(route.Id)">
@if (adding) { <span class="spinner-border spinner-border-sm me-1"></span> }
Add Path
</button>
<button class="btn btn-sm btn-outline-secondary" @onclick="CancelAdd">Cancel</button>
</div>
</div>
}
else
{
<div class="px-3 py-2 border-top">
<button class="btn btn-sm btn-outline-secondary"
@onclick="() => StartAdd(route.Id)">
<i class="bi bi-plus me-1"></i>Add Path
</button>
</div>
}
}
</div>
</div>
}
<div class="alert alert-info small py-2">
<i class="bi bi-info-circle me-1"></i>
Path changes update the routing configuration. Your platform team will apply the updated HTTPRoute to the cluster.
</div>
}
@code {
[Parameter] public required Data.App App { get; set; }
[Parameter] public CustomerAccessRole AccessRole { get; set; }
private List<AppRoute>? routes;
private List<AppDeployment>? 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<KubeServiceInfo> addAvailableServices = [];
private List<KubeServicePort> 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<KubeServiceInfo> editAvailableServices = [];
private List<KubeServicePort> 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();
}
};
}

View File

@@ -36,11 +36,8 @@
@* ── Users ── *@ @* ── Users ── *@
@if (activeTab == "users") @if (activeTab == "users")
{ {
@if (loadingUsers) <LoadingPanel Loading="@loadingUsers">
{ @if (!loadingUsers)
<div class="spinner-border spinner-border-sm text-primary"></div>
}
else
{ {
<div class="d-flex justify-content-between align-items-center mb-3"> <div class="d-flex justify-content-between align-items-center mb-3">
<span class="text-muted small">@(users?.Count ?? 0) users</span> <span class="text-muted small">@(users?.Count ?? 0) users</span>
@@ -117,25 +114,32 @@
} }
</div> </div>
<button class="btn btn-sm btn-outline-danger border-0 ms-2" <button class="btn btn-sm btn-outline-danger border-0 ms-2"
@onclick="() => DeleteUser(user.Id)"> @onclick="() => confirmDeleteUserId = user.Id">
<i class="bi bi-trash"></i> <i class="bi bi-trash"></i>
</button> </button>
</div> </div>
@if (confirmDeleteUserId == user.Id)
{
<ConfirmDialog Visible="true"
Message="@($"Delete user '{user.Username}'? This cannot be undone.")"
ConfirmText="Delete"
IsBusy="deletingItem"
OnConfirm="() => DeleteUser(user.Id)"
OnCancel="() => confirmDeleteUserId = null" />
}
</div> </div>
} }
</div> </div>
} }
} }
</LoadingPanel>
} }
@* ── Identity Providers ── *@ @* ── Identity Providers ── *@
@if (activeTab == "idp") @if (activeTab == "idp")
{ {
@if (loadingIdps) <LoadingPanel Loading="@loadingIdps">
{ @if (!loadingIdps)
<div class="spinner-border spinner-border-sm text-primary"></div>
}
else
{ {
<div class="d-flex justify-content-between align-items-center mb-3"> <div class="d-flex justify-content-between align-items-center mb-3">
<span class="text-muted small">@(idps?.Count ?? 0) providers</span> <span class="text-muted small">@(idps?.Count ?? 0) providers</span>
@@ -188,7 +192,8 @@
<div class="list-group"> <div class="list-group">
@foreach (KeycloakIdpInfo idp in idps) @foreach (KeycloakIdpInfo idp in idps)
{ {
<div class="list-group-item d-flex justify-content-between align-items-center py-2"> <div class="list-group-item py-2">
<div class="d-flex justify-content-between align-items-center">
<div> <div>
<i class="bi bi-box-arrow-in-right me-2 text-muted"></i> <i class="bi bi-box-arrow-in-right me-2 text-muted"></i>
<strong>@idp.Alias</strong> <strong>@idp.Alias</strong>
@@ -199,24 +204,32 @@
} }
</div> </div>
<button class="btn btn-sm btn-outline-danger border-0" <button class="btn btn-sm btn-outline-danger border-0"
@onclick="() => DeleteIdp(idp.Alias)"> @onclick="() => confirmDeleteIdpAlias = idp.Alias">
<i class="bi bi-trash"></i> <i class="bi bi-trash"></i>
</button> </button>
</div> </div>
@if (confirmDeleteIdpAlias == idp.Alias)
{
<ConfirmDialog Visible="true"
Message="@($"Delete identity provider '{idp.Alias}'?")"
ConfirmText="Delete"
IsBusy="deletingItem"
OnConfirm="() => DeleteIdp(idp.Alias)"
OnCancel="() => confirmDeleteIdpAlias = null" />
}
</div>
} }
</div> </div>
} }
} }
</LoadingPanel>
} }
@* ── Groups ── *@ @* ── Groups ── *@
@if (activeTab == "groups") @if (activeTab == "groups")
{ {
@if (loadingGroups) <LoadingPanel Loading="@loadingGroups">
{ @if (!loadingGroups)
<div class="spinner-border spinner-border-sm text-primary"></div>
}
else
{ {
<div class="d-flex justify-content-between align-items-center mb-3"> <div class="d-flex justify-content-between align-items-center mb-3">
<span class="text-muted small">@(groups?.Count ?? 0) groups</span> <span class="text-muted small">@(groups?.Count ?? 0) groups</span>
@@ -246,27 +259,36 @@
<div class="list-group"> <div class="list-group">
@foreach (KeycloakGroupInfo g in groups) @foreach (KeycloakGroupInfo g in groups)
{ {
<div class="list-group-item d-flex justify-content-between align-items-center py-2"> <div class="list-group-item py-2">
<div class="d-flex justify-content-between align-items-center">
<span><i class="bi bi-people me-2 text-muted"></i>@g.Name <span class="text-muted small">@g.Path</span></span> <span><i class="bi bi-people me-2 text-muted"></i>@g.Name <span class="text-muted small">@g.Path</span></span>
<button class="btn btn-sm btn-outline-danger border-0" <button class="btn btn-sm btn-outline-danger border-0"
@onclick="() => DeleteGroup(g.Id)"> @onclick="() => confirmDeleteGroupId = g.Id">
<i class="bi bi-trash"></i> <i class="bi bi-trash"></i>
</button> </button>
</div> </div>
@if (confirmDeleteGroupId == g.Id)
{
<ConfirmDialog Visible="true"
Message="@($"Delete group '{g.Name}'?")"
ConfirmText="Delete"
IsBusy="deletingItem"
OnConfirm="() => DeleteGroup(g.Id)"
OnCancel="() => confirmDeleteGroupId = null" />
}
</div>
} }
</div> </div>
} }
} }
</LoadingPanel>
} }
@* ── Organizations ── *@ @* ── Organizations ── *@
@if (activeTab == "orgs") @if (activeTab == "orgs")
{ {
@if (loadingOrgs) <LoadingPanel Loading="@loadingOrgs">
{ @if (!loadingOrgs)
<div class="spinner-border spinner-border-sm text-primary"></div>
}
else
{ {
<div class="d-flex justify-content-between align-items-center mb-3"> <div class="d-flex justify-content-between align-items-center mb-3">
<span class="text-muted small">@(orgs?.Count ?? 0) organizations (Keycloak 26+)</span> <span class="text-muted small">@(orgs?.Count ?? 0) organizations (Keycloak 26+)</span>
@@ -322,6 +344,7 @@
</div> </div>
} }
} }
</LoadingPanel>
} }
@code { @code {
@@ -337,6 +360,12 @@
private bool loadingUsers, loadingIdps, loadingGroups, loadingOrgs; private bool loadingUsers, loadingIdps, loadingGroups, loadingOrgs;
// Delete confirm state
private string? confirmDeleteUserId;
private string? confirmDeleteIdpAlias;
private string? confirmDeleteGroupId;
private bool deletingItem;
// User form // User form
private bool showAddUser, savingUser; private bool showAddUser, savingUser;
private string? userError; private string? userError;
@@ -426,12 +455,15 @@
private async Task DeleteUser(string userId) private async Task DeleteUser(string userId)
{ {
deletingItem = true;
try try
{ {
await KeycloakService.DeleteUserAsync(TenantId, Realm.Id, userId); await KeycloakService.DeleteUserAsync(TenantId, Realm.Id, userId);
confirmDeleteUserId = null;
users = await KeycloakService.GetUsersAsync(TenantId, Realm.Id); users = await KeycloakService.GetUsersAsync(TenantId, Realm.Id);
} }
catch (Exception ex) { userError = ex.Message; } catch (Exception ex) { userError = ex.Message; }
finally { deletingItem = false; }
} }
private async Task AddIdp() private async Task AddIdp()
@@ -453,12 +485,15 @@
private async Task DeleteIdp(string alias) private async Task DeleteIdp(string alias)
{ {
deletingItem = true;
try try
{ {
await KeycloakService.DeleteIdpAsync(TenantId, Realm.Id, alias); await KeycloakService.DeleteIdpAsync(TenantId, Realm.Id, alias);
confirmDeleteIdpAlias = null;
idps = await KeycloakService.GetIdpsAsync(TenantId, Realm.Id); idps = await KeycloakService.GetIdpsAsync(TenantId, Realm.Id);
} }
catch (Exception ex) { idpError = ex.Message; } catch (Exception ex) { idpError = ex.Message; }
finally { deletingItem = false; }
} }
private async Task AddGroup() private async Task AddGroup()
@@ -478,12 +513,15 @@
private async Task DeleteGroup(string groupId) private async Task DeleteGroup(string groupId)
{ {
deletingItem = true;
try try
{ {
await KeycloakService.DeleteGroupAsync(TenantId, Realm.Id, groupId); await KeycloakService.DeleteGroupAsync(TenantId, Realm.Id, groupId);
confirmDeleteGroupId = null;
groups = await KeycloakService.GetGroupsAsync(TenantId, Realm.Id); groups = await KeycloakService.GetGroupsAsync(TenantId, Realm.Id);
} }
catch (Exception ex) { groupError = ex.Message; } catch (Exception ex) { groupError = ex.Message; }
finally { deletingItem = false; }
} }
private async Task AddOrg() private async Task AddOrg()

View File

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

View File

@@ -1,28 +1,47 @@
@using EntKube.Web.Data @using EntKube.Web.Data
@using EntKube.Web.Services @using EntKube.Web.Services
@using Microsoft.AspNetCore.Components.Authorization
@inject VaultService VaultService @inject VaultService VaultService
@inject AppGovernanceService AppGovernanceService
@inject TenantService TenantService @inject TenantService TenantService
@inject DeploymentService DeploymentService
@inject CnpgService CnpgService
@inject MongoService MongoService
@inject RedisService RedisService
@inject AuthenticationStateProvider AuthStateProvider
@using EntKube.Web.Components.Pages.Shared @using EntKube.Web.Components.Pages.Shared
@* ═══════════════════════════════════════════════════════════════════ @* ═══════════════════════════════════════════════════════════════════
App Secrets — customer portal view for managing vault secrets App Secrets — customer portal view for managing vault secrets and
scoped to a single app. Docker registry credentials scoped to a single app.
Tabs:
App Secrets — vault secrets tied directly to the app
Registries — Docker pull secrets
Note: Database, Messaging, Cache, and Storage bindings are now
top-level tabs in AppEnvironmentDetail.
Access rules: Access rules:
Viewer+ — list secret names, reveal values on demand Viewer+ — list and reveal values
Admin — add / update / delete secrets, configure K8s sync Admin — add / update / delete secrets, trigger syncs
═══════════════════════════════════════════════════════════════════ *@ ═══════════════════════════════════════════════════════════════════ *@
<div class="d-flex align-items-center mb-3"> @* ── Scope tab navigation ── *@
<i class="bi bi-shield-lock fs-4 me-2 text-primary"></i> <ul class="nav nav-tabs mb-3">
<div> <li class="nav-item">
<h5 class="mb-0">Secrets — @App.Name</h5> <button class="nav-link @(activeScope is "app" or "" ? "active" : "")"
<small class="text-muted"> @onclick='() => SwitchScope("app")'>
Application secrets stored with AES-256-GCM envelope encryption. <i class="bi bi-key me-1"></i>App Secrets
Values are never transmitted in plain text. </button>
</small> </li>
</div> <li class="nav-item">
</div> <button class="nav-link @(activeScope == "docker" ? "active" : "")"
@onclick='() => SwitchScope("docker")'>
<i class="bi bi-box-seam me-1"></i>Registries
</button>
</li>
</ul>
@if (!vaultInitialized) @if (!vaultInitialized)
{ {
@@ -33,6 +52,11 @@
} }
else else
{ {
@* ════════════════════════════════════════════════════════════════
Tab: App Secrets
════════════════════════════════════════════════════════════════ *@
@if (activeScope == "app" || activeScope == "")
{
@* ── Add Secret form (Admin only) ── *@ @* ── Add Secret form (Admin only) ── *@
@if (AccessRole >= CustomerAccessRole.Admin) @if (AccessRole >= CustomerAccessRole.Admin)
{ {
@@ -102,24 +126,13 @@ else
} }
</div> </div>
<div class="card-body p-0"> <div class="card-body p-0">
@if (secrets is null) <LoadingPanel Loading="@(secrets is null)">
@if (secrets is not null && secrets.Count == 0)
{ {
<div class="text-center py-4"> <EmptyState Icon="bi-lock"
<div class="spinner-border spinner-border-sm text-primary" role="status"></div> Message="@(AccessRole >= CustomerAccessRole.Admin ? "No secrets stored yet. Use the form above to add one." : "No secrets stored for this app yet.")" />
</div>
} }
else if (secrets.Count == 0) else if (secrets is not null)
{
<div class="text-center py-5">
<i class="bi bi-lock text-muted" style="font-size: 2rem;"></i>
<p class="text-muted small mt-2 mb-0">No secrets stored for this app yet.</p>
@if (AccessRole >= CustomerAccessRole.Admin)
{
<p class="text-muted small mb-0">Use the form above to add one.</p>
}
</div>
}
else
{ {
<div class="table-responsive"> <div class="table-responsive">
<table class="table table-hover align-middle mb-0"> <table class="table table-hover align-middle mb-0">
@@ -152,34 +165,49 @@ else
<td><small class="text-muted">@(secret.KubernetesNamespace ?? "—")</small></td> <td><small class="text-muted">@(secret.KubernetesNamespace ?? "—")</small></td>
<td><small class="text-muted">@secret.UpdatedAt.ToString("MMM d, HH:mm")</small></td> <td><small class="text-muted">@secret.UpdatedAt.ToString("MMM d, HH:mm")</small></td>
<td class="text-end"> <td class="text-end">
@* Reveal — available to all roles *@
<button class="btn btn-sm btn-outline-secondary me-1" <button class="btn btn-sm btn-outline-secondary me-1"
@onclick="() => RevealSecret(secret)" @onclick="() => RevealSecret(secret)"
title="@(revealedSecretId == secret.Id ? "Hide value" : "Reveal value")"> title="@(revealedSecretId == secret.Id ? "Hide value" : "Reveal value")">
<i class="bi @(revealedSecretId == secret.Id ? "bi-eye-slash" : "bi-eye")"></i> <i class="bi @(revealedSecretId == secret.Id ? "bi-eye-slash" : "bi-eye")"></i>
</button> </button>
@* Admin-only: edit, K8s sync, delete *@
@if (AccessRole >= CustomerAccessRole.Admin) @if (AccessRole >= CustomerAccessRole.Admin)
{ {
<button class="btn btn-sm btn-outline-warning me-1" <button class="btn btn-sm btn-outline-warning me-1"
@onclick="() => StartEdit(secret)" title="Update value"> @onclick="() => StartEdit(secret)" title="Update value">
<i class="bi bi-pencil"></i> <i class="bi bi-pencil"></i>
</button> </button>
<button class="btn btn-sm btn-outline-secondary me-1"
@onclick="() => ToggleHistory(secret.Id)" title="Version history">
<i class="bi bi-clock-history"></i>
</button>
<button class="btn btn-sm btn-outline-info me-1" <button class="btn btn-sm btn-outline-info me-1"
@onclick="() => ToggleSync(secret)" @onclick="() => ToggleSync(secret)"
title="@(secret.SyncToKubernetes ? "Disable K8s sync" : "Configure K8s sync")"> title="@(secret.SyncToKubernetes ? "Disable K8s sync" : "Configure K8s sync")">
<i class="bi @(secret.SyncToKubernetes ? "bi-cloud-slash" : "bi-cloud-arrow-up")"></i> <i class="bi @(secret.SyncToKubernetes ? "bi-cloud-slash" : "bi-cloud-arrow-up")"></i>
</button> </button>
<button class="btn btn-sm btn-outline-danger" <button class="btn btn-sm btn-outline-danger"
@onclick="() => DeleteSecret(secret.Id)" title="Delete secret"> @onclick="() => confirmDeleteSecretId = secret.Id" title="Delete secret">
<i class="bi bi-trash"></i> <i class="bi bi-trash"></i>
</button> </button>
} }
</td> </td>
</tr> </tr>
@* ── Reveal value row ── *@ @if (confirmDeleteSecretId == secret.Id)
{
<tr class="table-danger table-sm">
<td colspan="6" class="px-3 py-2">
<ConfirmDialog Visible="true"
Message="@($"Permanently delete secret '{secret.Name}'?")"
ConfirmText="Delete"
IsBusy="deletingSecret"
OnConfirm="() => DeleteSecret(secret.Id)"
OnCancel="() => confirmDeleteSecretId = null" />
</td>
</tr>
}
@if (revealedSecretId == secret.Id) @if (revealedSecretId == secret.Id)
{ {
<tr class="table-light"> <tr class="table-light">
@@ -199,7 +227,6 @@ else
</tr> </tr>
} }
@* ── Edit value row ── *@
@if (editSecretId == secret.Id) @if (editSecretId == secret.Id)
{ {
<tr class="table-light"> <tr class="table-light">
@@ -225,33 +252,34 @@ else
</tr> </tr>
} }
@* ── K8s sync config row ── *@
@if (syncConfigSecretId == secret.Id) @if (syncConfigSecretId == secret.Id)
{ {
bool nsLocked = syncClusterId != Guid.Empty && GetLockedNamespaceForCluster(syncClusterId) is not null;
<tr class="table-light"> <tr class="table-light">
<td colspan="6"> <td colspan="6">
<div class="row g-2 p-2 align-items-center"> <div class="row g-2 p-2 align-items-center">
@if (clusters is not null)
{
<div class="col-md-3"> <div class="col-md-3">
<select class="form-select form-select-sm" @bind="syncClusterId"> <select class="form-select form-select-sm"
value="@syncClusterId"
@onchange="OnSyncClusterChanged">
<option value="@Guid.Empty">Target cluster…</option> <option value="@Guid.Empty">Target cluster…</option>
@foreach (KubernetesCluster cluster in clusters) @foreach (KubernetesCluster cluster in appEnvironmentClusters)
{ {
<option value="@cluster.Id">@cluster.Name</option> <option value="@cluster.Id">@cluster.Name</option>
} }
</select> </select>
</div> </div>
}
<div class="col-md-3"> <div class="col-md-3">
<input type="text" class="form-control form-control-sm" <input type="text" class="form-control form-control-sm"
placeholder="K8s Secret name (e.g. my-app-secrets)" placeholder="K8s Secret name"
@bind="syncSecretName" /> @bind="syncSecretName" />
</div> </div>
<div class="col-md-3"> <div class="col-md-3">
<input type="text" class="form-control form-control-sm" <input type="text" class="form-control form-control-sm"
placeholder="Namespace (e.g. my-app)" placeholder="Namespace"
@bind="syncNamespace" /> @bind="syncNamespace"
disabled="@nsLocked"
title="@(nsLocked ? "Namespace is locked by governance policy" : null)" />
</div> </div>
<div class="col-auto"> <div class="col-auto">
<button class="btn btn-sm btn-success" @onclick="SaveSync"> <button class="btn btn-sm btn-success" @onclick="SaveSync">
@@ -263,6 +291,87 @@ else
</button> </button>
</div> </div>
</div> </div>
@if (nsLocked)
{
<div class="px-2 pb-1">
<small class="text-muted"><i class="bi bi-shield-lock me-1"></i>Namespace locked by governance policy.</small>
</div>
}
</td>
</tr>
}
@* Version history row *@
@if (historySecretId == secret.Id)
{
<tr>
<td colspan="6" class="bg-light p-0">
<div class="p-3">
<div class="d-flex align-items-center mb-2">
<i class="bi bi-clock-history me-2 text-secondary"></i>
<strong class="small">Version History</strong>
<span class="badge bg-secondary ms-2">@(secretVersions?.Count ?? 0)</span>
</div>
@if (secretVersions is null)
{
<div class="spinner-border spinner-border-sm text-secondary" role="status"></div>
}
else if (secretVersions.Count == 0)
{
<p class="text-muted small mb-0">No previous versions recorded.</p>
}
else
{
<table class="table table-sm table-bordered mb-0">
<thead class="table-secondary">
<tr>
<th style="width: 60px;">#</th>
<th>Set by</th>
<th>Date</th>
<th class="text-end" style="width: 160px;">Actions</th>
</tr>
</thead>
<tbody>
@foreach (VaultSecretVersion ver in secretVersions)
{
<tr>
<td><span class="badge bg-secondary">v@ver.VersionNumber</span></td>
<td><small class="text-muted">@(ver.CreatedBy ?? "—")</small></td>
<td><small class="text-muted">@ver.CreatedAt.ToString("MMM d, HH:mm")</small></td>
<td class="text-end">
<button class="btn btn-xs btn-outline-secondary me-1"
@onclick="() => RevealVersion(secret.Id, ver)"
title="@(revealedVersionId == ver.Id ? "Hide" : "Reveal value")">
<i class="bi @(revealedVersionId == ver.Id ? "bi-eye-slash" : "bi-eye")"></i>
</button>
<button class="btn btn-xs btn-outline-warning"
@onclick="() => RollbackToVersion(secret.Id, ver.Id)"
title="Restore this value">
<i class="bi bi-arrow-counterclockwise me-1"></i>Restore
</button>
</td>
</tr>
@if (revealedVersionId == ver.Id)
{
<tr class="table-light">
<td colspan="4" class="px-2 py-1">
<span class="text-muted small me-2">Value:</span>
@if (versionRevealLoading)
{
<span class="spinner-border spinner-border-sm"></span>
}
else
{
<code class="user-select-all">@revealedVersionValue</code>
}
</td>
</tr>
}
}
</tbody>
</table>
}
</div>
</td> </td>
</tr> </tr>
} }
@@ -271,82 +380,140 @@ else
</table> </table>
</div> </div>
} }
</LoadingPanel>
@if (syncOutput is not null) @if (syncOutput is not null)
{ {
<div class="m-3 p-2 bg-dark text-white rounded font-monospace small" style="white-space: pre-wrap; max-height: 200px; overflow-y: auto;">@syncOutput</div> <div class="m-3 p-2 bg-dark text-white rounded font-monospace small" style="white-space: pre-wrap; max-height: 200px; overflow-y: auto;">@syncOutput</div>
} }
</div> </div>
</div> </div>
}
@* ── Docker Registry Credentials ── *@ @* ════════════════════════════════════════════════════════════════
<div class="mt-4"> Tab: Docker Registries
<div class="d-flex align-items-center mb-2"> ════════════════════════════════════════════════════════════════ *@
<i class="bi bi-box-seam fs-5 me-2 text-primary"></i> else if (activeScope == "docker")
<h6 class="mb-0">Docker Registry Credentials</h6> {
<span class="ms-2 text-muted small">Pull secrets for private container registries</span>
</div>
<DockerRegistriesPanel <DockerRegistriesPanel
TenantId="TenantId" TenantId="TenantId"
AppId="App.Id" AppId="App.Id"
IsAdmin="AccessRole >= CustomerAccessRole.Admin" /> IsAdmin="AccessRole >= CustomerAccessRole.Admin" />
</div> }
} }
@code { @code {
[Parameter] public required Data.App App { get; set; } [Parameter] public required Data.App App { get; set; }
[Parameter] public Guid TenantId { get; set; } [Parameter] public Guid TenantId { get; set; }
[Parameter] public CustomerAccessRole AccessRole { get; set; } [Parameter] public CustomerAccessRole AccessRole { get; set; }
[Parameter] public string InitialScope { get; set; } = "app";
private string activeScope = "app";
private bool vaultInitialized; private bool vaultInitialized;
private List<VaultSecret>? secrets;
private List<KubernetesCluster>? clusters;
// Add form // ── App Secrets ──────────────────────────────────────────────────
private List<VaultSecret>? secrets;
private Dictionary<Guid, string?> environmentNamespaceLock = new();
private List<KubernetesCluster> appEnvironmentClusters = [];
private string newSecretName = ""; private string newSecretName = "";
private string newSecretValue = ""; private string newSecretValue = "";
private bool saving; private bool saving;
private string? errorMessage; private string? errorMessage;
private string? successMessage; private string? successMessage;
// Reveal
private Guid? revealedSecretId; private Guid? revealedSecretId;
private string? revealedValue; private string? revealedValue;
private bool revealLoading; private bool revealLoading;
// Edit
private Guid? editSecretId; private Guid? editSecretId;
private string editSecretValue = ""; private string editSecretValue = "";
// K8s sync config private Guid? confirmDeleteSecretId;
private bool deletingSecret;
private Guid? syncConfigSecretId; private Guid? syncConfigSecretId;
private string syncSecretName = ""; private string syncSecretName = "";
private string syncNamespace = ""; private string syncNamespace = "";
private Guid syncClusterId; private Guid syncClusterId;
// Sync-to-K8s state
private bool syncingSecrets; private bool syncingSecrets;
private string? syncOutput; private string? syncOutput;
// ── Version history state ────────────────────────────────────────
private Guid? historySecretId;
private List<VaultSecretVersion>? secretVersions;
private Guid? revealedVersionId;
private string? revealedVersionValue;
private bool versionRevealLoading;
// ── Shared binding state ─────────────────────────────────────────
private List<AppDeployment>? deployments;
private string? currentUserEmail;
protected override async Task OnInitializedAsync() protected override async Task OnInitializedAsync()
{ {
activeScope = InitialScope;
AuthenticationState authState = await AuthStateProvider.GetAuthenticationStateAsync();
currentUserEmail = authState.User.Identity?.Name;
SecretVault? vault = await VaultService.GetVaultAsync(TenantId); SecretVault? vault = await VaultService.GetVaultAsync(TenantId);
vaultInitialized = vault is not null; vaultInitialized = vault is not null;
if (vaultInitialized) await EnsureDeployments();
{ List<AppEnvironment> appEnvs = await AppGovernanceService.GetAppEnvironmentsAsync(App.Id);
await LoadSecrets(); environmentNamespaceLock = appEnvs.ToDictionary(ae => ae.EnvironmentId, ae => ae.Namespace);
HashSet<Guid> 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);
} }
// Load clusters for the K8s sync config dropdown. private async Task SwitchScope(string scope)
clusters = await TenantService.GetClustersAsync(TenantId); {
activeScope = scope;
await LoadScopeData(scope);
} }
private async Task LoadScopeData(string scope)
{
switch (scope)
{
case "docker":
break;
default:
if (secrets is null) await LoadSecrets();
break;
}
}
private async Task EnsureDeployments()
{
if (deployments is null)
deployments = await DeploymentService.GetDeploymentsAsync(App.Id);
}
// ── App Secrets: Add ─────────────────────────────────────────────
private async Task LoadSecrets() private async Task LoadSecrets()
{ {
secrets = await VaultService.GetAppSecretsAsync(TenantId, App.Id); secrets = await VaultService.GetAppSecretsAsync(TenantId, App.Id);
} }
// ──────── Add ──────── private async Task ShowSuccessAsync(string message)
{
successMessage = message;
StateHasChanged();
await Task.Delay(3500);
successMessage = null;
StateHasChanged();
}
private async Task AddSecret() private async Task AddSecret()
{ {
@@ -356,11 +523,12 @@ else
try try
{ {
await VaultService.SetAppSecretAsync(TenantId, App.Id, newSecretName.Trim(), newSecretValue.Trim()); string name = newSecretName.Trim();
successMessage = $"Secret '{newSecretName.Trim()}' saved."; await VaultService.SetAppSecretAsync(TenantId, App.Id, name, newSecretValue.Trim());
newSecretName = ""; newSecretName = "";
newSecretValue = ""; newSecretValue = "";
await LoadSecrets(); await LoadSecrets();
_ = ShowSuccessAsync($"Secret '{name}' saved.");
} }
catch (Exception ex) catch (Exception ex)
{ {
@@ -369,7 +537,7 @@ else
finally { saving = false; } finally { saving = false; }
} }
// ──────── Reveal ──────── // ── App Secrets: Reveal ──────────────────────────────────────────
private async Task RevealSecret(VaultSecret secret) private async Task RevealSecret(VaultSecret secret)
{ {
@@ -387,7 +555,7 @@ else
revealLoading = false; revealLoading = false;
} }
// ──────── Edit ──────── // ── App Secrets: Edit ────────────────────────────────────────────
private void StartEdit(VaultSecret secret) private void StartEdit(VaultSecret secret)
{ {
@@ -412,9 +580,11 @@ else
try try
{ {
bool updated = await VaultService.UpdateSecretValueAsync(editSecretId.Value, editSecretValue.Trim()); bool updated = await VaultService.UpdateSecretValueAsync(editSecretId.Value, editSecretValue.Trim(), currentUserEmail);
successMessage = updated ? "Secret value updated." : null; if (updated)
errorMessage = updated ? null : "Failed to update secret."; _ = ShowSuccessAsync("Secret value updated.");
else
errorMessage = "Failed to update secret.";
} }
finally { saving = false; } finally { saving = false; }
@@ -423,26 +593,31 @@ else
await LoadSecrets(); await LoadSecrets();
} }
// ──────── Delete ──────── // ── App Secrets: Delete ──────────────────────────────────────────
private async Task DeleteSecret(Guid secretId) private async Task DeleteSecret(Guid secretId)
{ {
errorMessage = null; errorMessage = null;
(bool canDelete, string? reason) = await VaultService.CanDeleteSecretAsync(secretId); deletingSecret = true;
(bool canDelete, string? reason) = await VaultService.CanDeleteSecretAsync(secretId);
if (!canDelete) if (!canDelete)
{ {
errorMessage = reason; errorMessage = reason;
deletingSecret = false;
confirmDeleteSecretId = null;
return; return;
} }
await VaultService.DeleteSecretAsync(secretId); await VaultService.DeleteSecretAsync(secretId);
deletingSecret = false;
confirmDeleteSecretId = null;
revealedSecretId = null; revealedSecretId = null;
editSecretId = null; editSecretId = null;
await LoadSecrets(); await LoadSecrets();
} }
// ──────── K8s Sync ──────── // ── App Secrets: K8s sync ────────────────────────────────────────
private void ToggleSync(VaultSecret secret) private void ToggleSync(VaultSecret secret)
{ {
@@ -456,8 +631,48 @@ else
syncSecretName = secret.KubernetesSecretName ?? ""; syncSecretName = secret.KubernetesSecretName ?? "";
syncNamespace = secret.KubernetesNamespace ?? ""; syncNamespace = secret.KubernetesNamespace ?? "";
syncClusterId = secret.KubernetesClusterId ?? Guid.Empty; 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) private async Task DisableSync(Guid secretId)
{ {
@@ -497,4 +712,61 @@ else
syncingSecrets = false; syncingSecrets = false;
} }
} }
// ── Version history ──────────────────────────────────────────────
private async Task ToggleHistory(Guid secretId)
{
if (historySecretId == secretId)
{
historySecretId = null;
secretVersions = null;
revealedVersionId = null;
revealedVersionValue = null;
return;
}
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)
{
revealedVersionId = null;
revealedVersionValue = null;
return;
}
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)
{
historySecretId = null;
secretVersions = null;
revealedVersionId = null;
revealedVersionValue = null;
await LoadSecrets();
_ = ShowSuccessAsync("Secret restored to selected version.");
}
else
{
errorMessage = "Failed to restore version.";
}
}
} }

View File

@@ -0,0 +1,62 @@
<nav aria-label="breadcrumb" class="mb-3">
<ol class="breadcrumb small">
@foreach (BreadcrumbItem item in Items)
{
@if (item.IsActive)
{
<li class="breadcrumb-item active">
@if (!string.IsNullOrEmpty(item.Icon))
{
<i class="bi @item.Icon me-1"></i>
}
@item.Label
</li>
}
else if (item.OnClick.HasDelegate)
{
<li class="breadcrumb-item">
<a href="javascript:void(0)" class="text-decoration-none" @onclick="item.OnClick">
@if (!string.IsNullOrEmpty(item.Icon))
{
<i class="bi @item.Icon me-1"></i>
}
@item.Label
</a>
</li>
}
else if (!string.IsNullOrEmpty(item.Href))
{
<li class="breadcrumb-item">
<a href="@item.Href" class="text-decoration-none">
@if (!string.IsNullOrEmpty(item.Icon))
{
<i class="bi @item.Icon me-1"></i>
}
@item.Label
</a>
</li>
}
else
{
<li class="breadcrumb-item text-muted">
@if (!string.IsNullOrEmpty(item.Icon))
{
<i class="bi @item.Icon me-1"></i>
}
@item.Label
</li>
}
}
</ol>
</nav>
@code {
[Parameter] public IReadOnlyList<BreadcrumbItem> Items { get; set; } = [];
public record BreadcrumbItem(
string Label,
string? Icon = null,
string? Href = null,
EventCallback OnClick = default,
bool IsActive = false);
}

View File

@@ -0,0 +1,27 @@
@if (Visible)
{
<div class="mt-2 p-2 bg-@Variant bg-opacity-10 rounded border border-@Variant border-opacity-25 d-flex align-items-center justify-content-between flex-wrap gap-2">
<span class="text-@Variant small">
<i class="bi bi-exclamation-triangle me-1"></i>
@Message
</span>
<div class="d-flex gap-1">
<button class="btn btn-sm btn-@Variant" @onclick="OnConfirm" disabled="@IsBusy">
@if (IsBusy) { <span class="spinner-border spinner-border-sm me-1"></span> }
@ConfirmText
</button>
<button class="btn btn-sm btn-outline-secondary" @onclick="OnCancel" disabled="@IsBusy">@CancelText</button>
</div>
</div>
}
@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; }
}

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,108 @@
@implements IAsyncDisposable
@inject IJSRuntime JS
<div class="yaml-editor-outer @(_fullscreen ? "yaml-editor-fullscreen" : "")">
@if (_fullscreen)
{
<div class="yaml-editor-fs-bar d-flex align-items-center justify-content-between px-3 py-2 border-bottom bg-light flex-shrink-0">
<span class="small fw-medium text-muted">
<i class="bi bi-code-slash me-1"></i>@Language.ToUpper()
</span>
<button class="btn btn-sm btn-outline-secondary" @onclick="ExitFullscreen">
<i class="bi bi-fullscreen-exit me-1"></i>Exit Fullscreen
</button>
</div>
}
<div class="yaml-editor-inner position-relative">
<div @ref="_container" class="yaml-editor-container @_borderClass" style="min-height: 100px; overflow: hidden;"></div>
@if (Expandable && !_fullscreen)
{
<button class="yaml-expand-btn btn btn-sm btn-light" @onclick="EnterFullscreen" title="Expand to fullscreen">
<i class="bi bi-arrows-fullscreen"></i>
</button>
}
</div>
</div>
@code {
[Parameter] public string Value { get; set; } = "";
[Parameter] public EventCallback<string> ValueChanged { get; set; }
[Parameter] public string Language { get; set; } = "yaml";
[Parameter] public bool ReadOnly { get; set; } = false;
[Parameter] public bool Borderless { get; set; } = false;
[Parameter] public bool AutoHeight { get; set; } = true;
[Parameter] public string MaxHeight { get; set; } = "600px";
[Parameter] public bool Expandable { get; set; } = true;
private string _borderClass => Borderless ? "" : "border border-secondary-subtle rounded";
private bool _fullscreen = false;
private bool _pendingLayoutUpdate = false;
private ElementReference _container;
private IJSObjectReference? _jsModule;
private IJSObjectReference? _editor;
private DotNetObjectReference<YamlEditor>? _dotNetRef;
private string _lastKnownValue = "";
private int MaxHeightPx => int.TryParse(MaxHeight?.Replace("px", "").Trim(), out var px) ? px : 0;
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender)
{
_dotNetRef = DotNetObjectReference.Create(this);
_jsModule = await JS.InvokeAsync<IJSObjectReference>(
"import", "./Components/Pages/Shared/YamlEditor.razor.js");
_editor = await _jsModule.InvokeAsync<IJSObjectReference>(
"createEditor", _container, Value ?? "", Language, ReadOnly, _dotNetRef, AutoHeight, MaxHeightPx);
_lastKnownValue = Value ?? "";
}
if (_pendingLayoutUpdate && _editor is not null && _jsModule is not null)
{
_pendingLayoutUpdate = false;
await _jsModule.InvokeVoidAsync("relayoutEditor", _editor, _container, _fullscreen);
}
}
protected override async Task OnParametersSetAsync()
{
if (_editor is not null && _jsModule is not null && Value != _lastKnownValue)
{
await _jsModule.InvokeVoidAsync("setEditorValue", _editor, Value ?? "");
_lastKnownValue = Value ?? "";
}
}
[JSInvokable]
public async Task OnValueChanged(string value)
{
_lastKnownValue = value;
Value = value;
await ValueChanged.InvokeAsync(value);
}
private void EnterFullscreen()
{
_fullscreen = true;
_pendingLayoutUpdate = true;
}
private void ExitFullscreen()
{
_fullscreen = false;
_pendingLayoutUpdate = true;
}
public async ValueTask DisposeAsync()
{
if (_editor is not null && _jsModule is not null)
{
try { await _jsModule.InvokeVoidAsync("disposeEditor", _editor); } catch { }
await _editor.DisposeAsync();
}
if (_jsModule is not null)
await _jsModule.DisposeAsync();
_dotNetRef?.Dispose();
}
}

View File

@@ -0,0 +1,43 @@
.yaml-editor-outer {
display: block;
}
.yaml-editor-fullscreen {
position: fixed;
inset: 0;
z-index: 1055;
display: flex;
flex-direction: column;
background: white;
box-shadow: 0 0 0 100vmax rgba(0, 0, 0, 0.45);
}
.yaml-editor-fullscreen .yaml-editor-inner {
flex: 1;
min-height: 0;
overflow: hidden;
}
.yaml-editor-fullscreen .yaml-editor-container {
height: 100%;
}
.yaml-expand-btn {
position: absolute;
top: 6px;
right: 6px;
z-index: 5;
padding: 2px 7px;
font-size: 0.7rem;
line-height: 1.4;
opacity: 0;
transition: opacity 0.15s;
}
.yaml-editor-outer:hover .yaml-expand-btn {
opacity: 0.7;
}
.yaml-expand-btn:hover {
opacity: 1 !important;
}

View File

@@ -0,0 +1,100 @@
const MONACO_VERSION = '0.52.0';
const MONACO_BASE = `https://cdnjs.cloudflare.com/ajax/libs/monaco-editor/${MONACO_VERSION}/min/vs`;
let _monacoPromise = null;
function loadMonaco() {
if (_monacoPromise) return _monacoPromise;
_monacoPromise = new Promise((resolve, reject) => {
if (window.monaco) {
resolve(window.monaco);
return;
}
const script = document.createElement('script');
script.src = `${MONACO_BASE}/loader.js`;
script.onload = () => {
window.require.config({ paths: { vs: MONACO_BASE } });
window.require(['vs/editor/editor.main'], () => resolve(window.monaco));
};
script.onerror = () => reject(new Error('Failed to load Monaco editor'));
document.head.appendChild(script);
});
return _monacoPromise;
}
export async function createEditor(container, value, language, readOnly, dotNetRef, autoHeight, maxHeightPx) {
const monaco = await loadMonaco();
const editor = monaco.editor.create(container, {
value: value ?? '',
language: language ?? 'yaml',
theme: 'vs',
readOnly: readOnly,
minimap: { enabled: false },
scrollBeyondLastLine: false,
automaticLayout: true,
fontSize: 13,
fontFamily: "ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', monospace",
tabSize: 2,
lineNumbers: 'on',
wordWrap: 'off',
fixedOverflowWidgets: true,
overviewRulerLanes: 0,
padding: { top: 8, bottom: 8 },
scrollbar: {
vertical: 'auto',
horizontal: 'auto',
useShadows: false,
verticalScrollbarSize: 8,
horizontalScrollbarSize: 8,
},
renderLineHighlight: 'line',
smoothScrolling: true,
});
editor.onDidChangeModelContent(() => {
dotNetRef.invokeMethodAsync('OnValueChanged', editor.getValue());
});
if (autoHeight) {
function updateAutoHeight() {
const contentHeight = editor.getContentHeight();
const h = maxHeightPx > 0 ? Math.min(contentHeight, maxHeightPx) : contentHeight;
container.style.height = Math.max(100, h) + 'px';
editor.layout();
}
editor._updateAutoHeight = updateAutoHeight;
editor.onDidContentSizeChange(updateAutoHeight);
updateAutoHeight();
}
return editor;
}
export function relayoutEditor(editor, container, isFullscreen) {
if (isFullscreen) {
container.style.height = '100%';
} else if (editor._updateAutoHeight) {
editor._updateAutoHeight();
return;
}
requestAnimationFrame(() => editor.layout());
}
export function setEditorValue(editor, value) {
if (editor.getValue() !== value) {
const model = editor.getModel();
model.pushEditOperations(
[],
[{ range: model.getFullModelRange(), text: value }],
() => null
);
}
}
export function disposeEditor(editor) {
editor.dispose();
}

View File

@@ -30,27 +30,15 @@
</div> </div>
</div> </div>
<div class="card-body p-0"> <div class="card-body p-0">
@if (loading) <LoadingPanel Loading="@loading" Error="@errorMessage">
{ @if (!loading && string.IsNullOrEmpty(errorMessage) && view == "alerts")
<div class="text-center py-3">
<div class="spinner-border spinner-border-sm text-primary"></div>
</div>
}
else if (!string.IsNullOrEmpty(errorMessage))
{
<div class="alert alert-warning m-3 mb-0">
<i class="bi bi-exclamation-triangle me-1"></i>@errorMessage
</div>
}
else if (view == "alerts")
{ {
@* ── Active Alerts ── *@ @* ── Active Alerts ── *@
@if (alerts is null || alerts.Count == 0) @if (alerts is null || alerts.Count == 0)
{ {
<div class="text-center py-4"> <EmptyState Icon="bi-check-circle"
<i class="bi bi-check-circle text-success" style="font-size: 2rem;"></i> Title="All clear!"
<p class="text-muted mt-2 mb-0">No active alerts. All clear!</p> Message="No active alerts right now." />
</div>
} }
else else
{ {
@@ -93,15 +81,12 @@
</div> </div>
} }
} }
else if (view == "silences") else if (!loading && string.IsNullOrEmpty(errorMessage) && view == "silences")
{ {
@* ── Silences ── *@ @* ── Silences ── *@
@if (silences is null || silences.Count == 0) @if (silences is null || silences.Count == 0)
{ {
<div class="text-center py-4"> <EmptyState Icon="bi-bell-slash" Message="No silences configured." />
<i class="bi bi-bell-slash text-muted" style="font-size: 2rem;"></i>
<p class="text-muted mt-2 mb-0">No silences configured.</p>
</div>
} }
else else
{ {
@@ -134,6 +119,8 @@
} }
} }
</LoadingPanel>
@* ── Create Silence Form ── *@ @* ── Create Silence Form ── *@
@if (showCreateSilence) @if (showCreateSilence)
{ {

View File

@@ -0,0 +1,481 @@
@using EntKube.Web.Data
@using EntKube.Web.Services
@using Microsoft.EntityFrameworkCore
@inject AlertRoutingService AlertRoutingService
@inject IDbContextFactory<ApplicationDbContext> DbFactory
<div class="mb-3">
<p class="text-muted small mb-2">
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.
</p>
</div>
<div class="d-flex justify-content-between align-items-center mb-3">
<h6 class="mb-0">Routing Rules</h6>
<button class="btn btn-sm btn-primary" @onclick="() => { showForm = !showForm; editId = null; ResetForm(); }">
<span class="bi bi-plus-circle me-1"></span>Add Rule
</button>
</div>
@if (showForm)
{
<div class="card border-primary mb-4">
<div class="card-header py-2">
<strong class="small">@(editId.HasValue ? "Edit Rule" : "New Rule")</strong>
</div>
<div class="card-body">
<div class="row g-2 mb-2">
<div class="col-md-4">
<label class="form-label small">Rule name <span class="text-danger">*</span></label>
<input class="form-control form-control-sm" @bind="formName" placeholder="Suppress KubeProxy on autoscale cluster" />
</div>
<div class="col-md-2">
<label class="form-label small">Priority</label>
<input type="number" class="form-control form-control-sm" @bind="formPriority" min="0" max="999" />
</div>
<div class="col-md-3">
<label class="form-label small">Scope to cluster</label>
<select class="form-select form-select-sm" @bind="formMatchClusterId">
<option value="">Any cluster</option>
@foreach (KubernetesCluster c in clusters)
{
<option value="@c.Id">@c.Name</option>
}
</select>
</div>
<div class="col-md-3 d-flex flex-column justify-content-end">
<div class="form-check mb-1">
<input class="form-check-input" type="checkbox" id="chkSuppress" @bind="formSuppressIncident" />
<label class="form-check-label small fw-semibold" for="chkSuppress">
Suppress incident
</label>
</div>
<div class="form-check">
<input class="form-check-input" type="checkbox" id="chkEnabled" @bind="formEnabled" />
<label class="form-check-label small" for="chkEnabled">Enabled</label>
</div>
</div>
</div>
@if (formSuppressIncident)
{
<div class="alert alert-warning py-2 mb-2 small">
<span class="bi bi-slash-circle me-1"></span>
Matching alerts will be silently dropped — no incident is created and no notification is sent.
No channel needed.
</div>
}
else
{
<div class="row g-2 mb-2">
<div class="col-md-6">
<label class="form-label small">Channel <span class="text-danger">*</span></label>
<select class="form-select form-select-sm" @bind="formChannelId">
<option value="">— select channel —</option>
@foreach (NotificationChannel ch in channels)
{
<option value="@ch.Id">@ch.Name (@ch.Type)</option>
}
</select>
</div>
</div>
}
<div class="row g-2 mb-3">
<div class="col-md-3">
<label class="form-label small text-muted">Match alert name (contains)</label>
<input class="form-control form-control-sm font-monospace" @bind="formAlertName" placeholder="KubeProxy…" />
</div>
<div class="col-md-2">
<label class="form-label small text-muted">Match severity</label>
<select class="form-select form-select-sm" @bind="formSeverity">
<option value="">Any</option>
<option value="critical">critical</option>
<option value="warning">warning</option>
<option value="info">info</option>
</select>
</div>
<div class="col-md-3">
<label class="form-label small text-muted">Match namespace</label>
<input class="form-control form-control-sm font-monospace" @bind="formNamespace" placeholder="kube-system" />
</div>
<div class="col-md-2">
<label class="form-label small text-muted">Label key</label>
<input class="form-control form-control-sm font-monospace" @bind="formLabelKey" placeholder="team" />
</div>
<div class="col-md-2">
<label class="form-label small text-muted">Label value</label>
<input class="form-control form-control-sm font-monospace" @bind="formLabelValue" placeholder="backend" />
</div>
</div>
@if (!string.IsNullOrEmpty(formError))
{
<div class="alert alert-danger py-1 small mb-2">@formError</div>
}
<div class="d-flex gap-2">
<button class="btn btn-sm btn-primary" @onclick="SaveRule"
disabled="@(string.IsNullOrWhiteSpace(formName) || (!formSuppressIncident && string.IsNullOrWhiteSpace(formChannelId)))">
@(editId.HasValue ? "Save Changes" : "Create Rule")
</button>
<button class="btn btn-sm btn-outline-secondary" @onclick="CancelForm">Cancel</button>
</div>
</div>
</div>
}
@if (rules.Count == 0 && !isLoading)
{
<EmptyState Icon="bi-funnel"
Title="No routing rules"
Message="Without rules, alerts are delivered to channels based on the channel's severity filter setting." />
}
else
{
<div class="table-responsive">
<table class="table table-hover align-middle">
<thead class="table-light">
<tr>
<th style="width:60px">Priority</th>
<th>Name</th>
<th>Action</th>
<th>Matches</th>
<th style="width:80px">Status</th>
<th style="width:100px"></th>
</tr>
</thead>
<tbody>
@foreach (AlertRoutingRule rule in rules)
{
<tr>
<td class="text-muted small font-monospace">@rule.Priority</td>
<td class="fw-semibold small">@rule.Name</td>
<td class="small">
@if (rule.SuppressIncident)
{
<span class="badge bg-danger"><span class="bi bi-slash-circle me-1"></span>Suppress</span>
}
else
{
<span class="bi bi-send me-1 text-muted"></span>@rule.Channel?.Name
}
</td>
<td>
<div class="d-flex flex-wrap gap-1">
@if (rule.MatchClusterId.HasValue)
{
string clusterName = clusters.FirstOrDefault(c => c.Id == rule.MatchClusterId)?.Name ?? rule.MatchClusterId.Value.ToString("D")[..8];
<span class="badge bg-secondary small font-monospace">cluster=@clusterName</span>
}
@if (!string.IsNullOrEmpty(rule.MatchAlertName))
{
<span class="badge bg-light text-dark border small font-monospace">alert~@rule.MatchAlertName</span>
}
@if (!string.IsNullOrEmpty(rule.MatchSeverity))
{
string sevColor = rule.MatchSeverity == "critical" ? "danger" : rule.MatchSeverity == "warning" ? "warning" : "info";
<span class="badge bg-@sevColor @(rule.MatchSeverity == "warning" ? "text-dark" : "") small">@rule.MatchSeverity</span>
}
@if (!string.IsNullOrEmpty(rule.MatchNamespace))
{
<span class="badge bg-light text-dark border small font-monospace">ns=@rule.MatchNamespace</span>
}
@if (!string.IsNullOrEmpty(rule.MatchLabelKey))
{
<span class="badge bg-light text-dark border small font-monospace">
@rule.MatchLabelKey@(!string.IsNullOrEmpty(rule.MatchLabelValue) ? $"={rule.MatchLabelValue}" : "")
</span>
}
@if (!rule.MatchClusterId.HasValue && string.IsNullOrEmpty(rule.MatchAlertName) && string.IsNullOrEmpty(rule.MatchSeverity)
&& string.IsNullOrEmpty(rule.MatchNamespace) && string.IsNullOrEmpty(rule.MatchLabelKey))
{
<span class="text-muted small">catch-all</span>
}
</div>
</td>
<td>
@if (rule.IsEnabled)
{
<span class="badge bg-success">Enabled</span>
}
else
{
<span class="badge bg-secondary">Disabled</span>
}
</td>
<td>
<div class="d-flex gap-1">
<button class="btn btn-sm btn-outline-secondary" @onclick="() => StartEdit(rule)"
title="Edit rule">
<span class="bi bi-pencil"></span>
</button>
<button class="btn btn-sm btn-outline-danger" @onclick="() => DeleteRule(rule)"
title="Delete rule">
<span class="bi bi-trash"></span>
</button>
</div>
</td>
</tr>
}
</tbody>
</table>
</div>
}
@* ── Test routing rules ── *@
@if (rules.Count > 0)
{
<div class="card mt-4 border-secondary">
<div class="card-header py-2 d-flex justify-content-between align-items-center">
<strong class="small"><span class="bi bi-play-circle me-1"></span>Test Routing</strong>
<button class="btn btn-sm btn-outline-secondary" @onclick="() => showTest = !showTest">
@(showTest ? "Hide" : "Test")
</button>
</div>
@if (showTest)
{
<div class="card-body">
<p class="text-muted small mb-3">Enter alert attributes to see which rule would match.</p>
<div class="row g-2 mb-3">
<div class="col-md-3">
<label class="form-label small">Cluster</label>
<select class="form-select form-select-sm" @bind="testClusterId">
<option value="">Any / unspecified</option>
@foreach (KubernetesCluster c in clusters)
{
<option value="@c.Id">@c.Name</option>
}
</select>
</div>
<div class="col-md-3">
<label class="form-label small">Alert name</label>
<input class="form-control form-control-sm font-monospace" @bind="testAlertName" placeholder="KubePodCrashLooping" />
</div>
<div class="col-md-2">
<label class="form-label small">Severity</label>
<select class="form-select form-select-sm" @bind="testSeverity">
<option value="">—</option>
<option value="critical">critical</option>
<option value="warning">warning</option>
<option value="info">info</option>
</select>
</div>
<div class="col-md-2">
<label class="form-label small">Namespace</label>
<input class="form-control form-control-sm font-monospace" @bind="testNamespace" placeholder="kube-system" />
</div>
<div class="col-md-1">
<label class="form-label small">Label key</label>
<input class="form-control form-control-sm font-monospace" @bind="testLabelKey" />
</div>
<div class="col-md-1">
<label class="form-label small">Label value</label>
<input class="form-control form-control-sm font-monospace" @bind="testLabelValue" />
</div>
</div>
<button class="btn btn-sm btn-secondary mb-3" @onclick="RunTest">
<span class="bi bi-play-fill me-1"></span>Test
</button>
@if (testRan)
{
@if (testMatchedRule is not null && testMatchedRule.SuppressIncident)
{
<div class="alert alert-danger py-2 mb-0">
<span class="bi bi-slash-circle me-1"></span>
Matched suppression rule <strong>@testMatchedRule.Name</strong>
(priority @testMatchedRule.Priority) — <strong>no incident would be created</strong>.
</div>
}
else if (testMatchedRule is not null)
{
<div class="alert alert-success py-2 mb-0">
<span class="bi bi-check-circle me-1"></span>
Matched rule <strong>@testMatchedRule.Name</strong>
(priority @testMatchedRule.Priority)
→ channel <strong>@testMatchedRule.Channel?.Name</strong>
</div>
}
else
{
<div class="alert alert-secondary py-2 mb-0">
<span class="bi bi-funnel me-1"></span>
No rule matched — alert would be sent to <strong>all enabled channels</strong>
(filtered by each channel's severity setting).
</div>
}
}
</div>
}
</div>
}
@code {
[Parameter] public required Guid TenantId { get; set; }
private List<AlertRoutingRule> rules = [];
private List<NotificationChannel> channels = [];
private List<KubernetesCluster> 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<string, string>? 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;
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -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).
═══════════════════════════════════════════════════════════════════ *@
<div class="card shadow-sm">
<div class="card-header bg-white py-2 d-flex align-items-center justify-content-between">
<div class="d-flex align-items-center">
<i class="bi bi-globe me-2 text-primary"></i>
<strong>External Access</strong>
</div>
@if (!IsPortalView)
{
<button class="btn btn-sm btn-outline-primary" @onclick="OpenAddRoute">
<i class="bi bi-plus me-1"></i>Add Hostname
</button>
}
</div>
@* ── Add new AppRoute form (ops only) ── *@
@if (!IsPortalView && showAddRoute)
{
<div class="card-body border-bottom">
@if (addRouteError is not null)
{
<div class="alert alert-danger py-1 small mb-2">@addRouteError</div>
}
<div class="row g-2">
<div class="col-md-5">
<label class="form-label small">Hostname</label>
<input class="form-control form-control-sm" placeholder="app.example.com"
@bind="newHostname" />
</div>
<div class="col-md-3">
<label class="form-label small">TLS Mode</label>
<select class="form-select form-select-sm" @bind="newTlsMode">
<option value="@TlsMode.ClusterIssuer">ClusterIssuer (auto)</option>
<option value="@TlsMode.Manual">Manual certificate</option>
</select>
</div>
@if (newTlsMode == TlsMode.ClusterIssuer)
{
@* Cluster selector drives the ClusterIssuer dropdown *@
<div class="col-md-4">
<label class="form-label small">Load issuers from cluster</label>
<select class="form-select form-select-sm" value="@issuerClusterId"
@onchange="OnIssuerClusterChanged">
<option value="">— pick cluster —</option>
@foreach (KubernetesCluster c in (tenantClusters ?? []))
{
<option value="@c.Id">@c.Name</option>
}
</select>
</div>
<div class="col-md-4">
<label class="form-label small">ClusterIssuer</label>
@if (loadingIssuers)
{
<div class="form-control form-control-sm text-muted small">
<span class="spinner-border spinner-border-sm me-1"></span>Loading…
</div>
}
else if (availableIssuers.Count > 0)
{
<select class="form-select form-select-sm" @bind="newClusterIssuer">
<option value="">— select —</option>
@foreach (string issuer in availableIssuers)
{
<option value="@issuer">@issuer</option>
}
</select>
}
else
{
<input class="form-control form-control-sm" placeholder="letsencrypt-prod"
@bind="newClusterIssuer" />
<div class="form-text small text-muted">
@(issuerClusterId == Guid.Empty ? "Pick a cluster above to load available issuers." : "No ClusterIssuers found — enter manually.")
</div>
}
</div>
}
else
{
<div class="col-12">
<label class="form-label small">Certificate (PEM)</label>
<textarea class="form-control form-control-sm font-monospace" rows="3"
placeholder="-----BEGIN CERTIFICATE-----" @bind="newTlsCert" />
</div>
<div class="col-12">
<label class="form-label small">Private Key (PEM, optional)</label>
<textarea class="form-control form-control-sm font-monospace" rows="3"
placeholder="-----BEGIN PRIVATE KEY-----" @bind="newTlsKey" />
</div>
}
</div>
<div class="d-flex gap-1 mt-2">
<button class="btn btn-sm btn-primary" @onclick="AddRoute"
disabled="@(string.IsNullOrWhiteSpace(newHostname) || addingRoute)">
@if (addingRoute) { <span class="spinner-border spinner-border-sm me-1"></span> }
Save
</button>
<button class="btn btn-sm btn-outline-secondary" @onclick="CancelAddRoute">Cancel</button>
</div>
</div>
}
@* ── Route list ── *@
<div class="card-body p-0">
@if (loadError is not null)
{
<div class="alert alert-danger m-3 py-2 small">
<i class="bi bi-exclamation-triangle me-1"></i>
Failed to load routes: @loadError
</div>
}
<LoadingPanel Loading="@(routes is null && loadError is null)">
@if (routes is not null && routes.Count == 0 && !showAddRoute)
{
<div class="p-3">
<EmptyState Icon="bi-globe"
Message="No hostnames configured. Click 'Add Hostname' to expose this app externally." />
</div>
}
else if (routes is not null)
{
@foreach (AppRoute route in routes)
{
bool isExpanded = expandedRouteId == route.Id;
<div class="border-bottom">
@* ── Route header ── *@
<div class="d-flex align-items-center px-3 py-2 gap-2"
style="cursor: pointer;" @onclick="() => ToggleExpand(route.Id)">
<i class="bi @(isExpanded ? "bi-chevron-down" : "bi-chevron-right") text-muted small"></i>
<i class="bi bi-globe2 text-primary"></i>
<a href="https://@route.Hostname" target="_blank" rel="noopener noreferrer"
class="fw-medium text-decoration-none" @onclick:stopPropagation="true">
@route.Hostname
</a>
@TlsBadge(route)
@if (!route.IsEnabled)
{
<span class="badge bg-secondary ms-1">Disabled</span>
}
<span class="badge bg-light text-dark border ms-1">
@route.DeploymentRoutes.Count deployment@(route.DeploymentRoutes.Count != 1 ? "s" : "")
</span>
@if (!IsPortalView)
{
<div class="ms-auto d-flex gap-1" @onclick:stopPropagation="true">
<button class="btn btn-sm btn-outline-secondary py-0"
title="Toggle enabled"
@onclick="() => ToggleRouteEnabled(route)">
<i class="bi @(route.IsEnabled ? "bi-toggle-on text-success" : "bi-toggle-off text-muted")"></i>
</button>
@if (deleteConfirmRouteId == route.Id)
{
<button class="btn btn-sm btn-danger py-0" @onclick="() => DeleteRoute(route.Id)">Confirm</button>
<button class="btn btn-sm btn-outline-secondary py-0" @onclick="() => deleteConfirmRouteId = null">Cancel</button>
}
else
{
<button class="btn btn-sm btn-outline-danger py-0"
title="Delete hostname and all deployment routes"
@onclick="() => deleteConfirmRouteId = route.Id">
<i class="bi bi-trash"></i>
</button>
}
</div>
}
</div>
@* ── Deployment routes (expanded) ── *@
@if (isExpanded)
{
<div class="px-4 pb-2">
@if (applyRouteError is not null)
{
<div class="alert alert-danger py-1 small mx-0 mb-2">
<i class="bi bi-exclamation-triangle me-1"></i>Apply failed: @applyRouteError
<button type="button" class="btn-close btn-close-sm float-end"
@onclick="() => applyRouteError = null"></button>
</div>
}
@if (route.DeploymentRoutes.Count > 0)
{
<table class="table table-sm table-hover mb-2">
<thead class="table-light">
<tr>
<th class="small">Deployment</th>
<th class="small">Env</th>
<th class="small">Path</th>
<th class="small">Service</th>
<th class="small">Cluster</th>
<th class="small">Health</th>
<th></th>
</tr>
</thead>
<tbody>
@foreach (AppDeploymentRoute dr in route.DeploymentRoutes.OrderBy(r => r.PathPrefix))
{
bool isApplying = applyingRouteId == dr.Id;
<tr>
<td class="small align-middle">@dr.AppDeployment.Name</td>
<td class="small align-middle text-muted">@dr.AppDeployment.Environment?.Name</td>
<td class="small align-middle"><code class="small">@dr.PathPrefix</code></td>
<td class="small align-middle text-muted">@dr.ServiceName:@dr.ServicePort</td>
<td class="small align-middle">@ClusterStatusBadge(dr)</td>
<td class="small align-middle">@HealthBadge(dr)</td>
<td class="text-end align-middle" style="white-space: nowrap;">
@if (dr.ClusterAppliedAt is null)
{
<button class="btn btn-sm btn-primary py-0"
title="Apply HTTPRoute to cluster"
disabled="@isApplying"
@onclick="() => ApplyRouteToCluster(dr.Id)">
@if (isApplying)
{
<span class="spinner-border spinner-border-sm me-1"></span>
}
else
{
<i class="bi bi-cloud-upload me-1"></i>
}
Apply to cluster
</button>
}
else
{
<button class="btn btn-sm btn-outline-secondary py-0"
title="Re-apply to cluster"
disabled="@isApplying"
@onclick="() => ApplyRouteToCluster(dr.Id)">
@if (isApplying)
{
<span class="spinner-border spinner-border-sm"></span>
}
else
{
<i class="bi bi-arrow-repeat"></i>
}
</button>
}
<button class="btn btn-sm btn-outline-secondary py-0 ms-1"
title="View manifest YAML"
@onclick="() => ShowYaml(dr)">
<i class="bi bi-code-slash"></i>
</button>
@if (deleteConfirmDrId == dr.Id)
{
<button class="btn btn-sm btn-danger py-0 ms-1"
@onclick="() => DeleteDeploymentRoute(dr.Id, route.Id)">Confirm</button>
<button class="btn btn-sm btn-outline-secondary py-0 ms-1"
@onclick="() => deleteConfirmDrId = null">Cancel</button>
}
else
{
<button class="btn btn-sm btn-outline-danger py-0 ms-1"
title="Remove deployment route"
@onclick="() => deleteConfirmDrId = dr.Id">
<i class="bi bi-x-lg"></i>
</button>
}
</td>
</tr>
}
</tbody>
</table>
}
@* ── Add deployment route form ── *@
@if (addDrRouteId == route.Id)
{
<div class="p-2 bg-light rounded mb-2">
@if (addDrError is not null)
{
<div class="alert alert-danger py-1 small mb-2">@addDrError</div>
}
<div class="row g-2">
@* Deployment selector — loading services when changed *@
<div class="col-md-4">
<label class="form-label small">Deployment</label>
<select class="form-select form-select-sm"
value="@newDrDeploymentId"
@onchange="OnDeploymentSelected">
<option value="">— select —</option>
@foreach (AppDeployment d in (deployments ?? []))
{
<option value="@d.Id">@d.Name (@d.Environment?.Name)</option>
}
</select>
</div>
@* Path prefix *@
<div class="col-md-2">
<label class="form-label small">Path Prefix</label>
<input class="form-control form-control-sm" placeholder="/"
@bind="newDrPath" />
</div>
@* Service name — dropdown from K8s or fallback text *@
<div class="col-md-3">
<label class="form-label small">
Service
@if (loadingServices)
{
<span class="spinner-border spinner-border-sm ms-1" style="width:.7rem;height:.7rem;"></span>
}
</label>
@if (availableServices.Count > 0)
{
<select class="form-select form-select-sm"
value="@newDrService"
@onchange="OnServiceSelected">
<option value="">— select —</option>
@foreach (KubeServiceInfo svc in availableServices)
{
<option value="@svc.Name">@svc.Name (@svc.Type)</option>
}
</select>
}
else
{
<input class="form-control form-control-sm" placeholder="my-service"
@bind="newDrService" />
}
</div>
@* Port — dropdown from selected service or fallback number *@
<div class="col-md-2">
<label class="form-label small">Port</label>
@if (availablePorts.Count > 0)
{
<select class="form-select form-select-sm" @bind="newDrPort">
@foreach (KubeServicePort p in availablePorts)
{
<option value="@p.Port">@p.Port@(p.Name is not null ? $" ({p.Name})" : "")</option>
}
</select>
}
else
{
<input type="number" class="form-control form-control-sm"
@bind="newDrPort" />
}
</div>
</div>
@* Endpoint health preview *@
@if (endpointSummary is not null && endpointSummary.HasEndpoints)
{
<div class="mt-1 small">
<span class="text-success me-2">
<i class="bi bi-circle-fill me-1" style="font-size:.5rem;vertical-align:middle;"></i>
@endpointSummary.TotalReady ready
</span>
@if (endpointSummary.TotalNotReady > 0)
{
<span class="text-warning">
<i class="bi bi-circle-fill me-1" style="font-size:.5rem;vertical-align:middle;"></i>
@endpointSummary.TotalNotReady not ready
</span>
}
</div>
}
else if (endpointSummary is not null && !endpointSummary.HasEndpoints && !string.IsNullOrEmpty(newDrService))
{
<div class="mt-1 small text-warning">
<i class="bi bi-exclamation-triangle me-1"></i>No endpoints — service has no ready pods.
</div>
}
<div class="d-flex gap-1 mt-2">
<button class="btn btn-sm btn-primary"
disabled="@(newDrDeploymentId == Guid.Empty || string.IsNullOrWhiteSpace(newDrService) || addingDr)"
@onclick="() => AddDeploymentRoute(route.Id)">
@if (addingDr) { <span class="spinner-border spinner-border-sm me-1"></span> }
Link Deployment
</button>
<button class="btn btn-sm btn-outline-secondary" @onclick="CancelAddDr">Cancel</button>
</div>
</div>
}
else
{
<button class="btn btn-sm btn-outline-secondary mb-1"
@onclick="() => StartAddDr(route.Id)">
<i class="bi bi-plus me-1"></i>Link Deployment
</button>
}
</div>
}
</div>
}
}
</LoadingPanel>
</div>
</div>
@* ── YAML preview modal ── *@
@if (yamlPreview is not null)
{
<div class="modal fade show d-block" tabindex="-1" style="background: rgba(0,0,0,0.4);">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header py-2">
<h6 class="modal-title mb-0">
<i class="bi bi-code-slash me-2"></i>HTTPRoute Manifest
</h6>
<button type="button" class="btn-close" @onclick="() => yamlPreview = null"></button>
</div>
<div class="modal-body p-0">
<pre class="m-0 p-3 bg-dark text-white small"
style="max-height: 400px; overflow-y: auto;"><code>@yamlPreview</code></pre>
</div>
<div class="modal-footer py-2">
<small class="text-muted me-auto">Apply this to the target cluster.</small>
<button class="btn btn-sm btn-secondary" @onclick="() => yamlPreview = null">Close</button>
</div>
</div>
</div>
</div>
}
@code {
[Parameter] public required Data.App App { get; set; }
[Parameter] public Guid TenantId { get; set; }
[Parameter] public Guid? EnvironmentId { get; set; }
[Parameter] public bool IsPortalView { get; set; }
private List<AppRoute>? routes;
private List<AppDeployment>? deployments;
private List<KubernetesCluster>? tenantClusters;
// Add route form
private bool showAddRoute;
private string newHostname = "";
private TlsMode newTlsMode = TlsMode.ClusterIssuer;
private string newClusterIssuer = "";
private string newTlsCert = "";
private string newTlsKey = "";
private string? addRouteError;
private bool addingRoute;
// ClusterIssuer selector
private Guid issuerClusterId;
private bool loadingIssuers;
private List<string> availableIssuers = [];
// Deployment route form
private Guid? addDrRouteId;
private Guid newDrDeploymentId;
private string newDrPath = "/";
private string newDrService = "";
private int newDrPort = 80;
private string? addDrError;
private bool addingDr;
// Service / endpoint discovery
private bool loadingServices;
private List<KubeServiceInfo> availableServices = [];
private List<KubeServicePort> availablePorts = [];
private KubeEndpointSummary? endpointSummary;
// Expand / delete state
private Guid? expandedRouteId;
private Guid? deleteConfirmRouteId;
private Guid? deleteConfirmDrId;
// Apply to cluster
private Guid? applyingRouteId;
private string? applyRouteError;
// YAML preview
private string? yamlPreview;
private string? loadError;
protected override async Task OnParametersSetAsync()
{
loadError = null;
try
{
await LoadAsync();
tenantClusters ??= await TenantService.GetClustersAsync(TenantId);
}
catch (Exception ex)
{
loadError = ex.Message;
}
}
private async Task LoadAsync()
{
List<AppRoute> allRoutes = await AppRouteService.GetRoutesForAppAsync(App.Id);
List<AppDeployment> allDeployments = await DeploymentService.GetDeploymentsAsync(App.Id);
if (EnvironmentId.HasValue)
{
HashSet<Guid> envDeploymentIds = allDeployments
.Where(d => d.EnvironmentId == EnvironmentId.Value)
.Select(d => d.Id)
.ToHashSet();
routes = allRoutes
.Where(r => r.DeploymentRoutes.Any(dr => envDeploymentIds.Contains(dr.AppDeploymentId))
|| r.DeploymentRoutes.Count == 0)
.ToList();
deployments = allDeployments.Where(d => d.EnvironmentId == EnvironmentId.Value).ToList();
}
else
{
routes = allRoutes;
deployments = allDeployments;
}
}
private void OpenAddRoute()
{
showAddRoute = !showAddRoute;
if (showAddRoute)
{
newHostname = "";
newTlsMode = TlsMode.ClusterIssuer;
newClusterIssuer = "";
newTlsCert = "";
newTlsKey = "";
addRouteError = null;
issuerClusterId = Guid.Empty;
availableIssuers = [];
}
}
private async Task OnIssuerClusterChanged(ChangeEventArgs e)
{
if (!Guid.TryParse(e.Value?.ToString(), out Guid clusterId) || clusterId == Guid.Empty)
{
issuerClusterId = Guid.Empty;
availableIssuers = [];
return;
}
issuerClusterId = clusterId;
loadingIssuers = true;
availableIssuers = [];
newClusterIssuer = "";
availableIssuers = await ComponentLifecycleService.ListClusterIssuersAsync(clusterId);
if (availableIssuers.Count == 1)
newClusterIssuer = availableIssuers[0];
loadingIssuers = false;
}
private async Task AddRoute()
{
addRouteError = null;
addingRoute = true;
try
{
await AppRouteService.AddRouteAsync(App.Id, new AppRouteRequest
{
Hostname = newHostname,
TlsMode = newTlsMode,
ClusterIssuerName = newTlsMode == TlsMode.ClusterIssuer ? newClusterIssuer : null,
TlsCertificate = newTlsMode == TlsMode.Manual ? newTlsCert : null,
TlsPrivateKey = newTlsMode == TlsMode.Manual ? newTlsKey : null,
});
showAddRoute = false;
await LoadAsync();
}
catch (Exception ex)
{
addRouteError = ex.Message;
}
finally
{
addingRoute = false;
}
}
private void CancelAddRoute()
{
showAddRoute = false;
addRouteError = null;
}
private async Task ToggleRouteEnabled(AppRoute route)
{
await AppRouteService.UpdateRouteAsync(route.Id, new AppRouteRequest
{
Hostname = route.Hostname,
TlsMode = route.TlsMode,
ClusterIssuerName = route.ClusterIssuerName,
TlsCertificate = route.TlsCertificate,
TlsPrivateKey = route.TlsPrivateKey,
IsEnabled = !route.IsEnabled
});
await LoadAsync();
}
private async Task DeleteRoute(Guid routeId)
{
await AppRouteService.DeleteRouteAsync(routeId);
deleteConfirmRouteId = null;
if (expandedRouteId == routeId) expandedRouteId = null;
await LoadAsync();
}
private void ToggleExpand(Guid routeId)
{
expandedRouteId = expandedRouteId == routeId ? null : routeId;
}
private async Task StartAddDr(Guid routeId)
{
addDrRouteId = routeId;
newDrDeploymentId = Guid.Empty;
newDrPath = "/";
newDrService = "";
newDrPort = 80;
addDrError = null;
availableServices = [];
availablePorts = [];
endpointSummary = null;
// If there's only one deployment, pre-select it and load its services.
if (deployments?.Count == 1)
await SelectDeployment(deployments[0].Id);
}
private void CancelAddDr()
{
addDrRouteId = null;
addDrError = null;
availableServices = [];
availablePorts = [];
endpointSummary = null;
}
private async Task OnDeploymentSelected(ChangeEventArgs e)
{
if (!Guid.TryParse(e.Value?.ToString(), out Guid id) || id == Guid.Empty)
{
newDrDeploymentId = Guid.Empty;
availableServices = [];
availablePorts = [];
endpointSummary = null;
return;
}
await SelectDeployment(id);
}
private async Task SelectDeployment(Guid deploymentId)
{
newDrDeploymentId = deploymentId;
newDrService = "";
availablePorts = [];
endpointSummary = null;
AppDeployment? deployment = deployments?.FirstOrDefault(d => d.Id == deploymentId);
if (deployment?.Cluster is null) return;
loadingServices = true;
availableServices = await K8sOps.GetServicesInNamespaceAsync(deployment.Cluster.Id, deployment.Namespace);
loadingServices = false;
// Auto-select if there's only one service.
if (availableServices.Count == 1)
await SelectService(availableServices[0].Name, deploymentId, deployment.Namespace, deployment.Cluster.Id);
}
private async Task OnServiceSelected(ChangeEventArgs e)
{
string svcName = e.Value?.ToString() ?? "";
newDrService = svcName;
availablePorts = [];
endpointSummary = null;
if (string.IsNullOrEmpty(svcName)) return;
AppDeployment? deployment = deployments?.FirstOrDefault(d => d.Id == newDrDeploymentId);
if (deployment?.Cluster is null) return;
await SelectService(svcName, newDrDeploymentId, deployment.Namespace, deployment.Cluster.Id);
}
private async Task SelectService(string serviceName, Guid deploymentId, string ns, Guid clusterId)
{
newDrService = serviceName;
KubeServiceInfo? svc = availableServices.FirstOrDefault(s => s.Name == serviceName);
availablePorts = svc?.Ports ?? [];
if (availablePorts.Count > 0) newDrPort = availablePorts[0].Port;
endpointSummary = await K8sOps.GetEndpointsForServiceAsync(clusterId, ns, serviceName);
}
private async Task AddDeploymentRoute(Guid routeId)
{
addDrError = null;
addingDr = true;
try
{
await AppRouteService.AddDeploymentRouteAsync(routeId, newDrDeploymentId, new AppDeploymentRouteRequest
{
ServiceName = newDrService,
ServicePort = newDrPort,
PathPrefix = newDrPath
});
CancelAddDr();
await LoadAsync();
}
catch (Exception ex)
{
addDrError = ex.Message;
}
finally
{
addingDr = false;
}
}
private async Task DeleteDeploymentRoute(Guid drId, Guid routeId)
{
await K8sOps.DeleteDeploymentRouteFromClusterAsync(drId);
await AppRouteService.DeleteDeploymentRouteAsync(drId);
deleteConfirmDrId = null;
await LoadAsync();
}
private async Task ApplyRouteToCluster(Guid drId)
{
applyingRouteId = drId;
applyRouteError = null;
try
{
KubernetesOperationResult<string> result = await K8sOps.ApplyDeploymentRouteAsync(drId);
if (!result.IsSuccess)
applyRouteError = result.Error;
await LoadAsync();
}
catch (Exception ex) { applyRouteError = ex.Message; }
finally { applyingRouteId = null; }
}
private void ShowYaml(AppDeploymentRoute dr)
{
yamlPreview = AppRouteService.GenerateManifestYaml(dr);
}
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 via {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 ClusterStatusBadge(AppDeploymentRoute dr) => __builder =>
{
if (dr.ClusterAppliedAt is null)
{
__builder.OpenElement(0, "span");
__builder.AddAttribute(1, "class", "badge bg-warning text-dark small");
__builder.AddAttribute(2, "title", "HTTPRoute not yet applied to cluster");
__builder.AddContent(3, "not applied");
__builder.CloseElement();
}
else
{
__builder.OpenElement(0, "span");
__builder.AddAttribute(1, "class", "badge bg-success bg-opacity-75 small");
__builder.AddAttribute(2, "title", $"Applied {dr.ClusterAppliedAt:yyyy-MM-dd HH:mm} UTC");
__builder.AddContent(3, $"applied {dr.ClusterAppliedAt:dd MMM HH:mm}");
__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, "—");
__builder.CloseElement();
}
};
}

View File

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

View File

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

View File

@@ -73,6 +73,11 @@
<i class="bi bi-heart-pulse me-1"></i>Monitoring <i class="bi bi-heart-pulse me-1"></i>Monitoring
</button> </button>
</li> </li>
<li class="nav-item">
<button class="nav-link @(section == "nodes" ? "active" : "")" @onclick='() => section = "nodes"'>
<i class="bi bi-hdd-stack me-1"></i>Nodes
</button>
</li>
<li class="nav-item"> <li class="nav-item">
<button class="nav-link @(section == "components" ? "active" : "")" @onclick='() => section = "components"'> <button class="nav-link @(section == "components" ? "active" : "")" @onclick='() => section = "components"'>
<i class="bi bi-puzzle me-1"></i>Components <i class="bi bi-puzzle me-1"></i>Components
@@ -89,6 +94,10 @@
{ {
<ClusterMonitoring ClusterId="Cluster.Id" /> <ClusterMonitoring ClusterId="Cluster.Id" />
} }
else if (section == "nodes")
{
<ClusterNodes ClusterId="Cluster.Id" />
}
else if (section == "logs") else if (section == "logs")
{ {
<LogBrowser ClusterId="Cluster.Id" /> <LogBrowser ClusterId="Cluster.Id" />
@@ -227,9 +236,7 @@ else if (section == "components")
</div> </div>
<div class="mb-2"> <div class="mb-2">
<label class="form-label small mb-0">Helm values (YAML)</label> <label class="form-label small mb-0">Helm values (YAML)</label>
<textarea class="form-control form-control-sm font-monospace" rows="4" <YamlEditor @bind-Value="regValues" />
placeholder="# Custom values..."
@bind="regValues" @bind:event="oninput"></textarea>
</div> </div>
<button class="btn btn-sm btn-success" @onclick="RegisterCustomComponent" <button class="btn btn-sm btn-success" @onclick="RegisterCustomComponent"
disabled="@(string.IsNullOrWhiteSpace(regName) || string.IsNullOrWhiteSpace(regChartName))"> disabled="@(string.IsNullOrWhiteSpace(regName) || string.IsNullOrWhiteSpace(regChartName))">
@@ -434,8 +441,7 @@ else if (section == "components")
@if (showAdvancedYaml) @if (showAdvancedYaml)
{ {
<div class="accordion-body p-2"> <div class="accordion-body p-2">
<textarea class="form-control form-control-sm font-monospace" rows="12" <YamlEditor @bind-Value="regValues" />
@bind="regValues" @bind:event="oninput"></textarea>
</div> </div>
} }
</div> </div>
@@ -779,6 +785,15 @@ else if (section == "components")
<i class="bi bi-sliders me-1"></i>Configure <i class="bi bi-sliders me-1"></i>Configure
</button> </button>
</li> </li>
<li class="nav-item">
<button class="nav-link py-1 px-2 @(compDetailTab == "values" ? "active" : "")" @onclick='() => compDetailTab = "values"'>
<i class="bi bi-code-slash me-1"></i>Values
@if (!string.IsNullOrWhiteSpace(editValues))
{
<span class="badge bg-secondary ms-1">customized</span>
}
</button>
</li>
<li class="nav-item"> <li class="nav-item">
<button class="nav-link py-1 px-2 @(compDetailTab == "secrets" ? "active" : "")" @onclick="() => ShowSecrets(comp.Id)"> <button class="nav-link py-1 px-2 @(compDetailTab == "secrets" ? "active" : "")" @onclick="() => ShowSecrets(comp.Id)">
<i class="bi bi-key me-1"></i>Secrets <i class="bi bi-key me-1"></i>Secrets
@@ -963,47 +978,10 @@ else if (section == "components")
</div> </div>
</div> </div>
@* Advanced YAML editor *@
<div class="accordion mb-3">
<div class="accordion-item border-0 bg-light rounded">
<h2 class="accordion-header">
<button class="accordion-button collapsed py-2 px-3 small bg-light rounded" type="button"
@onclick="() => showAdvancedYaml = !showAdvancedYaml">
<i class="bi bi-code-slash me-1"></i>Values YAML
@if (!string.IsNullOrWhiteSpace(editValues))
{
<span class="badge bg-secondary ms-2">customized</span>
} }
</button> else if (compDetailTab == "values")
</h2>
@if (showAdvancedYaml)
{ {
<div class="p-3"> <YamlEditor @bind-Value="editValues" />
<textarea class="form-control form-control-sm font-monospace" rows="10"
@bind="editValues" @bind:event="oninput"></textarea>
</div>
}
</div>
</div>
@* Action bar *@
<div class="d-flex align-items-center gap-2 pt-2 border-top">
<button class="btn btn-sm btn-primary" @onclick="() => SaveConfiguration(comp.Id)">
<i class="bi bi-check-lg me-1"></i>Save
</button>
@if (comp.Status == ComponentStatus.NotInstalled || comp.Status == ComponentStatus.Failed)
{
<button class="btn btn-sm btn-success" @onclick="() => SaveAndInstall(comp.Id)">
<i class="bi bi-play-fill me-1"></i>Save &amp; Install
</button>
}
else if (comp.Status == ComponentStatus.Installed)
{
<button class="btn btn-sm btn-success" @onclick="() => SaveAndApply(comp.Id)">
<i class="bi bi-arrow-clockwise me-1"></i>Save &amp; Apply
</button>
}
</div>
} }
else if (compDetailTab == "secrets") else if (compDetailTab == "secrets")
{ {
@@ -1325,6 +1303,27 @@ else if (section == "components")
} }
</div> </div>
} }
@if (compDetailTab is "config" or "values")
{
<div class="d-flex align-items-center gap-2 pt-2 mt-2 border-top">
<button class="btn btn-sm btn-primary" @onclick="() => SaveConfiguration(comp.Id)">
<i class="bi bi-check-lg me-1"></i>Save
</button>
@if (comp.Status == ComponentStatus.NotInstalled || comp.Status == ComponentStatus.Failed)
{
<button class="btn btn-sm btn-success" @onclick="() => SaveAndInstall(comp.Id)">
<i class="bi bi-play-fill me-1"></i>Save &amp; Install
</button>
}
else if (comp.Status == ComponentStatus.Installed)
{
<button class="btn btn-sm btn-success" @onclick="() => SaveAndApply(comp.Id)">
<i class="bi bi-arrow-clockwise me-1"></i>Save &amp; Apply
</button>
}
</div>
}
</div> </div>
} }
</div> </div>
@@ -1788,6 +1787,16 @@ else if (section == "components")
editFormFieldValues["admin-username"] = harborConfig.AdminUsername; editFormFieldValues["admin-username"] = harborConfig.AdminUsername;
} }
// Load Loki storage link ID from the component's Configuration JSON.
if (catalogMatch?.Key == "loki")
{
Guid? lokiStorageLinkId = await LokiService.GetStorageLinkIdForComponentAsync(TenantId, componentId);
if (lokiStorageLinkId.HasValue)
{
editFormFieldValues["storage-link"] = lokiStorageLinkId.Value.ToString();
}
}
// Pre-populate hostname and TLS fields from the existing external route. // Pre-populate hostname and TLS fields from the existing external route.
List<ExternalRoute> routes = await RouteService.GetRoutesAsync(componentId); List<ExternalRoute> routes = await RouteService.GetRoutesAsync(componentId);
ExternalRoute? route = routes.FirstOrDefault(); ExternalRoute? route = routes.FirstOrDefault();
@@ -2639,10 +2648,11 @@ else if (section == "components")
continue; continue;
} }
// cnpg:/harbor: fields are not in YAML — loaded from component configs in ToggleComponentDetail. // cnpg:/harbor:/loki: fields are not in YAML — loaded from component configs in ToggleComponentDetail.
if (field.YamlPath.StartsWith("cnpg:", StringComparison.Ordinal) if (field.YamlPath.StartsWith("cnpg:", StringComparison.Ordinal)
|| field.YamlPath.StartsWith("harbor:", StringComparison.Ordinal)) || field.YamlPath.StartsWith("harbor:", StringComparison.Ordinal)
|| field.YamlPath.StartsWith("loki:", StringComparison.Ordinal))
{ {
continue; continue;
} }

View File

@@ -0,0 +1,848 @@
@using EntKube.Web.Data
@using EntKube.Web.Services
@inject NodeManagementService NodeService
@inject AuthenticationStateProvider AuthState
@* ═══════════════════════════════════════════════════════════════════
ClusterNodes — live node management + server inventory panel.
Shows all K8s nodes with cordon/drain/uncordon actions, detailed
condition/label/taint inspection, pods-per-node, and the server
inventory records that back each node.
═══════════════════════════════════════════════════════════════════ *@
@if (loading)
{
<div class="text-center py-5 text-muted">
<div class="spinner-border spinner-border-sm me-2"></div>Loading nodes…
</div>
}
else if (loadError is not null)
{
<div class="alert alert-warning d-flex align-items-center gap-2">
<i class="bi bi-exclamation-triangle-fill"></i>
<span>@loadError</span>
<button class="btn btn-sm btn-outline-secondary ms-auto" @onclick="LoadAsync">Retry</button>
</div>
}
else
{
<div class="d-flex align-items-center justify-content-between mb-3">
<span class="text-muted small">@nodes.Count node@(nodes.Count == 1 ? "" : "s") in cluster</span>
<button class="btn btn-sm btn-outline-secondary" @onclick="LoadAsync" disabled="@loading">
<i class="bi bi-arrow-clockwise me-1"></i>Refresh
</button>
</div>
@* ── Node table ──────────────────────────────────────────── *@
<div class="list-group list-group-flush mb-4">
@foreach (NodeInfo node in nodes)
{
bool expanded = expandedNode == node.Name;
ClusterServer? server = servers.FirstOrDefault(s => s.NodeName == node.Name);
<div class="list-group-item p-0 border rounded mb-2 shadow-sm">
@* ── Row header ── *@
<div class="d-flex align-items-center gap-2 p-3" style="cursor:pointer"
@onclick="() => ToggleExpand(node.Name)">
@* Status dot *@
<span class="@(node.Ready ? "text-success" : "text-danger")" title="@(node.Ready ? "Ready" : "Not Ready")">
<i class="bi @(node.Ready ? "bi-circle-fill" : "bi-exclamation-circle-fill")"></i>
</span>
<span class="fw-semibold">@node.Name</span>
@* Schedulability badge *@
@if (!node.Schedulable)
{
<span class="badge bg-warning text-dark">cordoned</span>
}
@* Roles *@
@foreach (string role in node.Roles)
{
<span class="badge bg-secondary bg-opacity-10 text-secondary border">@role</span>
}
@* Taints indicator *@
@if (node.Taints.Count > 0)
{
<span class="badge bg-warning bg-opacity-10 text-warning border border-warning"
title="@string.Join(", ", node.Taints.Select(t => $"{t.Key}={t.Value}:{t.Effect}"))">
<i class="bi bi-shield-exclamation me-1"></i>@node.Taints.Count taint@(node.Taints.Count == 1 ? "" : "s")
</span>
}
@* OS + version *@
<span class="text-muted small ms-auto">@node.OsImage</span>
<span class="text-muted small">@node.KubeletVersion</span>
@* Server link icon *@
@if (server is not null)
{
<i class="bi bi-server text-primary" title="Server: @server.DisplayName"></i>
}
<i class="bi @(expanded ? "bi-chevron-up" : "bi-chevron-down") text-muted ms-1"></i>
</div>
@* ── Action buttons (visible without expanding) ── *@
<div class="d-flex gap-2 px-3 pb-3 pt-0">
@if (node.Schedulable)
{
<button class="btn btn-sm btn-outline-warning"
@onclick="() => CordonAsync(node.Name)"
@onclick:stopPropagation
disabled="@IsBusy(node.Name)">
<i class="bi bi-slash-circle me-1"></i>Cordon
</button>
}
else
{
<button class="btn btn-sm btn-outline-success"
@onclick="() => UncordonAsync(node.Name)"
@onclick:stopPropagation
disabled="@IsBusy(node.Name)">
<i class="bi bi-check-circle me-1"></i>Uncordon
</button>
}
@if (drainConfirmNode == node.Name)
{
<div class="d-flex align-items-center gap-2" @onclick:stopPropagation>
<span class="text-danger small"><i class="bi bi-exclamation-triangle me-1"></i>Evicts all pods from <strong>@node.Name</strong>. Continue?</span>
<button class="btn btn-sm btn-danger" @onclick="() => DrainConfirmedAsync(node.Name)" disabled="@IsBusy(node.Name)">Drain</button>
<button class="btn btn-sm btn-outline-secondary" @onclick="() => drainConfirmNode = null">Cancel</button>
</div>
}
else
{
<button class="btn btn-sm btn-outline-danger"
@onclick="() => drainConfirmNode = node.Name"
@onclick:stopPropagation
disabled="@IsBusy(node.Name)">
<i class="bi bi-water me-1"></i>Drain
</button>
}
@if (IsBusy(node.Name))
{
<span class="text-muted small align-self-center">
<span class="spinner-border spinner-border-sm me-1"></span>@busyMessage
</span>
}
@if (actionResults.TryGetValue(node.Name, out (bool Ok, string Msg) r))
{
<span class="small align-self-center @(r.Ok ? "text-success" : "text-danger")">
<i class="bi @(r.Ok ? "bi-check-circle" : "bi-x-circle") me-1"></i>@r.Msg
</span>
}
</div>
@* ── Expanded detail ── *@
@if (expanded)
{
<div class="border-top p-3">
<ul class="nav nav-pills nav-sm gap-1 mb-3">
@foreach (string tab in new[] { "overview", "conditions", "labels", "taints", "pods", "server" })
{
<li class="nav-item">
<button class="nav-link py-1 px-2 @(detailTab == tab ? "active" : "")"
@onclick="() => detailTab = tab">
@TabLabel(tab)
</button>
</li>
}
</ul>
@* Overview *@
@if (detailTab == "overview")
{
<div class="row g-3">
<div class="col-md-6">
<table class="table table-sm table-borderless mb-0">
<tbody>
<tr><th class="text-muted fw-normal small" style="width:40%">Architecture</th><td class="small">@(node.Architecture ?? "—")</td></tr>
<tr><th class="text-muted fw-normal small">OS Image</th><td class="small">@(node.OsImage ?? "—")</td></tr>
<tr><th class="text-muted fw-normal small">Kernel</th><td class="small">@(node.KernelVersion ?? "—")</td></tr>
<tr><th class="text-muted fw-normal small">Container Runtime</th><td class="small">@(node.ContainerRuntime ?? "—")</td></tr>
<tr><th class="text-muted fw-normal small">Kubelet Version</th><td class="small">@(node.KubeletVersion ?? "—")</td></tr>
<tr><th class="text-muted fw-normal small">Age</th><td class="small">@(node.CreatedAt.HasValue ? FormatAge(node.CreatedAt.Value) : "—")</td></tr>
</tbody>
</table>
</div>
<div class="col-md-6">
<table class="table table-sm table-borderless mb-0">
<tbody>
<tr><th class="text-muted fw-normal small" style="width:50%">CPU (capacity)</th><td class="small">@(node.CpuCapacity ?? "—")</td></tr>
<tr><th class="text-muted fw-normal small">CPU (allocatable)</th><td class="small">@(node.CpuAllocatable ?? "—")</td></tr>
<tr><th class="text-muted fw-normal small">Memory (capacity)</th><td class="small">@(FormatMemory(node.MemoryCapacity))</td></tr>
<tr><th class="text-muted fw-normal small">Memory (allocatable)</th><td class="small">@(FormatMemory(node.MemoryAllocatable))</td></tr>
<tr><th class="text-muted fw-normal small">Ephemeral storage</th><td class="small">@(FormatMemory(node.EphemeralStorageAllocatable))</td></tr>
<tr><th class="text-muted fw-normal small">Max pods</th><td class="small">@(node.MaxPodsAllocatable?.ToString() ?? "—")</td></tr>
</tbody>
</table>
@if (node.Addresses.Count > 0)
{
<div class="mt-2">
@foreach (string addr in node.Addresses)
{
<span class="badge bg-light text-dark border me-1 mb-1">@addr</span>
}
</div>
}
</div>
</div>
}
@* Conditions *@
else if (detailTab == "conditions")
{
<table class="table table-sm mb-0">
<thead><tr><th>Type</th><th>Status</th><th>Reason</th><th>Message</th><th>Since</th></tr></thead>
<tbody>
@foreach (NodeConditionInfo cond in node.Conditions)
{
<tr>
<td class="fw-semibold small">@cond.Type</td>
<td>
<span class="badge @ConditionBadge(cond)">@cond.Status</span>
</td>
<td class="small text-muted">@(cond.Reason ?? "—")</td>
<td class="small text-muted" style="max-width:300px;white-space:normal">@(cond.Message ?? "—")</td>
<td class="small text-muted">@(cond.LastTransitionTime.HasValue ? FormatAge(cond.LastTransitionTime.Value) : "—")</td>
</tr>
}
</tbody>
</table>
}
@* Labels *@
else if (detailTab == "labels")
{
<div class="mb-3">
<div class="d-flex flex-wrap gap-1 mb-2">
@foreach ((string k, string v) in node.Labels)
{
<span class="badge bg-primary bg-opacity-10 text-primary border border-primary font-monospace small">
@k=@v
</span>
}
@if (node.Labels.Count == 0)
{
<span class="text-muted small">No custom labels.</span>
}
</div>
</div>
@* Add label form *@
<div class="card card-body bg-light border-0 p-2">
<div class="row g-2 align-items-end">
<div class="col-5">
<label class="form-label small mb-1">Key</label>
<input class="form-control form-control-sm font-monospace" @bind="newLabelKey" placeholder="e.g. topology.kubernetes.io/zone" />
</div>
<div class="col-5">
<label class="form-label small mb-1">Value</label>
<input class="form-control form-control-sm font-monospace" @bind="newLabelValue" placeholder="us-east-1a" />
</div>
<div class="col-2">
<button class="btn btn-sm btn-primary w-100"
@onclick="() => SetLabelAsync(node.Name)"
disabled="@(string.IsNullOrWhiteSpace(newLabelKey))">
Set
</button>
</div>
</div>
<p class="text-muted small mt-2 mb-0">Leave value blank to remove the label.</p>
</div>
}
@* Taints *@
else if (detailTab == "taints")
{
@if (node.Taints.Count > 0)
{
<table class="table table-sm mb-2">
<thead><tr><th>Key</th><th>Value</th><th>Effect</th><th></th></tr></thead>
<tbody>
@foreach (NodeTaintInfo taint in node.Taints)
{
<tr>
<td class="font-monospace small">@taint.Key</td>
<td class="font-monospace small text-muted">@(taint.Value ?? "—")</td>
<td><span class="badge @TaintEffectBadge(taint.Effect)">@taint.Effect</span></td>
<td>
<button class="btn btn-sm btn-outline-danger py-0"
@onclick="() => RemoveTaintAsync(node.Name, node.Taints, taint)">
<i class="bi bi-x"></i>
</button>
</td>
</tr>
}
</tbody>
</table>
}
else
{
<p class="text-muted small mb-2">No taints on this node.</p>
}
@* Add taint form *@
<div class="card card-body bg-light border-0 p-2">
<div class="row g-2 align-items-end">
<div class="col-4">
<label class="form-label small mb-1">Key</label>
<input class="form-control form-control-sm font-monospace" @bind="newTaintKey" placeholder="dedicated" />
</div>
<div class="col-3">
<label class="form-label small mb-1">Value</label>
<input class="form-control form-control-sm font-monospace" @bind="newTaintValue" placeholder="gpu" />
</div>
<div class="col-3">
<label class="form-label small mb-1">Effect</label>
<select class="form-select form-select-sm" @bind="newTaintEffect">
<option value="NoSchedule">NoSchedule</option>
<option value="PreferNoSchedule">PreferNoSchedule</option>
<option value="NoExecute">NoExecute</option>
</select>
</div>
<div class="col-2">
<button class="btn btn-sm btn-primary w-100"
@onclick="() => AddTaintAsync(node.Name, node.Taints)"
disabled="@(string.IsNullOrWhiteSpace(newTaintKey))">
Add
</button>
</div>
</div>
</div>
}
@* Pods on node *@
else if (detailTab == "pods")
{
@if (loadingPods)
{
<div class="text-muted small">
<span class="spinner-border spinner-border-sm me-1"></span>Loading pods…
</div>
}
else if (nodePods.Count == 0)
{
<p class="text-muted small mb-0">No pods running on this node.</p>
}
else
{
<table class="table table-sm mb-0">
<thead>
<tr><th>Namespace</th><th>Pod</th><th>Status</th><th>Ready</th><th>Restarts</th></tr>
</thead>
<tbody>
@foreach (PodInfo pod in nodePods)
{
<tr>
<td class="small text-muted">@pod.Namespace</td>
<td class="small font-monospace">@pod.Name</td>
<td><span class="badge @PodStatusBadge(pod.Status)">@pod.Status</span></td>
<td class="small">@pod.ReadyContainers/@pod.TotalContainers</td>
<td class="small @(pod.Restarts > 5 ? "text-danger" : "")">@pod.Restarts</td>
</tr>
}
</tbody>
</table>
}
}
@* Server inventory *@
else if (detailTab == "server")
{
@if (editingServer is null)
{
@if (server is not null)
{
<table class="table table-sm table-borderless mb-2">
<tbody>
<tr><th class="text-muted fw-normal small" style="width:30%">Display name</th><td class="small">@server.DisplayName</td></tr>
<tr><th class="text-muted fw-normal small">Provider</th><td class="small">@server.Provider</td></tr>
<tr><th class="text-muted fw-normal small">IP address</th><td class="small font-monospace">@(server.IpAddress ?? "—")</td></tr>
<tr><th class="text-muted fw-normal small">Management IP</th><td class="small font-monospace">@(server.ManagementIpAddress ?? "—")</td></tr>
<tr><th class="text-muted fw-normal small">OS</th><td class="small">@(server.OsDistribution ?? "—")</td></tr>
<tr><th class="text-muted fw-normal small">Hardware</th><td class="small">@ServerHardwareSummary(server)</td></tr>
<tr><th class="text-muted fw-normal small">Location</th><td class="small">@(server.Location ?? "—")</td></tr>
<tr><th class="text-muted fw-normal small">SSH</th><td class="small font-monospace">@ServerSshSummary(server)</td></tr>
@if (!string.IsNullOrEmpty(server.JumpHost))
{
<tr><th class="text-muted fw-normal small">Jump host</th><td class="small font-monospace">@server.JumpHost</td></tr>
}
@if (!string.IsNullOrEmpty(server.Notes))
{
<tr><th class="text-muted fw-normal small">Notes</th><td class="small">@server.Notes</td></tr>
}
</tbody>
</table>
<div class="d-flex gap-2">
<button class="btn btn-sm btn-outline-primary"
@onclick="() => StartEditServer(server, node.Name)">
<i class="bi bi-pencil me-1"></i>Edit
</button>
<button class="btn btn-sm btn-outline-danger"
@onclick="() => DeleteServerAsync(server.Id)">
<i class="bi bi-trash me-1"></i>Remove
</button>
</div>
}
else
{
<p class="text-muted small mb-2">No server record linked to this node.</p>
<button class="btn btn-sm btn-outline-success"
@onclick="() => StartEditServer(null, node.Name)">
<i class="bi bi-plus-circle me-1"></i>Add server record
</button>
}
}
else
{
<ServerEditForm Server="editingServer"
OnSave="SaveServerAsync"
OnCancel="CancelEditServer" />
}
}
</div>
}
</div>
}
</div>
@* ── Unlinked servers ───────────────────────────────────────── *@
@if (UnlinkedServers.Count > 0 || showAddUnlinked)
{
<div class="card shadow-sm mb-3">
<div class="card-header bg-white d-flex align-items-center justify-content-between py-2">
<span><i class="bi bi-server me-2"></i><strong>Unlinked server records</strong></span>
<button class="btn btn-sm btn-outline-success"
@onclick="() => { showAddUnlinked = true; StartEditServer(null, null); }">
<i class="bi bi-plus-circle me-1"></i>Add
</button>
</div>
<div class="card-body p-0">
@if (UnlinkedServers.Count == 0)
{
<p class="text-muted small p-3 mb-0">No unlinked server records.</p>
}
else
{
<table class="table table-sm mb-0">
<thead><tr><th>Name</th><th>IP</th><th>Provider</th><th>Hardware</th><th>Location</th><th></th></tr></thead>
<tbody>
@foreach (ClusterServer s in UnlinkedServers)
{
<tr>
<td class="small fw-semibold">@s.DisplayName</td>
<td class="small font-monospace">@(s.IpAddress ?? "—")</td>
<td class="small">@s.Provider</td>
<td class="small">@ServerHardwareSummary(s)</td>
<td class="small text-muted">@(s.Location ?? "—")</td>
<td>
<button class="btn btn-sm btn-outline-primary py-0 me-1"
@onclick="() => StartEditServer(s, s.NodeName)">
<i class="bi bi-pencil"></i>
</button>
<button class="btn btn-sm btn-outline-danger py-0"
@onclick="() => DeleteServerAsync(s.Id)">
<i class="bi bi-trash"></i>
</button>
</td>
</tr>
}
</tbody>
</table>
}
@if (showAddUnlinked && editingServer is not null)
{
<div class="p-3 border-top">
<ServerEditForm Server="editingServer"
OnSave="SaveServerAsync"
OnCancel="CancelEditServer" />
</div>
}
</div>
</div>
}
}
@* ══════════════════════════════════════════════════════════════════
Inline server edit form component
══════════════════════════════════════════════════════════════════ *@
@* Defined as a nested private RenderFragment-style via code — see below *@
@code {
[Parameter, EditorRequired] public Guid ClusterId { get; set; }
private List<ClusterServer> UnlinkedServers => servers
.Where(s => string.IsNullOrEmpty(s.NodeName) || !nodes.Any(n => n.Name == s.NodeName))
.ToList();
// ── State ─────────────────────────────────────────────────────
private List<NodeInfo> nodes = [];
private List<ClusterServer> servers = [];
private string? loadError;
private bool loading = true;
private string? expandedNode;
private string detailTab = "overview";
// busy state per-node
private string? busyNode;
private string busyMessage = "";
private string? drainConfirmNode;
private Dictionary<string, (bool Ok, string Msg)> actionResults = new();
// pods-on-node
private List<PodInfo> nodePods = [];
private bool loadingPods;
// label form
private string newLabelKey = "";
private string newLabelValue = "";
// taint form
private string newTaintKey = "";
private string newTaintValue = "";
private string newTaintEffect = "NoSchedule";
// server edit
private ClusterServer? editingServer;
private bool showAddUnlinked;
// ── Lifecycle ─────────────────────────────────────────────────
protected override async Task OnInitializedAsync() => await LoadAsync();
private async Task LoadAsync()
{
loading = true;
loadError = null;
StateHasChanged();
Task<KubernetesOperationResult<List<NodeInfo>>> nodeTask = NodeService.GetNodesAsync(ClusterId);
Task<List<ClusterServer>> serverTask = NodeService.GetServersAsync(ClusterId);
await Task.WhenAll(nodeTask, serverTask);
KubernetesOperationResult<List<NodeInfo>> nodeResult = nodeTask.Result;
List<ClusterServer> serverList = serverTask.Result;
if (nodeResult.IsSuccess)
nodes = nodeResult.Data!;
else
loadError = nodeResult.Error;
servers = serverList;
loading = false;
}
// ── Expand / tabs ─────────────────────────────────────────────
private async Task ToggleExpand(string nodeName)
{
if (expandedNode == nodeName)
{
expandedNode = null;
nodePods = [];
}
else
{
expandedNode = nodeName;
detailTab = "overview";
await LoadPodsAsync(nodeName);
}
}
private async Task LoadPodsAsync(string nodeName)
{
loadingPods = true;
nodePods = [];
StateHasChanged();
KubernetesOperationResult<List<PodInfo>> result =
await NodeService.GetPodsOnNodeAsync(ClusterId, nodeName);
if (result.IsSuccess) nodePods = result.Data!;
loadingPods = false;
}
// Switch to pods tab and reload if needed
private async Task SwitchTabAsync(string tab)
{
detailTab = tab;
if (tab == "pods" && expandedNode is not null && nodePods.Count == 0 && !loadingPods)
await LoadPodsAsync(expandedNode);
}
// ── Node actions ──────────────────────────────────────────────
private bool IsBusy(string nodeName) => busyNode == nodeName;
private async Task CordonAsync(string nodeName)
{
await RunNodeActionAsync(nodeName, "Cordoning…", async () =>
{
string? user = await GetCurrentUserAsync();
return await NodeService.CordonNodeAsync(ClusterId, nodeName, user);
}, "Cordoned", "Cordon failed");
}
private async Task UncordonAsync(string nodeName)
{
await RunNodeActionAsync(nodeName, "Uncordoning…", async () =>
{
string? user = await GetCurrentUserAsync();
return await NodeService.UncordonNodeAsync(ClusterId, nodeName, user);
}, "Uncordoned", "Uncordon failed");
}
private async Task DrainConfirmedAsync(string nodeName)
{
drainConfirmNode = null;
await RunNodeActionAsync(nodeName, "Draining…", async () =>
{
string? user = await GetCurrentUserAsync();
KubernetesOperationResult<string> r = await NodeService.DrainNodeAsync(
ClusterId, nodeName, performedBy: user);
return r.IsSuccess
? KubernetesOperationResult.Success()
: KubernetesOperationResult.Failure(r.Error ?? "Drain failed");
}, "Drained", "Drain failed");
}
private async Task RunNodeActionAsync(
string nodeName, string progressMsg,
Func<Task<KubernetesOperationResult>> action,
string successMsg, string failPrefix)
{
busyNode = nodeName;
busyMessage = progressMsg;
actionResults.Remove(nodeName);
StateHasChanged();
KubernetesOperationResult result = await action();
busyNode = null;
actionResults[nodeName] = result.IsSuccess
? (true, successMsg)
: (false, result.Error ?? failPrefix);
if (result.IsSuccess) await LoadAsync();
StateHasChanged();
}
// ── Label management ──────────────────────────────────────────
private async Task SetLabelAsync(string nodeName)
{
if (string.IsNullOrWhiteSpace(newLabelKey)) return;
string? user = await GetCurrentUserAsync();
string? value = string.IsNullOrWhiteSpace(newLabelValue) ? null : newLabelValue.Trim();
KubernetesOperationResult result = await NodeService.SetNodeLabelAsync(
ClusterId, nodeName, newLabelKey.Trim(), value, user);
actionResults[nodeName] = result.IsSuccess
? (true, value is null ? "Label removed" : "Label set")
: (false, result.Error ?? "Failed");
if (result.IsSuccess)
{
newLabelKey = "";
newLabelValue = "";
await LoadAsync();
}
}
// ── Taint management ──────────────────────────────────────────
private async Task AddTaintAsync(string nodeName, List<NodeTaintInfo> existing)
{
if (string.IsNullOrWhiteSpace(newTaintKey)) return;
string? user = await GetCurrentUserAsync();
List<NodeTaintInfo> updated = [.. existing, new NodeTaintInfo
{
Key = newTaintKey.Trim(),
Value = string.IsNullOrWhiteSpace(newTaintValue) ? null : newTaintValue.Trim(),
Effect = newTaintEffect
}];
KubernetesOperationResult result = await NodeService.SetNodeTaintsAsync(
ClusterId, nodeName, updated, user);
actionResults[nodeName] = result.IsSuccess
? (true, "Taint added") : (false, result.Error ?? "Failed");
if (result.IsSuccess)
{
newTaintKey = "";
newTaintValue = "";
newTaintEffect = "NoSchedule";
await LoadAsync();
}
}
private async Task RemoveTaintAsync(string nodeName, List<NodeTaintInfo> existing, NodeTaintInfo remove)
{
string? user = await GetCurrentUserAsync();
List<NodeTaintInfo> updated = existing
.Where(t => !(t.Key == remove.Key && t.Effect == remove.Effect))
.ToList();
KubernetesOperationResult result = await NodeService.SetNodeTaintsAsync(
ClusterId, nodeName, updated, user);
actionResults[nodeName] = result.IsSuccess
? (true, "Taint removed") : (false, result.Error ?? "Failed");
if (result.IsSuccess) await LoadAsync();
}
// ── Server inventory ──────────────────────────────────────────
private void StartEditServer(ClusterServer? existing, string? nodeName)
{
editingServer = existing is null
? new ClusterServer { Id = Guid.Empty, ClusterId = ClusterId, DisplayName = "", NodeName = nodeName }
: new ClusterServer
{
Id = existing.Id,
ClusterId = existing.ClusterId,
NodeName = existing.NodeName,
DisplayName = existing.DisplayName,
IpAddress = existing.IpAddress,
ManagementIpAddress = existing.ManagementIpAddress,
Provider = existing.Provider,
OsDistribution = existing.OsDistribution,
CpuCores = existing.CpuCores,
RamGb = existing.RamGb,
DiskGb = existing.DiskGb,
Location = existing.Location,
SshUser = existing.SshUser,
SshPort = existing.SshPort,
JumpHost = existing.JumpHost,
Notes = existing.Notes
};
}
private async Task SaveServerAsync(ClusterServer server)
{
await NodeService.SaveServerAsync(server);
editingServer = null;
showAddUnlinked = false;
await LoadAsync();
}
private void CancelEditServer()
{
editingServer = null;
showAddUnlinked = false;
}
private async Task DeleteServerAsync(Guid serverId)
{
await NodeService.DeleteServerAsync(serverId);
await LoadAsync();
}
// ── Helpers ───────────────────────────────────────────────────
private async Task<string?> GetCurrentUserAsync()
{
AuthenticationState state = await AuthState.GetAuthenticationStateAsync();
return state.User.Identity?.Name;
}
private static string FormatAge(DateTime dt)
{
TimeSpan age = DateTime.UtcNow - dt.ToUniversalTime();
if (age.TotalDays >= 1) return $"{(int)age.TotalDays}d";
if (age.TotalHours >= 1) return $"{(int)age.TotalHours}h";
return $"{(int)age.TotalMinutes}m";
}
private static string FormatMemory(string? raw)
{
if (raw is null) return "—";
if (raw.EndsWith("Ki") && long.TryParse(raw[..^2], out long ki))
{
double gb = ki / 1024.0 / 1024.0;
return gb >= 1 ? $"{gb:F1} GiB" : $"{ki / 1024.0:F0} MiB";
}
return raw;
}
private static string ServerHardwareSummary(ClusterServer s)
{
List<string> parts = [];
if (s.CpuCores.HasValue) parts.Add($"{s.CpuCores} vCPU");
if (s.RamGb.HasValue) parts.Add($"{s.RamGb} GB RAM");
if (s.DiskGb.HasValue) parts.Add($"{s.DiskGb} GB disk");
return parts.Count > 0 ? string.Join(", ", parts) : "—";
}
private static string ServerSshSummary(ClusterServer s)
{
if (string.IsNullOrEmpty(s.SshUser) && string.IsNullOrEmpty(s.IpAddress)) return "—";
string host = s.IpAddress ?? "?";
string user = string.IsNullOrEmpty(s.SshUser) ? "" : s.SshUser + "@";
int port = s.SshPort == 22 ? 0 : s.SshPort;
string portSuffix = port > 0 ? $":{port}" : "";
return $"{user}{host}{portSuffix}";
}
private static string ConditionBadge(NodeConditionInfo cond) => cond.Type switch
{
"Ready" when cond.Status == "True" => "bg-success",
"Ready" when cond.Status != "True" => "bg-danger",
"MemoryPressure" when cond.Status == "True" => "bg-warning text-dark",
"DiskPressure" when cond.Status == "True" => "bg-warning text-dark",
"PIDPressure" when cond.Status == "True" => "bg-warning text-dark",
"NetworkUnavailable" when cond.Status == "True" => "bg-danger",
_ when cond.Status == "False" => "bg-success bg-opacity-75",
_ => "bg-secondary"
};
private static string TaintEffectBadge(string effect) => effect switch
{
"NoExecute" => "bg-danger",
"NoSchedule" => "bg-warning text-dark",
_ => "bg-secondary"
};
private static string PodStatusBadge(string status) => status switch
{
"Running" => "bg-success",
"Pending" => "bg-warning text-dark",
"Succeeded" => "bg-secondary",
"Failed" => "bg-danger",
_ => "bg-light text-dark border"
};
private static string TabLabel(string tab) => tab switch
{
"overview" => "Overview",
"conditions" => "Conditions",
"labels" => "Labels",
"taints" => "Taints",
"pods" => "Pods",
"server" => "Server",
_ => tab
};
}
@* ══════════════════════════════════════════════════════════════════
Nested server edit form — defined as a separate component
══════════════════════════════════════════════════════════════════ *@
@* This would normally be a separate file but is declared here
as a partial-style inline component via a RenderFragment approach.
Instead we use a sibling file ServerEditForm.razor below. *@

View File

@@ -57,17 +57,8 @@ else if (selectedCustomer is not null)
</button> </button>
</div> </div>
<div class="card-body"> <div class="card-body">
@if (customerMetricsLoading) <LoadingPanel Loading="@customerMetricsLoading" Error="@customerMetricsError">
{ @if (!customerMetricsLoading && customerMetrics is not null)
<div class="text-center py-4"><div class="spinner-border spinner-border-sm text-primary"></div></div>
}
else if (customerMetricsError is not null)
{
<div class="alert alert-warning small py-2 mb-0">
<i class="bi bi-exclamation-triangle me-1"></i>@customerMetricsError
</div>
}
else if (customerMetrics is not null)
{ {
<div class="row g-3 mb-2"> <div class="row g-3 mb-2">
<div class="col-sm-6 col-lg-3"> <div class="col-sm-6 col-lg-3">
@@ -101,6 +92,7 @@ else if (selectedCustomer is not null)
<i class="bi bi-clock me-1"></i>Queried @customerMetrics.QueriedAt.ToLocalTime().ToString("HH:mm:ss") <i class="bi bi-clock me-1"></i>Queried @customerMetrics.QueriedAt.ToLocalTime().ToString("HH:mm:ss")
</div> </div>
} }
</LoadingPanel>
</div> </div>
</div> </div>
} }
@@ -125,20 +117,14 @@ else if (selectedCustomer is not null)
</div> </div>
@* --- App cards --- *@ @* --- App cards --- *@
@if (apps is null) <LoadingPanel Loading="@(apps is null)">
@if (apps is not null && apps.Count == 0)
{ {
<div class="text-center py-3"> <EmptyState Icon="bi-app-indicator"
<div class="spinner-border spinner-border-sm text-primary" role="status"></div> Title="No apps yet"
</div> Message="Create one above to get started." />
} }
else if (apps.Count == 0) else if (apps is not null)
{
<div class="text-center py-5">
<i class="bi bi-app-indicator text-muted" style="font-size: 3rem;"></i>
<p class="text-muted mt-2 mb-0">No apps yet. Create one above.</p>
</div>
}
else
{ {
<div class="row row-cols-1 row-cols-md-2 g-3"> <div class="row row-cols-1 row-cols-md-2 g-3">
@foreach (Data.App app in apps) @foreach (Data.App app in apps)
@@ -173,6 +159,7 @@ else if (selectedCustomer is not null)
} }
</div> </div>
} }
</LoadingPanel>
@* --- Portal Access Management --- *@ @* --- Portal Access Management --- *@
<div class="card shadow-sm mt-4"> <div class="card shadow-sm mt-4">
@@ -210,17 +197,13 @@ else if (selectedCustomer is not null)
} }
@* --- Current access list --- *@ @* --- Current access list --- *@
@if (customerAccesses is null) <LoadingPanel Loading="@(customerAccesses is null)">
@if (customerAccesses is not null && customerAccesses.Count == 0)
{ {
<div class="text-center py-2"> <EmptyState Icon="bi-person-x"
<div class="spinner-border spinner-border-sm text-primary" role="status"></div> Message="No users have portal access to this customer yet." />
</div>
} }
else if (customerAccesses.Count == 0) else if (customerAccesses is not null)
{
<p class="text-muted small mb-0">No users have portal access to this customer yet.</p>
}
else
{ {
@foreach (CustomerAccess access in customerAccesses) @foreach (CustomerAccess access in customerAccesses)
{ {
@@ -230,13 +213,24 @@ else if (selectedCustomer is not null)
<span class="fw-medium">@access.User.Email</span> <span class="fw-medium">@access.User.Email</span>
<span class="badge @GetRoleBadgeClass(access.Role) ms-1">@access.Role</span> <span class="badge @GetRoleBadgeClass(access.Role) ms-1">@access.Role</span>
</div> </div>
@if (confirmRevokeUserId == access.UserId)
{
<div class="d-flex gap-1">
<button class="btn btn-sm btn-danger" @onclick="() => RevokeAccess(access.UserId)">Revoke</button>
<button class="btn btn-sm btn-link p-0" @onclick="() => confirmRevokeUserId = null">Cancel</button>
</div>
}
else
{
<button class="btn btn-sm btn-outline-danger" title="Revoke access" <button class="btn btn-sm btn-outline-danger" title="Revoke access"
@onclick="() => RevokeAccess(access.UserId)"> @onclick="() => confirmRevokeUserId = access.UserId">
<i class="bi bi-x"></i> <i class="bi bi-x"></i>
</button> </button>
}
</div> </div>
} }
} }
</LoadingPanel>
</div> </div>
</div> </div>
} }
@@ -272,21 +266,14 @@ else
</div> </div>
} }
@if (customers is null) <LoadingPanel Loading="@(customers is null)">
@if (customers is not null && customers.Count == 0)
{ {
<div class="text-center py-4"> <EmptyState Icon="bi-people"
<div class="spinner-border spinner-border-sm text-primary" role="status"></div> Title="No customers yet"
<span class="ms-2 text-muted">Loading customers...</span> Message="Create one above to get started." />
</div>
} }
else if (customers.Count == 0) else if (customers is not null)
{
<div class="text-center py-5">
<i class="bi bi-people text-muted" style="font-size: 3rem;"></i>
<p class="text-muted mt-2 mb-0">No customers yet. Create one above to get started.</p>
</div>
}
else
{ {
<div class="list-group shadow-sm"> <div class="list-group shadow-sm">
@foreach (Customer customer in customers) @foreach (Customer customer in customers)
@@ -326,6 +313,7 @@ else
} }
</div> </div>
} }
</LoadingPanel>
} }
@code { @code {
@@ -360,6 +348,10 @@ else
private string? accessError; private string? accessError;
private string? accessSuccess; private string? accessSuccess;
// --- Portal access ---
private string? confirmRevokeUserId;
protected override async Task OnInitializedAsync() protected override async Task OnInitializedAsync()
{ {
await LoadCustomers(); await LoadCustomers();
@@ -521,6 +513,7 @@ else
private async Task RevokeAccess(string userId) private async Task RevokeAccess(string userId)
{ {
confirmRevokeUserId = null;
await CustomerAccessService.RevokeAccessAsync(userId, selectedCustomer!.Id); await CustomerAccessService.RevokeAccessAsync(userId, selectedCustomer!.Id);
await LoadAccesses(); await LoadAccesses();
} }

View File

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

View File

@@ -52,9 +52,7 @@ else if (selectedEnv is not null)
{ {
<p class="text-muted small mb-2">Paste your kubeconfig YAML or upload a file to auto-detect clusters and API server URLs.</p> <p class="text-muted small mb-2">Paste your kubeconfig YAML or upload a file to auto-detect clusters and API server URLs.</p>
<textarea class="form-control mb-2" rows="5" <YamlEditor @bind-Value="kubeconfigInput" />
placeholder="apiVersion: v1&#10;kind: Config&#10;clusters:&#10; - cluster:&#10; server: https://..."
@bind="kubeconfigInput" @bind:event="oninput"></textarea>
<div class="d-flex align-items-center gap-2"> <div class="d-flex align-items-center gap-2">
<label class="btn btn-sm btn-outline-secondary mb-0"> <label class="btn btn-sm btn-outline-secondary mb-0">
@@ -125,20 +123,14 @@ else if (selectedEnv is not null)
} }
@* --- Cluster Cards --- *@ @* --- Cluster Cards --- *@
@if (envClusters is null) <LoadingPanel Loading="@(envClusters is null)">
@if (envClusters is not null && envClusters.Count == 0)
{ {
<div class="text-center py-3"> <EmptyState Icon="bi-hdd-rack"
<div class="spinner-border spinner-border-sm text-primary" role="status"></div> Title="No clusters yet"
</div> Message="Register a cluster above to start deploying." />
} }
else if (envClusters.Count == 0) else if (envClusters is not null)
{
<div class="text-center py-5">
<i class="bi bi-hdd-rack text-muted" style="font-size: 3rem;"></i>
<p class="text-muted mt-2 mb-0">No clusters registered yet.</p>
</div>
}
else
{ {
<div class="row row-cols-1 row-cols-md-2 g-3"> <div class="row row-cols-1 row-cols-md-2 g-3">
@foreach (KubernetesCluster cluster in envClusters) @foreach (KubernetesCluster cluster in envClusters)
@@ -168,6 +160,7 @@ else if (selectedEnv is not null)
} }
</div> </div>
} }
</LoadingPanel>
} }
else else
{ {
@@ -201,21 +194,14 @@ else
</div> </div>
} }
@if (environments is null) <LoadingPanel Loading="@(environments is null)">
@if (environments is not null && environments.Count == 0)
{ {
<div class="text-center py-4"> <EmptyState Icon="bi-layers"
<div class="spinner-border spinner-border-sm text-primary" role="status"></div> Title="No environments yet"
<span class="ms-2 text-muted">Loading environments...</span> Message="Create one above to get started." />
</div>
} }
else if (environments.Count == 0) else if (environments is not null)
{
<div class="text-center py-5">
<i class="bi bi-layers text-muted" style="font-size: 3rem;"></i>
<p class="text-muted mt-2 mb-0">No environments yet. Create one above to get started.</p>
</div>
}
else
{ {
<div class="list-group shadow-sm"> <div class="list-group shadow-sm">
@foreach (Data.Environment env in environments) @foreach (Data.Environment env in environments)
@@ -255,6 +241,7 @@ else
} }
</div> </div>
} }
</LoadingPanel>
} }
@code { @code {

View File

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

View File

@@ -2,32 +2,86 @@
@using EntKube.Web.Services @using EntKube.Web.Services
@inject AppGovernanceService GovernanceService @inject AppGovernanceService GovernanceService
@inject TenantService TenantService @inject TenantService TenantService
@inject DeploymentService DeploymentService
@inject StorageService StorageService
@* ═══════════════════════════════════════════════════════════════════ @* ═══════════════════════════════════════════════════════════════════
GovernanceTab — tenant admin view for app-level namespace, resource GovernanceTab — per-environment governance: resource quotas,
quotas, network policies, and RBAC. All changes are saved to the network policies, and RBAC for this app in this specific environment.
database. "Apply to clusters" pushes to every cluster where this Namespace is derived from each deployment (not stored here).
app has a deployment.
═══════════════════════════════════════════════════════════════════ *@ ═══════════════════════════════════════════════════════════════════ *@
<div class="d-flex align-items-center justify-content-between mb-3"> <div class="d-flex align-items-center justify-content-between mb-3">
<div> <div>
<h5 class="mb-0"><i class="bi bi-shield-check me-2 text-primary"></i>Governance</h5> <h5 class="mb-0"><i class="bi bi-shield-check me-2 text-primary"></i>Governance</h5>
<small class="text-muted">Namespace, quotas, network policies, and RBAC — read-only in the customer portal.</small> <small class="text-muted">Quota, network policies, and RBAC for this environment. Applied to each deployment's namespace on its cluster.</small>
</div> </div>
<button class="btn btn-sm btn-success" @onclick="ApplyToAllClusters" disabled="@(applying || string.IsNullOrWhiteSpace(ns))"> <div class="d-flex gap-2 align-items-center">
<button class="btn btn-sm btn-outline-secondary" @onclick="OpenCopyFrom">
<i class="bi bi-copy me-1"></i>Copy from env
</button>
<button class="btn btn-sm btn-success" @onclick="ApplyToEnvironmentClusters" disabled="@applying">
@if (applying) { <span class="spinner-border spinner-border-sm me-1"></span> } @if (applying) { <span class="spinner-border spinner-border-sm me-1"></span> }
else { <i class="bi bi-cloud-upload me-1"></i> } else { <i class="bi bi-cloud-upload me-1"></i> }
Apply to clusters Apply to clusters
</button> </button>
</div>
</div> </div>
@* ── Copy from environment ── *@
@if (showCopyFrom)
{
<div class="card border-info mb-3">
<div class="card-body py-2">
<div class="d-flex align-items-center gap-2 flex-wrap">
<i class="bi bi-copy text-info"></i>
<span class="small fw-medium">Copy governance from:</span>
<select class="form-select form-select-sm w-auto" @bind="copyFromEnvId">
<option value="@Guid.Empty">Select source environment…</option>
@if (otherEnvironments is not null)
{
@foreach (Data.Environment env in otherEnvironments)
{
<option value="@env.Id">@env.Name</option>
}
}
</select>
@if (!confirmCopy)
{
<button class="btn btn-sm btn-info" @onclick="() => confirmCopy = true"
disabled="@(copyFromEnvId == Guid.Empty)">
Copy
</button>
}
else
{
<span class="small text-info">This will overwrite all governance in <strong>this environment</strong>.</span>
<button class="btn btn-sm btn-danger" @onclick="DoCopyFrom" disabled="@copying">
@if (copying) { <span class="spinner-border spinner-border-sm me-1"></span> }
Yes, overwrite
</button>
<button class="btn btn-sm btn-outline-secondary" @onclick="() => confirmCopy = false">Cancel</button>
}
<button class="btn btn-sm btn-link text-muted p-0 ms-auto" @onclick="() => showCopyFrom = false">
<i class="bi bi-x"></i>
</button>
</div>
@if (copyMessage is not null)
{
<div class="mt-1 small @(copyOk ? "text-success" : "text-danger")">
<i class="bi @(copyOk ? "bi-check-circle" : "bi-x-circle") me-1"></i>@copyMessage
</div>
}
</div>
</div>
}
@if (applyOutput is not null) @if (applyOutput is not null)
{ {
<div class="alert @(applySuccess ? "alert-success" : "alert-danger") py-2 small mb-3 d-flex align-items-start justify-content-between"> <div class="alert @(applySuccess ? "alert-success" : "alert-danger") py-2 small mb-3 d-flex align-items-start justify-content-between">
<div> <div>
<i class="bi @(applySuccess ? "bi-check-circle" : "bi-exclamation-triangle") me-1"></i> <i class="bi @(applySuccess ? "bi-check-circle" : "bi-exclamation-triangle") me-1"></i>
<strong>@(applySuccess ? "Applied successfully" : "Apply failed")</strong> <strong>@(applySuccess ? "Applied successfully" : "Apply had errors")</strong>
<pre class="mb-0 mt-1 small" style="white-space:pre-wrap;max-height:200px;overflow-y:auto;">@applyOutput</pre> <pre class="mb-0 mt-1 small" style="white-space:pre-wrap;max-height:200px;overflow-y:auto;">@applyOutput</pre>
</div> </div>
<button class="btn-close btn-close-sm ms-2 flex-shrink-0" @onclick="() => applyOutput = null"></button> <button class="btn-close btn-close-sm ms-2 flex-shrink-0" @onclick="() => applyOutput = null"></button>
@@ -40,47 +94,55 @@
} }
else else
{ {
@* ── 1. Namespace ─────────────────────────────────────────────── *@ @* ── 0. Namespace ─────────────────────────────────────────────── *@
<div class="card shadow-sm mb-3"> <div class="card shadow-sm mb-3">
<div class="card-header bg-white py-2"> <div class="card-header bg-white py-2">
<i class="bi bi-box me-2 text-primary"></i> <i class="bi bi-box me-2 text-primary"></i>
<strong>Namespace</strong> <strong>Namespace</strong>
<span class="text-muted small ms-2">The Kubernetes namespace where quotas and policies are enforced.</span> <span class="text-muted small ms-2">Locks the Kubernetes namespace for all deployments of this app in this environment.</span>
</div> </div>
<div class="card-body"> <div class="card-body">
<div class="row g-2 align-items-end"> @if (nsMessage is not null)
<div class="col-md-5"> {
<label class="form-label small">Namespace</label> <div class="alert @(nsOk ? "alert-success" : "alert-danger") py-1 small mb-2">
<input class="form-control form-control-sm font-monospace" @bind="ns" <i class="bi @(nsOk ? "bi-check-circle" : "bi-x-circle") me-1"></i>@nsMessage
placeholder="e.g. my-app" />
<div class="form-text">Must be a valid DNS label (lowercase, hyphens, max 63 chars).</div>
</div> </div>
<div class="col-auto"> }
<button class="btn btn-sm btn-primary" @onclick="SaveNamespace" <div class="d-flex gap-2 align-items-center flex-wrap">
disabled="@savingNs"> <input class="form-control form-control-sm font-monospace" style="max-width:280px"
@bind="nsValue" placeholder="e.g. customer-prod" />
<button class="btn btn-sm btn-primary" @onclick="SaveNamespace" disabled="@savingNs">
@if (savingNs) { <span class="spinner-border spinner-border-sm me-1"></span> } @if (savingNs) { <span class="spinner-border spinner-border-sm me-1"></span> }
Save Save
</button> </button>
</div> @if (!string.IsNullOrWhiteSpace(nsValue))
@if (nsMessage is not null)
{ {
<div class="col-auto"> <button class="btn btn-sm btn-outline-danger" @onclick="ClearNamespace">
<span class="small @(nsOk ? "text-success" : "text-danger")"> <i class="bi bi-x me-1"></i>Remove lock
<i class="bi @(nsOk ? "bi-check-circle" : "bi-x-circle") me-1"></i>@nsMessage </button>
</span> }
</div> </div>
@if (!string.IsNullOrWhiteSpace(nsValue))
{
<div class="form-text mt-1">
<i class="bi bi-lock-fill text-warning me-1"></i>
Deployments in this environment will be locked to namespace <code>@nsValue</code>.
</div>
}
else
{
<div class="form-text mt-1">No namespace lock — deployments may use any namespace.</div>
} }
</div> </div>
</div> </div>
</div>
@* ── 2. Resource Quota ─────────────────────────────────────────── *@ @* ── 1. Resource Quota ─────────────────────────────────────────── *@
<div class="card shadow-sm mb-3"> <div class="card shadow-sm mb-3">
<div class="card-header bg-white py-2 d-flex align-items-center justify-content-between"> <div class="card-header bg-white py-2 d-flex align-items-center justify-content-between">
<div> <div>
<i class="bi bi-speedometer me-2 text-warning"></i> <i class="bi bi-speedometer me-2 text-warning"></i>
<strong>Resource Quota</strong> <strong>Resource Quota</strong>
<span class="text-muted small ms-2">Limits applied as a Kubernetes ResourceQuota in the namespace.</span> <span class="text-muted small ms-2">Applied as a Kubernetes ResourceQuota in each deployment's namespace.</span>
</div> </div>
@if (quota is not null) @if (quota is not null)
{ {
@@ -96,6 +158,19 @@ else
<i class="bi @(quotaOk ? "bi-check-circle" : "bi-x-circle") me-1"></i>@quotaMessage <i class="bi @(quotaOk ? "bi-check-circle" : "bi-x-circle") me-1"></i>@quotaMessage
</div> </div>
} }
<div class="mb-2 d-flex align-items-center gap-2 flex-wrap">
<span class="text-muted small">Presets:</span>
<button class="btn btn-sm btn-outline-secondary py-0" title="12 pods · single service · no autoscaling"
@onclick='() => ApplyQuotaPreset("100m", "500m", "128Mi", "512Mi", 5, 2)'>Micro</button>
<button class="btn btn-sm btn-outline-secondary py-0" title="~5 services · light autoscaling · up to 20 pods"
@onclick='() => ApplyQuotaPreset("500m", "2", "512Mi", "2Gi", 20, 5)'>Small</button>
<button class="btn btn-sm btn-outline-secondary py-0" title="~15 services · moderate autoscaling · up to 60 pods"
@onclick='() => ApplyQuotaPreset("4", "16", "4Gi", "16Gi", 60, 20)'>Medium</button>
<button class="btn btn-sm btn-outline-secondary py-0" title="~30 services · active autoscaling · up to 150 pods"
@onclick='() => ApplyQuotaPreset("8", "32", "16Gi", "64Gi", 150, 50)'>Large</button>
<button class="btn btn-sm btn-outline-secondary py-0" title="70+ microservices + frontend · heavy autoscaling · up to 500 pods"
@onclick='() => ApplyQuotaPreset("32", "128", "64Gi", "256Gi", 500, 100)'>XLarge</button>
</div>
<div class="row g-2 mb-2"> <div class="row g-2 mb-2">
<div class="col-md-3"> <div class="col-md-3">
<label class="form-label small">CPU request</label> <label class="form-label small">CPU request</label>
@@ -129,7 +204,7 @@ else
</div> </div>
</div> </div>
@* ── 3. Network Policies ───────────────────────────────────────── *@ @* ── 2. Network Policies ───────────────────────────────────────── *@
<div class="card shadow-sm mb-3"> <div class="card shadow-sm mb-3">
<div class="card-header bg-white py-2 d-flex align-items-center justify-content-between"> <div class="card-header bg-white py-2 d-flex align-items-center justify-content-between">
<div> <div>
@@ -180,9 +255,7 @@ else
{ {
<div class="mb-2"> <div class="mb-2">
<label class="form-label small">Custom NetworkPolicy YAML</label> <label class="form-label small">Custom NetworkPolicy YAML</label>
<textarea class="form-control form-control-sm font-monospace" rows="8" <YamlEditor @bind-Value="newNpCustomYaml" />
@bind="newNpCustomYaml"
placeholder="apiVersion: networking.k8s.io/v1&#10;kind: NetworkPolicy&#10;..." />
</div> </div>
} }
<div class="d-flex gap-1"> <div class="d-flex gap-1">
@@ -253,15 +326,13 @@ else
</div> </div>
</div> </div>
@* ── 4. RBAC ───────────────────────────────────────────────────── *@ @* ── 3. RBAC ───────────────────────────────────────────────────── *@
<div class="card shadow-sm"> <div class="card shadow-sm mb-3">
<div class="card-header bg-white py-2 d-flex align-items-center justify-content-between"> <div class="card-header bg-white py-2">
<div>
<i class="bi bi-person-badge me-2 text-success"></i> <i class="bi bi-person-badge me-2 text-success"></i>
<strong>RBAC</strong> <strong>RBAC</strong>
<span class="text-muted small ms-2">ServiceAccount, Role, and RoleBinding applied to the namespace.</span> <span class="text-muted small ms-2">ServiceAccount, Role, and RoleBinding applied to the namespace.</span>
</div> </div>
</div>
<div class="card-body"> <div class="card-body">
@if (rbacMessage is not null) @if (rbacMessage is not null)
{ {
@@ -270,7 +341,6 @@ else
</div> </div>
} }
@* Service account settings *@
<div class="row g-2 mb-3 align-items-end"> <div class="row g-2 mb-3 align-items-end">
<div class="col-md-4"> <div class="col-md-4">
<label class="form-label small">Service account name</label> <label class="form-label small">Service account name</label>
@@ -279,8 +349,8 @@ else
</div> </div>
<div class="col-auto d-flex align-items-center gap-2" style="padding-bottom: 0.25rem;"> <div class="col-auto d-flex align-items-center gap-2" style="padding-bottom: 0.25rem;">
<div class="form-check mb-0"> <div class="form-check mb-0">
<input class="form-check-input" type="checkbox" id="rbacAutoMount" @bind="rbacAutoMount" /> <input class="form-check-input" type="checkbox" id="rbacAutoMountEnv" @bind="rbacAutoMount" />
<label class="form-check-label small" for="rbacAutoMount">Auto-mount token</label> <label class="form-check-label small" for="rbacAutoMountEnv">Auto-mount token</label>
</div> </div>
</div> </div>
<div class="col-auto"> <div class="col-auto">
@@ -296,7 +366,6 @@ else
</div> </div>
</div> </div>
@* Rules table *@
@if (rbacPolicy is not null) @if (rbacPolicy is not null)
{ {
<div class="mb-2 d-flex align-items-center justify-content-between"> <div class="mb-2 d-flex align-items-center justify-content-between">
@@ -382,20 +451,279 @@ else
} }
</div> </div>
</div> </div>
@* ── 4. Allowed Services ───────────────────────────────────────── *@
<div class="card shadow-sm mt-3">
<div class="card-header bg-white py-2">
<i class="bi bi-shield-lock me-2 text-secondary"></i>
<strong>Allowed Services</strong>
<span class="text-muted small ms-2">Restrict which databases, cache clusters, and storage buckets this app may link to. Empty = no restriction.</span>
</div>
<div class="card-body">
@* ── Allowed Databases ── *@
<div class="mb-4">
<div class="d-flex align-items-center justify-content-between mb-2">
<span class="small fw-medium"><i class="bi bi-database me-1 text-primary"></i>Allowed Databases</span>
<button class="btn btn-sm btn-outline-primary" @onclick="OpenAddDb">
<i class="bi bi-plus me-1"></i>Add
</button>
</div>
@if (allowedDbMessage is not null)
{
<div class="alert alert-danger py-1 small mb-2">@allowedDbMessage</div>
}
@if (showAddDb)
{
<div class="card border-primary mb-2">
<div class="card-body py-2">
@if (availableDbs is null)
{
<div class="spinner-border spinner-border-sm text-primary me-2"></div>
<span class="small text-muted">Loading databases…</span>
}
else if (availableDbs.Count == 0)
{
<span class="text-muted small">No databases found in this environment.</span>
}
else
{
<div class="d-flex gap-2 align-items-center flex-wrap">
<select class="form-select form-select-sm w-auto" @bind="newDbOptionId">
<option value="@Guid.Empty">Select database…</option>
@foreach (AvailableDatabaseOption opt in availableDbs)
{
<option value="@opt.Id">@opt.TypeLabel — @opt.DisplayName</option>
}
</select>
<button class="btn btn-sm btn-primary" @onclick="AddAllowedDatabase"
disabled="@(newDbOptionId == Guid.Empty || addingDb)">
@if (addingDb) { <span class="spinner-border spinner-border-sm me-1"></span> }
Add
</button>
<button class="btn btn-sm btn-outline-secondary" @onclick="() => showAddDb = false">Cancel</button>
</div>
}
</div>
</div>
}
@if (allowedDatabases.Count == 0 && !showAddDb)
{
<p class="text-muted small mb-0">No restrictions — all databases are available.</p>
}
else if (allowedDatabases.Count > 0)
{
<div class="list-group list-group-flush">
@foreach (AppAllowedDatabase entry in allowedDatabases)
{
<div class="list-group-item d-flex align-items-center justify-content-between px-0 py-1">
<div class="small">
<span class="badge bg-primary-subtle text-primary border border-primary-subtle me-2">@DbTypeLabel(entry)</span>
<span class="fw-medium">@DbAllowedName(entry)</span>
</div>
@if (confirmDeleteDbId != entry.Id)
{
<button class="btn btn-sm btn-outline-danger p-0 px-1"
@onclick="() => confirmDeleteDbId = entry.Id">
<i class="bi bi-x" style="font-size:.8rem;"></i>
</button>
}
else
{
<button class="btn btn-sm btn-danger" @onclick="() => DeleteAllowedDatabase(entry.Id)">Yes</button>
<button class="btn btn-sm btn-outline-secondary ms-1" @onclick="() => confirmDeleteDbId = null">No</button>
}
</div>
}
</div>
}
</div>
@* ── Allowed Cache Services ── *@
<div class="mb-4">
<div class="d-flex align-items-center justify-content-between mb-2">
<span class="small fw-medium"><i class="bi bi-lightning me-1 text-warning"></i>Allowed Cache Services</span>
<button class="btn btn-sm btn-outline-warning" @onclick="OpenAddCache">
<i class="bi bi-plus me-1"></i>Add
</button>
</div>
@if (allowedCacheMessage is not null)
{
<div class="alert alert-danger py-1 small mb-2">@allowedCacheMessage</div>
}
@if (showAddCache)
{
<div class="card border-warning mb-2">
<div class="card-body py-2">
@if (availableCaches is null)
{
<div class="spinner-border spinner-border-sm text-warning me-2"></div>
<span class="small text-muted">Loading Redis clusters…</span>
}
else if (availableCaches.Count == 0)
{
<span class="text-muted small">No Redis clusters found for this tenant.</span>
}
else
{
<div class="d-flex gap-2 align-items-center flex-wrap">
<select class="form-select form-select-sm w-auto" @bind="newCacheId">
<option value="@Guid.Empty">Select Redis cluster…</option>
@foreach (RedisCluster c in availableCaches)
{
<option value="@c.Id">@c.Name</option>
}
</select>
<button class="btn btn-sm btn-warning" @onclick="AddAllowedCache"
disabled="@(newCacheId == Guid.Empty || addingCache)">
@if (addingCache) { <span class="spinner-border spinner-border-sm me-1"></span> }
Add
</button>
<button class="btn btn-sm btn-outline-secondary" @onclick="() => showAddCache = false">Cancel</button>
</div>
}
</div>
</div>
}
@if (allowedCaches.Count == 0 && !showAddCache)
{
<p class="text-muted small mb-0">No restrictions — all Redis clusters are available.</p>
}
else if (allowedCaches.Count > 0)
{
<div class="list-group list-group-flush">
@foreach (AppAllowedCache entry in allowedCaches)
{
<div class="list-group-item d-flex align-items-center justify-content-between px-0 py-1">
<div class="small">
<i class="bi bi-lightning text-warning me-1"></i>
<span class="fw-medium">@entry.RedisCluster.Name</span>
</div>
@if (confirmDeleteCacheId != entry.Id)
{
<button class="btn btn-sm btn-outline-danger p-0 px-1"
@onclick="() => confirmDeleteCacheId = entry.Id">
<i class="bi bi-x" style="font-size:.8rem;"></i>
</button>
}
else
{
<button class="btn btn-sm btn-danger" @onclick="() => DeleteAllowedCache(entry.Id)">Yes</button>
<button class="btn btn-sm btn-outline-secondary ms-1" @onclick="() => confirmDeleteCacheId = null">No</button>
}
</div>
}
</div>
}
</div>
@* ── Allowed S3 Storage ── *@
<div>
<div class="d-flex align-items-center justify-content-between mb-2">
<span class="small fw-medium"><i class="bi bi-bucket me-1 text-info"></i>Allowed S3 Storage</span>
<button class="btn btn-sm btn-outline-info" @onclick="OpenAddStorage">
<i class="bi bi-plus me-1"></i>Add
</button>
</div>
@if (allowedStorageMessage is not null)
{
<div class="alert alert-danger py-1 small mb-2">@allowedStorageMessage</div>
}
@if (showAddStorage)
{
<div class="card border-info mb-2">
<div class="card-body py-2">
@if (availableStorages is null)
{
<div class="spinner-border spinner-border-sm text-info me-2"></div>
<span class="small text-muted">Loading storage buckets…</span>
}
else if (availableStorages.Count == 0)
{
<span class="text-muted small">No storage buckets found in this environment.</span>
}
else
{
<div class="d-flex gap-2 align-items-center flex-wrap">
<select class="form-select form-select-sm w-auto" @bind="newStorageId">
<option value="@Guid.Empty">Select storage bucket…</option>
@foreach (StorageLink s in availableStorages)
{
<option value="@s.Id">@s.Name (@s.Provider@(s.BucketName is not null ? $" · {s.BucketName}" : ""))</option>
}
</select>
<button class="btn btn-sm btn-info" @onclick="AddAllowedStorage"
disabled="@(newStorageId == Guid.Empty || addingStorage)">
@if (addingStorage) { <span class="spinner-border spinner-border-sm me-1"></span> }
Add
</button>
<button class="btn btn-sm btn-outline-secondary" @onclick="() => showAddStorage = false">Cancel</button>
</div>
}
</div>
</div>
}
@if (allowedStorages.Count == 0 && !showAddStorage)
{
<p class="text-muted small mb-0">No restrictions — all storage buckets are available.</p>
}
else if (allowedStorages.Count > 0)
{
<div class="list-group list-group-flush">
@foreach (AppAllowedStorage entry in allowedStorages)
{
<div class="list-group-item d-flex align-items-center justify-content-between px-0 py-1">
<div class="small">
<span class="badge bg-info-subtle text-info border border-info-subtle me-2">@entry.StorageLink.Provider</span>
<span class="fw-medium">@entry.StorageLink.Name</span>
@if (!string.IsNullOrWhiteSpace(entry.StorageLink.BucketName))
{
<code class="ms-1 text-muted">@entry.StorageLink.BucketName</code>
}
</div>
@if (confirmDeleteStorageId != entry.Id)
{
<button class="btn btn-sm btn-outline-danger p-0 px-1"
@onclick="() => confirmDeleteStorageId = entry.Id">
<i class="bi bi-x" style="font-size:.8rem;"></i>
</button>
}
else
{
<button class="btn btn-sm btn-danger" @onclick="() => DeleteAllowedStorage(entry.Id)">Yes</button>
<button class="btn btn-sm btn-outline-secondary ms-1" @onclick="() => confirmDeleteStorageId = null">No</button>
}
</div>
}
</div>
}
</div>
</div>
</div>
} }
@code { @code {
[Parameter, EditorRequired] public Guid AppId { get; set; } [Parameter, EditorRequired] public Guid AppId { get; set; }
[Parameter, EditorRequired] public Guid TenantId { get; set; } [Parameter, EditorRequired] public Guid TenantId { get; set; }
[Parameter, EditorRequired] public Guid EnvironmentId { get; set; }
private bool loading = true; private bool loading = true;
// Namespace // Namespace
private string ns = ""; private string nsValue = "";
private bool savingNs; private bool savingNs;
private string? nsMessage; private string? nsMessage;
private bool nsOk; private bool nsOk;
// Copy from environment
private bool showCopyFrom;
private List<Data.Environment>? otherEnvironments;
private Guid copyFromEnvId;
private bool confirmCopy, copying;
private string? copyMessage;
private bool copyOk;
// Quota // Quota
private AppQuota? quota; private AppQuota? quota;
private string qCpuReq = "", qCpuLim = "", qMemReq = "", qMemLim = ""; private string qCpuReq = "", qCpuLim = "", qMemReq = "", qMemLim = "";
@@ -438,10 +766,17 @@ else
loading = false; loading = false;
} }
protected override async Task OnParametersSetAsync()
{
if (showCopyFrom && otherEnvironments is null)
await LoadOtherEnvironments();
}
private async Task LoadAsync() private async Task LoadAsync()
{ {
AppGovernanceData data = await GovernanceService.LoadAsync(AppId); AppGovernanceData data = await GovernanceService.LoadAsync(AppId, EnvironmentId);
ns = data.Namespace ?? "";
nsValue = data.Namespace ?? "";
quota = data.Quota; quota = data.Quota;
if (quota is not null) if (quota is not null)
@@ -462,20 +797,66 @@ else
rbacSaName = rbacPolicy.ServiceAccountName; rbacSaName = rbacPolicy.ServiceAccountName;
rbacAutoMount = rbacPolicy.AutoMountToken; rbacAutoMount = rbacPolicy.AutoMountToken;
} }
allowedDatabases = data.AllowedDatabases;
allowedCaches = data.AllowedCaches;
allowedStorages = data.AllowedStorages;
} }
private async Task LoadOtherEnvironments()
{
List<Data.Environment> 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() private async Task SaveNamespace()
{ {
savingNs = true; nsMessage = null; savingNs = true; nsMessage = null;
await GovernanceService.SetNamespaceAsync(AppId, ns); await GovernanceService.SaveNamespaceAsync(AppId, EnvironmentId, nsValue);
savingNs = false; nsOk = true; nsValue = nsValue.Trim().ToLowerInvariant();
nsMessage = "Saved."; 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() private async Task SaveQuota()
{ {
savingQuota = true; quotaMessage = null; savingQuota = true; quotaMessage = null;
quota = await GovernanceService.SaveQuotaAsync(AppId, quota = await GovernanceService.SaveQuotaAsync(AppId, EnvironmentId,
qCpuReq, qCpuLim, qMemReq, qMemLim, qMaxPods, qMaxPvcs); qCpuReq, qCpuLim, qMemReq, qMemLim, qMaxPods, qMaxPvcs);
savingQuota = false; quotaOk = true; savingQuota = false; quotaOk = true;
quotaMessage = "Quota saved."; quotaMessage = "Quota saved.";
@@ -483,19 +864,21 @@ else
private async Task DeleteQuota() private async Task DeleteQuota()
{ {
await GovernanceService.DeleteQuotaAsync(AppId); await GovernanceService.DeleteQuotaAsync(AppId, EnvironmentId);
quota = null; quota = null;
qCpuReq = qCpuLim = qMemReq = qMemLim = ""; qCpuReq = qCpuLim = qMemReq = qMemLim = "";
qMaxPods = qMaxPvcs = null; qMaxPods = qMaxPvcs = null;
} }
// ── Network policies ───────────────────────────────────────────────────────
private async Task AddNetworkPolicy() private async Task AddNetworkPolicy()
{ {
addingNp = true; npMessage = null; addingNp = true; npMessage = null;
try try
{ {
AppNetworkPolicy p = await GovernanceService.AddNetworkPolicyAsync( AppNetworkPolicy p = await GovernanceService.AddNetworkPolicyAsync(
AppId, newNpName, newNpType, AppId, EnvironmentId, newNpName, newNpType,
newNpAllowNs, newNpCustomYaml); newNpAllowNs, newNpCustomYaml);
networkPolicies.Add(p); networkPolicies.Add(p);
showAddNp = false; showAddNp = false;
@@ -513,17 +896,19 @@ else
confirmDeleteNpId = null; confirmDeleteNpId = null;
} }
// ── RBAC ───────────────────────────────────────────────────────────────────
private async Task SaveRbac() private async Task SaveRbac()
{ {
savingRbac = true; rbacMessage = null; savingRbac = true; rbacMessage = null;
rbacPolicy = await GovernanceService.SaveRbacPolicyAsync(AppId, rbacSaName, rbacAutoMount); rbacPolicy = await GovernanceService.SaveRbacPolicyAsync(AppId, EnvironmentId, rbacSaName, rbacAutoMount);
savingRbac = false; rbacOk = true; savingRbac = false; rbacOk = true;
rbacMessage = "Saved."; rbacMessage = "Saved.";
} }
private async Task DeleteRbac() private async Task DeleteRbac()
{ {
await GovernanceService.DeleteRbacPolicyAsync(AppId); await GovernanceService.DeleteRbacPolicyAsync(AppId, EnvironmentId);
rbacPolicy = null; rbacSaName = ""; rbacAutoMount = false; rbacPolicy = null; rbacSaName = ""; rbacAutoMount = false;
} }
@@ -551,26 +936,49 @@ else
rbacPolicy?.Rules.Remove(rbacPolicy.Rules.First(r => r.Id == id)); rbacPolicy?.Rules.Remove(rbacPolicy.Rules.First(r => r.Id == id));
} }
private async Task ApplyToAllClusters() // ── Apply ──────────────────────────────────────────────────────────────────
private async Task ApplyToEnvironmentClusters()
{ {
applying = true; applyOutput = null; applying = true; applyOutput = null;
var sb = new System.Text.StringBuilder(); var sb = new System.Text.StringBuilder();
List<KubernetesCluster> clusters = await TenantService.GetClustersAsync(TenantId); // Only apply to clusters that have a deployment for this app+environment.
List<AppDeployment> 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); applyOutput = "No deployments found for this environment. Create a deployment first.";
sb.AppendLine($"── {cluster.Name} ──"); applySuccess = false;
sb.AppendLine(ok ? "✓ Applied" : $"✗ {output}"); applying = false;
if (!ok && !string.IsNullOrWhiteSpace(output)) return;
sb.AppendLine(output);
applySuccess = ok;
} }
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(); 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 private static string NpTypeLabel(AppNetworkPolicyType t) => t switch
@@ -582,4 +990,148 @@ else
AppNetworkPolicyType.Custom => "Custom YAML", AppNetworkPolicyType.Custom => "Custom YAML",
_ => t.ToString() _ => t.ToString()
}; };
// ── Allowed databases ──────────────────────────────────────────────────────
private List<AppAllowedDatabase> allowedDatabases = [];
private List<AvailableDatabaseOption>? 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<AppAllowedCache> allowedCaches = [];
private List<RedisCluster>? 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<AppAllowedStorage> allowedStorages = [];
private List<StorageLink>? 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;
}
} }

View File

@@ -31,21 +31,14 @@
</div> </div>
} }
@if (groups is null) <LoadingPanel Loading="@(groups is null)" LoadingText="Loading groups…">
@if (groups is not null && groups.Count == 0)
{ {
<div class="text-center py-4"> <EmptyState Icon="bi-diagram-3"
<div class="spinner-border spinner-border-sm text-primary" role="status"></div> Title="No groups yet"
<span class="ms-2 text-muted">Loading groups...</span> Message="Create one above to get started." />
</div>
} }
else if (groups.Count == 0) else if (groups is not null)
{
<div class="text-center py-5">
<i class="bi bi-diagram-3 text-muted" style="font-size: 3rem;"></i>
<p class="text-muted mt-2 mb-0">No groups yet. Create one above to get started.</p>
</div>
}
else
{ {
<div class="row row-cols-1 row-cols-md-2 row-cols-lg-3 g-3"> <div class="row row-cols-1 row-cols-md-2 row-cols-lg-3 g-3">
@foreach (Group group in groups) @foreach (Group group in groups)
@@ -90,6 +83,7 @@ else
} }
</div> </div>
} }
</LoadingPanel>
@code { @code {
[Parameter] public Guid TenantId { get; set; } [Parameter] public Guid TenantId { get; set; }

View File

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

View File

@@ -1,8 +1,26 @@
@using EntKube.Web.Data @using EntKube.Web.Data
@using EntKube.Web.Services @using EntKube.Web.Services
@inject IncidentService IncidentService @inject IncidentService IncidentService
@inject OnCallService OnCallService
@inject NotificationService NotificationService
@inject AuthenticationStateProvider AuthStateProvider @inject AuthenticationStateProvider AuthStateProvider
@* ── On-call banner ── *@
@if (currentOnCall is not null)
{
<div class="alert alert-success d-flex align-items-center gap-2 py-2 mb-3">
<span class="bi bi-person-check-fill"></span>
<span class="small">
On call now: <strong>@currentOnCall.AssigneeName</strong>
@if (!string.IsNullOrEmpty(currentOnCall.AssigneeEmail))
{
<a href="mailto:@currentOnCall.AssigneeEmail" class="ms-1">@currentOnCall.AssigneeEmail</a>
}
— until @currentOnCall.EndsAt.ToLocalTime().ToString("MMM d HH:mm")
</span>
</div>
}
@if (stats is not null) @if (stats is not null)
{ {
<div class="row g-2 mb-3"> <div class="row g-2 mb-3">
@@ -42,6 +60,20 @@
<div class="text-muted small">MTTA</div> <div class="text-muted small">MTTA</div>
</div> </div>
</div> </div>
@{
int unassignedActive = incidents.Count(i =>
i.AssignedTo is null && i.Status != IncidentStatus.Resolved);
}
@if (unassignedActive > 0)
{
<div class="col-auto">
<div class="card border-0 bg-warning-subtle text-center px-3 py-2"
style="cursor:pointer" @onclick="() => { filterUnassigned = true; _ = LoadIncidents(); }">
<div class="fw-bold fs-5 text-warning">@unassignedActive</div>
<div class="text-muted small">Unassigned</div>
</div>
</div>
}
@if (stats.Daily.Count > 1) @if (stats.Daily.Count > 1)
{ {
int maxCount = stats.Daily.Max(d => d.Count); int maxCount = stats.Daily.Max(d => d.Count);
@@ -87,45 +119,82 @@
<option value="30">Last 30 days</option> <option value="30">Last 30 days</option>
<option value="90">Last 90 days</option> <option value="90">Last 90 days</option>
</select> </select>
<div class="form-check ms-1">
<input class="form-check-input" type="checkbox" id="chkUnassigned" @bind="filterUnassigned" @bind:after="LoadIncidents" />
<label class="form-check-label small" for="chkUnassigned">Unassigned only</label>
</div>
<input class="form-control form-control-sm ms-1" style="max-width:200px"
@bind="searchText" @bind:event="oninput" placeholder="Search alerts…" />
@if (selectedIds.Count > 0)
{
<span class="text-muted small">@selectedIds.Count selected</span>
<button class="btn btn-sm btn-outline-warning" @onclick="BulkAcknowledge">
<span class="bi bi-check-all me-1"></span>Ack
</button>
<button class="btn btn-sm btn-outline-success" @onclick="BulkResolve">
<span class="bi bi-check-circle me-1"></span>Resolve
</button>
<button class="btn btn-sm btn-outline-secondary" @onclick="() => selectedIds.Clear()">Clear</button>
}
<button class="btn btn-sm btn-outline-secondary" @onclick="LoadIncidents"> <button class="btn btn-sm btn-outline-secondary" @onclick="LoadIncidents">
<span class="bi bi-arrow-clockwise"></span> Refresh <span class="bi bi-arrow-clockwise"></span> Refresh
</button> </button>
<span class="ms-auto text-muted small">@incidents.Count incident@(incidents.Count == 1 ? "" : "s")</span> <span class="ms-auto text-muted small">@incidents.Count incident@(incidents.Count == 1 ? "" : "s")</span>
</div> </div>
@if (isLoading) <LoadingPanel Loading="@isLoading" LoadingText="Loading incidents…">
@if (!isLoading && incidents.Count == 0)
{ {
<div class="text-center py-4 text-muted"><span class="bi bi-hourglass-split"></span> Loading incidents…</div> <EmptyState Icon="bi-check-circle"
Title="No incidents found"
Message="No incidents match the selected filters." />
} }
else if (incidents.Count == 0) else if (!isLoading)
{
<div class="text-center py-5 text-muted">
<span class="bi bi-check-circle display-4 d-block mb-2"></span>
No incidents found for the selected filters.
</div>
}
else
{ {
<div class="table-responsive"> <div class="table-responsive">
<table class="table table-hover align-middle"> <table class="table table-hover align-middle">
<thead class="table-light"> <thead class="table-light">
<tr> <tr>
<th style="width:32px">
<input type="checkbox" class="form-check-input"
@onchange="e => ToggleSelectAll((bool)(e.Value ?? false))" />
</th>
<th style="width:90px">Severity</th> <th style="width:90px">Severity</th>
<th>Alert</th> <th>Alert</th>
<th>Cluster</th> <th>Cluster</th>
<th>Started</th> <th>Started</th>
<th>Duration</th> <th>Duration</th>
<th style="width:110px">Status</th> <th style="width:110px">Status</th>
<th style="width:180px">Actions</th> <th style="width:200px">Actions</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@foreach (AlertIncident incident in incidents) @foreach (AlertIncident incident in incidents)
{ {
bool expanded = expandedId == incident.Id; bool expanded = expandedId == incident.Id;
<tr class="@(expanded ? "table-active" : "")" style="cursor:pointer" @onclick="() => ToggleExpand(incident.Id)"> bool selected = selectedIds.Contains(incident.Id);
<td>@SeverityBadge(incident.Severity)</td> <tr class="@(expanded ? "table-active" : selected ? "table-warning" : "")"
<td class="fw-semibold">@incident.AlertName</td> style="cursor:pointer" @onclick="() => ToggleExpand(incident.Id)">
<td @onclick:stopPropagation="true">
<input type="checkbox" class="form-check-input" checked="@selected"
@onchange="() => ToggleSelect(incident.Id)" />
</td>
<td>
@SeverityBadge(incident.Severity)
@if (incident.EscalatedAt.HasValue)
{
<div class="mt-1"><span class="badge bg-danger-subtle text-danger border border-danger small">Escalated</span></div>
}
</td>
<td>
<div class="fw-semibold small">@incident.AlertName</div>
@if (incident.AssignedTo is not null)
{
<div class="text-muted" style="font-size:.77rem">
<span class="bi bi-person me-1"></span>@incident.AssignedTo
</div>
}
</td>
<td class="text-muted small">@incident.Cluster?.Name</td> <td class="text-muted small">@incident.Cluster?.Name</td>
<td class="text-muted small">@incident.StartsAt.ToLocalTime().ToString("MMM d HH:mm")</td> <td class="text-muted small">@incident.StartsAt.ToLocalTime().ToString("MMM d HH:mm")</td>
<td class="text-muted small">@FormatDuration(incident)</td> <td class="text-muted small">@FormatDuration(incident)</td>
@@ -144,50 +213,162 @@ else
</button> </button>
} }
<button class="btn btn-sm btn-outline-secondary" @onclick="() => ToggleExpand(incident.Id)"> <button class="btn btn-sm btn-outline-secondary" @onclick="() => ToggleExpand(incident.Id)">
Notes @(incident.Notes.Count > 0 ? $"({incident.Notes.Count})" : "") Details @(incident.Notes.Count > 0 ? $"({incident.Notes.Count})" : "")
</button> </button>
</td> </td>
</tr> </tr>
@if (expanded) @if (expanded)
{ {
<tr> <tr>
<td colspan="7" class="bg-light px-4 py-3"> <td colspan="8" class="bg-light px-4 py-3">
<div class="row g-4">
@* ── Left: Timeline ── *@
<div class="col-12 col-lg-5">
@if (!string.IsNullOrEmpty(incident.Summary)) @if (!string.IsNullOrEmpty(incident.Summary))
{ {
<p class="mb-1"><strong>Summary:</strong> @incident.Summary</p> <p class="mb-1 small"><strong>Summary:</strong> @incident.Summary</p>
} }
@if (!string.IsNullOrEmpty(incident.Description)) @if (!string.IsNullOrEmpty(incident.Description))
{ {
<p class="mb-2 text-muted">@incident.Description</p> <p class="mb-2 small text-muted">@incident.Description</p>
} }
@if (incident.AcknowledgedBy is not null) @if (!string.IsNullOrEmpty(incident.RunbookUrl))
{ {
<p class="mb-2 small text-muted"> <p class="mb-3">
Acknowledged by <strong>@incident.AcknowledgedBy</strong> <a href="@incident.RunbookUrl" target="_blank" rel="noopener noreferrer"
at @incident.AcknowledgedAt?.ToLocalTime().ToString("MMM d HH:mm") class="btn btn-sm btn-outline-secondary">
<span class="bi bi-book me-1"></span>Runbook
</a>
</p> </p>
} }
@if (incident.Notes.Count > 0) <strong class="small d-block mb-2">Timeline</strong>
<div style="position:relative;padding-left:28px">
<div style="position:absolute;left:9px;top:0;bottom:0;width:2px;background:#dee2e6"></div>
@foreach (TimelineEvent ev in BuildTimeline(incident))
{ {
<div class="mb-3"> <div style="position:relative;margin-bottom:14px">
<strong class="small">Notes</strong> <span style="position:absolute;left:-19px;top:2px;width:20px;height:20px;border-radius:50%;background:white;border:2px solid #dee2e6;display:flex;align-items:center;justify-content:center;font-size:.72rem"
@foreach (IncidentNote note in incident.Notes) class="@ev.IconColor">
<i class="bi @ev.Icon"></i>
</span>
<div class="d-flex gap-2 align-items-baseline">
<span class="small fw-semibold">@ev.Label</span>
<span class="text-muted" style="font-size:.72rem">@ev.At.ToLocalTime().ToString("MMM d HH:mm")</span>
</div>
@if (!string.IsNullOrEmpty(ev.Detail))
{ {
<div class="border-start border-3 border-secondary ps-3 mb-2 mt-1"> <div class="text-muted small mt-1">@ev.Detail</div>
<div class="small text-muted">@note.Author · @note.CreatedAt.ToLocalTime().ToString("MMM d HH:mm")</div> }
<div>@note.Content</div>
</div> </div>
} }
</div> </div>
</div>
@* ── Right: Actions ── *@
<div class="col-12 col-lg-7">
@* Assignment *@
<div class="mb-3">
<strong class="small d-block mb-2">Assignment</strong>
@if (incident.AssignedTo is not null)
{
<p class="mb-1 small text-muted">
<span class="bi bi-person-check me-1"></span>
Assigned to <strong>@incident.AssignedTo</strong>
@if (incident.AssignedAt.HasValue)
{
<span>at @incident.AssignedAt.Value.ToLocalTime().ToString("MMM d HH:mm")</span>
}
</p>
}
@if (assigningId == incident.Id)
{
<div class="d-flex gap-2 align-items-center mt-1">
<input class="form-control form-control-sm" style="max-width:220px"
@bind="assigneeInput" placeholder="Assignee name or email" />
<button class="btn btn-sm btn-primary"
@onclick="() => SubmitAssign(incident)"
disabled="@string.IsNullOrWhiteSpace(assigneeInput)">
Assign
</button>
<button class="btn btn-sm btn-outline-secondary"
@onclick="() => assigningId = null">Cancel</button>
</div>
}
else
{
<button class="btn btn-sm btn-outline-secondary mt-1"
@onclick="() => StartAssign(incident)">
<span class="bi bi-person-plus me-1"></span>
@(incident.AssignedTo is null ? "Assign" : "Reassign")
</button>
}
</div>
@* Notification delivery history *@
@if (deliveriesByIncident.TryGetValue(incident.Id, out List<NotificationDelivery>? deliveries) && deliveries.Count > 0)
{
<div class="mb-3">
<strong class="small d-block mb-1">Notification history</strong>
<div class="table-responsive">
<table class="table table-sm table-borderless mb-0" style="font-size:.77rem">
<tbody>
@foreach (NotificationDelivery d in deliveries)
{
<tr>
<td class="py-0 text-muted">@d.SentAt.ToLocalTime().ToString("MMM d HH:mm")</td>
<td class="py-0">@d.Channel?.Name</td>
<td class="py-0 text-muted">@(d.IsFiring ? "FIRING" : "RESOLVED")</td>
<td class="py-0">
@if (d.Success)
{
<span class="text-success"><span class="bi bi-check-circle me-1"></span>Sent</span>
}
else
{
<span class="text-danger" title="@d.Error"><span class="bi bi-x-circle me-1"></span>Failed</span>
}
</td>
</tr>
}
</tbody>
</table>
</div>
</div>
} }
<div class="d-flex gap-2"> @* Re-notify *@
<textarea class="form-control form-control-sm" rows="2" placeholder="Add a note…" <div class="mb-3 d-flex gap-2 align-items-center">
@bind="noteText" style="max-width:400px"></textarea> <button class="btn btn-sm btn-outline-info"
<button class="btn btn-sm btn-primary align-self-end" @onclick="() => AddNote(incident)"> @onclick="() => ReNotify(incident)"
Add Note disabled="@(reNotifyingId == incident.Id)">
@if (reNotifyingId == incident.Id)
{
<span class="spinner-border spinner-border-sm me-1"></span>
}
<span class="bi bi-send me-1"></span>Re-notify
</button> </button>
@if (reNotifyResults.TryGetValue(incident.Id, out string? reNotifyMsg))
{
<span class="small @(reNotifyMsg.StartsWith("✓") ? "text-success" : "text-danger")">@reNotifyMsg</span>
}
</div>
@* Add note *@
<strong class="small d-block mb-1">Add note</strong>
<div class="d-flex gap-2">
<textarea class="form-control form-control-sm" rows="2"
placeholder="Add a note…"
@bind="noteText" style="max-width:360px"></textarea>
<button class="btn btn-sm btn-primary align-self-end"
@onclick="() => AddNote(incident)">
Add
</button>
</div>
</div>
</div> </div>
</td> </td>
</tr> </tr>
@@ -197,6 +378,7 @@ else
</table> </table>
</div> </div>
} }
</LoadingPanel>
@code { @code {
[Parameter] public required Guid TenantId { get; set; } [Parameter] public required Guid TenantId { get; set; }
@@ -213,11 +395,26 @@ else
private bool isLoading = true; private bool isLoading = true;
private string? currentUser; private string? currentUser;
private HashSet<Guid> selectedIds = [];
private Guid? assigningId;
private string assigneeInput = "";
private bool filterUnassigned;
private string searchText = "";
private OnCallShift? currentOnCall;
private Dictionary<Guid, List<NotificationDelivery>> deliveriesByIncident = new();
private Guid? reNotifyingId;
private Dictionary<Guid, string> reNotifyResults = new();
protected override async Task OnInitializedAsync() protected override async Task OnInitializedAsync()
{ {
AuthenticationState authState = await AuthStateProvider.GetAuthenticationStateAsync(); AuthenticationState authState = await AuthStateProvider.GetAuthenticationStateAsync();
currentUser = authState.User.Identity?.Name ?? "Unknown"; 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() private async Task LoadStats()
@@ -230,6 +427,8 @@ else
isLoading = true; isLoading = true;
StateHasChanged(); StateHasChanged();
try
{
IncidentStatus? status = filterStatus switch IncidentStatus? status = filterStatus switch
{ {
"Active" => IncidentStatus.Active, "Active" => IncidentStatus.Active,
@@ -248,21 +447,127 @@ else
from: DateTime.UtcNow.AddDays(-filterDays), from: DateTime.UtcNow.AddDays(-filterDays),
to: null); to: null);
// Collect unique clusters from results if (filterUnassigned)
incidents = incidents.Where(i => i.AssignedTo is null).ToList();
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 clusters = incidents
.Where(i => i.Cluster is not null) .Where(i => i.Cluster is not null)
.Select(i => i.Cluster!) .Select(i => i.Cluster!)
.DistinctBy(c => c.Id) .DistinctBy(c => c.Id)
.OrderBy(c => c.Name) .OrderBy(c => c.Name)
.ToList(); .ToList();
}
catch
{
incidents = [];
}
finally
{
isLoading = false; isLoading = false;
} }
}
private void ToggleExpand(Guid id) private void ToggleExpand(Guid id)
{ {
expandedId = expandedId == id ? null : id; if (expandedId == id)
{
expandedId = null;
}
else
{
expandedId = id;
_ = LoadDeliveriesAsync(id);
}
noteText = ""; noteText = "";
if (assigningId != id) assigningId = null;
}
private async Task LoadDeliveriesAsync(Guid incidentId)
{
List<NotificationDelivery> 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) private async Task Acknowledge(AlertIncident incident)
@@ -286,6 +591,36 @@ else
expandedId = incident.Id; expandedId = incident.Id;
} }
private record TimelineEvent(DateTime At, string Icon, string IconColor, string Label, string Detail);
private static List<TimelineEvent> BuildTimeline(AlertIncident incident)
{
List<TimelineEvent> 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) private static string FormatDuration(AlertIncident incident)
{ {
DateTime end = incident.EndsAt ?? DateTime.UtcNow; DateTime end = incident.EndsAt ?? DateTime.UtcNow;

View File

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

View File

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

View File

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

View File

@@ -3,6 +3,7 @@
@using Microsoft.EntityFrameworkCore @using Microsoft.EntityFrameworkCore
@inject IDbContextFactory<ApplicationDbContext> DbFactory @inject IDbContextFactory<ApplicationDbContext> DbFactory
@inject NotificationService NotificationService @inject NotificationService NotificationService
@inject NotificationProviderConfigService ProviderConfigService
<div class="d-flex justify-content-between align-items-center mb-3"> <div class="d-flex justify-content-between align-items-center mb-3">
<h6 class="mb-0">Notification Channels</h6> <h6 class="mb-0">Notification Channels</h6>
@@ -13,10 +14,9 @@
@if (channels.Count == 0) @if (channels.Count == 0)
{ {
<div class="text-center py-5 text-muted"> <EmptyState Icon="bi-send"
<span class="bi bi-send display-4 d-block mb-2"></span> Title="No notification channels"
No notification channels configured. Add a channel to get alerted via Slack, Teams, email, or webhook. Message="Add a channel to get alerted via Slack, Teams, email, or webhook." />
</div>
} }
else else
{ {
@@ -109,13 +109,45 @@ else
<input class="form-control" @bind="newName" placeholder="e.g. Ops Slack" /> <input class="form-control" @bind="newName" placeholder="e.g. Ops Slack" />
</div> </div>
@if (newType == NotificationChannelType.Slack || newType == NotificationChannelType.Teams) @if (newType == NotificationChannelType.Slack)
{ {
<div class="mb-3"> <div class="mb-3">
<label class="form-label">Webhook URL</label> <label class="form-label">Webhook URL</label>
<input class="form-control" @bind="newWebhookUrl" placeholder="https://hooks.slack.com/…" /> <input class="form-control" @bind="newWebhookUrl" placeholder="https://hooks.slack.com/services/…" />
</div> </div>
} }
else if (newType == NotificationChannelType.Teams)
{
@if (teamsGraphConfigured)
{
<div class="alert alert-info py-2 small mb-3">
<i class="bi bi-microsoft-teams me-1"></i>
Teams Graph API is configured. Messages will be sent via Microsoft Graph.
</div>
<div class="mb-3">
<label class="form-label">Team ID</label>
<input class="form-control font-monospace" @bind="newTeamId" placeholder="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" />
<div class="form-text">Found in Teams → right-click team → Get link to team</div>
</div>
<div class="mb-3">
<label class="form-label">Channel ID</label>
<input class="form-control font-monospace" @bind="newChannelId" placeholder="19:xxxxxxxxxxxxxxxx@thread.tacv2" />
<div class="form-text">Found in Teams → right-click channel → Get link to channel</div>
</div>
}
else
{
<div class="alert alert-warning py-2 small mb-3">
<i class="bi bi-exclamation-triangle me-1"></i>
Teams Graph API is not configured. Using legacy incoming webhook.
<a href="/admin/notifications" target="_blank">Configure it here</a>.
</div>
<div class="mb-3">
<label class="form-label">Webhook URL</label>
<input class="form-control" @bind="newWebhookUrl" placeholder="https://…webhook.office.com/…" />
</div>
}
}
else if (newType == NotificationChannelType.Email) else if (newType == NotificationChannelType.Email)
{ {
<div class="mb-3"> <div class="mb-3">
@@ -178,12 +210,20 @@ else
private string newWebhookUrl = ""; private string newWebhookUrl = "";
private string newEmail = ""; private string newEmail = "";
private string newBearerToken = ""; private string newBearerToken = "";
private string newTeamId = "";
private string newChannelId = "";
private AlertSeverityFilter newSeverityFilter = AlertSeverityFilter.All; private AlertSeverityFilter newSeverityFilter = AlertSeverityFilter.All;
private string? modalError; private string? modalError;
private Guid? testingId; private Guid? testingId;
private Dictionary<Guid, string> testResults = new(); private Dictionary<Guid, string> 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() private async Task LoadChannels()
{ {
@@ -212,6 +252,8 @@ else
newWebhookUrl = ""; newWebhookUrl = "";
newEmail = ""; newEmail = "";
newBearerToken = ""; newBearerToken = "";
newTeamId = "";
newChannelId = "";
newSeverityFilter = AlertSeverityFilter.All; newSeverityFilter = AlertSeverityFilter.All;
modalError = null; modalError = null;
} }
@@ -262,7 +304,17 @@ else
{ {
return newType switch 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) !string.IsNullOrWhiteSpace(newWebhookUrl)
? System.Text.Json.JsonSerializer.Serialize(new { webhookUrl = newWebhookUrl.Trim() }) ? System.Text.Json.JsonSerializer.Serialize(new { webhookUrl = newWebhookUrl.Trim() })
: throw new InvalidOperationException("Webhook URL is required."), : throw new InvalidOperationException("Webhook URL is required."),

View File

@@ -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)
{
<div class="alert alert-success d-flex align-items-center gap-2 py-2 mb-3">
<span class="bi bi-person-check-fill fs-5"></span>
<div>
<strong>@currentShift.AssigneeName</strong> is currently on call
@if (!string.IsNullOrEmpty(currentShift.AssigneeEmail))
{
<span class="text-muted ms-1">— <a href="mailto:@currentShift.AssigneeEmail">@currentShift.AssigneeEmail</a></span>
}
<span class="text-muted ms-2 small">until @currentShift.EndsAt.ToLocalTime().ToString("MMM d HH:mm")</span>
<span class="badge bg-success ms-2 small">@currentShift.Schedule.Name</span>
</div>
</div>
}
else
{
<div class="alert alert-warning d-flex align-items-center gap-2 py-2 mb-3">
<span class="bi bi-person-exclamation fs-5"></span>
<span>No one is currently on call. Add a shift to cover now.</span>
</div>
}
@* ── Schedules + Create ── *@
<div class="d-flex justify-content-between align-items-center mb-3">
<h6 class="mb-0">Schedules</h6>
<button class="btn btn-sm btn-primary" @onclick="() => showCreateSchedule = !showCreateSchedule">
<span class="bi bi-plus-circle me-1"></span>New Schedule
</button>
</div>
@if (showCreateSchedule)
{
<div class="card border-primary mb-3">
<div class="card-body">
<div class="row g-2 mb-2">
<div class="col-md-5">
<label class="form-label small">Name</label>
<input class="form-control form-control-sm" @bind="newScheduleName" placeholder="Primary On-Call" />
</div>
<div class="col-md-7">
<label class="form-label small">Description</label>
<input class="form-control form-control-sm" @bind="newScheduleDescription" placeholder="Optional description" />
</div>
</div>
@if (!string.IsNullOrEmpty(scheduleError))
{
<div class="alert alert-danger py-1 small mb-2">@scheduleError</div>
}
<div class="d-flex gap-2">
<button class="btn btn-sm btn-primary" @onclick="CreateSchedule"
disabled="@(string.IsNullOrWhiteSpace(newScheduleName))">
Create
</button>
<button class="btn btn-sm btn-outline-secondary" @onclick="() => showCreateSchedule = false">Cancel</button>
</div>
</div>
</div>
}
@if (schedules.Count == 0 && !isLoading)
{
<EmptyState Icon="bi-calendar2-week"
Title="No schedules"
Message="Create a schedule to track who is on call." />
}
else
{
@foreach (OnCallSchedule schedule in schedules)
{
bool isExpanded = expandedScheduleId == schedule.Id;
<div class="card mb-3 @(isExpanded ? "border-primary" : "")">
<div class="card-header d-flex align-items-center py-2 gap-2" style="cursor:pointer"
@onclick="() => ToggleSchedule(schedule.Id)">
<span class="bi bi-calendar2-week text-primary"></span>
<strong class="me-auto">@schedule.Name</strong>
@if (!string.IsNullOrEmpty(schedule.Description))
{
<span class="text-muted small me-2">@schedule.Description</span>
}
@if (!schedule.IsEnabled)
{
<span class="badge bg-secondary">Disabled</span>
}
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)
{
<span class="badge bg-success">Active now</span>
}
<span class="text-muted small">@schedule.Shifts.Count shift@(schedule.Shifts.Count == 1 ? "" : "s")</span>
}
<button class="btn btn-sm btn-outline-danger ms-2"
@onclick:stopPropagation="true"
@onclick="() => DeleteSchedule(schedule)">
<span class="bi bi-trash"></span>
</button>
<span class="bi bi-chevron-@(isExpanded ? "up" : "down") text-muted"></span>
</div>
@if (isExpanded)
{
<div class="card-body">
@* ── Shifts list ── *@
@if (schedule.Shifts.Count == 0)
{
<p class="text-muted small mb-3">No shifts yet. Add the first shift below.</p>
}
else
{
<div class="table-responsive mb-3">
<table class="table table-sm align-middle">
<thead class="table-light">
<tr>
<th>Assignee</th>
<th>Start</th>
<th>End</th>
<th>Status</th>
<th style="width:60px"></th>
</tr>
</thead>
<tbody>
@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;
<tr class="@(active ? "table-success" : past ? "text-muted" : "")">
<td>
<div class="fw-semibold small">@shift.AssigneeName</div>
@if (!string.IsNullOrEmpty(shift.AssigneeEmail))
{
<div class="text-muted" style="font-size:.77rem">@shift.AssigneeEmail</div>
}
</td>
<td class="small">@shift.StartsAt.ToLocalTime().ToString("MMM d HH:mm")</td>
<td class="small">@shift.EndsAt.ToLocalTime().ToString("MMM d HH:mm")</td>
<td>
@if (active)
{
<span class="badge bg-success">Active</span>
}
else if (past)
{
<span class="badge bg-secondary">Past</span>
}
else
{
<span class="badge bg-info text-dark">Upcoming</span>
}
</td>
<td>
<button class="btn btn-sm btn-outline-danger"
@onclick="() => DeleteShift(shift)">
<span class="bi bi-trash"></span>
</button>
</td>
</tr>
}
</tbody>
</table>
</div>
}
@* ── Add shift form ── *@
@if (addShiftScheduleId == schedule.Id)
{
<div class="border rounded p-3 bg-light">
<h6 class="mb-2 small fw-semibold">Add Shift</h6>
<div class="row g-2 mb-2">
<div class="col-md-4">
<label class="form-label small">Assignee name</label>
<input class="form-control form-control-sm" @bind="newShiftName" placeholder="Alice Smith" />
</div>
<div class="col-md-4">
<label class="form-label small">Email (optional)</label>
<input class="form-control form-control-sm" @bind="newShiftEmail" placeholder="alice@example.com" />
</div>
<div class="col-md-4">
<label class="form-label small">Notes</label>
<input class="form-control form-control-sm" @bind="newShiftNotes" placeholder="Optional notes" />
</div>
</div>
<div class="row g-2 mb-2">
<div class="col-md-4">
<label class="form-label small">Starts at (local time)</label>
<input type="datetime-local" class="form-control form-control-sm" @bind="newShiftStartsAt" />
</div>
<div class="col-md-4">
<label class="form-label small">Ends at (local time)</label>
<input type="datetime-local" class="form-control form-control-sm" @bind="newShiftEndsAt" />
</div>
</div>
@if (!string.IsNullOrEmpty(shiftError))
{
<div class="alert alert-danger py-1 small mb-2">@shiftError</div>
}
<div class="d-flex gap-2">
<button class="btn btn-sm btn-primary" @onclick="() => AddShift(schedule.Id)"
disabled="@(string.IsNullOrWhiteSpace(newShiftName))">
Add Shift
</button>
<button class="btn btn-sm btn-outline-secondary" @onclick="CancelAddShift">Cancel</button>
</div>
</div>
}
else
{
<button class="btn btn-sm btn-outline-primary" @onclick="() => StartAddShift(schedule.Id)">
<span class="bi bi-plus-circle me-1"></span>Add Shift
</button>
}
</div>
}
</div>
}
}
@code {
[Parameter] public required Guid TenantId { get; set; }
private List<OnCallSchedule> 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<List<OnCallSchedule>> schedulesTask = OnCallService.GetSchedulesAsync(TenantId);
Task<OnCallShift?> 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;
}
}

View File

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

View File

@@ -0,0 +1,91 @@
@using EntKube.Web.Data
@* ── Server record edit / create form ── *@
<div class="row g-2">
<div class="col-md-6">
<label class="form-label small mb-1">Display name <span class="text-danger">*</span></label>
<input class="form-control form-control-sm" @bind="Server.DisplayName" placeholder="e.g. node-01" />
</div>
<div class="col-md-3">
<label class="form-label small mb-1">Provider</label>
<select class="form-select form-select-sm" @bind="Server.Provider">
@foreach (ServerProvider p in Enum.GetValues<ServerProvider>())
{
<option value="@p">@p</option>
}
</select>
</div>
<div class="col-md-3">
<label class="form-label small mb-1">Link to node</label>
<input class="form-control form-control-sm font-monospace" @bind="Server.NodeName"
placeholder="node-1.example.com" />
</div>
<div class="col-md-3">
<label class="form-label small mb-1">IP address</label>
<input class="form-control form-control-sm font-monospace" @bind="Server.IpAddress" placeholder="10.0.0.1" />
</div>
<div class="col-md-3">
<label class="form-label small mb-1">Management IP</label>
<input class="form-control form-control-sm font-monospace" @bind="Server.ManagementIpAddress" placeholder="10.0.1.1" />
</div>
<div class="col-md-6">
<label class="form-label small mb-1">OS distribution</label>
<input class="form-control form-control-sm" @bind="Server.OsDistribution" placeholder="Ubuntu 22.04 LTS" />
</div>
<div class="col-md-2">
<label class="form-label small mb-1">CPU cores</label>
<input class="form-control form-control-sm" type="number" min="1" @bind="Server.CpuCores" />
</div>
<div class="col-md-2">
<label class="form-label small mb-1">RAM (GB)</label>
<input class="form-control form-control-sm" type="number" min="1" @bind="Server.RamGb" />
</div>
<div class="col-md-2">
<label class="form-label small mb-1">Disk (GB)</label>
<input class="form-control form-control-sm" type="number" min="1" @bind="Server.DiskGb" />
</div>
<div class="col-md-6">
<label class="form-label small mb-1">Location / zone / datacenter</label>
<input class="form-control form-control-sm" @bind="Server.Location" placeholder="us-east-1a / rack-B2" />
</div>
<div class="col-md-3">
<label class="form-label small mb-1">SSH user</label>
<input class="form-control form-control-sm font-monospace" @bind="Server.SshUser" placeholder="ubuntu" />
</div>
<div class="col-md-2">
<label class="form-label small mb-1">SSH port</label>
<input class="form-control form-control-sm" type="number" min="1" max="65535" @bind="Server.SshPort" />
</div>
<div class="col-md-7">
<label class="form-label small mb-1">Jump host</label>
<input class="form-control form-control-sm font-monospace" @bind="Server.JumpHost"
placeholder="bastion.example.com:22 (optional)" />
</div>
<div class="col-12">
<label class="form-label small mb-1">Notes</label>
<textarea class="form-control form-control-sm" rows="2" @bind="Server.Notes"
placeholder="Maintenance history, special config, caveats…"></textarea>
</div>
<div class="col-12 d-flex gap-2 mt-1">
<button class="btn btn-sm btn-primary" @onclick="HandleSave"
disabled="@(string.IsNullOrWhiteSpace(Server.DisplayName))">
<i class="bi bi-check-lg me-1"></i>Save
</button>
<button class="btn btn-sm btn-outline-secondary" @onclick="() => OnCancel.InvokeAsync()">
Cancel
</button>
</div>
</div>
@code {
[Parameter, EditorRequired] public ClusterServer Server { get; set; } = null!;
[Parameter, EditorRequired] public EventCallback<ClusterServer> OnSave { get; set; }
[Parameter, EditorRequired] public EventCallback OnCancel { get; set; }
private Task HandleSave() => OnSave.InvokeAsync(Server);
}

View File

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

View File

@@ -10,14 +10,8 @@
External providers are managed as vault-backed links. External providers are managed as vault-backed links.
=========================================================================== *@ =========================================================================== *@
@if (loading) <LoadingPanel Loading="@loading" LoadingText="Discovering storage resources…">
{ @if (!loading)
<div class="text-center py-5">
<div class="spinner-border text-primary" role="status"></div>
<p class="text-muted mt-2">Discovering storage resources...</p>
</div>
}
else
{ {
@* ── MinIO Section ── *@ @* ── MinIO Section ── *@
<div class="mb-5"> <div class="mb-5">
@@ -462,8 +456,7 @@ else
<div> <div>
<h6 class="text-muted"><i class="bi bi-shield-lock me-1"></i>Bucket Policy (JSON)</h6> <h6 class="text-muted"><i class="bi bi-shield-lock me-1"></i>Bucket Policy (JSON)</h6>
<div class="mb-2"> <div class="mb-2">
<textarea class="form-control form-control-sm font-monospace" rows="8" @bind="bucketPolicyJson" <YamlEditor @bind-Value="bucketPolicyJson" Language="json" />
placeholder='{"Version": "2012-10-17", "Statement": [...]}'></textarea>
</div> </div>
<div class="d-flex gap-2"> <div class="d-flex gap-2">
<button class="btn btn-sm btn-primary" @onclick="SavePolicy" disabled="@manageSaving"> <button class="btn btn-sm btn-primary" @onclick="SavePolicy" disabled="@manageSaving">
@@ -834,6 +827,7 @@ else
</button> </button>
</div> </div>
} }
</LoadingPanel>
@code { @code {
[Parameter] public Guid TenantId { get; set; } [Parameter] public Guid TenantId { get; set; }

View File

@@ -0,0 +1,80 @@
@using EntKube.Web.Data
@using EntKube.Web.Services
@using Microsoft.EntityFrameworkCore
@inject IDbContextFactory<ApplicationDbContext> DbFactory
@* Cluster picker — AlertRulesPanel is cluster-scoped *@
<div class="d-flex align-items-center gap-3 mb-4">
<label class="form-label small text-muted mb-0 text-nowrap">Cluster</label>
<select class="form-select form-select-sm" style="min-width:220px"
@bind="selectedClusterId" @bind:after="OnClusterChanged">
<option value="">— select a cluster —</option>
@foreach (ClusterOption opt in clusterOptions)
{
<option value="@opt.Id" disabled="@(!opt.HasPrometheus)">
@opt.Name@(!opt.HasPrometheus ? " (Prometheus not installed)" : "")
</option>
}
</select>
@if (clusterOptions.Any(c => c.HasPrometheus) && string.IsNullOrEmpty(selectedClusterId))
{
<span class="text-muted small">Select a cluster to view its alert rules.</span>
}
</div>
@if (string.IsNullOrEmpty(selectedClusterId))
{
@if (!isLoading && clusterOptions.Count == 0)
{
<EmptyState Icon="bi-hdd-rack"
Title="No clusters"
Message="No clusters are registered for this tenant." />
}
else if (!isLoading && !clusterOptions.Any(c => c.HasPrometheus))
{
<EmptyState Icon="bi-speedometer2"
Title="No Prometheus clusters"
Message="Install kube-prometheus-stack on a cluster to enable alert rules." />
}
}
else
{
<AlertRulesPanel ClusterId="@(Guid.Parse(selectedClusterId))" />
}
@code {
[Parameter] public required Guid TenantId { get; set; }
private List<ClusterOption> clusterOptions = [];
private string selectedClusterId = "";
private bool isLoading = true;
protected override async Task OnInitializedAsync()
{
using ApplicationDbContext db = DbFactory.CreateDbContext();
List<KubernetesCluster> 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);
}

View File

@@ -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<ApplicationDbContext> DbFactory
@inject AuthenticationStateProvider AuthStateProvider
@implements IAsyncDisposable
@* ── Toolbar ── *@
<div class="d-flex flex-wrap gap-2 align-items-center mb-3">
<select class="form-select form-select-sm" style="width:auto" @bind="filterClusterId">
<option value="">All clusters</option>
@foreach (ClusterAlertVm vm in clusterAlerts)
{
int count = vm.Alerts?.Count(a => a.State == "active") ?? 0;
<option value="@vm.Cluster.Id">@vm.Cluster.Name @(count > 0 ? $"({count})" : "")</option>
}
</select>
<select class="form-select form-select-sm" style="width:auto" @bind="filterSeverity">
<option value="">All severities</option>
<option value="critical">Critical</option>
<option value="warning">Warning</option>
<option value="info">Info</option>
</select>
@if (selectedKeys.Count > 0)
{
<span class="text-muted small">@selectedKeys.Count selected</span>
<button class="btn btn-sm btn-outline-warning" @onclick="BulkAcknowledge">
<span class="bi bi-check-all me-1"></span>Ack
</button>
<button class="btn btn-sm btn-outline-warning" @onclick="StartBulkSilence">
<span class="bi bi-bell-slash me-1"></span>Silence
</button>
<button class="btn btn-sm btn-outline-secondary" @onclick="() => selectedKeys.Clear()">
Clear
</button>
}
<div class="ms-auto d-flex align-items-center gap-2">
@if (lastRefreshed.HasValue)
{
<span class="text-muted small">Refreshed @lastRefreshed.Value.ToLocalTime().ToString("HH:mm:ss")</span>
}
<button class="btn btn-sm btn-outline-secondary" @onclick="Refresh" disabled="@isLoading">
<span class="bi bi-arrow-clockwise @(isLoading ? "spin" : "")"></span> Refresh
</button>
</div>
</div>
@* ── 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))
{
<div class="d-flex gap-2 flex-wrap mb-3">
<div class="card border-0 bg-light text-center px-3 py-2">
<div class="fw-bold fs-5 @(totalFiring > 0 ? "text-danger" : "text-success")">@totalFiring</div>
<div class="text-muted small">Firing</div>
</div>
<div class="card border-0 bg-light text-center px-3 py-2">
<div class="fw-bold fs-5 @(totalWarning > 0 ? "text-warning" : "")">@totalWarning</div>
<div class="text-muted small">Warning</div>
</div>
<div class="card border-0 bg-light text-center px-3 py-2">
<div class="fw-bold fs-5 @(withRemediation > 0 ? "text-primary" : "")">@withRemediation</div>
<div class="text-muted small">Auto-fixable</div>
</div>
@foreach (ClusterAlertVm vm in clusterAlerts.Where(v => v.Error is not null))
{
<div class="card border-warning text-center px-3 py-2">
<div class="text-muted small"><span class="bi bi-exclamation-triangle me-1 text-warning"></span>@vm.Cluster.Name unavailable</div>
</div>
}
</div>
}
@if (isLoading && !clusterAlerts.Any())
{
<div class="text-center py-5">
<div class="spinner-border spinner-border-sm text-primary" role="status"></div>
<p class="text-muted small mt-2">Loading alerts from clusters…</p>
</div>
}
else if (!Filtered.Any())
{
<EmptyState Icon="bi-check-circle"
Title="No alerts"
Message="@(clusterAlerts.Any() ? "All clusters healthy — no active alerts match the filter." : "No clusters with Prometheus found.")" />
}
else
{
<div class="table-responsive">
<table class="table table-hover align-middle">
<thead class="table-light">
<tr>
<th style="width:32px">
<input type="checkbox" class="form-check-input"
@onchange="e => ToggleSelectAll((bool)(e.Value ?? false))" />
</th>
<th style="width:90px">Severity</th>
<th>Alert</th>
<th>Cluster</th>
<th>Namespace</th>
<th>Started</th>
<th style="width:180px">Actions</th>
</tr>
</thead>
<tbody>
@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);
<tr class="@(expanded || confirming ? "table-active" : selected ? "table-warning" : "")"
style="cursor:pointer" @onclick="() => Toggle(row.Key)">
<td @onclick:stopPropagation="true">
<input type="checkbox" class="form-check-input" checked="@selected"
@onchange="() => ToggleSelect(row.Key)" />
</td>
<td>@SeverityBadge(row.Alert.Severity)</td>
<td>
<div class="fw-semibold small">@row.Alert.Name</div>
@if (!string.IsNullOrEmpty(row.Alert.Summary))
{
<div class="text-muted" style="font-size:0.77rem">@row.Alert.Summary</div>
}
@if (!string.IsNullOrEmpty(row.Alert.RunbookUrl))
{
<a href="@row.Alert.RunbookUrl" target="_blank" rel="noopener noreferrer"
class="small text-decoration-none" style="font-size:0.73rem"
@onclick:stopPropagation="true">
<span class="bi bi-book me-1"></span>Runbook
</a>
}
</td>
<td class="small text-muted">@row.ClusterName</td>
<td class="small text-muted font-monospace">
@row.Alert.Labels.GetValueOrDefault("namespace", "—")
</td>
<td class="small text-muted">@FormatAge(row.Alert.StartsAt)</td>
<td @onclick:stopPropagation="true">
<div class="d-flex gap-1 flex-wrap align-items-center">
@* DB incident status badge *@
@if (row.Incident is not null)
{
@if (row.Incident.Status == IncidentStatus.Active)
{
<button class="btn btn-sm btn-outline-warning"
@onclick="() => AcknowledgeIncident(row.Incident)"
title="Acknowledge incident">
Ack
</button>
}
else if (row.Incident.Status == IncidentStatus.Acknowledged)
{
<span class="badge bg-warning text-dark">Acked</span>
<button class="btn btn-sm btn-outline-success"
@onclick="() => ResolveIncident(row.Incident)"
title="Resolve incident">
Resolve
</button>
}
}
@if (row.Remediation is not null)
{
<button class="btn btn-sm @(confirming ? "btn-primary" : "btn-outline-primary")"
@onclick="() => StartConfirm(row.Key)"
title="Auto-fix available">
<span class="bi bi-lightning-charge me-1"></span>Fix
</button>
}
<button class="btn btn-sm btn-outline-warning"
@onclick="() => StartSilence(row)"
title="Silence this alert">
<span class="bi bi-bell-slash"></span>
</button>
<button class="btn btn-sm btn-outline-secondary"
@onclick="() => Toggle(row.Key)"
title="Show labels">
<span class="bi bi-tags"></span>
</button>
</div>
</td>
</tr>
@* ── Confirm remediation ── *@
@if (confirming && row.Remediation is not null)
{
<tr>
<td colspan="7" class="px-4 py-3 bg-primary-subtle border-start border-primary border-3">
<div class="d-flex align-items-start gap-3">
<span class="bi bi-lightning-charge-fill text-primary fs-4 mt-1"></span>
<div class="flex-grow-1">
<div class="fw-semibold mb-1">Auto-fix: @row.Remediation.ResourceKind/@row.Remediation.ResourceName</div>
<div class="text-muted small mb-3">@row.Remediation.Description</div>
@if (remediationResults.TryGetValue(row.Key, out string? result))
{
<div class="alert @(result.StartsWith("✓") ? "alert-success" : "alert-danger") py-2 px-3 small mb-2">
@result
</div>
}
<div class="d-flex gap-2">
<button class="btn btn-sm btn-primary"
@onclick="() => ExecuteRemediation(row)"
disabled="@(executingKey == row.Key)">
@if (executingKey == row.Key)
{
<span class="spinner-border spinner-border-sm me-1"></span>
}
Confirm Fix
</button>
<button class="btn btn-sm btn-outline-secondary"
@onclick="() => confirmKey = null">
Cancel
</button>
</div>
</div>
</div>
</td>
</tr>
}
@* ── Labels / details expand ── *@
@if (expanded)
{
<tr>
<td colspan="7" class="px-4 py-3 bg-light">
@if (!string.IsNullOrEmpty(row.Alert.Description))
{
<p class="mb-2 text-muted small">@row.Alert.Description</p>
}
<div class="d-flex flex-wrap gap-1 mb-2">
@foreach (KeyValuePair<string, string> label in row.Alert.Labels
.Where(l => l.Key != "alertname" && l.Key != "severity"))
{
<span class="badge bg-light text-dark border small font-monospace">
@label.Key=@label.Value
</span>
}
</div>
<div class="small text-muted">
Fingerprint: <code>@row.Alert.Fingerprint</code>
</div>
</td>
</tr>
}
}
</tbody>
</table>
</div>
}
@* ── Bulk silence form ── *@
@if (showBulkSilenceForm)
{
<div class="card mt-3 border-warning">
<div class="card-header d-flex justify-content-between align-items-center py-2">
<h6 class="mb-0"><span class="bi bi-bell-slash me-1"></span>Silence @selectedKeys.Count selected alert@(selectedKeys.Count == 1 ? "" : "s")</h6>
<button class="btn-close btn-sm" @onclick="() => showBulkSilenceForm = false"></button>
</div>
<div class="card-body">
<div class="row g-2 mb-2">
<div class="col-md-3">
<label class="form-label small">Duration</label>
<select class="form-select form-select-sm" @bind="bulkSilenceHours">
<option value="1">1 hour</option>
<option value="2">2 hours</option>
<option value="4">4 hours</option>
<option value="8">8 hours</option>
<option value="24">24 hours</option>
<option value="72">3 days</option>
</select>
</div>
<div class="col-md-9">
<label class="form-label small">Comment</label>
<input class="form-control form-control-sm" @bind="bulkSilenceComment"
placeholder="Reason for silence" />
</div>
</div>
@if (!string.IsNullOrEmpty(bulkSilenceError))
{
<div class="alert alert-danger py-1 small mb-2">@bulkSilenceError</div>
}
@if (!string.IsNullOrEmpty(bulkSilenceResult))
{
<div class="alert alert-success py-1 small mb-2">@bulkSilenceResult</div>
}
<div class="d-flex gap-2">
<button class="btn btn-sm btn-warning" @onclick="SubmitBulkSilence"
disabled="@(string.IsNullOrWhiteSpace(bulkSilenceComment) || isBulkSilencing)">
@if (isBulkSilencing) { <span class="spinner-border spinner-border-sm me-1"></span> }
<span class="bi bi-bell-slash me-1"></span>Create Silences
</button>
<button class="btn btn-sm btn-outline-secondary" @onclick="() => showBulkSilenceForm = false">Cancel</button>
</div>
</div>
</div>
}
@* ── Create silence form ── *@
@if (showSilenceForm && silenceTarget is not null)
{
<div class="card mt-3 border-warning">
<div class="card-header d-flex justify-content-between align-items-center py-2">
<h6 class="mb-0"><span class="bi bi-bell-slash me-1"></span>Silence — @silenceTarget.Alert.Name</h6>
<button class="btn-close btn-sm" @onclick="CancelSilence"></button>
</div>
<div class="card-body">
<div class="row g-2 mb-2">
<div class="col-md-4">
<label class="form-label small">Matcher (label=value)</label>
<input class="form-control form-control-sm font-monospace"
@bind="silenceMatcher" placeholder="alertname=…" />
</div>
<div class="col-md-3">
<label class="form-label small">Duration</label>
<select class="form-select form-select-sm" @bind="silenceHours">
<option value="1">1 hour</option>
<option value="2">2 hours</option>
<option value="4">4 hours</option>
<option value="8">8 hours</option>
<option value="24">24 hours</option>
<option value="72">3 days</option>
</select>
</div>
<div class="col-md-5">
<label class="form-label small">Comment</label>
<input class="form-control form-control-sm" @bind="silenceComment"
placeholder="Reason for silence" />
</div>
</div>
@if (!string.IsNullOrEmpty(silenceError))
{
<div class="alert alert-danger py-1 small mb-2">@silenceError</div>
}
<div class="d-flex gap-2">
<button class="btn btn-sm btn-warning" @onclick="SubmitSilence"
disabled="@(string.IsNullOrWhiteSpace(silenceMatcher) || string.IsNullOrWhiteSpace(silenceComment) || isSilencing)">
@if (isSilencing) { <span class="spinner-border spinner-border-sm me-1"></span> }
<span class="bi bi-bell-slash me-1"></span>Create Silence
</button>
<button class="btn btn-sm btn-outline-secondary" @onclick="CancelSilence">Cancel</button>
</div>
</div>
</div>
}
@code {
[Parameter] public required Guid TenantId { get; set; }
private List<ClusterAlertVm> clusterAlerts = [];
private Dictionary<string, AlertIncident> incidentsByFingerprint = new();
private string filterClusterId = "";
private string filterSeverity = "";
private string? expandedKey;
private string? confirmKey;
private string? executingKey;
private Dictionary<string, string> 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<string> selectedKeys = [];
private IEnumerable<AlertRow> 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<KubernetesCluster> 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<KubernetesCluster> prometheusEnabled = clusters
.Where(c => c.Components.Any(comp =>
comp.HelmChartName == "kube-prometheus-stack" &&
comp.Status == ComponentStatus.Installed))
.ToList();
List<Task<ClusterAlertVm>> tasks = prometheusEnabled
.Select(c => LoadClusterAlertsAsync(c))
.ToList();
clusterAlerts = [.. await Task.WhenAll(tasks)];
// Build fingerprint → incident lookup keyed by "clusterId:fingerprint"
List<AlertIncident> 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<ClusterAlertVm> LoadClusterAlertsAsync(KubernetesCluster cluster)
{
KubernetesOperationResult<List<AlertInfo>> 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<Guid> 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<AlertRow> 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" => @<span class="badge bg-danger">Critical</span>,
"warning" => @<span class="badge bg-warning text-dark">Warning</span>,
"info" => @<span class="badge bg-info text-dark">Info</span>,
_ => @<span class="badge bg-secondary">@severity</span>
};
public async ValueTask DisposeAsync()
{
if (refreshTimer is not null)
await refreshTimer.DisposeAsync();
}
private record ClusterAlertVm(KubernetesCluster Cluster, List<AlertInfo>? Alerts, string? Error);
private record AlertRow(string Key, Guid ClusterId, string ClusterName, AlertInfo Alert, AlertRemediation? Remediation, AlertIncident? Incident);
}

View File

@@ -34,96 +34,164 @@ else
</div> </div>
</div> </div>
<ul class="nav nav-pills mb-4 gap-1"> @* ── Category (top-level) nav ── *@
<ul class="nav nav-pills mb-2 gap-1 tenant-category-nav">
<li class="nav-item"> <li class="nav-item">
<button class="nav-link @(activeTab == "environments" ? "active" : "")" @onclick='() => activeTab = "environments"'> <button class="nav-link @(activeCategory == "apps" ? "active" : "")" @onclick='() => SetCategory("apps")' disabled="@tabLoading">
<i class="bi bi-layers me-1"></i>Environments <i class="bi bi-people me-1"></i>Applications
</button> </button>
</li> </li>
<li class="nav-item"> <li class="nav-item">
<button class="nav-link @(activeTab == "customers" ? "active" : "")" @onclick='() => activeTab = "customers"'> <button class="nav-link @(activeCategory == "infra" ? "active" : "")" @onclick='() => SetCategory("infra")' disabled="@tabLoading">
<i class="bi bi-people me-1"></i>Customers <i class="bi bi-layers me-1"></i>Infrastructure
</button> </button>
</li> </li>
<li class="nav-item"> <li class="nav-item">
<button class="nav-link @(activeTab == "vault" ? "active" : "")" @onclick='() => activeTab = "vault"'> <button class="nav-link @(activeCategory == "services" ? "active" : "")" @onclick='() => SetCategory("services")' disabled="@tabLoading">
<i class="bi bi-shield-lock me-1"></i>Vault <i class="bi bi-box-seam me-1"></i>Services
</button> </button>
</li> </li>
<li class="nav-item"> <li class="nav-item">
<button class="nav-link @(activeTab == "groups" ? "active" : "")" @onclick='() => activeTab = "groups"'> <button class="nav-link @(activeCategory == "ops" ? "active" : "")" @onclick='() => SetCategory("ops")' disabled="@tabLoading">
<i class="bi bi-diagram-3 me-1"></i>Groups <i class="bi bi-speedometer2 me-1"></i>Operations
</button>
</li>
<li class="nav-item">
<button class="nav-link @(activeTab == "databases" ? "active" : "")" @onclick='() => activeTab = "databases"'>
<i class="bi bi-database me-1"></i>Databases
</button>
</li>
<li class="nav-item">
<button class="nav-link @(activeTab == "storage" ? "active" : "")" @onclick='() => activeTab = "storage"'>
<i class="bi bi-bucket me-1"></i>Storage
</button>
</li>
<li class="nav-item">
<button class="nav-link @(activeTab == "identity" ? "active" : "")" @onclick='() => activeTab = "identity"'>
<i class="bi bi-person-badge me-1"></i>Identity
</button>
</li>
<li class="nav-item">
<button class="nav-link @(activeTab == "registry" ? "active" : "")" @onclick='() => activeTab = "registry"'>
<i class="bi bi-archive me-1"></i>Registry
</button>
</li>
<li class="nav-item">
<button class="nav-link @(activeTab == "messaging" ? "active" : "")" @onclick='() => activeTab = "messaging"'>
<i class="bi bi-collection me-1"></i>Messaging
</button>
</li>
<li class="nav-item">
<button class="nav-link @(activeTab == "cache" ? "active" : "")" @onclick='() => activeTab = "cache"'>
<i class="bi bi-lightning-charge me-1"></i>Cache
</button>
</li>
<li class="nav-item">
<button class="nav-link @(activeTab == "overview" ? "active" : "")" @onclick='() => activeTab = "overview"'>
<i class="bi bi-speedometer2 me-1"></i>Overview
</button>
</li>
<li class="nav-item">
<button class="nav-link @(activeTab == "incidents" ? "active" : "")" @onclick='() => activeTab = "incidents"'>
<i class="bi bi-bell-fill me-1"></i>Incidents
</button>
</li>
<li class="nav-item">
<button class="nav-link @(activeTab == "notifications" ? "active" : "")" @onclick='() => activeTab = "notifications"'>
<i class="bi bi-send-fill me-1"></i>Notifications
</button>
</li>
<li class="nav-item">
<button class="nav-link @(activeTab == "maintenance" ? "active" : "")" @onclick='() => activeTab = "maintenance"'>
<i class="bi bi-tools me-1"></i>Maintenance
</button>
</li>
<li class="nav-item">
<button class="nav-link @(activeTab == "sla" ? "active" : "")" @onclick='() => activeTab = "sla"'>
<i class="bi bi-shield-check me-1"></i>SLA
</button>
</li>
<li class="nav-item">
<button class="nav-link @(activeTab == "networking" ? "active" : "")" @onclick='() => activeTab = "networking"'>
<i class="bi bi-shield-lock me-1"></i>Networking
</button>
</li>
<li class="nav-item">
<button class="nav-link @(activeTab == "git" ? "active" : "")" @onclick='() => activeTab = "git"'>
<i class="bi bi-git me-1"></i>Git
</button> </button>
</li> </li>
</ul> </ul>
@* ── Sub-tab strip for the active category ── *@
<ul class="nav nav-tabs mb-4">
@if (activeCategory == "apps")
{
<li class="nav-item">
<button class="nav-link @(activeTab == "customers" ? "active" : "")" @onclick='() => SetTab("customers")'>
<i class="bi bi-people me-1"></i>Customers
</button>
</li>
<li class="nav-item">
<button class="nav-link @(activeTab == "groups" ? "active" : "")" @onclick='() => SetTab("groups")'>
<i class="bi bi-diagram-3 me-1"></i>Groups
</button>
</li>
}
else if (activeCategory == "infra")
{
<li class="nav-item">
<button class="nav-link @(activeTab == "environments" ? "active" : "")" @onclick='() => SetTab("environments")'>
<i class="bi bi-layers me-1"></i>Environments
</button>
</li>
<li class="nav-item">
<button class="nav-link @(activeTab == "networking" ? "active" : "")" @onclick='() => SetTab("networking")'>
<i class="bi bi-diagram-2 me-1"></i>Networking
</button>
</li>
}
else if (activeCategory == "services")
{
<li class="nav-item">
<button class="nav-link @(activeTab == "databases" ? "active" : "")" @onclick='() => SetTab("databases")'>
<i class="bi bi-database me-1"></i>Databases
</button>
</li>
<li class="nav-item">
<button class="nav-link @(activeTab == "storage" ? "active" : "")" @onclick='() => SetTab("storage")'>
<i class="bi bi-bucket me-1"></i>Storage
</button>
</li>
<li class="nav-item">
<button class="nav-link @(activeTab == "messaging" ? "active" : "")" @onclick='() => SetTab("messaging")'>
<i class="bi bi-collection me-1"></i>Messaging
</button>
</li>
<li class="nav-item">
<button class="nav-link @(activeTab == "cache" ? "active" : "")" @onclick='() => SetTab("cache")'>
<i class="bi bi-lightning-charge me-1"></i>Cache
</button>
</li>
<li class="nav-item">
<button class="nav-link @(activeTab == "identity" ? "active" : "")" @onclick='() => SetTab("identity")'>
<i class="bi bi-person-badge me-1"></i>Identity
</button>
</li>
<li class="nav-item">
<button class="nav-link @(activeTab == "registry" ? "active" : "")" @onclick='() => SetTab("registry")'>
<i class="bi bi-archive me-1"></i>Registry
</button>
</li>
<li class="nav-item">
<button class="nav-link @(activeTab == "vault" ? "active" : "")" @onclick='() => SetTab("vault")'>
<i class="bi bi-shield-lock me-1"></i>Vault
</button>
</li>
}
else if (activeCategory == "ops")
{
<li class="nav-item">
<button class="nav-link @(activeTab == "overview" ? "active" : "")" @onclick='() => SetTab("overview")'>
<i class="bi bi-speedometer2 me-1"></i>Overview
</button>
</li>
<li class="nav-item">
<button class="nav-link @(activeTab == "alerts" ? "active" : "")" @onclick='() => SetTab("alerts")'>
<i class="bi bi-bell me-1"></i>Alerts
</button>
</li>
<li class="nav-item">
<button class="nav-link @(activeTab == "alert-rules" ? "active" : "")" @onclick='() => SetTab("alert-rules")'>
<i class="bi bi-list-check me-1"></i>Alert Rules
</button>
</li>
<li class="nav-item">
<button class="nav-link @(activeTab == "logs" ? "active" : "")" @onclick='() => SetTab("logs")'>
<i class="bi bi-journal-text me-1"></i>Logs
</button>
</li>
<li class="nav-item">
<button class="nav-link @(activeTab == "incidents" ? "active" : "")" @onclick='() => SetTab("incidents")'>
<i class="bi bi-bell-fill me-1"></i>Incidents
</button>
</li>
<li class="nav-item">
<button class="nav-link @(activeTab == "notifications" ? "active" : "")" @onclick='() => SetTab("notifications")'>
<i class="bi bi-send-fill me-1"></i>Notifications
</button>
</li>
<li class="nav-item">
<button class="nav-link @(activeTab == "maintenance" ? "active" : "")" @onclick='() => SetTab("maintenance")'>
<i class="bi bi-tools me-1"></i>Maintenance
</button>
</li>
<li class="nav-item">
<button class="nav-link @(activeTab == "on-call" ? "active" : "")" @onclick='() => SetTab("on-call")'>
<i class="bi bi-person-badge me-1"></i>On-Call
</button>
</li>
<li class="nav-item">
<button class="nav-link @(activeTab == "alert-routing" ? "active" : "")" @onclick='() => SetTab("alert-routing")'>
<i class="bi bi-funnel me-1"></i>Alert Routing
</button>
</li>
<li class="nav-item">
<button class="nav-link @(activeTab == "analytics" ? "active" : "")" @onclick='() => SetTab("analytics")'>
<i class="bi bi-bar-chart-line me-1"></i>Analytics
</button>
</li>
<li class="nav-item">
<button class="nav-link @(activeTab == "sla" ? "active" : "")" @onclick='() => SetTab("sla")'>
<i class="bi bi-shield-check me-1"></i>SLA
</button>
</li>
}
</ul>
<div class="tab-content"> <div class="tab-content">
@switch (activeTab) @if (tabLoading)
{
<div class="text-center py-5">
<div class="spinner-border spinner-border-sm text-primary" role="status"></div>
</div>
}
else @switch (activeTab)
{ {
case "environments": case "environments":
<EnvironmentTab TenantId="tenant.Id" /> <EnvironmentTab TenantId="tenant.Id" />
@@ -156,7 +224,16 @@ else
<CacheTab TenantId="tenant.Id" /> <CacheTab TenantId="tenant.Id" />
break; break;
case "overview": case "overview":
<TenantMonitoringOverview TenantId="tenant.Id" TenantSlug="tenant.Slug" /> <TenantMonitoringOverview TenantId="tenant.Id" TenantSlug="tenant.Slug" OnNavigateTab="SetTab" />
break;
case "alerts":
<TenantAlertsTab TenantId="tenant.Id" />
break;
case "alert-rules":
<TenantAlertRulesTab TenantId="tenant.Id" />
break;
case "logs":
<TenantLogsTab TenantId="tenant.Id" />
break; break;
case "incidents": case "incidents":
<IncidentManagement TenantId="tenant.Id" /> <IncidentManagement TenantId="tenant.Id" />
@@ -167,15 +244,21 @@ else
case "maintenance": case "maintenance":
<MaintenanceWindows TenantId="tenant.Id" /> <MaintenanceWindows TenantId="tenant.Id" />
break; break;
case "on-call":
<OnCallTab TenantId="tenant.Id" />
break;
case "alert-routing":
<AlertRoutingTab TenantId="tenant.Id" />
break;
case "analytics":
<TenantOpsAnalyticsTab TenantId="tenant.Id" />
break;
case "sla": case "sla":
<SlaTargetManager TenantId="tenant.Id" /> <SlaTargetManager TenantId="tenant.Id" />
break; break;
case "networking": case "networking":
<NetworkingTab TenantId="tenant.Id" /> <NetworkingTab TenantId="tenant.Id" />
break; break;
case "git":
<GitReposTab TenantId="tenant.Id" />
break;
} }
</div> </div>
} }
@@ -184,7 +267,36 @@ else
[Parameter] public string Slug { get; set; } = ""; [Parameter] public string Slug { get; set; } = "";
private Tenant? tenant; private Tenant? tenant;
private string activeTab = "environments"; private string activeCategory = "apps";
private string activeTab = "customers";
private bool tabLoading;
private static readonly Dictionary<string, string> CategoryDefaults = new()
{
["apps"] = "customers",
["infra"] = "environments",
["services"] = "databases",
["ops"] = "overview",
};
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() protected override async Task OnInitializedAsync()
{ {

View File

@@ -10,14 +10,8 @@
<PageTitle>Tenants</PageTitle> <PageTitle>Tenants</PageTitle>
@if (!loaded) <LoadingPanel Loading="@(!loaded)" LoadingText="Loading tenants…">
{ @if (loaded)
<div class="text-center py-5">
<div class="spinner-border text-primary" role="status"></div>
<p class="text-muted mt-2">Loading tenants...</p>
</div>
}
else
{ {
<div class="d-flex align-items-center justify-content-between mb-4"> <div class="d-flex align-items-center justify-content-between mb-4">
<div> <div>
@@ -35,32 +29,30 @@ else
<input type="text" class="form-control border-start-0" placeholder="New tenant name (e.g. Acme Corp)" <input type="text" class="form-control border-start-0" placeholder="New tenant name (e.g. Acme Corp)"
@bind="newTenantName" @bind:event="oninput" @bind="newTenantName" @bind:event="oninput"
@onkeydown="@(async (e) => { if (e.Key == "Enter" && !string.IsNullOrWhiteSpace(newTenantName)) await CreateTenant(); })" /> @onkeydown="@(async (e) => { if (e.Key == "Enter" && !string.IsNullOrWhiteSpace(newTenantName)) await CreateTenant(); })" />
<button class="btn btn-primary" @onclick="CreateTenant" disabled="@string.IsNullOrWhiteSpace(newTenantName)"> <button class="btn btn-primary" @onclick="CreateTenant"
<i class="bi bi-plus-lg me-1"></i>Create Tenant disabled="@(string.IsNullOrWhiteSpace(newTenantName) || creating)">
@if (creating) { <span class="spinner-border spinner-border-sm me-1"></span> }
else { <i class="bi bi-plus-lg me-1"></i> }
Create Tenant
</button> </button>
</div> </div>
@if (!string.IsNullOrEmpty(errorMessage)) @if (!string.IsNullOrEmpty(errorMessage))
{ {
<div class="text-danger small mt-2"><i class="bi bi-exclamation-triangle me-1"></i>@errorMessage</div> <div class="text-danger small mt-2"><i class="bi bi-exclamation-triangle me-1"></i>@errorMessage</div>
} }
@if (!string.IsNullOrEmpty(successMessage))
{
<div class="text-success small mt-2"><i class="bi bi-check-circle me-1"></i>@successMessage</div>
}
</div> </div>
</div> </div>
} }
@if (tenants is null || tenants.Count == 0) @if (tenants is null || tenants.Count == 0)
{ {
<div class="text-center py-5"> <EmptyState Icon="bi-building"
<i class="bi bi-building text-muted" style="font-size: 4rem;"></i> Title="No tenants yet"
<h5 class="text-muted mt-3">No tenants yet</h5> Message="@(isAdmin ? "Create your first tenant above to get started." : "You have not been added to any tenants yet.")" />
@if (isAdmin)
{
<p class="text-muted">Create your first tenant above to get started.</p>
}
else
{
<p class="text-muted">You have not been added to any tenants yet.</p>
}
</div>
} }
else else
{ {
@@ -91,14 +83,13 @@ else
@if (isAdmin && confirmDeleteId == tenant.Id) @if (isAdmin && confirmDeleteId == tenant.Id)
{ {
<div class="card-body py-2 border-top bg-danger bg-opacity-10"> <div class="card-body py-2 border-top">
<div class="d-flex align-items-center justify-content-between"> <ConfirmDialog Visible="true"
<small class="text-danger"><i class="bi bi-exclamation-triangle me-1"></i>Delete this tenant?</small> Message="@($"Permanently delete tenant '{tenant.Name}' and all its data?")"
<div> ConfirmText="Delete"
<button class="btn btn-sm btn-danger me-1" @onclick="() => DeleteTenant(tenant.Id)">Delete</button> IsBusy="deleting"
<button class="btn btn-sm btn-outline-secondary" @onclick="() => confirmDeleteId = null">Cancel</button> OnConfirm="() => DeleteTenant(tenant.Id)"
</div> OnCancel="() => confirmDeleteId = null" />
</div>
</div> </div>
} }
@@ -113,14 +104,18 @@ else
</div> </div>
} }
} }
</LoadingPanel>
@code { @code {
private List<Tenant>? tenants; private List<Tenant>? tenants;
private string newTenantName = ""; private string newTenantName = "";
private string? errorMessage; private string? errorMessage;
private string? successMessage;
private Guid? confirmDeleteId; private Guid? confirmDeleteId;
private bool isAdmin; private bool isAdmin;
private bool loaded; private bool loaded;
private bool creating;
private bool deleting;
private string? userId; private string? userId;
protected override async Task OnInitializedAsync() protected override async Task OnInitializedAsync()
@@ -160,28 +155,43 @@ else
tenants = await UserAccessService.GetAccessibleTenantsAsync(userId); 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() private async Task CreateTenant()
{ {
if (!isAdmin) return; if (!isAdmin) return;
errorMessage = null; errorMessage = null;
creating = true;
try try
{ {
await TenantService.CreateTenantAsync(newTenantName.Trim()); string name = newTenantName.Trim();
await TenantService.CreateTenantAsync(name);
newTenantName = ""; newTenantName = "";
await LoadTenants(); await LoadTenants();
_ = ShowSuccessAsync($"Tenant '{name}' created.");
} }
catch (DbUpdateException) catch (DbUpdateException)
{ {
errorMessage = "A tenant with that name already exists."; errorMessage = "A tenant with that name already exists.";
} }
finally { creating = false; }
} }
private async Task DeleteTenant(Guid id) private async Task DeleteTenant(Guid id)
{ {
if (!isAdmin) return; if (!isAdmin) return;
confirmDeleteId = null; deleting = true;
await TenantService.DeleteTenantAsync(id); await TenantService.DeleteTenantAsync(id);
deleting = false;
confirmDeleteId = null;
await LoadTenants(); await LoadTenants();
} }
} }

View File

@@ -0,0 +1,97 @@
@using EntKube.Web.Data
@using EntKube.Web.Services
@using Microsoft.EntityFrameworkCore
@inject IDbContextFactory<ApplicationDbContext> DbFactory
@* Cluster picker — required because LogBrowser is cluster-scoped *@
<div class="d-flex align-items-center gap-3 mb-4">
<div class="d-flex align-items-center gap-2">
<label class="form-label small text-muted mb-0 text-nowrap">Cluster</label>
<select class="form-select form-select-sm" style="min-width:220px"
@bind="selectedClusterId" @bind:after="OnClusterChanged">
<option value="">— select a cluster —</option>
@foreach (ClusterOption opt in clusterOptions)
{
<option value="@opt.Id" disabled="@(!opt.HasLoki && !opt.HasPrometheus)">
@opt.Name
@if (!opt.HasLoki) { @(" (Loki not installed)") }
</option>
}
</select>
</div>
@if (selectedCluster is not null)
{
<div class="d-flex gap-2 small">
<span class="badge @(selectedCluster.HasLoki ? "bg-success" : "bg-secondary")">
<span class="bi bi-journal me-1"></span>Loki @(selectedCluster.HasLoki ? "installed" : "not installed")
</span>
</div>
}
</div>
@if (string.IsNullOrEmpty(selectedClusterId) && clusterOptions.Count == 0 && !isLoading)
{
<EmptyState Icon="bi-hdd-rack"
Title="No clusters"
Message="No clusters are registered for this tenant." />
}
else if (string.IsNullOrEmpty(selectedClusterId))
{
@if (clusterOptions.Count > 0)
{
<div class="text-center py-5 text-muted">
<span class="bi bi-journal display-4 d-block mb-2 opacity-25"></span>
Select a cluster above to browse logs.
</div>
}
}
else
{
<LogBrowser ClusterId="@(Guid.Parse(selectedClusterId))" />
}
@code {
[Parameter] public required Guid TenantId { get; set; }
private List<ClusterOption> clusterOptions = [];
private ClusterOption? selectedCluster;
private string selectedClusterId = "";
private bool isLoading = true;
protected override async Task OnInitializedAsync()
{
using ApplicationDbContext db = DbFactory.CreateDbContext();
List<KubernetesCluster> 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);
}

View File

@@ -3,6 +3,7 @@
@using Microsoft.EntityFrameworkCore @using Microsoft.EntityFrameworkCore
@inject PrometheusService PrometheusService @inject PrometheusService PrometheusService
@inject IncidentService IncidentService @inject IncidentService IncidentService
@inject OnCallService OnCallService
@inject IDbContextFactory<ApplicationDbContext> DbFactory @inject IDbContextFactory<ApplicationDbContext> DbFactory
@implements IAsyncDisposable @implements IAsyncDisposable
@@ -18,37 +19,54 @@
</button> </button>
</div> </div>
@* ── Tenant-level summary strip ── *@
@if (clusterSummaries.Count > 0) @if (clusterSummaries.Count > 0)
{ {
<div class="row g-2 mb-4"> <div class="d-flex flex-wrap gap-2 mb-4">
<div class="col-auto"> <div class="card border-0 bg-light text-center px-3 py-2">
<div class="card text-center border-0 bg-light px-3 py-2">
<div class="fw-bold fs-4">@clusterSummaries.Count</div> <div class="fw-bold fs-4">@clusterSummaries.Count</div>
<div class="text-muted small">Clusters</div> <div class="text-muted small">Clusters</div>
</div> </div>
</div> <div class="card border-0 bg-light text-center px-3 py-2">
<div class="col-auto">
<div class="card text-center border-0 bg-light px-3 py-2">
<div class="fw-bold fs-4">@clusterSummaries.Sum(s => s.Health?.TotalNodes ?? 0)</div> <div class="fw-bold fs-4">@clusterSummaries.Sum(s => s.Health?.TotalNodes ?? 0)</div>
<div class="text-muted small">Nodes</div> <div class="text-muted small">Nodes</div>
</div> </div>
</div> <div class="card border-0 bg-light text-center px-3 py-2">
<div class="col-auto">
<div class="card text-center border-0 bg-light px-3 py-2">
<div class="fw-bold fs-4">@clusterSummaries.Sum(s => s.Health?.RunningPods ?? 0)</div> <div class="fw-bold fs-4">@clusterSummaries.Sum(s => s.Health?.RunningPods ?? 0)</div>
<div class="text-muted small">Running Pods</div> <div class="text-muted small">Running Pods</div>
</div> </div>
</div> <div class="card border-0 text-center px-3 py-2 @(tenantActiveAlerts > 0 ? "bg-danger-subtle" : "bg-light")"
<div class="col-auto"> style="cursor:@(OnNavigateTab.HasDelegate ? "pointer" : "default")"
<div class="card text-center border-0 bg-light px-3 py-2"> @onclick='() => NavigateTo("alerts")'>
<div class="fw-bold fs-4 @(tenantActiveAlerts > 0 ? "text-danger" : "text-success")">@tenantActiveAlerts</div> <div class="fw-bold fs-4 @(tenantActiveAlerts > 0 ? "text-danger" : "text-success")">@tenantActiveAlerts</div>
<div class="text-muted small">Active Alerts</div> <div class="text-muted small d-flex align-items-center gap-1">
Active Alerts
@if (OnNavigateTab.HasDelegate)
{
<span class="bi bi-arrow-right" style="font-size:.7rem"></span>
}
</div> </div>
</div> </div>
@if (criticalIncidents > 0)
{
<div class="card border-danger text-center px-3 py-2"
style="cursor:@(OnNavigateTab.HasDelegate ? "pointer" : "default")"
@onclick='() => NavigateTo("incidents")'>
<div class="fw-bold fs-4 text-danger">@criticalIncidents</div>
<div class="text-muted small d-flex align-items-center gap-1">
Critical
@if (OnNavigateTab.HasDelegate)
{
<span class="bi bi-arrow-right" style="font-size:.7rem"></span>
}
</div>
</div>
}
</div> </div>
} }
<div class="row g-3"> @* ── Cluster cards ── *@
<div class="row g-3 mb-4">
@foreach (ClusterSummaryVm vm in clusterSummaries) @foreach (ClusterSummaryVm vm in clusterSummaries)
{ {
<div class="col-12 col-md-6 col-xl-4"> <div class="col-12 col-md-6 col-xl-4">
@@ -97,25 +115,146 @@
<span class="bi bi-server me-1"></span>@vm.Health.ReadyNodes/@vm.Health.TotalNodes nodes ready <span class="bi bi-server me-1"></span>@vm.Health.ReadyNodes/@vm.Health.TotalNodes nodes ready
&nbsp;·&nbsp; &nbsp;·&nbsp;
<span class="bi bi-box me-1"></span>@vm.Health.RunningPods pods running <span class="bi bi-box me-1"></span>@vm.Health.RunningPods pods running
@if (vm.Health.FailedPods > 0)
{
<span class="text-danger">&nbsp;·&nbsp;<span class="bi bi-exclamation-circle me-1"></span>@vm.Health.FailedPods failed</span>
}
@if (vm.Health.PendingPods > 0)
{
<span class="text-warning">&nbsp;·&nbsp;<span class="bi bi-hourglass me-1"></span>@vm.Health.PendingPods pending</span>
}
</div> </div>
} }
</div> </div>
<div class="card-footer py-1 bg-transparent border-0"> <div class="card-footer py-1 bg-transparent border-0 d-flex gap-3">
<a href="tenants/@TenantSlug/clusters/@vm.Cluster.Id/monitoring" class="btn btn-sm btn-link p-0 text-decoration-none"> <a href="tenants/@TenantSlug/clusters/@vm.Cluster.Id/monitoring"
<span class="bi bi-graph-up me-1"></span>View monitoring class="btn btn-sm btn-link p-0 text-decoration-none">
<span class="bi bi-graph-up me-1"></span>Monitoring
</a> </a>
@if (vm.ActiveAlerts > 0 && OnNavigateTab.HasDelegate)
{
<button class="btn btn-sm btn-link p-0 text-decoration-none text-danger"
@onclick='() => NavigateTo("alerts")'>
<span class="bi bi-bell me-1"></span>View alerts
</button>
}
</div> </div>
</div> </div>
</div> </div>
} }
</div> </div>
@* ── On-call banner ── *@
@if (currentOnCall is not null)
{
<div class="alert alert-success d-flex align-items-center gap-2 py-2 mb-3">
<span class="bi bi-person-check-fill"></span>
<span class="small">
On call: <strong>@currentOnCall.AssigneeName</strong>
@if (!string.IsNullOrEmpty(currentOnCall.AssigneeEmail))
{
<a href="mailto:@currentOnCall.AssigneeEmail" class="ms-1">@currentOnCall.AssigneeEmail</a>
}
— until @currentOnCall.EndsAt.ToLocalTime().ToString("MMM d HH:mm")
</span>
@if (OnNavigateTab.HasDelegate)
{
<button class="btn btn-sm btn-link p-0 ms-auto text-decoration-none small"
@onclick='() => NavigateTo("on-call")'>
Schedule <span class="bi bi-arrow-right ms-1"></span>
</button>
}
</div>
}
@* ── Active incidents ── *@
@if (activeIncidents.Count > 0)
{
<div class="mb-4">
<div class="d-flex justify-content-between align-items-center mb-2">
<h6 class="mb-0"><span class="bi bi-bell-fill text-danger me-1"></span>Active Incidents</h6>
@if (OnNavigateTab.HasDelegate)
{
<button class="btn btn-sm btn-link p-0 text-decoration-none"
@onclick='() => NavigateTo("incidents")'>
View all <span class="bi bi-arrow-right ms-1"></span>
</button>
}
</div>
<div class="list-group">
@foreach (AlertIncident incident in activeIncidents.Take(5))
{
<div class="list-group-item list-group-item-action py-2 px-3 d-flex align-items-center gap-2">
@SeverityDot(incident.Severity)
<div class="flex-grow-1">
<div class="fw-semibold small">@incident.AlertName</div>
@if (!string.IsNullOrEmpty(incident.Summary))
{
<div class="text-muted" style="font-size:.77rem">@incident.Summary</div>
}
</div>
<div class="text-end">
<div class="small text-muted">@incident.Cluster?.Name</div>
<div class="text-muted" style="font-size:.72rem">@FormatAge(incident.StartsAt)</div>
</div>
@if (incident.Status == IncidentStatus.Acknowledged)
{
<span class="badge bg-warning text-dark ms-1">Acked</span>
}
</div>
}
</div>
@if (activeIncidents.Count > 5)
{
<div class="text-center mt-1">
<button class="btn btn-sm btn-link text-decoration-none"
@onclick='() => NavigateTo("incidents")'>
+@(activeIncidents.Count - 5) more
</button>
</div>
}
</div>
}
@* ── Upcoming maintenance ── *@
@if (upcomingMaintenance.Count > 0)
{
<div class="alert alert-warning d-flex align-items-start gap-2 py-2 mb-3">
<span class="bi bi-tools fs-5 mt-1"></span>
<div>
<strong class="small">Upcoming maintenance</strong>
<div class="d-flex flex-column gap-1 mt-1">
@foreach (MaintenanceWindow w in upcomingMaintenance)
{
bool active = w.StartsAt <= DateTime.UtcNow && w.EndsAt >= DateTime.UtcNow;
<div class="small">
@if (active)
{
<span class="badge bg-warning text-dark me-1">Active now</span>
}
<strong>@w.Title</strong>
— @(w.Cluster?.Name ?? "All clusters")
<span class="text-muted ms-1">
@w.StartsAt.ToLocalTime().ToString("MMM d HH:mm") @w.EndsAt.ToLocalTime().ToString("HH:mm")
</span>
</div>
}
</div>
</div>
</div>
}
@code { @code {
[Parameter] public required Guid TenantId { get; set; } [Parameter] public required Guid TenantId { get; set; }
[Parameter] public required string TenantSlug { get; set; } [Parameter] public required string TenantSlug { get; set; }
[Parameter] public EventCallback<string> OnNavigateTab { get; set; }
private List<ClusterSummaryVm> clusterSummaries = []; private List<ClusterSummaryVm> clusterSummaries = [];
private List<AlertIncident> activeIncidents = [];
private List<MaintenanceWindow> upcomingMaintenance = [];
private OnCallShift? currentOnCall;
private int tenantActiveAlerts; private int tenantActiveAlerts;
private int criticalIncidents;
private DateTime? lastRefreshed; private DateTime? lastRefreshed;
private bool isLoading; private bool isLoading;
private Timer? refreshTimer; private Timer? refreshTimer;
@@ -145,10 +284,28 @@
.ToListAsync(); .ToListAsync();
} }
tenantActiveAlerts = await IncidentService.GetActiveAlertCountForTenantAsync(TenantId); // Load per-cluster active alert counts and tenant-level stats in parallel
Dictionary<Guid, int> perClusterCounts = await IncidentService
.GetActiveCountsPerClusterAsync(clusters.Select(c => c.Id));
tenantActiveAlerts = perClusterCounts.Values.Sum();
Task<List<AlertIncident>> incidentTask =
IncidentService.GetActiveIncidentsForTenantAsync(TenantId);
Task<List<MaintenanceWindow>> maintenanceTask =
IncidentService.GetUpcomingMaintenanceAsync(TenantId);
Task<OnCallShift?> 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<Task<ClusterSummaryVm>> tasks = clusters List<Task<ClusterSummaryVm>> tasks = clusters
.Select(c => LoadClusterSummaryAsync(c)) .Select(c => LoadClusterSummaryAsync(c, perClusterCounts.GetValueOrDefault(c.Id)))
.ToList(); .ToList();
clusterSummaries = [.. await Task.WhenAll(tasks)]; clusterSummaries = [.. await Task.WhenAll(tasks)];
@@ -157,13 +314,13 @@
isLoading = false; isLoading = false;
} }
private async Task<ClusterSummaryVm> LoadClusterSummaryAsync(KubernetesCluster cluster) private async Task<ClusterSummaryVm> LoadClusterSummaryAsync(KubernetesCluster cluster, int activeAlerts)
{ {
bool hasPrometheus = cluster.Components.Any(c => bool hasPrometheus = cluster.Components.Any(c =>
c.HelmChartName == "kube-prometheus-stack" && c.Status == ComponentStatus.Installed); c.HelmChartName == "kube-prometheus-stack" && c.Status == ComponentStatus.Installed);
if (!hasPrometheus) if (!hasPrometheus)
return new ClusterSummaryVm(cluster, null, null, 0); return new ClusterSummaryVm(cluster, null, null, activeAlerts);
KubernetesOperationResult<ClusterHealthSummary> result = KubernetesOperationResult<ClusterHealthSummary> result =
await PrometheusService.GetClusterHealthAsync(cluster.Id); await PrometheusService.GetClusterHealthAsync(cluster.Id);
@@ -172,7 +329,13 @@
cluster, cluster,
result.IsSuccess ? result.Data : null, result.IsSuccess ? result.Data : null,
result.IsSuccess ? null : result.Error, 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) => private static string UtilColor(double pct) =>
@@ -186,6 +349,24 @@
return "success"; 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" => @<span class="bi bi-circle-fill text-danger" style="font-size:.55rem"></span>,
"warning" => @<span class="bi bi-circle-fill text-warning" style="font-size:.55rem"></span>,
_ => @<span class="bi bi-circle-fill text-info" style="font-size:.55rem"></span>
};
public async ValueTask DisposeAsync() public async ValueTask DisposeAsync()
{ {
if (refreshTimer is not null) if (refreshTimer is not null)

View File

@@ -0,0 +1,301 @@
@using EntKube.Web.Data
@using EntKube.Web.Services
@inject IncidentService IncidentService
@if (isLoading)
{
<div class="text-center py-5">
<div class="spinner-border spinner-border-sm text-primary" role="status"></div>
<p class="text-muted small mt-2">Loading analytics…</p>
</div>
}
else if (loadError is not null)
{
<div class="alert alert-danger small">
<strong>Failed to load analytics.</strong> @loadError
</div>
}
else if (stats is null)
{
<EmptyState Icon="bi-bar-chart" Title="No data" Message="No incidents in the selected window." />
}
else
{
<div class="d-flex justify-content-between align-items-center mb-3">
<div class="d-flex gap-2 align-items-center">
<label class="form-label small text-muted mb-0">Window</label>
<select class="form-select form-select-sm" style="width:auto" @bind="windowDays" @bind:after="Load">
<option value="7">Last 7 days</option>
<option value="30">Last 30 days</option>
<option value="90">Last 90 days</option>
</select>
</div>
<button class="btn btn-sm btn-outline-secondary" @onclick="Load">
<span class="bi bi-arrow-clockwise"></span> Refresh
</button>
</div>
@* ── Summary strip ── *@
<div class="d-flex flex-wrap gap-2 mb-4">
<div class="card border-0 bg-light text-center px-3 py-2">
<div class="fw-bold fs-4">@stats.Total</div>
<div class="text-muted small">Total incidents</div>
</div>
<div class="card border-0 bg-danger-subtle text-center px-3 py-2">
<div class="fw-bold fs-4 text-danger">@stats.Critical</div>
<div class="text-muted small">Critical</div>
</div>
<div class="card border-0 bg-warning-subtle text-center px-3 py-2">
<div class="fw-bold fs-4 text-warning">@stats.Warning</div>
<div class="text-muted small">Warning</div>
</div>
<div class="card border-0 bg-light text-center px-3 py-2">
<div class="fw-bold fs-4">@stats.FormatMttr()</div>
<div class="text-muted small">Avg MTTR</div>
</div>
<div class="card border-0 bg-light text-center px-3 py-2">
<div class="fw-bold fs-4">@stats.FormatMtta()</div>
<div class="text-muted small">Avg MTTA</div>
</div>
<div class="card border-0 bg-light text-center px-3 py-2">
<div class="fw-bold fs-4 @(stats.Active > 0 ? "text-danger" : "text-success")">@stats.Active</div>
<div class="text-muted small">Still active</div>
</div>
</div>
<div class="row g-4">
@* ── Daily volume sparkline ── *@
<div class="col-12">
<div class="card">
<div class="card-header py-2 small fw-semibold">
<span class="bi bi-bar-chart me-1"></span>Alert volume — last @windowDays days
</div>
<div class="card-body py-3">
@{
int maxDaily = stats.Daily.Count > 0 ? Math.Max(1, stats.Daily.Max(d => d.Count)) : 1;
}
<div class="d-flex align-items-end gap-px" style="height:80px;gap:3px;overflow:hidden">
@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";
<div style="flex:1;min-width:4px;height:@(h)px;background:@color;border-radius:2px 2px 0 0"
title="@day.Date.ToString("MMM d"): @day.Count incident@(day.Count == 1 ? "" : "s")">
</div>
}
</div>
<div class="d-flex justify-content-between mt-1">
@if (stats.Daily.Count > 0)
{
<span class="text-muted" style="font-size:.72rem">@stats.Daily.First().Date.ToString("MMM d")</span>
<span class="text-muted" style="font-size:.72rem">@stats.Daily.Last().Date.ToString("MMM d")</span>
}
</div>
</div>
</div>
</div>
@* ── Top alerts ── *@
<div class="col-12 col-lg-6">
<div class="card h-100">
<div class="card-header py-2 small fw-semibold">
<span class="bi bi-list-ol me-1"></span>Most frequent alerts
</div>
<div class="card-body py-2">
@if (topAlerts.Count == 0)
{
<p class="text-muted small mb-0">No data.</p>
}
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";
<div class="mb-2">
<div class="d-flex justify-content-between mb-1">
<span class="small text-truncate me-2" style="max-width:200px"
title="@alert.Name">@alert.Name</span>
<span class="small fw-semibold text-muted">@alert.Count</span>
</div>
<div class="progress" style="height:6px">
<div class="progress-bar" role="progressbar"
style="width:@(pct)%;background:@barColor"></div>
</div>
</div>
}
}
</div>
</div>
</div>
@* ── Top namespaces ── *@
<div class="col-12 col-lg-6">
<div class="card h-100">
<div class="card-header py-2 small fw-semibold">
<span class="bi bi-diagram-2 me-1"></span>Alerts by namespace
</div>
<div class="card-body py-2">
@if (topNamespaces.Count == 0)
{
<p class="text-muted small mb-0">No namespace data.</p>
}
else
{
int maxCount = topNamespaces.Max(n => n.Count);
@foreach (AlertFrequency ns in topNamespaces)
{
int pct = maxCount > 0 ? (int)(ns.Count * 100.0 / maxCount) : 0;
<div class="mb-2">
<div class="d-flex justify-content-between mb-1">
<span class="small font-monospace text-truncate me-2">@ns.Name</span>
<span class="small fw-semibold text-muted">@ns.Count</span>
</div>
<div class="progress" style="height:6px">
<div class="progress-bar bg-primary" role="progressbar"
style="width:@(pct)%"></div>
</div>
</div>
}
}
</div>
</div>
</div>
@* ── Alerts by cluster ── *@
@if (byCluster.Count > 1)
{
<div class="col-12 col-lg-6">
<div class="card">
<div class="card-header py-2 small fw-semibold">
<span class="bi bi-hdd-rack me-1"></span>Alerts by cluster
</div>
<div class="card-body py-2">
@{
int maxCluster = byCluster.Max(c => c.Count);
}
@foreach (AlertFrequency cluster in byCluster)
{
int pct = maxCluster > 0 ? (int)(cluster.Count * 100.0 / maxCluster) : 0;
<div class="mb-2">
<div class="d-flex justify-content-between mb-1">
<span class="small text-truncate me-2">@cluster.Name</span>
<span class="small fw-semibold text-muted">@cluster.Count</span>
</div>
<div class="progress" style="height:6px">
<div class="progress-bar bg-secondary" role="progressbar"
style="width:@(pct)%"></div>
</div>
</div>
}
</div>
</div>
</div>
}
@* ── Severity breakdown ── *@
<div class="col-12 col-lg-@(byCluster.Count > 1 ? "6" : "12")">
<div class="card">
<div class="card-header py-2 small fw-semibold">
<span class="bi bi-pie-chart me-1"></span>Severity breakdown
</div>
<div class="card-body py-2">
@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;
<div class="d-flex rounded overflow-hidden mb-2" style="height:24px">
@if (critPct > 0)
{
<div class="bg-danger d-flex align-items-center justify-content-center text-white"
style="width:@(critPct)%;font-size:.7rem" title="Critical: @stats.Critical">
@if (critPct > 10) { <span>@(critPct)%</span> }
</div>
}
@if (warnPct > 0)
{
<div class="bg-warning d-flex align-items-center justify-content-center text-dark"
style="width:@(warnPct)%;font-size:.7rem" title="Warning: @stats.Warning">
@if (warnPct > 10) { <span>@(warnPct)%</span> }
</div>
}
@if (otherPct > 0)
{
<div class="bg-info d-flex align-items-center justify-content-center text-white"
style="width:@(otherPct)%;font-size:.7rem" title="Other: @(stats.Total - stats.Critical - stats.Warning)">
@if (otherPct > 10) { <span>@(otherPct)%</span> }
</div>
}
</div>
<div class="d-flex gap-3 small">
<span><span class="badge bg-danger me-1">@stats.Critical</span>Critical</span>
<span><span class="badge bg-warning text-dark me-1">@stats.Warning</span>Warning</span>
<span><span class="badge bg-info me-1">@(stats.Total - stats.Critical - stats.Warning)</span>Other</span>
</div>
}
else
{
<p class="text-muted small mb-0">No incidents in window.</p>
}
</div>
</div>
</div>
</div>
}
@code {
[Parameter] public required Guid TenantId { get; set; }
private AlertStats? stats;
private List<AlertFrequency> topAlerts = [];
private List<AlertFrequency> topNamespaces = [];
private List<AlertFrequency> 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);
}

View File

@@ -6,6 +6,8 @@
@inject TenantService TenantService @inject TenantService TenantService
@inject StorageService StorageService @inject StorageService StorageService
@inject CnpgService CnpgService @inject CnpgService CnpgService
@inject MongoService MongoService
@inject RegisteredPostgresService RegisteredPostgresService
<div class="d-flex align-items-center mb-2"> <div class="d-flex align-items-center mb-2">
<i class="bi bi-shield-lock fs-4 me-2 text-primary"></i> <i class="bi bi-shield-lock fs-4 me-2 text-primary"></i>
@@ -42,7 +44,13 @@ else
<i class="bi bi-hdd me-1"></i>Storage Secrets <i class="bi bi-hdd me-1"></i>Storage Secrets
</button> </button>
<button class="btn btn-sm @(scope == "cnpg" ? "btn-primary" : "btn-outline-primary")" @onclick='() => SwitchScope("cnpg")'> <button class="btn btn-sm @(scope == "cnpg" ? "btn-primary" : "btn-outline-primary")" @onclick='() => SwitchScope("cnpg")'>
<i class="bi bi-database me-1"></i>Database Secrets <i class="bi bi-database me-1"></i>PostgreSQL
</button>
<button class="btn btn-sm @(scope == "mongodb" ? "btn-primary" : "btn-outline-primary")" @onclick='() => SwitchScope("mongodb")'>
<i class="bi bi-database me-1"></i>MongoDB
</button>
<button class="btn btn-sm @(scope == "regpostgres" ? "btn-primary" : "btn-outline-primary")" @onclick='() => SwitchScope("regpostgres")'>
<i class="bi bi-database me-1"></i>Registered DB
</button> </button>
<button class="btn btn-sm @(scope == "docker" ? "btn-primary" : "btn-outline-primary")" @onclick='() => SwitchScope("docker")'> <button class="btn btn-sm @(scope == "docker" ? "btn-primary" : "btn-outline-primary")" @onclick='() => SwitchScope("docker")'>
<i class="bi bi-box-seam me-1"></i>Docker Registries <i class="bi bi-box-seam me-1"></i>Docker Registries
@@ -72,10 +80,8 @@ else
} }
else else
{ {
<div class="text-center py-4"> <EmptyState Icon="bi-app"
<i class="bi bi-app text-muted" style="font-size: 2rem;"></i> Message="Create a customer and app first to store app secrets." />
<p class="text-muted small mt-2 mb-0">Create a customer and app first to store app secrets.</p>
</div>
} }
} }
else if (scope == "component") else if (scope == "component")
@@ -99,10 +105,8 @@ else
} }
else else
{ {
<div class="text-center py-4"> <EmptyState Icon="bi-puzzle"
<i class="bi bi-puzzle text-muted" style="font-size: 2rem;"></i> Message="Create an environment, cluster, and component first to store component secrets." />
<p class="text-muted small mt-2 mb-0">Create an environment, cluster, and component first to store component secrets.</p>
</div>
} }
} }
else if (scope == "storage") else if (scope == "storage")
@@ -126,10 +130,8 @@ else
} }
else else
{ {
<div class="text-center py-4"> <EmptyState Icon="bi-hdd"
<i class="bi bi-hdd text-muted" style="font-size: 2rem;"></i> Message="Create a storage link first to view its secrets." />
<p class="text-muted small mt-2 mb-0">Create a storage link first to view its secrets.</p>
</div>
} }
} }
@@ -154,10 +156,60 @@ else
} }
else else
{ {
<div class="text-center py-4"> <EmptyState Icon="bi-database"
<i class="bi bi-database text-muted" style="font-size: 2rem;"></i> Message="No CNPG clusters found for this tenant." />
<p class="text-muted small mt-2 mb-0">No CNPG clusters found for this tenant.</p> }
}
else if (scope == "mongodb")
{
@if (mongoClusterOptions is not null && mongoClusterOptions.Count > 0)
{
<div class="card border-0 shadow-sm mb-3">
<div class="card-body py-2">
<div class="input-group">
<span class="input-group-text bg-transparent"><i class="bi bi-database"></i></span>
<select class="form-select" @bind="selectedMongoClusterId" @bind:after="LoadSecrets">
<option value="@Guid.Empty">Select a MongoDB cluster...</option>
@foreach ((string label, Guid id) in mongoClusterOptions)
{
<option value="@id">@label</option>
}
</select>
</div> </div>
</div>
</div>
}
else
{
<EmptyState Icon="bi-database"
Message="No MongoDB clusters found for this tenant." />
}
}
else if (scope == "regpostgres")
{
@if (regPostgresOptions is not null && regPostgresOptions.Count > 0)
{
<div class="card border-0 shadow-sm mb-3">
<div class="card-body py-2">
<div class="input-group">
<span class="input-group-text bg-transparent"><i class="bi bi-database"></i></span>
<select class="form-select" @bind="selectedRegPostgresInstanceId" @bind:after="LoadSecrets">
<option value="@Guid.Empty">Select a registered Postgres instance...</option>
@foreach ((string label, Guid id) in regPostgresOptions)
{
<option value="@id">@label</option>
}
</select>
</div>
</div>
</div>
}
else
{
<EmptyState Icon="bi-database"
Message="No registered Postgres instances found for this tenant." />
} }
} }
@@ -187,7 +239,9 @@ else
} }
</div> </div>
<div class="card-body"> <div class="card-body">
@* Add secret form *@ @* Add secret form — hidden for read-only DB scopes *@
@if (scope is not "mongodb" and not "regpostgres")
{
<div class="row g-2 mb-3"> <div class="row g-2 mb-3">
<div class="col-md-4"> <div class="col-md-4">
<div class="input-group input-group-sm"> <div class="input-group input-group-sm">
@@ -210,6 +264,7 @@ else
</button> </button>
</div> </div>
</div> </div>
}
@if (!string.IsNullOrEmpty(errorMessage)) @if (!string.IsNullOrEmpty(errorMessage))
{ {
@@ -226,29 +281,22 @@ else
} }
@* Secrets table *@ @* Secrets table *@
@if (secrets is null) <LoadingPanel Loading="@(secrets is null)">
@if (secrets is not null && secrets.Count == 0)
{ {
<div class="text-center py-3"> <EmptyState Icon="bi-lock"
<div class="spinner-border spinner-border-sm text-primary" role="status"></div> Message="No secrets stored yet. Add one above." />
</div>
} }
else if (secrets.Count == 0) else if (secrets is not null)
{
<div class="text-center py-3">
<i class="bi bi-lock text-muted" style="font-size: 2rem;"></i>
<p class="text-muted small mt-1 mb-0">No secrets stored yet. Add one above.</p>
</div>
}
else
{ {
<div class="table-responsive"> <div class="table-responsive">
<table class="table table-hover align-middle mb-0"> <table class="table table-hover align-middle mb-0">
<thead class="table-light"> <thead class="table-light">
<tr> <tr>
<th>Name</th> <th>Name</th>
@if (scope == "cnpg") @if (scope is "cnpg" or "mongodb" or "regpostgres")
{ {
<th>Scope</th> <th>Database</th>
} }
<th>K8s Sync</th> <th>K8s Sync</th>
<th>K8s Secret</th> <th>K8s Secret</th>
@@ -262,13 +310,21 @@ else
{ {
<tr> <tr>
<td><code>@secret.Name</code></td> <td><code>@secret.Name</code></td>
@if (scope == "cnpg") @if (scope is "cnpg" or "mongodb" or "regpostgres")
{ {
<td> <td>
@if (secret.CnpgDatabase is not null) @if (secret.CnpgDatabase is not null)
{ {
<span class="badge bg-info-subtle text-info border border-info-subtle">@secret.CnpgDatabase.Name</span> <span class="badge bg-info-subtle text-info border border-info-subtle">@secret.CnpgDatabase.Name</span>
} }
else if (secret.MongoDatabase is not null)
{
<span class="badge bg-success-subtle text-success border border-success-subtle">@secret.MongoDatabase.Name</span>
}
else if (secret.RegisteredPostgresDatabase is not null)
{
<span class="badge bg-primary-subtle text-primary border border-primary-subtle">@secret.RegisteredPostgresDatabase.Name</span>
}
else else
{ {
<span class="badge bg-secondary-subtle text-secondary border border-secondary-subtle">cluster</span> <span class="badge bg-secondary-subtle text-secondary border border-secondary-subtle">cluster</span>
@@ -296,6 +352,9 @@ else
<button class="btn btn-sm btn-outline-warning me-1" @onclick="() => StartEdit(secret)" title="Edit value"> <button class="btn btn-sm btn-outline-warning me-1" @onclick="() => StartEdit(secret)" title="Edit value">
<i class="bi bi-pencil"></i> <i class="bi bi-pencil"></i>
</button> </button>
<button class="btn btn-sm btn-outline-secondary me-1" @onclick="() => ToggleHistory(secret.Id)" title="Version history">
<i class="bi bi-clock-history"></i>
</button>
<button class="btn btn-sm btn-outline-info me-1" @onclick="() => ToggleSync(secret)" <button class="btn btn-sm btn-outline-info me-1" @onclick="() => ToggleSync(secret)"
title="@(secret.SyncToKubernetes ? "Disable K8s sync" : "Enable K8s sync")"> title="@(secret.SyncToKubernetes ? "Disable K8s sync" : "Enable K8s sync")">
<i class="bi @(secret.SyncToKubernetes ? "bi-cloud-slash" : "bi-cloud-arrow-up")"></i> <i class="bi @(secret.SyncToKubernetes ? "bi-cloud-slash" : "bi-cloud-arrow-up")"></i>
@@ -343,6 +402,81 @@ else
</tr> </tr>
} }
@* Version history row *@
@if (historySecretId == secret.Id)
{
<tr>
<td colspan="7" class="bg-light p-0">
<div class="p-3">
<div class="d-flex align-items-center mb-2">
<i class="bi bi-clock-history me-2 text-secondary"></i>
<strong class="small">Version History</strong>
<span class="badge bg-secondary ms-2">@(secretVersions?.Count ?? 0)</span>
</div>
@if (secretVersions is null)
{
<div class="spinner-border spinner-border-sm text-secondary" role="status"></div>
}
else if (secretVersions.Count == 0)
{
<p class="text-muted small mb-0">No previous versions recorded.</p>
}
else
{
<table class="table table-sm table-bordered mb-0">
<thead class="table-secondary">
<tr>
<th style="width: 60px;">#</th>
<th>Set by</th>
<th>Date</th>
<th class="text-end" style="width: 160px;">Actions</th>
</tr>
</thead>
<tbody>
@foreach (VaultSecretVersion ver in secretVersions)
{
<tr>
<td><span class="badge bg-secondary">v@ver.VersionNumber</span></td>
<td><small class="text-muted">@(ver.CreatedBy ?? "—")</small></td>
<td><small class="text-muted">@ver.CreatedAt.ToString("MMM d, HH:mm")</small></td>
<td class="text-end">
<button class="btn btn-xs btn-outline-secondary me-1"
@onclick="() => RevealVersion(secret.Id, ver)"
title="@(revealedVersionId == ver.Id ? "Hide" : "Reveal value")">
<i class="bi @(revealedVersionId == ver.Id ? "bi-eye-slash" : "bi-eye")"></i>
</button>
<button class="btn btn-xs btn-outline-warning"
@onclick="() => RollbackToVersion(secret.Id, ver.Id)"
title="Restore this value">
<i class="bi bi-arrow-counterclockwise me-1"></i>Restore
</button>
</td>
</tr>
@if (revealedVersionId == ver.Id)
{
<tr class="table-light">
<td colspan="4" class="px-2 py-1">
<span class="text-muted small me-2">Value:</span>
@if (versionRevealLoading)
{
<span class="spinner-border spinner-border-sm"></span>
}
else
{
<code class="user-select-all">@revealedVersionValue</code>
}
</td>
</tr>
}
}
</tbody>
</table>
}
</div>
</td>
</tr>
}
@if (syncConfigSecretId == secret.Id) @if (syncConfigSecretId == secret.Id)
{ {
<tr> <tr>
@@ -385,6 +519,7 @@ else
</table> </table>
</div> </div>
} }
</LoadingPanel>
@if (syncOutput is not null) @if (syncOutput is not null)
{ {
<div class="mt-3 p-2 bg-dark text-white rounded font-monospace small" style="white-space: pre-wrap; max-height: 200px; overflow-y: auto;">@syncOutput</div> <div class="mt-3 p-2 bg-dark text-white rounded font-monospace small" style="white-space: pre-wrap; max-height: 200px; overflow-y: auto;">@syncOutput</div>
@@ -416,6 +551,14 @@ else
private List<(string Label, Guid Id)>? cnpgClusterOptions; private List<(string Label, Guid Id)>? cnpgClusterOptions;
private Guid selectedCnpgClusterId; private Guid selectedCnpgClusterId;
// MongoDB cluster scope
private List<(string Label, Guid Id)>? mongoClusterOptions;
private Guid selectedMongoClusterId;
// Registered Postgres scope
private List<(string Label, Guid Id)>? regPostgresOptions;
private Guid selectedRegPostgresInstanceId;
// Cluster options (used in app-scope sync config) // Cluster options (used in app-scope sync config)
private List<(string Label, Guid Id)>? clusterOptions; private List<(string Label, Guid Id)>? clusterOptions;
@@ -440,6 +583,13 @@ else
private Guid? editSecretId; private Guid? editSecretId;
private string editSecretValue = ""; private string editSecretValue = "";
// Version history state
private Guid? historySecretId;
private List<VaultSecretVersion>? secretVersions;
private Guid? revealedVersionId;
private string? revealedVersionValue;
private bool versionRevealLoading;
protected override async Task OnInitializedAsync() protected override async Task OnInitializedAsync()
{ {
SecretVault? vault = await VaultService.GetVaultAsync(TenantId); SecretVault? vault = await VaultService.GetVaultAsync(TenantId);
@@ -500,6 +650,18 @@ else
cnpgClusterOptions = cnpgClusters cnpgClusterOptions = cnpgClusters
.Select(c => ($"{c.KubernetesCluster.Name} / {c.Name}", c.Id)) .Select(c => ($"{c.KubernetesCluster.Name} / {c.Name}", c.Id))
.ToList(); .ToList();
// MongoDB clusters
List<MongoCluster> mongoClusters = await MongoService.GetClustersAsync(TenantId);
mongoClusterOptions = mongoClusters
.Select(c => ($"{c.KubernetesCluster.Name} / {c.Name}", c.Id))
.ToList();
// Registered Postgres instances
List<RegisteredPostgresInstance> regInstances = await RegisteredPostgresService.GetInstancesAsync(TenantId);
regPostgresOptions = regInstances
.Select(i => (i.Name, i.Id))
.ToList();
} }
private void SwitchScope(string newScope) private void SwitchScope(string newScope)
@@ -510,6 +672,8 @@ else
selectedComponentId = Guid.Empty; selectedComponentId = Guid.Empty;
selectedStorageLinkId = Guid.Empty; selectedStorageLinkId = Guid.Empty;
selectedCnpgClusterId = Guid.Empty; selectedCnpgClusterId = Guid.Empty;
selectedMongoClusterId = Guid.Empty;
selectedRegPostgresInstanceId = Guid.Empty;
errorMessage = null; errorMessage = null;
successMessage = null; successMessage = null;
syncClusterId = Guid.Empty; syncClusterId = Guid.Empty;
@@ -522,7 +686,9 @@ else
return (scope == "app" && selectedAppId != Guid.Empty) return (scope == "app" && selectedAppId != Guid.Empty)
|| (scope == "component" && selectedComponentId != Guid.Empty) || (scope == "component" && selectedComponentId != Guid.Empty)
|| (scope == "storage" && selectedStorageLinkId != Guid.Empty) || (scope == "storage" && selectedStorageLinkId != Guid.Empty)
|| (scope == "cnpg" && selectedCnpgClusterId != Guid.Empty); || (scope == "cnpg" && selectedCnpgClusterId != Guid.Empty)
|| (scope == "mongodb" && selectedMongoClusterId != Guid.Empty)
|| (scope == "regpostgres" && selectedRegPostgresInstanceId != Guid.Empty);
} }
private async Task LoadSecrets() private async Task LoadSecrets()
@@ -546,6 +712,14 @@ else
{ {
secrets = await VaultService.GetAllCnpgSecretsForClusterAsync(TenantId, selectedCnpgClusterId); secrets = await VaultService.GetAllCnpgSecretsForClusterAsync(TenantId, selectedCnpgClusterId);
} }
else if (scope == "mongodb" && selectedMongoClusterId != Guid.Empty)
{
secrets = await VaultService.GetAllMongoSecretsForClusterAsync(TenantId, selectedMongoClusterId);
}
else if (scope == "regpostgres" && selectedRegPostgresInstanceId != Guid.Empty)
{
secrets = await VaultService.GetAllRegisteredPostgresSecretsForInstanceAsync(TenantId, selectedRegPostgresInstanceId);
}
else else
{ {
secrets = null; secrets = null;
@@ -721,4 +895,58 @@ else
syncingSecrets = false; syncingSecrets = false;
} }
} }
private async Task ToggleHistory(Guid secretId)
{
if (historySecretId == secretId)
{
historySecretId = null;
secretVersions = null;
revealedVersionId = null;
revealedVersionValue = null;
return;
}
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)
{
revealedVersionId = null;
revealedVersionValue = null;
return;
}
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);
if (ok)
{
successMessage = "Secret restored to selected version.";
historySecretId = null;
secretVersions = null;
revealedVersionId = null;
revealedVersionValue = null;
await LoadSecrets();
}
else
{
errorMessage = "Failed to restore version.";
}
}
} }

View File

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

View File

@@ -0,0 +1,9 @@
namespace EntKube.Web.Data;
public enum AccessLevel
{
None = 0,
View = 1,
Edit = 2,
Manage = 3,
}

View File

@@ -11,6 +11,7 @@ public class AlertIncident
public required string Severity { get; set; } public required string Severity { get; set; }
public string Summary { get; set; } = ""; public string Summary { get; set; } = "";
public string Description { get; set; } = ""; public string Description { get; set; } = "";
public string RunbookUrl { get; set; } = "";
public string LabelsJson { get; set; } = "{}"; public string LabelsJson { get; set; } = "{}";
public DateTime StartsAt { get; set; } public DateTime StartsAt { get; set; }
public DateTime? EndsAt { get; set; } public DateTime? EndsAt { get; set; }
@@ -18,6 +19,9 @@ public class AlertIncident
public string? AcknowledgedBy { get; set; } public string? AcknowledgedBy { get; set; }
public DateTime? AcknowledgedAt { get; set; } public DateTime? AcknowledgedAt { get; set; }
public DateTime? ResolvedAt { get; set; } public DateTime? ResolvedAt { get; set; }
public string? AssignedTo { get; set; }
public DateTime? AssignedAt { get; set; }
public DateTime? EscalatedAt { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow; public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;

View File

@@ -0,0 +1,27 @@
namespace EntKube.Web.Data;
public class AlertRoutingRule
{
public Guid Id { get; set; }
public Guid TenantId { get; set; }
public required string Name { get; set; }
public int Priority { get; set; }
public Guid? ChannelId { get; set; }
public string? MatchAlertName { get; set; }
public string? MatchNamespace { get; set; }
public string? MatchSeverity { get; set; }
public string? MatchLabelKey { get; set; }
public string? MatchLabelValue { get; set; }
public bool IsEnabled { get; set; } = true;
// When true, matching alerts are silently dropped — no incident is created.
// ChannelId is not required for suppression rules.
public bool SuppressIncident { get; set; }
// Optional: restrict this rule to a specific cluster (null = match all clusters).
public Guid? MatchClusterId { get; set; }
public Tenant Tenant { get; set; } = null!;
public NotificationChannel? Channel { get; set; }
public KubernetesCluster? MatchCluster { get; set; }
}

View File

@@ -29,7 +29,11 @@ public class App
public ICollection<AppEnvironment> AppEnvironments { get; set; } = []; public ICollection<AppEnvironment> AppEnvironments { get; set; } = [];
public ICollection<VaultSecret> Secrets { get; set; } = []; public ICollection<VaultSecret> Secrets { get; set; } = [];
public ICollection<AppDeployment> Deployments { get; set; } = []; public ICollection<AppDeployment> Deployments { get; set; } = [];
public AppQuota? Quota { get; set; } public ICollection<AppQuota> Quotas { get; set; } = [];
public ICollection<AppNetworkPolicy> NetworkPolicies { get; set; } = []; public ICollection<AppNetworkPolicy> NetworkPolicies { get; set; } = [];
public AppRbacPolicy? RbacPolicy { get; set; } public ICollection<AppRbacPolicy> RbacPolicies { get; set; } = [];
public ICollection<AppRoute> Routes { get; set; } = [];
public ICollection<AppAllowedDatabase> AllowedDatabases { get; set; } = [];
public ICollection<AppAllowedCache> AllowedCaches { get; set; } = [];
public ICollection<AppAllowedStorage> AllowedStorages { get; set; } = [];
} }

View File

@@ -0,0 +1,19 @@
namespace EntKube.Web.Data;
/// <summary>
/// Restricts which Redis clusters a customer's app may link to in a specific environment.
/// When any entries exist, only those clusters may be bound via CacheBinding. Empty = no restriction.
/// </summary>
public class AppAllowedCache
{
public Guid Id { get; set; }
public Guid AppId { get; set; }
public Guid EnvironmentId { get; set; }
public Guid RedisClusterId { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public App App { get; set; } = null!;
public Environment Environment { get; set; } = null!;
public RedisCluster RedisCluster { get; set; } = null!;
}

View File

@@ -0,0 +1,25 @@
namespace EntKube.Web.Data;
/// <summary>
/// Restricts which databases a customer's app may link to in a specific environment.
/// When any entries exist for an app+environment, only those databases may be bound
/// via DatabaseBinding. An empty list means no restriction.
/// </summary>
public class AppAllowedDatabase
{
public Guid Id { get; set; }
public Guid AppId { get; set; }
public Guid EnvironmentId { get; set; }
public Guid? CnpgDatabaseId { get; set; }
public Guid? MongoDatabaseId { get; set; }
public Guid? RegisteredPostgresDatabaseId { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public App App { get; set; } = null!;
public Environment Environment { get; set; } = null!;
public CnpgDatabase? CnpgDatabase { get; set; }
public MongoDatabase? MongoDatabase { get; set; }
public RegisteredPostgresDatabase? RegisteredPostgresDatabase { get; set; }
}

View File

@@ -0,0 +1,19 @@
namespace EntKube.Web.Data;
/// <summary>
/// Restricts which storage buckets (StorageLinks) a customer's app may link to in a specific environment.
/// When any entries exist, only those storage links may be bound via StorageBinding. Empty = no restriction.
/// </summary>
public class AppAllowedStorage
{
public Guid Id { get; set; }
public Guid AppId { get; set; }
public Guid EnvironmentId { get; set; }
public Guid StorageLinkId { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public App App { get; set; } = null!;
public Environment Environment { get; set; } = null!;
public StorageLink StorageLink { get; set; } = null!;
}

View File

@@ -82,7 +82,13 @@ public class AppDeployment
// ── Git source fields (only used when Type is GitYaml, GitHelm, or GitAppOfApps) ── // ── Git source fields (only used when Type is GitYaml, GitHelm, or GitAppOfApps) ──
/// <summary> /// <summary>
/// The registered GitRepository to sync from. /// The URL of the Git repository to sync from. Used for policy-credential-based
/// git access (no GitRepository record required). Takes precedence over GitRepositoryId.
/// </summary>
public string? GitUrl { get; set; }
/// <summary>
/// Legacy: FK to a registered GitRepository. Superseded by GitUrl for new deployments.
/// </summary> /// </summary>
public Guid? GitRepositoryId { get; set; } public Guid? GitRepositoryId { get; set; }
@@ -132,4 +138,5 @@ public class AppDeployment
public ICollection<StorageBinding> StorageBindings { get; set; } = []; public ICollection<StorageBinding> StorageBindings { get; set; } = [];
public ICollection<DatabaseBinding> DatabaseBindings { get; set; } = []; public ICollection<DatabaseBinding> DatabaseBindings { get; set; } = [];
public ICollection<CacheBinding> CacheBindings { get; set; } = []; public ICollection<CacheBinding> CacheBindings { get; set; } = [];
public ICollection<AppDeploymentRoute> Routes { get; set; } = [];
} }

View File

@@ -13,6 +13,12 @@ public class AppEnvironment
public DateTime LinkedAt { get; set; } = DateTime.UtcNow; public DateTime LinkedAt { get; set; } = DateTime.UtcNow;
/// <summary>
/// Locked namespace for this app in this environment. When set, deployments
/// must use this namespace — customers cannot override it.
/// </summary>
public string? Namespace { get; set; }
// Navigation // Navigation
public App App { get; set; } = null!; public App App { get; set; } = null!;
public Environment Environment { get; set; } = null!; public Environment Environment { get; set; } = null!;

View File

@@ -28,6 +28,9 @@ public class AppNetworkPolicy
public Guid Id { get; set; } public Guid Id { get; set; }
public Guid AppId { get; set; } public Guid AppId { get; set; }
/// <summary>Which environment this policy applies to. Policies are per-environment.</summary>
public Guid EnvironmentId { get; set; }
/// <summary>Human-readable name and also the K8s NetworkPolicy resource name.</summary> /// <summary>Human-readable name and also the K8s NetworkPolicy resource name.</summary>
public required string Name { get; set; } public required string Name { get; set; }
@@ -41,4 +44,5 @@ public class AppNetworkPolicy
// Navigation // Navigation
public App App { get; set; } = null!; public App App { get; set; } = null!;
public Environment Environment { get; set; } = null!;
} }

View File

@@ -24,6 +24,10 @@ public class AppQuota
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
/// <summary>Which environment this quota applies to. One quota per app per environment.</summary>
public Guid EnvironmentId { get; set; }
// Navigation // Navigation
public App App { get; set; } = null!; public App App { get; set; } = null!;
public Environment Environment { get; set; } = null!;
} }

View File

@@ -12,6 +12,9 @@ public class AppRbacPolicy
public Guid Id { get; set; } public Guid Id { get; set; }
public Guid AppId { get; set; } public Guid AppId { get; set; }
/// <summary>Which environment this RBAC policy applies to. One policy per app per environment.</summary>
public Guid EnvironmentId { get; set; }
/// <summary> /// <summary>
/// The Kubernetes ServiceAccount name, e.g. "billing-api". /// The Kubernetes ServiceAccount name, e.g. "billing-api".
/// Must be a valid DNS label. /// Must be a valid DNS label.
@@ -26,6 +29,7 @@ public class AppRbacPolicy
// Navigation // Navigation
public App App { get; set; } = null!; public App App { get; set; } = null!;
public Environment Environment { get; set; } = null!;
public ICollection<AppRbacRule> Rules { get; set; } = []; public ICollection<AppRbacRule> Rules { get; set; } = [];
} }

View File

@@ -0,0 +1,86 @@
namespace EntKube.Web.Data;
/// <summary>
/// App-level hostname configuration for exposing a customer application externally.
/// Holds the hostname and TLS strategy shared across all deployment environments.
/// Each deployment environment can attach with its own path prefix via AppDeploymentRoute.
/// </summary>
public class AppRoute
{
public Guid Id { get; set; }
public Guid AppId { get; set; }
/// <summary>
/// The public hostname (e.g. "myapp.example.com"). Must be unique within a cluster.
/// </summary>
public required string Hostname { get; set; }
/// <summary>How TLS is handled — automatic via ClusterIssuer or manual cert upload.</summary>
public TlsMode TlsMode { get; set; } = TlsMode.ClusterIssuer;
/// <summary>Name of the ClusterIssuer when TlsMode is ClusterIssuer (e.g. "letsencrypt-prod").</summary>
public string? ClusterIssuerName { get; set; }
/// <summary>PEM-encoded certificate for manual TLS mode.</summary>
public string? TlsCertificate { get; set; }
/// <summary>PEM-encoded private key for manual TLS mode.</summary>
public string? TlsPrivateKey { get; set; }
/// <summary>When false the route is kept in the database but no Kubernetes resources are applied.</summary>
public bool IsEnabled { get; set; } = true;
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
// Navigation
public App App { get; set; } = null!;
public ICollection<AppDeploymentRoute> DeploymentRoutes { get; set; } = [];
}
/// <summary>
/// Links one AppRoute to one AppDeployment, specifying the path prefix and target service
/// for that environment. A Kubernetes HTTPRoute is generated per AppDeploymentRoute.
/// </summary>
public class AppDeploymentRoute
{
public Guid Id { get; set; }
public Guid AppRouteId { get; set; }
public Guid AppDeploymentId { get; set; }
/// <summary>
/// Path prefix for this deployment (e.g. "/" for prod, "/staging" for staging).
/// Multiple deployments share the same hostname via different path prefixes.
/// </summary>
public string PathPrefix { get; set; } = "/";
/// <summary>The Kubernetes service name to route traffic to.</summary>
public required string ServiceName { get; set; }
/// <summary>The port on the service to route traffic to.</summary>
public int ServicePort { get; set; } = 80;
/// <summary>Gateway resource name resolved from the cluster's installed ingress controller.</summary>
public string? GatewayName { get; set; }
/// <summary>Namespace where the Gateway resource lives.</summary>
public string? GatewayNamespace { get; set; }
public bool IsEnabled { get; set; } = true;
/// <summary>Set when the HTTPRoute manifest was last successfully applied to the cluster. Null means not yet applied.</summary>
public DateTime? ClusterAppliedAt { get; set; }
// Health monitoring (updated by background health checks)
public DateTime? LastHealthCheckAt { get; set; }
public int? LastStatusCode { get; set; }
public bool? IsReachable { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
// Navigation
public AppRoute AppRoute { get; set; } = null!;
public AppDeployment AppDeployment { get; set; } = null!;
}

View File

@@ -24,6 +24,7 @@ public class ApplicationDbContext(DbContextOptions options) : IdentityDbContext<
public DbSet<KubernetesCluster> KubernetesClusters => Set<KubernetesCluster>(); public DbSet<KubernetesCluster> KubernetesClusters => Set<KubernetesCluster>();
public DbSet<SecretVault> SecretVaults => Set<SecretVault>(); public DbSet<SecretVault> SecretVaults => Set<SecretVault>();
public DbSet<VaultSecret> VaultSecrets => Set<VaultSecret>(); public DbSet<VaultSecret> VaultSecrets => Set<VaultSecret>();
public DbSet<VaultSecretVersion> VaultSecretVersions => Set<VaultSecretVersion>();
public DbSet<ClusterComponent> ClusterComponents => Set<ClusterComponent>(); public DbSet<ClusterComponent> ClusterComponents => Set<ClusterComponent>();
public DbSet<ExternalRoute> ExternalRoutes => Set<ExternalRoute>(); public DbSet<ExternalRoute> ExternalRoutes => Set<ExternalRoute>();
public DbSet<StorageLink> StorageLinks => Set<StorageLink>(); public DbSet<StorageLink> StorageLinks => Set<StorageLink>();
@@ -69,10 +70,22 @@ public class ApplicationDbContext(DbContextOptions options) : IdentityDbContext<
public DbSet<CacheBinding> CacheBindings => Set<CacheBinding>(); public DbSet<CacheBinding> CacheBindings => Set<CacheBinding>();
public DbSet<GitRepository> GitRepositories => Set<GitRepository>(); public DbSet<GitRepository> GitRepositories => Set<GitRepository>();
public DbSet<GitKnownHost> GitKnownHosts => Set<GitKnownHost>(); public DbSet<GitKnownHost> GitKnownHosts => Set<GitKnownHost>();
public DbSet<CustomerGitRepoPolicy> CustomerGitRepoPolicies => Set<CustomerGitRepoPolicy>();
public DbSet<CustomerGitCredential> CustomerGitCredentials => Set<CustomerGitCredential>();
public DbSet<AppQuota> AppQuotas => Set<AppQuota>(); public DbSet<AppQuota> AppQuotas => Set<AppQuota>();
public DbSet<AppNetworkPolicy> AppNetworkPolicies => Set<AppNetworkPolicy>(); public DbSet<AppNetworkPolicy> AppNetworkPolicies => Set<AppNetworkPolicy>();
public DbSet<AppRbacPolicy> AppRbacPolicies => Set<AppRbacPolicy>(); public DbSet<AppRbacPolicy> AppRbacPolicies => Set<AppRbacPolicy>();
public DbSet<AppRbacRule> AppRbacRules => Set<AppRbacRule>(); public DbSet<AppRbacRule> AppRbacRules => Set<AppRbacRule>();
public DbSet<AppRoute> AppRoutes => Set<AppRoute>();
public DbSet<AppDeploymentRoute> AppDeploymentRoutes => Set<AppDeploymentRoute>();
public DbSet<AppAllowedDatabase> AppAllowedDatabases => Set<AppAllowedDatabase>();
public DbSet<AppAllowedCache> AppAllowedCaches => Set<AppAllowedCache>();
public DbSet<AppAllowedStorage> AppAllowedStorages => Set<AppAllowedStorage>();
public DbSet<OnCallSchedule> OnCallSchedules => Set<OnCallSchedule>();
public DbSet<OnCallShift> OnCallShifts => Set<OnCallShift>();
public DbSet<AlertRoutingRule> AlertRoutingRules => Set<AlertRoutingRule>();
public DbSet<NotificationProviderConfig> NotificationProviderConfigs => Set<NotificationProviderConfig>();
public DbSet<ClusterServer> ClusterServers => Set<ClusterServer>();
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{ {
@@ -339,6 +352,22 @@ public class ApplicationDbContext(DbContextOptions options) : IdentityDbContext<
.WithMany() .WithMany()
.HasForeignKey(s => s.RedisClusterId) .HasForeignKey(s => s.RedisClusterId)
.OnDelete(DeleteBehavior.Cascade); .OnDelete(DeleteBehavior.Cascade);
entity.HasMany(s => s.Versions)
.WithOne(v => v.Secret)
.HasForeignKey(v => v.SecretId)
.OnDelete(DeleteBehavior.Cascade);
});
// VaultSecretVersion — immutable historical snapshots of a secret's value.
// At most 10 versions are retained per secret (pruned on write).
builder.Entity<VaultSecretVersion>(entity =>
{
entity.HasKey(v => v.Id);
entity.Property(v => v.CreatedBy).HasMaxLength(254);
entity.HasIndex(v => new { v.SecretId, v.VersionNumber });
}); });
// DockerRegistryCredential — encrypted registry auth stored in the tenant vault. // DockerRegistryCredential — encrypted registry auth stored in the tenant vault.
@@ -991,8 +1020,11 @@ public class ApplicationDbContext(DbContextOptions options) : IdentityDbContext<
entity.Property(i => i.Severity).HasMaxLength(20).IsRequired(); entity.Property(i => i.Severity).HasMaxLength(20).IsRequired();
entity.Property(i => i.Summary).HasMaxLength(500); entity.Property(i => i.Summary).HasMaxLength(500);
entity.Property(i => i.Description).HasMaxLength(2000); entity.Property(i => i.Description).HasMaxLength(2000);
entity.Property(i => i.RunbookUrl).HasMaxLength(500);
entity.Property(i => i.AcknowledgedBy).HasMaxLength(256); entity.Property(i => i.AcknowledgedBy).HasMaxLength(256);
entity.Property(i => i.AssignedTo).HasMaxLength(256);
entity.Property(i => i.Status).HasConversion<string>().HasMaxLength(20); entity.Property(i => i.Status).HasConversion<string>().HasMaxLength(20);
entity.HasIndex(i => i.EscalatedAt);
entity.HasOne(i => i.Cluster) entity.HasOne(i => i.Cluster)
.WithMany() .WithMany()
@@ -1208,33 +1240,89 @@ public class ApplicationDbContext(DbContextOptions options) : IdentityDbContext<
.HasForeignKey(s => s.GitRepositoryId) .HasForeignKey(s => s.GitRepositoryId)
.OnDelete(DeleteBehavior.Cascade); .OnDelete(DeleteBehavior.Cascade);
// CustomerGitRepoPolicy — URL allowlist per customer per environment (wildcard patterns).
builder.Entity<CustomerGitRepoPolicy>(entity =>
{
entity.HasKey(p => p.Id);
entity.HasIndex(p => new { p.CustomerId, p.EnvironmentId, p.UrlPattern }).IsUnique();
entity.Property(p => p.UrlPattern).HasMaxLength(2000).IsRequired();
entity.HasOne(p => p.Customer)
.WithMany(c => c.GitRepoPolicies)
.HasForeignKey(p => p.CustomerId)
.OnDelete(DeleteBehavior.Cascade);
entity.HasOne(p => p.Environment)
.WithMany()
.HasForeignKey(p => p.EnvironmentId)
.OnDelete(DeleteBehavior.Restrict);
});
// CustomerGitCredential — reusable credential sets per customer per environment.
builder.Entity<CustomerGitCredential>(entity =>
{
entity.HasKey(c => c.Id);
entity.HasIndex(c => new { c.CustomerId, c.EnvironmentId, c.Name }).IsUnique();
entity.Property(c => c.Name).HasMaxLength(200).IsRequired();
entity.Property(c => c.AuthType).HasConversion<string>().HasMaxLength(20);
entity.Property(c => c.Username).HasMaxLength(300);
entity.Property(c => c.UrlPattern).HasMaxLength(500);
entity.HasOne(c => c.Customer)
.WithMany(cu => cu.GitCredentials)
.HasForeignKey(c => c.CustomerId)
.OnDelete(DeleteBehavior.Cascade);
entity.HasOne(c => c.Tenant)
.WithMany()
.HasForeignKey(c => c.TenantId)
.OnDelete(DeleteBehavior.Cascade);
entity.HasOne(c => c.Environment)
.WithMany()
.HasForeignKey(c => c.EnvironmentId)
.OnDelete(DeleteBehavior.Restrict);
});
// VaultSecret — CustomerGitCredential scoping (PAT / SSH key / password for a customer credential).
builder.Entity<VaultSecret>()
.HasOne(s => s.CustomerGitCredential)
.WithMany()
.HasForeignKey(s => s.CustomerGitCredentialId)
.OnDelete(DeleteBehavior.Cascade);
// App — add Namespace field constraint. // App — add Namespace field constraint.
builder.Entity<App>(entity => builder.Entity<App>(entity =>
{ {
entity.Property(a => a.Namespace).HasMaxLength(63); entity.Property(a => a.Namespace).HasMaxLength(63);
}); });
// AppQuota — 1:1 with App, cascades on app delete. // AppQuota — one per app per environment. Cascades on app delete.
builder.Entity<AppQuota>(entity => builder.Entity<AppQuota>(entity =>
{ {
entity.HasKey(q => q.Id); entity.HasKey(q => q.Id);
entity.HasIndex(q => q.AppId).IsUnique(); entity.HasIndex(q => new { q.AppId, q.EnvironmentId }).IsUnique();
entity.Property(q => q.CpuRequest).HasMaxLength(20); entity.Property(q => q.CpuRequest).HasMaxLength(20);
entity.Property(q => q.CpuLimit).HasMaxLength(20); entity.Property(q => q.CpuLimit).HasMaxLength(20);
entity.Property(q => q.MemoryRequest).HasMaxLength(20); entity.Property(q => q.MemoryRequest).HasMaxLength(20);
entity.Property(q => q.MemoryLimit).HasMaxLength(20); entity.Property(q => q.MemoryLimit).HasMaxLength(20);
entity.HasOne(q => q.App) entity.HasOne(q => q.App)
.WithOne(a => a.Quota) .WithMany(a => a.Quotas)
.HasForeignKey<AppQuota>(q => q.AppId) .HasForeignKey(q => q.AppId)
.OnDelete(DeleteBehavior.Cascade); .OnDelete(DeleteBehavior.Cascade);
entity.HasOne(q => q.Environment)
.WithMany()
.HasForeignKey(q => q.EnvironmentId)
.OnDelete(DeleteBehavior.Restrict);
}); });
// AppNetworkPolicy — many per app, name unique within app. // AppNetworkPolicy — many per app per environment; name unique within (app, environment).
builder.Entity<AppNetworkPolicy>(entity => builder.Entity<AppNetworkPolicy>(entity =>
{ {
entity.HasKey(p => p.Id); entity.HasKey(p => p.Id);
entity.HasIndex(p => new { p.AppId, p.Name }).IsUnique(); entity.HasIndex(p => new { p.AppId, p.EnvironmentId, p.Name }).IsUnique();
entity.Property(p => p.Name).HasMaxLength(63).IsRequired(); entity.Property(p => p.Name).HasMaxLength(63).IsRequired();
entity.Property(p => p.PolicyType).HasConversion<string>().HasMaxLength(30); entity.Property(p => p.PolicyType).HasConversion<string>().HasMaxLength(30);
entity.Property(p => p.AllowFromNamespace).HasMaxLength(63); entity.Property(p => p.AllowFromNamespace).HasMaxLength(63);
@@ -1243,19 +1331,29 @@ public class ApplicationDbContext(DbContextOptions options) : IdentityDbContext<
.WithMany(a => a.NetworkPolicies) .WithMany(a => a.NetworkPolicies)
.HasForeignKey(p => p.AppId) .HasForeignKey(p => p.AppId)
.OnDelete(DeleteBehavior.Cascade); .OnDelete(DeleteBehavior.Cascade);
entity.HasOne(p => p.Environment)
.WithMany()
.HasForeignKey(p => p.EnvironmentId)
.OnDelete(DeleteBehavior.Restrict);
}); });
// AppRbacPolicy — 1:1 with App. // AppRbacPolicy — one per app per environment.
builder.Entity<AppRbacPolicy>(entity => builder.Entity<AppRbacPolicy>(entity =>
{ {
entity.HasKey(p => p.Id); entity.HasKey(p => p.Id);
entity.HasIndex(p => p.AppId).IsUnique(); entity.HasIndex(p => new { p.AppId, p.EnvironmentId }).IsUnique();
entity.Property(p => p.ServiceAccountName).HasMaxLength(63).IsRequired(); entity.Property(p => p.ServiceAccountName).HasMaxLength(63).IsRequired();
entity.HasOne(p => p.App) entity.HasOne(p => p.App)
.WithOne(a => a.RbacPolicy) .WithMany(a => a.RbacPolicies)
.HasForeignKey<AppRbacPolicy>(p => p.AppId) .HasForeignKey(p => p.AppId)
.OnDelete(DeleteBehavior.Cascade); .OnDelete(DeleteBehavior.Cascade);
entity.HasOne(p => p.Environment)
.WithMany()
.HasForeignKey(p => p.EnvironmentId)
.OnDelete(DeleteBehavior.Restrict);
}); });
// AppRbacRule — many per AppRbacPolicy. // AppRbacRule — many per AppRbacPolicy.
@@ -1288,6 +1386,11 @@ public class ApplicationDbContext(DbContextOptions options) : IdentityDbContext<
.WithMany(t => t.GitRepositories) .WithMany(t => t.GitRepositories)
.HasForeignKey(r => r.TenantId) .HasForeignKey(r => r.TenantId)
.OnDelete(DeleteBehavior.Cascade); .OnDelete(DeleteBehavior.Cascade);
entity.HasOne(r => r.CustomerGitCredential)
.WithMany()
.HasForeignKey(r => r.CustomerGitCredentialId)
.OnDelete(DeleteBehavior.SetNull);
}); });
// GitKnownHost — trusted SSH host fingerprints, unique per (TenantId, Hostname). // GitKnownHost — trusted SSH host fingerprints, unique per (TenantId, Hostname).
@@ -1324,6 +1427,215 @@ public class ApplicationDbContext(DbContextOptions options) : IdentityDbContext<
.HasForeignKey(d => d.ParentDeploymentId) .HasForeignKey(d => d.ParentDeploymentId)
.OnDelete(DeleteBehavior.Cascade); .OnDelete(DeleteBehavior.Cascade);
}); });
// AppRoute — app-level hostname + TLS config. Cascades from App.
builder.Entity<AppRoute>(entity =>
{
entity.HasKey(r => r.Id);
entity.Property(r => r.Hostname).HasMaxLength(253).IsRequired();
entity.Property(r => r.TlsMode).HasConversion<string>().HasMaxLength(20);
entity.Property(r => r.ClusterIssuerName).HasMaxLength(200);
entity.HasOne(r => r.App)
.WithMany(a => a.Routes)
.HasForeignKey(r => r.AppId)
.OnDelete(DeleteBehavior.Cascade);
});
// AppDeploymentRoute — per-deployment path + service target. Cascades from AppRoute.
builder.Entity<AppDeploymentRoute>(entity =>
{
entity.HasKey(r => r.Id);
entity.Property(r => r.PathPrefix).HasMaxLength(200).IsRequired();
entity.Property(r => r.ServiceName).HasMaxLength(200).IsRequired();
entity.Property(r => r.GatewayName).HasMaxLength(200);
entity.Property(r => r.GatewayNamespace).HasMaxLength(63);
entity.HasOne(r => r.AppRoute)
.WithMany(ar => ar.DeploymentRoutes)
.HasForeignKey(r => r.AppRouteId)
.OnDelete(DeleteBehavior.Cascade);
entity.HasOne(r => r.AppDeployment)
.WithMany(d => d.Routes)
.HasForeignKey(r => r.AppDeploymentId)
.OnDelete(DeleteBehavior.Cascade);
});
// AppAllowedDatabase — governance allowlist for which databases an app may bind to
// in a given environment. Cascades when the app is deleted; restricted on environment.
builder.Entity<AppAllowedDatabase>(entity =>
{
entity.HasKey(a => a.Id);
entity.HasOne(a => a.App)
.WithMany(app => app.AllowedDatabases)
.HasForeignKey(a => a.AppId)
.OnDelete(DeleteBehavior.Cascade);
entity.HasOne(a => a.Environment)
.WithMany()
.HasForeignKey(a => a.EnvironmentId)
.OnDelete(DeleteBehavior.Restrict);
entity.HasOne(a => a.CnpgDatabase)
.WithMany()
.HasForeignKey(a => a.CnpgDatabaseId)
.OnDelete(DeleteBehavior.Cascade);
entity.HasOne(a => a.MongoDatabase)
.WithMany()
.HasForeignKey(a => a.MongoDatabaseId)
.OnDelete(DeleteBehavior.Cascade);
entity.HasOne(a => a.RegisteredPostgresDatabase)
.WithMany()
.HasForeignKey(a => a.RegisteredPostgresDatabaseId)
.OnDelete(DeleteBehavior.Cascade);
});
// AppAllowedCache — governance allowlist for which Redis clusters an app may bind to.
builder.Entity<AppAllowedCache>(entity =>
{
entity.HasKey(a => a.Id);
entity.HasOne(a => a.App)
.WithMany(app => app.AllowedCaches)
.HasForeignKey(a => a.AppId)
.OnDelete(DeleteBehavior.Cascade);
entity.HasOne(a => a.Environment)
.WithMany()
.HasForeignKey(a => a.EnvironmentId)
.OnDelete(DeleteBehavior.Restrict);
entity.HasOne(a => a.RedisCluster)
.WithMany()
.HasForeignKey(a => a.RedisClusterId)
.OnDelete(DeleteBehavior.Cascade);
});
// AppAllowedStorage — governance allowlist for which StorageLinks an app may bind to.
builder.Entity<AppAllowedStorage>(entity =>
{
entity.HasKey(a => a.Id);
entity.HasOne(a => a.App)
.WithMany(app => app.AllowedStorages)
.HasForeignKey(a => a.AppId)
.OnDelete(DeleteBehavior.Cascade);
entity.HasOne(a => a.Environment)
.WithMany()
.HasForeignKey(a => a.EnvironmentId)
.OnDelete(DeleteBehavior.Restrict);
entity.HasOne(a => a.StorageLink)
.WithMany()
.HasForeignKey(a => a.StorageLinkId)
.OnDelete(DeleteBehavior.Cascade);
});
// OnCallSchedule — named rotation schedule owned by a tenant.
builder.Entity<OnCallSchedule>(entity =>
{
entity.HasKey(s => s.Id);
entity.HasIndex(s => new { s.TenantId, s.Name }).IsUnique();
entity.Property(s => s.Name).HasMaxLength(200).IsRequired();
entity.Property(s => s.Description).HasMaxLength(500);
entity.HasOne(s => s.Tenant)
.WithMany()
.HasForeignKey(s => s.TenantId)
.OnDelete(DeleteBehavior.Cascade);
entity.HasMany(s => s.Shifts)
.WithOne(sh => sh.Schedule)
.HasForeignKey(sh => sh.ScheduleId)
.OnDelete(DeleteBehavior.Cascade);
});
// OnCallShift — a single time-boxed assignment within a schedule.
builder.Entity<OnCallShift>(entity =>
{
entity.HasKey(sh => sh.Id);
entity.HasIndex(sh => sh.ScheduleId);
entity.HasIndex(sh => sh.StartsAt);
entity.Property(sh => sh.AssigneeName).HasMaxLength(256).IsRequired();
entity.Property(sh => sh.AssigneeEmail).HasMaxLength(256);
entity.Property(sh => sh.Notes).HasMaxLength(1000);
});
// AlertRoutingRule — tenant-specific rules that map alert criteria to a notification channel.
builder.Entity<AlertRoutingRule>(entity =>
{
entity.HasKey(r => r.Id);
entity.HasIndex(r => r.TenantId);
entity.HasIndex(r => r.ChannelId);
entity.Property(r => r.Name).HasMaxLength(200).IsRequired();
entity.Property(r => r.MatchAlertName).HasMaxLength(200);
entity.Property(r => r.MatchNamespace).HasMaxLength(200);
entity.Property(r => r.MatchSeverity).HasMaxLength(20);
entity.Property(r => r.MatchLabelKey).HasMaxLength(100);
entity.Property(r => r.MatchLabelValue).HasMaxLength(200);
entity.HasOne(r => r.Tenant)
.WithMany()
.HasForeignKey(r => r.TenantId)
.OnDelete(DeleteBehavior.Cascade);
entity.HasOne(r => r.Channel)
.WithMany()
.HasForeignKey(r => r.ChannelId)
.OnDelete(DeleteBehavior.Cascade)
.IsRequired(false);
entity.HasOne(r => r.MatchCluster)
.WithMany()
.HasForeignKey(r => r.MatchClusterId)
.OnDelete(DeleteBehavior.SetNull)
.IsRequired(false);
});
builder.Entity<NotificationProviderConfig>(entity =>
{
entity.HasKey(c => c.Id);
entity.HasIndex(c => c.ProviderType).IsUnique();
entity.Property(c => c.ProviderType).HasConversion<string>().HasMaxLength(30);
entity.Property(c => c.UpdatedByUserId).HasMaxLength(450);
});
// ClusterServer — inventory record for physical/VM nodes behind a K8s cluster.
// Name (DisplayName) must be unique within a cluster.
builder.Entity<ClusterServer>(entity =>
{
entity.HasKey(s => s.Id);
entity.HasIndex(s => new { s.ClusterId, s.DisplayName }).IsUnique();
entity.Property(s => s.DisplayName).HasMaxLength(200).IsRequired();
entity.Property(s => s.NodeName).HasMaxLength(253);
entity.Property(s => s.IpAddress).HasMaxLength(45);
entity.Property(s => s.ManagementIpAddress).HasMaxLength(45);
entity.Property(s => s.Provider).HasConversion<string>().HasMaxLength(20);
entity.Property(s => s.OsDistribution).HasMaxLength(200);
entity.Property(s => s.Location).HasMaxLength(200);
entity.Property(s => s.SshUser).HasMaxLength(100);
entity.Property(s => s.JumpHost).HasMaxLength(500);
entity.Property(s => s.Notes).HasMaxLength(2000);
entity.HasOne(s => s.Cluster)
.WithMany(c => c.Servers)
.HasForeignKey(s => s.ClusterId)
.OnDelete(DeleteBehavior.Cascade);
});
} }
} }

View File

@@ -0,0 +1,69 @@
namespace EntKube.Web.Data;
public enum ServerProvider
{
BareMetal,
CloudVm,
OnPremVm,
Other
}
/// <summary>
/// Inventory record for the physical or virtual machine that backs a Kubernetes node.
/// Stores hardware specs, location, and SSH access info for out-of-band management.
/// Optionally linked to a K8s node name for the live-node ↔ server mapping.
/// </summary>
public class ClusterServer
{
public Guid Id { get; set; }
public Guid ClusterId { get; set; }
/// <summary>
/// K8s node name this server maps to (e.g. "node-1.example.com").
/// Null means the server is registered but not yet associated with a live node.
/// </summary>
public string? NodeName { get; set; }
public required string DisplayName { get; set; }
/// <summary>Primary IP reachable within the cluster network.</summary>
public string? IpAddress { get; set; }
/// <summary>Out-of-band / IPMI / management IP for direct server access.</summary>
public string? ManagementIpAddress { get; set; }
public ServerProvider Provider { get; set; } = ServerProvider.BareMetal;
/// <summary>OS description, e.g. "Ubuntu 22.04 LTS".</summary>
public string? OsDistribution { get; set; }
public int? CpuCores { get; set; }
/// <summary>RAM in GB.</summary>
public int? RamGb { get; set; }
/// <summary>Primary disk size in GB.</summary>
public int? DiskGb { get; set; }
/// <summary>Physical or cloud location — datacenter, availability zone, region, etc.</summary>
public string? Location { get; set; }
// ── SSH access ────────────────────────────────────────────────────────────
public string? SshUser { get; set; }
public int SshPort { get; set; } = 22;
/// <summary>Jump / bastion host to tunnel through, e.g. "bastion.example.com:22".</summary>
public string? JumpHost { get; set; }
/// <summary>Free-form notes — maintenance history, special config, caveats, etc.</summary>
public string? Notes { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
// Navigation
public KubernetesCluster Cluster { get; set; } = null!;
}

View File

@@ -20,4 +20,6 @@ public class Customer
// Navigation // Navigation
public Tenant Tenant { get; set; } = null!; public Tenant Tenant { get; set; } = null!;
public ICollection<App> Apps { get; set; } = []; public ICollection<App> Apps { get; set; } = [];
public ICollection<CustomerGitRepoPolicy> GitRepoPolicies { get; set; } = [];
public ICollection<CustomerGitCredential> GitCredentials { get; set; } = [];
} }

View File

@@ -0,0 +1,49 @@
namespace EntKube.Web.Data;
/// <summary>
/// A reusable git credential set scoped to a customer. One credential can cover
/// any number of repositories that match the customer's GitRepoPolicies.
/// Actual secret values (PAT, SSH key, password) are encrypted in the tenant
/// vault as VaultSecret rows with CustomerGitCredentialId set.
///
/// Supported auth types (same as GitRepository):
/// None — public repos, no credentials needed
/// HttpsPat — HTTPS with a Personal Access Token (vault: "PAT")
/// HttpsPassword — HTTPS with username + password (vault: "PASSWORD"; username stored here)
/// SshKey — SSH private key (vault: "SSH_PRIVATE_KEY")
/// </summary>
public class CustomerGitCredential
{
public Guid Id { get; set; }
public Guid CustomerId { get; set; }
public Guid TenantId { get; set; }
public Guid EnvironmentId { get; set; }
/// <summary>Human-friendly label, e.g. "GitHub PAT". Unique within a customer.</summary>
public required string Name { get; set; }
public GitAuthType AuthType { get; set; } = GitAuthType.None;
/// <summary>
/// For HttpsPassword auth: the username paired with the vault-stored password.
/// Ignored for other auth types.
/// </summary>
public string? Username { get; set; }
/// <summary>
/// The URL pattern this credential covers — same glob syntax as CustomerGitRepoPolicy
/// (e.g. https://github.com/acme/*). When set, the sync service picks this credential
/// automatically for repos whose URL matches. Null means "applies to any URL".
/// </summary>
public string? UrlPattern { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
// Navigation
public Customer Customer { get; set; } = null!;
public Tenant Tenant { get; set; } = null!;
public Environment Environment { get; set; } = null!;
}

View File

@@ -0,0 +1,30 @@
namespace EntKube.Web.Data;
/// <summary>
/// An allowed git repository URL pattern for a customer. Supports wildcards
/// using '*' (matches any sequence of characters within a segment) and '**'
/// (matches across path separators). For example:
/// https://github.com/acme/* — any repo under the acme org
/// git@github.com:acme/* — SSH variant of the same
/// https://dev.azure.com/contoso/** — any repo anywhere under contoso's org
/// </summary>
public class CustomerGitRepoPolicy
{
public Guid Id { get; set; }
public Guid CustomerId { get; set; }
/// <summary>
/// The URL pattern. May contain '*' or '**' wildcards.
/// Must be unique within a customer.
/// </summary>
public required string UrlPattern { get; set; }
public Guid EnvironmentId { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
// Navigation
public Customer Customer { get; set; } = null!;
public Environment Environment { get; set; } = null!;
}

View File

@@ -45,8 +45,15 @@ public class GitRepository
public DateTime CreatedAt { get; set; } = DateTime.UtcNow; public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
/// <summary>
/// When set, this repository was created on behalf of a customer and uses the
/// customer's stored credential for authentication rather than its own vault entries.
/// </summary>
public Guid? CustomerGitCredentialId { get; set; }
// Navigation // Navigation
public Tenant Tenant { get; set; } = null!; public Tenant Tenant { get; set; } = null!;
public CustomerGitCredential? CustomerGitCredential { get; set; }
public ICollection<AppDeployment> Deployments { get; set; } = []; public ICollection<AppDeployment> Deployments { get; set; } = [];
public ICollection<GitKnownHost> KnownHosts { get; set; } = []; public ICollection<GitKnownHost> KnownHosts { get; set; } = [];
} }

View File

@@ -42,4 +42,5 @@ public class KubernetesCluster
public Tenant Tenant { get; set; } = null!; public Tenant Tenant { get; set; } = null!;
public Environment Environment { get; set; } = null!; public Environment Environment { get; set; } = null!;
public ICollection<ClusterComponent> Components { get; set; } = []; public ICollection<ClusterComponent> Components { get; set; } = [];
public ICollection<ClusterServer> Servers { get; set; } = [];
} }

View File

@@ -1,4 +1,4 @@
using System; using System;
using Microsoft.EntityFrameworkCore.Migrations; using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable #nullable disable
@@ -11,34 +11,9 @@ namespace EntKube.Web.Data.Migrations.Postgres
/// <inheritdoc /> /// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder) protected override void Up(MigrationBuilder migrationBuilder)
{ {
migrationBuilder.DropForeignKey( // The migration that originally created KeycloakConnections and KeycloakRealms
name: "FK_KeycloakRealms_KeycloakConnections_KeycloakConnectionId", // was removed from history. This migration now creates both tables from scratch
table: "KeycloakRealms"); // using the final schema (post-rename), so fresh databases work correctly.
migrationBuilder.DropForeignKey(
name: "FK_VaultSecrets_KeycloakConnections_KeycloakConnectionId",
table: "VaultSecrets");
migrationBuilder.DropTable(
name: "KeycloakConnections");
migrationBuilder.DropIndex(
name: "IX_VaultSecrets_KeycloakConnectionId",
table: "VaultSecrets");
migrationBuilder.DropColumn(
name: "KeycloakConnectionId",
table: "VaultSecrets");
migrationBuilder.RenameColumn(
name: "KeycloakConnectionId",
table: "KeycloakRealms",
newName: "KeycloakComponentConfigId");
migrationBuilder.RenameIndex(
name: "IX_KeycloakRealms_KeycloakConnectionId_RealmName",
table: "KeycloakRealms",
newName: "IX_KeycloakRealms_KeycloakComponentConfigId_RealmName");
migrationBuilder.CreateTable( migrationBuilder.CreateTable(
name: "KeycloakComponentConfigs", name: "KeycloakComponentConfigs",
@@ -75,6 +50,38 @@ namespace EntKube.Web.Data.Migrations.Postgres
onDelete: ReferentialAction.Cascade); onDelete: ReferentialAction.Cascade);
}); });
migrationBuilder.CreateTable(
name: "KeycloakRealms",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
AccountTheme = table.Column<string>(type: "text", nullable: true),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
DisplayName = table.Column<string>(type: "character varying(200)", maxLength: 200, nullable: false),
Enabled = table.Column<bool>(type: "boolean", nullable: false),
KeycloakComponentConfigId = table.Column<Guid>(type: "uuid", nullable: false),
LinkedAppId = table.Column<Guid>(type: "uuid", nullable: true),
LoginTheme = table.Column<string>(type: "text", nullable: true),
RealmName = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
TenantId = table.Column<Guid>(type: "uuid", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_KeycloakRealms", x => x.Id);
table.ForeignKey(
name: "FK_KeycloakRealms_Apps_LinkedAppId",
column: x => x.LinkedAppId,
principalTable: "Apps",
principalColumn: "Id",
onDelete: ReferentialAction.SetNull);
table.ForeignKey(
name: "FK_KeycloakRealms_KeycloakComponentConfigs_KeycloakComponentCo~",
column: x => x.KeycloakComponentConfigId,
principalTable: "KeycloakComponentConfigs",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex( migrationBuilder.CreateIndex(
name: "IX_KeycloakComponentConfigs_ClusterComponentId", name: "IX_KeycloakComponentConfigs_ClusterComponentId",
table: "KeycloakComponentConfigs", table: "KeycloakComponentConfigs",
@@ -90,111 +97,23 @@ namespace EntKube.Web.Data.Migrations.Postgres
table: "KeycloakComponentConfigs", table: "KeycloakComponentConfigs",
column: "TenantId"); column: "TenantId");
migrationBuilder.AddForeignKey( migrationBuilder.CreateIndex(
name: "FK_KeycloakRealms_KeycloakComponentConfigs_KeycloakComponentCo~", name: "IX_KeycloakRealms_LinkedAppId",
table: "KeycloakRealms", table: "KeycloakRealms",
column: "KeycloakComponentConfigId", column: "LinkedAppId");
principalTable: "KeycloakComponentConfigs",
principalColumn: "Id", migrationBuilder.CreateIndex(
onDelete: ReferentialAction.Cascade); name: "IX_KeycloakRealms_KeycloakComponentConfigId_RealmName",
table: "KeycloakRealms",
columns: new[] { "KeycloakComponentConfigId", "RealmName" },
unique: true);
} }
/// <inheritdoc /> /// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder) protected override void Down(MigrationBuilder migrationBuilder)
{ {
migrationBuilder.DropForeignKey( migrationBuilder.DropTable(name: "KeycloakRealms");
name: "FK_KeycloakRealms_KeycloakComponentConfigs_KeycloakComponentCo~", migrationBuilder.DropTable(name: "KeycloakComponentConfigs");
table: "KeycloakRealms");
migrationBuilder.DropTable(
name: "KeycloakComponentConfigs");
migrationBuilder.RenameColumn(
name: "KeycloakComponentConfigId",
table: "KeycloakRealms",
newName: "KeycloakConnectionId");
migrationBuilder.RenameIndex(
name: "IX_KeycloakRealms_KeycloakComponentConfigId_RealmName",
table: "KeycloakRealms",
newName: "IX_KeycloakRealms_KeycloakConnectionId_RealmName");
migrationBuilder.AddColumn<Guid>(
name: "KeycloakConnectionId",
table: "VaultSecrets",
type: "uuid",
nullable: true);
migrationBuilder.CreateTable(
name: "KeycloakConnections",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
AdminPasswordSecretId = table.Column<Guid>(type: "uuid", nullable: true),
KubernetesClusterId = table.Column<Guid>(type: "uuid", nullable: false),
TenantId = table.Column<Guid>(type: "uuid", nullable: false),
AdminUrl = table.Column<string>(type: "character varying(500)", maxLength: 500, nullable: false),
AdminUsername = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_KeycloakConnections", x => x.Id);
table.ForeignKey(
name: "FK_KeycloakConnections_KubernetesClusters_KubernetesClusterId",
column: x => x.KubernetesClusterId,
principalTable: "KubernetesClusters",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_KeycloakConnections_Tenants_TenantId",
column: x => x.TenantId,
principalTable: "Tenants",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_KeycloakConnections_VaultSecrets_AdminPasswordSecretId",
column: x => x.AdminPasswordSecretId,
principalTable: "VaultSecrets",
principalColumn: "Id",
onDelete: ReferentialAction.SetNull);
});
migrationBuilder.CreateIndex(
name: "IX_VaultSecrets_KeycloakConnectionId",
table: "VaultSecrets",
column: "KeycloakConnectionId");
migrationBuilder.CreateIndex(
name: "IX_KeycloakConnections_AdminPasswordSecretId",
table: "KeycloakConnections",
column: "AdminPasswordSecretId");
migrationBuilder.CreateIndex(
name: "IX_KeycloakConnections_KubernetesClusterId",
table: "KeycloakConnections",
column: "KubernetesClusterId");
migrationBuilder.CreateIndex(
name: "IX_KeycloakConnections_TenantId",
table: "KeycloakConnections",
column: "TenantId");
migrationBuilder.AddForeignKey(
name: "FK_KeycloakRealms_KeycloakConnections_KeycloakConnectionId",
table: "KeycloakRealms",
column: "KeycloakConnectionId",
principalTable: "KeycloakConnections",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
migrationBuilder.AddForeignKey(
name: "FK_VaultSecrets_KeycloakConnections_KeycloakConnectionId",
table: "VaultSecrets",
column: "KeycloakConnectionId",
principalTable: "KeycloakConnections",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
} }
} }
} }

View File

@@ -1,4 +1,4 @@
using System; using System;
using Microsoft.EntityFrameworkCore.Migrations; using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable #nullable disable
@@ -11,51 +11,56 @@ namespace EntKube.Web.Data.Migrations.Postgres
/// <inheritdoc /> /// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder) protected override void Up(MigrationBuilder migrationBuilder)
{ {
migrationBuilder.DropForeignKey( // The migration that originally created KeycloakBackups was removed from history.
// This migration now creates the table directly with the final schema (nullable StorageLinkId).
migrationBuilder.CreateTable(
name: "KeycloakBackups",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
CompletedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
KeycloakRealmId = table.Column<Guid>(type: "uuid", nullable: false),
LastError = table.Column<string>(type: "text", nullable: true),
ObjectKey = table.Column<string>(type: "character varying(1024)", maxLength: 1024, nullable: false),
RealmName = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
SizeBytes = table.Column<long>(type: "bigint", nullable: false),
Status = table.Column<int>(type: "integer", nullable: false),
StorageLinkId = table.Column<Guid>(type: "uuid", nullable: true),
TenantId = table.Column<Guid>(type: "uuid", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_KeycloakBackups", x => x.Id);
table.ForeignKey(
name: "FK_KeycloakBackups_KeycloakRealms_KeycloakRealmId",
column: x => x.KeycloakRealmId,
principalTable: "KeycloakRealms",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_KeycloakBackups_StorageLinks_StorageLinkId", name: "FK_KeycloakBackups_StorageLinks_StorageLinkId",
table: "KeycloakBackups"); column: x => x.StorageLinkId,
migrationBuilder.AlterColumn<Guid>(
name: "StorageLinkId",
table: "KeycloakBackups",
type: "uuid",
nullable: true,
oldClrType: typeof(Guid),
oldType: "uuid");
migrationBuilder.AddForeignKey(
name: "FK_KeycloakBackups_StorageLinks_StorageLinkId",
table: "KeycloakBackups",
column: "StorageLinkId",
principalTable: "StorageLinks", principalTable: "StorageLinks",
principalColumn: "Id", principalColumn: "Id",
onDelete: ReferentialAction.SetNull); onDelete: ReferentialAction.SetNull);
});
migrationBuilder.CreateIndex(
name: "IX_KeycloakBackups_KeycloakRealmId",
table: "KeycloakBackups",
column: "KeycloakRealmId");
migrationBuilder.CreateIndex(
name: "IX_KeycloakBackups_StorageLinkId",
table: "KeycloakBackups",
column: "StorageLinkId");
} }
/// <inheritdoc /> /// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder) protected override void Down(MigrationBuilder migrationBuilder)
{ {
migrationBuilder.DropForeignKey( migrationBuilder.DropTable(name: "KeycloakBackups");
name: "FK_KeycloakBackups_StorageLinks_StorageLinkId",
table: "KeycloakBackups");
migrationBuilder.AlterColumn<Guid>(
name: "StorageLinkId",
table: "KeycloakBackups",
type: "uuid",
nullable: false,
defaultValue: new Guid("00000000-0000-0000-0000-000000000000"),
oldClrType: typeof(Guid),
oldType: "uuid",
oldNullable: true);
migrationBuilder.AddForeignKey(
name: "FK_KeycloakBackups_StorageLinks_StorageLinkId",
table: "KeycloakBackups",
column: "StorageLinkId",
principalTable: "StorageLinks",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
} }
} }
} }

Some files were not shown because too many files have changed in this diff Show More