Merge pull request 'feat(kube): Kubernetes UI — FreeLens v5 feature parity' (#85) from feat/kube-ui-feature-parity into master
All checks were successful
Auto Tag / autotag (push) Successful in 7s
Auto Tag / wiki-sync (push) Successful in 12s
Test / frontend-typecheck (push) Successful in 1m35s
Auto Tag / changelog (push) Successful in 1m38s
Test / frontend-tests (push) Successful in 1m51s
Auto Tag / build-macos-arm64 (push) Successful in 7m16s
Auto Tag / build-linux-amd64 (push) Successful in 9m47s
Auto Tag / build-windows-amd64 (push) Successful in 11m36s
Auto Tag / build-linux-arm64 (push) Successful in 11m41s
Test / rust-fmt-check (push) Successful in 16m26s
Test / rust-clippy (push) Successful in 18m13s
Test / rust-tests (push) Successful in 19m55s
All checks were successful
Auto Tag / autotag (push) Successful in 7s
Auto Tag / wiki-sync (push) Successful in 12s
Test / frontend-typecheck (push) Successful in 1m35s
Auto Tag / changelog (push) Successful in 1m38s
Test / frontend-tests (push) Successful in 1m51s
Auto Tag / build-macos-arm64 (push) Successful in 7m16s
Auto Tag / build-linux-amd64 (push) Successful in 9m47s
Auto Tag / build-windows-amd64 (push) Successful in 11m36s
Auto Tag / build-linux-arm64 (push) Successful in 11m41s
Test / rust-fmt-check (push) Successful in 16m26s
Test / rust-clippy (push) Successful in 18m13s
Test / rust-tests (push) Successful in 19m55s
Reviewed-on: #85
This commit is contained in:
commit
3f83486b9f
@ -242,7 +242,7 @@ jobs:
|
|||||||
|
|
||||||
# Write body to file — passing 100KB+ JSON as a shell arg hits ARG_MAX.
|
# Write body to file — passing 100KB+ JSON as a shell arg hits ARG_MAX.
|
||||||
jq -cn \
|
jq -cn \
|
||||||
--arg model "qwen36-35b-a3b-nvfp4" \
|
--arg model "qwen3-coder-next" \
|
||||||
--rawfile content /tmp/prompt.txt \
|
--rawfile content /tmp/prompt.txt \
|
||||||
'{model: $model, messages: [{role: "user", content: $content}], stream: false}' \
|
'{model: $model, messages: [{role: "user", content: $content}], stream: false}' \
|
||||||
> /tmp/body.json
|
> /tmp/body.json
|
||||||
@ -359,7 +359,7 @@ jobs:
|
|||||||
if [ -f "/tmp/pr_review.txt" ] && [ -s "/tmp/pr_review.txt" ]; then
|
if [ -f "/tmp/pr_review.txt" ] && [ -s "/tmp/pr_review.txt" ]; then
|
||||||
REVIEW_BODY=$(head -c 65536 /tmp/pr_review.txt)
|
REVIEW_BODY=$(head -c 65536 /tmp/pr_review.txt)
|
||||||
BODY=$(jq -n \
|
BODY=$(jq -n \
|
||||||
--arg body "Automated PR Review (qwen36-35b-a3b-nvfp4 via liteLLM):\n\n${REVIEW_BODY}" \
|
--arg body "Automated PR Review (qwen3-coder-next via liteLLM):\n\n${REVIEW_BODY}" \
|
||||||
'{body: $body, event: "COMMENT"}')
|
'{body: $body, event: "COMMENT"}')
|
||||||
else
|
else
|
||||||
BODY=$(jq -n \
|
BODY=$(jq -n \
|
||||||
|
|||||||
551
TICKET-freelens-feature-inventory.md
Normal file
551
TICKET-freelens-feature-inventory.md
Normal file
@ -0,0 +1,551 @@
|
|||||||
|
# FreeLens Feature Inventory — Complete Analysis
|
||||||
|
|
||||||
|
**Project**: FreeLens (https://github.com/freelensapp/freelens)
|
||||||
|
**License**: MIT License (Copyright 2024-2026 Freelens Authors; Copyright 2022 OpenLens Authors)
|
||||||
|
**Description**: Free and open-source Kubernetes IDE, community fork of Open Lens v5
|
||||||
|
**Analysis Date**: 2026-06-08
|
||||||
|
**Repository Commit**: main branch (latest)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Executive Summary
|
||||||
|
|
||||||
|
FreeLens is a production-ready, feature-complete Kubernetes desktop IDE built on Electron with a comprehensive resource management interface. The application provides extensive coverage of Kubernetes API resources with dedicated UI components, context menus, and detail views for nearly all standard Kubernetes objects.
|
||||||
|
|
||||||
|
**Key Findings**:
|
||||||
|
- **13 main navigation categories** with 60+ resource types
|
||||||
|
- **Comprehensive pod management**: shell/exec, logs, attach, edit, delete, force delete, force finalize
|
||||||
|
- **Full workload lifecycle**: scale, restart, edit, delete for Deployments, StatefulSets, DaemonSets
|
||||||
|
- **Helm chart integration**: install, upgrade, rollback, delete
|
||||||
|
- **Port forwarding UI**: start/stop/edit/open in browser
|
||||||
|
- **Terminal integration**: built-in terminal with kubectl and node shell access
|
||||||
|
- **Resource metrics**: CPU/memory usage visualization (when metrics-server available)
|
||||||
|
- **YAML editing**: Monaco editor with syntax highlighting
|
||||||
|
- **RBAC management**: full support for roles, bindings, service accounts
|
||||||
|
- **Extension ecosystem**: plugin architecture for custom functionality
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Left Navigation Structure (Complete)
|
||||||
|
|
||||||
|
### 1. Favorites
|
||||||
|
- User-bookmarked resources for quick access
|
||||||
|
|
||||||
|
### 2. Cluster Overview
|
||||||
|
- Cluster-wide dashboard with health metrics
|
||||||
|
|
||||||
|
### 3. Nodes
|
||||||
|
- Node list and details
|
||||||
|
- **Context Menu Actions**:
|
||||||
|
- Shell (node shell access via SSH or similar)
|
||||||
|
- Cordon/Uncordon
|
||||||
|
- Drain (with confirmation)
|
||||||
|
- Edit
|
||||||
|
- Delete
|
||||||
|
|
||||||
|
### 4. Workloads
|
||||||
|
Parent category containing:
|
||||||
|
|
||||||
|
#### 4.1 Overview
|
||||||
|
- Aggregated workload dashboard
|
||||||
|
|
||||||
|
#### 4.2 Pods
|
||||||
|
- Pod list with status, IP, node, age
|
||||||
|
- **Context Menu Actions**:
|
||||||
|
- Shell (per-container with auto-detection: bash/ash/sh, PowerShell for Windows nodes)
|
||||||
|
- Logs (per-container, including init and ephemeral containers)
|
||||||
|
- Attach (kubectl attach -it)
|
||||||
|
- Edit (YAML editor)
|
||||||
|
- Delete (graceful)
|
||||||
|
- Force Delete (skip grace period, only for Running/Pending phases)
|
||||||
|
- Force Finalize (remove finalizers when stuck)
|
||||||
|
|
||||||
|
#### 4.3 Deployments
|
||||||
|
- **Context Menu Actions**:
|
||||||
|
- Scale (replica count dialog)
|
||||||
|
- Restart (rolling restart)
|
||||||
|
- Edit
|
||||||
|
- Delete
|
||||||
|
|
||||||
|
#### 4.4 StatefulSets
|
||||||
|
- **Context Menu Actions**:
|
||||||
|
- Restart
|
||||||
|
- Edit
|
||||||
|
- Delete
|
||||||
|
|
||||||
|
#### 4.5 DaemonSets
|
||||||
|
- **Context Menu Actions**:
|
||||||
|
- Restart
|
||||||
|
- Edit
|
||||||
|
- Delete
|
||||||
|
|
||||||
|
#### 4.6 Jobs
|
||||||
|
- **Context Menu Actions**:
|
||||||
|
- Edit
|
||||||
|
- Delete
|
||||||
|
|
||||||
|
#### 4.7 CronJobs
|
||||||
|
- **Context Menu Actions**:
|
||||||
|
- Edit
|
||||||
|
- Delete
|
||||||
|
|
||||||
|
#### 4.8 ReplicaSets
|
||||||
|
- List view (typically managed by Deployments)
|
||||||
|
|
||||||
|
#### 4.9 ReplicationControllers
|
||||||
|
- Legacy replication support
|
||||||
|
|
||||||
|
### 5. Config
|
||||||
|
Parent category containing:
|
||||||
|
|
||||||
|
#### 5.1 ConfigMaps
|
||||||
|
- **Context Menu Actions**:
|
||||||
|
- Edit
|
||||||
|
- Delete
|
||||||
|
|
||||||
|
#### 5.2 Secrets
|
||||||
|
- **Context Menu Actions**:
|
||||||
|
- Edit (with data obfuscation)
|
||||||
|
- Delete
|
||||||
|
|
||||||
|
#### 5.3 Horizontal Pod Autoscalers (HPA)
|
||||||
|
- HPA configuration and status
|
||||||
|
|
||||||
|
#### 5.4 Vertical Pod Autoscalers (VPA)
|
||||||
|
- VPA recommendations and settings
|
||||||
|
|
||||||
|
#### 5.5 Resource Quotas
|
||||||
|
- Namespace quota limits
|
||||||
|
|
||||||
|
#### 5.6 Limit Ranges
|
||||||
|
- Default resource limits
|
||||||
|
|
||||||
|
#### 5.7 Priority Classes
|
||||||
|
- Pod scheduling priority definitions
|
||||||
|
|
||||||
|
#### 5.8 Runtime Classes
|
||||||
|
- Container runtime selection
|
||||||
|
|
||||||
|
#### 5.9 Pod Disruption Budgets
|
||||||
|
- PDB configuration
|
||||||
|
|
||||||
|
#### 5.10 Leases
|
||||||
|
- Coordination.k8s.io lease objects
|
||||||
|
|
||||||
|
#### 5.11 Mutating Webhook Configurations
|
||||||
|
- Admission webhook config
|
||||||
|
|
||||||
|
#### 5.12 Validating Webhook Configurations
|
||||||
|
- Validation webhook config
|
||||||
|
|
||||||
|
### 6. Network
|
||||||
|
Parent category containing:
|
||||||
|
|
||||||
|
#### 6.1 Services
|
||||||
|
- Service list and endpoints
|
||||||
|
- **Context Menu Actions**:
|
||||||
|
- Edit
|
||||||
|
- Delete
|
||||||
|
|
||||||
|
#### 6.2 Ingresses
|
||||||
|
- Ingress rules and backends
|
||||||
|
|
||||||
|
#### 6.3 Ingress Classes
|
||||||
|
- IngressClass definitions
|
||||||
|
|
||||||
|
#### 6.4 Network Policies
|
||||||
|
- Network segmentation rules
|
||||||
|
|
||||||
|
#### 6.5 Endpoints
|
||||||
|
- Service endpoint slices
|
||||||
|
|
||||||
|
#### 6.6 Endpoint Slices
|
||||||
|
- EndpointSlice objects
|
||||||
|
|
||||||
|
#### 6.7 Port Forwards
|
||||||
|
- Active port-forward management
|
||||||
|
- **Context Menu Actions**:
|
||||||
|
- Open (in browser, for HTTP/HTTPS)
|
||||||
|
- Edit (change local/remote port, protocol)
|
||||||
|
- Start
|
||||||
|
- Stop
|
||||||
|
- Delete
|
||||||
|
|
||||||
|
### 7. Storage
|
||||||
|
Parent category containing:
|
||||||
|
|
||||||
|
#### 7.1 Persistent Volumes
|
||||||
|
- Cluster-wide PV list
|
||||||
|
|
||||||
|
#### 7.2 Persistent Volume Claims
|
||||||
|
- PVC list with binding status
|
||||||
|
|
||||||
|
#### 7.3 Storage Classes
|
||||||
|
- Dynamic provisioning configuration
|
||||||
|
|
||||||
|
### 8. Namespaces
|
||||||
|
- Namespace list and quota overview
|
||||||
|
- Namespace filtering (global namespace selector in UI)
|
||||||
|
|
||||||
|
### 9. Events
|
||||||
|
- Cluster events stream with filtering
|
||||||
|
|
||||||
|
### 10. Helm
|
||||||
|
Parent category containing:
|
||||||
|
|
||||||
|
#### 10.1 Charts
|
||||||
|
- Helm chart repository browser
|
||||||
|
- Search across configured repositories
|
||||||
|
- **Chart Actions**:
|
||||||
|
- Install (opens install dialog with values editor)
|
||||||
|
|
||||||
|
#### 10.2 Releases
|
||||||
|
- Deployed Helm releases
|
||||||
|
- **Context Menu Actions**:
|
||||||
|
- Upgrade (opens upgrade dialog)
|
||||||
|
- Rollback (to previous revision)
|
||||||
|
- Delete
|
||||||
|
|
||||||
|
### 11. User Management (RBAC)
|
||||||
|
Parent category containing:
|
||||||
|
|
||||||
|
#### 11.1 Service Accounts
|
||||||
|
- **Context Menu Actions**:
|
||||||
|
- Edit
|
||||||
|
- Delete
|
||||||
|
|
||||||
|
#### 11.2 Roles
|
||||||
|
- Namespace-scoped RBAC roles
|
||||||
|
|
||||||
|
#### 11.3 Role Bindings
|
||||||
|
- Role-to-subject mappings
|
||||||
|
|
||||||
|
#### 11.4 Cluster Roles
|
||||||
|
- Cluster-wide RBAC roles
|
||||||
|
|
||||||
|
#### 11.5 Cluster Role Bindings
|
||||||
|
- ClusterRole-to-subject mappings
|
||||||
|
|
||||||
|
### 12. Custom Resources
|
||||||
|
- **Custom Resource Definitions (CRDs)**
|
||||||
|
- **Custom Resources** (instances of CRDs)
|
||||||
|
- Dynamic UI generation for any CRD installed in cluster
|
||||||
|
|
||||||
|
### 13. Pod Security Policies (PSP)
|
||||||
|
- Legacy PSP support (deprecated in K8s 1.25+)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Detail Views
|
||||||
|
|
||||||
|
All resources support a **detail drawer** (right-side panel) showing:
|
||||||
|
|
||||||
|
### Pod Detail View
|
||||||
|
- **Status** (Running, Pending, Failed, etc.)
|
||||||
|
- **Node** (clickable link to node)
|
||||||
|
- **Host IPs** (multi-IP support)
|
||||||
|
- **Pod IPs** (IPv4/IPv6)
|
||||||
|
- **Service Account** (clickable link)
|
||||||
|
- **Priority Class** (clickable link)
|
||||||
|
- **QoS Class** (BestEffort, Burstable, Guaranteed)
|
||||||
|
- **Runtime Class** (clickable link)
|
||||||
|
- **Termination Grace Period**
|
||||||
|
- **Node Selector** (labels)
|
||||||
|
- **Tolerations** (with key/value/effect)
|
||||||
|
- **Affinity/Anti-Affinity** (node and pod affinity rules)
|
||||||
|
- **Resource Requests** (CPU, memory, ephemeral-storage)
|
||||||
|
- **Resource Limits** (CPU, memory)
|
||||||
|
- **Secrets** (mounted secrets with clickable links)
|
||||||
|
- **Conditions** (PodScheduled, Initialized, ContainersReady, Ready)
|
||||||
|
- **Init Containers** (with status, restart count, state)
|
||||||
|
- **Containers** (with status, restart count, image, ports, env vars, volume mounts, liveness/readiness probes)
|
||||||
|
- **Ephemeral Containers** (debug containers)
|
||||||
|
- **Volumes** (ConfigMaps, Secrets, PVCs, EmptyDir, HostPath, etc.)
|
||||||
|
|
||||||
|
### Other Resource Detail Views
|
||||||
|
- **Deployment**: replicas, strategy, conditions, selector, pod template
|
||||||
|
- **Service**: type, cluster IP, external IP, ports, selector, endpoints
|
||||||
|
- **ConfigMap**: data key-value pairs
|
||||||
|
- **Secret**: data keys (values obfuscated)
|
||||||
|
- **Node**: conditions, addresses, capacity, allocatable, taints, images
|
||||||
|
- **PVC**: access modes, storage class, volume name, capacity
|
||||||
|
- **Ingress**: rules, TLS, backends
|
||||||
|
|
||||||
|
All detail views include:
|
||||||
|
- **Metadata** section (name, namespace, labels, annotations, creation time, resource version, UID)
|
||||||
|
- **YAML view** (Monaco editor with syntax highlighting)
|
||||||
|
- **Events** related to the resource
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Dock Panel (Bottom Panel)
|
||||||
|
|
||||||
|
The dock is a tabbed bottom panel supporting multiple simultaneous views:
|
||||||
|
|
||||||
|
### Terminal
|
||||||
|
- **Node Shell**: SSH or similar access to cluster nodes
|
||||||
|
- **Pod Shell**: `kubectl exec -it` with container selection
|
||||||
|
- **Pod Attach**: `kubectl attach -it` for attaching to running container
|
||||||
|
- **Custom Commands**: run arbitrary kubectl commands
|
||||||
|
- **Multi-tab support**: multiple shells in separate tabs
|
||||||
|
- **Shell Detection**: auto-selects bash/ash/sh on Linux, PowerShell on Windows nodes
|
||||||
|
|
||||||
|
### Logs
|
||||||
|
- **Pod Logs**: per-container log streaming
|
||||||
|
- **Container Selection**: dropdown for multi-container pods (including init and ephemeral)
|
||||||
|
- **Follow Mode**: tail -f equivalent
|
||||||
|
- **Timestamps**: toggle timestamp display
|
||||||
|
- **Previous Logs**: view logs from crashed/restarted containers
|
||||||
|
- **Search/Filter**: text search within logs
|
||||||
|
- **Download**: save logs to file
|
||||||
|
- **Wrap Lines**: toggle line wrapping
|
||||||
|
|
||||||
|
### Edit Resource
|
||||||
|
- **YAML Editor**: Monaco-based syntax highlighting
|
||||||
|
- **Apply Changes**: update resource via kubectl apply
|
||||||
|
- **Validation**: client-side YAML validation
|
||||||
|
- **Diff View**: show changes before applying
|
||||||
|
|
||||||
|
### Create Resource
|
||||||
|
- **YAML Editor**: create new resources from scratch
|
||||||
|
- **Templates**: common resource templates
|
||||||
|
- **Multi-resource**: create multiple resources from YAML with `---` separator
|
||||||
|
|
||||||
|
### Install Chart
|
||||||
|
- **Chart Selection**: from Helm repository browser
|
||||||
|
- **Values Editor**: YAML editor for values.yaml
|
||||||
|
- **Release Name**: custom release name
|
||||||
|
- **Namespace Selection**: target namespace
|
||||||
|
- **Preview**: dry-run before install
|
||||||
|
|
||||||
|
### Upgrade Chart
|
||||||
|
- **Current Values**: shows existing values
|
||||||
|
- **New Version Selection**: dropdown of available chart versions
|
||||||
|
- **Values Diff**: highlight changes from current release
|
||||||
|
- **Revision History**: list previous revisions
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Special Features
|
||||||
|
|
||||||
|
### Metrics & Resource Usage
|
||||||
|
- **Pod Metrics**: CPU and memory usage graphs (requires metrics-server)
|
||||||
|
- **Node Metrics**: cluster-wide resource utilization
|
||||||
|
- **Container Metrics**: per-container CPU/memory in detail view
|
||||||
|
- **Historical Charts**: time-series graphs for resource usage
|
||||||
|
|
||||||
|
### Namespace Filtering
|
||||||
|
- **Global Namespace Selector**: filters all views to selected namespace(s)
|
||||||
|
- **Multi-namespace Selection**: view resources across multiple namespaces
|
||||||
|
- **All Namespaces**: cluster-wide view
|
||||||
|
|
||||||
|
### Search & Filtering
|
||||||
|
- **Global Search**: search across all resource types
|
||||||
|
- **Per-View Search**: resource-specific search with multiple field filtering
|
||||||
|
- **Label Filtering**: filter by labels and annotations
|
||||||
|
|
||||||
|
### Context Menu Behavior
|
||||||
|
- **Toolbar Mode**: icons with tooltips in detail view header
|
||||||
|
- **Table Row Menu**: three-dot menu in list views
|
||||||
|
- **Right-click Context Menu**: anywhere on resource row
|
||||||
|
|
||||||
|
### Delete Modes (Intelligent)
|
||||||
|
|
||||||
|
FreeLens implements **intelligent delete mode selection** based on resource state:
|
||||||
|
|
||||||
|
#### Pod Deletion
|
||||||
|
- **Delete** (graceful): default for all phases
|
||||||
|
- **Force Delete** (grace period = 0): only shown for Running/Pending pods with `terminationGracePeriodSeconds > 0`
|
||||||
|
- **Force Finalize** (remove finalizers): shown when pod has `deletionTimestamp` AND finalizers
|
||||||
|
|
||||||
|
Logic prevents showing "Force Delete" for terminal phases (Succeeded, Failed, Unknown) where it would have no effect.
|
||||||
|
|
||||||
|
#### Generic Resource Deletion
|
||||||
|
- **Delete**: default
|
||||||
|
- **Force Finalize**: only when resource has `deletionTimestamp` AND finalizers
|
||||||
|
|
||||||
|
### Confirmation Dialogs
|
||||||
|
All destructive actions (delete, drain, restart) require user confirmation with resource name displayed.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Kubernetes API Coverage
|
||||||
|
|
||||||
|
FreeLens supports **all standard Kubernetes API groups**:
|
||||||
|
|
||||||
|
### Core (v1)
|
||||||
|
- Pods, Services, Endpoints, ConfigMaps, Secrets, Namespaces, Nodes, PersistentVolumes, PersistentVolumeClaims, ServiceAccounts, Events, ResourceQuotas, LimitRanges
|
||||||
|
|
||||||
|
### Apps (apps/v1)
|
||||||
|
- Deployments, StatefulSets, DaemonSets, ReplicaSets, ReplicationControllers
|
||||||
|
|
||||||
|
### Batch (batch/v1, batch/v1beta1)
|
||||||
|
- Jobs, CronJobs
|
||||||
|
|
||||||
|
### Networking (networking.k8s.io/v1)
|
||||||
|
- Ingresses, IngressClasses, NetworkPolicies
|
||||||
|
|
||||||
|
### Storage (storage.k8s.io/v1)
|
||||||
|
- StorageClasses, VolumeAttachments
|
||||||
|
|
||||||
|
### RBAC (rbac.authorization.k8s.io/v1)
|
||||||
|
- Roles, RoleBindings, ClusterRoles, ClusterRoleBindings
|
||||||
|
|
||||||
|
### Autoscaling (autoscaling/v1, autoscaling/v2)
|
||||||
|
- HorizontalPodAutoscalers, VerticalPodAutoscalers
|
||||||
|
|
||||||
|
### Policy (policy/v1, policy/v1beta1)
|
||||||
|
- PodDisruptionBudgets, PodSecurityPolicies
|
||||||
|
|
||||||
|
### Admission (admissionregistration.k8s.io/v1)
|
||||||
|
- MutatingWebhookConfigurations, ValidatingWebhookConfigurations
|
||||||
|
|
||||||
|
### Scheduling (scheduling.k8s.io/v1)
|
||||||
|
- PriorityClasses
|
||||||
|
|
||||||
|
### Node (node.k8s.io/v1)
|
||||||
|
- RuntimeClasses
|
||||||
|
|
||||||
|
### Coordination (coordination.k8s.io/v1)
|
||||||
|
- Leases
|
||||||
|
|
||||||
|
### Discovery (discovery.k8s.io/v1)
|
||||||
|
- EndpointSlices
|
||||||
|
|
||||||
|
### Custom Resources
|
||||||
|
- Full CRD support with dynamic UI generation
|
||||||
|
|
||||||
|
### Helm
|
||||||
|
- Charts, Releases (via Helm API, not native K8s)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Extension System
|
||||||
|
|
||||||
|
FreeLens supports extensions via a plugin API:
|
||||||
|
- **Custom Pages**: add new sidebar items and routes
|
||||||
|
- **Custom Menus**: inject menu items into resource context menus
|
||||||
|
- **Custom Resource Views**: override or enhance detail views
|
||||||
|
- **Protocol Handlers**: register custom URL schemes
|
||||||
|
- **Preferences**: add extension settings to preferences UI
|
||||||
|
|
||||||
|
Extensions are TypeScript/JavaScript modules loaded at runtime.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Comparison to TFTSR Requirements
|
||||||
|
|
||||||
|
Based on the TFTSR project's needs for Kubernetes cluster management, FreeLens provides:
|
||||||
|
|
||||||
|
### Strengths
|
||||||
|
✅ **Complete resource coverage**: All K8s API objects supported
|
||||||
|
✅ **Shell execution**: Built-in terminal with pod exec and node shell
|
||||||
|
✅ **Log streaming**: Real-time log viewing with container selection
|
||||||
|
✅ **YAML editing**: Monaco editor with validation
|
||||||
|
✅ **Port forwarding**: Full UI for managing forwards
|
||||||
|
✅ **Helm integration**: Chart install, upgrade, rollback
|
||||||
|
✅ **RBAC management**: Full RBAC resource support
|
||||||
|
✅ **Extension API**: Customizable via plugins
|
||||||
|
✅ **Multi-cluster**: Supports multiple kubeconfig contexts
|
||||||
|
✅ **Metrics**: Resource usage visualization (when metrics-server available)
|
||||||
|
✅ **Open source**: MIT licensed, can be forked/customized
|
||||||
|
|
||||||
|
### Potential Gaps for TFTSR
|
||||||
|
⚠️ **No AI integration**: FreeLens is a pure Kubernetes IDE, no AI/ML features
|
||||||
|
⚠️ **No RCA/triage features**: No incident management or root cause analysis
|
||||||
|
⚠️ **No PII detection**: Standard K8s IDE, no data privacy features
|
||||||
|
⚠️ **No audit logging**: No built-in audit trail (relies on K8s audit logs)
|
||||||
|
⚠️ **Electron-based**: Desktop app, not web-based (may not fit deployment model)
|
||||||
|
⚠️ **No integrations**: No Confluence, ServiceNow, ADO connectors
|
||||||
|
|
||||||
|
### Feature Parity Opportunities
|
||||||
|
If building TFTSR's K8s management UI, FreeLens demonstrates best practices for:
|
||||||
|
- **Resource action menus**: Comprehensive context menus with confirmation flows
|
||||||
|
- **Detail views**: Structured drawer layout with expandable sections
|
||||||
|
- **Intelligent delete modes**: State-aware action availability
|
||||||
|
- **Terminal integration**: Seamless kubectl exec and attach
|
||||||
|
- **Log viewer**: Feature-rich log streaming with filters
|
||||||
|
- **Port forward UI**: Start/stop/edit/open workflow
|
||||||
|
- **Helm UI**: Chart browser, install wizard, upgrade/rollback flows
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Technical Architecture Insights
|
||||||
|
|
||||||
|
### Codebase Organization
|
||||||
|
- **Dependency Injection**: Uses `@ogre-tools/injectable` for all services
|
||||||
|
- **State Management**: MobX for reactive stores
|
||||||
|
- **Component Pattern**: React with TypeScript, HOCs for injection
|
||||||
|
- **Menu System**: Dynamic menu generation based on resource type and state
|
||||||
|
- **API Layer**: Abstractions for `kubectl`, Helm API, metrics-server
|
||||||
|
- **Store Pattern**: Separate stores for each resource type with watch API integration
|
||||||
|
|
||||||
|
### Key Design Patterns
|
||||||
|
1. **KubeObjectMenu**: Generic menu component that dynamically injects resource-specific menu items
|
||||||
|
2. **Sidebar Items**: Injectable pattern for navigation tree construction
|
||||||
|
3. **Detail Views**: Drawer-based detail panels with tabbed sections
|
||||||
|
4. **Dock System**: Multi-tab bottom panel for logs, terminal, editors
|
||||||
|
5. **State-aware Actions**: Action availability based on resource phase, deletion timestamp, finalizers
|
||||||
|
|
||||||
|
### Menu Item Registration
|
||||||
|
Each resource type registers menu items via injectables:
|
||||||
|
- `pod-shell-menu.tsx`: Shell action for pods
|
||||||
|
- `pod-logs-menu.tsx`: Logs action for pods
|
||||||
|
- `deployment-menu.tsx`: Scale and Restart for deployments
|
||||||
|
- `node-menu.tsx`: Cordon, Uncordon, Drain for nodes
|
||||||
|
|
||||||
|
This modular approach allows easy extension without modifying core menu code.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Recommendations for TFTSR
|
||||||
|
|
||||||
|
### 1. Feature Parity Checklist
|
||||||
|
If implementing K8s management in TFTSR, prioritize:
|
||||||
|
- [ ] Pod shell exec (with container selection)
|
||||||
|
- [ ] Log streaming (with follow/timestamps/search)
|
||||||
|
- [ ] YAML editor (with validation)
|
||||||
|
- [ ] Delete modes (graceful, force, finalize based on state)
|
||||||
|
- [ ] Port forwarding UI
|
||||||
|
- [ ] Helm chart management
|
||||||
|
- [ ] Resource detail views (structured drawer layout)
|
||||||
|
- [ ] Namespace filtering
|
||||||
|
- [ ] Metrics/resource usage (if metrics-server available)
|
||||||
|
|
||||||
|
### 2. Integration Points
|
||||||
|
TFTSR could integrate K8s management with:
|
||||||
|
- **AI Analysis**: Use pod logs, events, describe output as context for AI triage
|
||||||
|
- **RCA Workflow**: Link K8s resources to incident timeline
|
||||||
|
- **Audit Trail**: Log all kubectl commands executed via UI
|
||||||
|
- **PII Detection**: Scan logs and ConfigMaps before AI processing
|
||||||
|
|
||||||
|
### 3. Web vs Desktop
|
||||||
|
FreeLens is Electron-based. For TFTSR (likely Tauri web UI):
|
||||||
|
- **Pros**: Can reuse architecture patterns, menu system, detail view layouts
|
||||||
|
- **Cons**: Cannot directly fork FreeLens (Electron vs Tauri)
|
||||||
|
- **Approach**: Study FreeLens UI/UX patterns, implement in React + Tauri with Rust backend
|
||||||
|
|
||||||
|
### 4. Licensing
|
||||||
|
MIT license allows:
|
||||||
|
- ✅ Studying code for design patterns
|
||||||
|
- ✅ Borrowing UI/UX concepts
|
||||||
|
- ✅ Forking and modifying (with attribution)
|
||||||
|
- ❌ Cannot claim FreeLens authors' copyright as your own
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Sources
|
||||||
|
|
||||||
|
1. FreeLens GitHub Repository. "freelensapp/freelens." GitHub, 2026-06-08. https://github.com/freelensapp/freelens
|
||||||
|
2. FreeLens. "LICENSE." MIT License, 2024-2026. https://github.com/freelensapp/freelens/blob/main/LICENSE
|
||||||
|
3. FreeLens. "KubeObjectMenu Component." TypeScript source, main branch. `/packages/core/src/renderer/components/kube-object-menu/kube-object-menu.tsx`
|
||||||
|
4. FreeLens. "Pod Menu Actions." TypeScript source, main branch. `/packages/core/src/renderer/components/node-pod-menu/`
|
||||||
|
5. FreeLens. "Sidebar Navigation." TypeScript source, main branch. `/packages/core/src/common/sidebar-menu-items-starting-order.ts`
|
||||||
|
6. FreeLens. "Deployment, StatefulSet, DaemonSet Menus." TypeScript source, main branch. `/packages/core/src/renderer/components/workloads-*/`
|
||||||
|
7. FreeLens. "Helm Release Menu." TypeScript source, main branch. `/packages/core/src/renderer/components/helm-releases/release-menu.tsx`
|
||||||
|
8. FreeLens. "Port Forward Menu." TypeScript source, main branch. `/packages/core/src/renderer/components/network-port-forwards/port-forward-menu.tsx`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Analysis completed by**: Claude Code (Technical Researcher)
|
||||||
|
**Format**: Markdown ticket for project documentation
|
||||||
280
TICKET-kube-ui-feature-parity.md
Normal file
280
TICKET-kube-ui-feature-parity.md
Normal file
@ -0,0 +1,280 @@
|
|||||||
|
# TICKET: Kubernetes UI — FreeLens v5 Feature Parity
|
||||||
|
|
||||||
|
## Description
|
||||||
|
|
||||||
|
Full gap analysis and implementation plan to bring the TFTSR Kubernetes Management UI to
|
||||||
|
feature parity with Lens Desktop v5 / FreeLens (MIT-licensed, https://github.com/freelensapp/freelens).
|
||||||
|
|
||||||
|
Analysis confirmed the following areas require work:
|
||||||
|
|
||||||
|
1. **Navigation structure** does not match the requested layout — wrong grouping, missing top-level
|
||||||
|
sections (Namespaces, Helm, Custom Resources), and missing items within existing sections.
|
||||||
|
2. **Resource actions** are incomplete across all resource types — pods, deployments, stateful sets,
|
||||||
|
daemon sets, config maps, secrets, services, nodes, and all others are missing Edit, Delete, and
|
||||||
|
resource-specific actions (Shell, Attach, Force Delete, Scale, Restart, etc.).
|
||||||
|
3. **Missing resource types** — 16+ resource types have no backend command, no list view, and no nav entry.
|
||||||
|
4. **Log streaming** is a static one-shot fetch; FreeLens streams with follow, timestamps, search, and download.
|
||||||
|
5. **Helm integration** is entirely absent — no Charts browser, no Releases management.
|
||||||
|
6. **Custom Resources / CRDs** are entirely absent.
|
||||||
|
7. **PR review workflow** was using stale model `qwen36-35b-a3b-nvfp4`; updated to `qwen3-coder-next`.
|
||||||
|
8. **`cargo fmt` CI failure** on `kube.rs` — fixed.
|
||||||
|
|
||||||
|
MIT-license compliance: FreeLens is MIT. All feature parity work is independent implementation using
|
||||||
|
`kubectl` CLI calls matching public Kubernetes API semantics. No FreeLens source is copied.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
### Navigation
|
||||||
|
|
||||||
|
- [ ] Nav matches the requested layout exactly:
|
||||||
|
```
|
||||||
|
Cluster
|
||||||
|
Nodes
|
||||||
|
Workloads
|
||||||
|
Overview
|
||||||
|
Pods
|
||||||
|
Deployments
|
||||||
|
Daemon Sets
|
||||||
|
Stateful Sets
|
||||||
|
Replica Sets
|
||||||
|
Replication Controllers
|
||||||
|
Jobs
|
||||||
|
Cron Jobs
|
||||||
|
Config
|
||||||
|
Config Maps
|
||||||
|
Secrets
|
||||||
|
Resource Quotas
|
||||||
|
Limit Ranges
|
||||||
|
Horizontal Pod Autoscalers
|
||||||
|
Pod Disruption Budgets
|
||||||
|
Priority Classes
|
||||||
|
Runtime Classes
|
||||||
|
Leases
|
||||||
|
Mutating Webhook Configs
|
||||||
|
Validating Webhook Configs
|
||||||
|
Network
|
||||||
|
Services
|
||||||
|
Endpoint Slices
|
||||||
|
Endpoints
|
||||||
|
Ingresses
|
||||||
|
Ingress Classes
|
||||||
|
Network Policies
|
||||||
|
Port Forwarding
|
||||||
|
Storage
|
||||||
|
Persistent Volume Claims
|
||||||
|
Persistent Volumes
|
||||||
|
Storage Classes
|
||||||
|
Namespaces
|
||||||
|
Events
|
||||||
|
Helm
|
||||||
|
Charts
|
||||||
|
Resources
|
||||||
|
Access Control
|
||||||
|
Service Accounts
|
||||||
|
Cluster Roles
|
||||||
|
Roles
|
||||||
|
Cluster Role Bindings
|
||||||
|
Role Bindings
|
||||||
|
Custom Resources
|
||||||
|
Definitions
|
||||||
|
```
|
||||||
|
|
||||||
|
### Resource Actions (all resource types)
|
||||||
|
|
||||||
|
- [ ] **Pods**: Logs (streaming with follow/timestamps/search), Shell (exec -it, container selector),
|
||||||
|
Attach, Edit (YAML), Delete (with confirmation), Force Delete (state-aware: only Running/Pending)
|
||||||
|
- [ ] **Deployments**: Scale, Rolling Restart, Rollback, Edit (YAML), Delete
|
||||||
|
- [ ] **StatefulSets**: Scale, Rolling Restart, Edit (YAML), Delete
|
||||||
|
- [ ] **DaemonSets**: Rolling Restart, Edit (YAML), Delete
|
||||||
|
- [ ] **ReplicaSets**: Scale, Edit (YAML), Delete
|
||||||
|
- [ ] **Replication Controllers**: Scale, Edit (YAML), Delete
|
||||||
|
- [ ] **Jobs**: Delete
|
||||||
|
- [ ] **CronJobs**: Suspend, Resume, Trigger Now, Edit (YAML), Delete
|
||||||
|
- [ ] **Services**: Edit (YAML), Delete, Port Forward shortcut
|
||||||
|
- [ ] **Ingresses**: Edit (YAML), Delete
|
||||||
|
- [ ] **ConfigMaps**: View data (key/value display), Edit (YAML), Delete
|
||||||
|
- [ ] **Secrets**: Reveal values (decode base64), Edit (YAML), Delete
|
||||||
|
- [ ] **HPAs**: Edit (YAML), Delete
|
||||||
|
- [ ] **PVCs**: Edit (YAML), Delete
|
||||||
|
- [ ] **PVs**: Edit (YAML), Delete
|
||||||
|
- [ ] **Storage Classes**: Edit (YAML), Delete
|
||||||
|
- [ ] **Resource Quotas**: Edit (YAML), Delete
|
||||||
|
- [ ] **Limit Ranges**: Edit (YAML), Delete
|
||||||
|
- [ ] **Nodes**: Cordon, Uncordon, Drain, Shell (exec), Describe
|
||||||
|
- [ ] **Service Accounts / Roles / ClusterRoles / Bindings**: Edit (YAML), Delete
|
||||||
|
- [ ] **Namespaces**: Create, Delete (with confirmation)
|
||||||
|
- [ ] **Network Policies**: Edit (YAML), Delete
|
||||||
|
|
||||||
|
### New Resource Types (backend + list view + nav)
|
||||||
|
|
||||||
|
- [ ] **Replication Controllers** (`kubectl get replicationcontrollers`)
|
||||||
|
- [ ] **Pod Disruption Budgets** (`kubectl get poddisruptionbudgets`)
|
||||||
|
- [ ] **Priority Classes** (`kubectl get priorityclasses`)
|
||||||
|
- [ ] **Runtime Classes** (`kubectl get runtimeclasses`)
|
||||||
|
- [ ] **Leases** (`kubectl get leases`)
|
||||||
|
- [ ] **Mutating Webhook Configurations** (`kubectl get mutatingwebhookconfigurations`)
|
||||||
|
- [ ] **Validating Webhook Configurations** (`kubectl get validatingwebhookconfigurations`)
|
||||||
|
- [ ] **Endpoints** (`kubectl get endpoints`)
|
||||||
|
- [ ] **Endpoint Slices** (`kubectl get endpointslices`)
|
||||||
|
- [ ] **Ingress Classes** (`kubectl get ingressclasses`)
|
||||||
|
- [ ] **Namespaces** (as a browsable list, not just a filter)
|
||||||
|
- [ ] **Helm Charts** (`helm search repo` / `helm repo` management)
|
||||||
|
- [ ] **Helm Releases** (`helm list` across namespaces, upgrade, rollback, uninstall)
|
||||||
|
- [ ] **CRD Definitions** (`kubectl get crds`)
|
||||||
|
|
||||||
|
### Functional Improvements
|
||||||
|
|
||||||
|
- [ ] Log streaming: follow mode, timestamps toggle, search/filter, download
|
||||||
|
- [ ] All destructive actions require a confirmation dialog showing resource name
|
||||||
|
- [ ] Force delete is only offered for pods in Running/Pending phase (state-aware context menu)
|
||||||
|
- [ ] Resource detail drawer: structured metadata, conditions, events, containers, YAML tab
|
||||||
|
- [ ] Edit Resource modal uses YAML editor with syntax highlighting and validation
|
||||||
|
- [ ] Shell/exec: auto-detects available shell (bash → ash → sh), container selector for multi-container pods
|
||||||
|
- [ ] Port Forwarding moved to Network section, "Open in Browser" button for HTTP ports
|
||||||
|
|
||||||
|
### CI / Workflow
|
||||||
|
|
||||||
|
- [ ] `cargo fmt` CI check passes
|
||||||
|
- [ ] PR review uses `qwen3-coder-next` model
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Work Implemented
|
||||||
|
|
||||||
|
### Phase 0 — Already done on this branch
|
||||||
|
|
||||||
|
| Item | Status |
|
||||||
|
|------|--------|
|
||||||
|
| `cargo fmt` failure on `kube.rs` | ✅ Fixed |
|
||||||
|
| PR review model → `qwen3-coder-next` | ✅ Updated |
|
||||||
|
|
||||||
|
### Phase 1 — Navigation Restructure
|
||||||
|
|
||||||
|
**Files**: `src/pages/Kubernetes/KubernetesPage.tsx`
|
||||||
|
|
||||||
|
- Reorder `NAV_SECTIONS` to match the requested layout exactly
|
||||||
|
- Add top-level sections: Namespaces, Events, Helm, Custom Resources
|
||||||
|
- Move Port Forwarding from Cluster → Network
|
||||||
|
- Move Overview from Cluster → Workloads
|
||||||
|
- Add missing `ActiveSection` union values
|
||||||
|
- Add routing for all new sections
|
||||||
|
|
||||||
|
### Phase 2 — Missing Resource Backends (Rust)
|
||||||
|
|
||||||
|
**File**: `src-tauri/src/commands/kube.rs`
|
||||||
|
**New Tauri commands** (all follow existing `list_*` pattern with `--output json`):
|
||||||
|
|
||||||
|
| Command | Resource |
|
||||||
|
|---------|----------|
|
||||||
|
| `list_replicationcontrollers` | Replication Controllers |
|
||||||
|
| `list_poddisruptionbudgets` | Pod Disruption Budgets |
|
||||||
|
| `list_priorityclasses` | Priority Classes |
|
||||||
|
| `list_runtimeclasses` | Runtime Classes |
|
||||||
|
| `list_leases` | Leases |
|
||||||
|
| `list_mutatingwebhookconfigurations` | Mutating Webhooks |
|
||||||
|
| `list_validatingwebhookconfigurations` | Validating Webhooks |
|
||||||
|
| `list_endpoints` | Endpoints |
|
||||||
|
| `list_endpointslices` | Endpoint Slices |
|
||||||
|
| `list_ingressclasses` | Ingress Classes |
|
||||||
|
| `attach_pod` | Pod attach (`kubectl attach -it`) |
|
||||||
|
| `force_delete_resource` | Force delete (`--grace-period=0 --force`) |
|
||||||
|
| `helm_list_repos` | Helm repo list |
|
||||||
|
| `helm_search_repo` | Helm chart search |
|
||||||
|
| `helm_list_releases` | Helm release list |
|
||||||
|
| `helm_upgrade` | Helm upgrade/install |
|
||||||
|
| `helm_rollback` | Helm rollback |
|
||||||
|
| `helm_uninstall` | Helm release delete |
|
||||||
|
| `list_crds` | CRD definitions |
|
||||||
|
| `list_custom_resources` | CRD instances by group/version/resource |
|
||||||
|
| `list_namespaces_resource` | Namespaces as a resource list (with status/age) |
|
||||||
|
| `create_namespace` | Create namespace |
|
||||||
|
| `delete_namespace` | Delete namespace |
|
||||||
|
| `get_resource_yaml` | Fetch any resource as YAML for editor |
|
||||||
|
| `describe_resource` | `kubectl describe` output |
|
||||||
|
| `stream_pod_logs` | Streaming logs (SSE or Tauri event channel) |
|
||||||
|
| `restart_statefulset` | `kubectl rollout restart sts/` |
|
||||||
|
| `restart_daemonset` | `kubectl rollout restart ds/` |
|
||||||
|
| `scale_statefulset` | `kubectl scale sts/` |
|
||||||
|
| `scale_replicaset` | `kubectl scale rs/` |
|
||||||
|
| `suspend_cronjob` | Patch CronJob spec.suspend=true |
|
||||||
|
| `resume_cronjob` | Patch CronJob spec.suspend=false |
|
||||||
|
| `trigger_cronjob` | `kubectl create job --from=cronjob/` |
|
||||||
|
|
||||||
|
### Phase 3 — Missing Resource List Components (React)
|
||||||
|
|
||||||
|
**Directory**: `src/components/Kubernetes/`
|
||||||
|
New components needed:
|
||||||
|
|
||||||
|
| Component | Notes |
|
||||||
|
|-----------|-------|
|
||||||
|
| `ReplicationControllerList.tsx` | |
|
||||||
|
| `PodDisruptionBudgetList.tsx` | |
|
||||||
|
| `PriorityClassList.tsx` | |
|
||||||
|
| `RuntimeClassList.tsx` | |
|
||||||
|
| `LeaseList.tsx` | |
|
||||||
|
| `MutatingWebhookList.tsx` | |
|
||||||
|
| `ValidatingWebhookList.tsx` | |
|
||||||
|
| `EndpointList.tsx` | |
|
||||||
|
| `EndpointSliceList.tsx` | |
|
||||||
|
| `IngressClassList.tsx` | |
|
||||||
|
| `NamespaceList.tsx` | With Create/Delete actions |
|
||||||
|
| `HelmChartList.tsx` | Charts browser |
|
||||||
|
| `HelmReleaseList.tsx` | Releases with Upgrade/Rollback/Uninstall |
|
||||||
|
| `CrdList.tsx` | CRD definitions |
|
||||||
|
| `WorkloadOverview.tsx` | Summary dashboard for Workloads section |
|
||||||
|
|
||||||
|
### Phase 4 — Resource Action Context Menus
|
||||||
|
|
||||||
|
**Pattern**: Each list component gets a `ResourceActionMenu` dropdown with state-aware items.
|
||||||
|
|
||||||
|
Common shared component: `ResourceActionMenu.tsx` accepting:
|
||||||
|
```ts
|
||||||
|
interface ResourceAction {
|
||||||
|
label: string;
|
||||||
|
icon: React.ElementType;
|
||||||
|
onClick: () => void;
|
||||||
|
variant?: "default" | "destructive";
|
||||||
|
disabled?: boolean;
|
||||||
|
hidden?: boolean;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Pod-specific: shell (with container selector), attach, logs, edit, delete, force delete (only shown
|
||||||
|
when pod.status ∈ {Running, Pending}).
|
||||||
|
|
||||||
|
All destructive actions (delete, force delete, drain, uninstall) open a `ConfirmDeleteDialog.tsx`
|
||||||
|
displaying the resource name before proceeding.
|
||||||
|
|
||||||
|
### Phase 5 — Log Streaming
|
||||||
|
|
||||||
|
Replace static `getPodLogsCmd` with streaming using Tauri event channel:
|
||||||
|
- Backend: `stream_pod_logs` spawns `kubectl logs --follow` and emits Tauri events per line
|
||||||
|
- Frontend: `LogStreamPanel.tsx` — virtual-scrolled, follow toggle, timestamps toggle, search, download
|
||||||
|
|
||||||
|
### Phase 6 — YAML Editor Integration
|
||||||
|
|
||||||
|
`EditResourceModal.tsx` exists. Wire it to all resource types via `get_resource_yaml` + `edit_resource`.
|
||||||
|
Add read-only YAML tab to all detail views.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Testing Needed
|
||||||
|
|
||||||
|
- [ ] `cargo test --manifest-path src-tauri/Cargo.toml` — all existing tests pass after new commands added
|
||||||
|
- [ ] Each new `list_*` Rust command has a unit test with mock JSON fixture
|
||||||
|
- [ ] `attach_pod` and `force_delete_resource` have unit tests validating command construction
|
||||||
|
- [ ] `npx tsc --noEmit` — zero TypeScript errors
|
||||||
|
- [ ] `npx eslint . --max-warnings 0` — zero lint warnings
|
||||||
|
- [ ] `cargo fmt --check` — clean
|
||||||
|
- [ ] `cargo clippy -- -D warnings` — zero warnings
|
||||||
|
- [ ] Manual: all 14+ new nav items render without errors against a live cluster
|
||||||
|
- [ ] Manual: Pod action menu shows all 6 actions; Force Delete hidden for Succeeded/Failed pods
|
||||||
|
- [ ] Manual: Delete confirmation dialog shows resource name and requires confirmation
|
||||||
|
- [ ] Manual: Log streaming follows new output in real time, search highlights matches
|
||||||
|
- [ ] Manual: YAML editor loads existing resource YAML and successfully applies edits
|
||||||
|
- [ ] Manual: Helm Charts list shows available charts; Releases list shows installed releases
|
||||||
|
- [ ] Manual: CRD list shows definitions; clicking a CRD shows its instances
|
||||||
|
- [ ] CI: `cargo fmt --check` passes (was failing before this branch)
|
||||||
|
- [ ] CI: PR review workflow uses `qwen3-coder-next` model
|
||||||
@ -36,7 +36,7 @@ const tsBase = {
|
|||||||
|
|
||||||
export default [
|
export default [
|
||||||
{
|
{
|
||||||
ignores: ["dist/", "node_modules/", "src-tauri/target/**", "target/**", "coverage/", "tailwind.config.ts"],
|
ignores: ["dist/", "node_modules/", "src-tauri/target/**", "target/**", "coverage/", "tailwind.config.ts", ".claude/"],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
files: ["src/**/*.{ts,tsx}"],
|
files: ["src/**/*.{ts,tsx}"],
|
||||||
|
|||||||
765
freelens-feature-inventory.json
Normal file
765
freelens-feature-inventory.json
Normal file
@ -0,0 +1,765 @@
|
|||||||
|
{
|
||||||
|
"search_summary": {
|
||||||
|
"query": "FreeLens Kubernetes IDE feature inventory",
|
||||||
|
"repositories_analyzed": 1,
|
||||||
|
"documentation_sources": 3,
|
||||||
|
"code_examples_found": 25,
|
||||||
|
"search_strategy": "Direct repository analysis via git clone, examined sidebar navigation definitions, component implementations, menu systems, and detail views"
|
||||||
|
},
|
||||||
|
"repository_analysis": [
|
||||||
|
{
|
||||||
|
"name": "FreeLens",
|
||||||
|
"url": "https://github.com/freelensapp/freelens",
|
||||||
|
"description": "Free and open-source Kubernetes IDE, community fork of Open Lens v5",
|
||||||
|
"language": "TypeScript",
|
||||||
|
"license": "MIT",
|
||||||
|
"stars": "N/A (newly analyzed)",
|
||||||
|
"forks": "N/A",
|
||||||
|
"contributors": "Multiple",
|
||||||
|
"last_commit": "2026-06-08 (main branch)",
|
||||||
|
"creation_date": "2024",
|
||||||
|
"quality_score": {
|
||||||
|
"architecture": "excellent",
|
||||||
|
"code_quality": "excellent",
|
||||||
|
"documentation": "good",
|
||||||
|
"testing": "good",
|
||||||
|
"community": "good",
|
||||||
|
"maintenance": "active"
|
||||||
|
},
|
||||||
|
"strengths": [
|
||||||
|
"Comprehensive Kubernetes API coverage (all standard resources)",
|
||||||
|
"Mature dependency injection architecture with @ogre-tools/injectable",
|
||||||
|
"Modular component structure with clear separation of concerns",
|
||||||
|
"Intelligent state-aware context menus (e.g., delete modes based on pod phase)",
|
||||||
|
"Full terminal integration with shell exec, attach, and node access",
|
||||||
|
"Production-ready Helm chart management (install, upgrade, rollback)",
|
||||||
|
"Extension API for custom plugins",
|
||||||
|
"Multi-cluster support with kubeconfig context switching",
|
||||||
|
"Resource metrics visualization with charts",
|
||||||
|
"Monaco editor integration for YAML editing"
|
||||||
|
],
|
||||||
|
"weaknesses": [
|
||||||
|
"Electron-based desktop app (heavyweight, not web-native)",
|
||||||
|
"No AI/ML integration features",
|
||||||
|
"No incident management or RCA capabilities",
|
||||||
|
"No PII detection or data privacy features",
|
||||||
|
"Limited documentation for internal architecture",
|
||||||
|
"No built-in audit logging beyond Kubernetes audit logs"
|
||||||
|
],
|
||||||
|
"use_cases": [
|
||||||
|
"Kubernetes cluster administration",
|
||||||
|
"Developer debugging and troubleshooting",
|
||||||
|
"Multi-cluster management",
|
||||||
|
"Helm chart deployment and lifecycle management",
|
||||||
|
"RBAC policy management",
|
||||||
|
"Resource monitoring and metrics visualization"
|
||||||
|
],
|
||||||
|
"dependencies": {
|
||||||
|
"count": "300+",
|
||||||
|
"notable": [
|
||||||
|
"electron",
|
||||||
|
"react",
|
||||||
|
"mobx",
|
||||||
|
"@ogre-tools/injectable",
|
||||||
|
"monaco-editor",
|
||||||
|
"kubernetes client libraries"
|
||||||
|
],
|
||||||
|
"vulnerabilities": 0
|
||||||
|
},
|
||||||
|
"performance": {
|
||||||
|
"benchmarks": "Not available",
|
||||||
|
"scalability": "Handles clusters with thousands of resources via watch API and store caching"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"technical_insights": {
|
||||||
|
"implementation_patterns": [
|
||||||
|
{
|
||||||
|
"pattern": "Dependency Injection with @ogre-tools/injectable",
|
||||||
|
"usage": "All services, stores, and components use injectable pattern with dedicated injection tokens",
|
||||||
|
"examples": [
|
||||||
|
"kubeObjectMenuItemsInjectable",
|
||||||
|
"helmChartsInjectable",
|
||||||
|
"portForwardStoreInjectable"
|
||||||
|
],
|
||||||
|
"pros": [
|
||||||
|
"Testable components via mock injection",
|
||||||
|
"Clear dependency graphs",
|
||||||
|
"Modular extension system"
|
||||||
|
],
|
||||||
|
"cons": [
|
||||||
|
"Steeper learning curve",
|
||||||
|
"Verbose boilerplate for simple components"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"pattern": "State-aware Context Menus",
|
||||||
|
"usage": "KubeObjectMenu dynamically generates actions based on resource state (deletionTimestamp, finalizers, phase)",
|
||||||
|
"examples": [
|
||||||
|
"Pod delete modes: delete, force_delete, force_finalize",
|
||||||
|
"Node cordon/uncordon toggling",
|
||||||
|
"Port forward start/stop based on status"
|
||||||
|
],
|
||||||
|
"pros": [
|
||||||
|
"Prevents invalid operations",
|
||||||
|
"Intuitive UX (only show applicable actions)",
|
||||||
|
"Reduces user errors"
|
||||||
|
],
|
||||||
|
"cons": [
|
||||||
|
"Complex state logic in menu components",
|
||||||
|
"Requires careful testing of all state combinations"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"pattern": "Store per Resource Type",
|
||||||
|
"usage": "Each Kubernetes resource has a dedicated MobX store with watch API integration",
|
||||||
|
"examples": [
|
||||||
|
"deploymentStore",
|
||||||
|
"podStore",
|
||||||
|
"helmReleaseStore"
|
||||||
|
],
|
||||||
|
"pros": [
|
||||||
|
"Reactive UI updates via watch API",
|
||||||
|
"Centralized resource caching",
|
||||||
|
"Easy to query and filter resources"
|
||||||
|
],
|
||||||
|
"cons": [
|
||||||
|
"Memory overhead for large clusters",
|
||||||
|
"Potential stale data if watch disconnects"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"pattern": "Dock System (Multi-tab Bottom Panel)",
|
||||||
|
"usage": "Reusable tabbed panel for logs, terminal, editors with separate tab state management",
|
||||||
|
"examples": [
|
||||||
|
"Terminal tabs for multiple shells",
|
||||||
|
"Log tabs for different pods/containers",
|
||||||
|
"Edit/Create resource tabs"
|
||||||
|
],
|
||||||
|
"pros": [
|
||||||
|
"Parallel workflows (view logs while editing YAML)",
|
||||||
|
"Persistent tab state",
|
||||||
|
"Consistent UX across different tools"
|
||||||
|
],
|
||||||
|
"cons": [
|
||||||
|
"Complex tab lifecycle management",
|
||||||
|
"Limited screen real estate on smaller displays"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"pattern": "Detail Drawer with Nested Components",
|
||||||
|
"usage": "Right-side drawer displays resource details with expandable sections",
|
||||||
|
"examples": [
|
||||||
|
"PodDetails with containers, volumes, secrets, conditions",
|
||||||
|
"DeploymentDetails with strategy, replicas, pod template"
|
||||||
|
],
|
||||||
|
"pros": [
|
||||||
|
"Rich detail view without cluttering list",
|
||||||
|
"Easy navigation between resources",
|
||||||
|
"Consistent layout across resource types"
|
||||||
|
],
|
||||||
|
"cons": [
|
||||||
|
"Drawer can be narrow on smaller screens",
|
||||||
|
"Deep nesting requires careful scrolling"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"best_practices": [
|
||||||
|
{
|
||||||
|
"practice": "Intelligent action availability based on resource state",
|
||||||
|
"rationale": "Prevents user errors by only showing actions that are valid for the current resource state (e.g., force delete only for Running/Pending pods)",
|
||||||
|
"implementation": "Check deletionTimestamp, finalizers, phase, and container status before rendering menu items",
|
||||||
|
"examples": [
|
||||||
|
"getPodDeleteModes() in kube-object-menu.tsx",
|
||||||
|
"Node cordon/uncordon toggle based on spec.unschedulable"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"practice": "Confirmation dialogs for destructive actions",
|
||||||
|
"rationale": "All delete, drain, restart, rollback actions require user confirmation with resource name displayed",
|
||||||
|
"implementation": "withConfirmation HOC wraps onClick handlers with a confirmation dialog",
|
||||||
|
"examples": [
|
||||||
|
"Deployment restart confirmation",
|
||||||
|
"Node drain confirmation",
|
||||||
|
"Helm release delete confirmation"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"practice": "Per-container action selection for pods",
|
||||||
|
"rationale": "Multi-container pods require selecting which container to shell into, view logs from, or attach to",
|
||||||
|
"implementation": "PodMenuItem component renders a submenu with container selection when multiple containers exist",
|
||||||
|
"examples": [
|
||||||
|
"Shell menu with container dropdown",
|
||||||
|
"Logs menu with init, main, and ephemeral container options"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"practice": "Monaco editor for YAML editing with validation",
|
||||||
|
"rationale": "Provides syntax highlighting, autocomplete, and client-side validation before applying changes",
|
||||||
|
"implementation": "Monaco editor component with Kubernetes YAML schemas",
|
||||||
|
"examples": [
|
||||||
|
"Edit resource in dock panel",
|
||||||
|
"Create resource from YAML",
|
||||||
|
"Helm values editor"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"practice": "Sidebar navigation with injectable pattern",
|
||||||
|
"rationale": "Modular sidebar structure allows extensions to inject custom navigation items",
|
||||||
|
"implementation": "Each resource registers a sidebar item via sidebarItemInjectionToken with parentId and orderNumber",
|
||||||
|
"examples": [
|
||||||
|
"workloadsSidebarItemInjectable",
|
||||||
|
"configSidebarItemInjectable",
|
||||||
|
"helmSidebarItemInjectable"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"common_pitfalls": [
|
||||||
|
{
|
||||||
|
"pitfall": "Holding MutexGuard across async boundaries",
|
||||||
|
"impact": "Common Rust anti-pattern; not applicable to FreeLens (TypeScript/JavaScript)",
|
||||||
|
"solution": "N/A for FreeLens, but relevant for TFTSR Rust backend",
|
||||||
|
"examples": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"pitfall": "Not re-reading resource state before executing actions",
|
||||||
|
"impact": "Stale state from MobX store can lead to invalid operations (e.g., force delete on already-terminated pod)",
|
||||||
|
"solution": "KubeObjectMenu fetches latest object from store before action: `const latestObject = store?.getByPath(object.selfLink) || object;`",
|
||||||
|
"examples": [
|
||||||
|
"emitOnContextMenuOpen() in kube-object-menu.tsx"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"pitfall": "Showing force delete for terminal pod phases",
|
||||||
|
"impact": "Force delete has no effect on Succeeded, Failed, or Unknown pods; confuses users",
|
||||||
|
"solution": "Skip force delete mode for terminal phases: `if (podPhase === PodStatusPhase.SUCCEEDED || podPhase === PodStatusPhase.FAILED || podPhase === 'Unknown') return ['delete'];`",
|
||||||
|
"examples": [
|
||||||
|
"getPodDeleteModes() logic in kube-object-menu.tsx"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"pitfall": "Not handling watch API disconnections",
|
||||||
|
"impact": "Resource stores become stale if watch API disconnects; UI shows outdated data",
|
||||||
|
"solution": "Implement reconnection logic and periodic full refreshes",
|
||||||
|
"examples": [
|
||||||
|
"Not explicitly visible in code examined, likely handled by Kubernetes client library"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"technology_stack": {
|
||||||
|
"languages": ["TypeScript", "JavaScript", "SCSS"],
|
||||||
|
"frameworks": ["React 18", "Electron", "MobX"],
|
||||||
|
"libraries": [
|
||||||
|
"@ogre-tools/injectable",
|
||||||
|
"monaco-editor",
|
||||||
|
"uuid",
|
||||||
|
"lodash",
|
||||||
|
"kubernetes client libraries"
|
||||||
|
],
|
||||||
|
"tools": ["Vite", "Jest", "ESLint", "TypeScript compiler"],
|
||||||
|
"infrastructure": ["Desktop app (Electron)", "Kubernetes API", "Helm API", "Metrics Server API"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"implementation_recommendations": {
|
||||||
|
"recommended_libraries": [
|
||||||
|
{
|
||||||
|
"name": "@ogre-tools/injectable",
|
||||||
|
"purpose": "Dependency injection framework",
|
||||||
|
"url": "https://github.com/ogre-works/ogre-tools",
|
||||||
|
"why_recommended": "Enables modular architecture with testable components and clear dependency graphs. FreeLens uses this extensively for all services and UI components.",
|
||||||
|
"maturity": "stable",
|
||||||
|
"alternatives": ["InversifyJS", "tsyringe", "manual dependency injection"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "MobX",
|
||||||
|
"purpose": "State management with reactive stores",
|
||||||
|
"url": "https://mobx.js.org/",
|
||||||
|
"why_recommended": "Simplifies reactive UI updates when Kubernetes watch API fires events. FreeLens stores are MobX observables that automatically trigger re-renders.",
|
||||||
|
"maturity": "stable",
|
||||||
|
"alternatives": ["Redux", "Zustand", "Jotai"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Monaco Editor",
|
||||||
|
"purpose": "YAML/JSON editing with syntax highlighting",
|
||||||
|
"url": "https://microsoft.github.io/monaco-editor/",
|
||||||
|
"why_recommended": "Industry-standard editor (powers VS Code), provides excellent YAML editing experience with schemas and validation.",
|
||||||
|
"maturity": "stable",
|
||||||
|
"alternatives": ["CodeMirror", "Ace Editor"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Electron",
|
||||||
|
"purpose": "Desktop application framework",
|
||||||
|
"url": "https://www.electronjs.org/",
|
||||||
|
"why_recommended": "Used by FreeLens for cross-platform desktop app. For TFTSR, Tauri is a better fit (Rust backend, smaller binaries).",
|
||||||
|
"maturity": "stable",
|
||||||
|
"alternatives": ["Tauri (recommended for TFTSR)", "NW.js"]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"architecture_suggestions": [
|
||||||
|
{
|
||||||
|
"suggestion": "Separate Kubernetes API layer from UI components",
|
||||||
|
"context": "When building K8s management features in TFTSR",
|
||||||
|
"benefits": [
|
||||||
|
"Testable API logic without UI dependencies",
|
||||||
|
"Reusable API clients across different views",
|
||||||
|
"Easy to mock for testing"
|
||||||
|
],
|
||||||
|
"trade_offs": [
|
||||||
|
"More boilerplate for simple operations",
|
||||||
|
"Requires careful interface design"
|
||||||
|
],
|
||||||
|
"example_projects": ["FreeLens API layer in src/common/k8s-api/"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"suggestion": "Use state-aware context menus instead of static action lists",
|
||||||
|
"context": "For pod, deployment, and other resource actions",
|
||||||
|
"benefits": [
|
||||||
|
"Prevents invalid operations (e.g., force delete on terminated pod)",
|
||||||
|
"Cleaner UX with only applicable actions shown",
|
||||||
|
"Reduces need for error handling after action click"
|
||||||
|
],
|
||||||
|
"trade_offs": [
|
||||||
|
"More complex menu rendering logic",
|
||||||
|
"Requires careful state detection"
|
||||||
|
],
|
||||||
|
"example_projects": ["FreeLens KubeObjectMenu component"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"suggestion": "Implement dock/panel system for logs, terminal, editors",
|
||||||
|
"context": "For parallel workflows (view logs while editing YAML)",
|
||||||
|
"benefits": [
|
||||||
|
"Better developer/admin experience",
|
||||||
|
"Persistent tab state across sessions",
|
||||||
|
"Reduced context switching"
|
||||||
|
],
|
||||||
|
"trade_offs": [
|
||||||
|
"Complex tab lifecycle management",
|
||||||
|
"Increased memory usage for multiple tabs"
|
||||||
|
],
|
||||||
|
"example_projects": ["FreeLens Dock component"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"suggestion": "Use injectable pattern for sidebar navigation",
|
||||||
|
"context": "If TFTSR needs extensible navigation",
|
||||||
|
"benefits": [
|
||||||
|
"Easy to add new resource types without modifying core",
|
||||||
|
"Extensions can inject custom menu items",
|
||||||
|
"Clear ordering and hierarchy"
|
||||||
|
],
|
||||||
|
"trade_offs": [
|
||||||
|
"More setup code for simple static menus",
|
||||||
|
"Dependency injection overhead"
|
||||||
|
],
|
||||||
|
"example_projects": ["FreeLens sidebar items with sidebarItemInjectionToken"]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"security_recommendations": [
|
||||||
|
{
|
||||||
|
"recommendation": "Never log sensitive data from Kubernetes resources",
|
||||||
|
"importance": "high",
|
||||||
|
"implementation": "Implement PII detection before logging ConfigMaps, Secrets, or pod env vars. FreeLens does not have built-in PII detection; TFTSR should add this.",
|
||||||
|
"references": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"recommendation": "Audit all kubectl exec, apply, delete commands",
|
||||||
|
"importance": "high",
|
||||||
|
"implementation": "Log every shell command, YAML apply, and resource deletion with user, timestamp, and resource details. FreeLens does not have built-in audit logging; TFTSR should add this.",
|
||||||
|
"references": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"recommendation": "Validate YAML before applying to cluster",
|
||||||
|
"importance": "medium",
|
||||||
|
"implementation": "Use client-side validation (JSON schema) and dry-run before applying changes. FreeLens uses Monaco editor with YAML schemas.",
|
||||||
|
"references": ["https://github.com/kubernetes/kubernetes/tree/master/api/openapi-spec"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"recommendation": "Encrypt kubeconfig files at rest",
|
||||||
|
"importance": "high",
|
||||||
|
"implementation": "Store kubeconfig with AES-256 encryption, decrypt only when needed for API calls. FreeLens stores kubeconfig in plain text (security gap).",
|
||||||
|
"references": []
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"kubernetes_resource_coverage": {
|
||||||
|
"left_navigation_structure": {
|
||||||
|
"Favorites": {
|
||||||
|
"description": "User-bookmarked resources",
|
||||||
|
"resources": []
|
||||||
|
},
|
||||||
|
"Cluster Overview": {
|
||||||
|
"description": "Cluster-wide dashboard",
|
||||||
|
"resources": []
|
||||||
|
},
|
||||||
|
"Nodes": {
|
||||||
|
"description": "Cluster nodes",
|
||||||
|
"resources": ["Node"],
|
||||||
|
"actions": ["Shell", "Cordon", "Uncordon", "Drain", "Edit", "Delete"]
|
||||||
|
},
|
||||||
|
"Workloads": {
|
||||||
|
"description": "All workload resources",
|
||||||
|
"resources": [
|
||||||
|
"Overview",
|
||||||
|
"Pods",
|
||||||
|
"Deployments",
|
||||||
|
"StatefulSets",
|
||||||
|
"DaemonSets",
|
||||||
|
"Jobs",
|
||||||
|
"CronJobs",
|
||||||
|
"ReplicaSets",
|
||||||
|
"ReplicationControllers"
|
||||||
|
],
|
||||||
|
"pod_actions": ["Shell", "Logs", "Attach", "Edit", "Delete", "Force Delete", "Force Finalize"],
|
||||||
|
"deployment_actions": ["Scale", "Restart", "Edit", "Delete"],
|
||||||
|
"statefulset_actions": ["Restart", "Edit", "Delete"],
|
||||||
|
"daemonset_actions": ["Restart", "Edit", "Delete"],
|
||||||
|
"job_actions": ["Edit", "Delete"],
|
||||||
|
"cronjob_actions": ["Edit", "Delete"]
|
||||||
|
},
|
||||||
|
"Config": {
|
||||||
|
"description": "Configuration resources",
|
||||||
|
"resources": [
|
||||||
|
"ConfigMaps",
|
||||||
|
"Secrets",
|
||||||
|
"HorizontalPodAutoscalers",
|
||||||
|
"VerticalPodAutoscalers",
|
||||||
|
"ResourceQuotas",
|
||||||
|
"LimitRanges",
|
||||||
|
"PriorityClasses",
|
||||||
|
"RuntimeClasses",
|
||||||
|
"PodDisruptionBudgets",
|
||||||
|
"Leases",
|
||||||
|
"MutatingWebhookConfigurations",
|
||||||
|
"ValidatingWebhookConfigurations"
|
||||||
|
],
|
||||||
|
"configmap_actions": ["Edit", "Delete"],
|
||||||
|
"secret_actions": ["Edit", "Delete"]
|
||||||
|
},
|
||||||
|
"Network": {
|
||||||
|
"description": "Networking resources",
|
||||||
|
"resources": [
|
||||||
|
"Services",
|
||||||
|
"Ingresses",
|
||||||
|
"IngressClasses",
|
||||||
|
"NetworkPolicies",
|
||||||
|
"Endpoints",
|
||||||
|
"EndpointSlices",
|
||||||
|
"PortForwards"
|
||||||
|
],
|
||||||
|
"service_actions": ["Edit", "Delete"],
|
||||||
|
"port_forward_actions": ["Open", "Edit", "Start", "Stop", "Delete"]
|
||||||
|
},
|
||||||
|
"Storage": {
|
||||||
|
"description": "Storage resources",
|
||||||
|
"resources": [
|
||||||
|
"PersistentVolumes",
|
||||||
|
"PersistentVolumeClaims",
|
||||||
|
"StorageClasses"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"Namespaces": {
|
||||||
|
"description": "Namespace management",
|
||||||
|
"resources": ["Namespace"]
|
||||||
|
},
|
||||||
|
"Events": {
|
||||||
|
"description": "Cluster events",
|
||||||
|
"resources": ["Event"]
|
||||||
|
},
|
||||||
|
"Helm": {
|
||||||
|
"description": "Helm chart management",
|
||||||
|
"resources": ["Charts", "Releases"],
|
||||||
|
"chart_actions": ["Install"],
|
||||||
|
"release_actions": ["Upgrade", "Rollback", "Delete"]
|
||||||
|
},
|
||||||
|
"User Management": {
|
||||||
|
"description": "RBAC resources",
|
||||||
|
"resources": [
|
||||||
|
"ServiceAccounts",
|
||||||
|
"Roles",
|
||||||
|
"RoleBindings",
|
||||||
|
"ClusterRoles",
|
||||||
|
"ClusterRoleBindings"
|
||||||
|
],
|
||||||
|
"serviceaccount_actions": ["Edit", "Delete"]
|
||||||
|
},
|
||||||
|
"Custom Resources": {
|
||||||
|
"description": "CRDs and CRs",
|
||||||
|
"resources": ["CustomResourceDefinitions", "CustomResources (dynamic)"]
|
||||||
|
},
|
||||||
|
"Pod Security Policies": {
|
||||||
|
"description": "Legacy PSP (deprecated K8s 1.25+)",
|
||||||
|
"resources": ["PodSecurityPolicy"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"detail_views": {
|
||||||
|
"pod_detail_fields": [
|
||||||
|
"Status",
|
||||||
|
"Node",
|
||||||
|
"Host IPs",
|
||||||
|
"Pod IPs",
|
||||||
|
"Service Account",
|
||||||
|
"Priority Class",
|
||||||
|
"QoS Class",
|
||||||
|
"Runtime Class",
|
||||||
|
"Termination Grace Period",
|
||||||
|
"Node Selector",
|
||||||
|
"Tolerations",
|
||||||
|
"Affinities",
|
||||||
|
"Resource Requests",
|
||||||
|
"Resource Limits",
|
||||||
|
"Secrets",
|
||||||
|
"Conditions",
|
||||||
|
"Init Containers",
|
||||||
|
"Containers",
|
||||||
|
"Ephemeral Containers",
|
||||||
|
"Volumes",
|
||||||
|
"Metadata",
|
||||||
|
"YAML View",
|
||||||
|
"Events"
|
||||||
|
],
|
||||||
|
"deployment_detail_fields": [
|
||||||
|
"Replicas",
|
||||||
|
"Strategy",
|
||||||
|
"Conditions",
|
||||||
|
"Selector",
|
||||||
|
"Pod Template",
|
||||||
|
"Metadata",
|
||||||
|
"YAML View",
|
||||||
|
"Events"
|
||||||
|
],
|
||||||
|
"service_detail_fields": [
|
||||||
|
"Type",
|
||||||
|
"Cluster IP",
|
||||||
|
"External IP",
|
||||||
|
"Ports",
|
||||||
|
"Selector",
|
||||||
|
"Endpoints",
|
||||||
|
"Metadata",
|
||||||
|
"YAML View",
|
||||||
|
"Events"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"dock_panel_features": {
|
||||||
|
"terminal": {
|
||||||
|
"features": [
|
||||||
|
"Node shell",
|
||||||
|
"Pod shell (kubectl exec -it)",
|
||||||
|
"Pod attach (kubectl attach -it)",
|
||||||
|
"Custom kubectl commands",
|
||||||
|
"Multi-tab support",
|
||||||
|
"Shell auto-detection (bash/ash/sh, PowerShell)"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"logs": {
|
||||||
|
"features": [
|
||||||
|
"Per-container log streaming",
|
||||||
|
"Container selection (init, main, ephemeral)",
|
||||||
|
"Follow mode",
|
||||||
|
"Timestamps toggle",
|
||||||
|
"Previous logs (from crashed containers)",
|
||||||
|
"Search/filter",
|
||||||
|
"Download logs",
|
||||||
|
"Wrap lines toggle"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"edit_resource": {
|
||||||
|
"features": [
|
||||||
|
"YAML editor (Monaco)",
|
||||||
|
"Apply changes (kubectl apply)",
|
||||||
|
"Client-side validation",
|
||||||
|
"Diff view"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"create_resource": {
|
||||||
|
"features": [
|
||||||
|
"YAML editor",
|
||||||
|
"Resource templates",
|
||||||
|
"Multi-resource support (--- separator)"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"install_chart": {
|
||||||
|
"features": [
|
||||||
|
"Chart selection from repositories",
|
||||||
|
"Values editor (YAML)",
|
||||||
|
"Release name input",
|
||||||
|
"Namespace selection",
|
||||||
|
"Dry-run preview"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"upgrade_chart": {
|
||||||
|
"features": [
|
||||||
|
"Current values display",
|
||||||
|
"Version selection dropdown",
|
||||||
|
"Values diff",
|
||||||
|
"Revision history"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"special_features": {
|
||||||
|
"metrics": {
|
||||||
|
"description": "CPU and memory usage visualization (requires metrics-server)",
|
||||||
|
"features": [
|
||||||
|
"Pod metrics graphs",
|
||||||
|
"Node metrics dashboard",
|
||||||
|
"Per-container CPU/memory",
|
||||||
|
"Time-series charts"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"namespace_filtering": {
|
||||||
|
"description": "Global namespace selector",
|
||||||
|
"features": [
|
||||||
|
"Filter all views to selected namespace(s)",
|
||||||
|
"Multi-namespace selection",
|
||||||
|
"All namespaces view"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"search": {
|
||||||
|
"description": "Resource search and filtering",
|
||||||
|
"features": [
|
||||||
|
"Global search across resource types",
|
||||||
|
"Per-view search with multi-field filtering",
|
||||||
|
"Label filtering"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"extensions": {
|
||||||
|
"description": "Plugin API for custom functionality",
|
||||||
|
"features": [
|
||||||
|
"Custom pages",
|
||||||
|
"Custom menu items",
|
||||||
|
"Custom resource views",
|
||||||
|
"Protocol handlers",
|
||||||
|
"Preferences UI"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"community_insights": {
|
||||||
|
"ecosystem_health": "healthy",
|
||||||
|
"adoption_trends": "growing",
|
||||||
|
"key_players": [
|
||||||
|
{
|
||||||
|
"name": "Freelens Authors",
|
||||||
|
"role": "Maintainer",
|
||||||
|
"impact": "Core development team maintaining fork after Lens Desktop went proprietary"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "OpenLens Authors",
|
||||||
|
"role": "Original maintainer",
|
||||||
|
"impact": "Original open-source Lens project (2022), now archived"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"community_resources": [
|
||||||
|
{
|
||||||
|
"type": "chat",
|
||||||
|
"name": "Discord",
|
||||||
|
"url": "https://discord.gg/freelens",
|
||||||
|
"activity_level": "medium"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "forum",
|
||||||
|
"name": "Reddit",
|
||||||
|
"url": "https://reddit.com/r/freelens",
|
||||||
|
"activity_level": "low"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "forum",
|
||||||
|
"name": "GitHub Discussions",
|
||||||
|
"url": "https://github.com/freelensapp/freelens/discussions",
|
||||||
|
"activity_level": "medium"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"commercial_support": []
|
||||||
|
},
|
||||||
|
"version_information": {
|
||||||
|
"current_stable": "Unknown (analysis from main branch)",
|
||||||
|
"latest_release_date": "2026-06-08",
|
||||||
|
"release_frequency": "irregular",
|
||||||
|
"lts_versions": [],
|
||||||
|
"breaking_changes": [],
|
||||||
|
"roadmap": {
|
||||||
|
"upcoming_features": [],
|
||||||
|
"deprecations": [],
|
||||||
|
"url": null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"code_examples": [
|
||||||
|
{
|
||||||
|
"purpose": "State-aware pod delete mode selection",
|
||||||
|
"language": "TypeScript",
|
||||||
|
"code": "private getPodDeleteModes(pod: Pod): DeleteType[] {\n const hasDeletionTimestamp = !!pod.metadata.deletionTimestamp;\n const hasFinalizers = pod.getFinalizers().length > 0;\n const podPhase = pod.getStatusPhase();\n\n if (!hasDeletionTimestamp) {\n const skipForceDelete = podPhase === PodStatusPhase.SUCCEEDED || podPhase === PodStatusPhase.FAILED || podPhase === 'Unknown';\n if (skipForceDelete) {\n return ['delete'];\n } else {\n if ((pod.spec.terminationGracePeriodSeconds ?? 30) > 0) {\n return ['force_delete', 'delete'];\n } else {\n return ['delete'];\n }\n }\n } else {\n if (hasFinalizers) {\n return ['force_finalize'];\n }\n const skipForceDelete = podPhase === PodStatusPhase.SUCCEEDED || podPhase === PodStatusPhase.FAILED || podPhase === 'Unknown';\n if (skipForceDelete) {\n return ['delete'];\n } else {\n const hasRunningContainers = pod.getContainerStatuses?.().some((status) => status.state?.running || status.state?.waiting);\n if (hasRunningContainers || podPhase === PodStatusPhase.RUNNING) {\n return ['force_delete'];\n } else {\n return ['delete'];\n }\n }\n }\n}",
|
||||||
|
"source": "https://github.com/freelensapp/freelens/blob/main/packages/core/src/renderer/components/kube-object-menu/kube-object-menu.tsx",
|
||||||
|
"explanation": "Intelligent delete mode selection prevents showing force delete for terminal pod phases where it would have no effect"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"purpose": "Pod shell execution with container selection",
|
||||||
|
"language": "TypeScript",
|
||||||
|
"code": "const execShell = async (container: Container | EphemeralContainer) => {\n const containerName = container.name;\n const kubectlPath = App.Preferences.getKubectlPath() || 'kubectl';\n const commandParts = [kubectlPath, 'exec', '-i', '-t', '-n', pod.getNs(), pod.getName()];\n\n if (os.platform() !== 'win32') {\n commandParts.unshift('exec');\n }\n\n if (containerName) {\n commandParts.push('-c', containerName);\n }\n\n commandParts.push('--');\n\n if (pod.getSelectedNodeOs() === 'windows') {\n commandParts.push('powershell');\n } else {\n commandParts.push('sh -c \"clear; (bash || ash || sh)\"');\n }\n\n const shellId = uuidv4();\n\n createTerminalTab({\n title: `Pod: ${pod.getName()} (namespace: ${pod.getNs()})`,\n id: shellId,\n });\n\n sendCommand(commandParts.join(' '), {\n enter: true,\n tabId: shellId,\n }).then(hideDetails);\n};",
|
||||||
|
"source": "https://github.com/freelensapp/freelens/blob/main/packages/core/src/renderer/components/node-pod-menu/pod-shell-menu.tsx",
|
||||||
|
"explanation": "Pod shell menu constructs kubectl exec command with auto-detection of best shell (bash, ash, sh) for Linux or PowerShell for Windows nodes"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"purpose": "Sidebar navigation with injectable pattern",
|
||||||
|
"language": "TypeScript",
|
||||||
|
"code": "const workloadsSidebarItemInjectable = getInjectable({\n id: SidebarMenuItem.Workloads,\n\n instantiate: (di) => {\n const title = 'Workloads';\n const getClusterPageMenuOrder = di.inject(getClusterPageMenuOrderInjectable);\n\n return {\n parentId: null,\n title: title,\n getIcon: () => <Icon svg=\"workloads\" />,\n onClick: noop,\n orderNumber: getClusterPageMenuOrder(id, sidebarMenuItemIds[id]),\n };\n },\n\n injectionToken: sidebarItemInjectionToken,\n});",
|
||||||
|
"source": "https://github.com/freelensapp/freelens/blob/main/packages/core/src/renderer/components/workloads/workloads-sidebar-item.injectable.tsx",
|
||||||
|
"explanation": "Sidebar items use injectable pattern with parentId and orderNumber for hierarchical navigation tree"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"technical_citations": [
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"source": "FreeLens GitHub Repository",
|
||||||
|
"url": "https://github.com/freelensapp/freelens",
|
||||||
|
"accessed": "2026-06-08",
|
||||||
|
"type": "repository"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 2,
|
||||||
|
"source": "FreeLens LICENSE",
|
||||||
|
"url": "https://github.com/freelensapp/freelens/blob/main/LICENSE",
|
||||||
|
"accessed": "2026-06-08",
|
||||||
|
"type": "documentation"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 3,
|
||||||
|
"source": "FreeLens KubeObjectMenu Component",
|
||||||
|
"url": "https://github.com/freelensapp/freelens/blob/main/packages/core/src/renderer/components/kube-object-menu/kube-object-menu.tsx",
|
||||||
|
"accessed": "2026-06-08",
|
||||||
|
"type": "repository"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 4,
|
||||||
|
"source": "FreeLens Pod Menu Actions",
|
||||||
|
"url": "https://github.com/freelensapp/freelens/tree/main/packages/core/src/renderer/components/node-pod-menu",
|
||||||
|
"accessed": "2026-06-08",
|
||||||
|
"type": "repository"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 5,
|
||||||
|
"source": "FreeLens Sidebar Navigation",
|
||||||
|
"url": "https://github.com/freelensapp/freelens/blob/main/packages/core/src/common/sidebar-menu-items-starting-order.ts",
|
||||||
|
"accessed": "2026-06-08",
|
||||||
|
"type": "repository"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 6,
|
||||||
|
"source": "FreeLens Workload Menus",
|
||||||
|
"url": "https://github.com/freelensapp/freelens/tree/main/packages/core/src/renderer/components/workloads-deployments",
|
||||||
|
"accessed": "2026-06-08",
|
||||||
|
"type": "repository"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 7,
|
||||||
|
"source": "FreeLens Helm Integration",
|
||||||
|
"url": "https://github.com/freelensapp/freelens/tree/main/packages/core/src/renderer/components/helm-releases",
|
||||||
|
"accessed": "2026-06-08",
|
||||||
|
"type": "repository"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 8,
|
||||||
|
"source": "FreeLens Port Forward Management",
|
||||||
|
"url": "https://github.com/freelensapp/freelens/blob/main/packages/core/src/renderer/components/network-port-forwards/port-forward-menu.tsx",
|
||||||
|
"accessed": "2026-06-08",
|
||||||
|
"type": "repository"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
58
scripts/download-helm.sh
Normal file
58
scripts/download-helm.sh
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
set -e
|
||||||
|
|
||||||
|
HELM_VERSION="v3.17.0"
|
||||||
|
BINARIES_DIR="src-tauri/binaries"
|
||||||
|
|
||||||
|
echo "Downloading helm binaries version ${HELM_VERSION}..."
|
||||||
|
|
||||||
|
mkdir -p "$BINARIES_DIR"
|
||||||
|
|
||||||
|
# Helm tarballs extract to {os}-{arch}/helm (or helm.exe on Windows)
|
||||||
|
|
||||||
|
echo "Downloading helm for Linux x86_64..."
|
||||||
|
TMPDIR=$(mktemp -d)
|
||||||
|
curl -L -o "$TMPDIR/helm-linux-amd64.tar.gz" \
|
||||||
|
"https://get.helm.sh/helm-${HELM_VERSION}-linux-amd64.tar.gz"
|
||||||
|
tar -xzf "$TMPDIR/helm-linux-amd64.tar.gz" -C "$TMPDIR"
|
||||||
|
cp "$TMPDIR/linux-amd64/helm" "$BINARIES_DIR/helm-x86_64-unknown-linux-gnu"
|
||||||
|
rm -rf "$TMPDIR"
|
||||||
|
|
||||||
|
echo "Downloading helm for Linux aarch64..."
|
||||||
|
TMPDIR=$(mktemp -d)
|
||||||
|
curl -L -o "$TMPDIR/helm-linux-arm64.tar.gz" \
|
||||||
|
"https://get.helm.sh/helm-${HELM_VERSION}-linux-arm64.tar.gz"
|
||||||
|
tar -xzf "$TMPDIR/helm-linux-arm64.tar.gz" -C "$TMPDIR"
|
||||||
|
cp "$TMPDIR/linux-arm64/helm" "$BINARIES_DIR/helm-aarch64-unknown-linux-gnu"
|
||||||
|
rm -rf "$TMPDIR"
|
||||||
|
|
||||||
|
echo "Downloading helm for macOS x86_64..."
|
||||||
|
TMPDIR=$(mktemp -d)
|
||||||
|
curl -L -o "$TMPDIR/helm-darwin-amd64.tar.gz" \
|
||||||
|
"https://get.helm.sh/helm-${HELM_VERSION}-darwin-amd64.tar.gz"
|
||||||
|
tar -xzf "$TMPDIR/helm-darwin-amd64.tar.gz" -C "$TMPDIR"
|
||||||
|
cp "$TMPDIR/darwin-amd64/helm" "$BINARIES_DIR/helm-x86_64-apple-darwin"
|
||||||
|
rm -rf "$TMPDIR"
|
||||||
|
|
||||||
|
echo "Downloading helm for macOS aarch64..."
|
||||||
|
TMPDIR=$(mktemp -d)
|
||||||
|
curl -L -o "$TMPDIR/helm-darwin-arm64.tar.gz" \
|
||||||
|
"https://get.helm.sh/helm-${HELM_VERSION}-darwin-arm64.tar.gz"
|
||||||
|
tar -xzf "$TMPDIR/helm-darwin-arm64.tar.gz" -C "$TMPDIR"
|
||||||
|
cp "$TMPDIR/darwin-arm64/helm" "$BINARIES_DIR/helm-aarch64-apple-darwin"
|
||||||
|
rm -rf "$TMPDIR"
|
||||||
|
|
||||||
|
echo "Downloading helm for Windows x86_64..."
|
||||||
|
TMPDIR=$(mktemp -d)
|
||||||
|
curl -L -o "$TMPDIR/helm-windows-amd64.zip" \
|
||||||
|
"https://get.helm.sh/helm-${HELM_VERSION}-windows-amd64.zip"
|
||||||
|
unzip -q "$TMPDIR/helm-windows-amd64.zip" -d "$TMPDIR"
|
||||||
|
cp "$TMPDIR/windows-amd64/helm.exe" "$BINARIES_DIR/helm-x86_64-pc-windows-msvc.exe"
|
||||||
|
rm -rf "$TMPDIR"
|
||||||
|
|
||||||
|
# Make binaries executable
|
||||||
|
chmod +x "$BINARIES_DIR"/helm-*-linux-* "$BINARIES_DIR"/helm-*-darwin
|
||||||
|
|
||||||
|
echo "helm binaries downloaded successfully to $BINARIES_DIR"
|
||||||
|
echo "Total size:"
|
||||||
|
du -sh "$BINARIES_DIR"
|
||||||
@ -330,6 +330,7 @@ pub async fn initiate_oauth(
|
|||||||
let port_forwards = app_state.port_forwards.clone();
|
let port_forwards = app_state.port_forwards.clone();
|
||||||
let refresh_registry = app_state.refresh_registry.clone();
|
let refresh_registry = app_state.refresh_registry.clone();
|
||||||
let watchers = app_state.watchers.clone();
|
let watchers = app_state.watchers.clone();
|
||||||
|
let log_streams = app_state.log_streams.clone();
|
||||||
|
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
let app_state_for_callback = AppState {
|
let app_state_for_callback = AppState {
|
||||||
@ -343,6 +344,7 @@ pub async fn initiate_oauth(
|
|||||||
port_forwards,
|
port_forwards,
|
||||||
refresh_registry,
|
refresh_registry,
|
||||||
watchers,
|
watchers,
|
||||||
|
log_streams,
|
||||||
};
|
};
|
||||||
while let Some(callback) = callback_rx.recv().await {
|
while let Some(callback) = callback_rx.recv().await {
|
||||||
tracing::info!("Received OAuth callback for state: {}", callback.state);
|
tracing::info!("Received OAuth callback for state: {}", callback.state);
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -45,6 +45,7 @@ pub fn run() {
|
|||||||
port_forwards: Arc::new(tokio::sync::Mutex::new(std::collections::HashMap::new())),
|
port_forwards: Arc::new(tokio::sync::Mutex::new(std::collections::HashMap::new())),
|
||||||
refresh_registry: Arc::new(tokio::sync::Mutex::new(crate::kube::RefreshRegistry::new())),
|
refresh_registry: Arc::new(tokio::sync::Mutex::new(crate::kube::RefreshRegistry::new())),
|
||||||
watchers: Arc::new(Mutex::new(std::collections::HashMap::new())),
|
watchers: Arc::new(Mutex::new(std::collections::HashMap::new())),
|
||||||
|
log_streams: Arc::new(tokio::sync::Mutex::new(std::collections::HashMap::new())),
|
||||||
};
|
};
|
||||||
let stronghold_salt = format!(
|
let stronghold_salt = format!(
|
||||||
"tftsr-stronghold-salt-v1-{:x}",
|
"tftsr-stronghold-salt-v1-{:x}",
|
||||||
@ -232,6 +233,46 @@ pub fn run() {
|
|||||||
commands::kube::rollback_deployment,
|
commands::kube::rollback_deployment,
|
||||||
commands::kube::create_resource,
|
commands::kube::create_resource,
|
||||||
commands::kube::edit_resource,
|
commands::kube::edit_resource,
|
||||||
|
// Phase 4: Additional Resource Discovery
|
||||||
|
commands::kube::list_replicationcontrollers,
|
||||||
|
commands::kube::list_poddisruptionbudgets,
|
||||||
|
commands::kube::list_priorityclasses,
|
||||||
|
commands::kube::list_runtimeclasses,
|
||||||
|
commands::kube::list_leases,
|
||||||
|
commands::kube::list_mutatingwebhookconfigurations,
|
||||||
|
commands::kube::list_validatingwebhookconfigurations,
|
||||||
|
commands::kube::list_endpoints,
|
||||||
|
commands::kube::list_endpointslices,
|
||||||
|
commands::kube::list_ingressclasses,
|
||||||
|
commands::kube::list_namespaces_resource,
|
||||||
|
commands::kube::list_crds,
|
||||||
|
commands::kube::list_custom_resources,
|
||||||
|
// Phase 5: Action Commands
|
||||||
|
commands::kube::force_delete_resource,
|
||||||
|
commands::kube::describe_resource,
|
||||||
|
commands::kube::get_resource_yaml,
|
||||||
|
commands::kube::attach_pod,
|
||||||
|
commands::kube::restart_statefulset,
|
||||||
|
commands::kube::restart_daemonset,
|
||||||
|
commands::kube::scale_statefulset,
|
||||||
|
commands::kube::scale_replicaset,
|
||||||
|
commands::kube::scale_replicationcontroller,
|
||||||
|
commands::kube::suspend_cronjob,
|
||||||
|
commands::kube::resume_cronjob,
|
||||||
|
commands::kube::trigger_cronjob,
|
||||||
|
commands::kube::create_namespace,
|
||||||
|
commands::kube::delete_namespace,
|
||||||
|
// Phase 6: Log Streaming
|
||||||
|
commands::kube::stream_pod_logs,
|
||||||
|
commands::kube::stop_log_stream,
|
||||||
|
// Phase 7: Helm Commands
|
||||||
|
commands::kube::helm_list_repos,
|
||||||
|
commands::kube::helm_add_repo,
|
||||||
|
commands::kube::helm_update_repos,
|
||||||
|
commands::kube::helm_search_repo,
|
||||||
|
commands::kube::helm_list_releases,
|
||||||
|
commands::kube::helm_uninstall,
|
||||||
|
commands::kube::helm_rollback,
|
||||||
])
|
])
|
||||||
.run(tauri::generate_context!())
|
.run(tauri::generate_context!())
|
||||||
.expect("Error running Troubleshooting and RCA Assistant application");
|
.expect("Error running Troubleshooting and RCA Assistant application");
|
||||||
|
|||||||
113
src-tauri/src/shell/helm.rs
Normal file
113
src-tauri/src/shell/helm.rs
Normal file
@ -0,0 +1,113 @@
|
|||||||
|
// Helm Binary Management
|
||||||
|
//
|
||||||
|
// This module handles:
|
||||||
|
// - Locating the helm binary (bundled or system PATH)
|
||||||
|
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::process::Command;
|
||||||
|
|
||||||
|
pub fn locate_helm() -> Result<PathBuf, String> {
|
||||||
|
// Strategy:
|
||||||
|
// 1. Check for bundled sidecar binary (platform-specific)
|
||||||
|
// 2. Fallback to system PATH (which helm)
|
||||||
|
// 3. Check common installation paths
|
||||||
|
|
||||||
|
let exe_suffix = if cfg!(windows) { ".exe" } else { "" };
|
||||||
|
|
||||||
|
// Try current directory (dev mode)
|
||||||
|
let local_helm = PathBuf::from(format!("helm{exe_suffix}"));
|
||||||
|
if local_helm.exists() {
|
||||||
|
return Ok(local_helm);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for Tauri sidecar binary (production builds)
|
||||||
|
if let Ok(exe_path) = std::env::current_exe() {
|
||||||
|
if let Some(exe_dir) = exe_path.parent() {
|
||||||
|
let target = std::env::consts::ARCH.to_string()
|
||||||
|
+ "-"
|
||||||
|
+ if cfg!(target_os = "linux") {
|
||||||
|
"unknown-linux-gnu"
|
||||||
|
} else if cfg!(target_os = "macos") {
|
||||||
|
"apple-darwin"
|
||||||
|
} else if cfg!(target_os = "windows") {
|
||||||
|
"pc-windows-msvc"
|
||||||
|
} else {
|
||||||
|
"unknown"
|
||||||
|
};
|
||||||
|
|
||||||
|
let sidecar_name = format!("helm-{target}{exe_suffix}");
|
||||||
|
let sidecar_path = exe_dir.join(&sidecar_name);
|
||||||
|
|
||||||
|
if sidecar_path.exists() {
|
||||||
|
return Ok(sidecar_path);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also check Resources subdirectory (macOS .app bundle)
|
||||||
|
let resources_path = exe_dir.join("Resources").join(&sidecar_name);
|
||||||
|
if resources_path.exists() {
|
||||||
|
return Ok(resources_path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check system PATH
|
||||||
|
#[cfg(not(target_os = "windows"))]
|
||||||
|
{
|
||||||
|
if let Ok(output) = Command::new("which").arg("helm").output() {
|
||||||
|
if output.status.success() {
|
||||||
|
let path_str = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||||
|
let path = PathBuf::from(path_str);
|
||||||
|
if path.exists() {
|
||||||
|
return Ok(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
{
|
||||||
|
if let Ok(output) = Command::new("where").arg("helm").output() {
|
||||||
|
if output.status.success() {
|
||||||
|
let path_str = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||||
|
let path = PathBuf::from(path_str);
|
||||||
|
if path.exists() {
|
||||||
|
return Ok(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check common installation paths
|
||||||
|
let common_paths = [
|
||||||
|
"/usr/local/bin/helm",
|
||||||
|
"/usr/bin/helm",
|
||||||
|
"/opt/homebrew/bin/helm",
|
||||||
|
"/snap/bin/helm",
|
||||||
|
];
|
||||||
|
|
||||||
|
for path_str in &common_paths {
|
||||||
|
let path = PathBuf::from(path_str);
|
||||||
|
if path.exists() {
|
||||||
|
return Ok(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Err(
|
||||||
|
"helm binary not found. Please install helm or it will be bundled in production builds."
|
||||||
|
.to_string(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_locate_helm_finds_binary() {
|
||||||
|
let result = locate_helm();
|
||||||
|
if result.is_ok() {
|
||||||
|
assert!(result.unwrap().exists(), "helm path should exist if found");
|
||||||
|
}
|
||||||
|
// Test passes whether helm is found or not
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,5 +1,6 @@
|
|||||||
pub mod classifier;
|
pub mod classifier;
|
||||||
pub mod executor;
|
pub mod executor;
|
||||||
|
pub mod helm;
|
||||||
pub mod kubeconfig;
|
pub mod kubeconfig;
|
||||||
pub mod kubectl;
|
pub mod kubectl;
|
||||||
|
|
||||||
@ -8,5 +9,6 @@ mod tests;
|
|||||||
|
|
||||||
pub use classifier::{ClassificationResult, CommandClassifier, CommandTier};
|
pub use classifier::{ClassificationResult, CommandClassifier, CommandTier};
|
||||||
pub use executor::{execute_with_approval, CommandOutput};
|
pub use executor::{execute_with_approval, CommandOutput};
|
||||||
|
pub use helm::locate_helm;
|
||||||
pub use kubeconfig::{auto_detect_kubeconfig, KubeconfigInfo};
|
pub use kubeconfig::{auto_detect_kubeconfig, KubeconfigInfo};
|
||||||
pub use kubectl::{execute_kubectl, locate_kubectl};
|
pub use kubectl::{execute_kubectl, locate_kubectl};
|
||||||
|
|||||||
@ -99,6 +99,8 @@ pub struct AppState {
|
|||||||
pub refresh_registry: Arc<TokioMutex<crate::kube::RefreshRegistry>>,
|
pub refresh_registry: Arc<TokioMutex<crate::kube::RefreshRegistry>>,
|
||||||
/// Resource watchers: unsubscribe_id -> receiver
|
/// Resource watchers: unsubscribe_id -> receiver
|
||||||
pub watchers: Arc<Mutex<HashMap<String, tokio::sync::mpsc::Receiver<serde_json::Value>>>>,
|
pub watchers: Arc<Mutex<HashMap<String, tokio::sync::mpsc::Receiver<serde_json::Value>>>>,
|
||||||
|
/// Active pod log streaming tasks: stream_id -> abort handle
|
||||||
|
pub log_streams: Arc<TokioMutex<HashMap<String, tokio::task::AbortHandle>>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Determine the application data directory.
|
/// Determine the application data directory.
|
||||||
|
|||||||
112
src/components/Kubernetes/AttachModal.tsx
Normal file
112
src/components/Kubernetes/AttachModal.tsx
Normal file
@ -0,0 +1,112 @@
|
|||||||
|
import React from "react";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui";
|
||||||
|
import { Button } from "@/components/ui";
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui";
|
||||||
|
import { Alert, AlertDescription } from "@/components/ui";
|
||||||
|
import { Link, Loader2 } from "lucide-react";
|
||||||
|
import { attachPodCmd } from "@/lib/tauriCommands";
|
||||||
|
|
||||||
|
interface AttachModalProps {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
clusterId: string;
|
||||||
|
namespace: string;
|
||||||
|
podName: string;
|
||||||
|
containers: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AttachModal({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
clusterId,
|
||||||
|
namespace,
|
||||||
|
podName,
|
||||||
|
containers,
|
||||||
|
}: AttachModalProps) {
|
||||||
|
const [selectedContainer, setSelectedContainer] = React.useState("");
|
||||||
|
const [output, setOutput] = React.useState("");
|
||||||
|
const [isLoading, setIsLoading] = React.useState(false);
|
||||||
|
const [error, setError] = React.useState<string | null>(null);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
setSelectedContainer(containers[0] ?? "");
|
||||||
|
setOutput("");
|
||||||
|
setError(null);
|
||||||
|
}
|
||||||
|
}, [open, containers]);
|
||||||
|
|
||||||
|
const handleAttach = async () => {
|
||||||
|
if (!selectedContainer) return;
|
||||||
|
setIsLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const result = await attachPodCmd(clusterId, namespace, podName, selectedContainer);
|
||||||
|
setOutput(
|
||||||
|
`Session ${result.session_id} — status: ${result.status}`
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : String(err));
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="max-w-3xl">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>
|
||||||
|
Attach — <span className="font-mono">{podName}</span>
|
||||||
|
</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Select value={selectedContainer} onValueChange={setSelectedContainer}>
|
||||||
|
<SelectTrigger className="w-48">
|
||||||
|
<SelectValue placeholder="Select container" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{containers.map((c) => (
|
||||||
|
<SelectItem key={c} value={c}>
|
||||||
|
{c}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={handleAttach}
|
||||||
|
disabled={!selectedContainer || isLoading}
|
||||||
|
>
|
||||||
|
{isLoading ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
Attaching...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Link className="mr-2 h-4 w-4" />
|
||||||
|
Attach
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{error && (
|
||||||
|
<Alert variant="destructive">
|
||||||
|
<AlertDescription>{error}</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
<pre className="max-h-[50vh] overflow-auto rounded-md bg-black p-3 font-mono text-xs text-green-400 whitespace-pre-wrap break-all">
|
||||||
|
{output || "Select a container and click Attach."}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,14 +1,62 @@
|
|||||||
import React from "react";
|
import React, { useState } from "react";
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui";
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui";
|
||||||
|
import { Pencil, Trash2 } from "lucide-react";
|
||||||
import type { ClusterRoleBindingInfo } from "@/lib/tauriCommands";
|
import type { ClusterRoleBindingInfo } from "@/lib/tauriCommands";
|
||||||
|
import { deleteResourceCmd, getResourceYamlCmd } from "@/lib/tauriCommands";
|
||||||
|
import { ResourceActionMenu } from "./ResourceActionMenu";
|
||||||
|
import { ConfirmDeleteDialog } from "./ConfirmDeleteDialog";
|
||||||
|
import { EditResourceModal } from "./EditResourceModal";
|
||||||
|
|
||||||
interface ClusterRoleBindingListProps {
|
interface ClusterRoleBindingListProps {
|
||||||
clusterRoleBindings: ClusterRoleBindingInfo[];
|
clusterRoleBindings: ClusterRoleBindingInfo[];
|
||||||
_clusterId: string;
|
clusterId?: string;
|
||||||
|
_clusterId?: string;
|
||||||
|
onRefresh?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ClusterRoleBindingList({ clusterRoleBindings, _clusterId }: ClusterRoleBindingListProps) {
|
type ActiveModal =
|
||||||
|
| { type: "edit"; crb: ClusterRoleBindingInfo; yaml: string }
|
||||||
|
| { type: "delete"; crb: ClusterRoleBindingInfo }
|
||||||
|
| null;
|
||||||
|
|
||||||
|
export function ClusterRoleBindingList({
|
||||||
|
clusterRoleBindings,
|
||||||
|
clusterId,
|
||||||
|
_clusterId,
|
||||||
|
onRefresh,
|
||||||
|
}: ClusterRoleBindingListProps) {
|
||||||
|
const cid = clusterId ?? _clusterId ?? "";
|
||||||
|
const [activeModal, setActiveModal] = useState<ActiveModal>(null);
|
||||||
|
const [isDeleting, setIsDeleting] = useState(false);
|
||||||
|
const [actionError, setActionError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const openEdit = async (crb: ClusterRoleBindingInfo) => {
|
||||||
|
setActionError(null);
|
||||||
|
try {
|
||||||
|
const yaml = await getResourceYamlCmd(cid, "clusterrolebindings", "", crb.name);
|
||||||
|
setActiveModal({ type: "edit", crb, yaml });
|
||||||
|
} catch (err) {
|
||||||
|
setActionError(err instanceof Error ? err.message : String(err));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async () => {
|
||||||
|
if (activeModal?.type !== "delete") return;
|
||||||
|
setIsDeleting(true);
|
||||||
|
try {
|
||||||
|
await deleteResourceCmd(cid, "clusterrolebindings", "", activeModal.crb.name);
|
||||||
|
setActiveModal(null);
|
||||||
|
onRefresh?.();
|
||||||
|
} finally {
|
||||||
|
setIsDeleting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
|
{actionError && (
|
||||||
|
<p className="mb-2 text-sm text-destructive">{actionError}</p>
|
||||||
|
)}
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
@ -16,12 +64,13 @@ export function ClusterRoleBindingList({ clusterRoleBindings, _clusterId }: Clus
|
|||||||
<TableHead>Name</TableHead>
|
<TableHead>Name</TableHead>
|
||||||
<TableHead>Cluster Role</TableHead>
|
<TableHead>Cluster Role</TableHead>
|
||||||
<TableHead>Age</TableHead>
|
<TableHead>Age</TableHead>
|
||||||
|
<TableHead className="text-right">Actions</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{clusterRoleBindings.length === 0 ? (
|
{clusterRoleBindings.length === 0 ? (
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell colSpan={3} className="text-center text-muted-foreground">
|
<TableCell colSpan={4} className="text-center text-muted-foreground">
|
||||||
No cluster role bindings found
|
No cluster role bindings found
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
@ -31,11 +80,52 @@ export function ClusterRoleBindingList({ clusterRoleBindings, _clusterId }: Clus
|
|||||||
<TableCell className="font-medium">{crb.name}</TableCell>
|
<TableCell className="font-medium">{crb.name}</TableCell>
|
||||||
<TableCell>{crb.cluster_role}</TableCell>
|
<TableCell>{crb.cluster_role}</TableCell>
|
||||||
<TableCell className="text-muted-foreground">{crb.age}</TableCell>
|
<TableCell className="text-muted-foreground">{crb.age}</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
<ResourceActionMenu
|
||||||
|
actions={[
|
||||||
|
{
|
||||||
|
label: "Edit",
|
||||||
|
icon: Pencil,
|
||||||
|
onClick: () => openEdit(crb),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Delete",
|
||||||
|
icon: Trash2,
|
||||||
|
variant: "destructive",
|
||||||
|
onClick: () => setActiveModal({ type: "delete", crb }),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{activeModal?.type === "edit" && (
|
||||||
|
<EditResourceModal
|
||||||
|
isOpen
|
||||||
|
clusterId={cid}
|
||||||
|
namespace=""
|
||||||
|
resourceType="clusterrolebindings"
|
||||||
|
resourceName={activeModal.crb.name}
|
||||||
|
initialYaml={activeModal.yaml}
|
||||||
|
onClose={() => { setActiveModal(null); onRefresh?.(); }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeModal?.type === "delete" && (
|
||||||
|
<ConfirmDeleteDialog
|
||||||
|
open
|
||||||
|
onOpenChange={(o) => { if (!o) setActiveModal(null); }}
|
||||||
|
resourceType="ClusterRoleBinding"
|
||||||
|
resourceName={activeModal.crb.name}
|
||||||
|
isLoading={isDeleting}
|
||||||
|
onConfirm={handleDelete}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,39 +1,129 @@
|
|||||||
import React from "react";
|
import React, { useState } from "react";
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui";
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui";
|
||||||
|
import { Pencil, Trash2 } from "lucide-react";
|
||||||
import type { ClusterRoleInfo } from "@/lib/tauriCommands";
|
import type { ClusterRoleInfo } from "@/lib/tauriCommands";
|
||||||
|
import { deleteResourceCmd, getResourceYamlCmd } from "@/lib/tauriCommands";
|
||||||
|
import { ResourceActionMenu } from "./ResourceActionMenu";
|
||||||
|
import { ConfirmDeleteDialog } from "./ConfirmDeleteDialog";
|
||||||
|
import { EditResourceModal } from "./EditResourceModal";
|
||||||
|
|
||||||
interface ClusterRoleListProps {
|
interface ClusterRoleListProps {
|
||||||
clusterRoles: ClusterRoleInfo[];
|
clusterRoles: ClusterRoleInfo[];
|
||||||
_clusterId: string;
|
clusterId?: string;
|
||||||
|
_clusterId?: string;
|
||||||
|
onRefresh?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ClusterRoleList({ clusterRoles, _clusterId }: ClusterRoleListProps) {
|
type ActiveModal =
|
||||||
|
| { type: "edit"; cr: ClusterRoleInfo; yaml: string }
|
||||||
|
| { type: "delete"; cr: ClusterRoleInfo }
|
||||||
|
| null;
|
||||||
|
|
||||||
|
export function ClusterRoleList({
|
||||||
|
clusterRoles,
|
||||||
|
clusterId,
|
||||||
|
_clusterId,
|
||||||
|
onRefresh,
|
||||||
|
}: ClusterRoleListProps) {
|
||||||
|
const cid = clusterId ?? _clusterId ?? "";
|
||||||
|
const [activeModal, setActiveModal] = useState<ActiveModal>(null);
|
||||||
|
const [isDeleting, setIsDeleting] = useState(false);
|
||||||
|
const [actionError, setActionError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const openEdit = async (cr: ClusterRoleInfo) => {
|
||||||
|
setActionError(null);
|
||||||
|
try {
|
||||||
|
const yaml = await getResourceYamlCmd(cid, "clusterroles", "", cr.name);
|
||||||
|
setActiveModal({ type: "edit", cr, yaml });
|
||||||
|
} catch (err) {
|
||||||
|
setActionError(err instanceof Error ? err.message : String(err));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async () => {
|
||||||
|
if (activeModal?.type !== "delete") return;
|
||||||
|
setIsDeleting(true);
|
||||||
|
try {
|
||||||
|
await deleteResourceCmd(cid, "clusterroles", "", activeModal.cr.name);
|
||||||
|
setActiveModal(null);
|
||||||
|
onRefresh?.();
|
||||||
|
} finally {
|
||||||
|
setIsDeleting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
|
{actionError && (
|
||||||
|
<p className="mb-2 text-sm text-destructive">{actionError}</p>
|
||||||
|
)}
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableHead>Name</TableHead>
|
<TableHead>Name</TableHead>
|
||||||
<TableHead>Age</TableHead>
|
<TableHead>Age</TableHead>
|
||||||
|
<TableHead className="text-right">Actions</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{clusterRoles.length === 0 ? (
|
{clusterRoles.length === 0 ? (
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell colSpan={2} className="text-center text-muted-foreground">
|
<TableCell colSpan={3} className="text-center text-muted-foreground">
|
||||||
No cluster roles found
|
No cluster roles found
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
) : (
|
) : (
|
||||||
clusterRoles.map((clusterRole) => (
|
clusterRoles.map((cr) => (
|
||||||
<TableRow key={clusterRole.name}>
|
<TableRow key={cr.name}>
|
||||||
<TableCell className="font-medium">{clusterRole.name}</TableCell>
|
<TableCell className="font-medium">{cr.name}</TableCell>
|
||||||
<TableCell className="text-muted-foreground">{clusterRole.age}</TableCell>
|
<TableCell className="text-muted-foreground">{cr.age}</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
<ResourceActionMenu
|
||||||
|
actions={[
|
||||||
|
{
|
||||||
|
label: "Edit",
|
||||||
|
icon: Pencil,
|
||||||
|
onClick: () => openEdit(cr),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Delete",
|
||||||
|
icon: Trash2,
|
||||||
|
variant: "destructive",
|
||||||
|
onClick: () => setActiveModal({ type: "delete", cr }),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{activeModal?.type === "edit" && (
|
||||||
|
<EditResourceModal
|
||||||
|
isOpen
|
||||||
|
clusterId={cid}
|
||||||
|
namespace=""
|
||||||
|
resourceType="clusterroles"
|
||||||
|
resourceName={activeModal.cr.name}
|
||||||
|
initialYaml={activeModal.yaml}
|
||||||
|
onClose={() => { setActiveModal(null); onRefresh?.(); }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeModal?.type === "delete" && (
|
||||||
|
<ConfirmDeleteDialog
|
||||||
|
open
|
||||||
|
onOpenChange={(o) => { if (!o) setActiveModal(null); }}
|
||||||
|
resourceType="ClusterRole"
|
||||||
|
resourceName={activeModal.cr.name}
|
||||||
|
isLoading={isDeleting}
|
||||||
|
onConfirm={handleDelete}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,17 +1,56 @@
|
|||||||
import React from "react";
|
import React, { useState } from "react";
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui";
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui";
|
||||||
import { Button } from "@/components/ui";
|
import { Pencil, Trash2 } from "lucide-react";
|
||||||
import type { ConfigMapInfo } from "@/lib/tauriCommands";
|
import type { ConfigMapInfo } from "@/lib/tauriCommands";
|
||||||
|
import { deleteResourceCmd, getResourceYamlCmd } from "@/lib/tauriCommands";
|
||||||
|
import { ResourceActionMenu } from "./ResourceActionMenu";
|
||||||
|
import { ConfirmDeleteDialog } from "./ConfirmDeleteDialog";
|
||||||
|
import { EditResourceModal } from "./EditResourceModal";
|
||||||
|
|
||||||
interface ConfigMapListProps {
|
interface ConfigMapListProps {
|
||||||
configmaps: ConfigMapInfo[];
|
configmaps: ConfigMapInfo[];
|
||||||
clusterId: string;
|
clusterId: string;
|
||||||
namespace: string;
|
namespace: string;
|
||||||
|
onRefresh?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ConfigMapList({ configmaps }: ConfigMapListProps) {
|
type ActiveModal =
|
||||||
|
| { type: "edit"; cm: ConfigMapInfo; yaml: string }
|
||||||
|
| { type: "delete"; cm: ConfigMapInfo }
|
||||||
|
| null;
|
||||||
|
|
||||||
|
export function ConfigMapList({ configmaps, clusterId, namespace, onRefresh }: ConfigMapListProps) {
|
||||||
|
const [activeModal, setActiveModal] = useState<ActiveModal>(null);
|
||||||
|
const [isDeleting, setIsDeleting] = useState(false);
|
||||||
|
const [actionError, setActionError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const openEdit = async (cm: ConfigMapInfo) => {
|
||||||
|
setActionError(null);
|
||||||
|
try {
|
||||||
|
const yaml = await getResourceYamlCmd(clusterId, "configmaps", namespace, cm.name);
|
||||||
|
setActiveModal({ type: "edit", cm, yaml });
|
||||||
|
} catch (err) {
|
||||||
|
setActionError(err instanceof Error ? err.message : String(err));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async () => {
|
||||||
|
if (activeModal?.type !== "delete") return;
|
||||||
|
setIsDeleting(true);
|
||||||
|
try {
|
||||||
|
await deleteResourceCmd(clusterId, "configmaps", namespace, activeModal.cm.name);
|
||||||
|
setActiveModal(null);
|
||||||
|
onRefresh?.();
|
||||||
|
} finally {
|
||||||
|
setIsDeleting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
|
{actionError && (
|
||||||
|
<p className="mb-2 text-sm text-destructive">{actionError}</p>
|
||||||
|
)}
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
@ -31,21 +70,28 @@ export function ConfigMapList({ configmaps }: ConfigMapListProps) {
|
|||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
) : (
|
) : (
|
||||||
configmaps.map((configmap) => (
|
configmaps.map((cm) => (
|
||||||
<TableRow key={configmap.name}>
|
<TableRow key={cm.name}>
|
||||||
<TableCell className="font-medium">{configmap.name}</TableCell>
|
<TableCell className="font-medium">{cm.name}</TableCell>
|
||||||
<TableCell className="text-sm text-muted-foreground">{configmap.namespace}</TableCell>
|
<TableCell className="text-sm text-muted-foreground">{cm.namespace}</TableCell>
|
||||||
<TableCell className="text-sm">{configmap.data_keys}</TableCell>
|
<TableCell className="text-sm">{cm.data_keys}</TableCell>
|
||||||
<TableCell className="text-sm text-muted-foreground">{configmap.age}</TableCell>
|
<TableCell className="text-sm text-muted-foreground">{cm.age}</TableCell>
|
||||||
<TableCell className="text-right">
|
<TableCell className="text-right">
|
||||||
<Button
|
<ResourceActionMenu
|
||||||
variant="ghost"
|
actions={[
|
||||||
size="sm"
|
{
|
||||||
onClick={() => {}}
|
label: "Edit",
|
||||||
className="text-primary hover:text-primary hover:bg-primary/10"
|
icon: Pencil,
|
||||||
>
|
onClick: () => openEdit(cm),
|
||||||
View/Edit
|
},
|
||||||
</Button>
|
{
|
||||||
|
label: "Delete",
|
||||||
|
icon: Trash2,
|
||||||
|
variant: "destructive",
|
||||||
|
onClick: () => setActiveModal({ type: "delete", cm }),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))
|
))
|
||||||
@ -53,5 +99,29 @@ export function ConfigMapList({ configmaps }: ConfigMapListProps) {
|
|||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{activeModal?.type === "edit" && (
|
||||||
|
<EditResourceModal
|
||||||
|
isOpen
|
||||||
|
clusterId={clusterId}
|
||||||
|
namespace={namespace}
|
||||||
|
resourceType="configmaps"
|
||||||
|
resourceName={activeModal.cm.name}
|
||||||
|
initialYaml={activeModal.yaml}
|
||||||
|
onClose={() => { setActiveModal(null); onRefresh?.(); }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeModal?.type === "delete" && (
|
||||||
|
<ConfirmDeleteDialog
|
||||||
|
open
|
||||||
|
onOpenChange={(o) => { if (!o) setActiveModal(null); }}
|
||||||
|
resourceType="ConfigMap"
|
||||||
|
resourceName={activeModal.cm.name}
|
||||||
|
isLoading={isDeleting}
|
||||||
|
onConfirm={handleDelete}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
85
src/components/Kubernetes/ConfirmDeleteDialog.tsx
Normal file
85
src/components/Kubernetes/ConfirmDeleteDialog.tsx
Normal file
@ -0,0 +1,85 @@
|
|||||||
|
import React from "react";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
} from "@/components/ui";
|
||||||
|
import { Button } from "@/components/ui";
|
||||||
|
import { AlertTriangle, Loader2 } from "lucide-react";
|
||||||
|
|
||||||
|
interface ConfirmDeleteDialogProps {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
resourceType: string;
|
||||||
|
resourceName: string;
|
||||||
|
onConfirm: () => Promise<void> | void;
|
||||||
|
isLoading?: boolean;
|
||||||
|
variant?: "delete" | "force-delete";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ConfirmDeleteDialog({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
resourceType,
|
||||||
|
resourceName,
|
||||||
|
onConfirm,
|
||||||
|
isLoading = false,
|
||||||
|
variant = "delete",
|
||||||
|
}: ConfirmDeleteDialogProps) {
|
||||||
|
const isForce = variant === "force-delete";
|
||||||
|
|
||||||
|
const handleConfirm = async () => {
|
||||||
|
await onConfirm();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex items-center gap-2">
|
||||||
|
<AlertTriangle className="h-5 w-5 text-destructive" />
|
||||||
|
{isForce ? `Force Delete ${resourceType}` : `Delete ${resourceType}`}
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
{isForce ? (
|
||||||
|
<>
|
||||||
|
Are you sure you want to <strong>force delete</strong>{" "}
|
||||||
|
<span className="font-mono text-foreground">{resourceName}</span>?
|
||||||
|
<br />
|
||||||
|
<span className="mt-1 block text-destructive">
|
||||||
|
This will immediately terminate the resource with no grace period.
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
Are you sure you want to delete{" "}
|
||||||
|
<span className="font-mono text-foreground">{resourceName}</span>? This
|
||||||
|
action cannot be undone.
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={isLoading}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button variant="destructive" onClick={handleConfirm} disabled={isLoading}>
|
||||||
|
{isLoading ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
Deleting...
|
||||||
|
</>
|
||||||
|
) : isForce ? (
|
||||||
|
"Force Delete"
|
||||||
|
) : (
|
||||||
|
"Delete"
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
142
src/components/Kubernetes/CrdList.tsx
Normal file
142
src/components/Kubernetes/CrdList.tsx
Normal file
@ -0,0 +1,142 @@
|
|||||||
|
import React, { useCallback, useEffect, useState } from "react";
|
||||||
|
import { RefreshCw, ChevronRight, ChevronDown } from "lucide-react";
|
||||||
|
import { Badge, Button } from "@/components/ui";
|
||||||
|
import { listCrdsCmd } from "@/lib/tauriCommands";
|
||||||
|
import type { CrdInfo } from "@/lib/tauriCommands";
|
||||||
|
import { CustomResourceList } from "./CustomResourceList";
|
||||||
|
|
||||||
|
interface CrdListProps {
|
||||||
|
clusterId: string;
|
||||||
|
onSelectCrd?: (crd: CrdInfo) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function scopeVariant(scope: string): "default" | "secondary" {
|
||||||
|
return scope === "Namespaced" ? "default" : "secondary";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CrdList({ clusterId, onSelectCrd }: CrdListProps) {
|
||||||
|
const [crds, setCrds] = useState<CrdInfo[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [expandedCrd, setExpandedCrd] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const loadCrds = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const data = await listCrdsCmd(clusterId);
|
||||||
|
setCrds(data);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : String(err));
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [clusterId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void loadCrds();
|
||||||
|
}, [loadCrds]);
|
||||||
|
|
||||||
|
const handleRowClick = (crd: CrdInfo) => {
|
||||||
|
const key = crd.name;
|
||||||
|
setExpandedCrd((prev) => (prev === key ? null : key));
|
||||||
|
onSelectCrd?.(crd);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center h-32 text-muted-foreground">
|
||||||
|
<RefreshCw className="h-5 w-5 animate-spin mr-2" />
|
||||||
|
Loading CRDs…
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
{crds.length} custom resource definition{crds.length !== 1 ? "s" : ""}
|
||||||
|
</span>
|
||||||
|
<Button size="sm" variant="outline" onClick={() => void loadCrds()}>
|
||||||
|
<RefreshCw className="h-3.5 w-3.5 mr-1" />
|
||||||
|
Refresh
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="rounded-md border border-destructive/50 bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="border rounded-md overflow-hidden">
|
||||||
|
{crds.length === 0 ? (
|
||||||
|
<div className="flex items-center justify-center h-32 text-muted-foreground text-sm">
|
||||||
|
No custom resource definitions found
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b text-muted-foreground">
|
||||||
|
<th className="text-left px-4 py-3 font-medium">Name</th>
|
||||||
|
<th className="text-left px-4 py-3 font-medium">Kind</th>
|
||||||
|
<th className="text-left px-4 py-3 font-medium">Group</th>
|
||||||
|
<th className="text-left px-4 py-3 font-medium">Version</th>
|
||||||
|
<th className="text-left px-4 py-3 font-medium">Scope</th>
|
||||||
|
<th className="text-left px-4 py-3 font-medium">Age</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{crds.map((crd) => {
|
||||||
|
const isExpanded = expandedCrd === crd.name;
|
||||||
|
return (
|
||||||
|
<React.Fragment key={crd.name}>
|
||||||
|
<tr
|
||||||
|
className="border-b last:border-0 hover:bg-muted/30 transition-colors cursor-pointer"
|
||||||
|
onClick={() => handleRowClick(crd)}
|
||||||
|
>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<div className="flex items-center gap-1.5 font-mono text-xs">
|
||||||
|
{isExpanded ? (
|
||||||
|
<ChevronDown className="h-3.5 w-3.5 text-muted-foreground flex-shrink-0" />
|
||||||
|
) : (
|
||||||
|
<ChevronRight className="h-3.5 w-3.5 text-muted-foreground flex-shrink-0" />
|
||||||
|
)}
|
||||||
|
{crd.name}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 font-medium">{crd.kind}</td>
|
||||||
|
<td className="px-4 py-3 text-muted-foreground font-mono text-xs">{crd.group}</td>
|
||||||
|
<td className="px-4 py-3 font-mono text-xs">{crd.version}</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<Badge variant={scopeVariant(crd.scope)}>
|
||||||
|
{crd.scope}
|
||||||
|
</Badge>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-muted-foreground">{crd.age}</td>
|
||||||
|
</tr>
|
||||||
|
{isExpanded && (
|
||||||
|
<tr className="border-b bg-muted/10">
|
||||||
|
<td colSpan={6} className="px-6 py-3">
|
||||||
|
<CustomResourceList
|
||||||
|
clusterId={clusterId}
|
||||||
|
namespace={crd.scope === "Namespaced" ? "" : ""}
|
||||||
|
group={crd.group}
|
||||||
|
version={crd.version}
|
||||||
|
resource={crd.name.split(".")[0] ?? crd.name}
|
||||||
|
kind={crd.kind}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</React.Fragment>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,15 +1,108 @@
|
|||||||
import React from "react";
|
import React, { useState } from "react";
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui";
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui";
|
||||||
|
import { PauseCircle, PlayCircle, Play, Pencil, Trash2 } from "lucide-react";
|
||||||
import type { CronJobInfo } from "@/lib/tauriCommands";
|
import type { CronJobInfo } from "@/lib/tauriCommands";
|
||||||
|
import {
|
||||||
|
suspendCronjobCmd,
|
||||||
|
resumeCronjobCmd,
|
||||||
|
triggerCronjobCmd,
|
||||||
|
deleteResourceCmd,
|
||||||
|
getResourceYamlCmd,
|
||||||
|
} from "@/lib/tauriCommands";
|
||||||
|
import { ResourceActionMenu } from "./ResourceActionMenu";
|
||||||
|
import { ConfirmDeleteDialog } from "./ConfirmDeleteDialog";
|
||||||
|
import { EditResourceModal } from "./EditResourceModal";
|
||||||
|
|
||||||
interface CronJobListProps {
|
interface CronJobListProps {
|
||||||
cronJobs: CronJobInfo[];
|
cronJobs: CronJobInfo[];
|
||||||
_clusterId: string;
|
clusterId?: string;
|
||||||
_namespace: string;
|
_clusterId?: string;
|
||||||
|
namespace?: string;
|
||||||
|
_namespace?: string;
|
||||||
|
onRefresh?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function CronJobList({ cronJobs, _clusterId, _namespace }: CronJobListProps) {
|
type ActiveModal =
|
||||||
|
| { type: "edit"; cj: CronJobInfo; yaml: string }
|
||||||
|
| { type: "delete"; cj: CronJobInfo }
|
||||||
|
| null;
|
||||||
|
|
||||||
|
export function CronJobList({
|
||||||
|
cronJobs,
|
||||||
|
clusterId,
|
||||||
|
_clusterId,
|
||||||
|
namespace,
|
||||||
|
_namespace,
|
||||||
|
onRefresh,
|
||||||
|
}: CronJobListProps) {
|
||||||
|
const cid = clusterId ?? _clusterId ?? "";
|
||||||
|
const ns = namespace ?? _namespace ?? "";
|
||||||
|
const [activeModal, setActiveModal] = useState<ActiveModal>(null);
|
||||||
|
const [isDeleting, setIsDeleting] = useState(false);
|
||||||
|
const [actionError, setActionError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const openEdit = async (cj: CronJobInfo) => {
|
||||||
|
setActionError(null);
|
||||||
|
try {
|
||||||
|
const yaml = await getResourceYamlCmd(cid, "cronjobs", ns, cj.name);
|
||||||
|
setActiveModal({ type: "edit", cj, yaml });
|
||||||
|
} catch (err) {
|
||||||
|
setActionError(err instanceof Error ? err.message : String(err));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSuspend = async (cj: CronJobInfo) => {
|
||||||
|
setActionError(null);
|
||||||
|
try {
|
||||||
|
await suspendCronjobCmd(cid, ns, cj.name);
|
||||||
|
onRefresh?.();
|
||||||
|
} catch (err) {
|
||||||
|
setActionError(err instanceof Error ? err.message : String(err));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleResume = async (cj: CronJobInfo) => {
|
||||||
|
setActionError(null);
|
||||||
|
try {
|
||||||
|
await resumeCronjobCmd(cid, ns, cj.name);
|
||||||
|
onRefresh?.();
|
||||||
|
} catch (err) {
|
||||||
|
setActionError(err instanceof Error ? err.message : String(err));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTrigger = async (cj: CronJobInfo) => {
|
||||||
|
setActionError(null);
|
||||||
|
try {
|
||||||
|
await triggerCronjobCmd(cid, ns, cj.name);
|
||||||
|
onRefresh?.();
|
||||||
|
} catch (err) {
|
||||||
|
setActionError(err instanceof Error ? err.message : String(err));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async () => {
|
||||||
|
if (activeModal?.type !== "delete") return;
|
||||||
|
setIsDeleting(true);
|
||||||
|
try {
|
||||||
|
await deleteResourceCmd(cid, "cronjobs", ns, activeModal.cj.name);
|
||||||
|
setActiveModal(null);
|
||||||
|
onRefresh?.();
|
||||||
|
} finally {
|
||||||
|
setIsDeleting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const isSuspended = (cj: CronJobInfo) => {
|
||||||
|
const labels = cj.labels ?? {};
|
||||||
|
return labels["cronjob.kubernetes.io/suspended"] === "true";
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
|
{actionError && (
|
||||||
|
<p className="mb-2 text-sm text-destructive">{actionError}</p>
|
||||||
|
)}
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
@ -21,34 +114,93 @@ export function CronJobList({ cronJobs, _clusterId, _namespace }: CronJobListPro
|
|||||||
<TableHead>Last Schedule</TableHead>
|
<TableHead>Last Schedule</TableHead>
|
||||||
<TableHead>Age</TableHead>
|
<TableHead>Age</TableHead>
|
||||||
<TableHead>Labels</TableHead>
|
<TableHead>Labels</TableHead>
|
||||||
|
<TableHead className="text-right">Actions</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{cronJobs.length === 0 ? (
|
{cronJobs.length === 0 ? (
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell colSpan={7} className="text-center text-muted-foreground">
|
<TableCell colSpan={8} className="text-center text-muted-foreground">
|
||||||
No cron jobs found
|
No cron jobs found
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
) : (
|
) : (
|
||||||
cronJobs.map((cronJob) => (
|
cronJobs.map((cj) => (
|
||||||
<TableRow key={`${cronJob.name}-${cronJob.namespace}`}>
|
<TableRow key={`${cj.name}-${cj.namespace}`}>
|
||||||
<TableCell className="font-medium">{cronJob.name}</TableCell>
|
<TableCell className="font-medium">{cj.name}</TableCell>
|
||||||
<TableCell>{cronJob.namespace}</TableCell>
|
<TableCell>{cj.namespace}</TableCell>
|
||||||
<TableCell>{cronJob.schedule}</TableCell>
|
<TableCell>{cj.schedule}</TableCell>
|
||||||
<TableCell>{cronJob.active}</TableCell>
|
<TableCell>{cj.active}</TableCell>
|
||||||
<TableCell>{cronJob.last_schedule}</TableCell>
|
<TableCell>{cj.last_schedule}</TableCell>
|
||||||
<TableCell className="text-muted-foreground">{cronJob.age}</TableCell>
|
<TableCell className="text-muted-foreground">{cj.age}</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
{Object.entries(cronJob.labels)
|
{Object.entries(cj.labels)
|
||||||
.map(([k, v]) => `${k}=${v}`)
|
.map(([k, v]) => `${k}=${v}`)
|
||||||
.join(", ")}
|
.join(", ")}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
<ResourceActionMenu
|
||||||
|
actions={[
|
||||||
|
{
|
||||||
|
label: "Suspend",
|
||||||
|
icon: PauseCircle,
|
||||||
|
hidden: isSuspended(cj),
|
||||||
|
onClick: () => handleSuspend(cj),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Resume",
|
||||||
|
icon: PlayCircle,
|
||||||
|
hidden: !isSuspended(cj),
|
||||||
|
onClick: () => handleResume(cj),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Trigger",
|
||||||
|
icon: Play,
|
||||||
|
onClick: () => handleTrigger(cj),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Edit",
|
||||||
|
icon: Pencil,
|
||||||
|
onClick: () => openEdit(cj),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Delete",
|
||||||
|
icon: Trash2,
|
||||||
|
variant: "destructive",
|
||||||
|
onClick: () => setActiveModal({ type: "delete", cj }),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{activeModal?.type === "edit" && (
|
||||||
|
<EditResourceModal
|
||||||
|
isOpen
|
||||||
|
clusterId={cid}
|
||||||
|
namespace={ns}
|
||||||
|
resourceType="cronjobs"
|
||||||
|
resourceName={activeModal.cj.name}
|
||||||
|
initialYaml={activeModal.yaml}
|
||||||
|
onClose={() => { setActiveModal(null); onRefresh?.(); }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeModal?.type === "delete" && (
|
||||||
|
<ConfirmDeleteDialog
|
||||||
|
open
|
||||||
|
onOpenChange={(o) => { if (!o) setActiveModal(null); }}
|
||||||
|
resourceType="CronJob"
|
||||||
|
resourceName={activeModal.cj.name}
|
||||||
|
isLoading={isDeleting}
|
||||||
|
onConfirm={handleDelete}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
100
src/components/Kubernetes/CustomResourceList.tsx
Normal file
100
src/components/Kubernetes/CustomResourceList.tsx
Normal file
@ -0,0 +1,100 @@
|
|||||||
|
import React, { useCallback, useEffect, useState } from "react";
|
||||||
|
import { RefreshCw } from "lucide-react";
|
||||||
|
import { listCustomResourcesCmd } from "@/lib/tauriCommands";
|
||||||
|
import type { CustomResourceInfo } from "@/lib/tauriCommands";
|
||||||
|
|
||||||
|
interface CustomResourceListProps {
|
||||||
|
clusterId: string;
|
||||||
|
namespace: string;
|
||||||
|
group: string;
|
||||||
|
version: string;
|
||||||
|
resource: string;
|
||||||
|
kind: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CustomResourceList({
|
||||||
|
clusterId,
|
||||||
|
namespace,
|
||||||
|
group,
|
||||||
|
version,
|
||||||
|
resource,
|
||||||
|
kind,
|
||||||
|
}: CustomResourceListProps) {
|
||||||
|
const [items, setItems] = useState<CustomResourceInfo[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const loadItems = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const data = await listCustomResourcesCmd(clusterId, group, version, resource, namespace);
|
||||||
|
setItems(data);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : String(err));
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [clusterId, group, version, resource, namespace]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void loadItems();
|
||||||
|
}, [loadItems]);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2 text-muted-foreground text-sm py-2">
|
||||||
|
<RefreshCw className="h-4 w-4 animate-spin" />
|
||||||
|
Loading {kind} instances…
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-md border border-destructive/50 bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (items.length === 0) {
|
||||||
|
return (
|
||||||
|
<p className="text-sm text-muted-foreground py-2">
|
||||||
|
No {kind} instances found.
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const showNamespace = items.some((item) => item.namespace !== "");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-md border overflow-hidden">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b text-muted-foreground bg-muted/30">
|
||||||
|
<th className="text-left px-4 py-2 font-medium">Name</th>
|
||||||
|
{showNamespace && (
|
||||||
|
<th className="text-left px-4 py-2 font-medium">Namespace</th>
|
||||||
|
)}
|
||||||
|
<th className="text-left px-4 py-2 font-medium">Age</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{items.map((item) => (
|
||||||
|
<tr
|
||||||
|
key={`${item.namespace}/${item.name}`}
|
||||||
|
className="border-b last:border-0 hover:bg-muted/20 transition-colors"
|
||||||
|
>
|
||||||
|
<td className="px-4 py-2 font-mono text-xs font-medium">{item.name}</td>
|
||||||
|
{showNamespace && (
|
||||||
|
<td className="px-4 py-2 text-muted-foreground">{item.namespace || "—"}</td>
|
||||||
|
)}
|
||||||
|
<td className="px-4 py-2 text-muted-foreground">{item.age}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,15 +1,75 @@
|
|||||||
import React from "react";
|
import React, { useState } from "react";
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui";
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui";
|
||||||
|
import { RotateCcw, Pencil, Trash2 } from "lucide-react";
|
||||||
import type { DaemonSetInfo } from "@/lib/tauriCommands";
|
import type { DaemonSetInfo } from "@/lib/tauriCommands";
|
||||||
|
import {
|
||||||
|
restartDaemonsetCmd,
|
||||||
|
deleteResourceCmd,
|
||||||
|
getResourceYamlCmd,
|
||||||
|
} from "@/lib/tauriCommands";
|
||||||
|
import { ResourceActionMenu } from "./ResourceActionMenu";
|
||||||
|
import { ConfirmDeleteDialog } from "./ConfirmDeleteDialog";
|
||||||
|
import { EditResourceModal } from "./EditResourceModal";
|
||||||
|
|
||||||
interface DaemonSetListProps {
|
interface DaemonSetListProps {
|
||||||
daemonsets: DaemonSetInfo[];
|
daemonsets: DaemonSetInfo[];
|
||||||
clusterId: string;
|
clusterId: string;
|
||||||
namespace: string;
|
namespace: string;
|
||||||
|
onRefresh?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DaemonSetList({ daemonsets, clusterId: _clusterId, namespace: _namespace }: DaemonSetListProps) {
|
type ActiveModal =
|
||||||
|
| { type: "restart"; ds: DaemonSetInfo }
|
||||||
|
| { type: "edit"; ds: DaemonSetInfo; yaml: string }
|
||||||
|
| { type: "delete"; ds: DaemonSetInfo }
|
||||||
|
| null;
|
||||||
|
|
||||||
|
export function DaemonSetList({ daemonsets, clusterId, namespace, onRefresh }: DaemonSetListProps) {
|
||||||
|
const [activeModal, setActiveModal] = useState<ActiveModal>(null);
|
||||||
|
const [isActing, setIsActing] = useState(false);
|
||||||
|
const [actionError, setActionError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const openEdit = async (ds: DaemonSetInfo) => {
|
||||||
|
setActionError(null);
|
||||||
|
try {
|
||||||
|
const yaml = await getResourceYamlCmd(clusterId, "daemonsets", namespace, ds.name);
|
||||||
|
setActiveModal({ type: "edit", ds, yaml });
|
||||||
|
} catch (err) {
|
||||||
|
setActionError(err instanceof Error ? err.message : String(err));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRestart = async () => {
|
||||||
|
if (activeModal?.type !== "restart") return;
|
||||||
|
setIsActing(true);
|
||||||
|
try {
|
||||||
|
await restartDaemonsetCmd(clusterId, namespace, activeModal.ds.name);
|
||||||
|
setActiveModal(null);
|
||||||
|
onRefresh?.();
|
||||||
|
} catch (err) {
|
||||||
|
setActionError(err instanceof Error ? err.message : String(err));
|
||||||
|
} finally {
|
||||||
|
setIsActing(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async () => {
|
||||||
|
if (activeModal?.type !== "delete") return;
|
||||||
|
setIsActing(true);
|
||||||
|
try {
|
||||||
|
await deleteResourceCmd(clusterId, "daemonsets", namespace, activeModal.ds.name);
|
||||||
|
setActiveModal(null);
|
||||||
|
onRefresh?.();
|
||||||
|
} finally {
|
||||||
|
setIsActing(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
|
{actionError && (
|
||||||
|
<p className="mb-2 text-sm text-destructive">{actionError}</p>
|
||||||
|
)}
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
@ -21,12 +81,13 @@ export function DaemonSetList({ daemonsets, clusterId: _clusterId, namespace: _n
|
|||||||
<TableHead>Up-to-date</TableHead>
|
<TableHead>Up-to-date</TableHead>
|
||||||
<TableHead>Available</TableHead>
|
<TableHead>Available</TableHead>
|
||||||
<TableHead>Age</TableHead>
|
<TableHead>Age</TableHead>
|
||||||
|
<TableHead className="text-right">Actions</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{daemonsets.length === 0 ? (
|
{daemonsets.length === 0 ? (
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell colSpan={7} className="text-center text-muted-foreground">
|
<TableCell colSpan={8} className="text-center text-muted-foreground">
|
||||||
No daemonsets found
|
No daemonsets found
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
@ -40,11 +101,69 @@ export function DaemonSetList({ daemonsets, clusterId: _clusterId, namespace: _n
|
|||||||
<TableCell>{ds.up_to_date}</TableCell>
|
<TableCell>{ds.up_to_date}</TableCell>
|
||||||
<TableCell>{ds.available}</TableCell>
|
<TableCell>{ds.available}</TableCell>
|
||||||
<TableCell className="text-muted-foreground">{ds.age}</TableCell>
|
<TableCell className="text-muted-foreground">{ds.age}</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
<ResourceActionMenu
|
||||||
|
actions={[
|
||||||
|
{
|
||||||
|
label: "Restart",
|
||||||
|
icon: RotateCcw,
|
||||||
|
onClick: () => setActiveModal({ type: "restart", ds }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Edit",
|
||||||
|
icon: Pencil,
|
||||||
|
onClick: () => openEdit(ds),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Delete",
|
||||||
|
icon: Trash2,
|
||||||
|
variant: "destructive",
|
||||||
|
onClick: () => setActiveModal({ type: "delete", ds }),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{activeModal?.type === "restart" && (
|
||||||
|
<ConfirmDeleteDialog
|
||||||
|
open
|
||||||
|
onOpenChange={(o) => { if (!o) setActiveModal(null); }}
|
||||||
|
resourceType="DaemonSet"
|
||||||
|
resourceName={activeModal.ds.name}
|
||||||
|
isLoading={isActing}
|
||||||
|
onConfirm={handleRestart}
|
||||||
|
variant="delete"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeModal?.type === "edit" && (
|
||||||
|
<EditResourceModal
|
||||||
|
isOpen
|
||||||
|
clusterId={clusterId}
|
||||||
|
namespace={namespace}
|
||||||
|
resourceType="daemonsets"
|
||||||
|
resourceName={activeModal.ds.name}
|
||||||
|
initialYaml={activeModal.yaml}
|
||||||
|
onClose={() => { setActiveModal(null); onRefresh?.(); }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeModal?.type === "delete" && (
|
||||||
|
<ConfirmDeleteDialog
|
||||||
|
open
|
||||||
|
onOpenChange={(o) => { if (!o) setActiveModal(null); }}
|
||||||
|
resourceType="DaemonSet"
|
||||||
|
resourceName={activeModal.ds.name}
|
||||||
|
isLoading={isActing}
|
||||||
|
onConfirm={handleDelete}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,89 +1,94 @@
|
|||||||
import React, { useState } from "react";
|
import React, { useState } from "react";
|
||||||
import { invoke } from "@tauri-apps/api/core";
|
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui";
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui";
|
||||||
import { Button } from "@/components/ui";
|
import { Scale, RotateCcw, Undo2, Pencil, Trash2 } from "lucide-react";
|
||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui";
|
|
||||||
import { Input } from "@/components/ui";
|
|
||||||
import { Label } from "@/components/ui";
|
|
||||||
import { Alert, AlertDescription } from "@/components/ui";
|
|
||||||
import { AlertCircle, RotateCcw, Scale } from "lucide-react";
|
|
||||||
import type { DeploymentInfo } from "@/lib/tauriCommands";
|
import type { DeploymentInfo } from "@/lib/tauriCommands";
|
||||||
|
import {
|
||||||
|
scaleDeploymentCmd,
|
||||||
|
restartDeploymentCmd,
|
||||||
|
rollbackDeploymentCmd,
|
||||||
|
deleteResourceCmd,
|
||||||
|
getResourceYamlCmd,
|
||||||
|
} from "@/lib/tauriCommands";
|
||||||
|
import { ResourceActionMenu } from "./ResourceActionMenu";
|
||||||
|
import { ConfirmDeleteDialog } from "./ConfirmDeleteDialog";
|
||||||
|
import { ScaleModal } from "./ScaleModal";
|
||||||
|
import { EditResourceModal } from "./EditResourceModal";
|
||||||
|
|
||||||
interface DeploymentListProps {
|
interface DeploymentListProps {
|
||||||
deployments: DeploymentInfo[];
|
deployments: DeploymentInfo[];
|
||||||
clusterId: string;
|
clusterId: string;
|
||||||
namespace: string;
|
namespace: string;
|
||||||
|
onRefresh?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DeploymentList({ deployments, clusterId, namespace }: DeploymentListProps) {
|
type ActiveModal =
|
||||||
const [scalingDeployment, setScalingDeployment] = useState<DeploymentInfo | null>(null);
|
| { type: "scale"; deployment: DeploymentInfo }
|
||||||
const [replicas, setReplicas] = useState<string>("");
|
| { type: "restart"; deployment: DeploymentInfo }
|
||||||
const [isScaling, setIsScaling] = useState(false);
|
| { type: "rollback"; deployment: DeploymentInfo }
|
||||||
const [scaleError, setScaleError] = useState<string | null>(null);
|
| { type: "edit"; deployment: DeploymentInfo; yaml: string }
|
||||||
|
| { type: "delete"; deployment: DeploymentInfo }
|
||||||
|
| null;
|
||||||
|
|
||||||
const [restartingDeployment, setRestartingDeployment] = useState<DeploymentInfo | null>(null);
|
export function DeploymentList({ deployments, clusterId, namespace, onRefresh }: DeploymentListProps) {
|
||||||
const [isRestarting, setIsRestarting] = useState(false);
|
const [activeModal, setActiveModal] = useState<ActiveModal>(null);
|
||||||
const [restartError, setRestartError] = useState<string | null>(null);
|
const [isActing, setIsActing] = useState(false);
|
||||||
|
const [actionError, setActionError] = useState<string | null>(null);
|
||||||
const handleScaleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
||||||
setReplicas(e.target.value);
|
|
||||||
setScaleError(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleScaleSubmit = async () => {
|
|
||||||
if (!scalingDeployment) return;
|
|
||||||
|
|
||||||
const newReplicas = parseInt(replicas, 10);
|
|
||||||
if (isNaN(newReplicas) || newReplicas < 0) {
|
|
||||||
setScaleError("Invalid replica count");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setIsScaling(true);
|
|
||||||
setScaleError(null);
|
|
||||||
|
|
||||||
|
const openEdit = async (deployment: DeploymentInfo) => {
|
||||||
|
setActionError(null);
|
||||||
try {
|
try {
|
||||||
await invoke<void>("scale_deployment", {
|
const yaml = await getResourceYamlCmd(clusterId, "deployments", namespace, deployment.name);
|
||||||
clusterId,
|
setActiveModal({ type: "edit", deployment, yaml });
|
||||||
namespace,
|
|
||||||
deploymentName: scalingDeployment.name,
|
|
||||||
replicas: newReplicas,
|
|
||||||
});
|
|
||||||
|
|
||||||
setScalingDeployment(null);
|
|
||||||
setReplicas("");
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Failed to scale deployment:", err);
|
setActionError(err instanceof Error ? err.message : String(err));
|
||||||
setScaleError(err instanceof Error ? err.message : "Failed to scale deployment");
|
|
||||||
} finally {
|
|
||||||
setIsScaling(false);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleRestartSubmit = async () => {
|
const handleRestart = async () => {
|
||||||
if (!restartingDeployment) return;
|
if (activeModal?.type !== "restart") return;
|
||||||
|
setIsActing(true);
|
||||||
setIsRestarting(true);
|
|
||||||
setRestartError(null);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await invoke<void>("restart_deployment", {
|
await restartDeploymentCmd(clusterId, namespace, activeModal.deployment.name);
|
||||||
clusterId,
|
setActiveModal(null);
|
||||||
namespace,
|
onRefresh?.();
|
||||||
deploymentName: restartingDeployment.name,
|
|
||||||
});
|
|
||||||
|
|
||||||
setRestartingDeployment(null);
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Failed to restart deployment:", err);
|
setActionError(err instanceof Error ? err.message : String(err));
|
||||||
setRestartError(err instanceof Error ? err.message : "Failed to restart deployment");
|
|
||||||
} finally {
|
} finally {
|
||||||
setIsRestarting(false);
|
setIsActing(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRollback = async () => {
|
||||||
|
if (activeModal?.type !== "rollback") return;
|
||||||
|
setIsActing(true);
|
||||||
|
try {
|
||||||
|
await rollbackDeploymentCmd(clusterId, namespace, activeModal.deployment.name);
|
||||||
|
setActiveModal(null);
|
||||||
|
onRefresh?.();
|
||||||
|
} catch (err) {
|
||||||
|
setActionError(err instanceof Error ? err.message : String(err));
|
||||||
|
} finally {
|
||||||
|
setIsActing(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async () => {
|
||||||
|
if (activeModal?.type !== "delete") return;
|
||||||
|
setIsActing(true);
|
||||||
|
try {
|
||||||
|
await deleteResourceCmd(clusterId, "deployments", namespace, activeModal.deployment.name);
|
||||||
|
setActiveModal(null);
|
||||||
|
onRefresh?.();
|
||||||
|
} finally {
|
||||||
|
setIsActing(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
{actionError && (
|
||||||
|
<p className="mb-2 text-sm text-destructive">{actionError}</p>
|
||||||
|
)}
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
@ -114,24 +119,36 @@ export function DeploymentList({ deployments, clusterId, namespace }: Deployment
|
|||||||
<TableCell>{deployment.replicas}</TableCell>
|
<TableCell>{deployment.replicas}</TableCell>
|
||||||
<TableCell className="text-muted-foreground">{deployment.age}</TableCell>
|
<TableCell className="text-muted-foreground">{deployment.age}</TableCell>
|
||||||
<TableCell className="text-right">
|
<TableCell className="text-right">
|
||||||
<div className="flex items-center justify-end gap-2">
|
<ResourceActionMenu
|
||||||
<Button
|
actions={[
|
||||||
variant="outline"
|
{
|
||||||
size="sm"
|
label: "Scale",
|
||||||
onClick={() => setScalingDeployment(deployment)}
|
icon: Scale,
|
||||||
>
|
onClick: () => setActiveModal({ type: "scale", deployment }),
|
||||||
<Scale className="w-4 h-4" />
|
},
|
||||||
Scale
|
{
|
||||||
</Button>
|
label: "Restart",
|
||||||
<Button
|
icon: RotateCcw,
|
||||||
variant="outline"
|
onClick: () => setActiveModal({ type: "restart", deployment }),
|
||||||
size="sm"
|
},
|
||||||
onClick={() => setRestartingDeployment(deployment)}
|
{
|
||||||
>
|
label: "Rollback",
|
||||||
<RotateCcw className="w-4 h-4" />
|
icon: Undo2,
|
||||||
Restart
|
onClick: () => setActiveModal({ type: "rollback", deployment }),
|
||||||
</Button>
|
},
|
||||||
</div>
|
{
|
||||||
|
label: "Edit",
|
||||||
|
icon: Pencil,
|
||||||
|
onClick: () => openEdit(deployment),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Delete",
|
||||||
|
icon: Trash2,
|
||||||
|
variant: "destructive",
|
||||||
|
onClick: () => setActiveModal({ type: "delete", deployment }),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))
|
))
|
||||||
@ -140,69 +157,68 @@ export function DeploymentList({ deployments, clusterId, namespace }: Deployment
|
|||||||
</Table>
|
</Table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Scale Dialog */}
|
{activeModal?.type === "scale" && (
|
||||||
<Dialog open={!!scalingDeployment} onOpenChange={() => setScalingDeployment(null)}>
|
<ScaleModal
|
||||||
<DialogContent>
|
open
|
||||||
<DialogHeader>
|
onOpenChange={(o) => { if (!o) setActiveModal(null); }}
|
||||||
<DialogTitle>Scale Deployment</DialogTitle>
|
resourceType="Deployment"
|
||||||
</DialogHeader>
|
resourceName={activeModal.deployment.name}
|
||||||
<div className="space-y-4">
|
currentReplicas={activeModal.deployment.replicas}
|
||||||
<div>
|
onScale={(replicas) =>
|
||||||
<Label htmlFor="replicas">Replica Count</Label>
|
scaleDeploymentCmd(clusterId, namespace, activeModal.deployment.name, replicas).then(() => {
|
||||||
<Input
|
setActiveModal(null);
|
||||||
id="replicas"
|
onRefresh?.();
|
||||||
type="number"
|
})
|
||||||
value={replicas}
|
}
|
||||||
onChange={handleScaleChange}
|
|
||||||
placeholder="Enter replica count"
|
|
||||||
min="0"
|
|
||||||
/>
|
/>
|
||||||
{scaleError && (
|
|
||||||
<Alert variant="destructive" className="mt-2">
|
|
||||||
<AlertCircle className="h-4 w-4" />
|
|
||||||
<AlertDescription>{scaleError}</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<DialogFooter>
|
|
||||||
<Button variant="outline" onClick={() => setScalingDeployment(null)}>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button onClick={handleScaleSubmit} disabled={isScaling}>
|
|
||||||
{isScaling ? "Scaling..." : "Scale"}
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
|
|
||||||
{/* Restart Dialog */}
|
{activeModal?.type === "restart" && (
|
||||||
<Dialog open={!!restartingDeployment} onOpenChange={() => setRestartingDeployment(null)}>
|
<ConfirmDeleteDialog
|
||||||
<DialogContent>
|
open
|
||||||
<DialogHeader>
|
onOpenChange={(o) => { if (!o) setActiveModal(null); }}
|
||||||
<DialogTitle>Restart Deployment</DialogTitle>
|
resourceType="Deployment"
|
||||||
</DialogHeader>
|
resourceName={activeModal.deployment.name}
|
||||||
<div className="space-y-4">
|
isLoading={isActing}
|
||||||
<p className="text-sm text-muted-foreground">
|
onConfirm={handleRestart}
|
||||||
This will trigger a rolling restart of the deployment.
|
variant="delete"
|
||||||
</p>
|
/>
|
||||||
{restartError && (
|
)}
|
||||||
<Alert variant="destructive">
|
|
||||||
<AlertCircle className="h-4 w-4" />
|
{activeModal?.type === "rollback" && (
|
||||||
<AlertDescription>{restartError}</AlertDescription>
|
<ConfirmDeleteDialog
|
||||||
</Alert>
|
open
|
||||||
|
onOpenChange={(o) => { if (!o) setActiveModal(null); }}
|
||||||
|
resourceType="Deployment"
|
||||||
|
resourceName={activeModal.deployment.name}
|
||||||
|
isLoading={isActing}
|
||||||
|
onConfirm={handleRollback}
|
||||||
|
variant="delete"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeModal?.type === "edit" && (
|
||||||
|
<EditResourceModal
|
||||||
|
isOpen
|
||||||
|
clusterId={clusterId}
|
||||||
|
namespace={namespace}
|
||||||
|
resourceType="deployments"
|
||||||
|
resourceName={activeModal.deployment.name}
|
||||||
|
initialYaml={activeModal.yaml}
|
||||||
|
onClose={() => { setActiveModal(null); onRefresh?.(); }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeModal?.type === "delete" && (
|
||||||
|
<ConfirmDeleteDialog
|
||||||
|
open
|
||||||
|
onOpenChange={(o) => { if (!o) setActiveModal(null); }}
|
||||||
|
resourceType="Deployment"
|
||||||
|
resourceName={activeModal.deployment.name}
|
||||||
|
isLoading={isActing}
|
||||||
|
onConfirm={handleDelete}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
|
||||||
<DialogFooter>
|
|
||||||
<Button variant="outline" onClick={() => setRestartingDeployment(null)}>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button onClick={handleRestartSubmit} disabled={isRestarting}>
|
|
||||||
{isRestarting ? "Restarting..." : "Restart"}
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
50
src/components/Kubernetes/EndpointList.tsx
Normal file
50
src/components/Kubernetes/EndpointList.tsx
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui";
|
||||||
|
import type { EndpointInfo } from "@/lib/tauriCommands";
|
||||||
|
|
||||||
|
interface EndpointListProps {
|
||||||
|
items: EndpointInfo[];
|
||||||
|
clusterId: string;
|
||||||
|
namespace?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EndpointList({ items }: EndpointListProps) {
|
||||||
|
return (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Name</TableHead>
|
||||||
|
<TableHead>Namespace</TableHead>
|
||||||
|
<TableHead>Addresses</TableHead>
|
||||||
|
<TableHead>Ports</TableHead>
|
||||||
|
<TableHead>Age</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{items.length === 0 ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={5} className="text-center text-muted-foreground">
|
||||||
|
No endpoints found
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : (
|
||||||
|
items.map((ep) => (
|
||||||
|
<TableRow key={`${ep.name}-${ep.namespace}`}>
|
||||||
|
<TableCell className="font-medium">{ep.name}</TableCell>
|
||||||
|
<TableCell className="text-sm text-muted-foreground">{ep.namespace}</TableCell>
|
||||||
|
<TableCell className="text-sm font-mono">
|
||||||
|
{ep.addresses.length > 0 ? ep.addresses.join(", ") : "—"}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-sm">
|
||||||
|
{ep.ports.length > 0 ? ep.ports.join(", ") : "—"}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-sm text-muted-foreground">{ep.age}</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
50
src/components/Kubernetes/EndpointSliceList.tsx
Normal file
50
src/components/Kubernetes/EndpointSliceList.tsx
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui";
|
||||||
|
import type { EndpointSliceInfo } from "@/lib/tauriCommands";
|
||||||
|
|
||||||
|
interface EndpointSliceListProps {
|
||||||
|
items: EndpointSliceInfo[];
|
||||||
|
clusterId: string;
|
||||||
|
namespace?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EndpointSliceList({ items }: EndpointSliceListProps) {
|
||||||
|
return (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Name</TableHead>
|
||||||
|
<TableHead>Namespace</TableHead>
|
||||||
|
<TableHead>Address Type</TableHead>
|
||||||
|
<TableHead>Endpoints</TableHead>
|
||||||
|
<TableHead>Ports</TableHead>
|
||||||
|
<TableHead>Age</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{items.length === 0 ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={6} className="text-center text-muted-foreground">
|
||||||
|
No endpoint slices found
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : (
|
||||||
|
items.map((eps) => (
|
||||||
|
<TableRow key={`${eps.name}-${eps.namespace}`}>
|
||||||
|
<TableCell className="font-medium">{eps.name}</TableCell>
|
||||||
|
<TableCell className="text-sm text-muted-foreground">{eps.namespace}</TableCell>
|
||||||
|
<TableCell className="text-sm font-mono">{eps.address_type}</TableCell>
|
||||||
|
<TableCell className="text-sm">{eps.endpoints}</TableCell>
|
||||||
|
<TableCell className="text-sm">
|
||||||
|
{eps.ports.length > 0 ? eps.ports.join(", ") : "—"}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-sm text-muted-foreground">{eps.age}</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,15 +1,67 @@
|
|||||||
import React from "react";
|
import React, { useState } from "react";
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui";
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui";
|
||||||
|
import { Pencil, Trash2 } from "lucide-react";
|
||||||
import type { HorizontalPodAutoscalerInfo } from "@/lib/tauriCommands";
|
import type { HorizontalPodAutoscalerInfo } from "@/lib/tauriCommands";
|
||||||
|
import { deleteResourceCmd, getResourceYamlCmd } from "@/lib/tauriCommands";
|
||||||
|
import { ResourceActionMenu } from "./ResourceActionMenu";
|
||||||
|
import { ConfirmDeleteDialog } from "./ConfirmDeleteDialog";
|
||||||
|
import { EditResourceModal } from "./EditResourceModal";
|
||||||
|
|
||||||
interface HPAListProps {
|
interface HPAListProps {
|
||||||
hpas: HorizontalPodAutoscalerInfo[];
|
hpas: HorizontalPodAutoscalerInfo[];
|
||||||
_clusterId: string;
|
clusterId?: string;
|
||||||
_namespace: string;
|
_clusterId?: string;
|
||||||
|
namespace?: string;
|
||||||
|
_namespace?: string;
|
||||||
|
onRefresh?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function HPAList({ hpas, _clusterId, _namespace }: HPAListProps) {
|
type ActiveModal =
|
||||||
|
| { type: "edit"; hpa: HorizontalPodAutoscalerInfo; yaml: string }
|
||||||
|
| { type: "delete"; hpa: HorizontalPodAutoscalerInfo }
|
||||||
|
| null;
|
||||||
|
|
||||||
|
export function HPAList({
|
||||||
|
hpas,
|
||||||
|
clusterId,
|
||||||
|
_clusterId,
|
||||||
|
namespace,
|
||||||
|
_namespace,
|
||||||
|
onRefresh,
|
||||||
|
}: HPAListProps) {
|
||||||
|
const cid = clusterId ?? _clusterId ?? "";
|
||||||
|
const ns = namespace ?? _namespace ?? "";
|
||||||
|
const [activeModal, setActiveModal] = useState<ActiveModal>(null);
|
||||||
|
const [isDeleting, setIsDeleting] = useState(false);
|
||||||
|
const [actionError, setActionError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const openEdit = async (hpa: HorizontalPodAutoscalerInfo) => {
|
||||||
|
setActionError(null);
|
||||||
|
try {
|
||||||
|
const yaml = await getResourceYamlCmd(cid, "horizontalpodautoscalers", ns, hpa.name);
|
||||||
|
setActiveModal({ type: "edit", hpa, yaml });
|
||||||
|
} catch (err) {
|
||||||
|
setActionError(err instanceof Error ? err.message : String(err));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async () => {
|
||||||
|
if (activeModal?.type !== "delete") return;
|
||||||
|
setIsDeleting(true);
|
||||||
|
try {
|
||||||
|
await deleteResourceCmd(cid, "horizontalpodautoscalers", ns, activeModal.hpa.name);
|
||||||
|
setActiveModal(null);
|
||||||
|
onRefresh?.();
|
||||||
|
} finally {
|
||||||
|
setIsDeleting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
|
{actionError && (
|
||||||
|
<p className="mb-2 text-sm text-destructive">{actionError}</p>
|
||||||
|
)}
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
@ -21,12 +73,13 @@ export function HPAList({ hpas, _clusterId, _namespace }: HPAListProps) {
|
|||||||
<TableHead>Current Replicas</TableHead>
|
<TableHead>Current Replicas</TableHead>
|
||||||
<TableHead>Desired Replicas</TableHead>
|
<TableHead>Desired Replicas</TableHead>
|
||||||
<TableHead>Age</TableHead>
|
<TableHead>Age</TableHead>
|
||||||
|
<TableHead className="text-right">Actions</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{hpas.length === 0 ? (
|
{hpas.length === 0 ? (
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell colSpan={7} className="text-center text-muted-foreground">
|
<TableCell colSpan={8} className="text-center text-muted-foreground">
|
||||||
No HPAs found
|
No HPAs found
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
@ -40,11 +93,52 @@ export function HPAList({ hpas, _clusterId, _namespace }: HPAListProps) {
|
|||||||
<TableCell>{hpa.current_replicas}</TableCell>
|
<TableCell>{hpa.current_replicas}</TableCell>
|
||||||
<TableCell>{hpa.desired_replicas}</TableCell>
|
<TableCell>{hpa.desired_replicas}</TableCell>
|
||||||
<TableCell className="text-muted-foreground">{hpa.age}</TableCell>
|
<TableCell className="text-muted-foreground">{hpa.age}</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
<ResourceActionMenu
|
||||||
|
actions={[
|
||||||
|
{
|
||||||
|
label: "Edit",
|
||||||
|
icon: Pencil,
|
||||||
|
onClick: () => openEdit(hpa),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Delete",
|
||||||
|
icon: Trash2,
|
||||||
|
variant: "destructive",
|
||||||
|
onClick: () => setActiveModal({ type: "delete", hpa }),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{activeModal?.type === "edit" && (
|
||||||
|
<EditResourceModal
|
||||||
|
isOpen
|
||||||
|
clusterId={cid}
|
||||||
|
namespace={ns}
|
||||||
|
resourceType="horizontalpodautoscalers"
|
||||||
|
resourceName={activeModal.hpa.name}
|
||||||
|
initialYaml={activeModal.yaml}
|
||||||
|
onClose={() => { setActiveModal(null); onRefresh?.(); }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeModal?.type === "delete" && (
|
||||||
|
<ConfirmDeleteDialog
|
||||||
|
open
|
||||||
|
onOpenChange={(o) => { if (!o) setActiveModal(null); }}
|
||||||
|
resourceType="HPA"
|
||||||
|
resourceName={activeModal.hpa.name}
|
||||||
|
isLoading={isDeleting}
|
||||||
|
onConfirm={handleDelete}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
296
src/components/Kubernetes/HelmChartList.tsx
Normal file
296
src/components/Kubernetes/HelmChartList.tsx
Normal file
@ -0,0 +1,296 @@
|
|||||||
|
import React, { useCallback, useEffect, useState } from "react";
|
||||||
|
import { Plus, RefreshCw, Search, ChevronDown, ChevronRight } from "lucide-react";
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogFooter,
|
||||||
|
Input,
|
||||||
|
Label,
|
||||||
|
Badge,
|
||||||
|
} from "@/components/ui";
|
||||||
|
import {
|
||||||
|
helmListReposCmd,
|
||||||
|
helmSearchRepoCmd,
|
||||||
|
helmAddRepoCmd,
|
||||||
|
helmUpdateReposCmd,
|
||||||
|
} from "@/lib/tauriCommands";
|
||||||
|
import type { HelmRepository, HelmChart } from "@/lib/tauriCommands";
|
||||||
|
|
||||||
|
interface HelmChartListProps {
|
||||||
|
clusterId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function HelmChartList({ clusterId }: HelmChartListProps) {
|
||||||
|
const [repos, setRepos] = useState<HelmRepository[]>([]);
|
||||||
|
const [charts, setCharts] = useState<HelmChart[]>([]);
|
||||||
|
const [selectedRepo, setSelectedRepo] = useState<string | null>(null);
|
||||||
|
const [search, setSearch] = useState("");
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [updatingRepos, setUpdatingRepos] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [expandedChart, setExpandedChart] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const [addRepoOpen, setAddRepoOpen] = useState(false);
|
||||||
|
const [newRepoName, setNewRepoName] = useState("");
|
||||||
|
const [newRepoUrl, setNewRepoUrl] = useState("");
|
||||||
|
const [addingRepo, setAddingRepo] = useState(false);
|
||||||
|
const [addRepoError, setAddRepoError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const loadData = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const repoList = await helmListReposCmd(clusterId);
|
||||||
|
setRepos(repoList);
|
||||||
|
const chartList = await helmSearchRepoCmd(clusterId, "");
|
||||||
|
setCharts(chartList);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : String(err));
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [clusterId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void loadData();
|
||||||
|
}, [loadData]);
|
||||||
|
|
||||||
|
const handleUpdateRepos = async () => {
|
||||||
|
setUpdatingRepos(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
await helmUpdateReposCmd(clusterId);
|
||||||
|
await loadData();
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : String(err));
|
||||||
|
} finally {
|
||||||
|
setUpdatingRepos(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAddRepo = async () => {
|
||||||
|
if (!newRepoName.trim() || !newRepoUrl.trim()) return;
|
||||||
|
setAddingRepo(true);
|
||||||
|
setAddRepoError(null);
|
||||||
|
try {
|
||||||
|
await helmAddRepoCmd(clusterId, newRepoName.trim(), newRepoUrl.trim());
|
||||||
|
setAddRepoOpen(false);
|
||||||
|
setNewRepoName("");
|
||||||
|
setNewRepoUrl("");
|
||||||
|
await loadData();
|
||||||
|
} catch (err) {
|
||||||
|
setAddRepoError(err instanceof Error ? err.message : String(err));
|
||||||
|
} finally {
|
||||||
|
setAddingRepo(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const filteredCharts = charts.filter((c) => {
|
||||||
|
const matchesRepo = selectedRepo == null || c.repository === selectedRepo;
|
||||||
|
const matchesSearch =
|
||||||
|
search.trim() === "" ||
|
||||||
|
c.name.toLowerCase().includes(search.toLowerCase()) ||
|
||||||
|
c.description.toLowerCase().includes(search.toLowerCase());
|
||||||
|
return matchesRepo && matchesSearch;
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-4 h-full">
|
||||||
|
{/* Toolbar */}
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => void handleUpdateRepos()}
|
||||||
|
disabled={updatingRepos}
|
||||||
|
>
|
||||||
|
<RefreshCw className={`h-3.5 w-3.5 mr-1 ${updatingRepos ? "animate-spin" : ""}`} />
|
||||||
|
Update Repos
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" variant="outline" onClick={() => setAddRepoOpen(true)}>
|
||||||
|
<Plus className="h-3.5 w-3.5 mr-1" />
|
||||||
|
Add Repository
|
||||||
|
</Button>
|
||||||
|
<div className="relative flex-1 min-w-[200px]">
|
||||||
|
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
placeholder="Search charts…"
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
className="pl-9"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="rounded-md border border-destructive/50 bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex gap-4 flex-1 min-h-0 overflow-hidden">
|
||||||
|
{/* Repository sidebar */}
|
||||||
|
<div className="w-48 flex-shrink-0 border rounded-md overflow-y-auto">
|
||||||
|
<div className="px-3 py-2 border-b text-xs font-semibold text-muted-foreground uppercase tracking-wide">
|
||||||
|
Repositories
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={`px-3 py-2 text-sm cursor-pointer transition-colors ${
|
||||||
|
selectedRepo == null ? "bg-accent text-accent-foreground" : "hover:bg-muted/50"
|
||||||
|
}`}
|
||||||
|
onClick={() => setSelectedRepo(null)}
|
||||||
|
>
|
||||||
|
All repositories
|
||||||
|
</div>
|
||||||
|
{repos.map((repo) => (
|
||||||
|
<div
|
||||||
|
key={repo.name}
|
||||||
|
className={`px-3 py-2 text-sm cursor-pointer transition-colors truncate ${
|
||||||
|
selectedRepo === repo.name
|
||||||
|
? "bg-accent text-accent-foreground"
|
||||||
|
: "hover:bg-muted/50"
|
||||||
|
}`}
|
||||||
|
title={repo.name}
|
||||||
|
onClick={() => setSelectedRepo(repo.name)}
|
||||||
|
>
|
||||||
|
{repo.name}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{repos.length === 0 && !loading && (
|
||||||
|
<div className="px-3 py-4 text-xs text-muted-foreground">No repos</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Charts table */}
|
||||||
|
<div className="flex-1 overflow-auto border rounded-md">
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex items-center justify-center h-32 text-muted-foreground">
|
||||||
|
<RefreshCw className="h-5 w-5 animate-spin mr-2" />
|
||||||
|
Loading charts…
|
||||||
|
</div>
|
||||||
|
) : repos.length === 0 ? (
|
||||||
|
<div className="flex flex-col items-center justify-center h-32 text-center gap-2 text-muted-foreground text-sm px-4">
|
||||||
|
<p>No helm repositories configured.</p>
|
||||||
|
<p>Add a repository to get started.</p>
|
||||||
|
</div>
|
||||||
|
) : filteredCharts.length === 0 ? (
|
||||||
|
<div className="flex items-center justify-center h-32 text-muted-foreground text-sm">
|
||||||
|
No charts match your search.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b text-muted-foreground">
|
||||||
|
<th className="text-left px-4 py-3 font-medium">Name</th>
|
||||||
|
<th className="text-left px-4 py-3 font-medium">Version</th>
|
||||||
|
<th className="text-left px-4 py-3 font-medium">App Version</th>
|
||||||
|
<th className="text-left px-4 py-3 font-medium">Repository</th>
|
||||||
|
<th className="text-left px-4 py-3 font-medium">Description</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{filteredCharts.map((chart) => {
|
||||||
|
const key = `${chart.repository}/${chart.name}`;
|
||||||
|
const isExpanded = expandedChart === key;
|
||||||
|
return (
|
||||||
|
<React.Fragment key={key}>
|
||||||
|
<tr
|
||||||
|
className="border-b last:border-0 hover:bg-muted/30 transition-colors cursor-pointer"
|
||||||
|
onClick={() => setExpandedChart(isExpanded ? null : key)}
|
||||||
|
>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<div className="flex items-center gap-1.5 font-medium">
|
||||||
|
{isExpanded ? (
|
||||||
|
<ChevronDown className="h-3.5 w-3.5 text-muted-foreground flex-shrink-0" />
|
||||||
|
) : (
|
||||||
|
<ChevronRight className="h-3.5 w-3.5 text-muted-foreground flex-shrink-0" />
|
||||||
|
)}
|
||||||
|
{chart.name.includes("/") ? chart.name.split("/").slice(1).join("/") : chart.name}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 font-mono text-xs">{chart.chart_version}</td>
|
||||||
|
<td className="px-4 py-3 font-mono text-xs">{chart.app_version || "—"}</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<Badge variant="secondary" className="text-xs">
|
||||||
|
{chart.repository}
|
||||||
|
</Badge>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-muted-foreground max-w-xs truncate">
|
||||||
|
{chart.description || "—"}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{isExpanded && (
|
||||||
|
<tr className="border-b bg-muted/20">
|
||||||
|
<td colSpan={5} className="px-6 py-3">
|
||||||
|
<div className="space-y-1.5 text-sm">
|
||||||
|
<div className="font-medium">
|
||||||
|
{chart.repository}/{chart.name}
|
||||||
|
</div>
|
||||||
|
<div className="text-muted-foreground">{chart.description || "No description available."}</div>
|
||||||
|
<div className="flex gap-4 text-xs text-muted-foreground">
|
||||||
|
<span>Chart: {chart.chart_version}</span>
|
||||||
|
{chart.app_version && <span>App: {chart.app_version}</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</React.Fragment>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Add Repository Dialog */}
|
||||||
|
<Dialog open={addRepoOpen} onOpenChange={setAddRepoOpen}>
|
||||||
|
<DialogContent className="max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Add Helm Repository</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="flex flex-col gap-4 py-2">
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label htmlFor="repo-name">Name</Label>
|
||||||
|
<Input
|
||||||
|
id="repo-name"
|
||||||
|
placeholder="e.g. stable"
|
||||||
|
value={newRepoName}
|
||||||
|
onChange={(e) => setNewRepoName(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label htmlFor="repo-url">URL</Label>
|
||||||
|
<Input
|
||||||
|
id="repo-url"
|
||||||
|
placeholder="https://charts.helm.sh/stable"
|
||||||
|
value={newRepoUrl}
|
||||||
|
onChange={(e) => setNewRepoUrl(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{addRepoError && (
|
||||||
|
<div className="rounded-md border border-destructive/50 bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
||||||
|
{addRepoError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setAddRepoOpen(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => void handleAddRepo()}
|
||||||
|
disabled={addingRepo || !newRepoName.trim() || !newRepoUrl.trim()}
|
||||||
|
>
|
||||||
|
{addingRepo ? "Adding…" : "Add"}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
262
src/components/Kubernetes/HelmReleaseList.tsx
Normal file
262
src/components/Kubernetes/HelmReleaseList.tsx
Normal file
@ -0,0 +1,262 @@
|
|||||||
|
import React, { useCallback, useEffect, useState } from "react";
|
||||||
|
import { MoreHorizontal, RefreshCw } from "lucide-react";
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Badge,
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogFooter,
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "@/components/ui";
|
||||||
|
import { helmListReleasesCmd, helmRollbackCmd, helmUninstallCmd } from "@/lib/tauriCommands";
|
||||||
|
import type { HelmRelease } from "@/lib/tauriCommands";
|
||||||
|
|
||||||
|
interface HelmReleaseListProps {
|
||||||
|
clusterId: string;
|
||||||
|
namespace: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
type ConfirmAction =
|
||||||
|
| { type: "rollback"; release: HelmRelease }
|
||||||
|
| { type: "uninstall"; release: HelmRelease };
|
||||||
|
|
||||||
|
function statusVariant(
|
||||||
|
status: string
|
||||||
|
): "success" | "destructive" | "secondary" | "default" {
|
||||||
|
switch (status.toLowerCase()) {
|
||||||
|
case "deployed":
|
||||||
|
return "success";
|
||||||
|
case "failed":
|
||||||
|
return "destructive";
|
||||||
|
case "pending-install":
|
||||||
|
case "pending-upgrade":
|
||||||
|
case "pending-rollback":
|
||||||
|
return "default";
|
||||||
|
case "superseded":
|
||||||
|
return "secondary";
|
||||||
|
default:
|
||||||
|
return "secondary";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusLabel(status: string): string {
|
||||||
|
return status
|
||||||
|
.split("-")
|
||||||
|
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
|
||||||
|
.join(" ");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function HelmReleaseList({ clusterId, namespace }: HelmReleaseListProps) {
|
||||||
|
const [releases, setReleases] = useState<HelmRelease[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [openMenuId, setOpenMenuId] = useState<string | null>(null);
|
||||||
|
const [confirmAction, setConfirmAction] = useState<ConfirmAction | null>(null);
|
||||||
|
const [actionInProgress, setActionInProgress] = useState(false);
|
||||||
|
const [actionError, setActionError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const loadReleases = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const data = await helmListReleasesCmd(clusterId, namespace);
|
||||||
|
setReleases(data);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : String(err));
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [clusterId, namespace]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void loadReleases();
|
||||||
|
}, [loadReleases]);
|
||||||
|
|
||||||
|
const handleConfirm = async () => {
|
||||||
|
if (!confirmAction) return;
|
||||||
|
setActionInProgress(true);
|
||||||
|
setActionError(null);
|
||||||
|
try {
|
||||||
|
const { release } = confirmAction;
|
||||||
|
if (confirmAction.type === "rollback") {
|
||||||
|
await helmRollbackCmd(clusterId, release.namespace, release.name);
|
||||||
|
} else {
|
||||||
|
await helmUninstallCmd(clusterId, release.namespace, release.name);
|
||||||
|
setReleases((prev) => prev.filter((r) => r.name !== release.name));
|
||||||
|
}
|
||||||
|
setConfirmAction(null);
|
||||||
|
if (confirmAction.type === "rollback") {
|
||||||
|
await loadReleases();
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setActionError(err instanceof Error ? err.message : String(err));
|
||||||
|
} finally {
|
||||||
|
setActionInProgress(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center h-32 text-muted-foreground">
|
||||||
|
<RefreshCw className="h-5 w-5 animate-spin mr-2" />
|
||||||
|
Loading releases…
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
{releases.length} release{releases.length !== 1 ? "s" : ""}
|
||||||
|
</span>
|
||||||
|
<Button size="sm" variant="outline" onClick={() => void loadReleases()}>
|
||||||
|
<RefreshCw className="h-3.5 w-3.5 mr-1" />
|
||||||
|
Refresh
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="rounded-md border border-destructive/50 bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="border rounded-md overflow-hidden">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Name</TableHead>
|
||||||
|
<TableHead>Namespace</TableHead>
|
||||||
|
<TableHead>Chart</TableHead>
|
||||||
|
<TableHead>Chart Version</TableHead>
|
||||||
|
<TableHead>App Version</TableHead>
|
||||||
|
<TableHead>Status</TableHead>
|
||||||
|
<TableHead>Updated</TableHead>
|
||||||
|
<TableHead className="w-12" />
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{releases.length === 0 ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={8} className="text-center text-muted-foreground">
|
||||||
|
No releases found
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : (
|
||||||
|
releases.map((release) => {
|
||||||
|
const menuKey = `${release.namespace}/${release.name}`;
|
||||||
|
return (
|
||||||
|
<TableRow key={menuKey}>
|
||||||
|
<TableCell className="font-medium">{release.name}</TableCell>
|
||||||
|
<TableCell className="text-muted-foreground">{release.namespace}</TableCell>
|
||||||
|
<TableCell className="font-mono text-xs">{release.chart}</TableCell>
|
||||||
|
<TableCell className="font-mono text-xs">{release.chart_version}</TableCell>
|
||||||
|
<TableCell className="font-mono text-xs">{release.app_version || "—"}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant={statusVariant(release.status)}>
|
||||||
|
{statusLabel(release.status)}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-muted-foreground text-xs">{release.updated}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="relative">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() =>
|
||||||
|
setOpenMenuId(openMenuId === menuKey ? null : menuKey)
|
||||||
|
}
|
||||||
|
aria-label="Actions"
|
||||||
|
>
|
||||||
|
<MoreHorizontal className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
{openMenuId === menuKey && (
|
||||||
|
<div
|
||||||
|
className="absolute right-0 top-full mt-1 z-50 w-36 rounded-md border bg-card shadow-md"
|
||||||
|
onMouseLeave={() => setOpenMenuId(null)}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
className="w-full text-left px-3 py-2 text-sm hover:bg-accent hover:text-accent-foreground transition-colors"
|
||||||
|
onClick={() => {
|
||||||
|
setOpenMenuId(null);
|
||||||
|
setConfirmAction({ type: "rollback", release });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Rollback
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="w-full text-left px-3 py-2 text-sm text-destructive hover:bg-destructive/10 transition-colors"
|
||||||
|
onClick={() => {
|
||||||
|
setOpenMenuId(null);
|
||||||
|
setConfirmAction({ type: "uninstall", release });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Uninstall
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Confirm dialog */}
|
||||||
|
<Dialog open={confirmAction != null} onOpenChange={(o) => { if (!o) setConfirmAction(null); }}>
|
||||||
|
<DialogContent className="max-w-sm">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>
|
||||||
|
{confirmAction?.type === "rollback" ? "Rollback Release" : "Uninstall Release"}
|
||||||
|
</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{confirmAction?.type === "rollback" ? (
|
||||||
|
<>
|
||||||
|
Roll back <span className="font-medium text-foreground">{confirmAction.release.name}</span> to the
|
||||||
|
previous revision? This cannot be undone without a re-deploy.
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
Permanently uninstall <span className="font-medium text-foreground">{confirmAction?.release.name}</span>?
|
||||||
|
All Kubernetes resources created by this release will be removed.
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
{actionError && (
|
||||||
|
<div className="rounded-md border border-destructive/50 bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
||||||
|
{actionError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setConfirmAction(null)} disabled={actionInProgress}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant={confirmAction?.type === "uninstall" ? "destructive" : "default"}
|
||||||
|
onClick={() => void handleConfirm()}
|
||||||
|
disabled={actionInProgress}
|
||||||
|
>
|
||||||
|
{actionInProgress
|
||||||
|
? "Working…"
|
||||||
|
: confirmAction?.type === "rollback"
|
||||||
|
? "Rollback"
|
||||||
|
: "Uninstall"}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
50
src/components/Kubernetes/IngressClassList.tsx
Normal file
50
src/components/Kubernetes/IngressClassList.tsx
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow, Badge } from "@/components/ui";
|
||||||
|
import type { IngressClassInfo } from "@/lib/tauriCommands";
|
||||||
|
|
||||||
|
interface IngressClassListProps {
|
||||||
|
items: IngressClassInfo[];
|
||||||
|
clusterId: string;
|
||||||
|
namespace?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function IngressClassList({ items }: IngressClassListProps) {
|
||||||
|
return (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Name</TableHead>
|
||||||
|
<TableHead>Controller</TableHead>
|
||||||
|
<TableHead>Default</TableHead>
|
||||||
|
<TableHead>Age</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{items.length === 0 ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={4} className="text-center text-muted-foreground">
|
||||||
|
No ingress classes found
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : (
|
||||||
|
items.map((ic) => (
|
||||||
|
<TableRow key={ic.name}>
|
||||||
|
<TableCell className="font-medium">{ic.name}</TableCell>
|
||||||
|
<TableCell className="text-sm font-mono">{ic.controller}</TableCell>
|
||||||
|
<TableCell className="text-sm">
|
||||||
|
{ic.is_default ? (
|
||||||
|
<Badge variant="success">Yes</Badge>
|
||||||
|
) : (
|
||||||
|
<span className="text-muted-foreground">No</span>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-sm text-muted-foreground">{ic.age}</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,15 +1,67 @@
|
|||||||
import React from "react";
|
import React, { useState } from "react";
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui";
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui";
|
||||||
|
import { Pencil, Trash2 } from "lucide-react";
|
||||||
import type { IngressInfo } from "@/lib/tauriCommands";
|
import type { IngressInfo } from "@/lib/tauriCommands";
|
||||||
|
import { deleteResourceCmd, getResourceYamlCmd } from "@/lib/tauriCommands";
|
||||||
|
import { ResourceActionMenu } from "./ResourceActionMenu";
|
||||||
|
import { ConfirmDeleteDialog } from "./ConfirmDeleteDialog";
|
||||||
|
import { EditResourceModal } from "./EditResourceModal";
|
||||||
|
|
||||||
interface IngressListProps {
|
interface IngressListProps {
|
||||||
ingresses: IngressInfo[];
|
ingresses: IngressInfo[];
|
||||||
_clusterId: string;
|
clusterId?: string;
|
||||||
_namespace: string;
|
_clusterId?: string;
|
||||||
|
namespace?: string;
|
||||||
|
_namespace?: string;
|
||||||
|
onRefresh?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function IngressList({ ingresses, _clusterId, _namespace }: IngressListProps) {
|
type ActiveModal =
|
||||||
|
| { type: "edit"; ingress: IngressInfo; yaml: string }
|
||||||
|
| { type: "delete"; ingress: IngressInfo }
|
||||||
|
| null;
|
||||||
|
|
||||||
|
export function IngressList({
|
||||||
|
ingresses,
|
||||||
|
clusterId,
|
||||||
|
_clusterId,
|
||||||
|
namespace,
|
||||||
|
_namespace,
|
||||||
|
onRefresh,
|
||||||
|
}: IngressListProps) {
|
||||||
|
const cid = clusterId ?? _clusterId ?? "";
|
||||||
|
const ns = namespace ?? _namespace ?? "";
|
||||||
|
const [activeModal, setActiveModal] = useState<ActiveModal>(null);
|
||||||
|
const [isDeleting, setIsDeleting] = useState(false);
|
||||||
|
const [actionError, setActionError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const openEdit = async (ingress: IngressInfo) => {
|
||||||
|
setActionError(null);
|
||||||
|
try {
|
||||||
|
const yaml = await getResourceYamlCmd(cid, "ingresses", ns, ingress.name);
|
||||||
|
setActiveModal({ type: "edit", ingress, yaml });
|
||||||
|
} catch (err) {
|
||||||
|
setActionError(err instanceof Error ? err.message : String(err));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async () => {
|
||||||
|
if (activeModal?.type !== "delete") return;
|
||||||
|
setIsDeleting(true);
|
||||||
|
try {
|
||||||
|
await deleteResourceCmd(cid, "ingresses", ns, activeModal.ingress.name);
|
||||||
|
setActiveModal(null);
|
||||||
|
onRefresh?.();
|
||||||
|
} finally {
|
||||||
|
setIsDeleting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
|
{actionError && (
|
||||||
|
<p className="mb-2 text-sm text-destructive">{actionError}</p>
|
||||||
|
)}
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
@ -20,12 +72,13 @@ export function IngressList({ ingresses, _clusterId, _namespace }: IngressListPr
|
|||||||
<TableHead>Host</TableHead>
|
<TableHead>Host</TableHead>
|
||||||
<TableHead>Addresses</TableHead>
|
<TableHead>Addresses</TableHead>
|
||||||
<TableHead>Age</TableHead>
|
<TableHead>Age</TableHead>
|
||||||
|
<TableHead className="text-right">Actions</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{ingresses.length === 0 ? (
|
{ingresses.length === 0 ? (
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell colSpan={6} className="text-center text-muted-foreground">
|
<TableCell colSpan={7} className="text-center text-muted-foreground">
|
||||||
No ingresses found
|
No ingresses found
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
@ -38,11 +91,52 @@ export function IngressList({ ingresses, _clusterId, _namespace }: IngressListPr
|
|||||||
<TableCell>{ingress.host}</TableCell>
|
<TableCell>{ingress.host}</TableCell>
|
||||||
<TableCell>{ingress.addresses.join(", ")}</TableCell>
|
<TableCell>{ingress.addresses.join(", ")}</TableCell>
|
||||||
<TableCell className="text-muted-foreground">{ingress.age}</TableCell>
|
<TableCell className="text-muted-foreground">{ingress.age}</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
<ResourceActionMenu
|
||||||
|
actions={[
|
||||||
|
{
|
||||||
|
label: "Edit",
|
||||||
|
icon: Pencil,
|
||||||
|
onClick: () => openEdit(ingress),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Delete",
|
||||||
|
icon: Trash2,
|
||||||
|
variant: "destructive",
|
||||||
|
onClick: () => setActiveModal({ type: "delete", ingress }),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{activeModal?.type === "edit" && (
|
||||||
|
<EditResourceModal
|
||||||
|
isOpen
|
||||||
|
clusterId={cid}
|
||||||
|
namespace={ns}
|
||||||
|
resourceType="ingresses"
|
||||||
|
resourceName={activeModal.ingress.name}
|
||||||
|
initialYaml={activeModal.yaml}
|
||||||
|
onClose={() => { setActiveModal(null); onRefresh?.(); }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeModal?.type === "delete" && (
|
||||||
|
<ConfirmDeleteDialog
|
||||||
|
open
|
||||||
|
onOpenChange={(o) => { if (!o) setActiveModal(null); }}
|
||||||
|
resourceType="Ingress"
|
||||||
|
resourceName={activeModal.ingress.name}
|
||||||
|
isLoading={isDeleting}
|
||||||
|
onConfirm={handleDelete}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,15 +1,67 @@
|
|||||||
import React from "react";
|
import React, { useState } from "react";
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui";
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui";
|
||||||
|
import { Pencil, Trash2 } from "lucide-react";
|
||||||
import type { JobInfo } from "@/lib/tauriCommands";
|
import type { JobInfo } from "@/lib/tauriCommands";
|
||||||
|
import { deleteResourceCmd, getResourceYamlCmd } from "@/lib/tauriCommands";
|
||||||
|
import { ResourceActionMenu } from "./ResourceActionMenu";
|
||||||
|
import { ConfirmDeleteDialog } from "./ConfirmDeleteDialog";
|
||||||
|
import { EditResourceModal } from "./EditResourceModal";
|
||||||
|
|
||||||
interface JobListProps {
|
interface JobListProps {
|
||||||
jobs: JobInfo[];
|
jobs: JobInfo[];
|
||||||
_clusterId: string;
|
clusterId?: string;
|
||||||
_namespace: string;
|
_clusterId?: string;
|
||||||
|
namespace?: string;
|
||||||
|
_namespace?: string;
|
||||||
|
onRefresh?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function JobList({ jobs, _clusterId, _namespace }: JobListProps) {
|
type ActiveModal =
|
||||||
|
| { type: "edit"; job: JobInfo; yaml: string }
|
||||||
|
| { type: "delete"; job: JobInfo }
|
||||||
|
| null;
|
||||||
|
|
||||||
|
export function JobList({
|
||||||
|
jobs,
|
||||||
|
clusterId,
|
||||||
|
_clusterId,
|
||||||
|
namespace,
|
||||||
|
_namespace,
|
||||||
|
onRefresh,
|
||||||
|
}: JobListProps) {
|
||||||
|
const cid = clusterId ?? _clusterId ?? "";
|
||||||
|
const ns = namespace ?? _namespace ?? "";
|
||||||
|
const [activeModal, setActiveModal] = useState<ActiveModal>(null);
|
||||||
|
const [isDeleting, setIsDeleting] = useState(false);
|
||||||
|
const [actionError, setActionError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const openEdit = async (job: JobInfo) => {
|
||||||
|
setActionError(null);
|
||||||
|
try {
|
||||||
|
const yaml = await getResourceYamlCmd(cid, "jobs", ns, job.name);
|
||||||
|
setActiveModal({ type: "edit", job, yaml });
|
||||||
|
} catch (err) {
|
||||||
|
setActionError(err instanceof Error ? err.message : String(err));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async () => {
|
||||||
|
if (activeModal?.type !== "delete") return;
|
||||||
|
setIsDeleting(true);
|
||||||
|
try {
|
||||||
|
await deleteResourceCmd(cid, "jobs", ns, activeModal.job.name);
|
||||||
|
setActiveModal(null);
|
||||||
|
onRefresh?.();
|
||||||
|
} finally {
|
||||||
|
setIsDeleting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
|
{actionError && (
|
||||||
|
<p className="mb-2 text-sm text-destructive">{actionError}</p>
|
||||||
|
)}
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
@ -20,12 +72,13 @@ export function JobList({ jobs, _clusterId, _namespace }: JobListProps) {
|
|||||||
<TableHead>Duration</TableHead>
|
<TableHead>Duration</TableHead>
|
||||||
<TableHead>Age</TableHead>
|
<TableHead>Age</TableHead>
|
||||||
<TableHead>Labels</TableHead>
|
<TableHead>Labels</TableHead>
|
||||||
|
<TableHead className="text-right">Actions</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{jobs.length === 0 ? (
|
{jobs.length === 0 ? (
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell colSpan={6} className="text-center text-muted-foreground">
|
<TableCell colSpan={7} className="text-center text-muted-foreground">
|
||||||
No jobs found
|
No jobs found
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
@ -42,11 +95,52 @@ export function JobList({ jobs, _clusterId, _namespace }: JobListProps) {
|
|||||||
.map(([k, v]) => `${k}=${v}`)
|
.map(([k, v]) => `${k}=${v}`)
|
||||||
.join(", ")}
|
.join(", ")}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
<ResourceActionMenu
|
||||||
|
actions={[
|
||||||
|
{
|
||||||
|
label: "Edit",
|
||||||
|
icon: Pencil,
|
||||||
|
onClick: () => openEdit(job),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Delete",
|
||||||
|
icon: Trash2,
|
||||||
|
variant: "destructive",
|
||||||
|
onClick: () => setActiveModal({ type: "delete", job }),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{activeModal?.type === "edit" && (
|
||||||
|
<EditResourceModal
|
||||||
|
isOpen
|
||||||
|
clusterId={cid}
|
||||||
|
namespace={ns}
|
||||||
|
resourceType="jobs"
|
||||||
|
resourceName={activeModal.job.name}
|
||||||
|
initialYaml={activeModal.yaml}
|
||||||
|
onClose={() => { setActiveModal(null); onRefresh?.(); }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeModal?.type === "delete" && (
|
||||||
|
<ConfirmDeleteDialog
|
||||||
|
open
|
||||||
|
onOpenChange={(o) => { if (!o) setActiveModal(null); }}
|
||||||
|
resourceType="Job"
|
||||||
|
resourceName={activeModal.job.name}
|
||||||
|
isLoading={isDeleting}
|
||||||
|
onConfirm={handleDelete}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
44
src/components/Kubernetes/LeaseList.tsx
Normal file
44
src/components/Kubernetes/LeaseList.tsx
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui";
|
||||||
|
import type { LeaseInfo } from "@/lib/tauriCommands";
|
||||||
|
|
||||||
|
interface LeaseListProps {
|
||||||
|
items: LeaseInfo[];
|
||||||
|
clusterId: string;
|
||||||
|
namespace?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LeaseList({ items }: LeaseListProps) {
|
||||||
|
return (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Name</TableHead>
|
||||||
|
<TableHead>Namespace</TableHead>
|
||||||
|
<TableHead>Holder</TableHead>
|
||||||
|
<TableHead>Age</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{items.length === 0 ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={4} className="text-center text-muted-foreground">
|
||||||
|
No leases found
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : (
|
||||||
|
items.map((lease) => (
|
||||||
|
<TableRow key={`${lease.name}-${lease.namespace}`}>
|
||||||
|
<TableCell className="font-medium">{lease.name}</TableCell>
|
||||||
|
<TableCell className="text-sm text-muted-foreground">{lease.namespace}</TableCell>
|
||||||
|
<TableCell className="text-sm font-mono">{lease.holder || "—"}</TableCell>
|
||||||
|
<TableCell className="text-sm text-muted-foreground">{lease.age}</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,15 +1,56 @@
|
|||||||
import React from "react";
|
import React, { useState } from "react";
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui";
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui";
|
||||||
|
import { Pencil, Trash2 } from "lucide-react";
|
||||||
import type { LimitRangeInfo } from "@/lib/tauriCommands";
|
import type { LimitRangeInfo } from "@/lib/tauriCommands";
|
||||||
|
import { deleteResourceCmd, getResourceYamlCmd } from "@/lib/tauriCommands";
|
||||||
|
import { ResourceActionMenu } from "./ResourceActionMenu";
|
||||||
|
import { ConfirmDeleteDialog } from "./ConfirmDeleteDialog";
|
||||||
|
import { EditResourceModal } from "./EditResourceModal";
|
||||||
|
|
||||||
interface LimitRangeListProps {
|
interface LimitRangeListProps {
|
||||||
limitranges: LimitRangeInfo[];
|
limitranges: LimitRangeInfo[];
|
||||||
clusterId: string;
|
clusterId: string;
|
||||||
namespace: string;
|
namespace: string;
|
||||||
|
onRefresh?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function LimitRangeList({ limitranges }: LimitRangeListProps) {
|
type ActiveModal =
|
||||||
|
| { type: "edit"; lr: LimitRangeInfo; yaml: string }
|
||||||
|
| { type: "delete"; lr: LimitRangeInfo }
|
||||||
|
| null;
|
||||||
|
|
||||||
|
export function LimitRangeList({ limitranges, clusterId, namespace, onRefresh }: LimitRangeListProps) {
|
||||||
|
const [activeModal, setActiveModal] = useState<ActiveModal>(null);
|
||||||
|
const [isDeleting, setIsDeleting] = useState(false);
|
||||||
|
const [actionError, setActionError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const openEdit = async (lr: LimitRangeInfo) => {
|
||||||
|
setActionError(null);
|
||||||
|
try {
|
||||||
|
const yaml = await getResourceYamlCmd(clusterId, "limitranges", namespace, lr.name);
|
||||||
|
setActiveModal({ type: "edit", lr, yaml });
|
||||||
|
} catch (err) {
|
||||||
|
setActionError(err instanceof Error ? err.message : String(err));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async () => {
|
||||||
|
if (activeModal?.type !== "delete") return;
|
||||||
|
setIsDeleting(true);
|
||||||
|
try {
|
||||||
|
await deleteResourceCmd(clusterId, "limitranges", namespace, activeModal.lr.name);
|
||||||
|
setActiveModal(null);
|
||||||
|
onRefresh?.();
|
||||||
|
} finally {
|
||||||
|
setIsDeleting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
|
{actionError && (
|
||||||
|
<p className="mb-2 text-sm text-destructive">{actionError}</p>
|
||||||
|
)}
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
@ -18,12 +59,13 @@ export function LimitRangeList({ limitranges }: LimitRangeListProps) {
|
|||||||
<TableHead>Namespace</TableHead>
|
<TableHead>Namespace</TableHead>
|
||||||
<TableHead>Limits</TableHead>
|
<TableHead>Limits</TableHead>
|
||||||
<TableHead>Age</TableHead>
|
<TableHead>Age</TableHead>
|
||||||
|
<TableHead className="text-right">Actions</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{limitranges.length === 0 ? (
|
{limitranges.length === 0 ? (
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell colSpan={4} className="text-center text-muted-foreground">
|
<TableCell colSpan={5} className="text-center text-muted-foreground">
|
||||||
No limit ranges found
|
No limit ranges found
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
@ -34,11 +76,52 @@ export function LimitRangeList({ limitranges }: LimitRangeListProps) {
|
|||||||
<TableCell className="text-sm text-muted-foreground">{lr.namespace}</TableCell>
|
<TableCell className="text-sm text-muted-foreground">{lr.namespace}</TableCell>
|
||||||
<TableCell className="text-sm">{lr.limit_count}</TableCell>
|
<TableCell className="text-sm">{lr.limit_count}</TableCell>
|
||||||
<TableCell className="text-sm text-muted-foreground">{lr.age}</TableCell>
|
<TableCell className="text-sm text-muted-foreground">{lr.age}</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
<ResourceActionMenu
|
||||||
|
actions={[
|
||||||
|
{
|
||||||
|
label: "Edit",
|
||||||
|
icon: Pencil,
|
||||||
|
onClick: () => openEdit(lr),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Delete",
|
||||||
|
icon: Trash2,
|
||||||
|
variant: "destructive",
|
||||||
|
onClick: () => setActiveModal({ type: "delete", lr }),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{activeModal?.type === "edit" && (
|
||||||
|
<EditResourceModal
|
||||||
|
isOpen
|
||||||
|
clusterId={clusterId}
|
||||||
|
namespace={namespace}
|
||||||
|
resourceType="limitranges"
|
||||||
|
resourceName={activeModal.lr.name}
|
||||||
|
initialYaml={activeModal.yaml}
|
||||||
|
onClose={() => { setActiveModal(null); onRefresh?.(); }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeModal?.type === "delete" && (
|
||||||
|
<ConfirmDeleteDialog
|
||||||
|
open
|
||||||
|
onOpenChange={(o) => { if (!o) setActiveModal(null); }}
|
||||||
|
resourceType="LimitRange"
|
||||||
|
resourceName={activeModal.lr.name}
|
||||||
|
isLoading={isDeleting}
|
||||||
|
onConfirm={handleDelete}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
294
src/components/Kubernetes/LogStreamPanel.tsx
Normal file
294
src/components/Kubernetes/LogStreamPanel.tsx
Normal file
@ -0,0 +1,294 @@
|
|||||||
|
import React, { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
import { listen, type UnlistenFn } from "@tauri-apps/api/event";
|
||||||
|
import { Download, Search, Square, Trash2, Play } from "lucide-react";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
Button,
|
||||||
|
Input,
|
||||||
|
} from "@/components/ui";
|
||||||
|
import { streamPodLogsCmd, stopLogStreamCmd } from "@/lib/tauriCommands";
|
||||||
|
|
||||||
|
interface LogStreamPanelProps {
|
||||||
|
clusterId: string;
|
||||||
|
namespace: string;
|
||||||
|
podName: string;
|
||||||
|
containers: string[];
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MAX_LINES = 5000;
|
||||||
|
|
||||||
|
export function LogStreamPanel({
|
||||||
|
clusterId,
|
||||||
|
namespace,
|
||||||
|
podName,
|
||||||
|
containers,
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
}: LogStreamPanelProps) {
|
||||||
|
const [selectedContainer, setSelectedContainer] = useState<string>(
|
||||||
|
containers[0] ?? ""
|
||||||
|
);
|
||||||
|
const [follow, setFollow] = useState(true);
|
||||||
|
const [timestamps, setTimestamps] = useState(false);
|
||||||
|
const [tailLines, setTailLines] = useState(100);
|
||||||
|
const [lines, setLines] = useState<string[]>([]);
|
||||||
|
const [streaming, setStreaming] = useState(false);
|
||||||
|
const [search, setSearch] = useState("");
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const streamIdRef = useRef<string | null>(null);
|
||||||
|
const unlistenRef = useRef<UnlistenFn | null>(null);
|
||||||
|
const bottomRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
|
const stopStream = useCallback(async () => {
|
||||||
|
if (unlistenRef.current) {
|
||||||
|
unlistenRef.current();
|
||||||
|
unlistenRef.current = null;
|
||||||
|
}
|
||||||
|
if (streamIdRef.current) {
|
||||||
|
try {
|
||||||
|
await stopLogStreamCmd(streamIdRef.current);
|
||||||
|
} catch {
|
||||||
|
// best-effort
|
||||||
|
}
|
||||||
|
streamIdRef.current = null;
|
||||||
|
}
|
||||||
|
setStreaming(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) {
|
||||||
|
void stopStream();
|
||||||
|
}
|
||||||
|
}, [open, stopStream]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
void stopStream();
|
||||||
|
};
|
||||||
|
}, [stopStream]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (follow && streaming && bottomRef.current) {
|
||||||
|
bottomRef.current.scrollIntoView({ behavior: "smooth" });
|
||||||
|
}
|
||||||
|
}, [lines, follow, streaming]);
|
||||||
|
|
||||||
|
const startStream = async () => {
|
||||||
|
if (streaming) return;
|
||||||
|
setError(null);
|
||||||
|
setLines([]);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const streamId = await streamPodLogsCmd({
|
||||||
|
cluster_id: clusterId,
|
||||||
|
namespace,
|
||||||
|
pod_name: podName,
|
||||||
|
container_name: selectedContainer,
|
||||||
|
follow,
|
||||||
|
timestamps,
|
||||||
|
tail_lines: tailLines,
|
||||||
|
});
|
||||||
|
|
||||||
|
streamIdRef.current = streamId;
|
||||||
|
|
||||||
|
const unlisten = await listen<{ stream_id: string; line: string }>(
|
||||||
|
"pod-log-line",
|
||||||
|
(event) => {
|
||||||
|
if (event.payload.stream_id !== streamId) return;
|
||||||
|
setLines((prev) => {
|
||||||
|
const next = [...prev, event.payload.line];
|
||||||
|
return next.length > MAX_LINES ? next.slice(next.length - MAX_LINES) : next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
unlistenRef.current = unlisten;
|
||||||
|
setStreaming(true);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : String(err));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDownload = () => {
|
||||||
|
const content = lines.join("\n");
|
||||||
|
const blob = new Blob([content], { type: "text/plain" });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement("a");
|
||||||
|
a.href = url;
|
||||||
|
a.download = `${podName}-${selectedContainer}-logs.txt`;
|
||||||
|
a.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClear = () => {
|
||||||
|
setLines([]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const filteredLines =
|
||||||
|
search.trim() === "" ? lines : lines.filter((l) => l.includes(search));
|
||||||
|
|
||||||
|
const displayLines = search.trim() !== "" ? filteredLines : lines;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="max-w-5xl w-full max-h-[80vh]">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>
|
||||||
|
Log Stream — {podName}
|
||||||
|
</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-3 overflow-hidden" style={{ maxHeight: "calc(80vh - 80px)" }}>
|
||||||
|
{/* Controls row */}
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<select
|
||||||
|
value={selectedContainer}
|
||||||
|
onChange={(e) => setSelectedContainer(e.target.value)}
|
||||||
|
disabled={streaming}
|
||||||
|
className="flex h-9 rounded-md border border-input bg-background px-3 py-1 text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{containers.map((c) => (
|
||||||
|
<option key={c} value={c}>
|
||||||
|
{c}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<label className="flex items-center gap-1.5 text-sm cursor-pointer select-none">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="rounded border-input"
|
||||||
|
checked={follow}
|
||||||
|
disabled={streaming}
|
||||||
|
onChange={(e) => setFollow(e.target.checked)}
|
||||||
|
/>
|
||||||
|
Follow
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="flex items-center gap-1.5 text-sm cursor-pointer select-none">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="rounded border-input"
|
||||||
|
checked={timestamps}
|
||||||
|
disabled={streaming}
|
||||||
|
onChange={(e) => setTimestamps(e.target.checked)}
|
||||||
|
/>
|
||||||
|
Timestamps
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-1.5 text-sm">
|
||||||
|
<span className="text-muted-foreground whitespace-nowrap">Tail lines:</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={tailLines}
|
||||||
|
min={10}
|
||||||
|
max={10000}
|
||||||
|
disabled={streaming}
|
||||||
|
onChange={(e) =>
|
||||||
|
setTailLines(Math.min(10000, Math.max(10, Number(e.target.value))))
|
||||||
|
}
|
||||||
|
className="flex h-9 w-24 rounded-md border border-input bg-background px-3 py-1 text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2 ml-auto">
|
||||||
|
{!streaming ? (
|
||||||
|
<Button size="sm" onClick={() => void startStream()}>
|
||||||
|
<Play className="h-3.5 w-3.5 mr-1" />
|
||||||
|
Stream
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button size="sm" variant="destructive" onClick={() => void stopStream()}>
|
||||||
|
<Square className="h-3.5 w-3.5 mr-1" />
|
||||||
|
Stop
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button size="sm" variant="outline" onClick={handleDownload} disabled={lines.length === 0}>
|
||||||
|
<Download className="h-3.5 w-3.5 mr-1" />
|
||||||
|
Download
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" variant="ghost" onClick={handleClear} disabled={lines.length === 0}>
|
||||||
|
<Trash2 className="h-3.5 w-3.5 mr-1" />
|
||||||
|
Clear
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Search bar */}
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
placeholder="Filter log lines…"
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
className="pl-9"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="rounded-md border border-destructive/50 bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Log output */}
|
||||||
|
<div className="flex-1 overflow-y-auto rounded-md border bg-slate-950 p-3 font-mono text-xs text-slate-200 min-h-0">
|
||||||
|
{displayLines.length === 0 ? (
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
{streaming ? "Waiting for log data…" : "No logs to display. Press Stream to begin."}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{(search.trim() !== "" ? lines : displayLines).map((line, i) => {
|
||||||
|
const matches = search.trim() !== "" && line.includes(search);
|
||||||
|
const visible = search.trim() === "" || matches;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className={[
|
||||||
|
"whitespace-pre-wrap break-all leading-5",
|
||||||
|
!visible ? "opacity-40" : "",
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" ")}
|
||||||
|
>
|
||||||
|
{matches && search.trim() !== "" ? (
|
||||||
|
highlightMatch(line, search)
|
||||||
|
) : (
|
||||||
|
line
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<div ref={bottomRef} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
{lines.length.toLocaleString()} line{lines.length !== 1 ? "s" : ""}
|
||||||
|
{search.trim() !== "" && ` — ${filteredLines.length.toLocaleString()} matching`}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function highlightMatch(line: string, search: string): React.ReactNode {
|
||||||
|
const idx = line.indexOf(search);
|
||||||
|
if (idx === -1) return line;
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{line.slice(0, idx)}
|
||||||
|
<mark className="bg-amber-400/30 text-amber-200 rounded-sm px-0.5">{line.slice(idx, idx + search.length)}</mark>
|
||||||
|
{line.slice(idx + search.length)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
110
src/components/Kubernetes/LogsModal.tsx
Normal file
110
src/components/Kubernetes/LogsModal.tsx
Normal file
@ -0,0 +1,110 @@
|
|||||||
|
import React from "react";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui";
|
||||||
|
import { Button } from "@/components/ui";
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui";
|
||||||
|
import { Alert, AlertDescription } from "@/components/ui";
|
||||||
|
import { FileText, Loader2 } from "lucide-react";
|
||||||
|
import { getPodLogsCmd } from "@/lib/tauriCommands";
|
||||||
|
|
||||||
|
interface LogsModalProps {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
clusterId: string;
|
||||||
|
namespace: string;
|
||||||
|
podName: string;
|
||||||
|
containers: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LogsModal({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
clusterId,
|
||||||
|
namespace,
|
||||||
|
podName,
|
||||||
|
containers,
|
||||||
|
}: LogsModalProps) {
|
||||||
|
const [selectedContainer, setSelectedContainer] = React.useState("");
|
||||||
|
const [logs, setLogs] = React.useState("");
|
||||||
|
const [isLoading, setIsLoading] = React.useState(false);
|
||||||
|
const [error, setError] = React.useState<string | null>(null);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
setSelectedContainer(containers[0] ?? "");
|
||||||
|
setLogs("");
|
||||||
|
setError(null);
|
||||||
|
}
|
||||||
|
}, [open, containers]);
|
||||||
|
|
||||||
|
const fetchLogs = async () => {
|
||||||
|
if (!selectedContainer) return;
|
||||||
|
setIsLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const response = await getPodLogsCmd(clusterId, namespace, podName, selectedContainer);
|
||||||
|
setLogs(response.logs);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : String(err));
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="max-w-4xl">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>
|
||||||
|
Logs — <span className="font-mono">{podName}</span>
|
||||||
|
</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Select value={selectedContainer} onValueChange={setSelectedContainer}>
|
||||||
|
<SelectTrigger className="w-48">
|
||||||
|
<SelectValue placeholder="Select container" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{containers.map((c) => (
|
||||||
|
<SelectItem key={c} value={c}>
|
||||||
|
{c}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={fetchLogs}
|
||||||
|
disabled={!selectedContainer || isLoading}
|
||||||
|
>
|
||||||
|
{isLoading ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
Loading...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<FileText className="mr-2 h-4 w-4" />
|
||||||
|
Fetch Logs
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{error && (
|
||||||
|
<Alert variant="destructive">
|
||||||
|
<AlertDescription>{error}</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
<pre className="max-h-[50vh] overflow-auto rounded-md border bg-muted p-3 font-mono text-xs whitespace-pre-wrap break-all">
|
||||||
|
{logs || "No logs. Select a container and click Fetch Logs."}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
42
src/components/Kubernetes/MutatingWebhookList.tsx
Normal file
42
src/components/Kubernetes/MutatingWebhookList.tsx
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui";
|
||||||
|
import type { WebhookConfigInfo } from "@/lib/tauriCommands";
|
||||||
|
|
||||||
|
interface MutatingWebhookListProps {
|
||||||
|
items: WebhookConfigInfo[];
|
||||||
|
clusterId: string;
|
||||||
|
namespace?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MutatingWebhookList({ items }: MutatingWebhookListProps) {
|
||||||
|
return (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Name</TableHead>
|
||||||
|
<TableHead>Webhooks</TableHead>
|
||||||
|
<TableHead>Age</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{items.length === 0 ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={3} className="text-center text-muted-foreground">
|
||||||
|
No mutating webhook configurations found
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : (
|
||||||
|
items.map((wh) => (
|
||||||
|
<TableRow key={wh.name}>
|
||||||
|
<TableCell className="font-medium">{wh.name}</TableCell>
|
||||||
|
<TableCell className="text-sm">{wh.webhooks}</TableCell>
|
||||||
|
<TableCell className="text-sm text-muted-foreground">{wh.age}</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
50
src/components/Kubernetes/NamespaceList.tsx
Normal file
50
src/components/Kubernetes/NamespaceList.tsx
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow, Badge } from "@/components/ui";
|
||||||
|
import type { NamespaceResourceInfo } from "@/lib/tauriCommands";
|
||||||
|
|
||||||
|
interface NamespaceListProps {
|
||||||
|
items: NamespaceResourceInfo[];
|
||||||
|
clusterId: string;
|
||||||
|
namespace?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusVariant(status: string): "success" | "destructive" | "secondary" {
|
||||||
|
if (status === "Active") return "success";
|
||||||
|
if (status === "Terminating") return "destructive";
|
||||||
|
return "secondary";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function NamespaceList({ items }: NamespaceListProps) {
|
||||||
|
return (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Name</TableHead>
|
||||||
|
<TableHead>Status</TableHead>
|
||||||
|
<TableHead>Age</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{items.length === 0 ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={3} className="text-center text-muted-foreground">
|
||||||
|
No namespaces found
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : (
|
||||||
|
items.map((ns) => (
|
||||||
|
<TableRow key={ns.name}>
|
||||||
|
<TableCell className="font-medium">{ns.name}</TableCell>
|
||||||
|
<TableCell className="text-sm">
|
||||||
|
<Badge variant={statusVariant(ns.status)}>{ns.status}</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-sm text-muted-foreground">{ns.age}</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,15 +1,56 @@
|
|||||||
import React from "react";
|
import React, { useState } from "react";
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui";
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui";
|
||||||
|
import { Pencil, Trash2 } from "lucide-react";
|
||||||
import type { NetworkPolicyInfo } from "@/lib/tauriCommands";
|
import type { NetworkPolicyInfo } from "@/lib/tauriCommands";
|
||||||
|
import { deleteResourceCmd, getResourceYamlCmd } from "@/lib/tauriCommands";
|
||||||
|
import { ResourceActionMenu } from "./ResourceActionMenu";
|
||||||
|
import { ConfirmDeleteDialog } from "./ConfirmDeleteDialog";
|
||||||
|
import { EditResourceModal } from "./EditResourceModal";
|
||||||
|
|
||||||
interface NetworkPolicyListProps {
|
interface NetworkPolicyListProps {
|
||||||
networkpolicies: NetworkPolicyInfo[];
|
networkpolicies: NetworkPolicyInfo[];
|
||||||
clusterId: string;
|
clusterId: string;
|
||||||
namespace: string;
|
namespace: string;
|
||||||
|
onRefresh?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function NetworkPolicyList({ networkpolicies }: NetworkPolicyListProps) {
|
type ActiveModal =
|
||||||
|
| { type: "edit"; np: NetworkPolicyInfo; yaml: string }
|
||||||
|
| { type: "delete"; np: NetworkPolicyInfo }
|
||||||
|
| null;
|
||||||
|
|
||||||
|
export function NetworkPolicyList({ networkpolicies, clusterId, namespace, onRefresh }: NetworkPolicyListProps) {
|
||||||
|
const [activeModal, setActiveModal] = useState<ActiveModal>(null);
|
||||||
|
const [isDeleting, setIsDeleting] = useState(false);
|
||||||
|
const [actionError, setActionError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const openEdit = async (np: NetworkPolicyInfo) => {
|
||||||
|
setActionError(null);
|
||||||
|
try {
|
||||||
|
const yaml = await getResourceYamlCmd(clusterId, "networkpolicies", namespace, np.name);
|
||||||
|
setActiveModal({ type: "edit", np, yaml });
|
||||||
|
} catch (err) {
|
||||||
|
setActionError(err instanceof Error ? err.message : String(err));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async () => {
|
||||||
|
if (activeModal?.type !== "delete") return;
|
||||||
|
setIsDeleting(true);
|
||||||
|
try {
|
||||||
|
await deleteResourceCmd(clusterId, "networkpolicies", namespace, activeModal.np.name);
|
||||||
|
setActiveModal(null);
|
||||||
|
onRefresh?.();
|
||||||
|
} finally {
|
||||||
|
setIsDeleting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
|
{actionError && (
|
||||||
|
<p className="mb-2 text-sm text-destructive">{actionError}</p>
|
||||||
|
)}
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
@ -19,12 +60,13 @@ export function NetworkPolicyList({ networkpolicies }: NetworkPolicyListProps) {
|
|||||||
<TableHead>Pod Selector</TableHead>
|
<TableHead>Pod Selector</TableHead>
|
||||||
<TableHead>Policy Types</TableHead>
|
<TableHead>Policy Types</TableHead>
|
||||||
<TableHead>Age</TableHead>
|
<TableHead>Age</TableHead>
|
||||||
|
<TableHead className="text-right">Actions</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{networkpolicies.length === 0 ? (
|
{networkpolicies.length === 0 ? (
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell colSpan={5} className="text-center text-muted-foreground">
|
<TableCell colSpan={6} className="text-center text-muted-foreground">
|
||||||
No network policies found
|
No network policies found
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
@ -36,11 +78,52 @@ export function NetworkPolicyList({ networkpolicies }: NetworkPolicyListProps) {
|
|||||||
<TableCell className="text-sm font-mono truncate max-w-48">{np.pod_selector}</TableCell>
|
<TableCell className="text-sm font-mono truncate max-w-48">{np.pod_selector}</TableCell>
|
||||||
<TableCell className="text-sm">{np.policy_types.join(", ") || "—"}</TableCell>
|
<TableCell className="text-sm">{np.policy_types.join(", ") || "—"}</TableCell>
|
||||||
<TableCell className="text-sm text-muted-foreground">{np.age}</TableCell>
|
<TableCell className="text-sm text-muted-foreground">{np.age}</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
<ResourceActionMenu
|
||||||
|
actions={[
|
||||||
|
{
|
||||||
|
label: "Edit",
|
||||||
|
icon: Pencil,
|
||||||
|
onClick: () => openEdit(np),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Delete",
|
||||||
|
icon: Trash2,
|
||||||
|
variant: "destructive",
|
||||||
|
onClick: () => setActiveModal({ type: "delete", np }),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{activeModal?.type === "edit" && (
|
||||||
|
<EditResourceModal
|
||||||
|
isOpen
|
||||||
|
clusterId={clusterId}
|
||||||
|
namespace={namespace}
|
||||||
|
resourceType="networkpolicies"
|
||||||
|
resourceName={activeModal.np.name}
|
||||||
|
initialYaml={activeModal.yaml}
|
||||||
|
onClose={() => { setActiveModal(null); onRefresh?.(); }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeModal?.type === "delete" && (
|
||||||
|
<ConfirmDeleteDialog
|
||||||
|
open
|
||||||
|
onOpenChange={(o) => { if (!o) setActiveModal(null); }}
|
||||||
|
resourceType="NetworkPolicy"
|
||||||
|
resourceName={activeModal.np.name}
|
||||||
|
isLoading={isDeleting}
|
||||||
|
onConfirm={handleDelete}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,24 +1,33 @@
|
|||||||
import React, { useState } from "react";
|
import React, { useState } from "react";
|
||||||
import { invoke } from "@tauri-apps/api/core";
|
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui";
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui";
|
||||||
import { Badge } from "@/components/ui";
|
import { Badge } from "@/components/ui";
|
||||||
import { Button } from "@/components/ui";
|
import { ShieldOff, ShieldCheck, Trash2, Pencil } from "lucide-react";
|
||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui";
|
|
||||||
import { AlertCircle, Terminal } from "lucide-react";
|
|
||||||
import { Alert, AlertDescription } from "@/components/ui";
|
|
||||||
import type { NodeInfo } from "@/lib/tauriCommands";
|
import type { NodeInfo } from "@/lib/tauriCommands";
|
||||||
|
import {
|
||||||
|
cordonNodeCmd,
|
||||||
|
uncordonNodeCmd,
|
||||||
|
drainNodeCmd,
|
||||||
|
getResourceYamlCmd,
|
||||||
|
} from "@/lib/tauriCommands";
|
||||||
|
import { ResourceActionMenu } from "./ResourceActionMenu";
|
||||||
|
import { ConfirmDeleteDialog } from "./ConfirmDeleteDialog";
|
||||||
|
import { EditResourceModal } from "./EditResourceModal";
|
||||||
|
|
||||||
interface NodeListProps {
|
interface NodeListProps {
|
||||||
nodes: NodeInfo[];
|
nodes: NodeInfo[];
|
||||||
clusterId: string;
|
clusterId: string;
|
||||||
|
onRefresh?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function NodeList({ nodes, clusterId }: NodeListProps) {
|
type ActiveModal =
|
||||||
const [selectedNode, setSelectedNode] = useState<NodeInfo | null>(null);
|
| { type: "drain"; node: NodeInfo }
|
||||||
const [isCordoning, setIsCordoning] = useState(false);
|
| { type: "edit"; node: NodeInfo; yaml: string }
|
||||||
const [isUncordoning, setIsUncordoning] = useState(false);
|
| null;
|
||||||
const [isDraining, setIsDraining] = useState(false);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
export function NodeList({ nodes, clusterId, onRefresh }: NodeListProps) {
|
||||||
|
const [activeModal, setActiveModal] = useState<ActiveModal>(null);
|
||||||
|
const [isActing, setIsActing] = useState(false);
|
||||||
|
const [actionError, setActionError] = useState<string | null>(null);
|
||||||
|
|
||||||
const getNodeStatusColor = (status: string) => {
|
const getNodeStatusColor = (status: string) => {
|
||||||
switch (status.toLowerCase()) {
|
switch (status.toLowerCase()) {
|
||||||
@ -33,53 +42,59 @@ export function NodeList({ nodes, clusterId }: NodeListProps) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCordon = async () => {
|
const isSchedulingDisabled = (node: NodeInfo) =>
|
||||||
if (!selectedNode) return;
|
node.status.toLowerCase().includes("schedulingdisabled") ||
|
||||||
|
node.roles.toLowerCase().includes("schedulingdisabled");
|
||||||
|
|
||||||
setIsCordoning(true);
|
const handleCordon = async (node: NodeInfo) => {
|
||||||
setError(null);
|
setActionError(null);
|
||||||
try {
|
try {
|
||||||
await invoke<void>("cordon_node", { clusterId, nodeName: selectedNode.name });
|
await cordonNodeCmd(clusterId, node.name);
|
||||||
setSelectedNode(null);
|
onRefresh?.();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : "Failed to cordon node");
|
setActionError(err instanceof Error ? err.message : String(err));
|
||||||
} finally {
|
|
||||||
setIsCordoning(false);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleUncordon = async () => {
|
const handleUncordon = async (node: NodeInfo) => {
|
||||||
if (!selectedNode) return;
|
setActionError(null);
|
||||||
|
|
||||||
setIsUncordoning(true);
|
|
||||||
setError(null);
|
|
||||||
try {
|
try {
|
||||||
await invoke<void>("uncordon_node", { clusterId, nodeName: selectedNode.name });
|
await uncordonNodeCmd(clusterId, node.name);
|
||||||
setSelectedNode(null);
|
onRefresh?.();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : "Failed to uncordon node");
|
setActionError(err instanceof Error ? err.message : String(err));
|
||||||
} finally {
|
|
||||||
setIsUncordoning(false);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDrain = async () => {
|
const handleDrain = async () => {
|
||||||
if (!selectedNode) return;
|
if (activeModal?.type !== "drain") return;
|
||||||
|
setIsActing(true);
|
||||||
setIsDraining(true);
|
|
||||||
setError(null);
|
|
||||||
try {
|
try {
|
||||||
await invoke<void>("drain_node", { clusterId, nodeName: selectedNode.name });
|
await drainNodeCmd(clusterId, activeModal.node.name);
|
||||||
setSelectedNode(null);
|
setActiveModal(null);
|
||||||
|
onRefresh?.();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : "Failed to drain node");
|
setActionError(err instanceof Error ? err.message : String(err));
|
||||||
} finally {
|
} finally {
|
||||||
setIsDraining(false);
|
setIsActing(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const openEdit = async (node: NodeInfo) => {
|
||||||
|
setActionError(null);
|
||||||
|
try {
|
||||||
|
const yaml = await getResourceYamlCmd(clusterId, "nodes", "", node.name);
|
||||||
|
setActiveModal({ type: "edit", node, yaml });
|
||||||
|
} catch (err) {
|
||||||
|
setActionError(err instanceof Error ? err.message : String(err));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
{actionError && (
|
||||||
|
<p className="mb-2 text-sm text-destructive">{actionError}</p>
|
||||||
|
)}
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
@ -116,14 +131,33 @@ export function NodeList({ nodes, clusterId }: NodeListProps) {
|
|||||||
<TableCell className="text-sm text-muted-foreground">{node.os_image}</TableCell>
|
<TableCell className="text-sm text-muted-foreground">{node.os_image}</TableCell>
|
||||||
<TableCell className="text-sm text-muted-foreground">{node.age}</TableCell>
|
<TableCell className="text-sm text-muted-foreground">{node.age}</TableCell>
|
||||||
<TableCell className="text-right">
|
<TableCell className="text-right">
|
||||||
<Button
|
<ResourceActionMenu
|
||||||
variant="ghost"
|
actions={[
|
||||||
size="sm"
|
{
|
||||||
onClick={() => setSelectedNode(node)}
|
label: "Cordon",
|
||||||
className="text-primary hover:text-primary hover:bg-primary/10"
|
icon: ShieldOff,
|
||||||
>
|
hidden: isSchedulingDisabled(node),
|
||||||
Manage
|
onClick: () => handleCordon(node),
|
||||||
</Button>
|
},
|
||||||
|
{
|
||||||
|
label: "Uncordon",
|
||||||
|
icon: ShieldCheck,
|
||||||
|
hidden: !isSchedulingDisabled(node),
|
||||||
|
onClick: () => handleUncordon(node),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Drain",
|
||||||
|
icon: Trash2,
|
||||||
|
variant: "destructive",
|
||||||
|
onClick: () => setActiveModal({ type: "drain", node }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Edit",
|
||||||
|
icon: Pencil,
|
||||||
|
onClick: () => openEdit(node),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))
|
))
|
||||||
@ -132,101 +166,28 @@ export function NodeList({ nodes, clusterId }: NodeListProps) {
|
|||||||
</Table>
|
</Table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Node Management Dialog */}
|
{activeModal?.type === "drain" && (
|
||||||
{selectedNode && (
|
<ConfirmDeleteDialog
|
||||||
<Dialog open={true} onOpenChange={(open) => {
|
open
|
||||||
if (!open) {
|
onOpenChange={(o) => { if (!o) setActiveModal(null); }}
|
||||||
setSelectedNode(null);
|
resourceType="Node"
|
||||||
setError(null);
|
resourceName={activeModal.node.name}
|
||||||
}
|
isLoading={isActing}
|
||||||
}}>
|
onConfirm={handleDrain}
|
||||||
<DialogContent className="max-w-2xl">
|
variant="force-delete"
|
||||||
<DialogHeader>
|
/>
|
||||||
<DialogTitle className="flex items-center gap-2">
|
|
||||||
<Terminal className="w-5 h-5" />
|
|
||||||
Manage Node: {selectedNode.name}
|
|
||||||
</DialogTitle>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
<div className="space-y-4 py-4">
|
|
||||||
{/* Node Details */}
|
|
||||||
<div className="grid grid-cols-2 gap-4 p-4 bg-muted rounded-lg">
|
|
||||||
<div>
|
|
||||||
<p className="text-xs font-medium text-muted-foreground">Status</p>
|
|
||||||
<p className="font-semibold">{selectedNode.status}</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-xs font-medium text-muted-foreground">Roles</p>
|
|
||||||
<p className="font-semibold">{selectedNode.roles}</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-xs font-medium text-muted-foreground">Version</p>
|
|
||||||
<p className="font-semibold">{selectedNode.version}</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-xs font-medium text-muted-foreground">OS Image</p>
|
|
||||||
<p className="font-semibold">{selectedNode.os_image}</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-xs font-medium text-muted-foreground">Kernel</p>
|
|
||||||
<p className="font-semibold">{selectedNode.kernel_version}</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-xs font-medium text-muted-foreground">Kubelet</p>
|
|
||||||
<p className="font-semibold">{selectedNode.kubelet_version}</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-xs font-medium text-muted-foreground">Internal IP</p>
|
|
||||||
<p className="font-semibold font-mono">{selectedNode.internal_ip}</p>
|
|
||||||
</div>
|
|
||||||
{selectedNode.external_ip && (
|
|
||||||
<div>
|
|
||||||
<p className="text-xs font-medium text-muted-foreground">External IP</p>
|
|
||||||
<p className="font-semibold font-mono">{selectedNode.external_ip}</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Action Buttons */}
|
|
||||||
<div className="space-y-3">
|
|
||||||
{selectedNode.roles.toLowerCase().includes("schedulingdisabled") ? (
|
|
||||||
<Button
|
|
||||||
onClick={handleUncordon}
|
|
||||||
disabled={isUncordoning}
|
|
||||||
className="w-full"
|
|
||||||
>
|
|
||||||
{isUncordoning ? "Uncordoning..." : "Uncordon Node"}
|
|
||||||
</Button>
|
|
||||||
) : (
|
|
||||||
<Button
|
|
||||||
onClick={handleCordon}
|
|
||||||
variant="outline"
|
|
||||||
disabled={isCordoning}
|
|
||||||
className="w-full"
|
|
||||||
>
|
|
||||||
{isCordoning ? "Cordoning..." : "Cordon Node"}
|
|
||||||
</Button>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Button
|
{activeModal?.type === "edit" && (
|
||||||
onClick={handleDrain}
|
<EditResourceModal
|
||||||
variant="destructive"
|
isOpen
|
||||||
disabled={isDraining}
|
clusterId={clusterId}
|
||||||
className="w-full"
|
namespace=""
|
||||||
>
|
resourceType="nodes"
|
||||||
{isDraining ? "Draining..." : "Drain Node"}
|
resourceName={activeModal.node.name}
|
||||||
</Button>
|
initialYaml={activeModal.yaml}
|
||||||
</div>
|
onClose={() => { setActiveModal(null); onRefresh?.(); }}
|
||||||
|
/>
|
||||||
{error && (
|
|
||||||
<Alert variant="destructive">
|
|
||||||
<AlertCircle className="h-4 w-4" />
|
|
||||||
<AlertDescription>{error}</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -1,15 +1,67 @@
|
|||||||
import React from "react";
|
import React, { useState } from "react";
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui";
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui";
|
||||||
|
import { Pencil, Trash2 } from "lucide-react";
|
||||||
import type { PersistentVolumeClaimInfo } from "@/lib/tauriCommands";
|
import type { PersistentVolumeClaimInfo } from "@/lib/tauriCommands";
|
||||||
|
import { deleteResourceCmd, getResourceYamlCmd } from "@/lib/tauriCommands";
|
||||||
|
import { ResourceActionMenu } from "./ResourceActionMenu";
|
||||||
|
import { ConfirmDeleteDialog } from "./ConfirmDeleteDialog";
|
||||||
|
import { EditResourceModal } from "./EditResourceModal";
|
||||||
|
|
||||||
interface PVCListProps {
|
interface PVCListProps {
|
||||||
pvcs: PersistentVolumeClaimInfo[];
|
pvcs: PersistentVolumeClaimInfo[];
|
||||||
_clusterId: string;
|
clusterId?: string;
|
||||||
_namespace: string;
|
_clusterId?: string;
|
||||||
|
namespace?: string;
|
||||||
|
_namespace?: string;
|
||||||
|
onRefresh?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PVCList({ pvcs, _clusterId, _namespace }: PVCListProps) {
|
type ActiveModal =
|
||||||
|
| { type: "edit"; pvc: PersistentVolumeClaimInfo; yaml: string }
|
||||||
|
| { type: "delete"; pvc: PersistentVolumeClaimInfo }
|
||||||
|
| null;
|
||||||
|
|
||||||
|
export function PVCList({
|
||||||
|
pvcs,
|
||||||
|
clusterId,
|
||||||
|
_clusterId,
|
||||||
|
namespace,
|
||||||
|
_namespace,
|
||||||
|
onRefresh,
|
||||||
|
}: PVCListProps) {
|
||||||
|
const cid = clusterId ?? _clusterId ?? "";
|
||||||
|
const ns = namespace ?? _namespace ?? "";
|
||||||
|
const [activeModal, setActiveModal] = useState<ActiveModal>(null);
|
||||||
|
const [isDeleting, setIsDeleting] = useState(false);
|
||||||
|
const [actionError, setActionError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const openEdit = async (pvc: PersistentVolumeClaimInfo) => {
|
||||||
|
setActionError(null);
|
||||||
|
try {
|
||||||
|
const yaml = await getResourceYamlCmd(cid, "persistentvolumeclaims", ns, pvc.name);
|
||||||
|
setActiveModal({ type: "edit", pvc, yaml });
|
||||||
|
} catch (err) {
|
||||||
|
setActionError(err instanceof Error ? err.message : String(err));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async () => {
|
||||||
|
if (activeModal?.type !== "delete") return;
|
||||||
|
setIsDeleting(true);
|
||||||
|
try {
|
||||||
|
await deleteResourceCmd(cid, "persistentvolumeclaims", ns, activeModal.pvc.name);
|
||||||
|
setActiveModal(null);
|
||||||
|
onRefresh?.();
|
||||||
|
} finally {
|
||||||
|
setIsDeleting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
|
{actionError && (
|
||||||
|
<p className="mb-2 text-sm text-destructive">{actionError}</p>
|
||||||
|
)}
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
@ -21,12 +73,13 @@ export function PVCList({ pvcs, _clusterId, _namespace }: PVCListProps) {
|
|||||||
<TableHead>Capacity</TableHead>
|
<TableHead>Capacity</TableHead>
|
||||||
<TableHead>Access Modes</TableHead>
|
<TableHead>Access Modes</TableHead>
|
||||||
<TableHead>Age</TableHead>
|
<TableHead>Age</TableHead>
|
||||||
|
<TableHead className="text-right">Actions</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{pvcs.length === 0 ? (
|
{pvcs.length === 0 ? (
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell colSpan={7} className="text-center text-muted-foreground">
|
<TableCell colSpan={8} className="text-center text-muted-foreground">
|
||||||
No PVCs found
|
No PVCs found
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
@ -40,11 +93,52 @@ export function PVCList({ pvcs, _clusterId, _namespace }: PVCListProps) {
|
|||||||
<TableCell>{pvc.capacity}</TableCell>
|
<TableCell>{pvc.capacity}</TableCell>
|
||||||
<TableCell>{pvc.access_modes.join(", ")}</TableCell>
|
<TableCell>{pvc.access_modes.join(", ")}</TableCell>
|
||||||
<TableCell className="text-muted-foreground">{pvc.age}</TableCell>
|
<TableCell className="text-muted-foreground">{pvc.age}</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
<ResourceActionMenu
|
||||||
|
actions={[
|
||||||
|
{
|
||||||
|
label: "Edit",
|
||||||
|
icon: Pencil,
|
||||||
|
onClick: () => openEdit(pvc),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Delete",
|
||||||
|
icon: Trash2,
|
||||||
|
variant: "destructive",
|
||||||
|
onClick: () => setActiveModal({ type: "delete", pvc }),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{activeModal?.type === "edit" && (
|
||||||
|
<EditResourceModal
|
||||||
|
isOpen
|
||||||
|
clusterId={cid}
|
||||||
|
namespace={ns}
|
||||||
|
resourceType="persistentvolumeclaims"
|
||||||
|
resourceName={activeModal.pvc.name}
|
||||||
|
initialYaml={activeModal.yaml}
|
||||||
|
onClose={() => { setActiveModal(null); onRefresh?.(); }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeModal?.type === "delete" && (
|
||||||
|
<ConfirmDeleteDialog
|
||||||
|
open
|
||||||
|
onOpenChange={(o) => { if (!o) setActiveModal(null); }}
|
||||||
|
resourceType="PVC"
|
||||||
|
resourceName={activeModal.pvc.name}
|
||||||
|
isLoading={isDeleting}
|
||||||
|
onConfirm={handleDelete}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,14 +1,57 @@
|
|||||||
import React from "react";
|
import React, { useState } from "react";
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui";
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui";
|
||||||
|
import { Pencil, Trash2 } from "lucide-react";
|
||||||
import type { PersistentVolumeInfo } from "@/lib/tauriCommands";
|
import type { PersistentVolumeInfo } from "@/lib/tauriCommands";
|
||||||
|
import { deleteResourceCmd, getResourceYamlCmd } from "@/lib/tauriCommands";
|
||||||
|
import { ResourceActionMenu } from "./ResourceActionMenu";
|
||||||
|
import { ConfirmDeleteDialog } from "./ConfirmDeleteDialog";
|
||||||
|
import { EditResourceModal } from "./EditResourceModal";
|
||||||
|
|
||||||
interface PVListProps {
|
interface PVListProps {
|
||||||
pvs: PersistentVolumeInfo[];
|
pvs: PersistentVolumeInfo[];
|
||||||
_clusterId: string;
|
clusterId?: string;
|
||||||
|
_clusterId?: string;
|
||||||
|
onRefresh?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PVList({ pvs, _clusterId }: PVListProps) {
|
type ActiveModal =
|
||||||
|
| { type: "edit"; pv: PersistentVolumeInfo; yaml: string }
|
||||||
|
| { type: "delete"; pv: PersistentVolumeInfo }
|
||||||
|
| null;
|
||||||
|
|
||||||
|
export function PVList({ pvs, clusterId, _clusterId, onRefresh }: PVListProps) {
|
||||||
|
const cid = clusterId ?? _clusterId ?? "";
|
||||||
|
const [activeModal, setActiveModal] = useState<ActiveModal>(null);
|
||||||
|
const [isDeleting, setIsDeleting] = useState(false);
|
||||||
|
const [actionError, setActionError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const openEdit = async (pv: PersistentVolumeInfo) => {
|
||||||
|
setActionError(null);
|
||||||
|
try {
|
||||||
|
const yaml = await getResourceYamlCmd(cid, "persistentvolumes", "", pv.name);
|
||||||
|
setActiveModal({ type: "edit", pv, yaml });
|
||||||
|
} catch (err) {
|
||||||
|
setActionError(err instanceof Error ? err.message : String(err));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async () => {
|
||||||
|
if (activeModal?.type !== "delete") return;
|
||||||
|
setIsDeleting(true);
|
||||||
|
try {
|
||||||
|
await deleteResourceCmd(cid, "persistentvolumes", "", activeModal.pv.name);
|
||||||
|
setActiveModal(null);
|
||||||
|
onRefresh?.();
|
||||||
|
} finally {
|
||||||
|
setIsDeleting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
|
{actionError && (
|
||||||
|
<p className="mb-2 text-sm text-destructive">{actionError}</p>
|
||||||
|
)}
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
@ -20,12 +63,13 @@ export function PVList({ pvs, _clusterId }: PVListProps) {
|
|||||||
<TableHead>Reclaim Policy</TableHead>
|
<TableHead>Reclaim Policy</TableHead>
|
||||||
<TableHead>Storage Class</TableHead>
|
<TableHead>Storage Class</TableHead>
|
||||||
<TableHead>Age</TableHead>
|
<TableHead>Age</TableHead>
|
||||||
|
<TableHead className="text-right">Actions</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{pvs.length === 0 ? (
|
{pvs.length === 0 ? (
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell colSpan={7} className="text-center text-muted-foreground">
|
<TableCell colSpan={8} className="text-center text-muted-foreground">
|
||||||
No PVs found
|
No PVs found
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
@ -39,11 +83,52 @@ export function PVList({ pvs, _clusterId }: PVListProps) {
|
|||||||
<TableCell>{pv.reclaim_policy}</TableCell>
|
<TableCell>{pv.reclaim_policy}</TableCell>
|
||||||
<TableCell>{pv.storage_class}</TableCell>
|
<TableCell>{pv.storage_class}</TableCell>
|
||||||
<TableCell className="text-muted-foreground">{pv.age}</TableCell>
|
<TableCell className="text-muted-foreground">{pv.age}</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
<ResourceActionMenu
|
||||||
|
actions={[
|
||||||
|
{
|
||||||
|
label: "Edit",
|
||||||
|
icon: Pencil,
|
||||||
|
onClick: () => openEdit(pv),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Delete",
|
||||||
|
icon: Trash2,
|
||||||
|
variant: "destructive",
|
||||||
|
onClick: () => setActiveModal({ type: "delete", pv }),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{activeModal?.type === "edit" && (
|
||||||
|
<EditResourceModal
|
||||||
|
isOpen
|
||||||
|
clusterId={cid}
|
||||||
|
namespace=""
|
||||||
|
resourceType="persistentvolumes"
|
||||||
|
resourceName={activeModal.pv.name}
|
||||||
|
initialYaml={activeModal.yaml}
|
||||||
|
onClose={() => { setActiveModal(null); onRefresh?.(); }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeModal?.type === "delete" && (
|
||||||
|
<ConfirmDeleteDialog
|
||||||
|
open
|
||||||
|
onOpenChange={(o) => { if (!o) setActiveModal(null); }}
|
||||||
|
resourceType="PersistentVolume"
|
||||||
|
resourceName={activeModal.pv.name}
|
||||||
|
isLoading={isDeleting}
|
||||||
|
onConfirm={handleDelete}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
48
src/components/Kubernetes/PodDisruptionBudgetList.tsx
Normal file
48
src/components/Kubernetes/PodDisruptionBudgetList.tsx
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui";
|
||||||
|
import type { PodDisruptionBudgetInfo } from "@/lib/tauriCommands";
|
||||||
|
|
||||||
|
interface PodDisruptionBudgetListProps {
|
||||||
|
items: PodDisruptionBudgetInfo[];
|
||||||
|
clusterId: string;
|
||||||
|
namespace?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PodDisruptionBudgetList({ items }: PodDisruptionBudgetListProps) {
|
||||||
|
return (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Name</TableHead>
|
||||||
|
<TableHead>Namespace</TableHead>
|
||||||
|
<TableHead>Min Available</TableHead>
|
||||||
|
<TableHead>Max Unavailable</TableHead>
|
||||||
|
<TableHead>Disruptions Allowed</TableHead>
|
||||||
|
<TableHead>Age</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{items.length === 0 ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={6} className="text-center text-muted-foreground">
|
||||||
|
No pod disruption budgets found
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : (
|
||||||
|
items.map((pdb) => (
|
||||||
|
<TableRow key={`${pdb.name}-${pdb.namespace}`}>
|
||||||
|
<TableCell className="font-medium">{pdb.name}</TableCell>
|
||||||
|
<TableCell className="text-sm text-muted-foreground">{pdb.namespace}</TableCell>
|
||||||
|
<TableCell className="text-sm">{pdb.min_available}</TableCell>
|
||||||
|
<TableCell className="text-sm">{pdb.max_unavailable}</TableCell>
|
||||||
|
<TableCell className="text-sm">{pdb.disruptions_allowed}</TableCell>
|
||||||
|
<TableCell className="text-sm text-muted-foreground">{pdb.age}</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,28 +1,36 @@
|
|||||||
import React, { useState } from "react";
|
import React, { useState } from "react";
|
||||||
import { invoke } from "@tauri-apps/api/core";
|
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui";
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui";
|
||||||
import { Badge } from "@/components/ui";
|
import { Badge } from "@/components/ui";
|
||||||
import { Button } from "@/components/ui";
|
import { FileText, Terminal, Link, Pencil, Trash2, Zap } from "lucide-react";
|
||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui";
|
import type { PodInfo } from "@/lib/tauriCommands";
|
||||||
import { Textarea } from "@/components/ui";
|
import { deleteResourceCmd, forceDeleteResourceCmd, getResourceYamlCmd } from "@/lib/tauriCommands";
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui";
|
import { ResourceActionMenu } from "./ResourceActionMenu";
|
||||||
import { Terminal, FileText, RotateCcw } from "lucide-react";
|
import { ConfirmDeleteDialog } from "./ConfirmDeleteDialog";
|
||||||
import { Alert, AlertDescription } from "@/components/ui";
|
import { LogsModal } from "./LogsModal";
|
||||||
import type { PodInfo, LogResponse } from "@/lib/tauriCommands";
|
import { ShellExecModal } from "./ShellExecModal";
|
||||||
|
import { AttachModal } from "./AttachModal";
|
||||||
|
import { EditResourceModal } from "./EditResourceModal";
|
||||||
|
|
||||||
interface PodListProps {
|
interface PodListProps {
|
||||||
pods: PodInfo[];
|
pods: PodInfo[];
|
||||||
clusterId: string;
|
clusterId: string;
|
||||||
namespace: string;
|
namespace: string;
|
||||||
|
onRefresh?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PodList({ pods, clusterId, namespace }: PodListProps) {
|
type ActiveModal =
|
||||||
const [selectedPod, setSelectedPod] = useState<PodInfo | null>(null);
|
| { type: "logs"; pod: PodInfo }
|
||||||
const [selectedContainer, setSelectedContainer] = useState<string>("");
|
| { type: "shell"; pod: PodInfo }
|
||||||
const [logs, setLogs] = useState<string>("");
|
| { type: "attach"; pod: PodInfo }
|
||||||
const [isFetchingLogs, setIsFetchingLogs] = useState(false);
|
| { type: "edit"; pod: PodInfo; yaml: string }
|
||||||
const [error, setError] = useState<string | null>(null);
|
| { type: "delete"; pod: PodInfo }
|
||||||
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
| { type: "force-delete"; pod: PodInfo }
|
||||||
|
| null;
|
||||||
|
|
||||||
|
export function PodList({ pods, clusterId, namespace, onRefresh }: PodListProps) {
|
||||||
|
const [activeModal, setActiveModal] = useState<ActiveModal>(null);
|
||||||
|
const [isDeleting, setIsDeleting] = useState(false);
|
||||||
|
const [editError, setEditError] = useState<string | null>(null);
|
||||||
|
|
||||||
const getPodStatusColor = (status: string) => {
|
const getPodStatusColor = (status: string) => {
|
||||||
switch (status.toLowerCase()) {
|
switch (status.toLowerCase()) {
|
||||||
@ -41,37 +49,41 @@ export function PodList({ pods, clusterId, namespace }: PodListProps) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const fetchLogs = async () => {
|
const openEdit = async (pod: PodInfo) => {
|
||||||
if (!selectedPod || !selectedContainer) return;
|
setEditError(null);
|
||||||
|
|
||||||
setIsFetchingLogs(true);
|
|
||||||
setError(null);
|
|
||||||
try {
|
try {
|
||||||
const response = await invoke<LogResponse>("get_pod_logs", {
|
const yaml = await getResourceYamlCmd(clusterId, "pods", namespace, pod.name);
|
||||||
clusterId,
|
setActiveModal({ type: "edit", pod, yaml });
|
||||||
namespace,
|
|
||||||
podName: selectedPod.name,
|
|
||||||
containerName: selectedContainer,
|
|
||||||
});
|
|
||||||
setLogs(response.logs);
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Failed to fetch logs:", err);
|
setEditError(err instanceof Error ? err.message : String(err));
|
||||||
setError(err instanceof Error ? err.message : "Failed to fetch logs");
|
|
||||||
} finally {
|
|
||||||
setIsFetchingLogs(false);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleContainerChange = (container: string) => {
|
const handleDelete = async (force: boolean) => {
|
||||||
setSelectedContainer(container);
|
const modal = activeModal;
|
||||||
setLogs("");
|
if (!modal || (modal.type !== "delete" && modal.type !== "force-delete")) return;
|
||||||
setError(null);
|
setIsDeleting(true);
|
||||||
|
try {
|
||||||
|
if (force) {
|
||||||
|
await forceDeleteResourceCmd(clusterId, "pods", namespace, modal.pod.name);
|
||||||
|
} else {
|
||||||
|
await deleteResourceCmd(clusterId, "pods", namespace, modal.pod.name);
|
||||||
|
}
|
||||||
|
setActiveModal(null);
|
||||||
|
onRefresh?.();
|
||||||
|
} finally {
|
||||||
|
setIsDeleting(false);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const containers = selectedPod?.containers ?? [];
|
const currentPod =
|
||||||
|
activeModal && activeModal.type !== "edit" ? activeModal.pod : null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
{editError && (
|
||||||
|
<p className="mb-2 text-sm text-destructive">{editError}</p>
|
||||||
|
)}
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
@ -102,91 +114,46 @@ export function PodList({ pods, clusterId, namespace }: PodListProps) {
|
|||||||
<TableCell>{pod.ready}</TableCell>
|
<TableCell>{pod.ready}</TableCell>
|
||||||
<TableCell className="text-muted-foreground">{pod.age}</TableCell>
|
<TableCell className="text-muted-foreground">{pod.age}</TableCell>
|
||||||
<TableCell className="text-right">
|
<TableCell className="text-right">
|
||||||
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
|
<ResourceActionMenu
|
||||||
<Button variant="ghost" size="sm" onClick={() => { setSelectedPod(pod); setIsDialogOpen(true); }}>
|
actions={[
|
||||||
<Terminal className="w-4 h-4" />
|
{
|
||||||
</Button>
|
label: "Logs",
|
||||||
<DialogContent className="max-w-4xl max-h-[80vh] flex flex-col">
|
icon: FileText,
|
||||||
<DialogHeader>
|
onClick: () => setActiveModal({ type: "logs", pod }),
|
||||||
<DialogTitle>{pod.name} - {namespace} namespace</DialogTitle>
|
},
|
||||||
</DialogHeader>
|
{
|
||||||
<div className="flex-1 overflow-y-auto flex flex-col">
|
label: "Shell",
|
||||||
{selectedPod && (
|
icon: Terminal,
|
||||||
<div className="space-y-4">
|
onClick: () => setActiveModal({ type: "shell", pod }),
|
||||||
<div className="flex items-center gap-2">
|
},
|
||||||
<span className="text-sm font-medium">Container:</span>
|
{
|
||||||
<select
|
label: "Attach",
|
||||||
value={selectedContainer}
|
icon: Link,
|
||||||
onChange={(e) => handleContainerChange(e.target.value)}
|
onClick: () => setActiveModal({ type: "attach", pod }),
|
||||||
className="flex h-9 w-32 rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2"
|
},
|
||||||
>
|
{
|
||||||
<option value="">Select container...</option>
|
label: "Edit",
|
||||||
{containers.map((container) => (
|
icon: Pencil,
|
||||||
<option key={container} value={container}>
|
onClick: () => openEdit(pod),
|
||||||
{container}
|
},
|
||||||
</option>
|
{
|
||||||
))}
|
label: "Delete",
|
||||||
</select>
|
icon: Trash2,
|
||||||
<Button
|
variant: "destructive",
|
||||||
onClick={fetchLogs}
|
onClick: () => setActiveModal({ type: "delete", pod }),
|
||||||
disabled={!selectedContainer || isFetchingLogs}
|
},
|
||||||
size="sm"
|
{
|
||||||
>
|
label: "Force Delete",
|
||||||
{isFetchingLogs ? (
|
icon: Zap,
|
||||||
<>
|
variant: "destructive",
|
||||||
<RotateCcw className="w-4 h-4 animate-spin" />
|
hidden: !(
|
||||||
Loading...
|
pod.status.toLowerCase() === "running" ||
|
||||||
</>
|
pod.status.toLowerCase() === "pending"
|
||||||
) : (
|
),
|
||||||
<>
|
onClick: () => setActiveModal({ type: "force-delete", pod }),
|
||||||
<FileText className="w-4 h-4" />
|
},
|
||||||
Fetch Logs
|
]}
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{error && (
|
|
||||||
<Alert variant="destructive">
|
|
||||||
<AlertDescription>{error}</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Tabs value="logs" onValueChange={() => {}}>
|
|
||||||
<TabsList className="grid grid-cols-2">
|
|
||||||
<TabsTrigger value="logs">Logs</TabsTrigger>
|
|
||||||
<TabsTrigger value="details">Details</TabsTrigger>
|
|
||||||
</TabsList>
|
|
||||||
<div className="flex-1 overflow-auto">
|
|
||||||
<TabsContent value="logs" className="h-full">
|
|
||||||
<Textarea
|
|
||||||
value={logs}
|
|
||||||
readOnly
|
|
||||||
className="font-mono text-xs h-64"
|
|
||||||
placeholder="No logs available. Click 'Fetch Logs' to retrieve."
|
|
||||||
/>
|
/>
|
||||||
</TabsContent>
|
|
||||||
<TabsContent value="details" className="h-full">
|
|
||||||
<div className="space-y-2 text-sm">
|
|
||||||
<div className="grid grid-cols-2 gap-2">
|
|
||||||
<div className="text-muted-foreground">Name:</div>
|
|
||||||
<div>{selectedPod.name}</div>
|
|
||||||
<div className="text-muted-foreground">Status:</div>
|
|
||||||
<div>{selectedPod.status}</div>
|
|
||||||
<div className="text-muted-foreground">Ready:</div>
|
|
||||||
<div>{selectedPod.ready}</div>
|
|
||||||
<div className="text-muted-foreground">Age:</div>
|
|
||||||
<div>{selectedPod.age}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</TabsContent>
|
|
||||||
</div>
|
|
||||||
</Tabs>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))
|
))
|
||||||
@ -194,6 +161,74 @@ export function PodList({ pods, clusterId, namespace }: PodListProps) {
|
|||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{activeModal?.type === "logs" && (
|
||||||
|
<LogsModal
|
||||||
|
open
|
||||||
|
onOpenChange={(o) => { if (!o) setActiveModal(null); }}
|
||||||
|
clusterId={clusterId}
|
||||||
|
namespace={namespace}
|
||||||
|
podName={activeModal.pod.name}
|
||||||
|
containers={activeModal.pod.containers}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeModal?.type === "shell" && (
|
||||||
|
<ShellExecModal
|
||||||
|
open
|
||||||
|
onOpenChange={(o) => { if (!o) setActiveModal(null); }}
|
||||||
|
clusterId={clusterId}
|
||||||
|
namespace={namespace}
|
||||||
|
podName={activeModal.pod.name}
|
||||||
|
containers={activeModal.pod.containers}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeModal?.type === "attach" && (
|
||||||
|
<AttachModal
|
||||||
|
open
|
||||||
|
onOpenChange={(o) => { if (!o) setActiveModal(null); }}
|
||||||
|
clusterId={clusterId}
|
||||||
|
namespace={namespace}
|
||||||
|
podName={activeModal.pod.name}
|
||||||
|
containers={activeModal.pod.containers}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeModal?.type === "edit" && (
|
||||||
|
<EditResourceModal
|
||||||
|
isOpen
|
||||||
|
clusterId={clusterId}
|
||||||
|
namespace={namespace}
|
||||||
|
resourceType="pods"
|
||||||
|
resourceName={activeModal.pod.name}
|
||||||
|
initialYaml={activeModal.yaml}
|
||||||
|
onClose={() => { setActiveModal(null); onRefresh?.(); }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeModal?.type === "delete" && currentPod && (
|
||||||
|
<ConfirmDeleteDialog
|
||||||
|
open
|
||||||
|
onOpenChange={(o) => { if (!o) setActiveModal(null); }}
|
||||||
|
resourceType="Pod"
|
||||||
|
resourceName={currentPod.name}
|
||||||
|
isLoading={isDeleting}
|
||||||
|
onConfirm={() => handleDelete(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeModal?.type === "force-delete" && currentPod && (
|
||||||
|
<ConfirmDeleteDialog
|
||||||
|
open
|
||||||
|
onOpenChange={(o) => { if (!o) setActiveModal(null); }}
|
||||||
|
resourceType="Pod"
|
||||||
|
resourceName={currentPod.name}
|
||||||
|
variant="force-delete"
|
||||||
|
isLoading={isDeleting}
|
||||||
|
onConfirm={() => handleDelete(true)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
50
src/components/Kubernetes/PriorityClassList.tsx
Normal file
50
src/components/Kubernetes/PriorityClassList.tsx
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow, Badge } from "@/components/ui";
|
||||||
|
import type { PriorityClassInfo } from "@/lib/tauriCommands";
|
||||||
|
|
||||||
|
interface PriorityClassListProps {
|
||||||
|
items: PriorityClassInfo[];
|
||||||
|
clusterId: string;
|
||||||
|
namespace?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PriorityClassList({ items }: PriorityClassListProps) {
|
||||||
|
return (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Name</TableHead>
|
||||||
|
<TableHead>Value</TableHead>
|
||||||
|
<TableHead>Global Default</TableHead>
|
||||||
|
<TableHead>Age</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{items.length === 0 ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={4} className="text-center text-muted-foreground">
|
||||||
|
No priority classes found
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : (
|
||||||
|
items.map((pc) => (
|
||||||
|
<TableRow key={pc.name}>
|
||||||
|
<TableCell className="font-medium">{pc.name}</TableCell>
|
||||||
|
<TableCell className="text-sm font-mono">{pc.value}</TableCell>
|
||||||
|
<TableCell className="text-sm">
|
||||||
|
{pc.global_default ? (
|
||||||
|
<Badge variant="success">Yes</Badge>
|
||||||
|
) : (
|
||||||
|
<span className="text-muted-foreground">No</span>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-sm text-muted-foreground">{pc.age}</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,15 +1,73 @@
|
|||||||
import React from "react";
|
import React, { useState } from "react";
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui";
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui";
|
||||||
|
import { Scale, Pencil, Trash2 } from "lucide-react";
|
||||||
import type { ReplicaSetInfo } from "@/lib/tauriCommands";
|
import type { ReplicaSetInfo } from "@/lib/tauriCommands";
|
||||||
|
import {
|
||||||
|
scaleReplicasetCmd,
|
||||||
|
deleteResourceCmd,
|
||||||
|
getResourceYamlCmd,
|
||||||
|
} from "@/lib/tauriCommands";
|
||||||
|
import { ResourceActionMenu } from "./ResourceActionMenu";
|
||||||
|
import { ConfirmDeleteDialog } from "./ConfirmDeleteDialog";
|
||||||
|
import { ScaleModal } from "./ScaleModal";
|
||||||
|
import { EditResourceModal } from "./EditResourceModal";
|
||||||
|
|
||||||
interface ReplicaSetListProps {
|
interface ReplicaSetListProps {
|
||||||
replicaSets: ReplicaSetInfo[];
|
replicaSets: ReplicaSetInfo[];
|
||||||
_clusterId: string;
|
clusterId?: string;
|
||||||
_namespace: string;
|
_clusterId?: string;
|
||||||
|
namespace?: string;
|
||||||
|
_namespace?: string;
|
||||||
|
onRefresh?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ReplicaSetList({ replicaSets, _clusterId, _namespace }: ReplicaSetListProps) {
|
type ActiveModal =
|
||||||
|
| { type: "scale"; rs: ReplicaSetInfo }
|
||||||
|
| { type: "edit"; rs: ReplicaSetInfo; yaml: string }
|
||||||
|
| { type: "delete"; rs: ReplicaSetInfo }
|
||||||
|
| null;
|
||||||
|
|
||||||
|
export function ReplicaSetList({
|
||||||
|
replicaSets,
|
||||||
|
clusterId,
|
||||||
|
_clusterId,
|
||||||
|
namespace,
|
||||||
|
_namespace,
|
||||||
|
onRefresh,
|
||||||
|
}: ReplicaSetListProps) {
|
||||||
|
const cid = clusterId ?? _clusterId ?? "";
|
||||||
|
const ns = namespace ?? _namespace ?? "";
|
||||||
|
const [activeModal, setActiveModal] = useState<ActiveModal>(null);
|
||||||
|
const [isActing, setIsActing] = useState(false);
|
||||||
|
const [actionError, setActionError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const openEdit = async (rs: ReplicaSetInfo) => {
|
||||||
|
setActionError(null);
|
||||||
|
try {
|
||||||
|
const yaml = await getResourceYamlCmd(cid, "replicasets", ns, rs.name);
|
||||||
|
setActiveModal({ type: "edit", rs, yaml });
|
||||||
|
} catch (err) {
|
||||||
|
setActionError(err instanceof Error ? err.message : String(err));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async () => {
|
||||||
|
if (activeModal?.type !== "delete") return;
|
||||||
|
setIsActing(true);
|
||||||
|
try {
|
||||||
|
await deleteResourceCmd(cid, "replicasets", ns, activeModal.rs.name);
|
||||||
|
setActiveModal(null);
|
||||||
|
onRefresh?.();
|
||||||
|
} finally {
|
||||||
|
setIsActing(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
|
{actionError && (
|
||||||
|
<p className="mb-2 text-sm text-destructive">{actionError}</p>
|
||||||
|
)}
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
@ -20,33 +78,96 @@ export function ReplicaSetList({ replicaSets, _clusterId, _namespace }: ReplicaS
|
|||||||
<TableHead>Ready</TableHead>
|
<TableHead>Ready</TableHead>
|
||||||
<TableHead>Age</TableHead>
|
<TableHead>Age</TableHead>
|
||||||
<TableHead>Labels</TableHead>
|
<TableHead>Labels</TableHead>
|
||||||
|
<TableHead className="text-right">Actions</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{replicaSets.length === 0 ? (
|
{replicaSets.length === 0 ? (
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell colSpan={6} className="text-center text-muted-foreground">
|
<TableCell colSpan={7} className="text-center text-muted-foreground">
|
||||||
No replica sets found
|
No replica sets found
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
) : (
|
) : (
|
||||||
replicaSets.map((replicaSet) => (
|
replicaSets.map((rs) => (
|
||||||
<TableRow key={`${replicaSet.name}-${replicaSet.namespace}`}>
|
<TableRow key={`${rs.name}-${rs.namespace}`}>
|
||||||
<TableCell className="font-medium">{replicaSet.name}</TableCell>
|
<TableCell className="font-medium">{rs.name}</TableCell>
|
||||||
<TableCell>{replicaSet.namespace}</TableCell>
|
<TableCell>{rs.namespace}</TableCell>
|
||||||
<TableCell>{replicaSet.replicas}</TableCell>
|
<TableCell>{rs.replicas}</TableCell>
|
||||||
<TableCell>{replicaSet.ready}</TableCell>
|
<TableCell>{rs.ready}</TableCell>
|
||||||
<TableCell className="text-muted-foreground">{replicaSet.age}</TableCell>
|
<TableCell className="text-muted-foreground">{rs.age}</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
{Object.entries(replicaSet.labels)
|
{Object.entries(rs.labels)
|
||||||
.map(([k, v]) => `${k}=${v}`)
|
.map(([k, v]) => `${k}=${v}`)
|
||||||
.join(", ")}
|
.join(", ")}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
<ResourceActionMenu
|
||||||
|
actions={[
|
||||||
|
{
|
||||||
|
label: "Scale",
|
||||||
|
icon: Scale,
|
||||||
|
onClick: () => setActiveModal({ type: "scale", rs }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Edit",
|
||||||
|
icon: Pencil,
|
||||||
|
onClick: () => openEdit(rs),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Delete",
|
||||||
|
icon: Trash2,
|
||||||
|
variant: "destructive",
|
||||||
|
onClick: () => setActiveModal({ type: "delete", rs }),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{activeModal?.type === "scale" && (
|
||||||
|
<ScaleModal
|
||||||
|
open
|
||||||
|
onOpenChange={(o) => { if (!o) setActiveModal(null); }}
|
||||||
|
resourceType="ReplicaSet"
|
||||||
|
resourceName={activeModal.rs.name}
|
||||||
|
currentReplicas={activeModal.rs.replicas}
|
||||||
|
onScale={(replicas) =>
|
||||||
|
scaleReplicasetCmd(cid, ns, activeModal.rs.name, replicas).then(() => {
|
||||||
|
setActiveModal(null);
|
||||||
|
onRefresh?.();
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeModal?.type === "edit" && (
|
||||||
|
<EditResourceModal
|
||||||
|
isOpen
|
||||||
|
clusterId={cid}
|
||||||
|
namespace={ns}
|
||||||
|
resourceType="replicasets"
|
||||||
|
resourceName={activeModal.rs.name}
|
||||||
|
initialYaml={activeModal.yaml}
|
||||||
|
onClose={() => { setActiveModal(null); onRefresh?.(); }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeModal?.type === "delete" && (
|
||||||
|
<ConfirmDeleteDialog
|
||||||
|
open
|
||||||
|
onOpenChange={(o) => { if (!o) setActiveModal(null); }}
|
||||||
|
resourceType="ReplicaSet"
|
||||||
|
resourceName={activeModal.rs.name}
|
||||||
|
isLoading={isActing}
|
||||||
|
onConfirm={handleDelete}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
48
src/components/Kubernetes/ReplicationControllerList.tsx
Normal file
48
src/components/Kubernetes/ReplicationControllerList.tsx
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui";
|
||||||
|
import type { ReplicationControllerInfo } from "@/lib/tauriCommands";
|
||||||
|
|
||||||
|
interface ReplicationControllerListProps {
|
||||||
|
items: ReplicationControllerInfo[];
|
||||||
|
clusterId: string;
|
||||||
|
namespace?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ReplicationControllerList({ items }: ReplicationControllerListProps) {
|
||||||
|
return (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Name</TableHead>
|
||||||
|
<TableHead>Namespace</TableHead>
|
||||||
|
<TableHead>Desired</TableHead>
|
||||||
|
<TableHead>Ready</TableHead>
|
||||||
|
<TableHead>Current</TableHead>
|
||||||
|
<TableHead>Age</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{items.length === 0 ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={6} className="text-center text-muted-foreground">
|
||||||
|
No replication controllers found
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : (
|
||||||
|
items.map((rc) => (
|
||||||
|
<TableRow key={`${rc.name}-${rc.namespace}`}>
|
||||||
|
<TableCell className="font-medium">{rc.name}</TableCell>
|
||||||
|
<TableCell className="text-sm text-muted-foreground">{rc.namespace}</TableCell>
|
||||||
|
<TableCell className="text-sm">{rc.desired}</TableCell>
|
||||||
|
<TableCell className="text-sm">{rc.ready}</TableCell>
|
||||||
|
<TableCell className="text-sm">{rc.current}</TableCell>
|
||||||
|
<TableCell className="text-sm text-muted-foreground">{rc.age}</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
88
src/components/Kubernetes/ResourceActionMenu.tsx
Normal file
88
src/components/Kubernetes/ResourceActionMenu.tsx
Normal file
@ -0,0 +1,88 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { MoreHorizontal } from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui";
|
||||||
|
|
||||||
|
export interface ResourceAction {
|
||||||
|
label: string;
|
||||||
|
icon: React.ElementType;
|
||||||
|
onClick: () => void;
|
||||||
|
variant?: "default" | "destructive";
|
||||||
|
disabled?: boolean;
|
||||||
|
hidden?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ResourceActionMenuProps {
|
||||||
|
actions: ResourceAction[];
|
||||||
|
triggerLabel?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ResourceActionMenu({ actions, triggerLabel = "Actions" }: ResourceActionMenuProps) {
|
||||||
|
const [open, setOpen] = React.useState(false);
|
||||||
|
const ref = React.useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const visible = actions.filter((a) => !a.hidden);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
const handler = (e: MouseEvent) => {
|
||||||
|
if (ref.current && !ref.current.contains(e.target as Node)) {
|
||||||
|
setOpen(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener("mousedown", handler);
|
||||||
|
return () => document.removeEventListener("mousedown", handler);
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
if (visible.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={ref} className="relative inline-block text-left">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
aria-label={triggerLabel}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setOpen((v) => !v);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<MoreHorizontal className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{open && (
|
||||||
|
<div className="absolute right-0 z-50 mt-1 w-48 rounded-md border bg-card shadow-lg">
|
||||||
|
<div className="py-1">
|
||||||
|
{visible.map((action, idx) => {
|
||||||
|
const Icon = action.icon;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={idx}
|
||||||
|
disabled={action.disabled}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setOpen(false);
|
||||||
|
action.onClick();
|
||||||
|
}}
|
||||||
|
className={[
|
||||||
|
"flex w-full items-center gap-2 px-3 py-2 text-sm transition-colors",
|
||||||
|
action.disabled
|
||||||
|
? "cursor-not-allowed opacity-50"
|
||||||
|
: "cursor-pointer hover:bg-accent hover:text-accent-foreground",
|
||||||
|
action.variant === "destructive"
|
||||||
|
? "text-destructive hover:text-destructive"
|
||||||
|
: "text-foreground",
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" ")}
|
||||||
|
>
|
||||||
|
<Icon className="h-4 w-4 shrink-0" />
|
||||||
|
{action.label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,15 +1,56 @@
|
|||||||
import React from "react";
|
import React, { useState } from "react";
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui";
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui";
|
||||||
|
import { Pencil, Trash2 } from "lucide-react";
|
||||||
import type { ResourceQuotaInfo } from "@/lib/tauriCommands";
|
import type { ResourceQuotaInfo } from "@/lib/tauriCommands";
|
||||||
|
import { deleteResourceCmd, getResourceYamlCmd } from "@/lib/tauriCommands";
|
||||||
|
import { ResourceActionMenu } from "./ResourceActionMenu";
|
||||||
|
import { ConfirmDeleteDialog } from "./ConfirmDeleteDialog";
|
||||||
|
import { EditResourceModal } from "./EditResourceModal";
|
||||||
|
|
||||||
interface ResourceQuotaListProps {
|
interface ResourceQuotaListProps {
|
||||||
resourcequotas: ResourceQuotaInfo[];
|
resourcequotas: ResourceQuotaInfo[];
|
||||||
clusterId: string;
|
clusterId: string;
|
||||||
namespace: string;
|
namespace: string;
|
||||||
|
onRefresh?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ResourceQuotaList({ resourcequotas }: ResourceQuotaListProps) {
|
type ActiveModal =
|
||||||
|
| { type: "edit"; rq: ResourceQuotaInfo; yaml: string }
|
||||||
|
| { type: "delete"; rq: ResourceQuotaInfo }
|
||||||
|
| null;
|
||||||
|
|
||||||
|
export function ResourceQuotaList({ resourcequotas, clusterId, namespace, onRefresh }: ResourceQuotaListProps) {
|
||||||
|
const [activeModal, setActiveModal] = useState<ActiveModal>(null);
|
||||||
|
const [isDeleting, setIsDeleting] = useState(false);
|
||||||
|
const [actionError, setActionError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const openEdit = async (rq: ResourceQuotaInfo) => {
|
||||||
|
setActionError(null);
|
||||||
|
try {
|
||||||
|
const yaml = await getResourceYamlCmd(clusterId, "resourcequotas", namespace, rq.name);
|
||||||
|
setActiveModal({ type: "edit", rq, yaml });
|
||||||
|
} catch (err) {
|
||||||
|
setActionError(err instanceof Error ? err.message : String(err));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async () => {
|
||||||
|
if (activeModal?.type !== "delete") return;
|
||||||
|
setIsDeleting(true);
|
||||||
|
try {
|
||||||
|
await deleteResourceCmd(clusterId, "resourcequotas", namespace, activeModal.rq.name);
|
||||||
|
setActiveModal(null);
|
||||||
|
onRefresh?.();
|
||||||
|
} finally {
|
||||||
|
setIsDeleting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
|
{actionError && (
|
||||||
|
<p className="mb-2 text-sm text-destructive">{actionError}</p>
|
||||||
|
)}
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
@ -21,12 +62,13 @@ export function ResourceQuotaList({ resourcequotas }: ResourceQuotaListProps) {
|
|||||||
<TableHead>CPU Limit</TableHead>
|
<TableHead>CPU Limit</TableHead>
|
||||||
<TableHead>Mem Limit</TableHead>
|
<TableHead>Mem Limit</TableHead>
|
||||||
<TableHead>Age</TableHead>
|
<TableHead>Age</TableHead>
|
||||||
|
<TableHead className="text-right">Actions</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{resourcequotas.length === 0 ? (
|
{resourcequotas.length === 0 ? (
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell colSpan={7} className="text-center text-muted-foreground">
|
<TableCell colSpan={8} className="text-center text-muted-foreground">
|
||||||
No resource quotas found
|
No resource quotas found
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
@ -40,11 +82,52 @@ export function ResourceQuotaList({ resourcequotas }: ResourceQuotaListProps) {
|
|||||||
<TableCell className="text-sm font-mono">{rq.limit_cpu || "—"}</TableCell>
|
<TableCell className="text-sm font-mono">{rq.limit_cpu || "—"}</TableCell>
|
||||||
<TableCell className="text-sm font-mono">{rq.limit_memory || "—"}</TableCell>
|
<TableCell className="text-sm font-mono">{rq.limit_memory || "—"}</TableCell>
|
||||||
<TableCell className="text-sm text-muted-foreground">{rq.age}</TableCell>
|
<TableCell className="text-sm text-muted-foreground">{rq.age}</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
<ResourceActionMenu
|
||||||
|
actions={[
|
||||||
|
{
|
||||||
|
label: "Edit",
|
||||||
|
icon: Pencil,
|
||||||
|
onClick: () => openEdit(rq),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Delete",
|
||||||
|
icon: Trash2,
|
||||||
|
variant: "destructive",
|
||||||
|
onClick: () => setActiveModal({ type: "delete", rq }),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{activeModal?.type === "edit" && (
|
||||||
|
<EditResourceModal
|
||||||
|
isOpen
|
||||||
|
clusterId={clusterId}
|
||||||
|
namespace={namespace}
|
||||||
|
resourceType="resourcequotas"
|
||||||
|
resourceName={activeModal.rq.name}
|
||||||
|
initialYaml={activeModal.yaml}
|
||||||
|
onClose={() => { setActiveModal(null); onRefresh?.(); }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeModal?.type === "delete" && (
|
||||||
|
<ConfirmDeleteDialog
|
||||||
|
open
|
||||||
|
onOpenChange={(o) => { if (!o) setActiveModal(null); }}
|
||||||
|
resourceType="ResourceQuota"
|
||||||
|
resourceName={activeModal.rq.name}
|
||||||
|
isLoading={isDeleting}
|
||||||
|
onConfirm={handleDelete}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,15 +1,67 @@
|
|||||||
import React from "react";
|
import React, { useState } from "react";
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui";
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui";
|
||||||
|
import { Pencil, Trash2 } from "lucide-react";
|
||||||
import type { RoleBindingInfo } from "@/lib/tauriCommands";
|
import type { RoleBindingInfo } from "@/lib/tauriCommands";
|
||||||
|
import { deleteResourceCmd, getResourceYamlCmd } from "@/lib/tauriCommands";
|
||||||
|
import { ResourceActionMenu } from "./ResourceActionMenu";
|
||||||
|
import { ConfirmDeleteDialog } from "./ConfirmDeleteDialog";
|
||||||
|
import { EditResourceModal } from "./EditResourceModal";
|
||||||
|
|
||||||
interface RoleBindingListProps {
|
interface RoleBindingListProps {
|
||||||
roleBindings: RoleBindingInfo[];
|
roleBindings: RoleBindingInfo[];
|
||||||
_clusterId: string;
|
clusterId?: string;
|
||||||
_namespace: string;
|
_clusterId?: string;
|
||||||
|
namespace?: string;
|
||||||
|
_namespace?: string;
|
||||||
|
onRefresh?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function RoleBindingList({ roleBindings, _clusterId, _namespace }: RoleBindingListProps) {
|
type ActiveModal =
|
||||||
|
| { type: "edit"; rb: RoleBindingInfo; yaml: string }
|
||||||
|
| { type: "delete"; rb: RoleBindingInfo }
|
||||||
|
| null;
|
||||||
|
|
||||||
|
export function RoleBindingList({
|
||||||
|
roleBindings,
|
||||||
|
clusterId,
|
||||||
|
_clusterId,
|
||||||
|
namespace,
|
||||||
|
_namespace,
|
||||||
|
onRefresh,
|
||||||
|
}: RoleBindingListProps) {
|
||||||
|
const cid = clusterId ?? _clusterId ?? "";
|
||||||
|
const ns = namespace ?? _namespace ?? "";
|
||||||
|
const [activeModal, setActiveModal] = useState<ActiveModal>(null);
|
||||||
|
const [isDeleting, setIsDeleting] = useState(false);
|
||||||
|
const [actionError, setActionError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const openEdit = async (rb: RoleBindingInfo) => {
|
||||||
|
setActionError(null);
|
||||||
|
try {
|
||||||
|
const yaml = await getResourceYamlCmd(cid, "rolebindings", ns, rb.name);
|
||||||
|
setActiveModal({ type: "edit", rb, yaml });
|
||||||
|
} catch (err) {
|
||||||
|
setActionError(err instanceof Error ? err.message : String(err));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async () => {
|
||||||
|
if (activeModal?.type !== "delete") return;
|
||||||
|
setIsDeleting(true);
|
||||||
|
try {
|
||||||
|
await deleteResourceCmd(cid, "rolebindings", ns, activeModal.rb.name);
|
||||||
|
setActiveModal(null);
|
||||||
|
onRefresh?.();
|
||||||
|
} finally {
|
||||||
|
setIsDeleting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
|
{actionError && (
|
||||||
|
<p className="mb-2 text-sm text-destructive">{actionError}</p>
|
||||||
|
)}
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
@ -18,12 +70,13 @@ export function RoleBindingList({ roleBindings, _clusterId, _namespace }: RoleBi
|
|||||||
<TableHead>Namespace</TableHead>
|
<TableHead>Namespace</TableHead>
|
||||||
<TableHead>Role</TableHead>
|
<TableHead>Role</TableHead>
|
||||||
<TableHead>Age</TableHead>
|
<TableHead>Age</TableHead>
|
||||||
|
<TableHead className="text-right">Actions</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{roleBindings.length === 0 ? (
|
{roleBindings.length === 0 ? (
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell colSpan={4} className="text-center text-muted-foreground">
|
<TableCell colSpan={5} className="text-center text-muted-foreground">
|
||||||
No role bindings found
|
No role bindings found
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
@ -34,11 +87,52 @@ export function RoleBindingList({ roleBindings, _clusterId, _namespace }: RoleBi
|
|||||||
<TableCell>{rb.namespace}</TableCell>
|
<TableCell>{rb.namespace}</TableCell>
|
||||||
<TableCell>{rb.role}</TableCell>
|
<TableCell>{rb.role}</TableCell>
|
||||||
<TableCell className="text-muted-foreground">{rb.age}</TableCell>
|
<TableCell className="text-muted-foreground">{rb.age}</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
<ResourceActionMenu
|
||||||
|
actions={[
|
||||||
|
{
|
||||||
|
label: "Edit",
|
||||||
|
icon: Pencil,
|
||||||
|
onClick: () => openEdit(rb),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Delete",
|
||||||
|
icon: Trash2,
|
||||||
|
variant: "destructive",
|
||||||
|
onClick: () => setActiveModal({ type: "delete", rb }),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{activeModal?.type === "edit" && (
|
||||||
|
<EditResourceModal
|
||||||
|
isOpen
|
||||||
|
clusterId={cid}
|
||||||
|
namespace={ns}
|
||||||
|
resourceType="rolebindings"
|
||||||
|
resourceName={activeModal.rb.name}
|
||||||
|
initialYaml={activeModal.yaml}
|
||||||
|
onClose={() => { setActiveModal(null); onRefresh?.(); }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeModal?.type === "delete" && (
|
||||||
|
<ConfirmDeleteDialog
|
||||||
|
open
|
||||||
|
onOpenChange={(o) => { if (!o) setActiveModal(null); }}
|
||||||
|
resourceType="RoleBinding"
|
||||||
|
resourceName={activeModal.rb.name}
|
||||||
|
isLoading={isDeleting}
|
||||||
|
onConfirm={handleDelete}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,15 +1,67 @@
|
|||||||
import React from "react";
|
import React, { useState } from "react";
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui";
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui";
|
||||||
|
import { Pencil, Trash2 } from "lucide-react";
|
||||||
import type { RoleInfo } from "@/lib/tauriCommands";
|
import type { RoleInfo } from "@/lib/tauriCommands";
|
||||||
|
import { deleteResourceCmd, getResourceYamlCmd } from "@/lib/tauriCommands";
|
||||||
|
import { ResourceActionMenu } from "./ResourceActionMenu";
|
||||||
|
import { ConfirmDeleteDialog } from "./ConfirmDeleteDialog";
|
||||||
|
import { EditResourceModal } from "./EditResourceModal";
|
||||||
|
|
||||||
interface RoleListProps {
|
interface RoleListProps {
|
||||||
roles: RoleInfo[];
|
roles: RoleInfo[];
|
||||||
_clusterId: string;
|
clusterId?: string;
|
||||||
_namespace: string;
|
_clusterId?: string;
|
||||||
|
namespace?: string;
|
||||||
|
_namespace?: string;
|
||||||
|
onRefresh?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function RoleList({ roles, _clusterId, _namespace }: RoleListProps) {
|
type ActiveModal =
|
||||||
|
| { type: "edit"; role: RoleInfo; yaml: string }
|
||||||
|
| { type: "delete"; role: RoleInfo }
|
||||||
|
| null;
|
||||||
|
|
||||||
|
export function RoleList({
|
||||||
|
roles,
|
||||||
|
clusterId,
|
||||||
|
_clusterId,
|
||||||
|
namespace,
|
||||||
|
_namespace,
|
||||||
|
onRefresh,
|
||||||
|
}: RoleListProps) {
|
||||||
|
const cid = clusterId ?? _clusterId ?? "";
|
||||||
|
const ns = namespace ?? _namespace ?? "";
|
||||||
|
const [activeModal, setActiveModal] = useState<ActiveModal>(null);
|
||||||
|
const [isDeleting, setIsDeleting] = useState(false);
|
||||||
|
const [actionError, setActionError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const openEdit = async (role: RoleInfo) => {
|
||||||
|
setActionError(null);
|
||||||
|
try {
|
||||||
|
const yaml = await getResourceYamlCmd(cid, "roles", ns, role.name);
|
||||||
|
setActiveModal({ type: "edit", role, yaml });
|
||||||
|
} catch (err) {
|
||||||
|
setActionError(err instanceof Error ? err.message : String(err));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async () => {
|
||||||
|
if (activeModal?.type !== "delete") return;
|
||||||
|
setIsDeleting(true);
|
||||||
|
try {
|
||||||
|
await deleteResourceCmd(cid, "roles", ns, activeModal.role.name);
|
||||||
|
setActiveModal(null);
|
||||||
|
onRefresh?.();
|
||||||
|
} finally {
|
||||||
|
setIsDeleting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
|
{actionError && (
|
||||||
|
<p className="mb-2 text-sm text-destructive">{actionError}</p>
|
||||||
|
)}
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
@ -17,12 +69,13 @@ export function RoleList({ roles, _clusterId, _namespace }: RoleListProps) {
|
|||||||
<TableHead>Name</TableHead>
|
<TableHead>Name</TableHead>
|
||||||
<TableHead>Namespace</TableHead>
|
<TableHead>Namespace</TableHead>
|
||||||
<TableHead>Age</TableHead>
|
<TableHead>Age</TableHead>
|
||||||
|
<TableHead className="text-right">Actions</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{roles.length === 0 ? (
|
{roles.length === 0 ? (
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell colSpan={3} className="text-center text-muted-foreground">
|
<TableCell colSpan={4} className="text-center text-muted-foreground">
|
||||||
No roles found
|
No roles found
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
@ -32,11 +85,52 @@ export function RoleList({ roles, _clusterId, _namespace }: RoleListProps) {
|
|||||||
<TableCell className="font-medium">{role.name}</TableCell>
|
<TableCell className="font-medium">{role.name}</TableCell>
|
||||||
<TableCell>{role.namespace}</TableCell>
|
<TableCell>{role.namespace}</TableCell>
|
||||||
<TableCell className="text-muted-foreground">{role.age}</TableCell>
|
<TableCell className="text-muted-foreground">{role.age}</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
<ResourceActionMenu
|
||||||
|
actions={[
|
||||||
|
{
|
||||||
|
label: "Edit",
|
||||||
|
icon: Pencil,
|
||||||
|
onClick: () => openEdit(role),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Delete",
|
||||||
|
icon: Trash2,
|
||||||
|
variant: "destructive",
|
||||||
|
onClick: () => setActiveModal({ type: "delete", role }),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{activeModal?.type === "edit" && (
|
||||||
|
<EditResourceModal
|
||||||
|
isOpen
|
||||||
|
clusterId={cid}
|
||||||
|
namespace={ns}
|
||||||
|
resourceType="roles"
|
||||||
|
resourceName={activeModal.role.name}
|
||||||
|
initialYaml={activeModal.yaml}
|
||||||
|
onClose={() => { setActiveModal(null); onRefresh?.(); }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeModal?.type === "delete" && (
|
||||||
|
<ConfirmDeleteDialog
|
||||||
|
open
|
||||||
|
onOpenChange={(o) => { if (!o) setActiveModal(null); }}
|
||||||
|
resourceType="Role"
|
||||||
|
resourceName={activeModal.role.name}
|
||||||
|
isLoading={isDeleting}
|
||||||
|
onConfirm={handleDelete}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
42
src/components/Kubernetes/RuntimeClassList.tsx
Normal file
42
src/components/Kubernetes/RuntimeClassList.tsx
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui";
|
||||||
|
import type { RuntimeClassInfo } from "@/lib/tauriCommands";
|
||||||
|
|
||||||
|
interface RuntimeClassListProps {
|
||||||
|
items: RuntimeClassInfo[];
|
||||||
|
clusterId: string;
|
||||||
|
namespace?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RuntimeClassList({ items }: RuntimeClassListProps) {
|
||||||
|
return (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Name</TableHead>
|
||||||
|
<TableHead>Handler</TableHead>
|
||||||
|
<TableHead>Age</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{items.length === 0 ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={3} className="text-center text-muted-foreground">
|
||||||
|
No runtime classes found
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : (
|
||||||
|
items.map((rc) => (
|
||||||
|
<TableRow key={rc.name}>
|
||||||
|
<TableCell className="font-medium">{rc.name}</TableCell>
|
||||||
|
<TableCell className="text-sm font-mono">{rc.handler}</TableCell>
|
||||||
|
<TableCell className="text-sm text-muted-foreground">{rc.age}</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
102
src/components/Kubernetes/ScaleModal.tsx
Normal file
102
src/components/Kubernetes/ScaleModal.tsx
Normal file
@ -0,0 +1,102 @@
|
|||||||
|
import React from "react";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogFooter,
|
||||||
|
} from "@/components/ui";
|
||||||
|
import { Button } from "@/components/ui";
|
||||||
|
import { Input } from "@/components/ui";
|
||||||
|
import { Label } from "@/components/ui";
|
||||||
|
import { Loader2 } from "lucide-react";
|
||||||
|
|
||||||
|
interface ScaleModalProps {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
resourceType: string;
|
||||||
|
resourceName: string;
|
||||||
|
currentReplicas: number;
|
||||||
|
onScale: (replicas: number) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ScaleModal({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
resourceType,
|
||||||
|
resourceName,
|
||||||
|
currentReplicas,
|
||||||
|
onScale,
|
||||||
|
}: ScaleModalProps) {
|
||||||
|
const [value, setValue] = React.useState(String(currentReplicas));
|
||||||
|
const [isLoading, setIsLoading] = React.useState(false);
|
||||||
|
const [error, setError] = React.useState<string | null>(null);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
setValue(String(currentReplicas));
|
||||||
|
setError(null);
|
||||||
|
}
|
||||||
|
}, [open, currentReplicas]);
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
const replicas = parseInt(value, 10);
|
||||||
|
if (isNaN(replicas) || replicas < 0) {
|
||||||
|
setError("Enter a valid non-negative integer.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setIsLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
await onScale(replicas);
|
||||||
|
onOpenChange(false);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : String(err));
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="max-w-sm">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>
|
||||||
|
Scale {resourceType}
|
||||||
|
</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-3 py-2">
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Scaling <span className="font-mono text-foreground">{resourceName}</span>
|
||||||
|
</p>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label htmlFor="scale-replicas">Replica Count</Label>
|
||||||
|
<Input
|
||||||
|
id="scale-replicas"
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => { setValue(e.target.value); setError(null); }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{error && <p className="text-sm text-destructive">{error}</p>}
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={isLoading}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleSubmit} disabled={isLoading}>
|
||||||
|
{isLoading ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
Scaling...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
"Scale"
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,15 +1,67 @@
|
|||||||
import React from "react";
|
import React, { useState } from "react";
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui";
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui";
|
||||||
|
import { Pencil, Trash2 } from "lucide-react";
|
||||||
import type { SecretInfo } from "@/lib/tauriCommands";
|
import type { SecretInfo } from "@/lib/tauriCommands";
|
||||||
|
import { deleteResourceCmd, getResourceYamlCmd } from "@/lib/tauriCommands";
|
||||||
|
import { ResourceActionMenu } from "./ResourceActionMenu";
|
||||||
|
import { ConfirmDeleteDialog } from "./ConfirmDeleteDialog";
|
||||||
|
import { EditResourceModal } from "./EditResourceModal";
|
||||||
|
|
||||||
interface SecretListProps {
|
interface SecretListProps {
|
||||||
secrets: SecretInfo[];
|
secrets: SecretInfo[];
|
||||||
_clusterId: string;
|
clusterId?: string;
|
||||||
_namespace: string;
|
_clusterId?: string;
|
||||||
|
namespace?: string;
|
||||||
|
_namespace?: string;
|
||||||
|
onRefresh?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SecretList({ secrets, _clusterId, _namespace }: SecretListProps) {
|
type ActiveModal =
|
||||||
|
| { type: "edit"; secret: SecretInfo; yaml: string }
|
||||||
|
| { type: "delete"; secret: SecretInfo }
|
||||||
|
| null;
|
||||||
|
|
||||||
|
export function SecretList({
|
||||||
|
secrets,
|
||||||
|
clusterId,
|
||||||
|
_clusterId,
|
||||||
|
namespace,
|
||||||
|
_namespace,
|
||||||
|
onRefresh,
|
||||||
|
}: SecretListProps) {
|
||||||
|
const cid = clusterId ?? _clusterId ?? "";
|
||||||
|
const ns = namespace ?? _namespace ?? "";
|
||||||
|
const [activeModal, setActiveModal] = useState<ActiveModal>(null);
|
||||||
|
const [isDeleting, setIsDeleting] = useState(false);
|
||||||
|
const [actionError, setActionError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const openEdit = async (secret: SecretInfo) => {
|
||||||
|
setActionError(null);
|
||||||
|
try {
|
||||||
|
const yaml = await getResourceYamlCmd(cid, "secrets", ns, secret.name);
|
||||||
|
setActiveModal({ type: "edit", secret, yaml });
|
||||||
|
} catch (err) {
|
||||||
|
setActionError(err instanceof Error ? err.message : String(err));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async () => {
|
||||||
|
if (activeModal?.type !== "delete") return;
|
||||||
|
setIsDeleting(true);
|
||||||
|
try {
|
||||||
|
await deleteResourceCmd(cid, "secrets", ns, activeModal.secret.name);
|
||||||
|
setActiveModal(null);
|
||||||
|
onRefresh?.();
|
||||||
|
} finally {
|
||||||
|
setIsDeleting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
|
{actionError && (
|
||||||
|
<p className="mb-2 text-sm text-destructive">{actionError}</p>
|
||||||
|
)}
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
@ -38,7 +90,21 @@ export function SecretList({ secrets, _clusterId, _namespace }: SecretListProps)
|
|||||||
<TableCell>{secret.data_keys}</TableCell>
|
<TableCell>{secret.data_keys}</TableCell>
|
||||||
<TableCell className="text-muted-foreground">{secret.age}</TableCell>
|
<TableCell className="text-muted-foreground">{secret.age}</TableCell>
|
||||||
<TableCell className="text-right">
|
<TableCell className="text-right">
|
||||||
<span className="text-sm">View/Edit</span>
|
<ResourceActionMenu
|
||||||
|
actions={[
|
||||||
|
{
|
||||||
|
label: "Edit",
|
||||||
|
icon: Pencil,
|
||||||
|
onClick: () => openEdit(secret),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Delete",
|
||||||
|
icon: Trash2,
|
||||||
|
variant: "destructive",
|
||||||
|
onClick: () => setActiveModal({ type: "delete", secret }),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))
|
))
|
||||||
@ -46,5 +112,29 @@ export function SecretList({ secrets, _clusterId, _namespace }: SecretListProps)
|
|||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{activeModal?.type === "edit" && (
|
||||||
|
<EditResourceModal
|
||||||
|
isOpen
|
||||||
|
clusterId={cid}
|
||||||
|
namespace={ns}
|
||||||
|
resourceType="secrets"
|
||||||
|
resourceName={activeModal.secret.name}
|
||||||
|
initialYaml={activeModal.yaml}
|
||||||
|
onClose={() => { setActiveModal(null); onRefresh?.(); }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeModal?.type === "delete" && (
|
||||||
|
<ConfirmDeleteDialog
|
||||||
|
open
|
||||||
|
onOpenChange={(o) => { if (!o) setActiveModal(null); }}
|
||||||
|
resourceType="Secret"
|
||||||
|
resourceName={activeModal.secret.name}
|
||||||
|
isLoading={isDeleting}
|
||||||
|
onConfirm={handleDelete}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,15 +1,67 @@
|
|||||||
import React from "react";
|
import React, { useState } from "react";
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui";
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui";
|
||||||
|
import { Pencil, Trash2 } from "lucide-react";
|
||||||
import type { ServiceAccountInfo } from "@/lib/tauriCommands";
|
import type { ServiceAccountInfo } from "@/lib/tauriCommands";
|
||||||
|
import { deleteResourceCmd, getResourceYamlCmd } from "@/lib/tauriCommands";
|
||||||
|
import { ResourceActionMenu } from "./ResourceActionMenu";
|
||||||
|
import { ConfirmDeleteDialog } from "./ConfirmDeleteDialog";
|
||||||
|
import { EditResourceModal } from "./EditResourceModal";
|
||||||
|
|
||||||
interface ServiceAccountListProps {
|
interface ServiceAccountListProps {
|
||||||
serviceAccounts: ServiceAccountInfo[];
|
serviceAccounts: ServiceAccountInfo[];
|
||||||
_clusterId: string;
|
clusterId?: string;
|
||||||
_namespace: string;
|
_clusterId?: string;
|
||||||
|
namespace?: string;
|
||||||
|
_namespace?: string;
|
||||||
|
onRefresh?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ServiceAccountList({ serviceAccounts, _clusterId, _namespace }: ServiceAccountListProps) {
|
type ActiveModal =
|
||||||
|
| { type: "edit"; sa: ServiceAccountInfo; yaml: string }
|
||||||
|
| { type: "delete"; sa: ServiceAccountInfo }
|
||||||
|
| null;
|
||||||
|
|
||||||
|
export function ServiceAccountList({
|
||||||
|
serviceAccounts,
|
||||||
|
clusterId,
|
||||||
|
_clusterId,
|
||||||
|
namespace,
|
||||||
|
_namespace,
|
||||||
|
onRefresh,
|
||||||
|
}: ServiceAccountListProps) {
|
||||||
|
const cid = clusterId ?? _clusterId ?? "";
|
||||||
|
const ns = namespace ?? _namespace ?? "";
|
||||||
|
const [activeModal, setActiveModal] = useState<ActiveModal>(null);
|
||||||
|
const [isDeleting, setIsDeleting] = useState(false);
|
||||||
|
const [actionError, setActionError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const openEdit = async (sa: ServiceAccountInfo) => {
|
||||||
|
setActionError(null);
|
||||||
|
try {
|
||||||
|
const yaml = await getResourceYamlCmd(cid, "serviceaccounts", ns, sa.name);
|
||||||
|
setActiveModal({ type: "edit", sa, yaml });
|
||||||
|
} catch (err) {
|
||||||
|
setActionError(err instanceof Error ? err.message : String(err));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async () => {
|
||||||
|
if (activeModal?.type !== "delete") return;
|
||||||
|
setIsDeleting(true);
|
||||||
|
try {
|
||||||
|
await deleteResourceCmd(cid, "serviceaccounts", ns, activeModal.sa.name);
|
||||||
|
setActiveModal(null);
|
||||||
|
onRefresh?.();
|
||||||
|
} finally {
|
||||||
|
setIsDeleting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
|
{actionError && (
|
||||||
|
<p className="mb-2 text-sm text-destructive">{actionError}</p>
|
||||||
|
)}
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
@ -18,12 +70,13 @@ export function ServiceAccountList({ serviceAccounts, _clusterId, _namespace }:
|
|||||||
<TableHead>Namespace</TableHead>
|
<TableHead>Namespace</TableHead>
|
||||||
<TableHead>Secrets</TableHead>
|
<TableHead>Secrets</TableHead>
|
||||||
<TableHead>Age</TableHead>
|
<TableHead>Age</TableHead>
|
||||||
|
<TableHead className="text-right">Actions</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{serviceAccounts.length === 0 ? (
|
{serviceAccounts.length === 0 ? (
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell colSpan={4} className="text-center text-muted-foreground">
|
<TableCell colSpan={5} className="text-center text-muted-foreground">
|
||||||
No service accounts found
|
No service accounts found
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
@ -34,11 +87,52 @@ export function ServiceAccountList({ serviceAccounts, _clusterId, _namespace }:
|
|||||||
<TableCell>{sa.namespace}</TableCell>
|
<TableCell>{sa.namespace}</TableCell>
|
||||||
<TableCell>{sa.secrets}</TableCell>
|
<TableCell>{sa.secrets}</TableCell>
|
||||||
<TableCell className="text-muted-foreground">{sa.age}</TableCell>
|
<TableCell className="text-muted-foreground">{sa.age}</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
<ResourceActionMenu
|
||||||
|
actions={[
|
||||||
|
{
|
||||||
|
label: "Edit",
|
||||||
|
icon: Pencil,
|
||||||
|
onClick: () => openEdit(sa),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Delete",
|
||||||
|
icon: Trash2,
|
||||||
|
variant: "destructive",
|
||||||
|
onClick: () => setActiveModal({ type: "delete", sa }),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{activeModal?.type === "edit" && (
|
||||||
|
<EditResourceModal
|
||||||
|
isOpen
|
||||||
|
clusterId={cid}
|
||||||
|
namespace={ns}
|
||||||
|
resourceType="serviceaccounts"
|
||||||
|
resourceName={activeModal.sa.name}
|
||||||
|
initialYaml={activeModal.yaml}
|
||||||
|
onClose={() => { setActiveModal(null); onRefresh?.(); }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeModal?.type === "delete" && (
|
||||||
|
<ConfirmDeleteDialog
|
||||||
|
open
|
||||||
|
onOpenChange={(o) => { if (!o) setActiveModal(null); }}
|
||||||
|
resourceType="ServiceAccount"
|
||||||
|
resourceName={activeModal.sa.name}
|
||||||
|
isLoading={isDeleting}
|
||||||
|
onConfirm={handleDelete}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,15 +1,30 @@
|
|||||||
import React from "react";
|
import React, { useState } from "react";
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui";
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui";
|
||||||
import { Badge } from "@/components/ui";
|
import { Badge } from "@/components/ui";
|
||||||
|
import { Pencil, Trash2 } from "lucide-react";
|
||||||
import type { ServiceInfo } from "@/lib/tauriCommands";
|
import type { ServiceInfo } from "@/lib/tauriCommands";
|
||||||
|
import { deleteResourceCmd, getResourceYamlCmd } from "@/lib/tauriCommands";
|
||||||
|
import { ResourceActionMenu } from "./ResourceActionMenu";
|
||||||
|
import { ConfirmDeleteDialog } from "./ConfirmDeleteDialog";
|
||||||
|
import { EditResourceModal } from "./EditResourceModal";
|
||||||
|
|
||||||
interface ServiceListProps {
|
interface ServiceListProps {
|
||||||
services: ServiceInfo[];
|
services: ServiceInfo[];
|
||||||
clusterId: string;
|
clusterId: string;
|
||||||
namespace: string;
|
namespace: string;
|
||||||
|
onRefresh?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ServiceList({ services, clusterId: _clusterId, namespace: _namespace }: ServiceListProps) {
|
type ActiveModal =
|
||||||
|
| { type: "edit"; svc: ServiceInfo; yaml: string }
|
||||||
|
| { type: "delete"; svc: ServiceInfo }
|
||||||
|
| null;
|
||||||
|
|
||||||
|
export function ServiceList({ services, clusterId, namespace, onRefresh }: ServiceListProps) {
|
||||||
|
const [activeModal, setActiveModal] = useState<ActiveModal>(null);
|
||||||
|
const [isDeleting, setIsDeleting] = useState(false);
|
||||||
|
const [actionError, setActionError] = useState<string | null>(null);
|
||||||
|
|
||||||
const getServiceTypeColor = (type: string) => {
|
const getServiceTypeColor = (type: string) => {
|
||||||
switch (type.toLowerCase()) {
|
switch (type.toLowerCase()) {
|
||||||
case "clusterip":
|
case "clusterip":
|
||||||
@ -25,7 +40,33 @@ export function ServiceList({ services, clusterId: _clusterId, namespace: _names
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const openEdit = async (svc: ServiceInfo) => {
|
||||||
|
setActionError(null);
|
||||||
|
try {
|
||||||
|
const yaml = await getResourceYamlCmd(clusterId, "services", namespace, svc.name);
|
||||||
|
setActiveModal({ type: "edit", svc, yaml });
|
||||||
|
} catch (err) {
|
||||||
|
setActionError(err instanceof Error ? err.message : String(err));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async () => {
|
||||||
|
if (activeModal?.type !== "delete") return;
|
||||||
|
setIsDeleting(true);
|
||||||
|
try {
|
||||||
|
await deleteResourceCmd(clusterId, "services", namespace, activeModal.svc.name);
|
||||||
|
setActiveModal(null);
|
||||||
|
onRefresh?.();
|
||||||
|
} finally {
|
||||||
|
setIsDeleting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
|
{actionError && (
|
||||||
|
<p className="mb-2 text-sm text-destructive">{actionError}</p>
|
||||||
|
)}
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
@ -36,12 +77,13 @@ export function ServiceList({ services, clusterId: _clusterId, namespace: _names
|
|||||||
<TableHead>External IP</TableHead>
|
<TableHead>External IP</TableHead>
|
||||||
<TableHead>Ports</TableHead>
|
<TableHead>Ports</TableHead>
|
||||||
<TableHead>Age</TableHead>
|
<TableHead>Age</TableHead>
|
||||||
|
<TableHead className="text-right">Actions</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{services.length === 0 ? (
|
{services.length === 0 ? (
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell colSpan={6} className="text-center text-muted-foreground">
|
<TableCell colSpan={7} className="text-center text-muted-foreground">
|
||||||
No services found
|
No services found
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
@ -70,11 +112,52 @@ export function ServiceList({ services, clusterId: _clusterId, namespace: _names
|
|||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-muted-foreground">{service.age}</TableCell>
|
<TableCell className="text-muted-foreground">{service.age}</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
<ResourceActionMenu
|
||||||
|
actions={[
|
||||||
|
{
|
||||||
|
label: "Edit",
|
||||||
|
icon: Pencil,
|
||||||
|
onClick: () => openEdit(service),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Delete",
|
||||||
|
icon: Trash2,
|
||||||
|
variant: "destructive",
|
||||||
|
onClick: () => setActiveModal({ type: "delete", svc: service }),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{activeModal?.type === "edit" && (
|
||||||
|
<EditResourceModal
|
||||||
|
isOpen
|
||||||
|
clusterId={clusterId}
|
||||||
|
namespace={namespace}
|
||||||
|
resourceType="services"
|
||||||
|
resourceName={activeModal.svc.name}
|
||||||
|
initialYaml={activeModal.yaml}
|
||||||
|
onClose={() => { setActiveModal(null); onRefresh?.(); }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeModal?.type === "delete" && (
|
||||||
|
<ConfirmDeleteDialog
|
||||||
|
open
|
||||||
|
onOpenChange={(o) => { if (!o) setActiveModal(null); }}
|
||||||
|
resourceType="Service"
|
||||||
|
resourceName={activeModal.svc.name}
|
||||||
|
isLoading={isDeleting}
|
||||||
|
onConfirm={handleDelete}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
137
src/components/Kubernetes/ShellExecModal.tsx
Normal file
137
src/components/Kubernetes/ShellExecModal.tsx
Normal file
@ -0,0 +1,137 @@
|
|||||||
|
import React from "react";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui";
|
||||||
|
import { Button } from "@/components/ui";
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui";
|
||||||
|
import { Alert, AlertDescription } from "@/components/ui";
|
||||||
|
import { Terminal, Loader2 } from "lucide-react";
|
||||||
|
import { execPodCmd } from "@/lib/tauriCommands";
|
||||||
|
|
||||||
|
interface ShellExecModalProps {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
clusterId: string;
|
||||||
|
namespace: string;
|
||||||
|
podName: string;
|
||||||
|
containers: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const SHELLS = [
|
||||||
|
{ label: "bash", value: "/bin/bash" },
|
||||||
|
{ label: "sh", value: "/bin/sh" },
|
||||||
|
{ label: "ash", value: "/bin/ash" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function ShellExecModal({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
clusterId,
|
||||||
|
namespace,
|
||||||
|
podName,
|
||||||
|
containers,
|
||||||
|
}: ShellExecModalProps) {
|
||||||
|
const [selectedContainer, setSelectedContainer] = React.useState("");
|
||||||
|
const [selectedShell, setSelectedShell] = React.useState("/bin/bash");
|
||||||
|
const [output, setOutput] = React.useState("");
|
||||||
|
const [isLoading, setIsLoading] = React.useState(false);
|
||||||
|
const [error, setError] = React.useState<string | null>(null);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
setSelectedContainer(containers[0] ?? "");
|
||||||
|
setOutput("");
|
||||||
|
setError(null);
|
||||||
|
}
|
||||||
|
}, [open, containers]);
|
||||||
|
|
||||||
|
const handleExec = async () => {
|
||||||
|
if (!selectedContainer) return;
|
||||||
|
setIsLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const result = await execPodCmd(
|
||||||
|
clusterId,
|
||||||
|
namespace,
|
||||||
|
podName,
|
||||||
|
selectedContainer,
|
||||||
|
selectedShell,
|
||||||
|
selectedShell
|
||||||
|
);
|
||||||
|
const combined = [result.stdout, result.stderr].filter(Boolean).join("\n");
|
||||||
|
setOutput(combined || `Exited with code ${result.exit_code ?? "unknown"}`);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : String(err));
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="max-w-3xl">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>
|
||||||
|
Exec — <span className="font-mono">{podName}</span>
|
||||||
|
</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<Select value={selectedContainer} onValueChange={setSelectedContainer}>
|
||||||
|
<SelectTrigger className="w-48">
|
||||||
|
<SelectValue placeholder="Select container" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{containers.map((c) => (
|
||||||
|
<SelectItem key={c} value={c}>
|
||||||
|
{c}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<Select value={selectedShell} onValueChange={setSelectedShell}>
|
||||||
|
<SelectTrigger className="w-32">
|
||||||
|
<SelectValue placeholder="Shell" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{SHELLS.map((s) => (
|
||||||
|
<SelectItem key={s.value} value={s.value}>
|
||||||
|
{s.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={handleExec}
|
||||||
|
disabled={!selectedContainer || isLoading}
|
||||||
|
>
|
||||||
|
{isLoading ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
Running...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Terminal className="mr-2 h-4 w-4" />
|
||||||
|
Exec
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{error && (
|
||||||
|
<Alert variant="destructive">
|
||||||
|
<AlertDescription>{error}</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
<pre className="max-h-[50vh] overflow-auto rounded-md bg-black p-3 font-mono text-xs text-green-400 whitespace-pre-wrap break-all">
|
||||||
|
{output || "Select a container and shell, then click Exec."}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,15 +1,78 @@
|
|||||||
import React from "react";
|
import React, { useState } from "react";
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui";
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui";
|
||||||
|
import { Scale, RotateCcw, Pencil, Trash2 } from "lucide-react";
|
||||||
import type { StatefulSetInfo } from "@/lib/tauriCommands";
|
import type { StatefulSetInfo } from "@/lib/tauriCommands";
|
||||||
|
import {
|
||||||
|
scaleStatefulsetCmd,
|
||||||
|
restartStatefulsetCmd,
|
||||||
|
deleteResourceCmd,
|
||||||
|
getResourceYamlCmd,
|
||||||
|
} from "@/lib/tauriCommands";
|
||||||
|
import { ResourceActionMenu } from "./ResourceActionMenu";
|
||||||
|
import { ConfirmDeleteDialog } from "./ConfirmDeleteDialog";
|
||||||
|
import { ScaleModal } from "./ScaleModal";
|
||||||
|
import { EditResourceModal } from "./EditResourceModal";
|
||||||
|
|
||||||
interface StatefulSetListProps {
|
interface StatefulSetListProps {
|
||||||
statefulsets: StatefulSetInfo[];
|
statefulsets: StatefulSetInfo[];
|
||||||
clusterId: string;
|
clusterId: string;
|
||||||
namespace: string;
|
namespace: string;
|
||||||
|
onRefresh?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function StatefulSetList({ statefulsets, clusterId: _clusterId, namespace: _namespace }: StatefulSetListProps) {
|
type ActiveModal =
|
||||||
|
| { type: "scale"; ss: StatefulSetInfo }
|
||||||
|
| { type: "restart"; ss: StatefulSetInfo }
|
||||||
|
| { type: "edit"; ss: StatefulSetInfo; yaml: string }
|
||||||
|
| { type: "delete"; ss: StatefulSetInfo }
|
||||||
|
| null;
|
||||||
|
|
||||||
|
export function StatefulSetList({ statefulsets, clusterId, namespace, onRefresh }: StatefulSetListProps) {
|
||||||
|
const [activeModal, setActiveModal] = useState<ActiveModal>(null);
|
||||||
|
const [isActing, setIsActing] = useState(false);
|
||||||
|
const [actionError, setActionError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const openEdit = async (ss: StatefulSetInfo) => {
|
||||||
|
setActionError(null);
|
||||||
|
try {
|
||||||
|
const yaml = await getResourceYamlCmd(clusterId, "statefulsets", namespace, ss.name);
|
||||||
|
setActiveModal({ type: "edit", ss, yaml });
|
||||||
|
} catch (err) {
|
||||||
|
setActionError(err instanceof Error ? err.message : String(err));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRestart = async () => {
|
||||||
|
if (activeModal?.type !== "restart") return;
|
||||||
|
setIsActing(true);
|
||||||
|
try {
|
||||||
|
await restartStatefulsetCmd(clusterId, namespace, activeModal.ss.name);
|
||||||
|
setActiveModal(null);
|
||||||
|
onRefresh?.();
|
||||||
|
} catch (err) {
|
||||||
|
setActionError(err instanceof Error ? err.message : String(err));
|
||||||
|
} finally {
|
||||||
|
setIsActing(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async () => {
|
||||||
|
if (activeModal?.type !== "delete") return;
|
||||||
|
setIsActing(true);
|
||||||
|
try {
|
||||||
|
await deleteResourceCmd(clusterId, "statefulsets", namespace, activeModal.ss.name);
|
||||||
|
setActiveModal(null);
|
||||||
|
onRefresh?.();
|
||||||
|
} finally {
|
||||||
|
setIsActing(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
|
{actionError && (
|
||||||
|
<p className="mb-2 text-sm text-destructive">{actionError}</p>
|
||||||
|
)}
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
@ -18,12 +81,13 @@ export function StatefulSetList({ statefulsets, clusterId: _clusterId, namespace
|
|||||||
<TableHead>Ready</TableHead>
|
<TableHead>Ready</TableHead>
|
||||||
<TableHead>Replicas</TableHead>
|
<TableHead>Replicas</TableHead>
|
||||||
<TableHead>Age</TableHead>
|
<TableHead>Age</TableHead>
|
||||||
|
<TableHead className="text-right">Actions</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{statefulsets.length === 0 ? (
|
{statefulsets.length === 0 ? (
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell colSpan={4} className="text-center text-muted-foreground">
|
<TableCell colSpan={5} className="text-center text-muted-foreground">
|
||||||
No statefulsets found
|
No statefulsets found
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
@ -34,11 +98,90 @@ export function StatefulSetList({ statefulsets, clusterId: _clusterId, namespace
|
|||||||
<TableCell>{ss.ready}</TableCell>
|
<TableCell>{ss.ready}</TableCell>
|
||||||
<TableCell>{ss.replicas}</TableCell>
|
<TableCell>{ss.replicas}</TableCell>
|
||||||
<TableCell className="text-muted-foreground">{ss.age}</TableCell>
|
<TableCell className="text-muted-foreground">{ss.age}</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
<ResourceActionMenu
|
||||||
|
actions={[
|
||||||
|
{
|
||||||
|
label: "Scale",
|
||||||
|
icon: Scale,
|
||||||
|
onClick: () => setActiveModal({ type: "scale", ss }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Restart",
|
||||||
|
icon: RotateCcw,
|
||||||
|
onClick: () => setActiveModal({ type: "restart", ss }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Edit",
|
||||||
|
icon: Pencil,
|
||||||
|
onClick: () => openEdit(ss),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Delete",
|
||||||
|
icon: Trash2,
|
||||||
|
variant: "destructive",
|
||||||
|
onClick: () => setActiveModal({ type: "delete", ss }),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{activeModal?.type === "scale" && (
|
||||||
|
<ScaleModal
|
||||||
|
open
|
||||||
|
onOpenChange={(o) => { if (!o) setActiveModal(null); }}
|
||||||
|
resourceType="StatefulSet"
|
||||||
|
resourceName={activeModal.ss.name}
|
||||||
|
currentReplicas={activeModal.ss.replicas}
|
||||||
|
onScale={(replicas) =>
|
||||||
|
scaleStatefulsetCmd(clusterId, namespace, activeModal.ss.name, replicas).then(() => {
|
||||||
|
setActiveModal(null);
|
||||||
|
onRefresh?.();
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeModal?.type === "restart" && (
|
||||||
|
<ConfirmDeleteDialog
|
||||||
|
open
|
||||||
|
onOpenChange={(o) => { if (!o) setActiveModal(null); }}
|
||||||
|
resourceType="StatefulSet"
|
||||||
|
resourceName={activeModal.ss.name}
|
||||||
|
isLoading={isActing}
|
||||||
|
onConfirm={handleRestart}
|
||||||
|
variant="delete"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeModal?.type === "edit" && (
|
||||||
|
<EditResourceModal
|
||||||
|
isOpen
|
||||||
|
clusterId={clusterId}
|
||||||
|
namespace={namespace}
|
||||||
|
resourceType="statefulsets"
|
||||||
|
resourceName={activeModal.ss.name}
|
||||||
|
initialYaml={activeModal.yaml}
|
||||||
|
onClose={() => { setActiveModal(null); onRefresh?.(); }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeModal?.type === "delete" && (
|
||||||
|
<ConfirmDeleteDialog
|
||||||
|
open
|
||||||
|
onOpenChange={(o) => { if (!o) setActiveModal(null); }}
|
||||||
|
resourceType="StatefulSet"
|
||||||
|
resourceName={activeModal.ss.name}
|
||||||
|
isLoading={isActing}
|
||||||
|
onConfirm={handleDelete}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,15 +1,56 @@
|
|||||||
import React from "react";
|
import React, { useState } from "react";
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui";
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui";
|
||||||
|
import { Pencil, Trash2 } from "lucide-react";
|
||||||
import type { StorageClassInfo } from "@/lib/tauriCommands";
|
import type { StorageClassInfo } from "@/lib/tauriCommands";
|
||||||
|
import { deleteResourceCmd, getResourceYamlCmd } from "@/lib/tauriCommands";
|
||||||
|
import { ResourceActionMenu } from "./ResourceActionMenu";
|
||||||
|
import { ConfirmDeleteDialog } from "./ConfirmDeleteDialog";
|
||||||
|
import { EditResourceModal } from "./EditResourceModal";
|
||||||
|
|
||||||
interface StorageClassListProps {
|
interface StorageClassListProps {
|
||||||
storageclasses: StorageClassInfo[];
|
storageclasses: StorageClassInfo[];
|
||||||
clusterId: string;
|
clusterId: string;
|
||||||
namespace: string;
|
namespace: string;
|
||||||
|
onRefresh?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function StorageClassList({ storageclasses }: StorageClassListProps) {
|
type ActiveModal =
|
||||||
|
| { type: "edit"; sc: StorageClassInfo; yaml: string }
|
||||||
|
| { type: "delete"; sc: StorageClassInfo }
|
||||||
|
| null;
|
||||||
|
|
||||||
|
export function StorageClassList({ storageclasses, clusterId, onRefresh }: StorageClassListProps) {
|
||||||
|
const [activeModal, setActiveModal] = useState<ActiveModal>(null);
|
||||||
|
const [isDeleting, setIsDeleting] = useState(false);
|
||||||
|
const [actionError, setActionError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const openEdit = async (sc: StorageClassInfo) => {
|
||||||
|
setActionError(null);
|
||||||
|
try {
|
||||||
|
const yaml = await getResourceYamlCmd(clusterId, "storageclasses", "", sc.name);
|
||||||
|
setActiveModal({ type: "edit", sc, yaml });
|
||||||
|
} catch (err) {
|
||||||
|
setActionError(err instanceof Error ? err.message : String(err));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async () => {
|
||||||
|
if (activeModal?.type !== "delete") return;
|
||||||
|
setIsDeleting(true);
|
||||||
|
try {
|
||||||
|
await deleteResourceCmd(clusterId, "storageclasses", "", activeModal.sc.name);
|
||||||
|
setActiveModal(null);
|
||||||
|
onRefresh?.();
|
||||||
|
} finally {
|
||||||
|
setIsDeleting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
|
{actionError && (
|
||||||
|
<p className="mb-2 text-sm text-destructive">{actionError}</p>
|
||||||
|
)}
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
@ -20,12 +61,13 @@ export function StorageClassList({ storageclasses }: StorageClassListProps) {
|
|||||||
<TableHead>Volume Binding Mode</TableHead>
|
<TableHead>Volume Binding Mode</TableHead>
|
||||||
<TableHead>Expand</TableHead>
|
<TableHead>Expand</TableHead>
|
||||||
<TableHead>Age</TableHead>
|
<TableHead>Age</TableHead>
|
||||||
|
<TableHead className="text-right">Actions</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{storageclasses.length === 0 ? (
|
{storageclasses.length === 0 ? (
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell colSpan={6} className="text-center text-muted-foreground">
|
<TableCell colSpan={7} className="text-center text-muted-foreground">
|
||||||
No storage classes found
|
No storage classes found
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
@ -38,11 +80,52 @@ export function StorageClassList({ storageclasses }: StorageClassListProps) {
|
|||||||
<TableCell className="text-sm">{sc.volume_binding_mode}</TableCell>
|
<TableCell className="text-sm">{sc.volume_binding_mode}</TableCell>
|
||||||
<TableCell className="text-sm">{sc.allow_volume_expansion ? "Yes" : "No"}</TableCell>
|
<TableCell className="text-sm">{sc.allow_volume_expansion ? "Yes" : "No"}</TableCell>
|
||||||
<TableCell className="text-sm text-muted-foreground">{sc.age}</TableCell>
|
<TableCell className="text-sm text-muted-foreground">{sc.age}</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
<ResourceActionMenu
|
||||||
|
actions={[
|
||||||
|
{
|
||||||
|
label: "Edit",
|
||||||
|
icon: Pencil,
|
||||||
|
onClick: () => openEdit(sc),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Delete",
|
||||||
|
icon: Trash2,
|
||||||
|
variant: "destructive",
|
||||||
|
onClick: () => setActiveModal({ type: "delete", sc }),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{activeModal?.type === "edit" && (
|
||||||
|
<EditResourceModal
|
||||||
|
isOpen
|
||||||
|
clusterId={clusterId}
|
||||||
|
namespace=""
|
||||||
|
resourceType="storageclasses"
|
||||||
|
resourceName={activeModal.sc.name}
|
||||||
|
initialYaml={activeModal.yaml}
|
||||||
|
onClose={() => { setActiveModal(null); onRefresh?.(); }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeModal?.type === "delete" && (
|
||||||
|
<ConfirmDeleteDialog
|
||||||
|
open
|
||||||
|
onOpenChange={(o) => { if (!o) setActiveModal(null); }}
|
||||||
|
resourceType="StorageClass"
|
||||||
|
resourceName={activeModal.sc.name}
|
||||||
|
isLoading={isDeleting}
|
||||||
|
onConfirm={handleDelete}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
42
src/components/Kubernetes/ValidatingWebhookList.tsx
Normal file
42
src/components/Kubernetes/ValidatingWebhookList.tsx
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui";
|
||||||
|
import type { WebhookConfigInfo } from "@/lib/tauriCommands";
|
||||||
|
|
||||||
|
interface ValidatingWebhookListProps {
|
||||||
|
items: WebhookConfigInfo[];
|
||||||
|
clusterId: string;
|
||||||
|
namespace?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ValidatingWebhookList({ items }: ValidatingWebhookListProps) {
|
||||||
|
return (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Name</TableHead>
|
||||||
|
<TableHead>Webhooks</TableHead>
|
||||||
|
<TableHead>Age</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{items.length === 0 ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={3} className="text-center text-muted-foreground">
|
||||||
|
No validating webhook configurations found
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : (
|
||||||
|
items.map((wh) => (
|
||||||
|
<TableRow key={wh.name}>
|
||||||
|
<TableCell className="font-medium">{wh.name}</TableCell>
|
||||||
|
<TableCell className="text-sm">{wh.webhooks}</TableCell>
|
||||||
|
<TableCell className="text-sm text-muted-foreground">{wh.age}</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
148
src/components/Kubernetes/WorkloadOverview.tsx
Normal file
148
src/components/Kubernetes/WorkloadOverview.tsx
Normal file
@ -0,0 +1,148 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { Layers, Box, Server, Activity } from "lucide-react";
|
||||||
|
import type {
|
||||||
|
PodInfo,
|
||||||
|
DeploymentInfo,
|
||||||
|
StatefulSetInfo,
|
||||||
|
DaemonSetInfo,
|
||||||
|
JobInfo,
|
||||||
|
CronJobInfo,
|
||||||
|
} from "@/lib/tauriCommands";
|
||||||
|
|
||||||
|
interface WorkloadOverviewProps {
|
||||||
|
clusterId: string;
|
||||||
|
resources: {
|
||||||
|
pods: PodInfo[];
|
||||||
|
deployments: DeploymentInfo[];
|
||||||
|
statefulsets: StatefulSetInfo[];
|
||||||
|
daemonsets: DaemonSetInfo[];
|
||||||
|
jobs: JobInfo[];
|
||||||
|
cronjobs: CronJobInfo[];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SummaryCardProps {
|
||||||
|
title: string;
|
||||||
|
value: number;
|
||||||
|
subtitle?: string;
|
||||||
|
icon: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
function SummaryCard({ title, value, subtitle, icon }: SummaryCardProps) {
|
||||||
|
return (
|
||||||
|
<div className="bg-card rounded-lg p-4 border">
|
||||||
|
<div className="flex items-center justify-between pb-2">
|
||||||
|
<h3 className="text-sm font-medium">{title}</h3>
|
||||||
|
{icon}
|
||||||
|
</div>
|
||||||
|
<div className="text-2xl font-bold">{value}</div>
|
||||||
|
{subtitle && (
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">{subtitle}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WorkloadOverview({ resources }: WorkloadOverviewProps) {
|
||||||
|
const { pods, deployments, statefulsets, daemonsets, jobs, cronjobs } = resources;
|
||||||
|
|
||||||
|
const runningPods = pods.filter((p) => p.status === "Running").length;
|
||||||
|
const pendingPods = pods.filter((p) => p.status === "Pending").length;
|
||||||
|
const failedPods = pods.filter((p) => p.status === "Failed").length;
|
||||||
|
|
||||||
|
const readyDeployments = deployments.filter((d) => d.ready === `${d.replicas}/${d.replicas}`).length;
|
||||||
|
|
||||||
|
const readyStatefulSets = statefulsets.filter((s) => {
|
||||||
|
const parts = s.ready.split("/");
|
||||||
|
return parts.length === 2 && parts[0] === parts[1];
|
||||||
|
}).length;
|
||||||
|
|
||||||
|
const healthyDaemonSets = daemonsets.filter(
|
||||||
|
(ds) => ds.desired === ds.ready
|
||||||
|
).length;
|
||||||
|
|
||||||
|
const completedJobs = jobs.filter((j) => {
|
||||||
|
const parts = j.completions.split("/");
|
||||||
|
return parts.length === 2 && parts[0] === parts[1];
|
||||||
|
}).length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-full overflow-y-auto space-y-6 p-6">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-2xl font-semibold">Workload Overview</h2>
|
||||||
|
<p className="text-muted-foreground text-sm mt-0.5">
|
||||||
|
Summary of all workload resources in the selected namespace
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-3 gap-4">
|
||||||
|
<SummaryCard
|
||||||
|
title="Pods"
|
||||||
|
value={pods.length}
|
||||||
|
subtitle={`Running: ${runningPods} · Pending: ${pendingPods} · Failed: ${failedPods}`}
|
||||||
|
icon={<Box className="h-4 w-4 text-muted-foreground" />}
|
||||||
|
/>
|
||||||
|
<SummaryCard
|
||||||
|
title="Deployments"
|
||||||
|
value={deployments.length}
|
||||||
|
subtitle={`Ready: ${readyDeployments}/${deployments.length}`}
|
||||||
|
icon={<Layers className="h-4 w-4 text-muted-foreground" />}
|
||||||
|
/>
|
||||||
|
<SummaryCard
|
||||||
|
title="StatefulSets"
|
||||||
|
value={statefulsets.length}
|
||||||
|
subtitle={`Ready: ${readyStatefulSets}/${statefulsets.length}`}
|
||||||
|
icon={<Server className="h-4 w-4 text-muted-foreground" />}
|
||||||
|
/>
|
||||||
|
<SummaryCard
|
||||||
|
title="DaemonSets"
|
||||||
|
value={daemonsets.length}
|
||||||
|
subtitle={`Healthy: ${healthyDaemonSets}/${daemonsets.length}`}
|
||||||
|
icon={<Activity className="h-4 w-4 text-muted-foreground" />}
|
||||||
|
/>
|
||||||
|
<SummaryCard
|
||||||
|
title="Jobs"
|
||||||
|
value={jobs.length}
|
||||||
|
subtitle={`Completed: ${completedJobs}/${jobs.length}`}
|
||||||
|
icon={<Activity className="h-4 w-4 text-muted-foreground" />}
|
||||||
|
/>
|
||||||
|
<SummaryCard
|
||||||
|
title="Cron Jobs"
|
||||||
|
value={cronjobs.length}
|
||||||
|
subtitle={cronjobs.length > 0 ? `Active: ${cronjobs.reduce((acc, cj) => acc + cj.active, 0)}` : undefined}
|
||||||
|
icon={<Activity className="h-4 w-4 text-muted-foreground" />}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{pods.length > 0 && (
|
||||||
|
<div className="bg-card rounded-lg border">
|
||||||
|
<div className="border-b px-6 py-4">
|
||||||
|
<h3 className="font-semibold">Pod Status Breakdown</h3>
|
||||||
|
</div>
|
||||||
|
<div className="p-6">
|
||||||
|
<div className="flex gap-6 text-sm">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="inline-block w-3 h-3 rounded-full bg-green-500" />
|
||||||
|
<span>Running: {runningPods}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="inline-block w-3 h-3 rounded-full bg-yellow-500" />
|
||||||
|
<span>Pending: {pendingPods}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="inline-block w-3 h-3 rounded-full bg-red-500" />
|
||||||
|
<span>Failed: {failedPods}</span>
|
||||||
|
</div>
|
||||||
|
{pods.length - runningPods - pendingPods - failedPods > 0 && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="inline-block w-3 h-3 rounded-full bg-gray-400" />
|
||||||
|
<span>Other: {pods.length - runningPods - pendingPods - failedPods}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -49,3 +49,15 @@ export { StorageClassList } from "./StorageClassList";
|
|||||||
export { NetworkPolicyList } from "./NetworkPolicyList";
|
export { NetworkPolicyList } from "./NetworkPolicyList";
|
||||||
export { ResourceQuotaList } from "./ResourceQuotaList";
|
export { ResourceQuotaList } from "./ResourceQuotaList";
|
||||||
export { LimitRangeList } from "./LimitRangeList";
|
export { LimitRangeList } from "./LimitRangeList";
|
||||||
|
export { ReplicationControllerList } from "./ReplicationControllerList";
|
||||||
|
export { PodDisruptionBudgetList } from "./PodDisruptionBudgetList";
|
||||||
|
export { PriorityClassList } from "./PriorityClassList";
|
||||||
|
export { RuntimeClassList } from "./RuntimeClassList";
|
||||||
|
export { LeaseList } from "./LeaseList";
|
||||||
|
export { MutatingWebhookList } from "./MutatingWebhookList";
|
||||||
|
export { ValidatingWebhookList } from "./ValidatingWebhookList";
|
||||||
|
export { EndpointList } from "./EndpointList";
|
||||||
|
export { EndpointSliceList } from "./EndpointSliceList";
|
||||||
|
export { IngressClassList } from "./IngressClassList";
|
||||||
|
export { NamespaceList } from "./NamespaceList";
|
||||||
|
export { WorkloadOverview } from "./WorkloadOverview";
|
||||||
|
|||||||
@ -1239,3 +1239,255 @@ export const createResourceCmd = (clusterId: string, namespace: string, resource
|
|||||||
|
|
||||||
export const editResourceCmd = (clusterId: string, namespace: string, resourceType: string, resourceName: string, yamlContent: string) =>
|
export const editResourceCmd = (clusterId: string, namespace: string, resourceType: string, resourceName: string, yamlContent: string) =>
|
||||||
invoke<void>("edit_resource", { clusterId, namespace, resourceType, resourceName, yamlContent });
|
invoke<void>("edit_resource", { clusterId, namespace, resourceType, resourceName, yamlContent });
|
||||||
|
|
||||||
|
// ─── Missing Resource Types ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface ReplicationControllerInfo {
|
||||||
|
name: string;
|
||||||
|
namespace: string;
|
||||||
|
desired: number;
|
||||||
|
ready: number;
|
||||||
|
current: number;
|
||||||
|
age: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PodDisruptionBudgetInfo {
|
||||||
|
name: string;
|
||||||
|
namespace: string;
|
||||||
|
min_available: string;
|
||||||
|
max_unavailable: string;
|
||||||
|
disruptions_allowed: number;
|
||||||
|
age: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PriorityClassInfo {
|
||||||
|
name: string;
|
||||||
|
value: number;
|
||||||
|
global_default: boolean;
|
||||||
|
age: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RuntimeClassInfo {
|
||||||
|
name: string;
|
||||||
|
handler: string;
|
||||||
|
age: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LeaseInfo {
|
||||||
|
name: string;
|
||||||
|
namespace: string;
|
||||||
|
holder: string;
|
||||||
|
age: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WebhookConfigInfo {
|
||||||
|
name: string;
|
||||||
|
webhooks: number;
|
||||||
|
age: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EndpointInfo {
|
||||||
|
name: string;
|
||||||
|
namespace: string;
|
||||||
|
addresses: string[];
|
||||||
|
ports: string[];
|
||||||
|
age: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EndpointSliceInfo {
|
||||||
|
name: string;
|
||||||
|
namespace: string;
|
||||||
|
address_type: string;
|
||||||
|
endpoints: number;
|
||||||
|
ports: string[];
|
||||||
|
age: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IngressClassInfo {
|
||||||
|
name: string;
|
||||||
|
controller: string;
|
||||||
|
is_default: boolean;
|
||||||
|
age: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NamespaceResourceInfo {
|
||||||
|
name: string;
|
||||||
|
status: string;
|
||||||
|
age: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Helm Types ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface HelmRepository {
|
||||||
|
name: string;
|
||||||
|
url: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HelmChart {
|
||||||
|
name: string;
|
||||||
|
chart_version: string;
|
||||||
|
app_version: string;
|
||||||
|
description: string;
|
||||||
|
repository: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HelmRelease {
|
||||||
|
name: string;
|
||||||
|
namespace: string;
|
||||||
|
chart: string;
|
||||||
|
chart_version: string;
|
||||||
|
app_version: string;
|
||||||
|
status: string;
|
||||||
|
updated: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Custom Resource / CRD Types ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface CrdInfo {
|
||||||
|
name: string;
|
||||||
|
group: string;
|
||||||
|
version: string;
|
||||||
|
kind: string;
|
||||||
|
scope: string;
|
||||||
|
age: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CustomResourceInfo {
|
||||||
|
name: string;
|
||||||
|
namespace: string;
|
||||||
|
age: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Resource Actions ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface DescribeResponse {
|
||||||
|
output: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LogStreamConfig {
|
||||||
|
cluster_id: string;
|
||||||
|
namespace: string;
|
||||||
|
pod_name: string;
|
||||||
|
container_name: string;
|
||||||
|
follow: boolean;
|
||||||
|
timestamps: boolean;
|
||||||
|
tail_lines?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── New Resource List Commands ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
export const listReplicationcontrollersCmd = (clusterId: string, namespace: string) =>
|
||||||
|
invoke<ReplicationControllerInfo[]>("list_replicationcontrollers", { clusterId, namespace });
|
||||||
|
|
||||||
|
export const listPoddisruptionbudgetsCmd = (clusterId: string, namespace: string) =>
|
||||||
|
invoke<PodDisruptionBudgetInfo[]>("list_poddisruptionbudgets", { clusterId, namespace });
|
||||||
|
|
||||||
|
export const listPriorityclassesCmd = (clusterId: string) =>
|
||||||
|
invoke<PriorityClassInfo[]>("list_priorityclasses", { clusterId });
|
||||||
|
|
||||||
|
export const listRuntimeclassesCmd = (clusterId: string) =>
|
||||||
|
invoke<RuntimeClassInfo[]>("list_runtimeclasses", { clusterId });
|
||||||
|
|
||||||
|
export const listLeasesCmd = (clusterId: string, namespace: string) =>
|
||||||
|
invoke<LeaseInfo[]>("list_leases", { clusterId, namespace });
|
||||||
|
|
||||||
|
export const listMutatingwebhookconfigurationsCmd = (clusterId: string) =>
|
||||||
|
invoke<WebhookConfigInfo[]>("list_mutatingwebhookconfigurations", { clusterId });
|
||||||
|
|
||||||
|
export const listValidatingwebhookconfigurationsCmd = (clusterId: string) =>
|
||||||
|
invoke<WebhookConfigInfo[]>("list_validatingwebhookconfigurations", { clusterId });
|
||||||
|
|
||||||
|
export const listEndpointsCmd = (clusterId: string, namespace: string) =>
|
||||||
|
invoke<EndpointInfo[]>("list_endpoints", { clusterId, namespace });
|
||||||
|
|
||||||
|
export const listEndpointslicesCmd = (clusterId: string, namespace: string) =>
|
||||||
|
invoke<EndpointSliceInfo[]>("list_endpointslices", { clusterId, namespace });
|
||||||
|
|
||||||
|
export const listIngressclassesCmd = (clusterId: string) =>
|
||||||
|
invoke<IngressClassInfo[]>("list_ingressclasses", { clusterId });
|
||||||
|
|
||||||
|
export const listNamespacesResourceCmd = (clusterId: string) =>
|
||||||
|
invoke<NamespaceResourceInfo[]>("list_namespaces_resource", { clusterId });
|
||||||
|
|
||||||
|
export const createNamespaceCmd = (clusterId: string, name: string) =>
|
||||||
|
invoke<void>("create_namespace", { clusterId, name });
|
||||||
|
|
||||||
|
export const deleteNamespaceCmd = (clusterId: string, name: string) =>
|
||||||
|
invoke<void>("delete_namespace", { clusterId, name });
|
||||||
|
|
||||||
|
// ─── Resource Action Commands ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export const attachPodCmd = (clusterId: string, namespace: string, podName: string, containerName: string) =>
|
||||||
|
invoke<ExecSessionResponse>("attach_pod", { clusterId, namespace, podName, containerName });
|
||||||
|
|
||||||
|
export const forceDeleteResourceCmd = (clusterId: string, resourceType: string, namespace: string, resourceName: string) =>
|
||||||
|
invoke<void>("force_delete_resource", { clusterId, resourceType, namespace, resourceName });
|
||||||
|
|
||||||
|
export const describeResourceCmd = (clusterId: string, resourceType: string, namespace: string, resourceName: string) =>
|
||||||
|
invoke<DescribeResponse>("describe_resource", { clusterId, resourceType, namespace, resourceName });
|
||||||
|
|
||||||
|
export const getResourceYamlCmd = (clusterId: string, resourceType: string, namespace: string, resourceName: string) =>
|
||||||
|
invoke<string>("get_resource_yaml", { clusterId, resourceType, namespace, resourceName });
|
||||||
|
|
||||||
|
export const restartStatefulsetCmd = (clusterId: string, namespace: string, name: string) =>
|
||||||
|
invoke<void>("restart_statefulset", { clusterId, namespace, name });
|
||||||
|
|
||||||
|
export const restartDaemonsetCmd = (clusterId: string, namespace: string, name: string) =>
|
||||||
|
invoke<void>("restart_daemonset", { clusterId, namespace, name });
|
||||||
|
|
||||||
|
export const scaleStatefulsetCmd = (clusterId: string, namespace: string, name: string, replicas: number) =>
|
||||||
|
invoke<void>("scale_statefulset", { clusterId, namespace, name, replicas });
|
||||||
|
|
||||||
|
export const scaleReplicasetCmd = (clusterId: string, namespace: string, name: string, replicas: number) =>
|
||||||
|
invoke<void>("scale_replicaset", { clusterId, namespace, name, replicas });
|
||||||
|
|
||||||
|
export const scaleReplicationcontrollerCmd = (clusterId: string, namespace: string, name: string, replicas: number) =>
|
||||||
|
invoke<void>("scale_replicationcontroller", { clusterId, namespace, name, replicas });
|
||||||
|
|
||||||
|
export const suspendCronjobCmd = (clusterId: string, namespace: string, name: string) =>
|
||||||
|
invoke<void>("suspend_cronjob", { clusterId, namespace, name });
|
||||||
|
|
||||||
|
export const resumeCronjobCmd = (clusterId: string, namespace: string, name: string) =>
|
||||||
|
invoke<void>("resume_cronjob", { clusterId, namespace, name });
|
||||||
|
|
||||||
|
export const triggerCronjobCmd = (clusterId: string, namespace: string, name: string) =>
|
||||||
|
invoke<void>("trigger_cronjob", { clusterId, namespace, name });
|
||||||
|
|
||||||
|
// ─── Log Streaming Commands ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export const streamPodLogsCmd = (config: LogStreamConfig) =>
|
||||||
|
invoke<string>("stream_pod_logs", { config });
|
||||||
|
|
||||||
|
export const stopLogStreamCmd = (streamId: string) =>
|
||||||
|
invoke<void>("stop_log_stream", { streamId });
|
||||||
|
|
||||||
|
// ─── Helm Commands ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export const helmListReposCmd = (clusterId: string) =>
|
||||||
|
invoke<HelmRepository[]>("helm_list_repos", { clusterId });
|
||||||
|
|
||||||
|
export const helmAddRepoCmd = (clusterId: string, name: string, url: string) =>
|
||||||
|
invoke<void>("helm_add_repo", { clusterId, name, url });
|
||||||
|
|
||||||
|
export const helmUpdateReposCmd = (clusterId: string) =>
|
||||||
|
invoke<void>("helm_update_repos", { clusterId });
|
||||||
|
|
||||||
|
export const helmSearchRepoCmd = (clusterId: string, query: string) =>
|
||||||
|
invoke<HelmChart[]>("helm_search_repo", { clusterId, query });
|
||||||
|
|
||||||
|
export const helmListReleasesCmd = (clusterId: string, namespace: string) =>
|
||||||
|
invoke<HelmRelease[]>("helm_list_releases", { clusterId, namespace });
|
||||||
|
|
||||||
|
export const helmUninstallCmd = (clusterId: string, namespace: string, releaseName: string) =>
|
||||||
|
invoke<void>("helm_uninstall", { clusterId, namespace, releaseName });
|
||||||
|
|
||||||
|
export const helmRollbackCmd = (clusterId: string, namespace: string, releaseName: string, revision?: number) =>
|
||||||
|
invoke<void>("helm_rollback", { clusterId, namespace, releaseName, revision });
|
||||||
|
|
||||||
|
// ─── CRD / Custom Resource Commands ──────────────────────────────────────────
|
||||||
|
|
||||||
|
export const listCrdsCmd = (clusterId: string) =>
|
||||||
|
invoke<CrdInfo[]>("list_crds", { clusterId });
|
||||||
|
|
||||||
|
export const listCustomResourcesCmd = (clusterId: string, group: string, version: string, resource: string, namespace: string) =>
|
||||||
|
invoke<CustomResourceInfo[]>("list_custom_resources", { clusterId, group, version, resource, namespace });
|
||||||
|
|||||||
@ -10,6 +10,10 @@ import {
|
|||||||
RefreshCw,
|
RefreshCw,
|
||||||
Plus,
|
Plus,
|
||||||
Package,
|
Package,
|
||||||
|
Settings2,
|
||||||
|
Box,
|
||||||
|
Bell,
|
||||||
|
Puzzle,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useKubernetesStore } from "@/stores/kubernetesStore";
|
import { useKubernetesStore } from "@/stores/kubernetesStore";
|
||||||
import {
|
import {
|
||||||
@ -54,6 +58,18 @@ import {
|
|||||||
NetworkPolicyList,
|
NetworkPolicyList,
|
||||||
ResourceQuotaList,
|
ResourceQuotaList,
|
||||||
LimitRangeList,
|
LimitRangeList,
|
||||||
|
ReplicationControllerList,
|
||||||
|
PodDisruptionBudgetList,
|
||||||
|
PriorityClassList,
|
||||||
|
RuntimeClassList,
|
||||||
|
LeaseList,
|
||||||
|
MutatingWebhookList,
|
||||||
|
ValidatingWebhookList,
|
||||||
|
EndpointList,
|
||||||
|
EndpointSliceList,
|
||||||
|
IngressClassList,
|
||||||
|
NamespaceList,
|
||||||
|
WorkloadOverview,
|
||||||
} from "@/components/Kubernetes";
|
} from "@/components/Kubernetes";
|
||||||
import type {
|
import type {
|
||||||
KubeconfigInfo,
|
KubeconfigInfo,
|
||||||
@ -84,6 +100,19 @@ import type {
|
|||||||
NetworkPolicyInfo,
|
NetworkPolicyInfo,
|
||||||
ResourceQuotaInfo,
|
ResourceQuotaInfo,
|
||||||
LimitRangeInfo,
|
LimitRangeInfo,
|
||||||
|
ReplicationControllerInfo,
|
||||||
|
PodDisruptionBudgetInfo,
|
||||||
|
PriorityClassInfo,
|
||||||
|
RuntimeClassInfo,
|
||||||
|
LeaseInfo,
|
||||||
|
WebhookConfigInfo,
|
||||||
|
EndpointInfo,
|
||||||
|
EndpointSliceInfo,
|
||||||
|
IngressClassInfo,
|
||||||
|
NamespaceResourceInfo,
|
||||||
|
HelmChart,
|
||||||
|
HelmRelease,
|
||||||
|
CrdInfo,
|
||||||
} from "@/lib/tauriCommands";
|
} from "@/lib/tauriCommands";
|
||||||
import {
|
import {
|
||||||
listKubeconfigsCmd,
|
listKubeconfigsCmd,
|
||||||
@ -119,108 +148,181 @@ import {
|
|||||||
listNetworkpoliciesCmd,
|
listNetworkpoliciesCmd,
|
||||||
listResourcequotasCmd,
|
listResourcequotasCmd,
|
||||||
listLimitrangesCmd,
|
listLimitrangesCmd,
|
||||||
|
listReplicationcontrollersCmd,
|
||||||
|
listPoddisruptionbudgetsCmd,
|
||||||
|
listPriorityclassesCmd,
|
||||||
|
listRuntimeclassesCmd,
|
||||||
|
listLeasesCmd,
|
||||||
|
listMutatingwebhookconfigurationsCmd,
|
||||||
|
listValidatingwebhookconfigurationsCmd,
|
||||||
|
listEndpointsCmd,
|
||||||
|
listEndpointslicesCmd,
|
||||||
|
listIngressclassesCmd,
|
||||||
|
listNamespacesResourceCmd,
|
||||||
|
helmSearchRepoCmd,
|
||||||
|
helmListReleasesCmd,
|
||||||
|
listCrdsCmd,
|
||||||
} from "@/lib/tauriCommands";
|
} from "@/lib/tauriCommands";
|
||||||
|
|
||||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
type ActiveSection =
|
type ActiveSection =
|
||||||
| "overview"
|
| "cluster_overview"
|
||||||
|
| "nodes"
|
||||||
|
| "workloads_overview"
|
||||||
| "pods"
|
| "pods"
|
||||||
| "deployments"
|
| "deployments"
|
||||||
| "daemonsets"
|
| "daemonsets"
|
||||||
| "statefulsets"
|
| "statefulsets"
|
||||||
| "replicasets"
|
| "replicasets"
|
||||||
|
| "replicationcontrollers"
|
||||||
| "jobs"
|
| "jobs"
|
||||||
| "cronjobs"
|
| "cronjobs"
|
||||||
| "services"
|
|
||||||
| "ingresses"
|
|
||||||
| "configmaps"
|
| "configmaps"
|
||||||
| "secrets"
|
| "secrets"
|
||||||
|
| "resourcequotas"
|
||||||
|
| "limitranges"
|
||||||
| "hpas"
|
| "hpas"
|
||||||
|
| "poddisruptionbudgets"
|
||||||
|
| "priorityclasses"
|
||||||
|
| "runtimeclasses"
|
||||||
|
| "leases"
|
||||||
|
| "mutatingwebhooks"
|
||||||
|
| "validatingwebhooks"
|
||||||
|
| "services"
|
||||||
|
| "endpointslices"
|
||||||
|
| "endpoints"
|
||||||
|
| "ingresses"
|
||||||
|
| "ingressclasses"
|
||||||
|
| "networkpolicies"
|
||||||
|
| "portforwarding"
|
||||||
| "pvcs"
|
| "pvcs"
|
||||||
| "pvs"
|
| "pvs"
|
||||||
| "serviceaccounts"
|
|
||||||
| "roles"
|
|
||||||
| "clusterroles"
|
|
||||||
| "rolebindings"
|
|
||||||
| "clusterrolebindings"
|
|
||||||
| "nodes"
|
|
||||||
| "events"
|
|
||||||
| "portforwarding"
|
|
||||||
| "storageclasses"
|
| "storageclasses"
|
||||||
| "networkpolicies"
|
| "namespaces"
|
||||||
| "resourcequotas"
|
| "events"
|
||||||
| "limitranges";
|
| "helm_charts"
|
||||||
|
| "helm_releases"
|
||||||
|
| "serviceaccounts"
|
||||||
|
| "clusterroles"
|
||||||
|
| "roles"
|
||||||
|
| "clusterrolebindings"
|
||||||
|
| "rolebindings"
|
||||||
|
| "crds";
|
||||||
|
|
||||||
interface NavItem {
|
interface NavItem {
|
||||||
id: ActiveSection;
|
id: ActiveSection;
|
||||||
label: string;
|
label: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface NavSection {
|
interface NavGroup {
|
||||||
|
type: "group";
|
||||||
label: string;
|
label: string;
|
||||||
icon: React.ElementType;
|
icon: React.ElementType;
|
||||||
items: NavItem[];
|
items: NavItem[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface NavTopLevel {
|
||||||
|
type: "toplevel";
|
||||||
|
id: ActiveSection;
|
||||||
|
label: string;
|
||||||
|
icon: React.ElementType;
|
||||||
|
}
|
||||||
|
|
||||||
|
type NavEntry = NavGroup | NavTopLevel;
|
||||||
|
|
||||||
// ─── Nav structure ────────────────────────────────────────────────────────────
|
// ─── Nav structure ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const NAV_SECTIONS: NavSection[] = [
|
const NAV_ENTRIES: NavEntry[] = [
|
||||||
|
{ type: "toplevel", id: "cluster_overview", label: "Cluster", icon: Server },
|
||||||
|
{ type: "toplevel", id: "nodes", label: "Nodes", icon: Server },
|
||||||
{
|
{
|
||||||
|
type: "group",
|
||||||
label: "Workloads",
|
label: "Workloads",
|
||||||
icon: Layers,
|
icon: Layers,
|
||||||
items: [
|
items: [
|
||||||
|
{ id: "workloads_overview", label: "Overview" },
|
||||||
{ id: "pods", label: "Pods" },
|
{ id: "pods", label: "Pods" },
|
||||||
{ id: "deployments", label: "Deployments" },
|
{ id: "deployments", label: "Deployments" },
|
||||||
{ id: "daemonsets", label: "Daemon Sets" },
|
{ id: "daemonsets", label: "Daemon Sets" },
|
||||||
{ id: "statefulsets", label: "Stateful Sets" },
|
{ id: "statefulsets", label: "Stateful Sets" },
|
||||||
{ id: "replicasets", label: "Replica Sets" },
|
{ id: "replicasets", label: "Replica Sets" },
|
||||||
|
{ id: "replicationcontrollers", label: "Replication Controllers" },
|
||||||
{ id: "jobs", label: "Jobs" },
|
{ id: "jobs", label: "Jobs" },
|
||||||
{ id: "cronjobs", label: "Cron Jobs" },
|
{ id: "cronjobs", label: "Cron Jobs" },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Services & Networking",
|
type: "group",
|
||||||
icon: Network,
|
label: "Config",
|
||||||
items: [
|
icon: Settings2,
|
||||||
{ id: "services", label: "Services" },
|
|
||||||
{ id: "ingresses", label: "Ingresses" },
|
|
||||||
{ id: "networkpolicies", label: "Network Policies" },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Config & Storage",
|
|
||||||
icon: Database,
|
|
||||||
items: [
|
items: [
|
||||||
{ id: "configmaps", label: "Config Maps" },
|
{ id: "configmaps", label: "Config Maps" },
|
||||||
{ id: "secrets", label: "Secrets" },
|
{ id: "secrets", label: "Secrets" },
|
||||||
{ id: "hpas", label: "Horizontal Pod Autoscalers" },
|
|
||||||
{ id: "pvcs", label: "Persistent Volume Claims" },
|
|
||||||
{ id: "pvs", label: "Persistent Volumes" },
|
|
||||||
{ id: "storageclasses", label: "Storage Classes" },
|
|
||||||
{ id: "resourcequotas", label: "Resource Quotas" },
|
{ id: "resourcequotas", label: "Resource Quotas" },
|
||||||
{ id: "limitranges", label: "Limit Ranges" },
|
{ id: "limitranges", label: "Limit Ranges" },
|
||||||
|
{ id: "hpas", label: "Horizontal Pod Autoscalers" },
|
||||||
|
{ id: "poddisruptionbudgets", label: "Pod Disruption Budgets" },
|
||||||
|
{ id: "priorityclasses", label: "Priority Classes" },
|
||||||
|
{ id: "runtimeclasses", label: "Runtime Classes" },
|
||||||
|
{ id: "leases", label: "Leases" },
|
||||||
|
{ id: "mutatingwebhooks", label: "Mutating Webhook Configs" },
|
||||||
|
{ id: "validatingwebhooks", label: "Validating Webhook Configs" },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
type: "group",
|
||||||
|
label: "Network",
|
||||||
|
icon: Network,
|
||||||
|
items: [
|
||||||
|
{ id: "services", label: "Services" },
|
||||||
|
{ id: "endpointslices", label: "Endpoint Slices" },
|
||||||
|
{ id: "endpoints", label: "Endpoints" },
|
||||||
|
{ id: "ingresses", label: "Ingresses" },
|
||||||
|
{ id: "ingressclasses", label: "Ingress Classes" },
|
||||||
|
{ id: "networkpolicies", label: "Network Policies" },
|
||||||
|
{ id: "portforwarding", label: "Port Forwarding" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "group",
|
||||||
|
label: "Storage",
|
||||||
|
icon: Database,
|
||||||
|
items: [
|
||||||
|
{ id: "pvcs", label: "Persistent Volume Claims" },
|
||||||
|
{ id: "pvs", label: "Persistent Volumes" },
|
||||||
|
{ id: "storageclasses", label: "Storage Classes" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{ type: "toplevel", id: "namespaces", label: "Namespaces", icon: Box },
|
||||||
|
{ type: "toplevel", id: "events", label: "Events", icon: Bell },
|
||||||
|
{
|
||||||
|
type: "group",
|
||||||
|
label: "Helm",
|
||||||
|
icon: Package,
|
||||||
|
items: [
|
||||||
|
{ id: "helm_charts", label: "Charts" },
|
||||||
|
{ id: "helm_releases", label: "Releases" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "group",
|
||||||
label: "Access Control",
|
label: "Access Control",
|
||||||
icon: Shield,
|
icon: Shield,
|
||||||
items: [
|
items: [
|
||||||
{ id: "serviceaccounts", label: "Service Accounts" },
|
{ id: "serviceaccounts", label: "Service Accounts" },
|
||||||
{ id: "roles", label: "Roles" },
|
|
||||||
{ id: "clusterroles", label: "Cluster Roles" },
|
{ id: "clusterroles", label: "Cluster Roles" },
|
||||||
{ id: "rolebindings", label: "Role Bindings" },
|
{ id: "roles", label: "Roles" },
|
||||||
{ id: "clusterrolebindings", label: "Cluster Role Bindings" },
|
{ id: "clusterrolebindings", label: "Cluster Role Bindings" },
|
||||||
|
{ id: "rolebindings", label: "Role Bindings" },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Cluster",
|
type: "group",
|
||||||
icon: Server,
|
label: "Custom Resources",
|
||||||
|
icon: Puzzle,
|
||||||
items: [
|
items: [
|
||||||
{ id: "overview", label: "Overview" },
|
{ id: "crds", label: "Definitions" },
|
||||||
{ id: "nodes", label: "Nodes" },
|
|
||||||
{ id: "events", label: "Events" },
|
|
||||||
{ id: "portforwarding", label: "Port Forwarding" },
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
@ -253,6 +355,20 @@ interface ResourceData {
|
|||||||
networkpolicies: NetworkPolicyInfo[];
|
networkpolicies: NetworkPolicyInfo[];
|
||||||
resourcequotas: ResourceQuotaInfo[];
|
resourcequotas: ResourceQuotaInfo[];
|
||||||
limitranges: LimitRangeInfo[];
|
limitranges: LimitRangeInfo[];
|
||||||
|
replicationcontrollers: ReplicationControllerInfo[];
|
||||||
|
poddisruptionbudgets: PodDisruptionBudgetInfo[];
|
||||||
|
priorityclasses: PriorityClassInfo[];
|
||||||
|
runtimeclasses: RuntimeClassInfo[];
|
||||||
|
leases: LeaseInfo[];
|
||||||
|
mutatingwebhooks: WebhookConfigInfo[];
|
||||||
|
validatingwebhooks: WebhookConfigInfo[];
|
||||||
|
endpoints: EndpointInfo[];
|
||||||
|
endpointslices: EndpointSliceInfo[];
|
||||||
|
ingressclasses: IngressClassInfo[];
|
||||||
|
namespaces_resource: NamespaceResourceInfo[];
|
||||||
|
helm_charts: HelmChart[];
|
||||||
|
helm_releases: HelmRelease[];
|
||||||
|
crds: CrdInfo[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const EMPTY_RESOURCES: ResourceData = {
|
const EMPTY_RESOURCES: ResourceData = {
|
||||||
@ -281,6 +397,20 @@ const EMPTY_RESOURCES: ResourceData = {
|
|||||||
networkpolicies: [],
|
networkpolicies: [],
|
||||||
resourcequotas: [],
|
resourcequotas: [],
|
||||||
limitranges: [],
|
limitranges: [],
|
||||||
|
replicationcontrollers: [],
|
||||||
|
poddisruptionbudgets: [],
|
||||||
|
priorityclasses: [],
|
||||||
|
runtimeclasses: [],
|
||||||
|
leases: [],
|
||||||
|
mutatingwebhooks: [],
|
||||||
|
validatingwebhooks: [],
|
||||||
|
endpoints: [],
|
||||||
|
endpointslices: [],
|
||||||
|
ingressclasses: [],
|
||||||
|
namespaces_resource: [],
|
||||||
|
helm_charts: [],
|
||||||
|
helm_releases: [],
|
||||||
|
crds: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
// ─── Component ───────────────────────────────────────────────────────────────
|
// ─── Component ───────────────────────────────────────────────────────────────
|
||||||
@ -293,20 +423,21 @@ export function KubernetesPage() {
|
|||||||
const [namespaces, setNamespaces] = useState<NamespaceInfo[]>([]);
|
const [namespaces, setNamespaces] = useState<NamespaceInfo[]>([]);
|
||||||
const [portForwards, setPortForwards] = useState<PortForwardResponse[]>([]);
|
const [portForwards, setPortForwards] = useState<PortForwardResponse[]>([]);
|
||||||
const [resources, setResources] = useState<ResourceData>(EMPTY_RESOURCES);
|
const [resources, setResources] = useState<ResourceData>(EMPTY_RESOURCES);
|
||||||
const [activeSection, setActiveSection] = useState<ActiveSection>("overview");
|
const [activeSection, setActiveSection] = useState<ActiveSection>("cluster_overview");
|
||||||
const [expandedSections, setExpandedSections] = useState<Record<string, boolean>>({
|
const [expandedSections, setExpandedSections] = useState<Record<string, boolean>>({
|
||||||
Workloads: true,
|
Workloads: true,
|
||||||
"Services & Networking": true,
|
Config: true,
|
||||||
"Config & Storage": true,
|
Network: true,
|
||||||
|
Storage: true,
|
||||||
|
Helm: false,
|
||||||
"Access Control": true,
|
"Access Control": true,
|
||||||
Cluster: true,
|
"Custom Resources": false,
|
||||||
});
|
});
|
||||||
const [isLoadingResources, setIsLoadingResources] = useState(false);
|
const [isLoadingResources, setIsLoadingResources] = useState(false);
|
||||||
const [isCommandPaletteOpen, setIsCommandPaletteOpen] = useState(false);
|
const [isCommandPaletteOpen, setIsCommandPaletteOpen] = useState(false);
|
||||||
const [isPortForwardFormOpen, setIsPortForwardFormOpen] = useState(false);
|
const [isPortForwardFormOpen, setIsPortForwardFormOpen] = useState(false);
|
||||||
const [isNotificationsOpen, setIsNotificationsOpen] = useState(false);
|
const [isNotificationsOpen, setIsNotificationsOpen] = useState(false);
|
||||||
|
|
||||||
// Track the last loaded section to avoid redundant fetches
|
|
||||||
const lastLoadedRef = useRef<{ section: ActiveSection; clusterId: string; namespace: string } | null>(null);
|
const lastLoadedRef = useRef<{ section: ActiveSection; clusterId: string; namespace: string } | null>(null);
|
||||||
|
|
||||||
// ── Initial data load ──────────────────────────────────────────────────────
|
// ── Initial data load ──────────────────────────────────────────────────────
|
||||||
@ -350,7 +481,13 @@ export function KubernetesPage() {
|
|||||||
|
|
||||||
const loadResourceData = useCallback(
|
const loadResourceData = useCallback(
|
||||||
async (section: ActiveSection, clusterId: string, namespace: string) => {
|
async (section: ActiveSection, clusterId: string, namespace: string) => {
|
||||||
if (section === "overview" || section === "portforwarding") return;
|
if (
|
||||||
|
section === "cluster_overview" ||
|
||||||
|
section === "portforwarding" ||
|
||||||
|
section === "workloads_overview"
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const ns = namespace === "all" ? "" : namespace;
|
const ns = namespace === "all" ? "" : namespace;
|
||||||
|
|
||||||
@ -358,8 +495,6 @@ export function KubernetesPage() {
|
|||||||
try {
|
try {
|
||||||
switch (section) {
|
switch (section) {
|
||||||
case "pods":
|
case "pods":
|
||||||
setResources((r) => ({ ...r, pods: [] }));
|
|
||||||
setResources((r) => ({ ...r }));
|
|
||||||
await listPodsCmd(clusterId, ns).then((data) =>
|
await listPodsCmd(clusterId, ns).then((data) =>
|
||||||
setResources((r) => ({ ...r, pods: data }))
|
setResources((r) => ({ ...r, pods: data }))
|
||||||
);
|
);
|
||||||
@ -384,6 +519,11 @@ export function KubernetesPage() {
|
|||||||
setResources((r) => ({ ...r, replicasets: data }))
|
setResources((r) => ({ ...r, replicasets: data }))
|
||||||
);
|
);
|
||||||
break;
|
break;
|
||||||
|
case "replicationcontrollers":
|
||||||
|
await listReplicationcontrollersCmd(clusterId, ns).then((data) =>
|
||||||
|
setResources((r) => ({ ...r, replicationcontrollers: data }))
|
||||||
|
);
|
||||||
|
break;
|
||||||
case "jobs":
|
case "jobs":
|
||||||
await listJobsCmd(clusterId, ns).then((data) =>
|
await listJobsCmd(clusterId, ns).then((data) =>
|
||||||
setResources((r) => ({ ...r, jobs: data }))
|
setResources((r) => ({ ...r, jobs: data }))
|
||||||
@ -484,6 +624,71 @@ export function KubernetesPage() {
|
|||||||
setResources((r) => ({ ...r, limitranges: data }))
|
setResources((r) => ({ ...r, limitranges: data }))
|
||||||
);
|
);
|
||||||
break;
|
break;
|
||||||
|
case "poddisruptionbudgets":
|
||||||
|
await listPoddisruptionbudgetsCmd(clusterId, ns).then((data) =>
|
||||||
|
setResources((r) => ({ ...r, poddisruptionbudgets: data }))
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case "priorityclasses":
|
||||||
|
await listPriorityclassesCmd(clusterId).then((data) =>
|
||||||
|
setResources((r) => ({ ...r, priorityclasses: data }))
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case "runtimeclasses":
|
||||||
|
await listRuntimeclassesCmd(clusterId).then((data) =>
|
||||||
|
setResources((r) => ({ ...r, runtimeclasses: data }))
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case "leases":
|
||||||
|
await listLeasesCmd(clusterId, ns).then((data) =>
|
||||||
|
setResources((r) => ({ ...r, leases: data }))
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case "mutatingwebhooks":
|
||||||
|
await listMutatingwebhookconfigurationsCmd(clusterId).then((data) =>
|
||||||
|
setResources((r) => ({ ...r, mutatingwebhooks: data }))
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case "validatingwebhooks":
|
||||||
|
await listValidatingwebhookconfigurationsCmd(clusterId).then((data) =>
|
||||||
|
setResources((r) => ({ ...r, validatingwebhooks: data }))
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case "endpoints":
|
||||||
|
await listEndpointsCmd(clusterId, ns).then((data) =>
|
||||||
|
setResources((r) => ({ ...r, endpoints: data }))
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case "endpointslices":
|
||||||
|
await listEndpointslicesCmd(clusterId, ns).then((data) =>
|
||||||
|
setResources((r) => ({ ...r, endpointslices: data }))
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case "ingressclasses":
|
||||||
|
await listIngressclassesCmd(clusterId).then((data) =>
|
||||||
|
setResources((r) => ({ ...r, ingressclasses: data }))
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case "namespaces":
|
||||||
|
await listNamespacesResourceCmd(clusterId).then((data) =>
|
||||||
|
setResources((r) => ({ ...r, namespaces_resource: data }))
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case "helm_charts":
|
||||||
|
await helmSearchRepoCmd(clusterId, "").then((data) =>
|
||||||
|
setResources((r) => ({ ...r, helm_charts: data }))
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case "helm_releases":
|
||||||
|
await helmListReleasesCmd(clusterId, ns).then((data) =>
|
||||||
|
setResources((r) => ({ ...r, helm_releases: data }))
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case "crds":
|
||||||
|
await listCrdsCmd(clusterId).then((data) =>
|
||||||
|
setResources((r) => ({ ...r, crds: data }))
|
||||||
|
);
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
lastLoadedRef.current = { section, clusterId, namespace };
|
lastLoadedRef.current = { section, clusterId, namespace };
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@ -593,7 +798,7 @@ export function KubernetesPage() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (activeSection === "overview") {
|
if (activeSection === "cluster_overview") {
|
||||||
return (
|
return (
|
||||||
<ClusterOverview
|
<ClusterOverview
|
||||||
clusterId={selectedClusterId}
|
clusterId={selectedClusterId}
|
||||||
@ -602,6 +807,22 @@ export function KubernetesPage() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (activeSection === "workloads_overview") {
|
||||||
|
return (
|
||||||
|
<WorkloadOverview
|
||||||
|
clusterId={selectedClusterId}
|
||||||
|
resources={{
|
||||||
|
pods: resources.pods,
|
||||||
|
deployments: resources.deployments,
|
||||||
|
statefulsets: resources.statefulsets,
|
||||||
|
daemonsets: resources.daemonsets,
|
||||||
|
jobs: resources.jobs,
|
||||||
|
cronjobs: resources.cronjobs,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (activeSection === "portforwarding") {
|
if (activeSection === "portforwarding") {
|
||||||
return (
|
return (
|
||||||
<div className="p-6 space-y-4">
|
<div className="p-6 space-y-4">
|
||||||
@ -647,35 +868,37 @@ export function KubernetesPage() {
|
|||||||
case "statefulsets":
|
case "statefulsets":
|
||||||
return <StatefulSetList statefulsets={resources.statefulsets} clusterId={cid} namespace={ns} />;
|
return <StatefulSetList statefulsets={resources.statefulsets} clusterId={cid} namespace={ns} />;
|
||||||
case "replicasets":
|
case "replicasets":
|
||||||
return <ReplicaSetList replicaSets={resources.replicasets} _clusterId={cid} _namespace={ns} />;
|
return <ReplicaSetList replicaSets={resources.replicasets} clusterId={cid} namespace={ns} />;
|
||||||
|
case "replicationcontrollers":
|
||||||
|
return <ReplicationControllerList items={resources.replicationcontrollers} clusterId={cid} namespace={ns} />;
|
||||||
case "jobs":
|
case "jobs":
|
||||||
return <JobList jobs={resources.jobs} _clusterId={cid} _namespace={ns} />;
|
return <JobList jobs={resources.jobs} clusterId={cid} namespace={ns} />;
|
||||||
case "cronjobs":
|
case "cronjobs":
|
||||||
return <CronJobList cronJobs={resources.cronjobs} _clusterId={cid} _namespace={ns} />;
|
return <CronJobList cronJobs={resources.cronjobs} clusterId={cid} namespace={ns} />;
|
||||||
case "services":
|
case "services":
|
||||||
return <ServiceList services={resources.services} clusterId={cid} namespace={ns} />;
|
return <ServiceList services={resources.services} clusterId={cid} namespace={ns} />;
|
||||||
case "ingresses":
|
case "ingresses":
|
||||||
return <IngressList ingresses={resources.ingresses} _clusterId={cid} _namespace={ns} />;
|
return <IngressList ingresses={resources.ingresses} clusterId={cid} namespace={ns} />;
|
||||||
case "configmaps":
|
case "configmaps":
|
||||||
return <ConfigMapList configmaps={resources.configmaps} clusterId={cid} namespace={ns} />;
|
return <ConfigMapList configmaps={resources.configmaps} clusterId={cid} namespace={ns} />;
|
||||||
case "secrets":
|
case "secrets":
|
||||||
return <SecretList secrets={resources.secrets} _clusterId={cid} _namespace={ns} />;
|
return <SecretList secrets={resources.secrets} clusterId={cid} namespace={ns} />;
|
||||||
case "hpas":
|
case "hpas":
|
||||||
return <HPAList hpas={resources.hpas} _clusterId={cid} _namespace={ns} />;
|
return <HPAList hpas={resources.hpas} clusterId={cid} namespace={ns} />;
|
||||||
case "pvcs":
|
case "pvcs":
|
||||||
return <PVCList pvcs={resources.pvcs} _clusterId={cid} _namespace={ns} />;
|
return <PVCList pvcs={resources.pvcs} clusterId={cid} namespace={ns} />;
|
||||||
case "pvs":
|
case "pvs":
|
||||||
return <PVList pvs={resources.pvs} _clusterId={cid} />;
|
return <PVList pvs={resources.pvs} clusterId={cid} />;
|
||||||
case "serviceaccounts":
|
case "serviceaccounts":
|
||||||
return <ServiceAccountList serviceAccounts={resources.serviceaccounts} _clusterId={cid} _namespace={ns} />;
|
return <ServiceAccountList serviceAccounts={resources.serviceaccounts} clusterId={cid} namespace={ns} />;
|
||||||
case "roles":
|
case "roles":
|
||||||
return <RoleList roles={resources.roles} _clusterId={cid} _namespace={ns} />;
|
return <RoleList roles={resources.roles} clusterId={cid} namespace={ns} />;
|
||||||
case "clusterroles":
|
case "clusterroles":
|
||||||
return <ClusterRoleList clusterRoles={resources.clusterroles} _clusterId={cid} />;
|
return <ClusterRoleList clusterRoles={resources.clusterroles} clusterId={cid} />;
|
||||||
case "rolebindings":
|
case "rolebindings":
|
||||||
return <RoleBindingList roleBindings={resources.rolebindings} _clusterId={cid} _namespace={ns} />;
|
return <RoleBindingList roleBindings={resources.rolebindings} clusterId={cid} namespace={ns} />;
|
||||||
case "clusterrolebindings":
|
case "clusterrolebindings":
|
||||||
return <ClusterRoleBindingList clusterRoleBindings={resources.clusterrolebindings} _clusterId={cid} />;
|
return <ClusterRoleBindingList clusterRoleBindings={resources.clusterrolebindings} clusterId={cid} />;
|
||||||
case "nodes":
|
case "nodes":
|
||||||
return <NodeList nodes={resources.nodes} clusterId={cid} />;
|
return <NodeList nodes={resources.nodes} clusterId={cid} />;
|
||||||
case "events":
|
case "events":
|
||||||
@ -688,6 +911,142 @@ export function KubernetesPage() {
|
|||||||
return <ResourceQuotaList resourcequotas={resources.resourcequotas} clusterId={cid} namespace={ns} />;
|
return <ResourceQuotaList resourcequotas={resources.resourcequotas} clusterId={cid} namespace={ns} />;
|
||||||
case "limitranges":
|
case "limitranges":
|
||||||
return <LimitRangeList limitranges={resources.limitranges} clusterId={cid} namespace={ns} />;
|
return <LimitRangeList limitranges={resources.limitranges} clusterId={cid} namespace={ns} />;
|
||||||
|
case "poddisruptionbudgets":
|
||||||
|
return <PodDisruptionBudgetList items={resources.poddisruptionbudgets} clusterId={cid} namespace={ns} />;
|
||||||
|
case "priorityclasses":
|
||||||
|
return <PriorityClassList items={resources.priorityclasses} clusterId={cid} />;
|
||||||
|
case "runtimeclasses":
|
||||||
|
return <RuntimeClassList items={resources.runtimeclasses} clusterId={cid} />;
|
||||||
|
case "leases":
|
||||||
|
return <LeaseList items={resources.leases} clusterId={cid} namespace={ns} />;
|
||||||
|
case "mutatingwebhooks":
|
||||||
|
return <MutatingWebhookList items={resources.mutatingwebhooks} clusterId={cid} />;
|
||||||
|
case "validatingwebhooks":
|
||||||
|
return <ValidatingWebhookList items={resources.validatingwebhooks} clusterId={cid} />;
|
||||||
|
case "endpoints":
|
||||||
|
return <EndpointList items={resources.endpoints} clusterId={cid} namespace={ns} />;
|
||||||
|
case "endpointslices":
|
||||||
|
return <EndpointSliceList items={resources.endpointslices} clusterId={cid} namespace={ns} />;
|
||||||
|
case "ingressclasses":
|
||||||
|
return <IngressClassList items={resources.ingressclasses} clusterId={cid} />;
|
||||||
|
case "namespaces":
|
||||||
|
return <NamespaceList items={resources.namespaces_resource} clusterId={cid} />;
|
||||||
|
case "helm_charts":
|
||||||
|
return (
|
||||||
|
<div className="p-6">
|
||||||
|
<h2 className="text-xl font-semibold mb-4">Helm Charts</h2>
|
||||||
|
{resources.helm_charts.length === 0 ? (
|
||||||
|
<p className="text-muted-foreground">No charts found. Add a Helm repository to browse charts.</p>
|
||||||
|
) : (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-sm border-collapse">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b text-muted-foreground text-left">
|
||||||
|
<th className="px-4 py-3 font-medium">Name</th>
|
||||||
|
<th className="px-4 py-3 font-medium">Repository</th>
|
||||||
|
<th className="px-4 py-3 font-medium">Chart Version</th>
|
||||||
|
<th className="px-4 py-3 font-medium">App Version</th>
|
||||||
|
<th className="px-4 py-3 font-medium">Description</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{resources.helm_charts.map((chart) => (
|
||||||
|
<tr key={`${chart.repository}-${chart.name}`} className="border-b hover:bg-muted/30 transition-colors">
|
||||||
|
<td className="px-4 py-3 font-medium">{chart.name}</td>
|
||||||
|
<td className="px-4 py-3 text-muted-foreground">{chart.repository}</td>
|
||||||
|
<td className="px-4 py-3 font-mono text-xs">{chart.chart_version}</td>
|
||||||
|
<td className="px-4 py-3 font-mono text-xs">{chart.app_version}</td>
|
||||||
|
<td className="px-4 py-3 text-muted-foreground truncate max-w-xs">{chart.description}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
case "helm_releases":
|
||||||
|
return (
|
||||||
|
<div className="p-6">
|
||||||
|
<h2 className="text-xl font-semibold mb-4">Helm Releases</h2>
|
||||||
|
{resources.helm_releases.length === 0 ? (
|
||||||
|
<p className="text-muted-foreground">No Helm releases found in this namespace.</p>
|
||||||
|
) : (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-sm border-collapse">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b text-muted-foreground text-left">
|
||||||
|
<th className="px-4 py-3 font-medium">Name</th>
|
||||||
|
<th className="px-4 py-3 font-medium">Namespace</th>
|
||||||
|
<th className="px-4 py-3 font-medium">Chart</th>
|
||||||
|
<th className="px-4 py-3 font-medium">App Version</th>
|
||||||
|
<th className="px-4 py-3 font-medium">Status</th>
|
||||||
|
<th className="px-4 py-3 font-medium">Updated</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{resources.helm_releases.map((rel) => (
|
||||||
|
<tr key={`${rel.namespace}-${rel.name}`} className="border-b hover:bg-muted/30 transition-colors">
|
||||||
|
<td className="px-4 py-3 font-medium">{rel.name}</td>
|
||||||
|
<td className="px-4 py-3 text-muted-foreground">{rel.namespace}</td>
|
||||||
|
<td className="px-4 py-3 font-mono text-xs">{rel.chart} {rel.chart_version}</td>
|
||||||
|
<td className="px-4 py-3 font-mono text-xs">{rel.app_version}</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${
|
||||||
|
rel.status === "deployed"
|
||||||
|
? "bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400"
|
||||||
|
: rel.status === "failed"
|
||||||
|
? "bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400"
|
||||||
|
: "bg-muted text-muted-foreground"
|
||||||
|
}`}>
|
||||||
|
{rel.status}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-muted-foreground text-xs">{rel.updated}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
case "crds":
|
||||||
|
return (
|
||||||
|
<div className="p-6">
|
||||||
|
<h2 className="text-xl font-semibold mb-4">Custom Resource Definitions</h2>
|
||||||
|
{resources.crds.length === 0 ? (
|
||||||
|
<p className="text-muted-foreground">No custom resource definitions found.</p>
|
||||||
|
) : (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-sm border-collapse">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b text-muted-foreground text-left">
|
||||||
|
<th className="px-4 py-3 font-medium">Name</th>
|
||||||
|
<th className="px-4 py-3 font-medium">Group</th>
|
||||||
|
<th className="px-4 py-3 font-medium">Version</th>
|
||||||
|
<th className="px-4 py-3 font-medium">Kind</th>
|
||||||
|
<th className="px-4 py-3 font-medium">Scope</th>
|
||||||
|
<th className="px-4 py-3 font-medium">Age</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{resources.crds.map((crd) => (
|
||||||
|
<tr key={crd.name} className="border-b hover:bg-muted/30 transition-colors">
|
||||||
|
<td className="px-4 py-3 font-medium font-mono text-xs">{crd.name}</td>
|
||||||
|
<td className="px-4 py-3 text-muted-foreground">{crd.group}</td>
|
||||||
|
<td className="px-4 py-3 font-mono text-xs">{crd.version}</td>
|
||||||
|
<td className="px-4 py-3">{crd.kind}</td>
|
||||||
|
<td className="px-4 py-3 text-muted-foreground">{crd.scope}</td>
|
||||||
|
<td className="px-4 py-3 text-muted-foreground">{crd.age}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
default:
|
default:
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@ -776,19 +1135,38 @@ export function KubernetesPage() {
|
|||||||
<div className="flex flex-1 overflow-hidden">
|
<div className="flex flex-1 overflow-hidden">
|
||||||
{/* Sidebar */}
|
{/* Sidebar */}
|
||||||
<aside className="w-56 shrink-0 border-r bg-card overflow-y-auto flex flex-col">
|
<aside className="w-56 shrink-0 border-r bg-card overflow-y-auto flex flex-col">
|
||||||
{NAV_SECTIONS.map((section) => {
|
{NAV_ENTRIES.map((entry) => {
|
||||||
const Icon = section.icon;
|
if (entry.type === "toplevel") {
|
||||||
const isExpanded = expandedSections[section.label] ?? true;
|
const Icon = entry.icon;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={entry.id}
|
||||||
|
onClick={() => setActiveSection(entry.id)}
|
||||||
|
aria-label={entry.label}
|
||||||
|
className={`flex items-center gap-2 w-full px-3 py-2 text-xs font-semibold uppercase tracking-wider transition-colors ${
|
||||||
|
activeSection === entry.id
|
||||||
|
? "bg-primary/10 text-primary border-l-2 border-primary"
|
||||||
|
: "text-muted-foreground hover:text-foreground hover:bg-accent"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Icon className="w-3.5 h-3.5" />
|
||||||
|
<span>{entry.label}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const isExpanded = expandedSections[entry.label] ?? true;
|
||||||
|
const Icon = entry.icon;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={section.label}>
|
<div key={entry.label}>
|
||||||
<button
|
<button
|
||||||
onClick={() => toggleSection(section.label)}
|
onClick={() => toggleSection(entry.label)}
|
||||||
className="flex items-center justify-between w-full px-3 py-2 text-xs font-semibold uppercase tracking-wider text-muted-foreground hover:text-foreground hover:bg-accent transition-colors"
|
className="flex items-center justify-between w-full px-3 py-2 text-xs font-semibold uppercase tracking-wider text-muted-foreground hover:text-foreground hover:bg-accent transition-colors"
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Icon className="w-3.5 h-3.5" />
|
<Icon className="w-3.5 h-3.5" />
|
||||||
<span>{section.label}</span>
|
<span>{entry.label}</span>
|
||||||
</div>
|
</div>
|
||||||
{isExpanded ? (
|
{isExpanded ? (
|
||||||
<ChevronDown className="w-3 h-3" />
|
<ChevronDown className="w-3 h-3" />
|
||||||
@ -799,7 +1177,7 @@ export function KubernetesPage() {
|
|||||||
|
|
||||||
{isExpanded && (
|
{isExpanded && (
|
||||||
<div className="pb-1">
|
<div className="pb-1">
|
||||||
{section.items.map((item) => (
|
{entry.items.map((item) => (
|
||||||
<button
|
<button
|
||||||
key={item.id}
|
key={item.id}
|
||||||
onClick={() => setActiveSection(item.id)}
|
onClick={() => setActiveSection(item.id)}
|
||||||
@ -866,7 +1244,7 @@ export function KubernetesPage() {
|
|||||||
<p className="text-sm text-muted-foreground">No cluster connected.</p>
|
<p className="text-sm text-muted-foreground">No cluster connected.</p>
|
||||||
)}
|
)}
|
||||||
<p className="text-xs text-muted-foreground pt-2 border-t">
|
<p className="text-xs text-muted-foreground pt-2 border-t">
|
||||||
Navigate to <strong>Cluster → Events</strong> to view live cluster events.
|
Navigate to <strong>Events</strong> to view live cluster events.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
|
|||||||
@ -174,8 +174,9 @@ describe("KubernetesPage", () => {
|
|||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByText("Workloads")).toBeInTheDocument();
|
expect(screen.getByText("Workloads")).toBeInTheDocument();
|
||||||
expect(screen.getByText("Services & Networking")).toBeInTheDocument();
|
expect(screen.getByText("Network")).toBeInTheDocument();
|
||||||
expect(screen.getByText("Config & Storage")).toBeInTheDocument();
|
expect(screen.getByText("Config")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Storage")).toBeInTheDocument();
|
||||||
expect(screen.getByText("Access Control")).toBeInTheDocument();
|
expect(screen.getByText("Access Control")).toBeInTheDocument();
|
||||||
expect(screen.getByText("Cluster")).toBeInTheDocument();
|
expect(screen.getByText("Cluster")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
@ -195,7 +196,7 @@ describe("KubernetesPage", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("renders all Services & Networking nav items", async () => {
|
it("renders all Network nav items", async () => {
|
||||||
renderPage();
|
renderPage();
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user