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.
|
||||
jq -cn \
|
||||
--arg model "qwen36-35b-a3b-nvfp4" \
|
||||
--arg model "qwen3-coder-next" \
|
||||
--rawfile content /tmp/prompt.txt \
|
||||
'{model: $model, messages: [{role: "user", content: $content}], stream: false}' \
|
||||
> /tmp/body.json
|
||||
@ -359,7 +359,7 @@ jobs:
|
||||
if [ -f "/tmp/pr_review.txt" ] && [ -s "/tmp/pr_review.txt" ]; then
|
||||
REVIEW_BODY=$(head -c 65536 /tmp/pr_review.txt)
|
||||
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"}')
|
||||
else
|
||||
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 [
|
||||
{
|
||||
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}"],
|
||||
|
||||
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 refresh_registry = app_state.refresh_registry.clone();
|
||||
let watchers = app_state.watchers.clone();
|
||||
let log_streams = app_state.log_streams.clone();
|
||||
|
||||
tokio::spawn(async move {
|
||||
let app_state_for_callback = AppState {
|
||||
@ -343,6 +344,7 @@ pub async fn initiate_oauth(
|
||||
port_forwards,
|
||||
refresh_registry,
|
||||
watchers,
|
||||
log_streams,
|
||||
};
|
||||
while let Some(callback) = callback_rx.recv().await {
|
||||
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())),
|
||||
refresh_registry: Arc::new(tokio::sync::Mutex::new(crate::kube::RefreshRegistry::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!(
|
||||
"tftsr-stronghold-salt-v1-{:x}",
|
||||
@ -232,6 +233,46 @@ pub fn run() {
|
||||
commands::kube::rollback_deployment,
|
||||
commands::kube::create_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!())
|
||||
.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 executor;
|
||||
pub mod helm;
|
||||
pub mod kubeconfig;
|
||||
pub mod kubectl;
|
||||
|
||||
@ -8,5 +9,6 @@ mod tests;
|
||||
|
||||
pub use classifier::{ClassificationResult, CommandClassifier, CommandTier};
|
||||
pub use executor::{execute_with_approval, CommandOutput};
|
||||
pub use helm::locate_helm;
|
||||
pub use kubeconfig::{auto_detect_kubeconfig, KubeconfigInfo};
|
||||
pub use kubectl::{execute_kubectl, locate_kubectl};
|
||||
|
||||
@ -99,6 +99,8 @@ pub struct AppState {
|
||||
pub refresh_registry: Arc<TokioMutex<crate::kube::RefreshRegistry>>,
|
||||
/// Resource watchers: unsubscribe_id -> receiver
|
||||
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.
|
||||
|
||||
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,41 +1,131 @@
|
||||
import React from "react";
|
||||
import React, { useState } from "react";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui";
|
||||
import { Pencil, Trash2 } from "lucide-react";
|
||||
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 {
|
||||
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 (
|
||||
<div className="overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Name</TableHead>
|
||||
<TableHead>Cluster Role</TableHead>
|
||||
<TableHead>Age</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{clusterRoleBindings.length === 0 ? (
|
||||
<>
|
||||
{actionError && (
|
||||
<p className="mb-2 text-sm text-destructive">{actionError}</p>
|
||||
)}
|
||||
<div className="overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableCell colSpan={3} className="text-center text-muted-foreground">
|
||||
No cluster role bindings found
|
||||
</TableCell>
|
||||
<TableHead>Name</TableHead>
|
||||
<TableHead>Cluster Role</TableHead>
|
||||
<TableHead>Age</TableHead>
|
||||
<TableHead className="text-right">Actions</TableHead>
|
||||
</TableRow>
|
||||
) : (
|
||||
clusterRoleBindings.map((crb) => (
|
||||
<TableRow key={crb.name}>
|
||||
<TableCell className="font-medium">{crb.name}</TableCell>
|
||||
<TableCell>{crb.cluster_role}</TableCell>
|
||||
<TableCell className="text-muted-foreground">{crb.age}</TableCell>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{clusterRoleBindings.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={4} className="text-center text-muted-foreground">
|
||||
No cluster role bindings found
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
) : (
|
||||
clusterRoleBindings.map((crb) => (
|
||||
<TableRow key={crb.name}>
|
||||
<TableCell className="font-medium">{crb.name}</TableCell>
|
||||
<TableCell>{crb.cluster_role}</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>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</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 { Pencil, Trash2 } from "lucide-react";
|
||||
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 {
|
||||
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 (
|
||||
<div className="overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Name</TableHead>
|
||||
<TableHead>Age</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{clusterRoles.length === 0 ? (
|
||||
<>
|
||||
{actionError && (
|
||||
<p className="mb-2 text-sm text-destructive">{actionError}</p>
|
||||
)}
|
||||
<div className="overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableCell colSpan={2} className="text-center text-muted-foreground">
|
||||
No cluster roles found
|
||||
</TableCell>
|
||||
<TableHead>Name</TableHead>
|
||||
<TableHead>Age</TableHead>
|
||||
<TableHead className="text-right">Actions</TableHead>
|
||||
</TableRow>
|
||||
) : (
|
||||
clusterRoles.map((clusterRole) => (
|
||||
<TableRow key={clusterRole.name}>
|
||||
<TableCell className="font-medium">{clusterRole.name}</TableCell>
|
||||
<TableCell className="text-muted-foreground">{clusterRole.age}</TableCell>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{clusterRoles.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={3} className="text-center text-muted-foreground">
|
||||
No cluster roles found
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
) : (
|
||||
clusterRoles.map((cr) => (
|
||||
<TableRow key={cr.name}>
|
||||
<TableCell className="font-medium">{cr.name}</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>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</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,57 +1,127 @@
|
||||
import React from "react";
|
||||
import React, { useState } from "react";
|
||||
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 { deleteResourceCmd, getResourceYamlCmd } from "@/lib/tauriCommands";
|
||||
import { ResourceActionMenu } from "./ResourceActionMenu";
|
||||
import { ConfirmDeleteDialog } from "./ConfirmDeleteDialog";
|
||||
import { EditResourceModal } from "./EditResourceModal";
|
||||
|
||||
interface ConfigMapListProps {
|
||||
configmaps: ConfigMapInfo[];
|
||||
clusterId: 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 (
|
||||
<div className="overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Name</TableHead>
|
||||
<TableHead>Namespace</TableHead>
|
||||
<TableHead>Data Keys</TableHead>
|
||||
<TableHead>Age</TableHead>
|
||||
<TableHead className="text-right">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{configmaps.length === 0 ? (
|
||||
<>
|
||||
{actionError && (
|
||||
<p className="mb-2 text-sm text-destructive">{actionError}</p>
|
||||
)}
|
||||
<div className="overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableCell colSpan={5} className="text-center text-muted-foreground">
|
||||
No configmaps found
|
||||
</TableCell>
|
||||
<TableHead>Name</TableHead>
|
||||
<TableHead>Namespace</TableHead>
|
||||
<TableHead>Data Keys</TableHead>
|
||||
<TableHead>Age</TableHead>
|
||||
<TableHead className="text-right">Actions</TableHead>
|
||||
</TableRow>
|
||||
) : (
|
||||
configmaps.map((configmap) => (
|
||||
<TableRow key={configmap.name}>
|
||||
<TableCell className="font-medium">{configmap.name}</TableCell>
|
||||
<TableCell className="text-sm text-muted-foreground">{configmap.namespace}</TableCell>
|
||||
<TableCell className="text-sm">{configmap.data_keys}</TableCell>
|
||||
<TableCell className="text-sm text-muted-foreground">{configmap.age}</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {}}
|
||||
className="text-primary hover:text-primary hover:bg-primary/10"
|
||||
>
|
||||
View/Edit
|
||||
</Button>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{configmaps.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={5} className="text-center text-muted-foreground">
|
||||
No configmaps found
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
) : (
|
||||
configmaps.map((cm) => (
|
||||
<TableRow key={cm.name}>
|
||||
<TableCell className="font-medium">{cm.name}</TableCell>
|
||||
<TableCell className="text-sm text-muted-foreground">{cm.namespace}</TableCell>
|
||||
<TableCell className="text-sm">{cm.data_keys}</TableCell>
|
||||
<TableCell className="text-sm text-muted-foreground">{cm.age}</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<ResourceActionMenu
|
||||
actions={[
|
||||
{
|
||||
label: "Edit",
|
||||
icon: Pencil,
|
||||
onClick: () => openEdit(cm),
|
||||
},
|
||||
{
|
||||
label: "Delete",
|
||||
icon: Trash2,
|
||||
variant: "destructive",
|
||||
onClick: () => setActiveModal({ type: "delete", cm }),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</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,54 +1,206 @@
|
||||
import React from "react";
|
||||
import React, { useState } from "react";
|
||||
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 {
|
||||
suspendCronjobCmd,
|
||||
resumeCronjobCmd,
|
||||
triggerCronjobCmd,
|
||||
deleteResourceCmd,
|
||||
getResourceYamlCmd,
|
||||
} from "@/lib/tauriCommands";
|
||||
import { ResourceActionMenu } from "./ResourceActionMenu";
|
||||
import { ConfirmDeleteDialog } from "./ConfirmDeleteDialog";
|
||||
import { EditResourceModal } from "./EditResourceModal";
|
||||
|
||||
interface CronJobListProps {
|
||||
cronJobs: CronJobInfo[];
|
||||
_clusterId: string;
|
||||
_namespace: string;
|
||||
clusterId?: 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 (
|
||||
<div className="overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Name</TableHead>
|
||||
<TableHead>Namespace</TableHead>
|
||||
<TableHead>Schedule</TableHead>
|
||||
<TableHead>Active</TableHead>
|
||||
<TableHead>Last Schedule</TableHead>
|
||||
<TableHead>Age</TableHead>
|
||||
<TableHead>Labels</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{cronJobs.length === 0 ? (
|
||||
<>
|
||||
{actionError && (
|
||||
<p className="mb-2 text-sm text-destructive">{actionError}</p>
|
||||
)}
|
||||
<div className="overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableCell colSpan={7} className="text-center text-muted-foreground">
|
||||
No cron jobs found
|
||||
</TableCell>
|
||||
<TableHead>Name</TableHead>
|
||||
<TableHead>Namespace</TableHead>
|
||||
<TableHead>Schedule</TableHead>
|
||||
<TableHead>Active</TableHead>
|
||||
<TableHead>Last Schedule</TableHead>
|
||||
<TableHead>Age</TableHead>
|
||||
<TableHead>Labels</TableHead>
|
||||
<TableHead className="text-right">Actions</TableHead>
|
||||
</TableRow>
|
||||
) : (
|
||||
cronJobs.map((cronJob) => (
|
||||
<TableRow key={`${cronJob.name}-${cronJob.namespace}`}>
|
||||
<TableCell className="font-medium">{cronJob.name}</TableCell>
|
||||
<TableCell>{cronJob.namespace}</TableCell>
|
||||
<TableCell>{cronJob.schedule}</TableCell>
|
||||
<TableCell>{cronJob.active}</TableCell>
|
||||
<TableCell>{cronJob.last_schedule}</TableCell>
|
||||
<TableCell className="text-muted-foreground">{cronJob.age}</TableCell>
|
||||
<TableCell>
|
||||
{Object.entries(cronJob.labels)
|
||||
.map(([k, v]) => `${k}=${v}`)
|
||||
.join(", ")}
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{cronJobs.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={8} className="text-center text-muted-foreground">
|
||||
No cron jobs found
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
) : (
|
||||
cronJobs.map((cj) => (
|
||||
<TableRow key={`${cj.name}-${cj.namespace}`}>
|
||||
<TableCell className="font-medium">{cj.name}</TableCell>
|
||||
<TableCell>{cj.namespace}</TableCell>
|
||||
<TableCell>{cj.schedule}</TableCell>
|
||||
<TableCell>{cj.active}</TableCell>
|
||||
<TableCell>{cj.last_schedule}</TableCell>
|
||||
<TableCell className="text-muted-foreground">{cj.age}</TableCell>
|
||||
<TableCell>
|
||||
{Object.entries(cj.labels)
|
||||
.map(([k, v]) => `${k}=${v}`)
|
||||
.join(", ")}
|
||||
</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>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</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,50 +1,169 @@
|
||||
import React from "react";
|
||||
import React, { useState } from "react";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui";
|
||||
import { RotateCcw, Pencil, Trash2 } from "lucide-react";
|
||||
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 {
|
||||
daemonsets: DaemonSetInfo[];
|
||||
clusterId: 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 (
|
||||
<div className="overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Name</TableHead>
|
||||
<TableHead>Desired</TableHead>
|
||||
<TableHead>Current</TableHead>
|
||||
<TableHead>Ready</TableHead>
|
||||
<TableHead>Up-to-date</TableHead>
|
||||
<TableHead>Available</TableHead>
|
||||
<TableHead>Age</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{daemonsets.length === 0 ? (
|
||||
<>
|
||||
{actionError && (
|
||||
<p className="mb-2 text-sm text-destructive">{actionError}</p>
|
||||
)}
|
||||
<div className="overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableCell colSpan={7} className="text-center text-muted-foreground">
|
||||
No daemonsets found
|
||||
</TableCell>
|
||||
<TableHead>Name</TableHead>
|
||||
<TableHead>Desired</TableHead>
|
||||
<TableHead>Current</TableHead>
|
||||
<TableHead>Ready</TableHead>
|
||||
<TableHead>Up-to-date</TableHead>
|
||||
<TableHead>Available</TableHead>
|
||||
<TableHead>Age</TableHead>
|
||||
<TableHead className="text-right">Actions</TableHead>
|
||||
</TableRow>
|
||||
) : (
|
||||
daemonsets.map((ds) => (
|
||||
<TableRow key={ds.name}>
|
||||
<TableCell className="font-medium">{ds.name}</TableCell>
|
||||
<TableCell>{ds.desired}</TableCell>
|
||||
<TableCell>{ds.current}</TableCell>
|
||||
<TableCell>{ds.ready}</TableCell>
|
||||
<TableCell>{ds.up_to_date}</TableCell>
|
||||
<TableCell>{ds.available}</TableCell>
|
||||
<TableCell className="text-muted-foreground">{ds.age}</TableCell>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{daemonsets.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={8} className="text-center text-muted-foreground">
|
||||
No daemonsets found
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
) : (
|
||||
daemonsets.map((ds) => (
|
||||
<TableRow key={ds.name}>
|
||||
<TableCell className="font-medium">{ds.name}</TableCell>
|
||||
<TableCell>{ds.desired}</TableCell>
|
||||
<TableCell>{ds.current}</TableCell>
|
||||
<TableCell>{ds.ready}</TableCell>
|
||||
<TableCell>{ds.up_to_date}</TableCell>
|
||||
<TableCell>{ds.available}</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>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</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 { invoke } from "@tauri-apps/api/core";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui";
|
||||
import { Button } from "@/components/ui";
|
||||
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 { Scale, RotateCcw, Undo2, Pencil, Trash2 } from "lucide-react";
|
||||
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 {
|
||||
deployments: DeploymentInfo[];
|
||||
clusterId: string;
|
||||
namespace: string;
|
||||
onRefresh?: () => void;
|
||||
}
|
||||
|
||||
export function DeploymentList({ deployments, clusterId, namespace }: DeploymentListProps) {
|
||||
const [scalingDeployment, setScalingDeployment] = useState<DeploymentInfo | null>(null);
|
||||
const [replicas, setReplicas] = useState<string>("");
|
||||
const [isScaling, setIsScaling] = useState(false);
|
||||
const [scaleError, setScaleError] = useState<string | null>(null);
|
||||
type ActiveModal =
|
||||
| { type: "scale"; deployment: DeploymentInfo }
|
||||
| { type: "restart"; deployment: DeploymentInfo }
|
||||
| { type: "rollback"; deployment: DeploymentInfo }
|
||||
| { type: "edit"; deployment: DeploymentInfo; yaml: string }
|
||||
| { type: "delete"; deployment: DeploymentInfo }
|
||||
| null;
|
||||
|
||||
const [restartingDeployment, setRestartingDeployment] = useState<DeploymentInfo | null>(null);
|
||||
const [isRestarting, setIsRestarting] = useState(false);
|
||||
const [restartError, setRestartError] = 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);
|
||||
export function DeploymentList({ deployments, clusterId, namespace, onRefresh }: DeploymentListProps) {
|
||||
const [activeModal, setActiveModal] = useState<ActiveModal>(null);
|
||||
const [isActing, setIsActing] = useState(false);
|
||||
const [actionError, setActionError] = useState<string | null>(null);
|
||||
|
||||
const openEdit = async (deployment: DeploymentInfo) => {
|
||||
setActionError(null);
|
||||
try {
|
||||
await invoke<void>("scale_deployment", {
|
||||
clusterId,
|
||||
namespace,
|
||||
deploymentName: scalingDeployment.name,
|
||||
replicas: newReplicas,
|
||||
});
|
||||
|
||||
setScalingDeployment(null);
|
||||
setReplicas("");
|
||||
const yaml = await getResourceYamlCmd(clusterId, "deployments", namespace, deployment.name);
|
||||
setActiveModal({ type: "edit", deployment, yaml });
|
||||
} catch (err) {
|
||||
console.error("Failed to scale deployment:", err);
|
||||
setScaleError(err instanceof Error ? err.message : "Failed to scale deployment");
|
||||
} finally {
|
||||
setIsScaling(false);
|
||||
setActionError(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
};
|
||||
|
||||
const handleRestartSubmit = async () => {
|
||||
if (!restartingDeployment) return;
|
||||
|
||||
setIsRestarting(true);
|
||||
setRestartError(null);
|
||||
|
||||
const handleRestart = async () => {
|
||||
if (activeModal?.type !== "restart") return;
|
||||
setIsActing(true);
|
||||
try {
|
||||
await invoke<void>("restart_deployment", {
|
||||
clusterId,
|
||||
namespace,
|
||||
deploymentName: restartingDeployment.name,
|
||||
});
|
||||
|
||||
setRestartingDeployment(null);
|
||||
await restartDeploymentCmd(clusterId, namespace, activeModal.deployment.name);
|
||||
setActiveModal(null);
|
||||
onRefresh?.();
|
||||
} catch (err) {
|
||||
console.error("Failed to restart deployment:", err);
|
||||
setRestartError(err instanceof Error ? err.message : "Failed to restart deployment");
|
||||
setActionError(err instanceof Error ? err.message : String(err));
|
||||
} 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 (
|
||||
<>
|
||||
{actionError && (
|
||||
<p className="mb-2 text-sm text-destructive">{actionError}</p>
|
||||
)}
|
||||
<div className="overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
@ -114,24 +119,36 @@ export function DeploymentList({ deployments, clusterId, namespace }: Deployment
|
||||
<TableCell>{deployment.replicas}</TableCell>
|
||||
<TableCell className="text-muted-foreground">{deployment.age}</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setScalingDeployment(deployment)}
|
||||
>
|
||||
<Scale className="w-4 h-4" />
|
||||
Scale
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setRestartingDeployment(deployment)}
|
||||
>
|
||||
<RotateCcw className="w-4 h-4" />
|
||||
Restart
|
||||
</Button>
|
||||
</div>
|
||||
<ResourceActionMenu
|
||||
actions={[
|
||||
{
|
||||
label: "Scale",
|
||||
icon: Scale,
|
||||
onClick: () => setActiveModal({ type: "scale", deployment }),
|
||||
},
|
||||
{
|
||||
label: "Restart",
|
||||
icon: RotateCcw,
|
||||
onClick: () => setActiveModal({ type: "restart", deployment }),
|
||||
},
|
||||
{
|
||||
label: "Rollback",
|
||||
icon: Undo2,
|
||||
onClick: () => setActiveModal({ type: "rollback", deployment }),
|
||||
},
|
||||
{
|
||||
label: "Edit",
|
||||
icon: Pencil,
|
||||
onClick: () => openEdit(deployment),
|
||||
},
|
||||
{
|
||||
label: "Delete",
|
||||
icon: Trash2,
|
||||
variant: "destructive",
|
||||
onClick: () => setActiveModal({ type: "delete", deployment }),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
@ -140,69 +157,68 @@ export function DeploymentList({ deployments, clusterId, namespace }: Deployment
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{/* Scale Dialog */}
|
||||
<Dialog open={!!scalingDeployment} onOpenChange={() => setScalingDeployment(null)}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Scale Deployment</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="replicas">Replica Count</Label>
|
||||
<Input
|
||||
id="replicas"
|
||||
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>
|
||||
{activeModal?.type === "scale" && (
|
||||
<ScaleModal
|
||||
open
|
||||
onOpenChange={(o) => { if (!o) setActiveModal(null); }}
|
||||
resourceType="Deployment"
|
||||
resourceName={activeModal.deployment.name}
|
||||
currentReplicas={activeModal.deployment.replicas}
|
||||
onScale={(replicas) =>
|
||||
scaleDeploymentCmd(clusterId, namespace, activeModal.deployment.name, replicas).then(() => {
|
||||
setActiveModal(null);
|
||||
onRefresh?.();
|
||||
})
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Restart Dialog */}
|
||||
<Dialog open={!!restartingDeployment} onOpenChange={() => setRestartingDeployment(null)}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Restart Deployment</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
This will trigger a rolling restart of the deployment.
|
||||
</p>
|
||||
{restartError && (
|
||||
<Alert variant="destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertDescription>{restartError}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setRestartingDeployment(null)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleRestartSubmit} disabled={isRestarting}>
|
||||
{isRestarting ? "Restarting..." : "Restart"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
{activeModal?.type === "restart" && (
|
||||
<ConfirmDeleteDialog
|
||||
open
|
||||
onOpenChange={(o) => { if (!o) setActiveModal(null); }}
|
||||
resourceType="Deployment"
|
||||
resourceName={activeModal.deployment.name}
|
||||
isLoading={isActing}
|
||||
onConfirm={handleRestart}
|
||||
variant="delete"
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeModal?.type === "rollback" && (
|
||||
<ConfirmDeleteDialog
|
||||
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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
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,50 +1,144 @@
|
||||
import React from "react";
|
||||
import React, { useState } from "react";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui";
|
||||
import { Pencil, Trash2 } from "lucide-react";
|
||||
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 {
|
||||
hpas: HorizontalPodAutoscalerInfo[];
|
||||
_clusterId: string;
|
||||
_namespace: string;
|
||||
clusterId?: 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 (
|
||||
<div className="overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Name</TableHead>
|
||||
<TableHead>Namespace</TableHead>
|
||||
<TableHead>Min Replicas</TableHead>
|
||||
<TableHead>Max Replicas</TableHead>
|
||||
<TableHead>Current Replicas</TableHead>
|
||||
<TableHead>Desired Replicas</TableHead>
|
||||
<TableHead>Age</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{hpas.length === 0 ? (
|
||||
<>
|
||||
{actionError && (
|
||||
<p className="mb-2 text-sm text-destructive">{actionError}</p>
|
||||
)}
|
||||
<div className="overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableCell colSpan={7} className="text-center text-muted-foreground">
|
||||
No HPAs found
|
||||
</TableCell>
|
||||
<TableHead>Name</TableHead>
|
||||
<TableHead>Namespace</TableHead>
|
||||
<TableHead>Min Replicas</TableHead>
|
||||
<TableHead>Max Replicas</TableHead>
|
||||
<TableHead>Current Replicas</TableHead>
|
||||
<TableHead>Desired Replicas</TableHead>
|
||||
<TableHead>Age</TableHead>
|
||||
<TableHead className="text-right">Actions</TableHead>
|
||||
</TableRow>
|
||||
) : (
|
||||
hpas.map((hpa) => (
|
||||
<TableRow key={`${hpa.name}-${hpa.namespace}`}>
|
||||
<TableCell className="font-medium">{hpa.name}</TableCell>
|
||||
<TableCell>{hpa.namespace}</TableCell>
|
||||
<TableCell>{hpa.min_replicas}</TableCell>
|
||||
<TableCell>{hpa.max_replicas}</TableCell>
|
||||
<TableCell>{hpa.current_replicas}</TableCell>
|
||||
<TableCell>{hpa.desired_replicas}</TableCell>
|
||||
<TableCell className="text-muted-foreground">{hpa.age}</TableCell>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{hpas.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={8} className="text-center text-muted-foreground">
|
||||
No HPAs found
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
) : (
|
||||
hpas.map((hpa) => (
|
||||
<TableRow key={`${hpa.name}-${hpa.namespace}`}>
|
||||
<TableCell className="font-medium">{hpa.name}</TableCell>
|
||||
<TableCell>{hpa.namespace}</TableCell>
|
||||
<TableCell>{hpa.min_replicas}</TableCell>
|
||||
<TableCell>{hpa.max_replicas}</TableCell>
|
||||
<TableCell>{hpa.current_replicas}</TableCell>
|
||||
<TableCell>{hpa.desired_replicas}</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>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</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,48 +1,142 @@
|
||||
import React from "react";
|
||||
import React, { useState } from "react";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui";
|
||||
import { Pencil, Trash2 } from "lucide-react";
|
||||
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 {
|
||||
ingresses: IngressInfo[];
|
||||
_clusterId: string;
|
||||
_namespace: string;
|
||||
clusterId?: 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 (
|
||||
<div className="overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Name</TableHead>
|
||||
<TableHead>Namespace</TableHead>
|
||||
<TableHead>Class</TableHead>
|
||||
<TableHead>Host</TableHead>
|
||||
<TableHead>Addresses</TableHead>
|
||||
<TableHead>Age</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{ingresses.length === 0 ? (
|
||||
<>
|
||||
{actionError && (
|
||||
<p className="mb-2 text-sm text-destructive">{actionError}</p>
|
||||
)}
|
||||
<div className="overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableCell colSpan={6} className="text-center text-muted-foreground">
|
||||
No ingresses found
|
||||
</TableCell>
|
||||
<TableHead>Name</TableHead>
|
||||
<TableHead>Namespace</TableHead>
|
||||
<TableHead>Class</TableHead>
|
||||
<TableHead>Host</TableHead>
|
||||
<TableHead>Addresses</TableHead>
|
||||
<TableHead>Age</TableHead>
|
||||
<TableHead className="text-right">Actions</TableHead>
|
||||
</TableRow>
|
||||
) : (
|
||||
ingresses.map((ingress) => (
|
||||
<TableRow key={`${ingress.name}-${ingress.namespace}`}>
|
||||
<TableCell className="font-medium">{ingress.name}</TableCell>
|
||||
<TableCell>{ingress.namespace}</TableCell>
|
||||
<TableCell>{ingress.class || "-"}</TableCell>
|
||||
<TableCell>{ingress.host}</TableCell>
|
||||
<TableCell>{ingress.addresses.join(", ")}</TableCell>
|
||||
<TableCell className="text-muted-foreground">{ingress.age}</TableCell>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{ingresses.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={7} className="text-center text-muted-foreground">
|
||||
No ingresses found
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
) : (
|
||||
ingresses.map((ingress) => (
|
||||
<TableRow key={`${ingress.name}-${ingress.namespace}`}>
|
||||
<TableCell className="font-medium">{ingress.name}</TableCell>
|
||||
<TableCell>{ingress.namespace}</TableCell>
|
||||
<TableCell>{ingress.class || "-"}</TableCell>
|
||||
<TableCell>{ingress.host}</TableCell>
|
||||
<TableCell>{ingress.addresses.join(", ")}</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>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</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,52 +1,146 @@
|
||||
import React from "react";
|
||||
import React, { useState } from "react";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui";
|
||||
import { Pencil, Trash2 } from "lucide-react";
|
||||
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 {
|
||||
jobs: JobInfo[];
|
||||
_clusterId: string;
|
||||
_namespace: string;
|
||||
clusterId?: 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 (
|
||||
<div className="overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Name</TableHead>
|
||||
<TableHead>Namespace</TableHead>
|
||||
<TableHead>Completions</TableHead>
|
||||
<TableHead>Duration</TableHead>
|
||||
<TableHead>Age</TableHead>
|
||||
<TableHead>Labels</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{jobs.length === 0 ? (
|
||||
<>
|
||||
{actionError && (
|
||||
<p className="mb-2 text-sm text-destructive">{actionError}</p>
|
||||
)}
|
||||
<div className="overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableCell colSpan={6} className="text-center text-muted-foreground">
|
||||
No jobs found
|
||||
</TableCell>
|
||||
<TableHead>Name</TableHead>
|
||||
<TableHead>Namespace</TableHead>
|
||||
<TableHead>Completions</TableHead>
|
||||
<TableHead>Duration</TableHead>
|
||||
<TableHead>Age</TableHead>
|
||||
<TableHead>Labels</TableHead>
|
||||
<TableHead className="text-right">Actions</TableHead>
|
||||
</TableRow>
|
||||
) : (
|
||||
jobs.map((job) => (
|
||||
<TableRow key={`${job.name}-${job.namespace}`}>
|
||||
<TableCell className="font-medium">{job.name}</TableCell>
|
||||
<TableCell>{job.namespace}</TableCell>
|
||||
<TableCell>{job.completions}</TableCell>
|
||||
<TableCell>{job.duration}</TableCell>
|
||||
<TableCell className="text-muted-foreground">{job.age}</TableCell>
|
||||
<TableCell>
|
||||
{Object.entries(job.labels)
|
||||
.map(([k, v]) => `${k}=${v}`)
|
||||
.join(", ")}
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{jobs.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={7} className="text-center text-muted-foreground">
|
||||
No jobs found
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
) : (
|
||||
jobs.map((job) => (
|
||||
<TableRow key={`${job.name}-${job.namespace}`}>
|
||||
<TableCell className="font-medium">{job.name}</TableCell>
|
||||
<TableCell>{job.namespace}</TableCell>
|
||||
<TableCell>{job.completions}</TableCell>
|
||||
<TableCell>{job.duration}</TableCell>
|
||||
<TableCell className="text-muted-foreground">{job.age}</TableCell>
|
||||
<TableCell>
|
||||
{Object.entries(job.labels)
|
||||
.map(([k, v]) => `${k}=${v}`)
|
||||
.join(", ")}
|
||||
</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>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</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,44 +1,127 @@
|
||||
import React from "react";
|
||||
import React, { useState } from "react";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui";
|
||||
import { Pencil, Trash2 } from "lucide-react";
|
||||
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 {
|
||||
limitranges: LimitRangeInfo[];
|
||||
clusterId: 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 (
|
||||
<div className="overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Name</TableHead>
|
||||
<TableHead>Namespace</TableHead>
|
||||
<TableHead>Limits</TableHead>
|
||||
<TableHead>Age</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{limitranges.length === 0 ? (
|
||||
<>
|
||||
{actionError && (
|
||||
<p className="mb-2 text-sm text-destructive">{actionError}</p>
|
||||
)}
|
||||
<div className="overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableCell colSpan={4} className="text-center text-muted-foreground">
|
||||
No limit ranges found
|
||||
</TableCell>
|
||||
<TableHead>Name</TableHead>
|
||||
<TableHead>Namespace</TableHead>
|
||||
<TableHead>Limits</TableHead>
|
||||
<TableHead>Age</TableHead>
|
||||
<TableHead className="text-right">Actions</TableHead>
|
||||
</TableRow>
|
||||
) : (
|
||||
limitranges.map((lr) => (
|
||||
<TableRow key={`${lr.name}-${lr.namespace}`}>
|
||||
<TableCell className="font-medium">{lr.name}</TableCell>
|
||||
<TableCell className="text-sm text-muted-foreground">{lr.namespace}</TableCell>
|
||||
<TableCell className="text-sm">{lr.limit_count}</TableCell>
|
||||
<TableCell className="text-sm text-muted-foreground">{lr.age}</TableCell>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{limitranges.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={5} className="text-center text-muted-foreground">
|
||||
No limit ranges found
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
) : (
|
||||
limitranges.map((lr) => (
|
||||
<TableRow key={`${lr.name}-${lr.namespace}`}>
|
||||
<TableCell className="font-medium">{lr.name}</TableCell>
|
||||
<TableCell className="text-sm text-muted-foreground">{lr.namespace}</TableCell>
|
||||
<TableCell className="text-sm">{lr.limit_count}</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>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</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,46 +1,129 @@
|
||||
import React from "react";
|
||||
import React, { useState } from "react";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui";
|
||||
import { Pencil, Trash2 } from "lucide-react";
|
||||
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 {
|
||||
networkpolicies: NetworkPolicyInfo[];
|
||||
clusterId: 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 (
|
||||
<div className="overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Name</TableHead>
|
||||
<TableHead>Namespace</TableHead>
|
||||
<TableHead>Pod Selector</TableHead>
|
||||
<TableHead>Policy Types</TableHead>
|
||||
<TableHead>Age</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{networkpolicies.length === 0 ? (
|
||||
<>
|
||||
{actionError && (
|
||||
<p className="mb-2 text-sm text-destructive">{actionError}</p>
|
||||
)}
|
||||
<div className="overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableCell colSpan={5} className="text-center text-muted-foreground">
|
||||
No network policies found
|
||||
</TableCell>
|
||||
<TableHead>Name</TableHead>
|
||||
<TableHead>Namespace</TableHead>
|
||||
<TableHead>Pod Selector</TableHead>
|
||||
<TableHead>Policy Types</TableHead>
|
||||
<TableHead>Age</TableHead>
|
||||
<TableHead className="text-right">Actions</TableHead>
|
||||
</TableRow>
|
||||
) : (
|
||||
networkpolicies.map((np) => (
|
||||
<TableRow key={`${np.name}-${np.namespace}`}>
|
||||
<TableCell className="font-medium">{np.name}</TableCell>
|
||||
<TableCell className="text-sm text-muted-foreground">{np.namespace}</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 text-muted-foreground">{np.age}</TableCell>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{networkpolicies.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={6} className="text-center text-muted-foreground">
|
||||
No network policies found
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
) : (
|
||||
networkpolicies.map((np) => (
|
||||
<TableRow key={`${np.name}-${np.namespace}`}>
|
||||
<TableCell className="font-medium">{np.name}</TableCell>
|
||||
<TableCell className="text-sm text-muted-foreground">{np.namespace}</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 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>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</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 { invoke } from "@tauri-apps/api/core";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui";
|
||||
import { Badge } from "@/components/ui";
|
||||
import { Button } from "@/components/ui";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui";
|
||||
import { AlertCircle, Terminal } from "lucide-react";
|
||||
import { Alert, AlertDescription } from "@/components/ui";
|
||||
import { ShieldOff, ShieldCheck, Trash2, Pencil } from "lucide-react";
|
||||
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 {
|
||||
nodes: NodeInfo[];
|
||||
clusterId: string;
|
||||
onRefresh?: () => void;
|
||||
}
|
||||
|
||||
export function NodeList({ nodes, clusterId }: NodeListProps) {
|
||||
const [selectedNode, setSelectedNode] = useState<NodeInfo | null>(null);
|
||||
const [isCordoning, setIsCordoning] = useState(false);
|
||||
const [isUncordoning, setIsUncordoning] = useState(false);
|
||||
const [isDraining, setIsDraining] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
type ActiveModal =
|
||||
| { type: "drain"; node: NodeInfo }
|
||||
| { type: "edit"; node: NodeInfo; yaml: string }
|
||||
| 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) => {
|
||||
switch (status.toLowerCase()) {
|
||||
@ -33,53 +42,59 @@ export function NodeList({ nodes, clusterId }: NodeListProps) {
|
||||
}
|
||||
};
|
||||
|
||||
const handleCordon = async () => {
|
||||
if (!selectedNode) return;
|
||||
|
||||
setIsCordoning(true);
|
||||
setError(null);
|
||||
const isSchedulingDisabled = (node: NodeInfo) =>
|
||||
node.status.toLowerCase().includes("schedulingdisabled") ||
|
||||
node.roles.toLowerCase().includes("schedulingdisabled");
|
||||
|
||||
const handleCordon = async (node: NodeInfo) => {
|
||||
setActionError(null);
|
||||
try {
|
||||
await invoke<void>("cordon_node", { clusterId, nodeName: selectedNode.name });
|
||||
setSelectedNode(null);
|
||||
await cordonNodeCmd(clusterId, node.name);
|
||||
onRefresh?.();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to cordon node");
|
||||
} finally {
|
||||
setIsCordoning(false);
|
||||
setActionError(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
};
|
||||
|
||||
const handleUncordon = async () => {
|
||||
if (!selectedNode) return;
|
||||
|
||||
setIsUncordoning(true);
|
||||
setError(null);
|
||||
const handleUncordon = async (node: NodeInfo) => {
|
||||
setActionError(null);
|
||||
try {
|
||||
await invoke<void>("uncordon_node", { clusterId, nodeName: selectedNode.name });
|
||||
setSelectedNode(null);
|
||||
await uncordonNodeCmd(clusterId, node.name);
|
||||
onRefresh?.();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to uncordon node");
|
||||
} finally {
|
||||
setIsUncordoning(false);
|
||||
setActionError(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
};
|
||||
|
||||
const handleDrain = async () => {
|
||||
if (!selectedNode) return;
|
||||
|
||||
setIsDraining(true);
|
||||
setError(null);
|
||||
if (activeModal?.type !== "drain") return;
|
||||
setIsActing(true);
|
||||
try {
|
||||
await invoke<void>("drain_node", { clusterId, nodeName: selectedNode.name });
|
||||
setSelectedNode(null);
|
||||
await drainNodeCmd(clusterId, activeModal.node.name);
|
||||
setActiveModal(null);
|
||||
onRefresh?.();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to drain node");
|
||||
setActionError(err instanceof Error ? err.message : String(err));
|
||||
} 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 (
|
||||
<>
|
||||
{actionError && (
|
||||
<p className="mb-2 text-sm text-destructive">{actionError}</p>
|
||||
)}
|
||||
<div className="overflow-x-auto">
|
||||
<Table>
|
||||
<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.age}</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setSelectedNode(node)}
|
||||
className="text-primary hover:text-primary hover:bg-primary/10"
|
||||
>
|
||||
Manage
|
||||
</Button>
|
||||
<ResourceActionMenu
|
||||
actions={[
|
||||
{
|
||||
label: "Cordon",
|
||||
icon: ShieldOff,
|
||||
hidden: isSchedulingDisabled(node),
|
||||
onClick: () => handleCordon(node),
|
||||
},
|
||||
{
|
||||
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>
|
||||
</TableRow>
|
||||
))
|
||||
@ -132,101 +166,28 @@ export function NodeList({ nodes, clusterId }: NodeListProps) {
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{/* Node Management Dialog */}
|
||||
{selectedNode && (
|
||||
<Dialog open={true} onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setSelectedNode(null);
|
||||
setError(null);
|
||||
}
|
||||
}}>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Terminal className="w-5 h-5" />
|
||||
Manage Node: {selectedNode.name}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
{activeModal?.type === "drain" && (
|
||||
<ConfirmDeleteDialog
|
||||
open
|
||||
onOpenChange={(o) => { if (!o) setActiveModal(null); }}
|
||||
resourceType="Node"
|
||||
resourceName={activeModal.node.name}
|
||||
isLoading={isActing}
|
||||
onConfirm={handleDrain}
|
||||
variant="force-delete"
|
||||
/>
|
||||
)}
|
||||
|
||||
<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
|
||||
onClick={handleDrain}
|
||||
variant="destructive"
|
||||
disabled={isDraining}
|
||||
className="w-full"
|
||||
>
|
||||
{isDraining ? "Draining..." : "Drain Node"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<Alert variant="destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
{activeModal?.type === "edit" && (
|
||||
<EditResourceModal
|
||||
isOpen
|
||||
clusterId={clusterId}
|
||||
namespace=""
|
||||
resourceType="nodes"
|
||||
resourceName={activeModal.node.name}
|
||||
initialYaml={activeModal.yaml}
|
||||
onClose={() => { setActiveModal(null); onRefresh?.(); }}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
@ -1,50 +1,144 @@
|
||||
import React from "react";
|
||||
import React, { useState } from "react";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui";
|
||||
import { Pencil, Trash2 } from "lucide-react";
|
||||
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 {
|
||||
pvcs: PersistentVolumeClaimInfo[];
|
||||
_clusterId: string;
|
||||
_namespace: string;
|
||||
clusterId?: 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 (
|
||||
<div className="overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Name</TableHead>
|
||||
<TableHead>Namespace</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>Volume</TableHead>
|
||||
<TableHead>Capacity</TableHead>
|
||||
<TableHead>Access Modes</TableHead>
|
||||
<TableHead>Age</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{pvcs.length === 0 ? (
|
||||
<>
|
||||
{actionError && (
|
||||
<p className="mb-2 text-sm text-destructive">{actionError}</p>
|
||||
)}
|
||||
<div className="overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableCell colSpan={7} className="text-center text-muted-foreground">
|
||||
No PVCs found
|
||||
</TableCell>
|
||||
<TableHead>Name</TableHead>
|
||||
<TableHead>Namespace</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>Volume</TableHead>
|
||||
<TableHead>Capacity</TableHead>
|
||||
<TableHead>Access Modes</TableHead>
|
||||
<TableHead>Age</TableHead>
|
||||
<TableHead className="text-right">Actions</TableHead>
|
||||
</TableRow>
|
||||
) : (
|
||||
pvcs.map((pvc) => (
|
||||
<TableRow key={`${pvc.name}-${pvc.namespace}`}>
|
||||
<TableCell className="font-medium">{pvc.name}</TableCell>
|
||||
<TableCell>{pvc.namespace}</TableCell>
|
||||
<TableCell>{pvc.status}</TableCell>
|
||||
<TableCell>{pvc.volume}</TableCell>
|
||||
<TableCell>{pvc.capacity}</TableCell>
|
||||
<TableCell>{pvc.access_modes.join(", ")}</TableCell>
|
||||
<TableCell className="text-muted-foreground">{pvc.age}</TableCell>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{pvcs.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={8} className="text-center text-muted-foreground">
|
||||
No PVCs found
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
) : (
|
||||
pvcs.map((pvc) => (
|
||||
<TableRow key={`${pvc.name}-${pvc.namespace}`}>
|
||||
<TableCell className="font-medium">{pvc.name}</TableCell>
|
||||
<TableCell>{pvc.namespace}</TableCell>
|
||||
<TableCell>{pvc.status}</TableCell>
|
||||
<TableCell>{pvc.volume}</TableCell>
|
||||
<TableCell>{pvc.capacity}</TableCell>
|
||||
<TableCell>{pvc.access_modes.join(", ")}</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>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</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,49 +1,134 @@
|
||||
import React from "react";
|
||||
import React, { useState } from "react";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui";
|
||||
import { Pencil, Trash2 } from "lucide-react";
|
||||
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 {
|
||||
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 (
|
||||
<div className="overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Name</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>Capacity</TableHead>
|
||||
<TableHead>Access Modes</TableHead>
|
||||
<TableHead>Reclaim Policy</TableHead>
|
||||
<TableHead>Storage Class</TableHead>
|
||||
<TableHead>Age</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{pvs.length === 0 ? (
|
||||
<>
|
||||
{actionError && (
|
||||
<p className="mb-2 text-sm text-destructive">{actionError}</p>
|
||||
)}
|
||||
<div className="overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableCell colSpan={7} className="text-center text-muted-foreground">
|
||||
No PVs found
|
||||
</TableCell>
|
||||
<TableHead>Name</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>Capacity</TableHead>
|
||||
<TableHead>Access Modes</TableHead>
|
||||
<TableHead>Reclaim Policy</TableHead>
|
||||
<TableHead>Storage Class</TableHead>
|
||||
<TableHead>Age</TableHead>
|
||||
<TableHead className="text-right">Actions</TableHead>
|
||||
</TableRow>
|
||||
) : (
|
||||
pvs.map((pv) => (
|
||||
<TableRow key={pv.name}>
|
||||
<TableCell className="font-medium">{pv.name}</TableCell>
|
||||
<TableCell>{pv.status}</TableCell>
|
||||
<TableCell>{pv.capacity}</TableCell>
|
||||
<TableCell>{pv.access_modes.join(", ")}</TableCell>
|
||||
<TableCell>{pv.reclaim_policy}</TableCell>
|
||||
<TableCell>{pv.storage_class}</TableCell>
|
||||
<TableCell className="text-muted-foreground">{pv.age}</TableCell>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{pvs.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={8} className="text-center text-muted-foreground">
|
||||
No PVs found
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
) : (
|
||||
pvs.map((pv) => (
|
||||
<TableRow key={pv.name}>
|
||||
<TableCell className="font-medium">{pv.name}</TableCell>
|
||||
<TableCell>{pv.status}</TableCell>
|
||||
<TableCell>{pv.capacity}</TableCell>
|
||||
<TableCell>{pv.access_modes.join(", ")}</TableCell>
|
||||
<TableCell>{pv.reclaim_policy}</TableCell>
|
||||
<TableCell>{pv.storage_class}</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>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</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 { invoke } from "@tauri-apps/api/core";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui";
|
||||
import { Badge } from "@/components/ui";
|
||||
import { Button } from "@/components/ui";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui";
|
||||
import { Textarea } from "@/components/ui";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui";
|
||||
import { Terminal, FileText, RotateCcw } from "lucide-react";
|
||||
import { Alert, AlertDescription } from "@/components/ui";
|
||||
import type { PodInfo, LogResponse } from "@/lib/tauriCommands";
|
||||
import { FileText, Terminal, Link, Pencil, Trash2, Zap } from "lucide-react";
|
||||
import type { PodInfo } from "@/lib/tauriCommands";
|
||||
import { deleteResourceCmd, forceDeleteResourceCmd, getResourceYamlCmd } from "@/lib/tauriCommands";
|
||||
import { ResourceActionMenu } from "./ResourceActionMenu";
|
||||
import { ConfirmDeleteDialog } from "./ConfirmDeleteDialog";
|
||||
import { LogsModal } from "./LogsModal";
|
||||
import { ShellExecModal } from "./ShellExecModal";
|
||||
import { AttachModal } from "./AttachModal";
|
||||
import { EditResourceModal } from "./EditResourceModal";
|
||||
|
||||
interface PodListProps {
|
||||
pods: PodInfo[];
|
||||
clusterId: string;
|
||||
namespace: string;
|
||||
onRefresh?: () => void;
|
||||
}
|
||||
|
||||
export function PodList({ pods, clusterId, namespace }: PodListProps) {
|
||||
const [selectedPod, setSelectedPod] = useState<PodInfo | null>(null);
|
||||
const [selectedContainer, setSelectedContainer] = useState<string>("");
|
||||
const [logs, setLogs] = useState<string>("");
|
||||
const [isFetchingLogs, setIsFetchingLogs] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
||||
type ActiveModal =
|
||||
| { type: "logs"; pod: PodInfo }
|
||||
| { type: "shell"; pod: PodInfo }
|
||||
| { type: "attach"; pod: PodInfo }
|
||||
| { type: "edit"; pod: PodInfo; yaml: string }
|
||||
| { type: "delete"; pod: PodInfo }
|
||||
| { 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) => {
|
||||
switch (status.toLowerCase()) {
|
||||
@ -41,37 +49,41 @@ export function PodList({ pods, clusterId, namespace }: PodListProps) {
|
||||
}
|
||||
};
|
||||
|
||||
const fetchLogs = async () => {
|
||||
if (!selectedPod || !selectedContainer) return;
|
||||
|
||||
setIsFetchingLogs(true);
|
||||
setError(null);
|
||||
const openEdit = async (pod: PodInfo) => {
|
||||
setEditError(null);
|
||||
try {
|
||||
const response = await invoke<LogResponse>("get_pod_logs", {
|
||||
clusterId,
|
||||
namespace,
|
||||
podName: selectedPod.name,
|
||||
containerName: selectedContainer,
|
||||
});
|
||||
setLogs(response.logs);
|
||||
const yaml = await getResourceYamlCmd(clusterId, "pods", namespace, pod.name);
|
||||
setActiveModal({ type: "edit", pod, yaml });
|
||||
} catch (err) {
|
||||
console.error("Failed to fetch logs:", err);
|
||||
setError(err instanceof Error ? err.message : "Failed to fetch logs");
|
||||
} finally {
|
||||
setIsFetchingLogs(false);
|
||||
setEditError(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
};
|
||||
|
||||
const handleContainerChange = (container: string) => {
|
||||
setSelectedContainer(container);
|
||||
setLogs("");
|
||||
setError(null);
|
||||
const handleDelete = async (force: boolean) => {
|
||||
const modal = activeModal;
|
||||
if (!modal || (modal.type !== "delete" && modal.type !== "force-delete")) return;
|
||||
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 (
|
||||
<>
|
||||
{editError && (
|
||||
<p className="mb-2 text-sm text-destructive">{editError}</p>
|
||||
)}
|
||||
<div className="overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
@ -102,91 +114,46 @@ export function PodList({ pods, clusterId, namespace }: PodListProps) {
|
||||
<TableCell>{pod.ready}</TableCell>
|
||||
<TableCell className="text-muted-foreground">{pod.age}</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
|
||||
<Button variant="ghost" size="sm" onClick={() => { setSelectedPod(pod); setIsDialogOpen(true); }}>
|
||||
<Terminal className="w-4 h-4" />
|
||||
</Button>
|
||||
<DialogContent className="max-w-4xl max-h-[80vh] flex flex-col">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{pod.name} - {namespace} namespace</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="flex-1 overflow-y-auto flex flex-col">
|
||||
{selectedPod && (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium">Container:</span>
|
||||
<select
|
||||
value={selectedContainer}
|
||||
onChange={(e) => handleContainerChange(e.target.value)}
|
||||
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>
|
||||
{containers.map((container) => (
|
||||
<option key={container} value={container}>
|
||||
{container}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<Button
|
||||
onClick={fetchLogs}
|
||||
disabled={!selectedContainer || isFetchingLogs}
|
||||
size="sm"
|
||||
>
|
||||
{isFetchingLogs ? (
|
||||
<>
|
||||
<RotateCcw className="w-4 h-4 animate-spin" />
|
||||
Loading...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<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>
|
||||
<ResourceActionMenu
|
||||
actions={[
|
||||
{
|
||||
label: "Logs",
|
||||
icon: FileText,
|
||||
onClick: () => setActiveModal({ type: "logs", pod }),
|
||||
},
|
||||
{
|
||||
label: "Shell",
|
||||
icon: Terminal,
|
||||
onClick: () => setActiveModal({ type: "shell", pod }),
|
||||
},
|
||||
{
|
||||
label: "Attach",
|
||||
icon: Link,
|
||||
onClick: () => setActiveModal({ type: "attach", pod }),
|
||||
},
|
||||
{
|
||||
label: "Edit",
|
||||
icon: Pencil,
|
||||
onClick: () => openEdit(pod),
|
||||
},
|
||||
{
|
||||
label: "Delete",
|
||||
icon: Trash2,
|
||||
variant: "destructive",
|
||||
onClick: () => setActiveModal({ type: "delete", pod }),
|
||||
},
|
||||
{
|
||||
label: "Force Delete",
|
||||
icon: Zap,
|
||||
variant: "destructive",
|
||||
hidden: !(
|
||||
pod.status.toLowerCase() === "running" ||
|
||||
pod.status.toLowerCase() === "pending"
|
||||
),
|
||||
onClick: () => setActiveModal({ type: "force-delete", pod }),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
@ -194,6 +161,74 @@ export function PodList({ pods, clusterId, namespace }: PodListProps) {
|
||||
</TableBody>
|
||||
</Table>
|
||||
</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,52 +1,173 @@
|
||||
import React from "react";
|
||||
import React, { useState } from "react";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui";
|
||||
import { Scale, Pencil, Trash2 } from "lucide-react";
|
||||
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 {
|
||||
replicaSets: ReplicaSetInfo[];
|
||||
_clusterId: string;
|
||||
_namespace: string;
|
||||
clusterId?: 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 (
|
||||
<div className="overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Name</TableHead>
|
||||
<TableHead>Namespace</TableHead>
|
||||
<TableHead>Replicas</TableHead>
|
||||
<TableHead>Ready</TableHead>
|
||||
<TableHead>Age</TableHead>
|
||||
<TableHead>Labels</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{replicaSets.length === 0 ? (
|
||||
<>
|
||||
{actionError && (
|
||||
<p className="mb-2 text-sm text-destructive">{actionError}</p>
|
||||
)}
|
||||
<div className="overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableCell colSpan={6} className="text-center text-muted-foreground">
|
||||
No replica sets found
|
||||
</TableCell>
|
||||
<TableHead>Name</TableHead>
|
||||
<TableHead>Namespace</TableHead>
|
||||
<TableHead>Replicas</TableHead>
|
||||
<TableHead>Ready</TableHead>
|
||||
<TableHead>Age</TableHead>
|
||||
<TableHead>Labels</TableHead>
|
||||
<TableHead className="text-right">Actions</TableHead>
|
||||
</TableRow>
|
||||
) : (
|
||||
replicaSets.map((replicaSet) => (
|
||||
<TableRow key={`${replicaSet.name}-${replicaSet.namespace}`}>
|
||||
<TableCell className="font-medium">{replicaSet.name}</TableCell>
|
||||
<TableCell>{replicaSet.namespace}</TableCell>
|
||||
<TableCell>{replicaSet.replicas}</TableCell>
|
||||
<TableCell>{replicaSet.ready}</TableCell>
|
||||
<TableCell className="text-muted-foreground">{replicaSet.age}</TableCell>
|
||||
<TableCell>
|
||||
{Object.entries(replicaSet.labels)
|
||||
.map(([k, v]) => `${k}=${v}`)
|
||||
.join(", ")}
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{replicaSets.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={7} className="text-center text-muted-foreground">
|
||||
No replica sets found
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
) : (
|
||||
replicaSets.map((rs) => (
|
||||
<TableRow key={`${rs.name}-${rs.namespace}`}>
|
||||
<TableCell className="font-medium">{rs.name}</TableCell>
|
||||
<TableCell>{rs.namespace}</TableCell>
|
||||
<TableCell>{rs.replicas}</TableCell>
|
||||
<TableCell>{rs.ready}</TableCell>
|
||||
<TableCell className="text-muted-foreground">{rs.age}</TableCell>
|
||||
<TableCell>
|
||||
{Object.entries(rs.labels)
|
||||
.map(([k, v]) => `${k}=${v}`)
|
||||
.join(", ")}
|
||||
</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>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</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,50 +1,133 @@
|
||||
import React from "react";
|
||||
import React, { useState } from "react";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui";
|
||||
import { Pencil, Trash2 } from "lucide-react";
|
||||
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 {
|
||||
resourcequotas: ResourceQuotaInfo[];
|
||||
clusterId: 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 (
|
||||
<div className="overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Name</TableHead>
|
||||
<TableHead>Namespace</TableHead>
|
||||
<TableHead>CPU Req</TableHead>
|
||||
<TableHead>Mem Req</TableHead>
|
||||
<TableHead>CPU Limit</TableHead>
|
||||
<TableHead>Mem Limit</TableHead>
|
||||
<TableHead>Age</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{resourcequotas.length === 0 ? (
|
||||
<>
|
||||
{actionError && (
|
||||
<p className="mb-2 text-sm text-destructive">{actionError}</p>
|
||||
)}
|
||||
<div className="overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableCell colSpan={7} className="text-center text-muted-foreground">
|
||||
No resource quotas found
|
||||
</TableCell>
|
||||
<TableHead>Name</TableHead>
|
||||
<TableHead>Namespace</TableHead>
|
||||
<TableHead>CPU Req</TableHead>
|
||||
<TableHead>Mem Req</TableHead>
|
||||
<TableHead>CPU Limit</TableHead>
|
||||
<TableHead>Mem Limit</TableHead>
|
||||
<TableHead>Age</TableHead>
|
||||
<TableHead className="text-right">Actions</TableHead>
|
||||
</TableRow>
|
||||
) : (
|
||||
resourcequotas.map((rq) => (
|
||||
<TableRow key={`${rq.name}-${rq.namespace}`}>
|
||||
<TableCell className="font-medium">{rq.name}</TableCell>
|
||||
<TableCell className="text-sm text-muted-foreground">{rq.namespace}</TableCell>
|
||||
<TableCell className="text-sm font-mono">{rq.request_cpu || "—"}</TableCell>
|
||||
<TableCell className="text-sm font-mono">{rq.request_memory || "—"}</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 text-muted-foreground">{rq.age}</TableCell>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{resourcequotas.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={8} className="text-center text-muted-foreground">
|
||||
No resource quotas found
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
) : (
|
||||
resourcequotas.map((rq) => (
|
||||
<TableRow key={`${rq.name}-${rq.namespace}`}>
|
||||
<TableCell className="font-medium">{rq.name}</TableCell>
|
||||
<TableCell className="text-sm text-muted-foreground">{rq.namespace}</TableCell>
|
||||
<TableCell className="text-sm font-mono">{rq.request_cpu || "—"}</TableCell>
|
||||
<TableCell className="text-sm font-mono">{rq.request_memory || "—"}</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 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>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</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,44 +1,138 @@
|
||||
import React from "react";
|
||||
import React, { useState } from "react";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui";
|
||||
import { Pencil, Trash2 } from "lucide-react";
|
||||
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 {
|
||||
roleBindings: RoleBindingInfo[];
|
||||
_clusterId: string;
|
||||
_namespace: string;
|
||||
clusterId?: 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 (
|
||||
<div className="overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Name</TableHead>
|
||||
<TableHead>Namespace</TableHead>
|
||||
<TableHead>Role</TableHead>
|
||||
<TableHead>Age</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{roleBindings.length === 0 ? (
|
||||
<>
|
||||
{actionError && (
|
||||
<p className="mb-2 text-sm text-destructive">{actionError}</p>
|
||||
)}
|
||||
<div className="overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableCell colSpan={4} className="text-center text-muted-foreground">
|
||||
No role bindings found
|
||||
</TableCell>
|
||||
<TableHead>Name</TableHead>
|
||||
<TableHead>Namespace</TableHead>
|
||||
<TableHead>Role</TableHead>
|
||||
<TableHead>Age</TableHead>
|
||||
<TableHead className="text-right">Actions</TableHead>
|
||||
</TableRow>
|
||||
) : (
|
||||
roleBindings.map((rb) => (
|
||||
<TableRow key={`${rb.name}-${rb.namespace}`}>
|
||||
<TableCell className="font-medium">{rb.name}</TableCell>
|
||||
<TableCell>{rb.namespace}</TableCell>
|
||||
<TableCell>{rb.role}</TableCell>
|
||||
<TableCell className="text-muted-foreground">{rb.age}</TableCell>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{roleBindings.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={5} className="text-center text-muted-foreground">
|
||||
No role bindings found
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
) : (
|
||||
roleBindings.map((rb) => (
|
||||
<TableRow key={`${rb.name}-${rb.namespace}`}>
|
||||
<TableCell className="font-medium">{rb.name}</TableCell>
|
||||
<TableCell>{rb.namespace}</TableCell>
|
||||
<TableCell>{rb.role}</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>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</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,42 +1,136 @@
|
||||
import React from "react";
|
||||
import React, { useState } from "react";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui";
|
||||
import { Pencil, Trash2 } from "lucide-react";
|
||||
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 {
|
||||
roles: RoleInfo[];
|
||||
_clusterId: string;
|
||||
_namespace: string;
|
||||
clusterId?: 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 (
|
||||
<div className="overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Name</TableHead>
|
||||
<TableHead>Namespace</TableHead>
|
||||
<TableHead>Age</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{roles.length === 0 ? (
|
||||
<>
|
||||
{actionError && (
|
||||
<p className="mb-2 text-sm text-destructive">{actionError}</p>
|
||||
)}
|
||||
<div className="overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableCell colSpan={3} className="text-center text-muted-foreground">
|
||||
No roles found
|
||||
</TableCell>
|
||||
<TableHead>Name</TableHead>
|
||||
<TableHead>Namespace</TableHead>
|
||||
<TableHead>Age</TableHead>
|
||||
<TableHead className="text-right">Actions</TableHead>
|
||||
</TableRow>
|
||||
) : (
|
||||
roles.map((role) => (
|
||||
<TableRow key={`${role.name}-${role.namespace}`}>
|
||||
<TableCell className="font-medium">{role.name}</TableCell>
|
||||
<TableCell>{role.namespace}</TableCell>
|
||||
<TableCell className="text-muted-foreground">{role.age}</TableCell>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{roles.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={4} className="text-center text-muted-foreground">
|
||||
No roles found
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
) : (
|
||||
roles.map((role) => (
|
||||
<TableRow key={`${role.name}-${role.namespace}`}>
|
||||
<TableCell className="font-medium">{role.name}</TableCell>
|
||||
<TableCell>{role.namespace}</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>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</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,50 +1,140 @@
|
||||
import React from "react";
|
||||
import React, { useState } from "react";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui";
|
||||
import { Pencil, Trash2 } from "lucide-react";
|
||||
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 {
|
||||
secrets: SecretInfo[];
|
||||
_clusterId: string;
|
||||
_namespace: string;
|
||||
clusterId?: 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 (
|
||||
<div className="overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Name</TableHead>
|
||||
<TableHead>Namespace</TableHead>
|
||||
<TableHead>Type</TableHead>
|
||||
<TableHead>Data Keys</TableHead>
|
||||
<TableHead>Age</TableHead>
|
||||
<TableHead className="text-right">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{secrets.length === 0 ? (
|
||||
<>
|
||||
{actionError && (
|
||||
<p className="mb-2 text-sm text-destructive">{actionError}</p>
|
||||
)}
|
||||
<div className="overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableCell colSpan={6} className="text-center text-muted-foreground">
|
||||
No secrets found
|
||||
</TableCell>
|
||||
<TableHead>Name</TableHead>
|
||||
<TableHead>Namespace</TableHead>
|
||||
<TableHead>Type</TableHead>
|
||||
<TableHead>Data Keys</TableHead>
|
||||
<TableHead>Age</TableHead>
|
||||
<TableHead className="text-right">Actions</TableHead>
|
||||
</TableRow>
|
||||
) : (
|
||||
secrets.map((secret) => (
|
||||
<TableRow key={`${secret.name}-${secret.namespace}`}>
|
||||
<TableCell className="font-medium">{secret.name}</TableCell>
|
||||
<TableCell>{secret.namespace}</TableCell>
|
||||
<TableCell>{secret.type}</TableCell>
|
||||
<TableCell>{secret.data_keys}</TableCell>
|
||||
<TableCell className="text-muted-foreground">{secret.age}</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<span className="text-sm">View/Edit</span>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{secrets.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={6} className="text-center text-muted-foreground">
|
||||
No secrets found
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
) : (
|
||||
secrets.map((secret) => (
|
||||
<TableRow key={`${secret.name}-${secret.namespace}`}>
|
||||
<TableCell className="font-medium">{secret.name}</TableCell>
|
||||
<TableCell>{secret.namespace}</TableCell>
|
||||
<TableCell>{secret.type}</TableCell>
|
||||
<TableCell>{secret.data_keys}</TableCell>
|
||||
<TableCell className="text-muted-foreground">{secret.age}</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<ResourceActionMenu
|
||||
actions={[
|
||||
{
|
||||
label: "Edit",
|
||||
icon: Pencil,
|
||||
onClick: () => openEdit(secret),
|
||||
},
|
||||
{
|
||||
label: "Delete",
|
||||
icon: Trash2,
|
||||
variant: "destructive",
|
||||
onClick: () => setActiveModal({ type: "delete", secret }),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</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,44 +1,138 @@
|
||||
import React from "react";
|
||||
import React, { useState } from "react";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui";
|
||||
import { Pencil, Trash2 } from "lucide-react";
|
||||
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 {
|
||||
serviceAccounts: ServiceAccountInfo[];
|
||||
_clusterId: string;
|
||||
_namespace: string;
|
||||
clusterId?: 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 (
|
||||
<div className="overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Name</TableHead>
|
||||
<TableHead>Namespace</TableHead>
|
||||
<TableHead>Secrets</TableHead>
|
||||
<TableHead>Age</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{serviceAccounts.length === 0 ? (
|
||||
<>
|
||||
{actionError && (
|
||||
<p className="mb-2 text-sm text-destructive">{actionError}</p>
|
||||
)}
|
||||
<div className="overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableCell colSpan={4} className="text-center text-muted-foreground">
|
||||
No service accounts found
|
||||
</TableCell>
|
||||
<TableHead>Name</TableHead>
|
||||
<TableHead>Namespace</TableHead>
|
||||
<TableHead>Secrets</TableHead>
|
||||
<TableHead>Age</TableHead>
|
||||
<TableHead className="text-right">Actions</TableHead>
|
||||
</TableRow>
|
||||
) : (
|
||||
serviceAccounts.map((sa) => (
|
||||
<TableRow key={`${sa.name}-${sa.namespace}`}>
|
||||
<TableCell className="font-medium">{sa.name}</TableCell>
|
||||
<TableCell>{sa.namespace}</TableCell>
|
||||
<TableCell>{sa.secrets}</TableCell>
|
||||
<TableCell className="text-muted-foreground">{sa.age}</TableCell>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{serviceAccounts.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={5} className="text-center text-muted-foreground">
|
||||
No service accounts found
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
) : (
|
||||
serviceAccounts.map((sa) => (
|
||||
<TableRow key={`${sa.name}-${sa.namespace}`}>
|
||||
<TableCell className="font-medium">{sa.name}</TableCell>
|
||||
<TableCell>{sa.namespace}</TableCell>
|
||||
<TableCell>{sa.secrets}</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>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</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 { Badge } from "@/components/ui";
|
||||
import { Pencil, Trash2 } from "lucide-react";
|
||||
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 {
|
||||
services: ServiceInfo[];
|
||||
clusterId: 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) => {
|
||||
switch (type.toLowerCase()) {
|
||||
case "clusterip":
|
||||
@ -25,56 +40,124 @@ 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 (
|
||||
<div className="overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Name</TableHead>
|
||||
<TableHead>Type</TableHead>
|
||||
<TableHead>Cluster IP</TableHead>
|
||||
<TableHead>External IP</TableHead>
|
||||
<TableHead>Ports</TableHead>
|
||||
<TableHead>Age</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{services.length === 0 ? (
|
||||
<>
|
||||
{actionError && (
|
||||
<p className="mb-2 text-sm text-destructive">{actionError}</p>
|
||||
)}
|
||||
<div className="overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableCell colSpan={6} className="text-center text-muted-foreground">
|
||||
No services found
|
||||
</TableCell>
|
||||
<TableHead>Name</TableHead>
|
||||
<TableHead>Type</TableHead>
|
||||
<TableHead>Cluster IP</TableHead>
|
||||
<TableHead>External IP</TableHead>
|
||||
<TableHead>Ports</TableHead>
|
||||
<TableHead>Age</TableHead>
|
||||
<TableHead className="text-right">Actions</TableHead>
|
||||
</TableRow>
|
||||
) : (
|
||||
services.map((service) => (
|
||||
<TableRow key={`${service.name}-${service.namespace}`}>
|
||||
<TableCell className="font-medium">{service.name}</TableCell>
|
||||
<TableCell>
|
||||
<Badge className={`${getServiceTypeColor(service.type)} text-white`}>
|
||||
{service.type}
|
||||
</Badge>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{services.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={7} className="text-center text-muted-foreground">
|
||||
No services found
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-sm">{service.cluster_ip}</TableCell>
|
||||
<TableCell className="font-mono text-sm">
|
||||
{service.external_ip || "N/A"}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="space-y-1">
|
||||
{service.ports.map((port) => (
|
||||
<div key={`${port.port}-${port.protocol}`} className="text-sm">
|
||||
{port.name ? `${port.name}: ` : ""}
|
||||
{port.port}/{port.protocol}
|
||||
{port.target_port && ` → ${port.target_port}`}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground">{service.age}</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
) : (
|
||||
services.map((service) => (
|
||||
<TableRow key={`${service.name}-${service.namespace}`}>
|
||||
<TableCell className="font-medium">{service.name}</TableCell>
|
||||
<TableCell>
|
||||
<Badge className={`${getServiceTypeColor(service.type)} text-white`}>
|
||||
{service.type}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-sm">{service.cluster_ip}</TableCell>
|
||||
<TableCell className="font-mono text-sm">
|
||||
{service.external_ip || "N/A"}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="space-y-1">
|
||||
{service.ports.map((port) => (
|
||||
<div key={`${port.port}-${port.protocol}`} className="text-sm">
|
||||
{port.name ? `${port.name}: ` : ""}
|
||||
{port.port}/{port.protocol}
|
||||
{port.target_port && ` → ${port.target_port}`}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</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>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</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,44 +1,187 @@
|
||||
import React from "react";
|
||||
import React, { useState } from "react";
|
||||
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 {
|
||||
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 {
|
||||
statefulsets: StatefulSetInfo[];
|
||||
clusterId: 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 (
|
||||
<div className="overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Name</TableHead>
|
||||
<TableHead>Ready</TableHead>
|
||||
<TableHead>Replicas</TableHead>
|
||||
<TableHead>Age</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{statefulsets.length === 0 ? (
|
||||
<>
|
||||
{actionError && (
|
||||
<p className="mb-2 text-sm text-destructive">{actionError}</p>
|
||||
)}
|
||||
<div className="overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableCell colSpan={4} className="text-center text-muted-foreground">
|
||||
No statefulsets found
|
||||
</TableCell>
|
||||
<TableHead>Name</TableHead>
|
||||
<TableHead>Ready</TableHead>
|
||||
<TableHead>Replicas</TableHead>
|
||||
<TableHead>Age</TableHead>
|
||||
<TableHead className="text-right">Actions</TableHead>
|
||||
</TableRow>
|
||||
) : (
|
||||
statefulsets.map((ss) => (
|
||||
<TableRow key={ss.name}>
|
||||
<TableCell className="font-medium">{ss.name}</TableCell>
|
||||
<TableCell>{ss.ready}</TableCell>
|
||||
<TableCell>{ss.replicas}</TableCell>
|
||||
<TableCell className="text-muted-foreground">{ss.age}</TableCell>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{statefulsets.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={5} className="text-center text-muted-foreground">
|
||||
No statefulsets found
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
) : (
|
||||
statefulsets.map((ss) => (
|
||||
<TableRow key={ss.name}>
|
||||
<TableCell className="font-medium">{ss.name}</TableCell>
|
||||
<TableCell>{ss.ready}</TableCell>
|
||||
<TableCell>{ss.replicas}</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>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</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,48 +1,131 @@
|
||||
import React from "react";
|
||||
import React, { useState } from "react";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui";
|
||||
import { Pencil, Trash2 } from "lucide-react";
|
||||
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 {
|
||||
storageclasses: StorageClassInfo[];
|
||||
clusterId: 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 (
|
||||
<div className="overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Name</TableHead>
|
||||
<TableHead>Provisioner</TableHead>
|
||||
<TableHead>Reclaim Policy</TableHead>
|
||||
<TableHead>Volume Binding Mode</TableHead>
|
||||
<TableHead>Expand</TableHead>
|
||||
<TableHead>Age</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{storageclasses.length === 0 ? (
|
||||
<>
|
||||
{actionError && (
|
||||
<p className="mb-2 text-sm text-destructive">{actionError}</p>
|
||||
)}
|
||||
<div className="overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableCell colSpan={6} className="text-center text-muted-foreground">
|
||||
No storage classes found
|
||||
</TableCell>
|
||||
<TableHead>Name</TableHead>
|
||||
<TableHead>Provisioner</TableHead>
|
||||
<TableHead>Reclaim Policy</TableHead>
|
||||
<TableHead>Volume Binding Mode</TableHead>
|
||||
<TableHead>Expand</TableHead>
|
||||
<TableHead>Age</TableHead>
|
||||
<TableHead className="text-right">Actions</TableHead>
|
||||
</TableRow>
|
||||
) : (
|
||||
storageclasses.map((sc) => (
|
||||
<TableRow key={sc.name}>
|
||||
<TableCell className="font-medium">{sc.name}</TableCell>
|
||||
<TableCell className="text-sm font-mono">{sc.provisioner}</TableCell>
|
||||
<TableCell className="text-sm">{sc.reclaim_policy}</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 text-muted-foreground">{sc.age}</TableCell>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{storageclasses.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={7} className="text-center text-muted-foreground">
|
||||
No storage classes found
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
) : (
|
||||
storageclasses.map((sc) => (
|
||||
<TableRow key={sc.name}>
|
||||
<TableCell className="font-medium">{sc.name}</TableCell>
|
||||
<TableCell className="text-sm font-mono">{sc.provisioner}</TableCell>
|
||||
<TableCell className="text-sm">{sc.reclaim_policy}</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 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>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</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 { ResourceQuotaList } from "./ResourceQuotaList";
|
||||
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) =>
|
||||
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,
|
||||
Plus,
|
||||
Package,
|
||||
Settings2,
|
||||
Box,
|
||||
Bell,
|
||||
Puzzle,
|
||||
} from "lucide-react";
|
||||
import { useKubernetesStore } from "@/stores/kubernetesStore";
|
||||
import {
|
||||
@ -54,6 +58,18 @@ import {
|
||||
NetworkPolicyList,
|
||||
ResourceQuotaList,
|
||||
LimitRangeList,
|
||||
ReplicationControllerList,
|
||||
PodDisruptionBudgetList,
|
||||
PriorityClassList,
|
||||
RuntimeClassList,
|
||||
LeaseList,
|
||||
MutatingWebhookList,
|
||||
ValidatingWebhookList,
|
||||
EndpointList,
|
||||
EndpointSliceList,
|
||||
IngressClassList,
|
||||
NamespaceList,
|
||||
WorkloadOverview,
|
||||
} from "@/components/Kubernetes";
|
||||
import type {
|
||||
KubeconfigInfo,
|
||||
@ -84,6 +100,19 @@ import type {
|
||||
NetworkPolicyInfo,
|
||||
ResourceQuotaInfo,
|
||||
LimitRangeInfo,
|
||||
ReplicationControllerInfo,
|
||||
PodDisruptionBudgetInfo,
|
||||
PriorityClassInfo,
|
||||
RuntimeClassInfo,
|
||||
LeaseInfo,
|
||||
WebhookConfigInfo,
|
||||
EndpointInfo,
|
||||
EndpointSliceInfo,
|
||||
IngressClassInfo,
|
||||
NamespaceResourceInfo,
|
||||
HelmChart,
|
||||
HelmRelease,
|
||||
CrdInfo,
|
||||
} from "@/lib/tauriCommands";
|
||||
import {
|
||||
listKubeconfigsCmd,
|
||||
@ -119,108 +148,181 @@ import {
|
||||
listNetworkpoliciesCmd,
|
||||
listResourcequotasCmd,
|
||||
listLimitrangesCmd,
|
||||
listReplicationcontrollersCmd,
|
||||
listPoddisruptionbudgetsCmd,
|
||||
listPriorityclassesCmd,
|
||||
listRuntimeclassesCmd,
|
||||
listLeasesCmd,
|
||||
listMutatingwebhookconfigurationsCmd,
|
||||
listValidatingwebhookconfigurationsCmd,
|
||||
listEndpointsCmd,
|
||||
listEndpointslicesCmd,
|
||||
listIngressclassesCmd,
|
||||
listNamespacesResourceCmd,
|
||||
helmSearchRepoCmd,
|
||||
helmListReleasesCmd,
|
||||
listCrdsCmd,
|
||||
} from "@/lib/tauriCommands";
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
type ActiveSection =
|
||||
| "overview"
|
||||
| "cluster_overview"
|
||||
| "nodes"
|
||||
| "workloads_overview"
|
||||
| "pods"
|
||||
| "deployments"
|
||||
| "daemonsets"
|
||||
| "statefulsets"
|
||||
| "replicasets"
|
||||
| "replicationcontrollers"
|
||||
| "jobs"
|
||||
| "cronjobs"
|
||||
| "services"
|
||||
| "ingresses"
|
||||
| "configmaps"
|
||||
| "secrets"
|
||||
| "resourcequotas"
|
||||
| "limitranges"
|
||||
| "hpas"
|
||||
| "poddisruptionbudgets"
|
||||
| "priorityclasses"
|
||||
| "runtimeclasses"
|
||||
| "leases"
|
||||
| "mutatingwebhooks"
|
||||
| "validatingwebhooks"
|
||||
| "services"
|
||||
| "endpointslices"
|
||||
| "endpoints"
|
||||
| "ingresses"
|
||||
| "ingressclasses"
|
||||
| "networkpolicies"
|
||||
| "portforwarding"
|
||||
| "pvcs"
|
||||
| "pvs"
|
||||
| "serviceaccounts"
|
||||
| "roles"
|
||||
| "clusterroles"
|
||||
| "rolebindings"
|
||||
| "clusterrolebindings"
|
||||
| "nodes"
|
||||
| "events"
|
||||
| "portforwarding"
|
||||
| "storageclasses"
|
||||
| "networkpolicies"
|
||||
| "resourcequotas"
|
||||
| "limitranges";
|
||||
| "namespaces"
|
||||
| "events"
|
||||
| "helm_charts"
|
||||
| "helm_releases"
|
||||
| "serviceaccounts"
|
||||
| "clusterroles"
|
||||
| "roles"
|
||||
| "clusterrolebindings"
|
||||
| "rolebindings"
|
||||
| "crds";
|
||||
|
||||
interface NavItem {
|
||||
id: ActiveSection;
|
||||
label: string;
|
||||
}
|
||||
|
||||
interface NavSection {
|
||||
interface NavGroup {
|
||||
type: "group";
|
||||
label: string;
|
||||
icon: React.ElementType;
|
||||
items: NavItem[];
|
||||
}
|
||||
|
||||
interface NavTopLevel {
|
||||
type: "toplevel";
|
||||
id: ActiveSection;
|
||||
label: string;
|
||||
icon: React.ElementType;
|
||||
}
|
||||
|
||||
type NavEntry = NavGroup | NavTopLevel;
|
||||
|
||||
// ─── 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",
|
||||
icon: Layers,
|
||||
items: [
|
||||
{ id: "workloads_overview", label: "Overview" },
|
||||
{ id: "pods", label: "Pods" },
|
||||
{ id: "deployments", label: "Deployments" },
|
||||
{ id: "daemonsets", label: "Daemon Sets" },
|
||||
{ id: "statefulsets", label: "Stateful Sets" },
|
||||
{ id: "replicasets", label: "Replica Sets" },
|
||||
{ id: "replicationcontrollers", label: "Replication Controllers" },
|
||||
{ id: "jobs", label: "Jobs" },
|
||||
{ id: "cronjobs", label: "Cron Jobs" },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Services & Networking",
|
||||
icon: Network,
|
||||
items: [
|
||||
{ id: "services", label: "Services" },
|
||||
{ id: "ingresses", label: "Ingresses" },
|
||||
{ id: "networkpolicies", label: "Network Policies" },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Config & Storage",
|
||||
icon: Database,
|
||||
type: "group",
|
||||
label: "Config",
|
||||
icon: Settings2,
|
||||
items: [
|
||||
{ id: "configmaps", label: "Config Maps" },
|
||||
{ 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: "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",
|
||||
icon: Shield,
|
||||
items: [
|
||||
{ id: "serviceaccounts", label: "Service Accounts" },
|
||||
{ id: "roles", label: "Roles" },
|
||||
{ id: "clusterroles", label: "Cluster Roles" },
|
||||
{ id: "rolebindings", label: "Role Bindings" },
|
||||
{ id: "roles", label: "Roles" },
|
||||
{ id: "clusterrolebindings", label: "Cluster Role Bindings" },
|
||||
{ id: "rolebindings", label: "Role Bindings" },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Cluster",
|
||||
icon: Server,
|
||||
type: "group",
|
||||
label: "Custom Resources",
|
||||
icon: Puzzle,
|
||||
items: [
|
||||
{ id: "overview", label: "Overview" },
|
||||
{ id: "nodes", label: "Nodes" },
|
||||
{ id: "events", label: "Events" },
|
||||
{ id: "portforwarding", label: "Port Forwarding" },
|
||||
{ id: "crds", label: "Definitions" },
|
||||
],
|
||||
},
|
||||
];
|
||||
@ -253,6 +355,20 @@ interface ResourceData {
|
||||
networkpolicies: NetworkPolicyInfo[];
|
||||
resourcequotas: ResourceQuotaInfo[];
|
||||
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 = {
|
||||
@ -281,6 +397,20 @@ const EMPTY_RESOURCES: ResourceData = {
|
||||
networkpolicies: [],
|
||||
resourcequotas: [],
|
||||
limitranges: [],
|
||||
replicationcontrollers: [],
|
||||
poddisruptionbudgets: [],
|
||||
priorityclasses: [],
|
||||
runtimeclasses: [],
|
||||
leases: [],
|
||||
mutatingwebhooks: [],
|
||||
validatingwebhooks: [],
|
||||
endpoints: [],
|
||||
endpointslices: [],
|
||||
ingressclasses: [],
|
||||
namespaces_resource: [],
|
||||
helm_charts: [],
|
||||
helm_releases: [],
|
||||
crds: [],
|
||||
};
|
||||
|
||||
// ─── Component ───────────────────────────────────────────────────────────────
|
||||
@ -293,20 +423,21 @@ export function KubernetesPage() {
|
||||
const [namespaces, setNamespaces] = useState<NamespaceInfo[]>([]);
|
||||
const [portForwards, setPortForwards] = useState<PortForwardResponse[]>([]);
|
||||
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>>({
|
||||
Workloads: true,
|
||||
"Services & Networking": true,
|
||||
"Config & Storage": true,
|
||||
Config: true,
|
||||
Network: true,
|
||||
Storage: true,
|
||||
Helm: false,
|
||||
"Access Control": true,
|
||||
Cluster: true,
|
||||
"Custom Resources": false,
|
||||
});
|
||||
const [isLoadingResources, setIsLoadingResources] = useState(false);
|
||||
const [isCommandPaletteOpen, setIsCommandPaletteOpen] = useState(false);
|
||||
const [isPortForwardFormOpen, setIsPortForwardFormOpen] = 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);
|
||||
|
||||
// ── Initial data load ──────────────────────────────────────────────────────
|
||||
@ -350,7 +481,13 @@ export function KubernetesPage() {
|
||||
|
||||
const loadResourceData = useCallback(
|
||||
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;
|
||||
|
||||
@ -358,8 +495,6 @@ export function KubernetesPage() {
|
||||
try {
|
||||
switch (section) {
|
||||
case "pods":
|
||||
setResources((r) => ({ ...r, pods: [] }));
|
||||
setResources((r) => ({ ...r }));
|
||||
await listPodsCmd(clusterId, ns).then((data) =>
|
||||
setResources((r) => ({ ...r, pods: data }))
|
||||
);
|
||||
@ -384,6 +519,11 @@ export function KubernetesPage() {
|
||||
setResources((r) => ({ ...r, replicasets: data }))
|
||||
);
|
||||
break;
|
||||
case "replicationcontrollers":
|
||||
await listReplicationcontrollersCmd(clusterId, ns).then((data) =>
|
||||
setResources((r) => ({ ...r, replicationcontrollers: data }))
|
||||
);
|
||||
break;
|
||||
case "jobs":
|
||||
await listJobsCmd(clusterId, ns).then((data) =>
|
||||
setResources((r) => ({ ...r, jobs: data }))
|
||||
@ -484,6 +624,71 @@ export function KubernetesPage() {
|
||||
setResources((r) => ({ ...r, limitranges: data }))
|
||||
);
|
||||
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 };
|
||||
} catch (err) {
|
||||
@ -593,7 +798,7 @@ export function KubernetesPage() {
|
||||
);
|
||||
}
|
||||
|
||||
if (activeSection === "overview") {
|
||||
if (activeSection === "cluster_overview") {
|
||||
return (
|
||||
<ClusterOverview
|
||||
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") {
|
||||
return (
|
||||
<div className="p-6 space-y-4">
|
||||
@ -647,35 +868,37 @@ export function KubernetesPage() {
|
||||
case "statefulsets":
|
||||
return <StatefulSetList statefulsets={resources.statefulsets} clusterId={cid} namespace={ns} />;
|
||||
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":
|
||||
return <JobList jobs={resources.jobs} _clusterId={cid} _namespace={ns} />;
|
||||
return <JobList jobs={resources.jobs} clusterId={cid} namespace={ns} />;
|
||||
case "cronjobs":
|
||||
return <CronJobList cronJobs={resources.cronjobs} _clusterId={cid} _namespace={ns} />;
|
||||
return <CronJobList cronJobs={resources.cronjobs} clusterId={cid} namespace={ns} />;
|
||||
case "services":
|
||||
return <ServiceList services={resources.services} clusterId={cid} namespace={ns} />;
|
||||
case "ingresses":
|
||||
return <IngressList ingresses={resources.ingresses} _clusterId={cid} _namespace={ns} />;
|
||||
return <IngressList ingresses={resources.ingresses} clusterId={cid} namespace={ns} />;
|
||||
case "configmaps":
|
||||
return <ConfigMapList configmaps={resources.configmaps} clusterId={cid} namespace={ns} />;
|
||||
case "secrets":
|
||||
return <SecretList secrets={resources.secrets} _clusterId={cid} _namespace={ns} />;
|
||||
return <SecretList secrets={resources.secrets} clusterId={cid} namespace={ns} />;
|
||||
case "hpas":
|
||||
return <HPAList hpas={resources.hpas} _clusterId={cid} _namespace={ns} />;
|
||||
return <HPAList hpas={resources.hpas} clusterId={cid} namespace={ns} />;
|
||||
case "pvcs":
|
||||
return <PVCList pvcs={resources.pvcs} _clusterId={cid} _namespace={ns} />;
|
||||
return <PVCList pvcs={resources.pvcs} clusterId={cid} namespace={ns} />;
|
||||
case "pvs":
|
||||
return <PVList pvs={resources.pvs} _clusterId={cid} />;
|
||||
return <PVList pvs={resources.pvs} clusterId={cid} />;
|
||||
case "serviceaccounts":
|
||||
return <ServiceAccountList serviceAccounts={resources.serviceaccounts} _clusterId={cid} _namespace={ns} />;
|
||||
return <ServiceAccountList serviceAccounts={resources.serviceaccounts} clusterId={cid} namespace={ns} />;
|
||||
case "roles":
|
||||
return <RoleList roles={resources.roles} _clusterId={cid} _namespace={ns} />;
|
||||
return <RoleList roles={resources.roles} clusterId={cid} namespace={ns} />;
|
||||
case "clusterroles":
|
||||
return <ClusterRoleList clusterRoles={resources.clusterroles} _clusterId={cid} />;
|
||||
return <ClusterRoleList clusterRoles={resources.clusterroles} clusterId={cid} />;
|
||||
case "rolebindings":
|
||||
return <RoleBindingList roleBindings={resources.rolebindings} _clusterId={cid} _namespace={ns} />;
|
||||
return <RoleBindingList roleBindings={resources.rolebindings} clusterId={cid} namespace={ns} />;
|
||||
case "clusterrolebindings":
|
||||
return <ClusterRoleBindingList clusterRoleBindings={resources.clusterrolebindings} _clusterId={cid} />;
|
||||
return <ClusterRoleBindingList clusterRoleBindings={resources.clusterrolebindings} clusterId={cid} />;
|
||||
case "nodes":
|
||||
return <NodeList nodes={resources.nodes} clusterId={cid} />;
|
||||
case "events":
|
||||
@ -688,6 +911,142 @@ export function KubernetesPage() {
|
||||
return <ResourceQuotaList resourcequotas={resources.resourcequotas} clusterId={cid} namespace={ns} />;
|
||||
case "limitranges":
|
||||
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:
|
||||
return null;
|
||||
}
|
||||
@ -776,19 +1135,38 @@ export function KubernetesPage() {
|
||||
<div className="flex flex-1 overflow-hidden">
|
||||
{/* Sidebar */}
|
||||
<aside className="w-56 shrink-0 border-r bg-card overflow-y-auto flex flex-col">
|
||||
{NAV_SECTIONS.map((section) => {
|
||||
const Icon = section.icon;
|
||||
const isExpanded = expandedSections[section.label] ?? true;
|
||||
{NAV_ENTRIES.map((entry) => {
|
||||
if (entry.type === "toplevel") {
|
||||
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 (
|
||||
<div key={section.label}>
|
||||
<div key={entry.label}>
|
||||
<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"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Icon className="w-3.5 h-3.5" />
|
||||
<span>{section.label}</span>
|
||||
<span>{entry.label}</span>
|
||||
</div>
|
||||
{isExpanded ? (
|
||||
<ChevronDown className="w-3 h-3" />
|
||||
@ -799,7 +1177,7 @@ export function KubernetesPage() {
|
||||
|
||||
{isExpanded && (
|
||||
<div className="pb-1">
|
||||
{section.items.map((item) => (
|
||||
{entry.items.map((item) => (
|
||||
<button
|
||||
key={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-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>
|
||||
</div>
|
||||
</DialogContent>
|
||||
|
||||
@ -174,8 +174,9 @@ describe("KubernetesPage", () => {
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Workloads")).toBeInTheDocument();
|
||||
expect(screen.getByText("Services & Networking")).toBeInTheDocument();
|
||||
expect(screen.getByText("Config & Storage")).toBeInTheDocument();
|
||||
expect(screen.getByText("Network")).toBeInTheDocument();
|
||||
expect(screen.getByText("Config")).toBeInTheDocument();
|
||||
expect(screen.getByText("Storage")).toBeInTheDocument();
|
||||
expect(screen.getByText("Access Control")).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();
|
||||
|
||||
await waitFor(() => {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user