feat: PTY kubectl终端 + 侧边栏对齐修复 + 构建缓存清理
**kubectl WebShell 修复:** - 引入 github.com/creack/pty 库实现真正的伪终端 - 替换 bash --noediting + TERM=dumb 方案为 PTY-backed bash - 支持 xterm-256color、窗口自适应、readline 交互 - 修复键盘输入无响应问题 **侧边栏优化:** - 独立菜单项高度从 py-1.5 改为 h-8,与父菜单对齐 - 独立菜单图标从 h-3 w-3 改为 h-3.5 w-3.5,统一 14px **构建优化:** - run.sh docker up 增加 docker image prune -f 清理悬空镜像 - 每次部署自动清理 builder cache + dangling images **认证修复(历史):** - Token 存储:Zustand + localStorage 双写,http.ts 双源 fallback - YamlViewer/DeploymentDetail token 来源统一为 Zustand store
This commit is contained in:
1 parent
cac1414917
commit
a3cd0d3910
29 files changed
+2954
-180
No files matched your search
@@ -151,6 +151,37 @@ func (c *Client) get(path string, result interface{}) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// getRaw performs a GET request returning raw bytes with custom Accept header
|
||||
func (c *Client) getRaw(path string, accept string) ([]byte, error) {
|
||||
req, err := http.NewRequest("GET", c.BaseURL+path, nil)
|
||||
if err != nil { return nil, err }
|
||||
req.Header.Set("Accept", accept)
|
||||
resp, err := c.HTTPClient.Do(req)
|
||||
if err != nil { return nil, fmt.Errorf("GET %s: %w", path, err) }
|
||||
defer resp.Body.Close()
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil { return nil, fmt.Errorf("read body: %w", err) }
|
||||
if resp.StatusCode >= 400 {
|
||||
return nil, fmt.Errorf("K8s API error %d: %s", resp.StatusCode, string(body[:min(len(body), 200)]))
|
||||
}
|
||||
return body, nil
|
||||
}
|
||||
|
||||
// putRaw performs a PUT request with raw body and custom Content-Type
|
||||
func (c *Client) putRaw(path string, contentType string, data []byte) error {
|
||||
req, err := http.NewRequest("PUT", c.BaseURL+path, strings.NewReader(string(data)))
|
||||
if err != nil { return err }
|
||||
req.Header.Set("Content-Type", contentType)
|
||||
resp, err := c.HTTPClient.Do(req)
|
||||
if err != nil { return fmt.Errorf("PUT %s: %w", path, err) }
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode >= 400 {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return fmt.Errorf("K8s API error %d: %s", resp.StatusCode, string(body[:min(len(body), 300)]))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// --- K8s API response types ---
|
||||
|
||||
// NodeList response
|
||||
@@ -212,7 +243,14 @@ type PodSpec struct {
|
||||
}
|
||||
|
||||
type Container struct {
|
||||
Image string `json:"image"`
|
||||
Name string `json:"name"`
|
||||
Image string `json:"image"`
|
||||
Resources ContainerRes `json:"resources"`
|
||||
}
|
||||
|
||||
type ContainerRes struct {
|
||||
Requests map[string]string `json:"requests"`
|
||||
Limits map[string]string `json:"limits"`
|
||||
}
|
||||
|
||||
type PodStatus struct {
|
||||
@@ -945,3 +983,336 @@ func (c *Client) ListSecrets(namespace string) (*SecretList, error) {
|
||||
}
|
||||
return &SecretList{Items: items}, nil
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// PersistentVolumeClaim (存储)
|
||||
// ============================================================================
|
||||
|
||||
type PVCList struct {
|
||||
Items []PVCItem `json:"items"`
|
||||
}
|
||||
|
||||
type PVCItem struct {
|
||||
Metadata ObjectMeta `json:"metadata"`
|
||||
Spec PVCSpec `json:"spec"`
|
||||
Status struct {
|
||||
Phase string `json:"phase"`
|
||||
Capacity map[string]string `json:"capacity"`
|
||||
} `json:"status"`
|
||||
}
|
||||
|
||||
type PVCSpec struct {
|
||||
AccessModes []string `json:"accessModes"`
|
||||
Resources struct {
|
||||
Requests map[string]string `json:"requests"`
|
||||
} `json:"resources"`
|
||||
StorageClassName string `json:"storageClassName"`
|
||||
}
|
||||
|
||||
// ListPVCs fetches PersistentVolumeClaims in a namespace
|
||||
func (c *Client) ListPVCs(namespace string) (*PVCList, error) {
|
||||
var result PVCList
|
||||
path := fmt.Sprintf("/api/v1/namespaces/%s/persistentvolumeclaims", namespace)
|
||||
if err := c.get(path, &result); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
// DeletePVC deletes a PersistentVolumeClaim
|
||||
func (c *Client) DeletePVC(namespace, name string) error {
|
||||
path := fmt.Sprintf("/api/v1/namespaces/%s/persistentvolumeclaims/%s", namespace, name)
|
||||
return c.delete(path)
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// StatefulSet
|
||||
// ============================================================================
|
||||
|
||||
type StatefulSetList struct {
|
||||
Items []StatefulSetItem `json:"items"`
|
||||
}
|
||||
|
||||
type StatefulSetItem struct {
|
||||
Metadata ObjectMeta `json:"metadata"`
|
||||
Spec StatefulSetSpec `json:"spec"`
|
||||
Status struct {
|
||||
Replicas int `json:"replicas"`
|
||||
ReadyReplicas int `json:"readyReplicas"`
|
||||
} `json:"status"`
|
||||
}
|
||||
|
||||
type StatefulSetSpec struct {
|
||||
Replicas int `json:"replicas"`
|
||||
Template struct {
|
||||
Spec struct {
|
||||
Containers []struct {
|
||||
Name string `json:"name"`
|
||||
Image string `json:"image"`
|
||||
} `json:"containers"`
|
||||
} `json:"spec"`
|
||||
} `json:"template"`
|
||||
}
|
||||
|
||||
// ListStatefulSets fetches StatefulSets across all namespaces
|
||||
func (c *Client) ListStatefulSets() (*StatefulSetList, error) {
|
||||
var result StatefulSetList
|
||||
if err := c.get("/apis/apps/v1/statefulsets", &result); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// DaemonSet
|
||||
// ============================================================================
|
||||
|
||||
type DaemonSetList struct {
|
||||
Items []DaemonSetItem `json:"items"`
|
||||
}
|
||||
|
||||
type DaemonSetItem struct {
|
||||
Metadata ObjectMeta `json:"metadata"`
|
||||
Spec DaemonSetSpec `json:"spec"`
|
||||
Status struct {
|
||||
DesiredNumberScheduled int `json:"desiredNumberScheduled"`
|
||||
CurrentNumberScheduled int `json:"currentNumberScheduled"`
|
||||
NumberReady int `json:"numberReady"`
|
||||
} `json:"status"`
|
||||
}
|
||||
|
||||
type DaemonSetSpec struct {
|
||||
Template struct {
|
||||
Spec struct {
|
||||
Containers []struct {
|
||||
Name string `json:"name"`
|
||||
Image string `json:"image"`
|
||||
} `json:"containers"`
|
||||
} `json:"spec"`
|
||||
} `json:"template"`
|
||||
}
|
||||
|
||||
// ListDaemonSets fetches DaemonSets across all namespaces
|
||||
func (c *Client) ListDaemonSets() (*DaemonSetList, error) {
|
||||
var result DaemonSetList
|
||||
if err := c.get("/apis/apps/v1/daemonsets", &result); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// NodeMetrics (metrics-server, optional)
|
||||
// ============================================================================
|
||||
|
||||
type NodeMetricsList struct {
|
||||
Items []NodeMetricsItem `json:"items"`
|
||||
}
|
||||
|
||||
type NodeMetricsItem struct {
|
||||
Metadata ObjectMeta `json:"metadata"`
|
||||
Usage map[string]string `json:"usage"` // cpu, memory
|
||||
}
|
||||
|
||||
// GetNodeMetrics fetches node CPU/memory usage from metrics-server
|
||||
func (c *Client) GetNodeMetrics() (*NodeMetricsList, error) {
|
||||
var result NodeMetricsList
|
||||
if err := c.get("/apis/metrics.k8s.io/v1beta1/nodes", &result); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// PodMetrics (metrics-server, per-container CPU/Memory)
|
||||
// ============================================================================
|
||||
|
||||
type PodMetricsList struct {
|
||||
Items []PodMetricsItem `json:"items"`
|
||||
}
|
||||
|
||||
type PodMetricsItem struct {
|
||||
Metadata ObjectMeta `json:"metadata"`
|
||||
Containers []ContainerMetrics `json:"containers"`
|
||||
}
|
||||
|
||||
type ContainerMetrics struct {
|
||||
Name string `json:"name"`
|
||||
Usage map[string]string `json:"usage"` // cpu, memory
|
||||
}
|
||||
|
||||
// ListPodMetrics fetches pod CPU/memory usage, optionally filtered by namespace
|
||||
func (c *Client) ListPodMetrics(namespace string) (*PodMetricsList, error) {
|
||||
var result PodMetricsList
|
||||
path := "/apis/metrics.k8s.io/v1beta1/pods"
|
||||
if namespace != "" {
|
||||
path = fmt.Sprintf("/apis/metrics.k8s.io/v1beta1/namespaces/%s/pods", namespace)
|
||||
}
|
||||
if err := c.get(path, &result); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Events
|
||||
// ============================================================================
|
||||
|
||||
type EventList struct {
|
||||
Items []EventItem `json:"items"`
|
||||
}
|
||||
|
||||
type EventItem struct {
|
||||
Metadata ObjectMeta `json:"metadata"`
|
||||
InvolvedObject InvolvedObject `json:"involvedObject"`
|
||||
Type string `json:"type"`
|
||||
Reason string `json:"reason"`
|
||||
Message string `json:"message"`
|
||||
Count int32 `json:"count"`
|
||||
FirstTimestamp time.Time `json:"firstTimestamp"`
|
||||
LastTimestamp time.Time `json:"lastTimestamp"`
|
||||
EventTime time.Time `json:"eventTime"`
|
||||
}
|
||||
|
||||
type InvolvedObject struct {
|
||||
Kind string `json:"kind"`
|
||||
Name string `json:"name"`
|
||||
Namespace string `json:"namespace"`
|
||||
}
|
||||
|
||||
// ListEvents fetches cluster events, optionally filtered by namespace
|
||||
func (c *Client) ListEvents(namespace string) (*EventList, error) {
|
||||
var result EventList
|
||||
path := "/api/v1/events"
|
||||
if namespace != "" {
|
||||
path = fmt.Sprintf("/api/v1/namespaces/%s/events", namespace)
|
||||
}
|
||||
if err := c.get(path, &result); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// YAML Apply (Dynamic resource apply)
|
||||
// ============================================================================
|
||||
|
||||
// ApplyYAML applies a raw YAML manifest using the K8s discovery API
|
||||
func (c *Client) ApplyYAML(yamlContent string) (map[string]interface{}, error) {
|
||||
return c.applyRaw(yamlContent)
|
||||
}
|
||||
|
||||
// GetResourceYAML fetches raw YAML for any K8s resource by API path
|
||||
func (c *Client) GetResourceYAML(apiPath string) ([]byte, error) {
|
||||
return c.getRaw(apiPath, "application/yaml")
|
||||
}
|
||||
|
||||
// UpdateResourceYAML applies raw YAML to update a K8s resource
|
||||
func (c *Client) UpdateResourceYAML(apiPath string, yamlData []byte) error {
|
||||
return c.putRaw(apiPath, "application/yaml", yamlData)
|
||||
}
|
||||
|
||||
// applyRaw parses YAML and applies it via create-or-update pattern
|
||||
func (c *Client) applyRaw(yamlContent string) (map[string]interface{}, error) {
|
||||
// Parse YAML to extract resource metadata
|
||||
var obj struct {
|
||||
APIVersion string `yaml:"apiVersion"`
|
||||
Kind string `yaml:"kind"`
|
||||
Metadata struct {
|
||||
Name string `yaml:"name"`
|
||||
Namespace string `yaml:"namespace"`
|
||||
} `yaml:"metadata"`
|
||||
}
|
||||
if err := yaml.Unmarshal([]byte(yamlContent), &obj); err != nil {
|
||||
return nil, fmt.Errorf("YAML 解析失败: %w", err)
|
||||
}
|
||||
if obj.Kind == "" || obj.Metadata.Name == "" {
|
||||
return nil, fmt.Errorf("YAML 中缺少 kind 或 name")
|
||||
}
|
||||
|
||||
// Build API path from kind
|
||||
apiPath, isNamespaced := c.resourceAPIPath(obj.APIVersion, obj.Kind, obj.Metadata.Namespace, obj.Metadata.Name)
|
||||
if apiPath == "" {
|
||||
return nil, fmt.Errorf("不支持的资源类型: %s/%s", obj.APIVersion, obj.Kind)
|
||||
}
|
||||
|
||||
// Try GET first — if 200, it exists, do PUT. If 404, do POST.
|
||||
parentPath := apiPath[:strings.LastIndex(apiPath, "/")] // without /name suffix
|
||||
_, getErr := c.getRaw(apiPath, "application/yaml")
|
||||
if getErr == nil {
|
||||
// Resource exists → PUT update
|
||||
if err := c.putRaw(apiPath, "application/yaml", []byte(yamlContent)); err != nil {
|
||||
return nil, fmt.Errorf("更新资源失败: %w", err)
|
||||
}
|
||||
return map[string]interface{}{"action": "updated", "path": apiPath}, nil
|
||||
}
|
||||
|
||||
// Resource doesn't exist → POST create (for namespaced) or POST to parent
|
||||
var result map[string]interface{}
|
||||
if isNamespaced && obj.Metadata.Namespace == "" {
|
||||
obj.Metadata.Namespace = "default"
|
||||
parentPath = strings.Replace(parentPath, "/namespaces//", "/namespaces/default/", 1)
|
||||
}
|
||||
if err := c.post(parentPath, []byte(yamlContent), &result); err != nil {
|
||||
return nil, fmt.Errorf("创建资源失败: %w", err)
|
||||
}
|
||||
return map[string]interface{}{"action": "created", "path": parentPath, "result": result}, nil
|
||||
}
|
||||
|
||||
// resourceAPIPath returns the K8s REST API path for a given resource kind
|
||||
func (c *Client) resourceAPIPath(apiVersion, kind, namespace, name string) (string, bool) {
|
||||
resource := strings.ToLower(kind) + "s"
|
||||
switch kind {
|
||||
case "Endpoints": resource = "endpoints"
|
||||
case "PodMetrics": resource = "pods"
|
||||
}
|
||||
|
||||
var groupPrefix string
|
||||
if apiVersion != "" && apiVersion != "v1" {
|
||||
parts := strings.SplitN(apiVersion, "/", 2)
|
||||
if len(parts) == 2 {
|
||||
groupPrefix = "/apis/" + parts[0] + "/" + parts[1]
|
||||
} else {
|
||||
groupPrefix = "/apis/" + parts[0]
|
||||
}
|
||||
}
|
||||
|
||||
isNamespaced := true
|
||||
switch kind {
|
||||
case "Namespace", "Node", "ClusterRole", "ClusterRoleBinding", "PersistentVolume":
|
||||
isNamespaced = false
|
||||
}
|
||||
|
||||
if !isNamespaced {
|
||||
if groupPrefix != "" {
|
||||
return groupPrefix + "/" + resource + "/" + name, false
|
||||
}
|
||||
return "/api/v1/" + resource + "/" + name, false
|
||||
}
|
||||
|
||||
ns := namespace
|
||||
if ns == "" { ns = "default" }
|
||||
|
||||
if groupPrefix != "" {
|
||||
switch kind {
|
||||
case "Deployment", "StatefulSet", "DaemonSet", "ReplicaSet":
|
||||
return groupPrefix + "/namespaces/" + ns + "/" + resource + "/" + name, true
|
||||
case "HorizontalPodAutoscaler":
|
||||
return groupPrefix + "/namespaces/" + ns + "/" + strings.ToLower(kind) + "s/" + name, true
|
||||
case "Ingress":
|
||||
return groupPrefix + "/namespaces/" + ns + "/" + resource + "/" + name, true
|
||||
default:
|
||||
return groupPrefix + "/namespaces/" + ns + "/" + resource + "/" + name, true
|
||||
}
|
||||
}
|
||||
|
||||
// Core v1 API
|
||||
coreResources := map[string]string{
|
||||
"Pod": "pods", "Service": "services", "ConfigMap": "configmaps",
|
||||
"Secret": "secrets", "PersistentVolumeClaim": "persistentvolumeclaims",
|
||||
}
|
||||
if r, ok := coreResources[kind]; ok {
|
||||
return "/api/v1/namespaces/" + ns + "/" + r + "/" + name, true
|
||||
}
|
||||
return "/api/v1/namespaces/" + ns + "/" + resource + "/" + name, true
|
||||
}
|
||||
Reference in new issue
Block a user