Compare commits

...

9 Commits

Author SHA256 Message Date
Nils Blomgren
5da40698ad server management
Some checks failed
Build and Deploy / Build and push image (push) Failing after 5m50s
Build and Deploy / Deploy to server (push) Has been skipped
2026-06-11 15:09:31 +02:00
Nils Blomgren
343de77810 blazor.web.js issues
Some checks failed
Build and Deploy / Deploy to server (push) Has been skipped
Build and Deploy / Build and push image (push) Failing after 6m0s
2026-06-08 16:48:36 +02:00
Nils Blomgren
ffe41b4413 publish1 2026-06-07 21:09:37 +02:00
Nils Blomgren
76b7e55931 database and gitsync issues fixed 2026-06-07 14:54:16 +02:00
Nils Blomgren
8dc46ef031 ignoring reference project 2026-06-04 08:53:05 +02:00
Nils Blomgren
658f15d086 first commit ish 2026-06-04 08:51:22 +02:00
Nils Blomgren
e0f967482e gateway api fixes 2026-05-21 16:57:50 +02:00
Nils Blomgren
8b94fb8826 Mark subproject as dirty in CMKS - Infra 2026-05-18 08:37:09 +02:00
Nils Blomgren
328d494530 too much for one commit 2026-05-13 14:01:32 +02:00
724 changed files with 581309 additions and 2746 deletions

BIN
.DS_Store vendored Normal file

Binary file not shown.

7
.claude/settings.json Normal file
View File

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

19
.dockerignore Normal file
View File

@@ -0,0 +1,19 @@
**/.dockerignore
**/.git
**/.gitignore
**/.vs
**/.vscode
**/bin
**/obj
**/out
**/node_modules
**/Charts
**/*.user
**/*.dbmdl
**/*.jfm
**/*.db
**/secrets.dev.yaml
**/values.dev.yaml
**/Tiltfile
tests
CMKS - Infra

26
.env.example Normal file
View File

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

View File

@@ -1,28 +0,0 @@
name: Build EntKube
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: '10.0.x'
- name: Restore dependencies
run: dotnet restore EntKube.slnx
- name: Build
run: dotnet build EntKube.slnx --no-restore --configuration Release
- name: Test
run: dotnet test EntKube.slnx --no-build --configuration Release

View File

@@ -1,48 +0,0 @@
name: Package Helm Chart
on:
push:
branches: [main]
paths:
- 'Charts/**'
pull_request:
branches: [main]
paths:
- 'Charts/**'
jobs:
lint:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Install Helm
uses: azure/setup-helm@v4
with:
version: v3.16.0
- name: Lint chart
run: helm lint Charts/entkube
package:
runs-on: ubuntu-latest
needs: lint
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Install Helm
uses: azure/setup-helm@v4
with:
version: v3.16.0
- name: Package chart
run: helm package Charts/entkube --destination .helm-packages/
- name: Login to Helm OCI registry
run: echo "${{ secrets.REGISTRY_PASSWORD }}" | helm registry login ${{ vars.REGISTRY_HOST }} --username ${{ vars.REGISTRY_USER }} --password-stdin
- name: Push chart to OCI registry
run: helm push .helm-packages/entkube-*.tgz oci://${{ vars.REGISTRY_HOST }}/entkube

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

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

14
.gitignore vendored
View File

@@ -66,6 +66,9 @@ artifacts/
# ASP.NET Scaffolding
ScaffoldingReadMe.txt
# Local dev data persistence
**/App_Data/
# StyleCop
StyleCopReport.xml
@@ -414,3 +417,14 @@ FodyWeavers.xsd
# Built Visual Studio Code Extensions
*.vsix
# Local SQLite databases
*.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/

22
.vscode/launch.json vendored Normal file
View File

@@ -0,0 +1,22 @@
{
"version": "0.2.0",
"configurations": [
{
"name": "EntKube.Web",
"type": "coreclr",
"request": "launch",
"preLaunchTask": "build",
"program": "${workspaceFolder}/src/EntKube.Web/bin/Debug/net10.0/EntKube.Web.dll",
"args": [],
"cwd": "${workspaceFolder}/src/EntKube.Web",
"stopAtEntry": false,
"serverReadyAction": {
"action": "openExternally",
"pattern": "\\bNow listening on:\\s+(https?://\\S+)"
},
"env": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
}
]
}

27
.vscode/tasks.json vendored Normal file
View File

@@ -0,0 +1,27 @@
{
"version": "2.0.0",
"tasks": [
{
"label": "build",
"command": "dotnet",
"type": "process",
"args": [
"build",
"${workspaceFolder}/src/EntKube.Web/EntKube.Web.csproj",
"/property:GenerateFullPaths=true",
"/consoleloggerparameters:NoSummary;ForceNoAlign"
],
"problemMatcher": "$msCompile"
},
{
"label": "test",
"command": "dotnet",
"type": "process",
"args": [
"test",
"${workspaceFolder}/tests/EntKube.Web.Tests/EntKube.Web.Tests.csproj"
],
"problemMatcher": "$msCompile"
}
]
}

7
Caddyfile Normal file
View File

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

View File

@@ -1,4 +0,0 @@
# Patterns to ignore when packaging the chart
.DS_Store
*.tgz
.git/

View File

@@ -1,13 +0,0 @@
apiVersion: v2
name: entkube
description: A Helm chart for the EntKube multi-tenant Kubernetes management platform
type: application
version: 0.1.0
appVersion: "1.0.0"
keywords:
- entkube
- kubernetes
- multi-tenant
- blazor
maintainers:
- name: EntKube Team

View File

@@ -1,67 +0,0 @@
{{/*
Expand the name of the chart.
*/}}
{{- define "entkube.name" -}}
{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }}
{{- end }}
{{/*
Create a default fully qualified app name.
*/}}
{{- define "entkube.fullname" -}}
{{- if .Values.fullnameOverride }}
{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }}
{{- else }}
{{- $name := default .Chart.Name .Values.nameOverride }}
{{- if contains $name .Release.Name }}
{{- .Release.Name | trunc 63 | trimSuffix "-" }}
{{- else }}
{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }}
{{- end }}
{{- end }}
{{- end }}
{{/*
Create chart name and version as used by the chart label.
*/}}
{{- define "entkube.chart" -}}
{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }}
{{- end }}
{{/*
Common labels
*/}}
{{- define "entkube.labels" -}}
helm.sh/chart: {{ include "entkube.chart" . }}
app.kubernetes.io/managed-by: {{ .Release.Service }}
app.kubernetes.io/version: {{ .Chart.AppVersion | quote }}
{{- end }}
{{/*
Selector labels for a specific component
*/}}
{{- define "entkube.selectorLabels" -}}
app.kubernetes.io/name: {{ include "entkube.name" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
{{- end }}
{{/*
Image reference helper
*/}}
{{- define "entkube.image" -}}
{{- $registry := .root.Values.image.registry -}}
{{- $repository := .component.image.repository -}}
{{- $tag := default .root.Chart.AppVersion .root.Values.image.tag -}}
{{- printf "%s/%s:%s" $registry $repository $tag -}}
{{- end }}
{{/*
Service account name
*/}}
{{- define "entkube.serviceAccountName" -}}
{{- if .Values.serviceAccount.create }}
{{- default (include "entkube.fullname" .) .Values.serviceAccount.name }}
{{- else }}
{{- default "default" .Values.serviceAccount.name }}
{{- end }}
{{- end }}

View File

@@ -1,51 +0,0 @@
{{- if .Values.clusters.enabled }}
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ include "entkube.fullname" . }}-clusters
labels:
{{- include "entkube.labels" . | nindent 4 }}
app.kubernetes.io/component: clusters
spec:
replicas: {{ .Values.clusters.replicaCount }}
selector:
matchLabels:
{{- include "entkube.selectorLabels" . | nindent 6 }}
app.kubernetes.io/component: clusters
template:
metadata:
labels:
{{- include "entkube.selectorLabels" . | nindent 8 }}
app.kubernetes.io/component: clusters
spec:
{{- with .Values.imagePullSecrets }}
imagePullSecrets:
{{- toYaml . | nindent 8 }}
{{- end }}
serviceAccountName: {{ include "entkube.serviceAccountName" . }}
containers:
- name: clusters
image: {{ include "entkube.image" (dict "root" . "component" .Values.clusters) }}
imagePullPolicy: {{ .Values.image.pullPolicy }}
ports:
- name: http
containerPort: {{ .Values.clusters.port }}
protocol: TCP
livenessProbe:
httpGet:
path: /health
port: http
initialDelaySeconds: 10
periodSeconds: 30
readinessProbe:
httpGet:
path: /health/ready
port: http
initialDelaySeconds: 5
periodSeconds: 10
resources:
{{- toYaml .Values.clusters.resources | nindent 12 }}
env:
- name: ASPNETCORE_URLS
value: "http://+:{{ .Values.clusters.port }}"
{{- end }}

View File

@@ -1,19 +0,0 @@
{{- if .Values.clusters.enabled }}
apiVersion: v1
kind: Service
metadata:
name: {{ include "entkube.fullname" . }}-clusters
labels:
{{- include "entkube.labels" . | nindent 4 }}
app.kubernetes.io/component: clusters
spec:
type: ClusterIP
ports:
- port: {{ .Values.clusters.port }}
targetPort: http
protocol: TCP
name: http
selector:
{{- include "entkube.selectorLabels" . | nindent 4 }}
app.kubernetes.io/component: clusters
{{- end }}

View File

@@ -1,51 +0,0 @@
{{- if .Values.identity.enabled }}
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ include "entkube.fullname" . }}-identity
labels:
{{- include "entkube.labels" . | nindent 4 }}
app.kubernetes.io/component: identity
spec:
replicas: {{ .Values.identity.replicaCount }}
selector:
matchLabels:
{{- include "entkube.selectorLabels" . | nindent 6 }}
app.kubernetes.io/component: identity
template:
metadata:
labels:
{{- include "entkube.selectorLabels" . | nindent 8 }}
app.kubernetes.io/component: identity
spec:
{{- with .Values.imagePullSecrets }}
imagePullSecrets:
{{- toYaml . | nindent 8 }}
{{- end }}
serviceAccountName: {{ include "entkube.serviceAccountName" . }}
containers:
- name: identity
image: {{ include "entkube.image" (dict "root" . "component" .Values.identity) }}
imagePullPolicy: {{ .Values.image.pullPolicy }}
ports:
- name: http
containerPort: {{ .Values.identity.port }}
protocol: TCP
livenessProbe:
httpGet:
path: /health
port: http
initialDelaySeconds: 10
periodSeconds: 30
readinessProbe:
httpGet:
path: /health/ready
port: http
initialDelaySeconds: 5
periodSeconds: 10
resources:
{{- toYaml .Values.identity.resources | nindent 12 }}
env:
- name: ASPNETCORE_URLS
value: "http://+:{{ .Values.identity.port }}"
{{- end }}

View File

@@ -1,19 +0,0 @@
{{- if .Values.identity.enabled }}
apiVersion: v1
kind: Service
metadata:
name: {{ include "entkube.fullname" . }}-identity
labels:
{{- include "entkube.labels" . | nindent 4 }}
app.kubernetes.io/component: identity
spec:
type: ClusterIP
ports:
- port: {{ .Values.identity.port }}
targetPort: http
protocol: TCP
name: http
selector:
{{- include "entkube.selectorLabels" . | nindent 4 }}
app.kubernetes.io/component: identity
{{- end }}

View File

@@ -1,61 +0,0 @@
{{- if .Values.ingress.enabled }}
{{- if eq .Values.ingress.provider "traefik" }}
apiVersion: traefik.io/v1alpha1
kind: IngressRoute
metadata:
name: {{ include "entkube.fullname" . }}
labels:
{{- include "entkube.labels" . | nindent 4 }}
{{- with .Values.ingress.annotations }}
annotations:
{{- toYaml . | nindent 4 }}
{{- end }}
spec:
entryPoints:
- websecure
routes:
- match: Host(`{{ .Values.ingress.host }}`)
kind: Rule
services:
- name: {{ include "entkube.fullname" . }}-web
port: {{ .Values.web.port }}
{{- if .Values.ingress.tls.enabled }}
tls:
secretName: {{ .Values.ingress.tls.secretName }}
{{- end }}
{{- else if eq .Values.ingress.provider "gatewayapi" }}
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
name: {{ include "entkube.fullname" . }}
labels:
{{- include "entkube.labels" . | nindent 4 }}
spec:
parentRefs:
- name: {{ include "entkube.fullname" . }}-gateway
hostnames:
- {{ .Values.ingress.host }}
rules:
- backendRefs:
- name: {{ include "entkube.fullname" . }}-web
port: {{ .Values.web.port }}
{{- else if eq .Values.ingress.provider "istio" }}
apiVersion: networking.istio.io/v1
kind: VirtualService
metadata:
name: {{ include "entkube.fullname" . }}
labels:
{{- include "entkube.labels" . | nindent 4 }}
spec:
hosts:
- {{ .Values.ingress.host }}
gateways:
- {{ include "entkube.fullname" . }}-gateway
http:
- route:
- destination:
host: {{ include "entkube.fullname" . }}-web
port:
number: {{ .Values.web.port }}
{{- end }}
{{- end }}

