Compare commits
5 Commits
658f15d086
...
main
| Author | SHA256 | Date | |
|---|---|---|---|
|
|
5da40698ad | ||
|
|
343de77810 | ||
|
|
ffe41b4413 | ||
|
|
76b7e55931 | ||
|
|
8dc46ef031 |
7
.claude/settings.json
Normal file
7
.claude/settings.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(grep -E \"\\\\.\\(cs|razor\\)$\")"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@
|
||||
**/*.user
|
||||
**/*.dbmdl
|
||||
**/*.jfm
|
||||
**/*.db
|
||||
**/secrets.dev.yaml
|
||||
**/values.dev.yaml
|
||||
**/Tiltfile
|
||||
|
||||
26
.env.example
Normal file
26
.env.example
Normal 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
69
.github/workflows/deploy.yml
vendored
Normal 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
6
.gitignore
vendored
@@ -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
7
Caddyfile
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
email {env.ACME_EMAIL}
|
||||
}
|
||||
|
||||
{env.DOMAIN} {
|
||||
reverse_proxy entkube:8080
|
||||
}
|
||||
37
Dockerfile
37
Dockerfile
@@ -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"]
|
||||
|
||||
53
README.md
53
README.md
@@ -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
81
docker-compose.yml
Normal 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:
|
||||
@@ -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>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
@using Microsoft.AspNetCore.Authorization
|
||||
|
||||
@attribute [Authorize]
|
||||
@rendermode InteractiveAuto
|
||||
@rendermode InteractiveServer
|
||||
|
||||
<PageTitle>Auth</PageTitle>
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
@page "/counter"
|
||||
@rendermode InteractiveAuto
|
||||
@rendermode InteractiveServer
|
||||
|
||||
<PageTitle>Counter</PageTitle>
|
||||
|
||||
|
||||
@@ -1,17 +1,8 @@
|
||||
using Microsoft.AspNetCore.Components.WebAssembly.Hosting;
|
||||
|
||||
namespace EntKube.Web.Client;
|
||||
|
||||
class Program
|
||||
{
|
||||
static async Task Main(string[] args)
|
||||
{
|
||||
var builder = WebAssemblyHostBuilder.CreateDefault(args);
|
||||
|
||||
builder.Services.AddAuthorizationCore();
|
||||
builder.Services.AddCascadingAuthenticationState();
|
||||
builder.Services.AddAuthenticationStateDeserialization();
|
||||
|
||||
await builder.Build().RunAsync();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,9 +14,17 @@
|
||||
@inject ILogger<Register> Logger
|
||||
@inject NavigationManager NavigationManager
|
||||
@inject IdentityRedirectManager RedirectManager
|
||||
@inject IConfiguration Configuration
|
||||
|
||||
<PageTitle>Register</PageTitle>
|
||||
|
||||
@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">
|
||||
@@ -53,9 +61,11 @@
|
||||
</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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
@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;
|
||||
|
||||
|
||||
@@ -4,8 +4,7 @@
|
||||
@rendermode InteractiveServer
|
||||
@attribute [Authorize(Roles = "Admin")]
|
||||
|
||||
@inject BackupService BackupService
|
||||
@inject AuthenticationStateProvider AuthStateProvider
|
||||
@inject NavigationManager Nav
|
||||
|
||||
<PageTitle>Backup & Restore</PageTitle>
|
||||
|
||||
@@ -41,16 +40,19 @@
|
||||
<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>
|
||||
|
||||
<form action="/api/admin/restore" method="post" enctype="multipart/form-data">
|
||||
<div class="mb-3">
|
||||
<InputFile OnChange="OnFileSelected" accept=".gz" class="form-control" />
|
||||
<input type="file" name="bundle" class="form-control" required />
|
||||
</div>
|
||||
|
||||
<div class="form-check mb-3">
|
||||
<input class="form-check-input" type="checkbox" id="wipeCheck" @bind="wipeExisting" />
|
||||
@* name+value on the checkbox so the browser submits "wipe=true" when it is checked *@
|
||||
<input class="form-check-input" type="checkbox" name="wipe" value="true" id="wipeCheck"
|
||||
@onchange="e => wipeExisting = (bool)(e.Value ?? false)" />
|
||||
<label class="form-check-label" for="wipeCheck">
|
||||
Wipe all existing data before restoring
|
||||
</label>
|
||||
@@ -66,19 +68,10 @@
|
||||
</div>
|
||||
}
|
||||
|
||||
<button class="btn btn-warning" @onclick="RunRestore"
|
||||
disabled="@(selectedFile is null || restoring)">
|
||||
@if (restoring)
|
||||
{
|
||||
<span class="spinner-border spinner-border-sm me-1" role="status"></span>
|
||||
<span>Restoring…</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<i class="bi bi-upload me-1"></i>
|
||||
<span>Restore</span>
|
||||
}
|
||||
<button 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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
365
src/EntKube.Web/Components/Pages/Admin/AdminRoles.razor
Normal file
365
src/EntKube.Web/Components/Pages/Admin/AdminRoles.razor
Normal 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);
|
||||
});
|
||||
}
|
||||
}
|
||||
497
src/EntKube.Web/Components/Pages/Admin/AdminUserDetail.razor
Normal file
497
src/EntKube.Web/Components/Pages/Admin/AdminUserDetail.razor
Normal 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");
|
||||
}
|
||||
}
|
||||
153
src/EntKube.Web/Components/Pages/Admin/AdminUsers.razor
Normal file
153
src/EntKube.Web/Components/Pages/Admin/AdminUsers.razor
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,28 +1,23 @@
|
||||
@page "/Error"
|
||||
@page "/Error"
|
||||
@using System.Diagnostics
|
||||
|
||||
<PageTitle>Error</PageTitle>
|
||||
|
||||
<h1 class="text-danger">Error.</h1>
|
||||
<h2 class="text-danger">An error occurred while processing your request.</h2>
|
||||
<PageTitle>Error — EntKube</PageTitle>
|
||||
|
||||
<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>
|
||||
<strong>Request ID:</strong> <code>@RequestId</code>
|
||||
</p>
|
||||
<p class="text-muted small mb-4">Request ID: <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>
|
||||
<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>
|
||||
|
||||
@code {
|
||||
[CascadingParameter]
|
||||
|
||||
@@ -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="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>
|
||||
|
||||
<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>
|
||||
<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 & 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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 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 "—";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,147 +172,206 @@ 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>
|
||||
}
|
||||
<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)
|
||||
{
|
||||
<button class="btn btn-sm btn-outline-primary"
|
||||
@onclick="() => SelectIdentityRealm(app.Id)">
|
||||
<i class="bi bi-shield-lock me-1"></i>Identity
|
||||
<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)
|
||||
{
|
||||
<button class="btn btn-sm btn-outline-primary"
|
||||
@onclick="() => SelectRegistryProject(app.Id)">
|
||||
<i class="bi bi-archive me-1"></i>Registry
|
||||
<li>
|
||||
<button class="dropdown-item" @onclick="() => SelectRegistryProject(app.Id)">
|
||||
<i class="bi bi-archive me-2 text-primary"></i>Registry
|
||||
</button>
|
||||
</li>
|
||||
}
|
||||
<button class="btn btn-sm btn-outline-secondary"
|
||||
@onclick="() => SelectSecretsApp(app)">
|
||||
<i class="bi bi-key me-1"></i>Secrets
|
||||
<li>
|
||||
<button class="dropdown-item" @onclick="() => SelectRoutesApp(app)">
|
||||
<i class="bi bi-globe me-2 text-primary"></i>URL Configuration
|
||||
</button>
|
||||
<button class="btn btn-sm btn-outline-secondary"
|
||||
@onclick="() => SelectGovernanceApp(app)">
|
||||
<i class="bi bi-shield-check me-1"></i>Policy
|
||||
</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>
|
||||
@if (editingAppId == app.Id)
|
||||
{
|
||||
<div class="px-3 py-2 border-top">
|
||||
<div class="d-flex gap-2 align-items-center flex-wrap">
|
||||
<input class="form-control form-control-sm" style="max-width:280px"
|
||||
@bind="editAppName" @bind:event="oninput"
|
||||
@onkeyup="OnEditAppNameKeyUp" />
|
||||
<button class="btn btn-sm btn-primary" @onclick="SaveAppName" disabled="@savingAppName">
|
||||
@if (savingAppName) { <span class="spinner-border spinner-border-sm me-1"></span> }
|
||||
Save
|
||||
</button>
|
||||
<button class="btn btn-sm btn-outline-secondary" @onclick="CancelEditApp" disabled="@savingAppName">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@if (deletingAppId == app.Id)
|
||||
{
|
||||
<div class="px-3 py-2 border-top">
|
||||
@if (deleteAppError is not null)
|
||||
{
|
||||
<div class="alert alert-danger py-1 small mb-2">
|
||||
<i class="bi bi-exclamation-triangle me-1"></i>@deleteAppError
|
||||
</div>
|
||||
}
|
||||
<ConfirmDialog Visible="true"
|
||||
Message="@($"Permanently delete {app.Name} and all its deployments and cluster resources?")"
|
||||
ConfirmText="Yes, delete"
|
||||
IsBusy="deletingApp"
|
||||
OnConfirm="() => ConfirmDeleteApp(app.Id)"
|
||||
OnCancel="CancelDeleteApp" />
|
||||
</div>
|
||||
}
|
||||
<div class="card-body">
|
||||
@if (showingCreateForm)
|
||||
@* ── Environment cards ── *@
|
||||
@if (appLinkedEnvironments.TryGetValue(app.Id, out List<Data.Environment>? linkedEnvs) && linkedEnvs.Count > 0)
|
||||
{
|
||||
<div class="card border-primary mb-3">
|
||||
<div class="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>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (appDeployments.TryGetValue(app.Id, out List<AppDeployment>? deploys) && deploys.Count > 0)
|
||||
{
|
||||
<div class="row row-cols-1 row-cols-lg-2 g-2">
|
||||
@foreach (AppDeployment deploy in deploys)
|
||||
<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)
|
||||
<i class="bi bi-chevron-right text-muted small"></i>
|
||||
</div>
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
else if (!showingCreateForm)
|
||||
<div class="small text-muted">
|
||||
@if (deployCount > 0)
|
||||
{
|
||||
<p class="text-muted small mb-0">No deployments configured.</p>
|
||||
<span><i class="bi bi-rocket-takeoff me-1"></i>@deployCount deployment@(deployCount == 1 ? "" : "s")</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="fst-italic">No deployments</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<EmptyState Icon="bi-layers"
|
||||
Message="No environments linked to this app yet." />
|
||||
}
|
||||
|
||||
@* ── External URLs ── *@
|
||||
@if (appRoutes.TryGetValue(app.Id, out List<AppRoute>? routes) && routes.Count > 0)
|
||||
{
|
||||
<div class="mt-3 pt-2 border-top">
|
||||
<div class="small text-muted mb-1">
|
||||
<i class="bi bi-globe me-1"></i>External URLs
|
||||
</div>
|
||||
<div class="d-flex flex-wrap gap-2">
|
||||
@foreach (AppRoute route in routes.Where(r => r.IsEnabled))
|
||||
{
|
||||
string href = $"https://{route.Hostname}";
|
||||
bool allUp = route.DeploymentRoutes.Any() && route.DeploymentRoutes.All(dr => dr.IsReachable == true);
|
||||
bool anyDown = route.DeploymentRoutes.Any(dr => dr.IsReachable == false);
|
||||
string statusClass = allUp ? "text-success" : anyDown ? "text-danger" : "text-muted";
|
||||
string statusIcon = allUp ? "bi-check-circle-fill" : anyDown ? "bi-x-circle-fill" : "bi-circle";
|
||||
string tlsTitle = route.TlsMode == TlsMode.ClusterIssuer
|
||||
? $"TLS: auto ({route.ClusterIssuerName})"
|
||||
: "TLS: manual certificate";
|
||||
|
||||
<div class="d-flex align-items-center gap-1 border rounded px-2 py-1 bg-light"
|
||||
title="@tlsTitle">
|
||||
<i class="bi @statusIcon @statusClass small"></i>
|
||||
<a href="@href" target="_blank" rel="noopener noreferrer"
|
||||
class="small text-decoration-none fw-medium">
|
||||
@route.Hostname
|
||||
</a>
|
||||
<i class="bi bi-lock-fill text-success small" title="@tlsTitle"></i>
|
||||
@if (route.DeploymentRoutes.Count(dr => dr.IsEnabled) > 1)
|
||||
{
|
||||
<span class="badge bg-secondary bg-opacity-50 small" title="Multiple deployment paths">
|
||||
@route.DeploymentRoutes.Count(dr => dr.IsEnabled)
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
@if (routes.Any(r => r.IsEnabled && r.DeploymentRoutes.Any(dr => dr.IsEnabled && dr.PathPrefix != "/")))
|
||||
{
|
||||
<div class="mt-1">
|
||||
@foreach (AppRoute route in routes.Where(r => r.IsEnabled))
|
||||
{
|
||||
@foreach (AppDeploymentRoute dr in route.DeploymentRoutes.Where(dr => dr.IsEnabled && dr.PathPrefix != "/").OrderBy(dr => dr.PathPrefix))
|
||||
{
|
||||
<span class="badge bg-light text-dark border me-1 small" title="@(dr.AppDeployment?.Environment?.Name)">
|
||||
<i class="bi bi-link-45deg me-1"></i>@dr.PathPrefix
|
||||
<span class="text-muted ms-1">(@(dr.AppDeployment?.Environment?.Name ?? "unknown"))</span>
|
||||
</span>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
@@ -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>
|
||||
};
|
||||
|
||||
|
||||
@@ -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 = [];
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
};
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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,7 +192,8 @@
|
||||
<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 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>
|
||||
@@ -199,24 +204,32 @@
|
||||
}
|
||||
</div>
|
||||
<button class="btn btn-sm btn-outline-danger border-0"
|
||||
@onclick="() => DeleteIdp(idp.Alias)">
|
||||
@onclick="() => confirmDeleteIdpAlias = idp.Alias">
|
||||
<i class="bi bi-trash"></i>
|
||||
</button>
|
||||
</div>
|
||||
@if (confirmDeleteIdpAlias == idp.Alias)
|
||||
{
|
||||
<ConfirmDialog Visible="true"
|
||||
Message="@($"Delete identity provider '{idp.Alias}'?")"
|
||||
ConfirmText="Delete"
|
||||
IsBusy="deletingItem"
|
||||
OnConfirm="() => DeleteIdp(idp.Alias)"
|
||||
OnCancel="() => confirmDeleteIdpAlias = null" />
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</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">
|
||||
<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="() => DeleteGroup(g.Id)">
|
||||
@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()
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
@@ -32,6 +51,11 @@
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
@* ════════════════════════════════════════════════════════════════
|
||||
Tab: App Secrets
|
||||
════════════════════════════════════════════════════════════════ *@
|
||||
@if (activeScope == "app" || activeScope == "")
|
||||
{
|
||||
@* ── Add Secret form (Admin only) ── *@
|
||||
@if (AccessRole >= CustomerAccessRole.Admin)
|
||||
@@ -102,24 +126,13 @@ else
|
||||
}
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
@if (secrets is null)
|
||||
<LoadingPanel Loading="@(secrets is null)">
|
||||
@if (secrets is not null && secrets.Count == 0)
|
||||
{
|
||||
<div class="text-center py-4">
|
||||
<div class="spinner-border spinner-border-sm text-primary" role="status"></div>
|
||||
</div>
|
||||
<EmptyState Icon="bi-lock"
|
||||
Message="@(AccessRole >= CustomerAccessRole.Admin ? "No secrets stored yet. Use the form above to add one." : "No secrets stored for this app yet.")" />
|
||||
}
|
||||
else if (secrets.Count == 0)
|
||||
{
|
||||
<div class="text-center py-5">
|
||||
<i class="bi bi-lock text-muted" style="font-size: 2rem;"></i>
|
||||
<p class="text-muted small mt-2 mb-0">No secrets stored for this app yet.</p>
|
||||
@if (AccessRole >= CustomerAccessRole.Admin)
|
||||
{
|
||||
<p class="text-muted small mb-0">Use the form above to add one.</p>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
else
|
||||
else if (secrets is not null)
|
||||
{
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover align-middle mb-0">
|
||||
@@ -152,34 +165,49 @@ else
|
||||
<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-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="() => DeleteSecret(secret.Id)" title="Delete secret">
|
||||
@onclick="() => confirmDeleteSecretId = secret.Id" title="Delete secret">
|
||||
<i class="bi bi-trash"></i>
|
||||
</button>
|
||||
}
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@* ── Reveal value row ── *@
|
||||
@if (confirmDeleteSecretId == secret.Id)
|
||||
{
|
||||
<tr class="table-danger table-sm">
|
||||
<td colspan="6" class="px-3 py-2">
|
||||
<ConfirmDialog Visible="true"
|
||||
Message="@($"Permanently delete secret '{secret.Name}'?")"
|
||||
ConfirmText="Delete"
|
||||
IsBusy="deletingSecret"
|
||||
OnConfirm="() => DeleteSecret(secret.Id)"
|
||||
OnCancel="() => confirmDeleteSecretId = null" />
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
|
||||
@if (revealedSecretId == secret.Id)
|
||||
{
|
||||
<tr class="table-light">
|
||||
@@ -199,7 +227,6 @@ else
|
||||
</tr>
|
||||
}
|
||||
|
||||
@* ── Edit value row ── *@
|
||||
@if (editSecretId == secret.Id)
|
||||
{
|
||||
<tr class="table-light">
|
||||
@@ -225,33 +252,34 @@ else
|
||||
</tr>
|
||||
}
|
||||
|
||||
@* ── K8s sync config row ── *@
|
||||
@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">
|
||||
@if (clusters is not null)
|
||||
{
|
||||
<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 (e.g. my-app-secrets)"
|
||||
placeholder="K8s Secret name"
|
||||
@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" />
|
||||
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">
|
||||
@@ -263,6 +291,87 @@ else
|
||||
</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>
|
||||
}
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
|
||||
@* Version history row *@
|
||||
@if (historySecretId == secret.Id)
|
||||
{
|
||||
<tr>
|
||||
<td colspan="6" class="bg-light p-0">
|
||||
<div class="p-3">
|
||||
<div class="d-flex align-items-center mb-2">
|
||||
<i class="bi bi-clock-history me-2 text-secondary"></i>
|
||||
<strong class="small">Version History</strong>
|
||||
<span class="badge bg-secondary ms-2">@(secretVersions?.Count ?? 0)</span>
|
||||
</div>
|
||||
@if (secretVersions is null)
|
||||
{
|
||||
<div class="spinner-border spinner-border-sm text-secondary" role="status"></div>
|
||||
}
|
||||
else if (secretVersions.Count == 0)
|
||||
{
|
||||
<p class="text-muted small mb-0">No previous versions recorded.</p>
|
||||
}
|
||||
else
|
||||
{
|
||||
<table class="table table-sm table-bordered mb-0">
|
||||
<thead class="table-secondary">
|
||||
<tr>
|
||||
<th style="width: 60px;">#</th>
|
||||
<th>Set by</th>
|
||||
<th>Date</th>
|
||||
<th class="text-end" style="width: 160px;">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (VaultSecretVersion ver in secretVersions)
|
||||
{
|
||||
<tr>
|
||||
<td><span class="badge bg-secondary">v@ver.VersionNumber</span></td>
|
||||
<td><small class="text-muted">@(ver.CreatedBy ?? "—")</small></td>
|
||||
<td><small class="text-muted">@ver.CreatedAt.ToString("MMM d, HH:mm")</small></td>
|
||||
<td class="text-end">
|
||||
<button class="btn btn-xs btn-outline-secondary me-1"
|
||||
@onclick="() => RevealVersion(secret.Id, ver)"
|
||||
title="@(revealedVersionId == ver.Id ? "Hide" : "Reveal value")">
|
||||
<i class="bi @(revealedVersionId == ver.Id ? "bi-eye-slash" : "bi-eye")"></i>
|
||||
</button>
|
||||
<button class="btn btn-xs btn-outline-warning"
|
||||
@onclick="() => RollbackToVersion(secret.Id, ver.Id)"
|
||||
title="Restore this value">
|
||||
<i class="bi bi-arrow-counterclockwise me-1"></i>Restore
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
@if (revealedVersionId == ver.Id)
|
||||
{
|
||||
<tr class="table-light">
|
||||
<td colspan="4" class="px-2 py-1">
|
||||
<span class="text-muted small me-2">Value:</span>
|
||||
@if (versionRevealLoading)
|
||||
{
|
||||
<span class="spinner-border spinner-border-sm"></span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<code class="user-select-all">@revealedVersionValue</code>
|
||||
}
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
@@ -271,82 +380,140 @@ else
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
</LoadingPanel>
|
||||
@if (syncOutput is not null)
|
||||
{
|
||||
<div class="m-3 p-2 bg-dark text-white rounded font-monospace small" style="white-space: pre-wrap; max-height: 200px; overflow-y: auto;">@syncOutput</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@* ── 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>
|
||||
</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);
|
||||
|
||||
HashSet<Guid> appEnvIds = appEnvs.Select(ae => ae.EnvironmentId).ToHashSet();
|
||||
appEnvironmentClusters = (await TenantService.GetClustersAsync(TenantId))
|
||||
.Where(c => appEnvIds.Contains(c.EnvironmentId))
|
||||
.ToList();
|
||||
|
||||
if (!vaultInitialized) return;
|
||||
|
||||
await LoadScopeData(activeScope);
|
||||
}
|
||||
|
||||
// Load clusters for the K8s sync config dropdown.
|
||||
clusters = await TenantService.GetClustersAsync(TenantId);
|
||||
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,8 +631,48 @@ 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)
|
||||
{
|
||||
@@ -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.";
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
62
src/EntKube.Web/Components/Pages/Shared/Breadcrumb.razor
Normal file
62
src/EntKube.Web/Components/Pages/Shared/Breadcrumb.razor
Normal 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);
|
||||
}
|
||||
27
src/EntKube.Web/Components/Pages/Shared/ConfirmDialog.razor
Normal file
27
src/EntKube.Web/Components/Pages/Shared/ConfirmDialog.razor
Normal 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; }
|
||||
}
|
||||
25
src/EntKube.Web/Components/Pages/Shared/EmptyState.razor
Normal file
25
src/EntKube.Web/Components/Pages/Shared/EmptyState.razor
Normal 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; }
|
||||
}
|
||||
27
src/EntKube.Web/Components/Pages/Shared/LoadingPanel.razor
Normal file
27
src/EntKube.Web/Components/Pages/Shared/LoadingPanel.razor
Normal 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
108
src/EntKube.Web/Components/Pages/Shared/YamlEditor.razor
Normal file
108
src/EntKube.Web/Components/Pages/Shared/YamlEditor.razor
Normal 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();
|
||||
}
|
||||
}
|
||||
43
src/EntKube.Web/Components/Pages/Shared/YamlEditor.razor.css
Normal file
43
src/EntKube.Web/Components/Pages/Shared/YamlEditor.razor.css
Normal 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;
|
||||
}
|
||||
100
src/EntKube.Web/Components/Pages/Shared/YamlEditor.razor.js
Normal file
100
src/EntKube.Web/Components/Pages/Shared/YamlEditor.razor.js
Normal 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();
|
||||
}
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
481
src/EntKube.Web/Components/Pages/Tenants/AlertRoutingTab.razor
Normal file
481
src/EntKube.Web/Components/Pages/Tenants/AlertRoutingTab.razor
Normal 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
2910
src/EntKube.Web/Components/Pages/Tenants/AppEnvironmentDetail.razor
Normal file
2910
src/EntKube.Web/Components/Pages/Tenants/AppEnvironmentDetail.razor
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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();
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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)
|
||||
@if (selectedDeployment is null)
|
||||
{
|
||||
@for (int i = 0; i < 3; i++)
|
||||
@* ── Level 1: deployment card grid ── *@
|
||||
<LoadingPanel Loading="@(deployments is null)" LoadingText="Loading deployments…">
|
||||
@if (deployments is not null && deployments.Count == 0)
|
||||
{
|
||||
<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>
|
||||
<EmptyState Icon="bi-rocket-takeoff" Message="No deployments yet." />
|
||||
}
|
||||
}
|
||||
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
|
||||
else if (deployments is not null)
|
||||
{
|
||||
<div class="app-grid">
|
||||
@foreach (AppDeployment d in deployments)
|
||||
{
|
||||
string hc = HealthColor(d.HealthStatus);
|
||||
bool open = expandedIds.Contains(d.Id);
|
||||
string hlabel = HealthLabel(d.HealthStatus);
|
||||
var (scol, sico, slabel) = SyncInfo(d.SyncStatus);
|
||||
|
||||
<div class="app-card @(open ? "expanded" : "")"
|
||||
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 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 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
|
||||
@TypeLabel(d.Type) · @d.Environment?.Name · <code style="font-size:.67rem;">@d.Namespace</code>
|
||||
</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
|
||||
<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>
|
||||
<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)
|
||||
@if (d.GitLastSyncedCommit is not null)
|
||||
{
|
||||
<span class="badge bg-secondary" style="font-size:.65rem;">
|
||||
<span class="badge bg-secondary" style="font-size:.62rem;">
|
||||
@d.GitLastSyncedCommit[..7]
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (d.Cluster is not null)
|
||||
{
|
||||
<div style="font-size:.72rem; color:#6c757d;">
|
||||
<div class="mt-1" style="font-size:.69rem;color:#6c757d;">
|
||||
<i class="bi bi-hdd-network me-1"></i>@d.Cluster.Name
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@* ── Expanded resource trees ─────────────────────────────────────────────── *@
|
||||
@if (deployments is not null)
|
||||
{
|
||||
@foreach (AppDeployment d in deployments.Where(d => expandedIds.Contains(d.Id)))
|
||||
{
|
||||
<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))
|
||||
{
|
||||
<div class="spinner-border spinner-border-sm text-primary" style="width:.9rem;height:.9rem;"></div>
|
||||
}
|
||||
</div>
|
||||
<button class="btn btn-sm btn-link text-muted p-0" @onclick="() => expandedIds.Remove(d.Id)" @onclick:stopPropagation>
|
||||
<i class="bi bi-x-lg"></i>
|
||||
</button>
|
||||
</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>
|
||||
</LoadingPanel>
|
||||
}
|
||||
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" />
|
||||
@* ── 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
|
||||
{
|
||||
@@ -315,9 +310,7 @@
|
||||
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",
|
||||
DeploymentType.GitYaml or DeploymentType.GitHelm or DeploymentType.GitAppOfApps => "bi-git",
|
||||
_ => "bi-rocket-takeoff",
|
||||
};
|
||||
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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)
|
||||
else if (compDetailTab == "values")
|
||||
{
|
||||
<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 & 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 & Apply
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
<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 & 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 & 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;
|
||||
}
|
||||
|
||||
848
src/EntKube.Web/Components/Pages/Tenants/ClusterNodes.razor
Normal file
848
src/EntKube.Web/Components/Pages/Tenants/ClusterNodes.razor
Normal 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. *@
|
||||
@@ -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>
|
||||
@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="() => RevokeAccess(access.UserId)">
|
||||
@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();
|
||||
}
|
||||
|
||||
@@ -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,6 +2669,12 @@ else
|
||||
<tr>
|
||||
<td colspan="4" class="bg-light p-0">
|
||||
<div class="p-2">
|
||||
@if (mongoDbCredentials.Count == 0)
|
||||
{
|
||||
<div class="text-muted small">No credentials found in vault for this database.</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<table class="table table-sm table-borderless mb-1" style="font-size:0.75rem;">
|
||||
<tbody>
|
||||
@foreach (KeyValuePair<string, string> cred in mongoDbCredentials)
|
||||
@@ -2667,6 +2686,10 @@ else
|
||||
}
|
||||
</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)
|
||||
{
|
||||
|
||||
@@ -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 kind: Config clusters: - cluster: 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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
<button class="btn btn-sm btn-success" @onclick="ApplyToAllClusters" disabled="@(applying || string.IsNullOrWhiteSpace(ns))">
|
||||
<div class="d-flex gap-2 align-items-center">
|
||||
<button class="btn btn-sm btn-outline-secondary" @onclick="OpenCopyFrom">
|
||||
<i class="bi bi-copy me-1"></i>Copy from env
|
||||
</button>
|
||||
<button class="btn btn-sm btn-success" @onclick="ApplyToEnvironmentClusters" disabled="@applying">
|
||||
@if (applying) { <span class="spinner-border spinner-border-sm me-1"></span> }
|
||||
else { <i class="bi bi-cloud-upload me-1"></i> }
|
||||
Apply to clusters
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@* ── Copy from environment ── *@
|
||||
@if (showCopyFrom)
|
||||
{
|
||||
<div class="card border-info mb-3">
|
||||
<div class="card-body py-2">
|
||||
<div class="d-flex align-items-center gap-2 flex-wrap">
|
||||
<i class="bi bi-copy text-info"></i>
|
||||
<span class="small fw-medium">Copy governance from:</span>
|
||||
<select class="form-select form-select-sm w-auto" @bind="copyFromEnvId">
|
||||
<option value="@Guid.Empty">Select source environment…</option>
|
||||
@if (otherEnvironments is not null)
|
||||
{
|
||||
@foreach (Data.Environment env in otherEnvironments)
|
||||
{
|
||||
<option value="@env.Id">@env.Name</option>
|
||||
}
|
||||
}
|
||||
</select>
|
||||
@if (!confirmCopy)
|
||||
{
|
||||
<button class="btn btn-sm btn-info" @onclick="() => confirmCopy = true"
|
||||
disabled="@(copyFromEnvId == Guid.Empty)">
|
||||
Copy
|
||||
</button>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="small text-info">This will overwrite all governance in <strong>this environment</strong>.</span>
|
||||
<button class="btn btn-sm btn-danger" @onclick="DoCopyFrom" disabled="@copying">
|
||||
@if (copying) { <span class="spinner-border spinner-border-sm me-1"></span> }
|
||||
Yes, overwrite
|
||||
</button>
|
||||
<button class="btn btn-sm btn-outline-secondary" @onclick="() => confirmCopy = false">Cancel</button>
|
||||
}
|
||||
<button class="btn btn-sm btn-link text-muted p-0 ms-auto" @onclick="() => showCopyFrom = false">
|
||||
<i class="bi bi-x"></i>
|
||||
</button>
|
||||
</div>
|
||||
@if (copyMessage is not null)
|
||||
{
|
||||
<div class="mt-1 small @(copyOk ? "text-success" : "text-danger")">
|
||||
<i class="bi @(copyOk ? "bi-check-circle" : "bi-x-circle") me-1"></i>@copyMessage
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (applyOutput is not null)
|
||||
{
|
||||
<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">
|
||||
}
|
||||
<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>
|
||||
</div>
|
||||
@if (nsMessage is not null)
|
||||
@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>
|
||||
<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>
|
||||
</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="1–2 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 kind: NetworkPolicy ..." />
|
||||
<YamlEditor @bind-Value="newNpCustomYaml" />
|
||||
</div>
|
||||
}
|
||||
<div class="d-flex gap-1">
|
||||
@@ -253,15 +326,13 @@ 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>
|
||||
@* ── 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>
|
||||
<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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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">
|
||||
<td colspan="8" class="bg-light px-4 py-3">
|
||||
<div class="row g-4">
|
||||
|
||||
@* ── Left: Timeline ── *@
|
||||
<div class="col-12 col-lg-5">
|
||||
@if (!string.IsNullOrEmpty(incident.Summary))
|
||||
{
|
||||
<p class="mb-1"><strong>Summary:</strong> @incident.Summary</p>
|
||||
<p class="mb-1 small"><strong>Summary:</strong> @incident.Summary</p>
|
||||
}
|
||||
@if (!string.IsNullOrEmpty(incident.Description))
|
||||
{
|
||||
<p class="mb-2 text-muted">@incident.Description</p>
|
||||
<p class="mb-2 small text-muted">@incident.Description</p>
|
||||
}
|
||||
@if (incident.AcknowledgedBy is not null)
|
||||
@if (!string.IsNullOrEmpty(incident.RunbookUrl))
|
||||
{
|
||||
<p class="mb-2 small text-muted">
|
||||
Acknowledged by <strong>@incident.AcknowledgedBy</strong>
|
||||
at @incident.AcknowledgedAt?.ToLocalTime().ToString("MMM d HH:mm")
|
||||
<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>
|
||||
}
|
||||
|
||||
@if (incident.Notes.Count > 0)
|
||||
<strong class="small d-block mb-2">Timeline</strong>
|
||||
<div style="position:relative;padding-left:28px">
|
||||
<div style="position:absolute;left:9px;top:0;bottom:0;width:2px;background:#dee2e6"></div>
|
||||
@foreach (TimelineEvent ev in BuildTimeline(incident))
|
||||
{
|
||||
<div class="mb-3">
|
||||
<strong class="small">Notes</strong>
|
||||
@foreach (IncidentNote note in incident.Notes)
|
||||
<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="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>
|
||||
<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 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
|
||||
@* 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,6 +427,8 @@ else
|
||||
isLoading = true;
|
||||
StateHasChanged();
|
||||
|
||||
try
|
||||
{
|
||||
IncidentStatus? status = filterStatus switch
|
||||
{
|
||||
"Active" => IncidentStatus.Active,
|
||||
@@ -248,21 +447,127 @@ else
|
||||
from: DateTime.UtcNow.AddDays(-filterDays),
|
||||
to: null);
|
||||
|
||||
// Collect unique clusters from results
|
||||
if (filterUnassigned)
|
||||
incidents = incidents.Where(i => i.AssignedTo is null).ToList();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(searchText))
|
||||
{
|
||||
string q = searchText.Trim();
|
||||
incidents = incidents.Where(i =>
|
||||
i.AlertName.Contains(q, StringComparison.OrdinalIgnoreCase) ||
|
||||
i.Summary.Contains(q, StringComparison.OrdinalIgnoreCase)).ToList();
|
||||
}
|
||||
|
||||
deliveriesByIncident.Clear();
|
||||
|
||||
clusters = incidents
|
||||
.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;
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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."),
|
||||
|
||||
342
src/EntKube.Web/Components/Pages/Tenants/OnCallTab.razor
Normal file
342
src/EntKube.Web/Components/Pages/Tenants/OnCallTab.razor
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
679
src/EntKube.Web/Components/Pages/Tenants/TenantAlertsTab.razor
Normal file
679
src/EntKube.Web/Components/Pages/Tenants/TenantAlertsTab.razor
Normal 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);
|
||||
}
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
97
src/EntKube.Web/Components/Pages/Tenants/TenantLogsTab.razor
Normal file
97
src/EntKube.Web/Components/Pages/Tenants/TenantLogsTab.razor
Normal 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);
|
||||
}
|
||||
@@ -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="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>
|
||||
<div class="col-auto">
|
||||
<div class="card text-center border-0 bg-light px-3 py-2">
|
||||
<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>
|
||||
<div class="col-auto">
|
||||
<div class="card text-center border-0 bg-light px-3 py-2">
|
||||
<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>
|
||||
<div class="col-auto">
|
||||
<div class="card text-center border-0 bg-light px-3 py-2">
|
||||
<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">Active Alerts</div>
|
||||
<div class="text-muted small d-flex align-items-center gap-1">
|
||||
Active Alerts
|
||||
@if (OnNavigateTab.HasDelegate)
|
||||
{
|
||||
<span class="bi bi-arrow-right" style="font-size:.7rem"></span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
@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="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
|
||||
·
|
||||
<span class="bi bi-box me-1"></span>@vm.Health.RunningPods pods running
|
||||
@if (vm.Health.FailedPods > 0)
|
||||
{
|
||||
<span class="text-danger"> · <span class="bi bi-exclamation-circle me-1"></span>@vm.Health.FailedPods failed</span>
|
||||
}
|
||||
@if (vm.Health.PendingPods > 0)
|
||||
{
|
||||
<span class="text-warning"> · <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)
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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,10 +156,60 @@ 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." />
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
9
src/EntKube.Web/Data/AccessLevel.cs
Normal file
9
src/EntKube.Web/Data/AccessLevel.cs
Normal file
@@ -0,0 +1,9 @@
|
||||
namespace EntKube.Web.Data;
|
||||
|
||||
public enum AccessLevel
|
||||
{
|
||||
None = 0,
|
||||
View = 1,
|
||||
Edit = 2,
|
||||
Manage = 3,
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
|
||||
27
src/EntKube.Web/Data/AlertRoutingRule.cs
Normal file
27
src/EntKube.Web/Data/AlertRoutingRule.cs
Normal 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; }
|
||||
}
|
||||
@@ -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; } = [];
|
||||
}
|
||||
|
||||
19
src/EntKube.Web/Data/AppAllowedCache.cs
Normal file
19
src/EntKube.Web/Data/AppAllowedCache.cs
Normal 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!;
|
||||
}
|
||||
25
src/EntKube.Web/Data/AppAllowedDatabase.cs
Normal file
25
src/EntKube.Web/Data/AppAllowedDatabase.cs
Normal 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; }
|
||||
}
|
||||
19
src/EntKube.Web/Data/AppAllowedStorage.cs
Normal file
19
src/EntKube.Web/Data/AppAllowedStorage.cs
Normal 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!;
|
||||
}
|
||||
@@ -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; } = [];
|
||||
}
|
||||
|
||||
@@ -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!;
|
||||
|
||||
@@ -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!;
|
||||
}
|
||||
|
||||
@@ -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!;
|
||||
}
|
||||
|
||||
@@ -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; } = [];
|
||||
}
|
||||
|
||||
|
||||
86
src/EntKube.Web/Data/AppRoute.cs
Normal file
86
src/EntKube.Web/Data/AppRoute.cs
Normal 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!;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
69
src/EntKube.Web/Data/ClusterServer.cs
Normal file
69
src/EntKube.Web/Data/ClusterServer.cs
Normal 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!;
|
||||
}
|
||||
@@ -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; } = [];
|
||||
}
|
||||
|
||||
49
src/EntKube.Web/Data/CustomerGitCredential.cs
Normal file
49
src/EntKube.Web/Data/CustomerGitCredential.cs
Normal 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!;
|
||||
}
|
||||
30
src/EntKube.Web/Data/CustomerGitRepoPolicy.cs
Normal file
30
src/EntKube.Web/Data/CustomerGitRepoPolicy.cs
Normal 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!;
|
||||
}
|
||||
@@ -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; } = [];
|
||||
}
|
||||
|
||||
@@ -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; } = [];
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
// 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",
|
||||
table: "KeycloakBackups");
|
||||
|
||||
migrationBuilder.AlterColumn<Guid>(
|
||||
name: "StorageLinkId",
|
||||
table: "KeycloakBackups",
|
||||
type: "uuid",
|
||||
nullable: true,
|
||||
oldClrType: typeof(Guid),
|
||||
oldType: "uuid");
|
||||
|
||||
migrationBuilder.AddForeignKey(
|
||||
name: "FK_KeycloakBackups_StorageLinks_StorageLinkId",
|
||||
table: "KeycloakBackups",
|
||||
column: "StorageLinkId",
|
||||
column: x => x.StorageLinkId,
|
||||
principalTable: "StorageLinks",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.SetNull);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_KeycloakBackups_KeycloakRealmId",
|
||||
table: "KeycloakBackups",
|
||||
column: "KeycloakRealmId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_KeycloakBackups_StorageLinkId",
|
||||
table: "KeycloakBackups",
|
||||
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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
4436
src/EntKube.Web/Data/Migrations/Postgres/20260604152714_AddCustomerGitPoliciesAndCredentials.Designer.cs
generated
Normal file
4436
src/EntKube.Web/Data/Migrations/Postgres/20260604152714_AddCustomerGitPoliciesAndCredentials.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user