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
**/*.dbmdl
**/*.jfm
**/*.db
**/secrets.dev.yaml
**/values.dev.yaml
**/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-shm
*.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
# 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 src/EntKube.Web/EntKube.Web.csproj src/EntKube.Web/
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/
RUN dotnet publish src/EntKube.Web/EntKube.Web.csproj \
--no-restore \
-c Release \
-o /app/publish
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS runtime
WORKDIR /app
# libgit2sharp bundles its native lib, but needs libssl/libcurl on Debian
# libgit2sharp needs libssl/libcurl; git is used by GitOperationsService;
# kubectl and helm are invoked by KubernetesOperationsService/ComponentLifecycleService.
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates \
curl \
libssl3 \
libcurl4 \
git \
openssh-client \
&& rm -rf /var/lib/apt/lists/*
# kubectl — latest stable, architecture-aware
RUN ARCH=$(uname -m | sed 's/x86_64/amd64/;s/aarch64/arm64/') && \
KUBECTL_VERSION=$(curl -fsSL https://dl.k8s.io/release/stable.txt) && \
curl -fsSL "https://dl.k8s.io/release/${KUBECTL_VERSION}/bin/linux/${ARCH}/kubectl" \
-o /usr/local/bin/kubectl && \
chmod +x /usr/local/bin/kubectl
# helm — latest stable via official installer script
RUN curl -fsSL https://raw.githubusercontent.com/helm/helm/main/scripts/get-helm-3 | bash
# Run as non-root
RUN groupadd --system appgroup && useradd --system --gid appgroup --no-create-home appuser
RUN mkdir -p /app/Data && chown appuser:appgroup /app/Data
COPY --from=build /app/publish .
RUN chown -R appuser:appgroup /app
USER appuser
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
ENTRYPOINT ["dotnet", "EntKube.Web.dll"]

View File

@@ -42,6 +42,59 @@ EntKube/
└── 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
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>
<StaticWebAssetProjectMode>Default</StaticWebAssetProjectMode>
<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>
<ItemGroup>
<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>
</Project>

View File

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

View File

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

View File

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

View File

@@ -14,48 +14,58 @@
@inject ILogger<Register> Logger
@inject NavigationManager NavigationManager
@inject IdentityRedirectManager RedirectManager
@inject IConfiguration Configuration
<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="col-lg-6">
<StatusMessage Message="@Message" />
<EditForm Model="Input" method="post" OnValidSubmit="RegisterUser" FormName="register">
<DataAnnotationsValidator />
<h2>Create a new account.</h2>
<hr />
<ValidationSummary class="text-danger" role="alert" />
<div class="form-floating mb-3">
<InputText @bind-Value="Input.Email" id="Input.Email" class="form-control" autocomplete="username" aria-required="true" placeholder="name@example.com" />
<label for="Input.Email">Email</label>
<ValidationMessage For="() => Input.Email" class="text-danger" />
</div>
<div class="form-floating mb-3">
<InputText type="password" @bind-Value="Input.Password" id="Input.Password" class="form-control" autocomplete="new-password" aria-required="true" placeholder="password" />
<label for="Input.Password">Password</label>
<ValidationMessage For="() => Input.Password" class="text-danger" />
</div>
<div class="form-floating mb-3">
<InputText type="password" @bind-Value="Input.ConfirmPassword" id="Input.ConfirmPassword" class="form-control" autocomplete="new-password" aria-required="true" placeholder="password" />
<label for="Input.ConfirmPassword">Confirm Password</label>
<ValidationMessage For="() => Input.ConfirmPassword" class="text-danger" />
</div>
<button type="submit" class="w-100 btn btn-lg btn-primary">Register</button>
</EditForm>
<div class="row">
<div class="col-lg-6">
<StatusMessage Message="@Message" />
<EditForm Model="Input" method="post" OnValidSubmit="RegisterUser" FormName="register">
<DataAnnotationsValidator />
<h2>Create a new account.</h2>
<hr />
<ValidationSummary class="text-danger" role="alert" />
<div class="form-floating mb-3">
<InputText @bind-Value="Input.Email" id="Input.Email" class="form-control" autocomplete="username" aria-required="true" placeholder="name@example.com" />
<label for="Input.Email">Email</label>
<ValidationMessage For="() => Input.Email" class="text-danger" />
</div>
<div class="form-floating mb-3">
<InputText type="password" @bind-Value="Input.Password" id="Input.Password" class="form-control" autocomplete="new-password" aria-required="true" placeholder="password" />
<label for="Input.Password">Password</label>
<ValidationMessage For="() => Input.Password" class="text-danger" />
</div>
<div class="form-floating mb-3">
<InputText type="password" @bind-Value="Input.ConfirmPassword" id="Input.ConfirmPassword" class="form-control" autocomplete="new-password" aria-required="true" placeholder="password" />
<label for="Input.ConfirmPassword">Confirm Password</label>
<ValidationMessage For="() => Input.ConfirmPassword" class="text-danger" />
</div>
<button type="submit" class="w-100 btn btn-lg btn-primary">Register</button>
</EditForm>
</div>
<div class="col-lg-4 col-lg-offset-2">
<section>
<h3>Use another service to register.</h3>
<hr />
<ExternalLoginPicker />
</section>
</div>
</div>
<div class="col-lg-4 col-lg-offset-2">
<section>
<h3>Use another service to register.</h3>
<hr />
<ExternalLoginPicker />
</section>
</div>
</div>
}
@code {
private IEnumerable<IdentityError>? identityErrors;
private bool allowRegistration = true;
[SupplyParameterFromForm]
private InputModel Input { get; set; } = default!;
@@ -68,10 +78,17 @@
protected override void OnInitialized()
{
Input ??= new();
allowRegistration = Configuration.GetValue<bool>("Auth:AllowRegistration", true);
}
public async Task RegisterUser(EditContext editContext)
{
if (!allowRegistration)
{
RedirectManager.RedirectTo("Account/Register");
return;
}
var user = CreateUser();
await UserStore.SetUserNameAsync(user, Input.Email, CancellationToken.None);

View File

@@ -18,8 +18,10 @@
<body>
<Routes />
<ReconnectModal />
<script src="@Assets["lib/bootstrap/dist/js/bootstrap.bundle.min.js"]"></script>
<script src="@Assets["_framework/blazor.web.js"]"></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>
</html>

View File

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

View File

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

View File

@@ -4,8 +4,7 @@
@rendermode InteractiveServer
@attribute [Authorize(Roles = "Admin")]
@inject BackupService BackupService
@inject AuthenticationStateProvider AuthStateProvider
@inject NavigationManager Nav
<PageTitle>Backup & Restore</PageTitle>
@@ -41,44 +40,38 @@
<div class="card-body">
<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">
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.
</p>
<div class="mb-3">
<InputFile OnChange="OnFileSelected" accept=".gz" class="form-control" />
</div>
<div class="form-check mb-3">
<input class="form-check-input" type="checkbox" id="wipeCheck" @bind="wipeExisting" />
<label class="form-check-label" for="wipeCheck">
Wipe all existing data before restoring
</label>
</div>
@if (wipeExisting)
{
<div class="alert alert-danger py-2 small mb-3">
<i class="bi bi-exclamation-triangle-fill me-1"></i>
<strong>All current data will be permanently deleted</strong> before the
backup is loaded. This cannot be undone. Only proceed on a fresh
installation or when you intend a full replacement.
<form action="/api/admin/restore" method="post" enctype="multipart/form-data">
<div class="mb-3">
<input type="file" name="bundle" class="form-control" required />
</div>
}
<button class="btn btn-warning" @onclick="RunRestore"
disabled="@(selectedFile is null || restoring)">
@if (restoring)
<div class="form-check mb-3">
@* 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">
Wipe all existing data before restoring
</label>
</div>
@if (wipeExisting)
{
<span class="spinner-border spinner-border-sm me-1" role="status"></span>
<span>Restoring…</span>
<div class="alert alert-danger py-2 small mb-3">
<i class="bi bi-exclamation-triangle-fill me-1"></i>
<strong>All current data will be permanently deleted</strong> before the
backup is loaded. This cannot be undone. Only proceed on a fresh
installation or when you intend a full replacement.
</div>
}
else
{
<i class="bi bi-upload me-1"></i>
<span>Restore</span>
}
</button>
<button type="submit" class="btn btn-warning">
<i class="bi bi-upload me-1"></i>Restore
</button>
</form>
</div>
</div>
</div>
@@ -100,42 +93,15 @@
}
@code {
private IBrowserFile? selectedFile;
private bool wipeExisting;
private bool restoring;
private string? successMessage;
private string? errorMessage;
private void OnFileSelected(InputFileChangeEventArgs e)
protected override void OnInitialized()
{
selectedFile = e.File;
successMessage = null;
errorMessage = null;
}
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;
}
var uri = new Uri(Nav.Uri);
var query = Microsoft.AspNetCore.WebUtilities.QueryHelpers.ParseQuery(uri.Query);
if (query.TryGetValue("success", out var s)) successMessage = s;
if (query.TryGetValue("error", out var e)) errorMessage = e;
}
}

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

View File

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

View File

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

View File

@@ -1,6 +1,7 @@
@using EntKube.Web.Data
@using EntKube.Web.Services
@inject AppGovernanceService GovernanceService
@inject CustomerGitService CustomerGitService
@* ═══════════════════════════════════════════════════════════════════
AppGovernancePanel — read-only portal view of an app's governance
@@ -23,17 +24,21 @@ else
</div>
@* Namespace *@
<div class="mb-3">
<label class="form-label small fw-medium">Kubernetes Namespace</label>
@if (!string.IsNullOrWhiteSpace(data?.Namespace))
{
<input class="form-control form-control-sm font-monospace" value="@data.Namespace" disabled />
}
else
{
<input class="form-control form-control-sm text-muted fst-italic" value="Not assigned" disabled />
}
</div>
@if (!string.IsNullOrWhiteSpace(data?.Namespace))
{
<div class="card shadow-sm mb-3">
<div class="card-header bg-white py-2">
<i class="bi bi-box me-2 text-primary"></i>
<strong class="small">Namespace</strong>
</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 *@
<div class="card shadow-sm mb-3">
@@ -166,19 +171,122 @@ else
}
</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 {
[Parameter, EditorRequired] public Guid AppId { get; set; }
[Parameter, EditorRequired] public string AppName { get; set; } = "";
[Parameter, EditorRequired] public Guid EnvironmentId { get; set; }
[Parameter] public Guid CustomerId { get; set; }
private AppGovernanceData? data;
private List<CustomerGitRepoPolicy> gitPolicies = [];
private bool loading = true;
protected override async Task OnParametersSetAsync()
{
loading = true;
data = await GovernanceService.LoadAsync(AppId);
data = await GovernanceService.LoadAsync(AppId, EnvironmentId);
if (CustomerId != Guid.Empty)
gitPolicies = await CustomerGitService.GetRepoPoliciesAsync(CustomerId, EnvironmentId);
loading = false;
}
@@ -198,4 +306,20 @@ else
AppNetworkPolicyType.AllowFromIngress or AppNetworkPolicyType.AllowFromNamespace or AppNetworkPolicyType.AllowFromSameNamespace => "bi-check-circle",
_ => "bi-code-slash"
};
private static string AllowedDbTypeLabel(AppAllowedDatabase entry)
{
if (entry.CnpgDatabase is not null) return "PostgreSQL";
if (entry.MongoDatabase is not null) return "MongoDB";
if (entry.RegisteredPostgresDatabase is not null) return "Postgres";
return "Database";
}
private static string AllowedDbName(AppAllowedDatabase entry)
{
if (entry.CnpgDatabase is not null) return entry.CnpgDatabase.Name;
if (entry.MongoDatabase is not null) return entry.MongoDatabase.Name;
if (entry.RegisteredPostgresDatabase is not null) return entry.RegisteredPostgresDatabase.Name;
return "—";
}
}

View File

@@ -15,6 +15,8 @@
@inject PrometheusService PrometheusService
@inject AuditService AuditService
@inject IncidentService IncidentService
@inject CustomerGitService CustomerGitService
@inject AppRouteService AppRouteService
@inject AuthenticationStateProvider AuthStateProvider
@inject NavigationManager Navigation
@@ -37,80 +39,14 @@
}
else if (customers is null || customers.Count == 0)
{
<div class="text-center py-5">
<i class="bi bi-shield-lock text-muted" style="font-size: 3rem;"></i>
<h4 class="mt-3">No access granted</h4>
<p class="text-muted">You don't have access to any customers yet. Ask a tenant administrator to grant you access.</p>
</div>
<EmptyState Icon="bi-shield-lock"
Title="No access granted"
Message="You don't have access to any customers yet. Ask a tenant administrator to grant you access." />
}
else
{
@* ── Level 0: Breadcrumb navigation ── *@
<nav aria-label="breadcrumb" class="mb-3">
<ol class="breadcrumb small">
<li class="breadcrumb-item">
@if (selectedDeployment is not null || selectedIdentityRealm is not null || selectedSecretsApp is not null || selectedRegistryProject is not null || selectedGovernanceApp is not null)
{
<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>
<Breadcrumb Items="PortalBreadcrumbs" />
@* ════════════════════════════════════════════════════════════════
Level 1: Customer list
@@ -144,10 +80,25 @@ else
</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
════════════════════════════════════════════════════════════════ *@
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">
<i class="bi bi-people fs-3 me-2 text-primary"></i>
@@ -173,7 +124,7 @@ else
@if (showMonitoringPanel)
{
<div class="card shadow-sm mb-4">
<div class="card-header d-flex align-items-center gap-2 py-2">
<div class="card-header bg-white d-flex align-items-center gap-2 py-2">
<span class="bi bi-heart-pulse text-primary"></span>
<strong>Monitoring — @selectedCustomer.Name</strong>
<button class="btn-close btn-sm ms-auto" @onclick="() => showMonitoringPanel = false"></button>
@@ -204,10 +155,8 @@ else
@if (selectedCustomer.Apps.Count == 0)
{
<div class="text-center py-4">
<i class="bi bi-app-indicator text-muted" style="font-size: 2rem;"></i>
<p class="text-muted mt-2">No apps configured for this customer yet.</p>
</div>
<EmptyState Icon="bi-app-indicator"
Message="No apps configured for this customer yet." />
}
else
{
@@ -215,7 +164,7 @@ else
{
bool hasIdentity = appRealms.ContainsKey(app.Id);
bool hasRegistry = appHarborProjects.ContainsKey(app.Id);
bool showingCreateForm = createDeployAppId == app.Id;
<div class="card shadow-sm mb-3">
<div class="card-header bg-white py-2">
<div class="d-flex align-items-center justify-content-between">
@@ -223,137 +172,141 @@ else
<i class="bi bi-app-indicator me-2 text-primary"></i>
<strong>@app.Name</strong>
</div>
<div class="d-flex gap-2">
@if (currentAccessRole >= CustomerAccessRole.Admin)
{
<button class="btn btn-sm btn-outline-primary"
@onclick="() => ToggleCreateDeployment(app.Id)">
<i class="bi bi-plus me-1"></i>New Deployment
<div class="d-flex gap-2 align-items-center">
<div class="dropdown">
<button class="btn btn-sm btn-outline-secondary dropdown-toggle" type="button"
data-bs-toggle="dropdown" aria-expanded="false">
<i class="bi bi-three-dots me-1"></i>More
</button>
}
@if (hasIdentity)
{
<button class="btn btn-sm btn-outline-primary"
@onclick="() => SelectIdentityRealm(app.Id)">
<i class="bi bi-shield-lock me-1"></i>Identity
</button>
}
@if (hasRegistry)
{
<button class="btn btn-sm btn-outline-primary"
@onclick="() => SelectRegistryProject(app.Id)">
<i class="bi bi-archive me-1"></i>Registry
</button>
}
<button class="btn btn-sm btn-outline-secondary"
@onclick="() => SelectSecretsApp(app)">
<i class="bi bi-key me-1"></i>Secrets
</button>
<button class="btn btn-sm btn-outline-secondary"
@onclick="() => SelectGovernanceApp(app)">
<i class="bi bi-shield-check me-1"></i>Policy
</button>
<ul class="dropdown-menu dropdown-menu-end">
<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)
{
<li>
<button class="dropdown-item" @onclick="() => SelectIdentityRealm(app.Id)">
<i class="bi bi-shield-lock me-2 text-primary"></i>Identity
</button>
</li>
}
@if (hasRegistry)
{
<li>
<button class="dropdown-item" @onclick="() => SelectRegistryProject(app.Id)">
<i class="bi bi-archive me-2 text-primary"></i>Registry
</button>
</li>
}
<li>
<button class="dropdown-item" @onclick="() => SelectRoutesApp(app)">
<i class="bi bi-globe me-2 text-primary"></i>URL Configuration
</button>
</li>
@if (currentAccessRole >= CustomerAccessRole.Admin)
{
<li><hr class="dropdown-divider" /></li>
<li>
<button class="dropdown-item" @onclick="() => StartEditApp(app)">
<i class="bi bi-pencil me-2"></i>Rename
</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 class="card-body">
@if (showingCreateForm)
{
<div class="card border-primary mb-3">
<div class="card-body">
<h6 class="mb-3"><i class="bi bi-plus-circle me-1"></i>Create Deployment</h6>
@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>
@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>
}
@if (appDeployments.TryGetValue(app.Id, out List<AppDeployment>? deploys) && deploys.Count > 0)
</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">
@* ── Environment cards ── *@
@if (appLinkedEnvironments.TryGetValue(app.Id, out List<Data.Environment>? linkedEnvs) && linkedEnvs.Count > 0)
{
<div class="row row-cols-1 row-cols-lg-2 g-2">
@foreach (AppDeployment deploy in deploys)
<div class="row row-cols-1 row-cols-md-2 row-cols-lg-3 g-2">
@foreach (Data.Environment env in linkedEnvs)
{
int deployCount = appDeployments.TryGetValue(app.Id, out List<AppDeployment>? deploys2)
? deploys2.Count(d => d.EnvironmentId == env.Id)
: 0;
<div class="col">
<div class="card border-light" role="button" style="cursor: pointer;"
@onclick="() => SelectDeployment(deploy)">
<div class="card border-light h-100 portal-env-card" role="button" style="cursor: pointer;"
@onclick="() => SelectPortalEnvironment(app, env)">
<div class="card-body py-2">
<div class="d-flex align-items-center justify-content-between mb-1">
<span class="fw-medium">
<i class="bi bi-rocket-takeoff me-1 text-primary"></i>@deploy.Name
<i class="bi bi-layers me-1 text-primary"></i>@env.Name
</span>
<div class="d-flex gap-1">
@SyncBadge(deploy.SyncStatus)
@HealthBadge(deploy.HealthStatus)
</div>
<i class="bi bi-chevron-right text-muted small"></i>
</div>
<div class="d-flex flex-wrap gap-2 text-muted small">
@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 class="small text-muted">
@if (deployCount > 0)
{
<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>
@@ -361,9 +314,64 @@ else
}
</div>
}
else if (!showingCreateForm)
else
{
<p class="text-muted small mb-0">No deployments configured.</p>
<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>
@@ -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
════════════════════════════════════════════════════════════════ *@
@@ -383,6 +404,10 @@ else
DeploymentService="DeploymentService"
PrometheusService="PrometheusService"
AuditService="AuditService"
CustomerId="selectedCustomer!.Id"
TenantId="selectedCustomer!.TenantId"
EnvironmentId="selectedDeployment.EnvironmentId"
GovernanceNamespace="@(selectedCustomer?.Apps.FirstOrDefault(a => a.Id == selectedDeployment.AppId)?.Namespace)"
OnBack="BackToApps"
OnDeleted="OnDeploymentDeleted" />
}
@@ -400,12 +425,14 @@ else
@* ════════════════════════════════════════════════════════════════
Level 3: App secrets vault
════════════════════════════════════════════════════════════════ *@
else if (selectedSecretsApp is not null)
else if (selectedSecrets is not null)
{
<PortalSecretsDetail
App="selectedSecretsApp"
@key="(selectedSecrets.App.Id, selectedSecrets.Scope)"
App="selectedSecrets.App"
TenantId="selectedCustomer!.TenantId"
AccessRole="currentAccessRole" />
AccessRole="currentAccessRole"
InitialScope="@selectedSecrets.Scope" />
}
@* ════════════════════════════════════════════════════════════════
@@ -418,24 +445,110 @@ else
TenantId="selectedCustomer!.TenantId"
AccessRole="currentAccessRole" />
}
else if (selectedGovernanceApp is not null)
@* Kept for reference — governance is now accessed via environment cards *@
else if (selectedResourcesApp is not null)
{
<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 {
private bool loading = true;
private bool loadingCustomer;
private string? currentUserId;
private CustomerAccessRole currentAccessRole;
private bool InDetailView =>
selectedDeployment is not null || selectedIdentityRealm is not null ||
selectedSecrets is not null || selectedRegistryProject is not null ||
selectedGovernanceApp is not null || selectedResourcesApp is not null ||
selectedRoutesApp is not null || selectedPortalEnvironment is not null;
private IReadOnlyList<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 Customer? selectedCustomer;
private AppDeployment? selectedDeployment;
private KeycloakRealm? selectedIdentityRealm;
private Data.App? selectedSecretsApp;
private sealed record SecretsNav(Data.App App, string Scope);
private SecretsNav? selectedSecrets;
private string SecretsScopeIcon => selectedSecrets?.Scope switch
{
"database" => "bi-database",
"messaging" => "bi-send",
"cache" => "bi-lightning",
"docker" => "bi-box-seam",
_ => "bi-key"
};
private string SecretsScopeLabel => selectedSecrets?.Scope switch
{
"database" => "Databases",
"messaging" => "Messaging",
"cache" => "Cache",
"docker" => "Registries",
_ => "App Secrets"
};
private HarborProject? selectedRegistryProject;
private Data.App? selectedGovernanceApp;
private Data.App? selectedResourcesApp;
private Data.App? selectedRoutesApp;
// Per-environment navigation in portal (environment-first view)
private Data.App? selectedPortalApp;
private Data.Environment? selectedPortalEnvironment;
private Dictionary<Guid, List<Data.Environment>> appLinkedEnvironments = new();
// Deployments indexed by app ID for the selected customer.
private Dictionary<Guid, List<AppDeployment>> appDeployments = new();
@@ -446,6 +559,9 @@ else
// Linked Harbor projects indexed by app ID for the selected customer.
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).
private List<Data.Environment> tenantEnvironments = [];
private List<KubernetesCluster> tenantClusters = [];
@@ -459,14 +575,15 @@ else
private HashSet<Guid> AllCustomerClusterIds =>
AllCustomerDeployments.Select(d => d.ClusterId).ToHashSet();
// Create deployment form (portal Admin).
private Guid? createDeployAppId;
private string newDeployName = "", newDeployNamespace = "";
private DeploymentType newDeployType = DeploymentType.Manual;
private Guid newDeployEnvId, newDeployClusterId;
private string newHelmRepoUrl = "", newHelmChartName = "", newHelmChartVersion = "";
private bool creatingDeployment;
private string? createDeployError;
// App edit/delete state
private Guid? editingAppId;
private string editAppName = "";
private bool savingAppName;
private Guid? deletingAppId;
private bool deletingApp;
private string? deleteAppError;
protected override async Task OnInitializedAsync()
{
@@ -488,17 +605,22 @@ else
private async Task SelectCustomer(Customer customer)
{
selectedCustomer = customer;
loadingCustomer = true;
showMonitoringPanel = false;
StateHasChanged();
// Look up the user's role for this customer.
CustomerAccess? access = await CustomerAccessService.GetAccessAsync(currentUserId!, customer.Id);
currentAccessRole = access?.Role ?? CustomerAccessRole.Viewer;
// Load deployments, linked Keycloak realms, and linked Harbor projects for all apps.
// Load deployments, linked Keycloak realms, Harbor projects, and external routes for all apps.
appDeployments.Clear();
appRealms.Clear();
appHarborProjects.Clear();
createDeployAppId = null;
appRoutes.Clear();
appLinkedEnvironments.Clear();
selectedPortalApp = null;
selectedPortalEnvironment = null;
foreach (Data.App app in customer.Apps)
{
@@ -512,14 +634,42 @@ else
HarborProject? harborProject = await HarborService.GetProjectForAppAsync(customer.TenantId, app.Id);
if (harborProject is not null)
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)
{
tenantEnvironments = await TenantService.GetEnvironmentsAsync(customer.TenantId);
tenantClusters = await TenantService.GetClustersAsync(customer.TenantId);
}
loadingCustomer = false;
}
private void SelectPortalEnvironment(Data.App app, Data.Environment env)
{
selectedPortalApp = app;
selectedPortalEnvironment = env;
}
private void SelectDeployment(AppDeployment deploy)
@@ -544,13 +694,84 @@ else
selectedCustomer = null;
selectedDeployment = null;
selectedIdentityRealm = null;
selectedSecretsApp = null;
selectedSecrets = 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)
@@ -558,13 +779,28 @@ else
selectedGovernanceApp = app;
}
private void SelectResourcesApp(Data.App app)
{
selectedResourcesApp = app;
}
private void SelectRoutesApp(Data.App app)
{
selectedRoutesApp = app;
}
private void BackToApps()
{
selectedDeployment = null;
selectedIdentityRealm = null;
selectedSecretsApp = null;
selectedSecrets = null;
selectedRegistryProject = null;
selectedGovernanceApp = null;
selectedResourcesApp = null;
selectedRoutesApp = null;
selectedPortalApp = null;
selectedPortalEnvironment = null;
}
private async Task OnDeploymentDeleted()
@@ -578,48 +814,8 @@ else
}
}
private void ToggleCreateDeployment(Guid appId)
{
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;
}
}
private static bool IsGitType(DeploymentType type)
=> type is DeploymentType.GitYaml or DeploymentType.GitHelm or DeploymentType.GitAppOfApps;
// ──────── Badge helpers ────────
@@ -628,6 +824,9 @@ else
DeploymentType.Manual => @<span class="badge bg-info">Manual</span>,
DeploymentType.Yaml => @<span class="badge bg-warning text-dark">YAML</span>,
DeploymentType.HelmChart => @<span class="badge bg-purple text-white" style="background-color: #6f42c1 !important;">Helm</span>,
DeploymentType.GitYaml => @<span class="badge bg-success"><i class="bi bi-git me-1"></i>Git YAML</span>,
DeploymentType.GitHelm => @<span class="badge bg-success"><i class="bi bi-git me-1"></i>Git Helm</span>,
DeploymentType.GitAppOfApps => @<span class="badge bg-success"><i class="bi bi-git me-1"></i>App of Apps</span>,
_ => @<span class="badge bg-secondary">Unknown</span>
};

View File

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

View File

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

View File

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

View File

@@ -1,28 +1,47 @@
@using EntKube.Web.Data
@using EntKube.Web.Services
@using Microsoft.AspNetCore.Components.Authorization
@inject VaultService VaultService
@inject AppGovernanceService AppGovernanceService
@inject TenantService TenantService
@inject DeploymentService DeploymentService
@inject CnpgService CnpgService
@inject MongoService MongoService
@inject RedisService RedisService
@inject AuthenticationStateProvider AuthStateProvider
@using EntKube.Web.Components.Pages.Shared
@* ═══════════════════════════════════════════════════════════════════
App Secrets — customer portal view for managing vault secrets
scoped to a single app.
App Secrets — customer portal view for managing vault secrets and
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:
Viewer+ — list secret names, reveal values on demand
Admin — add / update / delete secrets, configure K8s sync
Viewer+ — list and reveal values
Admin — add / update / delete secrets, trigger syncs
═══════════════════════════════════════════════════════════════════ *@
<div class="d-flex align-items-center mb-3">
<i class="bi bi-shield-lock fs-4 me-2 text-primary"></i>
<div>
<h5 class="mb-0">Secrets — @App.Name</h5>
<small class="text-muted">
Application secrets stored with AES-256-GCM envelope encryption.
Values are never transmitted in plain text.
</small>
</div>
</div>
@* ── Scope tab navigation ── *@
<ul class="nav nav-tabs mb-3">
<li class="nav-item">
<button class="nav-link @(activeScope is "app" or "" ? "active" : "")"
@onclick='() => SwitchScope("app")'>
<i class="bi bi-key me-1"></i>App Secrets
</button>
</li>
<li class="nav-item">
<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)
{
@@ -33,320 +52,468 @@
}
else
{
@* ── Add Secret form (Admin only) ── *@
@if (AccessRole >= CustomerAccessRole.Admin)
@* ════════════════════════════════════════════════════════════════
Tab: App Secrets
════════════════════════════════════════════════════════════════ *@
@if (activeScope == "app" || activeScope == "")
{
<div class="card shadow-sm mb-3">
<div class="card-header bg-white py-2">
<i class="bi bi-plus-circle me-1 text-primary"></i><strong>Add / Update Secret</strong>
</div>
<div class="card-body">
<div class="row g-2 align-items-end">
<div class="col-md-4">
<label class="form-label small">Name</label>
<input type="text" class="form-control form-control-sm"
placeholder="e.g. API_KEY, DB_PASSWORD"
@bind="newSecretName" @bind:event="oninput" />
</div>
<div class="col-md-5">
<label class="form-label small">Value</label>
<input type="password" class="form-control form-control-sm"
placeholder="Secret value"
@bind="newSecretValue" @bind:event="oninput" />
</div>
<div class="col-auto">
<button class="btn btn-sm btn-primary" @onclick="AddSecret"
disabled="@(string.IsNullOrWhiteSpace(newSecretName) || string.IsNullOrWhiteSpace(newSecretValue) || saving)">
@if (saving) { <span class="spinner-border spinner-border-sm me-1"></span> }
<i class="bi bi-plus-lg me-1"></i>Set Secret
</button>
</div>
@* ── Add Secret form (Admin only) ── *@
@if (AccessRole >= CustomerAccessRole.Admin)
{
<div class="card shadow-sm mb-3">
<div class="card-header bg-white py-2">
<i class="bi bi-plus-circle me-1 text-primary"></i><strong>Add / Update Secret</strong>
</div>
<div class="form-text">
If a secret with this name already exists it will be updated in place.
</div>
@if (errorMessage is not null)
{
<div class="alert alert-danger py-1 small mt-2 mb-0">
<i class="bi bi-exclamation-triangle me-1"></i>@errorMessage
<div class="card-body">
<div class="row g-2 align-items-end">
<div class="col-md-4">
<label class="form-label small">Name</label>
<input type="text" class="form-control form-control-sm"
placeholder="e.g. API_KEY, DB_PASSWORD"
@bind="newSecretName" @bind:event="oninput" />
</div>
<div class="col-md-5">
<label class="form-label small">Value</label>
<input type="password" class="form-control form-control-sm"
placeholder="Secret value"
@bind="newSecretValue" @bind:event="oninput" />
</div>
<div class="col-auto">
<button class="btn btn-sm btn-primary" @onclick="AddSecret"
disabled="@(string.IsNullOrWhiteSpace(newSecretName) || string.IsNullOrWhiteSpace(newSecretValue) || saving)">
@if (saving) { <span class="spinner-border spinner-border-sm me-1"></span> }
<i class="bi bi-plus-lg me-1"></i>Set Secret
</button>
</div>
</div>
}
@if (successMessage is not null)
{
<div class="alert alert-success py-1 small mt-2 mb-0">
<i class="bi bi-check-circle me-1"></i>@successMessage
<div class="form-text">
If a secret with this name already exists it will be updated in place.
</div>
}
</div>
</div>
}
@* ── Secrets list ── *@
<div class="card shadow-sm">
<div class="card-header bg-white py-2 d-flex align-items-center justify-content-between">
<div>
<i class="bi bi-lock me-2 text-primary"></i>
<strong>Stored Secrets</strong>
@if (secrets is not null)
{
<span class="badge bg-secondary ms-1">@secrets.Count</span>
}
</div>
@if (AccessRole >= CustomerAccessRole.Admin && secrets?.Any(s => s.SyncToKubernetes) == true)
{
<button class="btn btn-sm btn-outline-success" @onclick="SyncAllToKubernetes" disabled="@syncingSecrets">
@if (syncingSecrets) { <span class="spinner-border spinner-border-sm me-1"></span> }
else { <i class="bi bi-cloud-arrow-up me-1"></i> }
Sync All to K8s
</button>
}
</div>
<div class="card-body p-0">
@if (secrets is null)
{
<div class="text-center py-4">
<div class="spinner-border spinner-border-sm text-primary" role="status"></div>
</div>
}
else if (secrets.Count == 0)
{
<div class="text-center py-5">
<i class="bi bi-lock text-muted" style="font-size: 2rem;"></i>
<p class="text-muted small mt-2 mb-0">No secrets stored for this app yet.</p>
@if (AccessRole >= CustomerAccessRole.Admin)
@if (errorMessage is not null)
{
<p class="text-muted small mb-0">Use the form above to add one.</p>
<div class="alert alert-danger py-1 small mt-2 mb-0">
<i class="bi bi-exclamation-triangle me-1"></i>@errorMessage
</div>
}
@if (successMessage is not null)
{
<div class="alert alert-success py-1 small mt-2 mb-0">
<i class="bi bi-check-circle me-1"></i>@successMessage
</div>
}
</div>
}
else
{
<div class="table-responsive">
<table class="table table-hover align-middle mb-0">
<thead class="table-light">
<tr>
<th>Name</th>
<th>K8s Sync</th>
<th>K8s Secret</th>
<th>Namespace</th>
<th>Updated</th>
<th class="text-end" style="min-width: 120px;">Actions</th>
</tr>
</thead>
<tbody>
@foreach (VaultSecret secret in secrets)
{
</div>
}
@* ── Secrets list ── *@
<div class="card shadow-sm">
<div class="card-header bg-white py-2 d-flex align-items-center justify-content-between">
<div>
<i class="bi bi-lock me-2 text-primary"></i>
<strong>Stored Secrets</strong>
@if (secrets is not null)
{
<span class="badge bg-secondary ms-1">@secrets.Count</span>
}
</div>
@if (AccessRole >= CustomerAccessRole.Admin && secrets?.Any(s => s.SyncToKubernetes) == true)
{
<button class="btn btn-sm btn-outline-success" @onclick="SyncAllToKubernetes" disabled="@syncingSecrets">
@if (syncingSecrets) { <span class="spinner-border spinner-border-sm me-1"></span> }
else { <i class="bi bi-cloud-arrow-up me-1"></i> }
Sync All to K8s
</button>
}
</div>
<div class="card-body p-0">
<LoadingPanel Loading="@(secrets is null)">
@if (secrets is not null && secrets.Count == 0)
{
<EmptyState Icon="bi-lock"
Message="@(AccessRole >= CustomerAccessRole.Admin ? "No secrets stored yet. Use the form above to add one." : "No secrets stored for this app yet.")" />
}
else if (secrets is not null)
{
<div class="table-responsive">
<table class="table table-hover align-middle mb-0">
<thead class="table-light">
<tr>
<td><code>@secret.Name</code></td>
<td>
@if (secret.SyncToKubernetes)
{
<span class="badge bg-success"><i class="bi bi-cloud-check me-1"></i>Enabled</span>
}
else
{
<span class="badge bg-secondary">Off</span>
}
</td>
<td><small class="text-muted">@(secret.KubernetesSecretName ?? "—")</small></td>
<td><small class="text-muted">@(secret.KubernetesNamespace ?? "—")</small></td>
<td><small class="text-muted">@secret.UpdatedAt.ToString("MMM d, HH:mm")</small></td>
<td class="text-end">
@* Reveal — available to all roles *@
<button class="btn btn-sm btn-outline-secondary me-1"
@onclick="() => RevealSecret(secret)"
title="@(revealedSecretId == secret.Id ? "Hide value" : "Reveal value")">
<i class="bi @(revealedSecretId == secret.Id ? "bi-eye-slash" : "bi-eye")"></i>
</button>
@* Admin-only: edit, K8s sync, delete *@
@if (AccessRole >= CustomerAccessRole.Admin)
{
<button class="btn btn-sm btn-outline-warning me-1"
@onclick="() => StartEdit(secret)" title="Update value">
<i class="bi bi-pencil"></i>
</button>
<button class="btn btn-sm btn-outline-info me-1"
@onclick="() => ToggleSync(secret)"
title="@(secret.SyncToKubernetes ? "Disable K8s sync" : "Configure K8s sync")">
<i class="bi @(secret.SyncToKubernetes ? "bi-cloud-slash" : "bi-cloud-arrow-up")"></i>
</button>
<button class="btn btn-sm btn-outline-danger"
@onclick="() => DeleteSecret(secret.Id)" title="Delete secret">
<i class="bi bi-trash"></i>
</button>
}
</td>
<th>Name</th>
<th>K8s Sync</th>
<th>K8s Secret</th>
<th>Namespace</th>
<th>Updated</th>
<th class="text-end" style="min-width: 120px;">Actions</th>
</tr>
@* ── Reveal value row ── *@
@if (revealedSecretId == secret.Id)
</thead>
<tbody>
@foreach (VaultSecret secret in secrets)
{
<tr class="table-light">
<td colspan="6">
<div class="d-flex align-items-center px-2 py-1 gap-2">
<span class="text-muted small">Value:</span>
@if (revealLoading)
{
<div class="spinner-border spinner-border-sm text-primary" role="status"></div>
}
else
{
<code class="user-select-all">@revealedValue</code>
}
</div>
<tr>
<td><code>@secret.Name</code></td>
<td>
@if (secret.SyncToKubernetes)
{
<span class="badge bg-success"><i class="bi bi-cloud-check me-1"></i>Enabled</span>
}
else
{
<span class="badge bg-secondary">Off</span>
}
</td>
<td><small class="text-muted">@(secret.KubernetesSecretName ?? "—")</small></td>
<td><small class="text-muted">@(secret.KubernetesNamespace ?? "—")</small></td>
<td><small class="text-muted">@secret.UpdatedAt.ToString("MMM d, HH:mm")</small></td>
<td class="text-end">
<button class="btn btn-sm btn-outline-secondary me-1"
@onclick="() => RevealSecret(secret)"
title="@(revealedSecretId == secret.Id ? "Hide value" : "Reveal value")">
<i class="bi @(revealedSecretId == secret.Id ? "bi-eye-slash" : "bi-eye")"></i>
</button>
@if (AccessRole >= CustomerAccessRole.Admin)
{
<button class="btn btn-sm btn-outline-warning me-1"
@onclick="() => StartEdit(secret)" title="Update value">
<i class="bi bi-pencil"></i>
</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)"
title="@(secret.SyncToKubernetes ? "Disable K8s sync" : "Configure K8s sync")">
<i class="bi @(secret.SyncToKubernetes ? "bi-cloud-slash" : "bi-cloud-arrow-up")"></i>
</button>
<button class="btn btn-sm btn-outline-danger"
@onclick="() => confirmDeleteSecretId = secret.Id" title="Delete secret">
<i class="bi bi-trash"></i>
</button>
}
</td>
</tr>
}
@* ── Edit value row ── *@
@if (editSecretId == secret.Id)
{
<tr class="table-light">
<td colspan="6">
<div class="row g-2 p-2 align-items-center">
<div class="col-md-6">
<input type="password" class="form-control form-control-sm"
placeholder="New secret value"
@bind="editSecretValue" />
</div>
<div class="col-auto">
<button class="btn btn-sm btn-success" @onclick="SaveEdit"
disabled="@(string.IsNullOrWhiteSpace(editSecretValue) || saving)">
@if (saving) { <span class="spinner-border spinner-border-sm me-1"></span> }
<i class="bi bi-check-lg me-1"></i>Update
</button>
<button class="btn btn-sm btn-outline-secondary ms-1" @onclick="CancelEdit">
Cancel
</button>
</div>
</div>
</td>
</tr>
}
@if (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>
}
@* ── K8s sync config row ── *@
@if (syncConfigSecretId == secret.Id)
{
<tr class="table-light">
<td colspan="6">
<div class="row g-2 p-2 align-items-center">
@if (clusters is not null)
{
@if (revealedSecretId == secret.Id)
{
<tr class="table-light">
<td colspan="6">
<div class="d-flex align-items-center px-2 py-1 gap-2">
<span class="text-muted small">Value:</span>
@if (revealLoading)
{
<div class="spinner-border spinner-border-sm text-primary" role="status"></div>
}
else
{
<code class="user-select-all">@revealedValue</code>
}
</div>
</td>
</tr>
}
@if (editSecretId == secret.Id)
{
<tr class="table-light">
<td colspan="6">
<div class="row g-2 p-2 align-items-center">
<div class="col-md-6">
<input type="password" class="form-control form-control-sm"
placeholder="New secret value"
@bind="editSecretValue" />
</div>
<div class="col-auto">
<button class="btn btn-sm btn-success" @onclick="SaveEdit"
disabled="@(string.IsNullOrWhiteSpace(editSecretValue) || saving)">
@if (saving) { <span class="spinner-border spinner-border-sm me-1"></span> }
<i class="bi bi-check-lg me-1"></i>Update
</button>
<button class="btn btn-sm btn-outline-secondary ms-1" @onclick="CancelEdit">
Cancel
</button>
</div>
</div>
</td>
</tr>
}
@if (syncConfigSecretId == secret.Id)
{
bool nsLocked = syncClusterId != Guid.Empty && GetLockedNamespaceForCluster(syncClusterId) is not null;
<tr class="table-light">
<td colspan="6">
<div class="row g-2 p-2 align-items-center">
<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>
@foreach (KubernetesCluster cluster in clusters)
@foreach (KubernetesCluster cluster in appEnvironmentClusters)
{
<option value="@cluster.Id">@cluster.Name</option>
}
</select>
</div>
<div class="col-md-3">
<input type="text" class="form-control form-control-sm"
placeholder="K8s Secret name"
@bind="syncSecretName" />
</div>
<div class="col-md-3">
<input type="text" class="form-control form-control-sm"
placeholder="Namespace"
@bind="syncNamespace"
disabled="@nsLocked"
title="@(nsLocked ? "Namespace is locked by governance policy" : null)" />
</div>
<div class="col-auto">
<button class="btn btn-sm btn-success" @onclick="SaveSync">
<i class="bi bi-check-lg me-1"></i>Save
</button>
<button class="btn btn-sm btn-outline-secondary ms-1"
@onclick="() => syncConfigSecretId = null">
Cancel
</button>
</div>
</div>
@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>
}
<div class="col-md-3">
<input type="text" class="form-control form-control-sm"
placeholder="K8s Secret name (e.g. my-app-secrets)"
@bind="syncSecretName" />
</div>
<div class="col-md-3">
<input type="text" class="form-control form-control-sm"
placeholder="Namespace (e.g. my-app)"
@bind="syncNamespace" />
</div>
<div class="col-auto">
<button class="btn btn-sm btn-success" @onclick="SaveSync">
<i class="bi bi-check-lg me-1"></i>Save
</button>
<button class="btn btn-sm btn-outline-secondary ms-1"
@onclick="() => syncConfigSecretId = null">
Cancel
</button>
</div>
</div>
</td>
</tr>
}
}
</tbody>
</table>
</div>
}
@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>
</div>
</td>
</tr>
}
@* ── Docker Registry Credentials ── *@
<div class="mt-4">
<div class="d-flex align-items-center mb-2">
<i class="bi bi-box-seam fs-5 me-2 text-primary"></i>
<h6 class="mb-0">Docker Registry Credentials</h6>
<span class="ms-2 text-muted small">Pull secrets for private container registries</span>
@* 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>
</tr>
}
}
</tbody>
</table>
</div>
}
</LoadingPanel>
@if (syncOutput is not null)
{
<div class="m-3 p-2 bg-dark text-white rounded font-monospace small" style="white-space: pre-wrap; max-height: 200px; overflow-y: auto;">@syncOutput</div>
}
</div>
</div>
}
@* ════════════════════════════════════════════════════════════════
Tab: Docker Registries
════════════════════════════════════════════════════════════════ *@
else if (activeScope == "docker")
{
<DockerRegistriesPanel
TenantId="TenantId"
AppId="App.Id"
IsAdmin="AccessRole >= CustomerAccessRole.Admin" />
</div>
}
}
@code {
[Parameter] public required Data.App App { get; set; }
[Parameter] public Guid TenantId { get; set; }
[Parameter] public CustomerAccessRole AccessRole { get; set; }
[Parameter] public string InitialScope { get; set; } = "app";
private string activeScope = "app";
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 newSecretValue = "";
private bool saving;
private string? errorMessage;
private string? successMessage;
// Reveal
private Guid? revealedSecretId;
private string? revealedValue;
private bool revealLoading;
// Edit
private Guid? editSecretId;
private string editSecretValue = "";
// K8s sync config
private Guid? confirmDeleteSecretId;
private bool deletingSecret;
private Guid? syncConfigSecretId;
private string syncSecretName = "";
private string syncNamespace = "";
private Guid syncClusterId;
// Sync-to-K8s state
private bool syncingSecrets;
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()
{
activeScope = InitialScope;
AuthenticationState authState = await AuthStateProvider.GetAuthenticationStateAsync();
currentUserEmail = authState.User.Identity?.Name;
SecretVault? vault = await VaultService.GetVaultAsync(TenantId);
vaultInitialized = vault is not null;
if (vaultInitialized)
{
await LoadSecrets();
}
await EnsureDeployments();
List<AppEnvironment> appEnvs = await AppGovernanceService.GetAppEnvironmentsAsync(App.Id);
environmentNamespaceLock = appEnvs.ToDictionary(ae => ae.EnvironmentId, ae => ae.Namespace);
// Load clusters for the K8s sync config dropdown.
clusters = await TenantService.GetClustersAsync(TenantId);
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);
}
private async Task SwitchScope(string scope)
{
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()
{
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()
{
@@ -356,11 +523,12 @@ else
try
{
await VaultService.SetAppSecretAsync(TenantId, App.Id, newSecretName.Trim(), newSecretValue.Trim());
successMessage = $"Secret '{newSecretName.Trim()}' saved.";
string name = newSecretName.Trim();
await VaultService.SetAppSecretAsync(TenantId, App.Id, name, newSecretValue.Trim());
newSecretName = "";
newSecretValue = "";
await LoadSecrets();
_ = ShowSuccessAsync($"Secret '{name}' saved.");
}
catch (Exception ex)
{
@@ -369,7 +537,7 @@ else
finally { saving = false; }
}
// ──────── Reveal ────────
// ── App Secrets: Reveal ──────────────────────────────────────────
private async Task RevealSecret(VaultSecret secret)
{
@@ -387,7 +555,7 @@ else
revealLoading = false;
}
// ──────── Edit ────────
// ── App Secrets: Edit ────────────────────────────────────────────
private void StartEdit(VaultSecret secret)
{
@@ -412,9 +580,11 @@ else
try
{
bool updated = await VaultService.UpdateSecretValueAsync(editSecretId.Value, editSecretValue.Trim());
successMessage = updated ? "Secret value updated." : null;
errorMessage = updated ? null : "Failed to update secret.";
bool updated = await VaultService.UpdateSecretValueAsync(editSecretId.Value, editSecretValue.Trim(), currentUserEmail);
if (updated)
_ = ShowSuccessAsync("Secret value updated.");
else
errorMessage = "Failed to update secret.";
}
finally { saving = false; }
@@ -423,26 +593,31 @@ else
await LoadSecrets();
}
// ──────── Delete ────────
// ── App Secrets: Delete ──────────────────────────────────────────
private async Task DeleteSecret(Guid secretId)
{
errorMessage = null;
(bool canDelete, string? reason) = await VaultService.CanDeleteSecretAsync(secretId);
deletingSecret = true;
(bool canDelete, string? reason) = await VaultService.CanDeleteSecretAsync(secretId);
if (!canDelete)
{
errorMessage = reason;
deletingSecret = false;
confirmDeleteSecretId = null;
return;
}
await VaultService.DeleteSecretAsync(secretId);
deletingSecret = false;
confirmDeleteSecretId = null;
revealedSecretId = null;
editSecretId = null;
await LoadSecrets();
}
// ──────── K8s Sync ────────
// ── App Secrets: K8s sync ────────────────────────────────────────
private void ToggleSync(VaultSecret secret)
{
@@ -456,9 +631,49 @@ else
syncSecretName = secret.KubernetesSecretName ?? "";
syncNamespace = secret.KubernetesNamespace ?? "";
syncClusterId = secret.KubernetesClusterId ?? Guid.Empty;
// Auto-select cluster when only one exists across the app's environments
if (syncClusterId == Guid.Empty && appEnvironmentClusters.Count == 1)
syncClusterId = appEnvironmentClusters[0].Id;
// Pre-fill namespace: governance lock takes precedence, then deployment namespace
if (syncClusterId != Guid.Empty)
{
string? locked = GetLockedNamespaceForCluster(syncClusterId);
if (locked is not null)
syncNamespace = locked;
else if (string.IsNullOrEmpty(syncNamespace))
syncNamespace = GetDeploymentNamespaceForCluster(syncClusterId) ?? "";
}
}
}
private void OnSyncClusterChanged(ChangeEventArgs e)
{
if (Guid.TryParse(e.Value?.ToString(), out Guid id))
{
syncClusterId = id;
if (id != Guid.Empty)
{
string? locked = GetLockedNamespaceForCluster(id);
if (locked is not null)
syncNamespace = locked;
else
syncNamespace = GetDeploymentNamespaceForCluster(id) ?? syncNamespace;
}
}
}
private string? GetLockedNamespaceForCluster(Guid clusterId)
{
KubernetesCluster? cluster = appEnvironmentClusters.FirstOrDefault(c => c.Id == clusterId);
if (cluster is null) return null;
return environmentNamespaceLock.GetValueOrDefault(cluster.EnvironmentId);
}
private string? GetDeploymentNamespaceForCluster(Guid clusterId) =>
deployments?.FirstOrDefault(d => d.ClusterId == clusterId)?.Namespace;
private async Task DisableSync(Guid secretId)
{
await VaultService.ConfigureKubernetesSyncAsync(secretId, syncEnabled: false, secretName: null, ns: null);
@@ -497,4 +712,61 @@ else
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 class="card-body p-0">
@if (loading)
{
<div class="text-center py-3">
<div class="spinner-border spinner-border-sm text-primary"></div>
</div>
}
else if (!string.IsNullOrEmpty(errorMessage))
{
<div class="alert alert-warning m-3 mb-0">
<i class="bi bi-exclamation-triangle me-1"></i>@errorMessage
</div>
}
else if (view == "alerts")
<LoadingPanel Loading="@loading" Error="@errorMessage">
@if (!loading && string.IsNullOrEmpty(errorMessage) && view == "alerts")
{
@* ── Active Alerts ── *@
@if (alerts is null || alerts.Count == 0)
{
<div class="text-center py-4">
<i class="bi bi-check-circle text-success" style="font-size: 2rem;"></i>
<p class="text-muted mt-2 mb-0">No active alerts. All clear!</p>
</div>
<EmptyState Icon="bi-check-circle"
Title="All clear!"
Message="No active alerts right now." />
}
else
{
@@ -93,15 +81,12 @@
</div>
}
}
else if (view == "silences")
else if (!loading && string.IsNullOrEmpty(errorMessage) && view == "silences")
{
@* ── Silences ── *@
@if (silences is null || silences.Count == 0)
{
<div class="text-center py-4">
<i class="bi bi-bell-slash text-muted" style="font-size: 2rem;"></i>
<p class="text-muted mt-2 mb-0">No silences configured.</p>
</div>
<EmptyState Icon="bi-bell-slash" Message="No silences configured." />
}
else
{
@@ -134,6 +119,8 @@
}
}
</LoadingPanel>
@* ── Create Silence Form ── *@
@if (showCreateSilence)
{

View File

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

View File

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

View File

@@ -73,6 +73,11 @@
<i class="bi bi-heart-pulse me-1"></i>Monitoring
</button>
</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">
<button class="nav-link @(section == "components" ? "active" : "")" @onclick='() => section = "components"'>
<i class="bi bi-puzzle me-1"></i>Components
@@ -89,6 +94,10 @@
{
<ClusterMonitoring ClusterId="Cluster.Id" />
}
else if (section == "nodes")
{
<ClusterNodes ClusterId="Cluster.Id" />
}
else if (section == "logs")
{
<LogBrowser ClusterId="Cluster.Id" />
@@ -227,9 +236,7 @@ else if (section == "components")
</div>
<div class="mb-2">
<label class="form-label small mb-0">Helm values (YAML)</label>
<textarea class="form-control form-control-sm font-monospace" rows="4"
placeholder="# Custom values..."
@bind="regValues" @bind:event="oninput"></textarea>
<YamlEditor @bind-Value="regValues" />
</div>
<button class="btn btn-sm btn-success" @onclick="RegisterCustomComponent"
disabled="@(string.IsNullOrWhiteSpace(regName) || string.IsNullOrWhiteSpace(regChartName))">
@@ -434,8 +441,7 @@ else if (section == "components")
@if (showAdvancedYaml)
{
<div class="accordion-body p-2">
<textarea class="form-control form-control-sm font-monospace" rows="12"
@bind="regValues" @bind:event="oninput"></textarea>
<YamlEditor @bind-Value="regValues" />
</div>
}
</div>
@@ -779,6 +785,15 @@ else if (section == "components")
<i class="bi bi-sliders me-1"></i>Configure
</button>
</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">
<button class="nav-link py-1 px-2 @(compDetailTab == "secrets" ? "active" : "")" @onclick="() => ShowSecrets(comp.Id)">
<i class="bi bi-key me-1"></i>Secrets
@@ -963,47 +978,10 @@ else if (section == "components")
</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>
</h2>
@if (showAdvancedYaml)
{
<div class="p-3">
<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 == "values")
{
<YamlEditor @bind-Value="editValues" />
}
else if (compDetailTab == "secrets")
{
@@ -1325,6 +1303,27 @@ else if (section == "components")
}
</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>
@@ -1788,6 +1787,16 @@ else if (section == "components")
editFormFieldValues["admin-username"] = harborConfig.AdminUsername;
}
// Load Loki storage link ID from the component's Configuration JSON.
if (catalogMatch?.Key == "loki")
{
Guid? lokiStorageLinkId = await LokiService.GetStorageLinkIdForComponentAsync(TenantId, componentId);
if (lokiStorageLinkId.HasValue)
{
editFormFieldValues["storage-link"] = lokiStorageLinkId.Value.ToString();
}
}
// Pre-populate hostname and TLS fields from the existing external route.
List<ExternalRoute> routes = await RouteService.GetRoutesAsync(componentId);
ExternalRoute? route = routes.FirstOrDefault();
@@ -2639,10 +2648,11 @@ else if (section == "components")
continue;
}
// cnpg:/harbor: fields are not in YAML — loaded from component configs in ToggleComponentDetail.
// cnpg:/harbor:/loki: fields are not in YAML — loaded from component configs in ToggleComponentDetail.
if (field.YamlPath.StartsWith("cnpg:", StringComparison.Ordinal)
|| field.YamlPath.StartsWith("harbor:", StringComparison.Ordinal))
|| field.YamlPath.StartsWith("harbor:", StringComparison.Ordinal)
|| field.YamlPath.StartsWith("loki:", StringComparison.Ordinal))
{
continue;
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -2,32 +2,86 @@
@using EntKube.Web.Services
@inject AppGovernanceService GovernanceService
@inject TenantService TenantService
@inject DeploymentService DeploymentService
@inject StorageService StorageService
@* ═══════════════════════════════════════════════════════════════════
GovernanceTab — tenant admin view for app-level namespace, resource
quotas, network policies, and RBAC. All changes are saved to the
database. "Apply to clusters" pushes to every cluster where this
app has a deployment.
GovernanceTab — per-environment governance: resource quotas,
network policies, and RBAC for this app in this specific environment.
Namespace is derived from each deployment (not stored here).
═══════════════════════════════════════════════════════════════════ *@
<div class="d-flex align-items-center justify-content-between mb-3">
<div>
<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 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> }
else { <i class="bi bi-cloud-upload me-1"></i> }
Apply to clusters
</button>
</div>
<button class="btn btn-sm btn-success" @onclick="ApplyToAllClusters" disabled="@(applying || string.IsNullOrWhiteSpace(ns))">
@if (applying) { <span class="spinner-border spinner-border-sm me-1"></span> }
else { <i class="bi bi-cloud-upload me-1"></i> }
Apply to clusters
</button>
</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)
{
<div class="alert @(applySuccess ? "alert-success" : "alert-danger") py-2 small mb-3 d-flex align-items-start justify-content-between">
<div>
<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>
</div>
<button class="btn-close btn-close-sm ms-2 flex-shrink-0" @onclick="() => applyOutput = null"></button>
@@ -40,47 +94,55 @@
}
else
{
@* ── 1. Namespace ─────────────────────────────────────────────── *@
@* ── 0. Namespace ─────────────────────────────────────────────── *@
<div class="card shadow-sm mb-3">
<div class="card-header bg-white py-2">
<i class="bi bi-box me-2 text-primary"></i>
<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 class="card-body">
<div class="row g-2 align-items-end">
<div class="col-md-5">
<label class="form-label small">Namespace</label>
<input class="form-control form-control-sm font-monospace" @bind="ns"
placeholder="e.g. my-app" />
<div class="form-text">Must be a valid DNS label (lowercase, hyphens, max 63 chars).</div>
@if (nsMessage is not null)
{
<div class="alert @(nsOk ? "alert-success" : "alert-danger") py-1 small mb-2">
<i class="bi @(nsOk ? "bi-check-circle" : "bi-x-circle") me-1"></i>@nsMessage
</div>
<div class="col-auto">
<button class="btn btn-sm btn-primary" @onclick="SaveNamespace"
disabled="@savingNs">
@if (savingNs) { <span class="spinner-border spinner-border-sm me-1"></span> }
Save
</button>
</div>
@if (nsMessage is not null)
}
<div class="d-flex gap-2 align-items-center flex-wrap">
<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> }
Save
</button>
@if (!string.IsNullOrWhiteSpace(nsValue))
{
<div class="col-auto">
<span class="small @(nsOk ? "text-success" : "text-danger")">
<i class="bi @(nsOk ? "bi-check-circle" : "bi-x-circle") me-1"></i>@nsMessage
</span>
</div>
<button class="btn btn-sm btn-outline-danger" @onclick="ClearNamespace">
<i class="bi bi-x me-1"></i>Remove lock
</button>
}
</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>
@* ── 2. Resource Quota ─────────────────────────────────────────── *@
@* ── 1. Resource Quota ─────────────────────────────────────────── *@
<div class="card shadow-sm mb-3">
<div class="card-header bg-white py-2 d-flex align-items-center justify-content-between">
<div>
<i class="bi bi-speedometer me-2 text-warning"></i>
<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>
@if (quota is not null)
{
@@ -96,6 +158,19 @@ else
<i class="bi @(quotaOk ? "bi-check-circle" : "bi-x-circle") me-1"></i>@quotaMessage
</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="col-md-3">
<label class="form-label small">CPU request</label>
@@ -129,7 +204,7 @@ else
</div>
</div>
@* ── 3. Network Policies ───────────────────────────────────────── *@
@* ── 2. Network Policies ───────────────────────────────────────── *@
<div class="card shadow-sm mb-3">
<div class="card-header bg-white py-2 d-flex align-items-center justify-content-between">
<div>
@@ -180,9 +255,7 @@ else
{
<div class="mb-2">
<label class="form-label small">Custom NetworkPolicy YAML</label>
<textarea class="form-control form-control-sm font-monospace" rows="8"
@bind="newNpCustomYaml"
placeholder="apiVersion: networking.k8s.io/v1&#10;kind: NetworkPolicy&#10;..." />
<YamlEditor @bind-Value="newNpCustomYaml" />
</div>
}
<div class="d-flex gap-1">
@@ -253,14 +326,12 @@ else
</div>
</div>
@* ── 4. RBAC ───────────────────────────────────────────────────── *@
<div class="card shadow-sm">
<div class="card-header bg-white py-2 d-flex align-items-center justify-content-between">
<div>
<i class="bi bi-person-badge me-2 text-success"></i>
<strong>RBAC</strong>
<span class="text-muted small ms-2">ServiceAccount, Role, and RoleBinding applied to the namespace.</span>
</div>
@* ── 3. RBAC ───────────────────────────────────────────────────── *@
<div class="card shadow-sm mb-3">
<div class="card-header bg-white py-2">
<i class="bi bi-person-badge me-2 text-success"></i>
<strong>RBAC</strong>
<span class="text-muted small ms-2">ServiceAccount, Role, and RoleBinding applied to the namespace.</span>
</div>
<div class="card-body">
@if (rbacMessage is not null)
@@ -270,7 +341,6 @@ else
</div>
}
@* Service account settings *@
<div class="row g-2 mb-3 align-items-end">
<div class="col-md-4">
<label class="form-label small">Service account name</label>
@@ -279,8 +349,8 @@ else
</div>
<div class="col-auto d-flex align-items-center gap-2" style="padding-bottom: 0.25rem;">
<div class="form-check mb-0">
<input class="form-check-input" type="checkbox" id="rbacAutoMount" @bind="rbacAutoMount" />
<label class="form-check-label small" for="rbacAutoMount">Auto-mount token</label>
<input class="form-check-input" type="checkbox" id="rbacAutoMountEnv" @bind="rbacAutoMount" />
<label class="form-check-label small" for="rbacAutoMountEnv">Auto-mount token</label>
</div>
</div>
<div class="col-auto">
@@ -296,7 +366,6 @@ else
</div>
</div>
@* Rules table *@
@if (rbacPolicy is not null)
{
<div class="mb-2 d-flex align-items-center justify-content-between">
@@ -382,20 +451,279 @@ else
}
</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 {
[Parameter, EditorRequired] public Guid AppId { get; set; }
[Parameter, EditorRequired] public Guid TenantId { get; set; }
[Parameter, EditorRequired] public Guid EnvironmentId { get; set; }
private bool loading = true;
// Namespace
private string ns = "";
private string nsValue = "";
private bool savingNs;
private string? nsMessage;
private bool nsOk;
// Copy from environment
private bool showCopyFrom;
private List<Data.Environment>? otherEnvironments;
private Guid copyFromEnvId;
private bool confirmCopy, copying;
private string? copyMessage;
private bool copyOk;
// Quota
private AppQuota? quota;
private string qCpuReq = "", qCpuLim = "", qMemReq = "", qMemLim = "";
@@ -438,10 +766,17 @@ else
loading = false;
}
protected override async Task OnParametersSetAsync()
{
if (showCopyFrom && otherEnvironments is null)
await LoadOtherEnvironments();
}
private async Task LoadAsync()
{
AppGovernanceData data = await GovernanceService.LoadAsync(AppId);
ns = data.Namespace ?? "";
AppGovernanceData data = await GovernanceService.LoadAsync(AppId, EnvironmentId);
nsValue = data.Namespace ?? "";
quota = data.Quota;
if (quota is not null)
@@ -462,20 +797,66 @@ else
rbacSaName = rbacPolicy.ServiceAccountName;
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()
{
savingNs = true; nsMessage = null;
await GovernanceService.SetNamespaceAsync(AppId, ns);
savingNs = false; nsOk = true;
nsMessage = "Saved.";
await GovernanceService.SaveNamespaceAsync(AppId, EnvironmentId, nsValue);
nsValue = nsValue.Trim().ToLowerInvariant();
savingNs = false; nsOk = true; nsMessage = "Namespace saved.";
}
private async Task ClearNamespace()
{
await GovernanceService.SaveNamespaceAsync(AppId, EnvironmentId, null);
nsValue = ""; nsMessage = null;
}
// ── Quota ──────────────────────────────────────────────────────────────────
private void ApplyQuotaPreset(string cpuReq, string cpuLim, string memReq, string memLim, int maxPods, int maxPvcs)
{
qCpuReq = cpuReq; qCpuLim = cpuLim;
qMemReq = memReq; qMemLim = memLim;
qMaxPods = maxPods; qMaxPvcs = maxPvcs;
quotaMessage = null;
}
private async Task SaveQuota()
{
savingQuota = true; quotaMessage = null;
quota = await GovernanceService.SaveQuotaAsync(AppId,
quota = await GovernanceService.SaveQuotaAsync(AppId, EnvironmentId,
qCpuReq, qCpuLim, qMemReq, qMemLim, qMaxPods, qMaxPvcs);
savingQuota = false; quotaOk = true;
quotaMessage = "Quota saved.";
@@ -483,19 +864,21 @@ else
private async Task DeleteQuota()
{
await GovernanceService.DeleteQuotaAsync(AppId);
await GovernanceService.DeleteQuotaAsync(AppId, EnvironmentId);
quota = null;
qCpuReq = qCpuLim = qMemReq = qMemLim = "";
qMaxPods = qMaxPvcs = null;
}
// ── Network policies ───────────────────────────────────────────────────────
private async Task AddNetworkPolicy()
{
addingNp = true; npMessage = null;
try
{
AppNetworkPolicy p = await GovernanceService.AddNetworkPolicyAsync(
AppId, newNpName, newNpType,
AppId, EnvironmentId, newNpName, newNpType,
newNpAllowNs, newNpCustomYaml);
networkPolicies.Add(p);
showAddNp = false;
@@ -513,17 +896,19 @@ else
confirmDeleteNpId = null;
}
// ── RBAC ───────────────────────────────────────────────────────────────────
private async Task SaveRbac()
{
savingRbac = true; rbacMessage = null;
rbacPolicy = await GovernanceService.SaveRbacPolicyAsync(AppId, rbacSaName, rbacAutoMount);
rbacPolicy = await GovernanceService.SaveRbacPolicyAsync(AppId, EnvironmentId, rbacSaName, rbacAutoMount);
savingRbac = false; rbacOk = true;
rbacMessage = "Saved.";
}
private async Task DeleteRbac()
{
await GovernanceService.DeleteRbacPolicyAsync(AppId);
await GovernanceService.DeleteRbacPolicyAsync(AppId, EnvironmentId);
rbacPolicy = null; rbacSaName = ""; rbacAutoMount = false;
}
@@ -551,26 +936,49 @@ else
rbacPolicy?.Rules.Remove(rbacPolicy.Rules.First(r => r.Id == id));
}
private async Task ApplyToAllClusters()
// ── Apply ──────────────────────────────────────────────────────────────────
private async Task ApplyToEnvironmentClusters()
{
applying = true; applyOutput = null;
var sb = new System.Text.StringBuilder();
List<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);
sb.AppendLine($"── {cluster.Name} ──");
sb.AppendLine(ok ? "✓ Applied" : $"✗ {output}");
if (!ok && !string.IsNullOrWhiteSpace(output))
sb.AppendLine(output);
applySuccess = ok;
applyOutput = "No deployments found for this environment. Create a deployment first.";
applySuccess = false;
applying = false;
return;
}
applying = false;
bool anyFail = false;
foreach (AppDeployment deploy in envDeployments)
{
(bool ok, string output) = await GovernanceService.ApplyToClusterAsync(
AppId, EnvironmentId, deploy.Cluster!);
sb.AppendLine($"── {deploy.Cluster!.Name} ({deploy.Namespace}) ──");
sb.AppendLine(ok ? "✓ Applied" : $"✗ {output}");
if (!ok) anyFail = true;
}
applySuccess = !anyFail;
applyOutput = sb.ToString().TrimEnd();
applySuccess = !applyOutput.Contains("✗");
applying = false;
}
// ── Lazy load copy panel ───────────────────────────────────────────────────
private async Task OpenCopyFrom()
{
showCopyFrom = !showCopyFrom;
if (showCopyFrom && otherEnvironments is null)
await LoadOtherEnvironments();
}
private static string NpTypeLabel(AppNetworkPolicyType t) => t switch
@@ -582,4 +990,148 @@ else
AppNetworkPolicyType.Custom => "Custom YAML",
_ => 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>
}
@if (groups is null)
<LoadingPanel Loading="@(groups is null)" LoadingText="Loading groups…">
@if (groups is not null && groups.Count == 0)
{
<div class="text-center py-4">
<div class="spinner-border spinner-border-sm text-primary" role="status"></div>
<span class="ms-2 text-muted">Loading groups...</span>
</div>
<EmptyState Icon="bi-diagram-3"
Title="No groups yet"
Message="Create one above to get started." />
}
else if (groups.Count == 0)
{
<div class="text-center py-5">
<i class="bi bi-diagram-3 text-muted" style="font-size: 3rem;"></i>
<p class="text-muted mt-2 mb-0">No groups yet. Create one above to get started.</p>
</div>
}
else
else if (groups is not null)
{
<div class="row row-cols-1 row-cols-md-2 row-cols-lg-3 g-3">
@foreach (Group group in groups)
@@ -90,6 +83,7 @@ else
}
</div>
}
</LoadingPanel>
@code {
[Parameter] public Guid TenantId { get; set; }

View File

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

View File

@@ -1,8 +1,26 @@
@using EntKube.Web.Data
@using EntKube.Web.Services
@inject IncidentService IncidentService
@inject OnCallService OnCallService
@inject NotificationService NotificationService
@inject AuthenticationStateProvider AuthStateProvider
@* ── On-call banner ── *@
@if (currentOnCall is not null)
{
<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)
{
<div class="row g-2 mb-3">
@@ -42,6 +60,20 @@
<div class="text-muted small">MTTA</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)
{
int maxCount = stats.Daily.Max(d => d.Count);
@@ -87,45 +119,82 @@
<option value="30">Last 30 days</option>
<option value="90">Last 90 days</option>
</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">
<span class="bi bi-arrow-clockwise"></span> Refresh
</button>
<span class="ms-auto text-muted small">@incidents.Count incident@(incidents.Count == 1 ? "" : "s")</span>
</div>
@if (isLoading)
<LoadingPanel Loading="@isLoading" LoadingText="Loading incidents…">
@if (!isLoading && incidents.Count == 0)
{
<div class="text-center py-4 text-muted"><span class="bi bi-hourglass-split"></span> Loading incidents…</div>
<EmptyState Icon="bi-check-circle"
Title="No incidents found"
Message="No incidents match the selected filters." />
}
else if (incidents.Count == 0)
{
<div class="text-center py-5 text-muted">
<span class="bi bi-check-circle display-4 d-block mb-2"></span>
No incidents found for the selected filters.
</div>
}
else
else if (!isLoading)
{
<div class="table-responsive">
<table class="table table-hover align-middle">
<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>Started</th>
<th>Duration</th>
<th style="width:110px">Status</th>
<th style="width:180px">Actions</th>
<th style="width:200px">Actions</th>
</tr>
</thead>
<tbody>
@foreach (AlertIncident incident in incidents)
{
bool expanded = expandedId == incident.Id;
<tr class="@(expanded ? "table-active" : "")" style="cursor:pointer" @onclick="() => ToggleExpand(incident.Id)">
<td>@SeverityBadge(incident.Severity)</td>
<td class="fw-semibold">@incident.AlertName</td>
bool selected = selectedIds.Contains(incident.Id);
<tr class="@(expanded ? "table-active" : selected ? "table-warning" : "")"
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.StartsAt.ToLocalTime().ToString("MMM d HH:mm")</td>
<td class="text-muted small">@FormatDuration(incident)</td>
@@ -144,50 +213,162 @@ else
</button>
}
<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>
</td>
</tr>
@if (expanded)
{
<tr>
<td colspan="7" class="bg-light px-4 py-3">
@if (!string.IsNullOrEmpty(incident.Summary))
{
<p class="mb-1"><strong>Summary:</strong> @incident.Summary</p>
}
@if (!string.IsNullOrEmpty(incident.Description))
{
<p class="mb-2 text-muted">@incident.Description</p>
}
@if (incident.AcknowledgedBy is not null)
{
<p class="mb-2 small text-muted">
Acknowledged by <strong>@incident.AcknowledgedBy</strong>
at @incident.AcknowledgedAt?.ToLocalTime().ToString("MMM d HH:mm")
</p>
}
<td colspan="8" class="bg-light px-4 py-3">
<div class="row g-4">
@if (incident.Notes.Count > 0)
{
<div class="mb-3">
<strong class="small">Notes</strong>
@foreach (IncidentNote note in incident.Notes)
@* ── Left: Timeline ── *@
<div class="col-12 col-lg-5">
@if (!string.IsNullOrEmpty(incident.Summary))
{
<div class="border-start border-3 border-secondary ps-3 mb-2 mt-1">
<div class="small text-muted">@note.Author · @note.CreatedAt.ToLocalTime().ToString("MMM d HH:mm")</div>
<div>@note.Content</div>
<p class="mb-1 small"><strong>Summary:</strong> @incident.Summary</p>
}
@if (!string.IsNullOrEmpty(incident.Description))
{
<p class="mb-2 small text-muted">@incident.Description</p>
}
@if (!string.IsNullOrEmpty(incident.RunbookUrl))
{
<p class="mb-3">
<a href="@incident.RunbookUrl" target="_blank" rel="noopener noreferrer"
class="btn btn-sm btn-outline-secondary">
<span class="bi bi-book me-1"></span>Runbook
</a>
</p>
}
<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 style="position:relative;margin-bottom:14px">
<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"
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="text-muted small mt-1">@ev.Detail</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>
}
<div class="d-flex gap-2">
<textarea class="form-control form-control-sm" rows="2" placeholder="Add a note…"
@bind="noteText" style="max-width:400px"></textarea>
<button class="btn btn-sm btn-primary align-self-end" @onclick="() => AddNote(incident)">
Add Note
</button>
@* Re-notify *@
<div class="mb-3 d-flex gap-2 align-items-center">
<button class="btn btn-sm btn-outline-info"
@onclick="() => ReNotify(incident)"
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>
@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>
</td>
</tr>
@@ -197,6 +378,7 @@ else
</table>
</div>
}
</LoadingPanel>
@code {
[Parameter] public required Guid TenantId { get; set; }
@@ -213,11 +395,26 @@ else
private bool isLoading = true;
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()
{
AuthenticationState authState = await AuthStateProvider.GetAuthenticationStateAsync();
currentUser = authState.User.Identity?.Name ?? "Unknown";
await Task.WhenAll(LoadIncidents(), LoadStats());
await Task.WhenAll(LoadIncidents(), LoadStats(), LoadOnCall());
}
private async Task LoadOnCall()
{
currentOnCall = await OnCallService.GetCurrentOnCallAsync(TenantId);
}
private async Task LoadStats()
@@ -230,39 +427,147 @@ else
isLoading = true;
StateHasChanged();
IncidentStatus? status = filterStatus switch
try
{
"Active" => IncidentStatus.Active,
"Acknowledged" => IncidentStatus.Acknowledged,
"Resolved" => IncidentStatus.Resolved,
_ => null
};
IncidentStatus? status = filterStatus switch
{
"Active" => IncidentStatus.Active,
"Acknowledged" => IncidentStatus.Acknowledged,
"Resolved" => IncidentStatus.Resolved,
_ => null
};
Guid? clusterId = Guid.TryParse(filterClusterId, out Guid cid) ? cid : null;
Guid? clusterId = Guid.TryParse(filterClusterId, out Guid cid) ? cid : null;
incidents = await IncidentService.GetIncidentsForTenantAsync(
TenantId,
status: status,
severity: string.IsNullOrEmpty(filterSeverity) ? null : filterSeverity,
clusterId: clusterId,
from: DateTime.UtcNow.AddDays(-filterDays),
to: null);
incidents = await IncidentService.GetIncidentsForTenantAsync(
TenantId,
status: status,
severity: string.IsNullOrEmpty(filterSeverity) ? null : filterSeverity,
clusterId: clusterId,
from: DateTime.UtcNow.AddDays(-filterDays),
to: null);
// Collect unique clusters from results
clusters = incidents
.Where(i => i.Cluster is not null)
.Select(i => i.Cluster!)
.DistinctBy(c => c.Id)
.OrderBy(c => c.Name)
.ToList();
if (filterUnassigned)
incidents = incidents.Where(i => i.AssignedTo is null).ToList();
isLoading = false;
if (!string.IsNullOrWhiteSpace(searchText))
{
string q = searchText.Trim();
incidents = incidents.Where(i =>
i.AlertName.Contains(q, StringComparison.OrdinalIgnoreCase) ||
i.Summary.Contains(q, StringComparison.OrdinalIgnoreCase)).ToList();
}
deliveriesByIncident.Clear();
clusters = incidents
.Where(i => i.Cluster is not null)
.Select(i => i.Cluster!)
.DistinctBy(c => c.Id)
.OrderBy(c => c.Name)
.ToList();
}
catch
{
incidents = [];
}
finally
{
isLoading = false;
}
}
private void ToggleExpand(Guid id)
{
expandedId = expandedId == id ? null : id;
if (expandedId == id)
{
expandedId = null;
}
else
{
expandedId = id;
_ = LoadDeliveriesAsync(id);
}
noteText = "";
if (assigningId != id) assigningId = null;
}
private async Task LoadDeliveriesAsync(Guid incidentId)
{
List<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)
@@ -286,6 +591,36 @@ else
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)
{
DateTime end = incident.EndsAt ?? DateTime.UtcNow;

View File

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

View File

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

View File

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

View File

@@ -3,6 +3,7 @@
@using Microsoft.EntityFrameworkCore
@inject IDbContextFactory<ApplicationDbContext> DbFactory
@inject NotificationService NotificationService
@inject NotificationProviderConfigService ProviderConfigService
<div class="d-flex justify-content-between align-items-center mb-3">
<h6 class="mb-0">Notification Channels</h6>
@@ -13,10 +14,9 @@
@if (channels.Count == 0)
{
<div class="text-center py-5 text-muted">
<span class="bi bi-send display-4 d-block mb-2"></span>
No notification channels configured. Add a channel to get alerted via Slack, Teams, email, or webhook.
</div>
<EmptyState Icon="bi-send"
Title="No notification channels"
Message="Add a channel to get alerted via Slack, Teams, email, or webhook." />
}
else
{
@@ -109,13 +109,45 @@ else
<input class="form-control" @bind="newName" placeholder="e.g. Ops Slack" />
</div>
@if (newType == NotificationChannelType.Slack || newType == NotificationChannelType.Teams)
@if (newType == NotificationChannelType.Slack)
{
<div class="mb-3">
<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>
}
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)
{
<div class="mb-3">
@@ -178,12 +210,20 @@ else
private string newWebhookUrl = "";
private string newEmail = "";
private string newBearerToken = "";
private string newTeamId = "";
private string newChannelId = "";
private AlertSeverityFilter newSeverityFilter = AlertSeverityFilter.All;
private string? modalError;
private Guid? testingId;
private Dictionary<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()
{
@@ -212,6 +252,8 @@ else
newWebhookUrl = "";
newEmail = "";
newBearerToken = "";
newTeamId = "";
newChannelId = "";
newSeverityFilter = AlertSeverityFilter.All;
modalError = null;
}
@@ -262,7 +304,17 @@ else
{
return newType switch
{
NotificationChannelType.Slack or NotificationChannelType.Teams =>
NotificationChannelType.Slack =>
!string.IsNullOrWhiteSpace(newWebhookUrl)
? System.Text.Json.JsonSerializer.Serialize(new { webhookUrl = newWebhookUrl.Trim() })
: throw new InvalidOperationException("Webhook URL is required."),
NotificationChannelType.Teams when teamsGraphConfigured =>
!string.IsNullOrWhiteSpace(newTeamId) && !string.IsNullOrWhiteSpace(newChannelId)
? System.Text.Json.JsonSerializer.Serialize(new { teamId = newTeamId.Trim(), channelId = newChannelId.Trim() })
: throw new InvalidOperationException("Team ID and Channel ID are required."),
NotificationChannelType.Teams =>
!string.IsNullOrWhiteSpace(newWebhookUrl)
? System.Text.Json.JsonSerializer.Serialize(new { webhookUrl = newWebhookUrl.Trim() })
: throw new InvalidOperationException("Webhook URL is required."),

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

View File

@@ -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)
{
<div class="text-center py-4 text-muted">
<span class="bi bi-shield display-4 d-block mb-2"></span>
No SLA targets configured. Add one to track uptime compliance.
</div>
<EmptyState Icon="bi-shield"
Title="No SLA targets"
Message="Add one to track uptime compliance." />
}
else
{

View File

@@ -10,14 +10,8 @@
External providers are managed as vault-backed links.
=========================================================================== *@
@if (loading)
{
<div class="text-center py-5">
<div class="spinner-border text-primary" role="status"></div>
<p class="text-muted mt-2">Discovering storage resources...</p>
</div>
}
else
<LoadingPanel Loading="@loading" LoadingText="Discovering storage resources…">
@if (!loading)
{
@* ── MinIO Section ── *@
<div class="mb-5">
@@ -462,8 +456,7 @@ else
<div>
<h6 class="text-muted"><i class="bi bi-shield-lock me-1"></i>Bucket Policy (JSON)</h6>
<div class="mb-2">
<textarea class="form-control form-control-sm font-monospace" rows="8" @bind="bucketPolicyJson"
placeholder='{"Version": "2012-10-17", "Statement": [...]}'></textarea>
<YamlEditor @bind-Value="bucketPolicyJson" Language="json" />
</div>
<div class="d-flex gap-2">
<button class="btn btn-sm btn-primary" @onclick="SavePolicy" disabled="@manageSaving">
@@ -834,6 +827,7 @@ else
</button>
</div>
}
</LoadingPanel>
@code {
[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>
<ul class="nav nav-pills mb-4 gap-1">
@* ── Category (top-level) nav ── *@
<ul class="nav nav-pills mb-2 gap-1 tenant-category-nav">
<li class="nav-item">
<button class="nav-link @(activeTab == "environments" ? "active" : "")" @onclick='() => activeTab = "environments"'>
<i class="bi bi-layers me-1"></i>Environments
<button class="nav-link @(activeCategory == "apps" ? "active" : "")" @onclick='() => SetCategory("apps")' disabled="@tabLoading">
<i class="bi bi-people me-1"></i>Applications
</button>
</li>
<li class="nav-item">
<button class="nav-link @(activeTab == "customers" ? "active" : "")" @onclick='() => activeTab = "customers"'>
<i class="bi bi-people me-1"></i>Customers
<button class="nav-link @(activeCategory == "infra" ? "active" : "")" @onclick='() => SetCategory("infra")' disabled="@tabLoading">
<i class="bi bi-layers me-1"></i>Infrastructure
</button>
</li>
<li class="nav-item">
<button class="nav-link @(activeTab == "vault" ? "active" : "")" @onclick='() => activeTab = "vault"'>
<i class="bi bi-shield-lock me-1"></i>Vault
<button class="nav-link @(activeCategory == "services" ? "active" : "")" @onclick='() => SetCategory("services")' disabled="@tabLoading">
<i class="bi bi-box-seam me-1"></i>Services
</button>
</li>
<li class="nav-item">
<button class="nav-link @(activeTab == "groups" ? "active" : "")" @onclick='() => activeTab = "groups"'>
<i class="bi bi-diagram-3 me-1"></i>Groups
</button>
</li>
<li class="nav-item">
<button class="nav-link @(activeTab == "databases" ? "active" : "")" @onclick='() => activeTab = "databases"'>
<i class="bi bi-database me-1"></i>Databases
</button>
</li>
<li class="nav-item">
<button class="nav-link @(activeTab == "storage" ? "active" : "")" @onclick='() => activeTab = "storage"'>
<i class="bi bi-bucket me-1"></i>Storage
</button>
</li>
<li class="nav-item">
<button class="nav-link @(activeTab == "identity" ? "active" : "")" @onclick='() => activeTab = "identity"'>
<i class="bi bi-person-badge me-1"></i>Identity
</button>
</li>
<li class="nav-item">
<button class="nav-link @(activeTab == "registry" ? "active" : "")" @onclick='() => activeTab = "registry"'>
<i class="bi bi-archive me-1"></i>Registry
</button>
</li>
<li class="nav-item">
<button class="nav-link @(activeTab == "messaging" ? "active" : "")" @onclick='() => activeTab = "messaging"'>
<i class="bi bi-collection me-1"></i>Messaging
</button>
</li>
<li class="nav-item">
<button class="nav-link @(activeTab == "cache" ? "active" : "")" @onclick='() => activeTab = "cache"'>
<i class="bi bi-lightning-charge me-1"></i>Cache
</button>
</li>
<li class="nav-item">
<button class="nav-link @(activeTab == "overview" ? "active" : "")" @onclick='() => activeTab = "overview"'>
<i class="bi bi-speedometer2 me-1"></i>Overview
</button>
</li>
<li class="nav-item">
<button class="nav-link @(activeTab == "incidents" ? "active" : "")" @onclick='() => activeTab = "incidents"'>
<i class="bi bi-bell-fill me-1"></i>Incidents
</button>
</li>
<li class="nav-item">
<button class="nav-link @(activeTab == "notifications" ? "active" : "")" @onclick='() => activeTab = "notifications"'>
<i class="bi bi-send-fill me-1"></i>Notifications
</button>
</li>
<li class="nav-item">
<button class="nav-link @(activeTab == "maintenance" ? "active" : "")" @onclick='() => activeTab = "maintenance"'>
<i class="bi bi-tools me-1"></i>Maintenance
</button>
</li>
<li class="nav-item">
<button class="nav-link @(activeTab == "sla" ? "active" : "")" @onclick='() => activeTab = "sla"'>
<i class="bi bi-shield-check me-1"></i>SLA
</button>
</li>
<li class="nav-item">
<button class="nav-link @(activeTab == "networking" ? "active" : "")" @onclick='() => activeTab = "networking"'>
<i class="bi bi-shield-lock me-1"></i>Networking
</button>
</li>
<li class="nav-item">
<button class="nav-link @(activeTab == "git" ? "active" : "")" @onclick='() => activeTab = "git"'>
<i class="bi bi-git me-1"></i>Git
<button class="nav-link @(activeCategory == "ops" ? "active" : "")" @onclick='() => SetCategory("ops")' disabled="@tabLoading">
<i class="bi bi-speedometer2 me-1"></i>Operations
</button>
</li>
</ul>
@* ── Sub-tab strip for the active category ── *@
<ul class="nav nav-tabs mb-4">
@if (activeCategory == "apps")
{
<li class="nav-item">
<button class="nav-link @(activeTab == "customers" ? "active" : "")" @onclick='() => 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">
@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":
<EnvironmentTab TenantId="tenant.Id" />
@@ -156,7 +224,16 @@ else
<CacheTab TenantId="tenant.Id" />
break;
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;
case "incidents":
<IncidentManagement TenantId="tenant.Id" />
@@ -167,15 +244,21 @@ else
case "maintenance":
<MaintenanceWindows TenantId="tenant.Id" />
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":
<SlaTargetManager TenantId="tenant.Id" />
break;
case "networking":
<NetworkingTab TenantId="tenant.Id" />
break;
case "git":
<GitReposTab TenantId="tenant.Id" />
break;
}
</div>
}
@@ -184,7 +267,36 @@ else
[Parameter] public string Slug { get; set; } = "";
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()
{

View File

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

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
@inject PrometheusService PrometheusService
@inject IncidentService IncidentService
@inject OnCallService OnCallService
@inject IDbContextFactory<ApplicationDbContext> DbFactory
@implements IAsyncDisposable
@@ -18,37 +19,54 @@
</button>
</div>
@* ── Tenant-level summary strip ── *@
@if (clusterSummaries.Count > 0)
{
<div class="row g-2 mb-4">
<div class="col-auto">
<div class="card text-center border-0 bg-light px-3 py-2">
<div class="fw-bold fs-4">@clusterSummaries.Count</div>
<div class="text-muted small">Clusters</div>
<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">@clusterSummaries.Count</div>
<div class="text-muted small">Clusters</div>
</div>
<div class="card border-0 bg-light text-center px-3 py-2">
<div class="fw-bold fs-4">@clusterSummaries.Sum(s => s.Health?.TotalNodes ?? 0)</div>
<div class="text-muted small">Nodes</div>
</div>
<div class="card border-0 bg-light text-center px-3 py-2">
<div class="fw-bold fs-4">@clusterSummaries.Sum(s => s.Health?.RunningPods ?? 0)</div>
<div class="text-muted small">Running Pods</div>
</div>
<div class="card border-0 text-center px-3 py-2 @(tenantActiveAlerts > 0 ? "bg-danger-subtle" : "bg-light")"
style="cursor:@(OnNavigateTab.HasDelegate ? "pointer" : "default")"
@onclick='() => NavigateTo("alerts")'>
<div class="fw-bold fs-4 @(tenantActiveAlerts > 0 ? "text-danger" : "text-success")">@tenantActiveAlerts</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 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="text-muted small">Nodes</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 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="text-muted small">Running Pods</div>
</div>
</div>
<div class="col-auto">
<div class="card text-center border-0 bg-light px-3 py-2">
<div class="fw-bold fs-4 @(tenantActiveAlerts > 0 ? "text-danger" : "text-success")">@tenantActiveAlerts</div>
<div class="text-muted small">Active Alerts</div>
</div>
</div>
}
</div>
}
<div class="row g-3">
@* ── Cluster cards ── *@
<div class="row g-3 mb-4">
@foreach (ClusterSummaryVm vm in clusterSummaries)
{
<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
&nbsp;·&nbsp;
<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 class="card-footer py-1 bg-transparent border-0">
<a href="tenants/@TenantSlug/clusters/@vm.Cluster.Id/monitoring" class="btn btn-sm btn-link p-0 text-decoration-none">
<span class="bi bi-graph-up me-1"></span>View monitoring
<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">
<span class="bi bi-graph-up me-1"></span>Monitoring
</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>
@* ── 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 {
[Parameter] public required Guid TenantId { get; set; }
[Parameter] public required string TenantSlug { get; set; }
[Parameter] public EventCallback<string> OnNavigateTab { get; set; }
private List<ClusterSummaryVm> clusterSummaries = [];
private List<AlertIncident> activeIncidents = [];
private List<MaintenanceWindow> upcomingMaintenance = [];
private OnCallShift? currentOnCall;
private int tenantActiveAlerts;
private int criticalIncidents;
private DateTime? lastRefreshed;
private bool isLoading;
private Timer? refreshTimer;
@@ -145,10 +284,28 @@
.ToListAsync();
}
tenantActiveAlerts = await IncidentService.GetActiveAlertCountForTenantAsync(TenantId);
// Load per-cluster active alert counts and tenant-level stats in parallel
Dictionary<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
.Select(c => LoadClusterSummaryAsync(c))
.Select(c => LoadClusterSummaryAsync(c, perClusterCounts.GetValueOrDefault(c.Id)))
.ToList();
clusterSummaries = [.. await Task.WhenAll(tasks)];
@@ -157,13 +314,13 @@
isLoading = false;
}
private async Task<ClusterSummaryVm> LoadClusterSummaryAsync(KubernetesCluster cluster)
private async Task<ClusterSummaryVm> LoadClusterSummaryAsync(KubernetesCluster cluster, int activeAlerts)
{
bool hasPrometheus = cluster.Components.Any(c =>
c.HelmChartName == "kube-prometheus-stack" && c.Status == ComponentStatus.Installed);
if (!hasPrometheus)
return new ClusterSummaryVm(cluster, null, null, 0);
return new ClusterSummaryVm(cluster, null, null, activeAlerts);
KubernetesOperationResult<ClusterHealthSummary> result =
await PrometheusService.GetClusterHealthAsync(cluster.Id);
@@ -172,7 +329,13 @@
cluster,
result.IsSuccess ? result.Data : null,
result.IsSuccess ? null : result.Error,
0);
activeAlerts);
}
private async Task NavigateTo(string tab)
{
if (OnNavigateTab.HasDelegate)
await OnNavigateTab.InvokeAsync(tab);
}
private static string UtilColor(double pct) =>
@@ -186,6 +349,24 @@
return "success";
}
private static string FormatAge(DateTime t)
{
TimeSpan ago = DateTime.UtcNow - t;
return ago switch
{
{ TotalMinutes: < 60 } => $"{(int)ago.TotalMinutes}m",
{ TotalHours: < 24 } => $"{(int)ago.TotalHours}h",
_ => $"{(int)ago.TotalDays}d"
};
}
private static RenderFragment SeverityDot(string severity) => severity switch
{
"critical" => @<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()
{
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 StorageService StorageService
@inject CnpgService CnpgService
@inject MongoService MongoService
@inject RegisteredPostgresService RegisteredPostgresService
<div class="d-flex align-items-center mb-2">
<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
</button>
<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 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
@@ -72,10 +80,8 @@ else
}
else
{
<div class="text-center py-4">
<i class="bi bi-app text-muted" style="font-size: 2rem;"></i>
<p class="text-muted small mt-2 mb-0">Create a customer and app first to store app secrets.</p>
</div>
<EmptyState Icon="bi-app"
Message="Create a customer and app first to store app secrets." />
}
}
else if (scope == "component")
@@ -99,10 +105,8 @@ else
}
else
{
<div class="text-center py-4">
<i class="bi bi-puzzle text-muted" style="font-size: 2rem;"></i>
<p class="text-muted small mt-2 mb-0">Create an environment, cluster, and component first to store component secrets.</p>
</div>
<EmptyState Icon="bi-puzzle"
Message="Create an environment, cluster, and component first to store component secrets." />
}
}
else if (scope == "storage")
@@ -126,10 +130,8 @@ else
}
else
{
<div class="text-center py-4">
<i class="bi bi-hdd text-muted" style="font-size: 2rem;"></i>
<p class="text-muted small mt-2 mb-0">Create a storage link first to view its secrets.</p>
</div>
<EmptyState Icon="bi-hdd"
Message="Create a storage link first to view its secrets." />
}
}
@@ -154,11 +156,61 @@ else
}
else
{
<div class="text-center py-4">
<i class="bi bi-database text-muted" style="font-size: 2rem;"></i>
<p class="text-muted small mt-2 mb-0">No CNPG clusters found for this tenant.</p>
<EmptyState Icon="bi-database"
Message="No CNPG clusters found for this tenant." />
}
}
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>
}
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." />
}
}
@* --- Docker Registries Panel --- *@
@@ -187,7 +239,9 @@ else
}
</div>
<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="col-md-4">
<div class="input-group input-group-sm">
@@ -210,6 +264,7 @@ else
</button>
</div>
</div>
}
@if (!string.IsNullOrEmpty(errorMessage))
{
@@ -226,29 +281,22 @@ else
}
@* Secrets table *@
@if (secrets is null)
<LoadingPanel Loading="@(secrets is null)">
@if (secrets is not null && secrets.Count == 0)
{
<div class="text-center py-3">
<div class="spinner-border spinner-border-sm text-primary" role="status"></div>
</div>
<EmptyState Icon="bi-lock"
Message="No secrets stored yet. Add one above." />
}
else if (secrets.Count == 0)
{
<div class="text-center py-3">
<i class="bi bi-lock text-muted" style="font-size: 2rem;"></i>
<p class="text-muted small mt-1 mb-0">No secrets stored yet. Add one above.</p>
</div>
}
else
else if (secrets is not null)
{
<div class="table-responsive">
<table class="table table-hover align-middle mb-0">
<thead class="table-light">
<tr>
<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 Secret</th>
@@ -262,13 +310,21 @@ else
{
<tr>
<td><code>@secret.Name</code></td>
@if (scope == "cnpg")
@if (scope is "cnpg" or "mongodb" or "regpostgres")
{
<td>
@if (secret.CnpgDatabase is not null)
{
<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
{
<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">
<i class="bi bi-pencil"></i>
</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)"
title="@(secret.SyncToKubernetes ? "Disable K8s sync" : "Enable K8s sync")">
<i class="bi @(secret.SyncToKubernetes ? "bi-cloud-slash" : "bi-cloud-arrow-up")"></i>
@@ -343,6 +402,81 @@ else
</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)
{
<tr>
@@ -385,6 +519,7 @@ else
</table>
</div>
}
</LoadingPanel>
@if (syncOutput is not null)
{
<div class="mt-3 p-2 bg-dark text-white rounded font-monospace small" style="white-space: pre-wrap; max-height: 200px; overflow-y: auto;">@syncOutput</div>
@@ -416,6 +551,14 @@ else
private List<(string Label, Guid Id)>? cnpgClusterOptions;
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)
private List<(string Label, Guid Id)>? clusterOptions;
@@ -440,6 +583,13 @@ else
private Guid? editSecretId;
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()
{
SecretVault? vault = await VaultService.GetVaultAsync(TenantId);
@@ -500,6 +650,18 @@ else
cnpgClusterOptions = cnpgClusters
.Select(c => ($"{c.KubernetesCluster.Name} / {c.Name}", c.Id))
.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)
@@ -510,6 +672,8 @@ else
selectedComponentId = Guid.Empty;
selectedStorageLinkId = Guid.Empty;
selectedCnpgClusterId = Guid.Empty;
selectedMongoClusterId = Guid.Empty;
selectedRegPostgresInstanceId = Guid.Empty;
errorMessage = null;
successMessage = null;
syncClusterId = Guid.Empty;
@@ -522,7 +686,9 @@ else
return (scope == "app" && selectedAppId != Guid.Empty)
|| (scope == "component" && selectedComponentId != 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()
@@ -546,6 +712,14 @@ else
{
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
{
secrets = null;
@@ -721,4 +895,58 @@ else
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.Layout
@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 string Summary { get; set; } = "";
public string Description { get; set; } = "";
public string RunbookUrl { get; set; } = "";
public string LabelsJson { get; set; } = "{}";
public DateTime StartsAt { get; set; }
public DateTime? EndsAt { get; set; }
@@ -18,6 +19,9 @@ public class AlertIncident
public string? AcknowledgedBy { get; set; }
public DateTime? AcknowledgedAt { 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 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<VaultSecret> Secrets { 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 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) ──
/// <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>
public Guid? GitRepositoryId { get; set; }
@@ -132,4 +138,5 @@ public class AppDeployment
public ICollection<StorageBinding> StorageBindings { get; set; } = [];
public ICollection<DatabaseBinding> DatabaseBindings { 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;
/// <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
public App App { 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 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>
public required string Name { get; set; }
@@ -41,4 +44,5 @@ public class AppNetworkPolicy
// Navigation
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;
/// <summary>Which environment this quota applies to. One quota per app per environment.</summary>
public Guid EnvironmentId { get; set; }
// Navigation
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 AppId { get; set; }
/// <summary>Which environment this RBAC policy applies to. One policy per app per environment.</summary>
public Guid EnvironmentId { get; set; }
/// <summary>
/// The Kubernetes ServiceAccount name, e.g. "billing-api".
/// Must be a valid DNS label.
@@ -26,6 +29,7 @@ public class AppRbacPolicy
// Navigation
public App App { get; set; } = null!;
public Environment Environment { get; set; } = null!;
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<SecretVault> SecretVaults => Set<SecretVault>();
public DbSet<VaultSecret> VaultSecrets => Set<VaultSecret>();
public DbSet<VaultSecretVersion> VaultSecretVersions => Set<VaultSecretVersion>();
public DbSet<ClusterComponent> ClusterComponents => Set<ClusterComponent>();
public DbSet<ExternalRoute> ExternalRoutes => Set<ExternalRoute>();
public DbSet<StorageLink> StorageLinks => Set<StorageLink>();
@@ -69,10 +70,22 @@ public class ApplicationDbContext(DbContextOptions options) : IdentityDbContext<
public DbSet<CacheBinding> CacheBindings => Set<CacheBinding>();
public DbSet<GitRepository> GitRepositories => Set<GitRepository>();
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<AppNetworkPolicy> AppNetworkPolicies => Set<AppNetworkPolicy>();
public DbSet<AppRbacPolicy> AppRbacPolicies => Set<AppRbacPolicy>();
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)
{
@@ -339,6 +352,22 @@ public class ApplicationDbContext(DbContextOptions options) : IdentityDbContext<
.WithMany()
.HasForeignKey(s => s.RedisClusterId)
.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.
@@ -991,8 +1020,11 @@ public class ApplicationDbContext(DbContextOptions options) : IdentityDbContext<
entity.Property(i => i.Severity).HasMaxLength(20).IsRequired();
entity.Property(i => i.Summary).HasMaxLength(500);
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.AssignedTo).HasMaxLength(256);
entity.Property(i => i.Status).HasConversion<string>().HasMaxLength(20);
entity.HasIndex(i => i.EscalatedAt);
entity.HasOne(i => i.Cluster)
.WithMany()
@@ -1208,33 +1240,89 @@ public class ApplicationDbContext(DbContextOptions options) : IdentityDbContext<
.HasForeignKey(s => s.GitRepositoryId)
.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.
builder.Entity<App>(entity =>
{
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 =>
{
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.CpuLimit).HasMaxLength(20);
entity.Property(q => q.MemoryRequest).HasMaxLength(20);
entity.Property(q => q.MemoryLimit).HasMaxLength(20);
entity.HasOne(q => q.App)
.WithOne(a => a.Quota)
.HasForeignKey<AppQuota>(q => q.AppId)
.WithMany(a => a.Quotas)
.HasForeignKey(q => q.AppId)
.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 =>
{
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.PolicyType).HasConversion<string>().HasMaxLength(30);
entity.Property(p => p.AllowFromNamespace).HasMaxLength(63);
@@ -1243,19 +1331,29 @@ public class ApplicationDbContext(DbContextOptions options) : IdentityDbContext<
.WithMany(a => a.NetworkPolicies)
.HasForeignKey(p => p.AppId)
.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 =>
{
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.HasOne(p => p.App)
.WithOne(a => a.RbacPolicy)
.HasForeignKey<AppRbacPolicy>(p => p.AppId)
.WithMany(a => a.RbacPolicies)
.HasForeignKey(p => p.AppId)
.OnDelete(DeleteBehavior.Cascade);
entity.HasOne(p => p.Environment)
.WithMany()
.HasForeignKey(p => p.EnvironmentId)
.OnDelete(DeleteBehavior.Restrict);
});
// AppRbacRule — many per AppRbacPolicy.
@@ -1288,6 +1386,11 @@ public class ApplicationDbContext(DbContextOptions options) : IdentityDbContext<
.WithMany(t => t.GitRepositories)
.HasForeignKey(r => r.TenantId)
.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).
@@ -1324,6 +1427,215 @@ public class ApplicationDbContext(DbContextOptions options) : IdentityDbContext<
.HasForeignKey(d => d.ParentDeploymentId)
.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
public Tenant Tenant { get; set; } = null!;
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;
/// <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
public Tenant Tenant { get; set; } = null!;
public CustomerGitCredential? CustomerGitCredential { get; set; }
public ICollection<AppDeployment> Deployments { get; set; } = [];
public ICollection<GitKnownHost> KnownHosts { get; set; } = [];
}

View File

@@ -42,4 +42,5 @@ public class KubernetesCluster
public Tenant Tenant { get; set; } = null!;
public Environment Environment { get; set; } = null!;
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;
#nullable disable
@@ -11,34 +11,9 @@ namespace EntKube.Web.Data.Migrations.Postgres
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_KeycloakRealms_KeycloakConnections_KeycloakConnectionId",
table: "KeycloakRealms");
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");
// The migration that originally created KeycloakConnections and KeycloakRealms
// was removed from history. This migration now creates both tables from scratch
// using the final schema (post-rename), so fresh databases work correctly.
migrationBuilder.CreateTable(
name: "KeycloakComponentConfigs",
@@ -75,6 +50,38 @@ namespace EntKube.Web.Data.Migrations.Postgres
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(
name: "IX_KeycloakComponentConfigs_ClusterComponentId",
table: "KeycloakComponentConfigs",
@@ -90,111 +97,23 @@ namespace EntKube.Web.Data.Migrations.Postgres
table: "KeycloakComponentConfigs",
column: "TenantId");
migrationBuilder.AddForeignKey(
name: "FK_KeycloakRealms_KeycloakComponentConfigs_KeycloakComponentCo~",
migrationBuilder.CreateIndex(
name: "IX_KeycloakRealms_LinkedAppId",
table: "KeycloakRealms",
column: "KeycloakComponentConfigId",
principalTable: "KeycloakComponentConfigs",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
column: "LinkedAppId");
migrationBuilder.CreateIndex(
name: "IX_KeycloakRealms_KeycloakComponentConfigId_RealmName",
table: "KeycloakRealms",
columns: new[] { "KeycloakComponentConfigId", "RealmName" },
unique: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_KeycloakRealms_KeycloakComponentConfigs_KeycloakComponentCo~",
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);
migrationBuilder.DropTable(name: "KeycloakRealms");
migrationBuilder.DropTable(name: "KeycloakComponentConfigs");
}
}
}

View File

@@ -1,4 +1,4 @@
using System;
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
@@ -11,51 +11,56 @@ namespace EntKube.Web.Data.Migrations.Postgres
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_KeycloakBackups_StorageLinks_StorageLinkId",
table: "KeycloakBackups");
// 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",
column: x => x.StorageLinkId,
principalTable: "StorageLinks",
principalColumn: "Id",
onDelete: ReferentialAction.SetNull);
});
migrationBuilder.AlterColumn<Guid>(
name: "StorageLinkId",
migrationBuilder.CreateIndex(
name: "IX_KeycloakBackups_KeycloakRealmId",
table: "KeycloakBackups",
type: "uuid",
nullable: true,
oldClrType: typeof(Guid),
oldType: "uuid");
column: "KeycloakRealmId");
migrationBuilder.AddForeignKey(
name: "FK_KeycloakBackups_StorageLinks_StorageLinkId",
migrationBuilder.CreateIndex(
name: "IX_KeycloakBackups_StorageLinkId",
table: "KeycloakBackups",
column: "StorageLinkId",
principalTable: "StorageLinks",
principalColumn: "Id",
onDelete: ReferentialAction.SetNull);
column: "StorageLinkId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
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);
migrationBuilder.DropTable(name: "KeycloakBackups");
}
}
}

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