View File

@@ -1,51 +0,0 @@
{{- if .Values.provisioning.enabled }}
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ include "entkube.fullname" . }}-provisioning
labels:
{{- include "entkube.labels" . | nindent 4 }}
app.kubernetes.io/component: provisioning
spec:
replicas: {{ .Values.provisioning.replicaCount }}
selector:
matchLabels:
{{- include "entkube.selectorLabels" . | nindent 6 }}
app.kubernetes.io/component: provisioning
template:
metadata:
labels:
{{- include "entkube.selectorLabels" . | nindent 8 }}
app.kubernetes.io/component: provisioning
spec:
{{- with .Values.imagePullSecrets }}
imagePullSecrets:
{{- toYaml . | nindent 8 }}
{{- end }}
serviceAccountName: {{ include "entkube.serviceAccountName" . }}
containers:
- name: provisioning
image: {{ include "entkube.image" (dict "root" . "component" .Values.provisioning) }}
imagePullPolicy: {{ .Values.image.pullPolicy }}
ports:
- name: http
containerPort: {{ .Values.provisioning.port }}
protocol: TCP
livenessProbe:
httpGet:
path: /health
port: http
initialDelaySeconds: 10
periodSeconds: 30
readinessProbe:
httpGet:
path: /health/ready
port: http
initialDelaySeconds: 5
periodSeconds: 10
resources:
{{- toYaml .Values.provisioning.resources | nindent 12 }}
env:
- name: ASPNETCORE_URLS
value: "http://+:{{ .Values.provisioning.port }}"
{{- end }}

View File

@@ -1,19 +0,0 @@
{{- if .Values.provisioning.enabled }}
apiVersion: v1
kind: Service
metadata:
name: {{ include "entkube.fullname" . }}-provisioning
labels:
{{- include "entkube.labels" . | nindent 4 }}
app.kubernetes.io/component: provisioning
spec:
type: ClusterIP
ports:
- port: {{ .Values.provisioning.port }}
targetPort: http
protocol: TCP
name: http
selector:
{{- include "entkube.selectorLabels" . | nindent 4 }}
app.kubernetes.io/component: provisioning
{{- end }}

View File

@@ -1,12 +0,0 @@
{{- if .Values.serviceAccount.create }}
apiVersion: v1
kind: ServiceAccount
metadata:
name: {{ include "entkube.serviceAccountName" . }}
labels:
{{- include "entkube.labels" . | nindent 4 }}
{{- with .Values.serviceAccount.annotations }}
annotations:
{{- toYaml . | nindent 4 }}
{{- end }}
{{- end }}

View File

@@ -1,57 +0,0 @@
{{- if .Values.web.enabled }}
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ include "entkube.fullname" . }}-web
labels:
{{- include "entkube.labels" . | nindent 4 }}
app.kubernetes.io/component: web
spec:
replicas: {{ .Values.web.replicaCount }}
selector:
matchLabels:
{{- include "entkube.selectorLabels" . | nindent 6 }}
app.kubernetes.io/component: web
template:
metadata:
labels:
{{- include "entkube.selectorLabels" . | nindent 8 }}
app.kubernetes.io/component: web
spec:
{{- with .Values.imagePullSecrets }}
imagePullSecrets:
{{- toYaml . | nindent 8 }}
{{- end }}
serviceAccountName: {{ include "entkube.serviceAccountName" . }}
containers:
- name: web
image: {{ include "entkube.image" (dict "root" . "component" .Values.web) }}
imagePullPolicy: {{ .Values.image.pullPolicy }}
ports:
- name: http
containerPort: {{ .Values.web.port }}
protocol: TCP
livenessProbe:
httpGet:
path: /health
port: http
initialDelaySeconds: 10
periodSeconds: 30
readinessProbe:
httpGet:
path: /health/ready
port: http
initialDelaySeconds: 5
periodSeconds: 10
resources:
{{- toYaml .Values.web.resources | nindent 12 }}
env:
- name: ASPNETCORE_URLS
value: "http://+:{{ .Values.web.port }}"
- name: Services__Clusters__BaseUrl
value: "http://{{ include "entkube.fullname" . }}-clusters:{{ .Values.clusters.port }}"
- name: Services__Provisioning__BaseUrl
value: "http://{{ include "entkube.fullname" . }}-provisioning:{{ .Values.provisioning.port }}"
- name: Services__Identity__BaseUrl
value: "http://{{ include "entkube.fullname" . }}-identity:{{ .Values.identity.port }}"
{{- end }}

View File

@@ -1,19 +0,0 @@
{{- if .Values.web.enabled }}
apiVersion: v1
kind: Service
metadata:
name: {{ include "entkube.fullname" . }}-web
labels:
{{- include "entkube.labels" . | nindent 4 }}
app.kubernetes.io/component: web
spec:
type: ClusterIP
ports:
- port: {{ .Values.web.port }}
targetPort: http
protocol: TCP
name: http
selector:
{{- include "entkube.selectorLabels" . | nindent 4 }}
app.kubernetes.io/component: web
{{- end }}

View File

@@ -1,84 +0,0 @@
# Default values for EntKube Helm chart
replicaCount: 1
image:
registry: gitea.example.com
pullPolicy: IfNotPresent
tag: "" # Defaults to appVersion
imagePullSecrets: []
nameOverride: ""
fullnameOverride: ""
# Service configurations per microservice
web:
enabled: true
replicaCount: 1
image:
repository: entkube/web
port: 5000
resources:
requests:
cpu: 100m
memory: 128Mi
limits:
cpu: 500m
memory: 512Mi
clusters:
enabled: true
replicaCount: 1
image:
repository: entkube/clusters
port: 5010
resources:
requests:
cpu: 100m
memory: 128Mi
limits:
cpu: 500m
memory: 256Mi
provisioning:
enabled: true
replicaCount: 1
image:
repository: entkube/provisioning
port: 5020
resources:
requests:
cpu: 100m
memory: 128Mi
limits:
cpu: 500m
memory: 256Mi
identity:
enabled: true
replicaCount: 1
image:
repository: entkube/identity
port: 5030
resources:
requests:
cpu: 100m
memory: 128Mi
limits:
cpu: 500m
memory: 256Mi
# Ingress configuration — select your strategy
ingress:
enabled: true
provider: traefik # Options: traefik | istio | gatewayapi
host: entkube.example.com
tls:
enabled: true
secretName: entkube-tls
annotations: {}
serviceAccount:
create: true
name: ""
annotations: {}

5
Directory.Build.props Normal file
View File

@@ -0,0 +1,5 @@
<Project>
<PropertyGroup>
<EnableSourceControlManagerQueries>false</EnableSourceControlManagerQueries>
</PropertyGroup>
</Project>

60
Dockerfile Normal file
View File

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

View File

@@ -1,14 +1,9 @@
<Solution>
<!-- Source Projects -->
<Project Path="src/EntKube.SharedKernel/EntKube.SharedKernel.csproj" />
<Project Path="src/EntKube.Identity/EntKube.Identity.csproj" />
<Project Path="src/EntKube.Clusters/EntKube.Clusters.csproj" />
<Project Path="src/EntKube.Provisioning/EntKube.Provisioning.csproj" />
<Project Path="src/EntKube.Web/EntKube.Web.csproj" />
<Folder Name="/src/">
<Project Path="src/EntKube.Web.Client/EntKube.Web.Client.csproj" />
<!-- Test Projects -->
<Project Path="tests/EntKube.Identity.Tests/EntKube.Identity.Tests.csproj" />
<Project Path="tests/EntKube.Clusters.Tests/EntKube.Clusters.Tests.csproj" />
<Project Path="tests/EntKube.Provisioning.Tests/EntKube.Provisioning.Tests.csproj" />
<Project Path="src/EntKube.Web/EntKube.Web.csproj" />
</Folder>
<Folder Name="/tests/">
<Project Path="tests/EntKube.Web.Tests/EntKube.Web.Tests.csproj" />
</Folder>
</Solution>

View File

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

81
docker-compose.yml Normal file
View File

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

View File

@@ -1,15 +0,0 @@
namespace EntKube.Clusters.Domain;
/// <summary>
/// Defines how the cluster service persists and retrieves cluster aggregates.
/// The implementation lives in the infrastructure layer — the domain only
/// knows about this contract, keeping it persistence-agnostic.
/// </summary>
public interface IClusterRepository
{
Task<KubernetesCluster?> GetByIdAsync(Guid id, CancellationToken ct = default);
Task<IReadOnlyList<KubernetesCluster>> GetAllAsync(CancellationToken ct = default);
Task AddAsync(KubernetesCluster cluster, CancellationToken ct = default);
Task UpdateAsync(KubernetesCluster cluster, CancellationToken ct = default);
Task DeleteAsync(Guid id, CancellationToken ct = default);
}

View File

@@ -1,76 +0,0 @@
namespace EntKube.Clusters.Domain;
/// <summary>
/// A KubernetesCluster represents a registered cluster that EntKube manages.
/// It holds the connection details, health state, and metadata needed to
/// interact with the cluster's API server. This is the aggregate root for
/// all cluster-related operations.
/// </summary>
public class KubernetesCluster
{
public Guid Id { get; private set; }
public string Name { get; private set; } = string.Empty;
public string ApiServerUrl { get; private set; } = string.Empty;
public ClusterStatus Status { get; private set; }
public string? KubeConfigSecret { get; private set; }
public DateTimeOffset RegisteredAt { get; private set; }
public DateTimeOffset? LastHealthCheckAt { get; private set; }
private KubernetesCluster() { }
/// <summary>
/// Registers a new cluster in the platform. At this point, we know the cluster
/// exists and we have connection details — but we haven't verified connectivity yet.
/// The cluster starts in a Pending state until a health check confirms it's reachable.
/// </summary>
public static KubernetesCluster Register(string name, string apiServerUrl, string? kubeConfigSecret)
{
if (string.IsNullOrWhiteSpace(name))
{
throw new ArgumentException("Cluster name is required.", nameof(name));
}
if (string.IsNullOrWhiteSpace(apiServerUrl))
{
throw new ArgumentException("API server URL is required.", nameof(apiServerUrl));
}
return new KubernetesCluster
{
Id = Guid.NewGuid(),
Name = name,
ApiServerUrl = apiServerUrl,
KubeConfigSecret = kubeConfigSecret,
Status = ClusterStatus.Pending,
RegisteredAt = DateTimeOffset.UtcNow
};
}
/// <summary>
/// After a successful health check, we mark the cluster as connected.
/// This means the platform can now schedule work against this cluster.
/// </summary>
public void MarkConnected()
{
Status = ClusterStatus.Connected;
LastHealthCheckAt = DateTimeOffset.UtcNow;
}
/// <summary>
/// When a health check fails, we mark the cluster as unreachable.
/// Existing workloads keep running, but no new provisioning can happen
/// until connectivity is restored.
/// </summary>
public void MarkUnreachable()
{
Status = ClusterStatus.Unreachable;
LastHealthCheckAt = DateTimeOffset.UtcNow;
}
}
public enum ClusterStatus
{
Pending,
Connected,
Unreachable
}

View File

@@ -1,17 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.1" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\EntKube.SharedKernel\EntKube.SharedKernel.csproj" />
</ItemGroup>
</Project>

View File

@@ -1,6 +0,0 @@
@EntKube.Clusters_HostAddress = http://localhost:5243
GET {{EntKube.Clusters_HostAddress}}/weatherforecast/
Accept: application/json
###

View File

