chore(frontend): remove kubepanel provider (#7105)

This commit is contained in:
cuisongliu
2026-07-27 11:00:12 +08:00
committed by GitHub
parent ac3023057b
commit 5ca668dce6
214 changed files with 32 additions and 17298 deletions
+1 -1
View File
@@ -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
-4
View File
@@ -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
-1
View File
@@ -22,7 +22,6 @@ buildTargets := \
providers/imagehub \
providers/template \
providers/license \
providers/kubepanel \
providers/objectstorage
buildTargets-all := $(addprefix image-build-,$(buildTargets))
+31 -2485
View File
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
}
}
-42
View File
@@ -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'
};
-37
View File
@@ -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.
![image-20240423105724369](https://raw.githubusercontent.com/mlhiter/typora-images/master/202404231101028.png)
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. ![image-20240423111024336](https://raw.githubusercontent.com/mlhiter/typora-images/master/202404231110609.png)
3. Refresh website.
4. Then you can get your own dev in this.
![image-20240423111123308](https://raw.githubusercontent.com/mlhiter/typora-images/master/202404231111720.png)
@@ -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"
@@ -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
@@ -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
@@ -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}
-58
View File
@@ -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;
-61
View File
@@ -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
@@ -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()}`}</>
);
@@ -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 <>&lt;unknown&gt;</>;
}
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>;
};
@@ -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;
@@ -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;
@@ -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;
@@ -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;
@@ -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;
@@ -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() || '-'} &nbsp;/&nbsp; Memory: {cluster.getMemory() || '-'}{' '}
&nbsp;/&nbsp; 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;
@@ -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;
@@ -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() || '-'} &nbsp;/&nbsp; Memory: {devbox.getMemory() || '-'}{' '}
&nbsp;/&nbsp; Storage: {devbox.getStorageLimit() || '-'}
</span>
}
/>
<DrawerItem name="Network" value={networkDisplay} />
</DrawerPanel>
</Drawer>
);
};
export default DevboxDetail;
@@ -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;
@@ -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;
@@ -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;
@@ -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;
@@ -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;
@@ -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;
@@ -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;
@@ -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;
@@ -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;
@@ -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;
@@ -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;
@@ -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;
@@ -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;
@@ -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;
@@ -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;
@@ -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;
@@ -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;
@@ -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;
@@ -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;
@@ -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;
@@ -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;
@@ -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;
@@ -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;
@@ -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;
@@ -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;
@@ -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;
@@ -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