Compare commits
9 Commits
a96dd33039
...
main
| Author | SHA256 | Date | |
|---|---|---|---|
|
|
5da40698ad | ||
|
|
343de77810 | ||
|
|
ffe41b4413 | ||
|
|
76b7e55931 | ||
|
|
8dc46ef031 | ||
|
|
658f15d086 | ||
|
|
e0f967482e | ||
|
|
8b94fb8826 | ||
|
|
328d494530 |
7
.claude/settings.json
Normal file
7
.claude/settings.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(grep -E \"\\\\.\\(cs|razor\\)$\")"
|
||||
]
|
||||
}
|
||||
}
|
||||
19
.dockerignore
Normal file
19
.dockerignore
Normal 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
26
.env.example
Normal file
@@ -0,0 +1,26 @@
|
||||
# Copy this file to .env and fill in the values before running docker compose.
|
||||
|
||||
# --- Domain / TLS ---
|
||||
# Public domain pointing at this server. Caddy will obtain a Let's Encrypt
|
||||
# certificate automatically on first start. Port 80 and 443 must be reachable.
|
||||
DOMAIN=entkube.example.com
|
||||
|
||||
# Email used for Let's Encrypt account registration and expiry notices.
|
||||
ACME_EMAIL=admin@example.com
|
||||
|
||||
# --- Registry ---
|
||||
# Registry to push/pull the entkube image.
|
||||
# Authenticate before running compose: docker login ${REGISTRY} -u ${REGISTRY_USERNAME} -p ${REGISTRY_PASSWORD}
|
||||
REGISTRY=entit.azurecr.io
|
||||
IMAGE_TAG=latest
|
||||
REGISTRY_USERNAME=<acr-username-or-service-principal-id>
|
||||
REGISTRY_PASSWORD=<acr-password-or-service-principal-secret>
|
||||
|
||||
# --- Postgres ---
|
||||
# Password used by both the postgres service and the app connection string.
|
||||
POSTGRES_PASSWORD=changeme
|
||||
|
||||
# --- Vault encryption ---
|
||||
# 32-byte base64 key for secret encryption.
|
||||
# Generate with: openssl rand -base64 32
|
||||
VAULT__ROOTKEY=REPLACE_WITH_BASE64_32_BYTE_KEY
|
||||
@@ -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
|
||||
@@ -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
69
.github/workflows/deploy.yml
vendored
Normal file
@@ -0,0 +1,69 @@
|
||||
name: Build and Deploy
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
REGISTRY: entit.azurecr.io
|
||||
IMAGE: entit.azurecr.io/entkube
|
||||
|
||||
jobs:
|
||||
build-and-push:
|
||||
name: Build and push image
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
image-tag: ${{ steps.meta.outputs.version }}
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to ACR
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ secrets.REGISTRY_USERNAME }}
|
||||
password: ${{ secrets.REGISTRY_PASSWORD }}
|
||||
|
||||
- name: Extract image metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ env.IMAGE }}
|
||||
tags: |
|
||||
type=sha,prefix=,format=short
|
||||
type=raw,value=latest,enable={{is_default_branch}}
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
deploy:
|
||||
name: Deploy to server
|
||||
needs: build-and-push
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Pull and restart via SSH
|
||||
uses: appleboy/ssh-action@v1
|
||||
with:
|
||||
host: ${{ secrets.SERVER_HOST }}
|
||||
username: ${{ secrets.SERVER_USER }}
|
||||
key: ${{ secrets.SSH_PRIVATE_KEY }}
|
||||
script: |
|
||||
cd ${{ secrets.COMPOSE_DIR }}
|
||||
echo "${{ secrets.REGISTRY_PASSWORD }}" | docker login ${{ secrets.REGISTRY_HOST }} -u "${{ secrets.REGISTRY_USERNAME }}" --password-stdin
|
||||
docker compose pull
|
||||
docker compose up -d --remove-orphans
|
||||
docker image prune -f
|
||||
14
.gitignore
vendored
14
.gitignore
vendored
@@ -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
22
.vscode/launch.json
vendored
Normal 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
27
.vscode/tasks.json
vendored
Normal 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
7
Caddyfile
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
email {env.ACME_EMAIL}
|
||||
}
|
||||
|
||||
{env.DOMAIN} {
|
||||
reverse_proxy entkube:8080
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
# Patterns to ignore when packaging the chart
|
||||
.DS_Store
|
||||
*.tgz
|
||||
.git/
|
||||
@@ -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
|
||||
@@ -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 }}
|
||||
@@ -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 }}
|
||||
@@ -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 }}
|
||||
@@ -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 }}
|
||||
@@ -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 }}
|
||||
@@ -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 }}
|
||||
@@ -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 }}
|
||||
@@ -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 }}
|
||||
@@ -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 }}
|
||||
@@ -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 }}
|
||||
@@ -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 }}
|
||||
@@ -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
5
Directory.Build.props
Normal file
@@ -0,0 +1,5 @@
|
||||
<Project>
|
||||
<PropertyGroup>
|
||||
<EnableSourceControlManagerQueries>false</EnableSourceControlManagerQueries>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
60
Dockerfile
Normal file
60
Dockerfile
Normal 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"]
|
||||
15
EntKube.slnx
15
EntKube.slnx
@@ -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>
|
||||
|
||||
53
README.md
53
README.md
@@ -42,6 +42,59 @@ EntKube/
|
||||
└── EntKube.Client/ # WebAssembly client project
|
||||
```
|
||||
|
||||
## Docker
|
||||
|
||||
### Build and push
|
||||
|
||||
> **Apple Silicon (M-series) Mac:** servers are typically `linux/amd64`. Use `docker buildx` to cross-compile and push in one step — the `--push` flag is required because multi-platform images cannot be loaded into the local Docker daemon.
|
||||
|
||||
```bash
|
||||
# One-time setup
|
||||
docker buildx create --use --name multiarch
|
||||
|
||||
# Build for amd64 and push directly to the registry
|
||||
docker buildx build --platform linux/amd64 \
|
||||
-t entit.azurecr.io/entkube:latest \
|
||||
--push .
|
||||
|
||||
# Tag a versioned release
|
||||
docker buildx build --platform linux/amd64 \
|
||||
-t entit.azurecr.io/entkube:latest \
|
||||
-t entit.azurecr.io/entkube:1.0.0 \
|
||||
--push .
|
||||
```
|
||||
|
||||
Replace `entit.azurecr.io/entkube` with your actual registry and image path.
|
||||
|
||||
### Run with Docker Compose
|
||||
|
||||
Copy `.env.example` to `.env` and fill in the required values, then authenticate with the registry and start the stack:
|
||||
|
||||
```bash
|
||||
# Authenticate with the container registry (once per machine / token expiry)
|
||||
docker login entit.azurecr.io -u $REGISTRY_USERNAME -p $REGISTRY_PASSWORD
|
||||
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
The app will be available at `http://localhost:8080`.
|
||||
|
||||
> **Data safety** — `docker compose down` does **not** delete the database volume.
|
||||
> Only `docker compose down -v` removes volumes.
|
||||
|
||||
### Required configuration
|
||||
|
||||
| Variable | Description |
|
||||
|---|---|
|
||||
| `DOMAIN` | Public domain pointing at the server (e.g. `entkube.example.com`). |
|
||||
| `ACME_EMAIL` | Email for Let's Encrypt account and expiry notices. |
|
||||
| `Vault__RootKey` | 32-byte base64 key for secret encryption. Generate with `openssl rand -base64 32`. |
|
||||
| `POSTGRES_PASSWORD` | Password for the Postgres database (set in `.env`). |
|
||||
|
||||
### TLS / HTTPS
|
||||
|
||||
Caddy is the reverse proxy and handles TLS automatically. On first start it obtains a Let's Encrypt certificate for `DOMAIN` and renews it automatically before it expires. Ports **80** and **443** must be open and your DNS must point at the server before starting the stack.
|
||||
|
||||
## License
|
||||
|
||||
See [LICENSE](LICENSE).
|
||||
|
||||
81
docker-compose.yml
Normal file
81
docker-compose.yml
Normal file
@@ -0,0 +1,81 @@
|
||||
services:
|
||||
caddy:
|
||||
image: caddy:2
|
||||
ports:
|
||||
- "80:80"
|
||||
- "443:443"
|
||||
- "443:443/udp"
|
||||
volumes:
|
||||
- ./Caddyfile:/etc/caddy/Caddyfile:ro
|
||||
- caddy-data:/data
|
||||
- caddy-config:/config
|
||||
environment:
|
||||
DOMAIN: "${DOMAIN}"
|
||||
ACME_EMAIL: "${ACME_EMAIL}"
|
||||
depends_on:
|
||||
- entkube
|
||||
restart: unless-stopped
|
||||
|
||||
postgres:
|
||||
image: postgres:17
|
||||
environment:
|
||||
POSTGRES_DB: entkube
|
||||
POSTGRES_USER: entkube
|
||||
POSTGRES_PASSWORD: "${POSTGRES_PASSWORD:-changeme}"
|
||||
volumes:
|
||||
- postgres-data:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U entkube -d entkube"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
start_period: 10s
|
||||
restart: unless-stopped
|
||||
|
||||
entkube:
|
||||
image: ${REGISTRY:-entit.azurecr.io}/entkube:${IMAGE_TAG:-latest}
|
||||
volumes:
|
||||
- entkube-data:/app/Data
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
# --- Database ---
|
||||
DatabaseProvider: Postgres
|
||||
ConnectionStrings__DefaultConnection: "Host=postgres;Port=5432;Database=entkube;Username=entkube;Password=${POSTGRES_PASSWORD:-changeme}"
|
||||
|
||||
# --- Vault encryption ---
|
||||
# Required — set VAULT__ROOTKEY in your .env file (openssl rand -base64 32)
|
||||
Vault__RootKey: "${VAULT__ROOTKEY}"
|
||||
|
||||
# --- Reverse proxy ---
|
||||
# Tells ASP.NET Core to trust X-Forwarded-For / X-Forwarded-Proto from Caddy.
|
||||
# Required for Blazor Server SignalR (WebSocket) to work correctly behind a proxy.
|
||||
ASPNETCORE_FORWARDEDHEADERS_ENABLED: "true"
|
||||
|
||||
# --- Auth ---
|
||||
# Set to false after the first admin account is created.
|
||||
Auth__AllowRegistration: "true"
|
||||
|
||||
# --- Temporary: log all HTTP requests so we can see 404s ---
|
||||
Logging__LogLevel__Microsoft__AspNetCore__Hosting__Diagnostics: "Information"
|
||||
|
||||
# --- Bootstrap ---
|
||||
# Optional. If set, this user is granted the Admin role on every startup
|
||||
# (no-op if already admin). Useful for recovering access.
|
||||
# Seed__AdminEmail: "admin@example.com"
|
||||
|
||||
# --- Email / SMTP (optional) ---
|
||||
# Smtp__Host: "smtp.example.com"
|
||||
# Smtp__Username: "user"
|
||||
# Smtp__Password: "password"
|
||||
# Smtp__FromAddress: "alerts@entkube.io"
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
# Named volumes are NOT removed by "docker compose down".
|
||||
# Data is only deleted if you explicitly run "docker compose down -v".
|
||||
postgres-data:
|
||||
entkube-data:
|
||||
caddy-data:
|
||||
caddy-config:
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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>
|
||||
@@ -1,6 +0,0 @@
|
||||
@EntKube.Clusters_HostAddress = http://localhost:5243
|
||||
|
||||
GET {{EntKube.Clusters_HostAddress}}/weatherforecast/
|
||||
Accept: application/json
|
||||
|
||||
###
|
||||
@@ -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);
|
||||
@@ -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!));
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
},
|
||||
"AllowedHosts": "*"
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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>
|
||||
@@ -1,6 +0,0 @@
|
||||
@EntKube.Identity_HostAddress = http://localhost:5076
|
||||
|
||||
GET {{EntKube.Identity_HostAddress}}/weatherforecast/
|
||||
Accept: application/json
|
||||
|
||||
###
|
||||
@@ -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!));
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
},
|
||||
"AllowedHosts": "*"
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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>
|
||||
@@ -1,6 +0,0 @@
|
||||
@EntKube.Provisioning_HostAddress = http://localhost:5260
|
||||
|
||||
GET {{EntKube.Provisioning_HostAddress}}/weatherforecast/
|
||||
Accept: application/json
|
||||
|
||||
###
|
||||
@@ -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);
|
||||
@@ -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!));
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
},
|
||||
"AllowedHosts": "*"
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
</Project>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
@using Microsoft.AspNetCore.Authorization
|
||||
|
||||
@attribute [Authorize]
|
||||
@rendermode InteractiveServer
|
||||
|
||||
<PageTitle>Auth</PageTitle>
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
@page "/counter"
|
||||
@rendermode InteractiveServer
|
||||
|
||||
<PageTitle>Counter</PageTitle>
|
||||
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
@page "/"
|
||||
|
||||
<PageTitle>Home</PageTitle>
|
||||
|
||||
<h1>Hello, world!</h1>
|
||||
|
||||
Welcome to your new app.
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,5 @@ var builder = WebAssemblyHostBuilder.CreateDefault(args);
|
||||
|
||||
builder.Services.AddAuthorizationCore();
|
||||
builder.Services.AddCascadingAuthenticationState();
|
||||
builder.Services.AddAuthenticationStateDeserialization();
|
||||
|
||||
await builder.Build().RunAsync();
|
||||
|
||||
@@ -8,4 +8,3 @@
|
||||
@using Microsoft.AspNetCore.Components.Web.Virtualization
|
||||
@using Microsoft.JSInterop
|
||||
@using EntKube.Web.Client
|
||||
@using EntKube.Web.Client.Layout
|
||||
|
||||
32
src/EntKube.Web/Authorization/HasTenantAccessRequirement.cs
Normal file
32
src/EntKube.Web/Authorization/HasTenantAccessRequirement.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -14,12 +14,20 @@
|
||||
@inject ILogger<Register> Logger
|
||||
@inject NavigationManager NavigationManager
|
||||
@inject IdentityRedirectManager RedirectManager
|
||||
@inject IConfiguration Configuration
|
||||
|
||||
<PageTitle>Register</PageTitle>
|
||||
|
||||
<h1>Register</h1>
|
||||
@if (!allowRegistration)
|
||||
{
|
||||
<h1>Registration Closed</h1>
|
||||
<p class="text-muted">Self-registration is currently disabled. Please contact an administrator to get access.</p>
|
||||
}
|
||||
else
|
||||
{
|
||||
<h1>Register</h1>
|
||||
|
||||
<div class="row">
|
||||
<div class="row">
|
||||
<div class="col-lg-6">
|
||||
<StatusMessage Message="@Message" />
|
||||
<EditForm Model="Input" method="post" OnValidSubmit="RegisterUser" FormName="register">
|
||||
@@ -52,10 +60,12 @@
|
||||
<ExternalLoginPicker />
|
||||
</section>
|
||||
</div>
|
||||
</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);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
@inherits LayoutComponentBase
|
||||
@layout EntKube.Web.Client.Layout.MainLayout
|
||||
@layout EntKube.Web.Components.Layout.MainLayout
|
||||
|
||||
<h1>Manage your account</h1>
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
33
src/EntKube.Web/Components/Layout/MainLayout.razor
Normal file
33
src/EntKube.Web/Components/Layout/MainLayout.razor
Normal 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>
|
||||
139
src/EntKube.Web/Components/Layout/NavMenu.razor
Normal file
139
src/EntKube.Web/Components/Layout/NavMenu.razor
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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">
|
||||
107
src/EntKube.Web/Components/Pages/Admin/AdminBackup.razor
Normal file
107
src/EntKube.Web/Components/Pages/Admin/AdminBackup.razor
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,499 @@
|
||||
@page "/admin/notifications"
|
||||
@using Microsoft.AspNetCore.Authorization
|
||||
@using System.Text.Json
|
||||
@using System.Security.Claims
|
||||
@using EntKube.Web.Data
|
||||
@using EntKube.Web.Services
|
||||
@rendermode InteractiveServer
|
||||
@attribute [Authorize(Roles = "Admin")]
|
||||
|
||||
@inject NotificationProviderConfigService ProviderConfigService
|
||||
@inject AuthenticationStateProvider AuthState
|
||||
|
||||
<PageTitle>Notification Settings</PageTitle>
|
||||
|
||||
<div class="d-flex align-items-center justify-content-between mb-4">
|
||||
<div>
|
||||
<h1 class="mb-0"><i class="bi bi-bell-fill me-2 text-primary"></i>Notification Settings</h1>
|
||||
<p class="text-muted small mb-0 mt-1">Configure global notification providers used by tenant alert channels.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (loading)
|
||||
{
|
||||
<div class="d-flex justify-content-center py-5">
|
||||
<div class="spinner-border text-primary" role="status"></div>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="row g-4">
|
||||
|
||||
<!-- SMTP -->
|
||||
<div class="col-12 col-xl-6">
|
||||
<div class="card border-0 shadow-sm h-100">
|
||||
<div class="card-header bg-transparent d-flex align-items-center gap-2">
|
||||
<i class="bi bi-envelope-fill text-warning fs-5"></i>
|
||||
<h5 class="mb-0">SMTP / Email</h5>
|
||||
@if (smtpConfig is not null)
|
||||
{
|
||||
<span class="badge @(smtpConfig.IsEnabled ? "bg-success" : "bg-secondary") ms-auto">@(smtpConfig.IsEnabled ? "Enabled" : "Disabled")</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="badge bg-light text-dark ms-auto">Not configured</span>
|
||||
}
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p class="text-muted small">Configure an SMTP server so email notification channels can deliver alerts.</p>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-semibold">Host</label>
|
||||
<input class="form-control" @bind="smtpHost" placeholder="smtp.example.com" />
|
||||
</div>
|
||||
<div class="row g-2 mb-3">
|
||||
<div class="col-4">
|
||||
<label class="form-label fw-semibold">Port</label>
|
||||
<input class="form-control" type="number" @bind="smtpPort" />
|
||||
</div>
|
||||
<div class="col-8">
|
||||
<label class="form-label fw-semibold">From Address</label>
|
||||
<input class="form-control" type="email" @bind="smtpFrom" placeholder="alerts@example.com" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-semibold">Username</label>
|
||||
<input class="form-control" @bind="smtpUsername" placeholder="(optional)" autocomplete="username" />
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-semibold">Password</label>
|
||||
<input class="form-control" type="password" @bind="smtpPassword" placeholder="@(smtpConfig is not null ? "•••••••• (leave blank to keep current)" : "(optional)")" autocomplete="current-password" />
|
||||
</div>
|
||||
<div class="mb-3 form-check form-switch">
|
||||
<input class="form-check-input" type="checkbox" role="switch" id="smtpSsl" @bind="smtpEnableSsl" />
|
||||
<label class="form-check-label" for="smtpSsl">Enable SSL/TLS</label>
|
||||
</div>
|
||||
<div class="mb-3 form-check form-switch">
|
||||
<input class="form-check-input" type="checkbox" role="switch" id="smtpEnabled" @bind="smtpEnabled" />
|
||||
<label class="form-check-label" for="smtpEnabled">Enabled</label>
|
||||
</div>
|
||||
|
||||
@if (!string.IsNullOrEmpty(smtpError))
|
||||
{
|
||||
<div class="alert alert-danger py-2 small">@smtpError</div>
|
||||
}
|
||||
@if (!string.IsNullOrEmpty(smtpSuccess))
|
||||
{
|
||||
<div class="alert alert-success py-2 small">@smtpSuccess</div>
|
||||
}
|
||||
|
||||
<div class="d-flex gap-2 mt-3">
|
||||
<button class="btn btn-primary" @onclick="SaveSmtp" disabled="@smtpBusy">
|
||||
@if (smtpBusy) { <span class="spinner-border spinner-border-sm me-1"></span> }
|
||||
Save
|
||||
</button>
|
||||
<div class="input-group" style="max-width:260px">
|
||||
<input class="form-control form-control-sm" @bind="smtpTestRecipient" placeholder="test@example.com" />
|
||||
<button class="btn btn-outline-secondary btn-sm" @onclick="TestSmtp" disabled="@smtpBusy">
|
||||
Test
|
||||
</button>
|
||||
</div>
|
||||
@if (smtpConfig is not null)
|
||||
{
|
||||
<button class="btn btn-outline-danger ms-auto" @onclick="DeleteSmtp" disabled="@smtpBusy">Remove</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- MS Teams via Microsoft Graph -->
|
||||
<div class="col-12 col-xl-6">
|
||||
<div class="card border-0 shadow-sm h-100">
|
||||
<div class="card-header bg-transparent d-flex align-items-center gap-2">
|
||||
<i class="bi bi-microsoft-teams text-primary fs-5"></i>
|
||||
<h5 class="mb-0">Microsoft Teams (Graph API)</h5>
|
||||
@if (teamsConfig is not null)
|
||||
{
|
||||
<span class="badge @(teamsConfig.IsEnabled ? "bg-success" : "bg-secondary") ms-auto">@(teamsConfig.IsEnabled ? "Enabled" : "Disabled")</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="badge bg-light text-dark ms-auto">Not configured</span>
|
||||
}
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p class="text-muted small">
|
||||
Send alerts directly to Teams channels using the Microsoft Graph API.
|
||||
Requires an Azure AD app registration with <code>ChannelMessage.Send</code> application permission.
|
||||
</p>
|
||||
<div class="alert alert-info py-2 small mb-3">
|
||||
<i class="bi bi-info-circle me-1"></i>
|
||||
Once configured, add a Teams notification channel to a tenant and supply a <strong>Team ID</strong> and <strong>Channel ID</strong> instead of a webhook URL.
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-semibold">Tenant ID (Directory ID)</label>
|
||||
<input class="form-control font-monospace" @bind="teamsTenantId" placeholder="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" />
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-semibold">Client ID (Application ID)</label>
|
||||
<input class="form-control font-monospace" @bind="teamsClientId" placeholder="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" />
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-semibold">Client Secret</label>
|
||||
<input class="form-control" type="password" @bind="teamsClientSecret"
|
||||
placeholder="@(teamsConfig is not null ? "•••••••• (leave blank to keep current)" : "")"
|
||||
autocomplete="current-password" />
|
||||
</div>
|
||||
<div class="mb-3 form-check form-switch">
|
||||
<input class="form-check-input" type="checkbox" role="switch" id="teamsEnabled" @bind="teamsEnabled" />
|
||||
<label class="form-check-label" for="teamsEnabled">Enabled</label>
|
||||
</div>
|
||||
|
||||
@if (!string.IsNullOrEmpty(teamsError))
|
||||
{
|
||||
<div class="alert alert-danger py-2 small">@teamsError</div>
|
||||
}
|
||||
@if (!string.IsNullOrEmpty(teamsSuccess))
|
||||
{
|
||||
<div class="alert alert-success py-2 small">@teamsSuccess</div>
|
||||
}
|
||||
|
||||
<div class="d-flex gap-2 mt-3">
|
||||
<button class="btn btn-primary" @onclick="SaveTeams" disabled="@teamsBusy">
|
||||
@if (teamsBusy) { <span class="spinner-border spinner-border-sm me-1"></span> }
|
||||
Save
|
||||
</button>
|
||||
<button class="btn btn-outline-secondary" @onclick="TestTeams" disabled="@teamsBusy">
|
||||
Test Credentials
|
||||
</button>
|
||||
@if (teamsConfig is not null)
|
||||
{
|
||||
<button class="btn btn-outline-danger ms-auto" @onclick="DeleteTeams" disabled="@teamsBusy">Remove</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Slack info card -->
|
||||
<div class="col-12 col-xl-6">
|
||||
<div class="card border-0 shadow-sm bg-light">
|
||||
<div class="card-body">
|
||||
<div class="d-flex align-items-center gap-2 mb-2">
|
||||
<i class="bi bi-slack text-success fs-5"></i>
|
||||
<h6 class="mb-0">Slack</h6>
|
||||
<span class="badge bg-success ms-auto">Ready</span>
|
||||
</div>
|
||||
<p class="text-muted small mb-0">
|
||||
Slack notifications use <strong>Incoming Webhooks</strong> — no global configuration needed.
|
||||
Each tenant notification channel stores its own webhook URL.
|
||||
Create webhooks at <strong>api.slack.com/apps</strong>.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Webhook info card -->
|
||||
<div class="col-12 col-xl-6">
|
||||
<div class="card border-0 shadow-sm bg-light">
|
||||
<div class="card-body">
|
||||
<div class="d-flex align-items-center gap-2 mb-2">
|
||||
<i class="bi bi-link-45deg text-secondary fs-5"></i>
|
||||
<h6 class="mb-0">Generic Webhook</h6>
|
||||
<span class="badge bg-success ms-auto">Ready</span>
|
||||
</div>
|
||||
<p class="text-muted small mb-0">
|
||||
Webhook channels post a JSON payload to any URL and optionally authenticate with a Bearer token.
|
||||
No global configuration required — configure per-channel in the tenant settings.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
}
|
||||
|
||||
@code {
|
||||
private bool loading = true;
|
||||
private string? currentUserId;
|
||||
|
||||
// SMTP state
|
||||
private NotificationProviderConfig? smtpConfig;
|
||||
private string smtpHost = "";
|
||||
private int smtpPort = 587;
|
||||
private string smtpFrom = "";
|
||||
private string smtpUsername = "";
|
||||
private string smtpPassword = "";
|
||||
private bool smtpEnableSsl = true;
|
||||
private bool smtpEnabled = true;
|
||||
private string smtpTestRecipient = "";
|
||||
private bool smtpBusy;
|
||||
private string? smtpError;
|
||||
private string? smtpSuccess;
|
||||
|
||||
// Teams state
|
||||
private NotificationProviderConfig? teamsConfig;
|
||||
private string teamsTenantId = "";
|
||||
private string teamsClientId = "";
|
||||
private string teamsClientSecret = "";
|
||||
private bool teamsEnabled = true;
|
||||
private bool teamsBusy;
|
||||
private string? teamsError;
|
||||
private string? teamsSuccess;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
AuthenticationState authState = await AuthState.GetAuthenticationStateAsync();
|
||||
currentUserId = authState.User.FindFirst(ClaimTypes.NameIdentifier)?.Value;
|
||||
|
||||
await LoadAsync();
|
||||
loading = false;
|
||||
}
|
||||
|
||||
private async Task LoadAsync()
|
||||
{
|
||||
smtpConfig = await ProviderConfigService.GetAsync(NotificationProviderType.Smtp);
|
||||
teamsConfig = await ProviderConfigService.GetAsync(NotificationProviderType.MsTeamsGraph);
|
||||
|
||||
if (smtpConfig is not null)
|
||||
{
|
||||
using JsonDocument doc = JsonDocument.Parse(smtpConfig.ConfigurationJson);
|
||||
JsonElement root = doc.RootElement;
|
||||
smtpHost = root.TryGetProperty("host", out var h) ? h.GetString() ?? "" : "";
|
||||
smtpPort = root.TryGetProperty("port", out var p) ? p.GetInt32() : 587;
|
||||
smtpFrom = root.TryGetProperty("from", out var f) ? f.GetString() ?? "" : "";
|
||||
smtpUsername = root.TryGetProperty("username", out var u) ? u.GetString() ?? "" : "";
|
||||
smtpEnableSsl = !root.TryGetProperty("enableSsl", out var ssl) || ssl.GetBoolean();
|
||||
smtpEnabled = smtpConfig.IsEnabled;
|
||||
smtpPassword = "";
|
||||
}
|
||||
|
||||
if (teamsConfig is not null)
|
||||
{
|
||||
using JsonDocument doc = JsonDocument.Parse(teamsConfig.ConfigurationJson);
|
||||
JsonElement root = doc.RootElement;
|
||||
teamsTenantId = root.TryGetProperty("tenantId", out var t) ? t.GetString() ?? "" : "";
|
||||
teamsClientId = root.TryGetProperty("clientId", out var c) ? c.GetString() ?? "" : "";
|
||||
teamsEnabled = teamsConfig.IsEnabled;
|
||||
teamsClientSecret = "";
|
||||
}
|
||||
}
|
||||
|
||||
private async Task SaveSmtp()
|
||||
{
|
||||
smtpError = null;
|
||||
smtpSuccess = null;
|
||||
if (string.IsNullOrWhiteSpace(smtpHost))
|
||||
{
|
||||
smtpError = "Host is required.";
|
||||
return;
|
||||
}
|
||||
|
||||
smtpBusy = true;
|
||||
try
|
||||
{
|
||||
string password = smtpPassword;
|
||||
if (string.IsNullOrEmpty(password) && smtpConfig is not null)
|
||||
{
|
||||
using JsonDocument existing = JsonDocument.Parse(smtpConfig.ConfigurationJson);
|
||||
password = existing.RootElement.TryGetProperty("password", out var pw) ? pw.GetString() ?? "" : "";
|
||||
}
|
||||
|
||||
string configJson = JsonSerializer.Serialize(new
|
||||
{
|
||||
host = smtpHost.Trim(),
|
||||
port = smtpPort,
|
||||
from = smtpFrom.Trim(),
|
||||
username = smtpUsername.Trim(),
|
||||
password,
|
||||
enableSsl = smtpEnableSsl
|
||||
});
|
||||
|
||||
await ProviderConfigService.SaveAsync(NotificationProviderType.Smtp, configJson, smtpEnabled, currentUserId);
|
||||
smtpSuccess = "SMTP configuration saved.";
|
||||
await LoadAsync();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
smtpError = ex.Message;
|
||||
}
|
||||
finally
|
||||
{
|
||||
smtpBusy = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task TestSmtp()
|
||||
{
|
||||
smtpError = null;
|
||||
smtpSuccess = null;
|
||||
if (string.IsNullOrWhiteSpace(smtpTestRecipient))
|
||||
{
|
||||
smtpError = "Enter a test recipient email address.";
|
||||
return;
|
||||
}
|
||||
|
||||
smtpBusy = true;
|
||||
try
|
||||
{
|
||||
string password = smtpPassword;
|
||||
if (string.IsNullOrEmpty(password) && smtpConfig is not null)
|
||||
{
|
||||
using JsonDocument existing = JsonDocument.Parse(smtpConfig.ConfigurationJson);
|
||||
password = existing.RootElement.TryGetProperty("password", out var pw) ? pw.GetString() ?? "" : "";
|
||||
}
|
||||
|
||||
string configJson = JsonSerializer.Serialize(new
|
||||
{
|
||||
host = smtpHost.Trim(),
|
||||
port = smtpPort,
|
||||
from = smtpFrom.Trim(),
|
||||
username = smtpUsername.Trim(),
|
||||
password,
|
||||
enableSsl = smtpEnableSsl
|
||||
});
|
||||
|
||||
await ProviderConfigService.TestSmtpAsync(configJson, smtpTestRecipient.Trim());
|
||||
smtpSuccess = $"Test email sent to {smtpTestRecipient}.";
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
smtpError = $"Test failed: {ex.Message}";
|
||||
}
|
||||
finally
|
||||
{
|
||||
smtpBusy = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task DeleteSmtp()
|
||||
{
|
||||
smtpBusy = true;
|
||||
smtpError = null;
|
||||
smtpSuccess = null;
|
||||
try
|
||||
{
|
||||
await ProviderConfigService.DeleteAsync(NotificationProviderType.Smtp);
|
||||
smtpHost = ""; smtpPort = 587; smtpFrom = ""; smtpUsername = ""; smtpPassword = ""; smtpEnableSsl = true; smtpEnabled = true;
|
||||
await LoadAsync();
|
||||
smtpSuccess = "SMTP configuration removed.";
|
||||
}
|
||||
finally
|
||||
{
|
||||
smtpBusy = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task SaveTeams()
|
||||
{
|
||||
teamsError = null;
|
||||
teamsSuccess = null;
|
||||
if (string.IsNullOrWhiteSpace(teamsTenantId) || string.IsNullOrWhiteSpace(teamsClientId))
|
||||
{
|
||||
teamsError = "Tenant ID and Client ID are required.";
|
||||
return;
|
||||
}
|
||||
|
||||
teamsBusy = true;
|
||||
try
|
||||
{
|
||||
string secret = teamsClientSecret;
|
||||
if (string.IsNullOrEmpty(secret) && teamsConfig is not null)
|
||||
{
|
||||
using JsonDocument existing = JsonDocument.Parse(teamsConfig.ConfigurationJson);
|
||||
secret = existing.RootElement.TryGetProperty("clientSecret", out var s) ? s.GetString() ?? "" : "";
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(secret))
|
||||
{
|
||||
teamsError = "Client Secret is required.";
|
||||
return;
|
||||
}
|
||||
|
||||
string configJson = JsonSerializer.Serialize(new
|
||||
{
|
||||
tenantId = teamsTenantId.Trim(),
|
||||
clientId = teamsClientId.Trim(),
|
||||
clientSecret = secret
|
||||
});
|
||||
|
||||
await ProviderConfigService.SaveAsync(NotificationProviderType.MsTeamsGraph, configJson, teamsEnabled, currentUserId);
|
||||
teamsSuccess = "Teams Graph configuration saved.";
|
||||
await LoadAsync();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
teamsError = ex.Message;
|
||||
}
|
||||
finally
|
||||
{
|
||||
teamsBusy = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task TestTeams()
|
||||
{
|
||||
teamsError = null;
|
||||
teamsSuccess = null;
|
||||
if (string.IsNullOrWhiteSpace(teamsTenantId) || string.IsNullOrWhiteSpace(teamsClientId))
|
||||
{
|
||||
teamsError = "Tenant ID and Client ID are required.";
|
||||
return;
|
||||
}
|
||||
|
||||
teamsBusy = true;
|
||||
try
|
||||
{
|
||||
string secret = teamsClientSecret;
|
||||
if (string.IsNullOrEmpty(secret) && teamsConfig is not null)
|
||||
{
|
||||
using JsonDocument existing = JsonDocument.Parse(teamsConfig.ConfigurationJson);
|
||||
secret = existing.RootElement.TryGetProperty("clientSecret", out var s) ? s.GetString() ?? "" : "";
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(secret))
|
||||
{
|
||||
teamsError = "Client Secret is required.";
|
||||
return;
|
||||
}
|
||||
|
||||
string configJson = JsonSerializer.Serialize(new
|
||||
{
|
||||
tenantId = teamsTenantId.Trim(),
|
||||
clientId = teamsClientId.Trim(),
|
||||
clientSecret = secret
|
||||
});
|
||||
|
||||
await ProviderConfigService.TestMsTeamsGraphAsync(configJson);
|
||||
teamsSuccess = "Successfully authenticated with Azure AD. Credentials are valid.";
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
teamsError = $"Test failed: {ex.Message}";
|
||||
}
|
||||
finally
|
||||
{
|
||||
teamsBusy = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task DeleteTeams()
|
||||
{
|
||||
teamsBusy = true;
|
||||
teamsError = null;
|
||||
teamsSuccess = null;
|
||||
try
|
||||
{
|
||||
await ProviderConfigService.DeleteAsync(NotificationProviderType.MsTeamsGraph);
|
||||
teamsTenantId = ""; teamsClientId = ""; teamsClientSecret = ""; teamsEnabled = true;
|
||||
await LoadAsync();
|
||||
teamsSuccess = "Teams Graph configuration removed.";
|
||||
}
|
||||
finally
|
||||
{
|
||||
teamsBusy = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
365
src/EntKube.Web/Components/Pages/Admin/AdminRoles.razor
Normal file
365
src/EntKube.Web/Components/Pages/Admin/AdminRoles.razor
Normal file
@@ -0,0 +1,365 @@
|
||||
@page "/admin/roles"
|
||||
@using Microsoft.AspNetCore.Authorization
|
||||
@using EntKube.Web.Data
|
||||
@using EntKube.Web.Services
|
||||
@rendermode InteractiveServer
|
||||
@attribute [Authorize(Roles = "Admin")]
|
||||
|
||||
@inject TenantRoleService RoleService
|
||||
@inject TenantService TenantService
|
||||
|
||||
<PageTitle>Role Management</PageTitle>
|
||||
|
||||
<div class="d-flex align-items-center justify-content-between mb-4">
|
||||
<div>
|
||||
<h1 class="mb-0"><i class="bi bi-shield-lock me-2 text-primary"></i>Role Management</h1>
|
||||
<p class="text-muted small mb-0 mt-1">Define roles and their feature permissions per tenant.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@* ── Tenant selector ── *@
|
||||
<div class="card border-0 shadow-sm mb-4">
|
||||
<div class="card-body py-2">
|
||||
<div class="d-flex align-items-center gap-3">
|
||||
<label class="text-muted small fw-semibold text-nowrap mb-0">Tenant</label>
|
||||
<select class="form-select" @onchange="OnTenantChanged">
|
||||
<option value="">Select a tenant…</option>
|
||||
@foreach (Tenant t in allTenants)
|
||||
{
|
||||
<option value="@t.Id" selected="@(t.Id == selectedTenantId)">@t.Name</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (selectedTenantId != Guid.Empty)
|
||||
{
|
||||
@* ── Create role ── *@
|
||||
<div class="card border-0 shadow-sm mb-4">
|
||||
<div class="card-body py-2">
|
||||
<div class="input-group">
|
||||
<span class="input-group-text bg-transparent border-end-0"><i class="bi bi-plus-circle"></i></span>
|
||||
<input type="text" class="form-control border-start-0" placeholder="New role name (e.g. DevOps)"
|
||||
@bind="newRoleName" @bind:event="oninput"
|
||||
@onkeydown="@(async e => { if (e.Key == "Enter" && !string.IsNullOrWhiteSpace(newRoleName)) await CreateRole(); })" />
|
||||
<button class="btn btn-primary" @onclick="CreateRole" disabled="@string.IsNullOrWhiteSpace(newRoleName)">
|
||||
<i class="bi bi-plus-lg me-1"></i>Add Role
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (!string.IsNullOrEmpty(errorMessage))
|
||||
{
|
||||
<div class="alert alert-danger alert-dismissible py-2 mb-3">
|
||||
<i class="bi bi-exclamation-triangle me-1"></i>@errorMessage
|
||||
<button type="button" class="btn-close btn-close-sm" @onclick="() => errorMessage = null"></button>
|
||||
</div>
|
||||
}
|
||||
|
||||
<LoadingPanel Loading="@(roles is null)" LoadingText="Loading roles…">
|
||||
@if (roles is not null && roles.Count == 0)
|
||||
{
|
||||
<EmptyState Icon="bi-shield-lock"
|
||||
Title="No roles yet"
|
||||
Message="Create a role above to define what users in this tenant can access." />
|
||||
}
|
||||
else if (roles is not null)
|
||||
{
|
||||
<div class="d-flex flex-column gap-3">
|
||||
@foreach (TenantRole role in roles)
|
||||
{
|
||||
bool isExpanded = expandedRoleId == role.Id;
|
||||
Dictionary<TenantFeature, AccessLevel> perms = TenantRoleService.DecodePermissions(role.PermissionsJson);
|
||||
|
||||
<div class="card border-0 shadow-sm">
|
||||
@* ── Role header ── *@
|
||||
<div class="card-body py-3 d-flex align-items-center justify-content-between"
|
||||
style="cursor:pointer" @onclick="() => ToggleExpand(role.Id)">
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<i class="bi bi-chevron-@(isExpanded ? "up" : "down") text-muted"></i>
|
||||
@if (editingNameId == role.Id)
|
||||
{
|
||||
<input type="text" class="form-control form-control-sm" style="width:220px"
|
||||
value="@editingName"
|
||||
@oninput="e => editingName = e.Value?.ToString() ?? string.Empty"
|
||||
@onkeydown="@(async e => { if (e.Key == "Enter") await SaveRename(role.Id); else if (e.Key == "Escape") CancelRename(); })"
|
||||
@onclick:stopPropagation="true" />
|
||||
<button class="btn btn-sm btn-success" @onclick:stopPropagation="true" @onclick="() => SaveRename(role.Id)">
|
||||
<i class="bi bi-check"></i>
|
||||
</button>
|
||||
<button class="btn btn-sm btn-outline-secondary" @onclick:stopPropagation="true" @onclick="CancelRename">
|
||||
<i class="bi bi-x"></i>
|
||||
</button>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="fw-semibold">@role.Name</span>
|
||||
<span class="badge bg-secondary-subtle text-secondary border border-secondary-subtle">
|
||||
@role.Memberships.Count members
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
<div class="d-flex gap-2" @onclick:stopPropagation="true">
|
||||
<button class="btn btn-sm btn-outline-secondary" title="Rename"
|
||||
@onclick="() => StartRename(role.Id, role.Name)">
|
||||
<i class="bi bi-pencil"></i>
|
||||
</button>
|
||||
@if (confirmDeleteId == role.Id)
|
||||
{
|
||||
<span class="text-danger small align-self-center me-1">Delete?</span>
|
||||
<button class="btn btn-sm btn-danger" @onclick="() => DeleteRole(role.Id)">Yes</button>
|
||||
<button class="btn btn-sm btn-outline-secondary" @onclick="() => confirmDeleteId = null">No</button>
|
||||
}
|
||||
else
|
||||
{
|
||||
<button class="btn btn-sm btn-outline-danger" title="Delete role"
|
||||
@onclick="() => confirmDeleteId = role.Id">
|
||||
<i class="bi bi-trash"></i>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@* ── Permission matrix ── *@
|
||||
@if (isExpanded)
|
||||
{
|
||||
Dictionary<TenantFeature, AccessLevel> draft = GetDraft(role.Id, perms);
|
||||
bool isDirty = savedMessages.ContainsKey(role.Id) || IsDirty(role.Id, perms);
|
||||
|
||||
<div class="card-body border-top pt-3 pb-3">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm table-borderless mb-3" style="max-width:700px">
|
||||
<thead>
|
||||
<tr class="text-muted small">
|
||||
<th style="width:40%">Feature</th>
|
||||
<th class="text-center">None</th>
|
||||
<th class="text-center">View</th>
|
||||
<th class="text-center">Edit</th>
|
||||
<th class="text-center">Manage</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach ((TenantFeature feature, string label, string icon) in TenantRoleService.Features)
|
||||
{
|
||||
AccessLevel current = TenantRoleService.GetPermission(draft, feature);
|
||||
<tr>
|
||||
<td class="align-middle">
|
||||
<i class="bi @icon me-2 text-muted"></i>@label
|
||||
</td>
|
||||
@foreach (AccessLevel level in Enum.GetValues<AccessLevel>())
|
||||
{
|
||||
<td class="text-center align-middle">
|
||||
<input type="radio"
|
||||
class="form-check-input"
|
||||
name="@($"perm-{role.Id}-{feature}")"
|
||||
checked="@(current == level)"
|
||||
@onchange="() => SetDraft(role.Id, feature, level)" />
|
||||
</td>
|
||||
}
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="d-flex align-items-center gap-3">
|
||||
<button class="btn btn-primary btn-sm" @onclick="() => SavePermissions(role.Id)">
|
||||
<i class="bi bi-floppy me-1"></i>Save Permissions
|
||||
</button>
|
||||
<button class="btn btn-outline-secondary btn-sm" @onclick="() => ResetDraft(role.Id, perms)">
|
||||
Reset
|
||||
</button>
|
||||
@if (savedMessages.TryGetValue(role.Id, out string? msg))
|
||||
{
|
||||
<span class="text-success small"><i class="bi bi-check-circle me-1"></i>@msg</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</LoadingPanel>
|
||||
}
|
||||
|
||||
@code {
|
||||
private List<Tenant> allTenants = [];
|
||||
private Guid selectedTenantId;
|
||||
private List<TenantRole>? roles;
|
||||
private string newRoleName = "";
|
||||
private string? errorMessage;
|
||||
|
||||
// Expand/collapse
|
||||
private Guid? expandedRoleId;
|
||||
|
||||
// Inline rename
|
||||
private Guid? editingNameId;
|
||||
private string editingName = "";
|
||||
|
||||
// Delete confirm
|
||||
private Guid? confirmDeleteId;
|
||||
|
||||
// Permission drafts: roleId → working copy of permissions
|
||||
private Dictionary<Guid, Dictionary<TenantFeature, AccessLevel>> drafts = [];
|
||||
|
||||
// Save confirmation messages
|
||||
private Dictionary<Guid, string> savedMessages = [];
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
allTenants = await TenantService.GetAllTenantsAsync();
|
||||
}
|
||||
|
||||
private async Task OnTenantChanged(ChangeEventArgs e)
|
||||
{
|
||||
selectedTenantId = Guid.TryParse(e.Value?.ToString(), out Guid id) ? id : Guid.Empty;
|
||||
roles = null;
|
||||
drafts.Clear();
|
||||
savedMessages.Clear();
|
||||
expandedRoleId = null;
|
||||
errorMessage = null;
|
||||
|
||||
if (selectedTenantId != Guid.Empty)
|
||||
await LoadRoles();
|
||||
}
|
||||
|
||||
private async Task LoadRoles()
|
||||
{
|
||||
roles = await RoleService.GetRolesAsync(selectedTenantId);
|
||||
// Prune drafts for roles that no longer exist.
|
||||
HashSet<Guid> ids = roles.Select(r => r.Id).ToHashSet();
|
||||
foreach (Guid gone in drafts.Keys.Except(ids).ToList())
|
||||
drafts.Remove(gone);
|
||||
}
|
||||
|
||||
private async Task CreateRole()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(newRoleName)) return;
|
||||
errorMessage = null;
|
||||
|
||||
try
|
||||
{
|
||||
TenantRole created = await RoleService.CreateRoleAsync(selectedTenantId, newRoleName.Trim());
|
||||
newRoleName = "";
|
||||
await LoadRoles();
|
||||
expandedRoleId = created.Id;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
errorMessage = $"Could not create role: {ex.Message}";
|
||||
}
|
||||
}
|
||||
|
||||
private async Task DeleteRole(Guid roleId)
|
||||
{
|
||||
confirmDeleteId = null;
|
||||
errorMessage = null;
|
||||
|
||||
bool ok = await RoleService.DeleteRoleAsync(roleId);
|
||||
if (!ok) errorMessage = "Role not found.";
|
||||
|
||||
drafts.Remove(roleId);
|
||||
savedMessages.Remove(roleId);
|
||||
if (expandedRoleId == roleId) expandedRoleId = null;
|
||||
|
||||
await LoadRoles();
|
||||
}
|
||||
|
||||
private void ToggleExpand(Guid roleId)
|
||||
{
|
||||
expandedRoleId = expandedRoleId == roleId ? null : roleId;
|
||||
}
|
||||
|
||||
// ── Rename ──
|
||||
|
||||
private void StartRename(Guid roleId, string currentName)
|
||||
{
|
||||
editingNameId = roleId;
|
||||
editingName = currentName;
|
||||
}
|
||||
|
||||
private void CancelRename()
|
||||
{
|
||||
editingNameId = null;
|
||||
editingName = "";
|
||||
}
|
||||
|
||||
private async Task SaveRename(Guid roleId)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(editingName)) return;
|
||||
errorMessage = null;
|
||||
|
||||
try
|
||||
{
|
||||
await RoleService.RenameRoleAsync(roleId, editingName.Trim());
|
||||
CancelRename();
|
||||
await LoadRoles();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
errorMessage = $"Could not rename role: {ex.Message}";
|
||||
}
|
||||
}
|
||||
|
||||
// ── Permission draft management ──
|
||||
|
||||
private Dictionary<TenantFeature, AccessLevel> GetDraft(Guid roleId, Dictionary<TenantFeature, AccessLevel> saved)
|
||||
{
|
||||
if (!drafts.TryGetValue(roleId, out Dictionary<TenantFeature, AccessLevel>? draft))
|
||||
{
|
||||
draft = new Dictionary<TenantFeature, AccessLevel>(saved);
|
||||
drafts[roleId] = draft;
|
||||
}
|
||||
return draft;
|
||||
}
|
||||
|
||||
private void SetDraft(Guid roleId, TenantFeature feature, AccessLevel level)
|
||||
{
|
||||
if (!drafts.TryGetValue(roleId, out Dictionary<TenantFeature, AccessLevel>? draft))
|
||||
{
|
||||
draft = [];
|
||||
drafts[roleId] = draft;
|
||||
}
|
||||
draft[feature] = level;
|
||||
savedMessages.Remove(roleId);
|
||||
}
|
||||
|
||||
private void ResetDraft(Guid roleId, Dictionary<TenantFeature, AccessLevel> saved)
|
||||
{
|
||||
drafts[roleId] = new Dictionary<TenantFeature, AccessLevel>(saved);
|
||||
savedMessages.Remove(roleId);
|
||||
}
|
||||
|
||||
private bool IsDirty(Guid roleId, Dictionary<TenantFeature, AccessLevel> saved)
|
||||
{
|
||||
if (!drafts.TryGetValue(roleId, out Dictionary<TenantFeature, AccessLevel>? draft)) return false;
|
||||
foreach (TenantFeature f in Enum.GetValues<TenantFeature>())
|
||||
{
|
||||
AccessLevel savedLevel = TenantRoleService.GetPermission(saved, f);
|
||||
AccessLevel draftLevel = TenantRoleService.GetPermission(draft, f);
|
||||
if (savedLevel != draftLevel) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private async Task SavePermissions(Guid roleId)
|
||||
{
|
||||
if (!drafts.TryGetValue(roleId, out Dictionary<TenantFeature, AccessLevel>? draft)) return;
|
||||
errorMessage = null;
|
||||
|
||||
await RoleService.SavePermissionsAsync(roleId, draft);
|
||||
|
||||
savedMessages[roleId] = "Saved";
|
||||
await LoadRoles();
|
||||
|
||||
// Clear the saved message after 3 seconds.
|
||||
_ = Task.Delay(3000).ContinueWith(_ =>
|
||||
{
|
||||
savedMessages.Remove(roleId);
|
||||
InvokeAsync(StateHasChanged);
|
||||
});
|
||||
}
|
||||
}
|
||||
497
src/EntKube.Web/Components/Pages/Admin/AdminUserDetail.razor
Normal file
497
src/EntKube.Web/Components/Pages/Admin/AdminUserDetail.razor
Normal file
@@ -0,0 +1,497 @@
|
||||
@page "/admin/users/{UserId}"
|
||||
@using Microsoft.AspNetCore.Authorization
|
||||
@using Microsoft.AspNetCore.Identity
|
||||
@using EntKube.Web.Data
|
||||
@using EntKube.Web.Services
|
||||
@rendermode InteractiveServer
|
||||
@attribute [Authorize(Roles = "Admin")]
|
||||
|
||||
@inject UserManagementService UserManagement
|
||||
@inject NavigationManager NavigationManager
|
||||
|
||||
<PageTitle>@(user?.Email ?? "User")</PageTitle>
|
||||
|
||||
@if (user is null && loaded)
|
||||
{
|
||||
<div class="alert alert-danger"><i class="bi bi-exclamation-triangle me-2"></i>User not found.</div>
|
||||
}
|
||||
else if (user is not null)
|
||||
{
|
||||
<div class="d-flex align-items-center mb-1">
|
||||
<a href="/admin/users" class="btn btn-sm btn-outline-secondary me-3">
|
||||
<i class="bi bi-arrow-left me-1"></i>Users
|
||||
</a>
|
||||
<h1 class="mb-0 fs-3"><i class="bi bi-person-circle me-2 text-primary"></i>@user.Email</h1>
|
||||
@if (user.EmailConfirmed)
|
||||
{
|
||||
<span class="badge bg-success-subtle text-success border border-success-subtle ms-3">Confirmed</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="badge bg-warning-subtle text-warning border border-warning-subtle ms-3">Unconfirmed</span>
|
||||
}
|
||||
</div>
|
||||
<p class="text-muted small mb-4">ID: @user.Id</p>
|
||||
|
||||
@if (!string.IsNullOrEmpty(errorMessage))
|
||||
{
|
||||
<div class="alert alert-danger alert-dismissible py-2" role="alert">
|
||||
<i class="bi bi-exclamation-triangle me-1"></i>@errorMessage
|
||||
<button type="button" class="btn-close btn-close-sm" @onclick="() => errorMessage = null"></button>
|
||||
</div>
|
||||
}
|
||||
@if (!string.IsNullOrEmpty(successMessage))
|
||||
{
|
||||
<div class="alert alert-success py-2 small" role="alert">
|
||||
<i class="bi bi-check-circle me-1"></i>@successMessage
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="row g-4">
|
||||
|
||||
@* ── Global Roles ── *@
|
||||
<div class="col-12">
|
||||
<div class="card border-0 shadow-sm">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title mb-3"><i class="bi bi-shield-lock me-2 text-primary"></i>Global Roles</h5>
|
||||
@if (allRoles.Count == 0)
|
||||
{
|
||||
<p class="text-muted small mb-0">No global roles defined.</p>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="d-flex flex-wrap gap-3">
|
||||
@foreach (IdentityRole role in allRoles)
|
||||
{
|
||||
bool hasRole = userRoles.Contains(role.Name ?? "");
|
||||
<div class="form-check form-switch">
|
||||
<input class="form-check-input" type="checkbox" role="switch"
|
||||
id="role-@role.Id"
|
||||
checked="@hasRole"
|
||||
@onchange="e => ToggleRole(role.Name!, (bool)(e.Value ?? false))" />
|
||||
<label class="form-check-label fw-semibold" for="role-@role.Id">
|
||||
@role.Name
|
||||
</label>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@* ── Tenant Memberships ── *@
|
||||
<div class="col-12">
|
||||
<div class="card border-0 shadow-sm">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title mb-3"><i class="bi bi-building me-2 text-primary"></i>Tenant Memberships</h5>
|
||||
|
||||
@if (tenantMemberships.Count > 0)
|
||||
{
|
||||
<div class="table-responsive mb-3">
|
||||
<table class="table table-sm mb-0">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>Tenant</th>
|
||||
<th>Role</th>
|
||||
<th>Joined</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (TenantMembership m in tenantMemberships)
|
||||
{
|
||||
<tr>
|
||||
<td class="align-middle">@m.Tenant.Name</td>
|
||||
<td class="align-middle">
|
||||
<span class="badge bg-primary-subtle text-primary border border-primary-subtle">@m.Role.Name</span>
|
||||
</td>
|
||||
<td class="align-middle text-muted small">@m.JoinedAt.ToString("MMM d, yyyy")</td>
|
||||
<td class="align-middle text-end">
|
||||
<button class="btn btn-sm btn-outline-danger"
|
||||
@onclick="() => confirmRemoveTenantId = m.TenantId">
|
||||
<i class="bi bi-trash"></i>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
@if (confirmRemoveTenantId == m.TenantId)
|
||||
{
|
||||
<tr class="table-danger table-sm">
|
||||
<td colspan="4" class="px-3 py-2">
|
||||
<ConfirmDialog Visible="true"
|
||||
Message="@($"Remove {user!.Email} from tenant '{m.Tenant.Name}'?")"
|
||||
ConfirmText="Remove"
|
||||
IsBusy="removingMembership"
|
||||
OnConfirm="() => RemoveTenantMembership(m.TenantId)"
|
||||
OnCancel="() => confirmRemoveTenantId = null" />
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
|
||||
@* Add tenant form *@
|
||||
@if (availableTenants.Count > 0)
|
||||
{
|
||||
<div class="d-flex gap-2 align-items-end flex-wrap">
|
||||
<div>
|
||||
<label class="form-label small text-muted mb-1">Tenant</label>
|
||||
<select class="form-select form-select-sm" @bind="addTenantId" style="min-width:180px">
|
||||
<option value="">Select tenant…</option>
|
||||
@foreach (Tenant t in availableTenants)
|
||||
{
|
||||
<option value="@t.Id">@t.Name</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="form-label small text-muted mb-1">Role</label>
|
||||
@if (addTenantId != Guid.Empty && !rolesForSelectedTenant.Any())
|
||||
{
|
||||
<div class="text-muted small">
|
||||
<i class="bi bi-exclamation-circle me-1"></i>No roles defined for this tenant.
|
||||
<a href="/admin/roles">Create roles first</a>.
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<select class="form-select form-select-sm" @bind="addTenantRoleId" style="min-width:150px">
|
||||
<option value="">Select role…</option>
|
||||
@foreach (TenantRole r in rolesForSelectedTenant)
|
||||
{
|
||||
<option value="@r.Id">@r.Name</option>
|
||||
}
|
||||
</select>
|
||||
}
|
||||
</div>
|
||||
<button class="btn btn-sm btn-primary" @onclick="AddTenantMembership"
|
||||
disabled="@(addTenantId == Guid.Empty || addTenantRoleId == Guid.Empty)">
|
||||
<i class="bi bi-plus-lg me-1"></i>Add
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
else if (tenantMemberships.Count == 0)
|
||||
{
|
||||
<p class="text-muted small mb-0">No tenants available to assign.</p>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@* ── Group Memberships ── *@
|
||||
<div class="col-12">
|
||||
<div class="card border-0 shadow-sm">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title mb-3"><i class="bi bi-diagram-3 me-2 text-primary"></i>Group Memberships</h5>
|
||||
|
||||
@if (groupMemberships.Count > 0)
|
||||
{
|
||||
<div class="table-responsive mb-3">
|
||||
<table class="table table-sm mb-0">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>Group</th>
|
||||
<th>Tenant</th>
|
||||
<th>Joined</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (GroupMembership gm in groupMemberships)
|
||||
{
|
||||
<tr>
|
||||
<td class="align-middle">@gm.Group.Name</td>
|
||||
<td class="align-middle text-muted small">@gm.Group.Tenant.Name</td>
|
||||
<td class="align-middle text-muted small">@gm.JoinedAt.ToString("MMM d, yyyy")</td>
|
||||
<td class="align-middle text-end">
|
||||
<button class="btn btn-sm btn-outline-danger"
|
||||
@onclick="() => confirmRemoveGroupId = gm.GroupId">
|
||||
<i class="bi bi-trash"></i>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
@if (confirmRemoveGroupId == gm.GroupId)
|
||||
{
|
||||
<tr class="table-danger table-sm">
|
||||
<td colspan="4" class="px-3 py-2">
|
||||
<ConfirmDialog Visible="true"
|
||||
Message="@($"Remove {user!.Email} from group '{gm.Group.Name}'?")"
|
||||
ConfirmText="Remove"
|
||||
IsBusy="removingMembership"
|
||||
OnConfirm="() => RemoveGroupMembership(gm.GroupId)"
|
||||
OnCancel="() => confirmRemoveGroupId = null" />
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
|
||||
@* Add group form — only tenants this user is a member of have groups *@
|
||||
@if (tenantMemberships.Count > 0)
|
||||
{
|
||||
<div class="d-flex gap-2 align-items-end flex-wrap">
|
||||
<div>
|
||||
<label class="form-label small text-muted mb-1">Tenant</label>
|
||||
<select class="form-select form-select-sm" @bind="addGroupTenantId" style="min-width:180px">
|
||||
<option value="">Select tenant…</option>
|
||||
@foreach (TenantMembership m in tenantMemberships)
|
||||
{
|
||||
<option value="@m.TenantId">@m.Tenant.Name</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="form-label small text-muted mb-1">Group</label>
|
||||
<select class="form-select form-select-sm" @bind="addGroupId" style="min-width:180px">
|
||||
<option value="">Select group…</option>
|
||||
@foreach (Group g in availableGroupsForTenant)
|
||||
{
|
||||
<option value="@g.Id">@g.Name</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
<button class="btn btn-sm btn-primary" @onclick="AddGroupMembership"
|
||||
disabled="@(addGroupId == Guid.Empty)">
|
||||
<i class="bi bi-plus-lg me-1"></i>Add
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
else if (groupMemberships.Count == 0)
|
||||
{
|
||||
<p class="text-muted small mb-0">Add the user to a tenant first, then assign groups.</p>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@* ── Danger Zone ── *@
|
||||
<div class="col-12">
|
||||
<div class="card border-danger border-opacity-50 shadow-sm">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title text-danger mb-1"><i class="bi bi-exclamation-triangle me-2"></i>Danger Zone</h5>
|
||||
<p class="text-muted small mb-3">Permanently delete this user account. All tenant and group memberships are removed. This cannot be undone.</p>
|
||||
<button class="btn btn-outline-danger" @onclick="() => confirmDelete = true"
|
||||
hidden="@confirmDelete">
|
||||
<i class="bi bi-trash me-1"></i>Delete User
|
||||
</button>
|
||||
<ConfirmDialog Visible="confirmDelete"
|
||||
Message="@($"Permanently delete {user.Email}? All memberships are removed and this cannot be undone.")"
|
||||
ConfirmText="Delete Permanently"
|
||||
IsBusy="deletingUser"
|
||||
OnConfirm="DeleteUser"
|
||||
OnCancel="() => confirmDelete = false" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
}
|
||||
|
||||
@code {
|
||||
[Parameter] public string UserId { get; set; } = null!;
|
||||
|
||||
private ApplicationUser? user;
|
||||
private bool loaded;
|
||||
private string? errorMessage;
|
||||
|
||||
private List<IdentityRole> allRoles = [];
|
||||
private IList<string> userRoles = [];
|
||||
|
||||
private List<TenantMembership> tenantMemberships = [];
|
||||
private List<Tenant> allTenants = [];
|
||||
private List<Tenant> availableTenants = [];
|
||||
|
||||
// Add-tenant form state
|
||||
private Guid _addTenantId;
|
||||
private Guid addTenantId
|
||||
{
|
||||
get => _addTenantId;
|
||||
set
|
||||
{
|
||||
_addTenantId = value;
|
||||
addTenantRoleId = Guid.Empty;
|
||||
}
|
||||
}
|
||||
private Guid addTenantRoleId;
|
||||
private IEnumerable<TenantRole> rolesForSelectedTenant =>
|
||||
allTenants.FirstOrDefault(t => t.Id == addTenantId)?.Roles ?? [];
|
||||
|
||||
private List<GroupMembership> groupMemberships = [];
|
||||
|
||||
// Add-group form state
|
||||
private Guid _addGroupTenantId;
|
||||
private Guid addGroupTenantId
|
||||
{
|
||||
get => _addGroupTenantId;
|
||||
set
|
||||
{
|
||||
_addGroupTenantId = value;
|
||||
addGroupId = Guid.Empty;
|
||||
_ = LoadGroupsForSelectedTenant();
|
||||
}
|
||||
}
|
||||
private Guid addGroupId;
|
||||
private List<Group> availableGroupsForTenant = [];
|
||||
|
||||
private Guid? confirmRemoveTenantId;
|
||||
private Guid? confirmRemoveGroupId;
|
||||
private bool confirmDelete;
|
||||
private bool removingMembership;
|
||||
private bool deletingUser;
|
||||
private string? successMessage;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
await Load();
|
||||
}
|
||||
|
||||
private async Task Load()
|
||||
{
|
||||
user = await UserManagement.GetUserByIdAsync(UserId);
|
||||
loaded = true;
|
||||
if (user is null) return;
|
||||
|
||||
// UserManager and RoleManager share the scoped DbContext — run sequentially.
|
||||
// The IDbContextFactory-based calls each create their own instance and are safe.
|
||||
allRoles = await UserManagement.GetAllRolesAsync();
|
||||
userRoles = await UserManagement.GetUserRolesAsync(UserId);
|
||||
tenantMemberships = await UserManagement.GetUserTenantMembershipsAsync(UserId);
|
||||
groupMemberships = await UserManagement.GetUserGroupMembershipsAsync(UserId);
|
||||
allTenants = await UserManagement.GetAllTenantsAsync();
|
||||
|
||||
RefreshAvailableTenants();
|
||||
}
|
||||
|
||||
private void RefreshAvailableTenants()
|
||||
{
|
||||
HashSet<Guid> joined = tenantMemberships.Select(m => m.TenantId).ToHashSet();
|
||||
availableTenants = allTenants.Where(t => !joined.Contains(t.Id)).ToList();
|
||||
if (!availableTenants.Any(t => t.Id == addTenantId))
|
||||
{
|
||||
addTenantId = Guid.Empty;
|
||||
addTenantRoleId = Guid.Empty;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task LoadGroupsForSelectedTenant()
|
||||
{
|
||||
if (_addGroupTenantId == Guid.Empty)
|
||||
{
|
||||
availableGroupsForTenant = [];
|
||||
return;
|
||||
}
|
||||
List<Group> all = await UserManagement.GetGroupsForTenantAsync(_addGroupTenantId);
|
||||
HashSet<Guid> joined = groupMemberships.Select(gm => gm.GroupId).ToHashSet();
|
||||
availableGroupsForTenant = all.Where(g => !joined.Contains(g.Id)).ToList();
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
private async Task ToggleRole(string role, bool grant)
|
||||
{
|
||||
errorMessage = null;
|
||||
IdentityResult result = grant
|
||||
? await UserManagement.AddToRoleAsync(UserId, role)
|
||||
: await UserManagement.RemoveFromRoleAsync(UserId, role);
|
||||
|
||||
if (!result.Succeeded)
|
||||
{
|
||||
errorMessage = string.Join(" ", result.Errors.Select(e => e.Description));
|
||||
return;
|
||||
}
|
||||
|
||||
userRoles = await UserManagement.GetUserRolesAsync(UserId);
|
||||
}
|
||||
|
||||
private async Task AddTenantMembership()
|
||||
{
|
||||
if (addTenantId == Guid.Empty || addTenantRoleId == Guid.Empty) return;
|
||||
errorMessage = null;
|
||||
|
||||
try
|
||||
{
|
||||
await UserManagement.AddTenantMembershipAsync(UserId, addTenantId, addTenantRoleId);
|
||||
addTenantId = Guid.Empty;
|
||||
tenantMemberships = await UserManagement.GetUserTenantMembershipsAsync(UserId);
|
||||
RefreshAvailableTenants();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
errorMessage = $"Failed to add tenant membership: {ex.Message}";
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ShowSuccessAsync(string message)
|
||||
{
|
||||
successMessage = message;
|
||||
StateHasChanged();
|
||||
await Task.Delay(3500);
|
||||
successMessage = null;
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
private async Task RemoveTenantMembership(Guid tenantId)
|
||||
{
|
||||
removingMembership = true;
|
||||
errorMessage = null;
|
||||
string? tenantName = tenantMemberships.FirstOrDefault(m => m.TenantId == tenantId)?.Tenant.Name;
|
||||
|
||||
await UserManagement.RemoveTenantMembershipAsync(UserId, tenantId);
|
||||
tenantMemberships = await UserManagement.GetUserTenantMembershipsAsync(UserId);
|
||||
RefreshAvailableTenants();
|
||||
removingMembership = false;
|
||||
confirmRemoveTenantId = null;
|
||||
if (tenantName is not null) _ = ShowSuccessAsync($"Removed from tenant '{tenantName}'.");
|
||||
}
|
||||
|
||||
private async Task AddGroupMembership()
|
||||
{
|
||||
if (addGroupId == Guid.Empty) return;
|
||||
errorMessage = null;
|
||||
|
||||
try
|
||||
{
|
||||
await UserManagement.AddGroupMembershipAsync(UserId, addGroupId);
|
||||
groupMemberships = await UserManagement.GetUserGroupMembershipsAsync(UserId);
|
||||
await LoadGroupsForSelectedTenant();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
errorMessage = $"Failed to add group membership: {ex.Message}";
|
||||
}
|
||||
}
|
||||
|
||||
private async Task RemoveGroupMembership(Guid groupId)
|
||||
{
|
||||
removingMembership = true;
|
||||
errorMessage = null;
|
||||
string? groupName = groupMemberships.FirstOrDefault(gm => gm.GroupId == groupId)?.Group.Name;
|
||||
|
||||
await UserManagement.RemoveGroupMembershipAsync(UserId, groupId);
|
||||
groupMemberships = await UserManagement.GetUserGroupMembershipsAsync(UserId);
|
||||
await LoadGroupsForSelectedTenant();
|
||||
removingMembership = false;
|
||||
confirmRemoveGroupId = null;
|
||||
if (groupName is not null) _ = ShowSuccessAsync($"Removed from group '{groupName}'.");
|
||||
}
|
||||
|
||||
private async Task DeleteUser()
|
||||
{
|
||||
deletingUser = true;
|
||||
IdentityResult result = await UserManagement.DeleteUserAsync(UserId);
|
||||
deletingUser = false;
|
||||
|
||||
if (!result.Succeeded)
|
||||
{
|
||||
errorMessage = string.Join(" ", result.Errors.Select(e => e.Description));
|
||||
confirmDelete = false;
|
||||
return;
|
||||
}
|
||||
NavigationManager.NavigateTo("/admin/users");
|
||||
}
|
||||
}
|
||||
153
src/EntKube.Web/Components/Pages/Admin/AdminUsers.razor
Normal file
153
src/EntKube.Web/Components/Pages/Admin/AdminUsers.razor
Normal file
@@ -0,0 +1,153 @@
|
||||
@page "/admin/users"
|
||||
@using Microsoft.AspNetCore.Authorization
|
||||
@using Microsoft.AspNetCore.Identity
|
||||
@using EntKube.Web.Data
|
||||
@using EntKube.Web.Services
|
||||
@rendermode InteractiveServer
|
||||
@attribute [Authorize(Roles = "Admin")]
|
||||
|
||||
@inject UserManagementService UserManagement
|
||||
@inject NavigationManager NavigationManager
|
||||
|
||||
<PageTitle>User Management</PageTitle>
|
||||
|
||||
<div class="d-flex align-items-center justify-content-between mb-4">
|
||||
<div>
|
||||
<h1 class="mb-0"><i class="bi bi-people me-2 text-primary"></i>Users</h1>
|
||||
<p class="text-muted small mb-0 mt-1">Manage system users, roles, and tenant access.</p>
|
||||
</div>
|
||||
<button class="btn btn-primary" @onclick="() => showAddForm = !showAddForm">
|
||||
<i class="bi bi-person-plus me-1"></i>Add User
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@if (showAddForm)
|
||||
{
|
||||
<div class="card border-0 shadow-sm mb-4">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title mb-3"><i class="bi bi-person-plus me-2 text-primary"></i>New User</h5>
|
||||
@if (!string.IsNullOrEmpty(addError))
|
||||
{
|
||||
<div class="alert alert-danger py-2 small">
|
||||
<i class="bi bi-exclamation-triangle me-1"></i>@addError
|
||||
</div>
|
||||
}
|
||||
<div class="row g-3">
|
||||
<div class="col-md-5">
|
||||
<input type="email" class="form-control" placeholder="Email address"
|
||||
@bind="newEmail" @bind:event="oninput" />
|
||||
</div>
|
||||
<div class="col-md-5">
|
||||
<input type="password" class="form-control" placeholder="Password"
|
||||
@bind="newPassword" @bind:event="oninput" />
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<button class="btn btn-primary w-100" @onclick="CreateUser"
|
||||
disabled="@(string.IsNullOrWhiteSpace(newEmail) || string.IsNullOrWhiteSpace(newPassword) || creating)">
|
||||
@if (creating)
|
||||
{
|
||||
<span class="spinner-border spinner-border-sm" role="status"></span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span>Create</span>
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<small class="text-muted mt-2 d-block">The account will be created with email pre-confirmed — no email verification needed.</small>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<LoadingPanel Loading="@(users is null)" LoadingText="Loading users…">
|
||||
@if (users is not null && users.Count == 0)
|
||||
{
|
||||
<EmptyState Icon="bi-people"
|
||||
Title="No users yet"
|
||||
Message="Add the first user above." />
|
||||
}
|
||||
else if (users is not null)
|
||||
{
|
||||
<div class="card border-0 shadow-sm">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover mb-0">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>Email</th>
|
||||
<th>Status</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (ApplicationUser user in users)
|
||||
{
|
||||
<tr style="cursor:pointer" @onclick='() => NavigationManager.NavigateTo($"/admin/users/{user.Id}")'>
|
||||
<td class="align-middle">
|
||||
<i class="bi bi-person-circle me-2 text-muted"></i>@user.Email
|
||||
</td>
|
||||
<td class="align-middle">
|
||||
@if (user.EmailConfirmed)
|
||||
{
|
||||
<span class="badge bg-success-subtle text-success border border-success-subtle">Confirmed</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="badge bg-warning-subtle text-warning border border-warning-subtle">Unconfirmed</span>
|
||||
}
|
||||
</td>
|
||||
<td class="align-middle text-end">
|
||||
<i class="bi bi-chevron-right text-muted"></i>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</LoadingPanel>
|
||||
|
||||
@code {
|
||||
private List<ApplicationUser>? users;
|
||||
private bool showAddForm;
|
||||
private bool creating;
|
||||
private string newEmail = "";
|
||||
private string newPassword = "";
|
||||
private string? addError;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
await Load();
|
||||
}
|
||||
|
||||
private async Task Load()
|
||||
{
|
||||
users = await UserManagement.GetAllUsersAsync();
|
||||
}
|
||||
|
||||
private async Task CreateUser()
|
||||
{
|
||||
addError = null;
|
||||
creating = true;
|
||||
|
||||
try
|
||||
{
|
||||
IdentityResult result = await UserManagement.CreateUserAsync(newEmail.Trim(), newPassword);
|
||||
if (!result.Succeeded)
|
||||
{
|
||||
addError = string.Join(" ", result.Errors.Select(e => e.Description));
|
||||
return;
|
||||
}
|
||||
|
||||
newEmail = "";
|
||||
newPassword = "";
|
||||
showAddForm = false;
|
||||
await Load();
|
||||
}
|
||||
finally
|
||||
{
|
||||
creating = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,30 +1,25 @@
|
||||
@page "/Error"
|
||||
@page "/Error"
|
||||
@using System.Diagnostics
|
||||
|
||||
<PageTitle>Error</PageTitle>
|
||||
<PageTitle>Error — EntKube</PageTitle>
|
||||
|
||||
<h1 class="text-danger">Error.</h1>
|
||||
<h2 class="text-danger">An error occurred while processing your request.</h2>
|
||||
<div class="text-center py-5">
|
||||
<i class="bi bi-exclamation-triangle text-danger" style="font-size: 4rem;"></i>
|
||||
<h2 class="mt-3 mb-1">Something went wrong</h2>
|
||||
<p class="text-muted mb-1">An unexpected error occurred while processing your request.</p>
|
||||
@if (ShowRequestId)
|
||||
{
|
||||
<p class="text-muted small mb-4">Request ID: <code>@RequestId</code></p>
|
||||
}
|
||||
<a href="/" class="btn btn-primary me-2">
|
||||
<i class="bi bi-house me-1"></i>Go Home
|
||||
</a>
|
||||
<a href="javascript:location.reload()" class="btn btn-outline-secondary">
|
||||
<i class="bi bi-arrow-clockwise me-1"></i>Retry
|
||||
</a>
|
||||
</div>
|
||||
|
||||
@if (ShowRequestId)
|
||||
{
|
||||
<p>
|
||||
<strong>Request ID:</strong> <code>@RequestId</code>
|
||||
</p>
|
||||
}
|
||||
|
||||
<h3>Development Mode</h3>
|
||||
<p>
|
||||
Swapping to <strong>Development</strong> environment will display more detailed information about the error that occurred.
|
||||
</p>
|
||||
<p>
|
||||
<strong>The Development environment shouldn't be enabled for deployed applications.</strong>
|
||||
It can result in displaying sensitive information from exceptions to end users.
|
||||
For local debugging, enable the <strong>Development</strong> environment by setting the <strong>ASPNETCORE_ENVIRONMENT</strong> environment variable to <strong>Development</strong>
|
||||
and restarting the app.
|
||||
</p>
|
||||
|
||||
@code{
|
||||
@code {
|
||||
[CascadingParameter]
|
||||
private HttpContext? HttpContext { get; set; }
|
||||
|
||||
|
||||
111
src/EntKube.Web/Components/Pages/Home.razor
Normal file
111
src/EntKube.Web/Components/Pages/Home.razor
Normal 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 & 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>
|
||||
16
src/EntKube.Web/Components/Pages/NotFound.razor
Normal file
16
src/EntKube.Web/Components/Pages/NotFound.razor
Normal 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>
|
||||
325
src/EntKube.Web/Components/Pages/Portal/AppGovernancePanel.razor
Normal file
325
src/EntKube.Web/Components/Pages/Portal/AppGovernancePanel.razor
Normal 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 "—";
|
||||
}
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user