@@ -1,39 +0,0 @@
using EntKube.Clusters.Domain;
using EntKube.SharedKernel.Contracts;
using Microsoft.AspNetCore.Mvc;
namespace EntKube.Clusters.Features.GetClusters;
/// <summary>
/// Lists all registered clusters. The BFF (Web service) calls this endpoint
/// to populate the clusters dashboard. Returns a lightweight summary of each
/// cluster's name, status, and last health check time.
/// </summary>
public static class GetClustersEndpoint
{
public static void Map(IEndpointRouteBuilder app)
{
app.MapGet("/api/clusters", async (
[FromServices] IClusterRepository repository,
CancellationToken ct) =>
{
IReadOnlyList<KubernetesCluster> clusters = await repository.GetAllAsync(ct);
List<ClusterSummary> summaries = clusters.Select(c => new ClusterSummary(
c.Id,
c.Name,
c.ApiServerUrl,
c.Status.ToString(),
c.LastHealthCheckAt)).ToList();
return Results.Ok(ApiResponse<List<ClusterSummary>>.Ok(summaries));
});
}
}
public record ClusterSummary(
Guid Id,
string Name,
string ApiServerUrl,
string Status,
DateTimeOffset? LastHealthCheckAt);

View File

@@ -1,30 +0,0 @@
using EntKube.Clusters.Domain;
using EntKube.SharedKernel.Contracts;
using Microsoft.AspNetCore.Mvc;
namespace EntKube.Clusters.Features.RegisterCluster;
/// <summary>
/// Maps the HTTP POST /api/clusters endpoint. Receives a registration request,
/// delegates to the handler, and returns the new cluster's ID on success.
/// </summary>
public static class RegisterClusterEndpoint
{
public static void Map(IEndpointRouteBuilder app)
{
app.MapPost("/api/clusters", async (
[FromBody] RegisterClusterRequest request,
[FromServices] RegisterClusterHandler handler,
CancellationToken ct) =>
{
EntKube.SharedKernel.Domain.Result<Guid> result = await handler.HandleAsync(request, ct);
if (result.IsFailure)
{
return Results.BadRequest(ApiResponse<Guid>.Fail(result.Error!));
}
return Results.Created($"/api/clusters/{result.Value}", ApiResponse<Guid>.Ok(result.Value!));
});
}
}

View File

@@ -1,53 +0,0 @@
using EntKube.Clusters.Domain;
using EntKube.SharedKernel.Domain;
namespace EntKube.Clusters.Features.RegisterCluster;
/// <summary>
/// Handles the registration of a new Kubernetes cluster into the platform.
/// A tenant admin provides the cluster name, API server URL, and optionally
/// a kubeconfig secret reference. We validate the input, create the cluster
/// aggregate, and persist it. The cluster starts in Pending state until the
/// background health-check service confirms connectivity.
/// </summary>
public class RegisterClusterHandler
{
private readonly IClusterRepository repository;
public RegisterClusterHandler(IClusterRepository repository)
{
this.repository = repository;
}
public async Task<Result<Guid>> HandleAsync(RegisterClusterRequest request, CancellationToken ct = default)
{
// Validate that the caller provided the minimum required information.
// Without a name and API URL, we cannot register a cluster.
if (string.IsNullOrWhiteSpace(request.Name))
{
return Result.Failure<Guid>("Cluster name is required.");
}
if (string.IsNullOrWhiteSpace(request.ApiServerUrl))
{
return Result.Failure<Guid>("API server URL is required.");
}
// Create the cluster aggregate using the domain factory method.
// This encapsulates all the business rules for what a valid new cluster looks like.
KubernetesCluster cluster = KubernetesCluster.Register(
request.Name,
request.ApiServerUrl,
request.KubeConfigSecret);
// Persist the new cluster so it can be picked up by the health-check background service.
await repository.AddAsync(cluster, ct);
return Result.Success(cluster.Id);
}
}
public record RegisterClusterRequest(string Name, string ApiServerUrl, string? KubeConfigSecret);

View File

@@ -1,43 +0,0 @@
using EntKube.Clusters.Domain;
namespace EntKube.Clusters.Infrastructure;
/// <summary>
/// In-memory implementation of the cluster repository for local development
/// and testing. Production will replace this with an EF Core or Dapper
/// implementation backed by PostgreSQL.
/// </summary>
public class InMemoryClusterRepository : IClusterRepository
{
private readonly List<KubernetesCluster> clusters = new();
public Task<KubernetesCluster?> GetByIdAsync(Guid id, CancellationToken ct = default)
{
KubernetesCluster? cluster = clusters.FirstOrDefault(c => c.Id == id);
return Task.FromResult(cluster);
}
public Task<IReadOnlyList<KubernetesCluster>> GetAllAsync(CancellationToken ct = default)
{
IReadOnlyList<KubernetesCluster> result = clusters.AsReadOnly();
return Task.FromResult(result);
}
public Task AddAsync(KubernetesCluster cluster, CancellationToken ct = default)
{
clusters.Add(cluster);
return Task.CompletedTask;
}
public Task UpdateAsync(KubernetesCluster cluster, CancellationToken ct = default)
{
// In-memory: the reference is already updated since we store the object directly.
return Task.CompletedTask;
}
public Task DeleteAsync(Guid id, CancellationToken ct = default)
{
clusters.RemoveAll(c => c.Id == id);
return Task.CompletedTask;
}
}

View File

@@ -1,39 +0,0 @@
using EntKube.Clusters.Domain;
using EntKube.Clusters.Features.GetClusters;
using EntKube.Clusters.Features.RegisterCluster;
using EntKube.Clusters.Infrastructure;
namespace EntKube.Clusters;
public class Program
{
public static void Main(string[] args)
{
WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
// Register application services. The cluster repository is a singleton
// for the in-memory implementation — production will use a scoped EF context.
builder.Services.AddSingleton<IClusterRepository, InMemoryClusterRepository>();
builder.Services.AddScoped<RegisterClusterHandler>();
builder.Services.AddOpenApi();
WebApplication app = builder.Build();
// Configure the HTTP pipeline with OpenAPI for development tooling.
if (app.Environment.IsDevelopment())
{
app.MapOpenApi();
}
app.UseHttpsRedirection();
// Map feature endpoints — each feature registers its own routes.
RegisterClusterEndpoint.Map(app);
GetClustersEndpoint.Map(app);
app.Run();
}
}

View File

@@ -1,23 +0,0 @@
{
"$schema": "https://json.schemastore.org/launchsettings.json",
"profiles": {
"http": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": false,
"applicationUrl": "http://localhost:5243",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
"https": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": false,
"applicationUrl": "https://localhost:7267;http://localhost:5243",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
}
}
}

View File

@@ -1,8 +0,0 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
}
}

View File

@@ -1,9 +0,0 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*"
}

View File

@@ -1,13 +0,0 @@
namespace EntKube.Identity.Domain;
/// <summary>
/// Defines how the identity service persists and retrieves tenants.
/// </summary>
public interface ITenantRepository
{
Task<Tenant?> GetByIdAsync(Guid id, CancellationToken ct = default);
Task<Tenant?> GetBySlugAsync(string slug, CancellationToken ct = default);
Task<IReadOnlyList<Tenant>> GetAllAsync(CancellationToken ct = default);
Task AddAsync(Tenant tenant, CancellationToken ct = default);
Task UpdateAsync(Tenant tenant, CancellationToken ct = default);
}

View File

@@ -1,91 +0,0 @@
namespace EntKube.Identity.Domain;
/// <summary>
/// A Tenant represents an organization or team using the EntKube platform.
/// Each tenant has isolated access to clusters and provisioned services.
/// The Identity service owns tenant lifecycle (creation, suspension, deletion)
/// and user membership within tenants.
/// </summary>
public class Tenant
{
public Guid Id { get; private set; }
public string Name { get; private set; } = string.Empty;
public string Slug { get; private set; } = string.Empty;
public TenantStatus Status { get; private set; }
public DateTimeOffset CreatedAt { get; private set; }
private readonly List<TenantMember> members = new();
public IReadOnlyList<TenantMember> Members => members.AsReadOnly();
private Tenant() { }
/// <summary>
/// Creates a new tenant in the platform. The creating user automatically
/// becomes the tenant's first admin member.
/// </summary>
public static Tenant Create(string name, string slug, Guid creatingUserId)
{
if (string.IsNullOrWhiteSpace(name))
{
throw new ArgumentException("Tenant name is required.", nameof(name));
}
if (string.IsNullOrWhiteSpace(slug))
{
throw new ArgumentException("Tenant slug is required.", nameof(slug));
}
Tenant tenant = new()
{
Id = Guid.NewGuid(),
Name = name,
Slug = slug.ToLowerInvariant(),
Status = TenantStatus.Active,
CreatedAt = DateTimeOffset.UtcNow
};
// The creator is automatically the admin of their new tenant.
tenant.members.Add(new TenantMember(creatingUserId, TenantRole.Admin));
return tenant;
}
/// <summary>
/// Adds a user to this tenant with the specified role.
/// </summary>
public void AddMember(Guid userId, TenantRole role)
{
if (members.Any(m => m.UserId == userId))
{
return;
}
members.Add(new TenantMember(userId, role));
}
public void Suspend()
{
Status = TenantStatus.Suspended;
}
public void Activate()
{
Status = TenantStatus.Active;
}
}
public record TenantMember(Guid UserId, TenantRole Role);
public enum TenantStatus
{
Active,
Suspended
}
public enum TenantRole
{
Admin,
Member,
Viewer
}

View File

@@ -1,17 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.1" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\EntKube.SharedKernel\EntKube.SharedKernel.csproj" />
</ItemGroup>
</Project>

View File

@@ -1,6 +0,0 @@
@EntKube.Identity_HostAddress = http://localhost:5076
GET {{EntKube.Identity_HostAddress}}/weatherforecast/
Accept: application/json
###

View File

@@ -1,28 +0,0 @@
using EntKube.SharedKernel.Contracts;
using Microsoft.AspNetCore.Mvc;
namespace EntKube.Identity.Features.CreateTenant;
/// <summary>
/// Maps POST /api/tenants — creates a new tenant organization.
/// </summary>
public static class CreateTenantEndpoint
{
public static void Map(IEndpointRouteBuilder app)
{
app.MapPost("/api/tenants", async (
[FromBody] CreateTenantRequest request,
[FromServices] CreateTenantHandler handler,
CancellationToken ct) =>
{
EntKube.SharedKernel.Domain.Result<Guid> result = await handler.HandleAsync(request, ct);
if (result.IsFailure)
{
return Results.BadRequest(ApiResponse<Guid>.Fail(result.Error!));
}
return Results.Created($"/api/tenants/{result.Value}", ApiResponse<Guid>.Ok(result.Value!));
});
}
}

View File

@@ -1,59 +0,0 @@
using EntKube.Identity.Domain;
using EntKube.SharedKernel.Domain;
namespace EntKube.Identity.Features.CreateTenant;
/// <summary>
/// Handles creation of a new tenant. When a user signs up or an admin creates
/// a new organization, this handler validates the request, ensures the slug is
/// unique, creates the tenant aggregate, and persists it. The creating user
/// becomes the first admin of the tenant.
/// </summary>
public class CreateTenantHandler
{
private readonly ITenantRepository repository;
public CreateTenantHandler(ITenantRepository repository)
{
this.repository = repository;
}
public async Task<Result<Guid>> HandleAsync(CreateTenantRequest request, CancellationToken ct = default)
{
// Validate required fields.
if (string.IsNullOrWhiteSpace(request.Name))
{
return Result.Failure<Guid>("Tenant name is required.");
}
if (string.IsNullOrWhiteSpace(request.Slug))
{
return Result.Failure<Guid>("Tenant slug is required.");
}
if (request.CreatingUserId == Guid.Empty)
{
return Result.Failure<Guid>("Creating user ID is required.");
}
// Ensure no other tenant already uses this slug. Slugs are used in URLs
// and must be globally unique across the platform.
Tenant? existing = await repository.GetBySlugAsync(request.Slug.ToLowerInvariant(), ct);
if (existing is not null)
{
return Result.Failure<Guid>($"A tenant with slug '{request.Slug}' already exists.");
}
// Create the tenant aggregate and persist it.
Tenant tenant = Tenant.Create(request.Name, request.Slug, request.CreatingUserId);
await repository.AddAsync(tenant, ct);
return Result.Success(tenant.Id);
}
}
public record CreateTenantRequest(string Name, string Slug, Guid CreatingUserId);

View File

