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
|
# ASP.NET Scaffolding
|
||||||
ScaffoldingReadMe.txt
|
ScaffoldingReadMe.txt
|
||||||
|
|
||||||
|
# Local dev data persistence
|
||||||
|
**/App_Data/
|
||||||
|
|
||||||
# StyleCop
|
# StyleCop
|
||||||
StyleCopReport.xml
|
StyleCopReport.xml
|
||||||
|
|
||||||
@@ -414,3 +417,14 @@ FodyWeavers.xsd
|
|||||||
# Built Visual Studio Code Extensions
|
# Built Visual Studio Code Extensions
|
||||||
*.vsix
|
*.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>
|
<Solution>
|
||||||
<!-- Source Projects -->
|
<Folder Name="/src/">
|
||||||
<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" />
|
|
||||||
<Project Path="src/EntKube.Web.Client/EntKube.Web.Client.csproj" />
|
<Project Path="src/EntKube.Web.Client/EntKube.Web.Client.csproj" />
|
||||||
<!-- Test Projects -->
|
<Project Path="src/EntKube.Web/EntKube.Web.csproj" />
|
||||||
<Project Path="tests/EntKube.Identity.Tests/EntKube.Identity.Tests.csproj" />
|
</Folder>
|
||||||
<Project Path="tests/EntKube.Clusters.Tests/EntKube.Clusters.Tests.csproj" />
|
<Folder Name="/tests/">
|
||||||
<Project Path="tests/EntKube.Provisioning.Tests/EntKube.Provisioning.Tests.csproj" />
|
|
||||||
<Project Path="tests/EntKube.Web.Tests/EntKube.Web.Tests.csproj" />
|
<Project Path="tests/EntKube.Web.Tests/EntKube.Web.Tests.csproj" />
|
||||||
|
</Folder>
|
||||||
</Solution>
|
</Solution>
|
||||||
|
|||||||
53
README.md
53
README.md
@@ -42,6 +42,59 @@ EntKube/
|
|||||||
└── EntKube.Client/ # WebAssembly client project
|
└── EntKube.Client/ # WebAssembly client project
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Docker
|
||||||
|
|
||||||
|
### Build and push
|
||||||
|
|
||||||
|
> **Apple Silicon (M-series) Mac:** servers are typically `linux/amd64`. Use `docker buildx` to cross-compile and push in one step — the `--push` flag is required because multi-platform images cannot be loaded into the local Docker daemon.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# One-time setup
|
||||||
|
docker buildx create --use --name multiarch
|
||||||
|
|
||||||
|
# Build for amd64 and push directly to the registry
|
||||||
|
docker buildx build --platform linux/amd64 \
|
||||||
|
-t entit.azurecr.io/entkube:latest \
|
||||||
|
--push .
|
||||||
|
|
||||||
|
# Tag a versioned release
|
||||||
|
docker buildx build --platform linux/amd64 \
|
||||||
|
-t entit.azurecr.io/entkube:latest \
|
||||||
|
-t entit.azurecr.io/entkube:1.0.0 \
|
||||||
|
--push .
|
||||||
|
```
|
||||||
|
|
||||||
|
Replace `entit.azurecr.io/entkube` with your actual registry and image path.
|
||||||
|
|
||||||
|
### Run with Docker Compose
|
||||||
|
|
||||||
|
Copy `.env.example` to `.env` and fill in the required values, then authenticate with the registry and start the stack:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Authenticate with the container registry (once per machine / token expiry)
|
||||||
|
docker login entit.azurecr.io -u $REGISTRY_USERNAME -p $REGISTRY_PASSWORD
|
||||||
|
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
The app will be available at `http://localhost:8080`.
|
||||||
|
|
||||||
|
> **Data safety** — `docker compose down` does **not** delete the database volume.
|
||||||
|
> Only `docker compose down -v` removes volumes.
|
||||||
|
|
||||||
|
### Required configuration
|
||||||
|
|
||||||
|
| Variable | Description |
|
||||||
|
|---|---|
|
||||||
|
| `DOMAIN` | Public domain pointing at the server (e.g. `entkube.example.com`). |
|
||||||
|
| `ACME_EMAIL` | Email for Let's Encrypt account and expiry notices. |
|
||||||
|
| `Vault__RootKey` | 32-byte base64 key for secret encryption. Generate with `openssl rand -base64 32`. |
|
||||||
|
| `POSTGRES_PASSWORD` | Password for the Postgres database (set in `.env`). |
|
||||||
|
|
||||||
|
### TLS / HTTPS
|
||||||
|
|
||||||
|
Caddy is the reverse proxy and handles TLS automatically. On first start it obtains a Let's Encrypt certificate for `DOMAIN` and renews it automatically before it expires. Ports **80** and **443** must be open and your DNS must point at the server before starting the stack.
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
See [LICENSE](LICENSE).
|
See [LICENSE](LICENSE).
|
||||||
|
|||||||
81
docker-compose.yml
Normal file
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>
|
<NoDefaultLaunchSettingsFile>true</NoDefaultLaunchSettingsFile>
|
||||||
<StaticWebAssetProjectMode>Default</StaticWebAssetProjectMode>
|
<StaticWebAssetProjectMode>Default</StaticWebAssetProjectMode>
|
||||||
<BlazorDisableThrowNavigationException>true</BlazorDisableThrowNavigationException>
|
<BlazorDisableThrowNavigationException>true</BlazorDisableThrowNavigationException>
|
||||||
|
<!-- Skip Emscripten native relink — no WASM rendering is active on the server,
|
||||||
|
so dotnet.wasm is never loaded. Use the pre-built runtime from the workload. -->
|
||||||
|
<WasmBuildNative>false</WasmBuildNative>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly" Version="10.0.1" />
|
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly" Version="10.0.1" />
|
||||||
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.Authentication" Version="10.0.1" />
|
<PackageReference Include="Microsoft.AspNetCore.Components.Authorization" Version="10.0.1" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -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
|
@using Microsoft.AspNetCore.Authorization
|
||||||
|
|
||||||
@attribute [Authorize]
|
@attribute [Authorize]
|
||||||
|
@rendermode InteractiveServer
|
||||||
|
|
||||||
<PageTitle>Auth</PageTitle>
|
<PageTitle>Auth</PageTitle>
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
@page "/counter"
|
@page "/counter"
|
||||||
|
@rendermode InteractiveServer
|
||||||
|
|
||||||
<PageTitle>Counter</PageTitle>
|
<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.AddAuthorizationCore();
|
||||||
builder.Services.AddCascadingAuthenticationState();
|
builder.Services.AddCascadingAuthenticationState();
|
||||||
builder.Services.AddAuthenticationStateDeserialization();
|
|
||||||
|
|
||||||
await builder.Build().RunAsync();
|
await builder.Build().RunAsync();
|
||||||
|
|||||||
@@ -8,4 +8,3 @@
|
|||||||
@using Microsoft.AspNetCore.Components.Web.Virtualization
|
@using Microsoft.AspNetCore.Components.Web.Virtualization
|
||||||
@using Microsoft.JSInterop
|
@using Microsoft.JSInterop
|
||||||
@using EntKube.Web.Client
|
@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,9 +14,17 @@
|
|||||||
@inject ILogger<Register> Logger
|
@inject ILogger<Register> Logger
|
||||||
@inject NavigationManager NavigationManager
|
@inject NavigationManager NavigationManager
|
||||||
@inject IdentityRedirectManager RedirectManager
|
@inject IdentityRedirectManager RedirectManager
|
||||||
|
@inject IConfiguration Configuration
|
||||||
|
|
||||||
<PageTitle>Register</PageTitle>
|
<PageTitle>Register</PageTitle>
|
||||||
|
|
||||||
|
@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>
|
<h1>Register</h1>
|
||||||
|
|
||||||
<div class="row">
|
<div class="row">
|
||||||
@@ -53,9 +61,11 @@
|
|||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
@code {
|
@code {
|
||||||
private IEnumerable<IdentityError>? identityErrors;
|
private IEnumerable<IdentityError>? identityErrors;
|
||||||
|
private bool allowRegistration = true;
|
||||||
|
|
||||||
[SupplyParameterFromForm]
|
[SupplyParameterFromForm]
|
||||||
private InputModel Input { get; set; } = default!;
|
private InputModel Input { get; set; } = default!;
|
||||||
@@ -68,10 +78,17 @@
|
|||||||
protected override void OnInitialized()
|
protected override void OnInitialized()
|
||||||
{
|
{
|
||||||
Input ??= new();
|
Input ??= new();
|
||||||
|
allowRegistration = Configuration.GetValue<bool>("Auth:AllowRegistration", true);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task RegisterUser(EditContext editContext)
|
public async Task RegisterUser(EditContext editContext)
|
||||||
{
|
{
|
||||||
|
if (!allowRegistration)
|
||||||
|
{
|
||||||
|
RedirectManager.RedirectTo("Account/Register");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
var user = CreateUser();
|
var user = CreateUser();
|
||||||
|
|
||||||
await UserStore.SetUserNameAsync(user, Input.Email, CancellationToken.None);
|
await UserStore.SetUserNameAsync(user, Input.Email, CancellationToken.None);
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
@inherits LayoutComponentBase
|
@inherits LayoutComponentBase
|
||||||
@layout EntKube.Web.Client.Layout.MainLayout
|
@layout EntKube.Web.Components.Layout.MainLayout
|
||||||
|
|
||||||
<h1>Manage your account</h1>
|
<h1>Manage your account</h1>
|
||||||
|
|
||||||
|
|||||||
@@ -7,26 +7,21 @@
|
|||||||
<base href="/" />
|
<base href="/" />
|
||||||
<ResourcePreloader />
|
<ResourcePreloader />
|
||||||
<link rel="stylesheet" href="@Assets["lib/bootstrap/dist/css/bootstrap.min.css"]" />
|
<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["app.css"]" />
|
||||||
<link rel="stylesheet" href="@Assets["EntKube.Web.styles.css"]" />
|
<link rel="stylesheet" href="@Assets["EntKube.Web.styles.css"]" />
|
||||||
<ImportMap />
|
<ImportMap />
|
||||||
<link rel="icon" type="image/png" href="favicon.png" />
|
<link rel="icon" type="image/png" href="favicon.png" />
|
||||||
<HeadOutlet @rendermode="PageRenderMode" />
|
<HeadOutlet />
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
<Routes @rendermode="PageRenderMode" />
|
<Routes />
|
||||||
<ReconnectModal />
|
<ReconnectModal />
|
||||||
|
<script src="@Assets["lib/bootstrap/dist/js/bootstrap.bundle.min.js"]"></script>
|
||||||
<script src="@Assets["_framework/blazor.web.js"]"></script>
|
<script src="@Assets["_framework/blazor.web.js"]"></script>
|
||||||
<script src="@Assets["Components/Account/Shared/PasskeySubmit.razor.js"]" type="module"></script>
|
<script src="@Assets["Components/Account/Shared/PasskeySubmit.razor.js"]" type="module"></script>
|
||||||
|
<script>window.scrollToBottom = (el) => { if (el) el.scrollTop = el.scrollHeight; };</script>
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
</html>
|
</html>
|
||||||
|
|
||||||
@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");
|
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 {
|
.nav-item {
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
padding-bottom: 0.5rem;
|
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>
|
<dialog id="components-reconnect-modal" data-nosnippet>
|
||||||
<div class="components-reconnect-container">
|
<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,28 +1,23 @@
|
|||||||
@page "/Error"
|
@page "/Error"
|
||||||
@using System.Diagnostics
|
@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)
|
@if (ShowRequestId)
|
||||||
{
|
{
|
||||||
<p>
|
<p class="text-muted small mb-4">Request ID: <code>@RequestId</code></p>
|
||||||
<strong>Request ID:</strong> <code>@RequestId</code>
|
|
||||||
</p>
|
|
||||||
}
|
}
|
||||||
|
<a href="/" class="btn btn-primary me-2">
|
||||||
<h3>Development Mode</h3>
|
<i class="bi bi-house me-1"></i>Go Home
|
||||||
<p>
|
</a>
|
||||||
Swapping to <strong>Development</strong> environment will display more detailed information about the error that occurred.
|
<a href="javascript:location.reload()" class="btn btn-outline-secondary">
|
||||||
</p>
|
<i class="bi bi-arrow-clockwise me-1"></i>Retry
|
||||||
<p>
|
</a>
|
||||||
<strong>The Development environment shouldn't be enabled for deployed applications.</strong>
|
</div>
|
||||||
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]
|
[CascadingParameter]
|
||||||
|
|||||||
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