mirror of
https://github.com/labring/sealos.git
synced 2026-09-01 04:58:47 +08:00
chore(frontend): remove kubepanel provider (#7105)
This commit is contained in:
@@ -21,7 +21,7 @@ sudo apt-get update && sudo apt-get install -y pkg-config libdevmapper-dev libbt
|
||||
### Repository Structure
|
||||
- **`/lifecycle`**: Main sealos CLI and core binaries (sealos, sealctl, lvscare, image-cri-shim)
|
||||
- **`/controllers`**: Multiple Kubernetes controllers with individual Go modules and Makefiles
|
||||
- **`/frontend`**: Next.js workspace with pnpm, 17 workspace projects (desktop, packages, providers)
|
||||
- **`/frontend`**: Next.js workspace with pnpm, 16 workspace projects (desktop, packages, providers)
|
||||
- **`/service`**: Backend Go services with individual modules
|
||||
- **`/webhooks`**: Kubernetes admission webhooks
|
||||
- **`/docs`**: Documentation and guides
|
||||
|
||||
@@ -96,8 +96,6 @@ jobs:
|
||||
- 'frontend/providers/costcenter/**'
|
||||
objectstorage:
|
||||
- 'frontend/providers/objectstorage/**'
|
||||
kubepanel:
|
||||
- 'frontend/providers/kubepanel/**'
|
||||
desktop:
|
||||
- 'frontend/desktop/**'
|
||||
- name: Generate build matrix
|
||||
@@ -118,7 +116,6 @@ jobs:
|
||||
"providers/dbprovider"
|
||||
"providers/costcenter"
|
||||
"providers/objectstorage"
|
||||
"providers/kubepanel"
|
||||
"desktop"
|
||||
)
|
||||
else
|
||||
@@ -130,7 +127,6 @@ jobs:
|
||||
[[ "${{ steps.filter.outputs.dbprovider }}" == "true" ]] && modules+=("providers/dbprovider")
|
||||
[[ "${{ steps.filter.outputs.costcenter }}" == "true" ]] && modules+=("providers/costcenter")
|
||||
[[ "${{ steps.filter.outputs.objectstorage }}" == "true" ]] && modules+=("providers/objectstorage")
|
||||
[[ "${{ steps.filter.outputs.kubepanel }}" == "true" ]] && modules+=("providers/kubepanel")
|
||||
[[ "${{ steps.filter.outputs.desktop }}" == "true" ]] && modules+=("desktop")
|
||||
fi
|
||||
|
||||
|
||||
@@ -22,7 +22,6 @@ buildTargets := \
|
||||
providers/imagehub \
|
||||
providers/template \
|
||||
providers/license \
|
||||
providers/kubepanel \
|
||||
providers/objectstorage
|
||||
|
||||
buildTargets-all := $(addprefix image-build-,$(buildTargets))
|
||||
|
||||
Generated
+31
-2485
File diff suppressed because it is too large
Load Diff
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"extends": ["next", "next/core-web-vitals"],
|
||||
"plugins": ["xss"],
|
||||
"rules": {
|
||||
"react-hooks/rules-of-hooks": 0,
|
||||
"xss/no-mixed-html": "error",
|
||||
"import/no-commonjs": 0
|
||||
}
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.js
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
/out/
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# local env files
|
||||
.env*.local
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
/public/trainData/
|
||||
/.vscode/
|
||||
platform.json
|
||||
.yalc/
|
||||
yalc.lock
|
||||
data/*.local
|
||||
@@ -1,4 +0,0 @@
|
||||
dist
|
||||
.vscode
|
||||
**/.DS_Store
|
||||
node_modules
|
||||
@@ -1,20 +0,0 @@
|
||||
module.exports = {
|
||||
printWidth: 100,
|
||||
tabWidth: 2,
|
||||
useTabs: false,
|
||||
semi: true,
|
||||
singleQuote: true,
|
||||
quoteProps: 'as-needed',
|
||||
jsxSingleQuote: false,
|
||||
trailingComma: 'none',
|
||||
bracketSpacing: true,
|
||||
jsxBracketSameLine: false,
|
||||
arrowParens: 'always',
|
||||
rangeStart: 0,
|
||||
rangeEnd: Infinity,
|
||||
requirePragma: false,
|
||||
insertPragma: false,
|
||||
proseWrap: 'preserve',
|
||||
htmlWhitespaceSensitivity: 'css',
|
||||
endOfLine: 'lf'
|
||||
};
|
||||
@@ -1,37 +0,0 @@
|
||||
## How to dev
|
||||
|
||||
1. First,you should refer to `frontend/README.md` ’s `How to dev` part.
|
||||
|
||||
2. Then you should config your env.
|
||||
|
||||
1. Create a new file `.env.local` in frontend/providers/kubepanel directory.
|
||||
|
||||
2. ```
|
||||
NEXT_PUBLIC_MOCK_USER=""
|
||||
SEALOS_DOMAIN="dev.sealos.top"
|
||||
NODE_ENV="development"
|
||||
```
|
||||
|
||||
3. Then we should get our `NEXT_PUBLIC_MOCK_USER`
|
||||
|
||||
1. go to `dev.sealos.top` and login (firstly you goin this website,you are signuping,so go on.)
|
||||
|
||||
2. Refer this picture,you should open `Console-application`,and get your own `session.state.session.kubeconfig`,copy as JSON string.
|
||||
|
||||