@@ -1,41 +0,0 @@
using EntKube.Identity.Domain;
namespace EntKube.Identity.Infrastructure;
/// <summary>
/// In-memory implementation of the tenant repository for local development.
/// Production will use EF Core with PostgreSQL.
/// </summary>
public class InMemoryTenantRepository : ITenantRepository
{
private readonly List<Tenant> tenants = new();
public Task<Tenant?> GetByIdAsync(Guid id, CancellationToken ct = default)
{
Tenant? tenant = tenants.FirstOrDefault(t => t.Id == id);
return Task.FromResult(tenant);
}
public Task<Tenant?> GetBySlugAsync(string slug, CancellationToken ct = default)
{
Tenant? tenant = tenants.FirstOrDefault(t => t.Slug == slug);
return Task.FromResult(tenant);
}
public Task<IReadOnlyList<Tenant>> GetAllAsync(CancellationToken ct = default)
{
IReadOnlyList<Tenant> result = tenants.AsReadOnly();
return Task.FromResult(result);
}
public Task AddAsync(Tenant tenant, CancellationToken ct = default)
{
tenants.Add(tenant);
return Task.CompletedTask;
}
public Task UpdateAsync(Tenant tenant, CancellationToken ct = default)
{
return Task.CompletedTask;
}
}

View File

@@ -1,35 +0,0 @@
using EntKube.Identity.Domain;
using EntKube.Identity.Features.CreateTenant;
using EntKube.Identity.Infrastructure;
namespace EntKube.Identity;
public class Program
{
public static void Main(string[] args)
{
WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
// Register identity and tenant services. In production, this will also
// integrate with Keycloak for authentication token validation and user management.
builder.Services.AddSingleton<ITenantRepository, InMemoryTenantRepository>();
builder.Services.AddScoped<CreateTenantHandler>();
builder.Services.AddOpenApi();
WebApplication app = builder.Build();
if (app.Environment.IsDevelopment())
{
app.MapOpenApi();
}
app.UseHttpsRedirection();
// Map feature endpoints.
CreateTenantEndpoint.Map(app);
app.Run();
}
}

View File

@@ -1,23 +0,0 @@
{
"$schema": "https://json.schemastore.org/launchsettings.json",
"profiles": {
"http": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": false,
"applicationUrl": "http://localhost:5076",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
"https": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": false,
"applicationUrl": "https://localhost:7194;http://localhost:5076",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
}
}
}

View File

@@ -1,8 +0,0 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
}
}

View File

@@ -1,9 +0,0 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*"
}

View File

@@ -1,13 +0,0 @@
namespace EntKube.Provisioning.Domain;
/// <summary>
/// Defines how the provisioning service persists and retrieves service instances.
/// </summary>
public interface IServiceInstanceRepository
{
Task<ServiceInstance?> GetByIdAsync(Guid id, CancellationToken ct = default);
Task<IReadOnlyList<ServiceInstance>> GetByClusterIdAsync(Guid clusterId, CancellationToken ct = default);
Task<IReadOnlyList<ServiceInstance>> GetAllAsync(CancellationToken ct = default);
Task AddAsync(ServiceInstance instance, CancellationToken ct = default);
Task UpdateAsync(ServiceInstance instance, CancellationToken ct = default);
}

View File

@@ -1,95 +0,0 @@
namespace EntKube.Provisioning.Domain;
/// <summary>
/// A ServiceInstance represents a shared Kubernetes application (MinIO, CloudNativePG,
/// Keycloak, etc.) that has been provisioned on a specific cluster. It tracks the
/// service type, desired state, current state, and the cluster it's deployed to.
/// This is the aggregate root for provisioning operations.
/// </summary>
public class ServiceInstance
{
public Guid Id { get; private set; }
public Guid ClusterId { get; private set; }
public ServiceType ServiceType { get; private set; }
public string Name { get; private set; } = string.Empty;
public string Namespace { get; private set; } = string.Empty;
public ServiceState DesiredState { get; private set; }
public ServiceState CurrentState { get; private set; }
public DateTimeOffset CreatedAt { get; private set; }
public DateTimeOffset? LastReconcileAt { get; private set; }
private ServiceInstance() { }
/// <summary>
/// Requests provisioning of a new shared service on a given cluster.
/// The service starts in a Pending state — the reconciliation loop will
/// pick it up and deploy the necessary Helm chart or Kubernetes resources.
/// </summary>
public static ServiceInstance Provision(Guid clusterId, ServiceType serviceType, string name, string ns)
{
if (string.IsNullOrWhiteSpace(name))
{
throw new ArgumentException("Service name is required.", nameof(name));
}
if (string.IsNullOrWhiteSpace(ns))
{
throw new ArgumentException("Namespace is required.", nameof(ns));
}
return new ServiceInstance
{
Id = Guid.NewGuid(),
ClusterId = clusterId,
ServiceType = serviceType,
Name = name,
Namespace = ns,
DesiredState = ServiceState.Running,
CurrentState = ServiceState.Pending,
CreatedAt = DateTimeOffset.UtcNow
};
}
/// <summary>
/// The reconciliation loop calls this after successfully deploying or verifying
/// the service is running on the cluster.
/// </summary>
public void MarkRunning()
{
CurrentState = ServiceState.Running;
LastReconcileAt = DateTimeOffset.UtcNow;
}
/// <summary>
/// When the reconciliation loop detects the service is degraded or unreachable.
/// </summary>
public void MarkDegraded()
{
CurrentState = ServiceState.Degraded;
LastReconcileAt = DateTimeOffset.UtcNow;
}
/// <summary>
/// A tenant requests decommissioning of the service. We set the desired state
/// to Decommissioned and the reconciliation loop will handle teardown.
/// </summary>
public void RequestDecommission()
{
DesiredState = ServiceState.Decommissioned;
}
}
public enum ServiceType
{
MinIO,
CloudNativePG,
Keycloak
}
public enum ServiceState
{
Pending,
Running,
Degraded,
Decommissioned
}

View File

@@ -1,17 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.1" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\EntKube.SharedKernel\EntKube.SharedKernel.csproj" />
</ItemGroup>
</Project>

View File

@@ -1,6 +0,0 @@
@EntKube.Provisioning_HostAddress = http://localhost:5260
GET {{EntKube.Provisioning_HostAddress}}/weatherforecast/
Accept: application/json
###

View File

@@ -1,44 +0,0 @@
using EntKube.Provisioning.Domain;
using EntKube.SharedKernel.Contracts;
using Microsoft.AspNetCore.Mvc;
namespace EntKube.Provisioning.Features.GetServices;
/// <summary>
/// Lists all provisioned service instances. Optionally filtered by cluster.
/// </summary>
public static class GetServicesEndpoint
{
public static void Map(IEndpointRouteBuilder app)
{
app.MapGet("/api/services", async (
[FromQuery] Guid? clusterId,
[FromServices] IServiceInstanceRepository repository,
CancellationToken ct) =>
{
IReadOnlyList<ServiceInstance> instances = clusterId.HasValue
? await repository.GetByClusterIdAsync(clusterId.Value, ct)
: await repository.GetAllAsync(ct);
List<ServiceSummary> summaries = instances.Select(s => new ServiceSummary(
s.Id,
s.ClusterId,
s.ServiceType.ToString(),
s.Name,
s.Namespace,
s.CurrentState.ToString(),
s.DesiredState.ToString())).ToList();
return Results.Ok(ApiResponse<List<ServiceSummary>>.Ok(summaries));
});
}
}
public record ServiceSummary(
Guid Id,
Guid ClusterId,
string ServiceType,
string Name,
string Namespace,
string CurrentState,
string DesiredState);

View File

@@ -1,29 +0,0 @@
using EntKube.Provisioning.Domain;
using EntKube.SharedKernel.Contracts;
using Microsoft.AspNetCore.Mvc;
namespace EntKube.Provisioning.Features.ProvisionService;
/// <summary>
/// Maps POST /api/services — creates a new provisioning request for a shared service.
/// </summary>
public static class ProvisionServiceEndpoint
{
public static void Map(IEndpointRouteBuilder app)
{
app.MapPost("/api/services", async (
[FromBody] ProvisionServiceRequest request,
[FromServices] ProvisionServiceHandler handler,
CancellationToken ct) =>
{
EntKube.SharedKernel.Domain.Result<Guid> result = await handler.HandleAsync(request, ct);
if (result.IsFailure)
{
return Results.BadRequest(ApiResponse<Guid>.Fail(result.Error!));
}
return Results.Created($"/api/services/{result.Value}", ApiResponse<Guid>.Ok(result.Value!));
});
}
}

View File

@@ -1,61 +0,0 @@
using EntKube.Provisioning.Domain;
using EntKube.SharedKernel.Domain;
namespace EntKube.Provisioning.Features.ProvisionService;
/// <summary>
/// Handles requests to provision a new shared service on a cluster. A tenant admin
/// selects a service type (MinIO, CloudNativePG, Keycloak), provides a name and
/// target namespace, and we create the ServiceInstance aggregate. The actual deployment
/// is handled asynchronously by the reconciliation background service.
/// </summary>
public class ProvisionServiceHandler
{
private readonly IServiceInstanceRepository repository;
public ProvisionServiceHandler(IServiceInstanceRepository repository)
{
this.repository = repository;
}
public async Task<Result<Guid>> HandleAsync(ProvisionServiceRequest request, CancellationToken ct = default)
{
// Validate the request contains all required fields.
if (request.ClusterId == Guid.Empty)
{
return Result.Failure<Guid>("Cluster ID is required.");
}
if (string.IsNullOrWhiteSpace(request.Name))
{
return Result.Failure<Guid>("Service name is required.");
}
if (string.IsNullOrWhiteSpace(request.Namespace))
{
return Result.Failure<Guid>("Namespace is required.");
}
// Create the service instance aggregate. The domain enforces any invariants
// about valid service configurations.
ServiceInstance instance = ServiceInstance.Provision(
request.ClusterId,
request.ServiceType,
request.Name,
request.Namespace);
// Persist it. The background reconciliation loop will pick it up and deploy it.
await repository.AddAsync(instance, ct);
return Result.Success(instance.Id);
}
}
public record ProvisionServiceRequest(
Guid ClusterId,
ServiceType ServiceType,
string Name,
string Namespace);

View File

@@ -1,41 +0,0 @@
using EntKube.Provisioning.Domain;
namespace EntKube.Provisioning.Infrastructure;
/// <summary>
/// In-memory implementation of the service instance repository for local development.
/// Production will use EF Core with PostgreSQL.
/// </summary>
public class InMemoryServiceInstanceRepository : IServiceInstanceRepository
{
private readonly List<ServiceInstance> instances = new();
public Task<ServiceInstance?> GetByIdAsync(Guid id, CancellationToken ct = default)
{
ServiceInstance? instance = instances.FirstOrDefault(i => i.Id == id);
return Task.FromResult(instance);
}
public Task<IReadOnlyList<ServiceInstance>> GetByClusterIdAsync(Guid clusterId, CancellationToken ct = default)
{
IReadOnlyList<ServiceInstance> result = instances.Where(i => i.ClusterId == clusterId).ToList().AsReadOnly();
return Task.FromResult(result);
}
public Task<IReadOnlyList<ServiceInstance>> GetAllAsync(CancellationToken ct = default)
{
IReadOnlyList<ServiceInstance> result = instances.AsReadOnly();
return Task.FromResult(result);
}
public Task AddAsync(ServiceInstance instance, CancellationToken ct = default)
{
instances.Add(instance);
return Task.CompletedTask;
}
public Task UpdateAsync(ServiceInstance instance, CancellationToken ct = default)
{
return Task.CompletedTask;
}
}

View File

@@ -1,37 +0,0 @@
using EntKube.Provisioning.Domain;
using EntKube.Provisioning.Features.GetServices;
using EntKube.Provisioning.Features.ProvisionService;
using EntKube.Provisioning.Infrastructure;
namespace EntKube.Provisioning;
public class Program
{
public static void Main(string[] args)
{
WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
// Register provisioning services. The repository is singleton for the in-memory
// implementation; production will use a scoped DbContext-backed repository.
builder.Services.AddSingleton<IServiceInstanceRepository, InMemoryServiceInstanceRepository>();
builder.Services.AddScoped<ProvisionServiceHandler>();
builder.Services.AddOpenApi();
WebApplication app = builder.Build();
if (app.Environment.IsDevelopment())
{
app.MapOpenApi();
}
app.UseHttpsRedirection();
// Map feature endpoints.
ProvisionServiceEndpoint.Map(app);
GetServicesEndpoint.Map(app);
app.Run();
}
}

View File

