diff --git a/pkg/handlers/resources/handler.go b/pkg/handlers/resources/handler.go index c9f65114..e278e093 100644 --- a/pkg/handlers/resources/handler.go +++ b/pkg/handlers/resources/handler.go @@ -6,6 +6,7 @@ import ( "github.com/gin-gonic/gin" "github.com/zxh326/kite/pkg/common" appsv1 "k8s.io/api/apps/v1" + autoscalingv2 "k8s.io/api/autoscaling/v2" batchv1 "k8s.io/api/batch/v1" corev1 "k8s.io/api/core/v1" discoveryv1 "k8s.io/api/discovery/v1" @@ -44,35 +45,36 @@ var handlers = map[string]resourceHandler{} func RegisterRoutes(group *gin.RouterGroup) { handlers = map[string]resourceHandler{ - "pods": NewPodHandler(), - "namespaces": NewGenericResourceHandler[*corev1.Namespace, *corev1.NamespaceList]("namespaces", true, false), - "nodes": NewNodeHandler(), - "services": NewGenericResourceHandler[*corev1.Service, *corev1.ServiceList]("services", false, true), - "endpoints": NewGenericResourceHandler[*corev1.Endpoints, *corev1.EndpointsList]("endpoints", false, false), - "endpointslices": NewGenericResourceHandler[*discoveryv1.EndpointSlice, *discoveryv1.EndpointSliceList]("endpointslices", false, false), - "configmaps": NewGenericResourceHandler[*corev1.ConfigMap, *corev1.ConfigMapList]("configmaps", false, true), - "secrets": NewGenericResourceHandler[*corev1.Secret, *corev1.SecretList]("secrets", false, true), - "persistentvolumes": NewGenericResourceHandler[*corev1.PersistentVolume, *corev1.PersistentVolumeList]("persistentvolumes", true, true), - "persistentvolumeclaims": NewGenericResourceHandler[*corev1.PersistentVolumeClaim, *corev1.PersistentVolumeClaimList]("persistentvolumeclaims", false, true), - "serviceaccounts": NewGenericResourceHandler[*corev1.ServiceAccount, *corev1.ServiceAccountList]("serviceaccounts", false, false), - "crds": NewGenericResourceHandler[*apiextensionsv1.CustomResourceDefinition, *apiextensionsv1.CustomResourceDefinitionList]("crds", true, false), - "events": NewEventHandler(), - "deployments": NewDeploymentHandler(), - "replicasets": NewGenericResourceHandler[*appsv1.ReplicaSet, *appsv1.ReplicaSetList]("replicasets", false, false), - "statefulsets": NewGenericResourceHandler[*appsv1.StatefulSet, *appsv1.StatefulSetList]("statefulsets", false, false), - "daemonsets": NewGenericResourceHandler[*appsv1.DaemonSet, *appsv1.DaemonSetList]("daemonsets", false, true), - "jobs": NewGenericResourceHandler[*batchv1.Job, *batchv1.JobList]("jobs", false, false), - "cronjobs": NewGenericResourceHandler[*batchv1.CronJob, *batchv1.CronJobList]("cronjobs", false, false), - "ingresses": NewGenericResourceHandler[*networkingv1.Ingress, *networkingv1.IngressList]("ingresses", false, false), - "storageclasses": NewGenericResourceHandler[*storagev1.StorageClass, *storagev1.StorageClassList]("storageclasses", true, false), - "roles": NewGenericResourceHandler[*rbacv1.Role, *rbacv1.RoleList]("roles", false, false), - "rolebindings": NewGenericResourceHandler[*rbacv1.RoleBinding, *rbacv1.RoleBindingList]("rolebindings", false, false), - "clusterroles": NewGenericResourceHandler[*rbacv1.ClusterRole, *rbacv1.ClusterRoleList]("clusterroles", true, false), - "clusterrolebindings": NewGenericResourceHandler[*rbacv1.ClusterRoleBinding, *rbacv1.ClusterRoleBindingList]("clusterrolebindings", true, false), - "podmetrics": NewGenericResourceHandler[*metricsv1.PodMetrics, *metricsv1.PodMetricsList]("metrics.k8s.io", false, false), - "nodemetrics": NewGenericResourceHandler[*metricsv1.NodeMetrics, *metricsv1.NodeMetricsList]("metrics.k8s.io", false, false), - "gatewssays": NewGenericResourceHandler[*gatewayapiv1.Gateway, *gatewayapiv1.GatewayList]("gateways", false, true), - "httproutes": NewGenericResourceHandler[*gatewayapiv1.HTTPRoute, *gatewayapiv1.HTTPRouteList]("httproutes", false, true), + "pods": NewPodHandler(), + "namespaces": NewGenericResourceHandler[*corev1.Namespace, *corev1.NamespaceList]("namespaces", true, false), + "nodes": NewNodeHandler(), + "services": NewGenericResourceHandler[*corev1.Service, *corev1.ServiceList]("services", false, true), + "endpoints": NewGenericResourceHandler[*corev1.Endpoints, *corev1.EndpointsList]("endpoints", false, false), + "endpointslices": NewGenericResourceHandler[*discoveryv1.EndpointSlice, *discoveryv1.EndpointSliceList]("endpointslices", false, false), + "configmaps": NewGenericResourceHandler[*corev1.ConfigMap, *corev1.ConfigMapList]("configmaps", false, true), + "secrets": NewGenericResourceHandler[*corev1.Secret, *corev1.SecretList]("secrets", false, true), + "persistentvolumes": NewGenericResourceHandler[*corev1.PersistentVolume, *corev1.PersistentVolumeList]("persistentvolumes", true, true), + "persistentvolumeclaims": NewGenericResourceHandler[*corev1.PersistentVolumeClaim, *corev1.PersistentVolumeClaimList]("persistentvolumeclaims", false, true), + "serviceaccounts": NewGenericResourceHandler[*corev1.ServiceAccount, *corev1.ServiceAccountList]("serviceaccounts", false, false), + "crds": NewGenericResourceHandler[*apiextensionsv1.CustomResourceDefinition, *apiextensionsv1.CustomResourceDefinitionList]("crds", true, false), + "events": NewEventHandler(), + "deployments": NewDeploymentHandler(), + "replicasets": NewGenericResourceHandler[*appsv1.ReplicaSet, *appsv1.ReplicaSetList]("replicasets", false, false), + "statefulsets": NewGenericResourceHandler[*appsv1.StatefulSet, *appsv1.StatefulSetList]("statefulsets", false, false), + "daemonsets": NewGenericResourceHandler[*appsv1.DaemonSet, *appsv1.DaemonSetList]("daemonsets", false, true), + "jobs": NewGenericResourceHandler[*batchv1.Job, *batchv1.JobList]("jobs", false, false), + "cronjobs": NewGenericResourceHandler[*batchv1.CronJob, *batchv1.CronJobList]("cronjobs", false, false), + "ingresses": NewGenericResourceHandler[*networkingv1.Ingress, *networkingv1.IngressList]("ingresses", false, false), + "storageclasses": NewGenericResourceHandler[*storagev1.StorageClass, *storagev1.StorageClassList]("storageclasses", true, false), + "roles": NewGenericResourceHandler[*rbacv1.Role, *rbacv1.RoleList]("roles", false, false), + "rolebindings": NewGenericResourceHandler[*rbacv1.RoleBinding, *rbacv1.RoleBindingList]("rolebindings", false, false), + "clusterroles": NewGenericResourceHandler[*rbacv1.ClusterRole, *rbacv1.ClusterRoleList]("clusterroles", true, false), + "clusterrolebindings": NewGenericResourceHandler[*rbacv1.ClusterRoleBinding, *rbacv1.ClusterRoleBindingList]("clusterrolebindings", true, false), + "podmetrics": NewGenericResourceHandler[*metricsv1.PodMetrics, *metricsv1.PodMetricsList]("metrics.k8s.io", false, false), + "nodemetrics": NewGenericResourceHandler[*metricsv1.NodeMetrics, *metricsv1.NodeMetricsList]("metrics.k8s.io", false, false), + "gatewssays": NewGenericResourceHandler[*gatewayapiv1.Gateway, *gatewayapiv1.GatewayList]("gateways", false, true), + "httproutes": NewGenericResourceHandler[*gatewayapiv1.HTTPRoute, *gatewayapiv1.HTTPRouteList]("httproutes", false, true), + "horizontalpodautoscalers": NewGenericResourceHandler[*autoscalingv2.HorizontalPodAutoscaler, *autoscalingv2.HorizontalPodAutoscalerList]("horizontalpodautoscalers", false, true), } for name, handler := range handlers { @@ -90,7 +92,7 @@ func RegisterRoutes(group *gin.RouterGroup) { } // Register related resources route for supported resource types - supportedRelatedResourceTypes := []string{"pods", "deployments", "statefulsets", "daemonsets", "configmaps", "secrets", "persistentvolumeclaims", "httproutes"} + supportedRelatedResourceTypes := []string{"pods", "deployments", "statefulsets", "daemonsets", "configmaps", "secrets", "persistentvolumeclaims", "httproutes", "horizontalpodautoscalers"} for _, resourceType := range supportedRelatedResourceTypes { if handler, exists := handlers[resourceType]; exists && !handler.IsClusterScoped() { g := group.Group("/" + resourceType) diff --git a/pkg/handlers/resources/related_resources.go b/pkg/handlers/resources/related_resources.go index f5d69722..f156654e 100644 --- a/pkg/handlers/resources/related_resources.go +++ b/pkg/handlers/resources/related_resources.go @@ -11,6 +11,7 @@ import ( "github.com/zxh326/kite/pkg/common" "github.com/zxh326/kite/pkg/kube" appsv1 "k8s.io/api/apps/v1" + autoscalingv2 "k8s.io/api/autoscaling/v2" corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/labels" @@ -247,6 +248,8 @@ func GetRelatedResources(c *gin.Context) { } case *gatewayapiv1.HTTPRoute: result = getHTTPRouteRelatedResouces(res, namespace) + case *autoscalingv2.HorizontalPodAutoscaler: + result = getAutoScalingRelatedResources(res, namespace) } if podSpec != nil && selector != nil { @@ -340,3 +343,15 @@ func getHTTPRouteRelatedResouces(res *gatewayapiv1.HTTPRoute, namespace string) } return result } + +func getAutoScalingRelatedResources(res *autoscalingv2.HorizontalPodAutoscaler, namespace string) []common.RelatedResource { + var result []common.RelatedResource + scaleTarget := res.Spec.ScaleTargetRef + result = append(result, common.RelatedResource{ + Type: strings.ToLower(scaleTarget.Kind) + "s", + APIVersion: scaleTarget.APIVersion, + Name: scaleTarget.Name, + Namespace: namespace, + }) + return result +} diff --git a/ui/src/components/dynamic-breadcrumb.tsx b/ui/src/components/dynamic-breadcrumb.tsx index 7e5ed8b5..d94a4de8 100644 --- a/ui/src/components/dynamic-breadcrumb.tsx +++ b/ui/src/components/dynamic-breadcrumb.tsx @@ -44,6 +44,7 @@ export function DynamicBreadcrumb() { pvcs: t('sidebar.short.pvcs'), crds: t('nav.crds'), crs: t('nav.customResources'), + horizontalpodautoscalers: t('nav.horizontalpodautoscalers'), } // Helper function to create breadcrumb item diff --git a/ui/src/components/global-search.tsx b/ui/src/components/global-search.tsx index 88bad363..357eaa85 100644 --- a/ui/src/components/global-search.tsx +++ b/ui/src/components/global-search.tsx @@ -1,5 +1,6 @@ import { useCallback, useEffect, useState } from 'react' import { + IconArrowsHorizontal, IconBox, IconBoxMultiple, IconLoadBalancer, @@ -64,6 +65,10 @@ const RESOURCE_CONFIG: Record< label: 'nav.daemonsets', icon: IconTopologyBus, }, + horizontalpodautoscalers: { + label: 'nav.horizontalpodautoscalers', + icon: IconArrowsHorizontal, + }, } interface GlobalSearchProps { diff --git a/ui/src/contexts/sidebar-config-context.tsx b/ui/src/contexts/sidebar-config-context.tsx index aa11f0f1..0bb1c90a 100644 --- a/ui/src/contexts/sidebar-config-context.tsx +++ b/ui/src/contexts/sidebar-config-context.tsx @@ -9,6 +9,7 @@ import { import * as React from 'react' import { Icon, + IconArrowsHorizontal, IconBell, IconBox, IconBoxMultiple, @@ -68,6 +69,7 @@ const iconMap = { IconServer2, IconBell, IconCode, + IconArrowsHorizontal, } const getIconName = (iconComponent: React.ComponentType): string => { @@ -159,6 +161,11 @@ export const SidebarConfigProvider: React.FC = ({ 'sidebar.groups.config': [ { titleKey: 'nav.configMaps', url: '/configmaps', icon: IconMap }, { titleKey: 'nav.secrets', url: '/secrets', icon: IconLock }, + { + titleKey: 'nav.horizontalpodautoscalers', + url: '/horizontalpodautoscalers', + icon: IconArrowsHorizontal, + }, ], 'sidebar.groups.security': [ { diff --git a/ui/src/i18n/locales/en.json b/ui/src/i18n/locales/en.json index dff773c0..b115b4e0 100644 --- a/ui/src/i18n/locales/en.json +++ b/ui/src/i18n/locales/en.json @@ -81,7 +81,8 @@ "roles": "Roles", "rolebindings": "Role Bindings", "clusterroles": "Cluster Roles", - "clusterrolebindings": "Cluster Role Bindings" + "clusterrolebindings": "Cluster Role Bindings", + "horizontalpodautoscalers": "HPA" }, "overview": { "title": "Overview", diff --git a/ui/src/i18n/locales/zh.json b/ui/src/i18n/locales/zh.json index c502d4aa..e1136ad4 100644 --- a/ui/src/i18n/locales/zh.json +++ b/ui/src/i18n/locales/zh.json @@ -73,7 +73,8 @@ "roles": "角色", "rolebindings": "角色绑定", "clusterroles": "集群角色", - "clusterrolebindings": "集群角色绑定" + "clusterrolebindings": "集群角色绑定", + "horizontalpodautoscalers": "Pod 水平自动扩缩" }, "sidebar": { "groups": { diff --git a/ui/src/pages/horizontalpodautoscaler-list-page.tsx b/ui/src/pages/horizontalpodautoscaler-list-page.tsx new file mode 100644 index 00000000..654399db --- /dev/null +++ b/ui/src/pages/horizontalpodautoscaler-list-page.tsx @@ -0,0 +1,127 @@ +import { useCallback, useMemo } from 'react' +import { createColumnHelper } from '@tanstack/react-table' +import { HorizontalPodAutoscaler } from 'kubernetes-types/autoscaling/v2' +import { Link } from 'react-router-dom' + +import { formatDate } from '@/lib/utils' +import { ResourceTable } from '@/components/resource-table' + +function getHpaTargetInfo(hpa: HorizontalPodAutoscaler): string { + if (!hpa.spec?.scaleTargetRef) { + return '-' + } + const { kind, name } = hpa.spec.scaleTargetRef + return `${kind}/${name}` +} + +function getCurrentReplicas(hpa: HorizontalPodAutoscaler): number { + return hpa.status?.currentReplicas || 0 +} + +function getMetricUtilization(hpa: HorizontalPodAutoscaler): string { + if (!hpa.status?.currentMetrics || hpa.status.currentMetrics.length === 0) { + return '-' + } + + const metrics = hpa.status.currentMetrics + const results: string[] = [] + + metrics.forEach((metric) => { + if ('resource' in metric && metric.resource) { + const current = metric.resource.current?.averageUtilization || 0 + const target = + hpa.spec?.metrics?.find( + (m) => 'resource' in m && m.resource?.name === metric.resource?.name + )?.resource?.target?.averageUtilization || 0 + results.push(`${metric.resource.name}: ${current}% / ${target}%`) + } else if (metric.type === 'Pods') { + results.push('Pods metric') + } else if (metric.type === 'Object') { + results.push('Object metric') + } else if (metric.type === 'External') { + results.push('External metric') + } + }) + + return results.join(', ') +} + +export function HorizontalPodAutoscalerListPage() { + const columnHelper = createColumnHelper() + + const columns = useMemo( + () => [ + columnHelper.accessor('metadata.name', { + header: 'Name', + cell: ({ row }) => ( +
+ + {row.original.metadata!.name} + +
+ ), + }), + columnHelper.accessor((row) => getHpaTargetInfo(row), { + header: 'Target', + cell: ({ getValue }) => getValue(), + }), + columnHelper.accessor((row) => row.spec?.minReplicas, { + id: 'minReplicas', + header: 'Min Pods', + cell: ({ getValue }) => getValue() || '-', + }), + columnHelper.accessor((row) => row.spec?.maxReplicas, { + id: 'maxReplicas', + header: 'Max Pods', + cell: ({ getValue }) => getValue() || '-', + }), + columnHelper.accessor((row) => getCurrentReplicas(row), { + id: 'currentReplicas', + header: 'Current Pods', + cell: ({ getValue }) => getValue(), + }), + columnHelper.accessor((row) => getMetricUtilization(row), { + id: 'metrics', + header: 'Metrics', + cell: ({ getValue }) => ( + {getValue()} + ), + }), + columnHelper.accessor('metadata.creationTimestamp', { + header: 'Created', + cell: ({ getValue }) => { + const dateStr = formatDate(getValue() || '') + + return ( + {dateStr} + ) + }, + }), + ], + [columnHelper] + ) + + const horizontalPodAutoscalerSearchFilter = useCallback( + (hpa: HorizontalPodAutoscaler, query: string) => { + const queryLower = query.toLowerCase() + return ( + hpa.metadata!.name!.toLowerCase().includes(queryLower) || + (hpa.metadata!.namespace?.toLowerCase() || '').includes(queryLower) || + getHpaTargetInfo(hpa).toLowerCase().includes(queryLower) + ) + }, + [] + ) + + return ( + + ) +} diff --git a/ui/src/pages/resource-list.tsx b/ui/src/pages/resource-list.tsx index 95316e1c..a9372512 100644 --- a/ui/src/pages/resource-list.tsx +++ b/ui/src/pages/resource-list.tsx @@ -7,6 +7,7 @@ import { CRDListPage } from './crd-list-page' import { DaemonSetListPage } from './daemonset-list-page' import { DeploymentListPage } from './deployment-list-page' import { GatewayListPage } from './gateway-list-page' +import { HorizontalPodAutoscalerListPage } from './horizontalpodautoscaler-list-page' import { HTTPRouteListPage } from './httproute-list-page' import { IngressListPage } from './ingress-list-page' import { JobListPage } from './job-list-page' @@ -53,6 +54,8 @@ export function ResourceList() { return case 'httproutes': return + case 'horizontalpodautoscalers': + return default: return } diff --git a/ui/src/types/api.ts b/ui/src/types/api.ts index ba517c71..a754e88d 100644 --- a/ui/src/types/api.ts +++ b/ui/src/types/api.ts @@ -14,6 +14,10 @@ import { StatefulSet, StatefulSetList, } from 'kubernetes-types/apps/v1' +import { + HorizontalPodAutoscaler, + HorizontalPodAutoscalerList, +} from 'kubernetes-types/autoscaling/v2' import { CronJob, CronJobList, Job, JobList } from 'kubernetes-types/batch/v1' import { ConfigMap, @@ -107,6 +111,7 @@ export type ResourceType = | 'rolebindings' | 'clusterroles' | 'clusterrolebindings' + | 'horizontalpodautoscalers' export const clusterScopeResources: ResourceType[] = [ 'crds', @@ -163,6 +168,7 @@ export interface ResourcesTypeMap { rolebindings: RoleBindingList clusterroles: ClusterRoleList clusterrolebindings: ClusterRoleBindingList + horizontalpodautoscalers: HorizontalPodAutoscalerList } export interface PodMetrics { @@ -227,6 +233,7 @@ export interface ResourceTypeMap { rolebindings: RoleBinding clusterroles: ClusterRole clusterrolebindings: ClusterRoleBinding + horizontalpodautoscalers: HorizontalPodAutoscaler } export interface RecentEvent {