Compare commits
4 Commits
a96dd33039
...
658f15d086
| Author | SHA256 | Date | |
|---|---|---|---|
|
|
658f15d086 | ||
|
|
e0f967482e | ||
|
|
8b94fb8826 | ||
|
|
328d494530 |
18
.dockerignore
Normal file
18
.dockerignore
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
**/.dockerignore
|
||||||
|
**/.git
|
||||||
|
**/.gitignore
|
||||||
|
**/.vs
|
||||||
|
**/.vscode
|
||||||
|
**/bin
|
||||||
|
**/obj
|
||||||
|
**/out
|
||||||
|
**/node_modules
|
||||||
|
**/Charts
|
||||||
|
**/*.user
|
||||||
|
**/*.dbmdl
|
||||||
|
**/*.jfm
|
||||||
|
**/secrets.dev.yaml
|
||||||
|
**/values.dev.yaml
|
||||||
|
**/Tiltfile
|
||||||
|
tests
|
||||||
|
CMKS - Infra
|
||||||
@@ -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
|
|
||||||
8
.gitignore
vendored
8
.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,8 @@ FodyWeavers.xsd
|
|||||||
# Built Visual Studio Code Extensions
|
# Built Visual Studio Code Extensions
|
||||||
*.vsix
|
*.vsix
|
||||||
|
|
||||||
|
|
||||||
|
# Local SQLite databases
|
||||||
|
*.db
|
||||||
|
*.db-shm
|
||||||
|
*.db-wal
|
||||||
|
|||||||
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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
1
CMKS - Infra
Submodule
1
CMKS - Infra
Submodule
Submodule CMKS - Infra added at ac8bf53d54
@@ -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>
|
||||||
29
Dockerfile
Normal file
29
Dockerfile
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
|
||||||
|
WORKDIR /src
|
||||||
|
|
||||||
|
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 \
|
||||||
|
--no-restore \
|
||||||
|
-c Release \
|
||||||
|
-o /app/publish
|
||||||
|
|
||||||
|
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS runtime
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# libgit2sharp bundles its native lib, but needs libssl/libcurl on Debian
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
libssl3 \
|
||||||
|
libcurl4 \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
COPY --from=build /app/publish .
|
||||||
|
|
||||||
|
ENV ASPNETCORE_URLS=http://+:8080
|
||||||
|
EXPOSE 8080
|
||||||
|
|
||||||
|
ENTRYPOINT ["dotnet", "EntKube.Web.dll"]
|
||||||
19
EntKube.slnx
19
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.Web.Client/EntKube.Web.Client.csproj" />
|
||||||
<Project Path="src/EntKube.Identity/EntKube.Identity.csproj" />
|
<Project Path="src/EntKube.Web/EntKube.Web.csproj" />
|
||||||
<Project Path="src/EntKube.Clusters/EntKube.Clusters.csproj" />
|
</Folder>
|
||||||
<Project Path="src/EntKube.Provisioning/EntKube.Provisioning.csproj" />
|
<Folder Name="/tests/">
|
||||||
<Project Path="src/EntKube.Web/EntKube.Web.csproj" />
|
<Project Path="tests/EntKube.Web.Tests/EntKube.Web.Tests.csproj" />
|
||||||
<Project Path="src/EntKube.Web.Client/EntKube.Web.Client.csproj" />
|
</Folder>
|
||||||
<!-- Test Projects -->
|
|
||||||
<Project Path="tests/EntKube.Identity.Tests/EntKube.Identity.Tests.csproj" />
|
|
||||||
<Project Path="tests/EntKube.Clusters.Tests/EntKube.Clusters.Tests.csproj" />
|
|
||||||
<Project Path="tests/EntKube.Provisioning.Tests/EntKube.Provisioning.Tests.csproj" />
|
|
||||||
<Project Path="tests/EntKube.Web.Tests/EntKube.Web.Tests.csproj" />
|
|
||||||
</Solution>
|
</Solution>
|
||||||
|
|||||||
@@ -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>
|
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
@using Microsoft.AspNetCore.Authorization
|
@using Microsoft.AspNetCore.Authorization
|
||||||
|
|
||||||
@attribute [Authorize]
|
@attribute [Authorize]
|
||||||
|
@rendermode InteractiveAuto
|
||||||
|
|
||||||
<PageTitle>Auth</PageTitle>
|
<PageTitle>Auth</PageTitle>
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
@page "/counter"
|
@page "/counter"
|
||||||
|
@rendermode InteractiveAuto
|
||||||
|
|
||||||
<PageTitle>Counter</PageTitle>
|
<PageTitle>Counter</PageTitle>
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +0,0 @@
|
|||||||
@page "/"
|
|
||||||
|
|
||||||
<PageTitle>Home</PageTitle>
|
|
||||||
|
|
||||||
<h1>Hello, world!</h1>
|
|
||||||
|
|
||||||
Welcome to your new app.
|
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,9 +1,17 @@
|
|||||||
using Microsoft.AspNetCore.Components.WebAssembly.Hosting;
|
using Microsoft.AspNetCore.Components.WebAssembly.Hosting;
|
||||||
|
|
||||||
var builder = WebAssemblyHostBuilder.CreateDefault(args);
|
namespace EntKube.Web.Client;
|
||||||
|
|
||||||
builder.Services.AddAuthorizationCore();
|
class Program
|
||||||
builder.Services.AddCascadingAuthenticationState();
|
{
|
||||||
builder.Services.AddAuthenticationStateDeserialization();
|
static async Task Main(string[] args)
|
||||||
|
{
|
||||||
|
var builder = WebAssemblyHostBuilder.CreateDefault(args);
|
||||||
|
|
||||||
await builder.Build().RunAsync();
|
builder.Services.AddAuthorizationCore();
|
||||||
|
builder.Services.AddCascadingAuthenticationState();
|
||||||
|
builder.Services.AddAuthenticationStateDeserialization();
|
||||||
|
|
||||||
|
await builder.Build().RunAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -8,4 +8,3 @@
|
|||||||
@using Microsoft.AspNetCore.Components.Web.Virtualization
|
@using Microsoft.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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,19 @@
|
|||||||
<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["_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>
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
</html>
|
</html>
|
||||||
|
|
||||||
@code {
|
|
||||||
[CascadingParameter]
|
|
||||||
private HttpContext HttpContext { get; set; } = default!;
|
|
||||||
|
|
||||||
private IComponentRenderMode? PageRenderMode =>
|
|
||||||
HttpContext.AcceptsInteractiveRouting() ? InteractiveAuto : null;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
@implements IDisposable
|
@implements IDisposable
|
||||||
|
|
||||||
@inject NavigationManager NavigationManager
|
@inject NavigationManager NavigationManager
|
||||||
|
@inject AuthenticationStateProvider AuthStateProvider
|
||||||
|
@inject EntKube.Web.Services.UserAccessService UserAccessService
|
||||||
|
|
||||||
<div class="top-row ps-3 navbar navbar-dark">
|
<div class="top-row ps-3 navbar navbar-dark">
|
||||||
<div class="container-fluid">
|
<div class="container-fluid">
|
||||||
<a class="navbar-brand" href="">EntKube.Web</a>
|
<a class="navbar-brand" href="">EntKube</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -18,23 +20,36 @@
|
|||||||
</NavLink>
|
</NavLink>
|
||||||
</div>
|
</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">
|
<div class="nav-item px-3">
|
||||||
<NavLink class="nav-link" href="counter">
|
<NavLink class="nav-link" href="portal">
|
||||||
<span class="bi bi-plus-square-fill-nav-menu" aria-hidden="true"></span> Counter
|
<span class="bi bi-grid-nav-menu" aria-hidden="true"></span> Portal
|
||||||
</NavLink>
|
</NavLink>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="nav-item px-3">
|
<div class="nav-item px-3">
|
||||||
<NavLink class="nav-link" href="weather">
|
<NavLink class="nav-link" href="portal/status">
|
||||||
<span class="bi bi-list-nested-nav-menu" aria-hidden="true"></span> Weather
|
<span class="bi bi-activity-nav-menu" aria-hidden="true"></span> Status
|
||||||
</NavLink>
|
</NavLink>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="nav-item px-3">
|
<AuthorizeView Roles="Admin">
|
||||||
<NavLink class="nav-link" href="auth">
|
<Authorized>
|
||||||
<span class="bi bi-lock-nav-menu" aria-hidden="true"></span> Auth Required
|
<div class="nav-item px-3">
|
||||||
</NavLink>
|
<NavLink class="nav-link" href="admin/backup">
|
||||||
</div>
|
<span class="bi bi-arrow-repeat-nav-menu" aria-hidden="true"></span> Backup
|
||||||
|
</NavLink>
|
||||||
|
</div>
|
||||||
|
</Authorized>
|
||||||
|
</AuthorizeView>
|
||||||
|
|
||||||
<AuthorizeView>
|
<AuthorizeView>
|
||||||
<Authorized>
|
<Authorized>
|
||||||
@@ -71,11 +86,22 @@
|
|||||||
|
|
||||||
@code {
|
@code {
|
||||||
private string? currentUrl;
|
private string? currentUrl;
|
||||||
|
private bool showTenantsLink;
|
||||||
|
|
||||||
protected override void OnInitialized()
|
protected override async Task OnInitializedAsync()
|
||||||
{
|
{
|
||||||
currentUrl = NavigationManager.ToBaseRelativePath(NavigationManager.Uri);
|
currentUrl = NavigationManager.ToBaseRelativePath(NavigationManager.Uri);
|
||||||
NavigationManager.LocationChanged += OnLocationChanged;
|
NavigationManager.LocationChanged += OnLocationChanged;
|
||||||
|
|
||||||
|
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)
|
private void OnLocationChanged(object? sender, LocationChangedEventArgs e)
|
||||||
@@ -89,4 +115,3 @@
|
|||||||
NavigationManager.LocationChanged -= OnLocationChanged;
|
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">
|
||||||
141
src/EntKube.Web/Components/Pages/Admin/AdminBackup.razor
Normal file
141
src/EntKube.Web/Components/Pages/Admin/AdminBackup.razor
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
@page "/admin/backup"
|
||||||
|
@using Microsoft.AspNetCore.Authorization
|
||||||
|
@using EntKube.Web.Services
|
||||||
|
@rendermode InteractiveServer
|
||||||
|
@attribute [Authorize(Roles = "Admin")]
|
||||||
|
|
||||||
|
@inject BackupService BackupService
|
||||||
|
@inject AuthenticationStateProvider AuthStateProvider
|
||||||
|
|
||||||
|
<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> backup file produced by this application.
|
||||||
|
Secrets are re-encrypted with the current server's root key on restore.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<InputFile OnChange="OnFileSelected" accept=".gz" class="form-control" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-check mb-3">
|
||||||
|
<input class="form-check-input" type="checkbox" id="wipeCheck" @bind="wipeExisting" />
|
||||||
|
<label class="form-check-label" for="wipeCheck">
|
||||||
|
Wipe all existing data before restoring
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (wipeExisting)
|
||||||
|
{
|
||||||
|
<div class="alert alert-danger py-2 small mb-3">
|
||||||
|
<i class="bi bi-exclamation-triangle-fill me-1"></i>
|
||||||
|
<strong>All current data will be permanently deleted</strong> before the
|
||||||
|
backup is loaded. This cannot be undone. Only proceed on a fresh
|
||||||
|
installation or when you intend a full replacement.
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<button class="btn btn-warning" @onclick="RunRestore"
|
||||||
|
disabled="@(selectedFile is null || restoring)">
|
||||||
|
@if (restoring)
|
||||||
|
{
|
||||||
|
<span class="spinner-border spinner-border-sm me-1" role="status"></span>
|
||||||
|
<span>Restoring…</span>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<i class="bi bi-upload me-1"></i>
|
||||||
|
<span>Restore</span>
|
||||||
|
}
|
||||||
|
</button>
|
||||||
|
</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 IBrowserFile? selectedFile;
|
||||||
|
private bool wipeExisting;
|
||||||
|
private bool restoring;
|
||||||
|
private string? successMessage;
|
||||||
|
private string? errorMessage;
|
||||||
|
|
||||||
|
private void OnFileSelected(InputFileChangeEventArgs e)
|
||||||
|
{
|
||||||
|
selectedFile = e.File;
|
||||||
|
successMessage = null;
|
||||||
|
errorMessage = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task RunRestore()
|
||||||
|
{
|
||||||
|
if (selectedFile is null) return;
|
||||||
|
|
||||||
|
restoring = true;
|
||||||
|
successMessage = null;
|
||||||
|
errorMessage = null;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// 50 MB ceiling — backups are gzip-compressed JSON so this is generous.
|
||||||
|
await using Stream stream = selectedFile.OpenReadStream(maxAllowedSize: 50 * 1024 * 1024);
|
||||||
|
await BackupService.ImportAsync(stream, wipeExisting);
|
||||||
|
successMessage = $"Restore completed successfully from '{selectedFile.Name}'.";
|
||||||
|
selectedFile = null;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
errorMessage = $"Restore failed: {ex.Message}";
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
restoring = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
19
src/EntKube.Web/Components/Pages/Home.razor
Normal file
19
src/EntKube.Web/Components/Pages/Home.razor
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
@page "/"
|
||||||
|
|
||||||
|
<PageTitle>EntKube</PageTitle>
|
||||||
|
|
||||||
|
<h1>EntKube</h1>
|
||||||
|
<p class="lead">Multi-tenant platform for managing shared Kubernetes applications and infrastructure services.</p>
|
||||||
|
|
||||||
|
<div class="mt-4 d-flex gap-2">
|
||||||
|
<AuthorizeView Policy="HasTenantAccess">
|
||||||
|
<Authorized>
|
||||||
|
<a href="/tenants" class="btn btn-primary btn-lg">Manage Tenants</a>
|
||||||
|
</Authorized>
|
||||||
|
</AuthorizeView>
|
||||||
|
<AuthorizeView>
|
||||||
|
<Authorized>
|
||||||
|
<a href="/portal" class="btn btn-outline-secondary btn-lg">Customer Portal</a>
|
||||||
|
</Authorized>
|
||||||
|
</AuthorizeView>
|
||||||
|
</div>
|
||||||
201
src/EntKube.Web/Components/Pages/Portal/AppGovernancePanel.razor
Normal file
201
src/EntKube.Web/Components/Pages/Portal/AppGovernancePanel.razor
Normal file
@@ -0,0 +1,201 @@
|
|||||||
|
@using EntKube.Web.Data
|
||||||
|
@using EntKube.Web.Services
|
||||||
|
@inject AppGovernanceService GovernanceService
|
||||||
|
|
||||||
|
@* ═══════════════════════════════════════════════════════════════════
|
||||||
|
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 *@
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label small fw-medium">Kubernetes Namespace</label>
|
||||||
|
@if (!string.IsNullOrWhiteSpace(data?.Namespace))
|
||||||
|
{
|
||||||
|
<input class="form-control form-control-sm font-monospace" value="@data.Namespace" disabled />
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<input class="form-control form-control-sm text-muted fst-italic" value="Not assigned" disabled />
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@* 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>
|
||||||
|
}
|
||||||
|
|
||||||
|
@code {
|
||||||
|
[Parameter, EditorRequired] public Guid AppId { get; set; }
|
||||||
|
[Parameter, EditorRequired] public string AppName { get; set; } = "";
|
||||||
|
|
||||||
|
private AppGovernanceData? data;
|
||||||
|
private bool loading = true;
|
||||||
|
|
||||||
|
protected override async Task OnParametersSetAsync()
|
||||||
|
{
|
||||||
|
loading = true;
|
||||||
|
data = await GovernanceService.LoadAsync(AppId);
|
||||||
|
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"
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
@using EntKube.Web.Data
|
||||||
|
@using EntKube.Web.Services
|
||||||
|
@inject IncidentService IncidentService
|
||||||
|
|
||||||
|
@if (windows.Count > 0)
|
||||||
|
{
|
||||||
|
<div class="alert @(hasActive ? "alert-warning" : "alert-info") d-flex align-items-start gap-3 py-2 mb-3">
|
||||||
|
<span class="bi bi-tools fs-5 mt-1 flex-shrink-0"></span>
|
||||||
|
<div class="flex-grow-1">
|
||||||
|
<strong>@(hasActive ? "Active Maintenance" : "Upcoming Maintenance")</strong>
|
||||||
|
<div class="mt-1">
|
||||||
|
@foreach (MaintenanceWindow w in windows)
|
||||||
|
{
|
||||||
|
bool active = w.StartsAt <= DateTime.UtcNow;
|
||||||
|
<div class="small @(active ? "fw-semibold" : "text-muted")">
|
||||||
|
@if (active)
|
||||||
|
{
|
||||||
|
<span class="badge bg-warning text-dark me-1">Now</span>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<span class="badge bg-info text-dark me-1">@w.StartsAt.ToLocalTime().ToString("MMM d HH:mm")</span>
|
||||||
|
}
|
||||||
|
@w.Title
|
||||||
|
@if (w.Cluster is not null)
|
||||||
|
{
|
||||||
|
<span class="text-muted ms-1">(@w.Cluster.Name)</span>
|
||||||
|
}
|
||||||
|
<span class="text-muted ms-1">— until @w.EndsAt.ToLocalTime().ToString("HH:mm")</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
@if (hasActive)
|
||||||
|
{
|
||||||
|
<div class="small text-muted mt-1">Alert notifications are suppressed during maintenance.</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@code {
|
||||||
|
[Parameter] public required Guid TenantId { get; set; }
|
||||||
|
[Parameter] public HashSet<Guid>? ClusterIds { get; set; }
|
||||||
|
|
||||||
|
private List<MaintenanceWindow> windows = [];
|
||||||
|
private bool hasActive;
|
||||||
|
|
||||||
|
protected override async Task OnParametersSetAsync()
|
||||||
|
{
|
||||||
|
windows = await IncidentService.GetUpcomingMaintenanceAsync(TenantId, ClusterIds);
|
||||||
|
DateTime now = DateTime.UtcNow;
|
||||||
|
hasActive = windows.Any(w => w.StartsAt <= now && w.EndsAt >= now);
|
||||||
|
}
|
||||||
|
}
|
||||||
652
src/EntKube.Web/Components/Pages/Portal/CustomerPortal.razor
Normal file
652
src/EntKube.Web/Components/Pages/Portal/CustomerPortal.razor
Normal file
@@ -0,0 +1,652 @@
|
|||||||
|
@page "/portal"
|
||||||
|
@using EntKube.Web.Data
|
||||||
|
@using EntKube.Web.Services
|
||||||
|
@using Microsoft.AspNetCore.Authorization
|
||||||
|
@using Microsoft.AspNetCore.Components.Authorization
|
||||||
|
@attribute [Authorize]
|
||||||
|
@rendermode InteractiveServer
|
||||||
|
@inject CustomerAccessService CustomerAccessService
|
||||||
|
@inject DeploymentService DeploymentService
|
||||||
|
@inject TenantService TenantService
|
||||||
|
@inject KubernetesOperationsService K8sOps
|
||||||
|
@inject KeycloakService KeycloakService
|
||||||
|
@inject HarborService HarborService
|
||||||
|
@inject VaultService VaultService
|
||||||
|
@inject PrometheusService PrometheusService
|
||||||
|
@inject AuditService AuditService
|
||||||
|
@inject IncidentService IncidentService
|
||||||
|
@inject AuthenticationStateProvider AuthStateProvider
|
||||||
|
@inject NavigationManager Navigation
|
||||||
|
|
||||||
|
<PageTitle>Customer Portal</PageTitle>
|
||||||
|
|
||||||
|
@* ═══════════════════════════════════════════════════════════════════
|
||||||
|
Customer Portal — a scoped view where a user sees only the customers
|
||||||
|
they've been granted access to. From here they can drill into apps,
|
||||||
|
deployments, view pod logs, restart deployments, and delete pods.
|
||||||
|
|
||||||
|
Navigation flow: My Customers → Apps → Deployments → Operations
|
||||||
|
═══════════════════════════════════════════════════════════════════ *@
|
||||||
|
|
||||||
|
@if (loading)
|
||||||
|
{
|
||||||
|
<div class="text-center py-5">
|
||||||
|
<div class="spinner-border text-primary" role="status"></div>
|
||||||
|
<p class="text-muted mt-2">Loading portal...</p>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
else if (customers is null || customers.Count == 0)
|
||||||
|
{
|
||||||
|
<div class="text-center py-5">
|
||||||
|
<i class="bi bi-shield-lock text-muted" style="font-size: 3rem;"></i>
|
||||||
|
<h4 class="mt-3">No access granted</h4>
|
||||||
|
<p class="text-muted">You don't have access to any customers yet. Ask a tenant administrator to grant you access.</p>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
@* ── Level 0: Breadcrumb navigation ── *@
|
||||||
|
<nav aria-label="breadcrumb" class="mb-3">
|
||||||
|
<ol class="breadcrumb small">
|
||||||
|
<li class="breadcrumb-item">
|
||||||
|
@if (selectedDeployment is not null || selectedIdentityRealm is not null || selectedSecretsApp is not null || selectedRegistryProject is not null || selectedGovernanceApp is not null)
|
||||||
|
{
|
||||||
|
<a href="javascript:void(0)" class="text-decoration-none" @onclick="BackToApps">
|
||||||
|
<i class="bi bi-grid me-1"></i>Portal
|
||||||
|
</a>
|
||||||
|
}
|
||||||
|
else if (selectedCustomer is not null)
|
||||||
|
{
|
||||||
|
<a href="javascript:void(0)" class="text-decoration-none" @onclick="BackToCustomers">
|
||||||
|
<i class="bi bi-grid me-1"></i>Portal
|
||||||
|
</a>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<span class="text-muted"><i class="bi bi-grid me-1"></i>Portal</span>
|
||||||
|
}
|
||||||
|
</li>
|
||||||
|
|
||||||
|
@if (selectedCustomer is not null)
|
||||||
|
{
|
||||||
|
<li class="breadcrumb-item">
|
||||||
|
@if (selectedDeployment is not null || selectedIdentityRealm is not null || selectedSecretsApp is not null || selectedRegistryProject is not null || selectedGovernanceApp is not null)
|
||||||
|
{
|
||||||
|
<a href="javascript:void(0)" class="text-decoration-none" @onclick="BackToApps">@selectedCustomer.Name</a>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<span class="text-muted">@selectedCustomer.Name</span>
|
||||||
|
}
|
||||||
|
</li>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (selectedDeployment is not null)
|
||||||
|
{
|
||||||
|
<li class="breadcrumb-item active">@selectedDeployment.Name</li>
|
||||||
|
}
|
||||||
|
else if (selectedIdentityRealm is not null)
|
||||||
|
{
|
||||||
|
<li class="breadcrumb-item active">
|
||||||
|
<i class="bi bi-shield-lock me-1"></i>@selectedIdentityRealm.DisplayName
|
||||||
|
</li>
|
||||||
|
}
|
||||||
|
else if (selectedSecretsApp is not null)
|
||||||
|
{
|
||||||
|
<li class="breadcrumb-item active">
|
||||||
|
<i class="bi bi-shield-lock me-1"></i>@selectedSecretsApp.Name — Secrets
|
||||||
|
</li>
|
||||||
|
}
|
||||||
|
else if (selectedRegistryProject is not null)
|
||||||
|
{
|
||||||
|
<li class="breadcrumb-item active">
|
||||||
|
<i class="bi bi-archive me-1"></i>@selectedRegistryProject.ProjectName — Registry
|
||||||
|
</li>
|
||||||
|
}
|
||||||
|
else if (selectedGovernanceApp is not null)
|
||||||
|
{
|
||||||
|
<li class="breadcrumb-item active">
|
||||||
|
<i class="bi bi-shield-check me-1"></i>@selectedGovernanceApp.Name — Policy
|
||||||
|
</li>
|
||||||
|
}
|
||||||
|
</ol>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
@* ════════════════════════════════════════════════════════════════
|
||||||
|
Level 1: Customer list
|
||||||
|
════════════════════════════════════════════════════════════════ *@
|
||||||
|
@if (selectedCustomer is null)
|
||||||
|
{
|
||||||
|
<div class="d-flex align-items-center mb-4">
|
||||||
|
<i class="bi bi-grid fs-3 me-2 text-primary"></i>
|
||||||
|
<h2 class="mb-0">My Customers</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row row-cols-1 row-cols-md-2 row-cols-lg-3 g-3">
|
||||||
|
@foreach (Customer customer in customers)
|
||||||
|
{
|
||||||
|
<div class="col">
|
||||||
|
<div class="card shadow-sm h-100" role="button" style="cursor: pointer;"
|
||||||
|
@onclick="() => SelectCustomer(customer)">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="d-flex align-items-center mb-2">
|
||||||
|
<i class="bi bi-people fs-4 me-2 text-primary"></i>
|
||||||
|
<h5 class="mb-0">@customer.Name</h5>
|
||||||
|
</div>
|
||||||
|
<div class="text-muted small">
|
||||||
|
<i class="bi bi-app-indicator me-1"></i>
|
||||||
|
@customer.Apps.Count app@(customer.Apps.Count != 1 ? "s" : "")
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@* ════════════════════════════════════════════════════════════════
|
||||||
|
Level 2: Apps and deployments for a selected customer
|
||||||
|
════════════════════════════════════════════════════════════════ *@
|
||||||
|
else if (selectedDeployment is null && selectedIdentityRealm is null && selectedSecretsApp is null && selectedRegistryProject is null)
|
||||||
|
{
|
||||||
|
<div class="d-flex align-items-center mb-3">
|
||||||
|
<i class="bi bi-people fs-3 me-2 text-primary"></i>
|
||||||
|
<div>
|
||||||
|
<h2 class="mb-0">@selectedCustomer.Name</h2>
|
||||||
|
<small class="text-muted">@selectedCustomer.Apps.Count app@(selectedCustomer.Apps.Count != 1 ? "s" : "")</small>
|
||||||
|
</div>
|
||||||
|
<div class="ms-auto d-flex gap-2">
|
||||||
|
<button class="btn btn-sm @(showMonitoringPanel ? "btn-primary" : "btn-outline-secondary")"
|
||||||
|
@onclick="() => showMonitoringPanel = !showMonitoringPanel">
|
||||||
|
<span class="bi bi-heart-pulse me-1"></span>Monitoring
|
||||||
|
</button>
|
||||||
|
<a class="btn btn-sm btn-outline-secondary" href="/portal/status">
|
||||||
|
<span class="bi bi-activity me-1"></span>Status Overview
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@* ── Maintenance notice ── *@
|
||||||
|
<CustomerMaintenanceNotice TenantId="selectedCustomer.TenantId" ClusterIds="AllCustomerClusterIds" />
|
||||||
|
|
||||||
|
@* ── Monitoring panel (toggled) ── *@
|
||||||
|
@if (showMonitoringPanel)
|
||||||
|
{
|
||||||
|
<div class="card shadow-sm mb-4">
|
||||||
|
<div class="card-header d-flex align-items-center gap-2 py-2">
|
||||||
|
<span class="bi bi-heart-pulse text-primary"></span>
|
||||||
|
<strong>Monitoring — @selectedCustomer.Name</strong>
|
||||||
|
<button class="btn-close btn-sm ms-auto" @onclick="() => showMonitoringPanel = false"></button>
|
||||||
|
</div>
|
||||||
|
<div class="card-body p-0">
|
||||||
|
<div class="p-3 border-bottom">
|
||||||
|
<h6 class="text-muted small mb-2"><span class="bi bi-shield-check me-1"></span>SLA & Uptime</h6>
|
||||||
|
@if (AllCustomerDeployments.Count > 0)
|
||||||
|
{
|
||||||
|
<CustomerSlaReport
|
||||||
|
Deployments="AllCustomerDeployments"
|
||||||
|
CustomerId="selectedCustomer.Id"
|
||||||
|
TenantId="selectedCustomer.TenantId" />
|
||||||
|
}
|
||||||
|
else { <div class="text-muted small">No deployments yet.</div> }
|
||||||
|
</div>
|
||||||
|
<div class="p-3">
|
||||||
|
<h6 class="text-muted small mb-2"><span class="bi bi-clock-history me-1"></span>Incident History (30 days)</h6>
|
||||||
|
@if (AllCustomerDeployments.Count > 0)
|
||||||
|
{
|
||||||
|
<CustomerIncidentHistory Deployments="AllCustomerDeployments" WindowDays="30" />
|
||||||
|
}
|
||||||
|
else { <div class="text-muted small">No deployments yet.</div> }
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (selectedCustomer.Apps.Count == 0)
|
||||||
|
{
|
||||||
|
<div class="text-center py-4">
|
||||||
|
<i class="bi bi-app-indicator text-muted" style="font-size: 2rem;"></i>
|
||||||
|
<p class="text-muted mt-2">No apps configured for this customer yet.</p>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
@foreach (Data.App app in selectedCustomer.Apps.OrderBy(a => a.Name))
|
||||||
|
{
|
||||||
|
bool hasIdentity = appRealms.ContainsKey(app.Id);
|
||||||
|
bool hasRegistry = appHarborProjects.ContainsKey(app.Id);
|
||||||
|
bool showingCreateForm = createDeployAppId == app.Id;
|
||||||
|
<div class="card shadow-sm mb-3">
|
||||||
|
<div class="card-header bg-white py-2">
|
||||||
|
<div class="d-flex align-items-center justify-content-between">
|
||||||
|
<div class="d-flex align-items-center">
|
||||||
|
<i class="bi bi-app-indicator me-2 text-primary"></i>
|
||||||
|
<strong>@app.Name</strong>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex gap-2">
|
||||||
|
@if (currentAccessRole >= CustomerAccessRole.Admin)
|
||||||
|
{
|
||||||
|
<button class="btn btn-sm btn-outline-primary"
|
||||||
|
@onclick="() => ToggleCreateDeployment(app.Id)">
|
||||||
|
<i class="bi bi-plus me-1"></i>New Deployment
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
@if (hasIdentity)
|
||||||
|
{
|
||||||
|
<button class="btn btn-sm btn-outline-primary"
|
||||||
|
@onclick="() => SelectIdentityRealm(app.Id)">
|
||||||
|
<i class="bi bi-shield-lock me-1"></i>Identity
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
@if (hasRegistry)
|
||||||
|
{
|
||||||
|
<button class="btn btn-sm btn-outline-primary"
|
||||||
|
@onclick="() => SelectRegistryProject(app.Id)">
|
||||||
|
<i class="bi bi-archive me-1"></i>Registry
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
<button class="btn btn-sm btn-outline-secondary"
|
||||||
|
@onclick="() => SelectSecretsApp(app)">
|
||||||
|
<i class="bi bi-key me-1"></i>Secrets
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-sm btn-outline-secondary"
|
||||||
|
@onclick="() => SelectGovernanceApp(app)">
|
||||||
|
<i class="bi bi-shield-check me-1"></i>Policy
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
@if (showingCreateForm)
|
||||||
|
{
|
||||||
|
<div class="card border-primary mb-3">
|
||||||
|
<div class="card-body">
|
||||||
|
<h6 class="mb-3"><i class="bi bi-plus-circle me-1"></i>Create Deployment</h6>
|
||||||
|
@if (createDeployError is not null)
|
||||||
|
{
|
||||||
|
<div class="alert alert-danger py-1 small mb-2">@createDeployError</div>
|
||||||
|
}
|
||||||
|
<div class="row g-2 mb-2">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label small">Name</label>
|
||||||
|
<input class="form-control form-control-sm" @bind="newDeployName" placeholder="e.g. my-app-prod" />
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label small">Type</label>
|
||||||
|
<select class="form-select form-select-sm" @bind="newDeployType">
|
||||||
|
<option value="@DeploymentType.Manual">Manual</option>
|
||||||
|
<option value="@DeploymentType.Yaml">YAML</option>
|
||||||
|
<option value="@DeploymentType.HelmChart">Helm Chart</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label class="form-label small">Environment</label>
|
||||||
|
<select class="form-select form-select-sm" @bind="newDeployEnvId">
|
||||||
|
<option value="@Guid.Empty">Select...</option>
|
||||||
|
@foreach (Data.Environment env in tenantEnvironments)
|
||||||
|
{
|
||||||
|
<option value="@env.Id">@env.Name</option>
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label class="form-label small">Cluster</label>
|
||||||
|
<select class="form-select form-select-sm" @bind="newDeployClusterId">
|
||||||
|
<option value="@Guid.Empty">Select...</option>
|
||||||
|
@foreach (KubernetesCluster cluster in tenantClusters.Where(c => newDeployEnvId == Guid.Empty || c.EnvironmentId == newDeployEnvId))
|
||||||
|
{
|
||||||
|
<option value="@cluster.Id">@cluster.Name</option>
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label class="form-label small">Namespace</label>
|
||||||
|
<input class="form-control form-control-sm" @bind="newDeployNamespace" placeholder="e.g. my-app" />
|
||||||
|
</div>
|
||||||
|
@if (newDeployType == DeploymentType.HelmChart)
|
||||||
|
{
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label class="form-label small">Helm Repo URL</label>
|
||||||
|
<input class="form-control form-control-sm" @bind="newHelmRepoUrl" placeholder="https://charts.example.com" />
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label class="form-label small">Chart Name</label>
|
||||||
|
<input class="form-control form-control-sm" @bind="newHelmChartName" />
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label class="form-label small">Chart Version</label>
|
||||||
|
<input class="form-control form-control-sm" @bind="newHelmChartVersion" placeholder="e.g. 1.0.0" />
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<div class="d-flex gap-1">
|
||||||
|
<button class="btn btn-sm btn-primary" @onclick="() => CreateDeployment(app.Id)"
|
||||||
|
disabled="@(string.IsNullOrWhiteSpace(newDeployName) || newDeployEnvId == Guid.Empty || newDeployClusterId == Guid.Empty || string.IsNullOrWhiteSpace(newDeployNamespace) || creatingDeployment)">
|
||||||
|
@if (creatingDeployment) { <span class="spinner-border spinner-border-sm me-1"></span> }
|
||||||
|
Create
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-sm btn-outline-secondary" @onclick="() => createDeployAppId = null">Cancel</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (appDeployments.TryGetValue(app.Id, out List<AppDeployment>? deploys) && deploys.Count > 0)
|
||||||
|
{
|
||||||
|
<div class="row row-cols-1 row-cols-lg-2 g-2">
|
||||||
|
@foreach (AppDeployment deploy in deploys)
|
||||||
|
{
|
||||||
|
<div class="col">
|
||||||
|
<div class="card border-light" role="button" style="cursor: pointer;"
|
||||||
|
@onclick="() => SelectDeployment(deploy)">
|
||||||
|
<div class="card-body py-2">
|
||||||
|
<div class="d-flex align-items-center justify-content-between mb-1">
|
||||||
|
<span class="fw-medium">
|
||||||
|
<i class="bi bi-rocket-takeoff me-1 text-primary"></i>@deploy.Name
|
||||||
|
</span>
|
||||||
|
<div class="d-flex gap-1">
|
||||||
|
@SyncBadge(deploy.SyncStatus)
|
||||||
|
@HealthBadge(deploy.HealthStatus)
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex flex-wrap gap-2 text-muted small">
|
||||||
|
@TypeBadge(deploy.Type)
|
||||||
|
<span><i class="bi bi-layers me-1"></i>@deploy.Environment?.Name</span>
|
||||||
|
<span><i class="bi bi-hdd-network me-1"></i>@deploy.Cluster?.Name</span>
|
||||||
|
<span><i class="bi bi-box me-1"></i>@deploy.Namespace</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
else if (!showingCreateForm)
|
||||||
|
{
|
||||||
|
<p class="text-muted small mb-0">No deployments configured.</p>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@* ════════════════════════════════════════════════════════════════
|
||||||
|
Level 3: Deployment detail with operations
|
||||||
|
════════════════════════════════════════════════════════════════ *@
|
||||||
|
else if (selectedDeployment is not null)
|
||||||
|
{
|
||||||
|
<PortalDeploymentDetail
|
||||||
|
Deployment="selectedDeployment"
|
||||||
|
AccessRole="currentAccessRole"
|
||||||
|
K8sOps="K8sOps"
|
||||||
|
DeploymentService="DeploymentService"
|
||||||
|
PrometheusService="PrometheusService"
|
||||||
|
AuditService="AuditService"
|
||||||
|
OnBack="BackToApps"
|
||||||
|
OnDeleted="OnDeploymentDeleted" />
|
||||||
|
}
|
||||||
|
|
||||||
|
@* ════════════════════════════════════════════════════════════════
|
||||||
|
Level 3: Identity (Keycloak realm) management
|
||||||
|
════════════════════════════════════════════════════════════════ *@
|
||||||
|
else if (selectedIdentityRealm is not null)
|
||||||
|
{
|
||||||
|
<PortalIdentityDetail
|
||||||
|
Realm="selectedIdentityRealm"
|
||||||
|
TenantId="selectedCustomer!.TenantId" />
|
||||||
|
}
|
||||||
|
|
||||||
|
@* ════════════════════════════════════════════════════════════════
|
||||||
|
Level 3: App secrets vault
|
||||||
|
════════════════════════════════════════════════════════════════ *@
|
||||||
|
else if (selectedSecretsApp is not null)
|
||||||
|
{
|
||||||
|
<PortalSecretsDetail
|
||||||
|
App="selectedSecretsApp"
|
||||||
|
TenantId="selectedCustomer!.TenantId"
|
||||||
|
AccessRole="currentAccessRole" />
|
||||||
|
}
|
||||||
|
|
||||||
|
@* ════════════════════════════════════════════════════════════════
|
||||||
|
Level 3: Harbor registry project management
|
||||||
|
════════════════════════════════════════════════════════════════ *@
|
||||||
|
else if (selectedRegistryProject is not null)
|
||||||
|
{
|
||||||
|
<PortalRegistryDetail
|
||||||
|
Project="selectedRegistryProject"
|
||||||
|
TenantId="selectedCustomer!.TenantId"
|
||||||
|
AccessRole="currentAccessRole" />
|
||||||
|
}
|
||||||
|
else if (selectedGovernanceApp is not null)
|
||||||
|
{
|
||||||
|
<AppGovernancePanel AppId="selectedGovernanceApp.Id" AppName="selectedGovernanceApp.Name" />
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@code {
|
||||||
|
private bool loading = true;
|
||||||
|
private string? currentUserId;
|
||||||
|
private CustomerAccessRole currentAccessRole;
|
||||||
|
|
||||||
|
private List<Customer>? customers;
|
||||||
|
private Customer? selectedCustomer;
|
||||||
|
private AppDeployment? selectedDeployment;
|
||||||
|
private KeycloakRealm? selectedIdentityRealm;
|
||||||
|
private Data.App? selectedSecretsApp;
|
||||||
|
private HarborProject? selectedRegistryProject;
|
||||||
|
private Data.App? selectedGovernanceApp;
|
||||||
|
|
||||||
|
// Deployments indexed by app ID for the selected customer.
|
||||||
|
private Dictionary<Guid, List<AppDeployment>> appDeployments = new();
|
||||||
|
|
||||||
|
// Linked Keycloak realms indexed by app ID for the selected customer.
|
||||||
|
private Dictionary<Guid, KeycloakRealm> appRealms = new();
|
||||||
|
|
||||||
|
// Linked Harbor projects indexed by app ID for the selected customer.
|
||||||
|
private Dictionary<Guid, HarborProject> appHarborProjects = new();
|
||||||
|
|
||||||
|
// Environments and clusters for deployment creation (loaded when Admin selects a customer).
|
||||||
|
private List<Data.Environment> tenantEnvironments = [];
|
||||||
|
private List<KubernetesCluster> tenantClusters = [];
|
||||||
|
|
||||||
|
// Monitoring panel toggle in Level 2
|
||||||
|
private bool showMonitoringPanel;
|
||||||
|
|
||||||
|
private List<AppDeployment> AllCustomerDeployments =>
|
||||||
|
appDeployments.Values.SelectMany(d => d).ToList();
|
||||||
|
|
||||||
|
private HashSet<Guid> AllCustomerClusterIds =>
|
||||||
|
AllCustomerDeployments.Select(d => d.ClusterId).ToHashSet();
|
||||||
|
|
||||||
|
// Create deployment form (portal Admin).
|
||||||
|
private Guid? createDeployAppId;
|
||||||
|
private string newDeployName = "", newDeployNamespace = "";
|
||||||
|
private DeploymentType newDeployType = DeploymentType.Manual;
|
||||||
|
private Guid newDeployEnvId, newDeployClusterId;
|
||||||
|
private string newHelmRepoUrl = "", newHelmChartName = "", newHelmChartVersion = "";
|
||||||
|
private bool creatingDeployment;
|
||||||
|
private string? createDeployError;
|
||||||
|
|
||||||
|
protected override async Task OnInitializedAsync()
|
||||||
|
{
|
||||||
|
// Determine who the logged-in user is.
|
||||||
|
AuthenticationState authState = await AuthStateProvider.GetAuthenticationStateAsync();
|
||||||
|
currentUserId = authState.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value;
|
||||||
|
|
||||||
|
if (string.IsNullOrEmpty(currentUserId))
|
||||||
|
{
|
||||||
|
loading = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load only the customers this user has access to.
|
||||||
|
customers = await CustomerAccessService.GetAccessibleCustomersAsync(currentUserId);
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task SelectCustomer(Customer customer)
|
||||||
|
{
|
||||||
|
selectedCustomer = customer;
|
||||||
|
showMonitoringPanel = false;
|
||||||
|
|
||||||
|
// Look up the user's role for this customer.
|
||||||
|
CustomerAccess? access = await CustomerAccessService.GetAccessAsync(currentUserId!, customer.Id);
|
||||||
|
currentAccessRole = access?.Role ?? CustomerAccessRole.Viewer;
|
||||||
|
|
||||||
|
// Load deployments, linked Keycloak realms, and linked Harbor projects for all apps.
|
||||||
|
appDeployments.Clear();
|
||||||
|
appRealms.Clear();
|
||||||
|
appHarborProjects.Clear();
|
||||||
|
createDeployAppId = null;
|
||||||
|
|
||||||
|
foreach (Data.App app in customer.Apps)
|
||||||
|
{
|
||||||
|
List<AppDeployment> deploys = await DeploymentService.GetDeploymentsAsync(app.Id);
|
||||||
|
appDeployments[app.Id] = deploys;
|
||||||
|
|
||||||
|
KeycloakRealm? realm = await KeycloakService.GetRealmForAppAsync(customer.TenantId, app.Id);
|
||||||
|
if (realm is not null)
|
||||||
|
appRealms[app.Id] = realm;
|
||||||
|
|
||||||
|
HarborProject? harborProject = await HarborService.GetProjectForAppAsync(customer.TenantId, app.Id);
|
||||||
|
if (harborProject is not null)
|
||||||
|
appHarborProjects[app.Id] = harborProject;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load environments and clusters so Admin users can create deployments.
|
||||||
|
if (currentAccessRole >= CustomerAccessRole.Admin)
|
||||||
|
{
|
||||||
|
tenantEnvironments = await TenantService.GetEnvironmentsAsync(customer.TenantId);
|
||||||
|
tenantClusters = await TenantService.GetClustersAsync(customer.TenantId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void SelectDeployment(AppDeployment deploy)
|
||||||
|
{
|
||||||
|
selectedDeployment = deploy;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void SelectIdentityRealm(Guid appId)
|
||||||
|
{
|
||||||
|
if (appRealms.TryGetValue(appId, out KeycloakRealm? realm))
|
||||||
|
selectedIdentityRealm = realm;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void SelectRegistryProject(Guid appId)
|
||||||
|
{
|
||||||
|
if (appHarborProjects.TryGetValue(appId, out HarborProject? project))
|
||||||
|
selectedRegistryProject = project;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void BackToCustomers()
|
||||||
|
{
|
||||||
|
selectedCustomer = null;
|
||||||
|
selectedDeployment = null;
|
||||||
|
selectedIdentityRealm = null;
|
||||||
|
selectedSecretsApp = null;
|
||||||
|
selectedRegistryProject = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void SelectSecretsApp(Data.App app)
|
||||||
|
{
|
||||||
|
selectedSecretsApp = app;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void SelectGovernanceApp(Data.App app)
|
||||||
|
{
|
||||||
|
selectedGovernanceApp = app;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void BackToApps()
|
||||||
|
{
|
||||||
|
selectedDeployment = null;
|
||||||
|
selectedIdentityRealm = null;
|
||||||
|
selectedSecretsApp = null;
|
||||||
|
selectedRegistryProject = null;
|
||||||
|
selectedGovernanceApp = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task OnDeploymentDeleted()
|
||||||
|
{
|
||||||
|
if (selectedDeployment is not null)
|
||||||
|
{
|
||||||
|
Guid appId = selectedDeployment.AppId;
|
||||||
|
selectedDeployment = null;
|
||||||
|
// Reload the affected app's deployments.
|
||||||
|
appDeployments[appId] = await DeploymentService.GetDeploymentsAsync(appId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ToggleCreateDeployment(Guid appId)
|
||||||
|
{
|
||||||
|
if (createDeployAppId == appId)
|
||||||
|
{
|
||||||
|
createDeployAppId = null;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
createDeployAppId = appId;
|
||||||
|
newDeployName = newDeployNamespace = newHelmRepoUrl = newHelmChartName = newHelmChartVersion = "";
|
||||||
|
newDeployType = DeploymentType.Manual;
|
||||||
|
newDeployEnvId = newDeployClusterId = Guid.Empty;
|
||||||
|
createDeployError = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task CreateDeployment(Guid appId)
|
||||||
|
{
|
||||||
|
creatingDeployment = true;
|
||||||
|
createDeployError = null;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await DeploymentService.CreateDeploymentAsync(
|
||||||
|
appId, newDeployName, newDeployType,
|
||||||
|
newDeployEnvId, newDeployClusterId, newDeployNamespace,
|
||||||
|
newDeployType == DeploymentType.HelmChart ? newHelmRepoUrl : null,
|
||||||
|
newDeployType == DeploymentType.HelmChart ? newHelmChartName : null,
|
||||||
|
newDeployType == DeploymentType.HelmChart ? newHelmChartVersion : null);
|
||||||
|
|
||||||
|
createDeployAppId = null;
|
||||||
|
appDeployments[appId] = await DeploymentService.GetDeploymentsAsync(appId);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
createDeployError = ex.Message;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
creatingDeployment = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ──────── Badge helpers ────────
|
||||||
|
|
||||||
|
private RenderFragment TypeBadge(DeploymentType type) => type switch
|
||||||
|
{
|
||||||
|
DeploymentType.Manual => @<span class="badge bg-info">Manual</span>,
|
||||||
|
DeploymentType.Yaml => @<span class="badge bg-warning text-dark">YAML</span>,
|
||||||
|
DeploymentType.HelmChart => @<span class="badge bg-purple text-white" style="background-color: #6f42c1 !important;">Helm</span>,
|
||||||
|
_ => @<span class="badge bg-secondary">Unknown</span>
|
||||||
|
};
|
||||||
|
|
||||||
|
private RenderFragment SyncBadge(SyncStatus status) => status switch
|
||||||
|
{
|
||||||
|
SyncStatus.Synced => @<span class="badge bg-success"><i class="bi bi-check-circle me-1"></i>Synced</span>,
|
||||||
|
SyncStatus.OutOfSync => @<span class="badge bg-warning text-dark"><i class="bi bi-exclamation-circle me-1"></i>OutOfSync</span>,
|
||||||
|
SyncStatus.Syncing => @<span class="badge bg-info"><i class="bi bi-arrow-repeat me-1"></i>Syncing</span>,
|
||||||
|
SyncStatus.Failed => @<span class="badge bg-danger"><i class="bi bi-x-circle me-1"></i>Failed</span>,
|
||||||
|
_ => @<span class="badge bg-secondary">Unknown</span>
|
||||||
|
};
|
||||||
|
|
||||||
|
private RenderFragment HealthBadge(HealthStatus status) => status switch
|
||||||
|
{
|
||||||
|
HealthStatus.Healthy => @<span class="badge bg-success"><i class="bi bi-heart-fill me-1"></i>Healthy</span>,
|
||||||
|
HealthStatus.Progressing => @<span class="badge bg-info"><i class="bi bi-hourglass-split me-1"></i>Progressing</span>,
|
||||||
|
HealthStatus.Degraded => @<span class="badge bg-warning text-dark"><i class="bi bi-exclamation-triangle me-1"></i>Degraded</span>,
|
||||||
|
HealthStatus.Missing => @<span class="badge bg-danger"><i class="bi bi-question-circle me-1"></i>Missing</span>,
|
||||||
|
HealthStatus.Suspended => @<span class="badge bg-secondary"><i class="bi bi-pause-circle me-1"></i>Suspended</span>,
|
||||||
|
_ => @<span class="badge bg-secondary">Unknown</span>
|
||||||
|
};
|
||||||
|
}
|
||||||
123
src/EntKube.Web/Components/Pages/Portal/CustomerSlaReport.razor
Normal file
123
src/EntKube.Web/Components/Pages/Portal/CustomerSlaReport.razor
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
@using EntKube.Web.Data
|
||||||
|
@using EntKube.Web.Services
|
||||||
|
@inject IncidentService IncidentService
|
||||||
|
|
||||||
|
@if (rows.Count == 0 && !isLoading)
|
||||||
|
{
|
||||||
|
<div class="text-muted small text-center py-2">No uptime data yet — health snapshots are collected every 5 minutes.</div>
|
||||||
|
}
|
||||||
|
else if (isLoading)
|
||||||
|
{
|
||||||
|
<div class="text-muted small text-center py-2"><span class="bi bi-hourglass-split me-1"></span> Loading…</div>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-sm align-middle mb-0">
|
||||||
|
<thead class="table-light">
|
||||||
|
<tr>
|
||||||
|
<th>Deployment</th>
|
||||||
|
<th>App</th>
|
||||||
|
<th class="text-end">7-day uptime</th>
|
||||||
|
<th class="text-end">30-day uptime</th>
|
||||||
|
<th class="text-end">SLA target</th>
|
||||||
|
<th class="text-end">Status</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@foreach (SlaRow row in rows)
|
||||||
|
{
|
||||||
|
bool met = !row.Uptime30d.HasValue || !row.SlaTarget.HasValue || row.Uptime30d.Value >= row.SlaTarget.Value;
|
||||||
|
<tr>
|
||||||
|
<td class="fw-semibold small">@row.DeploymentName</td>
|
||||||
|
<td class="text-muted small">@row.AppName</td>
|
||||||
|
<td class="text-end">
|
||||||
|
@if (row.Uptime7d.HasValue)
|
||||||
|
{
|
||||||
|
<span class="@UptimeColor(row.Uptime7d.Value, row.SlaTarget)">@row.Uptime7d.Value.ToString("F2")%</span>
|
||||||
|
}
|
||||||
|
else { <span class="text-muted">—</span> }
|
||||||
|
</td>
|
||||||
|
<td class="text-end">
|
||||||
|
@if (row.Uptime30d.HasValue)
|
||||||
|
{
|
||||||
|
<span class="@UptimeColor(row.Uptime30d.Value, row.SlaTarget)">@row.Uptime30d.Value.ToString("F2")%</span>
|
||||||
|
}
|
||||||
|
else { <span class="text-muted">—</span> }
|
||||||
|
</td>
|
||||||
|
<td class="text-end text-muted small">
|
||||||
|
@(row.SlaTarget.HasValue ? $"{row.SlaTarget.Value:F1}%" : "—")
|
||||||
|
</td>
|
||||||
|
<td class="text-end">
|
||||||
|
@if (!row.Uptime30d.HasValue)
|
||||||
|
{
|
||||||
|
<span class="text-muted small">No data</span>
|
||||||
|
}
|
||||||
|
else if (!row.SlaTarget.HasValue)
|
||||||
|
{
|
||||||
|
<span class="text-muted small">No target</span>
|
||||||
|
}
|
||||||
|
else if (met)
|
||||||
|
{
|
||||||
|
<span class="badge bg-success"><span class="bi bi-check me-1"></span>Meeting SLA</span>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<span class="badge bg-danger"><span class="bi bi-exclamation-triangle me-1"></span>Below SLA</span>
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@code {
|
||||||
|
[Parameter] public required List<AppDeployment> Deployments { get; set; }
|
||||||
|
[Parameter] public Guid CustomerId { get; set; }
|
||||||
|
[Parameter] public Guid TenantId { get; set; }
|
||||||
|
|
||||||
|
private List<SlaRow> rows = [];
|
||||||
|
private bool isLoading = true;
|
||||||
|
|
||||||
|
protected override async Task OnParametersSetAsync() => await Load();
|
||||||
|
|
||||||
|
private async Task Load()
|
||||||
|
{
|
||||||
|
isLoading = true;
|
||||||
|
StateHasChanged();
|
||||||
|
|
||||||
|
List<SlaRow> result = [];
|
||||||
|
foreach (AppDeployment dep in Deployments)
|
||||||
|
{
|
||||||
|
UptimeResult u7 = await IncidentService.GetUptimeAsync(dep.Id, 7);
|
||||||
|
UptimeResult u30 = await IncidentService.GetUptimeAsync(dep.Id, 30);
|
||||||
|
SlaTarget? sla = await IncidentService.GetSlaTargetAsync(TenantId, CustomerId, dep.AppId);
|
||||||
|
|
||||||
|
result.Add(new SlaRow(
|
||||||
|
dep.Name,
|
||||||
|
dep.App?.Name ?? dep.AppId.ToString(),
|
||||||
|
u7.Percent,
|
||||||
|
u30.Percent,
|
||||||
|
sla?.TargetPercent));
|
||||||
|
}
|
||||||
|
rows = result.OrderBy(r => r.AppName).ThenBy(r => r.DeploymentName).ToList();
|
||||||
|
isLoading = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string UptimeColor(double pct, double? target)
|
||||||
|
{
|
||||||
|
double threshold = target ?? 99.0;
|
||||||
|
if (pct >= threshold) return "text-success fw-semibold";
|
||||||
|
if (pct >= threshold - 2) return "text-warning fw-semibold";
|
||||||
|
return "text-danger fw-semibold";
|
||||||
|
}
|
||||||
|
|
||||||
|
private record SlaRow(
|
||||||
|
string DeploymentName,
|
||||||
|
string AppName,
|
||||||
|
double? Uptime7d,
|
||||||
|
double? Uptime30d,
|
||||||
|
double? SlaTarget);
|
||||||
|
}
|
||||||
@@ -0,0 +1,281 @@
|
|||||||
|
@page "/portal/status"
|
||||||
|
@using EntKube.Web.Data
|
||||||
|
@using EntKube.Web.Services
|
||||||
|
@using Microsoft.EntityFrameworkCore
|
||||||
|
@using Microsoft.AspNetCore.Authorization
|
||||||
|
@using Microsoft.AspNetCore.Components.Authorization
|
||||||
|
@attribute [Authorize]
|
||||||
|
@rendermode InteractiveServer
|
||||||
|
@inject CustomerAccessService CustomerAccessService
|
||||||
|
@inject IncidentService IncidentService
|
||||||
|
@inject IDbContextFactory<ApplicationDbContext> DbFactory
|
||||||
|
@inject AuthenticationStateProvider AuthStateProvider
|
||||||
|
@inject NavigationManager Navigation
|
||||||
|
|
||||||
|
<PageTitle>Status Overview</PageTitle>
|
||||||
|
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||||
|
<div>
|
||||||
|
<h4 class="mb-0"><i class="bi bi-activity me-2"></i>Status Overview</h4>
|
||||||
|
<div class="text-muted small">Health and uptime across all your applications</div>
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-sm btn-outline-secondary" @onclick="LoadData" disabled="@isLoading">
|
||||||
|
<span class="bi bi-arrow-clockwise @(isLoading ? "spin" : "")"></span> Refresh
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (isLoading)
|
||||||
|
{
|
||||||
|
<div class="text-center py-5">
|
||||||
|
<div class="spinner-border text-primary" role="status"></div>
|
||||||
|
<p class="text-muted mt-2">Loading status…</p>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
else if (rows.Count == 0)
|
||||||
|
{
|
||||||
|
<div class="text-center py-5 text-muted">
|
||||||
|
<i class="bi bi-shield-lock display-4 d-block mb-2"></i>
|
||||||
|
No applications to show. You may not have access to any customer yet.
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<div class="card shadow-sm">
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-hover align-middle mb-0">
|
||||||
|
<thead class="table-light">
|
||||||
|
<tr>
|
||||||
|
<th>Application</th>
|
||||||
|
<th>Customer</th>
|
||||||
|
<th>Health</th>
|
||||||
|
<th>Uptime (7d)</th>
|
||||||
|
<th>Uptime (30d)</th>
|
||||||
|
<th>SLA Target</th>
|
||||||
|
<th>Active Alerts</th>
|
||||||
|
<th>Last Sync</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@foreach (StatusRow row in rows)
|
||||||
|
{
|
||||||
|
<tr style="cursor:pointer" @onclick="() => GoToPortal(row)">
|
||||||
|
<td class="fw-semibold">@row.AppName</td>
|
||||||
|
<td class="text-muted">@row.CustomerName</td>
|
||||||
|
<td>@HealthBadge(row.WorstHealth)</td>
|
||||||
|
<td>
|
||||||
|
@if (row.Uptime7d.HasValue)
|
||||||
|
{
|
||||||
|
<span class="fw-semibold @UptimeColor(row.Uptime7d.Value, row.SlaTarget)">@row.Uptime7d.Value.ToString("F2")%</span>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<span class="text-muted">—</span>
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
@if (row.Uptime30d.HasValue)
|
||||||
|
{
|
||||||
|
<span class="fw-semibold @UptimeColor(row.Uptime30d.Value, row.SlaTarget)">@row.Uptime30d.Value.ToString("F2")%</span>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<span class="text-muted">—</span>
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
<td class="text-muted small">
|
||||||
|
@if (row.SlaTarget.HasValue)
|
||||||
|
{
|
||||||
|
bool met30 = !row.Uptime30d.HasValue || row.Uptime30d.Value >= row.SlaTarget.Value;
|
||||||
|
<span class="@(met30 ? "text-success" : "text-danger")">
|
||||||
|
<i class="bi bi-@(met30 ? "check" : "exclamation-triangle") me-1"></i>@row.SlaTarget.Value.ToString("F1")%
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<span class="text-muted">—</span>
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
@if (row.ActiveAlerts > 0)
|
||||||
|
{
|
||||||
|
<span class="badge bg-danger">@row.ActiveAlerts</span>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<span class="text-success small"><i class="bi bi-check-circle me-1"></i>None</span>
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
<td class="text-muted small">
|
||||||
|
@(row.LastSync.HasValue ? row.LastSync.Value.ToLocalTime().ToString("MMM d HH:mm") : "—")
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="text-muted small mt-2 text-end">
|
||||||
|
Refreshed @lastRefreshed.ToLocalTime().ToString("HH:mm:ss") · Health snapshots every 5 minutes
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@code {
|
||||||
|
private List<StatusRow> rows = [];
|
||||||
|
private bool isLoading = true;
|
||||||
|
private DateTime lastRefreshed;
|
||||||
|
private string? currentUserId;
|
||||||
|
|
||||||
|
protected override async Task OnInitializedAsync()
|
||||||
|
{
|
||||||
|
AuthenticationState authState = await AuthStateProvider.GetAuthenticationStateAsync();
|
||||||
|
currentUserId = authState.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value;
|
||||||
|
await LoadData();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task LoadData()
|
||||||
|
{
|
||||||
|
if (currentUserId is null) return;
|
||||||
|
|
||||||
|
isLoading = true;
|
||||||
|
StateHasChanged();
|
||||||
|
|
||||||
|
List<Customer> customers = await CustomerAccessService.GetAccessibleCustomersAsync(currentUserId);
|
||||||
|
|
||||||
|
using ApplicationDbContext db = DbFactory.CreateDbContext();
|
||||||
|
|
||||||
|
List<Guid> appIds = customers.SelectMany(c => c.Apps).Select(a => a.Id).ToList();
|
||||||
|
|
||||||
|
// Load all deployments for these apps
|
||||||
|
List<AppDeployment> deployments = await db.AppDeployments
|
||||||
|
.Where(d => appIds.Contains(d.AppId))
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
List<Guid> deploymentIds = deployments.Select(d => d.Id).ToList();
|
||||||
|
|
||||||
|
// Load uptime data for last 30 days
|
||||||
|
DateTime from30 = DateTime.UtcNow.AddDays(-30);
|
||||||
|
DateTime from7 = DateTime.UtcNow.AddDays(-7);
|
||||||
|
|
||||||
|
var snapshots = await db.DeploymentHealthSnapshots
|
||||||
|
.Where(s => deploymentIds.Contains(s.DeploymentId) && s.SnapshotAt >= from30)
|
||||||
|
.Select(s => new { s.DeploymentId, s.HealthStatus, s.SnapshotAt })
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
// Load active incidents for these clusters, fetching LabelsJson for namespace matching
|
||||||
|
List<Guid> clusterIds = deployments.Select(d => d.ClusterId).Distinct().ToList();
|
||||||
|
List<(Guid ClusterId, string LabelsJson)> activeIncidentLabels = await db.AlertIncidents
|
||||||
|
.Where(i => clusterIds.Contains(i.ClusterId)
|
||||||
|
&& (i.Status == IncidentStatus.Active || i.Status == IncidentStatus.Acknowledged))
|
||||||
|
.Select(i => new ValueTuple<Guid, string>(i.ClusterId, i.LabelsJson))
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
// Build rows per app
|
||||||
|
rows = [];
|
||||||
|
foreach (Customer customer in customers.OrderBy(c => c.Name))
|
||||||
|
{
|
||||||
|
foreach (EntKube.Web.Data.App app in customer.Apps.OrderBy(a => a.Name))
|
||||||
|
{
|
||||||
|
List<AppDeployment> appDeployments = deployments.Where(d => d.AppId == app.Id).ToList();
|
||||||
|
if (appDeployments.Count == 0) continue;
|
||||||
|
|
||||||
|
HealthStatus worstHealth = appDeployments
|
||||||
|
.Select(d => d.HealthStatus)
|
||||||
|
.OrderByDescending(h => (int)h)
|
||||||
|
.FirstOrDefault();
|
||||||
|
|
||||||
|
DateTime? lastSync = appDeployments
|
||||||
|
.Where(d => d.LastSyncedAt.HasValue)
|
||||||
|
.Max(d => d.LastSyncedAt);
|
||||||
|
|
||||||
|
// Compute uptime per deployment then average
|
||||||
|
double? uptime7d = null, uptime30d = null;
|
||||||
|
foreach (AppDeployment dep in appDeployments)
|
||||||
|
{
|
||||||
|
var depSnaps = snapshots.Where(s => s.DeploymentId == dep.Id).ToList();
|
||||||
|
if (depSnaps.Count == 0) continue;
|
||||||
|
|
||||||
|
var snaps7 = depSnaps.Where(s => s.SnapshotAt >= from7).ToList();
|
||||||
|
var snaps30 = depSnaps;
|
||||||
|
|
||||||
|
if (snaps7.Count > 0)
|
||||||
|
{
|
||||||
|
double pct = (double)snaps7.Count(s => s.HealthStatus == HealthStatus.Healthy) / snaps7.Count * 100;
|
||||||
|
uptime7d = uptime7d.HasValue ? (uptime7d + pct) / 2 : pct;
|
||||||
|
}
|
||||||
|
if (snaps30.Count > 0)
|
||||||
|
{
|
||||||
|
double pct = (double)snaps30.Count(s => s.HealthStatus == HealthStatus.Healthy) / snaps30.Count * 100;
|
||||||
|
uptime30d = uptime30d.HasValue ? (uptime30d + pct) / 2 : pct;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Count alerts by matching namespace label in LabelsJson
|
||||||
|
HashSet<Guid> appClusterIds = appDeployments.Select(d => d.ClusterId).ToHashSet();
|
||||||
|
HashSet<string> appNamespaces = appDeployments.Select(d => d.Namespace).ToHashSet();
|
||||||
|
int activeAlerts = activeIncidentLabels
|
||||||
|
.Where(x => appClusterIds.Contains(x.ClusterId))
|
||||||
|
.Count(x =>
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var doc = System.Text.Json.JsonDocument.Parse(x.LabelsJson);
|
||||||
|
return doc.RootElement.TryGetProperty("namespace", out var ns)
|
||||||
|
&& appNamespaces.Contains(ns.GetString() ?? "");
|
||||||
|
}
|
||||||
|
catch { return false; }
|
||||||
|
});
|
||||||
|
|
||||||
|
// Look up SLA target (app > customer > none)
|
||||||
|
SlaTarget? sla = await IncidentService.GetSlaTargetAsync(
|
||||||
|
customer.TenantId, customer.Id, app.Id);
|
||||||
|
|
||||||
|
rows.Add(new StatusRow(
|
||||||
|
app.Name,
|
||||||
|
customer.Name,
|
||||||
|
customer.Id,
|
||||||
|
worstHealth,
|
||||||
|
uptime7d.HasValue ? Math.Round(uptime7d.Value, 2) : null,
|
||||||
|
uptime30d.HasValue ? Math.Round(uptime30d.Value, 2) : null,
|
||||||
|
sla?.TargetPercent,
|
||||||
|
activeAlerts,
|
||||||
|
lastSync));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
lastRefreshed = DateTime.UtcNow;
|
||||||
|
isLoading = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void GoToPortal(StatusRow row) =>
|
||||||
|
Navigation.NavigateTo("/portal");
|
||||||
|
|
||||||
|
private static string UptimeColor(double pct, double? slaTarget)
|
||||||
|
{
|
||||||
|
double threshold = slaTarget ?? 99.0;
|
||||||
|
if (pct >= threshold) return "text-success";
|
||||||
|
if (pct >= threshold - 2) return "text-warning";
|
||||||
|
return "text-danger";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static RenderFragment HealthBadge(HealthStatus health) => health switch
|
||||||
|
{
|
||||||
|
HealthStatus.Healthy => @<span class="badge bg-success">Healthy</span>,
|
||||||
|
HealthStatus.Progressing => @<span class="badge bg-primary">Progressing</span>,
|
||||||
|
HealthStatus.Degraded => @<span class="badge bg-warning text-dark">Degraded</span>,
|
||||||
|
HealthStatus.Suspended => @<span class="badge bg-secondary">Suspended</span>,
|
||||||
|
HealthStatus.Missing => @<span class="badge bg-danger">Missing</span>,
|
||||||
|
_ => @<span class="badge bg-secondary">Unknown</span>
|
||||||
|
};
|
||||||
|
|
||||||
|
private record StatusRow(
|
||||||
|
string AppName,
|
||||||
|
string CustomerName,
|
||||||
|
Guid CustomerId,
|
||||||
|
HealthStatus WorstHealth,
|
||||||
|
double? Uptime7d,
|
||||||
|
double? Uptime30d,
|
||||||
|
double? SlaTarget,
|
||||||
|
int ActiveAlerts,
|
||||||
|
DateTime? LastSync);
|
||||||
|
}
|
||||||
1452
src/EntKube.Web/Components/Pages/Portal/PortalDeploymentDetail.razor
Normal file
1452
src/EntKube.Web/Components/Pages/Portal/PortalDeploymentDetail.razor
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,4 @@
|
|||||||
|
export function getElementValue(id) {
|
||||||
|
const el = document.getElementById(id);
|
||||||
|
return el ? el.value : '';
|
||||||
|
}
|
||||||
@@ -0,0 +1,506 @@
|
|||||||
|
@using EntKube.Web.Data
|
||||||
|
@using EntKube.Web.Services
|
||||||
|
@inject KeycloakService KeycloakService
|
||||||
|
|
||||||
|
@* Realm management for customer portal — same as IdentityTab realm detail
|
||||||
|
but without the ability to create realms or manage themes. *@
|
||||||
|
|
||||||
|
<div class="d-flex align-items-center justify-content-between mb-3">
|
||||||
|
<div class="d-flex align-items-center gap-2">
|
||||||
|
<i class="bi bi-shield-lock fs-4 text-primary"></i>
|
||||||
|
<div>
|
||||||
|
<h5 class="mb-0">@Realm.DisplayName</h5>
|
||||||
|
<small class="text-muted font-monospace">@Realm.RealmName</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ul class="nav nav-tabs mb-3 flex-wrap">
|
||||||
|
@foreach (string tab in new[] { "users", "idp", "groups", "orgs" })
|
||||||
|
{
|
||||||
|
string label = tab switch
|
||||||
|
{
|
||||||
|
"users" => "Users",
|
||||||
|
"idp" => "Identity Providers",
|
||||||
|
"groups" => "Groups",
|
||||||
|
"orgs" => "Organizations",
|
||||||
|
_ => tab
|
||||||
|
};
|
||||||
|
<li class="nav-item">
|
||||||
|
<button class="nav-link @(activeTab == tab ? "active" : "")"
|
||||||
|
@onclick="() => SwitchTab(tab)">@label</button>
|
||||||
|
</li>
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
@* ── Users ── *@
|
||||||
|
@if (activeTab == "users")
|
||||||
|
{
|
||||||
|
@if (loadingUsers)
|
||||||
|
{
|
||||||
|
<div class="spinner-border spinner-border-sm text-primary"></div>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||||
|
<span class="text-muted small">@(users?.Count ?? 0) users</span>
|
||||||
|
<button class="btn btn-sm btn-primary" @onclick="() => showAddUser = !showAddUser">
|
||||||
|
<i class="bi bi-person-plus me-1"></i>Add User
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (showAddUser)
|
||||||
|
{
|
||||||
|
<div class="card mb-3 border-primary-subtle">
|
||||||
|
<div class="card-body">
|
||||||
|
@if (userError is not null)
|
||||||
|
{
|
||||||
|
<div class="alert alert-danger py-2 small">@userError</div>
|
||||||
|
}
|
||||||
|
<div class="row g-2">
|
||||||
|
<div class="col-md-4">
|
||||||
|
<input class="form-control form-control-sm" placeholder="Username" @bind="newUserUsername" />
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<input class="form-control form-control-sm" placeholder="Email" @bind="newUserEmail" />
|
||||||
|
</div>
|
||||||
|
<div class="col-md-2">
|
||||||
|
<input class="form-control form-control-sm" placeholder="First name" @bind="newUserFirstName" />
|
||||||
|
</div>
|
||||||
|
<div class="col-md-2">
|
||||||
|
<input class="form-control form-control-sm" placeholder="Last name" @bind="newUserLastName" />
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<input type="password" class="form-control form-control-sm" placeholder="Password (optional)" @bind="newUserPassword" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mt-2 d-flex gap-2">
|
||||||
|
<button class="btn btn-sm btn-primary" @onclick="AddUser" disabled="@savingUser">
|
||||||
|
@if (savingUser) { <span class="spinner-border spinner-border-sm me-1"></span> }
|
||||||
|
Create
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-sm btn-outline-secondary" @onclick="() => showAddUser = false">Cancel</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (users is not null)
|
||||||
|
{
|
||||||
|
<div class="list-group">
|
||||||
|
@foreach (KeycloakUserInfo user in users)
|
||||||
|
{
|
||||||
|
<div class="list-group-item py-2">
|
||||||
|
<div class="d-flex justify-content-between align-items-start">
|
||||||
|
<div>
|
||||||
|
<strong>@user.Username</strong>
|
||||||
|
<span class="text-muted small ms-2">@user.Email</span>
|
||||||
|
@if (!user.Enabled)
|
||||||
|
{
|
||||||
|
<span class="badge bg-secondary-subtle text-secondary ms-1 small">Disabled</span>
|
||||||
|
}
|
||||||
|
@if (!user.EmailVerified)
|
||||||
|
{
|
||||||
|
<span class="badge bg-warning-subtle text-warning ms-1 small">Unverified</span>
|
||||||
|
}
|
||||||
|
@if (user.Attributes.Count > 0)
|
||||||
|
{
|
||||||
|
<div class="mt-1 d-flex flex-wrap gap-1">
|
||||||
|
@foreach (KeyValuePair<string, List<string>> attr in user.Attributes)
|
||||||
|
{
|
||||||
|
<span class="badge bg-light text-secondary border font-monospace"
|
||||||
|
style="font-size:0.7rem">
|
||||||
|
@attr.Key: @string.Join(", ", attr.Value)
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-sm btn-outline-danger border-0 ms-2"
|
||||||
|
@onclick="() => DeleteUser(user.Id)">
|
||||||
|
<i class="bi bi-trash"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@* ── Identity Providers ── *@
|
||||||
|
@if (activeTab == "idp")
|
||||||
|
{
|
||||||
|
@if (loadingIdps)
|
||||||
|
{
|
||||||
|
<div class="spinner-border spinner-border-sm text-primary"></div>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||||
|
<span class="text-muted small">@(idps?.Count ?? 0) providers</span>
|
||||||
|
<button class="btn btn-sm btn-primary" @onclick="() => showAddIdp = !showAddIdp">
|
||||||
|
<i class="bi bi-plus-circle me-1"></i>Add Provider
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (showAddIdp)
|
||||||
|
{
|
||||||
|
<div class="card mb-3 border-primary-subtle">
|
||||||
|
<div class="card-body">
|
||||||
|
@if (idpError is not null)
|
||||||
|
{
|
||||||
|
<div class="alert alert-danger py-2 small">@idpError</div>
|
||||||
|
}
|
||||||
|
<div class="row g-2">
|
||||||
|
<div class="col-md-3">
|
||||||
|
<input class="form-control form-control-sm" placeholder="Alias (e.g. google)" @bind="newIdpAlias" />
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<select class="form-select form-select-sm" @bind="newIdpProviderId">
|
||||||
|
<option value="oidc">OpenID Connect</option>
|
||||||
|
<option value="saml">SAML</option>
|
||||||
|
<option value="google">Google</option>
|
||||||
|
<option value="github">GitHub</option>
|
||||||
|
<option value="microsoft">Microsoft</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<input class="form-control form-control-sm" placeholder="Client ID" @bind="newIdpClientId" />
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<input type="password" class="form-control form-control-sm" placeholder="Client Secret" @bind="newIdpClientSecret" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mt-2 d-flex gap-2">
|
||||||
|
<button class="btn btn-sm btn-primary" @onclick="AddIdp" disabled="@savingIdp">
|
||||||
|
@if (savingIdp) { <span class="spinner-border spinner-border-sm me-1"></span> }
|
||||||
|
Add
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-sm btn-outline-secondary" @onclick="() => showAddIdp = false">Cancel</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (idps is not null)
|
||||||
|
{
|
||||||
|
<div class="list-group">
|
||||||
|
@foreach (KeycloakIdpInfo idp in idps)
|
||||||
|
{
|
||||||
|
<div class="list-group-item d-flex justify-content-between align-items-center py-2">
|
||||||
|
<div>
|
||||||
|
<i class="bi bi-box-arrow-in-right me-2 text-muted"></i>
|
||||||
|
<strong>@idp.Alias</strong>
|
||||||
|
<span class="badge bg-secondary-subtle text-secondary ms-2 small">@idp.ProviderId</span>
|
||||||
|
@if (!idp.Enabled)
|
||||||
|
{
|
||||||
|
<span class="badge bg-secondary-subtle text-secondary ms-1 small">Disabled</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-sm btn-outline-danger border-0"
|
||||||
|
@onclick="() => DeleteIdp(idp.Alias)">
|
||||||
|
<i class="bi bi-trash"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@* ── Groups ── *@
|
||||||
|
@if (activeTab == "groups")
|
||||||
|
{
|
||||||
|
@if (loadingGroups)
|
||||||
|
{
|
||||||
|
<div class="spinner-border spinner-border-sm text-primary"></div>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||||
|
<span class="text-muted small">@(groups?.Count ?? 0) groups</span>
|
||||||
|
<button class="btn btn-sm btn-primary" @onclick="() => showAddGroup = !showAddGroup">
|
||||||
|
<i class="bi bi-people me-1"></i>Add Group
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (showAddGroup)
|
||||||
|
{
|
||||||
|
<div class="d-flex gap-2 mb-3">
|
||||||
|
<input class="form-control form-control-sm" placeholder="Group name" @bind="newGroupName" />
|
||||||
|
<button class="btn btn-sm btn-primary" @onclick="AddGroup" disabled="@savingGroup">
|
||||||
|
@if (savingGroup) { <span class="spinner-border spinner-border-sm me-1"></span> }
|
||||||
|
Create
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-sm btn-outline-secondary" @onclick="() => showAddGroup = false">Cancel</button>
|
||||||
|
</div>
|
||||||
|
@if (groupError is not null)
|
||||||
|
{
|
||||||
|
<div class="alert alert-danger py-2 small mb-3">@groupError</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (groups is not null)
|
||||||
|
{
|
||||||
|
<div class="list-group">
|
||||||
|
@foreach (KeycloakGroupInfo g in groups)
|
||||||
|
{
|
||||||
|
<div class="list-group-item d-flex justify-content-between align-items-center py-2">
|
||||||
|
<span><i class="bi bi-people me-2 text-muted"></i>@g.Name <span class="text-muted small">@g.Path</span></span>
|
||||||
|
<button class="btn btn-sm btn-outline-danger border-0"
|
||||||
|
@onclick="() => DeleteGroup(g.Id)">
|
||||||
|
<i class="bi bi-trash"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@* ── Organizations ── *@
|
||||||
|
@if (activeTab == "orgs")
|
||||||
|
{
|
||||||
|
@if (loadingOrgs)
|
||||||
|
{
|
||||||
|
<div class="spinner-border spinner-border-sm text-primary"></div>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||||
|
<span class="text-muted small">@(orgs?.Count ?? 0) organizations (Keycloak 26+)</span>
|
||||||
|
<button class="btn btn-sm btn-primary" @onclick="() => showAddOrg = !showAddOrg">
|
||||||
|
<i class="bi bi-building me-1"></i>Add Organization
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (showAddOrg)
|
||||||
|
{
|
||||||
|
<div class="card mb-3 border-primary-subtle">
|
||||||
|
<div class="card-body">
|
||||||
|
@if (orgError is not null)
|
||||||
|
{
|
||||||
|
<div class="alert alert-danger py-2 small">@orgError</div>
|
||||||
|
}
|
||||||
|
<div class="row g-2">
|
||||||
|
<div class="col-md-4">
|
||||||
|
<input class="form-control form-control-sm" placeholder="Name" @bind="newOrgName" />
|
||||||
|
</div>
|
||||||
|
<div class="col-md-8">
|
||||||
|
<input class="form-control form-control-sm" placeholder="Description (optional)" @bind="newOrgDescription" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mt-2 d-flex gap-2">
|
||||||
|
<button class="btn btn-sm btn-primary" @onclick="AddOrg" disabled="@savingOrg">
|
||||||
|
@if (savingOrg) { <span class="spinner-border spinner-border-sm me-1"></span> }
|
||||||
|
Create
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-sm btn-outline-secondary" @onclick="() => showAddOrg = false">Cancel</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (orgs is not null)
|
||||||
|
{
|
||||||
|
<div class="list-group">
|
||||||
|
@foreach (KeycloakOrganizationInfo org in orgs)
|
||||||
|
{
|
||||||
|
<div class="list-group-item py-2">
|
||||||
|
<strong>@org.Name</strong>
|
||||||
|
@if (!string.IsNullOrWhiteSpace(org.Description))
|
||||||
|
{
|
||||||
|
<span class="text-muted small ms-2">@org.Description</span>
|
||||||
|
}
|
||||||
|
@if (!org.Enabled)
|
||||||
|
{
|
||||||
|
<span class="badge bg-secondary-subtle text-secondary ms-1 small">Disabled</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@code {
|
||||||
|
[Parameter] public required KeycloakRealm Realm { get; set; }
|
||||||
|
[Parameter] public Guid TenantId { get; set; }
|
||||||
|
|
||||||
|
private string activeTab = "users";
|
||||||
|
|
||||||
|
private List<KeycloakUserInfo>? users;
|
||||||
|
private List<KeycloakIdpInfo>? idps;
|
||||||
|
private List<KeycloakGroupInfo>? groups;
|
||||||
|
private List<KeycloakOrganizationInfo>? orgs;
|
||||||
|
|
||||||
|
private bool loadingUsers, loadingIdps, loadingGroups, loadingOrgs;
|
||||||
|
|
||||||
|
// User form
|
||||||
|
private bool showAddUser, savingUser;
|
||||||
|
private string? userError;
|
||||||
|
private string newUserUsername = "", newUserEmail = "", newUserFirstName = "", newUserLastName = "", newUserPassword = "";
|
||||||
|
|
||||||
|
// IdP form
|
||||||
|
private bool showAddIdp, savingIdp;
|
||||||
|
private string? idpError;
|
||||||
|
private string newIdpAlias = "", newIdpProviderId = "oidc", newIdpClientId = "", newIdpClientSecret = "";
|
||||||
|
|
||||||
|
// Group form
|
||||||
|
private bool showAddGroup, savingGroup;
|
||||||
|
private string? groupError;
|
||||||
|
private string newGroupName = "";
|
||||||
|
|
||||||
|
// Org form
|
||||||
|
private bool showAddOrg, savingOrg;
|
||||||
|
private string? orgError;
|
||||||
|
private string newOrgName = "", newOrgDescription = "";
|
||||||
|
|
||||||
|
protected override async Task OnInitializedAsync()
|
||||||
|
{
|
||||||
|
await LoadTab("users");
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task SwitchTab(string tab)
|
||||||
|
{
|
||||||
|
activeTab = tab;
|
||||||
|
await LoadTab(tab);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task LoadTab(string tab)
|
||||||
|
{
|
||||||
|
switch (tab)
|
||||||
|
{
|
||||||
|
case "users" when users is null:
|
||||||
|
loadingUsers = true;
|
||||||
|
StateHasChanged();
|
||||||
|
try { users = await KeycloakService.GetUsersAsync(TenantId, Realm.Id); }
|
||||||
|
catch (Exception ex) { userError = ex.Message; }
|
||||||
|
finally { loadingUsers = false; }
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "idp" when idps is null:
|
||||||
|
loadingIdps = true;
|
||||||
|
StateHasChanged();
|
||||||
|
try { idps = await KeycloakService.GetIdpsAsync(TenantId, Realm.Id); }
|
||||||
|
catch (Exception ex) { idpError = ex.Message; }
|
||||||
|
finally { loadingIdps = false; }
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "groups" when groups is null:
|
||||||
|
loadingGroups = true;
|
||||||
|
StateHasChanged();
|
||||||
|
try { groups = await KeycloakService.GetGroupsAsync(TenantId, Realm.Id); }
|
||||||
|
catch (Exception ex) { groupError = ex.Message; }
|
||||||
|
finally { loadingGroups = false; }
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "orgs" when orgs is null:
|
||||||
|
loadingOrgs = true;
|
||||||
|
StateHasChanged();
|
||||||
|
try { orgs = await KeycloakService.GetOrganizationsAsync(TenantId, Realm.Id); }
|
||||||
|
catch (Exception ex) { orgError = ex.Message; }
|
||||||
|
finally { loadingOrgs = false; }
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task AddUser()
|
||||||
|
{
|
||||||
|
savingUser = true;
|
||||||
|
userError = null;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await KeycloakService.CreateUserAsync(
|
||||||
|
TenantId, Realm.Id,
|
||||||
|
newUserUsername, newUserEmail, newUserFirstName, newUserLastName,
|
||||||
|
string.IsNullOrWhiteSpace(newUserPassword) ? null : newUserPassword);
|
||||||
|
users = await KeycloakService.GetUsersAsync(TenantId, Realm.Id);
|
||||||
|
showAddUser = false;
|
||||||
|
newUserUsername = newUserEmail = newUserFirstName = newUserLastName = newUserPassword = "";
|
||||||
|
}
|
||||||
|
catch (Exception ex) { userError = ex.Message; }
|
||||||
|
finally { savingUser = false; }
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task DeleteUser(string userId)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await KeycloakService.DeleteUserAsync(TenantId, Realm.Id, userId);
|
||||||
|
users = await KeycloakService.GetUsersAsync(TenantId, Realm.Id);
|
||||||
|
}
|
||||||
|
catch (Exception ex) { userError = ex.Message; }
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task AddIdp()
|
||||||
|
{
|
||||||
|
savingIdp = true;
|
||||||
|
idpError = null;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await KeycloakService.CreateIdpAsync(
|
||||||
|
TenantId, Realm.Id,
|
||||||
|
newIdpAlias, newIdpProviderId, newIdpClientId, newIdpClientSecret);
|
||||||
|
idps = await KeycloakService.GetIdpsAsync(TenantId, Realm.Id);
|
||||||
|
showAddIdp = false;
|
||||||
|
newIdpAlias = newIdpClientId = newIdpClientSecret = "";
|
||||||
|
}
|
||||||
|
catch (Exception ex) { idpError = ex.Message; }
|
||||||
|
finally { savingIdp = false; }
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task DeleteIdp(string alias)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await KeycloakService.DeleteIdpAsync(TenantId, Realm.Id, alias);
|
||||||
|
idps = await KeycloakService.GetIdpsAsync(TenantId, Realm.Id);
|
||||||
|
}
|
||||||
|
catch (Exception ex) { idpError = ex.Message; }
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task AddGroup()
|
||||||
|
{
|
||||||
|
savingGroup = true;
|
||||||
|
groupError = null;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await KeycloakService.CreateGroupAsync(TenantId, Realm.Id, newGroupName);
|
||||||
|
groups = await KeycloakService.GetGroupsAsync(TenantId, Realm.Id);
|
||||||
|
showAddGroup = false;
|
||||||
|
newGroupName = "";
|
||||||
|
}
|
||||||
|
catch (Exception ex) { groupError = ex.Message; }
|
||||||
|
finally { savingGroup = false; }
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task DeleteGroup(string groupId)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await KeycloakService.DeleteGroupAsync(TenantId, Realm.Id, groupId);
|
||||||
|
groups = await KeycloakService.GetGroupsAsync(TenantId, Realm.Id);
|
||||||
|
}
|
||||||
|
catch (Exception ex) { groupError = ex.Message; }
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task AddOrg()
|
||||||
|
{
|
||||||
|
savingOrg = true;
|
||||||
|
orgError = null;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await KeycloakService.CreateOrganizationAsync(
|
||||||
|
TenantId, Realm.Id, newOrgName,
|
||||||
|
string.IsNullOrWhiteSpace(newOrgDescription) ? null : newOrgDescription);
|
||||||
|
orgs = await KeycloakService.GetOrganizationsAsync(TenantId, Realm.Id);
|
||||||
|
showAddOrg = false;
|
||||||
|
newOrgName = newOrgDescription = "";
|
||||||
|
}
|
||||||
|
catch (Exception ex) { orgError = ex.Message; }
|
||||||
|
finally { savingOrg = false; }
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,524 @@
|
|||||||
|
@using EntKube.Web.Data
|
||||||
|
@using EntKube.Web.Services
|
||||||
|
@inject HarborService HarborService
|
||||||
|
|
||||||
|
@if (loading)
|
||||||
|
{
|
||||||
|
<div class="text-center py-5">
|
||||||
|
<div class="spinner-border text-primary" role="status"></div>
|
||||||
|
<p class="text-muted mt-2">Loading registry...</p>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
else if (loadError is not null)
|
||||||
|
{
|
||||||
|
<div class="alert alert-danger">
|
||||||
|
<i class="bi bi-exclamation-triangle me-2"></i>@loadError
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<div class="d-flex align-items-center gap-2 mb-3">
|
||||||
|
<i class="bi bi-archive fs-4 text-primary"></i>
|
||||||
|
<div>
|
||||||
|
<h5 class="mb-0">@Project.ProjectName</h5>
|
||||||
|
@if (Project.HarborComponentConfig?.RegistryUrl is not null)
|
||||||
|
{
|
||||||
|
<small class="text-muted font-monospace">@Project.HarborComponentConfig.RegistryUrl/@Project.ProjectName</small>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ul class="nav nav-tabs mb-3">
|
||||||
|
<li class="nav-item">
|
||||||
|
<button class="nav-link @(activeTab == "repos" ? "active" : "")" @onclick='() => SwitchTab("repos")'>
|
||||||
|
<i class="bi bi-box-seam me-1"></i>Repositories
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<button class="nav-link @(activeTab == "robots" ? "active" : "")" @onclick='() => SwitchTab("robots")'>
|
||||||
|
<i class="bi bi-robot me-1"></i>Robot Accounts
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<button class="nav-link @(activeTab == "info" ? "active" : "")" @onclick='() => SwitchTab("info")'>
|
||||||
|
<i class="bi bi-info-circle me-1"></i>Info
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
@* ── Repositories tab ── *@
|
||||||
|
@if (activeTab == "repos")
|
||||||
|
{
|
||||||
|
@if (loadingRepos)
|
||||||
|
{
|
||||||
|
<div class="spinner-border spinner-border-sm text-primary"></div>
|
||||||
|
}
|
||||||
|
else if (repoError is not null)
|
||||||
|
{
|
||||||
|
<div class="alert alert-danger small">@repoError</div>
|
||||||
|
}
|
||||||
|
else if (repos is not null && repos.Count == 0)
|
||||||
|
{
|
||||||
|
<div class="alert alert-light border text-muted small">
|
||||||
|
<i class="bi bi-inbox me-1"></i>No repositories in this project yet. Push an image to get started.
|
||||||
|
@if (Project.HarborComponentConfig?.RegistryUrl is not null)
|
||||||
|
{
|
||||||
|
<div class="mt-2 font-monospace">docker push @Project.HarborComponentConfig.RegistryUrl.TrimEnd('/')/@Project.ProjectName/your-image:tag</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
else if (repos is not null)
|
||||||
|
{
|
||||||
|
<div class="list-group">
|
||||||
|
@foreach (HarborRepositoryInfo repo in repos)
|
||||||
|
{
|
||||||
|
string shortName = repo.Name.Contains('/') ? repo.Name[(repo.Name.IndexOf('/') + 1)..] : repo.Name;
|
||||||
|
bool isExpanded = expandedRepo == repo.Name;
|
||||||
|
|
||||||
|
<div class="list-group-item p-0">
|
||||||
|
<button class="btn w-100 text-start py-2 px-3 d-flex align-items-center justify-content-between"
|
||||||
|
@onclick="() => ToggleRepo(repo)">
|
||||||
|
<div class="d-flex align-items-center gap-2">
|
||||||
|
<i class="bi @(isExpanded ? "bi-chevron-down" : "bi-chevron-right") text-muted small"></i>
|
||||||
|
<i class="bi bi-box-seam text-primary"></i>
|
||||||
|
<span class="font-monospace small fw-medium">@shortName</span>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex gap-3 text-muted small">
|
||||||
|
<span>@repo.ArtifactCount artifacts</span>
|
||||||
|
<span>@FormatSize(repo.SizeBytes)</span>
|
||||||
|
@if (repo.UpdatedAt.HasValue)
|
||||||
|
{
|
||||||
|
<span>@repo.UpdatedAt.Value.ToString("yyyy-MM-dd")</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
@if (isExpanded)
|
||||||
|
{
|
||||||
|
<div class="border-top bg-light px-3 py-2">
|
||||||
|
@if (loadingArtifacts)
|
||||||
|
{
|
||||||
|
<div class="spinner-border spinner-border-sm text-primary m-2"></div>
|
||||||
|
}
|
||||||
|
else if (artifacts is not null && artifacts.Count == 0)
|
||||||
|
{
|
||||||
|
<p class="text-muted small mb-0">No artifacts in this repository.</p>
|
||||||
|
}
|
||||||
|
else if (artifacts is not null)
|
||||||
|
{
|
||||||
|
<table class="table table-sm table-borderless mb-0 small">
|
||||||
|
<thead>
|
||||||
|
<tr class="text-muted">
|
||||||
|
<th>Tags</th>
|
||||||
|
<th>Digest</th>
|
||||||
|
<th>Size</th>
|
||||||
|
<th>Pushed</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@foreach (HarborArtifactInfo artifact in artifacts)
|
||||||
|
{
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
@if (artifact.Tags.Count == 0)
|
||||||
|
{
|
||||||
|
<span class="text-muted fst-italic">untagged</span>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
@foreach (string tag in artifact.Tags)
|
||||||
|
{
|
||||||
|
<span class="badge bg-primary-subtle text-primary border border-primary-subtle me-1">@tag</span>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
<td class="font-monospace text-muted">@artifact.Digest[..19]…</td>
|
||||||
|
<td>@FormatSize(artifact.SizeBytes)</td>
|
||||||
|
<td>@artifact.PushedAt.ToString("yyyy-MM-dd HH:mm")</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@* ── Robot Accounts tab ── *@
|
||||||
|
@if (activeTab == "robots")
|
||||||
|
{
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||||
|
<span class="text-muted small">@(robots?.Count ?? 0) robot accounts for this project</span>
|
||||||
|
@if (AccessRole >= CustomerAccessRole.Admin)
|
||||||
|
{
|
||||||
|
<button class="btn btn-sm btn-primary" @onclick="() => showCreateRobot = !showCreateRobot">
|
||||||
|
<i class="bi bi-robot me-1"></i>New Robot
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (showCreateRobot && AccessRole >= CustomerAccessRole.Admin)
|
||||||
|
{
|
||||||
|
<div class="card mb-3 border-primary-subtle">
|
||||||
|
<div class="card-body">
|
||||||
|
@if (robotError is not null)
|
||||||
|
{
|
||||||
|
<div class="alert alert-danger py-2 small">@robotError</div>
|
||||||
|
}
|
||||||
|
@if (newRobotSecret is not null)
|
||||||
|
{
|
||||||
|
<div class="alert alert-success py-2">
|
||||||
|
<strong><i class="bi bi-key me-1"></i>Robot created!</strong> Copy the secret now — it will not be shown again.
|
||||||
|
<div class="mt-2 font-monospace small bg-white border rounded p-2 user-select-all">@newRobotSecret</div>
|
||||||
|
<button class="btn btn-sm btn-outline-secondary mt-2"
|
||||||
|
@onclick="() => { showCreateRobot = false; newRobotSecret = null; }">Close</button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<div class="row g-2">
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label class="form-label form-label-sm">Robot name</label>
|
||||||
|
<input class="form-control form-control-sm font-monospace" placeholder="ci-runner"
|
||||||
|
@bind="newRobotName" />
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label class="form-label form-label-sm">Description (optional)</label>
|
||||||
|
<input class="form-control form-control-sm" @bind="newRobotDescription" />
|
||||||
|
</div>
|
||||||
|
<div class="col-12 d-flex gap-3 align-items-center">
|
||||||
|
<div class="form-check">
|
||||||
|
<input type="checkbox" class="form-check-input" id="rPull" @bind="newRobotCanPull" />
|
||||||
|
<label class="form-check-label small" for="rPull">Pull</label>
|
||||||
|
</div>
|
||||||
|
<div class="form-check">
|
||||||
|
<input type="checkbox" class="form-check-input" id="rPush" @bind="newRobotCanPush" />
|
||||||
|
<label class="form-check-label small" for="rPush">Push</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mt-2 d-flex gap-2">
|
||||||
|
<button class="btn btn-sm btn-primary" @onclick="CreateRobot" disabled="@savingRobot">
|
||||||
|
@if (savingRobot) { <span class="spinner-border spinner-border-sm me-1"></span> }
|
||||||
|
Create
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-sm btn-outline-secondary"
|
||||||
|
@onclick="() => { showCreateRobot = false; robotError = null; }">Cancel</button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (loadingRobots)
|
||||||
|
{
|
||||||
|
<div class="spinner-border spinner-border-sm text-primary"></div>
|
||||||
|
}
|
||||||
|
else if (robotListError is not null)
|
||||||
|
{
|
||||||
|
<div class="alert alert-danger small">@robotListError</div>
|
||||||
|
}
|
||||||
|
else if (robots is not null)
|
||||||
|
{
|
||||||
|
@if (robots.Count == 0)
|
||||||
|
{
|
||||||
|
<p class="text-muted small">No robot accounts yet.</p>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<div class="list-group">
|
||||||
|
@foreach (HarborRobotInfo robot in robots)
|
||||||
|
{
|
||||||
|
<div class="list-group-item py-2">
|
||||||
|
<div class="d-flex justify-content-between align-items-start">
|
||||||
|
<div>
|
||||||
|
<div class="d-flex align-items-center gap-2">
|
||||||
|
<i class="bi bi-robot text-primary"></i>
|
||||||
|
<span class="font-monospace small fw-medium">@robot.Name</span>
|
||||||
|
@if (robot.Disabled)
|
||||||
|
{
|
||||||
|
<span class="badge bg-danger-subtle text-danger border border-danger-subtle small">Disabled</span>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<span class="badge bg-success-subtle text-success border border-success-subtle small">Active</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
@if (!string.IsNullOrWhiteSpace(robot.Description))
|
||||||
|
{
|
||||||
|
<div class="text-muted small mt-1">@robot.Description</div>
|
||||||
|
}
|
||||||
|
<div class="text-muted small mt-1">
|
||||||
|
Created @robot.CreatedAt.ToString("yyyy-MM-dd")
|
||||||
|
@if (robot.ExpiresAt == -1)
|
||||||
|
{
|
||||||
|
<span class="ms-2">· Never expires</span>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
DateTime expiry = DateTimeOffset.FromUnixTimeSeconds(robot.ExpiresAt).UtcDateTime;
|
||||||
|
<span class="ms-2">· Expires @expiry.ToString("yyyy-MM-dd")</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (AccessRole >= CustomerAccessRole.Admin)
|
||||||
|
{
|
||||||
|
@if (confirmDeleteRobot == robot.Id)
|
||||||
|
{
|
||||||
|
<div class="d-flex gap-1 align-items-center">
|
||||||
|
<span class="text-danger small me-2">Delete?</span>
|
||||||
|
<button class="btn btn-sm btn-danger" @onclick="() => DeleteRobot(robot.Id)">Yes</button>
|
||||||
|
<button class="btn btn-sm btn-outline-secondary"
|
||||||
|
@onclick="() => confirmDeleteRobot = null">Cancel</button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<button class="btn btn-sm btn-outline-danger"
|
||||||
|
@onclick="() => confirmDeleteRobot = robot.Id"
|
||||||
|
title="Delete robot">
|
||||||
|
<i class="bi bi-trash"></i>
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@* ── Info tab ── *@
|
||||||
|
@if (activeTab == "info")
|
||||||
|
{
|
||||||
|
<div class="card border-0 bg-light">
|
||||||
|
<div class="card-body">
|
||||||
|
<dl class="row mb-0 small">
|
||||||
|
<dt class="col-sm-3 text-muted">Project</dt>
|
||||||
|
<dd class="col-sm-9 font-monospace">@Project.ProjectName</dd>
|
||||||
|
|
||||||
|
@if (Project.HarborComponentConfig?.RegistryUrl is not null)
|
||||||
|
{
|
||||||
|
<dt class="col-sm-3 text-muted">Registry</dt>
|
||||||
|
<dd class="col-sm-9"><code>@Project.HarborComponentConfig.RegistryUrl</code></dd>
|
||||||
|
}
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (Project.HarborComponentConfig?.RegistryUrl is not null)
|
||||||
|
{
|
||||||
|
<div class="mt-3">
|
||||||
|
<h6 class="text-muted mb-2">Quick start</h6>
|
||||||
|
<div class="bg-dark text-white rounded p-3 font-monospace small">
|
||||||
|
<div class="text-success mb-1"># Login</div>
|
||||||
|
<div>docker login @Project.HarborComponentConfig.RegistryUrl.TrimEnd('/')</div>
|
||||||
|
<div class="text-success mt-2 mb-1"># Pull an image</div>
|
||||||
|
<div>docker pull @Project.HarborComponentConfig.RegistryUrl.TrimEnd('/')/@Project.ProjectName/<span class="text-warning">image:tag</span></div>
|
||||||
|
<div class="text-success mt-2 mb-1"># Push an image</div>
|
||||||
|
<div>docker tag <span class="text-warning">local-image:tag</span> @Project.HarborComponentConfig.RegistryUrl.TrimEnd('/')/@Project.ProjectName/<span class="text-warning">image:tag</span></div>
|
||||||
|
<div>docker push @Project.HarborComponentConfig.RegistryUrl.TrimEnd('/')/@Project.ProjectName/<span class="text-warning">image:tag</span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@code {
|
||||||
|
[Parameter] public required HarborProject Project { get; set; }
|
||||||
|
[Parameter] public Guid TenantId { get; set; }
|
||||||
|
[Parameter] public CustomerAccessRole AccessRole { get; set; }
|
||||||
|
|
||||||
|
private bool loading = true;
|
||||||
|
private string? loadError;
|
||||||
|
private string activeTab = "repos";
|
||||||
|
|
||||||
|
// Repos
|
||||||
|
private List<HarborRepositoryInfo>? repos;
|
||||||
|
private bool loadingRepos;
|
||||||
|
private string? expandedRepo;
|
||||||
|
private List<HarborArtifactInfo>? artifacts;
|
||||||
|
private bool loadingArtifacts;
|
||||||
|
private string? repoError;
|
||||||
|
|
||||||
|
// Robots
|
||||||
|
private List<HarborRobotInfo>? robots;
|
||||||
|
private bool loadingRobots;
|
||||||
|
private bool showCreateRobot;
|
||||||
|
private bool savingRobot;
|
||||||
|
private string newRobotName = "";
|
||||||
|
private string newRobotDescription = "";
|
||||||
|
private bool newRobotCanPull = true;
|
||||||
|
private bool newRobotCanPush;
|
||||||
|
private string? newRobotSecret;
|
||||||
|
private string? robotError;
|
||||||
|
private string? robotListError;
|
||||||
|
private long? confirmDeleteRobot;
|
||||||
|
|
||||||
|
protected override async Task OnInitializedAsync()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await LoadRepos();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
loadError = ex.Message;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task SwitchTab(string tab)
|
||||||
|
{
|
||||||
|
activeTab = tab;
|
||||||
|
repoError = null;
|
||||||
|
robotListError = null;
|
||||||
|
robotError = null;
|
||||||
|
|
||||||
|
if (tab == "repos" && repos is null)
|
||||||
|
await LoadRepos();
|
||||||
|
else if (tab == "robots" && robots is null)
|
||||||
|
await LoadRobots();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task LoadRepos()
|
||||||
|
{
|
||||||
|
loadingRepos = true;
|
||||||
|
repoError = null;
|
||||||
|
StateHasChanged();
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
repos = await HarborService.GetRepositoriesAsync(TenantId, Project.HarborComponentConfig!, Project.ProjectName);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
repoError = ex.Message;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
loadingRepos = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task LoadRobots()
|
||||||
|
{
|
||||||
|
loadingRobots = true;
|
||||||
|
robotListError = null;
|
||||||
|
StateHasChanged();
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
List<HarborRobotInfo> all = await HarborService.GetRobotsAsync(TenantId, Project.HarborComponentConfig!);
|
||||||
|
// Filter to robots scoped to this project (robot name contains project name after the $ prefix).
|
||||||
|
string projectPrefix = $"robot${Project.ProjectName}+";
|
||||||
|
robots = all.Where(r => r.Name.StartsWith(projectPrefix, StringComparison.OrdinalIgnoreCase)).ToList();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
robotListError = ex.Message;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
loadingRobots = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task ToggleRepo(HarborRepositoryInfo repo)
|
||||||
|
{
|
||||||
|
if (expandedRepo == repo.Name)
|
||||||
|
{
|
||||||
|
expandedRepo = null;
|
||||||
|
artifacts = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
expandedRepo = repo.Name;
|
||||||
|
artifacts = null;
|
||||||
|
loadingArtifacts = true;
|
||||||
|
repoError = null;
|
||||||
|
StateHasChanged();
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
string shortName = repo.Name.Contains('/') ? repo.Name[(repo.Name.IndexOf('/') + 1)..] : repo.Name;
|
||||||
|
artifacts = await HarborService.GetArtifactsAsync(
|
||||||
|
TenantId, Project.HarborComponentConfig!, Project.ProjectName, shortName);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
repoError = ex.Message;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
loadingArtifacts = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task CreateRobot()
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(newRobotName)) return;
|
||||||
|
|
||||||
|
savingRobot = true;
|
||||||
|
robotError = null;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
string secret = await HarborService.CreateRobotAsync(
|
||||||
|
TenantId, Project.HarborComponentConfig!,
|
||||||
|
newRobotName.Trim(),
|
||||||
|
string.IsNullOrWhiteSpace(newRobotDescription) ? null : newRobotDescription.Trim(),
|
||||||
|
[Project.ProjectName],
|
||||||
|
newRobotCanPush, newRobotCanPull);
|
||||||
|
|
||||||
|
newRobotSecret = secret;
|
||||||
|
await LoadRobots();
|
||||||
|
newRobotName = "";
|
||||||
|
newRobotDescription = "";
|
||||||
|
newRobotCanPull = true;
|
||||||
|
newRobotCanPush = false;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
robotError = ex.Message;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
savingRobot = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task DeleteRobot(long robotId)
|
||||||
|
{
|
||||||
|
confirmDeleteRobot = null;
|
||||||
|
robotListError = null;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await HarborService.DeleteRobotAsync(TenantId, Project.HarborComponentConfig!, robotId);
|
||||||
|
await LoadRobots();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
robotListError = ex.Message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string FormatSize(long bytes)
|
||||||
|
{
|
||||||
|
if (bytes <= 0) return "0 B";
|
||||||
|
if (bytes < 1024) return $"{bytes} B";
|
||||||
|
if (bytes < 1024 * 1024) return $"{bytes / 1024.0:F1} KB";
|
||||||
|
if (bytes < 1024L * 1024 * 1024) return $"{bytes / (1024.0 * 1024):F1} MB";
|
||||||
|
return $"{bytes / (1024.0 * 1024 * 1024):F1} GB";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,500 @@
|
|||||||
|
@using EntKube.Web.Data
|
||||||
|
@using EntKube.Web.Services
|
||||||
|
@inject VaultService VaultService
|
||||||
|
@inject TenantService TenantService
|
||||||
|
@using EntKube.Web.Components.Pages.Shared
|
||||||
|
|
||||||
|
@* ═══════════════════════════════════════════════════════════════════
|
||||||
|
App Secrets — customer portal view for managing vault secrets
|
||||||
|
scoped to a single app.
|
||||||
|
|
||||||
|
Access rules:
|
||||||
|
Viewer+ — list secret names, reveal values on demand
|
||||||
|
Admin — add / update / delete secrets, configure K8s sync
|
||||||
|
═══════════════════════════════════════════════════════════════════ *@
|
||||||
|
|
||||||
|
<div class="d-flex align-items-center mb-3">
|
||||||
|
<i class="bi bi-shield-lock fs-4 me-2 text-primary"></i>
|
||||||
|
<div>
|
||||||
|
<h5 class="mb-0">Secrets — @App.Name</h5>
|
||||||
|
<small class="text-muted">
|
||||||
|
Application secrets stored with AES-256-GCM envelope encryption.
|
||||||
|
Values are never transmitted in plain text.
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (!vaultInitialized)
|
||||||
|
{
|
||||||
|
<div class="alert alert-warning py-2 small">
|
||||||
|
<i class="bi bi-shield-exclamation me-1"></i>
|
||||||
|
No vault has been set up for this tenant. Ask your platform administrator to initialize it.
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
@* ── Add Secret form (Admin only) ── *@
|
||||||
|
@if (AccessRole >= CustomerAccessRole.Admin)
|
||||||
|
{
|
||||||
|
<div class="card shadow-sm mb-3">
|
||||||
|
<div class="card-header bg-white py-2">
|
||||||
|
<i class="bi bi-plus-circle me-1 text-primary"></i><strong>Add / Update Secret</strong>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="row g-2 align-items-end">
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label class="form-label small">Name</label>
|
||||||
|
<input type="text" class="form-control form-control-sm"
|
||||||
|
placeholder="e.g. API_KEY, DB_PASSWORD"
|
||||||
|
@bind="newSecretName" @bind:event="oninput" />
|
||||||
|
</div>
|
||||||
|
<div class="col-md-5">
|
||||||
|
<label class="form-label small">Value</label>
|
||||||
|
<input type="password" class="form-control form-control-sm"
|
||||||
|
placeholder="Secret value"
|
||||||
|
@bind="newSecretValue" @bind:event="oninput" />
|
||||||
|
</div>
|
||||||
|
<div class="col-auto">
|
||||||
|
<button class="btn btn-sm btn-primary" @onclick="AddSecret"
|
||||||
|
disabled="@(string.IsNullOrWhiteSpace(newSecretName) || string.IsNullOrWhiteSpace(newSecretValue) || saving)">
|
||||||
|
@if (saving) { <span class="spinner-border spinner-border-sm me-1"></span> }
|
||||||
|
<i class="bi bi-plus-lg me-1"></i>Set Secret
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-text">
|
||||||
|
If a secret with this name already exists it will be updated in place.
|
||||||
|
</div>
|
||||||
|
@if (errorMessage is not null)
|
||||||
|
{
|
||||||
|
<div class="alert alert-danger py-1 small mt-2 mb-0">
|
||||||
|
<i class="bi bi-exclamation-triangle me-1"></i>@errorMessage
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
@if (successMessage is not null)
|
||||||
|
{
|
||||||
|
<div class="alert alert-success py-1 small mt-2 mb-0">
|
||||||
|
<i class="bi bi-check-circle me-1"></i>@successMessage
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@* ── Secrets list ── *@
|
||||||
|
<div class="card shadow-sm">
|
||||||
|
<div class="card-header bg-white py-2 d-flex align-items-center justify-content-between">
|
||||||
|
<div>
|
||||||
|
<i class="bi bi-lock me-2 text-primary"></i>
|
||||||
|
<strong>Stored Secrets</strong>
|
||||||
|
@if (secrets is not null)
|
||||||
|
{
|
||||||
|
<span class="badge bg-secondary ms-1">@secrets.Count</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
@if (AccessRole >= CustomerAccessRole.Admin && secrets?.Any(s => s.SyncToKubernetes) == true)
|
||||||
|
{
|
||||||
|
<button class="btn btn-sm btn-outline-success" @onclick="SyncAllToKubernetes" disabled="@syncingSecrets">
|
||||||
|
@if (syncingSecrets) { <span class="spinner-border spinner-border-sm me-1"></span> }
|
||||||
|
else { <i class="bi bi-cloud-arrow-up me-1"></i> }
|
||||||
|
Sync All to K8s
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<div class="card-body p-0">
|
||||||
|
@if (secrets is null)
|
||||||
|
{
|
||||||
|
<div class="text-center py-4">
|
||||||
|
<div class="spinner-border spinner-border-sm text-primary" role="status"></div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
else if (secrets.Count == 0)
|
||||||
|
{
|
||||||
|
<div class="text-center py-5">
|
||||||
|
<i class="bi bi-lock text-muted" style="font-size: 2rem;"></i>
|
||||||
|
<p class="text-muted small mt-2 mb-0">No secrets stored for this app yet.</p>
|
||||||
|
@if (AccessRole >= CustomerAccessRole.Admin)
|
||||||
|
{
|
||||||
|
<p class="text-muted small mb-0">Use the form above to add one.</p>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-hover align-middle mb-0">
|
||||||
|
<thead class="table-light">
|
||||||
|
<tr>
|
||||||
|
<th>Name</th>
|
||||||
|
<th>K8s Sync</th>
|
||||||
|
<th>K8s Secret</th>
|
||||||
|
<th>Namespace</th>
|
||||||
|
<th>Updated</th>
|
||||||
|
<th class="text-end" style="min-width: 120px;">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@foreach (VaultSecret secret in secrets)
|
||||||
|
{
|
||||||
|
<tr>
|
||||||
|
<td><code>@secret.Name</code></td>
|
||||||
|
<td>
|
||||||
|
@if (secret.SyncToKubernetes)
|
||||||
|
{
|
||||||
|
<span class="badge bg-success"><i class="bi bi-cloud-check me-1"></i>Enabled</span>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<span class="badge bg-secondary">Off</span>
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
<td><small class="text-muted">@(secret.KubernetesSecretName ?? "—")</small></td>
|
||||||
|
<td><small class="text-muted">@(secret.KubernetesNamespace ?? "—")</small></td>
|
||||||
|
<td><small class="text-muted">@secret.UpdatedAt.ToString("MMM d, HH:mm")</small></td>
|
||||||
|
<td class="text-end">
|
||||||
|
@* Reveal — available to all roles *@
|
||||||
|
<button class="btn btn-sm btn-outline-secondary me-1"
|
||||||
|
@onclick="() => RevealSecret(secret)"
|
||||||
|
title="@(revealedSecretId == secret.Id ? "Hide value" : "Reveal value")">
|
||||||
|
<i class="bi @(revealedSecretId == secret.Id ? "bi-eye-slash" : "bi-eye")"></i>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
@* Admin-only: edit, K8s sync, delete *@
|
||||||
|
@if (AccessRole >= CustomerAccessRole.Admin)
|
||||||
|
{
|
||||||
|
<button class="btn btn-sm btn-outline-warning me-1"
|
||||||
|
@onclick="() => StartEdit(secret)" title="Update value">
|
||||||
|
<i class="bi bi-pencil"></i>
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-sm btn-outline-info me-1"
|
||||||
|
@onclick="() => ToggleSync(secret)"
|
||||||
|
title="@(secret.SyncToKubernetes ? "Disable K8s sync" : "Configure K8s sync")">
|
||||||
|
<i class="bi @(secret.SyncToKubernetes ? "bi-cloud-slash" : "bi-cloud-arrow-up")"></i>
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-sm btn-outline-danger"
|
||||||
|
@onclick="() => DeleteSecret(secret.Id)" title="Delete secret">
|
||||||
|
<i class="bi bi-trash"></i>
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
@* ── Reveal value row ── *@
|
||||||
|
@if (revealedSecretId == secret.Id)
|
||||||
|
{
|
||||||
|
<tr class="table-light">
|
||||||
|
<td colspan="6">
|
||||||
|
<div class="d-flex align-items-center px-2 py-1 gap-2">
|
||||||
|
<span class="text-muted small">Value:</span>
|
||||||
|
@if (revealLoading)
|
||||||
|
{
|
||||||
|
<div class="spinner-border spinner-border-sm text-primary" role="status"></div>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<code class="user-select-all">@revealedValue</code>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
|
||||||
|
@* ── Edit value row ── *@
|
||||||
|
@if (editSecretId == secret.Id)
|
||||||
|
{
|
||||||
|
<tr class="table-light">
|
||||||
|
<td colspan="6">
|
||||||
|
<div class="row g-2 p-2 align-items-center">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<input type="password" class="form-control form-control-sm"
|
||||||
|
placeholder="New secret value"
|
||||||
|
@bind="editSecretValue" />
|
||||||
|
</div>
|
||||||
|
<div class="col-auto">
|
||||||
|
<button class="btn btn-sm btn-success" @onclick="SaveEdit"
|
||||||
|
disabled="@(string.IsNullOrWhiteSpace(editSecretValue) || saving)">
|
||||||
|
@if (saving) { <span class="spinner-border spinner-border-sm me-1"></span> }
|
||||||
|
<i class="bi bi-check-lg me-1"></i>Update
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-sm btn-outline-secondary ms-1" @onclick="CancelEdit">
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
|
||||||
|
@* ── K8s sync config row ── *@
|
||||||
|
@if (syncConfigSecretId == secret.Id)
|
||||||
|
{
|
||||||
|
<tr class="table-light">
|
||||||
|
<td colspan="6">
|
||||||
|
<div class="row g-2 p-2 align-items-center">
|
||||||
|
@if (clusters is not null)
|
||||||
|
{
|
||||||
|
<div class="col-md-3">
|
||||||
|
<select class="form-select form-select-sm" @bind="syncClusterId">
|
||||||
|
<option value="@Guid.Empty">Target cluster…</option>
|
||||||
|
@foreach (KubernetesCluster cluster in clusters)
|
||||||
|
{
|
||||||
|
<option value="@cluster.Id">@cluster.Name</option>
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
<div class="col-md-3">
|
||||||
|
<input type="text" class="form-control form-control-sm"
|
||||||
|
placeholder="K8s Secret name (e.g. my-app-secrets)"
|
||||||
|
@bind="syncSecretName" />
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<input type="text" class="form-control form-control-sm"
|
||||||
|
placeholder="Namespace (e.g. my-app)"
|
||||||
|
@bind="syncNamespace" />
|
||||||
|
</div>
|
||||||
|
<div class="col-auto">
|
||||||
|
<button class="btn btn-sm btn-success" @onclick="SaveSync">
|
||||||
|
<i class="bi bi-check-lg me-1"></i>Save
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-sm btn-outline-secondary ms-1"
|
||||||
|
@onclick="() => syncConfigSecretId = null">
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
@if (syncOutput is not null)
|
||||||
|
{
|
||||||
|
<div class="m-3 p-2 bg-dark text-white rounded font-monospace small" style="white-space: pre-wrap; max-height: 200px; overflow-y: auto;">@syncOutput</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@* ── Docker Registry Credentials ── *@
|
||||||
|
<div class="mt-4">
|
||||||
|
<div class="d-flex align-items-center mb-2">
|
||||||
|
<i class="bi bi-box-seam fs-5 me-2 text-primary"></i>
|
||||||
|
<h6 class="mb-0">Docker Registry Credentials</h6>
|
||||||
|
<span class="ms-2 text-muted small">Pull secrets for private container registries</span>
|
||||||
|
</div>
|
||||||
|
<DockerRegistriesPanel
|
||||||
|
TenantId="TenantId"
|
||||||
|
AppId="App.Id"
|
||||||
|
IsAdmin="AccessRole >= CustomerAccessRole.Admin" />
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@code {
|
||||||
|
[Parameter] public required Data.App App { get; set; }
|
||||||
|
[Parameter] public Guid TenantId { get; set; }
|
||||||
|
[Parameter] public CustomerAccessRole AccessRole { get; set; }
|
||||||
|
|
||||||
|
private bool vaultInitialized;
|
||||||
|
private List<VaultSecret>? secrets;
|
||||||
|
private List<KubernetesCluster>? clusters;
|
||||||
|
|
||||||
|
// Add form
|
||||||
|
private string newSecretName = "";
|
||||||
|
private string newSecretValue = "";
|
||||||
|
private bool saving;
|
||||||
|
private string? errorMessage;
|
||||||
|
private string? successMessage;
|
||||||
|
|
||||||
|
// Reveal
|
||||||
|
private Guid? revealedSecretId;
|
||||||
|
private string? revealedValue;
|
||||||
|
private bool revealLoading;
|
||||||
|
|
||||||
|
// Edit
|
||||||
|
private Guid? editSecretId;
|
||||||
|
private string editSecretValue = "";
|
||||||
|
|
||||||
|
// K8s sync config
|
||||||
|
private Guid? syncConfigSecretId;
|
||||||
|
private string syncSecretName = "";
|
||||||
|
private string syncNamespace = "";
|
||||||
|
private Guid syncClusterId;
|
||||||
|
|
||||||
|
// Sync-to-K8s state
|
||||||
|
private bool syncingSecrets;
|
||||||
|
private string? syncOutput;
|
||||||
|
|
||||||
|
protected override async Task OnInitializedAsync()
|
||||||
|
{
|
||||||
|
SecretVault? vault = await VaultService.GetVaultAsync(TenantId);
|
||||||
|
vaultInitialized = vault is not null;
|
||||||
|
|
||||||
|
if (vaultInitialized)
|
||||||
|
{
|
||||||
|
await LoadSecrets();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load clusters for the K8s sync config dropdown.
|
||||||
|
clusters = await TenantService.GetClustersAsync(TenantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task LoadSecrets()
|
||||||
|
{
|
||||||
|
secrets = await VaultService.GetAppSecretsAsync(TenantId, App.Id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ──────── Add ────────
|
||||||
|
|
||||||
|
private async Task AddSecret()
|
||||||
|
{
|
||||||
|
errorMessage = null;
|
||||||
|
successMessage = null;
|
||||||
|
saving = true;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await VaultService.SetAppSecretAsync(TenantId, App.Id, newSecretName.Trim(), newSecretValue.Trim());
|
||||||
|
successMessage = $"Secret '{newSecretName.Trim()}' saved.";
|
||||||
|
newSecretName = "";
|
||||||
|
newSecretValue = "";
|
||||||
|
await LoadSecrets();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
errorMessage = ex.Message;
|
||||||
|
}
|
||||||
|
finally { saving = false; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ──────── Reveal ────────
|
||||||
|
|
||||||
|
private async Task RevealSecret(VaultSecret secret)
|
||||||
|
{
|
||||||
|
if (revealedSecretId == secret.Id)
|
||||||
|
{
|
||||||
|
revealedSecretId = null;
|
||||||
|
revealedValue = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
revealLoading = true;
|
||||||
|
revealedSecretId = secret.Id;
|
||||||
|
revealedValue = null;
|
||||||
|
revealedValue = await VaultService.GetSecretValueByIdAsync(secret.Id);
|
||||||
|
revealLoading = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ──────── Edit ────────
|
||||||
|
|
||||||
|
private void StartEdit(VaultSecret secret)
|
||||||
|
{
|
||||||
|
editSecretId = secret.Id;
|
||||||
|
editSecretValue = "";
|
||||||
|
revealedSecretId = null;
|
||||||
|
revealedValue = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void CancelEdit()
|
||||||
|
{
|
||||||
|
editSecretId = null;
|
||||||
|
editSecretValue = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task SaveEdit()
|
||||||
|
{
|
||||||
|
if (editSecretId is null || string.IsNullOrWhiteSpace(editSecretValue)) return;
|
||||||
|
|
||||||
|
saving = true;
|
||||||
|
errorMessage = null;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
bool updated = await VaultService.UpdateSecretValueAsync(editSecretId.Value, editSecretValue.Trim());
|
||||||
|
successMessage = updated ? "Secret value updated." : null;
|
||||||
|
errorMessage = updated ? null : "Failed to update secret.";
|
||||||
|
}
|
||||||
|
finally { saving = false; }
|
||||||
|
|
||||||
|
editSecretId = null;
|
||||||
|
editSecretValue = "";
|
||||||
|
await LoadSecrets();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ──────── Delete ────────
|
||||||
|
|
||||||
|
private async Task DeleteSecret(Guid secretId)
|
||||||
|
{
|
||||||
|
errorMessage = null;
|
||||||
|
(bool canDelete, string? reason) = await VaultService.CanDeleteSecretAsync(secretId);
|
||||||
|
|
||||||
|
if (!canDelete)
|
||||||
|
{
|
||||||
|
errorMessage = reason;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await VaultService.DeleteSecretAsync(secretId);
|
||||||
|
revealedSecretId = null;
|
||||||
|
editSecretId = null;
|
||||||
|
await LoadSecrets();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ──────── K8s Sync ────────
|
||||||
|
|
||||||
|
private void ToggleSync(VaultSecret secret)
|
||||||
|
{
|
||||||
|
if (secret.SyncToKubernetes)
|
||||||
|
{
|
||||||
|
_ = DisableSync(secret.Id);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
syncConfigSecretId = secret.Id;
|
||||||
|
syncSecretName = secret.KubernetesSecretName ?? "";
|
||||||
|
syncNamespace = secret.KubernetesNamespace ?? "";
|
||||||
|
syncClusterId = secret.KubernetesClusterId ?? Guid.Empty;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task DisableSync(Guid secretId)
|
||||||
|
{
|
||||||
|
await VaultService.ConfigureKubernetesSyncAsync(secretId, syncEnabled: false, secretName: null, ns: null);
|
||||||
|
await LoadSecrets();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task SaveSync()
|
||||||
|
{
|
||||||
|
if (syncConfigSecretId is null) return;
|
||||||
|
|
||||||
|
Guid? clusterId = syncClusterId != Guid.Empty ? syncClusterId : (Guid?)null;
|
||||||
|
|
||||||
|
await VaultService.ConfigureKubernetesSyncAsync(
|
||||||
|
syncConfigSecretId.Value,
|
||||||
|
syncEnabled: true,
|
||||||
|
secretName: syncSecretName.Trim(),
|
||||||
|
ns: syncNamespace.Trim(),
|
||||||
|
clusterId: clusterId);
|
||||||
|
|
||||||
|
syncConfigSecretId = null;
|
||||||
|
await LoadSecrets();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task SyncAllToKubernetes()
|
||||||
|
{
|
||||||
|
syncingSecrets = true;
|
||||||
|
syncOutput = null;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
HelmExecutionResult result = await VaultService.SyncAppSecretsToKubernetesAsync(TenantId, App.Id);
|
||||||
|
syncOutput = result.Output;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
syncingSecrets = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,615 @@
|
|||||||
|
@using EntKube.Web.Data
|
||||||
|
@using EntKube.Web.Services
|
||||||
|
@inject DockerRegistryService DockerRegistryService
|
||||||
|
@inject TenantService TenantService
|
||||||
|
@inject VaultService VaultService
|
||||||
|
|
||||||
|
@* ═══════════════════════════════════════════════════════════════════
|
||||||
|
Docker / OCI Registry Credentials panel.
|
||||||
|
|
||||||
|
Reused in both the tenant admin Vault tab and the customer portal
|
||||||
|
Secrets detail view. Pass AppId to scope credentials to one app;
|
||||||
|
leave it as Guid.Empty for a tenant-wide view.
|
||||||
|
|
||||||
|
Access rules (controlled by IsAdmin parameter):
|
||||||
|
Any user — list credentials, reveal password on demand
|
||||||
|
Admin — add / edit / delete credentials, configure K8s sync, trigger sync
|
||||||
|
═══════════════════════════════════════════════════════════════════ *@
|
||||||
|
|
||||||
|
@if (!vaultInitialized)
|
||||||
|
{
|
||||||
|
<div class="alert alert-warning py-2 small mb-0">
|
||||||
|
<i class="bi bi-shield-exclamation me-1"></i>
|
||||||
|
No vault has been set up for this tenant — initialize it first before storing credentials.
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
@* ── Add credential form (Admin only, collapsible) ── *@
|
||||||
|
@if (IsAdmin)
|
||||||
|
{
|
||||||
|
<div class="card shadow-sm mb-3">
|
||||||
|
<div class="card-header bg-white py-2 d-flex align-items-center justify-content-between"
|
||||||
|
role="button" @onclick="() => showAddForm = !showAddForm" style="cursor: pointer;">
|
||||||
|
<div>
|
||||||
|
<i class="bi bi-plus-circle me-1 text-primary"></i>
|
||||||
|
<strong>Add Registry Credential</strong>
|
||||||
|
</div>
|
||||||
|
<i class="bi @(showAddForm ? "bi-chevron-up" : "bi-chevron-down") text-muted"></i>
|
||||||
|
</div>
|
||||||
|
@if (showAddForm)
|
||||||
|
{
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="row g-2 mb-2">
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label class="form-label small">Display Name</label>
|
||||||
|
<input class="form-control form-control-sm" placeholder="e.g. Production ACR"
|
||||||
|
@bind="addName" @bind:event="oninput" />
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label class="form-label small">Registry Type</label>
|
||||||
|
<select class="form-select form-select-sm" @bind="addType" @bind:after="OnAddTypeChanged">
|
||||||
|
@foreach (DockerRegistryType t in Enum.GetValues<DockerRegistryType>())
|
||||||
|
{
|
||||||
|
<option value="@t">@DockerRegistryService.TypeLabel(t)</option>
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label class="form-label small">
|
||||||
|
Registry Server
|
||||||
|
@if (!string.IsNullOrEmpty(DockerRegistryService.DefaultServer(addType)))
|
||||||
|
{
|
||||||
|
<span class="text-muted ms-1">(auto-filled)</span>
|
||||||
|
}
|
||||||
|
</label>
|
||||||
|
<input class="form-control form-control-sm"
|
||||||
|
placeholder="@DockerRegistryService.ServerPlaceholder(addType)"
|
||||||
|
@bind="addServer" @bind:event="oninput"
|
||||||
|
readonly="@(!string.IsNullOrEmpty(DockerRegistryService.DefaultServer(addType)))" />
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label class="form-label small">Username</label>
|
||||||
|
<input class="form-control form-control-sm"
|
||||||
|
placeholder="@(addType == DockerRegistryType.AzureContainerRegistry ? "Service principal ID" : "username")"
|
||||||
|
@bind="addUsername" @bind:event="oninput" />
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label class="form-label small">
|
||||||
|
Password / Token
|
||||||
|
@if (addType == DockerRegistryType.AzureContainerRegistry)
|
||||||
|
{
|
||||||
|
<span class="text-muted ms-1">or service principal secret</span>
|
||||||
|
}
|
||||||
|
</label>
|
||||||
|
<input type="password" class="form-control form-control-sm"
|
||||||
|
@bind="addPassword" @bind:event="oninput" />
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label class="form-label small">Email <span class="text-muted">(optional)</span></label>
|
||||||
|
<input class="form-control form-control-sm" placeholder="user@example.com"
|
||||||
|
@bind="addEmail" @bind:event="oninput" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (addError is not null)
|
||||||
|
{
|
||||||
|
<div class="alert alert-danger py-1 small mb-2">
|
||||||
|
<i class="bi bi-exclamation-triangle me-1"></i>@addError
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
@if (addSuccess is not null)
|
||||||
|
{
|
||||||
|
<div class="alert alert-success py-1 small mb-2">
|
||||||
|
<i class="bi bi-check-circle me-1"></i>@addSuccess
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="d-flex gap-1">
|
||||||
|
<button class="btn btn-sm btn-primary" @onclick="AddCredential"
|
||||||
|
disabled="@(string.IsNullOrWhiteSpace(addName)
|
||||||
|
|| string.IsNullOrWhiteSpace(addServer)
|
||||||
|
|| string.IsNullOrWhiteSpace(addUsername)
|
||||||
|
|| string.IsNullOrWhiteSpace(addPassword)
|
||||||
|
|| saving)">
|
||||||
|
@if (saving) { <span class="spinner-border spinner-border-sm me-1"></span> }
|
||||||
|
<i class="bi bi-plus-lg me-1"></i>Add Credential
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-sm btn-outline-secondary" @onclick="ResetAddForm">Cancel</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@* ── Credentials table ── *@
|
||||||
|
<div class="card shadow-sm">
|
||||||
|
<div class="card-header bg-white py-2 d-flex align-items-center">
|
||||||
|
<i class="bi bi-box-seam me-2 text-primary"></i>
|
||||||
|
<strong>Registry Credentials</strong>
|
||||||
|
@if (credentials is not null)
|
||||||
|
{
|
||||||
|
<span class="badge bg-secondary ms-1">@credentials.Count</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<div class="card-body p-0">
|
||||||
|
@if (credentials is null)
|
||||||
|
{
|
||||||
|
<div class="text-center py-4">
|
||||||
|
<div class="spinner-border spinner-border-sm text-primary" role="status"></div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
else if (credentials.Count == 0)
|
||||||
|
{
|
||||||
|
<div class="text-center py-5">
|
||||||
|
<i class="bi bi-box-seam text-muted" style="font-size: 2rem;"></i>
|
||||||
|
<p class="text-muted small mt-2 mb-0">No registry credentials stored yet.</p>
|
||||||
|
@if (IsAdmin)
|
||||||
|
{
|
||||||
|
<p class="text-muted small mb-0">Use the form above to add one.</p>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-hover align-middle mb-0">
|
||||||
|
<thead class="table-light">
|
||||||
|
<tr>
|
||||||
|
<th>Name</th>
|
||||||
|
<th>Type</th>
|
||||||
|
<th>Server</th>
|
||||||
|
<th>Username</th>
|
||||||
|
<th>K8s Sync</th>
|
||||||
|
<th>Updated</th>
|
||||||
|
<th class="text-end" style="min-width: 130px;">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@foreach (DockerRegistryCredential cred in credentials)
|
||||||
|
{
|
||||||
|
<tr>
|
||||||
|
<td class="fw-medium small">@cred.Name</td>
|
||||||
|
<td>
|
||||||
|
<span class="badge @TypeBadgeClass(cred.RegistryType)">
|
||||||
|
@DockerRegistryService.TypeLabel(cred.RegistryType)
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td><small class="text-muted font-monospace">@cred.Server</small></td>
|
||||||
|
<td><code class="small">@cred.Username</code></td>
|
||||||
|
<td>
|
||||||
|
@if (!string.IsNullOrEmpty(cred.KubernetesSecretName))
|
||||||
|
{
|
||||||
|
<span class="badge bg-success"><i class="bi bi-cloud-check me-1"></i>Configured</span>
|
||||||
|
<br />
|
||||||
|
<small class="text-muted">@cred.KubernetesCluster?.Name · @cred.KubernetesNamespace · @cred.KubernetesSecretName</small>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<span class="badge bg-secondary">Not configured</span>
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
<td><small class="text-muted">@cred.UpdatedAt.ToString("MMM d, HH:mm")</small></td>
|
||||||
|
<td class="text-end">
|
||||||
|
@* Reveal password — all roles *@
|
||||||
|
<button class="btn btn-sm btn-outline-secondary me-1"
|
||||||
|
title="@(revealedId == cred.Id ? "Hide password" : "Reveal password")"
|
||||||
|
@onclick="() => RevealPassword(cred)">
|
||||||
|
<i class="bi @(revealedId == cred.Id ? "bi-eye-slash" : "bi-eye")"></i>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
@if (IsAdmin)
|
||||||
|
{
|
||||||
|
@* Edit *@
|
||||||
|
<button class="btn btn-sm btn-outline-warning me-1" title="Edit credentials"
|
||||||
|
@onclick="() => StartEdit(cred)">
|
||||||
|
<i class="bi bi-pencil"></i>
|
||||||
|
</button>
|
||||||
|
@* Configure K8s sync *@
|
||||||
|
<button class="btn btn-sm btn-outline-info me-1" title="Configure K8s sync"
|
||||||
|
@onclick="() => StartSyncConfig(cred)">
|
||||||
|
<i class="bi bi-cloud-arrow-up"></i>
|
||||||
|
</button>
|
||||||
|
@* Sync to K8s (only if configured) *@
|
||||||
|
@if (!string.IsNullOrEmpty(cred.KubernetesSecretName))
|
||||||
|
{
|
||||||
|
<button class="btn btn-sm btn-outline-success me-1"
|
||||||
|
title="Apply to Kubernetes cluster"
|
||||||
|
disabled="@(syncingId == cred.Id)"
|
||||||
|
@onclick="() => SyncToKubernetes(cred)">
|
||||||
|
@if (syncingId == cred.Id)
|
||||||
|
{
|
||||||
|
<span class="spinner-border spinner-border-sm"></span>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<i class="bi bi-play-fill"></i>
|
||||||
|
}
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
@* Delete *@
|
||||||
|
@if (confirmDeleteId == cred.Id)
|
||||||
|
{
|
||||||
|
<button class="btn btn-sm btn-danger me-1" @onclick="() => DeleteCredential(cred.Id)">
|
||||||
|
Confirm
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-sm btn-outline-secondary" @onclick="() => confirmDeleteId = null">
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<button class="btn btn-sm btn-outline-danger" title="Delete"
|
||||||
|
@onclick="() => confirmDeleteId = cred.Id">
|
||||||
|
<i class="bi bi-trash"></i>
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
@* ── Reveal password row ── *@
|
||||||
|
@if (revealedId == cred.Id)
|
||||||
|
{
|
||||||
|
<tr class="table-light">
|
||||||
|
<td colspan="7">
|
||||||
|
<div class="d-flex align-items-center px-2 py-1 gap-2">
|
||||||
|
<span class="text-muted small">Password:</span>
|
||||||
|
@if (revealLoading)
|
||||||
|
{
|
||||||
|
<div class="spinner-border spinner-border-sm text-primary" role="status"></div>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<code class="user-select-all">@revealedPassword</code>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
|
||||||
|
@* ── Edit row ── *@
|
||||||
|
@if (editId == cred.Id)
|
||||||
|
{
|
||||||
|
<tr class="table-light">
|
||||||
|
<td colspan="7">
|
||||||
|
<div class="row g-2 p-2 align-items-end">
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label class="form-label small mb-1">Username</label>
|
||||||
|
<input class="form-control form-control-sm" @bind="editUsername" />
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label class="form-label small mb-1">New password / token</label>
|
||||||
|
<input type="password" class="form-control form-control-sm"
|
||||||
|
placeholder="Leave blank to keep existing"
|
||||||
|
@bind="editPassword" />
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label class="form-label small mb-1">Email (optional)</label>
|
||||||
|
<input class="form-control form-control-sm" @bind="editEmail" />
|
||||||
|
</div>
|
||||||
|
<div class="col-12 d-flex gap-1">
|
||||||
|
<button class="btn btn-sm btn-success" @onclick="SaveEdit" disabled="@saving">
|
||||||
|
@if (saving) { <span class="spinner-border spinner-border-sm me-1"></span> }
|
||||||
|
<i class="bi bi-check-lg me-1"></i>Update
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-sm btn-outline-secondary" @onclick="CancelEdit">Cancel</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
|
||||||
|
@* ── K8s sync config row ── *@
|
||||||
|
@if (syncConfigId == cred.Id)
|
||||||
|
{
|
||||||
|
<tr class="table-light">
|
||||||
|
<td colspan="7">
|
||||||
|
<div class="row g-2 p-2 align-items-end">
|
||||||
|
<div class="col-md-3">
|
||||||
|
<label class="form-label small mb-1">Cluster</label>
|
||||||
|
@if (clusters is null)
|
||||||
|
{
|
||||||
|
<div class="spinner-border spinner-border-sm text-primary" role="status"></div>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<select class="form-select form-select-sm" @bind="syncClusterId">
|
||||||
|
<option value="@Guid.Empty">Select cluster…</option>
|
||||||
|
@foreach (KubernetesCluster cluster in clusters)
|
||||||
|
{
|
||||||
|
<option value="@cluster.Id">@cluster.Name</option>
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<label class="form-label small mb-1">Namespace</label>
|
||||||
|
<input class="form-control form-control-sm"
|
||||||
|
placeholder="e.g. my-app" @bind="syncNamespace" />
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<label class="form-label small mb-1">K8s Secret name</label>
|
||||||
|
<input class="form-control form-control-sm"
|
||||||
|
placeholder="e.g. registry-creds" @bind="syncSecretName" />
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3 d-flex gap-1 align-items-end">
|
||||||
|
<button class="btn btn-sm btn-success" @onclick="() => SaveSyncConfig(cred.Id)"
|
||||||
|
disabled="@(syncClusterId == Guid.Empty || string.IsNullOrWhiteSpace(syncNamespace) || string.IsNullOrWhiteSpace(syncSecretName))">
|
||||||
|
<i class="bi bi-check-lg me-1"></i>Save
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-sm btn-outline-secondary" @onclick="CancelSyncConfig">
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
|
||||||
|
@* ── Sync output row ── *@
|
||||||
|
@if (syncOutput.TryGetValue(cred.Id, out (bool ok, string msg) syncResult))
|
||||||
|
{
|
||||||
|
<tr class="table-light">
|
||||||
|
<td colspan="7">
|
||||||
|
<div class="d-flex align-items-center justify-content-between px-2 pt-1 mb-1">
|
||||||
|
<span class="small fw-medium @(syncResult.ok ? "text-success" : "text-danger")">
|
||||||
|
<i class="bi @(syncResult.ok ? "bi-check-circle" : "bi-x-circle") me-1"></i>
|
||||||
|
@(syncResult.ok ? "Synced to cluster successfully" : "Sync failed")
|
||||||
|
</span>
|
||||||
|
<button class="btn btn-sm btn-link text-muted py-0"
|
||||||
|
@onclick="() => syncOutput.Remove(cred.Id)">
|
||||||
|
<i class="bi bi-x"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<pre class="bg-dark text-light p-2 rounded small mb-2 mx-2"
|
||||||
|
style="max-height: 180px; overflow-y: auto; white-space: pre-wrap; word-break: break-all;">@syncResult.msg</pre>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@code {
|
||||||
|
[Parameter] public Guid TenantId { get; set; }
|
||||||
|
[Parameter] public Guid AppId { get; set; } // Guid.Empty = tenant-wide
|
||||||
|
[Parameter] public bool IsAdmin { get; set; }
|
||||||
|
|
||||||
|
private bool vaultInitialized;
|
||||||
|
private List<DockerRegistryCredential>? credentials;
|
||||||
|
private List<KubernetesCluster>? clusters;
|
||||||
|
|
||||||
|
// Add form
|
||||||
|
private bool showAddForm;
|
||||||
|
private string addName = "";
|
||||||
|
private DockerRegistryType addType = DockerRegistryType.Generic;
|
||||||
|
private string addServer = "";
|
||||||
|
private string addUsername = "";
|
||||||
|
private string addPassword = "";
|
||||||
|
private string addEmail = "";
|
||||||
|
private bool saving;
|
||||||
|
private string? addError;
|
||||||
|
private string? addSuccess;
|
||||||
|
|
||||||
|
// Reveal
|
||||||
|
private Guid? revealedId;
|
||||||
|
private string? revealedPassword;
|
||||||
|
private bool revealLoading;
|
||||||
|
|
||||||
|
// Edit
|
||||||
|
private Guid? editId;
|
||||||
|
private string editUsername = "";
|
||||||
|
private string editPassword = "";
|
||||||
|
private string editEmail = "";
|
||||||
|
|
||||||
|
// K8s sync config
|
||||||
|
private Guid? syncConfigId;
|
||||||
|
private Guid syncClusterId;
|
||||||
|
private string syncNamespace = "";
|
||||||
|
private string syncSecretName = "";
|
||||||
|
|
||||||
|
// Active sync operation
|
||||||
|
private Guid? syncingId;
|
||||||
|
private Dictionary<Guid, (bool Ok, string Msg)> syncOutput = new();
|
||||||
|
|
||||||
|
// Delete confirm
|
||||||
|
private Guid? confirmDeleteId;
|
||||||
|
|
||||||
|
protected override async Task OnInitializedAsync()
|
||||||
|
{
|
||||||
|
await CheckVaultAndLoad();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task CheckVaultAndLoad()
|
||||||
|
{
|
||||||
|
SecretVault? vault = await VaultService.GetVaultAsync(TenantId);
|
||||||
|
vaultInitialized = vault is not null;
|
||||||
|
|
||||||
|
if (vaultInitialized)
|
||||||
|
{
|
||||||
|
await LoadCredentials();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task LoadCredentials()
|
||||||
|
{
|
||||||
|
credentials = await DockerRegistryService.GetAsync(
|
||||||
|
TenantId,
|
||||||
|
AppId == Guid.Empty ? null : AppId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ──────── Add ────────
|
||||||
|
|
||||||
|
private void OnAddTypeChanged()
|
||||||
|
{
|
||||||
|
string def = DockerRegistryService.DefaultServer(addType);
|
||||||
|
addServer = def; // auto-fill or clear
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ResetAddForm()
|
||||||
|
{
|
||||||
|
showAddForm = false;
|
||||||
|
addName = addServer = addUsername = addPassword = addEmail = "";
|
||||||
|
addType = DockerRegistryType.Generic;
|
||||||
|
addError = addSuccess = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task AddCredential()
|
||||||
|
{
|
||||||
|
addError = null;
|
||||||
|
addSuccess = null;
|
||||||
|
saving = true;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await DockerRegistryService.CreateAsync(
|
||||||
|
TenantId,
|
||||||
|
AppId == Guid.Empty ? null : AppId,
|
||||||
|
addName.Trim(),
|
||||||
|
addType,
|
||||||
|
addServer.Trim(),
|
||||||
|
addUsername.Trim(),
|
||||||
|
addPassword,
|
||||||
|
string.IsNullOrWhiteSpace(addEmail) ? null : addEmail.Trim());
|
||||||
|
|
||||||
|
addSuccess = $"Credential '{addName.Trim()}' added.";
|
||||||
|
addName = addUsername = addPassword = addEmail = "";
|
||||||
|
// Keep form open so user can add another, but clear fields.
|
||||||
|
await LoadCredentials();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
addError = ex.Message;
|
||||||
|
}
|
||||||
|
finally { saving = false; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ──────── Reveal ────────
|
||||||
|
|
||||||
|
private async Task RevealPassword(DockerRegistryCredential cred)
|
||||||
|
{
|
||||||
|
if (revealedId == cred.Id)
|
||||||
|
{
|
||||||
|
revealedId = null;
|
||||||
|
revealedPassword = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
revealLoading = true;
|
||||||
|
revealedId = cred.Id;
|
||||||
|
revealedPassword = null;
|
||||||
|
revealedPassword = await DockerRegistryService.GetPasswordAsync(cred.Id);
|
||||||
|
revealLoading = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ──────── Edit ────────
|
||||||
|
|
||||||
|
private void StartEdit(DockerRegistryCredential cred)
|
||||||
|
{
|
||||||
|
editId = cred.Id;
|
||||||
|
editUsername = cred.Username;
|
||||||
|
editPassword = "";
|
||||||
|
editEmail = cred.Email ?? "";
|
||||||
|
revealedId = null;
|
||||||
|
syncConfigId = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void CancelEdit()
|
||||||
|
{
|
||||||
|
editId = null;
|
||||||
|
editUsername = editPassword = editEmail = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task SaveEdit()
|
||||||
|
{
|
||||||
|
if (editId is null) return;
|
||||||
|
saving = true;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await DockerRegistryService.UpdateAsync(
|
||||||
|
editId.Value,
|
||||||
|
string.IsNullOrWhiteSpace(editUsername) ? null : editUsername.Trim(),
|
||||||
|
string.IsNullOrWhiteSpace(editPassword) ? null : editPassword,
|
||||||
|
editEmail);
|
||||||
|
}
|
||||||
|
finally { saving = false; }
|
||||||
|
|
||||||
|
editId = null;
|
||||||
|
await LoadCredentials();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ──────── K8s Sync Config ────────
|
||||||
|
|
||||||
|
private async Task StartSyncConfig(DockerRegistryCredential cred)
|
||||||
|
{
|
||||||
|
syncConfigId = cred.Id;
|
||||||
|
syncClusterId = cred.KubernetesClusterId ?? Guid.Empty;
|
||||||
|
syncNamespace = cred.KubernetesNamespace ?? "";
|
||||||
|
syncSecretName = cred.KubernetesSecretName ?? "";
|
||||||
|
editId = null;
|
||||||
|
|
||||||
|
// Load clusters lazily.
|
||||||
|
if (clusters is null)
|
||||||
|
{
|
||||||
|
clusters = await TenantService.GetClustersAsync(TenantId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void CancelSyncConfig()
|
||||||
|
{
|
||||||
|
syncConfigId = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task SaveSyncConfig(Guid credId)
|
||||||
|
{
|
||||||
|
await DockerRegistryService.ConfigureSyncAsync(credId, syncClusterId, syncSecretName, syncNamespace);
|
||||||
|
syncConfigId = null;
|
||||||
|
await LoadCredentials();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ──────── Sync to K8s ────────
|
||||||
|
|
||||||
|
private async Task SyncToKubernetes(DockerRegistryCredential cred)
|
||||||
|
{
|
||||||
|
syncingId = cred.Id;
|
||||||
|
syncOutput.Remove(cred.Id);
|
||||||
|
|
||||||
|
KubernetesOperationResult<string> result = await DockerRegistryService.SyncToKubernetesAsync(cred.Id);
|
||||||
|
|
||||||
|
syncOutput[cred.Id] = (result.IsSuccess, result.IsSuccess ? result.Data ?? "" : result.Error ?? "Unknown error");
|
||||||
|
syncingId = null;
|
||||||
|
|
||||||
|
await LoadCredentials();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ──────── Delete ────────
|
||||||
|
|
||||||
|
private async Task DeleteCredential(Guid credId)
|
||||||
|
{
|
||||||
|
await DockerRegistryService.DeleteAsync(credId);
|
||||||
|
confirmDeleteId = null;
|
||||||
|
revealedId = null;
|
||||||
|
editId = null;
|
||||||
|
syncConfigId = null;
|
||||||
|
syncOutput.Remove(credId);
|
||||||
|
await LoadCredentials();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ──────── Rendering helpers ────────
|
||||||
|
|
||||||
|
private static string TypeBadgeClass(DockerRegistryType type) => type switch
|
||||||
|
{
|
||||||
|
DockerRegistryType.DockerHub => "bg-info text-dark",
|
||||||
|
DockerRegistryType.AzureContainerRegistry => "bg-primary",
|
||||||
|
DockerRegistryType.Harbor => "bg-success",
|
||||||
|
DockerRegistryType.Quay => "bg-danger",
|
||||||
|
DockerRegistryType.GitHubContainerRegistry => "bg-dark",
|
||||||
|
_ => "bg-secondary"
|
||||||
|
};
|
||||||
|
}
|
||||||
570
src/EntKube.Web/Components/Pages/Shared/ResourceTreePanel.razor
Normal file
570
src/EntKube.Web/Components/Pages/Shared/ResourceTreePanel.razor
Normal file
@@ -0,0 +1,570 @@
|
|||||||
|
@using EntKube.Web.Data
|
||||||
|
@using EntKube.Web.Services
|
||||||
|
@inject KubernetesOperationsService K8sOps
|
||||||
|
|
||||||
|
@* ═══════════════════════════════════════════════════════════════════
|
||||||
|
ResourceTreePanel — ArgoCD-style resource ownership tree.
|
||||||
|
|
||||||
|
Each node renders as a card with a health-coloured left border,
|
||||||
|
kind icon, kind badge, resource name, status and health label.
|
||||||
|
Children are indented beneath a dashed vertical guide line,
|
||||||
|
matching the ArgoCD Application detail layout.
|
||||||
|
═══════════════════════════════════════════════════════════════════ *@
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.argo-tree { font-size: .84rem; }
|
||||||
|
|
||||||
|
.argo-node-card {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
border: 1px solid #e9ecef;
|
||||||
|
border-left: 3px solid var(--hc, #adb5bd);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 5px 10px;
|
||||||
|
background: white;
|
||||||
|
box-shadow: 0 1px 3px rgba(0,0,0,.06);
|
||||||
|
margin-bottom: 4px;
|
||||||
|
min-width: 0;
|
||||||
|
transition: background .1s;
|
||||||
|
}
|
||||||
|
.argo-node-card:hover { background: #f8f9ff; }
|
||||||
|
.argo-node-card.panel-open { background: #f0f4ff; border-left-color: #6ea8fe; }
|
||||||
|
|
||||||
|
.argo-children {
|
||||||
|
margin-left: 22px;
|
||||||
|
padding-left: 14px;
|
||||||
|
border-left: 1px dashed #ced4da;
|
||||||
|
padding-bottom: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.argo-kind-badge {
|
||||||
|
font-size: .65rem;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: .03em;
|
||||||
|
border: 1px solid #dee2e6;
|
||||||
|
border-radius: 3px;
|
||||||
|
padding: 1px 5px;
|
||||||
|
background: #f8f9fa;
|
||||||
|
color: #495057;
|
||||||
|
white-space: nowrap;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.argo-action-panel {
|
||||||
|
margin-left: 36px;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid #3a3a4e;
|
||||||
|
background: #1e1e2e;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.argo-action-toolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 4px 10px;
|
||||||
|
background: #2a2a3e;
|
||||||
|
border-bottom: 1px solid #3a3a4e;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
@if (Loading)
|
||||||
|
{
|
||||||
|
<div class="text-center py-4">
|
||||||
|
<div class="spinner-border spinner-border-sm text-primary" role="status"></div>
|
||||||
|
<p class="text-muted small mt-2 mb-0">Querying cluster…</p>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
else if (Error is not null)
|
||||||
|
{
|
||||||
|
<div class="alert alert-warning py-2 small mb-0">
|
||||||
|
<i class="bi bi-exclamation-triangle me-1"></i>@Error
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
else if (Resources is null || Resources.Count == 0)
|
||||||
|
{
|
||||||
|
<div class="text-center py-4">
|
||||||
|
<i class="bi bi-diagram-3 text-muted" style="font-size: 2rem;"></i>
|
||||||
|
<p class="text-muted small mt-2 mb-0">
|
||||||
|
No resources found in namespace <code>@Namespace</code>.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
@if (_actionFeedback is not null)
|
||||||
|
{
|
||||||
|
<div class="alert @(_actionFeedbackOk ? "alert-success" : "alert-danger") alert-dismissible py-2 small mb-3">
|
||||||
|
<i class="bi @(_actionFeedbackOk ? "bi-check-circle" : "bi-exclamation-triangle") me-1"></i>
|
||||||
|
@_actionFeedback
|
||||||
|
<button type="button" class="btn-close btn-close-sm" @onclick="() => _actionFeedback = null"></button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="argo-tree">
|
||||||
|
@foreach (DeploymentResource root in Resources)
|
||||||
|
{
|
||||||
|
@RenderNode(root)
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@code {
|
||||||
|
[Parameter] public bool Loading { get; set; }
|
||||||
|
[Parameter] public string? Error { get; set; }
|
||||||
|
[Parameter] public List<DeploymentResource>? Resources { get; set; }
|
||||||
|
[Parameter] public string? Namespace { get; set; }
|
||||||
|
[Parameter] public Guid DeploymentId { get; set; }
|
||||||
|
/// <summary>Null = full admin access (tenant view).</summary>
|
||||||
|
[Parameter] public CustomerAccessRole? AccessRole { get; set; }
|
||||||
|
|
||||||
|
private bool CanLogs => AccessRole is null || AccessRole >= CustomerAccessRole.Viewer;
|
||||||
|
private bool CanEvents => AccessRole is null || AccessRole >= CustomerAccessRole.Viewer;
|
||||||
|
private bool CanRestart => AccessRole is null || AccessRole >= CustomerAccessRole.Operator;
|
||||||
|
private bool CanDelete => AccessRole is null || AccessRole >= CustomerAccessRole.Operator;
|
||||||
|
private bool CanScale => AccessRole is null || AccessRole >= CustomerAccessRole.Admin;
|
||||||
|
|
||||||
|
private Guid? _activePanelId;
|
||||||
|
private string? _panelMode;
|
||||||
|
private bool _panelLoading;
|
||||||
|
private string? _panelText;
|
||||||
|
private string? _panelError;
|
||||||
|
private List<string> _containers = [];
|
||||||
|
private string? _selectedContainer;
|
||||||
|
private int _scaleValue = 1;
|
||||||
|
private bool _opBusy;
|
||||||
|
private string? _actionFeedback;
|
||||||
|
private bool _actionFeedbackOk;
|
||||||
|
|
||||||
|
// ── Static visual helpers ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private static string HealthColor(HealthStatus h) => h switch
|
||||||
|
{
|
||||||
|
HealthStatus.Healthy => "#198754",
|
||||||
|
HealthStatus.Progressing => "#0d6efd",
|
||||||
|
HealthStatus.Degraded => "#fd7e14",
|
||||||
|
HealthStatus.Missing => "#dc3545",
|
||||||
|
HealthStatus.Suspended => "#6c757d",
|
||||||
|
_ => "#adb5bd",
|
||||||
|
};
|
||||||
|
|
||||||
|
private static string HealthLabel(HealthStatus h) => h switch
|
||||||
|
{
|
||||||
|
HealthStatus.Healthy => "Healthy",
|
||||||
|
HealthStatus.Progressing => "Progressing",
|
||||||
|
HealthStatus.Degraded => "Degraded",
|
||||||
|
HealthStatus.Missing => "Missing",
|
||||||
|
HealthStatus.Suspended => "Suspended",
|
||||||
|
_ => "Unknown",
|
||||||
|
};
|
||||||
|
|
||||||
|
private static string KindIcon(string kind) => kind switch
|
||||||
|
{
|
||||||
|
"Deployment" => "bi-layers",
|
||||||
|
"StatefulSet" => "bi-database",
|
||||||
|
"DaemonSet" => "bi-broadcast",
|
||||||
|
"ReplicaSet" => "bi-files",
|
||||||
|
"Pod" => "bi-cpu",
|
||||||
|
"Service" => "bi-share",
|
||||||
|
"Ingress" => "bi-globe",
|
||||||
|
"HTTPRoute" => "bi-signpost-split",
|
||||||
|
"PersistentVolumeClaim" => "bi-hdd",
|
||||||
|
"ConfigMap" => "bi-file-code",
|
||||||
|
"Secret" => "bi-key",
|
||||||
|
"Job" => "bi-lightning",
|
||||||
|
"CronJob" => "bi-clock",
|
||||||
|
_ => "bi-box",
|
||||||
|
};
|
||||||
|
|
||||||
|
private static string KindColor(string kind) => kind switch
|
||||||
|
{
|
||||||
|
"Deployment" or "StatefulSet" or "DaemonSet" => "text-primary",
|
||||||
|
"ReplicaSet" => "text-secondary",
|
||||||
|
"Pod" => "text-success",
|
||||||
|
"Service" or "Ingress" or "HTTPRoute" => "text-info",
|
||||||
|
"Job" or "CronJob" => "text-warning",
|
||||||
|
"PersistentVolumeClaim" => "text-danger",
|
||||||
|
_ => "text-muted",
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Panel open/close ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private void ClosePanel()
|
||||||
|
{
|
||||||
|
_activePanelId = null;
|
||||||
|
_panelMode = null;
|
||||||
|
_panelText = null;
|
||||||
|
_panelError = null;
|
||||||
|
_containers = [];
|
||||||
|
_selectedContainer = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool IsPanelOpen(Guid id, string mode) =>
|
||||||
|
_activePanelId == id && _panelMode == mode;
|
||||||
|
|
||||||
|
// ── Action handlers ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private async Task OpenLogPanel(DeploymentResource node)
|
||||||
|
{
|
||||||
|
if (_activePanelId == node.Id && _panelMode == "logs") { ClosePanel(); return; }
|
||||||
|
ClosePanel();
|
||||||
|
_activePanelId = node.Id;
|
||||||
|
_panelMode = "logs";
|
||||||
|
_panelLoading = true;
|
||||||
|
StateHasChanged();
|
||||||
|
|
||||||
|
var cr = await K8sOps.GetPodContainersAsync(DeploymentId, node.Name);
|
||||||
|
if (!cr.IsSuccess) { _panelError = cr.Error; _panelLoading = false; return; }
|
||||||
|
|
||||||
|
_containers = cr.Data ?? [];
|
||||||
|
_selectedContainer = _containers.Count > 0 ? _containers[0] : null;
|
||||||
|
await FetchLogs(node.Name);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task FetchLogs(string podName)
|
||||||
|
{
|
||||||
|
_panelLoading = true;
|
||||||
|
_panelText = null;
|
||||||
|
_panelError = null;
|
||||||
|
StateHasChanged();
|
||||||
|
|
||||||
|
var r = await K8sOps.GetPodLogsAsync(DeploymentId, podName, _selectedContainer);
|
||||||
|
_panelLoading = false;
|
||||||
|
if (r.IsSuccess) _panelText = r.Data ?? "(no output)";
|
||||||
|
else _panelError = r.Error;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task OpenEventsPanel(DeploymentResource node)
|
||||||
|
{
|
||||||
|
if (_activePanelId == node.Id && _panelMode == "events") { ClosePanel(); return; }
|
||||||
|
ClosePanel();
|
||||||
|
_activePanelId = node.Id;
|
||||||
|
_panelMode = "events";
|
||||||
|
_panelLoading = true;
|
||||||
|
StateHasChanged();
|
||||||
|
|
||||||
|
var r = await K8sOps.GetResourceEventsAsync(DeploymentId, node.Kind, node.Name);
|
||||||
|
_panelLoading = false;
|
||||||
|
if (r.IsSuccess)
|
||||||
|
_panelText = r.Data is { Count: > 0 } lines ? string.Join("\n", lines) : "(no events)";
|
||||||
|
else _panelError = r.Error;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OpenScalePanel(DeploymentResource node)
|
||||||
|
{
|
||||||
|
if (_activePanelId == node.Id && _panelMode == "scale") { ClosePanel(); return; }
|
||||||
|
ClosePanel();
|
||||||
|
_activePanelId = node.Id;
|
||||||
|
_panelMode = "scale";
|
||||||
|
_scaleValue = ParseDesiredReplicas(node.StatusMessage) ?? 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task ApplyScale(DeploymentResource node)
|
||||||
|
{
|
||||||
|
_opBusy = true;
|
||||||
|
StateHasChanged();
|
||||||
|
var r = await K8sOps.ScaleWorkloadAsync(DeploymentId, node.Kind, node.Name, _scaleValue);
|
||||||
|
_opBusy = false;
|
||||||
|
ClosePanel();
|
||||||
|
Feedback(r.IsSuccess, r.IsSuccess
|
||||||
|
? $"{node.Kind} {node.Name} scaled to {_scaleValue}."
|
||||||
|
: r.Error ?? "Scale failed.");
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OpenRestartConfirm(DeploymentResource node)
|
||||||
|
{
|
||||||
|
if (_activePanelId == node.Id && _panelMode == "restart-confirm") { ClosePanel(); return; }
|
||||||
|
ClosePanel();
|
||||||
|
_activePanelId = node.Id;
|
||||||
|
_panelMode = "restart-confirm";
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task ConfirmRestart(DeploymentResource node)
|
||||||
|
{
|
||||||
|
_opBusy = true;
|
||||||
|
StateHasChanged();
|
||||||
|
var r = await K8sOps.RestartWorkloadAsync(DeploymentId, node.Kind, node.Name);
|
||||||
|
_opBusy = false;
|
||||||
|
ClosePanel();
|
||||||
|
Feedback(r.IsSuccess, r.IsSuccess
|
||||||
|
? $"Rolling restart triggered for {node.Name}."
|
||||||
|
: r.Error ?? "Restart failed.");
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OpenDeleteConfirm(DeploymentResource node)
|
||||||
|
{
|
||||||
|
if (_activePanelId == node.Id && _panelMode == "delete-confirm") { ClosePanel(); return; }
|
||||||
|
ClosePanel();
|
||||||
|
_activePanelId = node.Id;
|
||||||
|
_panelMode = "delete-confirm";
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task ConfirmDelete(DeploymentResource node)
|
||||||
|
{
|
||||||
|
_opBusy = true;
|
||||||
|
StateHasChanged();
|
||||||
|
var r = await K8sOps.DeleteResourceAsync(DeploymentId, node.Kind, node.Name);
|
||||||
|
_opBusy = false;
|
||||||
|
ClosePanel();
|
||||||
|
Feedback(r.IsSuccess, r.IsSuccess
|
||||||
|
? $"{node.Kind} {node.Name} deleted."
|
||||||
|
: r.Error ?? "Delete failed.");
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Feedback(bool ok, string msg)
|
||||||
|
{
|
||||||
|
_actionFeedbackOk = ok;
|
||||||
|
_actionFeedback = msg;
|
||||||
|
StateHasChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int? ParseDesiredReplicas(string? s)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(s)) return null;
|
||||||
|
int slash = s.IndexOf('/');
|
||||||
|
if (slash < 0) return null;
|
||||||
|
string after = s[(slash + 1)..].Split(' ')[0];
|
||||||
|
return int.TryParse(after, out int n) ? n : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Recursive card renderer ──────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// Each call emits one node card, then (if its panel is open) the action
|
||||||
|
// panel, then recursively all children inside .argo-children.
|
||||||
|
//
|
||||||
|
// IMPORTANT: keep this method free of async lambdas and complex captures.
|
||||||
|
// Only use simple @onclick="() => SyncMethod(node)" references here.
|
||||||
|
|
||||||
|
private RenderFragment RenderNode(DeploymentResource node) => __builder =>
|
||||||
|
{
|
||||||
|
string hColor = HealthColor(node.HealthStatus);
|
||||||
|
bool panelOpen = _activePanelId == node.Id;
|
||||||
|
bool hasKids = node.ChildResources is { Count: > 0 };
|
||||||
|
|
||||||
|
// ── Card ─────────────────────────────────────────────────────────────
|
||||||
|
<div class="argo-node-card @(panelOpen ? "panel-open" : "")"
|
||||||
|
style="--hc: @hColor">
|
||||||
|
|
||||||
|
<i class="bi @KindIcon(node.Kind) @KindColor(node.Kind)"
|
||||||
|
style="font-size: .95rem; flex-shrink: 0;"></i>
|
||||||
|
|
||||||
|
<span class="argo-kind-badge">@node.Kind</span>
|
||||||
|
|
||||||
|
<span class="fw-semibold text-truncate" style="flex: 1 1 0; min-width: 0;"
|
||||||
|
title="@node.Name">@node.Name</span>
|
||||||
|
|
||||||
|
@if (!string.IsNullOrEmpty(node.StatusMessage))
|
||||||
|
{
|
||||||
|
<span class="text-muted small text-nowrap">@node.StatusMessage</span>
|
||||||
|
}
|
||||||
|
|
||||||
|
<span class="small text-nowrap" style="color: @hColor; flex-shrink: 0;">
|
||||||
|
● @HealthLabel(node.HealthStatus)
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<div class="d-flex gap-1 flex-shrink-0">
|
||||||
|
@if (node.Kind == "Pod" && CanLogs)
|
||||||
|
{
|
||||||
|
<button class="btn btn-sm py-0 px-1 @(IsPanelOpen(node.Id, "logs") ? "btn-dark" : "btn-outline-secondary")"
|
||||||
|
title="Logs" @onclick="() => OpenLogPanel(node)">
|
||||||
|
<i class="bi bi-terminal" style="font-size: .72rem;"></i>
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
@if (CanEvents)
|
||||||
|
{
|
||||||
|
<button class="btn btn-sm py-0 px-1 @(IsPanelOpen(node.Id, "events") ? "btn-secondary" : "btn-outline-secondary")"
|
||||||
|
title="Events" @onclick="() => OpenEventsPanel(node)">
|
||||||
|
<i class="bi bi-list-ul" style="font-size: .72rem;"></i>
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
@if (node.Kind is "Deployment" or "StatefulSet" or "DaemonSet" && CanRestart)
|
||||||
|
{
|
||||||
|
<button class="btn btn-sm py-0 px-1 @(IsPanelOpen(node.Id, "restart-confirm") ? "btn-warning" : "btn-outline-warning")"
|
||||||
|
title="Restart" @onclick="() => OpenRestartConfirm(node)">
|
||||||
|
<i class="bi bi-arrow-repeat" style="font-size: .72rem;"></i>
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
@if (node.Kind is "Deployment" or "StatefulSet" or "ReplicaSet" && CanScale)
|
||||||
|
{
|
||||||
|
<button class="btn btn-sm py-0 px-1 @(IsPanelOpen(node.Id, "scale") ? "btn-primary" : "btn-outline-primary")"
|
||||||
|
title="Scale" @onclick="() => OpenScalePanel(node)">
|
||||||
|
<i class="bi bi-sliders" style="font-size: .72rem;"></i>
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
@if (node.Kind == "Pod" && CanDelete)
|
||||||
|
{
|
||||||
|
<button class="btn btn-sm py-0 px-1 @(IsPanelOpen(node.Id, "delete-confirm") ? "btn-danger" : "btn-outline-danger")"
|
||||||
|
title="Delete pod" @onclick="() => OpenDeleteConfirm(node)">
|
||||||
|
<i class="bi bi-trash" style="font-size: .72rem;"></i>
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
@if (node.Kind == "Job" && CanScale)
|
||||||
|
{
|
||||||
|
<button class="btn btn-sm py-0 px-1 @(IsPanelOpen(node.Id, "delete-confirm") ? "btn-danger" : "btn-outline-danger")"
|
||||||
|
title="Delete job" @onclick="() => OpenDeleteConfirm(node)">
|
||||||
|
<i class="bi bi-trash" style="font-size: .72rem;"></i>
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@* ── Action panel (between card and children) ─────────────────────── *@
|
||||||
|
@if (panelOpen && _panelMode is not null)
|
||||||
|
{
|
||||||
|
@RenderActionPanel(node)
|
||||||
|
}
|
||||||
|
|
||||||
|
@* ── Children ─────────────────────────────────────────────────────── *@
|
||||||
|
@if (hasKids)
|
||||||
|
{
|
||||||
|
<div class="argo-children">
|
||||||
|
@foreach (DeploymentResource child in node.ChildResources!)
|
||||||
|
{
|
||||||
|
@RenderNode(child)
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Separate method so the heavy panel HTML is not inside the recursive lambda.
|
||||||
|
private RenderFragment RenderActionPanel(DeploymentResource node) => __builder =>
|
||||||
|
{
|
||||||
|
<div class="argo-action-panel">
|
||||||
|
<div class="argo-action-toolbar">
|
||||||
|
<span class="text-white small fw-medium">
|
||||||
|
@if (_panelMode == "logs")
|
||||||
|
{
|
||||||
|
<i class="bi bi-terminal me-1"></i>@:Logs — @node.Name
|
||||||
|
}
|
||||||
|
else if (_panelMode == "events")
|
||||||
|
{
|
||||||
|
<i class="bi bi-list-ul me-1"></i>@:Events — @node.Name
|
||||||
|
}
|
||||||
|
else if (_panelMode == "scale")
|
||||||
|
{
|
||||||
|
<i class="bi bi-sliders me-1"></i>@:Scale — @node.Name
|
||||||
|
}
|
||||||
|
else if (_panelMode == "restart-confirm")
|
||||||
|
{
|
||||||
|
<i class="bi bi-arrow-repeat me-1"></i>@:Restart — @node.Name
|
||||||
|
}
|
||||||
|
else if (_panelMode == "delete-confirm")
|
||||||
|
{
|
||||||
|
<i class="bi bi-trash me-1"></i>@:Delete — @node.Name
|
||||||
|
}
|
||||||
|
</span>
|
||||||
|
<button class="btn btn-sm btn-link text-white py-0 px-1" @onclick="ClosePanel">
|
||||||
|
<i class="bi bi-x"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (_panelMode == "logs")
|
||||||
|
{
|
||||||
|
@if (_containers.Count > 1)
|
||||||
|
{
|
||||||
|
<div class="px-2 py-1 d-flex gap-2 align-items-center"
|
||||||
|
style="background:#2a2a3e;border-bottom:1px solid #333;">
|
||||||
|
<span class="text-muted small">Container:</span>
|
||||||
|
@foreach (string c in _containers)
|
||||||
|
{
|
||||||
|
string containerName = c;
|
||||||
|
<button class="btn btn-sm py-0 px-2 @(_selectedContainer == containerName ? "btn-primary" : "btn-outline-secondary")"
|
||||||
|
style="font-size:.7rem;"
|
||||||
|
@onclick="() => SwitchContainer(node.Name, containerName)">@containerName</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
@if (_panelLoading)
|
||||||
|
{
|
||||||
|
<div class="text-center py-3">
|
||||||
|
<div class="spinner-border spinner-border-sm text-light"></div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
else if (_panelError is not null)
|
||||||
|
{
|
||||||
|
<div class="alert alert-warning m-2 py-1 small">@_panelError</div>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<pre class="m-0 p-2 small text-light"
|
||||||
|
style="max-height:380px;overflow-y:auto;white-space:pre-wrap;word-break:break-all;font-size:.73rem;background:transparent;">@_panelText</pre>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (_panelMode == "events")
|
||||||
|
{
|
||||||
|
@if (_panelLoading)
|
||||||
|
{
|
||||||
|
<div class="text-center py-3">
|
||||||
|
<div class="spinner-border spinner-border-sm text-light"></div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
else if (_panelError is not null)
|
||||||
|
{
|
||||||
|
<div class="alert alert-warning m-2 py-1 small">@_panelError</div>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<pre class="m-0 p-2 small text-light"
|
||||||
|
style="max-height:260px;overflow-y:auto;white-space:pre-wrap;word-break:break-all;font-size:.73rem;background:transparent;">@_panelText</pre>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (_panelMode == "scale")
|
||||||
|
{
|
||||||
|
<div class="p-3 d-flex align-items-center gap-2 flex-wrap">
|
||||||
|
<span class="text-light small">Replicas for <strong>@node.Name</strong></span>
|
||||||
|
<input type="number" class="form-control form-control-sm"
|
||||||
|
style="width:70px;background:#2a2a3e;border-color:#555;color:white;"
|
||||||
|
min="0" max="99" @bind="_scaleValue" />
|
||||||
|
<button class="btn btn-sm btn-primary" disabled="@_opBusy"
|
||||||
|
@onclick="() => ApplyScale(node)">
|
||||||
|
@if (_opBusy) { <span class="spinner-border spinner-border-sm me-1"></span> }
|
||||||
|
Apply
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-sm btn-outline-secondary" @onclick="ClosePanel">Cancel</button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (_panelMode == "restart-confirm")
|
||||||
|
{
|
||||||
|
<div class="p-3 d-flex align-items-center gap-2 flex-wrap">
|
||||||
|
<span class="text-light small">Rolling restart <strong>@node.Name</strong>?</span>
|
||||||
|
<button class="btn btn-sm btn-warning" disabled="@_opBusy"
|
||||||
|
@onclick="() => ConfirmRestart(node)">
|
||||||
|
@if (_opBusy) { <span class="spinner-border spinner-border-sm me-1"></span> }
|
||||||
|
<i class="bi bi-arrow-repeat me-1"></i>Restart
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-sm btn-outline-secondary" @onclick="ClosePanel">Cancel</button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (_panelMode == "delete-confirm")
|
||||||
|
{
|
||||||
|
<div class="p-3 d-flex align-items-center gap-2 flex-wrap">
|
||||||
|
<span class="text-light small">
|
||||||
|
Delete <strong>@node.Kind @node.Name</strong>?
|
||||||
|
@if (node.Kind == "Pod")
|
||||||
|
{
|
||||||
|
<span class="text-muted"> K8s will restart it automatically.</span>
|
||||||
|
}
|
||||||
|
</span>
|
||||||
|
<button class="btn btn-sm btn-danger" disabled="@_opBusy"
|
||||||
|
@onclick="() => ConfirmDelete(node)">
|
||||||
|
@if (_opBusy) { <span class="spinner-border spinner-border-sm me-1"></span> }
|
||||||
|
<i class="bi bi-trash me-1"></i>Delete
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-sm btn-outline-secondary" @onclick="ClosePanel">Cancel</button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
};
|
||||||
|
|
||||||
|
// Non-async wrapper to avoid async lambdas inside RenderFragment.
|
||||||
|
private void SwitchContainer(string podName, string containerName)
|
||||||
|
{
|
||||||
|
_selectedContainer = containerName;
|
||||||
|
_ = FetchLogs(podName);
|
||||||
|
}
|
||||||
|
}
|
||||||
325
src/EntKube.Web/Components/Pages/Tenants/AlertPanel.razor
Normal file
325
src/EntKube.Web/Components/Pages/Tenants/AlertPanel.razor
Normal file
@@ -0,0 +1,325 @@
|
|||||||
|
@using EntKube.Web.Services
|
||||||
|
|
||||||
|
@* ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
AlertPanel — shows active alerts from Alertmanager, allows creating silences,
|
||||||
|
and displays existing silences. The central tool for operations teams to
|
||||||
|
monitor and manage alert noise.
|
||||||
|
═══════════════════════════════════════════════════════════════════════════ *@
|
||||||
|
|
||||||
|
<div class="card border-0 shadow-sm">
|
||||||
|
<div class="card-header bg-white d-flex justify-content-between align-items-center">
|
||||||
|
<span class="fw-medium">
|
||||||
|
<i class="bi bi-bell me-2 text-warning"></i>Alerts
|
||||||
|
@if (alerts is not null && alerts.Count > 0)
|
||||||
|
{
|
||||||
|
<span class="badge bg-danger ms-1">@alerts.Count</span>
|
||||||
|
}
|
||||||
|
</span>
|
||||||
|
<div class="d-flex gap-1">
|
||||||
|
<button class="btn btn-sm @(view == "alerts" ? "btn-primary" : "btn-outline-primary")"
|
||||||
|
@onclick='() => view = "alerts"'>
|
||||||
|
<i class="bi bi-bell me-1"></i>Active
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-sm @(view == "silences" ? "btn-primary" : "btn-outline-primary")"
|
||||||
|
@onclick="LoadSilences">
|
||||||
|
<i class="bi bi-bell-slash me-1"></i>Silences
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-sm btn-outline-secondary" @onclick="RefreshAlerts" disabled="@loading">
|
||||||
|
<i class="bi bi-arrow-clockwise"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-body p-0">
|
||||||
|
@if (loading)
|
||||||
|
{
|
||||||
|
<div class="text-center py-3">
|
||||||
|
<div class="spinner-border spinner-border-sm text-primary"></div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
else if (!string.IsNullOrEmpty(errorMessage))
|
||||||
|
{
|
||||||
|
<div class="alert alert-warning m-3 mb-0">
|
||||||
|
<i class="bi bi-exclamation-triangle me-1"></i>@errorMessage
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
else if (view == "alerts")
|
||||||
|
{
|
||||||
|
@* ── Active Alerts ── *@
|
||||||
|
@if (alerts is null || alerts.Count == 0)
|
||||||
|
{
|
||||||
|
<div class="text-center py-4">
|
||||||
|
<i class="bi bi-check-circle text-success" style="font-size: 2rem;"></i>
|
||||||
|
<p class="text-muted mt-2 mb-0">No active alerts. All clear!</p>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<div class="list-group list-group-flush">
|
||||||
|
@foreach (AlertInfo alert in alerts.OrderByDescending(a => a.Severity == "critical")
|
||||||
|
.ThenByDescending(a => a.Severity == "warning"))
|
||||||
|
{
|
||||||
|
<div class="list-group-item px-3 py-2">
|
||||||
|
<div class="d-flex align-items-start justify-content-between">
|
||||||
|
<div class="flex-grow-1">
|
||||||
|
<div class="d-flex align-items-center gap-2 mb-1">
|
||||||
|
<span class="badge @GetSeverityBadgeClass(alert.Severity)">@alert.Severity</span>
|
||||||
|
<strong class="small">@alert.Name</strong>
|
||||||
|
<span class="badge bg-light text-dark border">@alert.State</span>
|
||||||
|
</div>
|
||||||
|
@if (!string.IsNullOrEmpty(alert.Summary))
|
||||||
|
{
|
||||||
|
<p class="mb-1 small text-muted">@alert.Summary</p>
|
||||||
|
}
|
||||||
|
<div class="d-flex flex-wrap gap-1">
|
||||||
|
@foreach (KeyValuePair<string, string> label in alert.Labels
|
||||||
|
.Where(l => l.Key != "alertname" && l.Key != "severity"))
|
||||||
|
{
|
||||||
|
<span class="badge bg-light text-dark border-0 small">
|
||||||
|
<code>@label.Key</code>=@label.Value
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<small class="text-muted">
|
||||||
|
<i class="bi bi-clock me-1"></i>Started @FormatTimeAgo(alert.StartsAt)
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-sm btn-outline-warning" title="Silence this alert"
|
||||||
|
@onclick="() => StartSilence(alert)">
|
||||||
|
<i class="bi bi-bell-slash"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (view == "silences")
|
||||||
|
{
|
||||||
|
@* ── Silences ── *@
|
||||||
|
@if (silences is null || silences.Count == 0)
|
||||||
|
{
|
||||||
|
<div class="text-center py-4">
|
||||||
|
<i class="bi bi-bell-slash text-muted" style="font-size: 2rem;"></i>
|
||||||
|
<p class="text-muted mt-2 mb-0">No silences configured.</p>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<div class="list-group list-group-flush">
|
||||||
|
@foreach (SilenceInfo silence in silences.Where(s => s.State == "active"))
|
||||||
|
{
|
||||||
|
<div class="list-group-item px-3 py-2">
|
||||||
|
<div class="d-flex align-items-start justify-content-between">
|
||||||
|
<div>
|
||||||
|
<div class="d-flex align-items-center gap-2 mb-1">
|
||||||
|
<span class="badge bg-warning text-dark">active</span>
|
||||||
|
<small class="fw-medium">@silence.Comment</small>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex flex-wrap gap-1 mb-1">
|
||||||
|
@foreach (SilenceMatcher matcher in silence.Matchers)
|
||||||
|
{
|
||||||
|
<span class="badge bg-light text-dark border small">
|
||||||
|
@matcher.Name @(matcher.IsEqual ? "=" : "!=") @(matcher.IsRegex ? "~" : "")@matcher.Value
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<small class="text-muted">
|
||||||
|
By @silence.CreatedBy · Expires @silence.EndsAt.ToString("g")
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@* ── Create Silence Form ── *@
|
||||||
|
@if (showCreateSilence)
|
||||||
|
{
|
||||||
|
<div class="border-top p-3">
|
||||||
|
<h6 class="small fw-bold mb-2"><i class="bi bi-bell-slash me-1"></i>Create Silence</h6>
|
||||||
|
<div class="row g-2 mb-2">
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label class="form-label small mb-0">Matcher (label=value)</label>
|
||||||
|
<input type="text" class="form-control form-control-sm" placeholder="alertname=HighCPU"
|
||||||
|
@bind="silenceMatcherInput" @bind:event="oninput" />
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<label class="form-label small mb-0">Duration</label>
|
||||||
|
<select class="form-select form-select-sm" @bind="silenceDurationHours">
|
||||||
|
<option value="1">1 hour</option>
|
||||||
|
<option value="2">2 hours</option>
|
||||||
|
<option value="4">4 hours</option>
|
||||||
|
<option value="8">8 hours</option>
|
||||||
|
<option value="24">24 hours</option>
|
||||||
|
<option value="72">3 days</option>
|
||||||
|
<option value="168">7 days</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-5">
|
||||||
|
<label class="form-label small mb-0">Comment</label>
|
||||||
|
<input type="text" class="form-control form-control-sm" placeholder="Reason for silence"
|
||||||
|
@bind="silenceComment" @bind:event="oninput" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex gap-2">
|
||||||
|
<button class="btn btn-sm btn-warning" @onclick="SubmitSilence"
|
||||||
|
disabled="@(string.IsNullOrWhiteSpace(silenceMatcherInput) || string.IsNullOrWhiteSpace(silenceComment))">
|
||||||
|
<i class="bi bi-bell-slash me-1"></i>Create Silence
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-sm btn-outline-secondary" @onclick="CancelSilence">Cancel</button>
|
||||||
|
</div>
|
||||||
|
@if (!string.IsNullOrEmpty(silenceError))
|
||||||
|
{
|
||||||
|
<div class="text-danger small mt-1">@silenceError</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
[Parameter] public Guid ClusterId { get; set; }
|
||||||
|
[Parameter] public PrometheusService PrometheusService { get; set; } = null!;
|
||||||
|
|
||||||
|
private List<AlertInfo>? alerts;
|
||||||
|
private List<SilenceInfo>? silences;
|
||||||
|
private string view = "alerts";
|
||||||
|
private bool loading;
|
||||||
|
private string? errorMessage;
|
||||||
|
|
||||||
|
// Silence form
|
||||||
|
private bool showCreateSilence;
|
||||||
|
private string silenceMatcherInput = "";
|
||||||
|
private int silenceDurationHours = 2;
|
||||||
|
private string silenceComment = "";
|
||||||
|
private string? silenceError;
|
||||||
|
|
||||||
|
protected override async Task OnInitializedAsync()
|
||||||
|
{
|
||||||
|
await RefreshAlerts();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task RefreshAlerts()
|
||||||
|
{
|
||||||
|
loading = true;
|
||||||
|
errorMessage = null;
|
||||||
|
StateHasChanged();
|
||||||
|
|
||||||
|
KubernetesOperationResult<List<AlertInfo>> result =
|
||||||
|
await PrometheusService.GetAlertsAsync(ClusterId);
|
||||||
|
|
||||||
|
if (result.IsSuccess)
|
||||||
|
{
|
||||||
|
alerts = result.Data;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
errorMessage = result.Error;
|
||||||
|
}
|
||||||
|
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task LoadSilences()
|
||||||
|
{
|
||||||
|
view = "silences";
|
||||||
|
loading = true;
|
||||||
|
errorMessage = null;
|
||||||
|
StateHasChanged();
|
||||||
|
|
||||||
|
KubernetesOperationResult<List<SilenceInfo>> result =
|
||||||
|
await PrometheusService.GetSilencesAsync(ClusterId);
|
||||||
|
|
||||||
|
if (result.IsSuccess)
|
||||||
|
{
|
||||||
|
silences = result.Data;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
errorMessage = result.Error;
|
||||||
|
}
|
||||||
|
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void StartSilence(AlertInfo alert)
|
||||||
|
{
|
||||||
|
showCreateSilence = true;
|
||||||
|
silenceMatcherInput = $"alertname={alert.Name}";
|
||||||
|
silenceComment = "";
|
||||||
|
silenceError = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void CancelSilence()
|
||||||
|
{
|
||||||
|
showCreateSilence = false;
|
||||||
|
silenceMatcherInput = "";
|
||||||
|
silenceComment = "";
|
||||||
|
silenceError = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task SubmitSilence()
|
||||||
|
{
|
||||||
|
silenceError = null;
|
||||||
|
|
||||||
|
// Parse matcher input "label=value" into a SilenceMatcher.
|
||||||
|
|
||||||
|
string[] parts = silenceMatcherInput.Split('=', 2);
|
||||||
|
if (parts.Length != 2 || string.IsNullOrWhiteSpace(parts[0]))
|
||||||
|
{
|
||||||
|
silenceError = "Matcher format: label=value";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
List<SilenceMatcher> matchers =
|
||||||
|
[
|
||||||
|
new SilenceMatcher { Name = parts[0].Trim(), Value = parts[1].Trim(), IsEqual = true }
|
||||||
|
];
|
||||||
|
|
||||||
|
KubernetesOperationResult result = await PrometheusService.CreateSilenceAsync(
|
||||||
|
ClusterId,
|
||||||
|
silenceComment,
|
||||||
|
"entkube-user",
|
||||||
|
TimeSpan.FromHours(silenceDurationHours),
|
||||||
|
matchers);
|
||||||
|
|
||||||
|
if (result.IsSuccess)
|
||||||
|
{
|
||||||
|
showCreateSilence = false;
|
||||||
|
silenceMatcherInput = "";
|
||||||
|
silenceComment = "";
|
||||||
|
await LoadSilences();
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
silenceError = result.Error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string GetSeverityBadgeClass(string severity) => severity switch
|
||||||
|
{
|
||||||
|
"critical" => "bg-danger",
|
||||||
|
"warning" => "bg-warning text-dark",
|
||||||
|
"info" => "bg-info text-dark",
|
||||||
|
_ => "bg-secondary"
|
||||||
|
};
|
||||||
|
|
||||||
|
private static string FormatTimeAgo(DateTime time)
|
||||||
|
{
|
||||||
|
if (time == DateTime.MinValue)
|
||||||
|
{
|
||||||
|
return "unknown";
|
||||||
|
}
|
||||||
|
|
||||||
|
TimeSpan ago = DateTime.UtcNow - time;
|
||||||
|
return ago switch
|
||||||
|
{
|
||||||
|
{ TotalMinutes: < 1 } => "just now",
|
||||||
|
{ TotalMinutes: < 60 } => $"{(int)ago.TotalMinutes}m ago",
|
||||||
|
{ TotalHours: < 24 } => $"{(int)ago.TotalHours}h ago",
|
||||||
|
_ => $"{(int)ago.TotalDays}d ago"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
195
src/EntKube.Web/Components/Pages/Tenants/AlertRulesPanel.razor
Normal file
195
src/EntKube.Web/Components/Pages/Tenants/AlertRulesPanel.razor
Normal file
@@ -0,0 +1,195 @@
|
|||||||
|
@using EntKube.Web.Data
|
||||||
|
@using EntKube.Web.Services
|
||||||
|
@inject PrometheusService PrometheusService
|
||||||
|
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||||
|
<div class="d-flex gap-2 align-items-center flex-wrap">
|
||||||
|
<select class="form-select form-select-sm" style="width:auto" @bind="filterState" @bind:after="ApplyFilter">
|
||||||
|
<option value="">All states</option>
|
||||||
|
<option value="firing">Firing</option>
|
||||||
|
<option value="pending">Pending</option>
|
||||||
|
<option value="inactive">Inactive</option>
|
||||||
|
</select>
|
||||||
|
<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>
|
||||||
|
<option value="none">None</option>
|
||||||
|
</select>
|
||||||
|
<input class="form-control form-control-sm" style="width:200px" placeholder="Search rules…"
|
||||||
|
value="@filterSearch" @oninput="OnSearchInput" />
|
||||||
|
</div>
|
||||||
|
<div class="d-flex gap-2 align-items-center">
|
||||||
|
<span class="text-muted small">@filtered.Count of @rules.Count rules</span>
|
||||||
|
<button class="btn btn-sm btn-outline-secondary" @onclick="Load" disabled="@isLoading">
|
||||||
|
<span class="bi bi-arrow-clockwise @(isLoading ? "spin" : "")"></span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (isLoading)
|
||||||
|
{
|
||||||
|
<div class="text-center py-4 text-muted">
|
||||||
|
<span class="bi bi-hourglass-split me-1"></span> Loading rules…
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
else if (errorMessage is not null)
|
||||||
|
{
|
||||||
|
<div class="alert alert-warning small py-2">
|
||||||
|
<span class="bi bi-exclamation-triangle me-1"></span>@errorMessage
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
else if (filtered.Count == 0)
|
||||||
|
{
|
||||||
|
<div class="text-center py-4 text-muted">No rules match the filter.</div>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
@* Summary counts *@
|
||||||
|
<div class="d-flex gap-3 mb-3 flex-wrap">
|
||||||
|
@{
|
||||||
|
int firingCount = rules.Count(r => r.State == "firing");
|
||||||
|
int pendingCount = rules.Count(r => r.State == "pending");
|
||||||
|
}
|
||||||
|
@if (firingCount > 0)
|
||||||
|
{
|
||||||
|
<span class="badge bg-danger px-2 py-1">@firingCount firing</span>
|
||||||
|
}
|
||||||
|
@if (pendingCount > 0)
|
||||||
|
{
|
||||||
|
<span class="badge bg-warning text-dark px-2 py-1">@pendingCount pending</span>
|
||||||
|
}
|
||||||
|
<span class="badge bg-success px-2 py-1">@rules.Count(r => r.State == "inactive") inactive</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@foreach (IGrouping<string, AlertRule> group in filtered.GroupBy(r => r.GroupName).OrderBy(g => g.Key))
|
||||||
|
{
|
||||||
|
<div class="mb-3">
|
||||||
|
<div class="text-muted small fw-semibold mb-1 text-uppercase" style="letter-spacing:.05em">
|
||||||
|
<span class="bi bi-collection me-1"></span>@group.Key
|
||||||
|
</div>
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-sm align-middle mb-0">
|
||||||
|
<tbody>
|
||||||
|
@foreach (AlertRule rule in group.OrderBy(r => r.Name))
|
||||||
|
{
|
||||||
|
<tr>
|
||||||
|
<td style="width:90px">
|
||||||
|
@StateIndicator(rule.State)
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div class="fw-semibold small">@rule.Name</div>
|
||||||
|
@if (!string.IsNullOrEmpty(rule.Summary))
|
||||||
|
{
|
||||||
|
<div class="text-muted" style="font-size:0.77rem">@rule.Summary</div>
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
<td style="width:80px">
|
||||||
|
@if (!string.IsNullOrEmpty(rule.Severity))
|
||||||
|
{
|
||||||
|
@SeverityBadge(rule.Severity)
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
<td style="width:90px" class="text-muted small text-end">
|
||||||
|
@if (rule.DurationSeconds > 0)
|
||||||
|
{
|
||||||
|
<span title="Fires after this duration">@FormatDuration(rule.DurationSeconds)</span>
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
<td style="width:36px" class="text-end">
|
||||||
|
@if (!string.IsNullOrEmpty(rule.RunbookUrl))
|
||||||
|
{
|
||||||
|
<a href="@rule.RunbookUrl" target="_blank" class="btn btn-sm btn-link p-0" title="Runbook">
|
||||||
|
<span class="bi bi-book"></span>
|
||||||
|
</a>
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
@if (expandedRule == rule.Name + rule.GroupName)
|
||||||
|
{
|
||||||
|
<tr>
|
||||||
|
<td colspan="5" class="bg-light py-2 px-3">
|
||||||
|
<div class="font-monospace small text-muted">@rule.Query</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@code {
|
||||||
|
[Parameter] public required Guid ClusterId { get; set; }
|
||||||
|
|
||||||
|
private List<AlertRule> rules = [];
|
||||||
|
private List<AlertRule> filtered = [];
|
||||||
|
private string filterState = "";
|
||||||
|
private string filterSeverity = "";
|
||||||
|
private string filterSearch = "";
|
||||||
|
private string? expandedRule;
|
||||||
|
private bool isLoading = true;
|
||||||
|
private string? errorMessage;
|
||||||
|
|
||||||
|
protected override async Task OnParametersSetAsync() => await Load();
|
||||||
|
|
||||||
|
private async Task Load()
|
||||||
|
{
|
||||||
|
isLoading = true;
|
||||||
|
errorMessage = null;
|
||||||
|
StateHasChanged();
|
||||||
|
|
||||||
|
KubernetesOperationResult<List<AlertRule>> result = await PrometheusService.GetAlertRulesAsync(ClusterId);
|
||||||
|
if (result.IsSuccess)
|
||||||
|
{
|
||||||
|
rules = result.Data ?? [];
|
||||||
|
ApplyFilter();
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
errorMessage = result.Error;
|
||||||
|
rules = [];
|
||||||
|
filtered = [];
|
||||||
|
}
|
||||||
|
isLoading = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnSearchInput(ChangeEventArgs e)
|
||||||
|
{
|
||||||
|
filterSearch = e.Value?.ToString() ?? string.Empty;
|
||||||
|
ApplyFilter();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ApplyFilter()
|
||||||
|
{
|
||||||
|
filtered = rules.Where(r =>
|
||||||
|
(string.IsNullOrEmpty(filterState) || r.State == filterState) &&
|
||||||
|
(string.IsNullOrEmpty(filterSeverity) || r.Severity == filterSeverity || (filterSeverity == "none" && string.IsNullOrEmpty(r.Severity))) &&
|
||||||
|
(string.IsNullOrEmpty(filterSearch) || r.Name.Contains(filterSearch, StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| r.Summary.Contains(filterSearch, StringComparison.OrdinalIgnoreCase))
|
||||||
|
).ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string FormatDuration(double seconds) =>
|
||||||
|
seconds >= 3600 ? $"{(int)(seconds / 3600)}h"
|
||||||
|
: seconds >= 60 ? $"{(int)(seconds / 60)}m"
|
||||||
|
: $"{(int)seconds}s";
|
||||||
|
|
||||||
|
private static RenderFragment StateIndicator(string state) => state switch
|
||||||
|
{
|
||||||
|
"firing" => @<span class="badge bg-danger"><span class="bi bi-fire me-1"></span>Firing</span>,
|
||||||
|
"pending" => @<span class="badge bg-warning text-dark"><span class="bi bi-hourglass me-1"></span>Pending</span>,
|
||||||
|
_ => @<span class="badge bg-success-subtle text-success border"><span class="bi bi-check me-1"></span>OK</span>
|
||||||
|
};
|
||||||
|
|
||||||
|
private static RenderFragment SeverityBadge(string sev) => sev switch
|
||||||
|
{
|
||||||
|
"critical" => @<span class="badge bg-danger-subtle text-danger border">critical</span>,
|
||||||
|
"warning" => @<span class="badge bg-warning-subtle text-warning border">warning</span>,
|
||||||
|
"info" => @<span class="badge bg-info-subtle text-info border">info</span>,
|
||||||
|
_ => @<span class="badge bg-secondary-subtle text-secondary border">@sev</span>
|
||||||
|
};
|
||||||
|
}
|
||||||
1956
src/EntKube.Web/Components/Pages/Tenants/AppDetail.razor
Normal file
1956
src/EntKube.Web/Components/Pages/Tenants/AppDetail.razor
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,4 @@
|
|||||||
|
export function getElementValue(id) {
|
||||||
|
const el = document.getElementById(id);
|
||||||
|
return el ? el.value : '';
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user