@@ -1,23 +0,0 @@
{
"$schema": "https://json.schemastore.org/launchsettings.json",
"profiles": {
"http": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": false,
"applicationUrl": "http://localhost:5260",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
"https": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": false,
"applicationUrl": "https://localhost:7085;http://localhost:5260",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
}
}
}

View File

@@ -1,8 +0,0 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
}
}

View File

@@ -1,9 +0,0 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*"
}

View File

@@ -1,26 +0,0 @@
namespace EntKube.SharedKernel.Contracts;
/// <summary>
/// Standard API response envelope used by all EntKube microservices.
/// Every HTTP response wraps its payload in this structure so clients
/// always know where to find the data, error messages, and metadata.
/// </summary>
public record ApiResponse<T>
{
public bool Success { get; init; }
public T? Data { get; init; }
public string? Error { get; init; }
public DateTimeOffset Timestamp { get; init; } = DateTimeOffset.UtcNow;
public static ApiResponse<T> Ok(T data) => new()
{
Success = true,
Data = data
};
public static ApiResponse<T> Fail(string error) => new()
{
Success = false,
Error = error
};
}

View File

@@ -1,24 +0,0 @@
namespace EntKube.SharedKernel.Domain;
/// <summary>
/// Every domain entity in EntKube has a unique identifier. This base class
/// provides that identity and equality comparison so that two entities
/// are considered the same if they share the same Id — regardless of
/// whether their other properties differ.
/// </summary>
public abstract class Entity<TId> where TId : notnull
{
public TId Id { get; protected set; } = default!;
public override bool Equals(object? obj)
{
if (obj is not Entity<TId> other)
{
return false;
}
return Id.Equals(other.Id);
}
public override int GetHashCode() => Id.GetHashCode();
}

View File

@@ -1,43 +0,0 @@
namespace EntKube.SharedKernel.Domain;
/// <summary>
/// A Result represents the outcome of an operation that can either succeed or fail.
/// Instead of throwing exceptions for expected failure scenarios (validation errors,
/// business rule violations, external service failures), we return a Result that
/// the caller can inspect and handle gracefully.
/// </summary>
public class Result
{
public bool IsSuccess { get; }
public string? Error { get; }
public bool IsFailure => !IsSuccess;
protected Result(bool isSuccess, string? error)
{
IsSuccess = isSuccess;
Error = error;
}
public static Result Success() => new(true, null);
public static Result Failure(string error) => new(false, error);
public static Result<T> Success<T>(T value) => new(value, true, null);
public static Result<T> Failure<T>(string error) => new(default, false, error);
}
/// <summary>
/// A typed Result that carries a value on success. When the operation succeeds,
/// the Value property contains the result. When it fails, Error describes what went wrong.
/// </summary>
public class Result<T> : Result
{
public T? Value { get; }
internal Result(T? value, bool isSuccess, string? error)
: base(isSuccess, error)
{
Value = value;
}
}

View File

@@ -1,9 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>

View File

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

View File

@@ -1,23 +0,0 @@
@inherits LayoutComponentBase
<div class="page">
<div class="sidebar">
<NavMenu />
</div>
<main>
<div class="top-row px-4">
<a href="https://learn.microsoft.com/aspnet/core/" target="_blank">About</a>
</div>
<article class="content px-4">
@Body
</article>
</main>
</div>
<div id="blazor-error-ui" data-nosnippet>
An unhandled error has occurred.
<a href="." class="reload">Reload</a>
<span class="dismiss">🗙</span>
</div>

View File

@@ -1,92 +0,0 @@
@implements IDisposable
@inject NavigationManager NavigationManager
<div class="top-row ps-3 navbar navbar-dark">
<div class="container-fluid">
<a class="navbar-brand" href="">EntKube.Web</a>
</div>
</div>
<input type="checkbox" title="Navigation menu" class="navbar-toggler" />
<div class="nav-scrollable" onclick="document.querySelector('.navbar-toggler').click()">
<nav class="nav flex-column">
<div class="nav-item px-3">
<NavLink class="nav-link" href="" Match="NavLinkMatch.All">
<span class="bi bi-house-door-fill-nav-menu" aria-hidden="true"></span> Home
</NavLink>
</div>
<div class="nav-item px-3">
<NavLink class="nav-link" href="counter">
<span class="bi bi-plus-square-fill-nav-menu" aria-hidden="true"></span> Counter
</NavLink>
</div>
<div class="nav-item px-3">
<NavLink class="nav-link" href="weather">
<span class="bi bi-list-nested-nav-menu" aria-hidden="true"></span> Weather
</NavLink>
</div>
<div class="nav-item px-3">
<NavLink class="nav-link" href="auth">
<span class="bi bi-lock-nav-menu" aria-hidden="true"></span> Auth Required
</NavLink>
</div>
<AuthorizeView>
<Authorized>
<div class="nav-item px-3">
<NavLink class="nav-link" href="Account/Manage">
<span class="bi bi-person-fill-nav-menu" aria-hidden="true"></span> @context.User.Identity?.Name
</NavLink>
</div>
<div class="nav-item px-3">
<form action="Account/Logout" method="post">
<AntiforgeryToken />
<input type="hidden" name="ReturnUrl" value="@currentUrl" />
<button type="submit" class="nav-link">
<span class="bi bi-arrow-bar-left-nav-menu" aria-hidden="true"></span> Logout
</button>
</form>
</div>
</Authorized>
<NotAuthorized>
<div class="nav-item px-3">
<NavLink class="nav-link" href="Account/Register">
<span class="bi bi-person-nav-menu" aria-hidden="true"></span> Register
</NavLink>
</div>
<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
</NavLink>
</div>
</NotAuthorized>
</AuthorizeView>
</nav>
</div>
@code {
private string? currentUrl;
protected override void OnInitialized()
{
currentUrl = NavigationManager.ToBaseRelativePath(NavigationManager.Uri);
NavigationManager.LocationChanged += OnLocationChanged;
}
private void OnLocationChanged(object? sender, LocationChangedEventArgs e)
{
currentUrl = NavigationManager.ToBaseRelativePath(e.Location);
StateHasChanged();
}
public void Dispose()
{
NavigationManager.LocationChanged -= OnLocationChanged;
}
}

View File

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

View File

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

View File

@@ -1,7 +0,0 @@
@page "/"
<PageTitle>Home</PageTitle>
<h1>Hello, world!</h1>
Welcome to your new app.

View File

@@ -1,5 +0,0 @@
@page "/not-found"
@layout MainLayout
<h3>Not Found</h3>
<p>Sorry, the content you are looking for does not exist.</p>

View File

@@ -1,63 +0,0 @@
@page "/weather"
<PageTitle>Weather</PageTitle>
<h1>Weather</h1>
<p>This component demonstrates showing data.</p>
@if (forecasts == null)
{
<p><em>Loading...</em></p>
}
else
{
<table class="table">
<thead>
<tr>
<th>Date</th>
<th aria-label="Temperature in Celsius">Temp. (C)</th>
<th aria-label="Temperature in Fahrenheit">Temp. (F)</th>
<th>Summary</th>
</tr>
</thead>
<tbody>
@foreach (var forecast in forecasts)
{
<tr>
<td>@forecast.Date.ToShortDateString()</td>
<td>@forecast.TemperatureC</td>
<td>@forecast.TemperatureF</td>
<td>@forecast.Summary</td>
</tr>
}
</tbody>
</table>
}
@code {
private WeatherForecast[]? forecasts;
protected override async Task OnInitializedAsync()
{
// Simulate asynchronous loading to demonstrate a loading indicator
await Task.Delay(500);
var startDate = DateOnly.FromDateTime(DateTime.Now);
var summaries = new[] { "Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching" };
forecasts = Enumerable.Range(1, 5).Select(index => new WeatherForecast
{
Date = startDate.AddDays(index),
TemperatureC = Random.Shared.Next(-20, 55),
Summary = summaries[Random.Shared.Next(summaries.Length)]
}).ToArray();
}
private class WeatherForecast
{
public DateOnly Date { get; set; }
public int TemperatureC { get; set; }
public string? Summary { get; set; }
public int TemperatureF => 32 + (int)(TemperatureC / 0.5556);
}
}

View File

@@ -4,6 +4,5 @@ var builder = WebAssemblyHostBuilder.CreateDefault(args);
builder.Services.AddAuthorizationCore();
builder.Services.AddCascadingAuthenticationState();
builder.Services.AddAuthenticationStateDeserialization();
await builder.Build().RunAsync();

View File

@@ -8,4 +8,3 @@
@using Microsoft.AspNetCore.Components.Web.Virtualization
@using Microsoft.JSInterop
@using EntKube.Web.Client
@using EntKube.Web.Client.Layout

View File

@@ -0,0 +1,32 @@
using System.Security.Claims;
using EntKube.Web.Data;
using Microsoft.AspNetCore.Authorization;
using Microsoft.EntityFrameworkCore;
namespace EntKube.Web.Authorization;
public class HasTenantAccessRequirement : IAuthorizationRequirement { }
public class HasTenantAccessHandler(IDbContextFactory<ApplicationDbContext> dbFactory)
: AuthorizationHandler<HasTenantAccessRequirement>
{
protected override async Task HandleRequirementAsync(
AuthorizationHandlerContext context,
HasTenantAccessRequirement requirement)
{
if (context.User.Identity?.IsAuthenticated != true) return;
if (context.User.IsInRole("Admin"))
{
context.Succeed(requirement);
return;
}
string? userId = context.User.FindFirstValue(ClaimTypes.NameIdentifier);
if (userId is null) return;
using ApplicationDbContext db = dbFactory.CreateDbContext();
if (await db.TenantMemberships.AnyAsync(m => m.UserId == userId))
context.Succeed(requirement);
}
}

View File

@@ -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);

View File

@@ -1,5 +1,5 @@
@inherits LayoutComponentBase
@layout EntKube.Web.Client.Layout.MainLayout
@layout EntKube.Web.Components.Layout.MainLayout
<h1>Manage your account</h1>

View File

@@ -7,26 +7,21 @@
<base href="/" />
<ResourcePreloader />
<link rel="stylesheet" href="@Assets["lib/bootstrap/dist/css/bootstrap.min.css"]" />
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css" />
<link rel="stylesheet" href="@Assets["app.css"]" />
<link rel="stylesheet" href="@Assets["EntKube.Web.styles.css"]" />
<ImportMap />
<link rel="icon" type="image/png" href="favicon.png" />
<HeadOutlet @rendermode="PageRenderMode" />
<HeadOutlet />
</head>
<body>
<Routes @rendermode="PageRenderMode" />
<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>
@code {
[CascadingParameter]
private HttpContext HttpContext { get; set; } = default!;
private IComponentRenderMode? PageRenderMode =>
HttpContext.AcceptsInteractiveRouting() ? InteractiveAuto : null;
}

View File

@@ -0,0 +1,33 @@
@inherits LayoutComponentBase
@inject NavigationManager NavigationManager
<div class="page">
<div class="sidebar">
<NavMenu />
</div>
<main>
<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">
@Body
</article>
</main>
</div>
<div id="blazor-error-ui" data-nosnippet>
An unhandled error has occurred.
<a href="." class="reload">Reload</a>
<span class="dismiss">🗙</span>
</div>

View File

@@ -0,0 +1,139 @@
@implements IDisposable
@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">
<a class="navbar-brand" href="">EntKube</a>
</div>
</div>
<input type="checkbox" title="Navigation menu" class="navbar-toggler" />
<div class="nav-scrollable" onclick="document.querySelector('.navbar-toggler').click()">
<nav class="nav flex-column">
<div class="nav-item px-3">
<NavLink class="nav-link" href="" Match="NavLinkMatch.All">
<span class="bi bi-house-door-fill-nav-menu" aria-hidden="true"></span> Home
</NavLink>
</div>
@if (showTenantsLink)
{
<div class="nav-item px-3">
<NavLink class="nav-link" href="tenants">
<span class="bi bi-list-nested-nav-menu" aria-hidden="true"></span> Tenants
</NavLink>
</div>
}
<div class="nav-item px-3">
<NavLink class="nav-link" href="portal">
<span class="bi bi-grid-nav-menu" aria-hidden="true"></span> Portal
</NavLink>
</div>
<div class="nav-item px-3">
<NavLink class="nav-link" href="portal/status">
<span class="bi bi-activity-nav-menu" aria-hidden="true"></span> Status
</NavLink>
</div>
<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>
<AuthorizeView>
<Authorized>
<div class="nav-item px-3">
<NavLink class="nav-link" href="Account/Manage">
<span class="bi bi-person-fill-nav-menu" aria-hidden="true"></span> @context.User.Identity?.Name
</NavLink>
</div>
<div class="nav-item px-3">
<form action="Account/Logout" method="post">
<AntiforgeryToken />
<input type="hidden" name="ReturnUrl" value="@currentUrl" />
<button type="submit" class="nav-link">
<span class="bi bi-arrow-bar-left-nav-menu" aria-hidden="true"></span> Logout
</button>
</form>
</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
</NavLink>
</div>
</NotAuthorized>
</AuthorizeView>
</nav>
</div>
@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;
if (user.Identity?.IsAuthenticated == true)
{
string? userId = user.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value;
if (userId is not null)
showTenantsLink = await UserAccessService.HasAnyAccessAsync(userId);
}
}
private void OnLocationChanged(object? sender, LocationChangedEventArgs e)
{
currentUrl = NavigationManager.ToBaseRelativePath(e.Location);
StateHasChanged();
}
public void Dispose()
{
NavigationManager.LocationChanged -= OnLocationChanged;
}
}