|
||||
|
||||
3. After that,have your own `test 3000 `page
|
||||
|
||||
> Why you should have that?
|
||||
>
|
||||
> If you open your own dev in `localhost:3000` directly,you cannot have sealos desktop border,which maybe influence your style.
|
||||
|
||||
1. This url:[website](https://cloud.sealos.run/?openapp=system-template%3FtemplateName%3Done-step-shortcuts)
|
||||
|
||||
2. 
|
||||
|
||||
3. Refresh website.
|
||||
|
||||
4. Then you can get your own dev in this.
|
||||
|
||||

|
||||
@@ -1,9 +0,0 @@
|
||||
FROM scratch
|
||||
|
||||
USER 65532:65532
|
||||
|
||||
COPY registry registry
|
||||
COPY charts charts
|
||||
COPY kubepanel-frontend-entrypoint.sh kubepanel-frontend-entrypoint.sh
|
||||
|
||||
CMD ["bash kubepanel-frontend-entrypoint.sh"]
|
||||
@@ -1,6 +0,0 @@
|
||||
apiVersion: v2
|
||||
name: kubepanel-frontend
|
||||
description: Sealos Kube Panel Frontend Helm Chart
|
||||
type: application
|
||||
version: 1.0.0
|
||||
appVersion: "latest"
|
||||
-14
@@ -1,14 +0,0 @@
|
||||
# Custom values for kubepanel-frontend helm chart.
|
||||
# This file contains user-customizable configurations.
|
||||
|
||||
replicaCount: 1
|
||||
|
||||
resources:
|
||||
limits:
|
||||
cpu: 1000m
|
||||
memory: 1024Mi
|
||||
requests:
|
||||
cpu: 10m
|
||||
memory: 128Mi
|
||||
|
||||
kubepanelConfig:
|
||||
@@ -1,84 +0,0 @@
|
||||
{{/* Expand the name of the chart. */}}
|
||||
{{- define "kubepanel-frontend.name" -}}
|
||||
{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }}
|
||||
{{- end }}
|
||||
|
||||
{{/* Create a default fully qualified app name. */}}
|
||||
{{- define "kubepanel-frontend.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 }}
|
||||
|
||||
{{- define "kubepanel-frontend.chart" -}}
|
||||
{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }}
|
||||
{{- end }}
|
||||
|
||||
{{- define "kubepanel-frontend.labels" -}}
|
||||
helm.sh/chart: {{ include "kubepanel-frontend.chart" . }}
|
||||
{{ include "kubepanel-frontend.selectorLabels" . }}
|
||||
{{ include "kubepanel-frontend.recommendedLabels" . }}
|
||||
{{- if .Chart.AppVersion }}
|
||||
app.kubernetes.io/version: {{ .Chart.AppVersion | quote }}
|
||||
{{- end }}
|
||||
app.kubernetes.io/managed-by: {{ .Release.Service }}
|
||||
{{- end }}
|
||||
|
||||
{{- define "kubepanel-frontend.selectorLabels" -}}
|
||||
app: {{ include "kubepanel-frontend.fullname" . }}
|
||||
{{- end }}
|
||||
|
||||
{{- define "kubepanel-frontend.recommendedLabels" -}}
|
||||
app.kubernetes.io/name: {{ include "kubepanel-frontend.name" . }}
|
||||
app.kubernetes.io/instance: {{ .Release.Name }}
|
||||
{{- end }}
|
||||
|
||||
{{- define "kubepanel-frontend.scheme" -}}
|
||||
{{- if eq (toString .Values.kubepanelConfig.disableHttps) "true" -}}http{{- else -}}https{{- end -}}
|
||||
{{- end }}
|
||||
|
||||
{{- define "kubepanel-frontend.port" -}}
|
||||
{{- $scheme := include "kubepanel-frontend.scheme" . -}}
|
||||
{{- $port := toString .Values.kubepanelConfig.cloudPort -}}
|
||||
{{- if eq $scheme "http" -}}
|
||||
{{- $port = toString .Values.kubepanelConfig.httpPort -}}
|
||||
{{- end -}}
|
||||
{{- if or (and (eq $scheme "https") (or (eq $port "") (eq $port "443"))) (and (eq $scheme "http") (or (eq $port "") (eq $port "80"))) -}}
|
||||
{{- "" -}}
|
||||
{{- else -}}
|
||||
{{- $port -}}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
|
||||
{{- define "kubepanel-frontend.portSuffix" -}}
|
||||
{{- $port := include "kubepanel-frontend.port" . -}}
|
||||
{{- if $port -}}:{{ $port }}{{- end -}}
|
||||
{{- end }}
|
||||
|
||||
{{- define "kubepanel-frontend.portEnv" -}}
|
||||
{{- $port := include "kubepanel-frontend.port" . -}}
|
||||
{{- if $port -}}:{{ $port }}{{- end -}}
|
||||
{{- end }}
|
||||
|
||||
{{- define "kubepanel-frontend.cloudOrigin" -}}
|
||||
{{- include "kubepanel-frontend.scheme" . -}}://{{ .Values.kubepanelConfig.cloudDomain }}{{ include "kubepanel-frontend.portSuffix" . }}
|
||||
{{- end }}
|
||||
|
||||
{{- define "kubepanel-frontend.wildcardCloudOrigin" -}}
|
||||
{{- include "kubepanel-frontend.scheme" . -}}://*.{{ .Values.kubepanelConfig.cloudDomain }}{{ include "kubepanel-frontend.portSuffix" . }}
|
||||
{{- end }}
|
||||
|
||||
{{- define "kubepanel-frontend.host" -}}
|
||||
{{- default (printf "kubepanel.%s" .Values.kubepanelConfig.cloudDomain) .Values.ingress.host -}}
|
||||
{{- end }}
|
||||
|
||||
{{- define "kubepanel-frontend.appUrl" -}}
|
||||
{{- include "kubepanel-frontend.scheme" . -}}://{{ include "kubepanel-frontend.host" . }}{{ include "kubepanel-frontend.portSuffix" . }}
|
||||
{{- end }}
|
||||
@@ -1,19 +0,0 @@
|
||||
apiVersion: app.sealos.io/v1
|
||||
kind: App
|
||||
metadata:
|
||||
name: kubepanel
|
||||
namespace: app-system
|
||||
spec:
|
||||
data:
|
||||
desc: Kube Panel
|
||||
url: {{ include "kubepanel-frontend.appUrl" . | quote }}
|
||||
icon: {{ printf "%s/logo.svg" (include "kubepanel-frontend.appUrl" .) | quote }}
|
||||
i18n:
|
||||
zh:
|
||||
name: 资源监控
|
||||
zh-Hans:
|
||||
name: 资源监控
|
||||
menuData:
|
||||
name: Kube Panel
|
||||
type: iframe
|
||||
displayType: normal
|
||||
-10
@@ -1,10 +0,0 @@
|
||||
apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: {{ include "kubepanel-frontend.fullname" . }}-config
|
||||
namespace: {{ .Release.Namespace }}
|
||||
labels:
|
||||
{{- include "kubepanel-frontend.labels" . | nindent 4 }}
|
||||
data:
|
||||
config.yaml: |-
|
||||
addr: :3000
|
||||
-63
@@ -1,63 +0,0 @@
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: {{ include "kubepanel-frontend.fullname" . }}
|
||||
namespace: {{ .Release.Namespace }}
|
||||
labels:
|
||||
{{- include "kubepanel-frontend.labels" . | nindent 4 }}
|
||||
spec:
|
||||
replicas: {{ .Values.replicaCount }}
|
||||
selector:
|
||||
matchLabels:
|
||||
{{- include "kubepanel-frontend.selectorLabels" . | nindent 6 }}
|
||||
strategy:
|
||||
type: RollingUpdate
|
||||
rollingUpdate:
|
||||
maxUnavailable: 25%
|
||||
maxSurge: 25%
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
{{- include "kubepanel-frontend.labels" . | nindent 8 }}
|
||||
{{- with .Values.podAnnotations }}
|
||||
annotations:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
spec:
|
||||
{{- with .Values.imagePullSecrets }}
|
||||
imagePullSecrets:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
securityContext:
|
||||
{{- toYaml .Values.podSecurityContext | nindent 8 }}
|
||||
containers:
|
||||
- name: {{ .Chart.Name }}
|
||||
image: {{ .Values.image | quote }}
|
||||
imagePullPolicy: {{ .Values.imagePullPolicy }}
|
||||
{{- if .Values.securityContext }}
|
||||
securityContext:
|
||||
{{- toYaml .Values.securityContext | nindent 12 }}
|
||||
{{- end }}
|
||||
resources:
|
||||
{{- toYaml .Values.resources | nindent 12 }}
|
||||
env:
|
||||
- name: SEALOS_DOMAIN
|
||||
value: {{ .Values.kubepanelConfig.cloudDomain | quote }}
|
||||
- name: SEALOS_PORT
|
||||
value: {{ include "kubepanel-frontend.portEnv" . | quote }}
|
||||
ports:
|
||||
- name: http
|
||||
containerPort: {{ .Values.service.port }}
|
||||
protocol: TCP
|
||||
volumeMounts:
|
||||
- name: kubepanel-frontend-volume
|
||||
mountPath: /config.yaml
|
||||
subPath: config.yaml
|
||||
{{- with .Values.affinity }}
|
||||
affinity:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
volumes:
|
||||
- name: kubepanel-frontend-volume
|
||||
configMap:
|
||||
name: {{ include "kubepanel-frontend.fullname" . }}-config
|
||||
@@ -1,43 +0,0 @@
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: {{ include "kubepanel-frontend.fullname" . }}
|
||||
namespace: {{ .Release.Namespace }}
|
||||
annotations:
|
||||
kubernetes.io/ingress.class: {{ .Values.ingress.className | quote }}
|
||||
nginx.ingress.kubernetes.io/enable-cors: "true"
|
||||
nginx.ingress.kubernetes.io/cors-allow-methods: "PUT, GET, POST, DELETE, PATCH, OPTIONS"
|
||||
nginx.ingress.kubernetes.io/cors-allow-origin: "{{ include "kubepanel-frontend.cloudOrigin" . }}, {{ include "kubepanel-frontend.wildcardCloudOrigin" . }}"
|
||||
nginx.ingress.kubernetes.io/cors-allow-credentials: "true"
|
||||
nginx.ingress.kubernetes.io/cors-max-age: "600"
|
||||
nginx.ingress.kubernetes.io/backend-protocol: "HTTP"
|
||||
nginx.ingress.kubernetes.io/configuration-snippet: |
|
||||
more_clear_headers "X-Frame-Options:";
|
||||
more_set_headers "Content-Security-Policy: default-src * blob: data: *.{{ .Values.kubepanelConfig.cloudDomain }}{{ include "kubepanel-frontend.portSuffix" . }} {{ .Values.kubepanelConfig.cloudDomain }}{{ include "kubepanel-frontend.portSuffix" . }}; img-src * data: blob: resource: *.{{ .Values.kubepanelConfig.cloudDomain }}{{ include "kubepanel-frontend.portSuffix" . }} {{ .Values.kubepanelConfig.cloudDomain }}{{ include "kubepanel-frontend.portSuffix" . }}; connect-src * wss: blob: resource:; style-src 'self' 'unsafe-inline' blob: *.{{ .Values.kubepanelConfig.cloudDomain }}{{ include "kubepanel-frontend.portSuffix" . }} {{ .Values.kubepanelConfig.cloudDomain }}{{ include "kubepanel-frontend.portSuffix" . }} resource:; script-src 'self' 'unsafe-inline' 'unsafe-eval' blob: *.{{ .Values.kubepanelConfig.cloudDomain }}{{ include "kubepanel-frontend.portSuffix" . }} {{ .Values.kubepanelConfig.cloudDomain }}{{ include "kubepanel-frontend.portSuffix" . }} resource: *.baidu.com *.bdstatic.com https://js.stripe.com; frame-src 'self' *.{{ .Values.kubepanelConfig.cloudDomain }}{{ include "kubepanel-frontend.portSuffix" . }} {{ .Values.kubepanelConfig.cloudDomain }}{{ include "kubepanel-frontend.portSuffix" . }} mailto: tel: weixin: mtt: *.baidu.com https://js.stripe.com; frame-ancestors 'self' {{ include "kubepanel-frontend.cloudOrigin" . }} {{ include "kubepanel-frontend.wildcardCloudOrigin" . }}";
|
||||
more_set_headers "X-Xss-Protection: 1; mode=block";
|
||||
higress.io/response-header-control-remove: X-Frame-Options
|
||||
higress.io/response-header-control-update: |
|
||||
Content-Security-Policy "default-src * blob: data: *.{{ .Values.kubepanelConfig.cloudDomain }}{{ include "kubepanel-frontend.portSuffix" . }} {{ .Values.kubepanelConfig.cloudDomain }}{{ include "kubepanel-frontend.portSuffix" . }}; img-src * data: blob: resource: *.{{ .Values.kubepanelConfig.cloudDomain }}{{ include "kubepanel-frontend.portSuffix" . }} {{ .Values.kubepanelConfig.cloudDomain }}{{ include "kubepanel-frontend.portSuffix" . }}; connect-src * wss: blob: resource:; style-src 'self' 'unsafe-inline' blob: *.{{ .Values.kubepanelConfig.cloudDomain }}{{ include "kubepanel-frontend.portSuffix" . }} {{ .Values.kubepanelConfig.cloudDomain }}{{ include "kubepanel-frontend.portSuffix" . }} resource:; script-src 'self' 'unsafe-inline' 'unsafe-eval' blob: *.{{ .Values.kubepanelConfig.cloudDomain }}{{ include "kubepanel-frontend.portSuffix" . }} {{ .Values.kubepanelConfig.cloudDomain }}{{ include "kubepanel-frontend.portSuffix" . }} resource: *.baidu.com *.bdstatic.com https://js.stripe.com; frame-src 'self' *.{{ .Values.kubepanelConfig.cloudDomain }}{{ include "kubepanel-frontend.portSuffix" . }} {{ .Values.kubepanelConfig.cloudDomain }}{{ include "kubepanel-frontend.portSuffix" . }} mailto: tel: weixin: mtt: *.baidu.com https://js.stripe.com; frame-ancestors 'self' {{ include "kubepanel-frontend.cloudOrigin" . }} {{ include "kubepanel-frontend.wildcardCloudOrigin" . }}"
|
||||
X-Xss-Protection "1; mode=block"
|
||||
{{- with .Values.ingress.annotations }}
|
||||
{{- toYaml . | nindent 4 }}
|
||||
{{- end }}
|
||||
spec:
|
||||
ingressClassName: {{ .Values.ingress.className | quote }}
|
||||
rules:
|
||||
- host: {{ include "kubepanel-frontend.host" . | quote }}
|
||||
http:
|
||||
paths:
|
||||
- pathType: Prefix
|
||||
path: /
|
||||
backend:
|
||||
service:
|
||||
name: {{ include "kubepanel-frontend.fullname" . }}
|
||||
port:
|
||||
number: {{ .Values.service.port }}
|
||||
{{- if not (eq (toString .Values.kubepanelConfig.disableHttps) "true") }}
|
||||
tls:
|
||||
- hosts:
|
||||
- {{ include "kubepanel-frontend.host" . | quote }}
|
||||
secretName: {{ .Values.kubepanelConfig.certSecretName | quote }}
|
||||
{{- end }}
|
||||
@@ -1,16 +0,0 @@
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
labels:
|
||||
{{- include "kubepanel-frontend.labels" . | nindent 4 }}
|
||||
name: {{ include "kubepanel-frontend.fullname" . }}
|
||||
namespace: {{ .Release.Namespace }}
|
||||
spec:
|
||||
type: {{ .Values.service.type }}
|
||||
ports:
|
||||
- name: http
|
||||
port: {{ .Values.service.port }}
|
||||
protocol: TCP
|
||||
targetPort: {{ .Values.service.port }}
|
||||
selector:
|
||||
{{- include "kubepanel-frontend.selectorLabels" . | nindent 4 }}
|
||||
@@ -1,61 +0,0 @@
|
||||
# Default values for kubepanel-frontend helm chart.
|
||||
|
||||
image: ghcr.io/labring/sealos-kubepanel-frontend:latest
|
||||
imagePullPolicy: Always
|
||||
|
||||
imagePullSecrets: []
|
||||
fullnameOverride: "kubepanel-frontend"
|
||||
|
||||
replicaCount: 1
|
||||
|
||||
podAnnotations: {}
|
||||
podLabels: {}
|
||||
|
||||
podSecurityContext:
|
||||
runAsNonRoot: true
|
||||
runAsUser: 1001
|
||||
|
||||
securityContext:
|
||||
runAsNonRoot: true
|
||||
runAsUser: 1001
|
||||
allowPrivilegeEscalation: false
|
||||
capabilities:
|
||||
drop:
|
||||
- "ALL"
|
||||
|
||||
service:
|
||||
type: ClusterIP
|
||||
port: 3000
|
||||
|
||||
ingress:
|
||||
className: nginx
|
||||
host: ""
|
||||
annotations: {}
|
||||
|
||||
kubepanelConfig:
|
||||
cloudDomain: "127.0.0.1.nip.io"
|
||||
cloudPort: ""
|
||||
httpPort: ""
|
||||
disableHttps: false
|
||||
certSecretName: "wildcard-cert"
|
||||
|
||||
resources:
|
||||
limits:
|
||||
cpu: 1000m
|
||||
memory: 1024Mi
|
||||
requests:
|
||||
cpu: 10m
|
||||
memory: 128Mi
|
||||
|
||||
affinity:
|
||||
podAntiAffinity:
|
||||
preferredDuringSchedulingIgnoredDuringExecution:
|
||||
- weight: 100
|
||||
podAffinityTerm:
|
||||
labelSelector:
|
||||
matchExpressions:
|
||||
- key: "app"
|
||||
operator: In
|
||||
values:
|
||||
- kubepanel-frontend
|
||||
topologyKey: "kubernetes.io/hostname"
|
||||
@@ -1,87 +0,0 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
RELEASE_NAME=${RELEASE_NAME:-"kubepanel-frontend"}
|
||||
RELEASE_NAMESPACE=${RELEASE_NAMESPACE:-"kubepanel-frontend"}
|
||||
CHART_PATH=${CHART_PATH:-"./charts/kubepanel-frontend"}
|
||||
HELM_OPTS=${HELM_OPTS:-""}
|
||||
HELM_OPTIONS=${HELM_OPTIONS:-""}
|
||||
AUTO_CONFIG_HELM_OPTS=""
|
||||
|
||||
get_cm_value() {
|
||||
local namespace="$1"
|
||||
local name="$2"
|
||||
local key="$3"
|
||||
kubectl get configmap "${name}" -n "${namespace}" -o "jsonpath={.data.${key}}" 2>/dev/null || true
|
||||
}
|
||||
|
||||
add_set_string() {
|
||||
local key="$1"
|
||||
local value="$2"
|
||||
if [ -n "${value}" ]; then
|
||||
AUTO_CONFIG_HELM_OPTS="${AUTO_CONFIG_HELM_OPTS} --set-string ${key}=${value}"
|
||||
fi
|
||||
}
|
||||
|
||||
SEALOS_CLOUD_DOMAIN=${SEALOS_CLOUD_DOMAIN:-"${cloudDomain:-$(get_cm_value sealos-system sealos-config cloudDomain)}"}
|
||||
add_set_string kubepanelConfig.cloudDomain "${SEALOS_CLOUD_DOMAIN}"
|
||||
SEALOS_CLOUD_PORT=${SEALOS_CLOUD_PORT:-"${cloudPort:-$(get_cm_value sealos-system sealos-config cloudPort)}"}
|
||||
add_set_string kubepanelConfig.cloudPort "${SEALOS_CLOUD_PORT}"
|
||||
SEALOS_HTTP_PORT=${SEALOS_HTTP_PORT:-"${httpPort:-$(get_cm_value sealos-system sealos-config httpPort)}"}
|
||||
add_set_string kubepanelConfig.httpPort "${SEALOS_HTTP_PORT}"
|
||||
SEALOS_DISABLE_HTTPS=${SEALOS_DISABLE_HTTPS:-"${disableHttps:-$(get_cm_value sealos-system sealos-config disableHttps)}"}
|
||||
add_set_string kubepanelConfig.disableHttps "${SEALOS_DISABLE_HTTPS}"
|
||||
SEALOS_CERT_SECRET_NAME=${SEALOS_CERT_SECRET_NAME:-"${certSecretName:-$(get_cm_value sealos-system sealos-config certSecretName)}"}
|
||||
add_set_string kubepanelConfig.certSecretName "${SEALOS_CERT_SECRET_NAME}"
|
||||
|
||||
|
||||
adopt_namespaced_resource() {
|
||||
local namespace="$1"
|
||||
local kind="$2"
|
||||
local name="$3"
|
||||
if kubectl -n "${namespace}" get "${kind}" "${name}" >/dev/null 2>&1; then
|
||||
echo "Adopting ${kind} ${namespace}/${name}..."
|
||||
kubectl -n "${namespace}" label "${kind}" "${name}" app.kubernetes.io/managed-by=Helm --overwrite >/dev/null 2>&1 || true
|
||||
kubectl -n "${namespace}" annotate "${kind}" "${name}" meta.helm.sh/release-name="${RELEASE_NAME}" meta.helm.sh/release-namespace="${RELEASE_NAMESPACE}" --overwrite >/dev/null 2>&1 || true
|
||||
fi
|
||||
}
|
||||
|
||||
adopt_cluster_resource() {
|
||||
local kind="$1"
|
||||
local name="$2"
|
||||
if kubectl get "${kind}" "${name}" >/dev/null 2>&1; then
|
||||
echo "Adopting ${kind} ${name}..."
|
||||
kubectl label "${kind}" "${name}" app.kubernetes.io/managed-by=Helm --overwrite >/dev/null 2>&1 || true
|
||||
kubectl annotate "${kind}" "${name}" meta.helm.sh/release-name="${RELEASE_NAME}" meta.helm.sh/release-namespace="${RELEASE_NAMESPACE}" --overwrite >/dev/null 2>&1 || true
|
||||
fi
|
||||
}
|
||||
|
||||
echo "Checking and adopting existing resources..."
|
||||
if kubectl get namespace "${RELEASE_NAMESPACE}" >/dev/null 2>&1; then
|
||||
kubectl label namespace "${RELEASE_NAMESPACE}" app.kubernetes.io/managed-by=Helm --overwrite >/dev/null 2>&1 || true
|
||||
kubectl annotate namespace "${RELEASE_NAMESPACE}" meta.helm.sh/release-name="${RELEASE_NAME}" meta.helm.sh/release-namespace="${RELEASE_NAMESPACE}" --overwrite >/dev/null 2>&1 || true
|
||||
|
||||
adopt_namespaced_resource "${RELEASE_NAMESPACE}" configmap kubepanel-frontend-config
|
||||
adopt_namespaced_resource "${RELEASE_NAMESPACE}" service kubepanel-frontend
|
||||
adopt_namespaced_resource "${RELEASE_NAMESPACE}" deployment kubepanel-frontend
|
||||
adopt_namespaced_resource "${RELEASE_NAMESPACE}" ingress kubepanel-frontend
|
||||
fi
|
||||
|
||||
adopt_namespaced_resource app-system apps.app.sealos.io kubepanel
|
||||
|
||||
|
||||
SERVICE_NAME="kubepanel-frontend"
|
||||
USER_VALUES_PATH="/root/.sealos/cloud/values/core/${SERVICE_NAME}-values.yaml"
|
||||
|
||||
if [ ! -f "${USER_VALUES_PATH}" ]; then
|
||||
mkdir -p "$(dirname "${USER_VALUES_PATH}")"
|
||||
cp "./charts/kubepanel-frontend/kubepanel-frontend-values.yaml" "${USER_VALUES_PATH}"
|
||||
fi
|
||||
|
||||
HELM_ARGS="${AUTO_CONFIG_HELM_OPTS} ${HELM_OPTIONS} ${HELM_OPTS}"
|
||||
|
||||
echo "Deploying Helm chart..."
|
||||
helm upgrade -i "${RELEASE_NAME}" -n "${RELEASE_NAMESPACE}" --create-namespace "${CHART_PATH}" \
|
||||
-f "./charts/kubepanel-frontend/values.yaml" \
|
||||
-f "${USER_VALUES_PATH}" \
|
||||
${HELM_ARGS}
|
||||
@@ -1,58 +0,0 @@
|
||||
# Install dependencies only when needed
|
||||
FROM node:current-alpine AS deps
|
||||
# Check https://github.com/nodejs/docker-node/tree/b4117f9333da4138b03a546ec926ef50a31506c3#nodealpine to understand why libc6-compat might be needed.
|
||||
RUN apk add --no-cache libc6-compat && npm install -g pnpm
|
||||
WORKDIR /app
|
||||
|
||||
# Install dependencies based on the preferred package manager
|
||||
COPY package.json pnpm-lock.yaml* ./
|
||||
RUN \
|
||||
[ -f pnpm-lock.yaml ] && pnpm install || \
|
||||
(echo "Lockfile not found." && exit 1)
|
||||
|
||||
# Rebuild the source code only when needed
|
||||
FROM node:current-alpine AS builder
|
||||
WORKDIR /app
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY . .
|
||||
|
||||
# Next.js collects completely anonymous telemetry data about general usage.
|
||||
# Learn more here: https://nextjs.org/telemetry
|
||||
# Uncomment the following line in case you want to disable telemetry during the build.
|
||||
ENV NEXT_TELEMETRY_DISABLED 1
|
||||
ENV NEXT_PUBLIC_MOCK_USER ''
|
||||
|
||||
RUN npm install -g pnpm && pnpm run build
|
||||
|
||||
# Production image, copy all the files and run next
|
||||
FROM node:current-alpine AS runner
|
||||
WORKDIR /app
|
||||
|
||||
ENV NODE_ENV production
|
||||
# Uncomment the following line in case you want to disable telemetry during runtime.
|
||||
ENV NEXT_TELEMETRY_DISABLED 1
|
||||
|
||||
RUN addgroup --system --gid 1001 nodejs
|
||||
RUN adduser --system --uid 1001 nextjs
|
||||
|
||||
RUN sed -i 's/https/http/' /etc/apk/repositories
|
||||
RUN apk add curl \
|
||||
&& apk add ca-certificates \
|
||||
&& update-ca-certificates
|
||||
|
||||
# You only need to copy next.config.js if you are NOT using the default configuration
|
||||
# COPY --from=builder /app/next.config.js ./
|
||||
COPY --from=builder /app/public ./public
|
||||
COPY --from=builder /app/package.json ./package.json
|
||||
# COPY --from=builder /app/.env* .
|
||||
|
||||
# Automatically leverage output traces to reduce image size
|
||||
# https://nextjs.org/docs/advanced-features/output-file-tracing
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
||||
|
||||
USER nextjs
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
CMD ["pnpm", "run", "start"]
|
||||
@@ -1,11 +0,0 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
const path = require('path');
|
||||
|
||||
const nextConfig = {
|
||||
output: 'standalone',
|
||||
reactStrictMode: false,
|
||||
compress: true,
|
||||
transpilePackages: ['monaco-editor']
|
||||
};
|
||||
|
||||
module.exports = nextConfig;
|
||||
@@ -1,61 +0,0 @@
|
||||
{
|
||||
"name": "kubepanel",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev --turbo",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ant-design/charts": "^2.6.6",
|
||||
"@ant-design/icons": "^6.1.0",
|
||||
"@ant-design/plots": "^2.6.6",
|
||||
"@chakra-ui/anatomy": "^2.2.1",
|
||||
"@chakra-ui/icons": "^2.1.1",
|
||||
"@chakra-ui/next-js": "^2.1.5",
|
||||
"@chakra-ui/react": "^2.8.1",
|
||||
"@chakra-ui/system": "^2.6.1",
|
||||
"@emotion/react": "^11.11.1",
|
||||
"@emotion/styled": "^11.11.0",
|
||||
"@monaco-editor/react": "^4.7.0",
|
||||
"antd": "^6.1.0",
|
||||
"auto-bind": "^5.0.1",
|
||||
"axios": "^1.13.2",
|
||||
"framer-motion": "^10.16.4",
|
||||
"i18next": "^23.11.5",
|
||||
"js-yaml": "^4.1.1",
|
||||
"lodash": "^4.17.21",
|
||||
"moment": "^2.29.4",
|
||||
"moment-timezone": "^0.5.43",
|
||||
"monaco-editor": "^0.55.1",
|
||||
"next": "14.2.35",
|
||||
"next-i18next": "^15.3.0",
|
||||
"nprogress": "^0.2.0",
|
||||
"react": "18.3.1",
|
||||
"react-dom": "18.3.1",
|
||||
"react-i18next": "^14.1.2",
|
||||
"sealos-desktop-sdk": "workspace:*",
|
||||
"type-fest": "^5.3.1",
|
||||
"typed-regex": "^0.0.8",
|
||||
"zustand": "^5.0.9"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4.1.17",
|
||||
"@types/js-yaml": "^4.0.9",
|
||||
"@types/lodash": "^4.17.21",
|
||||
"@types/node": "^24.0.0",
|
||||
"@types/nprogress": "^0.2.3",
|
||||
"@types/react": "18.3.27",
|
||||
"autoprefixer": "^10.4.22",
|
||||
"eslint": "^8.57.0",
|
||||
"eslint-config-next": "14.2.3",
|
||||
"eslint-plugin-xss": "^0.1.12",
|
||||
"postcss": "^8.5.6",
|
||||
"postcss-import": "^16.1.1",
|
||||
"postcss-nesting": "^13.0.2",
|
||||
"tailwindcss": "^4.1.17",
|
||||
"typescript": "^5.9.3"
|
||||
}
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
// Tailwind v4 requires @tailwindcss/postcss as the PostCSS entry.
|
||||
'@tailwindcss/postcss': {}
|
||||
}
|
||||
};
|
||||
@@ -1,17 +0,0 @@
|
||||
apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: game-demo
|
||||
data:
|
||||
# property-like keys; each key maps to a simple value
|
||||
player_initial_lives: "3"
|
||||
ui_properties_file_name: "user-interface.properties"
|
||||
|
||||
# file-like keys
|
||||
game.properties: |
|
||||
enemy.types=aliens,monsters
|
||||
player.maximum-lives=5
|
||||
user-interface.properties: |
|
||||
color.good=purple
|
||||
color.bad=yellow
|
||||
allow.textmode=true
|
||||
@@ -1,23 +0,0 @@
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: nginx-deployment
|
||||
labels:
|
||||
app: nginx
|
||||
spec:
|
||||
replicas: 3
|
||||
selector:
|
||||
matchLabels:
|
||||
app: nginx
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: nginx
|
||||
spec:
|
||||
containers:
|
||||
- name: nginx
|
||||
image: nginx:1.14.2
|
||||
ports:
|
||||
- containerPort: 80
|
||||
securityContext:
|
||||
allowPrivilegeEscalation: false
|
||||
@@ -1,17 +0,0 @@
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: minimal-ingress
|
||||
annotations:
|
||||
nginx.ingress.kubernetes.io/rewrite-target: /
|
||||
spec:
|
||||
rules:
|
||||
- http:
|
||||
paths:
|
||||
- path: /testpath
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: test
|
||||
port:
|
||||
number: 80
|
||||
-17
@@ -1,17 +0,0 @@
|
||||
apiVersion: v1
|
||||
kind: PersistentVolumeClaim
|
||||
metadata:
|
||||
name: myclaim
|
||||
spec:
|
||||
accessModes:
|
||||
- ReadWriteOnce
|
||||
volumeMode: Filesystem
|
||||
resources:
|
||||
requests:
|
||||
storage: 8Gi
|
||||
storageClassName: slow
|
||||
selector:
|
||||
matchLabels:
|
||||
release: "stable"
|
||||
matchExpressions:
|
||||
- {key: environment, operator: In, values: [dev]}
|
||||
@@ -1,16 +0,0 @@
|
||||
apiVersion: v1
|
||||
kind: Pod
|
||||
metadata:
|
||||
name: static-web
|
||||
labels:
|
||||
role: myrole
|
||||
spec:
|
||||
containers:
|
||||
- name: web
|
||||
image: nginx
|
||||
ports:
|
||||
- name: web
|
||||
containerPort: 80
|
||||
protocol: TCP
|
||||
securityContext:
|
||||
allowPrivilegeEscalation: false
|
||||
@@ -1,8 +0,0 @@
|
||||
apiVersion: v1
|
||||
kind: Secret
|
||||
metadata:
|
||||
name: secret-basic-auth
|
||||
type: kubernetes.io/basic-auth
|
||||
stringData:
|
||||
username: admin
|
||||
password: t0p-Secret
|
||||
@@ -1,36 +0,0 @@
|
||||
apiVersion: apps/v1
|
||||
kind: StatefulSet
|
||||
metadata:
|
||||
name: web
|
||||
spec:
|
||||
selector:
|
||||
matchLabels:
|
||||
app: nginx # has to match .spec.template.metadata.labels
|
||||
serviceName: "nginx"
|
||||
replicas: 3 # by default is 1
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: nginx # has to match .spec.selector.matchLabels
|
||||
spec:
|
||||
terminationGracePeriodSeconds: 10
|
||||
containers:
|
||||
- name: nginx
|
||||
image: registry.k8s.io/nginx-slim:0.8
|
||||
ports:
|
||||
- containerPort: 80
|
||||
name: web
|
||||
volumeMounts:
|
||||
- name: www
|
||||
mountPath: /usr/share/nginx/html
|
||||
securityContext:
|
||||
allowPrivilegeEscalation: false
|
||||
volumeClaimTemplates:
|
||||
- metadata:
|
||||
name: www
|
||||
spec:
|
||||
accessModes: [ "ReadWriteOnce" ]
|
||||
storageClassName: "my-storage-class"
|
||||
resources:
|
||||
requests:
|
||||
storage: 1Gi
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 4.2 KiB |
@@ -1,73 +0,0 @@
|
||||
<svg width="78" height="78" viewBox="0 0 78 78" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_1180_19240)">
|
||||
<rect width="78" height="78" fill="url(#paint0_linear_1180_19240)"/>
|
||||
<path d="M39.0001 8.8938L51.3689 14.8844L63.7643 20.8196L66.7925 34.225L69.8805 47.6167L61.2879 58.3423L52.7432 69.1062L39.0001 69.0755L25.257 69.1062L16.7123 58.3423L8.11963 47.6167L11.2077 34.225L14.2359 20.8196L26.6313 14.8844L39.0001 8.8938Z" fill="url(#paint1_linear_1180_19240)" fill-opacity="0.3"/>
|
||||
<g filter="url(#filter0_d_1180_19240)">
|
||||
<path d="M31.6655 13.1953L43.7417 15.5082L55.8282 17.7671L61.5493 28.6508L67.319 39.5088L62.3769 50.7676L57.4851 62.0484L45.6012 65.2042L33.7316 68.4131L23.8548 61.0896L13.9454 53.8102L13.5131 41.5221L13.0259 29.236L22.3636 21.2365L31.6655 13.1953Z" fill="url(#paint2_linear_1180_19240)" fill-opacity="0.5" shape-rendering="crispEdges"/>
|
||||
</g>
|
||||
<g filter="url(#filter1_d_1180_19240)">
|
||||
<path d="M39.0003 15.2849L48.8734 20.0667L58.7678 24.8044L61.185 35.505L63.6499 46.1946L56.791 54.7562L49.9704 63.3482L39.0003 63.3237L28.0301 63.3482L21.2095 54.7562L14.3506 46.1946L16.8156 35.505L19.2328 24.8044L29.1271 20.0667L39.0003 15.2849Z" fill="url(#paint3_linear_1180_19240)" shape-rendering="crispEdges"/>
|
||||
</g>
|
||||
<path d="M25.5139 34.8794V45.9808H23.8616V35.0638L25.5139 34.8794ZM30.6699 38.1914L27.5202 41.673L31.0019 45.9808H28.9808L25.6319 41.7173L28.7447 38.1914H30.6699Z" fill="url(#paint4_linear_1180_19240)"/>
|
||||
<path d="M34.4196 38.1914V43.6351C34.4196 44.0974 34.5106 44.4268 34.6925 44.6236C34.8794 44.8203 35.1597 44.9186 35.5334 44.9186C35.8875 44.9186 36.2292 44.8129 36.5587 44.6014C36.8882 44.39 37.1488 44.1318 37.3406 43.8269V38.1914H38.9929V45.9808H37.5693L37.466 44.985C37.1759 45.3735 36.8095 45.671 36.3669 45.8775C35.9244 46.0792 35.4719 46.18 35.0097 46.18C34.2524 46.18 33.6893 45.9734 33.3205 45.5603C32.9517 45.1424 32.7673 44.5695 32.7673 43.8417V38.1914H34.4196Z" fill="url(#paint5_linear_1180_19240)"/>
|
||||
<path d="M43.3769 39.1208C43.6425 38.7667 43.9572 38.4889 44.3211 38.2873C44.685 38.0856 45.0833 37.9848 45.5161 37.9848C46.1996 37.9848 46.7528 38.1545 47.1757 38.4938C47.6036 38.8282 47.9158 39.3027 48.1125 39.9174C48.3141 40.5272 48.415 41.2452 48.415 42.0713C48.415 42.8631 48.2969 43.5687 48.0609 44.1883C47.8249 44.808 47.4757 45.2948 47.0135 45.6489C46.5561 46.0029 45.9906 46.18 45.3169 46.18C44.4465 46.18 43.7629 45.8579 43.2663 45.2137L43.1778 45.9808H41.7246V35.0638L43.3769 34.8794V39.1208ZM44.9038 44.9186C45.4644 44.9186 45.8972 44.6899 46.2021 44.2326C46.5119 43.7753 46.6668 43.0573 46.6668 42.0787C46.6668 41.0804 46.5266 40.3625 46.2463 39.9248C45.9709 39.4822 45.5775 39.2609 45.0661 39.2609C44.6973 39.2609 44.3703 39.3716 44.085 39.5929C43.8047 39.8142 43.5687 40.0748 43.3769 40.3748V44.0187C43.5589 44.299 43.7777 44.5203 44.0334 44.6826C44.294 44.8399 44.5842 44.9186 44.9038 44.9186Z" fill="url(#paint6_linear_1180_19240)"/>
|
||||
<path d="M52.0539 42.6172C52.1031 43.4187 52.3269 43.999 52.7252 44.358C53.1284 44.717 53.6128 44.8965 54.1783 44.8965C54.557 44.8965 54.9086 44.8375 55.2332 44.7194C55.5626 44.6014 55.8921 44.4342 56.2216 44.2179L56.9371 45.2063C56.5732 45.5013 56.1478 45.7374 55.661 45.9144C55.1741 46.0914 54.6529 46.18 54.0972 46.18C53.2858 46.18 52.5998 46.0078 52.0392 45.6636C51.4835 45.3194 51.0606 44.8399 50.7705 44.2252C50.4852 43.6105 50.3426 42.8999 50.3426 42.0935C50.3426 41.3116 50.4852 40.6133 50.7705 39.9986C51.0557 39.379 51.4638 38.8897 51.9949 38.5307C52.526 38.1668 53.1604 37.9848 53.898 37.9848C54.9307 37.9848 55.747 38.3315 56.347 39.0249C56.9518 39.7183 57.2543 40.6698 57.2543 41.8795C57.2543 42.0123 57.2494 42.1451 57.2395 42.2779C57.2346 42.4057 57.2272 42.5188 57.2174 42.6172H52.0539ZM53.9128 39.2093C53.3964 39.2093 52.9686 39.3937 52.6293 39.7625C52.29 40.1314 52.0957 40.7067 52.0466 41.4886H55.6536C55.6389 40.7559 55.4815 40.1928 55.1815 39.7994C54.8865 39.406 54.4636 39.2093 53.9128 39.2093Z" fill="url(#paint7_linear_1180_19240)"/>
|
||||
</g>
|
||||
<defs>
|
||||
<filter id="filter0_d_1180_19240" x="9.02588" y="10.1953" width="62.2932" height="63.2178" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
||||
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
|
||||
<feOffset dy="1"/>
|
||||
<feGaussianBlur stdDeviation="2"/>
|
||||
<feComposite in2="hardAlpha" operator="out"/>
|
||||
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0.347118 0 0 0 0 0.520677 0 0 0 0.1 0"/>
|
||||
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_1180_19240"/>
|
||||
<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_1180_19240" result="shape"/>
|
||||
</filter>
|
||||
<filter id="filter1_d_1180_19240" x="10.3506" y="12.2849" width="57.2993" height="56.0632" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
||||
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
|
||||
<feOffset dy="1"/>
|
||||
<feGaussianBlur stdDeviation="2"/>
|
||||
<feComposite in2="hardAlpha" operator="out"/>
|
||||
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0.347118 0 0 0 0 0.520677 0 0 0 0.1 0"/>
|
||||
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_1180_19240"/>
|
||||
<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_1180_19240" result="shape"/>
|
||||
</filter>
|
||||
<linearGradient id="paint0_linear_1180_19240" x1="78" y1="0" x2="0" y2="78" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#34C6F2"/>
|
||||
<stop offset="1" stop-color="#22B1FF"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="paint1_linear_1180_19240" x1="39.0001" y1="8.8938" x2="39.0001" y2="72.243" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="white"/>
|
||||
<stop offset="1" stop-color="white" stop-opacity="0.8"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="paint2_linear_1180_19240" x1="31.6656" y1="13.1955" x2="46.3348" y2="67.9417" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="white"/>
|
||||
<stop offset="1" stop-color="white" stop-opacity="0.8"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="paint3_linear_1180_19240" x1="39.0003" y1="15.2849" x2="39.0003" y2="65.8521" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="white"/>
|
||||
<stop offset="1" stop-color="white" stop-opacity="0.8"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="paint4_linear_1180_19240" x1="40.4971" y1="31.9807" x2="40.4971" y2="50.9807" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#003897"/>
|
||||
<stop offset="1" stop-color="#0092DC"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="paint5_linear_1180_19240" x1="40.4971" y1="31.9807" x2="40.4971" y2="50.9807" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#003897"/>
|
||||
<stop offset="1" stop-color="#0092DC"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="paint6_linear_1180_19240" x1="40.4971" y1="31.9807" x2="40.4971" y2="50.9807" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#003897"/>
|
||||
<stop offset="1" stop-color="#0092DC"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="paint7_linear_1180_19240" x1="40.4971" y1="31.9807" x2="40.4971" y2="50.9807" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#003897"/>
|
||||
<stop offset="1" stop-color="#0092DC"/>
|
||||
</linearGradient>
|
||||
<clipPath id="clip0_1180_19240">
|
||||
<rect width="78" height="78" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 7.1 KiB |
@@ -1,112 +0,0 @@
|
||||
import {
|
||||
KubeJsonApiDataFor,
|
||||
KubeObject,
|
||||
isJsonApiData,
|
||||
isPartialJsonApiData
|
||||
} from '@/k8slens/kube-object';
|
||||
import { bindPredicate, hasTypedProperty, isTypedArray } from '@/k8slens/utilities';
|
||||
import { ErrnoCode, buildErrno } from '@/services/backend/error';
|
||||
import { DELETE, GET, POST, PUT } from '@/services/request';
|
||||
import { KubeList } from '@/types/kube-resource';
|
||||
import { isKubeList } from '@/utils/types';
|
||||
|
||||
/**
|
||||
* Retrieves a yaml-like template of a specified kind from Backend API.
|
||||
*
|
||||
* @param kind The kind of resource to retrieve.
|
||||
* @returns A promise that resolves to a `SuccessResponse` containing the yaml-like template.
|
||||
*/
|
||||
export function getTemplate(kind: string) {
|
||||
return GET<SuccessResponse<string>>(`/api/kubernetes/template?kind=${kind}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves a list of resources of a specified kind from the Backend API.
|
||||
*
|
||||
* @param kind The kind of resource to retrieve.
|
||||
* @returns A promise that resolves to a `SuccessResponse` containing the list of resources.
|
||||
*/
|
||||
export async function listResources<
|
||||
Object extends KubeObject = KubeObject,
|
||||
Data extends KubeJsonApiDataFor<Object> = KubeJsonApiDataFor<Object>
|
||||
>(kind: string): Promise<SuccessResponse<KubeList<Data>>> {
|
||||
const res = await GET<SuccessResponse<KubeList<Data>>>(`/api/kubernetes/list?kind=${kind}`);
|
||||
|
||||
if (
|
||||
isKubeList<Data>(res.data) &&
|
||||
hasTypedProperty(res.data, 'items', bindPredicate(isTypedArray, isPartialJsonApiData))
|
||||
) {
|
||||
return res;
|
||||
}
|
||||
|
||||
throw buildErrno('Response is not correct type', ErrnoCode.ServerInternalError);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a resource of the specified kind using the provided data.
|
||||
*
|
||||
* @param kind The kind of resource to create.
|
||||
* @param data The data to use for creating the resource.
|
||||
* @returns A Promise that resolves to a `SuccessResponse` containing the created resource data.
|
||||
*/
|
||||
export async function createResource<
|
||||
Object extends KubeObject = KubeObject,
|
||||
Data extends KubeJsonApiDataFor<Object> = KubeJsonApiDataFor<Object>
|
||||
>(kind: string, data: string): Promise<SuccessResponse<Data>> {
|
||||
const res = await POST<SuccessResponse<Data>>(`/api/kubernetes/create?kind=${kind}`, {
|
||||
data
|
||||
});
|
||||
|
||||
if (isJsonApiData(res.data)) {
|
||||
return res;
|
||||
}
|
||||
|
||||
throw buildErrno('Response is not correct type', ErrnoCode.ServerInternalError);
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes a resource from the Kubernetes cluster.
|
||||
*
|
||||
* @param kind The kind of resource to delete.
|
||||
* @param name The name of the resource to delete.
|
||||
* @return A promise that resolves with a `SuccessResponse` containing the data, which is old resource data,
|
||||
* if the resource is deleted successfully.
|
||||
*/
|
||||
export async function deleteResource<
|
||||
Object extends KubeObject = KubeObject,
|
||||
Data extends KubeJsonApiDataFor<Object> = KubeJsonApiDataFor<Object>
|
||||
>(kind: string, name: string): Promise<SuccessResponse<Data>> {
|
||||
const res = await DELETE<SuccessResponse<Data>>('/api/kubernetes/delete', {
|
||||
kind,
|
||||
name
|
||||
});
|
||||
|
||||
if (isJsonApiData(res.data)) {
|
||||
return res;
|
||||
}
|
||||
|
||||
throw buildErrno('Response is not correct type', ErrnoCode.ServerInternalError);
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates a resource in the Kubernetes API.
|
||||
*
|
||||
* @param kind The type of resource to update.
|
||||
* @param name The name of the resource to update.
|
||||
* @param data The updated data for the resource.
|
||||
* @return A promise that resolves to a `SuccessResponse` containing the old data.
|
||||
*/
|
||||
export async function updateResource<
|
||||
Object extends KubeObject = KubeObject,
|
||||
Data extends KubeJsonApiDataFor<Object> = KubeJsonApiDataFor<Object>
|
||||
>(kind: string, name: string, data: string): Promise<SuccessResponse<Data>> {
|
||||
const res = await PUT<SuccessResponse<Data>>(`/api/kubernetes/update?kind=${kind}&name=${name}`, {
|
||||
data
|
||||
});
|
||||
|
||||
if (isJsonApiData(res.data)) {
|
||||
return res;
|
||||
}
|
||||
|
||||
throw buildErrno('Response is not correct type', ErrnoCode.ServerInternalError);
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
import { Response } from '@/pages/api/platform/init-data';
|
||||
import { GET } from '@/services/request';
|
||||
|
||||
export const getInitData = () => GET<Response>('/api/platform/init-data');
|
||||
@@ -1,70 +0,0 @@
|
||||
import { sum } from 'lodash';
|
||||
import dynamic from 'next/dynamic';
|
||||
|
||||
export type PieChartData = {
|
||||
type: string;
|
||||
value: number;
|
||||
};
|
||||
|
||||
interface PieChartProps {
|
||||
title: string;
|
||||
data: Array<PieChartData>;
|
||||
color: (type: string) => string;
|
||||
}
|
||||
|
||||
// @ts-ignore - @ant-design/plots has complex types that cause deep instantiation in full tsc
|
||||
const DynamicPie = dynamic(() => import('@ant-design/plots').then((item) => item.Pie), {
|
||||
ssr: false
|
||||
});
|
||||
|
||||
export const PieChart = ({ title, data, color }: PieChartProps) => {
|
||||
const total = sum(data.map((d) => d.value));
|
||||
|
||||
const config = {
|
||||
data: data,
|
||||
angleField: 'value',
|
||||
colorField: 'type',
|
||||
color: data.map((item) => color(item.type)),
|
||||
width: 220,
|
||||
height: 350,
|
||||
innerRadius: 0.6,
|
||||
radius: 0.92,
|
||||
label: false,
|
||||
legend: {
|
||||
position: 'bottom' as const,
|
||||
layout: 'vertical' as const,
|
||||
offsetY: -30,
|
||||
marker: {
|
||||
symbol: 'square' as const,
|
||||
style: {
|
||||
r: 6
|
||||
}
|
||||
},
|
||||
itemSpacing: 13
|
||||
},
|
||||
annotations: [
|
||||
{
|
||||
type: 'text',
|
||||
style: {
|
||||
text: `${title}\n${total}`,
|
||||
x: '50%',
|
||||
y: '50%',
|
||||
textAlign: 'center',
|
||||
fontSize: 16,
|
||||
fontWeight: '500',
|
||||
textBaseline: 'middle'
|
||||
}
|
||||
}
|
||||
],
|
||||
tooltip: {
|
||||
items: [
|
||||
(d: any) => ({
|
||||
name: d.type,
|
||||
value: d.value
|
||||
})
|
||||
]
|
||||
}
|
||||
};
|
||||
|
||||
return <DynamicPie {...config} />;
|
||||
};
|
||||
@@ -1,58 +0,0 @@
|
||||
import { KubeObject } from '@/k8slens/kube-object';
|
||||
import { CloseOutlined, MoreOutlined, RetweetOutlined } from '@ant-design/icons';
|
||||
import { Button, Dropdown, type MenuProps } from 'antd';
|
||||
import { useState } from 'react';
|
||||
import { DeletePopconfirm } from './delete-popconfirm';
|
||||
import { UpdateEditorModal } from './update-editor-modal';
|
||||
|
||||
interface Props<K extends KubeObject> {
|
||||
obj: K;
|
||||
}
|
||||
|
||||
export const ActionButton = <K extends KubeObject = KubeObject>({ obj }: Props<K>) => {
|
||||
const [openUpdateModal, setOpenUpdateModal] = useState(false);
|
||||
|
||||
const items: MenuProps['items'] = [
|
||||
{
|
||||
key: 'delete',
|
||||
label: (
|
||||
<DeletePopconfirm obj={obj}>
|
||||
<Button icon={<CloseOutlined />} type="link" size="small" danger>
|
||||
Delete
|
||||
</Button>
|
||||
</DeletePopconfirm>
|
||||
)
|
||||
},
|
||||
{
|
||||
key: 'update',
|
||||
label: (
|
||||
<Button
|
||||
icon={<RetweetOutlined />}
|
||||
type="link"
|
||||
size="small"
|
||||
onClick={() => setOpenUpdateModal(true)}
|
||||
>
|
||||
Update
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
].filter((item) => item.label);
|
||||
|
||||
return (
|
||||
// wrapper to stop click event propagation
|
||||
<div onClick={(e) => e.stopPropagation()}>
|
||||
<Dropdown disabled={items.length === 0} menu={{ items }} arrow trigger={['click']}>
|
||||
<span>
|
||||
<MoreOutlined className="text-[25px] text-[#667085] hover:text-[#24282C] cursor-pointer" />
|
||||
</span>
|
||||
</Dropdown>
|
||||
<UpdateEditorModal
|
||||
key={'update'}
|
||||
obj={obj}
|
||||
open={openUpdateModal}
|
||||
onCancel={() => setOpenUpdateModal(false)}
|
||||
onOk={() => setOpenUpdateModal(false)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,157 +0,0 @@
|
||||
import { createResource, getTemplate } from '@/api/kubernetes';
|
||||
import StyledEditor from '@/components/common/editor/styled';
|
||||
import Title from '@/components/common/title/title';
|
||||
import { KubeObjectKind } from '@/constants/kube-object';
|
||||
import { buildErrorResponse } from '@/services/backend/response';
|
||||
import { Button, Flex, Modal, Spin, notification } from 'antd';
|
||||
import { editor as EditorNS } from 'monaco-editor';
|
||||
import { useCallback, useRef, useState } from 'react';
|
||||
import { TemplateToggle, TemplateToggleChooseEventHandler } from './toggle';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
setClose: () => void;
|
||||
}
|
||||
|
||||
const defaultTemplate = 'Please select a template first.';
|
||||
const noticeKey = 'createResource';
|
||||
|
||||
export const CreateResourceModal = ({ open, setClose }: Props) => {
|
||||
const [disabled, setDisabled] = useState(true);
|
||||
const [kind, setKind] = useState<KubeObjectKind | undefined>(undefined);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [template, setTemplate] = useState<string>(defaultTemplate);
|
||||
const [confirmLoading, setConfirmLoading] = useState(false);
|
||||
const [notifyApi, contextHolder] = notification.useNotification();
|
||||
const editorRef = useRef<EditorNS.IStandaloneCodeEditor | null>(null);
|
||||
const onEditorMount = (editor: EditorNS.IStandaloneCodeEditor) => {
|
||||
editorRef.current = editor;
|
||||
};
|
||||
|
||||
const onChoose = useCallback<TemplateToggleChooseEventHandler>(
|
||||
(kind) => {
|
||||
setKind(kind);
|
||||
setDisabled(false);
|
||||
const storage = localStorage.getItem(`template-${kind}`);
|
||||
if (storage) {
|
||||
setTemplate(storage);
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
// fetch template
|
||||
getTemplate(kind)
|
||||
.then((res) => {
|
||||
setTemplate(res.data);
|
||||
})
|
||||
.catch((err: any) => {
|
||||
const errResp = buildErrorResponse(err);
|
||||
notifyApi.error({
|
||||
key: noticeKey,
|
||||
message: errResp.error.reason,
|
||||
description: errResp.error.message
|
||||
});
|
||||
})
|
||||
.finally(() => {
|
||||
setLoading(false);
|
||||
});
|
||||
},
|
||||
[notifyApi]
|
||||
);
|
||||
|
||||
const onCreate = useCallback(() => {
|
||||
setConfirmLoading(true);
|
||||
const editor = editorRef.current;
|
||||
if (!editor) {
|
||||
setConfirmLoading(false);
|
||||
notifyApi.error({
|
||||
key: noticeKey,
|
||||
message: 'Editor Not Found',
|
||||
description: 'Please reopen the create resource panel, sorry for the inconvenience.'
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (!kind) {
|
||||
setConfirmLoading(false);
|
||||
notifyApi.error({
|
||||
key: noticeKey,
|
||||
message: 'Template Not Found',
|
||||
description: 'Please select a template first, sorry for the inconvenience.'
|
||||
});
|
||||
return;
|
||||
}
|
||||
const value = editor.getValue();
|
||||
createResource(kind, value)
|
||||
.then(() => {
|
||||
notifyApi.success({
|
||||
key: noticeKey,
|
||||
message: 'Success',
|
||||
description: 'Your resource has been created.'
|
||||
});
|
||||
})
|
||||
.catch((err: any) => {
|
||||
const errResp = buildErrorResponse(err);
|
||||
notifyApi.error({
|
||||
key: noticeKey,
|
||||
message: errResp.error.reason,
|
||||
description: errResp.error.message
|
||||
});
|
||||
})
|
||||
.finally(() => {
|
||||
setConfirmLoading(false);
|
||||
});
|
||||
}, [kind, notifyApi]);
|
||||
return (
|
||||
<>
|
||||
{contextHolder}
|
||||
<Modal
|
||||
open={open}
|
||||
onCancel={setClose}
|
||||
onOk={setClose}
|
||||
footer={[
|
||||
<Button key="cancel" onClick={setClose} className="mr-2">
|
||||
Cancel
|
||||
</Button>,
|
||||
<Button
|
||||
key="create"
|
||||
type="primary"
|
||||
loading={confirmLoading}
|
||||
onClick={onCreate}
|
||||
disabled={disabled}
|
||||
>
|
||||
Create
|
||||
</Button>
|
||||
]}
|
||||
>
|
||||
<Title type="primary" className="pb-3">
|
||||
Create Resource
|
||||
</Title>
|
||||
<Flex vertical gap="12px">
|
||||
<TemplateToggle onChoose={onChoose} />
|
||||
<Spin spinning={loading}>
|
||||
<StyledEditor
|
||||
value={template}
|
||||
language="yaml"
|
||||
onMount={onEditorMount}
|
||||
options={{
|
||||
readOnly: disabled,
|
||||
minimap: { enabled: false },
|
||||
scrollbar: {
|
||||
vertical: 'auto',
|
||||
horizontal: 'auto'
|
||||
},
|
||||
contextmenu: false,
|
||||
cursorBlinking: 'smooth',
|
||||
scrollBeyondLastLine: false,
|
||||
overviewRulerBorder: false,
|
||||
codeLens: false,
|
||||
acceptSuggestionOnCommitCharacter: false,
|
||||
acceptSuggestionOnEnter: 'off',
|
||||
accessibilitySupport: 'off'
|
||||
}}
|
||||
/>
|
||||
</Spin>
|
||||
</Flex>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -1,122 +0,0 @@
|
||||
import { deleteResource } from '@/api/kubernetes';
|
||||
import { KubeObjectKind } from '@/constants/kube-object';
|
||||
import { KubeObject } from '@/k8slens/kube-object';
|
||||
import { buildErrorResponse } from '@/services/backend/response';
|
||||
import {
|
||||
useBucketStore,
|
||||
useClusterStore,
|
||||
useConfigMapStore,
|
||||
useDeploymentStore,
|
||||
useDevboxStore,
|
||||
useIngressStore,
|
||||
usePodStore,
|
||||
useSecretStore,
|
||||
useServiceStore,
|
||||
useStatefulSetStore,
|
||||
useVolumeClaimStore
|
||||
} from '@/store/kube';
|
||||
import { WarningOutlined } from '@ant-design/icons';
|
||||
import { Popconfirm, message } from 'antd';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
interface Props<K extends KubeObject> {
|
||||
obj: K;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function DeletePopconfirm<K extends KubeObject = KubeObject>({ obj, children }: Props<K>) {
|
||||
if (!obj) return null;
|
||||
const [confirmLoading, setConfirmLoading] = useState(false);
|
||||
const msgKey = `deletedMsg-${obj.getId()}`;
|
||||
|
||||
const bucketStore = useBucketStore();
|
||||
const clusterStore = useClusterStore();
|
||||
const configMapStore = useConfigMapStore();
|
||||
const deploymentStore = useDeploymentStore();
|
||||
const devboxStore = useDevboxStore();
|
||||
const ingressStore = useIngressStore();
|
||||
const podStore = usePodStore();
|
||||
const secretStore = useSecretStore();
|
||||
const serviceStore = useServiceStore();
|
||||
const statefulSetStore = useStatefulSetStore();
|
||||
const volumeClaimStore = useVolumeClaimStore();
|
||||
|
||||
const getStore = () => {
|
||||
switch (obj.kind) {
|
||||
case KubeObjectKind.Bucket:
|
||||
return bucketStore;
|
||||
case KubeObjectKind.Cluster:
|
||||
return clusterStore;
|
||||
case KubeObjectKind.ConfigMap:
|
||||
return configMapStore;
|
||||
case KubeObjectKind.Deployment:
|
||||
return deploymentStore;
|
||||
case KubeObjectKind.Devbox:
|
||||
case KubeObjectKind.DevboxOld:
|
||||
return devboxStore;
|
||||
case KubeObjectKind.Ingress:
|
||||
return ingressStore;
|
||||
case KubeObjectKind.Pod:
|
||||
return podStore;
|
||||
case KubeObjectKind.Secret:
|
||||
return secretStore;
|
||||
case KubeObjectKind.Service:
|
||||
return serviceStore;
|
||||
case KubeObjectKind.StatefulSet:
|
||||
return statefulSetStore;
|
||||
case KubeObjectKind.PersistentVolumeClaim:
|
||||
return volumeClaimStore;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Popconfirm
|
||||
title={'Delete Resource'}
|
||||
description={
|
||||
<>
|
||||
Are you sure to delete{' '}
|
||||
<span className="text-[#0884DD]">
|
||||
{obj.kind}: {obj.getName()}
|
||||
</span>
|
||||
?
|
||||
</>
|
||||
}
|
||||
icon={<WarningOutlined className="text-red-500" />}
|
||||
onConfirm={() => {
|
||||
setConfirmLoading(true);
|
||||
deleteResource(obj.kind, obj.getName())
|
||||
.then((res) => {
|
||||
const store = getStore();
|
||||
if (store) {
|
||||
store.remove(obj);
|
||||
}
|
||||
console.log('res', res);
|
||||
message.success({
|
||||
content: `Successfully deleted ${res.data.kind} ${res.data.metadata.name}`,
|
||||
key: msgKey,
|
||||
duration: 3
|
||||
});
|
||||
})
|
||||
.catch((err) => {
|
||||
const errResp = buildErrorResponse(err);
|
||||
message.error({
|
||||
content: `Failed to delete ${obj.kind} ${obj.getName()}: ${errResp.error.message}`,
|
||||
key: msgKey
|
||||
});
|
||||
})
|
||||
.finally(() => {
|
||||
setConfirmLoading(false);
|
||||
});
|
||||
}}
|
||||
okText="Yes"
|
||||
okButtonProps={{ loading: confirmLoading }}
|
||||
cancelText="No"
|
||||
>
|
||||
{children}
|
||||
</Popconfirm>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,112 +0,0 @@
|
||||
import { KubeObjectKind } from '@/constants/kube-object';
|
||||
import {
|
||||
DashboardOutlined,
|
||||
DatabaseOutlined,
|
||||
DownOutlined,
|
||||
GatewayOutlined,
|
||||
SettingOutlined
|
||||
} from '@ant-design/icons';
|
||||
import { Dropdown, MenuProps, Space } from 'antd';
|
||||
import { useCallback, useState } from 'react';
|
||||
|
||||
const items: MenuProps['items'] = [
|
||||
{
|
||||
key: 'Workload',
|
||||
label: 'Workload',
|
||||
icon: <DashboardOutlined />,
|
||||
children: [
|
||||
{
|
||||
key: KubeObjectKind.Pod,
|
||||
label: 'Pod'
|
||||
},
|
||||
{
|
||||
key: KubeObjectKind.Deployment,
|
||||
label: 'Deployment'
|
||||
},
|
||||
{
|
||||
key: KubeObjectKind.StatefulSet,
|
||||
label: 'Stateful Set'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'Network',
|
||||
label: 'Network',
|
||||
icon: <GatewayOutlined />,
|
||||
children: [
|
||||
{
|
||||
key: KubeObjectKind.Ingress,
|
||||
label: 'Ingress'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'Config',
|
||||
label: 'Config',
|
||||
icon: <SettingOutlined />,
|
||||
children: [
|
||||
{
|
||||
key: KubeObjectKind.ConfigMap,
|
||||
label: 'Config Map'
|
||||
},
|
||||
{
|
||||
key: KubeObjectKind.Secret,
|
||||
label: 'Secret'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'Storage',
|
||||
label: 'Storage',
|
||||
icon: <DatabaseOutlined />,
|
||||
children: [
|
||||
{
|
||||
key: KubeObjectKind.PersistentVolumeClaim,
|
||||
label: 'Persistent Volume Claim'
|
||||
}
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
export type TemplateToggleChooseEventHandler = (kind: KubeObjectKind) => void;
|
||||
|
||||
interface TemplateToggleProps {
|
||||
onChoose: TemplateToggleChooseEventHandler;
|
||||
}
|
||||
|
||||
const placeholder = 'Choose a Template';
|
||||
|
||||
export function TemplateToggle({ onChoose }: TemplateToggleProps) {
|
||||
const [input, setInput] = useState(placeholder);
|
||||
const [hovered, setHovered] = useState(false);
|
||||
|
||||
const onClick = useCallback<Required<MenuProps>['onClick']>(
|
||||
(e) => {
|
||||
setInput(e.key);
|
||||
onChoose(e.key as KubeObjectKind);
|
||||
},
|
||||
[setInput, onChoose]
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Dropdown menu={{ items, onClick }} onOpenChange={(e) => setHovered(e)}>
|
||||
<Space
|
||||
size={10}
|
||||
className={`transition duration-500 px-2.5 py-1.5 cursor-pointer ${
|
||||
hovered ? 'bg-[#9699B41A]' : input !== placeholder ? 'bg-[#9699B41A]' : 'bg-none'
|
||||
} rounded-lg`}
|
||||
>
|
||||
<span
|
||||
className={`transition duration-500 text-sm ${
|
||||
hovered ? 'text-[#0884DD]' : 'text-[#485264]'
|
||||
}`}
|
||||
>
|
||||
{input}
|
||||
</span>
|
||||
<DownOutlined className="text-[8px] text-[#485264]" />
|
||||
</Space>
|
||||
</Dropdown>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,88 +0,0 @@
|
||||
import { updateResource } from '@/api/kubernetes';
|
||||
import StyledEditor from '@/components/common/editor/styled';
|
||||
import Title from '@/components/common/title/title';
|
||||
import { KubeObject } from '@/k8slens/kube-object';
|
||||
import { buildErrorResponse } from '@/services/backend/response';
|
||||
import { dumpKubeObject } from '@/utils/yaml';
|
||||
import { Button, Modal, message } from 'antd';
|
||||
import { editor } from 'monaco-editor';
|
||||
import { useRef } from 'react';
|
||||
|
||||
interface Props<K extends KubeObject> {
|
||||
obj?: K;
|
||||
open: boolean;
|
||||
onCancel: () => void;
|
||||
onOk: () => void;
|
||||
}
|
||||
|
||||
export const UpdateEditorModal = <K extends KubeObject = KubeObject>({
|
||||
obj,
|
||||
open,
|
||||
onCancel,
|
||||
onOk
|
||||
}: Props<K>) => {
|
||||
if (!obj) return null;
|
||||
|
||||
const [msgApi, contextHolder] = message.useMessage();
|
||||
const msgKey = 'updatedMsg';
|
||||
const editorRef = useRef<editor.IStandaloneCodeEditor>();
|
||||
|
||||
const editorValue = dumpKubeObject<KubeObject>(obj);
|
||||
|
||||
return (
|
||||
<>
|
||||
{contextHolder}
|
||||
<Modal
|
||||
open={open}
|
||||
width={'90vw'}
|
||||
onCancel={onCancel}
|
||||
footer={[
|
||||
<Button key="cancel" onClick={onCancel}>
|
||||
Cancel
|
||||
</Button>,
|
||||
<Button
|
||||
key="update"
|
||||
type="primary"
|
||||
onClick={() => {
|
||||
msgApi.loading({ content: 'Updating...', key: msgKey }, 0);
|
||||
if (!editorRef.current) {
|
||||
msgApi.error(
|
||||
'There is an error to get the editor value. You can try to reopen the modal.'
|
||||
);
|
||||
return;
|
||||
}
|
||||
updateResource(obj.kind, obj.getName(), editorRef.current.getValue())
|
||||
.then((res) => {
|
||||
msgApi.success({
|
||||
content: `Successfully updated ${res.data.kind} ${res.data.metadata.name}`,
|
||||
key: msgKey
|
||||
});
|
||||
onOk();
|
||||
})
|
||||
.catch((err) => {
|
||||
const errResp = buildErrorResponse(err);
|
||||
msgApi.error({
|
||||
content: `Failed to update ${obj.kind} ${obj.getName()}: ${
|
||||
errResp.error.message
|
||||
}`,
|
||||
key: msgKey
|
||||
});
|
||||
});
|
||||
}}
|
||||
>
|
||||
Update
|
||||
</Button>
|
||||
]}
|
||||
>
|
||||
<Title type="primary" className="pb-5">
|
||||
{obj.kind}: {obj.getName()}
|
||||
</Title>
|
||||
<StyledEditor
|
||||
onMount={(editor) => (editorRef.current = editor)}
|
||||
value={editorValue}
|
||||
language="yaml"
|
||||
/>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -1,42 +0,0 @@
|
||||
interface DrawerItemProps {
|
||||
name: React.ReactNode;
|
||||
value?: React.ReactNode;
|
||||
hidden?: boolean;
|
||||
padding?: boolean;
|
||||
className?: string;
|
||||
vertical?: boolean;
|
||||
}
|
||||
|
||||
export const DrawerItem = ({
|
||||
name,
|
||||
value,
|
||||
hidden = false,
|
||||
padding = true,
|
||||
className = '',
|
||||
vertical = false
|
||||
}: DrawerItemProps) => {
|
||||
if (hidden) return null;
|
||||
|
||||
if (vertical) {
|
||||
return (
|
||||
<div
|
||||
className={`flex flex-col ${padding ? 'py-2' : ''} border-b border-[#E8E8E8] last:border-b-0 ${className}`}
|
||||
>
|
||||
<span className="text-[#8C8C8C] font-medium text-sm mb-2">{name}</span>
|
||||
<div className="text-[#262626] text-xs break-all">{value}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`grid ${
|
||||
padding ? 'py-2' : ''
|
||||
} border-b border-[#E8E8E8] last:border-b-0 items-start ${className}`}
|
||||
style={{ gridTemplateColumns: '100px minmax(0, 1fr)', gap: '16px' }}
|
||||
>
|
||||
<span className="text-[#8C8C8C] font-medium text-sm">{name}</span>
|
||||
<div className="text-[#262626] text-xs break-all">{value}</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,19 +0,0 @@
|
||||
import { DrawerTitle } from './drawer-title';
|
||||
|
||||
interface Props {
|
||||
title?: React.ReactNode;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export const DrawerPanel = ({ title, children }: Props) => {
|
||||
return (
|
||||
<div className="flex-1">
|
||||
{title && (
|
||||
<div className="pb-4 mb-4 border-b border-[#E8E8E8]">
|
||||
{typeof title === 'string' ? <DrawerTitle>{title}</DrawerTitle> : title}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-col">{children}</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,9 +0,0 @@
|
||||
interface Props {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export const DrawerTitle = ({ children }: Props) => {
|
||||
return (
|
||||
<div className="py-2.5 pl-4 bg-[#F4F6F8] text-[#24282C] font-medium text-base">{children}</div>
|
||||
);
|
||||
};
|
||||
@@ -1,30 +0,0 @@
|
||||
import { CloseOutlined } from '@ant-design/icons';
|
||||
import { Drawer as AntdDrawer } from 'antd';
|
||||
interface Props {
|
||||
title: string;
|
||||
children: React.ReactNode;
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
width?: string | number;
|
||||
extra?: React.ReactNode;
|
||||
}
|
||||
export const Drawer = ({ title, children, open, onClose, width = '550px', extra }: Props) => {
|
||||
return (
|
||||
<AntdDrawer
|
||||
styles={{
|
||||
header: {
|
||||
padding: '16px 24px'
|
||||
}
|
||||
}}
|
||||
open={open}
|
||||
closeIcon={<CloseOutlined style={{ fontSize: '18px' }} />}
|
||||
title={<span className="text-gray-900 font-medium text-base">{title}</span>}
|
||||
width={width}
|
||||
extra={extra}
|
||||
onClose={onClose}
|
||||
destroyOnHidden
|
||||
>
|
||||
<div className="flex flex-col gap-6">{children}</div>
|
||||
</AntdDrawer>
|
||||
);
|
||||
};
|
||||
@@ -1,57 +0,0 @@
|
||||
import { monacoTheme } from '@/constants/theme';
|
||||
import { EditorProps } from '@monaco-editor/react';
|
||||
import { Spin } from 'antd';
|
||||
import dynamic from 'next/dynamic';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
const Editor = dynamic(
|
||||
() => import('@monaco-editor/react').then((mod) => ({ default: mod.Editor })),
|
||||
{
|
||||
ssr: false,
|
||||
loading: () => (
|
||||
<div className="flex items-center justify-center h-[70vh] border-solid border-[1px] border-color-border rounded-lg bg-[#FBFBFC]">
|
||||
<Spin size="large" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
);
|
||||
|
||||
interface StyledEditorProps extends EditorProps {
|
||||
wrapperClassName?: string;
|
||||
}
|
||||
|
||||
export default function StyledEditor({ wrapperClassName = '', ...props }: StyledEditorProps) {
|
||||
useEffect(() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
import('monaco-editor').then((monaco) => {
|
||||
try {
|
||||
monaco.editor.defineTheme('kubepanel', monacoTheme);
|
||||
} catch (e) {
|
||||
console.warn('Failed to define Monaco theme:', e);
|
||||
}
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`pt-5 border-solid border-[1px] border-color-border rounded-lg bg-[#FBFBFC] ${wrapperClassName}`}
|
||||
>
|
||||
<Editor
|
||||
height={'70vh'}
|
||||
theme="kubepanel"
|
||||
loading={<Spin />}
|
||||
options={{
|
||||
minimap: { enabled: false }, // Hide minimap
|
||||
scrollBeyondLastLine: false, // Remove whitespace at bottom
|
||||
scrollbar: {
|
||||
useShadows: false,
|
||||
vertical: 'auto'
|
||||
},
|
||||
...props.options // Allow overrides
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,121 +0,0 @@
|
||||
import { LeftOutlined, RightOutlined } from '@ant-design/icons';
|
||||
import { useMemo, useState } from 'react';
|
||||
|
||||
interface SwitchButtonProps {
|
||||
onClick?: () => void;
|
||||
disabled: boolean;
|
||||
icon: React.ReactNode;
|
||||
}
|
||||
|
||||
const SwitchButton = ({ onClick, disabled, icon }: SwitchButtonProps) => {
|
||||
const baseClass =
|
||||
'flex items-center justify-center w-8 h-8 rounded-md transition-colors duration-200 border border-[#E8E8E8] bg-white';
|
||||
const stateClass = disabled
|
||||
? 'cursor-not-allowed text-gray-300 bg-gray-50 border-gray-100' // Disabled: lighter grey, no border
|
||||
: 'cursor-pointer text-gray-600 hover:text-[#1890FF] hover:border-[#1890FF] hover:bg-[#E6F7FF]'; // Active: Blue hover
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (disabled) return;
|
||||
onClick?.();
|
||||
}}
|
||||
disabled={disabled}
|
||||
className={`${baseClass} ${stateClass}`}
|
||||
>
|
||||
{icon}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
export const PanelPagination = ({
|
||||
total,
|
||||
current,
|
||||
pageSize,
|
||||
onChange
|
||||
}: {
|
||||
total: number;
|
||||
current: number;
|
||||
pageSize: number;
|
||||
onChange: (page: number) => void;
|
||||
}) => {
|
||||
const totalPage = Math.ceil(total / pageSize);
|
||||
const [inputVal, setInputVal] = useState(current.toString());
|
||||
|
||||
// Sync input with external current page change
|
||||
useMemo(() => {
|
||||
setInputVal(current.toString());
|
||||
}, [current]);
|
||||
|
||||
const handleValuesChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const val = e.target.value;
|
||||
if (/^\d*$/.test(val)) {
|
||||
setInputVal(val);
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === 'Enter') {
|
||||
let val = parseInt(inputVal);
|
||||
if (isNaN(val) || val < 1) val = 1;
|
||||
if (val > totalPage) val = totalPage;
|
||||
onChange(val);
|
||||
setInputVal(val.toString());
|
||||
}
|
||||
};
|
||||
|
||||
const handleBlur = () => {
|
||||
let val = parseInt(inputVal);
|
||||
if (isNaN(val) || val < 1) val = 1;
|
||||
if (val > totalPage) val = totalPage;
|
||||
// Only trigger change if different (optional, but good for UX)
|
||||
if (val !== current) {
|
||||
onChange(val);
|
||||
}
|
||||
setInputVal(val.toString());
|
||||
};
|
||||
|
||||
const isFirst = current === 1;
|
||||
const isLast = current === totalPage;
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-end w-full py-3 select-none">
|
||||
{/* Total Items Text */}
|
||||
<span className="text-gray-500 font-medium mr-4">Total Items: {total}</span>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Previous Button */}
|
||||
<SwitchButton
|
||||
icon={<LeftOutlined />}
|
||||
onClick={() => onChange(current - 1)}
|
||||
disabled={isFirst}
|
||||
/>
|
||||
|
||||
{/* Pager Block: Input / Total */}
|
||||
<div
|
||||
className="flex items-center bg-white border border-[#E8E8E8] rounded-md px-2 h-8"
|
||||
style={{ gap: '4px' }}
|
||||
>
|
||||
<input
|
||||
className="w-8 h-full text-center border-none outline-none bg-transparent font-medium text-gray-800 p-0 m-0"
|
||||
value={inputVal}
|
||||
onChange={handleValuesChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
onBlur={handleBlur}
|
||||
/>
|
||||
<span className="text-gray-400 font-medium">/</span>
|
||||
{/* THE REQUESTED DOM STRUCTURE FOR "2" */}
|
||||
<span className="text-gray-500 font-medium w-8 text-center">{totalPage}</span>
|
||||
</div>
|
||||
|
||||
{/* Next Button */}
|
||||
<SwitchButton
|
||||
icon={<RightOutlined />}
|
||||
onClick={() => onChange(current + 1)}
|
||||
disabled={isLast}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,104 +0,0 @@
|
||||
import { SearchOutlined } from '@ant-design/icons';
|
||||
import { Input, InputRef } from 'antd';
|
||||
import { ColumnType } from 'antd/lib/table';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
interface SearchFilterPanelProps {
|
||||
onSearched: (value: string) => void;
|
||||
visible: boolean;
|
||||
}
|
||||
|
||||
function SearchFilterPanel({ onSearched, visible }: SearchFilterPanelProps) {
|
||||
const searchInput = useRef<InputRef>(null);
|
||||
const [searchText, setSearchText] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
setTimeout(() => searchInput.current?.focus(), 100);
|
||||
}
|
||||
}, [visible]);
|
||||
|
||||
return (
|
||||
<div className="p-2 w-[200px]">
|
||||
<Input
|
||||
ref={searchInput}
|
||||
placeholder="Search by name"
|
||||
prefix={<SearchOutlined style={{ color: '#BFBFBF' }} />}
|
||||
allowClear
|
||||
onChange={(e) => {
|
||||
onSearched(e.target.value);
|
||||
setSearchText(e.target.value);
|
||||
}}
|
||||
value={searchText}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface HighlightNameProps {
|
||||
name: string;
|
||||
highlight: string;
|
||||
enable?: boolean;
|
||||
}
|
||||
|
||||
function HighlightName({ name, highlight, enable = true }: HighlightNameProps) {
|
||||
if (!enable) {
|
||||
return <>{name}</>;
|
||||
}
|
||||
|
||||
const begin_idx = name.toLocaleLowerCase().indexOf(highlight.toLocaleLowerCase());
|
||||
if (begin_idx === -1) {
|
||||
return <>{name}</>;
|
||||
}
|
||||
|
||||
const end_idx = begin_idx + highlight.length;
|
||||
const begin = name.slice(0, begin_idx);
|
||||
const middle = name.slice(begin_idx, end_idx);
|
||||
const end = name.slice(end_idx);
|
||||
|
||||
return (
|
||||
<>
|
||||
{begin}
|
||||
<span className="inline-block bg-[#DBF3FF]">{middle}</span>
|
||||
{end}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function useSearchNameFilterProps<DataType>(
|
||||
getName: (value: any, data: DataType) => string
|
||||
): ColumnType<DataType> & { searchText: string; filterFn: (record: DataType) => boolean } {
|
||||
const [searchText, setSearchText] = useState('');
|
||||
const [searched, setSearched] = useState(false);
|
||||
const [visible, setVisible] = useState(false);
|
||||
|
||||
const filterFn = useCallback(
|
||||
(record: DataType) => {
|
||||
if (!searchText) return true;
|
||||
return getName(null, record).toLocaleLowerCase().includes(searchText.toLocaleLowerCase());
|
||||
},
|
||||
[searchText, getName]
|
||||
);
|
||||
|
||||
return {
|
||||
filterDropdown({ setSelectedKeys, confirm }) {
|
||||
return (
|
||||
<SearchFilterPanel
|
||||
onSearched={(value) => {
|
||||
setSelectedKeys([value]);
|
||||
setSearchText(value);
|
||||
setSearched(true);
|
||||
confirm({ closeDropdown: false });
|
||||
}}
|
||||
visible={visible}
|
||||
/>
|
||||
);
|
||||
},
|
||||
onFilterDropdownOpenChange: (visible) => setVisible(visible),
|
||||
render: (value, data) => (
|
||||
<HighlightName name={getName(value, data)} highlight={searchText} enable={searched} />
|
||||
),
|
||||
searchText,
|
||||
filterFn
|
||||
};
|
||||
}
|
||||
@@ -1,121 +0,0 @@
|
||||
import { Section } from '@/components/common/section/section';
|
||||
import Title from '@/components/common/title/title';
|
||||
import { useWatcher } from '@/hooks/useWatcher';
|
||||
import { KubeObject } from '@/k8slens/kube-object';
|
||||
import { KubeStoreAction } from '@/types/state';
|
||||
import { Table, TableProps } from 'antd';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { PanelPagination } from './pagination';
|
||||
import { useSearchNameFilterProps } from './search-filter';
|
||||
|
||||
type DefaultRecordType = Record<string, any>;
|
||||
|
||||
export interface DetailDrawerProps<T = any> {
|
||||
open: boolean;
|
||||
obj: T | null;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
interface Props<RecordType = unknown, Item = any> extends Omit<TableProps<RecordType>, 'title'> {
|
||||
sectionTitle: string;
|
||||
getRowKey: (record: RecordType) => string;
|
||||
getDetailItem: (record: RecordType) => Item;
|
||||
initializers: Array<KubeStoreAction<any>['initialize']>;
|
||||
DetailDrawer: (props: DetailDrawerProps<Item>) => JSX.Element | null;
|
||||
}
|
||||
|
||||
export const PanelTable = <RecordType extends DefaultRecordType, Item extends KubeObject>(
|
||||
tableProps: Props<RecordType, Item>
|
||||
) => {
|
||||
const {
|
||||
scroll = { x: true },
|
||||
sectionTitle: title,
|
||||
columns = [],
|
||||
DetailDrawer,
|
||||
getRowKey,
|
||||
getDetailItem,
|
||||
initializers
|
||||
} = tableProps;
|
||||
|
||||
const [selectedItem, setSelectedItem] = useState<Item | null>(null);
|
||||
const [openDetail, setOpenDetail] = useState(false);
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const PAGE_SIZE = 15;
|
||||
|
||||
const cxtHolder = useWatcher({ initializers });
|
||||
const searchNameFilterProps = useSearchNameFilterProps<RecordType>((_, record) =>
|
||||
getDetailItem(record).getName()
|
||||
);
|
||||
|
||||
const dataSource = useMemo(() => tableProps.dataSource || [], [tableProps.dataSource]);
|
||||
|
||||
// Apply filter based on searchText
|
||||
const filteredData = useMemo(() => {
|
||||
return dataSource.filter(searchNameFilterProps.filterFn);
|
||||
}, [dataSource, searchNameFilterProps.filterFn]);
|
||||
|
||||
const total = filteredData.length;
|
||||
|
||||
// Reset to page 1 when search text changes
|
||||
useEffect(() => {
|
||||
setCurrentPage(1);
|
||||
}, [searchNameFilterProps.searchText]);
|
||||
|
||||
// Calculate current page data after filtering
|
||||
const currentData = useMemo(() => {
|
||||
const start = (currentPage - 1) * PAGE_SIZE;
|
||||
const end = start + PAGE_SIZE;
|
||||
return filteredData.slice(start, end);
|
||||
}, [filteredData, currentPage]);
|
||||
|
||||
const modifiedCols = useMemo(() => {
|
||||
const { searchText, filterFn, ...filterProps } = searchNameFilterProps;
|
||||
return [
|
||||
{
|
||||
title: 'Name',
|
||||
key: 'name',
|
||||
className: 'min-w-[150px]',
|
||||
ellipsis: true,
|
||||
filteredValue: searchText ? [searchText] : [],
|
||||
...filterProps
|
||||
},
|
||||
...columns
|
||||
];
|
||||
}, [columns, searchNameFilterProps]);
|
||||
|
||||
return (
|
||||
<Section>
|
||||
{cxtHolder}
|
||||
<Title type="primary">{title}</Title>
|
||||
<Table
|
||||
className="kubepanel-table"
|
||||
size="middle"
|
||||
{...tableProps}
|
||||
dataSource={currentData}
|
||||
columns={modifiedCols}
|
||||
rowKey={getRowKey}
|
||||
scroll={scroll}
|
||||
pagination={false}
|
||||
rowClassName="cursor-pointer"
|
||||
onRow={(record) => ({
|
||||
onClick: () => {
|
||||
setSelectedItem(getDetailItem(record));
|
||||
setOpenDetail(true);
|
||||
}
|
||||
})}
|
||||
/>
|
||||
{total > 0 && (
|
||||
<PanelPagination
|
||||
total={total}
|
||||
current={currentPage}
|
||||
pageSize={PAGE_SIZE}
|
||||
onChange={(page) => setCurrentPage(page)}
|
||||
/>
|
||||
)}
|
||||
{/* nextjs prerender fix */}
|
||||
{DetailDrawer && (
|
||||
<DetailDrawer open={openDetail} obj={selectedItem} onClose={() => setOpenDetail(false)} />
|
||||
)}
|
||||
</Section>
|
||||
);
|
||||
};
|
||||
@@ -1,17 +0,0 @@
|
||||
interface SectionProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a section component with the provided children.
|
||||
*
|
||||
* @param {SectionProps} children - The children to render within the section.
|
||||
* @return {JSX.Element} The rendered section component.
|
||||
*/
|
||||
export function Section({ children }: SectionProps) {
|
||||
return (
|
||||
<section className="w-full bg-white px-6 py-5 flex flex-col flex-wrap gap-4 rounded-lg border border-[#E8E8E8]">
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,108 +0,0 @@
|
||||
import {
|
||||
AppstoreOutlined,
|
||||
DashboardOutlined,
|
||||
DatabaseOutlined,
|
||||
GatewayOutlined,
|
||||
ReloadOutlined,
|
||||
SettingOutlined
|
||||
} from '@ant-design/icons';
|
||||
import { Button, Flex, Menu, MenuProps } from 'antd';
|
||||
import { useRouter } from 'next/router';
|
||||
|
||||
type MenuItem = Required<MenuProps>['items'][number];
|
||||
|
||||
export enum SideNavItemKey {
|
||||
Overview = 'overview',
|
||||
Pod = 'pod',
|
||||
Deployment = 'deployment',
|
||||
ConfigMap = 'config-map',
|
||||
PersistentVolumeClaim = 'volume-claim',
|
||||
StatefulSet = 'stateful-set',
|
||||
Secret = 'secret',
|
||||
Ingress = 'ingress',
|
||||
Service = 'service',
|
||||
// CustomResource
|
||||
Devbox = 'devbox',
|
||||
Cluster = 'cluster',
|
||||
Bucket = 'bucket'
|
||||
}
|
||||
|
||||
function getItem(
|
||||
label: React.ReactNode,
|
||||
key: React.Key,
|
||||
icon?: React.ReactNode,
|
||||
children?: MenuItem[],
|
||||
type?: 'group'
|
||||
): MenuItem {
|
||||
return {
|
||||
key,
|
||||
icon,
|
||||
children,
|
||||
label,
|
||||
type
|
||||
} as MenuItem;
|
||||
}
|
||||
|
||||
const items: MenuProps['items'] = [
|
||||
getItem('Workload', 'workload', <DashboardOutlined />, [
|
||||
getItem('Overview', SideNavItemKey.Overview),
|
||||
getItem('Pods', SideNavItemKey.Pod),
|
||||
getItem('Deployments', SideNavItemKey.Deployment),
|
||||
getItem('Stateful Sets', SideNavItemKey.StatefulSet)
|
||||
]),
|
||||
getItem('Config', 'config', <SettingOutlined />, [
|
||||
getItem('Config Maps', SideNavItemKey.ConfigMap),
|
||||
getItem('Secrets', SideNavItemKey.Secret)
|
||||
]),
|
||||
getItem('Network', 'network', <GatewayOutlined />, [
|
||||
getItem('Ingress', SideNavItemKey.Ingress),
|
||||
getItem('Service', SideNavItemKey.Service)
|
||||
]),
|
||||
getItem('Storage', 'storage', <DatabaseOutlined />, [
|
||||
getItem('Persistent Volume Claims', SideNavItemKey.PersistentVolumeClaim)
|
||||
]),
|
||||
getItem('CustomResource', 'custom-resource', <AppstoreOutlined />, [
|
||||
getItem('Devbox', SideNavItemKey.Devbox),
|
||||
getItem('Cluster', SideNavItemKey.Cluster),
|
||||
getItem('Bucket', SideNavItemKey.Bucket)
|
||||
])
|
||||
];
|
||||
|
||||
interface Props {
|
||||
onClick?: (key: SideNavItemKey) => void;
|
||||
selectedKey?: SideNavItemKey;
|
||||
}
|
||||
|
||||
export const ResourceSideNav = ({
|
||||
onClick = () => {},
|
||||
selectedKey = SideNavItemKey.Overview
|
||||
}: Props) => {
|
||||
const router = useRouter();
|
||||
|
||||
return (
|
||||
<Flex vertical className="h-full bg-[#FAFAFA] border-r border-[#E8E8E8]">
|
||||
<div className="px-4.5 py-3 w-full">
|
||||
<div className="flex justify-between items-center">
|
||||
<div className="text-[#262626] text-[16px] font-semibold pl-2 align-middle">
|
||||
<span>KubePanel</span>
|
||||
</div>
|
||||
<Button
|
||||
className="flex items-center justify-center hover:bg-black/5"
|
||||
type="text"
|
||||
icon={<ReloadOutlined style={{ color: '#18181B', fontSize: 'large' }} />}
|
||||
onClick={() => router.reload()}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Menu
|
||||
className="kubepanel-sidebar-menu"
|
||||
style={{ backgroundColor: 'transparent', borderRight: 'none', fontWeight: 500 }}
|
||||
selectedKeys={[selectedKey]}
|
||||
defaultOpenKeys={['workload']}
|
||||
mode="inline"
|
||||
items={items}
|
||||
onClick={({ key }) => onClick(key as SideNavItemKey)}
|
||||
/>
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
@@ -1,14 +0,0 @@
|
||||
import { Tag } from 'antd';
|
||||
import React from 'react';
|
||||
|
||||
// Fix for "Tag cannot be used as a JSX component" lint error
|
||||
const AnyTag = Tag as any;
|
||||
|
||||
interface StatusTagProps {
|
||||
color?: string;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export const StatusTag = ({ color, children }: StatusTagProps) => {
|
||||
return <AnyTag color={color}>{children}</AnyTag>;
|
||||
};
|
||||
@@ -1,27 +0,0 @@
|
||||
interface PageTitleProps {
|
||||
children: string | string[];
|
||||
className?: string;
|
||||
type: 'primary' | 'secondary' | 'table';
|
||||
}
|
||||
|
||||
const titleClassName: Record<PageTitleProps['type'], string> = {
|
||||
primary: 'text-2xl font-medium',
|
||||
secondary: 'text-xl font-medium',
|
||||
table: 'text-xs font-medium text-gray-500'
|
||||
};
|
||||
|
||||
/**
|
||||
* Renders a title component with the specified type and children.
|
||||
*
|
||||
* @param {PageTitleProps} props - The properties of the title component.
|
||||
* @param {ReactNode} props.children - The content of the title component.
|
||||
* @param {string} props.type - The type of the title component.
|
||||
* @return {JSX.Element} The rendered title component.
|
||||
*/
|
||||
export default function Title({ children, type, className }: PageTitleProps) {
|
||||
return (
|
||||
<div className={className}>
|
||||
<span className={titleClassName[type]}>{children}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
interface KubeBadgeProps {
|
||||
label: React.ReactNode;
|
||||
expandedLabel?: React.ReactNode;
|
||||
disabled?: boolean;
|
||||
expandable?: boolean;
|
||||
color?: { textColor?: string; backgroundColor?: string };
|
||||
}
|
||||
|
||||
export const KubeBadge = ({
|
||||
label,
|
||||
expandedLabel,
|
||||
expandable = true,
|
||||
color,
|
||||
disabled = false,
|
||||
className
|
||||
}: KubeBadgeProps & { className?: string }) => {
|
||||
const spanRef = useRef<HTMLSpanElement>(null);
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
const [isExpandable, setIsExpandable] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const checkExpandable = () => {
|
||||
if (spanRef.current) {
|
||||
const { offsetWidth = 0, scrollWidth = 0 } = spanRef.current;
|
||||
setIsExpandable((expandable && offsetWidth < scrollWidth) || !!expandedLabel);
|
||||
}
|
||||
};
|
||||
|
||||
checkExpandable();
|
||||
// 添加延迟检查以确保渲染完成
|
||||
const timer = setTimeout(checkExpandable, 100);
|
||||
return () => clearTimeout(timer);
|
||||
}, [expandable, expandedLabel, label]);
|
||||
|
||||
const onClick = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (isExpandable) {
|
||||
setIsExpanded(!isExpanded);
|
||||
}
|
||||
e.stopPropagation();
|
||||
};
|
||||
|
||||
const { textColor, backgroundColor } = color ?? {};
|
||||
const disabledClass = disabled && 'opacity-50 cursor-not-allowed';
|
||||
const expandableClass = isExpandable && 'cursor-pointer';
|
||||
const bgColorClass = backgroundColor ? `bg-${backgroundColor}` : 'bg-[#FDFDFD]';
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`py-0.5 px-2 rounded-xl ${!isExpanded ? 'max-w-125' : ''} ${disabledClass} ${expandableClass} ${bgColorClass} ${className}`}
|
||||
style={{ border: '0.5px solid #D4D4D8' }}
|
||||
onClick={onClick}
|
||||
>
|
||||
<span
|
||||
ref={spanRef}
|
||||
className={`block text-xs font-medium ${textColor ? `text-${textColor}` : 'text-black'} ${isExpanded ? 'whitespace-pre-wrap break-all' : 'truncate'}`}
|
||||
>
|
||||
{isExpanded && expandedLabel ? expandedLabel : label}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,10 +0,0 @@
|
||||
import moment from 'moment-timezone';
|
||||
|
||||
export type LocaleDateProps = {
|
||||
date: string;
|
||||
localeTimezone: string;
|
||||
};
|
||||
|
||||
export const LocaleDate = ({ date, localeTimezone }: LocaleDateProps) => (
|
||||
<>{`${moment.tz(date, localeTimezone).format()}`}</>
|
||||
);
|
||||
-169
@@ -1,169 +0,0 @@
|
||||
import { DrawerItem } from '@/components/common/drawer/drawer-item';
|
||||
import { KubeObject } from '@/k8slens/kube-object';
|
||||
import { getKubeObjectInfo } from '@/utils/kube-object-info';
|
||||
import { getStatusTextTone } from '@/utils/status-color';
|
||||
import { Box } from '@chakra-ui/react';
|
||||
import { Divider } from 'antd';
|
||||
import { KubeBadge } from '../../kube-badge';
|
||||
import { KubeObjectAge } from '../kube-object-age';
|
||||
|
||||
interface Props {
|
||||
obj: KubeObject;
|
||||
}
|
||||
|
||||
export const KubeObjectInfoList = ({ obj }: Props) => {
|
||||
const {
|
||||
creationTimestamp,
|
||||
name,
|
||||
uid,
|
||||
resourceVersion,
|
||||
labels,
|
||||
annotations,
|
||||
finalizers,
|
||||
ownerRefs
|
||||
} = getKubeObjectInfo(obj);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="[&>div:first-child]:pt-0 [&>div:last-child]:pb-0">
|
||||
<div className="flex items-center justify-between py-2 border-b border-[#E8E8E8]">
|
||||
<div>
|
||||
<span className="text-[#8C8C8C] font-medium text-sm block mb-1">Name</span>
|
||||
<span className="text-[#262626] text-xs">{name}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-[#8C8C8C] font-medium text-sm block mb-1">Created</span>
|
||||
<span className="text-[#262626] text-xs">
|
||||
<KubeObjectAge obj={obj} compact={false} />
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-[#8C8C8C] font-medium text-sm block mb-1">Status</span>
|
||||
<span className="text-[#262626] text-xs">
|
||||
{(() => {
|
||||
const status =
|
||||
(obj as any).getStatusMessage?.() ||
|
||||
(obj as any).getStatus?.() ||
|
||||
(obj as any).status?.phase ||
|
||||
'-';
|
||||
const textTone = getStatusTextTone(status);
|
||||
const colorClass =
|
||||
textTone === 'success'
|
||||
? 'text-green-600'
|
||||
: textTone === 'warning'
|
||||
? 'text-yellow-600'
|
||||
: textTone === 'danger'
|
||||
? 'text-red-600'
|
||||
: 'text-[#262626]';
|
||||
return <span className={colorClass}>{status}</span>;
|
||||
})()}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<DrawerItem name="UID" value={<span className="text-xs">{uid}</span>} />
|
||||
{finalizers.length > 0 && (
|
||||
<DrawerItem
|
||||
name="Finalizers"
|
||||
value={
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{finalizers.map((label) => (
|
||||
<KubeBadge key={label} label={label} />
|
||||
))}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{ownerRefs.length > 0 && (
|
||||
<DrawerItem
|
||||
name="ControlledBy"
|
||||
value={ownerRefs.map(({ name, kind }) => (
|
||||
<Box key={name} className="text-xs">
|
||||
{kind}{' '}
|
||||
<Box as="span" color={'blue.300'}>
|
||||
{name}
|
||||
</Box>
|
||||
</Box>
|
||||
))}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<Divider />
|
||||
{(labels.length > 0 || annotations.length > 0) && (
|
||||
<div className="[&>div:first-child]:pt-0 [&>div:last-child]:pb-0">
|
||||
{labels.length > 0 && (
|
||||
<DrawerItem
|
||||
name="Labels"
|
||||
vertical
|
||||
value={
|
||||
<div className="flex flex-wrap gap-1 items-start">
|
||||
{[...labels].sort().map((label) => {
|
||||
const sepIndex = label.indexOf(': ');
|
||||
if (sepIndex === -1)
|
||||
return <KubeBadge key={label} label={label} className="m-0!" />;
|
||||
const key = label.slice(0, sepIndex);
|
||||
const value = label.slice(sepIndex + 2);
|
||||
return (
|
||||
<KubeBadge
|
||||
key={label}
|
||||
label={
|
||||
<span>
|
||||
<span className="font-medium text-gray-900">{key}</span>
|
||||
<span className="text-gray-900 mr-1">:</span>
|
||||
<span className="text-gray-600">{value}</span>
|
||||
</span>
|
||||
}
|
||||
className="m-0!"
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{annotations.length > 0 && (
|
||||
<DrawerItem
|
||||
name="Annotations"
|
||||
vertical
|
||||
value={
|
||||
<div className="flex flex-wrap gap-1 items-start">
|
||||
{[...annotations].sort().map((label) => {
|
||||
if (label.startsWith('kubectl.kubernetes.io/last-applied-configuration')) {
|
||||
return (
|
||||
<KubeBadge
|
||||
key={label}
|
||||
label="kubectl.kubernetes.io/last-applied-configuration: -"
|
||||
expandedLabel={label}
|
||||
className="m-0!"
|
||||
/>
|
||||
);
|
||||
}
|
||||
const sepIndex = label.indexOf(': ');
|
||||
if (sepIndex === -1)
|
||||
return <KubeBadge key={label} label={label} className="m-0!" />;
|
||||
const key = label.slice(0, sepIndex);
|
||||
const value = label.slice(sepIndex + 2);
|
||||
|
||||
return (
|
||||
<KubeBadge
|
||||
key={label}
|
||||
label={
|
||||
<span>
|
||||
<span className="font-medium text-gray-900">{key}</span>
|
||||
<span className="text-gray-400 mr-1">:</span>
|
||||
<span className="text-gray-600">{value}</span>
|
||||
</span>
|
||||
}
|
||||
className="m-0!"
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<>{(labels.length > 0 || annotations.length > 0) && <Divider />}</>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -1,246 +0,0 @@
|
||||
import {
|
||||
Affinity,
|
||||
LabelMatchExpression,
|
||||
LabelSelector,
|
||||
NodeAffinity,
|
||||
PodAffinity,
|
||||
SpecificAffinity
|
||||
} from '@/k8slens/kube-object';
|
||||
import React from 'react';
|
||||
|
||||
interface Props {
|
||||
affinity: Affinity;
|
||||
}
|
||||
|
||||
// --- Helper: Render a single LabelMatchExpression as natural language ---
|
||||
const renderExpression = (expr: LabelMatchExpression): string => {
|
||||
const { key, operator, values } = expr;
|
||||
switch (operator) {
|
||||
case 'In':
|
||||
return `${key} in [${values?.join(', ') || ''}]`;
|
||||
case 'NotIn':
|
||||
return `${key} not in [${values?.join(', ') || ''}]`;
|
||||
case 'Exists':
|
||||
return `${key} exists`;
|
||||
case 'DoesNotExist':
|
||||
return `${key} does not exist`;
|
||||
default:
|
||||
return `${key} ${operator} ${(values as string[] | undefined)?.join(', ') || ''}`;
|
||||
}
|
||||
};
|
||||
|
||||
// --- Helper: Render LabelSelector as badges ---
|
||||
const SelectorBadges = ({ selector }: { selector?: LabelSelector }) => {
|
||||
if (!selector) return null;
|
||||
|
||||
const labels = selector.matchLabels ? Object.entries(selector.matchLabels) : [];
|
||||
const expressions = selector.matchExpressions || [];
|
||||
|
||||
if (labels.length === 0 && expressions.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-1 items-start mb-1">
|
||||
{labels.map(([k, v]) => (
|
||||
<span
|
||||
key={k}
|
||||
className="inline-block bg-gray-100 text-gray-700 px-2 py-0.5 rounded text-xs border border-gray-200 break-all whitespace-normal"
|
||||
>
|
||||
<span className="font-medium text-gray-900">{k}</span>
|
||||
<span className="text-gray-900 mr-1">:</span>
|
||||
<span className="text-gray-600">{v}</span>
|
||||
</span>
|
||||
))}
|
||||
{expressions.map((expr, i) => (
|
||||
<span
|
||||
key={i}
|
||||
className="inline-block bg-blue-50 text-blue-700 px-2 py-0.5 rounded text-xs border border-blue-200 break-all whitespace-normal"
|
||||
>
|
||||
{renderExpression(expr)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// --- NodeAffinity Rendering ---
|
||||
const NodeAffinityCard = ({ affinity }: { affinity: SpecificAffinity<NodeAffinity> }) => {
|
||||
const required =
|
||||
(affinity.requiredDuringSchedulingIgnoredDuringExecution as any)?.nodeSelectorTerms || [];
|
||||
const preferred = affinity.preferredDuringSchedulingIgnoredDuringExecution || [];
|
||||
|
||||
if (required.length === 0 && preferred.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="font-medium text-gray-900 text-xs">Node Affinity</div>
|
||||
{required.map((term: any, i: number) => (
|
||||
<div key={`req-${i}`} className="flex flex-wrap items-center gap-1.5 text-xs">
|
||||
<span className="inline-block bg-red-100 text-red-700 px-1.5 py-0.5 rounded text-xs border border-red-200 font-medium">
|
||||
Required
|
||||
</span>
|
||||
<span className="text-gray-500 mr-1">:</span>
|
||||
<span className="text-gray-500">node</span>
|
||||
{term.matchExpressions?.map((expr: any, k: number) => (
|
||||
<div key={k} className="contents">
|
||||
{k > 0 && <span className="text-gray-500">and</span>}
|
||||
<span className="font-semibold text-gray-900 break-all">{expr.key}</span>
|
||||
<span className="text-gray-500">{expr.operator.toLowerCase()}</span>
|
||||
{expr.values && (
|
||||
<span className="font-semibold text-gray-900 break-all">
|
||||
[{expr.values.join(', ')}]
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{term.matchFields?.map((expr: any, k: number) => (
|
||||
<div key={`field-${k}`} className="contents">
|
||||
{(k > 0 || (term.matchExpressions?.length ?? 0) > 0) && (
|
||||
<span className="text-gray-500">and</span>
|
||||
)}
|
||||
<span className="font-semibold text-gray-900 break-all">{expr.key}</span>
|
||||
<span className="text-gray-500">{expr.operator.toLowerCase()}</span>
|
||||
{expr.values && (
|
||||
<span className="font-semibold text-gray-900 break-all">
|
||||
[{expr.values.join(', ')}]
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{preferred.map((pref: any, i: number) => (
|
||||
<div key={`pref-${i}`} className="flex flex-wrap items-center gap-1.5 text-xs">
|
||||
<span className="inline-block bg-orange-100 text-orange-700 px-1.5 py-0.5 rounded text-xs border border-orange-200 font-medium">
|
||||
Preferred
|
||||
</span>
|
||||
<span className="text-gray-400 text-xs">(weight: {pref.weight})</span>
|
||||
<span className="text-gray-500 mr-1">:</span>
|
||||
<span className="text-gray-500">node</span>
|
||||
{pref.preference?.matchExpressions?.map((expr: any, j: number) => (
|
||||
<div key={j} className="contents">
|
||||
{j > 0 && <span className="text-gray-500">and</span>}
|
||||
<span className="font-semibold text-gray-900 break-all">{expr.key}</span>
|
||||
<span className="text-gray-500">{expr.operator.toLowerCase()}</span>
|
||||
{expr.values && (
|
||||
<span className="font-semibold text-gray-900 break-all">
|
||||
[{expr.values.join(', ')}]
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// --- PodAffinity / PodAntiAffinity Rendering ---
|
||||
const PodAffinityCard = ({
|
||||
title,
|
||||
affinity
|
||||
}: {
|
||||
title: string;
|
||||
affinity: SpecificAffinity<PodAffinity>;
|
||||
}) => {
|
||||
const isAnti = title.includes('Anti');
|
||||
const required = affinity.requiredDuringSchedulingIgnoredDuringExecution;
|
||||
const preferred = affinity.preferredDuringSchedulingIgnoredDuringExecution;
|
||||
|
||||
const hasRequired = (required?.length || 0) > 0;
|
||||
const hasPreferred = (preferred?.length || 0) > 0;
|
||||
|
||||
if (!hasRequired && !hasPreferred) return null;
|
||||
|
||||
const actionText = isAnti ? 'Avoid pods' : 'Run with pods';
|
||||
|
||||
const renderSelector = (selector?: LabelSelector) => {
|
||||
if (!selector) return <span className="italic text-gray-400">any</span>;
|
||||
const parts: React.ReactNode[] = [];
|
||||
if (selector.matchLabels) {
|
||||
Object.entries(selector.matchLabels).forEach(([k, v]) => {
|
||||
parts.push(
|
||||
<span key={`l-${k}`} className="font-semibold text-gray-900 break-all">
|
||||
{k}={v}
|
||||
</span>
|
||||
);
|
||||
});
|
||||
}
|
||||
if (selector.matchExpressions) {
|
||||
selector.matchExpressions.forEach((expr, i) => {
|
||||
parts.push(
|
||||
<span key={`e-${i}`} className="font-semibold text-gray-900 break-all">
|
||||
{expr.key} {expr.operator.toLowerCase()}{' '}
|
||||
{expr.values ? `[${expr.values.join(', ')}]` : ''}
|
||||
</span>
|
||||
);
|
||||
});
|
||||
}
|
||||
if (parts.length === 0) return <span className="italic text-gray-400">any</span>;
|
||||
return (
|
||||
<span className="inline-flex flex-wrap gap-1">
|
||||
{parts.map((p, i) => (
|
||||
<React.Fragment key={i}>
|
||||
{i > 0 && <span className="text-gray-400">,</span>}
|
||||
{p}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="font-medium text-gray-900 text-xs">{title}</div>
|
||||
{required?.map((term, i) => (
|
||||
<div key={`req-${i}`} className="flex flex-wrap items-center gap-1.5 text-xs">
|
||||
<span className="inline-block bg-red-100 text-red-700 px-1.5 py-0.5 rounded text-xs border border-red-200 font-medium">
|
||||
Required
|
||||
</span>
|
||||
<span className="text-gray-500 mr-1">:</span>
|
||||
<span className="text-gray-500">{actionText.toLowerCase()}</span>
|
||||
{renderSelector(term.labelSelector)}
|
||||
<span className="text-gray-500">in</span>
|
||||
<span className="font-semibold text-gray-900 break-all">{term.topologyKey}</span>
|
||||
{(term as any).namespaces && (
|
||||
<span className="text-gray-400 text-xs">
|
||||
(ns: {(term as any).namespaces.join(', ')})
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{preferred?.map((pref, i) => {
|
||||
const weight = (pref as any).weight;
|
||||
const term = (pref as any).podAffinityTerm || pref;
|
||||
|
||||
return (
|
||||
<div key={`pref-${i}`} className="flex flex-wrap items-center gap-1.5 text-xs">
|
||||
<span className="inline-block bg-orange-100 text-orange-700 px-1.5 py-0.5 rounded text-xs border border-orange-200 font-medium">
|
||||
Preferred
|
||||
</span>
|
||||
<span className="text-gray-400 text-xs">(weight: {weight})</span>
|
||||
<span className="text-gray-500 mr-1">:</span>
|
||||
<span className="text-gray-500">{actionText.toLowerCase()}</span>
|
||||
{renderSelector(term.labelSelector)}
|
||||
<span className="text-gray-500">in</span>
|
||||
<span className="font-semibold text-gray-900 break-all">{term.topologyKey}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// --- Main Component ---
|
||||
export const KubeObjectAffinities = ({ affinity }: Props) => {
|
||||
const { nodeAffinity, podAffinity, podAntiAffinity } = affinity;
|
||||
|
||||
return (
|
||||
<div className="grid gap-3 grid-cols-1">
|
||||
{nodeAffinity && <NodeAffinityCard affinity={nodeAffinity} />}
|
||||
{podAffinity && <PodAffinityCard title="Pod Affinity" affinity={podAffinity} />}
|
||||
{podAntiAffinity && <PodAffinityCard title="Pod Anti-Affinity" affinity={podAntiAffinity} />}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,24 +0,0 @@
|
||||
/**
|
||||
* Copyright (c) OpenLens Authors. All rights reserved.
|
||||
* Licensed under MIT License. See LICENSE in root directory for more information.
|
||||
*/
|
||||
|
||||
import { ReactiveDuration } from '../reactive-duration';
|
||||
|
||||
export interface KubeObjectAgeProps {
|
||||
obj: {
|
||||
metadata: {
|
||||
creationTimestamp?: string;
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Whether the display string should prefer length over precision
|
||||
* @default true
|
||||
*/
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
export const KubeObjectAge = ({ obj, compact = true }: KubeObjectAgeProps) => (
|
||||
<ReactiveDuration timestamp={obj.metadata.creationTimestamp} compact={compact} />
|
||||
);
|
||||
@@ -1,42 +0,0 @@
|
||||
import { Toleration } from '@/k8slens/kube-object';
|
||||
|
||||
interface Props {
|
||||
tolerations: Array<Toleration>;
|
||||
}
|
||||
|
||||
export const KubeObjectTolerations = ({ tolerations }: Props) => {
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
{tolerations.map((toleration, index) => {
|
||||
const op = (toleration.operator || 'Equal').toLowerCase();
|
||||
return (
|
||||
<div key={index} className="flex flex-wrap items-center gap-1.5 text-xs">
|
||||
<span className="text-gray-500">when</span>
|
||||
<span className="font-semibold text-gray-900 break-all">
|
||||
{toleration.key || 'all keys'}
|
||||
</span>
|
||||
<span className="text-gray-500">{op === 'exists' ? 'exists' : 'equals'}</span>
|
||||
{op !== 'exists' && (
|
||||
<span className="font-semibold text-gray-900 break-all">
|
||||
{toleration.value !== undefined ? toleration.value : '""'}
|
||||
</span>
|
||||
)}
|
||||
<span className="text-gray-500 mr-1">:</span>
|
||||
{toleration.effect ? (
|
||||
<span className="inline-block bg-orange-100 text-orange-700 px-1.5 py-0.5 rounded text-xs border border-orange-200 font-medium">
|
||||
{toleration.effect}
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-block bg-gray-100 text-gray-700 px-1.5 py-0.5 rounded text-xs border border-gray-200 font-medium">
|
||||
All effects
|
||||
</span>
|
||||
)}
|
||||
{typeof toleration.tolerationSeconds === 'number' && (
|
||||
<span className="text-gray-500 ml-1">({toleration.tolerationSeconds}s)</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,83 +0,0 @@
|
||||
import { Tag, Tooltip } from 'antd';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
export const ResponsiveKeyList = ({ keys }: { keys: string[] }) => {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [visibleCount, setVisibleCount] = useState(3);
|
||||
const [containerWidth, setContainerWidth] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (!containerRef.current) return;
|
||||
const observer = new ResizeObserver((entries) => {
|
||||
for (const entry of entries) {
|
||||
setContainerWidth(entry.contentRect.width);
|
||||
}
|
||||
});
|
||||
observer.observe(containerRef.current);
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (containerWidth === 0) return;
|
||||
let currentWidth = 0;
|
||||
let count = 0;
|
||||
const GAP = 4;
|
||||
const OVERFLOW_TAG_WIDTH = 45; // Width for +99 tag
|
||||
const TAG_PADDING = 16;
|
||||
const CHAR_WIDTH = 7.5; // Approx width for standard font
|
||||
|
||||
for (let i = 0; i < keys.length; i++) {
|
||||
const key = keys[i];
|
||||
if (!key) continue;
|
||||
const keyWidth = key.length * CHAR_WIDTH + TAG_PADDING;
|
||||
|
||||
// If this is the last item and it fits, good.
|
||||
// If not last, we need to check if it fits ALONG with the overflow tag space
|
||||
if (i === keys.length - 1) {
|
||||
if (currentWidth + keyWidth <= containerWidth) {
|
||||
count++;
|
||||
}
|
||||
} else {
|
||||
// Check if adding this key + potential overflow tag fits
|
||||
if (currentWidth + keyWidth + GAP + OVERFLOW_TAG_WIDTH <= containerWidth) {
|
||||
currentWidth += keyWidth + GAP;
|
||||
count++;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Always show at least 1 if possible, or 0 if width is tiny
|
||||
setVisibleCount(Math.max(1, count));
|
||||
}, [containerWidth, keys]);
|
||||
|
||||
if (keys.length === 0) return <>-</>;
|
||||
|
||||
const visibleKeys = keys.slice(0, visibleCount);
|
||||
const hiddenKeys = keys.slice(visibleCount);
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="w-full flex flex-wrap gap-1 overflow-hidden h-6">
|
||||
{visibleKeys.map((key) => (
|
||||
<Tag key={key} className="mr-0" bordered={false}>
|
||||
{key}
|
||||
</Tag>
|
||||
))}
|
||||
{hiddenKeys.length > 0 && (
|
||||
<Tooltip
|
||||
title={
|
||||
<div className="flex flex-col gap-1">
|
||||
{hiddenKeys.map((key) => (
|
||||
<span key={key}>{key}</span>
|
||||
))}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Tag className="cursor-pointer mr-0" bordered={false}>
|
||||
+{hiddenKeys.length}
|
||||
</Tag>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,67 +0,0 @@
|
||||
/**
|
||||
* Copyright (c) OpenLens Authors. All rights reserved.
|
||||
* Licensed under MIT License. See LICENSE in root directory for more information.
|
||||
*/
|
||||
|
||||
import { formatDuration } from '@/k8slens/utilities';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
export interface ReactiveDurationProps {
|
||||
timestamp: string | undefined;
|
||||
|
||||
/**
|
||||
* Whether the display string should prefer length over precision
|
||||
* @default true
|
||||
*/
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
const everySecond = 1000;
|
||||
const everyMinute = 60 * 1000;
|
||||
|
||||
/**
|
||||
* This function computes a reasonable update interval, matching `formatDuration`'s rules on when to display seconds
|
||||
*/
|
||||
function computeUpdateInterval(creationTimestampEpoch: number, compact: boolean): number {
|
||||
const seconds = Math.floor((Date.now() - creationTimestampEpoch) / 1000);
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
|
||||
if (minutes < 10) {
|
||||
return everySecond;
|
||||
}
|
||||
|
||||
if (compact) {
|
||||
return everyMinute;
|
||||
}
|
||||
|
||||
if (minutes < 60 * 3) {
|
||||
return everySecond;
|
||||
}
|
||||
|
||||
return everyMinute;
|
||||
}
|
||||
|
||||
export const ReactiveDuration = ({ timestamp, compact = true }: ReactiveDurationProps) => {
|
||||
if (!timestamp) {
|
||||
return <><unknown></>;
|
||||
}
|
||||
const timestampSeconds = new Date(timestamp).getTime();
|
||||
|
||||
const [duration, setDuration] = useState(Date.now() - timestampSeconds);
|
||||
const [ms, setMs] = useState(computeUpdateInterval(timestampSeconds, compact));
|
||||
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
const nextMs = computeUpdateInterval(timestampSeconds, compact);
|
||||
if (ms !== nextMs) {
|
||||
setMs(computeUpdateInterval(timestampSeconds, compact));
|
||||
clearInterval(interval);
|
||||
}
|
||||
setDuration(Date.now() - timestampSeconds);
|
||||
}, ms);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [compact, ms, timestampSeconds]);
|
||||
|
||||
return <span suppressHydrationWarning>{formatDuration(duration, compact)}</span>;
|
||||
};
|
||||
-195
@@ -1,195 +0,0 @@
|
||||
import { updateResource } from '@/api/kubernetes';
|
||||
import { Drawer } from '@/components/common/drawer/drawer';
|
||||
import { DrawerPanel } from '@/components/common/drawer/drawer-panel';
|
||||
import { KubeObjectInfoList } from '@/components/kube/object/detail/kube-object-detail-info-list';
|
||||
import { ConfigMap } from '@/k8slens/kube-object';
|
||||
import { useConfigMapStore } from '@/store/kube';
|
||||
import { dumpKubeObject } from '@/utils/yaml';
|
||||
import { CloseOutlined, EditOutlined, SaveOutlined } from '@ant-design/icons';
|
||||
import { Button, Input } from 'antd';
|
||||
import useMessage from 'antd/lib/message/useMessage';
|
||||
import { entries, isEqual } from 'lodash';
|
||||
import React from 'react';
|
||||
|
||||
const ConfigMapDetail = ({ obj: configMap, open, onClose }: DetailDrawerProps<ConfigMap>) => {
|
||||
if (!configMap || !(configMap instanceof ConfigMap)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const [isEditing, setIsEditing] = React.useState(false);
|
||||
const [isSaving, setIsSaving] = React.useState(false);
|
||||
const [msgApi, contextHolder] = useMessage();
|
||||
const { initialize } = useConfigMapStore();
|
||||
|
||||
const originalData = React.useMemo(() => {
|
||||
const map: Record<string, string> = {};
|
||||
entries(configMap.data).forEach(([key, val]) => {
|
||||
map[key] = val ?? '';
|
||||
});
|
||||
return map;
|
||||
}, [configMap.data]);
|
||||
|
||||
const [formValues, setFormValues] = React.useState<Record<string, string>>(originalData);
|
||||
|
||||
React.useEffect(() => {
|
||||
setFormValues(originalData);
|
||||
}, [originalData]);
|
||||
|
||||
// Reset editing state when drawer closes or ConfigMap changes
|
||||
React.useEffect(() => {
|
||||
if (!open) {
|
||||
setIsEditing(false);
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
const id = configMap?.getId();
|
||||
React.useEffect(() => {
|
||||
setIsEditing(false);
|
||||
}, [id]);
|
||||
|
||||
const data = Array.from(entries(formValues));
|
||||
const simpleData = data.filter(([_, val]) => !val?.includes('\n') && (val?.length || 0) < 50);
|
||||
const complexData = data.filter(([_, val]) => val?.includes('\n') || (val?.length || 0) >= 50);
|
||||
|
||||
const isModified = !isEqual(originalData, formValues);
|
||||
|
||||
const handleSave = () => {
|
||||
setIsSaving(true);
|
||||
updateResource(
|
||||
configMap.kind,
|
||||
configMap.getName(),
|
||||
dumpKubeObject<ConfigMap>({
|
||||
...configMap,
|
||||
data: formValues
|
||||
})
|
||||
)
|
||||
.then(() => {
|
||||
msgApi.success('Saved');
|
||||
initialize(() => {}, true);
|
||||
setIsEditing(false);
|
||||
})
|
||||
.catch((err) => {
|
||||
msgApi.error(err?.message || 'Save failed');
|
||||
})
|
||||
.finally(() => setIsSaving(false));
|
||||
};
|
||||
|
||||
return (
|
||||
<Drawer open={open} title={`ConfigMap: ${configMap.getName()}`} onClose={onClose}>
|
||||
<div className="flex flex-col">
|
||||
<DrawerPanel>
|
||||
<KubeObjectInfoList obj={configMap} />
|
||||
</DrawerPanel>
|
||||
|
||||
{data.length > 0 && (
|
||||
<div className="flex flex-col gap-6 pb-6">
|
||||
{contextHolder}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-sm font-medium text-gray-900">Data</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{!isEditing && (
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
icon={<EditOutlined />}
|
||||
onClick={() => setIsEditing(true)}
|
||||
className="px-0"
|
||||
>
|
||||
Edit
|
||||
</Button>
|
||||
)}
|
||||
{isEditing && (
|
||||
<>
|
||||
<Button
|
||||
size="small"
|
||||
icon={<CloseOutlined />}
|
||||
onClick={() => {
|
||||
setIsEditing(false);
|
||||
setFormValues(originalData);
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
size="small"
|
||||
icon={<SaveOutlined />}
|
||||
loading={isSaving}
|
||||
onClick={handleSave}
|
||||
disabled={!isModified}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{simpleData.length > 0 && (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="text-sm font-medium text-gray-500">Entries</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 bg-[#F9F9FA] p-4 rounded-lg border border-[#E8E8E8]">
|
||||
{simpleData.map(([name, value]) => {
|
||||
const isLong = name.length >= 30 || (value?.length || 0) >= 30;
|
||||
return (
|
||||
<div
|
||||
key={name}
|
||||
className={`flex flex-col gap-0.5 ${isLong ? 'col-span-1 md:col-span-2' : 'col-span-1'}`}
|
||||
>
|
||||
<span className="text-[#64748B] text-xs font-medium">{name}</span>
|
||||
{isEditing ? (
|
||||
<Input
|
||||
className="font-mono text-sm bg-white"
|
||||
value={value}
|
||||
onChange={(e) =>
|
||||
setFormValues((prev) => ({ ...prev, [name]: e.target.value }))
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<div className="min-h-[32px] flex items-center">
|
||||
<span className="text-[#262626] text-sm break-all font-mono select-text">
|
||||
{value}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{complexData.length > 0 && (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="text-sm font-medium text-gray-500">Files</div>
|
||||
<div className="flex flex-col gap-4">
|
||||
{complexData.map(([name, value = '']) => (
|
||||
<div key={name} className="flex flex-col gap-1">
|
||||
<div className="text-[#262626] font-medium text-sm select-text">{name}</div>
|
||||
{isEditing ? (
|
||||
<Input.TextArea
|
||||
autoSize={{ minRows: 4, maxRows: 16 }}
|
||||
className="bg-white border-[#E8E8E8] rounded-md text-sm font-mono"
|
||||
value={value}
|
||||
onChange={(e) =>
|
||||
setFormValues((prev) => ({ ...prev, [name]: e.target.value }))
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<pre className="bg-[#F4F4F5] p-3 rounded-md text-sm font-mono overflow-auto max-h-75 whitespace-pre-wrap break-all text-[#262626] border border-[#E8E8E8] m-0">
|
||||
{value}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Drawer>
|
||||
);
|
||||
};
|
||||
|
||||
export default ConfigMapDetail;
|
||||
-46
@@ -1,46 +0,0 @@
|
||||
import { ActionButton } from '@/components/common/action/action-button';
|
||||
import { PanelTable } from '@/components/common/panel-table/table';
|
||||
import { KubeObjectAge } from '@/components/kube/object/kube-object-age';
|
||||
import { ResponsiveKeyList } from '@/components/kube/object/responsive-key-list';
|
||||
import { ConfigMap } from '@/k8slens/kube-object';
|
||||
import { useConfigMapStore } from '@/store/kube';
|
||||
import { ColumnsType } from 'antd/es/table';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import ConfigMapDetail from './config-map-detail';
|
||||
|
||||
const columns: ColumnsType<ConfigMap> = [
|
||||
{
|
||||
title: 'Keys',
|
||||
key: 'keys',
|
||||
render: (_, configMap) => <ResponsiveKeyList keys={configMap.getKeys()} />
|
||||
},
|
||||
{
|
||||
title: 'Age',
|
||||
key: 'age',
|
||||
render: (_, configMap) => <KubeObjectAge obj={configMap} />
|
||||
},
|
||||
{
|
||||
fixed: 'right',
|
||||
key: 'action',
|
||||
render: (_, configMap) => <ActionButton obj={configMap} />
|
||||
}
|
||||
];
|
||||
|
||||
const ConfigMapOverviewPage = () => {
|
||||
const { items, initialize, isLoaded } = useConfigMapStore();
|
||||
|
||||
return (
|
||||
<PanelTable
|
||||
columns={columns}
|
||||
dataSource={items}
|
||||
loading={!isLoaded}
|
||||
sectionTitle="Config Maps"
|
||||
DetailDrawer={ConfigMapDetail}
|
||||
getRowKey={(configMap) => configMap.getId()}
|
||||
initializers={[initialize]}
|
||||
getDetailItem={(configMap) => configMap}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default ConfigMapOverviewPage;
|
||||
-249
@@ -1,249 +0,0 @@
|
||||
import { Secret } from '@/k8slens/kube-object';
|
||||
import { useSecretStore } from '@/store/kube';
|
||||
import { DrawerPanel } from '@/components/common/drawer/drawer-panel';
|
||||
import { KubeObjectInfoList } from '@/components/kube/object/detail/kube-object-detail-info-list';
|
||||
import { Drawer } from '@/components/common/drawer/drawer';
|
||||
import React from 'react';
|
||||
import { Button, Input, Space } from 'antd';
|
||||
import { EyeInvisibleOutlined, EyeOutlined, CloseOutlined, SaveOutlined } from '@ant-design/icons';
|
||||
import { entries, isEqual } from 'lodash';
|
||||
import { dumpKubeObject } from '@/utils/yaml';
|
||||
import useMessage from 'antd/lib/message/useMessage';
|
||||
import { updateResource } from '@/api/kubernetes';
|
||||
import { buildErrorResponse } from '@/services/backend/response';
|
||||
|
||||
type RevealableInput = {
|
||||
name: string;
|
||||
value: string;
|
||||
onChange?: (value: string) => void;
|
||||
};
|
||||
|
||||
const SecretDataInput = ({
|
||||
name,
|
||||
value,
|
||||
onChange,
|
||||
isComplex,
|
||||
revealed
|
||||
}: RevealableInput & { isComplex?: boolean; revealed: boolean }) => {
|
||||
return (
|
||||
<div className={`flex flex-col ${isComplex ? 'gap-1' : 'gap-0.5'}`}>
|
||||
<span className="text-[#64748B] text-xs font-medium">{name}</span>
|
||||
{isComplex ? (
|
||||
revealed ? (
|
||||
<Input.TextArea
|
||||
autoSize={{ minRows: 3, maxRows: 20 }}
|
||||
className="font-mono text-sm bg-[#F4F4F5] border-[#E8E8E8] rounded-md"
|
||||
value={value}
|
||||
onChange={(e) => onChange?.(e.target.value)}
|
||||
/>
|
||||
) : (
|
||||
<div className="h-20 bg-[#F4F4F5] border border-[#E8E8E8] rounded-md flex items-center justify-center text-gray-400 text-sm italic select-none">
|
||||
Hidden content
|
||||
</div>
|
||||
)
|
||||
) : revealed ? (
|
||||
<Input.Password
|
||||
className="font-mono text-sm border-0 bg-transparent px-0 shadow-none"
|
||||
value={value}
|
||||
onChange={(e) => onChange?.(e.target.value)}
|
||||
visibilityToggle={{ visible: true }}
|
||||
iconRender={() => null} // Hide individual toggle
|
||||
/>
|
||||
) : (
|
||||
<Input.Password
|
||||
disabled
|
||||
className="font-mono text-sm border-0 bg-transparent px-0 shadow-none text-gray-400"
|
||||
value="••••••••"
|
||||
visibilityToggle={false}
|
||||
iconRender={() => null}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const SecretDataForm = ({ secret }: { secret: Secret }) => {
|
||||
const [isSaving, setIsSaving] = React.useState(false);
|
||||
const [isRevealed, setIsRevealed] = React.useState(false);
|
||||
const [msgApi, contextHolder] = useMessage();
|
||||
const { initialize } = useSecretStore();
|
||||
|
||||
// Clean Data Map tracking
|
||||
// We need state to track edits to enable/disable button
|
||||
// originalData is static reference for comparison.
|
||||
const originalData = React.useMemo(() => {
|
||||
const map: Record<string, string> = {};
|
||||
entries(secret.data).forEach(([key, val]) => {
|
||||
map[key] = Buffer.from(val || '', 'base64').toString();
|
||||
});
|
||||
return map;
|
||||
}, [secret.data]);
|
||||
|
||||
const [formValues, setFormValues] = React.useState<Record<string, string>>(originalData);
|
||||
|
||||
// Sync formValues if secret changes externally (though usually drawer remounts)
|
||||
React.useEffect(() => {
|
||||
setFormValues(originalData);
|
||||
}, [originalData]);
|
||||
|
||||
const isModified = !isEqual(originalData, formValues);
|
||||
|
||||
// Process data for hybrid layout based on current form values
|
||||
const processedData = React.useMemo(() => {
|
||||
return entries(formValues).map(([key, val]) => {
|
||||
// Treat as complex (File) if it has newlines or is very long (>100 chars)
|
||||
const isComplex = val.includes('\n') || val.length > 100;
|
||||
// Treat as long entry (Full width in grid) if distinctively long (>30 chars) or key is long
|
||||
const isLong = val.length >= 30 || key.length >= 30;
|
||||
return { key, val, isComplex, isLong };
|
||||
});
|
||||
}, [formValues]);
|
||||
|
||||
const simpleData = processedData.filter((d) => !d.isComplex);
|
||||
const complexData = processedData.filter((d) => d.isComplex);
|
||||
|
||||
const handleSave = () => {
|
||||
setIsSaving(true);
|
||||
// Re-encode values
|
||||
const encodedData: Record<string, string> = {};
|
||||
entries(formValues).forEach(([key, val]) => {
|
||||
encodedData[key] = Buffer.from(val, 'ascii').toString('base64');
|
||||
});
|
||||
|
||||
updateResource(
|
||||
secret.kind,
|
||||
secret.getName(),
|
||||
dumpKubeObject<Secret>({
|
||||
...secret,
|
||||
data: encodedData
|
||||
})
|
||||
)
|
||||
.then(() => {
|
||||
msgApi.success('Saved');
|
||||
initialize(() => {}, true);
|
||||
// Implicitly originalData will update via parent refresh or we should wait for props update
|
||||
// But for UX we just reset saving
|
||||
})
|
||||
.catch((err) => {
|
||||
const errResp = buildErrorResponse(err);
|
||||
msgApi.error(errResp.error.message);
|
||||
})
|
||||
.finally(() => {
|
||||
setIsSaving(false);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
{contextHolder}
|
||||
|
||||
{/* Global Data Header & Toggle */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-sm font-medium text-gray-900">Data</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{!isRevealed ? (
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
icon={<EyeOutlined />}
|
||||
onClick={() => setIsRevealed(true)}
|
||||
className="px-0"
|
||||
>
|
||||
Show / Edit
|
||||
</Button>
|
||||
) : (
|
||||
<>
|
||||
<Button
|
||||
size="small"
|
||||
icon={<CloseOutlined />}
|
||||
onClick={() => {
|
||||
setFormValues(originalData);
|
||||
setIsRevealed(false);
|
||||
}}
|
||||
>
|
||||
Hide / Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
size="small"
|
||||
icon={<SaveOutlined />}
|
||||
loading={isSaving}
|
||||
onClick={handleSave}
|
||||
disabled={!isModified}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{simpleData.length > 0 && (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="text-sm font-medium text-gray-500">Entries</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 bg-[#F9F9FA] p-4 rounded-lg border border-[#E8E8E8]">
|
||||
{simpleData.map(({ key, val, isLong }) => (
|
||||
<div key={key} className={isLong ? 'col-span-1 md:col-span-2' : 'col-span-1'}>
|
||||
<SecretDataInput
|
||||
name={key}
|
||||
// Use formValues implicitly via map
|
||||
value={formValues[key] ?? ''}
|
||||
revealed={isRevealed}
|
||||
onChange={(newValue) => {
|
||||
setFormValues((prev) => ({ ...prev, [key]: newValue }));
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{complexData.length > 0 && (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="text-sm font-medium text-gray-500">Files</div>
|
||||
<div className="flex flex-col gap-4">
|
||||
{complexData.map(({ key, val }) => (
|
||||
<SecretDataInput
|
||||
key={key}
|
||||
name={key}
|
||||
value={formValues[key] ?? ''}
|
||||
isComplex
|
||||
revealed={isRevealed}
|
||||
onChange={(newValue) => {
|
||||
setFormValues((prev) => ({ ...prev, [key]: newValue }));
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Inline buttons now moved to header; keep spacer for layout consistency if needed */}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const SecretDetail = ({ obj: secret, open, onClose }: DetailDrawerProps<Secret>) => {
|
||||
if (!secret || !(secret instanceof Secret)) return null;
|
||||
|
||||
const hasData = secret.data && Object.keys(secret.data).length > 0;
|
||||
|
||||
return (
|
||||
<Drawer open={open} title={`Secret: ${secret.getName()}`} onClose={onClose}>
|
||||
<div className="flex flex-col">
|
||||
<DrawerPanel>
|
||||
<KubeObjectInfoList obj={secret} />
|
||||
</DrawerPanel>
|
||||
|
||||
{hasData && (
|
||||
<div className="flex flex-col pb-6">
|
||||
<SecretDataForm secret={secret} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Drawer>
|
||||
);
|
||||
};
|
||||
|
||||
export default SecretDetail;
|
||||
@@ -1,51 +0,0 @@
|
||||
import { KubeObjectAge } from '@/components/kube/object/kube-object-age';
|
||||
import { Secret } from '@/k8slens/kube-object';
|
||||
import { ColumnsType } from 'antd/lib/table';
|
||||
import { useSecretStore } from '@/store/kube';
|
||||
import SecretDetail from './secret-detail';
|
||||
import { ResponsiveKeyList } from '@/components/kube/object/responsive-key-list';
|
||||
import { PanelTable } from '@/components/common/panel-table/table';
|
||||
import { ActionButton } from '@/components/common/action/action-button';
|
||||
|
||||
const columns: ColumnsType<Secret> = [
|
||||
{
|
||||
title: 'Keys',
|
||||
key: 'keys',
|
||||
render: (_, secret) => <ResponsiveKeyList keys={secret.getKeys()} />
|
||||
},
|
||||
{
|
||||
title: 'Type',
|
||||
key: 'type',
|
||||
dataIndex: 'type',
|
||||
width: 250 // Keeping Type explicit width as it's usually short but important
|
||||
},
|
||||
{
|
||||
title: 'Age',
|
||||
key: 'age',
|
||||
render: (_, secret) => <KubeObjectAge obj={secret} />
|
||||
},
|
||||
{
|
||||
fixed: 'right',
|
||||
key: 'action',
|
||||
render: (_, secret) => <ActionButton obj={secret} />
|
||||
}
|
||||
];
|
||||
|
||||
const SecretOverviewPage = () => {
|
||||
const { items, initialize, isLoaded } = useSecretStore();
|
||||
|
||||
return (
|
||||
<PanelTable
|
||||
columns={columns}
|
||||
dataSource={items}
|
||||
loading={!isLoaded}
|
||||
sectionTitle="Secrets"
|
||||
DetailDrawer={SecretDetail}
|
||||
getRowKey={(secret) => secret.getId()}
|
||||
initializers={[initialize]}
|
||||
getDetailItem={(secret) => secret}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default SecretOverviewPage;
|
||||
-45
@@ -1,45 +0,0 @@
|
||||
import { Bucket } from '@/k8slens/kube-object/src/specifics/bucket';
|
||||
import { DrawerItem } from '@/components/common/drawer/drawer-item';
|
||||
import { DrawerPanel } from '@/components/common/drawer/drawer-panel';
|
||||
import { Drawer } from '@/components/common/drawer/drawer';
|
||||
import { KubeObjectInfoList } from '@/components/kube/object/detail/kube-object-detail-info-list';
|
||||
import { DetailDrawerProps } from '@/components/common/panel-table/table';
|
||||
import { StatusTag } from '@/components/common/status-tag';
|
||||
|
||||
const getPolicyColor = (policy: string) => {
|
||||
switch (policy) {
|
||||
case 'publicRead':
|
||||
return 'green';
|
||||
case 'publicReadwrite':
|
||||
return 'orange';
|
||||
case 'private':
|
||||
return 'default';
|
||||
default:
|
||||
return 'blue';
|
||||
}
|
||||
};
|
||||
|
||||
const BucketDetail = ({ open, obj: bucket, onClose }: DetailDrawerProps<Bucket>) => {
|
||||
if (!bucket) return null;
|
||||
|
||||
return (
|
||||
<Drawer open={open} title={`Bucket: ${bucket.getName()}`} onClose={onClose}>
|
||||
<DrawerPanel>
|
||||
<KubeObjectInfoList obj={bucket} />
|
||||
|
||||
<DrawerItem
|
||||
name="Policy"
|
||||
value={
|
||||
<StatusTag color={getPolicyColor(bucket.getPolicy())}>
|
||||
{bucket.getPolicy() || '-'}
|
||||
</StatusTag>
|
||||
}
|
||||
/>
|
||||
|
||||
<DrawerItem name="Bucket Name" value={bucket.getBucketName() || '-'} />
|
||||
</DrawerPanel>
|
||||
</Drawer>
|
||||
);
|
||||
};
|
||||
|
||||
export default BucketDetail;
|
||||
-65
@@ -1,65 +0,0 @@
|
||||
import { Bucket } from '@/k8slens/kube-object/src/specifics/bucket';
|
||||
import { KubeObjectAge } from '@/components/kube/object/kube-object-age';
|
||||
import { PanelTable } from '@/components/common/panel-table/table';
|
||||
import { useBucketStore } from '@/store/kube';
|
||||
import { ActionButton } from '@/components/common/action/action-button';
|
||||
import BucketDetail from './bucket-detail';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import { StatusTag } from '@/components/common/status-tag';
|
||||
|
||||
const getPolicyColor = (policy: string) => {
|
||||
switch (policy) {
|
||||
case 'publicRead':
|
||||
return 'green';
|
||||
case 'publicReadwrite':
|
||||
return 'orange';
|
||||
case 'private':
|
||||
return 'default';
|
||||
default:
|
||||
return 'blue';
|
||||
}
|
||||
};
|
||||
|
||||
const columns: ColumnsType<Bucket> = [
|
||||
{
|
||||
title: 'Policy',
|
||||
key: 'policy',
|
||||
render: (_, bucket) => (
|
||||
<StatusTag color={getPolicyColor(bucket.getPolicy())}>{bucket.getPolicy() || '-'}</StatusTag>
|
||||
)
|
||||
},
|
||||
{
|
||||
title: 'Bucket Name',
|
||||
key: 'bucketName',
|
||||
render: (_, bucket) => bucket.getBucketName() || '-'
|
||||
},
|
||||
{
|
||||
title: 'Age',
|
||||
key: 'age',
|
||||
render: (_, bucket) => <KubeObjectAge obj={bucket} />
|
||||
},
|
||||
{
|
||||
fixed: 'right',
|
||||
key: 'action',
|
||||
render: (_, bucket) => <ActionButton obj={bucket} />
|
||||
}
|
||||
];
|
||||
|
||||
const BucketPage = () => {
|
||||
const { items, initialize, isLoaded } = useBucketStore();
|
||||
|
||||
return (
|
||||
<PanelTable<Bucket, Bucket>
|
||||
columns={columns}
|
||||
loading={!isLoaded}
|
||||
dataSource={items}
|
||||
sectionTitle="Bucket"
|
||||
getRowKey={(bucket) => bucket.getId()}
|
||||
getDetailItem={(bucket) => bucket}
|
||||
DetailDrawer={BucketDetail}
|
||||
initializers={[initialize]}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default BucketPage;
|
||||
-74
@@ -1,74 +0,0 @@
|
||||
import { Drawer } from '@/components/common/drawer/drawer';
|
||||
import { DrawerItem } from '@/components/common/drawer/drawer-item';
|
||||
import { DrawerPanel } from '@/components/common/drawer/drawer-panel';
|
||||
import { DetailDrawerProps } from '@/components/common/panel-table/table';
|
||||
import { StatusTag } from '@/components/common/status-tag';
|
||||
import { KubeObjectInfoList } from '@/components/kube/object/detail/kube-object-detail-info-list';
|
||||
import { Cluster } from '@/k8slens/kube-object/src/specifics/cluster';
|
||||
|
||||
const getPhaseColor = (phase: string) => {
|
||||
switch (phase) {
|
||||
case 'Running':
|
||||
return 'green';
|
||||
case 'Stopped':
|
||||
case 'Deleting':
|
||||
return 'default';
|
||||
case 'Creating':
|
||||
case 'Updating':
|
||||
return 'blue';
|
||||
case 'Failed':
|
||||
return 'red';
|
||||
default:
|
||||
return 'orange';
|
||||
}
|
||||
};
|
||||
|
||||
const ClusterDetail = ({ open, obj: cluster, onClose }: DetailDrawerProps<Cluster>) => {
|
||||
if (!cluster) return null;
|
||||
|
||||
return (
|
||||
<Drawer open={open} title={`Cluster: ${cluster.getName()}`} onClose={onClose}>
|
||||
<DrawerPanel>
|
||||
<KubeObjectInfoList obj={cluster} />
|
||||
|
||||
<DrawerItem
|
||||
name="Phase"
|
||||
value={
|
||||
<StatusTag color={getPhaseColor(cluster.getPhase())}>{cluster.getPhase()}</StatusTag>
|
||||
}
|
||||
/>
|
||||
|
||||
<DrawerItem name="Definition" value={cluster.getClusterDefinition() || '-'} />
|
||||
|
||||
<DrawerItem name="Version" value={cluster.getClusterVersion() || '-'} />
|
||||
|
||||
<DrawerItem
|
||||
name="Resources"
|
||||
value={
|
||||
<span className="text-[#262626] text-sm">
|
||||
CPU: {cluster.getCpu() || '-'} / Memory: {cluster.getMemory() || '-'}{' '}
|
||||
/ Storage: {cluster.getStorage() || '-'}
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
|
||||
<DrawerItem name="Replicas" value={cluster.getReplicas().toString()} />
|
||||
|
||||
<DrawerItem
|
||||
name="Backup"
|
||||
value={
|
||||
cluster.isBackupEnabled() ? (
|
||||
<StatusTag color="green">Enabled</StatusTag>
|
||||
) : (
|
||||
<StatusTag>Disabled</StatusTag>
|
||||
)
|
||||
}
|
||||
/>
|
||||
|
||||
<DrawerItem name="Termination" value={cluster.getTerminationPolicy() || '-'} />
|
||||
</DrawerPanel>
|
||||
</Drawer>
|
||||
);
|
||||
};
|
||||
|
||||
export default ClusterDetail;
|
||||
-79
@@ -1,79 +0,0 @@
|
||||
import { Cluster } from '@/k8slens/kube-object/src/specifics/cluster';
|
||||
import { KubeObjectAge } from '@/components/kube/object/kube-object-age';
|
||||
import { PanelTable } from '@/components/common/panel-table/table';
|
||||
import { useClusterStore } from '@/store/kube';
|
||||
import { ActionButton } from '@/components/common/action/action-button';
|
||||
import ClusterDetail from './cluster-detail';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import { StatusTag } from '@/components/common/status-tag';
|
||||
|
||||
const getPhaseColor = (phase: string) => {
|
||||
switch (phase) {
|
||||
case 'Running':
|
||||
return 'green';
|
||||
case 'Stopped':
|
||||
case 'Deleting':
|
||||
return 'default';
|
||||
case 'Creating':
|
||||
case 'Updating':
|
||||
return 'blue';
|
||||
case 'Failed':
|
||||
return 'red';
|
||||
default:
|
||||
return 'orange';
|
||||
}
|
||||
};
|
||||
|
||||
const columns: ColumnsType<Cluster> = [
|
||||
{
|
||||
title: 'Phase',
|
||||
key: 'phase',
|
||||
render: (_, cluster) => (
|
||||
<StatusTag color={getPhaseColor(cluster.getPhase())}>{cluster.getPhase()}</StatusTag>
|
||||
)
|
||||
},
|
||||
{
|
||||
title: 'Definition',
|
||||
key: 'definition',
|
||||
render: (_, cluster) => cluster.getClusterDefinition() || '-'
|
||||
},
|
||||
{
|
||||
title: 'Version',
|
||||
key: 'version',
|
||||
render: (_, cluster) => cluster.getClusterVersion() || '-'
|
||||
},
|
||||
{
|
||||
title: 'Replicas',
|
||||
key: 'replicas',
|
||||
render: (_, cluster) => cluster.getReplicas()
|
||||
},
|
||||
{
|
||||
title: 'Age',
|
||||
key: 'age',
|
||||
render: (_, cluster) => <KubeObjectAge obj={cluster} />
|
||||
},
|
||||
{
|
||||
fixed: 'right',
|
||||
key: 'action',
|
||||
render: (_, cluster) => <ActionButton obj={cluster} />
|
||||
}
|
||||
];
|
||||
|
||||
const ClusterPage = () => {
|
||||
const { items, initialize, isLoaded } = useClusterStore();
|
||||
|
||||
return (
|
||||
<PanelTable<Cluster, Cluster>
|
||||
columns={columns}
|
||||
loading={!isLoaded}
|
||||
dataSource={items}
|
||||
sectionTitle="Cluster"
|
||||
getRowKey={(cluster) => cluster.getId()}
|
||||
getDetailItem={(cluster) => cluster}
|
||||
DetailDrawer={ClusterDetail}
|
||||
initializers={[initialize]}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default ClusterPage;
|
||||
-73
@@ -1,73 +0,0 @@
|
||||
import { Drawer } from '@/components/common/drawer/drawer';
|
||||
import { DrawerItem } from '@/components/common/drawer/drawer-item';
|
||||
import { DrawerPanel } from '@/components/common/drawer/drawer-panel';
|
||||
import { DetailDrawerProps } from '@/components/common/panel-table/table';
|
||||
import { StatusTag } from '@/components/common/status-tag';
|
||||
import { KubeObjectInfoList } from '@/components/kube/object/detail/kube-object-detail-info-list';
|
||||
import { Devbox } from '@/k8slens/kube-object/src/specifics/devbox';
|
||||
|
||||
const getStateColor = (state: string) => {
|
||||
switch (state.toLowerCase()) {
|
||||
case 'running':
|
||||
return 'green';
|
||||
case 'stopped':
|
||||
return 'default';
|
||||
case 'pending':
|
||||
return 'orange';
|
||||
case 'error':
|
||||
case 'failed':
|
||||
return 'red';
|
||||
default:
|
||||
return 'blue';
|
||||
}
|
||||
};
|
||||
|
||||
const DevboxDetail = ({ open, obj: devbox, onClose }: DetailDrawerProps<Devbox>) => {
|
||||
if (!devbox) return null;
|
||||
|
||||
const networkType = devbox.getNetworkType();
|
||||
const nodePort = devbox.getNodePort();
|
||||
const networkDisplay = networkType ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="px-1.5 py-0.5 text-xs bg-gray-100 text-gray-500 rounded border border-gray-200">
|
||||
{networkType}
|
||||
</span>
|
||||
{nodePort && <span className="text-sm">{nodePort}</span>}
|
||||
</div>
|
||||
) : (
|
||||
'-'
|
||||
);
|
||||
|
||||
return (
|
||||
<Drawer open={open} title={`Devbox: ${devbox.getName()}`} onClose={onClose}>
|
||||
<DrawerPanel>
|
||||
<KubeObjectInfoList obj={devbox} />
|
||||
|
||||
<DrawerItem
|
||||
name="State"
|
||||
value={
|
||||
<StatusTag color={getStateColor(devbox.getState())}>{devbox.getState()}</StatusTag>
|
||||
}
|
||||
/>
|
||||
|
||||
<DrawerItem name="Phase" value={devbox.getPhase()} />
|
||||
|
||||
<DrawerItem name="Image" value={devbox.getImage() || '-'} />
|
||||
|
||||
<DrawerItem
|
||||
name="Resources"
|
||||
value={
|
||||
<span className="text-[#262626] text-sm">
|
||||
CPU: {devbox.getCpu() || '-'} / Memory: {devbox.getMemory() || '-'}{' '}
|
||||
/ Storage: {devbox.getStorageLimit() || '-'}
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
|
||||
<DrawerItem name="Network" value={networkDisplay} />
|
||||
</DrawerPanel>
|
||||
</Drawer>
|
||||
);
|
||||
};
|
||||
|
||||
export default DevboxDetail;
|
||||
-80
@@ -1,80 +0,0 @@
|
||||
import { Devbox } from '@/k8slens/kube-object/src/specifics/devbox';
|
||||
import { KubeObjectAge } from '@/components/kube/object/kube-object-age';
|
||||
import { PanelTable } from '@/components/common/panel-table/table';
|
||||
import { useDevboxStore } from '@/store/kube';
|
||||
import { ActionButton } from '@/components/common/action/action-button';
|
||||
import DevboxDetail from './devbox-detail';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import { StatusTag } from '@/components/common/status-tag';
|
||||
|
||||
const getStateColor = (state: string | undefined | null) => {
|
||||
// Ensure state is a string before calling toLowerCase
|
||||
const stateStr = typeof state === 'string' ? state : String(state || '');
|
||||
switch (stateStr.toLowerCase()) {
|
||||
case 'running':
|
||||
return 'green';
|
||||
case 'stopped':
|
||||
return 'default';
|
||||
case 'pending':
|
||||
return 'orange';
|
||||
case 'error':
|
||||
case 'failed':
|
||||
return 'red';
|
||||
default:
|
||||
return 'blue';
|
||||
}
|
||||
};
|
||||
|
||||
const columns: ColumnsType<Devbox> = [
|
||||
{
|
||||
title: 'State',
|
||||
key: 'state',
|
||||
render: (_, devbox) => (
|
||||
<StatusTag color={getStateColor(devbox.getState())}>{devbox.getState()}</StatusTag>
|
||||
)
|
||||
},
|
||||
{
|
||||
title: 'CPU',
|
||||
key: 'cpu',
|
||||
render: (_, devbox) => devbox.getCpu() || '-'
|
||||
},
|
||||
{
|
||||
title: 'Memory',
|
||||
key: 'memory',
|
||||
render: (_, devbox) => devbox.getMemory() || '-'
|
||||
},
|
||||
{
|
||||
title: 'Storage',
|
||||
key: 'storage',
|
||||
render: (_, devbox) => devbox.getStorageLimit() || '-'
|
||||
},
|
||||
{
|
||||
title: 'Age',
|
||||
key: 'age',
|
||||
render: (_, devbox) => <KubeObjectAge obj={devbox} />
|
||||
},
|
||||
{
|
||||
fixed: 'right',
|
||||
key: 'action',
|
||||
render: (_, devbox) => <ActionButton obj={devbox} />
|
||||
}
|
||||
];
|
||||
|
||||
const DevboxPage = () => {
|
||||
const { items, initialize, isLoaded } = useDevboxStore();
|
||||
|
||||
return (
|
||||
<PanelTable<Devbox, Devbox>
|
||||
columns={columns}
|
||||
loading={!isLoaded}
|
||||
dataSource={items}
|
||||
sectionTitle="Devbox"
|
||||
getRowKey={(devbox) => devbox.getId()}
|
||||
getDetailItem={(devbox) => devbox}
|
||||
DetailDrawer={DevboxDetail}
|
||||
initializers={[initialize]}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default DevboxPage;
|
||||
-123
@@ -1,123 +0,0 @@
|
||||
import { updateResource } from '@/api/kubernetes';
|
||||
import { Drawer } from '@/components/common/drawer/drawer';
|
||||
import { DrawerPanel } from '@/components/common/drawer/drawer-panel';
|
||||
import { KubeObjectInfoList } from '@/components/kube/object/detail/kube-object-detail-info-list';
|
||||
import { Ingress, IngressRule } from '@/k8slens/kube-object';
|
||||
import { buildErrorResponse } from '@/services/backend/response';
|
||||
import { useIngressStore } from '@/store/kube';
|
||||
import { dumpKubeObject } from '@/utils/yaml';
|
||||
import { SaveOutlined } from '@ant-design/icons';
|
||||
import { Button, message } from 'antd';
|
||||
import { useEffect, useState } from 'react';
|
||||
import IngressPathEditor from './ingress-path-editor';
|
||||
|
||||
const IngressDetail = ({ obj: ingress, open, onClose }: DetailDrawerProps<Ingress>) => {
|
||||
if (!ingress || !(ingress instanceof Ingress)) return null;
|
||||
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [draftRules, setDraftRules] = useState<IngressRule[]>([]);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const { items, initialize } = useIngressStore();
|
||||
const [msgApi, contextHolder] = message.useMessage();
|
||||
const msgKey = 'ingressEditMsg';
|
||||
|
||||
const latest = items.find((it) => it.getId() === ingress.getId()) ?? ingress;
|
||||
|
||||
// Reset editing state when drawer opens/closes or ingress changes
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setIsEditing(false);
|
||||
setDraftRules([]);
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
const id = ingress?.getId();
|
||||
useEffect(() => {
|
||||
setIsEditing(false);
|
||||
setDraftRules([]);
|
||||
}, [id]);
|
||||
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
setSubmitting(true);
|
||||
|
||||
const updated: Partial<Ingress> = {
|
||||
...latest,
|
||||
spec: {
|
||||
...latest.spec,
|
||||
rules: draftRules
|
||||
}
|
||||
};
|
||||
|
||||
msgApi.loading({ content: 'Updating Ingress...', key: msgKey }, 0);
|
||||
const yaml = dumpKubeObject(updated);
|
||||
const res = await updateResource(latest.kind, latest.getName(), yaml);
|
||||
msgApi.success({
|
||||
content: `Updated ${res.data.kind} ${res.data.metadata.name}`,
|
||||
key: msgKey
|
||||
});
|
||||
|
||||
// Refresh the store to ensure the detail view shows the latest rules immediately
|
||||
await initialize(() => {}, true);
|
||||
setIsEditing(false);
|
||||
} catch (err: any) {
|
||||
const resp = buildErrorResponse(err);
|
||||
msgApi.error({
|
||||
content: `Update failed: ${resp.error.message}`,
|
||||
key: msgKey
|
||||
});
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
setIsEditing(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{contextHolder}
|
||||
<Drawer open={open} title={`Ingress: ${latest.getName()}`} onClose={onClose}>
|
||||
<DrawerPanel>
|
||||
<KubeObjectInfoList obj={latest} />
|
||||
|
||||
<div
|
||||
className="flex items-center justify-between mt-8 mb-4"
|
||||
style={{ minHeight: '32px' }}
|
||||
>
|
||||
<span className="text-[#262626] font-medium text-base">Rules</span>
|
||||
{!isEditing ? (
|
||||
<Button type="link" onClick={() => setIsEditing(true)} className="p-0! h-auto">
|
||||
Edit
|
||||
</Button>
|
||||
) : (
|
||||
<div className="flex items-center gap-2">
|
||||
<Button onClick={handleCancel} size="small" type="default">
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={handleSave}
|
||||
icon={<SaveOutlined />}
|
||||
size="small"
|
||||
loading={submitting}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<IngressPathEditor
|
||||
ingress={latest}
|
||||
isEditing={isEditing}
|
||||
onChange={(rules) => setDraftRules(rules)}
|
||||
/>
|
||||
</DrawerPanel>
|
||||
</Drawer>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default IngressDetail;
|
||||
-368
@@ -1,368 +0,0 @@
|
||||
import {
|
||||
ComputedIngressRoute,
|
||||
computeRuleDeclarations,
|
||||
ExtensionsBackend,
|
||||
Ingress,
|
||||
IngressRule,
|
||||
NetworkingBackend
|
||||
} from '@/k8slens/kube-object';
|
||||
import { DeleteOutlined, PlusOutlined } from '@ant-design/icons';
|
||||
import { Button, Input, Select, Table, Tooltip } from 'antd';
|
||||
import { ColumnsType } from 'antd/lib/table';
|
||||
import { isEqual } from 'lodash';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
interface IngressPathEditorProps {
|
||||
ingress: Ingress;
|
||||
isEditing?: boolean;
|
||||
onChange?: (rules: IngressRule[]) => void;
|
||||
}
|
||||
|
||||
type PathForm = {
|
||||
path?: string;
|
||||
pathType: 'Exact' | 'Prefix';
|
||||
serviceName: string;
|
||||
servicePort?: number | string;
|
||||
};
|
||||
|
||||
type RuleForm = {
|
||||
host: string;
|
||||
paths: PathForm[];
|
||||
};
|
||||
|
||||
const IngressPathEditor = ({ ingress, isEditing = false, onChange }: IngressPathEditorProps) => {
|
||||
const [rules, setRules] = useState<RuleForm[]>([]);
|
||||
|
||||
const convertIngressToForm = useMemo(() => {
|
||||
return (ing: Ingress): RuleForm[] => {
|
||||
if (!ing || !ing.spec) {
|
||||
return [];
|
||||
}
|
||||
return (
|
||||
ing.spec.rules?.map((rule) => ({
|
||||
host: rule.host || '*',
|
||||
paths: (rule.http?.paths || []).map((p) => ({
|
||||
path: p.path || '/',
|
||||
pathType:
|
||||
p.pathType === 'ImplementationSpecific'
|
||||
? 'Prefix'
|
||||
: (p.pathType as 'Exact' | 'Prefix') || 'Prefix',
|
||||
serviceName:
|
||||
(p.backend as NetworkingBackend)?.service?.name ??
|
||||
(p.backend as ExtensionsBackend)?.serviceName ??
|
||||
'',
|
||||
servicePort:
|
||||
(p.backend as NetworkingBackend)?.service?.port?.number ??
|
||||
(p.backend as NetworkingBackend)?.service?.port?.name ??
|
||||
(p.backend as ExtensionsBackend)?.servicePort
|
||||
}))
|
||||
})) || []
|
||||
);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const resetDraft = useCallback(
|
||||
(ing: Ingress) => {
|
||||
setRules(convertIngressToForm(ing));
|
||||
},
|
||||
[convertIngressToForm]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isEditing) {
|
||||
resetDraft(ingress);
|
||||
}
|
||||
}, [ingress, isEditing, resetDraft]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isEditing) {
|
||||
resetDraft(ingress);
|
||||
}
|
||||
}, [isEditing, ingress, resetDraft]);
|
||||
|
||||
const draftRules = useMemo(() => {
|
||||
return rules.map((rule) => ({
|
||||
host: rule.host === '*' ? undefined : rule.host,
|
||||
http: {
|
||||
paths: rule.paths.map((p) => ({
|
||||
pathType: p.pathType,
|
||||
path: p.path,
|
||||
backend: {
|
||||
service: {
|
||||
name: p.serviceName,
|
||||
port:
|
||||
typeof p.servicePort === 'number'
|
||||
? { number: p.servicePort }
|
||||
: (() => {
|
||||
const asNum = Number(p.servicePort);
|
||||
return Number.isFinite(asNum)
|
||||
? { number: asNum }
|
||||
: { name: String(p.servicePort || '') };
|
||||
})()
|
||||
}
|
||||
}
|
||||
}))
|
||||
}
|
||||
})) as IngressRule[];
|
||||
}, [rules]);
|
||||
|
||||
const lastSentRef = useRef<IngressRule[]>();
|
||||
|
||||
useEffect(() => {
|
||||
if (!isEditing) {
|
||||
lastSentRef.current = undefined;
|
||||
return;
|
||||
}
|
||||
if (!isEqual(draftRules, lastSentRef.current)) {
|
||||
lastSentRef.current = draftRules;
|
||||
onChange?.(draftRules);
|
||||
}
|
||||
}, [draftRules, isEditing, onChange]);
|
||||
|
||||
const addPath = (ruleIndex: number) => {
|
||||
setRules((prev) => {
|
||||
const newRules = [...prev];
|
||||
const rule = newRules[ruleIndex];
|
||||
if (!rule) return newRules;
|
||||
rule.paths.push({
|
||||
path: '',
|
||||
pathType: 'Prefix',
|
||||
serviceName: '',
|
||||
servicePort: ''
|
||||
});
|
||||
return newRules;
|
||||
});
|
||||
};
|
||||
|
||||
const removePath = (ruleIndex: number, pathIndex: number) => {
|
||||
setRules((prev) => {
|
||||
const newRules = [...prev];
|
||||
const rule = newRules[ruleIndex];
|
||||
if (!rule) return newRules;
|
||||
rule.paths.splice(pathIndex, 1);
|
||||
return newRules;
|
||||
});
|
||||
};
|
||||
|
||||
const updatePath = (ruleIndex: number, pathIndex: number, field: keyof PathForm, value: any) => {
|
||||
setRules((prev) => {
|
||||
const newRules = [...prev];
|
||||
const rule = newRules[ruleIndex];
|
||||
if (!rule) return newRules;
|
||||
const path = rule.paths[pathIndex];
|
||||
if (!path) return newRules;
|
||||
rule.paths[pathIndex] = {
|
||||
...path,
|
||||
[field]: value,
|
||||
// Ensure pathType is always defined
|
||||
pathType: field === 'pathType' ? (value as 'Exact' | 'Prefix') : path.pathType
|
||||
};
|
||||
return newRules;
|
||||
});
|
||||
};
|
||||
|
||||
const displayRules = isEditing ? rules : convertIngressToForm(ingress);
|
||||
|
||||
if (!isEditing) {
|
||||
if (!ingress) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
{ingress.getRules().map((rule, idx) => {
|
||||
const pathTypeMap = new Map<string, string>();
|
||||
rule.http?.paths?.forEach((p) => {
|
||||
const pathKey = p.path || '/';
|
||||
pathTypeMap.set(pathKey, p.pathType || 'Prefix');
|
||||
});
|
||||
|
||||
const rulesColumns: ColumnsType<ComputedIngressRoute> = [
|
||||
{
|
||||
key: 'path',
|
||||
title: 'Path',
|
||||
render: (_, { pathname, displayAsLink, url }) => {
|
||||
const text = pathname === '' ? '_' : pathname;
|
||||
const pathType = pathTypeMap.get(pathname) || 'Prefix';
|
||||
const typeLabel = pathType === 'Prefix' ? '≈' : pathType === 'Exact' ? '=' : '~';
|
||||
const typeColor =
|
||||
pathType === 'Prefix' ? '#1890ff' : pathType === 'Exact' ? '#52c41a' : '#722ed1';
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<Tooltip title={pathType}>
|
||||
<span
|
||||
className="inline-flex items-center justify-center w-5 h-5 text-xs font-bold rounded"
|
||||
style={{
|
||||
backgroundColor: `${typeColor}15`,
|
||||
color: typeColor,
|
||||
border: `1px solid ${typeColor}40`
|
||||
}}
|
||||
>
|
||||
{typeLabel}
|
||||
</span>
|
||||
</Tooltip>
|
||||
{displayAsLink ? (
|
||||
<Button
|
||||
href={url}
|
||||
rel="noreferrer"
|
||||
target="_blank"
|
||||
type="link"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="p-0! h-auto"
|
||||
>
|
||||
{text}
|
||||
</Button>
|
||||
) : (
|
||||
<span>{text}</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
key: 'backends',
|
||||
title: 'Service',
|
||||
dataIndex: 'service'
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<div key={rule.host || idx}>
|
||||
{rule.http && (
|
||||
<Table
|
||||
size="small"
|
||||
bordered
|
||||
columns={rulesColumns}
|
||||
dataSource={computeRuleDeclarations(ingress, rule)}
|
||||
pagination={false}
|
||||
rowKey={(record) => record.url}
|
||||
title={() => {
|
||||
const tlsEntry = ingress.spec.tls?.find((tls) =>
|
||||
tls.hosts?.includes(rule.host || '')
|
||||
);
|
||||
const isHttps = !!tlsEntry;
|
||||
const protocol = isHttps ? 'https' : 'http';
|
||||
|
||||
return (
|
||||
<div className="font-medium text-gray-900 flex items-center gap-1">
|
||||
<span className="flex items-center">
|
||||
{isHttps ? (
|
||||
<Tooltip title={`Secret: ${tlsEntry.secretName}`}>
|
||||
<span className="px-1.5 py-0.5 rounded text-xs font-medium bg-green-100 text-green-700 border border-green-200 cursor-help">
|
||||
{protocol.toUpperCase()}
|
||||
</span>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<span className="px-1.5 py-0.5 rounded text-xs font-medium bg-gray-100 text-gray-500 border border-gray-200">
|
||||
{protocol.toUpperCase()}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<span>{rule.host || '*'}</span>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
{displayRules.map((rule, ruleIndex) => (
|
||||
<div key={ruleIndex}>
|
||||
<div className="border border-gray-200 rounded">
|
||||
<div className="flex items-center justify-between px-3 py-2 bg-gray-50 border-b border-gray-200">
|
||||
<span className="font-medium text-gray-900">
|
||||
{rule.host ? `Host: ${rule.host}` : 'Host: *'}
|
||||
</span>
|
||||
<Button
|
||||
onClick={() => addPath(ruleIndex)}
|
||||
type="dashed"
|
||||
size="small"
|
||||
icon={<PlusOutlined />}
|
||||
>
|
||||
Add Path
|
||||
</Button>
|
||||
</div>
|
||||
<div className="divide-y divide-gray-200">
|
||||
{rule.paths.map((path, pathIndex) => (
|
||||
<div key={pathIndex} className="p-3">
|
||||
<div className="grid grid-cols-[140px_1fr] gap-3 mb-2">
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-xs text-gray-600 font-medium">Type</label>
|
||||
<Select
|
||||
size="small"
|
||||
style={{ width: '100%' }}
|
||||
value={path.pathType}
|
||||
onChange={(val) => updatePath(ruleIndex, pathIndex, 'pathType', val)}
|
||||
disabled={String(path.path || '').trim() === '/'}
|
||||
options={[
|
||||
{ label: 'Prefix', value: 'Prefix' },
|
||||
{ label: 'Exact', value: 'Exact' }
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-xs text-gray-600 font-medium">Path</label>
|
||||
<Input
|
||||
size="small"
|
||||
placeholder="/"
|
||||
value={path.path}
|
||||
onChange={(e) => updatePath(ruleIndex, pathIndex, 'path', e.target.value)}
|
||||
readOnly={String(path.path || '').trim() === '/'}
|
||||
disabled={String(path.path || '').trim() === '/'}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-[1fr_auto_auto] gap-3 items-end">
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-xs text-gray-600 font-medium">Service</label>
|
||||
<Input
|
||||
size="small"
|
||||
placeholder="Service Name"
|
||||
value={path.serviceName}
|
||||
onChange={(e) =>
|
||||
updatePath(ruleIndex, pathIndex, 'serviceName', e.target.value)
|
||||
}
|
||||
readOnly={String(path.path || '').trim() === '/'}
|
||||
disabled={String(path.path || '').trim() === '/'}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1" style={{ width: '70px' }}>
|
||||
<label className="text-xs text-gray-600 font-medium">Port</label>
|
||||
<Input
|
||||
size="small"
|
||||
placeholder="80"
|
||||
value={path.servicePort}
|
||||
onChange={(e) =>
|
||||
updatePath(ruleIndex, pathIndex, 'servicePort', e.target.value)
|
||||
}
|
||||
readOnly={String(path.path || '').trim() === '/'}
|
||||
disabled={String(path.path || '').trim() === '/'}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
danger
|
||||
type="text"
|
||||
size="small"
|
||||
disabled={String(path.path || '').trim() === '/'}
|
||||
onClick={() => removePath(ruleIndex, pathIndex)}
|
||||
icon={<DeleteOutlined />}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default IngressPathEditor;
|
||||
-345
@@ -1,345 +0,0 @@
|
||||
import { Form, Input, Button, Select, Space, message, Modal } from 'antd';
|
||||
import { Drawer } from '@/components/common/drawer/drawer';
|
||||
import { Ingress, ExtensionsBackend, NetworkingBackend } from '@/k8slens/kube-object';
|
||||
import { updateResource } from '@/api/kubernetes';
|
||||
import { buildErrorResponse } from '@/services/backend/response';
|
||||
import { dumpKubeObject } from '@/utils/yaml';
|
||||
import { PlusOutlined, DeleteOutlined, SaveOutlined } from '@ant-design/icons';
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
|
||||
type PathForm = {
|
||||
path?: string;
|
||||
pathType: 'Exact' | 'Prefix' | 'ImplementationSpecific';
|
||||
serviceName: string;
|
||||
servicePort?: number | string;
|
||||
};
|
||||
|
||||
type RuleForm = {
|
||||
paths: PathForm[];
|
||||
};
|
||||
|
||||
type FormValues = {
|
||||
rules: RuleForm[];
|
||||
tls: { secretName: string }[];
|
||||
};
|
||||
|
||||
interface Props {
|
||||
ingress?: Ingress | null;
|
||||
open: boolean;
|
||||
onCancel: () => void;
|
||||
onOk: () => void;
|
||||
}
|
||||
|
||||
export const IngressVisualEditorDrawer = ({ ingress, open, onCancel, onOk }: Props) => {
|
||||
if (!ingress) return null;
|
||||
|
||||
const [form] = Form.useForm<FormValues>();
|
||||
const [msgApi, contextHolder] = message.useMessage();
|
||||
const msgKey = 'ingressVisualEditMsg';
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
// Helper function to check if path is root path
|
||||
const getPathValue = useCallback(
|
||||
(fieldName: number, pathFieldName: number): string => {
|
||||
return String(
|
||||
form.getFieldValue(['rules', fieldName, 'paths'])?.[pathFieldName]?.path ?? ''
|
||||
).trim();
|
||||
},
|
||||
[form]
|
||||
);
|
||||
|
||||
const isRootPath = useCallback(
|
||||
(fieldName: number, pathFieldName: number): boolean => {
|
||||
return getPathValue(fieldName, pathFieldName) === '/';
|
||||
},
|
||||
[getPathValue]
|
||||
);
|
||||
|
||||
const initialValues: FormValues = {
|
||||
rules:
|
||||
ingress.spec.rules?.map((rule) => ({
|
||||
paths: (rule.http?.paths || []).map((p) => ({
|
||||
path: p.path || '/',
|
||||
pathType: p.pathType || 'Prefix',
|
||||
serviceName:
|
||||
(p.backend as NetworkingBackend)?.service?.name ??
|
||||
(p.backend as ExtensionsBackend)?.serviceName ??
|
||||
'',
|
||||
servicePort:
|
||||
(p.backend as NetworkingBackend)?.service?.port?.number ??
|
||||
(p.backend as NetworkingBackend)?.service?.port?.name ??
|
||||
(p.backend as ExtensionsBackend)?.servicePort
|
||||
}))
|
||||
})) || [],
|
||||
tls: []
|
||||
};
|
||||
|
||||
// Ensure the form reflects original ingress data each time the drawer opens
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
form.setFieldsValue(initialValues);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [open, ingress]);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
try {
|
||||
setSubmitting(true);
|
||||
const values = await form.validateFields();
|
||||
|
||||
const newRules = (ingress.spec.rules || []).map((origRule, idx) => ({
|
||||
host: origRule.host,
|
||||
http: {
|
||||
paths: (values.rules[idx]?.paths || origRule.http?.paths || []).map((p) => {
|
||||
// Type guard: check if p is PathForm (has serviceName/servicePort) or HTTPIngressPath
|
||||
const isPathForm = 'serviceName' in p && 'servicePort' in p;
|
||||
if (isPathForm) {
|
||||
const pathForm = p as PathForm;
|
||||
return {
|
||||
pathType: pathForm.pathType,
|
||||
path: pathForm.path,
|
||||
backend: {
|
||||
service: {
|
||||
name: pathForm.serviceName,
|
||||
port:
|
||||
typeof pathForm.servicePort === 'number'
|
||||
? { number: pathForm.servicePort }
|
||||
: (() => {
|
||||
const asNum = Number(pathForm.servicePort);
|
||||
return Number.isFinite(asNum)
|
||||
? { number: asNum }
|
||||
: { name: String(pathForm.servicePort || '') };
|
||||
})()
|
||||
}
|
||||
}
|
||||
};
|
||||
} else {
|
||||
// It's HTTPIngressPath, use it as-is
|
||||
return p;
|
||||
}
|
||||
})
|
||||
}
|
||||
}));
|
||||
|
||||
const updated: Partial<Ingress> = {
|
||||
...ingress,
|
||||
spec: {
|
||||
...ingress.spec,
|
||||
rules: newRules
|
||||
}
|
||||
};
|
||||
|
||||
msgApi.loading({ content: 'Updating Ingress...', key: msgKey }, 0);
|
||||
const yaml = dumpKubeObject(updated);
|
||||
const res = await updateResource(ingress.kind, ingress.getName(), yaml);
|
||||
msgApi.success({
|
||||
content: `Updated ${res.data.kind} ${res.data.metadata.name}`,
|
||||
key: msgKey
|
||||
});
|
||||
onOk();
|
||||
} catch (err: any) {
|
||||
const resp = buildErrorResponse(err);
|
||||
msgApi.error({
|
||||
content: `Update failed: ${resp.error.message}`,
|
||||
key: msgKey
|
||||
});
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
if (form.isFieldsTouched(true)) {
|
||||
Modal.confirm({
|
||||
title: 'Discard changes?',
|
||||
content: 'Unsaved changes will be lost.',
|
||||
okText: 'Confirm',
|
||||
cancelText: 'Continue Editing',
|
||||
onOk: () => {
|
||||
form.setFieldsValue(initialValues);
|
||||
onCancel();
|
||||
}
|
||||
});
|
||||
} else {
|
||||
form.setFieldsValue(initialValues);
|
||||
onCancel();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{contextHolder}
|
||||
<Drawer
|
||||
open={open}
|
||||
onClose={handleCancel}
|
||||
title={`Edit Ingress Paths: ${ingress.getName()}`}
|
||||
width="750px"
|
||||
extra={
|
||||
<div className="flex items-center gap-2">
|
||||
<Button onClick={handleCancel} size="middle" type="default">
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={handleSubmit}
|
||||
icon={<SaveOutlined />}
|
||||
size="middle"
|
||||
loading={submitting}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Form
|
||||
form={form}
|
||||
layout="vertical"
|
||||
initialValues={initialValues}
|
||||
style={{ paddingRight: 8 }}
|
||||
>
|
||||
<Form.List name="rules">
|
||||
{(fields) => (
|
||||
<div className="flex flex-col gap-6">
|
||||
{fields.map((field) => (
|
||||
<div key={field.key} className="flex flex-col gap-4">
|
||||
<div className="font-medium text-base text-gray-900 border-b border-gray-200 pb-2">
|
||||
Host: {ingress.spec.rules?.[field.name]?.host || '*'}
|
||||
</div>
|
||||
<Form.List name={[field.name, 'paths']}>
|
||||
{(pathFields, { add: addPath, remove: removePath }) => (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-gray-600 font-medium">Paths</span>
|
||||
<Button
|
||||
onClick={() =>
|
||||
addPath({
|
||||
path: '',
|
||||
pathType: 'Prefix',
|
||||
serviceName: '',
|
||||
servicePort: ''
|
||||
})
|
||||
}
|
||||
type="dashed"
|
||||
size="small"
|
||||
icon={<PlusOutlined />}
|
||||
>
|
||||
Add Path
|
||||
</Button>
|
||||
</div>
|
||||
{pathFields.map((pf) => (
|
||||
<div
|
||||
key={pf.key}
|
||||
className="p-4 rounded-lg bg-gray-50 border border-gray-100"
|
||||
>
|
||||
<Space wrap align="start" style={{ width: '100%' }} size="middle">
|
||||
<Form.Item
|
||||
label="Path"
|
||||
name={[pf.name, 'path']}
|
||||
rules={[{ required: true, message: 'Please enter Path' }]}
|
||||
className="mb-0!"
|
||||
>
|
||||
<Input
|
||||
placeholder="/"
|
||||
style={{ width: 160 }}
|
||||
disabled={isRootPath(field.name, pf.name)}
|
||||
readOnly={isRootPath(field.name, pf.name)}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label="PathType"
|
||||
name={[pf.name, 'pathType']}
|
||||
rules={[{ required: true, message: 'Please select PathType' }]}
|
||||
className="mb-0!"
|
||||
>
|
||||
<Select
|
||||
style={{ width: 140 }}
|
||||
disabled={isRootPath(field.name, pf.name)}
|
||||
options={[
|
||||
{ label: 'Prefix', value: 'Prefix' },
|
||||
{ label: 'Exact', value: 'Exact' },
|
||||
{
|
||||
label: 'ImplementationSpecific',
|
||||
value: 'ImplementationSpecific'
|
||||
}
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label="Service Name"
|
||||
name={[pf.name, 'serviceName']}
|
||||
rules={[{ required: true, message: 'Please enter Service Name' }]}
|
||||
className="mb-0!"
|
||||
>
|
||||
<Input
|
||||
placeholder="Service Name"
|
||||
style={{ width: 160 }}
|
||||
disabled={isRootPath(field.name, pf.name)}
|
||||
readOnly={isRootPath(field.name, pf.name)}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label="Port"
|
||||
name={[pf.name, 'servicePort']}
|
||||
tooltip="Port number or name"
|
||||
className="mb-0!"
|
||||
>
|
||||
<Input
|
||||
placeholder="80"
|
||||
style={{ width: 80 }}
|
||||
disabled={isRootPath(field.name, pf.name)}
|
||||
readOnly={isRootPath(field.name, pf.name)}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label=" "
|
||||
colon={false}
|
||||
className="mb-0!"
|
||||
shouldUpdate={(prev, curr) => {
|
||||
const prevPath =
|
||||
prev?.rules?.[field.name]?.paths?.[pf.name]?.path;
|
||||
const currPath =
|
||||
curr?.rules?.[field.name]?.paths?.[pf.name]?.path;
|
||||
return prevPath !== currPath;
|
||||
}}
|
||||
>
|
||||
{() => {
|
||||
const isRoot = isRootPath(field.name, pf.name);
|
||||
return (
|
||||
<Button
|
||||
danger
|
||||
type="text"
|
||||
disabled={isRoot}
|
||||
onClick={() => {
|
||||
if (isRoot) {
|
||||
msgApi.warning('The root path "/" cannot be deleted');
|
||||
return;
|
||||
}
|
||||
removePath(pf.name);
|
||||
}}
|
||||
icon={<DeleteOutlined />}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
</Form.Item>
|
||||
</Space>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Form.List>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Form.List>
|
||||
</Form>
|
||||
</Drawer>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default IngressVisualEditorDrawer;
|
||||
-66
@@ -1,66 +0,0 @@
|
||||
import { KubeObjectAge } from '@/components/kube/object/kube-object-age';
|
||||
import { Ingress, computeRouteDeclarations } from '@/k8slens/kube-object';
|
||||
import { ColumnsType } from 'antd/lib/table';
|
||||
import { useIngressStore } from '@/store/kube';
|
||||
import { Button } from 'antd';
|
||||
import IngressDetail from './ingress-detail';
|
||||
import { PanelTable } from '@/components/common/panel-table/table';
|
||||
import { ActionButton } from '@/components/common/action/action-button';
|
||||
|
||||
const columns: ColumnsType<Ingress> = [
|
||||
{
|
||||
title: 'Rules',
|
||||
key: 'rules',
|
||||
render: (_, ingress) =>
|
||||
computeRouteDeclarations(ingress).map((decl) => (
|
||||
<div key={decl.url} className="overflow-hidden">
|
||||
{decl.displayAsLink ? (
|
||||
<>
|
||||
<Button
|
||||
href={decl.url}
|
||||
rel="noreferrer"
|
||||
target="_blank"
|
||||
type="link"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
style={{ padding: 0, display: 'inline' }}
|
||||
>
|
||||
{decl.url}
|
||||
</Button>
|
||||
{` ⇢ ${decl.service}`}
|
||||
</>
|
||||
) : (
|
||||
<>{`${decl.url} ⇢ ${decl.service}`}</>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
},
|
||||
{
|
||||
title: 'Age',
|
||||
key: 'age',
|
||||
render: (_, ingress) => <KubeObjectAge obj={ingress} />
|
||||
},
|
||||
{
|
||||
fixed: 'right',
|
||||
key: 'action',
|
||||
render: (_, ingress) => <ActionButton obj={ingress} />
|
||||
}
|
||||
];
|
||||
|
||||
const IngressOverviewPage = () => {
|
||||
const { items, initialize, isLoaded } = useIngressStore();
|
||||
|
||||
return (
|
||||
<PanelTable
|
||||
columns={columns}
|
||||
dataSource={items}
|
||||
loading={!isLoaded}
|
||||
sectionTitle="Ingresses"
|
||||
DetailDrawer={IngressDetail}
|
||||
getRowKey={(ingress) => ingress.getId()}
|
||||
initializers={[initialize]}
|
||||
getDetailItem={(ingress) => ingress}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default IngressOverviewPage;
|
||||
-96
@@ -1,96 +0,0 @@
|
||||
import { Drawer } from '@/components/common/drawer/drawer';
|
||||
import { DrawerPanel } from '@/components/common/drawer/drawer-panel';
|
||||
import { KubeObjectInfoList } from '@/components/kube/object/detail/kube-object-detail-info-list';
|
||||
import { ComputedIngressRoute, ILoadBalancerIngress, Service } from '@/k8slens/kube-object';
|
||||
import { Button, Table } from 'antd';
|
||||
import { ColumnsType } from 'antd/lib/table';
|
||||
|
||||
const rulesColumns: ColumnsType<ComputedIngressRoute> = [
|
||||
{
|
||||
key: 'path',
|
||||
title: 'Path',
|
||||
dataIndex: 'pathname',
|
||||
render: (pathname: string) => (pathname === '' ? '_' : pathname)
|
||||
},
|
||||
{
|
||||
key: 'link',
|
||||
title: 'Link',
|
||||
render: (_, { displayAsLink, url }) =>
|
||||
displayAsLink ? (
|
||||
<Button
|
||||
href={url}
|
||||
rel="noreferrer"
|
||||
target="_blank"
|
||||
type="link"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{url}
|
||||
</Button>
|
||||
) : (
|
||||
url
|
||||
)
|
||||
},
|
||||
{
|
||||
key: 'backends',
|
||||
title: 'Backends',
|
||||
dataIndex: 'service'
|
||||
}
|
||||
];
|
||||
|
||||
const pointsColumns: ColumnsType<ILoadBalancerIngress> = [
|
||||
{
|
||||
key: 'hostname',
|
||||
title: 'Hostname',
|
||||
dataIndex: 'hostname',
|
||||
render: (hostname?: string) => (hostname ? '_' : hostname)
|
||||
},
|
||||
{
|
||||
key: 'ip',
|
||||
title: 'IP',
|
||||
dataIndex: 'ip',
|
||||
render: (ip?: string) => (ip ? '_' : ip)
|
||||
}
|
||||
];
|
||||
|
||||
const IngressPoints = ({ points }: { points?: ILoadBalancerIngress[] }) => {
|
||||
if (!points || points.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Table size="small" bordered columns={pointsColumns} dataSource={points} pagination={false} />
|
||||
);
|
||||
};
|
||||
|
||||
const ServiceDetail = ({ obj: service, open, onClose }: DetailDrawerProps<Service>) => {
|
||||
if (!service || !(service instanceof Service)) return null;
|
||||
return (
|
||||
<Drawer open={open} title={`Service: ${service.getName()}`} onClose={onClose}>
|
||||
<DrawerPanel>
|
||||
<KubeObjectInfoList obj={service} />
|
||||
<div className="flex items-center justify-between py-2 border-b border-[#E8E8E8]">
|
||||
<div>
|
||||
<span className="text-[#8C8C8C] font-medium text-sm block mb-1">Cluster IP</span>
|
||||
<span className="text-[#262626] text-xs">{service.getClusterIp()}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-[#8C8C8C] font-medium text-sm block mb-1">Type</span>
|
||||
<span className="text-[#262626] text-xs">{service.getType()}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-[#8C8C8C] font-medium text-sm block mb-1">Ports</span>
|
||||
<div className="flex flex-col gap-0.5">
|
||||
{service.getPorts().map((port) => (
|
||||
<span key={port.toString()} className="text-[#262626] text-xs leading-snug">
|
||||
{port.toString()}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DrawerPanel>
|
||||
</Drawer>
|
||||
);
|
||||
};
|
||||
|
||||
export default ServiceDetail;
|
||||
-96
@@ -1,96 +0,0 @@
|
||||
import { KubeObjectAge } from '@/components/kube/object/kube-object-age';
|
||||
import {
|
||||
KubeObject,
|
||||
KubeObjectMetadata,
|
||||
KubeObjectScope,
|
||||
Service,
|
||||
computeRouteDeclarations
|
||||
} from '@/k8slens/kube-object';
|
||||
import { ColumnsType } from 'antd/lib/table';
|
||||
import { useServiceStore } from '@/store/kube';
|
||||
import { Button } from 'antd';
|
||||
import ServiceDetail from './service-detail';
|
||||
import { PanelTable } from '@/components/common/panel-table/table';
|
||||
import { ActionButton } from '@/components/common/action/action-button';
|
||||
|
||||
const columns: ColumnsType<Service> = [
|
||||
{
|
||||
title: 'ClusterIps',
|
||||
key: 'ClusterIps',
|
||||
render: (_, service: Service) => (
|
||||
<div className="flex flex-col gap-0.5">
|
||||
{service.getClusterIps().map((ip) => (
|
||||
<span key={ip} className="leading-snug">
|
||||
{ip}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
{
|
||||
title: 'Type',
|
||||
key: 'type',
|
||||
render: (_, service: Service) => <span>{service.getType()}</span>
|
||||
},
|
||||
{
|
||||
title: 'Selector',
|
||||
key: 'selector',
|
||||
render: (_, service: Service) => (
|
||||
<div className="flex flex-col gap-0.5">
|
||||
{service.getSelector().map((selector) => (
|
||||
<span key={selector} className="leading-snug">
|
||||
{selector}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
{
|
||||
title: 'Ports',
|
||||
key: 'ports',
|
||||
render: (_, service: Service) => (
|
||||
<div className="flex flex-col gap-0.5">
|
||||
{service.getPorts().map((port) => (
|
||||
<span key={port.toString()} className="leading-snug">
|
||||
{port.toString()}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
{
|
||||
title: 'Status',
|
||||
key: 'status',
|
||||
render: (_, service: Service) => <span>{service.getStatus()}</span>
|
||||
},
|
||||
{
|
||||
title: 'Age',
|
||||
key: 'age',
|
||||
render: (_, service: Service) => <KubeObjectAge key="age" obj={service} />
|
||||
},
|
||||
{
|
||||
key: 'action',
|
||||
fixed: 'right',
|
||||
width: 20,
|
||||
render: (_, service: Service) => <ActionButton obj={service} />
|
||||
}
|
||||
];
|
||||
|
||||
const ServiceOverviewPage = () => {
|
||||
const { items, initialize, isLoaded } = useServiceStore();
|
||||
|
||||
return (
|
||||
<PanelTable
|
||||
columns={columns}
|
||||
dataSource={items}
|
||||
loading={!isLoaded}
|
||||
sectionTitle="Services"
|
||||
DetailDrawer={ServiceDetail}
|
||||
getRowKey={(service) => service.getId()}
|
||||
initializers={[initialize]}
|
||||
getDetailItem={(service) => service}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default ServiceOverviewPage;
|
||||
-79
@@ -1,79 +0,0 @@
|
||||
import { PersistentVolumeClaim, Pod } from '@/k8slens/kube-object';
|
||||
import { Drawer } from '@/components/common/drawer/drawer';
|
||||
import { KubeObjectInfoList } from '@/components/kube/object/detail/kube-object-detail-info-list';
|
||||
import { DrawerItem } from '@/components/common/drawer/drawer-item';
|
||||
import { KubeBadge } from '@/components/kube/kube-badge';
|
||||
import React from 'react';
|
||||
import { DrawerPanel } from '@/components/common/drawer/drawer-panel';
|
||||
import { isArray } from 'lodash';
|
||||
|
||||
const PersistentVolumeClaimDetail = ({
|
||||
obj,
|
||||
open,
|
||||
onClose
|
||||
}: DetailDrawerProps<{ volumeClaim: PersistentVolumeClaim; pods: Pod[] }>) => {
|
||||
if (!obj) return null;
|
||||
|
||||
const { volumeClaim, pods } = obj;
|
||||
if (!volumeClaim || !(volumeClaim instanceof PersistentVolumeClaim)) return null;
|
||||
if (!pods || !isArray(pods)) return null;
|
||||
|
||||
const { storageClassName, accessModes } = volumeClaim.spec;
|
||||
|
||||
// const storageClassDetailsUrl = getDetailsUrl(storageClassApi.formatUrlForNotListing({
|
||||
// name: storageClassName,
|
||||
// }));
|
||||
|
||||
return (
|
||||
<Drawer open={open} title={`PersistentVolumeClaim: ${volumeClaim.getName()}`} onClose={onClose}>
|
||||
<DrawerPanel>
|
||||
<KubeObjectInfoList obj={volumeClaim} />
|
||||
<DrawerItem name={'Access Modes'} value={accessModes?.join(', ')} />
|
||||
<DrawerItem name="Storage Class" value={storageClassName} />
|
||||
<DrawerItem name="Storage" value={volumeClaim.getStorage()} />
|
||||
<DrawerItem
|
||||
name="Pods"
|
||||
value={volumeClaim.getPods(pods).map((pod) => (
|
||||
<span key={pod.getName()} className="mr-1">
|
||||
{pod.getName()}
|
||||
</span>
|
||||
))}
|
||||
/>
|
||||
<DrawerItem name="Status" value={volumeClaim.getStatus()} />
|
||||
|
||||
{(volumeClaim.getMatchLabels().length > 0 ||
|
||||
volumeClaim.getMatchExpressions().length > 0) && (
|
||||
<>
|
||||
<div className="text-[#262626] font-medium text-base mt-8 mb-4">Selector</div>
|
||||
{volumeClaim.getMatchLabels().length > 0 && (
|
||||
<DrawerItem
|
||||
name="Match Labels"
|
||||
value={
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{volumeClaim.getMatchLabels().map((label) => (
|
||||
<KubeBadge key={label} label={label} />
|
||||
))}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{volumeClaim.getMatchExpressions().length > 0 && (
|
||||
<DrawerItem
|
||||
name="Match Expressions"
|
||||
value={volumeClaim.getMatchExpressions().map(({ key, operator, values }, i) => (
|
||||
<React.Fragment key={i}>
|
||||
<DrawerItem name="Key" value={key} />
|
||||
<DrawerItem name="Operator" value={operator} />
|
||||
<DrawerItem name="Values" value={values?.join(', ')} />
|
||||
</React.Fragment>
|
||||
))}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</DrawerPanel>
|
||||
</Drawer>
|
||||
);
|
||||
};
|
||||
|
||||
export default PersistentVolumeClaimDetail;
|
||||
-87
@@ -1,87 +0,0 @@
|
||||
import { KubeObjectAge } from '@/components/kube/object/kube-object-age';
|
||||
import { PersistentVolumeClaim, Pod } from '@/k8slens/kube-object';
|
||||
import { ColumnsType } from 'antd/es/table';
|
||||
import PersistentVolumeClaimDetail from './volume-claim-detail';
|
||||
import { usePodStore, useVolumeClaimStore } from '@/store/kube';
|
||||
import { PanelTable } from '@/components/common/panel-table/table';
|
||||
import { ActionButton } from '@/components/common/action/action-button';
|
||||
|
||||
const columns: ColumnsType<{ volumeClaim: PersistentVolumeClaim; pods: Pod[] }> = [
|
||||
{
|
||||
title: 'Storage Class',
|
||||
key: 'storageClass',
|
||||
render: (_, { volumeClaim }) => volumeClaim.spec.storageClassName
|
||||
},
|
||||
{
|
||||
title: 'Size',
|
||||
key: 'size',
|
||||
render: (_, { volumeClaim }) => volumeClaim.getStorage()
|
||||
},
|
||||
{
|
||||
title: 'Pods',
|
||||
key: 'pods',
|
||||
ellipsis: true,
|
||||
render: (_, { volumeClaim, pods }) => {
|
||||
const podsNames = volumeClaim.getPods(pods).map((pod) => pod.getName());
|
||||
return (
|
||||
<div className="flex flex-col gap-0.5">
|
||||
{podsNames.map((name) => (
|
||||
<span key={name} className="leading-snug text-[#1890FF]">
|
||||
{name}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
title: 'Status',
|
||||
key: 'status',
|
||||
render: (_, { volumeClaim }) => volumeClaim.getStatus()
|
||||
},
|
||||
{
|
||||
title: 'Age',
|
||||
key: 'age',
|
||||
render: (_, { volumeClaim }) => <KubeObjectAge obj={volumeClaim} />
|
||||
},
|
||||
{
|
||||
key: 'action',
|
||||
fixed: 'right',
|
||||
render: (_, { volumeClaim }) => <ActionButton obj={volumeClaim} />
|
||||
}
|
||||
];
|
||||
|
||||
const PersistentVolumeClaimOverviewPage = () => {
|
||||
const {
|
||||
items: volumeClaims,
|
||||
initialize: initializeVolumeClaims,
|
||||
isLoaded: isVolumeClaimsLoaded
|
||||
} = useVolumeClaimStore();
|
||||
const { items: pods, initialize: initializePods, isLoaded: isPodsLoaded } = usePodStore();
|
||||
|
||||
const dataSource = volumeClaims.map((volumeClaim) => ({
|
||||
volumeClaim,
|
||||
pods
|
||||
}));
|
||||
|
||||
return (
|
||||
<PanelTable
|
||||
columns={columns}
|
||||
dataSource={dataSource}
|
||||
loading={!isVolumeClaimsLoaded || !isPodsLoaded}
|
||||
sectionTitle="Persistent Volume Claims"
|
||||
DetailDrawer={({ obj, open, onClose }) => (
|
||||
<PersistentVolumeClaimDetail
|
||||
obj={obj instanceof PersistentVolumeClaim ? { volumeClaim: obj, pods } : null}
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
/>
|
||||
)}
|
||||
getRowKey={({ volumeClaim }) => volumeClaim.getId()}
|
||||
initializers={[initializeVolumeClaims, initializePods]}
|
||||
getDetailItem={(record) => record.volumeClaim}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default PersistentVolumeClaimOverviewPage;
|
||||
-112
@@ -1,112 +0,0 @@
|
||||
import { Drawer } from '@/components/common/drawer/drawer';
|
||||
import { DrawerItem } from '@/components/common/drawer/drawer-item';
|
||||
import { DrawerPanel } from '@/components/common/drawer/drawer-panel';
|
||||
import { KubeBadge } from '@/components/kube/kube-badge';
|
||||
import { KubeObjectInfoList } from '@/components/kube/object/detail/kube-object-detail-info-list';
|
||||
import { Deployment } from '@/k8slens/kube-object';
|
||||
import { useDeploymentStore } from '@/store/kube';
|
||||
import { getConditionTextTone } from '@/utils/condtion-color';
|
||||
import { Divider, Tooltip, Typography } from 'antd';
|
||||
import ContainerDetail from '../pod/container-detail';
|
||||
import PodDetailAffinities from '../pod/pod-detail-affinities';
|
||||
import PodDetailSecurityContext from '../pod/pod-detail-security-context';
|
||||
import PodDetailTolerations from '../pod/pod-detail-tolerations';
|
||||
|
||||
const DeploymentDetail = ({ obj: dep, open, onClose }: DetailDrawerProps<Deployment>) => {
|
||||
if (!dep || !(dep instanceof Deployment)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { status, spec } = dep;
|
||||
const selectors = dep.getSelectors();
|
||||
const conditions = dep.getConditions();
|
||||
const { initialize } = useDeploymentStore();
|
||||
|
||||
return (
|
||||
<Drawer open={open} title={`Deployment: ${dep.getName()}`} onClose={onClose}>
|
||||
<DrawerPanel>
|
||||
<KubeObjectInfoList obj={dep} />
|
||||
<DrawerItem
|
||||
name="Replicas"
|
||||
value={
|
||||
<>
|
||||
{`${spec.replicas} desired, ${status?.updatedReplicas ?? 0} updated, `}
|
||||
{`${status?.replicas ?? 0} total, ${status?.availableReplicas ?? 0} available`}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
{selectors.length > 0 && (
|
||||
<DrawerItem
|
||||
name="Selector"
|
||||
vertical
|
||||
value={
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{[...selectors].sort().map((label) => {
|
||||
const sepIndex = label.indexOf(': ');
|
||||
if (sepIndex === -1)
|
||||
return <KubeBadge key={label} label={label} className="m-0!" />;
|
||||
const key = label.slice(0, sepIndex);
|
||||
const value = label.slice(sepIndex + 2);
|
||||
return (
|
||||
<KubeBadge
|
||||
key={label}
|
||||
label={
|
||||
<span>
|
||||
<span className="font-medium text-gray-900">{key}</span>
|
||||
<span className="text-gray-900 mr-1">:</span>
|
||||
<span className="text-gray-600">{value}</span>
|
||||
</span>
|
||||
}
|
||||
className="m-0!"
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<DrawerItem name="Strategy Type" value={spec.strategy.type} />
|
||||
<DrawerItem
|
||||
name="Conditions"
|
||||
value={
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{conditions.map(({ type, message, lastTransitionTime }) => (
|
||||
<Tooltip
|
||||
key={type}
|
||||
title={
|
||||
<>
|
||||
<p>{message}</p>
|
||||
<br />
|
||||
<p>Last transition time: {lastTransitionTime ?? '<unknown>'}</p>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Typography.Text className="mr-0" type={getConditionTextTone(type)}>
|
||||
{type}
|
||||
</Typography.Text>
|
||||
</Tooltip>
|
||||
))}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
<PodDetailTolerations workload={dep} />
|
||||
<PodDetailAffinities workload={dep} />
|
||||
<PodDetailSecurityContext
|
||||
securityContext={spec.template.spec.securityContext}
|
||||
workload={dep}
|
||||
onUpdate={() => initialize(() => {}, true)}
|
||||
/>
|
||||
<Divider />
|
||||
|
||||
<ContainerDetail
|
||||
containers={spec.template.spec.containers}
|
||||
initContainers={spec.template.spec.initContainers}
|
||||
workload={dep}
|
||||
onUpdate={() => initialize(() => {}, true)}
|
||||
/>
|
||||
</DrawerPanel>
|
||||
</Drawer>
|
||||
);
|
||||
};
|
||||
|
||||
export default DeploymentDetail;
|
||||
-63
@@ -1,63 +0,0 @@
|
||||
import { KubeObjectAge } from '@/components/kube/object/kube-object-age';
|
||||
import { Deployment } from '@/k8slens/kube-object';
|
||||
import { getConditionTextTone } from '@/utils/condtion-color';
|
||||
import { Tooltip, Typography } from 'antd';
|
||||
import { ColumnsType } from 'antd/es/table';
|
||||
import DeploymentDetail from './deployment-detail';
|
||||
import { useDeploymentStore } from '@/store/kube';
|
||||
import { PanelTable } from '@/components/common/panel-table/table';
|
||||
import { ActionButton } from '@/components/common/action/action-button';
|
||||
|
||||
const columns: ColumnsType<Deployment> = [
|
||||
{
|
||||
title: 'Pods',
|
||||
key: 'pods',
|
||||
render: (_, dep) => {
|
||||
const { replicas = 0, availableReplicas = 0 } = dep.status ?? {};
|
||||
return `${availableReplicas}/${replicas}`;
|
||||
}
|
||||
},
|
||||
|
||||
{
|
||||
title: 'Conditions',
|
||||
key: 'conditions',
|
||||
render: (_, dep) =>
|
||||
dep.getConditions().map(({ type, message }) => (
|
||||
<Tooltip key={type} title={message}>
|
||||
<Typography.Text className="mr-2 last:mr-0" type={getConditionTextTone(type)}>
|
||||
{type}
|
||||
</Typography.Text>
|
||||
</Tooltip>
|
||||
))
|
||||
},
|
||||
{
|
||||
title: 'Age',
|
||||
dataIndex: 'creationTimestamp',
|
||||
key: 'age',
|
||||
render: (_, dep) => <KubeObjectAge obj={dep} />
|
||||
},
|
||||
{
|
||||
key: 'action',
|
||||
fixed: 'right',
|
||||
render: (_, dep) => <ActionButton obj={dep} />
|
||||
}
|
||||
];
|
||||
|
||||
const DeploymentOverviewPage = () => {
|
||||
const { items, initialize, isLoaded } = useDeploymentStore();
|
||||
|
||||
return (
|
||||
<PanelTable
|
||||
columns={columns}
|
||||
loading={!isLoaded}
|
||||
dataSource={items}
|
||||
sectionTitle="Deployments"
|
||||
DetailDrawer={DeploymentDetail}
|
||||
getRowKey={(deployment) => deployment.getId()}
|
||||
initializers={[initialize]}
|
||||
getDetailItem={(deployment) => deployment}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default DeploymentOverviewPage;
|
||||
-108
@@ -1,108 +0,0 @@
|
||||
import Title from '@/components/common/title/title';
|
||||
import { KubeObjectAge } from '@/components/kube/object/kube-object-age';
|
||||
import { ReactiveDuration } from '@/components/kube/reactive-duration';
|
||||
import { useWatcher } from '@/hooks/useWatcher';
|
||||
import { KubeEvent, ObjectReference } from '@/k8slens/kube-object';
|
||||
import { useEventStore } from '@/store/k8s/event.store';
|
||||
import { Table, Typography } from 'antd';
|
||||
import { ColumnsType } from 'antd/lib/table';
|
||||
|
||||
const columns: ColumnsType<KubeEvent> = [
|
||||
{
|
||||
title: <Title type="table">Type</Title>,
|
||||
key: 'type',
|
||||
dataIndex: 'type',
|
||||
fixed: 'start',
|
||||
width: 80,
|
||||
align: 'center',
|
||||
render: (type: string | undefined) => {
|
||||
if (!type) return 'Unknown';
|
||||
return type;
|
||||
}
|
||||
},
|
||||
{
|
||||
title: <Title type="table">Message</Title>,
|
||||
key: 'message',
|
||||
dataIndex: 'message',
|
||||
render: (message: string | undefined, event: KubeEvent) => {
|
||||
if (!message) return '<unknown>';
|
||||
|
||||
switch (event.type) {
|
||||
case 'Warning':
|
||||
return <Typography.Text type="danger">{message}</Typography.Text>;
|
||||
case 'Normal':
|
||||
default:
|
||||
return message;
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
title: <Title type="table">Involved Object</Title>,
|
||||
key: 'involved-object',
|
||||
dataIndex: 'involvedObject',
|
||||
render: (involvedObject: Required<ObjectReference> | undefined) => {
|
||||
if (!involvedObject) return '<unknown>';
|
||||
return <span className="text-[#0884DD]">{involvedObject.name}</span>;
|
||||
}
|
||||
},
|
||||
{
|
||||
title: <Title type="table">Source</Title>,
|
||||
key: 'source',
|
||||
width: 200,
|
||||
render: (_, event: KubeEvent) => event.getSource()
|
||||
},
|
||||
{
|
||||
title: <Title type="table">Count</Title>,
|
||||
dataIndex: 'count',
|
||||
key: 'count',
|
||||
width: 70,
|
||||
fixed: 'end',
|
||||
align: 'center',
|
||||
render: (count: number | undefined) => {
|
||||
if (!count) return '<unknown>';
|
||||
return count;
|
||||
}
|
||||
},
|
||||
{
|
||||
title: <Title type="table">Age</Title>,
|
||||
key: 'age',
|
||||
width: 80,
|
||||
fixed: 'end',
|
||||
align: 'center',
|
||||
render: (_, event: KubeEvent) => {
|
||||
return <KubeObjectAge obj={event} />;
|
||||
}
|
||||
},
|
||||
{
|
||||
title: <Title type="table">Last Seen</Title>,
|
||||
key: 'last-seen',
|
||||
width: 80,
|
||||
fixed: 'end',
|
||||
align: 'center',
|
||||
render: (_, event: KubeEvent) => {
|
||||
return <ReactiveDuration timestamp={event.lastTimestamp} />;
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
const EventOverview = () => {
|
||||
const { items, initialize, isLoaded } = useEventStore();
|
||||
const cxtHolder = useWatcher({ initializers: [initialize] });
|
||||
|
||||
return (
|
||||
<>
|
||||
{cxtHolder}
|
||||
<Table
|
||||
loading={!isLoaded}
|
||||
size="middle"
|
||||
columns={columns}
|
||||
rowKey={(event) => event.getId()}
|
||||
dataSource={items.slice(0, 10)}
|
||||
pagination={false}
|
||||
scroll={{ x: 'max-content' }}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default EventOverview;
|
||||
-56
@@ -1,56 +0,0 @@
|
||||
import { Flex } from 'antd';
|
||||
import WorkloadStatusOverview from './status-overview';
|
||||
import { convertToPieChartStatusData } from '@/utils/pie-chart';
|
||||
import { countBy, entries, startCase } from 'lodash';
|
||||
import {
|
||||
getDeploymentsStatuses,
|
||||
getStatefulSetsStatuses,
|
||||
useDeploymentStore,
|
||||
usePodStore,
|
||||
useStatefulSetStore
|
||||
} from '@/store/kube';
|
||||
import { Section } from '@/components/common/section/section';
|
||||
import Title from '@/components/common/title/title';
|
||||
import EventOverview from './event-overview';
|
||||
import { useWatcher } from '@/hooks/useWatcher';
|
||||
|
||||
const OverviewPage = () => {
|
||||
const { items: pods, initialize: initializePods } = usePodStore();
|
||||
|
||||
const getPodStatuses = countBy(pods, (pod) => pod.getStatus());
|
||||
|
||||
const { items: deps, initialize: initializeDeployments } = useDeploymentStore();
|
||||
const { items: stats, initialize: initializeStatefulSets } = useStatefulSetStore();
|
||||
|
||||
const cxtHolder = useWatcher({
|
||||
initializers: [initializePods, initializeDeployments, initializeStatefulSets]
|
||||
});
|
||||
|
||||
const statuses = {
|
||||
Pod: convertToPieChartStatusData(getPodStatuses),
|
||||
Deployment: convertToPieChartStatusData(getDeploymentsStatuses(deps, pods)),
|
||||
StatefulSet: convertToPieChartStatusData(getStatefulSetsStatuses(stats, pods))
|
||||
};
|
||||
|
||||
const overviewStatuses = entries(statuses).map(([key, value]) => ({
|
||||
title: startCase(key),
|
||||
data: value
|
||||
}));
|
||||
|
||||
return (
|
||||
<Flex vertical gap="12px" className="p-0">
|
||||
<Section>
|
||||
<Title type="primary">Overview</Title>
|
||||
<div className="w-full flex flex-col flex-wrap gap-3 mt-2">
|
||||
<WorkloadStatusOverview data={overviewStatuses} />
|
||||
</div>
|
||||
</Section>
|
||||
<Section>
|
||||
<Title type="primary">Events</Title>
|
||||
<EventOverview />
|
||||
</Section>
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
||||
export default OverviewPage;
|
||||
-54
@@ -1,54 +0,0 @@
|
||||
import { PieChart } from '@/components/chart/pie-chart';
|
||||
|
||||
type WorkloadStatusType =
|
||||
| 'Running'
|
||||
| 'Scheduled'
|
||||
| 'Pending'
|
||||
| 'Suspended'
|
||||
| 'Evicted'
|
||||
| 'Failed'
|
||||
| 'Succeeded'
|
||||
| 'Terminated'
|
||||
| 'Terminating'
|
||||
| 'Unknown'
|
||||
| 'Empty'
|
||||
| 'Complete';
|
||||
|
||||
export type WorkloadStatusData = {
|
||||
type: WorkloadStatusType;
|
||||
value: number;
|
||||
};
|
||||
|
||||
const statusBackgroundColorMapping = {
|
||||
Running: '#039855',
|
||||
Scheduled: '#399c3d',
|
||||
Pending: '#F79009',
|
||||
Suspended: '#ff9800',
|
||||
Evicted: '#ce3933',
|
||||
Succeeded: '#206923',
|
||||
Failed: '#D92D20',
|
||||
Terminated: '#9dabb5',
|
||||
Terminating: '#9dabb5',
|
||||
Unknown: '#EDEDED',
|
||||
Empty: '#ededed',
|
||||
Complete: '#206923'
|
||||
} as { [key in WorkloadStatusType]: string };
|
||||
|
||||
const WorkloadStatus = ({ title, data }: { title: string; data?: Array<WorkloadStatusData> }) => {
|
||||
if (!data) return null;
|
||||
if (data.length === 0) {
|
||||
data = [{ type: 'Empty', value: 0 }];
|
||||
}
|
||||
|
||||
return (
|
||||
<PieChart
|
||||
{...{
|
||||
title,
|
||||
data,
|
||||
color: (type) => statusBackgroundColorMapping[type as WorkloadStatusType]
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default WorkloadStatus;
|
||||
-21
@@ -1,21 +0,0 @@
|
||||
import { Flex } from 'antd';
|
||||
import WorkloadStatus, { WorkloadStatusData } from './status-chart';
|
||||
|
||||
export interface WorkloadStatusOverviewData {
|
||||
title: string;
|
||||
data: Array<WorkloadStatusData>;
|
||||
onClickTitle?: () => void;
|
||||
}
|
||||
|
||||
const WorkloadStatusOverview = ({ data }: { data?: Array<WorkloadStatusOverviewData> }) => {
|
||||
if (!data) return null;
|
||||
return (
|
||||
<Flex wrap="wrap" justify="space-around">
|
||||
{data.map(({ title, data, onClickTitle }) => (
|
||||
<WorkloadStatus key={title} title={title} data={data} />
|
||||
))}
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
||||
export default WorkloadStatusOverview;
|
||||
-901
@@ -1,901 +0,0 @@
|
||||
import { updateResource } from '@/api/kubernetes';
|
||||
import { DrawerItem } from '@/components/common/drawer/drawer-item';
|
||||
import { KubeBadge } from '@/components/kube/kube-badge';
|
||||
import {
|
||||
Container,
|
||||
DaemonSet,
|
||||
Deployment,
|
||||
Job,
|
||||
Pod,
|
||||
ReplicaSet,
|
||||
StatefulSet
|
||||
} from '@/k8slens/kube-object';
|
||||
import { buildErrorResponse } from '@/services/backend/response';
|
||||
import { dumpKubeObject } from '@/utils/yaml';
|
||||
import { CheckOutlined, CloseOutlined, EditOutlined, SaveOutlined } from '@ant-design/icons';
|
||||
import { Button, InputNumber, Select, Tooltip } from 'antd';
|
||||
import useMessage from 'antd/lib/message/useMessage';
|
||||
import { cloneDeep, isEqual, keys } from 'lodash';
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import ContainerStatus, { ContainerLastState } from './container-status';
|
||||
import ContainerStatusBrick from './container-status-brick';
|
||||
import ProbeCard, { ExtendedProbe } from './probe-card';
|
||||
|
||||
interface Props {
|
||||
pod?: Pod;
|
||||
containers?: Container[];
|
||||
initContainers?: Container[];
|
||||
editable?: boolean;
|
||||
workload?: Deployment | StatefulSet | DaemonSet | ReplicaSet | Job | Pod;
|
||||
onUpdate?: () => void;
|
||||
}
|
||||
|
||||
const ContainerDetail = ({
|
||||
pod,
|
||||
containers: propContainers,
|
||||
initContainers: propInitContainers,
|
||||
editable = true,
|
||||
workload,
|
||||
onUpdate
|
||||
}: Props) => {
|
||||
const initContainers = useMemo(
|
||||
() => (pod ? pod.getInitContainers() : propInitContainers || []),
|
||||
[pod, propInitContainers]
|
||||
);
|
||||
const containers = useMemo(
|
||||
() => (pod ? pod.getContainers() : propContainers || []),
|
||||
[pod, propContainers]
|
||||
);
|
||||
|
||||
if (initContainers.length === 0 && containers.length === 0) return null;
|
||||
|
||||
const [selectedContainer, setSelectedContainer] = useState<string>(
|
||||
containers[0]?.name || initContainers[0]?.name || ''
|
||||
);
|
||||
|
||||
const podId = pod?.getId();
|
||||
useEffect(() => {
|
||||
setSelectedContainer(containers[0]?.name || initContainers[0]?.name || '');
|
||||
}, [podId, containers, initContainers]);
|
||||
|
||||
const currentContainer =
|
||||
containers.find((c) => c.name === selectedContainer) ||
|
||||
initContainers.find((c) => c.name === selectedContainer);
|
||||
const isInit = !!initContainers.find((c) => c.name === selectedContainer);
|
||||
|
||||
const renderOptionLabel = (container: Container, isInit: boolean) => {
|
||||
let state = '';
|
||||
let status = undefined;
|
||||
|
||||
if (pod) {
|
||||
status = pod.getContainerStatuses().find((status) => status.name === container.name);
|
||||
const stateKeys = status ? keys(status?.state ?? {}) : [];
|
||||
state = stateKeys[0] ?? '';
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
{pod && <ContainerStatusBrick state={state} status={status} />}
|
||||
{container.name}
|
||||
{isInit && ' (Init)'}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const options = [
|
||||
{
|
||||
label: 'Init Containers',
|
||||
options: initContainers.map((c) => ({
|
||||
label: renderOptionLabel(c, true),
|
||||
value: c.name
|
||||
}))
|
||||
},
|
||||
{
|
||||
label: 'Containers',
|
||||
options: containers.map((c) => ({
|
||||
label: renderOptionLabel(c, false),
|
||||
value: c.name
|
||||
}))
|
||||
}
|
||||
].filter((group) => group.options.length > 0);
|
||||
|
||||
const isSingleContainer = containers.length + initContainers.length <= 1;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center justify-between mt-8 mb-4">
|
||||
<div className="text-[#262626] font-medium text-base">Containers</div>
|
||||
<Select
|
||||
className="min-w-50"
|
||||
value={selectedContainer}
|
||||
onChange={setSelectedContainer}
|
||||
options={options}
|
||||
open={isSingleContainer ? false : undefined}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{currentContainer && (
|
||||
<ContainerInfo
|
||||
pod={pod}
|
||||
container={currentContainer}
|
||||
isInitial={isInit}
|
||||
editable={editable}
|
||||
workload={workload}
|
||||
onUpdate={onUpdate}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const ContainerInfo = ({
|
||||
container,
|
||||
pod,
|
||||
isInitial,
|
||||
editable,
|
||||
workload,
|
||||
onUpdate
|
||||
}: {
|
||||
container: Container;
|
||||
pod?: Pod;
|
||||
isInitial: boolean;
|
||||
editable: boolean;
|
||||
workload?: Deployment | StatefulSet | DaemonSet | ReplicaSet | Job | Pod;
|
||||
onUpdate?: () => void;
|
||||
}) => {
|
||||
if (!container) return null;
|
||||
const { name, image, imagePullPolicy, ports, volumeMounts, command, args, env } = container;
|
||||
|
||||
const startupProbe = container.startupProbe;
|
||||
const livenessProbe = container.livenessProbe;
|
||||
const readinessProbe = container.readinessProbe;
|
||||
|
||||
const [msgApi, contextHolder] = useMessage();
|
||||
const [probes, setProbes] = useState<{
|
||||
startup?: ExtendedProbe;
|
||||
liveness?: ExtendedProbe;
|
||||
readiness?: ExtendedProbe;
|
||||
}>({ startup: startupProbe, liveness: livenessProbe, readiness: readinessProbe });
|
||||
|
||||
const [draftProbes, setDraftProbes] = useState<{
|
||||
startup?: ExtendedProbe;
|
||||
liveness?: ExtendedProbe;
|
||||
readiness?: ExtendedProbe;
|
||||
}>(probes);
|
||||
const [isEditingProbes, setIsEditingProbes] = useState(false);
|
||||
const [isSavingProbes, setIsSavingProbes] = useState(false);
|
||||
const msgKey = 'probe-save';
|
||||
|
||||
useEffect(() => {
|
||||
setProbes({ startup: startupProbe, liveness: livenessProbe, readiness: readinessProbe });
|
||||
setDraftProbes({ startup: startupProbe, liveness: livenessProbe, readiness: readinessProbe });
|
||||
setIsEditingProbes(false);
|
||||
}, [startupProbe, livenessProbe, readinessProbe]);
|
||||
|
||||
// Reset editing state when drawer closes or container changes
|
||||
|
||||
useEffect(() => {
|
||||
setIsEditingProbes(false);
|
||||
}, [container?.name]);
|
||||
|
||||
const handleProbeChange = (key: 'startup' | 'liveness' | 'readiness', value?: ExtendedProbe) => {
|
||||
setDraftProbes((prev) => ({ ...prev, [key]: value }));
|
||||
};
|
||||
|
||||
const isModified = useMemo(() => !isEqual(probes, draftProbes), [probes, draftProbes]);
|
||||
|
||||
// Resolve the relevant pod spec for the current context (Pod or Workload)
|
||||
const podSpec = useMemo(() => {
|
||||
const owner = workload || pod;
|
||||
if (!owner || !owner.spec) return null;
|
||||
|
||||
const ownerSpec = owner.spec as any;
|
||||
const hasTemplateSpec = Boolean(ownerSpec?.template?.spec);
|
||||
|
||||
return hasTemplateSpec ? cloneDeep(ownerSpec.template.spec) : cloneDeep(ownerSpec);
|
||||
}, [workload, pod]);
|
||||
|
||||
const handleSaveProbes = async () => {
|
||||
if (!isModified) return;
|
||||
|
||||
// Decide which owner object to persist (workload preferred, fallback to pod)
|
||||
const owner = workload || pod;
|
||||
if (!owner || !owner.spec) {
|
||||
msgApi.error('No parent resource found to update probes.');
|
||||
return;
|
||||
}
|
||||
|
||||
const containerField = isInitial ? 'initContainers' : 'containers';
|
||||
|
||||
// Clone current pod spec (template.spec for workloads, or spec for pods)
|
||||
// Use type guard to check if template exists on the spec
|
||||
const ownerSpec = owner.spec as any;
|
||||
const hasTemplateSpec = Boolean(ownerSpec?.template?.spec);
|
||||
const currentPodSpec = hasTemplateSpec
|
||||
? cloneDeep(ownerSpec.template.spec)
|
||||
: cloneDeep(ownerSpec);
|
||||
|
||||
if (!currentPodSpec) {
|
||||
msgApi.error('Cannot locate pod spec for this resource.');
|
||||
return;
|
||||
}
|
||||
|
||||
const sourceContainers = currentPodSpec[containerField] || [];
|
||||
const updatedContainers = sourceContainers.map((c: any) => {
|
||||
if (c?.name !== container.name) return c;
|
||||
|
||||
const updated = { ...c };
|
||||
|
||||
// Handle probes: undefined means remove the field, otherwise set the value
|
||||
if (draftProbes.startup === undefined) {
|
||||
delete updated.startupProbe;
|
||||
} else {
|
||||
updated.startupProbe = draftProbes.startup;
|
||||
}
|
||||
|
||||
if (draftProbes.liveness === undefined) {
|
||||
delete updated.livenessProbe;
|
||||
} else {
|
||||
updated.livenessProbe = draftProbes.liveness;
|
||||
}
|
||||
|
||||
if (draftProbes.readiness === undefined) {
|
||||
delete updated.readinessProbe;
|
||||
} else {
|
||||
updated.readinessProbe = draftProbes.readiness;
|
||||
}
|
||||
|
||||
return updated;
|
||||
});
|
||||
|
||||
const updatedPodSpec = {
|
||||
...currentPodSpec,
|
||||
[containerField]: updatedContainers
|
||||
};
|
||||
|
||||
const updatedSpec = hasTemplateSpec
|
||||
? {
|
||||
...cloneDeep(ownerSpec),
|
||||
template: {
|
||||
...cloneDeep(ownerSpec.template),
|
||||
spec: updatedPodSpec
|
||||
}
|
||||
}
|
||||
: {
|
||||
...cloneDeep(ownerSpec),
|
||||
...updatedPodSpec
|
||||
};
|
||||
|
||||
// Build a clean resource object with only serializable fields
|
||||
const updatedResource = {
|
||||
apiVersion: owner.apiVersion,
|
||||
kind: owner.kind,
|
||||
metadata: {
|
||||
name: owner.metadata.name,
|
||||
namespace: owner.metadata.namespace,
|
||||
resourceVersion: owner.metadata.resourceVersion,
|
||||
...(owner.metadata.labels && { labels: owner.metadata.labels }),
|
||||
...(owner.metadata.annotations && { annotations: owner.metadata.annotations })
|
||||
},
|
||||
spec: updatedSpec
|
||||
} as any;
|
||||
|
||||
try {
|
||||
setIsSavingProbes(true);
|
||||
msgApi.loading({ content: 'Saving probes...', key: msgKey }, 0);
|
||||
const yaml = dumpKubeObject(updatedResource);
|
||||
await updateResource(owner.kind, owner.getName(), yaml);
|
||||
|
||||
setProbes(draftProbes);
|
||||
setIsEditingProbes(false);
|
||||
msgApi.success({ content: 'Probes saved', key: msgKey });
|
||||
onUpdate?.();
|
||||
} catch (err) {
|
||||
const resp = buildErrorResponse(err);
|
||||
msgApi.error({ content: resp.error.message || 'Failed to save probes', key: msgKey });
|
||||
} finally {
|
||||
setIsSavingProbes(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancelProbes = () => {
|
||||
setDraftProbes(probes);
|
||||
setIsEditingProbes(false);
|
||||
};
|
||||
|
||||
let status, state, lastState, ready, imageId;
|
||||
|
||||
if (pod) {
|
||||
status = pod.getContainerStatuses().find((status) => status.name === container.name);
|
||||
state = status ? keys(status?.state ?? {})[0] : '';
|
||||
lastState = status ? keys(status?.lastState ?? {})[0] : '';
|
||||
ready = status ? status.ready : '';
|
||||
imageId = status ? (status.imageID ?? '') : '';
|
||||
}
|
||||
|
||||
// Helper to render env var value source
|
||||
const renderEnvValueSource = (envVar: any) => {
|
||||
if (envVar.value !== undefined) {
|
||||
return <span className="text-gray-700 break-all">{envVar.value || '""'}</span>;
|
||||
}
|
||||
if (envVar.valueFrom) {
|
||||
const { configMapKeyRef, secretKeyRef, fieldRef, resourceFieldRef } = envVar.valueFrom;
|
||||
if (configMapKeyRef) {
|
||||
return (
|
||||
<span className="text-gray-500">
|
||||
configMap:{configMapKeyRef.name}/{configMapKeyRef.key}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (secretKeyRef) {
|
||||
return (
|
||||
<span className="text-gray-500">
|
||||
secret:{secretKeyRef.name}/{secretKeyRef.key}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (fieldRef) {
|
||||
return <span className="text-gray-500">field:{fieldRef.fieldPath}</span>;
|
||||
}
|
||||
if (resourceFieldRef) {
|
||||
return <span className="text-gray-500">resource:{resourceFieldRef.resource}</span>;
|
||||
}
|
||||
}
|
||||
return <span className="text-gray-400">-</span>;
|
||||
};
|
||||
|
||||
// Helper to split image name for visual truncation
|
||||
const splitImage = (img: string) => {
|
||||
const lastColon = img.lastIndexOf(':');
|
||||
const lastAt = img.lastIndexOf('@');
|
||||
const index = Math.max(lastColon, lastAt);
|
||||
if (index > -1) {
|
||||
return { prefix: img.slice(0, index), suffix: img.slice(index) };
|
||||
}
|
||||
return { prefix: img, suffix: '' };
|
||||
};
|
||||
|
||||
const { prefix, suffix } = splitImage(image || '');
|
||||
|
||||
// Get Pod-level securityContext for comparison
|
||||
const podSecurityContext = podSpec?.securityContext;
|
||||
const containerSecurityContext = container.securityContext;
|
||||
|
||||
// Security context editing state
|
||||
const [securityContext, setSecurityContext] = useState(containerSecurityContext);
|
||||
const [draftSecurityContext, setDraftSecurityContext] = useState(containerSecurityContext);
|
||||
const [isEditingSecurity, setIsEditingSecurity] = useState(false);
|
||||
const [isSavingSecurity, setIsSavingSecurity] = useState(false);
|
||||
const securityMsgKey = 'security-context-save';
|
||||
|
||||
useEffect(() => {
|
||||
setSecurityContext(containerSecurityContext);
|
||||
setDraftSecurityContext(containerSecurityContext);
|
||||
setIsEditingSecurity(false);
|
||||
}, [containerSecurityContext]);
|
||||
|
||||
useEffect(() => {
|
||||
setIsEditingSecurity(false);
|
||||
}, [container?.name]);
|
||||
|
||||
const isSecurityModified = useMemo(
|
||||
() => !isEqual(securityContext, draftSecurityContext),
|
||||
[securityContext, draftSecurityContext]
|
||||
);
|
||||
|
||||
const handleSecurityChange = (field: string, value: any) => {
|
||||
setDraftSecurityContext((prev) => ({
|
||||
...(prev || {}),
|
||||
[field]: value
|
||||
}));
|
||||
};
|
||||
|
||||
const handleSaveSecurity = async () => {
|
||||
if (!isSecurityModified || !workload) return;
|
||||
|
||||
const ownerSpec = workload.spec as any;
|
||||
const hasTemplateSpec = Boolean(ownerSpec?.template?.spec);
|
||||
const currentPodSpec = hasTemplateSpec
|
||||
? cloneDeep(ownerSpec.template.spec)
|
||||
: cloneDeep(ownerSpec);
|
||||
|
||||
if (!currentPodSpec) {
|
||||
msgApi.error('Cannot locate pod spec for this resource.');
|
||||
return;
|
||||
}
|
||||
|
||||
const containerField = isInitial ? 'initContainers' : 'containers';
|
||||
const sourceContainers = currentPodSpec[containerField] || [];
|
||||
const updatedContainers = sourceContainers.map((c: any) => {
|
||||
if (c?.name !== container.name) return c;
|
||||
|
||||
const updated = { ...c };
|
||||
|
||||
// Clean up undefined fields from draftSecurityContext
|
||||
const cleanedSecurityContext: any = {};
|
||||
if (draftSecurityContext) {
|
||||
Object.entries(draftSecurityContext).forEach(([key, value]) => {
|
||||
if (value !== undefined) {
|
||||
cleanedSecurityContext[key] = value;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (Object.keys(cleanedSecurityContext).length === 0) {
|
||||
delete updated.securityContext;
|
||||
} else {
|
||||
updated.securityContext = cleanedSecurityContext;
|
||||
}
|
||||
|
||||
return updated;
|
||||
});
|
||||
|
||||
const updatedPodSpec = {
|
||||
...currentPodSpec,
|
||||
[containerField]: updatedContainers
|
||||
};
|
||||
|
||||
const updatedSpec = hasTemplateSpec
|
||||
? {
|
||||
...cloneDeep(ownerSpec),
|
||||
template: {
|
||||
...cloneDeep(ownerSpec.template),
|
||||
spec: updatedPodSpec
|
||||
}
|
||||
}
|
||||
: {
|
||||
...cloneDeep(ownerSpec),
|
||||
...updatedPodSpec
|
||||
};
|
||||
|
||||
const updatedResource = {
|
||||
apiVersion: workload.apiVersion,
|
||||
kind: workload.kind,
|
||||
metadata: {
|
||||
name: workload.metadata.name,
|
||||
namespace: workload.metadata.namespace,
|
||||
resourceVersion: workload.metadata.resourceVersion,
|
||||
...(workload.metadata.labels && { labels: workload.metadata.labels }),
|
||||
...(workload.metadata.annotations && { annotations: workload.metadata.annotations })
|
||||
},
|
||||
spec: updatedSpec
|
||||
} as any;
|
||||
|
||||
try {
|
||||
setIsSavingSecurity(true);
|
||||
msgApi.loading({ content: 'Saving security context...', key: securityMsgKey }, 0);
|
||||
const yaml = dumpKubeObject(updatedResource);
|
||||
await updateResource(workload.kind, workload.getName(), yaml);
|
||||
|
||||
setSecurityContext(draftSecurityContext);
|
||||
setIsEditingSecurity(false);
|
||||
msgApi.success({ content: 'Security context saved', key: securityMsgKey });
|
||||
onUpdate?.();
|
||||
} catch (err) {
|
||||
const resp = buildErrorResponse(err);
|
||||
msgApi.error({
|
||||
content: resp.error.message || 'Failed to save security context',
|
||||
key: securityMsgKey
|
||||
});
|
||||
} finally {
|
||||
setIsSavingSecurity(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancelSecurity = () => {
|
||||
setDraftSecurityContext(securityContext);
|
||||
setIsEditingSecurity(false);
|
||||
};
|
||||
|
||||
const displaySecurityContext = isEditingSecurity ? draftSecurityContext : securityContext;
|
||||
|
||||
const securityFieldCount = useMemo(() => {
|
||||
let count = 0;
|
||||
if (isEditingSecurity || displaySecurityContext?.runAsUser !== undefined) count++;
|
||||
if (isEditingSecurity || displaySecurityContext?.runAsGroup !== undefined) count++;
|
||||
if (isEditingSecurity || displaySecurityContext?.privileged !== undefined) count++;
|
||||
return count;
|
||||
}, [isEditingSecurity, displaySecurityContext]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{contextHolder}
|
||||
{pod && (
|
||||
<DrawerItem
|
||||
name="Phase"
|
||||
className="items-center!"
|
||||
value={
|
||||
<div className="flex items-center gap-2">
|
||||
<ContainerStatus state={state as any} status={status as any} />
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{pod && lastState === 'terminated' && (
|
||||
<DrawerItem
|
||||
name="Last State"
|
||||
value={<ContainerLastState lastState={lastState as any} status={status as any} />}
|
||||
/>
|
||||
)}
|
||||
|
||||
<DrawerItem
|
||||
name="Image"
|
||||
className="items-center!"
|
||||
value={
|
||||
<div className="flex items-center gap-2 min-w-0 text-xs">
|
||||
<Tooltip title={imageId || image}>
|
||||
<div className="flex items-center min-w-0 cursor-pointer text-[#262626]">
|
||||
<span className="truncate">{prefix}</span>
|
||||
<span className="whitespace-nowrap shrink-0">{suffix}</span>
|
||||
</div>
|
||||
</Tooltip>
|
||||
{imagePullPolicy && <KubeBadge label={imagePullPolicy} />}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
{(displaySecurityContext &&
|
||||
(displaySecurityContext.runAsUser !== undefined ||
|
||||
displaySecurityContext.runAsGroup !== undefined ||
|
||||
displaySecurityContext.privileged !== undefined)) ||
|
||||
isEditingSecurity ? (
|
||||
<DrawerItem
|
||||
name="Security"
|
||||
className={securityFieldCount > 1 ? 'items-start!' : 'items-center!'}
|
||||
value={
|
||||
<div
|
||||
className={`flex justify-between w-full gap-4 ${
|
||||
securityFieldCount > 1 ? 'items-start' : 'items-center'
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className={`flex gap-2 text-xs flex-1 ${
|
||||
securityFieldCount > 1 ? 'flex-col' : 'flex-row items-center'
|
||||
}`}
|
||||
>
|
||||
{(isEditingSecurity || displaySecurityContext?.runAsUser !== undefined) && (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-gray-500 whitespace-nowrap w-16">User:</span>
|
||||
{isEditingSecurity ? (
|
||||
<InputNumber
|
||||
size="small"
|
||||
style={{ width: '120px' }}
|
||||
value={draftSecurityContext?.runAsUser}
|
||||
onChange={(val) => handleSecurityChange('runAsUser', val ?? undefined)}
|
||||
placeholder="UID"
|
||||
min={0}
|
||||
/>
|
||||
) : (
|
||||
<span
|
||||
className={`font-medium ${
|
||||
podSecurityContext?.runAsUser !== undefined &&
|
||||
podSecurityContext.runAsUser !== displaySecurityContext?.runAsUser
|
||||
? 'text-orange-600'
|
||||
: 'text-gray-900'
|
||||
}`}
|
||||
>
|
||||
{displaySecurityContext?.runAsUser}
|
||||
{podSecurityContext?.runAsUser !== undefined &&
|
||||
podSecurityContext.runAsUser !== displaySecurityContext?.runAsUser &&
|
||||
` (override: ${podSecurityContext.runAsUser})`}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{(isEditingSecurity || displaySecurityContext?.runAsGroup !== undefined) && (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-gray-500 whitespace-nowrap w-16">Group:</span>
|
||||
{isEditingSecurity ? (
|
||||
<InputNumber
|
||||
size="small"
|
||||
style={{ width: '120px' }}
|
||||
value={draftSecurityContext?.runAsGroup}
|
||||
onChange={(val) => handleSecurityChange('runAsGroup', val ?? undefined)}
|
||||
placeholder="GID"
|
||||
min={0}
|
||||
/>
|
||||
) : (
|
||||
<span
|
||||
className={`font-medium ${
|
||||
podSecurityContext?.runAsGroup !== undefined &&
|
||||
podSecurityContext.runAsGroup !== displaySecurityContext?.runAsGroup
|
||||
? 'text-orange-600'
|
||||
: 'text-gray-900'
|
||||
}`}
|
||||
>
|
||||
{displaySecurityContext?.runAsGroup}
|
||||
{podSecurityContext?.runAsGroup !== undefined &&
|
||||
podSecurityContext.runAsGroup !== displaySecurityContext?.runAsGroup &&
|
||||
` (override: ${podSecurityContext.runAsGroup})`}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{(isEditingSecurity || displaySecurityContext?.privileged !== undefined) && (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-gray-500 whitespace-nowrap w-16">Privileged:</span>
|
||||
<span
|
||||
className={`font-medium ${
|
||||
displaySecurityContext?.privileged ? 'text-red-600' : 'text-gray-900'
|
||||
}`}
|
||||
>
|
||||
{displaySecurityContext?.privileged ? 'true ⚠️' : 'false'}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{editable && workload && (
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
{!isEditingSecurity && (
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
icon={<EditOutlined />}
|
||||
onClick={() => setIsEditingSecurity(true)}
|
||||
/>
|
||||
)}
|
||||
{isEditingSecurity && (
|
||||
<>
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
danger
|
||||
icon={<CloseOutlined />}
|
||||
onClick={handleCancelSecurity}
|
||||
/>
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
icon={<CheckOutlined style={{ color: '#16a34a' }} />}
|
||||
disabled={!isSecurityModified}
|
||||
loading={isSavingSecurity}
|
||||
onClick={handleSaveSecurity}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{ports && ports.length > 0 && (
|
||||
<DrawerItem
|
||||
name="Ports"
|
||||
value={ports.map((port) => (
|
||||
<div key={port.containerPort} className="mb-1 last:mb-0 text-xs">
|
||||
<span className="mr-2">
|
||||
{port.containerPort}/{port.protocol}
|
||||
</span>
|
||||
{port.name && <span className="text-gray-500">({port.name})</span>}
|
||||
</div>
|
||||
))}
|
||||
/>
|
||||
)}
|
||||
|
||||
{container.resources?.limits && (
|
||||
<DrawerItem
|
||||
name="Resources"
|
||||
className="items-center!"
|
||||
value={
|
||||
<div className="flex flex-wrap gap-4 text-xs">
|
||||
{container.resources.limits.cpu && (
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-gray-500">CPU:</span>
|
||||
<span className="font-medium text-gray-900">
|
||||
{container.resources.limits.cpu}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{container.resources.limits.memory && (
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-gray-500">Memory:</span>
|
||||
<span className="font-medium text-gray-900">
|
||||
{container.resources.limits.memory}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{container.resources.limits['ephemeral-storage'] && (
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-gray-500">Ephemeral Storage:</span>
|
||||
<span className="font-medium text-gray-900">
|
||||
{container.resources.limits['ephemeral-storage']}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{volumeMounts && volumeMounts.length > 0 && (
|
||||
<DrawerItem
|
||||
name="Mounts"
|
||||
value={volumeMounts.map((mount) => {
|
||||
const { name, mountPath, readOnly } = mount;
|
||||
const volume = podSpec?.volumes?.find((v: any) => v.name === name);
|
||||
let sourceLabel = '';
|
||||
let sourceValue = '';
|
||||
|
||||
if (volume) {
|
||||
if (volume.persistentVolumeClaim) {
|
||||
sourceLabel = 'pvc';
|
||||
sourceValue = volume.persistentVolumeClaim.claimName;
|
||||
} else if (volume.configMap) {
|
||||
sourceLabel = 'cm';
|
||||
sourceValue = volume.configMap.name;
|
||||
} else if (volume.secret) {
|
||||
sourceLabel = 's';
|
||||
sourceValue = volume.secret.secretName;
|
||||
} else if (volume.emptyDir) {
|
||||
sourceLabel = 'emptyDir';
|
||||
} else if (volume.hostPath) {
|
||||
sourceLabel = 'hostPath';
|
||||
sourceValue = volume.hostPath.path;
|
||||
} else if (volume.downwardAPI) {
|
||||
sourceLabel = 'downwardAPI';
|
||||
sourceValue =
|
||||
volume.downwardAPI.items?.map((item: any) => item.path).join(', ') ||
|
||||
'downwardAPI';
|
||||
} else if (volume.projected) {
|
||||
sourceLabel = 'projected';
|
||||
sourceValue =
|
||||
(
|
||||
volume.projected.sources?.map((s: any) => {
|
||||
if (s.serviceAccountToken) return 'serviceAccountToken';
|
||||
if (s.configMap) return `cm/${s.configMap.name}`;
|
||||
if (s.secret) return `secret/${s.secret.name}`;
|
||||
if (s.downwardAPI) return 'downwardAPI';
|
||||
return 'unknown';
|
||||
}) || []
|
||||
).join(', ') || 'projected';
|
||||
}
|
||||
}
|
||||
|
||||
const isImmutableSource =
|
||||
sourceLabel === 'cm' ||
|
||||
sourceLabel === 's' ||
|
||||
sourceLabel === 'downwardAPI' ||
|
||||
sourceLabel === 'projected';
|
||||
|
||||
return (
|
||||
<div key={name + mountPath} className="mb-2 last:mb-0 text-xs">
|
||||
<span className="mount-path mr-2">{mountPath}</span>
|
||||
<span className="mount-from text-gray-500">
|
||||
{`from ${name}`}
|
||||
{!isImmutableSource && ` (${readOnly ? 'ro' : 'rw'})`}
|
||||
</span>
|
||||
{sourceLabel && (
|
||||
<Tooltip title={sourceValue}>
|
||||
<span className="ml-2 px-1.5 py-0.5 text-xs bg-gray-100 text-gray-500 rounded border border-gray-200 cursor-pointer">
|
||||
{sourceLabel}
|
||||
</span>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
/>
|
||||
)}
|
||||
|
||||
{command && (
|
||||
<DrawerItem
|
||||
name="Command"
|
||||
className="items-center!"
|
||||
value={<span className="text-xs">{command.join(' ')}</span>}
|
||||
/>
|
||||
)}
|
||||
{args && (
|
||||
<DrawerItem
|
||||
name="Arguments"
|
||||
className="items-center!"
|
||||
value={<span className="text-xs">{args.join(' ')}</span>}
|
||||
/>
|
||||
)}
|
||||
|
||||
{env && env.length > 0 && (
|
||||
<div className="py-3 border-b border-[#E8E8E8] last:border-b-0">
|
||||
<div className="text-[#8C8C8C] font-medium text-sm mb-2">Env</div>
|
||||
<div className="overflow-x-auto text-xs text-[#262626] bg-[#F9F9FA] p-3 rounded-lg border border-[#E8E8E8]">
|
||||
{(() => {
|
||||
const getTypeScore = (e: any) => {
|
||||
if (e.value !== undefined) return 0;
|
||||
if (e.valueFrom?.fieldRef || e.valueFrom?.resourceFieldRef) return 1;
|
||||
if (e.valueFrom?.configMapKeyRef || e.valueFrom?.secretKeyRef) return 2;
|
||||
return 3;
|
||||
};
|
||||
|
||||
const sortedEnv = [...env].sort((a: any, b: any) => {
|
||||
const scoreA = getTypeScore(a);
|
||||
const scoreB = getTypeScore(b);
|
||||
if (scoreA !== scoreB) return scoreA - scoreB;
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
|
||||
return sortedEnv.map((envVar: any, index: number) => {
|
||||
const currentScore = getTypeScore(envVar);
|
||||
const prevScore = index > 0 ? getTypeScore(sortedEnv[index - 1]) : currentScore;
|
||||
const showDivider = index > 0 && currentScore !== prevScore;
|
||||
|
||||
return (
|
||||
<React.Fragment key={envVar.name}>
|
||||
{showDivider && <div className="h-px bg-[#E8E8E8] my-1" />}
|
||||
<div className="mb-1 last:mb-0 whitespace-nowrap">
|
||||
<span className="font-medium">{envVar.name}=</span>
|
||||
{renderEnvValueSource(envVar)}
|
||||
</div>
|
||||
</React.Fragment>
|
||||
);
|
||||
});
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{contextHolder}
|
||||
{!isInitial && (
|
||||
<div className="pt-3 border-b border-[#E8E8E8] last:border-b-0">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="text-[#8C8C8C] font-medium text-sm">Health Check</div>
|
||||
{editable && (
|
||||
<div className="flex items-center gap-2">
|
||||
{!isEditingProbes && (
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
className="px-0"
|
||||
onClick={() => setIsEditingProbes(true)}
|
||||
>
|
||||
Edit
|
||||
</Button>
|
||||
)}
|
||||
{isEditingProbes && (
|
||||
<>
|
||||
<Button size="small" icon={<CloseOutlined />} onClick={handleCancelProbes}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
size="small"
|
||||
icon={<SaveOutlined />}
|
||||
disabled={!isModified}
|
||||
loading={isSavingProbes}
|
||||
onClick={handleSaveProbes}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<ProbeCard
|
||||
title="Startup"
|
||||
probe={isEditingProbes ? draftProbes.startup : probes.startup}
|
||||
isEditing={editable && isEditingProbes}
|
||||
onChange={(val) => handleProbeChange('startup', val)}
|
||||
/>
|
||||
<ProbeCard
|
||||
title="Liveness"
|
||||
probe={isEditingProbes ? draftProbes.liveness : probes.liveness}
|
||||
isEditing={editable && isEditingProbes}
|
||||
onChange={(val) => handleProbeChange('liveness', val)}
|
||||
/>
|
||||
<ProbeCard
|
||||
title="Readiness"
|
||||
probe={isEditingProbes ? draftProbes.readiness : probes.readiness}
|
||||
isEditing={editable && isEditingProbes}
|
||||
onChange={(val) => handleProbeChange('readiness', val)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default ContainerDetail;
|
||||
-29
@@ -1,29 +0,0 @@
|
||||
import { PodContainerStatus } from '@/k8slens/kube-object';
|
||||
import { getStatusBadgeTone } from '@/utils/status-color';
|
||||
import { lowerCase } from 'lodash';
|
||||
|
||||
interface ContainerStatusBrickProps {
|
||||
state: string;
|
||||
status?: PodContainerStatus | null;
|
||||
}
|
||||
|
||||
const statusToColor: Record<string, string> = {
|
||||
success: '#52c41a',
|
||||
warning: '#faad14',
|
||||
error: '#ff4d4f',
|
||||
default: '#d9d9d9',
|
||||
processing: '#1890ff'
|
||||
};
|
||||
|
||||
const ContainerStatusBrick = ({ state, status }: ContainerStatusBrickProps) => {
|
||||
const { ready = false } = status ?? {};
|
||||
|
||||
const badgeTone =
|
||||
!ready && lowerCase(state) === 'running' ? 'warning' : (getStatusBadgeTone(state) ?? 'default');
|
||||
|
||||
const color = statusToColor[badgeTone];
|
||||
|
||||
return <div className="inline-block w-3 h-3 rounded-sm" style={{ backgroundColor: color }} />;
|
||||
};
|
||||
|
||||
export default ContainerStatusBrick;
|
||||
-88
@@ -1,88 +0,0 @@
|
||||
import { PodContainerStatus } from '@/k8slens/kube-object';
|
||||
import { LocaleDate } from '@/components/kube/local-date';
|
||||
import moment from 'moment-timezone';
|
||||
import { Typography } from 'antd';
|
||||
import { getStatusTextTone } from '@/utils/status-color';
|
||||
import { entries, startCase } from 'lodash';
|
||||
import React from 'react';
|
||||
|
||||
interface ContainerStatusProps {
|
||||
state: string;
|
||||
status: PodContainerStatus | null | undefined;
|
||||
}
|
||||
|
||||
const ContainerStatus = ({ state, status }: ContainerStatusProps) => {
|
||||
const { ready = false, state: containerState = {} } = status ?? {};
|
||||
const { terminated } = containerState;
|
||||
|
||||
const textTone = getStatusTextTone(state);
|
||||
|
||||
return (
|
||||
<Typography.Text type={textTone}>
|
||||
{state}
|
||||
{ready ? ', ready' : null}
|
||||
{terminated ? ` - ${terminated.reason} (exit code: ${terminated.exitCode})` : null}
|
||||
</Typography.Text>
|
||||
);
|
||||
};
|
||||
|
||||
interface ContainerLastStateProps {
|
||||
lastState: string;
|
||||
status: PodContainerStatus | null | undefined;
|
||||
}
|
||||
|
||||
export const ContainerLastState = ({ lastState, status }: ContainerLastStateProps) => {
|
||||
const { lastState: lastContainerState = {} } = status ?? {};
|
||||
const { terminated } = lastContainerState;
|
||||
const localeTimezone = moment.tz.guess();
|
||||
|
||||
if (lastState === 'terminated' && terminated) {
|
||||
return (
|
||||
<div className="text-xs text-gray-600">
|
||||
<div className="font-medium mb-1">{lastState}</div>
|
||||
<div>
|
||||
Reason: {terminated.reason} - exit code: {terminated.exitCode}
|
||||
</div>
|
||||
<div>
|
||||
Started at: <LocaleDate date={terminated.startedAt} localeTimezone={localeTimezone} />
|
||||
</div>
|
||||
<div>
|
||||
Finished at: <LocaleDate date={terminated.finishedAt} localeTimezone={localeTimezone} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const legalKeys = ['running', 'terminated', 'waiting'] as const;
|
||||
export const renderContainerStateTooltipTitle = (
|
||||
name: string,
|
||||
stateKey: string,
|
||||
status: PodContainerStatus | undefined | null
|
||||
) => {
|
||||
const { state } = status ?? {};
|
||||
if (!state) return null;
|
||||
|
||||
const key = stateKey as keyof typeof state;
|
||||
if (!legalKeys.includes(key)) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="text-left font-medium">
|
||||
{name} {<ContainerStatus state={key} status={status} />}
|
||||
</div>
|
||||
<div className="flex flex-col align-center flex-wrap">
|
||||
{entries(state[key]).map(([name, value]) => (
|
||||
<div className="grid gap-3 grid-cols-[max-content_1fr] grid-flow-row " key={name}>
|
||||
<div className="text-left font-medium">{startCase(name)}</div>
|
||||
<div className="text-left font-[#131A26]">{value}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default ContainerStatus;
|
||||
-24
@@ -1,24 +0,0 @@
|
||||
import { DaemonSet, Deployment, Job, Pod, ReplicaSet, StatefulSet } from '@/k8slens/kube-object';
|
||||
import { DrawerItem } from '@/components/common/drawer/drawer-item';
|
||||
import { KubeObjectAffinities } from '@/components/kube/object/kube-object-affinities';
|
||||
|
||||
export type Props = {
|
||||
workload?: Pod | Deployment | DaemonSet | StatefulSet | ReplicaSet | Job;
|
||||
};
|
||||
|
||||
const PodDetailAffinities = ({ workload }: Props) => {
|
||||
if (!workload) return null;
|
||||
const affinitiesNum = workload.getAffinityNumber();
|
||||
const affinities = workload.getAffinity();
|
||||
|
||||
if (!affinitiesNum || !affinities) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<DrawerItem name="Affinities" />
|
||||
<KubeObjectAffinities affinity={affinities} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default PodDetailAffinities;
|
||||
-244
@@ -1,244 +0,0 @@
|
||||
import { updateResource } from '@/api/kubernetes';
|
||||
import {
|
||||
DaemonSet,
|
||||
Deployment,
|
||||
Job,
|
||||
PodSecurityContext,
|
||||
ReplicaSet,
|
||||
StatefulSet
|
||||
} from '@/k8slens/kube-object';
|
||||
import { buildErrorResponse } from '@/services/backend/response';
|
||||
import { dumpKubeObject } from '@/utils/yaml';
|
||||
import { CheckOutlined, CloseOutlined, EditOutlined } from '@ant-design/icons';
|
||||
import { Button, InputNumber } from 'antd';
|
||||
import useMessage from 'antd/lib/message/useMessage';
|
||||
import { cloneDeep, isEqual } from 'lodash';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
interface Props {
|
||||
securityContext?: PodSecurityContext;
|
||||
editable?: boolean;
|
||||
workload?: Deployment | StatefulSet | DaemonSet | ReplicaSet | Job;
|
||||
onUpdate?: () => void;
|
||||
}
|
||||
|
||||
const PodDetailSecurityContext = ({
|
||||
securityContext,
|
||||
editable = true,
|
||||
workload,
|
||||
onUpdate
|
||||
}: Props) => {
|
||||
const [msgApi, contextHolder] = useMessage();
|
||||
const [context, setContext] = useState<PodSecurityContext | undefined>(securityContext);
|
||||
const [draftContext, setDraftContext] = useState<PodSecurityContext | undefined>(securityContext);
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const msgKey = 'security-context-save';
|
||||
|
||||
useEffect(() => {
|
||||
setContext(securityContext);
|
||||
setDraftContext(securityContext);
|
||||
setIsEditing(false);
|
||||
}, [securityContext]);
|
||||
|
||||
const isModified = useMemo(() => !isEqual(context, draftContext), [context, draftContext]);
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!isModified || !workload) return;
|
||||
|
||||
const ownerSpec = workload.spec as any;
|
||||
const hasTemplateSpec = Boolean(ownerSpec?.template?.spec);
|
||||
const currentPodSpec = hasTemplateSpec
|
||||
? cloneDeep(ownerSpec.template.spec)
|
||||
: cloneDeep(ownerSpec);
|
||||
|
||||
if (!currentPodSpec) {
|
||||
msgApi.error('Cannot locate pod spec for this resource.');
|
||||
return;
|
||||
}
|
||||
|
||||
const updatedPodSpec = {
|
||||
...currentPodSpec,
|
||||
securityContext: draftContext || undefined
|
||||
};
|
||||
|
||||
// Remove securityContext if all fields are undefined
|
||||
if (
|
||||
!draftContext ||
|
||||
(draftContext.runAsUser === undefined &&
|
||||
draftContext.runAsGroup === undefined &&
|
||||
draftContext.fsGroup === undefined)
|
||||
) {
|
||||
delete updatedPodSpec.securityContext;
|
||||
}
|
||||
|
||||
const updatedSpec = hasTemplateSpec
|
||||
? {
|
||||
...cloneDeep(ownerSpec),
|
||||
template: {
|
||||
...cloneDeep(ownerSpec.template),
|
||||
spec: updatedPodSpec
|
||||
}
|
||||
}
|
||||
: {
|
||||
...cloneDeep(ownerSpec),
|
||||
...updatedPodSpec
|
||||
};
|
||||
|
||||
const updatedResource = {
|
||||
apiVersion: workload.apiVersion,
|
||||
kind: workload.kind,
|
||||
metadata: {
|
||||
name: workload.metadata.name,
|
||||
namespace: workload.metadata.namespace,
|
||||
resourceVersion: workload.metadata.resourceVersion,
|
||||
...(workload.metadata.labels && { labels: workload.metadata.labels }),
|
||||
...(workload.metadata.annotations && { annotations: workload.metadata.annotations })
|
||||
},
|
||||
spec: updatedSpec
|
||||
} as any;
|
||||
|
||||
try {
|
||||
setIsSaving(true);
|
||||
msgApi.loading({ content: 'Saving security context...', key: msgKey }, 0);
|
||||
const yaml = dumpKubeObject(updatedResource);
|
||||
await updateResource(workload.kind, workload.getName(), yaml);
|
||||
|
||||
setContext(draftContext);
|
||||
setIsEditing(false);
|
||||
msgApi.success({ content: 'Security context saved', key: msgKey });
|
||||
onUpdate?.();
|
||||
} catch (err) {
|
||||
const resp = buildErrorResponse(err);
|
||||
msgApi.error({
|
||||
content: resp.error.message || 'Failed to save security context',
|
||||
key: msgKey
|
||||
});
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
setDraftContext(context);
|
||||
setIsEditing(false);
|
||||
};
|
||||
|
||||
const handleChange = (field: keyof PodSecurityContext, value: number | undefined) => {
|
||||
setDraftContext((prev) => ({
|
||||
...(prev || {}),
|
||||
[field]: value
|
||||
}));
|
||||
};
|
||||
|
||||
const displayContext = isEditing ? draftContext : context;
|
||||
|
||||
// 如果 securityContext 为空且不在编辑状态,不渲染
|
||||
if (!displayContext && !isEditing) return null;
|
||||
|
||||
// 如果所有字段都为空且不在编辑状态,不渲染
|
||||
const hasAnyValue =
|
||||
displayContext?.runAsUser !== undefined ||
|
||||
displayContext?.runAsGroup !== undefined ||
|
||||
displayContext?.fsGroup !== undefined;
|
||||
|
||||
if (!hasAnyValue && !isEditing) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
{contextHolder}
|
||||
<div className="py-3 border-b border-[#E8E8E8] last:border-b-0">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="text-[#8C8C8C] font-medium text-sm">Security</div>
|
||||
{editable && workload && (
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
{!isEditing && (
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
icon={<EditOutlined />}
|
||||
onClick={() => setIsEditing(true)}
|
||||
/>
|
||||
)}
|
||||
{isEditing && (
|
||||
<>
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
danger
|
||||
icon={<CloseOutlined />}
|
||||
onClick={handleCancel}
|
||||
/>
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
icon={<CheckOutlined style={{ color: '#16a34a' }} />}
|
||||
disabled={!isModified}
|
||||
loading={isSaving}
|
||||
onClick={handleSave}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-6 text-xs w-full min-h-[32px]">
|
||||
{(isEditing || displayContext?.runAsUser !== undefined) && (
|
||||
<div className="flex items-center gap-1.5 flex-1 min-w-0">
|
||||
<span className="text-gray-500 whitespace-nowrap">User:</span>
|
||||
{isEditing ? (
|
||||
<InputNumber
|
||||
size="small"
|
||||
className="flex-1 min-w-0"
|
||||
value={draftContext?.runAsUser}
|
||||
onChange={(val) => handleChange('runAsUser', val ?? undefined)}
|
||||
placeholder="UID"
|
||||
min={0}
|
||||
/>
|
||||
) : (
|
||||
<span className="text-gray-900 font-medium">{displayContext?.runAsUser}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{(isEditing || displayContext?.runAsGroup !== undefined) && (
|
||||
<div className="flex items-center gap-1.5 flex-1 min-w-0">
|
||||
<span className="text-gray-500 whitespace-nowrap">Group:</span>
|
||||
{isEditing ? (
|
||||
<InputNumber
|
||||
size="small"
|
||||
className="flex-1 min-w-0"
|
||||
value={draftContext?.runAsGroup}
|
||||
onChange={(val) => handleChange('runAsGroup', val ?? undefined)}
|
||||
placeholder="GID"
|
||||
min={0}
|
||||
/>
|
||||
) : (
|
||||
<span className="text-gray-900 font-medium">{displayContext?.runAsGroup}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{(isEditing || displayContext?.fsGroup !== undefined) && (
|
||||
<div className="flex items-center gap-1.5 flex-1 min-w-0">
|
||||
<span className="text-gray-500 whitespace-nowrap">FS Group:</span>
|
||||
{isEditing ? (
|
||||
<InputNumber
|
||||
size="small"
|
||||
className="flex-1 min-w-0"
|
||||
value={draftContext?.fsGroup}
|
||||
onChange={(val) => handleChange('fsGroup', val ?? undefined)}
|
||||
placeholder="GID"
|
||||
min={0}
|
||||
/>
|
||||
) : (
|
||||
<span className="text-gray-900 font-medium">{displayContext?.fsGroup}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default PodDetailSecurityContext;
|
||||
-27
@@ -1,27 +0,0 @@
|
||||
import { Pod } from '@/k8slens/kube-object';
|
||||
import { Typography } from 'antd';
|
||||
import { getStatusTextTone } from '@/utils/status-color';
|
||||
import { countBy, entries } from 'lodash';
|
||||
|
||||
interface Props {
|
||||
pods?: Pod[];
|
||||
}
|
||||
const PodDetailStatuses = ({ pods }: Props) => {
|
||||
if (!pods) return null;
|
||||
if (!pods.length) return null;
|
||||
const statuses = countBy(pods.map((pod) => pod.getStatus()));
|
||||
|
||||
return (
|
||||
<div>
|
||||
{entries(statuses).map(([phase, count]) => (
|
||||
<Typography.Text
|
||||
key={phase}
|
||||
className="mr-1"
|
||||
type={getStatusTextTone(phase)}
|
||||
>{`${phase}: ${count}`}</Typography.Text>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PodDetailStatuses;
|
||||
-26
@@ -1,26 +0,0 @@
|
||||
import { KubeObjectTolerations } from '@/components/kube/object/kube-object-tolerations';
|
||||
import { KubeObject, Toleration } from '@/k8slens/kube-object';
|
||||
import { DrawerItem } from '@/components/common/drawer/drawer-item';
|
||||
|
||||
interface KubeObjectWithTolerations extends KubeObject {
|
||||
getTolerations(): Toleration[];
|
||||
}
|
||||
|
||||
interface Props {
|
||||
workload?: KubeObjectWithTolerations;
|
||||
}
|
||||
|
||||
const PodDetailTolerations = ({ workload }: Props) => {
|
||||
if (!workload) return null;
|
||||
const tolerations = workload.getTolerations();
|
||||
if (tolerations.length === 0) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<DrawerItem name="Tolerations" />
|
||||
<KubeObjectTolerations tolerations={tolerations} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default PodDetailTolerations;
|
||||
-104
@@ -1,104 +0,0 @@
|
||||
import { Drawer } from '@/components/common/drawer/drawer';
|
||||
import { DrawerItem } from '@/components/common/drawer/drawer-item';
|
||||
import { DrawerPanel } from '@/components/common/drawer/drawer-panel';
|
||||
import { KubeBadge } from '@/components/kube/kube-badge';
|
||||
import { KubeObjectInfoList } from '@/components/kube/object/detail/kube-object-detail-info-list';
|
||||
import { Pod } from '@/k8slens/kube-object';
|
||||
import { Divider, Tooltip } from 'antd';
|
||||
import ContainerDetail from './container-detail';
|
||||
import PodDetailAffinities from './pod-detail-affinities';
|
||||
import PodDetailTolerations from './pod-detail-tolerations';
|
||||
|
||||
const PodDetail = ({ obj: pod, open, onClose }: DetailDrawerProps<Pod>) => {
|
||||
if (!pod || !(pod instanceof Pod)) return null;
|
||||
|
||||
return (
|
||||
<Drawer open={open} title={`Pod: ${pod.getName()}`} onClose={onClose}>
|
||||
<DrawerPanel>
|
||||
<PodInfo pod={pod} />
|
||||
<Divider />
|
||||
<ContainerDetail pod={pod} editable={false} />
|
||||
</DrawerPanel>
|
||||
</Drawer>
|
||||
);
|
||||
};
|
||||
|
||||
const PodInfo = ({ pod }: { pod: Pod }) => {
|
||||
const { status } = pod;
|
||||
const { conditions = [], podIP } = status ?? {};
|
||||
|
||||
const priorityClassName = pod.getPriorityClassName();
|
||||
const runtimeClassName = pod.getRuntimeClassName();
|
||||
const serviceAccountName = pod.getServiceAccountName();
|
||||
|
||||
// TODO: Link
|
||||
// const priorityClassDetailsUrl = getDetailsUrl(this.props.priorityClassApi.formatUrlForNotListing({
|
||||
// name: priorityClassName,
|
||||
// }));
|
||||
// const runtimeClassDetailsUrl = getDetailsUrl(this.props.runtimeClassApi.formatUrlForNotListing({
|
||||
// name: runtimeClassName,
|
||||
// }));
|
||||
// const serviceAccountDetailsUrl = getDetailsUrl(this.props.serviceAccountApi.formatUrlForNotListing({
|
||||
// name: serviceAccountName,
|
||||
// namespace,
|
||||
// }));
|
||||
|
||||
return (
|
||||
<>
|
||||
<KubeObjectInfoList obj={pod} />
|
||||
<div className="[&>div:first-child]:pt-0 [&>div:last-child]:pb-0">
|
||||
<div className="flex items-center justify-between py-2 border-b border-[#E8E8E8]">
|
||||
<div>
|
||||
<span className="text-[#8C8C8C] font-medium text-sm block mb-1">Pod IP</span>
|
||||
<span className="text-blue-600 text-xs">{podIP || '-'}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-[#8C8C8C] font-medium text-sm block mb-1">Service Account</span>
|
||||
<span className="text-[#262626] text-xs">{serviceAccountName || '-'}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-[#8C8C8C] font-medium text-sm block mb-1">QoS Class</span>
|
||||
<span className="text-[#262626] text-xs">{pod.getQosClass() || '-'}</span>
|
||||
</div>
|
||||
</div>
|
||||
<DrawerItem
|
||||
hidden={priorityClassName === ''}
|
||||
name="Priority Class"
|
||||
value={
|
||||
// TODO: Link
|
||||
<>{priorityClassName}</>
|
||||
}
|
||||
/>
|
||||
<DrawerItem
|
||||
hidden={runtimeClassName === ''}
|
||||
name="Runtime Class"
|
||||
value={
|
||||
// TODO: Link
|
||||
<>{runtimeClassName}</>
|
||||
}
|
||||
/>
|
||||
|
||||
<DrawerItem
|
||||
hidden={conditions.length === 0}
|
||||
name={'Conditions'}
|
||||
value={
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{conditions.map(({ type, status, lastTransitionTime }) => (
|
||||
<Tooltip
|
||||
key={type}
|
||||
title={`Last transition time: ${lastTransitionTime ?? '<unknown>'}`}
|
||||
>
|
||||
<KubeBadge label={type} disabled={status === 'False'} />
|
||||
</Tooltip>
|
||||
))}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
<PodDetailTolerations workload={pod} />
|
||||
<PodDetailAffinities workload={pod} />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default PodDetail;
|
||||
-9
@@ -1,9 +0,0 @@
|
||||
import { Typography } from 'antd';
|
||||
import { getStatusTextTone } from '@/utils/status-color';
|
||||
|
||||
const PodStatus = ({ status }: { status: string }) => {
|
||||
const textTone = getStatusTextTone(status);
|
||||
return <Typography.Text type={textTone}>{status}</Typography.Text>;
|
||||
};
|
||||
|
||||
export default PodStatus;
|
||||
@@ -1,98 +0,0 @@
|
||||
import { Pod } from '@/k8slens/kube-object';
|
||||
import { Tooltip } from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import { keys } from 'lodash';
|
||||
import ContainerStatusBrick from './container-status-brick';
|
||||
import { KubeObjectAge } from '@/components/kube/object/kube-object-age';
|
||||
import { renderContainerStateTooltipTitle } from './container-status';
|
||||
import { PodStatusMessage } from '@/constants/pod';
|
||||
import PodStatus from './pod-status';
|
||||
import PodDetail from './pod-detail';
|
||||
import { usePodStore } from '@/store/kube';
|
||||
import { PanelTable } from '@/components/common/panel-table/table';
|
||||
import { ActionButton } from '@/components/common/action/action-button';
|
||||
|
||||
const columns: ColumnsType<Pod> = [
|
||||
{
|
||||
title: 'Containers',
|
||||
key: 'containers',
|
||||
render: (_, pod) => {
|
||||
const containers = pod.getContainers().map((container) => {
|
||||
const status = pod.getContainerStatuses().find((status) => status.name === container.name);
|
||||
const stateKeys = status ? keys(status?.state ?? {}) : [];
|
||||
const state = stateKeys[0] ?? '';
|
||||
return {
|
||||
name: container.name,
|
||||
state,
|
||||
status
|
||||
};
|
||||
});
|
||||
return (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{containers.map(({ name, state, status }) => (
|
||||
<Tooltip key={name} title={renderContainerStateTooltipTitle(name, state || '', status)}>
|
||||
<span className="inline-block">
|
||||
<ContainerStatusBrick state={state || ''} status={status} />
|
||||
</span>
|
||||
</Tooltip>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
title: 'Restarts',
|
||||
key: 'restarts',
|
||||
render: (_, pod) => pod.getRestartsCount()
|
||||
},
|
||||
{
|
||||
title: 'Controlled By',
|
||||
key: 'controlled-by',
|
||||
render: (_, pod) => (
|
||||
<div className="flex flex-col gap-0.5">
|
||||
{pod.getOwnerRefs().map(({ name, kind }) => (
|
||||
<span key={name} className="leading-snug">
|
||||
{kind}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
{
|
||||
title: 'Status',
|
||||
key: 'status',
|
||||
filters: PodStatusMessage.map((value) => ({ text: value, value })),
|
||||
onFilter: (value, pod) => pod.getStatusMessage() === value,
|
||||
render: (_, pod) => <PodStatus status={pod.getStatusMessage()} />
|
||||
},
|
||||
{
|
||||
title: 'Age',
|
||||
dataIndex: 'creationTimestamp',
|
||||
key: 'age',
|
||||
render: (_, pod) => <KubeObjectAge obj={pod} />
|
||||
},
|
||||
{
|
||||
fixed: 'right',
|
||||
key: 'action',
|
||||
render: (_, pod) => <ActionButton obj={pod} />
|
||||
}
|
||||
];
|
||||
|
||||
const PodOverviewPage = () => {
|
||||
const { items, initialize, isLoaded } = usePodStore();
|
||||
|
||||
return (
|
||||
<PanelTable
|
||||
columns={columns}
|
||||
loading={!isLoaded}
|
||||
dataSource={items}
|
||||
sectionTitle="Pods"
|
||||
DetailDrawer={PodDetail}
|
||||
getRowKey={(pod) => pod.getId()}
|
||||
getDetailItem={(pod) => pod}
|
||||
initializers={[initialize]}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default PodOverviewPage;
|
||||
-551
@@ -1,551 +0,0 @@
|
||||
import { Probe } from '@/k8slens/kube-object';
|
||||
import { ConfigProvider, Input, InputNumber, Select } from 'antd';
|
||||
import { isEqual } from 'lodash';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
interface ProbeCardProps {
|
||||
title: string;
|
||||
probe?: ExtendedProbe;
|
||||
onChange?: (probe?: ExtendedProbe) => void;
|
||||
isEditing?: boolean;
|
||||
}
|
||||
|
||||
type ProbeType = 'none' | 'httpGet' | 'tcpSocket' | 'exec' | 'grpc';
|
||||
|
||||
export type ExtendedProbe = Probe & {
|
||||
grpc?: {
|
||||
port: number;
|
||||
service?: string;
|
||||
};
|
||||
};
|
||||
|
||||
const toNumberOrUndefined = (value: unknown): number | undefined => {
|
||||
if (typeof value === 'number') return value;
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : undefined;
|
||||
};
|
||||
|
||||
const parseHttpUrl = (
|
||||
url: string
|
||||
): { scheme: string; host?: string; port?: number; path: string } => {
|
||||
try {
|
||||
// Handle special case like "http://:8080/" where there's no hostname
|
||||
const match = url.match(/^(https?):\/\/([^/:]*)?(?::(\d+))?(.*)$/);
|
||||
if (match) {
|
||||
const [, scheme, host, port, pathAndQuery] = match;
|
||||
return {
|
||||
scheme: (scheme || 'HTTP').toUpperCase(),
|
||||
host: host || undefined,
|
||||
port: port ? parseInt(port) : undefined,
|
||||
path: pathAndQuery || '/'
|
||||
};
|
||||
}
|
||||
|
||||
// Fallback to URL parser for standard URLs
|
||||
const urlObj = new URL(url.startsWith('http') ? url : `http://${url}`);
|
||||
return {
|
||||
scheme: urlObj.protocol.replace(':', '').toUpperCase(),
|
||||
host: urlObj.hostname || undefined,
|
||||
port: urlObj.port ? parseInt(urlObj.port) : undefined,
|
||||
path: urlObj.pathname || '/'
|
||||
};
|
||||
} catch {
|
||||
return { scheme: 'HTTP', host: undefined, port: undefined, path: '/' };
|
||||
}
|
||||
};
|
||||
|
||||
const buildHttpUrl = (scheme: string, host?: string, port?: number, path: string = '/'): string => {
|
||||
const protocol = scheme.toLowerCase();
|
||||
if (!host) {
|
||||
return `${protocol}://:${port || 80}${path}`;
|
||||
}
|
||||
const portStr = port && port !== 80 && port !== 443 ? `:${port}` : '';
|
||||
return `${protocol}://${host}${portStr}${path}`;
|
||||
};
|
||||
|
||||
const ProbeCard = ({ title, probe, onChange, isEditing = false }: ProbeCardProps) => {
|
||||
const numberStyles = useMemo(
|
||||
() => ({
|
||||
input: {
|
||||
textAlign: 'center' as const,
|
||||
padding: '2px 0',
|
||||
height: 22,
|
||||
lineHeight: '18px',
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
background: '#fff',
|
||||
borderRadius: 4,
|
||||
border: '1px solid #D9D9D9',
|
||||
width: 34,
|
||||
minWidth: 34,
|
||||
maxWidth: 34
|
||||
}
|
||||
}),
|
||||
[]
|
||||
);
|
||||
|
||||
const [probeType, setProbeType] = useState<ProbeType>('none');
|
||||
const [httpUrl, setHttpUrl] = useState('');
|
||||
const [httpFields, setHttpFields] = useState({
|
||||
scheme: 'HTTP',
|
||||
host: '',
|
||||
port: 80 as number | undefined,
|
||||
path: '/'
|
||||
});
|
||||
const [tcpPort, setTcpPort] = useState<number | undefined>(undefined);
|
||||
const [grpcFields, setGrpcFields] = useState({
|
||||
port: undefined as number | undefined,
|
||||
service: ''
|
||||
});
|
||||
const [execCommand, setExecCommand] = useState('');
|
||||
const [timing, setTiming] = useState({
|
||||
initialDelaySeconds: 0,
|
||||
periodSeconds: 10,
|
||||
timeoutSeconds: 1,
|
||||
successThreshold: 1,
|
||||
failureThreshold: 3
|
||||
});
|
||||
|
||||
const deriveProbeType = (target?: ExtendedProbe): ProbeType => {
|
||||
if (!target) return 'none';
|
||||
if (target.httpGet) return 'httpGet';
|
||||
if (target.tcpSocket) return 'tcpSocket';
|
||||
if (target.exec) return 'exec';
|
||||
if (target.grpc) return 'grpc';
|
||||
return 'none';
|
||||
};
|
||||
|
||||
const resetDraft = useCallback((target?: ExtendedProbe) => {
|
||||
setProbeType(deriveProbeType(target));
|
||||
|
||||
setTiming({
|
||||
initialDelaySeconds: target?.initialDelaySeconds ?? 0,
|
||||
periodSeconds: target?.periodSeconds ?? 10,
|
||||
timeoutSeconds: target?.timeoutSeconds ?? 1,
|
||||
successThreshold: target?.successThreshold ?? 1,
|
||||
failureThreshold: target?.failureThreshold ?? 3
|
||||
});
|
||||
|
||||
const httpGet = target?.httpGet;
|
||||
setHttpFields({
|
||||
scheme: httpGet?.scheme || 'HTTP',
|
||||
host: httpGet?.host || '',
|
||||
port: toNumberOrUndefined(httpGet?.port) ?? 80,
|
||||
path: httpGet?.path || '/'
|
||||
});
|
||||
|
||||
// Build URL for editing
|
||||
if (httpGet) {
|
||||
setHttpUrl(
|
||||
buildHttpUrl(
|
||||
httpGet.scheme || 'HTTP',
|
||||
httpGet.host,
|
||||
toNumberOrUndefined(httpGet.port),
|
||||
httpGet.path || '/'
|
||||
)
|
||||
);
|
||||
} else {
|
||||
setHttpUrl('');
|
||||
}
|
||||
|
||||
setTcpPort(toNumberOrUndefined(target?.tcpSocket?.port));
|
||||
setExecCommand(target?.exec?.command?.join(' ') || '');
|
||||
setGrpcFields({
|
||||
port: toNumberOrUndefined(target?.grpc?.port),
|
||||
service: target?.grpc?.service || ''
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
// Refresh draft when source probe changes while not editing
|
||||
if (!isEditing) {
|
||||
resetDraft(probe);
|
||||
}
|
||||
}, [probe, isEditing, resetDraft]);
|
||||
|
||||
useEffect(() => {
|
||||
// Initialize draft when entering edit mode
|
||||
if (isEditing) {
|
||||
resetDraft(probe);
|
||||
}
|
||||
}, [isEditing, probe, resetDraft]);
|
||||
|
||||
const draftProbe = useMemo(() => {
|
||||
const base = { ...timing } as ExtendedProbe;
|
||||
switch (probeType) {
|
||||
case 'httpGet':
|
||||
return {
|
||||
...base,
|
||||
httpGet: {
|
||||
scheme: httpFields.scheme || 'HTTP',
|
||||
host: httpFields.host || undefined,
|
||||
port: httpFields.port ?? 80,
|
||||
path: httpFields.path || '/'
|
||||
}
|
||||
} as ExtendedProbe;
|
||||
case 'tcpSocket':
|
||||
return {
|
||||
...base,
|
||||
tcpSocket: {
|
||||
port: tcpPort ?? undefined
|
||||
}
|
||||
} as ExtendedProbe;
|
||||
case 'exec':
|
||||
return {
|
||||
...base,
|
||||
exec: {
|
||||
command: execCommand.trim() ? execCommand.trim().split(/\s+/) : []
|
||||
}
|
||||
} as ExtendedProbe;
|
||||
case 'grpc':
|
||||
return {
|
||||
...base,
|
||||
grpc: {
|
||||
port: grpcFields.port ?? undefined,
|
||||
...(grpcFields.service ? { service: grpcFields.service } : {})
|
||||
}
|
||||
} as ExtendedProbe;
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
}, [
|
||||
execCommand,
|
||||
grpcFields.port,
|
||||
grpcFields.service,
|
||||
httpFields.host,
|
||||
httpFields.path,
|
||||
httpFields.port,
|
||||
httpFields.scheme,
|
||||
probeType,
|
||||
tcpPort,
|
||||
timing
|
||||
]);
|
||||
|
||||
const displayProbe: ExtendedProbe | undefined = probe;
|
||||
|
||||
const deriveTypeLabel = (type: ProbeType): string => {
|
||||
switch (type) {
|
||||
case 'httpGet':
|
||||
return 'HTTP GET';
|
||||
case 'tcpSocket':
|
||||
return 'TCP Socket';
|
||||
case 'exec':
|
||||
return 'Exec';
|
||||
case 'grpc':
|
||||
return 'gRPC';
|
||||
default:
|
||||
return 'Null';
|
||||
}
|
||||
};
|
||||
|
||||
let actionDescription = '';
|
||||
if (displayProbe?.httpGet) {
|
||||
const { scheme = 'HTTP', host, port, path } = displayProbe.httpGet;
|
||||
const url = `${scheme.toLowerCase()}://${host || ''}${typeof port === 'number' || !host ? `:${port}` : ''}${path}`;
|
||||
actionDescription = `${url}`;
|
||||
} else if (displayProbe?.exec && displayProbe.exec.command) {
|
||||
actionDescription = `[${displayProbe.exec.command.join(', ')}]`;
|
||||
} else if (displayProbe?.tcpSocket) {
|
||||
actionDescription = `:${displayProbe.tcpSocket.port}`;
|
||||
} else if (displayProbe?.grpc) {
|
||||
actionDescription = `:${displayProbe.grpc.port}`;
|
||||
}
|
||||
|
||||
const lastSentRef = useRef<ExtendedProbe | undefined>();
|
||||
|
||||
useEffect(() => {
|
||||
if (!isEditing) {
|
||||
lastSentRef.current = undefined;
|
||||
return;
|
||||
}
|
||||
if (!isEqual(draftProbe ?? null, lastSentRef.current ?? null)) {
|
||||
lastSentRef.current = draftProbe;
|
||||
onChange?.(draftProbe);
|
||||
}
|
||||
}, [draftProbe, isEditing, onChange]);
|
||||
|
||||
if (!displayProbe && !isEditing) {
|
||||
return (
|
||||
<div className="flex-1 min-w-75 bg-[#F9F9FA] p-3 rounded-lg border border-[#E8E8E8] flex items-center justify-between">
|
||||
<div className="text-gray-500 text-xs font-medium">{title}</div>
|
||||
<div className="text-gray-400 text-sm">-</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex-1 min-w-75 bg-[#F9F9FA] p-3 rounded-lg border border-[#E8E8E8]">
|
||||
<div className="text-gray-500 text-xs font-medium mb-2">{title}</div>
|
||||
|
||||
{(isEditing || displayProbe) && (
|
||||
<div className="flex flex-col">
|
||||
<div className="flex items-center gap-3 mb-2" style={{ minHeight: '24px' }}>
|
||||
{!isEditing && displayProbe && (
|
||||
<>
|
||||
<span
|
||||
className="text-[#262626] text-xs font-medium shrink-0 w-28"
|
||||
style={{ paddingLeft: '8px' }}
|
||||
>
|
||||
{deriveTypeLabel(deriveProbeType(displayProbe))}
|
||||
</span>
|
||||
<span
|
||||
className="text-[#262626] text-xs font-medium flex-1 truncate"
|
||||
style={{ paddingLeft: '8px' }}
|
||||
>
|
||||
{actionDescription}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
|
||||
{isEditing && (
|
||||
<>
|
||||
<ConfigProvider
|
||||
theme={{
|
||||
components: {
|
||||
Select: { fontSize: 12 },
|
||||
Input: { fontSize: 12 },
|
||||
InputNumber: { fontSize: 12 }
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Select
|
||||
className="w-26 font-medium"
|
||||
size="small"
|
||||
value={probeType}
|
||||
onChange={(val) => setProbeType(val as ProbeType)}
|
||||
options={[
|
||||
{ value: 'none', label: 'Null' },
|
||||
{ value: 'httpGet', label: 'HTTP GET' },
|
||||
{ value: 'tcpSocket', label: 'TCP Socket' },
|
||||
{ value: 'exec', label: 'Exec' },
|
||||
{ value: 'grpc', label: 'gRPC' }
|
||||
]}
|
||||
/>
|
||||
{probeType === 'httpGet' && (
|
||||
<Input
|
||||
size="small"
|
||||
placeholder="http://example.com:8080/path (optional host)"
|
||||
className="flex-1 font-medium"
|
||||
value={httpUrl}
|
||||
onChange={(e) => {
|
||||
setHttpUrl(e.target.value);
|
||||
const parsed = parseHttpUrl(e.target.value);
|
||||
setHttpFields((prev) => ({
|
||||
...prev,
|
||||
scheme: parsed.scheme,
|
||||
host: parsed.host || '',
|
||||
port: parsed.port,
|
||||
path: parsed.path
|
||||
}));
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{probeType === 'tcpSocket' && (
|
||||
<InputNumber
|
||||
size="small"
|
||||
min={0}
|
||||
placeholder="Port"
|
||||
className="w-25 font-medium"
|
||||
value={tcpPort}
|
||||
onChange={(val) => setTcpPort(val ?? undefined)}
|
||||
/>
|
||||
)}
|
||||
{probeType === 'grpc' && (
|
||||
<>
|
||||
<InputNumber
|
||||
size="small"
|
||||
min={0}
|
||||
placeholder="Port"
|
||||
className="w-25 font-medium"
|
||||
value={grpcFields.port}
|
||||
onChange={(val) =>
|
||||
setGrpcFields((prev) => ({ ...prev, port: val ?? undefined }))
|
||||
}
|
||||
/>
|
||||
<Input
|
||||
size="small"
|
||||
placeholder="Service (optional)"
|
||||
className="flex-1 min-w-30 font-medium"
|
||||
value={grpcFields.service}
|
||||
onChange={(e) =>
|
||||
setGrpcFields((prev) => ({ ...prev, service: e.target.value }))
|
||||
}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{probeType === 'exec' && (
|
||||
<Input
|
||||
size="small"
|
||||
placeholder={'Command (e.g. /bin/sh -c "echo ok")'}
|
||||
className="flex-1 font-medium"
|
||||
value={execCommand}
|
||||
onChange={(e) => setExecCommand(e.target.value)}
|
||||
/>
|
||||
)}
|
||||
</ConfigProvider>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="text-gray-500 text-xs leading-5 flex flex-col gap-1">
|
||||
<div className="flex items-center gap-1 flex-wrap" style={{ minHeight: '22px' }}>
|
||||
<span>First probe</span>
|
||||
{isEditing ? (
|
||||
<ConfigProvider
|
||||
theme={{
|
||||
components: {
|
||||
InputNumber: {
|
||||
controlWidth: 36,
|
||||
paddingInlineSM: 0
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
<InputNumber
|
||||
size="small"
|
||||
min={0}
|
||||
controls={false}
|
||||
bordered={false}
|
||||
styles={numberStyles}
|
||||
className="text-[#262626] text-xs font-semibold text-center"
|
||||
value={timing.initialDelaySeconds}
|
||||
onChange={(val) =>
|
||||
setTiming((prev) => ({ ...prev, initialDelaySeconds: val ?? 0 }))
|
||||
}
|
||||
/>
|
||||
</ConfigProvider>
|
||||
) : (
|
||||
<span className="inline-block w-9 font-semibold text-[#262626] text-center">
|
||||
{displayProbe?.initialDelaySeconds ?? 0}
|
||||
</span>
|
||||
)}
|
||||
<span>s after startup, then every</span>
|
||||
{isEditing ? (
|
||||
<ConfigProvider
|
||||
theme={{
|
||||
components: {
|
||||
InputNumber: {
|
||||
controlWidth: 36,
|
||||
paddingInlineSM: 0
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
<InputNumber
|
||||
size="small"
|
||||
min={1}
|
||||
controls={false}
|
||||
bordered={false}
|
||||
styles={numberStyles}
|
||||
className="text-[#262626] text-xs font-semibold text-center"
|
||||
value={timing.periodSeconds}
|
||||
onChange={(val) => setTiming((prev) => ({ ...prev, periodSeconds: val ?? 1 }))}
|
||||
/>
|
||||
</ConfigProvider>
|
||||
) : (
|
||||
<span className="inline-block w-9 font-semibold text-[#262626] text-center">
|
||||
{displayProbe?.periodSeconds ?? 0}
|
||||
</span>
|
||||
)}
|
||||
<span>s with</span>
|
||||
{isEditing ? (
|
||||
<ConfigProvider
|
||||
theme={{
|
||||
components: {
|
||||
InputNumber: {
|
||||
controlWidth: 36,
|
||||
paddingInlineSM: 0
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
<InputNumber
|
||||
size="small"
|
||||
min={1}
|
||||
controls={false}
|
||||
bordered={false}
|
||||
styles={numberStyles}
|
||||
className="text-[#262626] text-xs font-semibold text-center"
|
||||
value={timing.timeoutSeconds}
|
||||
onChange={(val) => setTiming((prev) => ({ ...prev, timeoutSeconds: val ?? 1 }))}
|
||||
/>
|
||||
</ConfigProvider>
|
||||
) : (
|
||||
<span className="inline-block w-9 font-semibold text-[#262626] text-center">
|
||||
{displayProbe?.timeoutSeconds ?? 0}
|
||||
</span>
|
||||
)}
|
||||
<span>s timeout.</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1 flex-wrap" style={{ minHeight: '22px' }}>
|
||||
<span>Status changes after</span>
|
||||
{isEditing ? (
|
||||
<ConfigProvider
|
||||
theme={{
|
||||
components: {
|
||||
InputNumber: {
|
||||
controlWidth: 36,
|
||||
paddingInlineSM: 0
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
<InputNumber
|
||||
size="small"
|
||||
min={1}
|
||||
controls={false}
|
||||
bordered={false}
|
||||
styles={numberStyles}
|
||||
className="text-[#262626] text-xs font-semibold text-center"
|
||||
value={timing.successThreshold}
|
||||
onChange={(val) =>
|
||||
setTiming((prev) => ({ ...prev, successThreshold: val ?? 1 }))
|
||||
}
|
||||
/>
|
||||
</ConfigProvider>
|
||||
) : (
|
||||
<span className="inline-block w-9 font-semibold text-[#262626] text-center">
|
||||
{displayProbe?.successThreshold ?? 0}
|
||||
</span>
|
||||
)}
|
||||
<span>success or</span>
|
||||
{isEditing ? (
|
||||
<ConfigProvider
|
||||
theme={{
|
||||
components: {
|
||||
InputNumber: {
|
||||
controlWidth: 36,
|
||||
paddingInlineSM: 0
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
<InputNumber
|
||||
size="small"
|
||||
min={1}
|
||||
controls={false}
|
||||
bordered={false}
|
||||
styles={numberStyles}
|
||||
className="text-[#262626] text-xs font-semibold text-center"
|
||||
value={timing.failureThreshold}
|
||||
onChange={(val) =>
|
||||
setTiming((prev) => ({ ...prev, failureThreshold: val ?? 1 }))
|
||||
}
|
||||
/>
|
||||
</ConfigProvider>
|
||||
) : (
|
||||
<span className="inline-block w-9 font-semibold text-[#262626] text-center">
|
||||
{displayProbe?.failureThreshold ?? 0}
|
||||
</span>
|
||||
)}
|
||||
<span>consecutive failures.</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProbeCard;
|
||||
-75
@@ -1,75 +0,0 @@
|
||||
import { Drawer } from '@/components/common/drawer/drawer';
|
||||
import { DrawerItem } from '@/components/common/drawer/drawer-item';
|
||||
import { DrawerPanel } from '@/components/common/drawer/drawer-panel';
|
||||
import { DetailDrawerProps } from '@/components/common/panel-table/table';
|
||||
import { KubeBadge } from '@/components/kube/kube-badge';
|
||||
import { KubeObjectInfoList } from '@/components/kube/object/detail/kube-object-detail-info-list';
|
||||
import { StatefulSet } from '@/k8slens/kube-object';
|
||||
import { useStatefulSetStore } from '@/store/kube';
|
||||
import { Divider } from 'antd';
|
||||
import ContainerDetail from '../pod/container-detail';
|
||||
import PodDetailAffinities from '../pod/pod-detail-affinities';
|
||||
import PodDetailSecurityContext from '../pod/pod-detail-security-context';
|
||||
import PodDetailTolerations from '../pod/pod-detail-tolerations';
|
||||
|
||||
const StatefulSetDetail = ({ obj: stat, open, onClose }: DetailDrawerProps<StatefulSet>) => {
|
||||
if (!stat || !(stat instanceof StatefulSet)) return null;
|
||||
|
||||
const selectors = stat.getSelectors();
|
||||
const spec = stat.spec;
|
||||
const { initialize } = useStatefulSetStore();
|
||||
|
||||
return (
|
||||
<Drawer open={open} title={`StatefulSet: ${stat.getName()}`} onClose={onClose}>
|
||||
<DrawerPanel>
|
||||
<KubeObjectInfoList obj={stat} />
|
||||
{selectors.length > 0 && (
|
||||
<DrawerItem
|
||||
name="Selector"
|
||||
vertical
|
||||
value={
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{[...selectors].sort().map((label) => {
|
||||
const sepIndex = label.indexOf(': ');
|
||||
if (sepIndex === -1)
|
||||
return <KubeBadge key={label} label={label} className="m-0!" />;
|
||||
const key = label.slice(0, sepIndex);
|
||||
const value = label.slice(sepIndex + 2);
|
||||
return (
|
||||
<KubeBadge
|
||||
key={label}
|
||||
label={
|
||||
<span>
|
||||
<span className="font-medium text-gray-900">{key}</span>
|
||||
<span className="text-gray-900 mr-1">:</span>
|
||||
<span className="text-gray-600">{value}</span>
|
||||
</span>
|
||||
}
|
||||
className="m-0!"
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<PodDetailTolerations workload={stat} />
|
||||
<PodDetailAffinities workload={stat} />
|
||||
<PodDetailSecurityContext
|
||||
securityContext={spec?.template?.spec?.securityContext}
|
||||
workload={stat}
|
||||
onUpdate={() => initialize(() => {}, true)}
|
||||
/>
|
||||
<Divider />
|
||||
<ContainerDetail
|
||||
containers={spec?.template?.spec?.containers}
|
||||
initContainers={spec?.template?.spec?.initContainers}
|
||||
workload={stat}
|
||||
onUpdate={() => initialize(() => {}, true)}
|
||||
/>
|
||||
</DrawerPanel>
|
||||
</Drawer>
|
||||
);
|
||||
};
|
||||
|
||||
export default StatefulSetDetail;
|
||||
-54
@@ -1,54 +0,0 @@
|
||||
import { KubeObjectAge } from '@/components/kube/object/kube-object-age';
|
||||
import { StatefulSet } from '@/k8slens/kube-object';
|
||||
import { ColumnsType } from 'antd/es/table';
|
||||
import StatefulSetDetail from './statefulset-detail';
|
||||
import { usePodStore, useStatefulSetStore } from '@/store/kube';
|
||||
import { PanelTable } from '@/components/common/panel-table/table';
|
||||
import { ActionButton } from '@/components/common/action/action-button';
|
||||
|
||||
const columns: ColumnsType<StatefulSet> = [
|
||||
{
|
||||
title: 'Pods',
|
||||
key: 'pods',
|
||||
render: (_, stat) => {
|
||||
const { readyReplicas = 0, replicas = 0 } = stat.status ?? {};
|
||||
return `${readyReplicas}/${replicas}`;
|
||||
}
|
||||
},
|
||||
|
||||
{
|
||||
title: 'Age',
|
||||
key: 'age',
|
||||
render: (_, stat) => <KubeObjectAge obj={stat} />
|
||||
},
|
||||
{
|
||||
key: 'action',
|
||||
fixed: 'right',
|
||||
render: (_, stat) => <ActionButton obj={stat} />
|
||||
}
|
||||
];
|
||||
|
||||
const StatefulSetOverviewPage = () => {
|
||||
// The detailed information of Stateful Set resource needs to be provided by Pod resource.
|
||||
const { initialize: initializePods, isLoaded: isPodsLoaded } = usePodStore();
|
||||
const {
|
||||
items: stats,
|
||||
initialize: initializeStats,
|
||||
isLoaded: isStatsLoaded
|
||||
} = useStatefulSetStore();
|
||||
|
||||
return (
|
||||
<PanelTable
|
||||
columns={columns}
|
||||
loading={!isStatsLoaded || !isPodsLoaded}
|
||||
dataSource={stats}
|
||||
sectionTitle="Stateful Sets"
|
||||
DetailDrawer={StatefulSetDetail}
|
||||
getRowKey={(stat) => stat.getId()}
|
||||
initializers={[initializeStats, initializePods]}
|
||||
getDetailItem={(stat) => stat}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default StatefulSetOverviewPage;
|
||||
@@ -1,16 +0,0 @@
|
||||
export enum KubeObjectKind {
|
||||
Pod = 'Pod',
|
||||
Deployment = 'Deployment',
|
||||
StatefulSet = 'StatefulSet',
|
||||
ConfigMap = 'ConfigMap',
|
||||
PersistentVolumeClaim = 'PersistentVolumeClaim',
|
||||
Secret = 'Secret',
|
||||
Ingress = 'Ingress',
|
||||
Event = 'Event',
|
||||
Service = 'Service',
|
||||
// CustomResource
|
||||
Devbox = 'Devbox',
|
||||
DevboxOld = 'DevboxOld',
|
||||
Cluster = 'Cluster',
|
||||
Bucket = 'Bucket'
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
export const PodStatusMessage = [
|
||||
'Evicted',
|
||||
'Terminating',
|
||||
'Waiting',
|
||||
'Terminated',
|
||||
'Pending',
|
||||
'Running',
|
||||
'Succeeded',
|
||||
'Failed'
|
||||
];
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user