View File

@@ -66,6 +66,14 @@
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' class='bi bi-arrow-bar-left' viewBox='0 0 16 16'%3E%3Cpath d='M12.5 15a.5.5 0 0 1-.5-.5v-13a.5.5 0 0 1 1 0v13a.5.5 0 0 1-.5.5ZM10 8a.5.5 0 0 1-.5.5H3.707l2.147 2.146a.5.5 0 0 1-.708.708l-3-3a.5.5 0 0 1 0-.708l3-3a.5.5 0 1 1 .708.708L3.707 7.5H9.5a.5.5 0 0 1 .5.5Z'/%3E%3C/svg%3E");
}
.bi-activity-nav-menu {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' class='bi bi-activity' viewBox='0 0 16 16'%3E%3Cpath fill-rule='evenodd' d='M6 2a.5.5 0 0 1 .47.33L10 12.036l1.53-4.208A.5.5 0 0 1 12 7.5h3.5a.5.5 0 0 1 0 1h-3.15l-1.88 5.17a.5.5 0 0 1-.94 0L6 3.964 4.47 8.171A.5.5 0 0 1 4 8.5H.5a.5.5 0 0 1 0-1h3.15l1.88-5.17A.5.5 0 0 1 6 2Z'/%3E%3C/svg%3E");
}
.bi-grid-nav-menu {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' class='bi bi-grid' viewBox='0 0 16 16'%3E%3Cpath d='M1 2.5A1.5 1.5 0 0 1 2.5 1h3A1.5 1.5 0 0 1 7 2.5v3A1.5 1.5 0 0 1 5.5 7h-3A1.5 1.5 0 0 1 1 5.5v-3zM2.5 2a.5.5 0 0 0-.5.5v3a.5.5 0 0 0 .5.5h3a.5.5 0 0 0 .5-.5v-3a.5.5 0 0 0-.5-.5h-3zm6.5.5A1.5 1.5 0 0 1 10.5 1h3A1.5 1.5 0 0 1 15 2.5v3A1.5 1.5 0 0 1 13.5 7h-3A1.5 1.5 0 0 1 9 5.5v-3zm1.5-.5a.5.5 0 0 0-.5.5v3a.5.5 0 0 0 .5.5h3a.5.5 0 0 0 .5-.5v-3a.5.5 0 0 0-.5-.5h-3zM1 10.5A1.5 1.5 0 0 1 2.5 9h3A1.5 1.5 0 0 1 7 10.5v3A1.5 1.5 0 0 1 5.5 15h-3A1.5 1.5 0 0 1 1 13.5v-3zm1.5-.5a.5.5 0 0 0-.5.5v3a.5.5 0 0 0 .5.5h3a.5.5 0 0 0 .5-.5v-3a.5.5 0 0 0-.5-.5h-3zm6.5.5A1.5 1.5 0 0 1 10.5 9h3a1.5 1.5 0 0 1 1.5 1.5v3a1.5 1.5 0 0 1-1.5 1.5h-3A1.5 1.5 0 0 1 9 13.5v-3z'/%3E%3C/svg%3E");
}
.nav-item {
font-size: 0.9rem;
padding-bottom: 0.5rem;

View File

@@ -1,4 +1,4 @@
<script type="module" src="@Assets["Layout/ReconnectModal.razor.js"]"></script>
<script type="module" src="@Assets["Components/Layout/ReconnectModal.razor.js"]"></script>
<dialog id="components-reconnect-modal" data-nosnippet>
<div class="components-reconnect-container">

View File

@@ -0,0 +1,107 @@
@page "/admin/backup"
@using Microsoft.AspNetCore.Authorization
@using EntKube.Web.Services
@rendermode InteractiveServer
@attribute [Authorize(Roles = "Admin")]
@inject NavigationManager Nav
<PageTitle>Backup & Restore</PageTitle>
<div class="d-flex align-items-center justify-content-between mb-4">
<div>
<h1 class="mb-0"><i class="bi bi-arrow-repeat me-2 text-primary"></i>Backup & Restore</h1>
<p class="text-muted small mb-0 mt-1">Export the full app state or restore from a previous backup.</p>
</div>
</div>
<div class="row g-4">
@* ── Export ── *@
<div class="col-md-6">
<div class="card border-0 shadow-sm h-100">
<div class="card-body">
<h5 class="card-title mb-1"><i class="bi bi-download me-2 text-success"></i>Export Backup</h5>
<p class="text-muted small mb-3">
Downloads a <code>.json.gz</code> archive containing all tenants, apps,
clusters, deployments, and decrypted secrets. Store it securely — it
contains plaintext credentials.
</p>
<a href="/api/admin/backup" class="btn btn-success" target="_blank">
<i class="bi bi-download me-1"></i>Download Backup
</a>
</div>
</div>
</div>
@* ── Restore ── *@
<div class="col-md-6">
<div class="card border-0 shadow-sm h-100">
<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> 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">
<input type="file" name="bundle" class="form-control" required />
</div>
<div class="form-check mb-3">
@* name+value on the checkbox so the browser submits "wipe=true" when it is checked *@
<input class="form-check-input" type="checkbox" name="wipe" value="true" id="wipeCheck"
@onchange="e => wipeExisting = (bool)(e.Value ?? false)" />
<label class="form-check-label" for="wipeCheck">
Wipe all existing data before restoring
</label>
</div>
@if (wipeExisting)
{
<div class="alert alert-danger py-2 small mb-3">
<i class="bi bi-exclamation-triangle-fill me-1"></i>
<strong>All current data will be permanently deleted</strong> before the
backup is loaded. This cannot be undone. Only proceed on a fresh
installation or when you intend a full replacement.
</div>
}
<button type="submit" class="btn btn-warning">
<i class="bi bi-upload me-1"></i>Restore
</button>
</form>
</div>
</div>
</div>
</div>
@if (!string.IsNullOrEmpty(successMessage))
{
<div class="alert alert-success mt-4">
<i class="bi bi-check-circle-fill me-2"></i>@successMessage
</div>
}
@if (!string.IsNullOrEmpty(errorMessage))
{
<div class="alert alert-danger mt-4">
<i class="bi bi-exclamation-triangle-fill me-2"></i>@errorMessage
</div>
}
@code {
private bool wipeExisting;
private string? successMessage;
private string? errorMessage;
protected override void OnInitialized()
{
var uri = new Uri(Nav.Uri);
var query = Microsoft.AspNetCore.WebUtilities.QueryHelpers.ParseQuery(uri.Query);
if (query.TryGetValue("success", out var s)) successMessage = s;
if (query.TryGetValue("error", out var e)) errorMessage = e;
}
}

View File

@@ -0,0 +1,499 @@
@page "/admin/notifications"
@using Microsoft.AspNetCore.Authorization
@using System.Text.Json
@using System.Security.Claims
@using EntKube.Web.Data
@using EntKube.Web.Services
@rendermode InteractiveServer
@attribute [Authorize(Roles = "Admin")]
@inject NotificationProviderConfigService ProviderConfigService
@inject AuthenticationStateProvider AuthState
<PageTitle>Notification Settings</PageTitle>
<div class="d-flex align-items-center justify-content-between mb-4">
<div>
<h1 class="mb-0"><i class="bi bi-bell-fill me-2 text-primary"></i>Notification Settings</h1>
<p class="text-muted small mb-0 mt-1">Configure global notification providers used by tenant alert channels.</p>
</div>
</div>
@if (loading)
{
<div class="d-flex justify-content-center py-5">
<div class="spinner-border text-primary" role="status"></div>
</div>
}
else
{
<div class="row g-4">
<!-- SMTP -->
<div class="col-12 col-xl-6">
<div class="card border-0 shadow-sm h-100">
<div class="card-header bg-transparent d-flex align-items-center gap-2">
<i class="bi bi-envelope-fill text-warning fs-5"></i>
<h5 class="mb-0">SMTP / Email</h5>
@if (smtpConfig is not null)
{
<span class="badge @(smtpConfig.IsEnabled ? "bg-success" : "bg-secondary") ms-auto">@(smtpConfig.IsEnabled ? "Enabled" : "Disabled")</span>
}
else
{
<span class="badge bg-light text-dark ms-auto">Not configured</span>
}
</div>
<div class="card-body">
<p class="text-muted small">Configure an SMTP server so email notification channels can deliver alerts.</p>
<div class="mb-3">
<label class="form-label fw-semibold">Host</label>
<input class="form-control" @bind="smtpHost" placeholder="smtp.example.com" />
</div>
<div class="row g-2 mb-3">
<div class="col-4">
<label class="form-label fw-semibold">Port</label>
<input class="form-control" type="number" @bind="smtpPort" />
</div>
<div class="col-8">
<label class="form-label fw-semibold">From Address</label>
<input class="form-control" type="email" @bind="smtpFrom" placeholder="alerts@example.com" />
</div>
</div>
<div class="mb-3">
<label class="form-label fw-semibold">Username</label>
<input class="form-control" @bind="smtpUsername" placeholder="(optional)" autocomplete="username" />
</div>
<div class="mb-3">
<label class="form-label fw-semibold">Password</label>
<input class="form-control" type="password" @bind="smtpPassword" placeholder="@(smtpConfig is not null ? "•••••••• (leave blank to keep current)" : "(optional)")" autocomplete="current-password" />
</div>
<div class="mb-3 form-check form-switch">
<input class="form-check-input" type="checkbox" role="switch" id="smtpSsl" @bind="smtpEnableSsl" />
<label class="form-check-label" for="smtpSsl">Enable SSL/TLS</label>
</div>
<div class="mb-3 form-check form-switch">
<input class="form-check-input" type="checkbox" role="switch" id="smtpEnabled" @bind="smtpEnabled" />
<label class="form-check-label" for="smtpEnabled">Enabled</label>
</div>
@if (!string.IsNullOrEmpty(smtpError))
{
<div class="alert alert-danger py-2 small">@smtpError</div>
}
@if (!string.IsNullOrEmpty(smtpSuccess))
{
<div class="alert alert-success py-2 small">@smtpSuccess</div>
}
<div class="d-flex gap-2 mt-3">
<button class="btn btn-primary" @onclick="SaveSmtp" disabled="@smtpBusy">
@if (smtpBusy) { <span class="spinner-border spinner-border-sm me-1"></span> }
Save
</button>
<div class="input-group" style="max-width:260px">
<input class="form-control form-control-sm" @bind="smtpTestRecipient" placeholder="test@example.com" />
<button class="btn btn-outline-secondary btn-sm" @onclick="TestSmtp" disabled="@smtpBusy">
Test
</button>
</div>
@if (smtpConfig is not null)
{
<button class="btn btn-outline-danger ms-auto" @onclick="DeleteSmtp" disabled="@smtpBusy">Remove</button>
}
</div>
</div>
</div>
</div>
<!-- MS Teams via Microsoft Graph -->
<div class="col-12 col-xl-6">
<div class="card border-0 shadow-sm h-100">
<div class="card-header bg-transparent d-flex align-items-center gap-2">
<i class="bi bi-microsoft-teams text-primary fs-5"></i>
<h5 class="mb-0">Microsoft Teams (Graph API)</h5>
@if (teamsConfig is not null)
{
<span class="badge @(teamsConfig.IsEnabled ? "bg-success" : "bg-secondary") ms-auto">@(teamsConfig.IsEnabled ? "Enabled" : "Disabled")</span>
}
else
{
<span class="badge bg-light text-dark ms-auto">Not configured</span>
}
</div>
<div class="card-body">
<p class="text-muted small">
Send alerts directly to Teams channels using the Microsoft Graph API.
Requires an Azure AD app registration with <code>ChannelMessage.Send</code> application permission.
</p>
<div class="alert alert-info py-2 small mb-3">
<i class="bi bi-info-circle me-1"></i>
Once configured, add a Teams notification channel to a tenant and supply a <strong>Team ID</strong> and <strong>Channel ID</strong> instead of a webhook URL.
</div>
<div class="mb-3">
<label class="form-label fw-semibold">Tenant ID (Directory ID)</label>
<input class="form-control font-monospace" @bind="teamsTenantId" placeholder="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" />
</div>
<div class="mb-3">
<label class="form-label fw-semibold">Client ID (Application ID)</label>
<input class="form-control font-monospace" @bind="teamsClientId" placeholder="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" />
</div>
<div class="mb-3">
<label class="form-label fw-semibold">Client Secret</label>
<input class="form-control" type="password" @bind="teamsClientSecret"
placeholder="@(teamsConfig is not null ? "•••••••• (leave blank to keep current)" : "")"
autocomplete="current-password" />
</div>
<div class="mb-3 form-check form-switch">
<input class="form-check-input" type="checkbox" role="switch" id="teamsEnabled" @bind="teamsEnabled" />
<label class="form-check-label" for="teamsEnabled">Enabled</label>
</div>
@if (!string.IsNullOrEmpty(teamsError))
{
<div class="alert alert-danger py-2 small">@teamsError</div>
}
@if (!string.IsNullOrEmpty(teamsSuccess))
{
<div class="alert alert-success py-2 small">@teamsSuccess</div>
}
<div class="d-flex gap-2 mt-3">
<button class="btn btn-primary" @onclick="SaveTeams" disabled="@teamsBusy">
@if (teamsBusy) { <span class="spinner-border spinner-border-sm me-1"></span> }
Save
</button>
<button class="btn btn-outline-secondary" @onclick="TestTeams" disabled="@teamsBusy">
Test Credentials
</button>
@if (teamsConfig is not null)
{
<button class="btn btn-outline-danger ms-auto" @onclick="DeleteTeams" disabled="@teamsBusy">Remove</button>
}
</div>
</div>
</div>
</div>
<!-- Slack info card -->
<div class="col-12 col-xl-6">
<div class="card border-0 shadow-sm bg-light">
<div class="card-body">
<div class="d-flex align-items-center gap-2 mb-2">
<i class="bi bi-slack text-success fs-5"></i>
<h6 class="mb-0">Slack</h6>
<span class="badge bg-success ms-auto">Ready</span>
</div>
<p class="text-muted small mb-0">
Slack notifications use <strong>Incoming Webhooks</strong> — no global configuration needed.
Each tenant notification channel stores its own webhook URL.
Create webhooks at <strong>api.slack.com/apps</strong>.
</p>
</div>
</div>
</div>
<!-- Webhook info card -->
<div class="col-12 col-xl-6">
<div class="card border-0 shadow-sm bg-light">
<div class="card-body">
<div class="d-flex align-items-center gap-2 mb-2">
<i class="bi bi-link-45deg text-secondary fs-5"></i>
<h6 class="mb-0">Generic Webhook</h6>
<span class="badge bg-success ms-auto">Ready</span>
</div>
<p class="text-muted small mb-0">
Webhook channels post a JSON payload to any URL and optionally authenticate with a Bearer token.
No global configuration required — configure per-channel in the tenant settings.
</p>
</div>
</div>
</div>
</div>
}
@code {
private bool loading = true;
private string? currentUserId;
// SMTP state
private NotificationProviderConfig? smtpConfig;
private string smtpHost = "";
private int smtpPort = 587;
private string smtpFrom = "";
private string smtpUsername = "";
private string smtpPassword = "";
private bool smtpEnableSsl = true;
private bool smtpEnabled = true;
private string smtpTestRecipient = "";
private bool smtpBusy;
private string? smtpError;
private string? smtpSuccess;
// Teams state
private NotificationProviderConfig? teamsConfig;
private string teamsTenantId = "";
private string teamsClientId = "";
private string teamsClientSecret = "";
private bool teamsEnabled = true;
private bool teamsBusy;
private string? teamsError;
private string? teamsSuccess;
protected override async Task OnInitializedAsync()
{
AuthenticationState authState = await AuthState.GetAuthenticationStateAsync();
currentUserId = authState.User.FindFirst(ClaimTypes.NameIdentifier)?.Value;
await LoadAsync();
loading = false;
}
private async Task LoadAsync()
{
smtpConfig = await ProviderConfigService.GetAsync(NotificationProviderType.Smtp);
teamsConfig = await ProviderConfigService.GetAsync(NotificationProviderType.MsTeamsGraph);
if (smtpConfig is not null)
{
using JsonDocument doc = JsonDocument.Parse(smtpConfig.ConfigurationJson);
JsonElement root = doc.RootElement;
smtpHost = root.TryGetProperty("host", out var h) ? h.GetString() ?? "" : "";
smtpPort = root.TryGetProperty("port", out var p) ? p.GetInt32() : 587;
smtpFrom = root.TryGetProperty("from", out var f) ? f.GetString() ?? "" : "";
smtpUsername = root.TryGetProperty("username", out var u) ? u.GetString() ?? "" : "";
smtpEnableSsl = !root.TryGetProperty("enableSsl", out var ssl) || ssl.GetBoolean();
smtpEnabled = smtpConfig.IsEnabled;
smtpPassword = "";
}
if (teamsConfig is not null)
{
using JsonDocument doc = JsonDocument.Parse(teamsConfig.ConfigurationJson);
JsonElement root = doc.RootElement;
teamsTenantId = root.TryGetProperty("tenantId", out var t) ? t.GetString() ?? "" : "";
teamsClientId = root.TryGetProperty("clientId", out var c) ? c.GetString() ?? "" : "";
teamsEnabled = teamsConfig.IsEnabled;
teamsClientSecret = "";
}
}
private async Task SaveSmtp()
{
smtpError = null;
smtpSuccess = null;
if (string.IsNullOrWhiteSpace(smtpHost))
{
smtpError = "Host is required.";
return;
}
smtpBusy = true;
try
{
string password = smtpPassword;
if (string.IsNullOrEmpty(password) && smtpConfig is not null)
{
using JsonDocument existing = JsonDocument.Parse(smtpConfig.ConfigurationJson);
password = existing.RootElement.TryGetProperty("password", out var pw) ? pw.GetString() ?? "" : "";
}
string configJson = JsonSerializer.Serialize(new
{
host = smtpHost.Trim(),
port = smtpPort,
from = smtpFrom.Trim(),
username = smtpUsername.Trim(),
password,
enableSsl = smtpEnableSsl
});
await ProviderConfigService.SaveAsync(NotificationProviderType.Smtp, configJson, smtpEnabled, currentUserId);
smtpSuccess = "SMTP configuration saved.";
await LoadAsync();
}
catch (Exception ex)
{
smtpError = ex.Message;
}
finally
{
smtpBusy = false;
}
}
private async Task TestSmtp()
{
smtpError = null;
smtpSuccess = null;
if (string.IsNullOrWhiteSpace(smtpTestRecipient))
{
smtpError = "Enter a test recipient email address.";
return;
}
smtpBusy = true;
try
{
string password = smtpPassword;
if (string.IsNullOrEmpty(password) && smtpConfig is not null)
{
using JsonDocument existing = JsonDocument.Parse(smtpConfig.ConfigurationJson);
password = existing.RootElement.TryGetProperty("password", out var pw) ? pw.GetString() ?? "" : "";
}
string configJson = JsonSerializer.Serialize(new
{
host = smtpHost.Trim(),
port = smtpPort,
from = smtpFrom.Trim(),
username = smtpUsername.Trim(),
password,
enableSsl = smtpEnableSsl
});
await ProviderConfigService.TestSmtpAsync(configJson, smtpTestRecipient.Trim());
smtpSuccess = $"Test email sent to {smtpTestRecipient}.";
}
catch (Exception ex)
{
smtpError = $"Test failed: {ex.Message}";
}
finally
{
smtpBusy = false;
}
}
private async Task DeleteSmtp()
{
smtpBusy = true;
smtpError = null;
smtpSuccess = null;
try
{
await ProviderConfigService.DeleteAsync(NotificationProviderType.Smtp);
smtpHost = ""; smtpPort = 587; smtpFrom = ""; smtpUsername = ""; smtpPassword = ""; smtpEnableSsl = true; smtpEnabled = true;
await LoadAsync();
smtpSuccess = "SMTP configuration removed.";
}
finally
{
smtpBusy = false;
}
}
private async Task SaveTeams()
{
teamsError = null;
teamsSuccess = null;
if (string.IsNullOrWhiteSpace(teamsTenantId) || string.IsNullOrWhiteSpace(teamsClientId))
{
teamsError = "Tenant ID and Client ID are required.";
return;
}
teamsBusy = true;
try
{
string secret = teamsClientSecret;
if (string.IsNullOrEmpty(secret) && teamsConfig is not null)
{
using JsonDocument existing = JsonDocument.Parse(teamsConfig.ConfigurationJson);
secret = existing.RootElement.TryGetProperty("clientSecret", out var s) ? s.GetString() ?? "" : "";
}
if (string.IsNullOrEmpty(secret))
{
teamsError = "Client Secret is required.";
return;
}
string configJson = JsonSerializer.Serialize(new
{
tenantId = teamsTenantId.Trim(),
clientId = teamsClientId.Trim(),
clientSecret = secret
});
await ProviderConfigService.SaveAsync(NotificationProviderType.MsTeamsGraph, configJson, teamsEnabled, currentUserId);
teamsSuccess = "Teams Graph configuration saved.";
await LoadAsync();
}
catch (Exception ex)
{
teamsError = ex.Message;
}
finally
{
teamsBusy = false;
}
}
private async Task TestTeams()
{
teamsError = null;
teamsSuccess = null;
if (string.IsNullOrWhiteSpace(teamsTenantId) || string.IsNullOrWhiteSpace(teamsClientId))
{
teamsError = "Tenant ID and Client ID are required.";
return;
}
teamsBusy = true;
try
{
string secret = teamsClientSecret;
if (string.IsNullOrEmpty(secret) && teamsConfig is not null)
{
using JsonDocument existing = JsonDocument.Parse(teamsConfig.ConfigurationJson);
secret = existing.RootElement.TryGetProperty("clientSecret", out var s) ? s.GetString() ?? "" : "";
}
if (string.IsNullOrEmpty(secret))
{
teamsError = "Client Secret is required.";
return;
}
string configJson = JsonSerializer.Serialize(new
{
tenantId = teamsTenantId.Trim(),
clientId = teamsClientId.Trim(),
clientSecret = secret
});
await ProviderConfigService.TestMsTeamsGraphAsync(configJson);
teamsSuccess = "Successfully authenticated with Azure AD. Credentials are valid.";
}
catch (Exception ex)
{
teamsError = $"Test failed: {ex.Message}";
}
finally
{
teamsBusy = false;
}
}
private async Task DeleteTeams()
{
teamsBusy = true;
teamsError = null;
teamsSuccess = null;
try
{
await ProviderConfigService.DeleteAsync(NotificationProviderType.MsTeamsGraph);
teamsTenantId = ""; teamsClientId = ""; teamsClientSecret = ""; teamsEnabled = true;
await LoadAsync();
teamsSuccess = "Teams Graph configuration removed.";
}
finally
{
teamsBusy = false;
}
}
}

View File

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

View File

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

View File

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

View File

@@ -1,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]

View File

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

View File

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

View File

@@ -0,0 +1,325 @@
@using EntKube.Web.Data
@using EntKube.Web.Services
@inject AppGovernanceService GovernanceService
@inject CustomerGitService CustomerGitService
@* ═══════════════════════════════════════════════════════════════════
AppGovernancePanel — read-only portal view of an app's governance
settings (namespace, quota, network policies, RBAC).
Customers can see what limits apply to their app but cannot change them.
═══════════════════════════════════════════════════════════════════ *@
@if (loading)
{
<div class="text-center py-4"><div class="spinner-border spinner-border-sm text-primary"></div></div>
}
else
{
<div class="d-flex align-items-center mb-3">
<i class="bi bi-shield-check fs-5 me-2 text-primary"></i>
<div>
<strong>@AppName — Resource Policy</strong>
<div class="text-muted small">Set by your platform administrator. Contact them to request changes.</div>
</div>
</div>
@* Namespace *@
@if (!string.IsNullOrWhiteSpace(data?.Namespace))
{
<div class="card shadow-sm mb-3">
<div class="card-header bg-white py-2">
<i class="bi bi-box me-2 text-primary"></i>
<strong class="small">Namespace</strong>
</div>
<div class="card-body py-2">
<div class="d-flex align-items-center gap-2">
<i class="bi bi-lock-fill text-warning small"></i>
<span class="small">Your app runs in namespace <code>@data.Namespace</code>.</span>
</div>
</div>
</div>
}
@* Quota *@
<div class="card shadow-sm mb-3">
<div class="card-header bg-white py-2">
<i class="bi bi-speedometer me-2 text-warning"></i>
<strong class="small">Resource Quota</strong>
</div>
<div class="card-body">
@if (data?.Quota is not { } q)
{
<p class="text-muted small mb-0">No quota restrictions configured.</p>
}
else
{
<table class="table table-sm mb-0">
<tbody>
@if (!string.IsNullOrWhiteSpace(q.CpuRequest) || !string.IsNullOrWhiteSpace(q.CpuLimit))
{
<tr>
<td class="small text-muted w-50">CPU</td>
<td class="small font-monospace">
request @(q.CpuRequest ?? "—") / limit @(q.CpuLimit ?? "—")
</td>
</tr>
}
@if (!string.IsNullOrWhiteSpace(q.MemoryRequest) || !string.IsNullOrWhiteSpace(q.MemoryLimit))
{
<tr>
<td class="small text-muted">Memory</td>
<td class="small font-monospace">
request @(q.MemoryRequest ?? "—") / limit @(q.MemoryLimit ?? "—")
</td>
</tr>
}
@if (q.MaxPods.HasValue)
{
<tr>
<td class="small text-muted">Max pods</td>
<td class="small font-monospace">@q.MaxPods</td>
</tr>
}
@if (q.MaxPvcs.HasValue)
{
<tr>
<td class="small text-muted">Max PVCs</td>
<td class="small font-monospace">@q.MaxPvcs</td>
</tr>
}
</tbody>
</table>
}
</div>
</div>
@* Network policies *@
<div class="card shadow-sm mb-3">
<div class="card-header bg-white py-2">
<i class="bi bi-diagram-2 me-2 text-info"></i>
<strong class="small">Network Policies</strong>
</div>
<div class="card-body">
@if (data?.NetworkPolicies is not { Count: > 0 } policies)
{
<p class="text-muted small mb-0">No network policies — namespace inherits cluster defaults.</p>
}
else
{
<div class="d-flex flex-wrap gap-2">
@foreach (AppNetworkPolicy np in policies)
{
<span class="badge border text-secondary d-flex align-items-center gap-1"
style="background:white;font-size:.72rem;">
<i class="bi @NpIcon(np.PolicyType)"></i>
<span>@NpTypeLabel(np.PolicyType)</span>
@if (np.PolicyType == AppNetworkPolicyType.AllowFromNamespace
&& !string.IsNullOrWhiteSpace(np.AllowFromNamespace))
{
<code class="ms-1">@np.AllowFromNamespace</code>
}
</span>
}
</div>
}
</div>
</div>
@* RBAC *@
<div class="card shadow-sm">
<div class="card-header bg-white py-2">
<i class="bi bi-person-badge me-2 text-success"></i>
<strong class="small">RBAC</strong>
</div>
<div class="card-body">
@if (data?.RbacPolicy is not { } rbac)
{
<p class="text-muted small mb-0">No service account or RBAC configured.</p>
}
else
{
<div class="mb-2">
<label class="form-label small fw-medium">Service account</label>
<input class="form-control form-control-sm font-monospace" value="@rbac.ServiceAccountName" disabled />
</div>
@if (rbac.Rules.Count > 0)
{
<table class="table table-sm mb-0">
<thead class="table-light">
<tr>
<th class="small">API groups</th>
<th class="small">Resources</th>
<th class="small">Verbs</th>
</tr>
</thead>
<tbody>
@foreach (AppRbacRule rule in rbac.Rules)
{
<tr>
<td class="small align-middle"><code>@(string.IsNullOrEmpty(rule.ApiGroups) ? "core" : rule.ApiGroups)</code></td>
<td class="small align-middle"><code>@rule.Resources</code></td>
<td class="small align-middle"><code>@rule.Verbs</code></td>
</tr>
}
</tbody>
</table>
}
else
{
<p class="text-muted small mb-0">Service account exists with no permissions (no-op role).</p>
}
}
</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, EnvironmentId);
if (CustomerId != Guid.Empty)
gitPolicies = await CustomerGitService.GetRepoPoliciesAsync(CustomerId, EnvironmentId);
loading = false;
}
private static string NpTypeLabel(AppNetworkPolicyType t) => t switch
{
AppNetworkPolicyType.DenyAll => "Deny all",
AppNetworkPolicyType.AllowFromIngress => "Allow ingress",
AppNetworkPolicyType.AllowFromSameNamespace => "Allow same ns",
AppNetworkPolicyType.AllowFromNamespace => "Allow from ns",
AppNetworkPolicyType.Custom => "Custom",
_ => t.ToString()
};
private static string NpIcon(AppNetworkPolicyType t) => t switch
{
AppNetworkPolicyType.DenyAll => "bi-slash-circle",
AppNetworkPolicyType.AllowFromIngress or AppNetworkPolicyType.AllowFromNamespace or AppNetworkPolicyType.AllowFromSameNamespace => "bi-check-circle",
_ => "bi-code-slash"
};
private static string AllowedDbTypeLabel(AppAllowedDatabase entry)
{
if (entry.CnpgDatabase is not null) return "PostgreSQL";
if (entry.MongoDatabase is not null) return "MongoDB";
if (entry.RegisteredPostgresDatabase is not null) return "Postgres";
return "Database";
}
private static string AllowedDbName(AppAllowedDatabase entry)
{
if (entry.CnpgDatabase is not null) return entry.CnpgDatabase.Name;
if (entry.MongoDatabase is not null) return entry.MongoDatabase.Name;
if (entry.RegisteredPostgresDatabase is not null) return entry.RegisteredPostgresDatabase.Name;
return "—";
}
}

View File

@@ -0,0 +1,165 @@
@using EntKube.Web.Data
@using EntKube.Web.Services
@inject IncidentService IncidentService
@if (isLoading)
{
<div class="text-center py-3 text-muted small">
<span class="bi bi-hourglass-split me-1"></span> Loading incident history…
</div>
}
else if (incidents.Count == 0)
{
<div class="text-center py-4 text-muted">
<span class="bi bi-check-circle-fill text-success d-block mb-1" style="font-size:1.5rem"></span>
No incidents in the last @WindowDays days.
</div>
}
else
{
<div class="d-flex gap-2 mb-2 flex-wrap align-items-center">
<select class="form-select form-select-sm" style="width:auto" @bind="filterSeverity" @bind:after="ApplyFilter">
<option value="">All severities</option>
<option value="critical">Critical</option>
<option value="warning">Warning</option>
<option value="info">Info</option>
</select>
<select class="form-select form-select-sm" style="width:auto" @bind="filterStatus" @bind:after="ApplyFilter">
<option value="">All statuses</option>
<option value="Active">Active</option>
<option value="Acknowledged">Acknowledged</option>
<option value="Resolved">Resolved</option>
</select>
<span class="ms-auto text-muted small">@filtered.Count of @incidents.Count incident@(incidents.Count == 1 ? "" : "s")</span>
</div>
<div class="list-group list-group-flush">
@foreach (AlertIncident incident in filtered.Take(50))
{
bool expanded = expandedId == incident.Id;
<div class="list-group-item list-group-item-action px-0 py-2"
style="cursor:pointer" @onclick="() => Toggle(incident.Id)">
<div class="d-flex align-items-center gap-2">
@SeverityDot(incident.Severity)
<div class="flex-grow-1 min-width-0">
<div class="fw-semibold small text-truncate">@incident.AlertName</div>
@if (!string.IsNullOrEmpty(incident.Summary))
{
<div class="text-muted" style="font-size:0.78rem">@incident.Summary</div>
}
</div>
<div class="text-end text-nowrap ms-2">
@StatusBadge(incident.Status)
<div class="text-muted mt-1" style="font-size:0.75rem">
@incident.StartsAt.ToLocalTime().ToString("MMM d HH:mm")
</div>
</div>
<span class="bi bi-chevron-@(expanded ? "up" : "down") text-muted small"></span>
</div>
@if (expanded)
{
<div class="mt-2 ps-3 border-start border-2" @onclick:stopPropagation="true">
@if (!string.IsNullOrEmpty(incident.Description))
{
<p class="small text-muted mb-1">@incident.Description</p>
}
<div class="small text-muted">
<span class="me-3">
<span class="bi bi-hdd-rack me-1"></span>@incident.Cluster?.Name
</span>
<span class="me-3">
<span class="bi bi-clock me-1"></span>Started @incident.StartsAt.ToLocalTime().ToString("MMM d yyyy HH:mm")
</span>
@if (incident.EndsAt.HasValue || incident.ResolvedAt.HasValue)
{
DateTime resolved = incident.ResolvedAt ?? incident.EndsAt!.Value;
TimeSpan dur = resolved - incident.StartsAt;
<span>Duration: @FormatDuration(dur)</span>
}
</div>
@if (incident.AcknowledgedBy is not null)
{
<div class="small text-muted mt-1">
Acknowledged by <strong>@incident.AcknowledgedBy</strong>
@if (incident.AcknowledgedAt.HasValue)
{
<span> at @incident.AcknowledgedAt.Value.ToLocalTime().ToString("HH:mm")</span>
}
</div>
}
@if (incident.Notes.Count > 0)
{
<div class="mt-2">
@foreach (IncidentNote note in incident.Notes)
{
<div class="small border-start border-2 ps-2 mb-1">
<span class="text-muted">@note.Author · @note.CreatedAt.ToLocalTime().ToString("MMM d HH:mm")</span>
<div>@note.Content</div>
</div>
}
</div>
}
</div>
}
</div>
}
</div>
@if (incidents.Count > 50)
{
<div class="text-muted small text-center mt-2">Showing 50 of @incidents.Count incidents</div>
}
}
@code {
[Parameter] public required List<AppDeployment> Deployments { get; set; }
[Parameter] public int WindowDays { get; set; } = 30;
private List<AlertIncident> incidents = [];
private List<AlertIncident> filtered = [];
private Guid? expandedId;
private string filterSeverity = "";
private string filterStatus = "";
private bool isLoading = true;
protected override async Task OnParametersSetAsync() => await Load();
private async Task Load()
{
if (Deployments.Count == 0) { isLoading = false; return; }
isLoading = true;
incidents = await IncidentService.GetIncidentsForDeploymentsAsync(Deployments, WindowDays);
ApplyFilter();
isLoading = false;
}
private void ApplyFilter()
{
filtered = incidents.Where(i =>
(string.IsNullOrEmpty(filterSeverity) || i.Severity == filterSeverity) &&
(string.IsNullOrEmpty(filterStatus) || i.Status.ToString() == filterStatus)
).ToList();
}
private void Toggle(Guid id) => expandedId = expandedId == id ? null : id;
private static string FormatDuration(TimeSpan d) =>
d.TotalDays >= 1 ? $"{(int)d.TotalDays}d {d.Hours}h"
: d.TotalHours >= 1 ? $"{(int)d.TotalHours}h {d.Minutes}m"
: $"{(int)d.TotalMinutes}m";
private static RenderFragment SeverityDot(string severity) => severity switch
{
"critical" => @<span style="width:10px;height:10px;border-radius:50%;background:#dc3545;flex-shrink:0;display:inline-block"></span>,
"warning" => @<span style="width:10px;height:10px;border-radius:50%;background:#ffc107;flex-shrink:0;display:inline-block"></span>,
_ => @<span style="width:10px;height:10px;border-radius:50%;background:#0dcaf0;flex-shrink:0;display:inline-block"></span>
};
private static RenderFragment StatusBadge(IncidentStatus status) => status switch
{
IncidentStatus.Active => @<span class="badge bg-danger">Active</span>,
IncidentStatus.Acknowledged => @<span class="badge bg-warning text-dark">Acked</span>,
IncidentStatus.Resolved => @<span class="badge bg-success">Resolved</span>,
_ => @<span class="badge bg-secondary">@status</span>
};
}

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