feat(kube): Kubernetes UI — FreeLens v5 feature parity #85

Merged
sarman merged 6 commits from feat/kube-ui-feature-parity into master 2026-06-09 02:05:06 +00:00
63 changed files with 11117 additions and 1211 deletions

View File

@ -242,7 +242,7 @@ jobs:
# Write body to file — passing 100KB+ JSON as a shell arg hits ARG_MAX. # Write body to file — passing 100KB+ JSON as a shell arg hits ARG_MAX.
jq -cn \ jq -cn \
--arg model "qwen36-35b-a3b-nvfp4" \ --arg model "qwen3-coder-next" \
--rawfile content /tmp/prompt.txt \ --rawfile content /tmp/prompt.txt \
'{model: $model, messages: [{role: "user", content: $content}], stream: false}' \ '{model: $model, messages: [{role: "user", content: $content}], stream: false}' \
> /tmp/body.json > /tmp/body.json
@ -359,7 +359,7 @@ jobs:
if [ -f "/tmp/pr_review.txt" ] && [ -s "/tmp/pr_review.txt" ]; then if [ -f "/tmp/pr_review.txt" ] && [ -s "/tmp/pr_review.txt" ]; then
REVIEW_BODY=$(head -c 65536 /tmp/pr_review.txt) REVIEW_BODY=$(head -c 65536 /tmp/pr_review.txt)
BODY=$(jq -n \ BODY=$(jq -n \
--arg body "Automated PR Review (qwen36-35b-a3b-nvfp4 via liteLLM):\n\n${REVIEW_BODY}" \ --arg body "Automated PR Review (qwen3-coder-next via liteLLM):\n\n${REVIEW_BODY}" \
'{body: $body, event: "COMMENT"}') '{body: $body, event: "COMMENT"}')
else else
BODY=$(jq -n \ BODY=$(jq -n \

View 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

View 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

View File

@ -36,7 +36,7 @@ const tsBase = {
export default [ export default [
{ {
ignores: ["dist/", "node_modules/", "src-tauri/target/**", "target/**", "coverage/", "tailwind.config.ts"], ignores: ["dist/", "node_modules/", "src-tauri/target/**", "target/**", "coverage/", "tailwind.config.ts", ".claude/"],
}, },
{ {
files: ["src/**/*.{ts,tsx}"], files: ["src/**/*.{ts,tsx}"],

View 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
View 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"

View File

@ -330,6 +330,7 @@ pub async fn initiate_oauth(
let port_forwards = app_state.port_forwards.clone(); let port_forwards = app_state.port_forwards.clone();
let refresh_registry = app_state.refresh_registry.clone(); let refresh_registry = app_state.refresh_registry.clone();
let watchers = app_state.watchers.clone(); let watchers = app_state.watchers.clone();
let log_streams = app_state.log_streams.clone();
tokio::spawn(async move { tokio::spawn(async move {
let app_state_for_callback = AppState { let app_state_for_callback = AppState {
@ -343,6 +344,7 @@ pub async fn initiate_oauth(
port_forwards, port_forwards,
refresh_registry, refresh_registry,
watchers, watchers,
log_streams,
}; };
while let Some(callback) = callback_rx.recv().await { while let Some(callback) = callback_rx.recv().await {
tracing::info!("Received OAuth callback for state: {}", callback.state); tracing::info!("Received OAuth callback for state: {}", callback.state);

File diff suppressed because it is too large Load Diff

View File

@ -45,6 +45,7 @@ pub fn run() {
port_forwards: Arc::new(tokio::sync::Mutex::new(std::collections::HashMap::new())), port_forwards: Arc::new(tokio::sync::Mutex::new(std::collections::HashMap::new())),
refresh_registry: Arc::new(tokio::sync::Mutex::new(crate::kube::RefreshRegistry::new())), refresh_registry: Arc::new(tokio::sync::Mutex::new(crate::kube::RefreshRegistry::new())),
watchers: Arc::new(Mutex::new(std::collections::HashMap::new())), watchers: Arc::new(Mutex::new(std::collections::HashMap::new())),
log_streams: Arc::new(tokio::sync::Mutex::new(std::collections::HashMap::new())),
}; };
let stronghold_salt = format!( let stronghold_salt = format!(
"tftsr-stronghold-salt-v1-{:x}", "tftsr-stronghold-salt-v1-{:x}",
@ -232,6 +233,46 @@ pub fn run() {
commands::kube::rollback_deployment, commands::kube::rollback_deployment,
commands::kube::create_resource, commands::kube::create_resource,
commands::kube::edit_resource, commands::kube::edit_resource,
// Phase 4: Additional Resource Discovery
commands::kube::list_replicationcontrollers,
commands::kube::list_poddisruptionbudgets,
commands::kube::list_priorityclasses,
commands::kube::list_runtimeclasses,
commands::kube::list_leases,
commands::kube::list_mutatingwebhookconfigurations,
commands::kube::list_validatingwebhookconfigurations,
commands::kube::list_endpoints,
commands::kube::list_endpointslices,
commands::kube::list_ingressclasses,
commands::kube::list_namespaces_resource,
commands::kube::list_crds,
commands::kube::list_custom_resources,
// Phase 5: Action Commands
commands::kube::force_delete_resource,
commands::kube::describe_resource,
commands::kube::get_resource_yaml,
commands::kube::attach_pod,
commands::kube::restart_statefulset,
commands::kube::restart_daemonset,
commands::kube::scale_statefulset,
commands::kube::scale_replicaset,
commands::kube::scale_replicationcontroller,
commands::kube::suspend_cronjob,
commands::kube::resume_cronjob,
commands::kube::trigger_cronjob,
commands::kube::create_namespace,
commands::kube::delete_namespace,
// Phase 6: Log Streaming
commands::kube::stream_pod_logs,
commands::kube::stop_log_stream,
// Phase 7: Helm Commands
commands::kube::helm_list_repos,
commands::kube::helm_add_repo,
commands::kube::helm_update_repos,
commands::kube::helm_search_repo,
commands::kube::helm_list_releases,
commands::kube::helm_uninstall,
commands::kube::helm_rollback,
]) ])
.run(tauri::generate_context!()) .run(tauri::generate_context!())
.expect("Error running Troubleshooting and RCA Assistant application"); .expect("Error running Troubleshooting and RCA Assistant application");

113
src-tauri/src/shell/helm.rs Normal file
View 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
}
}

View File

@ -1,5 +1,6 @@
pub mod classifier; pub mod classifier;
pub mod executor; pub mod executor;
pub mod helm;
pub mod kubeconfig; pub mod kubeconfig;
pub mod kubectl; pub mod kubectl;
@ -8,5 +9,6 @@ mod tests;
pub use classifier::{ClassificationResult, CommandClassifier, CommandTier}; pub use classifier::{ClassificationResult, CommandClassifier, CommandTier};
pub use executor::{execute_with_approval, CommandOutput}; pub use executor::{execute_with_approval, CommandOutput};
pub use helm::locate_helm;
pub use kubeconfig::{auto_detect_kubeconfig, KubeconfigInfo}; pub use kubeconfig::{auto_detect_kubeconfig, KubeconfigInfo};
pub use kubectl::{execute_kubectl, locate_kubectl}; pub use kubectl::{execute_kubectl, locate_kubectl};

View File

@ -99,6 +99,8 @@ pub struct AppState {
pub refresh_registry: Arc<TokioMutex<crate::kube::RefreshRegistry>>, pub refresh_registry: Arc<TokioMutex<crate::kube::RefreshRegistry>>,
/// Resource watchers: unsubscribe_id -> receiver /// Resource watchers: unsubscribe_id -> receiver
pub watchers: Arc<Mutex<HashMap<String, tokio::sync::mpsc::Receiver<serde_json::Value>>>>, pub watchers: Arc<Mutex<HashMap<String, tokio::sync::mpsc::Receiver<serde_json::Value>>>>,
/// Active pod log streaming tasks: stream_id -> abort handle
pub log_streams: Arc<TokioMutex<HashMap<String, tokio::task::AbortHandle>>>,
} }
/// Determine the application data directory. /// Determine the application data directory.

View 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>
);
}

View File

@ -1,14 +1,62 @@
import React from "react"; import React, { useState } from "react";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui";
import { Pencil, Trash2 } from "lucide-react";
import type { ClusterRoleBindingInfo } from "@/lib/tauriCommands"; import type { ClusterRoleBindingInfo } from "@/lib/tauriCommands";
import { deleteResourceCmd, getResourceYamlCmd } from "@/lib/tauriCommands";
import { ResourceActionMenu } from "./ResourceActionMenu";
import { ConfirmDeleteDialog } from "./ConfirmDeleteDialog";
import { EditResourceModal } from "./EditResourceModal";
interface ClusterRoleBindingListProps { interface ClusterRoleBindingListProps {
clusterRoleBindings: ClusterRoleBindingInfo[]; clusterRoleBindings: ClusterRoleBindingInfo[];
_clusterId: string; clusterId?: string;
_clusterId?: string;
onRefresh?: () => void;
} }
export function ClusterRoleBindingList({ clusterRoleBindings, _clusterId }: ClusterRoleBindingListProps) { type ActiveModal =
| { type: "edit"; crb: ClusterRoleBindingInfo; yaml: string }
| { type: "delete"; crb: ClusterRoleBindingInfo }
| null;
export function ClusterRoleBindingList({
clusterRoleBindings,
clusterId,
_clusterId,
onRefresh,
}: ClusterRoleBindingListProps) {
const cid = clusterId ?? _clusterId ?? "";
const [activeModal, setActiveModal] = useState<ActiveModal>(null);
const [isDeleting, setIsDeleting] = useState(false);
const [actionError, setActionError] = useState<string | null>(null);
const openEdit = async (crb: ClusterRoleBindingInfo) => {
setActionError(null);
try {
const yaml = await getResourceYamlCmd(cid, "clusterrolebindings", "", crb.name);
setActiveModal({ type: "edit", crb, yaml });
} catch (err) {
setActionError(err instanceof Error ? err.message : String(err));
}
};
const handleDelete = async () => {
if (activeModal?.type !== "delete") return;
setIsDeleting(true);
try {
await deleteResourceCmd(cid, "clusterrolebindings", "", activeModal.crb.name);
setActiveModal(null);
onRefresh?.();
} finally {
setIsDeleting(false);
}
};
return ( return (
<>
{actionError && (
<p className="mb-2 text-sm text-destructive">{actionError}</p>
)}
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<Table> <Table>
<TableHeader> <TableHeader>
@ -16,12 +64,13 @@ export function ClusterRoleBindingList({ clusterRoleBindings, _clusterId }: Clus
<TableHead>Name</TableHead> <TableHead>Name</TableHead>
<TableHead>Cluster Role</TableHead> <TableHead>Cluster Role</TableHead>
<TableHead>Age</TableHead> <TableHead>Age</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{clusterRoleBindings.length === 0 ? ( {clusterRoleBindings.length === 0 ? (
<TableRow> <TableRow>
<TableCell colSpan={3} className="text-center text-muted-foreground"> <TableCell colSpan={4} className="text-center text-muted-foreground">
No cluster role bindings found No cluster role bindings found
</TableCell> </TableCell>
</TableRow> </TableRow>
@ -31,11 +80,52 @@ export function ClusterRoleBindingList({ clusterRoleBindings, _clusterId }: Clus
<TableCell className="font-medium">{crb.name}</TableCell> <TableCell className="font-medium">{crb.name}</TableCell>
<TableCell>{crb.cluster_role}</TableCell> <TableCell>{crb.cluster_role}</TableCell>
<TableCell className="text-muted-foreground">{crb.age}</TableCell> <TableCell className="text-muted-foreground">{crb.age}</TableCell>
<TableCell className="text-right">
<ResourceActionMenu
actions={[
{
label: "Edit",
icon: Pencil,
onClick: () => openEdit(crb),
},
{
label: "Delete",
icon: Trash2,
variant: "destructive",
onClick: () => setActiveModal({ type: "delete", crb }),
},
]}
/>
</TableCell>
</TableRow> </TableRow>
)) ))
)} )}
</TableBody> </TableBody>
</Table> </Table>
</div> </div>
{activeModal?.type === "edit" && (
<EditResourceModal
isOpen
clusterId={cid}
namespace=""
resourceType="clusterrolebindings"
resourceName={activeModal.crb.name}
initialYaml={activeModal.yaml}
onClose={() => { setActiveModal(null); onRefresh?.(); }}
/>
)}
{activeModal?.type === "delete" && (
<ConfirmDeleteDialog
open
onOpenChange={(o) => { if (!o) setActiveModal(null); }}
resourceType="ClusterRoleBinding"
resourceName={activeModal.crb.name}
isLoading={isDeleting}
onConfirm={handleDelete}
/>
)}
</>
); );
} }

View File

@ -1,39 +1,129 @@
import React from "react"; import React, { useState } from "react";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui";
import { Pencil, Trash2 } from "lucide-react";
import type { ClusterRoleInfo } from "@/lib/tauriCommands"; import type { ClusterRoleInfo } from "@/lib/tauriCommands";
import { deleteResourceCmd, getResourceYamlCmd } from "@/lib/tauriCommands";
import { ResourceActionMenu } from "./ResourceActionMenu";
import { ConfirmDeleteDialog } from "./ConfirmDeleteDialog";
import { EditResourceModal } from "./EditResourceModal";
interface ClusterRoleListProps { interface ClusterRoleListProps {
clusterRoles: ClusterRoleInfo[]; clusterRoles: ClusterRoleInfo[];
_clusterId: string; clusterId?: string;
_clusterId?: string;
onRefresh?: () => void;
} }
export function ClusterRoleList({ clusterRoles, _clusterId }: ClusterRoleListProps) { type ActiveModal =
| { type: "edit"; cr: ClusterRoleInfo; yaml: string }
| { type: "delete"; cr: ClusterRoleInfo }
| null;
export function ClusterRoleList({
clusterRoles,
clusterId,
_clusterId,
onRefresh,
}: ClusterRoleListProps) {
const cid = clusterId ?? _clusterId ?? "";
const [activeModal, setActiveModal] = useState<ActiveModal>(null);
const [isDeleting, setIsDeleting] = useState(false);
const [actionError, setActionError] = useState<string | null>(null);
const openEdit = async (cr: ClusterRoleInfo) => {
setActionError(null);
try {
const yaml = await getResourceYamlCmd(cid, "clusterroles", "", cr.name);
setActiveModal({ type: "edit", cr, yaml });
} catch (err) {
setActionError(err instanceof Error ? err.message : String(err));
}
};
const handleDelete = async () => {
if (activeModal?.type !== "delete") return;
setIsDeleting(true);
try {
await deleteResourceCmd(cid, "clusterroles", "", activeModal.cr.name);
setActiveModal(null);
onRefresh?.();
} finally {
setIsDeleting(false);
}
};
return ( return (
<>
{actionError && (
<p className="mb-2 text-sm text-destructive">{actionError}</p>
)}
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<Table> <Table>
<TableHeader> <TableHeader>
<TableRow> <TableRow>
<TableHead>Name</TableHead> <TableHead>Name</TableHead>
<TableHead>Age</TableHead> <TableHead>Age</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{clusterRoles.length === 0 ? ( {clusterRoles.length === 0 ? (
<TableRow> <TableRow>
<TableCell colSpan={2} className="text-center text-muted-foreground"> <TableCell colSpan={3} className="text-center text-muted-foreground">
No cluster roles found No cluster roles found
</TableCell> </TableCell>
</TableRow> </TableRow>
) : ( ) : (
clusterRoles.map((clusterRole) => ( clusterRoles.map((cr) => (
<TableRow key={clusterRole.name}> <TableRow key={cr.name}>
<TableCell className="font-medium">{clusterRole.name}</TableCell> <TableCell className="font-medium">{cr.name}</TableCell>
<TableCell className="text-muted-foreground">{clusterRole.age}</TableCell> <TableCell className="text-muted-foreground">{cr.age}</TableCell>
<TableCell className="text-right">
<ResourceActionMenu
actions={[
{
label: "Edit",
icon: Pencil,
onClick: () => openEdit(cr),
},
{
label: "Delete",
icon: Trash2,
variant: "destructive",
onClick: () => setActiveModal({ type: "delete", cr }),
},
]}
/>
</TableCell>
</TableRow> </TableRow>
)) ))
)} )}
</TableBody> </TableBody>
</Table> </Table>
</div> </div>
{activeModal?.type === "edit" && (
<EditResourceModal
isOpen
clusterId={cid}
namespace=""
resourceType="clusterroles"
resourceName={activeModal.cr.name}
initialYaml={activeModal.yaml}
onClose={() => { setActiveModal(null); onRefresh?.(); }}
/>
)}
{activeModal?.type === "delete" && (
<ConfirmDeleteDialog
open
onOpenChange={(o) => { if (!o) setActiveModal(null); }}
resourceType="ClusterRole"
resourceName={activeModal.cr.name}
isLoading={isDeleting}
onConfirm={handleDelete}
/>
)}
</>
); );
} }

View File

@ -1,17 +1,56 @@
import React from "react"; import React, { useState } from "react";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui";
import { Button } from "@/components/ui"; import { Pencil, Trash2 } from "lucide-react";
import type { ConfigMapInfo } from "@/lib/tauriCommands"; import type { ConfigMapInfo } from "@/lib/tauriCommands";
import { deleteResourceCmd, getResourceYamlCmd } from "@/lib/tauriCommands";
import { ResourceActionMenu } from "./ResourceActionMenu";
import { ConfirmDeleteDialog } from "./ConfirmDeleteDialog";
import { EditResourceModal } from "./EditResourceModal";
interface ConfigMapListProps { interface ConfigMapListProps {
configmaps: ConfigMapInfo[]; configmaps: ConfigMapInfo[];
clusterId: string; clusterId: string;
namespace: string; namespace: string;
onRefresh?: () => void;
} }
export function ConfigMapList({ configmaps }: ConfigMapListProps) { type ActiveModal =
| { type: "edit"; cm: ConfigMapInfo; yaml: string }
| { type: "delete"; cm: ConfigMapInfo }
| null;
export function ConfigMapList({ configmaps, clusterId, namespace, onRefresh }: ConfigMapListProps) {
const [activeModal, setActiveModal] = useState<ActiveModal>(null);
const [isDeleting, setIsDeleting] = useState(false);
const [actionError, setActionError] = useState<string | null>(null);
const openEdit = async (cm: ConfigMapInfo) => {
setActionError(null);
try {
const yaml = await getResourceYamlCmd(clusterId, "configmaps", namespace, cm.name);
setActiveModal({ type: "edit", cm, yaml });
} catch (err) {
setActionError(err instanceof Error ? err.message : String(err));
}
};
const handleDelete = async () => {
if (activeModal?.type !== "delete") return;
setIsDeleting(true);
try {
await deleteResourceCmd(clusterId, "configmaps", namespace, activeModal.cm.name);
setActiveModal(null);
onRefresh?.();
} finally {
setIsDeleting(false);
}
};
return ( return (
<>
{actionError && (
<p className="mb-2 text-sm text-destructive">{actionError}</p>
)}
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<Table> <Table>
<TableHeader> <TableHeader>
@ -31,21 +70,28 @@ export function ConfigMapList({ configmaps }: ConfigMapListProps) {
</TableCell> </TableCell>
</TableRow> </TableRow>
) : ( ) : (
configmaps.map((configmap) => ( configmaps.map((cm) => (
<TableRow key={configmap.name}> <TableRow key={cm.name}>
<TableCell className="font-medium">{configmap.name}</TableCell> <TableCell className="font-medium">{cm.name}</TableCell>
<TableCell className="text-sm text-muted-foreground">{configmap.namespace}</TableCell> <TableCell className="text-sm text-muted-foreground">{cm.namespace}</TableCell>
<TableCell className="text-sm">{configmap.data_keys}</TableCell> <TableCell className="text-sm">{cm.data_keys}</TableCell>
<TableCell className="text-sm text-muted-foreground">{configmap.age}</TableCell> <TableCell className="text-sm text-muted-foreground">{cm.age}</TableCell>
<TableCell className="text-right"> <TableCell className="text-right">
<Button <ResourceActionMenu
variant="ghost" actions={[
size="sm" {
onClick={() => {}} label: "Edit",
className="text-primary hover:text-primary hover:bg-primary/10" icon: Pencil,
> onClick: () => openEdit(cm),
View/Edit },
</Button> {
label: "Delete",
icon: Trash2,
variant: "destructive",
onClick: () => setActiveModal({ type: "delete", cm }),
},
]}
/>
</TableCell> </TableCell>
</TableRow> </TableRow>
)) ))
@ -53,5 +99,29 @@ export function ConfigMapList({ configmaps }: ConfigMapListProps) {
</TableBody> </TableBody>
</Table> </Table>
</div> </div>
{activeModal?.type === "edit" && (
<EditResourceModal
isOpen
clusterId={clusterId}
namespace={namespace}
resourceType="configmaps"
resourceName={activeModal.cm.name}
initialYaml={activeModal.yaml}
onClose={() => { setActiveModal(null); onRefresh?.(); }}
/>
)}
{activeModal?.type === "delete" && (
<ConfirmDeleteDialog
open
onOpenChange={(o) => { if (!o) setActiveModal(null); }}
resourceType="ConfigMap"
resourceName={activeModal.cm.name}
isLoading={isDeleting}
onConfirm={handleDelete}
/>
)}
</>
); );
} }

View 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>
);
}

View 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>
);
}

View File

@ -1,15 +1,108 @@
import React from "react"; import React, { useState } from "react";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui";
import { PauseCircle, PlayCircle, Play, Pencil, Trash2 } from "lucide-react";
import type { CronJobInfo } from "@/lib/tauriCommands"; import type { CronJobInfo } from "@/lib/tauriCommands";
import {
suspendCronjobCmd,
resumeCronjobCmd,
triggerCronjobCmd,
deleteResourceCmd,
getResourceYamlCmd,
} from "@/lib/tauriCommands";
import { ResourceActionMenu } from "./ResourceActionMenu";
import { ConfirmDeleteDialog } from "./ConfirmDeleteDialog";
import { EditResourceModal } from "./EditResourceModal";
interface CronJobListProps { interface CronJobListProps {
cronJobs: CronJobInfo[]; cronJobs: CronJobInfo[];
_clusterId: string; clusterId?: string;
_namespace: string; _clusterId?: string;
namespace?: string;
_namespace?: string;
onRefresh?: () => void;
} }
export function CronJobList({ cronJobs, _clusterId, _namespace }: CronJobListProps) { type ActiveModal =
| { type: "edit"; cj: CronJobInfo; yaml: string }
| { type: "delete"; cj: CronJobInfo }
| null;
export function CronJobList({
cronJobs,
clusterId,
_clusterId,
namespace,
_namespace,
onRefresh,
}: CronJobListProps) {
const cid = clusterId ?? _clusterId ?? "";
const ns = namespace ?? _namespace ?? "";
const [activeModal, setActiveModal] = useState<ActiveModal>(null);
const [isDeleting, setIsDeleting] = useState(false);
const [actionError, setActionError] = useState<string | null>(null);
const openEdit = async (cj: CronJobInfo) => {
setActionError(null);
try {
const yaml = await getResourceYamlCmd(cid, "cronjobs", ns, cj.name);
setActiveModal({ type: "edit", cj, yaml });
} catch (err) {
setActionError(err instanceof Error ? err.message : String(err));
}
};
const handleSuspend = async (cj: CronJobInfo) => {
setActionError(null);
try {
await suspendCronjobCmd(cid, ns, cj.name);
onRefresh?.();
} catch (err) {
setActionError(err instanceof Error ? err.message : String(err));
}
};
const handleResume = async (cj: CronJobInfo) => {
setActionError(null);
try {
await resumeCronjobCmd(cid, ns, cj.name);
onRefresh?.();
} catch (err) {
setActionError(err instanceof Error ? err.message : String(err));
}
};
const handleTrigger = async (cj: CronJobInfo) => {
setActionError(null);
try {
await triggerCronjobCmd(cid, ns, cj.name);
onRefresh?.();
} catch (err) {
setActionError(err instanceof Error ? err.message : String(err));
}
};
const handleDelete = async () => {
if (activeModal?.type !== "delete") return;
setIsDeleting(true);
try {
await deleteResourceCmd(cid, "cronjobs", ns, activeModal.cj.name);
setActiveModal(null);
onRefresh?.();
} finally {
setIsDeleting(false);
}
};
const isSuspended = (cj: CronJobInfo) => {
const labels = cj.labels ?? {};
return labels["cronjob.kubernetes.io/suspended"] === "true";
};
return ( return (
<>
{actionError && (
<p className="mb-2 text-sm text-destructive">{actionError}</p>
)}
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<Table> <Table>
<TableHeader> <TableHeader>
@ -21,34 +114,93 @@ export function CronJobList({ cronJobs, _clusterId, _namespace }: CronJobListPro
<TableHead>Last Schedule</TableHead> <TableHead>Last Schedule</TableHead>
<TableHead>Age</TableHead> <TableHead>Age</TableHead>
<TableHead>Labels</TableHead> <TableHead>Labels</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{cronJobs.length === 0 ? ( {cronJobs.length === 0 ? (
<TableRow> <TableRow>
<TableCell colSpan={7} className="text-center text-muted-foreground"> <TableCell colSpan={8} className="text-center text-muted-foreground">
No cron jobs found No cron jobs found
</TableCell> </TableCell>
</TableRow> </TableRow>
) : ( ) : (
cronJobs.map((cronJob) => ( cronJobs.map((cj) => (
<TableRow key={`${cronJob.name}-${cronJob.namespace}`}> <TableRow key={`${cj.name}-${cj.namespace}`}>
<TableCell className="font-medium">{cronJob.name}</TableCell> <TableCell className="font-medium">{cj.name}</TableCell>
<TableCell>{cronJob.namespace}</TableCell> <TableCell>{cj.namespace}</TableCell>
<TableCell>{cronJob.schedule}</TableCell> <TableCell>{cj.schedule}</TableCell>
<TableCell>{cronJob.active}</TableCell> <TableCell>{cj.active}</TableCell>
<TableCell>{cronJob.last_schedule}</TableCell> <TableCell>{cj.last_schedule}</TableCell>
<TableCell className="text-muted-foreground">{cronJob.age}</TableCell> <TableCell className="text-muted-foreground">{cj.age}</TableCell>
<TableCell> <TableCell>
{Object.entries(cronJob.labels) {Object.entries(cj.labels)
.map(([k, v]) => `${k}=${v}`) .map(([k, v]) => `${k}=${v}`)
.join(", ")} .join(", ")}
</TableCell> </TableCell>
<TableCell className="text-right">
<ResourceActionMenu
actions={[
{
label: "Suspend",
icon: PauseCircle,
hidden: isSuspended(cj),
onClick: () => handleSuspend(cj),
},
{
label: "Resume",
icon: PlayCircle,
hidden: !isSuspended(cj),
onClick: () => handleResume(cj),
},
{
label: "Trigger",
icon: Play,
onClick: () => handleTrigger(cj),
},
{
label: "Edit",
icon: Pencil,
onClick: () => openEdit(cj),
},
{
label: "Delete",
icon: Trash2,
variant: "destructive",
onClick: () => setActiveModal({ type: "delete", cj }),
},
]}
/>
</TableCell>
</TableRow> </TableRow>
)) ))
)} )}
</TableBody> </TableBody>
</Table> </Table>
</div> </div>
{activeModal?.type === "edit" && (
<EditResourceModal
isOpen
clusterId={cid}
namespace={ns}
resourceType="cronjobs"
resourceName={activeModal.cj.name}
initialYaml={activeModal.yaml}
onClose={() => { setActiveModal(null); onRefresh?.(); }}
/>
)}
{activeModal?.type === "delete" && (
<ConfirmDeleteDialog
open
onOpenChange={(o) => { if (!o) setActiveModal(null); }}
resourceType="CronJob"
resourceName={activeModal.cj.name}
isLoading={isDeleting}
onConfirm={handleDelete}
/>
)}
</>
); );
} }

View 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>
);
}

View File

@ -1,15 +1,75 @@
import React from "react"; import React, { useState } from "react";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui";
import { RotateCcw, Pencil, Trash2 } from "lucide-react";
import type { DaemonSetInfo } from "@/lib/tauriCommands"; import type { DaemonSetInfo } from "@/lib/tauriCommands";
import {
restartDaemonsetCmd,
deleteResourceCmd,
getResourceYamlCmd,
} from "@/lib/tauriCommands";
import { ResourceActionMenu } from "./ResourceActionMenu";
import { ConfirmDeleteDialog } from "./ConfirmDeleteDialog";
import { EditResourceModal } from "./EditResourceModal";
interface DaemonSetListProps { interface DaemonSetListProps {
daemonsets: DaemonSetInfo[]; daemonsets: DaemonSetInfo[];
clusterId: string; clusterId: string;
namespace: string; namespace: string;
onRefresh?: () => void;
} }
export function DaemonSetList({ daemonsets, clusterId: _clusterId, namespace: _namespace }: DaemonSetListProps) { type ActiveModal =
| { type: "restart"; ds: DaemonSetInfo }
| { type: "edit"; ds: DaemonSetInfo; yaml: string }
| { type: "delete"; ds: DaemonSetInfo }
| null;
export function DaemonSetList({ daemonsets, clusterId, namespace, onRefresh }: DaemonSetListProps) {
const [activeModal, setActiveModal] = useState<ActiveModal>(null);
const [isActing, setIsActing] = useState(false);
const [actionError, setActionError] = useState<string | null>(null);
const openEdit = async (ds: DaemonSetInfo) => {
setActionError(null);
try {
const yaml = await getResourceYamlCmd(clusterId, "daemonsets", namespace, ds.name);
setActiveModal({ type: "edit", ds, yaml });
} catch (err) {
setActionError(err instanceof Error ? err.message : String(err));
}
};
const handleRestart = async () => {
if (activeModal?.type !== "restart") return;
setIsActing(true);
try {
await restartDaemonsetCmd(clusterId, namespace, activeModal.ds.name);
setActiveModal(null);
onRefresh?.();
} catch (err) {
setActionError(err instanceof Error ? err.message : String(err));
} finally {
setIsActing(false);
}
};
const handleDelete = async () => {
if (activeModal?.type !== "delete") return;
setIsActing(true);
try {
await deleteResourceCmd(clusterId, "daemonsets", namespace, activeModal.ds.name);
setActiveModal(null);
onRefresh?.();
} finally {
setIsActing(false);
}
};
return ( return (
<>
{actionError && (
<p className="mb-2 text-sm text-destructive">{actionError}</p>
)}
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<Table> <Table>
<TableHeader> <TableHeader>
@ -21,12 +81,13 @@ export function DaemonSetList({ daemonsets, clusterId: _clusterId, namespace: _n
<TableHead>Up-to-date</TableHead> <TableHead>Up-to-date</TableHead>
<TableHead>Available</TableHead> <TableHead>Available</TableHead>
<TableHead>Age</TableHead> <TableHead>Age</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{daemonsets.length === 0 ? ( {daemonsets.length === 0 ? (
<TableRow> <TableRow>
<TableCell colSpan={7} className="text-center text-muted-foreground"> <TableCell colSpan={8} className="text-center text-muted-foreground">
No daemonsets found No daemonsets found
</TableCell> </TableCell>
</TableRow> </TableRow>
@ -40,11 +101,69 @@ export function DaemonSetList({ daemonsets, clusterId: _clusterId, namespace: _n
<TableCell>{ds.up_to_date}</TableCell> <TableCell>{ds.up_to_date}</TableCell>
<TableCell>{ds.available}</TableCell> <TableCell>{ds.available}</TableCell>
<TableCell className="text-muted-foreground">{ds.age}</TableCell> <TableCell className="text-muted-foreground">{ds.age}</TableCell>
<TableCell className="text-right">
<ResourceActionMenu
actions={[
{
label: "Restart",
icon: RotateCcw,
onClick: () => setActiveModal({ type: "restart", ds }),
},
{
label: "Edit",
icon: Pencil,
onClick: () => openEdit(ds),
},
{
label: "Delete",
icon: Trash2,
variant: "destructive",
onClick: () => setActiveModal({ type: "delete", ds }),
},
]}
/>
</TableCell>
</TableRow> </TableRow>
)) ))
)} )}
</TableBody> </TableBody>
</Table> </Table>
</div> </div>
{activeModal?.type === "restart" && (
<ConfirmDeleteDialog
open
onOpenChange={(o) => { if (!o) setActiveModal(null); }}
resourceType="DaemonSet"
resourceName={activeModal.ds.name}
isLoading={isActing}
onConfirm={handleRestart}
variant="delete"
/>
)}
{activeModal?.type === "edit" && (
<EditResourceModal
isOpen
clusterId={clusterId}
namespace={namespace}
resourceType="daemonsets"
resourceName={activeModal.ds.name}
initialYaml={activeModal.yaml}
onClose={() => { setActiveModal(null); onRefresh?.(); }}
/>
)}
{activeModal?.type === "delete" && (
<ConfirmDeleteDialog
open
onOpenChange={(o) => { if (!o) setActiveModal(null); }}
resourceType="DaemonSet"
resourceName={activeModal.ds.name}
isLoading={isActing}
onConfirm={handleDelete}
/>
)}
</>
); );
} }

View File

@ -1,89 +1,94 @@
import React, { useState } from "react"; import React, { useState } from "react";
import { invoke } from "@tauri-apps/api/core";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui";
import { Button } from "@/components/ui"; import { Scale, RotateCcw, Undo2, Pencil, Trash2 } from "lucide-react";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui";
import { Input } from "@/components/ui";
import { Label } from "@/components/ui";
import { Alert, AlertDescription } from "@/components/ui";
import { AlertCircle, RotateCcw, Scale } from "lucide-react";
import type { DeploymentInfo } from "@/lib/tauriCommands"; import type { DeploymentInfo } from "@/lib/tauriCommands";
import {
scaleDeploymentCmd,
restartDeploymentCmd,
rollbackDeploymentCmd,
deleteResourceCmd,
getResourceYamlCmd,
} from "@/lib/tauriCommands";
import { ResourceActionMenu } from "./ResourceActionMenu";
import { ConfirmDeleteDialog } from "./ConfirmDeleteDialog";
import { ScaleModal } from "./ScaleModal";
import { EditResourceModal } from "./EditResourceModal";
interface DeploymentListProps { interface DeploymentListProps {
deployments: DeploymentInfo[]; deployments: DeploymentInfo[];
clusterId: string; clusterId: string;
namespace: string; namespace: string;
onRefresh?: () => void;
} }
export function DeploymentList({ deployments, clusterId, namespace }: DeploymentListProps) { type ActiveModal =
const [scalingDeployment, setScalingDeployment] = useState<DeploymentInfo | null>(null); | { type: "scale"; deployment: DeploymentInfo }
const [replicas, setReplicas] = useState<string>(""); | { type: "restart"; deployment: DeploymentInfo }
const [isScaling, setIsScaling] = useState(false); | { type: "rollback"; deployment: DeploymentInfo }
const [scaleError, setScaleError] = useState<string | null>(null); | { type: "edit"; deployment: DeploymentInfo; yaml: string }
| { type: "delete"; deployment: DeploymentInfo }
| null;
const [restartingDeployment, setRestartingDeployment] = useState<DeploymentInfo | null>(null); export function DeploymentList({ deployments, clusterId, namespace, onRefresh }: DeploymentListProps) {
const [isRestarting, setIsRestarting] = useState(false); const [activeModal, setActiveModal] = useState<ActiveModal>(null);
const [restartError, setRestartError] = useState<string | null>(null); const [isActing, setIsActing] = useState(false);
const [actionError, setActionError] = useState<string | null>(null);
const handleScaleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setReplicas(e.target.value);
setScaleError(null);
};
const handleScaleSubmit = async () => {
if (!scalingDeployment) return;
const newReplicas = parseInt(replicas, 10);
if (isNaN(newReplicas) || newReplicas < 0) {
setScaleError("Invalid replica count");
return;
}
setIsScaling(true);
setScaleError(null);
const openEdit = async (deployment: DeploymentInfo) => {
setActionError(null);
try { try {
await invoke<void>("scale_deployment", { const yaml = await getResourceYamlCmd(clusterId, "deployments", namespace, deployment.name);
clusterId, setActiveModal({ type: "edit", deployment, yaml });
namespace,
deploymentName: scalingDeployment.name,
replicas: newReplicas,
});
setScalingDeployment(null);
setReplicas("");
} catch (err) { } catch (err) {
console.error("Failed to scale deployment:", err); setActionError(err instanceof Error ? err.message : String(err));
setScaleError(err instanceof Error ? err.message : "Failed to scale deployment");
} finally {
setIsScaling(false);
} }
}; };
const handleRestartSubmit = async () => { const handleRestart = async () => {
if (!restartingDeployment) return; if (activeModal?.type !== "restart") return;
setIsActing(true);
setIsRestarting(true);
setRestartError(null);
try { try {
await invoke<void>("restart_deployment", { await restartDeploymentCmd(clusterId, namespace, activeModal.deployment.name);
clusterId, setActiveModal(null);
namespace, onRefresh?.();
deploymentName: restartingDeployment.name,
});
setRestartingDeployment(null);
} catch (err) { } catch (err) {
console.error("Failed to restart deployment:", err); setActionError(err instanceof Error ? err.message : String(err));
setRestartError(err instanceof Error ? err.message : "Failed to restart deployment");
} finally { } finally {
setIsRestarting(false); setIsActing(false);
}
};
const handleRollback = async () => {
if (activeModal?.type !== "rollback") return;
setIsActing(true);
try {
await rollbackDeploymentCmd(clusterId, namespace, activeModal.deployment.name);
setActiveModal(null);
onRefresh?.();
} catch (err) {
setActionError(err instanceof Error ? err.message : String(err));
} finally {
setIsActing(false);
}
};
const handleDelete = async () => {
if (activeModal?.type !== "delete") return;
setIsActing(true);
try {
await deleteResourceCmd(clusterId, "deployments", namespace, activeModal.deployment.name);
setActiveModal(null);
onRefresh?.();
} finally {
setIsActing(false);
} }
}; };
return ( return (
<> <>
{actionError && (
<p className="mb-2 text-sm text-destructive">{actionError}</p>
)}
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<Table> <Table>
<TableHeader> <TableHeader>
@ -114,24 +119,36 @@ export function DeploymentList({ deployments, clusterId, namespace }: Deployment
<TableCell>{deployment.replicas}</TableCell> <TableCell>{deployment.replicas}</TableCell>
<TableCell className="text-muted-foreground">{deployment.age}</TableCell> <TableCell className="text-muted-foreground">{deployment.age}</TableCell>
<TableCell className="text-right"> <TableCell className="text-right">
<div className="flex items-center justify-end gap-2"> <ResourceActionMenu
<Button actions={[
variant="outline" {
size="sm" label: "Scale",
onClick={() => setScalingDeployment(deployment)} icon: Scale,
> onClick: () => setActiveModal({ type: "scale", deployment }),
<Scale className="w-4 h-4" /> },
Scale {
</Button> label: "Restart",
<Button icon: RotateCcw,
variant="outline" onClick: () => setActiveModal({ type: "restart", deployment }),
size="sm" },
onClick={() => setRestartingDeployment(deployment)} {
> label: "Rollback",
<RotateCcw className="w-4 h-4" /> icon: Undo2,
Restart onClick: () => setActiveModal({ type: "rollback", deployment }),
</Button> },
</div> {
label: "Edit",
icon: Pencil,
onClick: () => openEdit(deployment),
},
{
label: "Delete",
icon: Trash2,
variant: "destructive",
onClick: () => setActiveModal({ type: "delete", deployment }),
},
]}
/>
</TableCell> </TableCell>
</TableRow> </TableRow>
)) ))
@ -140,69 +157,68 @@ export function DeploymentList({ deployments, clusterId, namespace }: Deployment
</Table> </Table>
</div> </div>
{/* Scale Dialog */} {activeModal?.type === "scale" && (
<Dialog open={!!scalingDeployment} onOpenChange={() => setScalingDeployment(null)}> <ScaleModal
<DialogContent> open
<DialogHeader> onOpenChange={(o) => { if (!o) setActiveModal(null); }}
<DialogTitle>Scale Deployment</DialogTitle> resourceType="Deployment"
</DialogHeader> resourceName={activeModal.deployment.name}
<div className="space-y-4"> currentReplicas={activeModal.deployment.replicas}
<div> onScale={(replicas) =>
<Label htmlFor="replicas">Replica Count</Label> scaleDeploymentCmd(clusterId, namespace, activeModal.deployment.name, replicas).then(() => {
<Input setActiveModal(null);
id="replicas" onRefresh?.();
type="number" })
value={replicas} }
onChange={handleScaleChange}
placeholder="Enter replica count"
min="0"
/> />
{scaleError && (
<Alert variant="destructive" className="mt-2">
<AlertCircle className="h-4 w-4" />
<AlertDescription>{scaleError}</AlertDescription>
</Alert>
)} )}
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setScalingDeployment(null)}>
Cancel
</Button>
<Button onClick={handleScaleSubmit} disabled={isScaling}>
{isScaling ? "Scaling..." : "Scale"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Restart Dialog */} {activeModal?.type === "restart" && (
<Dialog open={!!restartingDeployment} onOpenChange={() => setRestartingDeployment(null)}> <ConfirmDeleteDialog
<DialogContent> open
<DialogHeader> onOpenChange={(o) => { if (!o) setActiveModal(null); }}
<DialogTitle>Restart Deployment</DialogTitle> resourceType="Deployment"
</DialogHeader> resourceName={activeModal.deployment.name}
<div className="space-y-4"> isLoading={isActing}
<p className="text-sm text-muted-foreground"> onConfirm={handleRestart}
This will trigger a rolling restart of the deployment. variant="delete"
</p> />
{restartError && ( )}
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" /> {activeModal?.type === "rollback" && (
<AlertDescription>{restartError}</AlertDescription> <ConfirmDeleteDialog
</Alert> open
onOpenChange={(o) => { if (!o) setActiveModal(null); }}
resourceType="Deployment"
resourceName={activeModal.deployment.name}
isLoading={isActing}
onConfirm={handleRollback}
variant="delete"
/>
)}
{activeModal?.type === "edit" && (
<EditResourceModal
isOpen
clusterId={clusterId}
namespace={namespace}
resourceType="deployments"
resourceName={activeModal.deployment.name}
initialYaml={activeModal.yaml}
onClose={() => { setActiveModal(null); onRefresh?.(); }}
/>
)}
{activeModal?.type === "delete" && (
<ConfirmDeleteDialog
open
onOpenChange={(o) => { if (!o) setActiveModal(null); }}
resourceType="Deployment"
resourceName={activeModal.deployment.name}
isLoading={isActing}
onConfirm={handleDelete}
/>
)} )}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setRestartingDeployment(null)}>
Cancel
</Button>
<Button onClick={handleRestartSubmit} disabled={isRestarting}>
{isRestarting ? "Restarting..." : "Restart"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</> </>
); );
} }

View 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>
);
}

View 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>
);
}

View File

@ -1,15 +1,67 @@
import React from "react"; import React, { useState } from "react";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui";
import { Pencil, Trash2 } from "lucide-react";
import type { HorizontalPodAutoscalerInfo } from "@/lib/tauriCommands"; import type { HorizontalPodAutoscalerInfo } from "@/lib/tauriCommands";
import { deleteResourceCmd, getResourceYamlCmd } from "@/lib/tauriCommands";
import { ResourceActionMenu } from "./ResourceActionMenu";
import { ConfirmDeleteDialog } from "./ConfirmDeleteDialog";
import { EditResourceModal } from "./EditResourceModal";
interface HPAListProps { interface HPAListProps {
hpas: HorizontalPodAutoscalerInfo[]; hpas: HorizontalPodAutoscalerInfo[];
_clusterId: string; clusterId?: string;
_namespace: string; _clusterId?: string;
namespace?: string;
_namespace?: string;
onRefresh?: () => void;
} }
export function HPAList({ hpas, _clusterId, _namespace }: HPAListProps) { type ActiveModal =
| { type: "edit"; hpa: HorizontalPodAutoscalerInfo; yaml: string }
| { type: "delete"; hpa: HorizontalPodAutoscalerInfo }
| null;
export function HPAList({
hpas,
clusterId,
_clusterId,
namespace,
_namespace,
onRefresh,
}: HPAListProps) {
const cid = clusterId ?? _clusterId ?? "";
const ns = namespace ?? _namespace ?? "";
const [activeModal, setActiveModal] = useState<ActiveModal>(null);
const [isDeleting, setIsDeleting] = useState(false);
const [actionError, setActionError] = useState<string | null>(null);
const openEdit = async (hpa: HorizontalPodAutoscalerInfo) => {
setActionError(null);
try {
const yaml = await getResourceYamlCmd(cid, "horizontalpodautoscalers", ns, hpa.name);
setActiveModal({ type: "edit", hpa, yaml });
} catch (err) {
setActionError(err instanceof Error ? err.message : String(err));
}
};
const handleDelete = async () => {
if (activeModal?.type !== "delete") return;
setIsDeleting(true);
try {
await deleteResourceCmd(cid, "horizontalpodautoscalers", ns, activeModal.hpa.name);
setActiveModal(null);
onRefresh?.();
} finally {
setIsDeleting(false);
}
};
return ( return (
<>
{actionError && (
<p className="mb-2 text-sm text-destructive">{actionError}</p>
)}
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<Table> <Table>
<TableHeader> <TableHeader>
@ -21,12 +73,13 @@ export function HPAList({ hpas, _clusterId, _namespace }: HPAListProps) {
<TableHead>Current Replicas</TableHead> <TableHead>Current Replicas</TableHead>
<TableHead>Desired Replicas</TableHead> <TableHead>Desired Replicas</TableHead>
<TableHead>Age</TableHead> <TableHead>Age</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{hpas.length === 0 ? ( {hpas.length === 0 ? (
<TableRow> <TableRow>
<TableCell colSpan={7} className="text-center text-muted-foreground"> <TableCell colSpan={8} className="text-center text-muted-foreground">
No HPAs found No HPAs found
</TableCell> </TableCell>
</TableRow> </TableRow>
@ -40,11 +93,52 @@ export function HPAList({ hpas, _clusterId, _namespace }: HPAListProps) {
<TableCell>{hpa.current_replicas}</TableCell> <TableCell>{hpa.current_replicas}</TableCell>
<TableCell>{hpa.desired_replicas}</TableCell> <TableCell>{hpa.desired_replicas}</TableCell>
<TableCell className="text-muted-foreground">{hpa.age}</TableCell> <TableCell className="text-muted-foreground">{hpa.age}</TableCell>
<TableCell className="text-right">
<ResourceActionMenu
actions={[
{
label: "Edit",
icon: Pencil,
onClick: () => openEdit(hpa),
},
{
label: "Delete",
icon: Trash2,
variant: "destructive",
onClick: () => setActiveModal({ type: "delete", hpa }),
},
]}
/>
</TableCell>
</TableRow> </TableRow>
)) ))
)} )}
</TableBody> </TableBody>
</Table> </Table>
</div> </div>
{activeModal?.type === "edit" && (
<EditResourceModal
isOpen
clusterId={cid}
namespace={ns}
resourceType="horizontalpodautoscalers"
resourceName={activeModal.hpa.name}
initialYaml={activeModal.yaml}
onClose={() => { setActiveModal(null); onRefresh?.(); }}
/>
)}
{activeModal?.type === "delete" && (
<ConfirmDeleteDialog
open
onOpenChange={(o) => { if (!o) setActiveModal(null); }}
resourceType="HPA"
resourceName={activeModal.hpa.name}
isLoading={isDeleting}
onConfirm={handleDelete}
/>
)}
</>
); );
} }

View 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>
);
}

View 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>
);
}

View 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>
);
}

View File

@ -1,15 +1,67 @@
import React from "react"; import React, { useState } from "react";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui";
import { Pencil, Trash2 } from "lucide-react";
import type { IngressInfo } from "@/lib/tauriCommands"; import type { IngressInfo } from "@/lib/tauriCommands";
import { deleteResourceCmd, getResourceYamlCmd } from "@/lib/tauriCommands";
import { ResourceActionMenu } from "./ResourceActionMenu";
import { ConfirmDeleteDialog } from "./ConfirmDeleteDialog";
import { EditResourceModal } from "./EditResourceModal";
interface IngressListProps { interface IngressListProps {
ingresses: IngressInfo[]; ingresses: IngressInfo[];
_clusterId: string; clusterId?: string;
_namespace: string; _clusterId?: string;
namespace?: string;
_namespace?: string;
onRefresh?: () => void;
} }
export function IngressList({ ingresses, _clusterId, _namespace }: IngressListProps) { type ActiveModal =
| { type: "edit"; ingress: IngressInfo; yaml: string }
| { type: "delete"; ingress: IngressInfo }
| null;
export function IngressList({
ingresses,
clusterId,
_clusterId,
namespace,
_namespace,
onRefresh,
}: IngressListProps) {
const cid = clusterId ?? _clusterId ?? "";
const ns = namespace ?? _namespace ?? "";
const [activeModal, setActiveModal] = useState<ActiveModal>(null);
const [isDeleting, setIsDeleting] = useState(false);
const [actionError, setActionError] = useState<string | null>(null);
const openEdit = async (ingress: IngressInfo) => {
setActionError(null);
try {
const yaml = await getResourceYamlCmd(cid, "ingresses", ns, ingress.name);
setActiveModal({ type: "edit", ingress, yaml });
} catch (err) {
setActionError(err instanceof Error ? err.message : String(err));
}
};
const handleDelete = async () => {
if (activeModal?.type !== "delete") return;
setIsDeleting(true);
try {
await deleteResourceCmd(cid, "ingresses", ns, activeModal.ingress.name);
setActiveModal(null);
onRefresh?.();
} finally {
setIsDeleting(false);
}
};
return ( return (
<>
{actionError && (
<p className="mb-2 text-sm text-destructive">{actionError}</p>
)}
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<Table> <Table>
<TableHeader> <TableHeader>
@ -20,12 +72,13 @@ export function IngressList({ ingresses, _clusterId, _namespace }: IngressListPr
<TableHead>Host</TableHead> <TableHead>Host</TableHead>
<TableHead>Addresses</TableHead> <TableHead>Addresses</TableHead>
<TableHead>Age</TableHead> <TableHead>Age</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{ingresses.length === 0 ? ( {ingresses.length === 0 ? (
<TableRow> <TableRow>
<TableCell colSpan={6} className="text-center text-muted-foreground"> <TableCell colSpan={7} className="text-center text-muted-foreground">
No ingresses found No ingresses found
</TableCell> </TableCell>
</TableRow> </TableRow>
@ -38,11 +91,52 @@ export function IngressList({ ingresses, _clusterId, _namespace }: IngressListPr
<TableCell>{ingress.host}</TableCell> <TableCell>{ingress.host}</TableCell>
<TableCell>{ingress.addresses.join(", ")}</TableCell> <TableCell>{ingress.addresses.join(", ")}</TableCell>
<TableCell className="text-muted-foreground">{ingress.age}</TableCell> <TableCell className="text-muted-foreground">{ingress.age}</TableCell>
<TableCell className="text-right">
<ResourceActionMenu
actions={[
{
label: "Edit",
icon: Pencil,
onClick: () => openEdit(ingress),
},
{
label: "Delete",
icon: Trash2,
variant: "destructive",
onClick: () => setActiveModal({ type: "delete", ingress }),
},
]}
/>
</TableCell>
</TableRow> </TableRow>
)) ))
)} )}
</TableBody> </TableBody>
</Table> </Table>
</div> </div>
{activeModal?.type === "edit" && (
<EditResourceModal
isOpen
clusterId={cid}
namespace={ns}
resourceType="ingresses"
resourceName={activeModal.ingress.name}
initialYaml={activeModal.yaml}
onClose={() => { setActiveModal(null); onRefresh?.(); }}
/>
)}
{activeModal?.type === "delete" && (
<ConfirmDeleteDialog
open
onOpenChange={(o) => { if (!o) setActiveModal(null); }}
resourceType="Ingress"
resourceName={activeModal.ingress.name}
isLoading={isDeleting}
onConfirm={handleDelete}
/>
)}
</>
); );
} }

View File

@ -1,15 +1,67 @@
import React from "react"; import React, { useState } from "react";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui";
import { Pencil, Trash2 } from "lucide-react";
import type { JobInfo } from "@/lib/tauriCommands"; import type { JobInfo } from "@/lib/tauriCommands";
import { deleteResourceCmd, getResourceYamlCmd } from "@/lib/tauriCommands";
import { ResourceActionMenu } from "./ResourceActionMenu";
import { ConfirmDeleteDialog } from "./ConfirmDeleteDialog";
import { EditResourceModal } from "./EditResourceModal";
interface JobListProps { interface JobListProps {
jobs: JobInfo[]; jobs: JobInfo[];
_clusterId: string; clusterId?: string;
_namespace: string; _clusterId?: string;
namespace?: string;
_namespace?: string;
onRefresh?: () => void;
} }
export function JobList({ jobs, _clusterId, _namespace }: JobListProps) { type ActiveModal =
| { type: "edit"; job: JobInfo; yaml: string }
| { type: "delete"; job: JobInfo }
| null;
export function JobList({
jobs,
clusterId,
_clusterId,
namespace,
_namespace,
onRefresh,
}: JobListProps) {
const cid = clusterId ?? _clusterId ?? "";
const ns = namespace ?? _namespace ?? "";
const [activeModal, setActiveModal] = useState<ActiveModal>(null);
const [isDeleting, setIsDeleting] = useState(false);
const [actionError, setActionError] = useState<string | null>(null);
const openEdit = async (job: JobInfo) => {
setActionError(null);
try {
const yaml = await getResourceYamlCmd(cid, "jobs", ns, job.name);
setActiveModal({ type: "edit", job, yaml });
} catch (err) {
setActionError(err instanceof Error ? err.message : String(err));
}
};
const handleDelete = async () => {
if (activeModal?.type !== "delete") return;
setIsDeleting(true);
try {
await deleteResourceCmd(cid, "jobs", ns, activeModal.job.name);
setActiveModal(null);
onRefresh?.();
} finally {
setIsDeleting(false);
}
};
return ( return (
<>
{actionError && (
<p className="mb-2 text-sm text-destructive">{actionError}</p>
)}
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<Table> <Table>
<TableHeader> <TableHeader>
@ -20,12 +72,13 @@ export function JobList({ jobs, _clusterId, _namespace }: JobListProps) {
<TableHead>Duration</TableHead> <TableHead>Duration</TableHead>
<TableHead>Age</TableHead> <TableHead>Age</TableHead>
<TableHead>Labels</TableHead> <TableHead>Labels</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{jobs.length === 0 ? ( {jobs.length === 0 ? (
<TableRow> <TableRow>
<TableCell colSpan={6} className="text-center text-muted-foreground"> <TableCell colSpan={7} className="text-center text-muted-foreground">
No jobs found No jobs found
</TableCell> </TableCell>
</TableRow> </TableRow>
@ -42,11 +95,52 @@ export function JobList({ jobs, _clusterId, _namespace }: JobListProps) {
.map(([k, v]) => `${k}=${v}`) .map(([k, v]) => `${k}=${v}`)
.join(", ")} .join(", ")}
</TableCell> </TableCell>
<TableCell className="text-right">
<ResourceActionMenu
actions={[
{
label: "Edit",
icon: Pencil,
onClick: () => openEdit(job),
},
{
label: "Delete",
icon: Trash2,
variant: "destructive",
onClick: () => setActiveModal({ type: "delete", job }),
},
]}
/>
</TableCell>
</TableRow> </TableRow>
)) ))
)} )}
</TableBody> </TableBody>
</Table> </Table>
</div> </div>
{activeModal?.type === "edit" && (
<EditResourceModal
isOpen
clusterId={cid}
namespace={ns}
resourceType="jobs"
resourceName={activeModal.job.name}
initialYaml={activeModal.yaml}
onClose={() => { setActiveModal(null); onRefresh?.(); }}
/>
)}
{activeModal?.type === "delete" && (
<ConfirmDeleteDialog
open
onOpenChange={(o) => { if (!o) setActiveModal(null); }}
resourceType="Job"
resourceName={activeModal.job.name}
isLoading={isDeleting}
onConfirm={handleDelete}
/>
)}
</>
); );
} }

View 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>
);
}

View File

@ -1,15 +1,56 @@
import React from "react"; import React, { useState } from "react";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui";
import { Pencil, Trash2 } from "lucide-react";
import type { LimitRangeInfo } from "@/lib/tauriCommands"; import type { LimitRangeInfo } from "@/lib/tauriCommands";
import { deleteResourceCmd, getResourceYamlCmd } from "@/lib/tauriCommands";
import { ResourceActionMenu } from "./ResourceActionMenu";
import { ConfirmDeleteDialog } from "./ConfirmDeleteDialog";
import { EditResourceModal } from "./EditResourceModal";
interface LimitRangeListProps { interface LimitRangeListProps {
limitranges: LimitRangeInfo[]; limitranges: LimitRangeInfo[];
clusterId: string; clusterId: string;
namespace: string; namespace: string;
onRefresh?: () => void;
} }
export function LimitRangeList({ limitranges }: LimitRangeListProps) { type ActiveModal =
| { type: "edit"; lr: LimitRangeInfo; yaml: string }
| { type: "delete"; lr: LimitRangeInfo }
| null;
export function LimitRangeList({ limitranges, clusterId, namespace, onRefresh }: LimitRangeListProps) {
const [activeModal, setActiveModal] = useState<ActiveModal>(null);
const [isDeleting, setIsDeleting] = useState(false);
const [actionError, setActionError] = useState<string | null>(null);
const openEdit = async (lr: LimitRangeInfo) => {
setActionError(null);
try {
const yaml = await getResourceYamlCmd(clusterId, "limitranges", namespace, lr.name);
setActiveModal({ type: "edit", lr, yaml });
} catch (err) {
setActionError(err instanceof Error ? err.message : String(err));
}
};
const handleDelete = async () => {
if (activeModal?.type !== "delete") return;
setIsDeleting(true);
try {
await deleteResourceCmd(clusterId, "limitranges", namespace, activeModal.lr.name);
setActiveModal(null);
onRefresh?.();
} finally {
setIsDeleting(false);
}
};
return ( return (
<>
{actionError && (
<p className="mb-2 text-sm text-destructive">{actionError}</p>
)}
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<Table> <Table>
<TableHeader> <TableHeader>
@ -18,12 +59,13 @@ export function LimitRangeList({ limitranges }: LimitRangeListProps) {
<TableHead>Namespace</TableHead> <TableHead>Namespace</TableHead>
<TableHead>Limits</TableHead> <TableHead>Limits</TableHead>
<TableHead>Age</TableHead> <TableHead>Age</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{limitranges.length === 0 ? ( {limitranges.length === 0 ? (
<TableRow> <TableRow>
<TableCell colSpan={4} className="text-center text-muted-foreground"> <TableCell colSpan={5} className="text-center text-muted-foreground">
No limit ranges found No limit ranges found
</TableCell> </TableCell>
</TableRow> </TableRow>
@ -34,11 +76,52 @@ export function LimitRangeList({ limitranges }: LimitRangeListProps) {
<TableCell className="text-sm text-muted-foreground">{lr.namespace}</TableCell> <TableCell className="text-sm text-muted-foreground">{lr.namespace}</TableCell>
<TableCell className="text-sm">{lr.limit_count}</TableCell> <TableCell className="text-sm">{lr.limit_count}</TableCell>
<TableCell className="text-sm text-muted-foreground">{lr.age}</TableCell> <TableCell className="text-sm text-muted-foreground">{lr.age}</TableCell>
<TableCell className="text-right">
<ResourceActionMenu
actions={[
{
label: "Edit",
icon: Pencil,
onClick: () => openEdit(lr),
},
{
label: "Delete",
icon: Trash2,
variant: "destructive",
onClick: () => setActiveModal({ type: "delete", lr }),
},
]}
/>
</TableCell>
</TableRow> </TableRow>
)) ))
)} )}
</TableBody> </TableBody>
</Table> </Table>
</div> </div>
{activeModal?.type === "edit" && (
<EditResourceModal
isOpen
clusterId={clusterId}
namespace={namespace}
resourceType="limitranges"
resourceName={activeModal.lr.name}
initialYaml={activeModal.yaml}
onClose={() => { setActiveModal(null); onRefresh?.(); }}
/>
)}
{activeModal?.type === "delete" && (
<ConfirmDeleteDialog
open
onOpenChange={(o) => { if (!o) setActiveModal(null); }}
resourceType="LimitRange"
resourceName={activeModal.lr.name}
isLoading={isDeleting}
onConfirm={handleDelete}
/>
)}
</>
); );
} }

View 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)}
</>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View File

@ -1,15 +1,56 @@
import React from "react"; import React, { useState } from "react";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui";
import { Pencil, Trash2 } from "lucide-react";
import type { NetworkPolicyInfo } from "@/lib/tauriCommands"; import type { NetworkPolicyInfo } from "@/lib/tauriCommands";
import { deleteResourceCmd, getResourceYamlCmd } from "@/lib/tauriCommands";
import { ResourceActionMenu } from "./ResourceActionMenu";
import { ConfirmDeleteDialog } from "./ConfirmDeleteDialog";
import { EditResourceModal } from "./EditResourceModal";
interface NetworkPolicyListProps { interface NetworkPolicyListProps {
networkpolicies: NetworkPolicyInfo[]; networkpolicies: NetworkPolicyInfo[];
clusterId: string; clusterId: string;
namespace: string; namespace: string;
onRefresh?: () => void;
} }
export function NetworkPolicyList({ networkpolicies }: NetworkPolicyListProps) { type ActiveModal =
| { type: "edit"; np: NetworkPolicyInfo; yaml: string }
| { type: "delete"; np: NetworkPolicyInfo }
| null;
export function NetworkPolicyList({ networkpolicies, clusterId, namespace, onRefresh }: NetworkPolicyListProps) {
const [activeModal, setActiveModal] = useState<ActiveModal>(null);
const [isDeleting, setIsDeleting] = useState(false);
const [actionError, setActionError] = useState<string | null>(null);
const openEdit = async (np: NetworkPolicyInfo) => {
setActionError(null);
try {
const yaml = await getResourceYamlCmd(clusterId, "networkpolicies", namespace, np.name);
setActiveModal({ type: "edit", np, yaml });
} catch (err) {
setActionError(err instanceof Error ? err.message : String(err));
}
};
const handleDelete = async () => {
if (activeModal?.type !== "delete") return;
setIsDeleting(true);
try {
await deleteResourceCmd(clusterId, "networkpolicies", namespace, activeModal.np.name);
setActiveModal(null);
onRefresh?.();
} finally {
setIsDeleting(false);
}
};
return ( return (
<>
{actionError && (
<p className="mb-2 text-sm text-destructive">{actionError}</p>
)}
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<Table> <Table>
<TableHeader> <TableHeader>
@ -19,12 +60,13 @@ export function NetworkPolicyList({ networkpolicies }: NetworkPolicyListProps) {
<TableHead>Pod Selector</TableHead> <TableHead>Pod Selector</TableHead>
<TableHead>Policy Types</TableHead> <TableHead>Policy Types</TableHead>
<TableHead>Age</TableHead> <TableHead>Age</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{networkpolicies.length === 0 ? ( {networkpolicies.length === 0 ? (
<TableRow> <TableRow>
<TableCell colSpan={5} className="text-center text-muted-foreground"> <TableCell colSpan={6} className="text-center text-muted-foreground">
No network policies found No network policies found
</TableCell> </TableCell>
</TableRow> </TableRow>
@ -36,11 +78,52 @@ export function NetworkPolicyList({ networkpolicies }: NetworkPolicyListProps) {
<TableCell className="text-sm font-mono truncate max-w-48">{np.pod_selector}</TableCell> <TableCell className="text-sm font-mono truncate max-w-48">{np.pod_selector}</TableCell>
<TableCell className="text-sm">{np.policy_types.join(", ") || "—"}</TableCell> <TableCell className="text-sm">{np.policy_types.join(", ") || "—"}</TableCell>
<TableCell className="text-sm text-muted-foreground">{np.age}</TableCell> <TableCell className="text-sm text-muted-foreground">{np.age}</TableCell>
<TableCell className="text-right">
<ResourceActionMenu
actions={[
{
label: "Edit",
icon: Pencil,
onClick: () => openEdit(np),
},
{
label: "Delete",
icon: Trash2,
variant: "destructive",
onClick: () => setActiveModal({ type: "delete", np }),
},
]}
/>
</TableCell>
</TableRow> </TableRow>
)) ))
)} )}
</TableBody> </TableBody>
</Table> </Table>
</div> </div>
{activeModal?.type === "edit" && (
<EditResourceModal
isOpen
clusterId={clusterId}
namespace={namespace}
resourceType="networkpolicies"
resourceName={activeModal.np.name}
initialYaml={activeModal.yaml}
onClose={() => { setActiveModal(null); onRefresh?.(); }}
/>
)}
{activeModal?.type === "delete" && (
<ConfirmDeleteDialog
open
onOpenChange={(o) => { if (!o) setActiveModal(null); }}
resourceType="NetworkPolicy"
resourceName={activeModal.np.name}
isLoading={isDeleting}
onConfirm={handleDelete}
/>
)}
</>
); );
} }

View File

@ -1,24 +1,33 @@
import React, { useState } from "react"; import React, { useState } from "react";
import { invoke } from "@tauri-apps/api/core";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui";
import { Badge } from "@/components/ui"; import { Badge } from "@/components/ui";
import { Button } from "@/components/ui"; import { ShieldOff, ShieldCheck, Trash2, Pencil } from "lucide-react";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui";
import { AlertCircle, Terminal } from "lucide-react";
import { Alert, AlertDescription } from "@/components/ui";
import type { NodeInfo } from "@/lib/tauriCommands"; import type { NodeInfo } from "@/lib/tauriCommands";
import {
cordonNodeCmd,
uncordonNodeCmd,
drainNodeCmd,
getResourceYamlCmd,
} from "@/lib/tauriCommands";
import { ResourceActionMenu } from "./ResourceActionMenu";
import { ConfirmDeleteDialog } from "./ConfirmDeleteDialog";
import { EditResourceModal } from "./EditResourceModal";
interface NodeListProps { interface NodeListProps {
nodes: NodeInfo[]; nodes: NodeInfo[];
clusterId: string; clusterId: string;
onRefresh?: () => void;
} }
export function NodeList({ nodes, clusterId }: NodeListProps) { type ActiveModal =
const [selectedNode, setSelectedNode] = useState<NodeInfo | null>(null); | { type: "drain"; node: NodeInfo }
const [isCordoning, setIsCordoning] = useState(false); | { type: "edit"; node: NodeInfo; yaml: string }
const [isUncordoning, setIsUncordoning] = useState(false); | null;
const [isDraining, setIsDraining] = useState(false);
const [error, setError] = useState<string | null>(null); export function NodeList({ nodes, clusterId, onRefresh }: NodeListProps) {
const [activeModal, setActiveModal] = useState<ActiveModal>(null);
const [isActing, setIsActing] = useState(false);
const [actionError, setActionError] = useState<string | null>(null);
const getNodeStatusColor = (status: string) => { const getNodeStatusColor = (status: string) => {
switch (status.toLowerCase()) { switch (status.toLowerCase()) {
@ -33,53 +42,59 @@ export function NodeList({ nodes, clusterId }: NodeListProps) {
} }
}; };
const handleCordon = async () => { const isSchedulingDisabled = (node: NodeInfo) =>
if (!selectedNode) return; node.status.toLowerCase().includes("schedulingdisabled") ||
node.roles.toLowerCase().includes("schedulingdisabled");
setIsCordoning(true); const handleCordon = async (node: NodeInfo) => {
setError(null); setActionError(null);
try { try {
await invoke<void>("cordon_node", { clusterId, nodeName: selectedNode.name }); await cordonNodeCmd(clusterId, node.name);
setSelectedNode(null); onRefresh?.();
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : "Failed to cordon node"); setActionError(err instanceof Error ? err.message : String(err));
} finally {
setIsCordoning(false);
} }
}; };
const handleUncordon = async () => { const handleUncordon = async (node: NodeInfo) => {
if (!selectedNode) return; setActionError(null);
setIsUncordoning(true);
setError(null);
try { try {
await invoke<void>("uncordon_node", { clusterId, nodeName: selectedNode.name }); await uncordonNodeCmd(clusterId, node.name);
setSelectedNode(null); onRefresh?.();
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : "Failed to uncordon node"); setActionError(err instanceof Error ? err.message : String(err));
} finally {
setIsUncordoning(false);
} }
}; };
const handleDrain = async () => { const handleDrain = async () => {
if (!selectedNode) return; if (activeModal?.type !== "drain") return;
setIsActing(true);
setIsDraining(true);
setError(null);
try { try {
await invoke<void>("drain_node", { clusterId, nodeName: selectedNode.name }); await drainNodeCmd(clusterId, activeModal.node.name);
setSelectedNode(null); setActiveModal(null);
onRefresh?.();
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : "Failed to drain node"); setActionError(err instanceof Error ? err.message : String(err));
} finally { } finally {
setIsDraining(false); setIsActing(false);
}
};
const openEdit = async (node: NodeInfo) => {
setActionError(null);
try {
const yaml = await getResourceYamlCmd(clusterId, "nodes", "", node.name);
setActiveModal({ type: "edit", node, yaml });
} catch (err) {
setActionError(err instanceof Error ? err.message : String(err));
} }
}; };
return ( return (
<> <>
{actionError && (
<p className="mb-2 text-sm text-destructive">{actionError}</p>
)}
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<Table> <Table>
<TableHeader> <TableHeader>
@ -116,14 +131,33 @@ export function NodeList({ nodes, clusterId }: NodeListProps) {
<TableCell className="text-sm text-muted-foreground">{node.os_image}</TableCell> <TableCell className="text-sm text-muted-foreground">{node.os_image}</TableCell>
<TableCell className="text-sm text-muted-foreground">{node.age}</TableCell> <TableCell className="text-sm text-muted-foreground">{node.age}</TableCell>
<TableCell className="text-right"> <TableCell className="text-right">
<Button <ResourceActionMenu
variant="ghost" actions={[
size="sm" {
onClick={() => setSelectedNode(node)} label: "Cordon",
className="text-primary hover:text-primary hover:bg-primary/10" icon: ShieldOff,
> hidden: isSchedulingDisabled(node),
Manage onClick: () => handleCordon(node),
</Button> },
{
label: "Uncordon",
icon: ShieldCheck,
hidden: !isSchedulingDisabled(node),
onClick: () => handleUncordon(node),
},
{
label: "Drain",
icon: Trash2,
variant: "destructive",
onClick: () => setActiveModal({ type: "drain", node }),
},
{
label: "Edit",
icon: Pencil,
onClick: () => openEdit(node),
},
]}
/>
</TableCell> </TableCell>
</TableRow> </TableRow>
)) ))
@ -132,101 +166,28 @@ export function NodeList({ nodes, clusterId }: NodeListProps) {
</Table> </Table>
</div> </div>
{/* Node Management Dialog */} {activeModal?.type === "drain" && (
{selectedNode && ( <ConfirmDeleteDialog
<Dialog open={true} onOpenChange={(open) => { open
if (!open) { onOpenChange={(o) => { if (!o) setActiveModal(null); }}
setSelectedNode(null); resourceType="Node"
setError(null); resourceName={activeModal.node.name}
} isLoading={isActing}
}}> onConfirm={handleDrain}
<DialogContent className="max-w-2xl"> variant="force-delete"
<DialogHeader> />
<DialogTitle className="flex items-center gap-2">
<Terminal className="w-5 h-5" />
Manage Node: {selectedNode.name}
</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-4">
{/* Node Details */}
<div className="grid grid-cols-2 gap-4 p-4 bg-muted rounded-lg">
<div>
<p className="text-xs font-medium text-muted-foreground">Status</p>
<p className="font-semibold">{selectedNode.status}</p>
</div>
<div>
<p className="text-xs font-medium text-muted-foreground">Roles</p>
<p className="font-semibold">{selectedNode.roles}</p>
</div>
<div>
<p className="text-xs font-medium text-muted-foreground">Version</p>
<p className="font-semibold">{selectedNode.version}</p>
</div>
<div>
<p className="text-xs font-medium text-muted-foreground">OS Image</p>
<p className="font-semibold">{selectedNode.os_image}</p>
</div>
<div>
<p className="text-xs font-medium text-muted-foreground">Kernel</p>
<p className="font-semibold">{selectedNode.kernel_version}</p>
</div>
<div>
<p className="text-xs font-medium text-muted-foreground">Kubelet</p>
<p className="font-semibold">{selectedNode.kubelet_version}</p>
</div>
<div>
<p className="text-xs font-medium text-muted-foreground">Internal IP</p>
<p className="font-semibold font-mono">{selectedNode.internal_ip}</p>
</div>
{selectedNode.external_ip && (
<div>
<p className="text-xs font-medium text-muted-foreground">External IP</p>
<p className="font-semibold font-mono">{selectedNode.external_ip}</p>
</div>
)}
</div>
{/* Action Buttons */}
<div className="space-y-3">
{selectedNode.roles.toLowerCase().includes("schedulingdisabled") ? (
<Button
onClick={handleUncordon}
disabled={isUncordoning}
className="w-full"
>
{isUncordoning ? "Uncordoning..." : "Uncordon Node"}
</Button>
) : (
<Button
onClick={handleCordon}
variant="outline"
disabled={isCordoning}
className="w-full"
>
{isCordoning ? "Cordoning..." : "Cordon Node"}
</Button>
)} )}
<Button {activeModal?.type === "edit" && (
onClick={handleDrain} <EditResourceModal
variant="destructive" isOpen
disabled={isDraining} clusterId={clusterId}
className="w-full" namespace=""
> resourceType="nodes"
{isDraining ? "Draining..." : "Drain Node"} resourceName={activeModal.node.name}
</Button> initialYaml={activeModal.yaml}
</div> onClose={() => { setActiveModal(null); onRefresh?.(); }}
/>
{error && (
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
</div>
</DialogContent>
</Dialog>
)} )}
</> </>
); );

View File

@ -1,15 +1,67 @@
import React from "react"; import React, { useState } from "react";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui";
import { Pencil, Trash2 } from "lucide-react";
import type { PersistentVolumeClaimInfo } from "@/lib/tauriCommands"; import type { PersistentVolumeClaimInfo } from "@/lib/tauriCommands";
import { deleteResourceCmd, getResourceYamlCmd } from "@/lib/tauriCommands";
import { ResourceActionMenu } from "./ResourceActionMenu";
import { ConfirmDeleteDialog } from "./ConfirmDeleteDialog";
import { EditResourceModal } from "./EditResourceModal";
interface PVCListProps { interface PVCListProps {
pvcs: PersistentVolumeClaimInfo[]; pvcs: PersistentVolumeClaimInfo[];
_clusterId: string; clusterId?: string;
_namespace: string; _clusterId?: string;
namespace?: string;
_namespace?: string;
onRefresh?: () => void;
} }
export function PVCList({ pvcs, _clusterId, _namespace }: PVCListProps) { type ActiveModal =
| { type: "edit"; pvc: PersistentVolumeClaimInfo; yaml: string }
| { type: "delete"; pvc: PersistentVolumeClaimInfo }
| null;
export function PVCList({
pvcs,
clusterId,
_clusterId,
namespace,
_namespace,
onRefresh,
}: PVCListProps) {
const cid = clusterId ?? _clusterId ?? "";
const ns = namespace ?? _namespace ?? "";
const [activeModal, setActiveModal] = useState<ActiveModal>(null);
const [isDeleting, setIsDeleting] = useState(false);
const [actionError, setActionError] = useState<string | null>(null);
const openEdit = async (pvc: PersistentVolumeClaimInfo) => {
setActionError(null);
try {
const yaml = await getResourceYamlCmd(cid, "persistentvolumeclaims", ns, pvc.name);
setActiveModal({ type: "edit", pvc, yaml });
} catch (err) {
setActionError(err instanceof Error ? err.message : String(err));
}
};
const handleDelete = async () => {
if (activeModal?.type !== "delete") return;
setIsDeleting(true);
try {
await deleteResourceCmd(cid, "persistentvolumeclaims", ns, activeModal.pvc.name);
setActiveModal(null);
onRefresh?.();
} finally {
setIsDeleting(false);
}
};
return ( return (
<>
{actionError && (
<p className="mb-2 text-sm text-destructive">{actionError}</p>
)}
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<Table> <Table>
<TableHeader> <TableHeader>
@ -21,12 +73,13 @@ export function PVCList({ pvcs, _clusterId, _namespace }: PVCListProps) {
<TableHead>Capacity</TableHead> <TableHead>Capacity</TableHead>
<TableHead>Access Modes</TableHead> <TableHead>Access Modes</TableHead>
<TableHead>Age</TableHead> <TableHead>Age</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{pvcs.length === 0 ? ( {pvcs.length === 0 ? (
<TableRow> <TableRow>
<TableCell colSpan={7} className="text-center text-muted-foreground"> <TableCell colSpan={8} className="text-center text-muted-foreground">
No PVCs found No PVCs found
</TableCell> </TableCell>
</TableRow> </TableRow>
@ -40,11 +93,52 @@ export function PVCList({ pvcs, _clusterId, _namespace }: PVCListProps) {
<TableCell>{pvc.capacity}</TableCell> <TableCell>{pvc.capacity}</TableCell>
<TableCell>{pvc.access_modes.join(", ")}</TableCell> <TableCell>{pvc.access_modes.join(", ")}</TableCell>
<TableCell className="text-muted-foreground">{pvc.age}</TableCell> <TableCell className="text-muted-foreground">{pvc.age}</TableCell>
<TableCell className="text-right">
<ResourceActionMenu
actions={[
{
label: "Edit",
icon: Pencil,
onClick: () => openEdit(pvc),
},
{
label: "Delete",
icon: Trash2,
variant: "destructive",
onClick: () => setActiveModal({ type: "delete", pvc }),
},
]}
/>
</TableCell>
</TableRow> </TableRow>
)) ))
)} )}
</TableBody> </TableBody>
</Table> </Table>
</div> </div>
{activeModal?.type === "edit" && (
<EditResourceModal
isOpen
clusterId={cid}
namespace={ns}
resourceType="persistentvolumeclaims"
resourceName={activeModal.pvc.name}
initialYaml={activeModal.yaml}
onClose={() => { setActiveModal(null); onRefresh?.(); }}
/>
)}
{activeModal?.type === "delete" && (
<ConfirmDeleteDialog
open
onOpenChange={(o) => { if (!o) setActiveModal(null); }}
resourceType="PVC"
resourceName={activeModal.pvc.name}
isLoading={isDeleting}
onConfirm={handleDelete}
/>
)}
</>
); );
} }

View File

@ -1,14 +1,57 @@
import React from "react"; import React, { useState } from "react";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui";
import { Pencil, Trash2 } from "lucide-react";
import type { PersistentVolumeInfo } from "@/lib/tauriCommands"; import type { PersistentVolumeInfo } from "@/lib/tauriCommands";
import { deleteResourceCmd, getResourceYamlCmd } from "@/lib/tauriCommands";
import { ResourceActionMenu } from "./ResourceActionMenu";
import { ConfirmDeleteDialog } from "./ConfirmDeleteDialog";
import { EditResourceModal } from "./EditResourceModal";
interface PVListProps { interface PVListProps {
pvs: PersistentVolumeInfo[]; pvs: PersistentVolumeInfo[];
_clusterId: string; clusterId?: string;
_clusterId?: string;
onRefresh?: () => void;
} }
export function PVList({ pvs, _clusterId }: PVListProps) { type ActiveModal =
| { type: "edit"; pv: PersistentVolumeInfo; yaml: string }
| { type: "delete"; pv: PersistentVolumeInfo }
| null;
export function PVList({ pvs, clusterId, _clusterId, onRefresh }: PVListProps) {
const cid = clusterId ?? _clusterId ?? "";
const [activeModal, setActiveModal] = useState<ActiveModal>(null);
const [isDeleting, setIsDeleting] = useState(false);
const [actionError, setActionError] = useState<string | null>(null);
const openEdit = async (pv: PersistentVolumeInfo) => {
setActionError(null);
try {
const yaml = await getResourceYamlCmd(cid, "persistentvolumes", "", pv.name);
setActiveModal({ type: "edit", pv, yaml });
} catch (err) {
setActionError(err instanceof Error ? err.message : String(err));
}
};
const handleDelete = async () => {
if (activeModal?.type !== "delete") return;
setIsDeleting(true);
try {
await deleteResourceCmd(cid, "persistentvolumes", "", activeModal.pv.name);
setActiveModal(null);
onRefresh?.();
} finally {
setIsDeleting(false);
}
};
return ( return (
<>
{actionError && (
<p className="mb-2 text-sm text-destructive">{actionError}</p>
)}
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<Table> <Table>
<TableHeader> <TableHeader>
@ -20,12 +63,13 @@ export function PVList({ pvs, _clusterId }: PVListProps) {
<TableHead>Reclaim Policy</TableHead> <TableHead>Reclaim Policy</TableHead>
<TableHead>Storage Class</TableHead> <TableHead>Storage Class</TableHead>
<TableHead>Age</TableHead> <TableHead>Age</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{pvs.length === 0 ? ( {pvs.length === 0 ? (
<TableRow> <TableRow>
<TableCell colSpan={7} className="text-center text-muted-foreground"> <TableCell colSpan={8} className="text-center text-muted-foreground">
No PVs found No PVs found
</TableCell> </TableCell>
</TableRow> </TableRow>
@ -39,11 +83,52 @@ export function PVList({ pvs, _clusterId }: PVListProps) {
<TableCell>{pv.reclaim_policy}</TableCell> <TableCell>{pv.reclaim_policy}</TableCell>
<TableCell>{pv.storage_class}</TableCell> <TableCell>{pv.storage_class}</TableCell>
<TableCell className="text-muted-foreground">{pv.age}</TableCell> <TableCell className="text-muted-foreground">{pv.age}</TableCell>
<TableCell className="text-right">
<ResourceActionMenu
actions={[
{
label: "Edit",
icon: Pencil,
onClick: () => openEdit(pv),
},
{
label: "Delete",
icon: Trash2,
variant: "destructive",
onClick: () => setActiveModal({ type: "delete", pv }),
},
]}
/>
</TableCell>
</TableRow> </TableRow>
)) ))
)} )}
</TableBody> </TableBody>
</Table> </Table>
</div> </div>
{activeModal?.type === "edit" && (
<EditResourceModal
isOpen
clusterId={cid}
namespace=""
resourceType="persistentvolumes"
resourceName={activeModal.pv.name}
initialYaml={activeModal.yaml}
onClose={() => { setActiveModal(null); onRefresh?.(); }}
/>
)}
{activeModal?.type === "delete" && (
<ConfirmDeleteDialog
open
onOpenChange={(o) => { if (!o) setActiveModal(null); }}
resourceType="PersistentVolume"
resourceName={activeModal.pv.name}
isLoading={isDeleting}
onConfirm={handleDelete}
/>
)}
</>
); );
} }

View 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>
);
}

View File

@ -1,28 +1,36 @@
import React, { useState } from "react"; import React, { useState } from "react";
import { invoke } from "@tauri-apps/api/core";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui";
import { Badge } from "@/components/ui"; import { Badge } from "@/components/ui";
import { Button } from "@/components/ui"; import { FileText, Terminal, Link, Pencil, Trash2, Zap } from "lucide-react";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui"; import type { PodInfo } from "@/lib/tauriCommands";
import { Textarea } from "@/components/ui"; import { deleteResourceCmd, forceDeleteResourceCmd, getResourceYamlCmd } from "@/lib/tauriCommands";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui"; import { ResourceActionMenu } from "./ResourceActionMenu";
import { Terminal, FileText, RotateCcw } from "lucide-react"; import { ConfirmDeleteDialog } from "./ConfirmDeleteDialog";
import { Alert, AlertDescription } from "@/components/ui"; import { LogsModal } from "./LogsModal";
import type { PodInfo, LogResponse } from "@/lib/tauriCommands"; import { ShellExecModal } from "./ShellExecModal";
import { AttachModal } from "./AttachModal";
import { EditResourceModal } from "./EditResourceModal";
interface PodListProps { interface PodListProps {
pods: PodInfo[]; pods: PodInfo[];
clusterId: string; clusterId: string;
namespace: string; namespace: string;
onRefresh?: () => void;
} }
export function PodList({ pods, clusterId, namespace }: PodListProps) { type ActiveModal =
const [selectedPod, setSelectedPod] = useState<PodInfo | null>(null); | { type: "logs"; pod: PodInfo }
const [selectedContainer, setSelectedContainer] = useState<string>(""); | { type: "shell"; pod: PodInfo }
const [logs, setLogs] = useState<string>(""); | { type: "attach"; pod: PodInfo }
const [isFetchingLogs, setIsFetchingLogs] = useState(false); | { type: "edit"; pod: PodInfo; yaml: string }
const [error, setError] = useState<string | null>(null); | { type: "delete"; pod: PodInfo }
const [isDialogOpen, setIsDialogOpen] = useState(false); | { type: "force-delete"; pod: PodInfo }
| null;
export function PodList({ pods, clusterId, namespace, onRefresh }: PodListProps) {
const [activeModal, setActiveModal] = useState<ActiveModal>(null);
const [isDeleting, setIsDeleting] = useState(false);
const [editError, setEditError] = useState<string | null>(null);
const getPodStatusColor = (status: string) => { const getPodStatusColor = (status: string) => {
switch (status.toLowerCase()) { switch (status.toLowerCase()) {
@ -41,37 +49,41 @@ export function PodList({ pods, clusterId, namespace }: PodListProps) {
} }
}; };
const fetchLogs = async () => { const openEdit = async (pod: PodInfo) => {
if (!selectedPod || !selectedContainer) return; setEditError(null);
setIsFetchingLogs(true);
setError(null);
try { try {
const response = await invoke<LogResponse>("get_pod_logs", { const yaml = await getResourceYamlCmd(clusterId, "pods", namespace, pod.name);
clusterId, setActiveModal({ type: "edit", pod, yaml });
namespace,
podName: selectedPod.name,
containerName: selectedContainer,
});
setLogs(response.logs);
} catch (err) { } catch (err) {
console.error("Failed to fetch logs:", err); setEditError(err instanceof Error ? err.message : String(err));
setError(err instanceof Error ? err.message : "Failed to fetch logs");
} finally {
setIsFetchingLogs(false);
} }
}; };
const handleContainerChange = (container: string) => { const handleDelete = async (force: boolean) => {
setSelectedContainer(container); const modal = activeModal;
setLogs(""); if (!modal || (modal.type !== "delete" && modal.type !== "force-delete")) return;
setError(null); setIsDeleting(true);
try {
if (force) {
await forceDeleteResourceCmd(clusterId, "pods", namespace, modal.pod.name);
} else {
await deleteResourceCmd(clusterId, "pods", namespace, modal.pod.name);
}
setActiveModal(null);
onRefresh?.();
} finally {
setIsDeleting(false);
}
}; };
const containers = selectedPod?.containers ?? []; const currentPod =
activeModal && activeModal.type !== "edit" ? activeModal.pod : null;
return ( return (
<> <>
{editError && (
<p className="mb-2 text-sm text-destructive">{editError}</p>
)}
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<Table> <Table>
<TableHeader> <TableHeader>
@ -102,91 +114,46 @@ export function PodList({ pods, clusterId, namespace }: PodListProps) {
<TableCell>{pod.ready}</TableCell> <TableCell>{pod.ready}</TableCell>
<TableCell className="text-muted-foreground">{pod.age}</TableCell> <TableCell className="text-muted-foreground">{pod.age}</TableCell>
<TableCell className="text-right"> <TableCell className="text-right">
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}> <ResourceActionMenu
<Button variant="ghost" size="sm" onClick={() => { setSelectedPod(pod); setIsDialogOpen(true); }}> actions={[
<Terminal className="w-4 h-4" /> {
</Button> label: "Logs",
<DialogContent className="max-w-4xl max-h-[80vh] flex flex-col"> icon: FileText,
<DialogHeader> onClick: () => setActiveModal({ type: "logs", pod }),
<DialogTitle>{pod.name} - {namespace} namespace</DialogTitle> },
</DialogHeader> {
<div className="flex-1 overflow-y-auto flex flex-col"> label: "Shell",
{selectedPod && ( icon: Terminal,
<div className="space-y-4"> onClick: () => setActiveModal({ type: "shell", pod }),
<div className="flex items-center gap-2"> },
<span className="text-sm font-medium">Container:</span> {
<select label: "Attach",
value={selectedContainer} icon: Link,
onChange={(e) => handleContainerChange(e.target.value)} onClick: () => setActiveModal({ type: "attach", pod }),
className="flex h-9 w-32 rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2" },
> {
<option value="">Select container...</option> label: "Edit",
{containers.map((container) => ( icon: Pencil,
<option key={container} value={container}> onClick: () => openEdit(pod),
{container} },
</option> {
))} label: "Delete",
</select> icon: Trash2,
<Button variant: "destructive",
onClick={fetchLogs} onClick: () => setActiveModal({ type: "delete", pod }),
disabled={!selectedContainer || isFetchingLogs} },
size="sm" {
> label: "Force Delete",
{isFetchingLogs ? ( icon: Zap,
<> variant: "destructive",
<RotateCcw className="w-4 h-4 animate-spin" /> hidden: !(
Loading... pod.status.toLowerCase() === "running" ||
</> pod.status.toLowerCase() === "pending"
) : ( ),
<> onClick: () => setActiveModal({ type: "force-delete", pod }),
<FileText className="w-4 h-4" /> },
Fetch Logs ]}
</>
)}
</Button>
</div>
{error && (
<Alert variant="destructive">
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
<Tabs value="logs" onValueChange={() => {}}>
<TabsList className="grid grid-cols-2">
<TabsTrigger value="logs">Logs</TabsTrigger>
<TabsTrigger value="details">Details</TabsTrigger>
</TabsList>
<div className="flex-1 overflow-auto">
<TabsContent value="logs" className="h-full">
<Textarea
value={logs}
readOnly
className="font-mono text-xs h-64"
placeholder="No logs available. Click 'Fetch Logs' to retrieve."
/> />
</TabsContent>
<TabsContent value="details" className="h-full">
<div className="space-y-2 text-sm">
<div className="grid grid-cols-2 gap-2">
<div className="text-muted-foreground">Name:</div>
<div>{selectedPod.name}</div>
<div className="text-muted-foreground">Status:</div>
<div>{selectedPod.status}</div>
<div className="text-muted-foreground">Ready:</div>
<div>{selectedPod.ready}</div>
<div className="text-muted-foreground">Age:</div>
<div>{selectedPod.age}</div>
</div>
</div>
</TabsContent>
</div>
</Tabs>
</div>
)}
</div>
</DialogContent>
</Dialog>
</TableCell> </TableCell>
</TableRow> </TableRow>
)) ))
@ -194,6 +161,74 @@ export function PodList({ pods, clusterId, namespace }: PodListProps) {
</TableBody> </TableBody>
</Table> </Table>
</div> </div>
{activeModal?.type === "logs" && (
<LogsModal
open
onOpenChange={(o) => { if (!o) setActiveModal(null); }}
clusterId={clusterId}
namespace={namespace}
podName={activeModal.pod.name}
containers={activeModal.pod.containers}
/>
)}
{activeModal?.type === "shell" && (
<ShellExecModal
open
onOpenChange={(o) => { if (!o) setActiveModal(null); }}
clusterId={clusterId}
namespace={namespace}
podName={activeModal.pod.name}
containers={activeModal.pod.containers}
/>
)}
{activeModal?.type === "attach" && (
<AttachModal
open
onOpenChange={(o) => { if (!o) setActiveModal(null); }}
clusterId={clusterId}
namespace={namespace}
podName={activeModal.pod.name}
containers={activeModal.pod.containers}
/>
)}
{activeModal?.type === "edit" && (
<EditResourceModal
isOpen
clusterId={clusterId}
namespace={namespace}
resourceType="pods"
resourceName={activeModal.pod.name}
initialYaml={activeModal.yaml}
onClose={() => { setActiveModal(null); onRefresh?.(); }}
/>
)}
{activeModal?.type === "delete" && currentPod && (
<ConfirmDeleteDialog
open
onOpenChange={(o) => { if (!o) setActiveModal(null); }}
resourceType="Pod"
resourceName={currentPod.name}
isLoading={isDeleting}
onConfirm={() => handleDelete(false)}
/>
)}
{activeModal?.type === "force-delete" && currentPod && (
<ConfirmDeleteDialog
open
onOpenChange={(o) => { if (!o) setActiveModal(null); }}
resourceType="Pod"
resourceName={currentPod.name}
variant="force-delete"
isLoading={isDeleting}
onConfirm={() => handleDelete(true)}
/>
)}
</> </>
); );
} }

View 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>
);
}

View File

@ -1,15 +1,73 @@
import React from "react"; import React, { useState } from "react";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui";
import { Scale, Pencil, Trash2 } from "lucide-react";
import type { ReplicaSetInfo } from "@/lib/tauriCommands"; import type { ReplicaSetInfo } from "@/lib/tauriCommands";
import {
scaleReplicasetCmd,
deleteResourceCmd,
getResourceYamlCmd,
} from "@/lib/tauriCommands";
import { ResourceActionMenu } from "./ResourceActionMenu";
import { ConfirmDeleteDialog } from "./ConfirmDeleteDialog";
import { ScaleModal } from "./ScaleModal";
import { EditResourceModal } from "./EditResourceModal";
interface ReplicaSetListProps { interface ReplicaSetListProps {
replicaSets: ReplicaSetInfo[]; replicaSets: ReplicaSetInfo[];
_clusterId: string; clusterId?: string;
_namespace: string; _clusterId?: string;
namespace?: string;
_namespace?: string;
onRefresh?: () => void;
} }
export function ReplicaSetList({ replicaSets, _clusterId, _namespace }: ReplicaSetListProps) { type ActiveModal =
| { type: "scale"; rs: ReplicaSetInfo }
| { type: "edit"; rs: ReplicaSetInfo; yaml: string }
| { type: "delete"; rs: ReplicaSetInfo }
| null;
export function ReplicaSetList({
replicaSets,
clusterId,
_clusterId,
namespace,
_namespace,
onRefresh,
}: ReplicaSetListProps) {
const cid = clusterId ?? _clusterId ?? "";
const ns = namespace ?? _namespace ?? "";
const [activeModal, setActiveModal] = useState<ActiveModal>(null);
const [isActing, setIsActing] = useState(false);
const [actionError, setActionError] = useState<string | null>(null);
const openEdit = async (rs: ReplicaSetInfo) => {
setActionError(null);
try {
const yaml = await getResourceYamlCmd(cid, "replicasets", ns, rs.name);
setActiveModal({ type: "edit", rs, yaml });
} catch (err) {
setActionError(err instanceof Error ? err.message : String(err));
}
};
const handleDelete = async () => {
if (activeModal?.type !== "delete") return;
setIsActing(true);
try {
await deleteResourceCmd(cid, "replicasets", ns, activeModal.rs.name);
setActiveModal(null);
onRefresh?.();
} finally {
setIsActing(false);
}
};
return ( return (
<>
{actionError && (
<p className="mb-2 text-sm text-destructive">{actionError}</p>
)}
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<Table> <Table>
<TableHeader> <TableHeader>
@ -20,33 +78,96 @@ export function ReplicaSetList({ replicaSets, _clusterId, _namespace }: ReplicaS
<TableHead>Ready</TableHead> <TableHead>Ready</TableHead>
<TableHead>Age</TableHead> <TableHead>Age</TableHead>
<TableHead>Labels</TableHead> <TableHead>Labels</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{replicaSets.length === 0 ? ( {replicaSets.length === 0 ? (
<TableRow> <TableRow>
<TableCell colSpan={6} className="text-center text-muted-foreground"> <TableCell colSpan={7} className="text-center text-muted-foreground">
No replica sets found No replica sets found
</TableCell> </TableCell>
</TableRow> </TableRow>
) : ( ) : (
replicaSets.map((replicaSet) => ( replicaSets.map((rs) => (
<TableRow key={`${replicaSet.name}-${replicaSet.namespace}`}> <TableRow key={`${rs.name}-${rs.namespace}`}>
<TableCell className="font-medium">{replicaSet.name}</TableCell> <TableCell className="font-medium">{rs.name}</TableCell>
<TableCell>{replicaSet.namespace}</TableCell> <TableCell>{rs.namespace}</TableCell>
<TableCell>{replicaSet.replicas}</TableCell> <TableCell>{rs.replicas}</TableCell>
<TableCell>{replicaSet.ready}</TableCell> <TableCell>{rs.ready}</TableCell>
<TableCell className="text-muted-foreground">{replicaSet.age}</TableCell> <TableCell className="text-muted-foreground">{rs.age}</TableCell>
<TableCell> <TableCell>
{Object.entries(replicaSet.labels) {Object.entries(rs.labels)
.map(([k, v]) => `${k}=${v}`) .map(([k, v]) => `${k}=${v}`)
.join(", ")} .join(", ")}
</TableCell> </TableCell>
<TableCell className="text-right">
<ResourceActionMenu
actions={[
{
label: "Scale",
icon: Scale,
onClick: () => setActiveModal({ type: "scale", rs }),
},
{
label: "Edit",
icon: Pencil,
onClick: () => openEdit(rs),
},
{
label: "Delete",
icon: Trash2,
variant: "destructive",
onClick: () => setActiveModal({ type: "delete", rs }),
},
]}
/>
</TableCell>
</TableRow> </TableRow>
)) ))
)} )}
</TableBody> </TableBody>
</Table> </Table>
</div> </div>
{activeModal?.type === "scale" && (
<ScaleModal
open
onOpenChange={(o) => { if (!o) setActiveModal(null); }}
resourceType="ReplicaSet"
resourceName={activeModal.rs.name}
currentReplicas={activeModal.rs.replicas}
onScale={(replicas) =>
scaleReplicasetCmd(cid, ns, activeModal.rs.name, replicas).then(() => {
setActiveModal(null);
onRefresh?.();
})
}
/>
)}
{activeModal?.type === "edit" && (
<EditResourceModal
isOpen
clusterId={cid}
namespace={ns}
resourceType="replicasets"
resourceName={activeModal.rs.name}
initialYaml={activeModal.yaml}
onClose={() => { setActiveModal(null); onRefresh?.(); }}
/>
)}
{activeModal?.type === "delete" && (
<ConfirmDeleteDialog
open
onOpenChange={(o) => { if (!o) setActiveModal(null); }}
resourceType="ReplicaSet"
resourceName={activeModal.rs.name}
isLoading={isActing}
onConfirm={handleDelete}
/>
)}
</>
); );
} }

View 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>
);
}

View 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>
);
}

View File

@ -1,15 +1,56 @@
import React from "react"; import React, { useState } from "react";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui";
import { Pencil, Trash2 } from "lucide-react";
import type { ResourceQuotaInfo } from "@/lib/tauriCommands"; import type { ResourceQuotaInfo } from "@/lib/tauriCommands";
import { deleteResourceCmd, getResourceYamlCmd } from "@/lib/tauriCommands";
import { ResourceActionMenu } from "./ResourceActionMenu";
import { ConfirmDeleteDialog } from "./ConfirmDeleteDialog";
import { EditResourceModal } from "./EditResourceModal";
interface ResourceQuotaListProps { interface ResourceQuotaListProps {
resourcequotas: ResourceQuotaInfo[]; resourcequotas: ResourceQuotaInfo[];
clusterId: string; clusterId: string;
namespace: string; namespace: string;
onRefresh?: () => void;
} }
export function ResourceQuotaList({ resourcequotas }: ResourceQuotaListProps) { type ActiveModal =
| { type: "edit"; rq: ResourceQuotaInfo; yaml: string }
| { type: "delete"; rq: ResourceQuotaInfo }
| null;
export function ResourceQuotaList({ resourcequotas, clusterId, namespace, onRefresh }: ResourceQuotaListProps) {
const [activeModal, setActiveModal] = useState<ActiveModal>(null);
const [isDeleting, setIsDeleting] = useState(false);
const [actionError, setActionError] = useState<string | null>(null);
const openEdit = async (rq: ResourceQuotaInfo) => {
setActionError(null);
try {
const yaml = await getResourceYamlCmd(clusterId, "resourcequotas", namespace, rq.name);
setActiveModal({ type: "edit", rq, yaml });
} catch (err) {
setActionError(err instanceof Error ? err.message : String(err));
}
};
const handleDelete = async () => {
if (activeModal?.type !== "delete") return;
setIsDeleting(true);
try {
await deleteResourceCmd(clusterId, "resourcequotas", namespace, activeModal.rq.name);
setActiveModal(null);
onRefresh?.();
} finally {
setIsDeleting(false);
}
};
return ( return (
<>
{actionError && (
<p className="mb-2 text-sm text-destructive">{actionError}</p>
)}
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<Table> <Table>
<TableHeader> <TableHeader>
@ -21,12 +62,13 @@ export function ResourceQuotaList({ resourcequotas }: ResourceQuotaListProps) {
<TableHead>CPU Limit</TableHead> <TableHead>CPU Limit</TableHead>
<TableHead>Mem Limit</TableHead> <TableHead>Mem Limit</TableHead>
<TableHead>Age</TableHead> <TableHead>Age</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{resourcequotas.length === 0 ? ( {resourcequotas.length === 0 ? (
<TableRow> <TableRow>
<TableCell colSpan={7} className="text-center text-muted-foreground"> <TableCell colSpan={8} className="text-center text-muted-foreground">
No resource quotas found No resource quotas found
</TableCell> </TableCell>
</TableRow> </TableRow>
@ -40,11 +82,52 @@ export function ResourceQuotaList({ resourcequotas }: ResourceQuotaListProps) {
<TableCell className="text-sm font-mono">{rq.limit_cpu || "—"}</TableCell> <TableCell className="text-sm font-mono">{rq.limit_cpu || "—"}</TableCell>
<TableCell className="text-sm font-mono">{rq.limit_memory || "—"}</TableCell> <TableCell className="text-sm font-mono">{rq.limit_memory || "—"}</TableCell>
<TableCell className="text-sm text-muted-foreground">{rq.age}</TableCell> <TableCell className="text-sm text-muted-foreground">{rq.age}</TableCell>
<TableCell className="text-right">
<ResourceActionMenu
actions={[
{
label: "Edit",
icon: Pencil,
onClick: () => openEdit(rq),
},
{
label: "Delete",
icon: Trash2,
variant: "destructive",
onClick: () => setActiveModal({ type: "delete", rq }),
},
]}
/>
</TableCell>
</TableRow> </TableRow>
)) ))
)} )}
</TableBody> </TableBody>
</Table> </Table>
</div> </div>
{activeModal?.type === "edit" && (
<EditResourceModal
isOpen
clusterId={clusterId}
namespace={namespace}
resourceType="resourcequotas"
resourceName={activeModal.rq.name}
initialYaml={activeModal.yaml}
onClose={() => { setActiveModal(null); onRefresh?.(); }}
/>
)}
{activeModal?.type === "delete" && (
<ConfirmDeleteDialog
open
onOpenChange={(o) => { if (!o) setActiveModal(null); }}
resourceType="ResourceQuota"
resourceName={activeModal.rq.name}
isLoading={isDeleting}
onConfirm={handleDelete}
/>
)}
</>
); );
} }

View File

@ -1,15 +1,67 @@
import React from "react"; import React, { useState } from "react";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui";
import { Pencil, Trash2 } from "lucide-react";
import type { RoleBindingInfo } from "@/lib/tauriCommands"; import type { RoleBindingInfo } from "@/lib/tauriCommands";
import { deleteResourceCmd, getResourceYamlCmd } from "@/lib/tauriCommands";
import { ResourceActionMenu } from "./ResourceActionMenu";
import { ConfirmDeleteDialog } from "./ConfirmDeleteDialog";
import { EditResourceModal } from "./EditResourceModal";
interface RoleBindingListProps { interface RoleBindingListProps {
roleBindings: RoleBindingInfo[]; roleBindings: RoleBindingInfo[];
_clusterId: string; clusterId?: string;
_namespace: string; _clusterId?: string;
namespace?: string;
_namespace?: string;
onRefresh?: () => void;
} }
export function RoleBindingList({ roleBindings, _clusterId, _namespace }: RoleBindingListProps) { type ActiveModal =
| { type: "edit"; rb: RoleBindingInfo; yaml: string }
| { type: "delete"; rb: RoleBindingInfo }
| null;
export function RoleBindingList({
roleBindings,
clusterId,
_clusterId,
namespace,
_namespace,
onRefresh,
}: RoleBindingListProps) {
const cid = clusterId ?? _clusterId ?? "";
const ns = namespace ?? _namespace ?? "";
const [activeModal, setActiveModal] = useState<ActiveModal>(null);
const [isDeleting, setIsDeleting] = useState(false);
const [actionError, setActionError] = useState<string | null>(null);
const openEdit = async (rb: RoleBindingInfo) => {
setActionError(null);
try {
const yaml = await getResourceYamlCmd(cid, "rolebindings", ns, rb.name);
setActiveModal({ type: "edit", rb, yaml });
} catch (err) {
setActionError(err instanceof Error ? err.message : String(err));
}
};
const handleDelete = async () => {
if (activeModal?.type !== "delete") return;
setIsDeleting(true);
try {
await deleteResourceCmd(cid, "rolebindings", ns, activeModal.rb.name);
setActiveModal(null);
onRefresh?.();
} finally {
setIsDeleting(false);
}
};
return ( return (
<>
{actionError && (
<p className="mb-2 text-sm text-destructive">{actionError}</p>
)}
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<Table> <Table>
<TableHeader> <TableHeader>
@ -18,12 +70,13 @@ export function RoleBindingList({ roleBindings, _clusterId, _namespace }: RoleBi
<TableHead>Namespace</TableHead> <TableHead>Namespace</TableHead>
<TableHead>Role</TableHead> <TableHead>Role</TableHead>
<TableHead>Age</TableHead> <TableHead>Age</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{roleBindings.length === 0 ? ( {roleBindings.length === 0 ? (
<TableRow> <TableRow>
<TableCell colSpan={4} className="text-center text-muted-foreground"> <TableCell colSpan={5} className="text-center text-muted-foreground">
No role bindings found No role bindings found
</TableCell> </TableCell>
</TableRow> </TableRow>
@ -34,11 +87,52 @@ export function RoleBindingList({ roleBindings, _clusterId, _namespace }: RoleBi
<TableCell>{rb.namespace}</TableCell> <TableCell>{rb.namespace}</TableCell>
<TableCell>{rb.role}</TableCell> <TableCell>{rb.role}</TableCell>
<TableCell className="text-muted-foreground">{rb.age}</TableCell> <TableCell className="text-muted-foreground">{rb.age}</TableCell>
<TableCell className="text-right">
<ResourceActionMenu
actions={[
{
label: "Edit",
icon: Pencil,
onClick: () => openEdit(rb),
},
{
label: "Delete",
icon: Trash2,
variant: "destructive",
onClick: () => setActiveModal({ type: "delete", rb }),
},
]}
/>
</TableCell>
</TableRow> </TableRow>
)) ))
)} )}
</TableBody> </TableBody>
</Table> </Table>
</div> </div>
{activeModal?.type === "edit" && (
<EditResourceModal
isOpen
clusterId={cid}
namespace={ns}
resourceType="rolebindings"
resourceName={activeModal.rb.name}
initialYaml={activeModal.yaml}
onClose={() => { setActiveModal(null); onRefresh?.(); }}
/>
)}
{activeModal?.type === "delete" && (
<ConfirmDeleteDialog
open
onOpenChange={(o) => { if (!o) setActiveModal(null); }}
resourceType="RoleBinding"
resourceName={activeModal.rb.name}
isLoading={isDeleting}
onConfirm={handleDelete}
/>
)}
</>
); );
} }

View File

@ -1,15 +1,67 @@
import React from "react"; import React, { useState } from "react";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui";
import { Pencil, Trash2 } from "lucide-react";
import type { RoleInfo } from "@/lib/tauriCommands"; import type { RoleInfo } from "@/lib/tauriCommands";
import { deleteResourceCmd, getResourceYamlCmd } from "@/lib/tauriCommands";
import { ResourceActionMenu } from "./ResourceActionMenu";
import { ConfirmDeleteDialog } from "./ConfirmDeleteDialog";
import { EditResourceModal } from "./EditResourceModal";
interface RoleListProps { interface RoleListProps {
roles: RoleInfo[]; roles: RoleInfo[];
_clusterId: string; clusterId?: string;
_namespace: string; _clusterId?: string;
namespace?: string;
_namespace?: string;
onRefresh?: () => void;
} }
export function RoleList({ roles, _clusterId, _namespace }: RoleListProps) { type ActiveModal =
| { type: "edit"; role: RoleInfo; yaml: string }
| { type: "delete"; role: RoleInfo }
| null;
export function RoleList({
roles,
clusterId,
_clusterId,
namespace,
_namespace,
onRefresh,
}: RoleListProps) {
const cid = clusterId ?? _clusterId ?? "";
const ns = namespace ?? _namespace ?? "";
const [activeModal, setActiveModal] = useState<ActiveModal>(null);
const [isDeleting, setIsDeleting] = useState(false);
const [actionError, setActionError] = useState<string | null>(null);
const openEdit = async (role: RoleInfo) => {
setActionError(null);
try {
const yaml = await getResourceYamlCmd(cid, "roles", ns, role.name);
setActiveModal({ type: "edit", role, yaml });
} catch (err) {
setActionError(err instanceof Error ? err.message : String(err));
}
};
const handleDelete = async () => {
if (activeModal?.type !== "delete") return;
setIsDeleting(true);
try {
await deleteResourceCmd(cid, "roles", ns, activeModal.role.name);
setActiveModal(null);
onRefresh?.();
} finally {
setIsDeleting(false);
}
};
return ( return (
<>
{actionError && (
<p className="mb-2 text-sm text-destructive">{actionError}</p>
)}
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<Table> <Table>
<TableHeader> <TableHeader>
@ -17,12 +69,13 @@ export function RoleList({ roles, _clusterId, _namespace }: RoleListProps) {
<TableHead>Name</TableHead> <TableHead>Name</TableHead>
<TableHead>Namespace</TableHead> <TableHead>Namespace</TableHead>
<TableHead>Age</TableHead> <TableHead>Age</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{roles.length === 0 ? ( {roles.length === 0 ? (
<TableRow> <TableRow>
<TableCell colSpan={3} className="text-center text-muted-foreground"> <TableCell colSpan={4} className="text-center text-muted-foreground">
No roles found No roles found
</TableCell> </TableCell>
</TableRow> </TableRow>
@ -32,11 +85,52 @@ export function RoleList({ roles, _clusterId, _namespace }: RoleListProps) {
<TableCell className="font-medium">{role.name}</TableCell> <TableCell className="font-medium">{role.name}</TableCell>
<TableCell>{role.namespace}</TableCell> <TableCell>{role.namespace}</TableCell>
<TableCell className="text-muted-foreground">{role.age}</TableCell> <TableCell className="text-muted-foreground">{role.age}</TableCell>
<TableCell className="text-right">
<ResourceActionMenu
actions={[
{
label: "Edit",
icon: Pencil,
onClick: () => openEdit(role),
},
{
label: "Delete",
icon: Trash2,
variant: "destructive",
onClick: () => setActiveModal({ type: "delete", role }),
},
]}
/>
</TableCell>
</TableRow> </TableRow>
)) ))
)} )}
</TableBody> </TableBody>
</Table> </Table>
</div> </div>
{activeModal?.type === "edit" && (
<EditResourceModal
isOpen
clusterId={cid}
namespace={ns}
resourceType="roles"
resourceName={activeModal.role.name}
initialYaml={activeModal.yaml}
onClose={() => { setActiveModal(null); onRefresh?.(); }}
/>
)}
{activeModal?.type === "delete" && (
<ConfirmDeleteDialog
open
onOpenChange={(o) => { if (!o) setActiveModal(null); }}
resourceType="Role"
resourceName={activeModal.role.name}
isLoading={isDeleting}
onConfirm={handleDelete}
/>
)}
</>
); );
} }

View 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>
);
}

View 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>
);
}

View File

@ -1,15 +1,67 @@
import React from "react"; import React, { useState } from "react";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui";
import { Pencil, Trash2 } from "lucide-react";
import type { SecretInfo } from "@/lib/tauriCommands"; import type { SecretInfo } from "@/lib/tauriCommands";
import { deleteResourceCmd, getResourceYamlCmd } from "@/lib/tauriCommands";
import { ResourceActionMenu } from "./ResourceActionMenu";
import { ConfirmDeleteDialog } from "./ConfirmDeleteDialog";
import { EditResourceModal } from "./EditResourceModal";
interface SecretListProps { interface SecretListProps {
secrets: SecretInfo[]; secrets: SecretInfo[];
_clusterId: string; clusterId?: string;
_namespace: string; _clusterId?: string;
namespace?: string;
_namespace?: string;
onRefresh?: () => void;
} }
export function SecretList({ secrets, _clusterId, _namespace }: SecretListProps) { type ActiveModal =
| { type: "edit"; secret: SecretInfo; yaml: string }
| { type: "delete"; secret: SecretInfo }
| null;
export function SecretList({
secrets,
clusterId,
_clusterId,
namespace,
_namespace,
onRefresh,
}: SecretListProps) {
const cid = clusterId ?? _clusterId ?? "";
const ns = namespace ?? _namespace ?? "";
const [activeModal, setActiveModal] = useState<ActiveModal>(null);
const [isDeleting, setIsDeleting] = useState(false);
const [actionError, setActionError] = useState<string | null>(null);
const openEdit = async (secret: SecretInfo) => {
setActionError(null);
try {
const yaml = await getResourceYamlCmd(cid, "secrets", ns, secret.name);
setActiveModal({ type: "edit", secret, yaml });
} catch (err) {
setActionError(err instanceof Error ? err.message : String(err));
}
};
const handleDelete = async () => {
if (activeModal?.type !== "delete") return;
setIsDeleting(true);
try {
await deleteResourceCmd(cid, "secrets", ns, activeModal.secret.name);
setActiveModal(null);
onRefresh?.();
} finally {
setIsDeleting(false);
}
};
return ( return (
<>
{actionError && (
<p className="mb-2 text-sm text-destructive">{actionError}</p>
)}
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<Table> <Table>
<TableHeader> <TableHeader>
@ -38,7 +90,21 @@ export function SecretList({ secrets, _clusterId, _namespace }: SecretListProps)
<TableCell>{secret.data_keys}</TableCell> <TableCell>{secret.data_keys}</TableCell>
<TableCell className="text-muted-foreground">{secret.age}</TableCell> <TableCell className="text-muted-foreground">{secret.age}</TableCell>
<TableCell className="text-right"> <TableCell className="text-right">
<span className="text-sm">View/Edit</span> <ResourceActionMenu
actions={[
{
label: "Edit",
icon: Pencil,
onClick: () => openEdit(secret),
},
{
label: "Delete",
icon: Trash2,
variant: "destructive",
onClick: () => setActiveModal({ type: "delete", secret }),
},
]}
/>
</TableCell> </TableCell>
</TableRow> </TableRow>
)) ))
@ -46,5 +112,29 @@ export function SecretList({ secrets, _clusterId, _namespace }: SecretListProps)
</TableBody> </TableBody>
</Table> </Table>
</div> </div>
{activeModal?.type === "edit" && (
<EditResourceModal
isOpen
clusterId={cid}
namespace={ns}
resourceType="secrets"
resourceName={activeModal.secret.name}
initialYaml={activeModal.yaml}
onClose={() => { setActiveModal(null); onRefresh?.(); }}
/>
)}
{activeModal?.type === "delete" && (
<ConfirmDeleteDialog
open
onOpenChange={(o) => { if (!o) setActiveModal(null); }}
resourceType="Secret"
resourceName={activeModal.secret.name}
isLoading={isDeleting}
onConfirm={handleDelete}
/>
)}
</>
); );
} }

View File

@ -1,15 +1,67 @@
import React from "react"; import React, { useState } from "react";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui";
import { Pencil, Trash2 } from "lucide-react";
import type { ServiceAccountInfo } from "@/lib/tauriCommands"; import type { ServiceAccountInfo } from "@/lib/tauriCommands";
import { deleteResourceCmd, getResourceYamlCmd } from "@/lib/tauriCommands";
import { ResourceActionMenu } from "./ResourceActionMenu";
import { ConfirmDeleteDialog } from "./ConfirmDeleteDialog";
import { EditResourceModal } from "./EditResourceModal";
interface ServiceAccountListProps { interface ServiceAccountListProps {
serviceAccounts: ServiceAccountInfo[]; serviceAccounts: ServiceAccountInfo[];
_clusterId: string; clusterId?: string;
_namespace: string; _clusterId?: string;
namespace?: string;
_namespace?: string;
onRefresh?: () => void;
} }
export function ServiceAccountList({ serviceAccounts, _clusterId, _namespace }: ServiceAccountListProps) { type ActiveModal =
| { type: "edit"; sa: ServiceAccountInfo; yaml: string }
| { type: "delete"; sa: ServiceAccountInfo }
| null;
export function ServiceAccountList({
serviceAccounts,
clusterId,
_clusterId,
namespace,
_namespace,
onRefresh,
}: ServiceAccountListProps) {
const cid = clusterId ?? _clusterId ?? "";
const ns = namespace ?? _namespace ?? "";
const [activeModal, setActiveModal] = useState<ActiveModal>(null);
const [isDeleting, setIsDeleting] = useState(false);
const [actionError, setActionError] = useState<string | null>(null);
const openEdit = async (sa: ServiceAccountInfo) => {
setActionError(null);
try {
const yaml = await getResourceYamlCmd(cid, "serviceaccounts", ns, sa.name);
setActiveModal({ type: "edit", sa, yaml });
} catch (err) {
setActionError(err instanceof Error ? err.message : String(err));
}
};
const handleDelete = async () => {
if (activeModal?.type !== "delete") return;
setIsDeleting(true);
try {
await deleteResourceCmd(cid, "serviceaccounts", ns, activeModal.sa.name);
setActiveModal(null);
onRefresh?.();
} finally {
setIsDeleting(false);
}
};
return ( return (
<>
{actionError && (
<p className="mb-2 text-sm text-destructive">{actionError}</p>
)}
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<Table> <Table>
<TableHeader> <TableHeader>
@ -18,12 +70,13 @@ export function ServiceAccountList({ serviceAccounts, _clusterId, _namespace }:
<TableHead>Namespace</TableHead> <TableHead>Namespace</TableHead>
<TableHead>Secrets</TableHead> <TableHead>Secrets</TableHead>
<TableHead>Age</TableHead> <TableHead>Age</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{serviceAccounts.length === 0 ? ( {serviceAccounts.length === 0 ? (
<TableRow> <TableRow>
<TableCell colSpan={4} className="text-center text-muted-foreground"> <TableCell colSpan={5} className="text-center text-muted-foreground">
No service accounts found No service accounts found
</TableCell> </TableCell>
</TableRow> </TableRow>
@ -34,11 +87,52 @@ export function ServiceAccountList({ serviceAccounts, _clusterId, _namespace }:
<TableCell>{sa.namespace}</TableCell> <TableCell>{sa.namespace}</TableCell>
<TableCell>{sa.secrets}</TableCell> <TableCell>{sa.secrets}</TableCell>
<TableCell className="text-muted-foreground">{sa.age}</TableCell> <TableCell className="text-muted-foreground">{sa.age}</TableCell>
<TableCell className="text-right">
<ResourceActionMenu
actions={[
{
label: "Edit",
icon: Pencil,
onClick: () => openEdit(sa),
},
{
label: "Delete",
icon: Trash2,
variant: "destructive",
onClick: () => setActiveModal({ type: "delete", sa }),
},
]}
/>
</TableCell>
</TableRow> </TableRow>
)) ))
)} )}
</TableBody> </TableBody>
</Table> </Table>
</div> </div>
{activeModal?.type === "edit" && (
<EditResourceModal
isOpen
clusterId={cid}
namespace={ns}
resourceType="serviceaccounts"
resourceName={activeModal.sa.name}
initialYaml={activeModal.yaml}
onClose={() => { setActiveModal(null); onRefresh?.(); }}
/>
)}
{activeModal?.type === "delete" && (
<ConfirmDeleteDialog
open
onOpenChange={(o) => { if (!o) setActiveModal(null); }}
resourceType="ServiceAccount"
resourceName={activeModal.sa.name}
isLoading={isDeleting}
onConfirm={handleDelete}
/>
)}
</>
); );
} }

View File

@ -1,15 +1,30 @@
import React from "react"; import React, { useState } from "react";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui";
import { Badge } from "@/components/ui"; import { Badge } from "@/components/ui";
import { Pencil, Trash2 } from "lucide-react";
import type { ServiceInfo } from "@/lib/tauriCommands"; import type { ServiceInfo } from "@/lib/tauriCommands";
import { deleteResourceCmd, getResourceYamlCmd } from "@/lib/tauriCommands";
import { ResourceActionMenu } from "./ResourceActionMenu";
import { ConfirmDeleteDialog } from "./ConfirmDeleteDialog";
import { EditResourceModal } from "./EditResourceModal";
interface ServiceListProps { interface ServiceListProps {
services: ServiceInfo[]; services: ServiceInfo[];
clusterId: string; clusterId: string;
namespace: string; namespace: string;
onRefresh?: () => void;
} }
export function ServiceList({ services, clusterId: _clusterId, namespace: _namespace }: ServiceListProps) { type ActiveModal =
| { type: "edit"; svc: ServiceInfo; yaml: string }
| { type: "delete"; svc: ServiceInfo }
| null;
export function ServiceList({ services, clusterId, namespace, onRefresh }: ServiceListProps) {
const [activeModal, setActiveModal] = useState<ActiveModal>(null);
const [isDeleting, setIsDeleting] = useState(false);
const [actionError, setActionError] = useState<string | null>(null);
const getServiceTypeColor = (type: string) => { const getServiceTypeColor = (type: string) => {
switch (type.toLowerCase()) { switch (type.toLowerCase()) {
case "clusterip": case "clusterip":
@ -25,7 +40,33 @@ export function ServiceList({ services, clusterId: _clusterId, namespace: _names
} }
}; };
const openEdit = async (svc: ServiceInfo) => {
setActionError(null);
try {
const yaml = await getResourceYamlCmd(clusterId, "services", namespace, svc.name);
setActiveModal({ type: "edit", svc, yaml });
} catch (err) {
setActionError(err instanceof Error ? err.message : String(err));
}
};
const handleDelete = async () => {
if (activeModal?.type !== "delete") return;
setIsDeleting(true);
try {
await deleteResourceCmd(clusterId, "services", namespace, activeModal.svc.name);
setActiveModal(null);
onRefresh?.();
} finally {
setIsDeleting(false);
}
};
return ( return (
<>
{actionError && (
<p className="mb-2 text-sm text-destructive">{actionError}</p>
)}
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<Table> <Table>
<TableHeader> <TableHeader>
@ -36,12 +77,13 @@ export function ServiceList({ services, clusterId: _clusterId, namespace: _names
<TableHead>External IP</TableHead> <TableHead>External IP</TableHead>
<TableHead>Ports</TableHead> <TableHead>Ports</TableHead>
<TableHead>Age</TableHead> <TableHead>Age</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{services.length === 0 ? ( {services.length === 0 ? (
<TableRow> <TableRow>
<TableCell colSpan={6} className="text-center text-muted-foreground"> <TableCell colSpan={7} className="text-center text-muted-foreground">
No services found No services found
</TableCell> </TableCell>
</TableRow> </TableRow>
@ -70,11 +112,52 @@ export function ServiceList({ services, clusterId: _clusterId, namespace: _names
</div> </div>
</TableCell> </TableCell>
<TableCell className="text-muted-foreground">{service.age}</TableCell> <TableCell className="text-muted-foreground">{service.age}</TableCell>
<TableCell className="text-right">
<ResourceActionMenu
actions={[
{
label: "Edit",
icon: Pencil,
onClick: () => openEdit(service),
},
{
label: "Delete",
icon: Trash2,
variant: "destructive",
onClick: () => setActiveModal({ type: "delete", svc: service }),
},
]}
/>
</TableCell>
</TableRow> </TableRow>
)) ))
)} )}
</TableBody> </TableBody>
</Table> </Table>
</div> </div>
{activeModal?.type === "edit" && (
<EditResourceModal
isOpen
clusterId={clusterId}
namespace={namespace}
resourceType="services"
resourceName={activeModal.svc.name}
initialYaml={activeModal.yaml}
onClose={() => { setActiveModal(null); onRefresh?.(); }}
/>
)}
{activeModal?.type === "delete" && (
<ConfirmDeleteDialog
open
onOpenChange={(o) => { if (!o) setActiveModal(null); }}
resourceType="Service"
resourceName={activeModal.svc.name}
isLoading={isDeleting}
onConfirm={handleDelete}
/>
)}
</>
); );
} }

View 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>
);
}

View File

@ -1,15 +1,78 @@
import React from "react"; import React, { useState } from "react";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui";
import { Scale, RotateCcw, Pencil, Trash2 } from "lucide-react";
import type { StatefulSetInfo } from "@/lib/tauriCommands"; import type { StatefulSetInfo } from "@/lib/tauriCommands";
import {
scaleStatefulsetCmd,
restartStatefulsetCmd,
deleteResourceCmd,
getResourceYamlCmd,
} from "@/lib/tauriCommands";
import { ResourceActionMenu } from "./ResourceActionMenu";
import { ConfirmDeleteDialog } from "./ConfirmDeleteDialog";
import { ScaleModal } from "./ScaleModal";
import { EditResourceModal } from "./EditResourceModal";
interface StatefulSetListProps { interface StatefulSetListProps {
statefulsets: StatefulSetInfo[]; statefulsets: StatefulSetInfo[];
clusterId: string; clusterId: string;
namespace: string; namespace: string;
onRefresh?: () => void;
} }
export function StatefulSetList({ statefulsets, clusterId: _clusterId, namespace: _namespace }: StatefulSetListProps) { type ActiveModal =
| { type: "scale"; ss: StatefulSetInfo }
| { type: "restart"; ss: StatefulSetInfo }
| { type: "edit"; ss: StatefulSetInfo; yaml: string }
| { type: "delete"; ss: StatefulSetInfo }
| null;
export function StatefulSetList({ statefulsets, clusterId, namespace, onRefresh }: StatefulSetListProps) {
const [activeModal, setActiveModal] = useState<ActiveModal>(null);
const [isActing, setIsActing] = useState(false);
const [actionError, setActionError] = useState<string | null>(null);
const openEdit = async (ss: StatefulSetInfo) => {
setActionError(null);
try {
const yaml = await getResourceYamlCmd(clusterId, "statefulsets", namespace, ss.name);
setActiveModal({ type: "edit", ss, yaml });
} catch (err) {
setActionError(err instanceof Error ? err.message : String(err));
}
};
const handleRestart = async () => {
if (activeModal?.type !== "restart") return;
setIsActing(true);
try {
await restartStatefulsetCmd(clusterId, namespace, activeModal.ss.name);
setActiveModal(null);
onRefresh?.();
} catch (err) {
setActionError(err instanceof Error ? err.message : String(err));
} finally {
setIsActing(false);
}
};
const handleDelete = async () => {
if (activeModal?.type !== "delete") return;
setIsActing(true);
try {
await deleteResourceCmd(clusterId, "statefulsets", namespace, activeModal.ss.name);
setActiveModal(null);
onRefresh?.();
} finally {
setIsActing(false);
}
};
return ( return (
<>
{actionError && (
<p className="mb-2 text-sm text-destructive">{actionError}</p>
)}
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<Table> <Table>
<TableHeader> <TableHeader>
@ -18,12 +81,13 @@ export function StatefulSetList({ statefulsets, clusterId: _clusterId, namespace
<TableHead>Ready</TableHead> <TableHead>Ready</TableHead>
<TableHead>Replicas</TableHead> <TableHead>Replicas</TableHead>
<TableHead>Age</TableHead> <TableHead>Age</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{statefulsets.length === 0 ? ( {statefulsets.length === 0 ? (
<TableRow> <TableRow>
<TableCell colSpan={4} className="text-center text-muted-foreground"> <TableCell colSpan={5} className="text-center text-muted-foreground">
No statefulsets found No statefulsets found
</TableCell> </TableCell>
</TableRow> </TableRow>
@ -34,11 +98,90 @@ export function StatefulSetList({ statefulsets, clusterId: _clusterId, namespace
<TableCell>{ss.ready}</TableCell> <TableCell>{ss.ready}</TableCell>
<TableCell>{ss.replicas}</TableCell> <TableCell>{ss.replicas}</TableCell>
<TableCell className="text-muted-foreground">{ss.age}</TableCell> <TableCell className="text-muted-foreground">{ss.age}</TableCell>
<TableCell className="text-right">
<ResourceActionMenu
actions={[
{
label: "Scale",
icon: Scale,
onClick: () => setActiveModal({ type: "scale", ss }),
},
{
label: "Restart",
icon: RotateCcw,
onClick: () => setActiveModal({ type: "restart", ss }),
},
{
label: "Edit",
icon: Pencil,
onClick: () => openEdit(ss),
},
{
label: "Delete",
icon: Trash2,
variant: "destructive",
onClick: () => setActiveModal({ type: "delete", ss }),
},
]}
/>
</TableCell>
</TableRow> </TableRow>
)) ))
)} )}
</TableBody> </TableBody>
</Table> </Table>
</div> </div>
{activeModal?.type === "scale" && (
<ScaleModal
open
onOpenChange={(o) => { if (!o) setActiveModal(null); }}
resourceType="StatefulSet"
resourceName={activeModal.ss.name}
currentReplicas={activeModal.ss.replicas}
onScale={(replicas) =>
scaleStatefulsetCmd(clusterId, namespace, activeModal.ss.name, replicas).then(() => {
setActiveModal(null);
onRefresh?.();
})
}
/>
)}
{activeModal?.type === "restart" && (
<ConfirmDeleteDialog
open
onOpenChange={(o) => { if (!o) setActiveModal(null); }}
resourceType="StatefulSet"
resourceName={activeModal.ss.name}
isLoading={isActing}
onConfirm={handleRestart}
variant="delete"
/>
)}
{activeModal?.type === "edit" && (
<EditResourceModal
isOpen
clusterId={clusterId}
namespace={namespace}
resourceType="statefulsets"
resourceName={activeModal.ss.name}
initialYaml={activeModal.yaml}
onClose={() => { setActiveModal(null); onRefresh?.(); }}
/>
)}
{activeModal?.type === "delete" && (
<ConfirmDeleteDialog
open
onOpenChange={(o) => { if (!o) setActiveModal(null); }}
resourceType="StatefulSet"
resourceName={activeModal.ss.name}
isLoading={isActing}
onConfirm={handleDelete}
/>
)}
</>
); );
} }

View File

@ -1,15 +1,56 @@
import React from "react"; import React, { useState } from "react";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui";
import { Pencil, Trash2 } from "lucide-react";
import type { StorageClassInfo } from "@/lib/tauriCommands"; import type { StorageClassInfo } from "@/lib/tauriCommands";
import { deleteResourceCmd, getResourceYamlCmd } from "@/lib/tauriCommands";
import { ResourceActionMenu } from "./ResourceActionMenu";
import { ConfirmDeleteDialog } from "./ConfirmDeleteDialog";
import { EditResourceModal } from "./EditResourceModal";
interface StorageClassListProps { interface StorageClassListProps {
storageclasses: StorageClassInfo[]; storageclasses: StorageClassInfo[];
clusterId: string; clusterId: string;
namespace: string; namespace: string;
onRefresh?: () => void;
} }
export function StorageClassList({ storageclasses }: StorageClassListProps) { type ActiveModal =
| { type: "edit"; sc: StorageClassInfo; yaml: string }
| { type: "delete"; sc: StorageClassInfo }
| null;
export function StorageClassList({ storageclasses, clusterId, onRefresh }: StorageClassListProps) {
const [activeModal, setActiveModal] = useState<ActiveModal>(null);
const [isDeleting, setIsDeleting] = useState(false);
const [actionError, setActionError] = useState<string | null>(null);
const openEdit = async (sc: StorageClassInfo) => {
setActionError(null);
try {
const yaml = await getResourceYamlCmd(clusterId, "storageclasses", "", sc.name);
setActiveModal({ type: "edit", sc, yaml });
} catch (err) {
setActionError(err instanceof Error ? err.message : String(err));
}
};
const handleDelete = async () => {
if (activeModal?.type !== "delete") return;
setIsDeleting(true);
try {
await deleteResourceCmd(clusterId, "storageclasses", "", activeModal.sc.name);
setActiveModal(null);
onRefresh?.();
} finally {
setIsDeleting(false);
}
};
return ( return (
<>
{actionError && (
<p className="mb-2 text-sm text-destructive">{actionError}</p>
)}
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<Table> <Table>
<TableHeader> <TableHeader>
@ -20,12 +61,13 @@ export function StorageClassList({ storageclasses }: StorageClassListProps) {
<TableHead>Volume Binding Mode</TableHead> <TableHead>Volume Binding Mode</TableHead>
<TableHead>Expand</TableHead> <TableHead>Expand</TableHead>
<TableHead>Age</TableHead> <TableHead>Age</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{storageclasses.length === 0 ? ( {storageclasses.length === 0 ? (
<TableRow> <TableRow>
<TableCell colSpan={6} className="text-center text-muted-foreground"> <TableCell colSpan={7} className="text-center text-muted-foreground">
No storage classes found No storage classes found
</TableCell> </TableCell>
</TableRow> </TableRow>
@ -38,11 +80,52 @@ export function StorageClassList({ storageclasses }: StorageClassListProps) {
<TableCell className="text-sm">{sc.volume_binding_mode}</TableCell> <TableCell className="text-sm">{sc.volume_binding_mode}</TableCell>
<TableCell className="text-sm">{sc.allow_volume_expansion ? "Yes" : "No"}</TableCell> <TableCell className="text-sm">{sc.allow_volume_expansion ? "Yes" : "No"}</TableCell>
<TableCell className="text-sm text-muted-foreground">{sc.age}</TableCell> <TableCell className="text-sm text-muted-foreground">{sc.age}</TableCell>
<TableCell className="text-right">
<ResourceActionMenu
actions={[
{
label: "Edit",
icon: Pencil,
onClick: () => openEdit(sc),
},
{
label: "Delete",
icon: Trash2,
variant: "destructive",
onClick: () => setActiveModal({ type: "delete", sc }),
},
]}
/>
</TableCell>
</TableRow> </TableRow>
)) ))
)} )}
</TableBody> </TableBody>
</Table> </Table>
</div> </div>
{activeModal?.type === "edit" && (
<EditResourceModal
isOpen
clusterId={clusterId}
namespace=""
resourceType="storageclasses"
resourceName={activeModal.sc.name}
initialYaml={activeModal.yaml}
onClose={() => { setActiveModal(null); onRefresh?.(); }}
/>
)}
{activeModal?.type === "delete" && (
<ConfirmDeleteDialog
open
onOpenChange={(o) => { if (!o) setActiveModal(null); }}
resourceType="StorageClass"
resourceName={activeModal.sc.name}
isLoading={isDeleting}
onConfirm={handleDelete}
/>
)}
</>
); );
} }

View 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>
);
}

View 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>
);
}

View File

@ -49,3 +49,15 @@ export { StorageClassList } from "./StorageClassList";
export { NetworkPolicyList } from "./NetworkPolicyList"; export { NetworkPolicyList } from "./NetworkPolicyList";
export { ResourceQuotaList } from "./ResourceQuotaList"; export { ResourceQuotaList } from "./ResourceQuotaList";
export { LimitRangeList } from "./LimitRangeList"; export { LimitRangeList } from "./LimitRangeList";
export { ReplicationControllerList } from "./ReplicationControllerList";
export { PodDisruptionBudgetList } from "./PodDisruptionBudgetList";
export { PriorityClassList } from "./PriorityClassList";
export { RuntimeClassList } from "./RuntimeClassList";
export { LeaseList } from "./LeaseList";
export { MutatingWebhookList } from "./MutatingWebhookList";
export { ValidatingWebhookList } from "./ValidatingWebhookList";
export { EndpointList } from "./EndpointList";
export { EndpointSliceList } from "./EndpointSliceList";
export { IngressClassList } from "./IngressClassList";
export { NamespaceList } from "./NamespaceList";
export { WorkloadOverview } from "./WorkloadOverview";

View File

@ -1239,3 +1239,255 @@ export const createResourceCmd = (clusterId: string, namespace: string, resource
export const editResourceCmd = (clusterId: string, namespace: string, resourceType: string, resourceName: string, yamlContent: string) => export const editResourceCmd = (clusterId: string, namespace: string, resourceType: string, resourceName: string, yamlContent: string) =>
invoke<void>("edit_resource", { clusterId, namespace, resourceType, resourceName, yamlContent }); invoke<void>("edit_resource", { clusterId, namespace, resourceType, resourceName, yamlContent });
// ─── Missing Resource Types ───────────────────────────────────────────────────
export interface ReplicationControllerInfo {
name: string;
namespace: string;
desired: number;
ready: number;
current: number;
age: string;
}
export interface PodDisruptionBudgetInfo {
name: string;
namespace: string;
min_available: string;
max_unavailable: string;
disruptions_allowed: number;
age: string;
}
export interface PriorityClassInfo {
name: string;
value: number;
global_default: boolean;
age: string;
}
export interface RuntimeClassInfo {
name: string;
handler: string;
age: string;
}
export interface LeaseInfo {
name: string;
namespace: string;
holder: string;
age: string;
}
export interface WebhookConfigInfo {
name: string;
webhooks: number;
age: string;
}
export interface EndpointInfo {
name: string;
namespace: string;
addresses: string[];
ports: string[];
age: string;
}
export interface EndpointSliceInfo {
name: string;
namespace: string;
address_type: string;
endpoints: number;
ports: string[];
age: string;
}
export interface IngressClassInfo {
name: string;
controller: string;
is_default: boolean;
age: string;
}
export interface NamespaceResourceInfo {
name: string;
status: string;
age: string;
}
// ─── Helm Types ───────────────────────────────────────────────────────────────
export interface HelmRepository {
name: string;
url: string;
}
export interface HelmChart {
name: string;
chart_version: string;
app_version: string;
description: string;
repository: string;
}
export interface HelmRelease {
name: string;
namespace: string;
chart: string;
chart_version: string;
app_version: string;
status: string;
updated: string;
}
// ─── Custom Resource / CRD Types ─────────────────────────────────────────────
export interface CrdInfo {
name: string;
group: string;
version: string;
kind: string;
scope: string;
age: string;
}
export interface CustomResourceInfo {
name: string;
namespace: string;
age: string;
}
// ─── Resource Actions ─────────────────────────────────────────────────────────
export interface DescribeResponse {
output: string;
}
export interface LogStreamConfig {
cluster_id: string;
namespace: string;
pod_name: string;
container_name: string;
follow: boolean;
timestamps: boolean;
tail_lines?: number;
}
// ─── New Resource List Commands ───────────────────────────────────────────────
export const listReplicationcontrollersCmd = (clusterId: string, namespace: string) =>
invoke<ReplicationControllerInfo[]>("list_replicationcontrollers", { clusterId, namespace });
export const listPoddisruptionbudgetsCmd = (clusterId: string, namespace: string) =>
invoke<PodDisruptionBudgetInfo[]>("list_poddisruptionbudgets", { clusterId, namespace });
export const listPriorityclassesCmd = (clusterId: string) =>
invoke<PriorityClassInfo[]>("list_priorityclasses", { clusterId });
export const listRuntimeclassesCmd = (clusterId: string) =>
invoke<RuntimeClassInfo[]>("list_runtimeclasses", { clusterId });
export const listLeasesCmd = (clusterId: string, namespace: string) =>
invoke<LeaseInfo[]>("list_leases", { clusterId, namespace });
export const listMutatingwebhookconfigurationsCmd = (clusterId: string) =>
invoke<WebhookConfigInfo[]>("list_mutatingwebhookconfigurations", { clusterId });
export const listValidatingwebhookconfigurationsCmd = (clusterId: string) =>
invoke<WebhookConfigInfo[]>("list_validatingwebhookconfigurations", { clusterId });
export const listEndpointsCmd = (clusterId: string, namespace: string) =>
invoke<EndpointInfo[]>("list_endpoints", { clusterId, namespace });
export const listEndpointslicesCmd = (clusterId: string, namespace: string) =>
invoke<EndpointSliceInfo[]>("list_endpointslices", { clusterId, namespace });
export const listIngressclassesCmd = (clusterId: string) =>
invoke<IngressClassInfo[]>("list_ingressclasses", { clusterId });
export const listNamespacesResourceCmd = (clusterId: string) =>
invoke<NamespaceResourceInfo[]>("list_namespaces_resource", { clusterId });
export const createNamespaceCmd = (clusterId: string, name: string) =>
invoke<void>("create_namespace", { clusterId, name });
export const deleteNamespaceCmd = (clusterId: string, name: string) =>
invoke<void>("delete_namespace", { clusterId, name });
// ─── Resource Action Commands ─────────────────────────────────────────────────
export const attachPodCmd = (clusterId: string, namespace: string, podName: string, containerName: string) =>
invoke<ExecSessionResponse>("attach_pod", { clusterId, namespace, podName, containerName });
export const forceDeleteResourceCmd = (clusterId: string, resourceType: string, namespace: string, resourceName: string) =>
invoke<void>("force_delete_resource", { clusterId, resourceType, namespace, resourceName });
export const describeResourceCmd = (clusterId: string, resourceType: string, namespace: string, resourceName: string) =>
invoke<DescribeResponse>("describe_resource", { clusterId, resourceType, namespace, resourceName });
export const getResourceYamlCmd = (clusterId: string, resourceType: string, namespace: string, resourceName: string) =>
invoke<string>("get_resource_yaml", { clusterId, resourceType, namespace, resourceName });
export const restartStatefulsetCmd = (clusterId: string, namespace: string, name: string) =>
invoke<void>("restart_statefulset", { clusterId, namespace, name });
export const restartDaemonsetCmd = (clusterId: string, namespace: string, name: string) =>
invoke<void>("restart_daemonset", { clusterId, namespace, name });
export const scaleStatefulsetCmd = (clusterId: string, namespace: string, name: string, replicas: number) =>
invoke<void>("scale_statefulset", { clusterId, namespace, name, replicas });
export const scaleReplicasetCmd = (clusterId: string, namespace: string, name: string, replicas: number) =>
invoke<void>("scale_replicaset", { clusterId, namespace, name, replicas });
export const scaleReplicationcontrollerCmd = (clusterId: string, namespace: string, name: string, replicas: number) =>
invoke<void>("scale_replicationcontroller", { clusterId, namespace, name, replicas });
export const suspendCronjobCmd = (clusterId: string, namespace: string, name: string) =>
invoke<void>("suspend_cronjob", { clusterId, namespace, name });
export const resumeCronjobCmd = (clusterId: string, namespace: string, name: string) =>
invoke<void>("resume_cronjob", { clusterId, namespace, name });
export const triggerCronjobCmd = (clusterId: string, namespace: string, name: string) =>
invoke<void>("trigger_cronjob", { clusterId, namespace, name });
// ─── Log Streaming Commands ───────────────────────────────────────────────────
export const streamPodLogsCmd = (config: LogStreamConfig) =>
invoke<string>("stream_pod_logs", { config });
export const stopLogStreamCmd = (streamId: string) =>
invoke<void>("stop_log_stream", { streamId });
// ─── Helm Commands ────────────────────────────────────────────────────────────
export const helmListReposCmd = (clusterId: string) =>
invoke<HelmRepository[]>("helm_list_repos", { clusterId });
export const helmAddRepoCmd = (clusterId: string, name: string, url: string) =>
invoke<void>("helm_add_repo", { clusterId, name, url });
export const helmUpdateReposCmd = (clusterId: string) =>
invoke<void>("helm_update_repos", { clusterId });
export const helmSearchRepoCmd = (clusterId: string, query: string) =>
invoke<HelmChart[]>("helm_search_repo", { clusterId, query });
export const helmListReleasesCmd = (clusterId: string, namespace: string) =>
invoke<HelmRelease[]>("helm_list_releases", { clusterId, namespace });
export const helmUninstallCmd = (clusterId: string, namespace: string, releaseName: string) =>
invoke<void>("helm_uninstall", { clusterId, namespace, releaseName });
export const helmRollbackCmd = (clusterId: string, namespace: string, releaseName: string, revision?: number) =>
invoke<void>("helm_rollback", { clusterId, namespace, releaseName, revision });
// ─── CRD / Custom Resource Commands ──────────────────────────────────────────
export const listCrdsCmd = (clusterId: string) =>
invoke<CrdInfo[]>("list_crds", { clusterId });
export const listCustomResourcesCmd = (clusterId: string, group: string, version: string, resource: string, namespace: string) =>
invoke<CustomResourceInfo[]>("list_custom_resources", { clusterId, group, version, resource, namespace });

View File

@ -10,6 +10,10 @@ import {
RefreshCw, RefreshCw,
Plus, Plus,
Package, Package,
Settings2,
Box,
Bell,
Puzzle,
} from "lucide-react"; } from "lucide-react";
import { useKubernetesStore } from "@/stores/kubernetesStore"; import { useKubernetesStore } from "@/stores/kubernetesStore";
import { import {
@ -54,6 +58,18 @@ import {
NetworkPolicyList, NetworkPolicyList,
ResourceQuotaList, ResourceQuotaList,
LimitRangeList, LimitRangeList,
ReplicationControllerList,
PodDisruptionBudgetList,
PriorityClassList,
RuntimeClassList,
LeaseList,
MutatingWebhookList,
ValidatingWebhookList,
EndpointList,
EndpointSliceList,
IngressClassList,
NamespaceList,
WorkloadOverview,
} from "@/components/Kubernetes"; } from "@/components/Kubernetes";
import type { import type {
KubeconfigInfo, KubeconfigInfo,
@ -84,6 +100,19 @@ import type {
NetworkPolicyInfo, NetworkPolicyInfo,
ResourceQuotaInfo, ResourceQuotaInfo,
LimitRangeInfo, LimitRangeInfo,
ReplicationControllerInfo,
PodDisruptionBudgetInfo,
PriorityClassInfo,
RuntimeClassInfo,
LeaseInfo,
WebhookConfigInfo,
EndpointInfo,
EndpointSliceInfo,
IngressClassInfo,
NamespaceResourceInfo,
HelmChart,
HelmRelease,
CrdInfo,
} from "@/lib/tauriCommands"; } from "@/lib/tauriCommands";
import { import {
listKubeconfigsCmd, listKubeconfigsCmd,
@ -119,108 +148,181 @@ import {
listNetworkpoliciesCmd, listNetworkpoliciesCmd,
listResourcequotasCmd, listResourcequotasCmd,
listLimitrangesCmd, listLimitrangesCmd,
listReplicationcontrollersCmd,
listPoddisruptionbudgetsCmd,
listPriorityclassesCmd,
listRuntimeclassesCmd,
listLeasesCmd,
listMutatingwebhookconfigurationsCmd,
listValidatingwebhookconfigurationsCmd,
listEndpointsCmd,
listEndpointslicesCmd,
listIngressclassesCmd,
listNamespacesResourceCmd,
helmSearchRepoCmd,
helmListReleasesCmd,
listCrdsCmd,
} from "@/lib/tauriCommands"; } from "@/lib/tauriCommands";
// ─── Types ──────────────────────────────────────────────────────────────────── // ─── Types ────────────────────────────────────────────────────────────────────
type ActiveSection = type ActiveSection =
| "overview" | "cluster_overview"
| "nodes"
| "workloads_overview"
| "pods" | "pods"
| "deployments" | "deployments"
| "daemonsets" | "daemonsets"
| "statefulsets" | "statefulsets"
| "replicasets" | "replicasets"
| "replicationcontrollers"
| "jobs" | "jobs"
| "cronjobs" | "cronjobs"
| "services"
| "ingresses"
| "configmaps" | "configmaps"
| "secrets" | "secrets"
| "resourcequotas"
| "limitranges"
| "hpas" | "hpas"
| "poddisruptionbudgets"
| "priorityclasses"
| "runtimeclasses"
| "leases"
| "mutatingwebhooks"
| "validatingwebhooks"
| "services"
| "endpointslices"
| "endpoints"
| "ingresses"
| "ingressclasses"
| "networkpolicies"
| "portforwarding"
| "pvcs" | "pvcs"
| "pvs" | "pvs"
| "serviceaccounts"
| "roles"
| "clusterroles"
| "rolebindings"
| "clusterrolebindings"
| "nodes"
| "events"
| "portforwarding"
| "storageclasses" | "storageclasses"
| "networkpolicies" | "namespaces"
| "resourcequotas" | "events"
| "limitranges"; | "helm_charts"
| "helm_releases"
| "serviceaccounts"
| "clusterroles"
| "roles"
| "clusterrolebindings"
| "rolebindings"
| "crds";
interface NavItem { interface NavItem {
id: ActiveSection; id: ActiveSection;
label: string; label: string;
} }
interface NavSection { interface NavGroup {
type: "group";
label: string; label: string;
icon: React.ElementType; icon: React.ElementType;
items: NavItem[]; items: NavItem[];
} }
interface NavTopLevel {
type: "toplevel";
id: ActiveSection;
label: string;
icon: React.ElementType;
}
type NavEntry = NavGroup | NavTopLevel;
// ─── Nav structure ──────────────────────────────────────────────────────────── // ─── Nav structure ────────────────────────────────────────────────────────────
const NAV_SECTIONS: NavSection[] = [ const NAV_ENTRIES: NavEntry[] = [
{ type: "toplevel", id: "cluster_overview", label: "Cluster", icon: Server },
{ type: "toplevel", id: "nodes", label: "Nodes", icon: Server },
{ {
type: "group",
label: "Workloads", label: "Workloads",
icon: Layers, icon: Layers,
items: [ items: [
{ id: "workloads_overview", label: "Overview" },
{ id: "pods", label: "Pods" }, { id: "pods", label: "Pods" },
{ id: "deployments", label: "Deployments" }, { id: "deployments", label: "Deployments" },
{ id: "daemonsets", label: "Daemon Sets" }, { id: "daemonsets", label: "Daemon Sets" },
{ id: "statefulsets", label: "Stateful Sets" }, { id: "statefulsets", label: "Stateful Sets" },
{ id: "replicasets", label: "Replica Sets" }, { id: "replicasets", label: "Replica Sets" },
{ id: "replicationcontrollers", label: "Replication Controllers" },
{ id: "jobs", label: "Jobs" }, { id: "jobs", label: "Jobs" },
{ id: "cronjobs", label: "Cron Jobs" }, { id: "cronjobs", label: "Cron Jobs" },
], ],
}, },
{ {
label: "Services & Networking", type: "group",
icon: Network, label: "Config",
items: [ icon: Settings2,
{ id: "services", label: "Services" },
{ id: "ingresses", label: "Ingresses" },
{ id: "networkpolicies", label: "Network Policies" },
],
},
{
label: "Config & Storage",
icon: Database,
items: [ items: [
{ id: "configmaps", label: "Config Maps" }, { id: "configmaps", label: "Config Maps" },
{ id: "secrets", label: "Secrets" }, { id: "secrets", label: "Secrets" },
{ id: "hpas", label: "Horizontal Pod Autoscalers" },
{ id: "pvcs", label: "Persistent Volume Claims" },
{ id: "pvs", label: "Persistent Volumes" },
{ id: "storageclasses", label: "Storage Classes" },
{ id: "resourcequotas", label: "Resource Quotas" }, { id: "resourcequotas", label: "Resource Quotas" },
{ id: "limitranges", label: "Limit Ranges" }, { id: "limitranges", label: "Limit Ranges" },
{ id: "hpas", label: "Horizontal Pod Autoscalers" },
{ id: "poddisruptionbudgets", label: "Pod Disruption Budgets" },
{ id: "priorityclasses", label: "Priority Classes" },
{ id: "runtimeclasses", label: "Runtime Classes" },
{ id: "leases", label: "Leases" },
{ id: "mutatingwebhooks", label: "Mutating Webhook Configs" },
{ id: "validatingwebhooks", label: "Validating Webhook Configs" },
], ],
}, },
{ {
type: "group",
label: "Network",
icon: Network,
items: [
{ id: "services", label: "Services" },
{ id: "endpointslices", label: "Endpoint Slices" },
{ id: "endpoints", label: "Endpoints" },
{ id: "ingresses", label: "Ingresses" },
{ id: "ingressclasses", label: "Ingress Classes" },
{ id: "networkpolicies", label: "Network Policies" },
{ id: "portforwarding", label: "Port Forwarding" },
],
},
{
type: "group",
label: "Storage",
icon: Database,
items: [
{ id: "pvcs", label: "Persistent Volume Claims" },
{ id: "pvs", label: "Persistent Volumes" },
{ id: "storageclasses", label: "Storage Classes" },
],
},
{ type: "toplevel", id: "namespaces", label: "Namespaces", icon: Box },
{ type: "toplevel", id: "events", label: "Events", icon: Bell },
{
type: "group",
label: "Helm",
icon: Package,
items: [
{ id: "helm_charts", label: "Charts" },
{ id: "helm_releases", label: "Releases" },
],
},
{
type: "group",
label: "Access Control", label: "Access Control",
icon: Shield, icon: Shield,
items: [ items: [
{ id: "serviceaccounts", label: "Service Accounts" }, { id: "serviceaccounts", label: "Service Accounts" },
{ id: "roles", label: "Roles" },
{ id: "clusterroles", label: "Cluster Roles" }, { id: "clusterroles", label: "Cluster Roles" },
{ id: "rolebindings", label: "Role Bindings" }, { id: "roles", label: "Roles" },
{ id: "clusterrolebindings", label: "Cluster Role Bindings" }, { id: "clusterrolebindings", label: "Cluster Role Bindings" },
{ id: "rolebindings", label: "Role Bindings" },
], ],
}, },
{ {
label: "Cluster", type: "group",
icon: Server, label: "Custom Resources",
icon: Puzzle,
items: [ items: [
{ id: "overview", label: "Overview" }, { id: "crds", label: "Definitions" },
{ id: "nodes", label: "Nodes" },
{ id: "events", label: "Events" },
{ id: "portforwarding", label: "Port Forwarding" },
], ],
}, },
]; ];
@ -253,6 +355,20 @@ interface ResourceData {
networkpolicies: NetworkPolicyInfo[]; networkpolicies: NetworkPolicyInfo[];
resourcequotas: ResourceQuotaInfo[]; resourcequotas: ResourceQuotaInfo[];
limitranges: LimitRangeInfo[]; limitranges: LimitRangeInfo[];
replicationcontrollers: ReplicationControllerInfo[];
poddisruptionbudgets: PodDisruptionBudgetInfo[];
priorityclasses: PriorityClassInfo[];
runtimeclasses: RuntimeClassInfo[];
leases: LeaseInfo[];
mutatingwebhooks: WebhookConfigInfo[];
validatingwebhooks: WebhookConfigInfo[];
endpoints: EndpointInfo[];
endpointslices: EndpointSliceInfo[];
ingressclasses: IngressClassInfo[];
namespaces_resource: NamespaceResourceInfo[];
helm_charts: HelmChart[];
helm_releases: HelmRelease[];
crds: CrdInfo[];
} }
const EMPTY_RESOURCES: ResourceData = { const EMPTY_RESOURCES: ResourceData = {
@ -281,6 +397,20 @@ const EMPTY_RESOURCES: ResourceData = {
networkpolicies: [], networkpolicies: [],
resourcequotas: [], resourcequotas: [],
limitranges: [], limitranges: [],
replicationcontrollers: [],
poddisruptionbudgets: [],
priorityclasses: [],
runtimeclasses: [],
leases: [],
mutatingwebhooks: [],
validatingwebhooks: [],
endpoints: [],
endpointslices: [],
ingressclasses: [],
namespaces_resource: [],
helm_charts: [],
helm_releases: [],
crds: [],
}; };
// ─── Component ─────────────────────────────────────────────────────────────── // ─── Component ───────────────────────────────────────────────────────────────
@ -293,20 +423,21 @@ export function KubernetesPage() {
const [namespaces, setNamespaces] = useState<NamespaceInfo[]>([]); const [namespaces, setNamespaces] = useState<NamespaceInfo[]>([]);
const [portForwards, setPortForwards] = useState<PortForwardResponse[]>([]); const [portForwards, setPortForwards] = useState<PortForwardResponse[]>([]);
const [resources, setResources] = useState<ResourceData>(EMPTY_RESOURCES); const [resources, setResources] = useState<ResourceData>(EMPTY_RESOURCES);
const [activeSection, setActiveSection] = useState<ActiveSection>("overview"); const [activeSection, setActiveSection] = useState<ActiveSection>("cluster_overview");
const [expandedSections, setExpandedSections] = useState<Record<string, boolean>>({ const [expandedSections, setExpandedSections] = useState<Record<string, boolean>>({
Workloads: true, Workloads: true,
"Services & Networking": true, Config: true,
"Config & Storage": true, Network: true,
Storage: true,
Helm: false,
"Access Control": true, "Access Control": true,
Cluster: true, "Custom Resources": false,
}); });
const [isLoadingResources, setIsLoadingResources] = useState(false); const [isLoadingResources, setIsLoadingResources] = useState(false);
const [isCommandPaletteOpen, setIsCommandPaletteOpen] = useState(false); const [isCommandPaletteOpen, setIsCommandPaletteOpen] = useState(false);
const [isPortForwardFormOpen, setIsPortForwardFormOpen] = useState(false); const [isPortForwardFormOpen, setIsPortForwardFormOpen] = useState(false);
const [isNotificationsOpen, setIsNotificationsOpen] = useState(false); const [isNotificationsOpen, setIsNotificationsOpen] = useState(false);
// Track the last loaded section to avoid redundant fetches
const lastLoadedRef = useRef<{ section: ActiveSection; clusterId: string; namespace: string } | null>(null); const lastLoadedRef = useRef<{ section: ActiveSection; clusterId: string; namespace: string } | null>(null);
// ── Initial data load ────────────────────────────────────────────────────── // ── Initial data load ──────────────────────────────────────────────────────
@ -350,7 +481,13 @@ export function KubernetesPage() {
const loadResourceData = useCallback( const loadResourceData = useCallback(
async (section: ActiveSection, clusterId: string, namespace: string) => { async (section: ActiveSection, clusterId: string, namespace: string) => {
if (section === "overview" || section === "portforwarding") return; if (
section === "cluster_overview" ||
section === "portforwarding" ||
section === "workloads_overview"
) {
return;
}
const ns = namespace === "all" ? "" : namespace; const ns = namespace === "all" ? "" : namespace;
@ -358,8 +495,6 @@ export function KubernetesPage() {
try { try {
switch (section) { switch (section) {
case "pods": case "pods":
setResources((r) => ({ ...r, pods: [] }));
setResources((r) => ({ ...r }));
await listPodsCmd(clusterId, ns).then((data) => await listPodsCmd(clusterId, ns).then((data) =>
setResources((r) => ({ ...r, pods: data })) setResources((r) => ({ ...r, pods: data }))
); );
@ -384,6 +519,11 @@ export function KubernetesPage() {
setResources((r) => ({ ...r, replicasets: data })) setResources((r) => ({ ...r, replicasets: data }))
); );
break; break;
case "replicationcontrollers":
await listReplicationcontrollersCmd(clusterId, ns).then((data) =>
setResources((r) => ({ ...r, replicationcontrollers: data }))
);
break;
case "jobs": case "jobs":
await listJobsCmd(clusterId, ns).then((data) => await listJobsCmd(clusterId, ns).then((data) =>
setResources((r) => ({ ...r, jobs: data })) setResources((r) => ({ ...r, jobs: data }))
@ -484,6 +624,71 @@ export function KubernetesPage() {
setResources((r) => ({ ...r, limitranges: data })) setResources((r) => ({ ...r, limitranges: data }))
); );
break; break;
case "poddisruptionbudgets":
await listPoddisruptionbudgetsCmd(clusterId, ns).then((data) =>
setResources((r) => ({ ...r, poddisruptionbudgets: data }))
);
break;
case "priorityclasses":
await listPriorityclassesCmd(clusterId).then((data) =>
setResources((r) => ({ ...r, priorityclasses: data }))
);
break;
case "runtimeclasses":
await listRuntimeclassesCmd(clusterId).then((data) =>
setResources((r) => ({ ...r, runtimeclasses: data }))
);
break;
case "leases":
await listLeasesCmd(clusterId, ns).then((data) =>
setResources((r) => ({ ...r, leases: data }))
);
break;
case "mutatingwebhooks":
await listMutatingwebhookconfigurationsCmd(clusterId).then((data) =>
setResources((r) => ({ ...r, mutatingwebhooks: data }))
);
break;
case "validatingwebhooks":
await listValidatingwebhookconfigurationsCmd(clusterId).then((data) =>
setResources((r) => ({ ...r, validatingwebhooks: data }))
);
break;
case "endpoints":
await listEndpointsCmd(clusterId, ns).then((data) =>
setResources((r) => ({ ...r, endpoints: data }))
);
break;
case "endpointslices":
await listEndpointslicesCmd(clusterId, ns).then((data) =>
setResources((r) => ({ ...r, endpointslices: data }))
);
break;
case "ingressclasses":
await listIngressclassesCmd(clusterId).then((data) =>
setResources((r) => ({ ...r, ingressclasses: data }))
);
break;
case "namespaces":
await listNamespacesResourceCmd(clusterId).then((data) =>
setResources((r) => ({ ...r, namespaces_resource: data }))
);
break;
case "helm_charts":
await helmSearchRepoCmd(clusterId, "").then((data) =>
setResources((r) => ({ ...r, helm_charts: data }))
);
break;
case "helm_releases":
await helmListReleasesCmd(clusterId, ns).then((data) =>
setResources((r) => ({ ...r, helm_releases: data }))
);
break;
case "crds":
await listCrdsCmd(clusterId).then((data) =>
setResources((r) => ({ ...r, crds: data }))
);
break;
} }
lastLoadedRef.current = { section, clusterId, namespace }; lastLoadedRef.current = { section, clusterId, namespace };
} catch (err) { } catch (err) {
@ -593,7 +798,7 @@ export function KubernetesPage() {
); );
} }
if (activeSection === "overview") { if (activeSection === "cluster_overview") {
return ( return (
<ClusterOverview <ClusterOverview
clusterId={selectedClusterId} clusterId={selectedClusterId}
@ -602,6 +807,22 @@ export function KubernetesPage() {
); );
} }
if (activeSection === "workloads_overview") {
return (
<WorkloadOverview
clusterId={selectedClusterId}
resources={{
pods: resources.pods,
deployments: resources.deployments,
statefulsets: resources.statefulsets,
daemonsets: resources.daemonsets,
jobs: resources.jobs,
cronjobs: resources.cronjobs,
}}
/>
);
}
if (activeSection === "portforwarding") { if (activeSection === "portforwarding") {
return ( return (
<div className="p-6 space-y-4"> <div className="p-6 space-y-4">
@ -647,35 +868,37 @@ export function KubernetesPage() {
case "statefulsets": case "statefulsets":
return <StatefulSetList statefulsets={resources.statefulsets} clusterId={cid} namespace={ns} />; return <StatefulSetList statefulsets={resources.statefulsets} clusterId={cid} namespace={ns} />;
case "replicasets": case "replicasets":
return <ReplicaSetList replicaSets={resources.replicasets} _clusterId={cid} _namespace={ns} />; return <ReplicaSetList replicaSets={resources.replicasets} clusterId={cid} namespace={ns} />;
case "replicationcontrollers":
return <ReplicationControllerList items={resources.replicationcontrollers} clusterId={cid} namespace={ns} />;
case "jobs": case "jobs":
return <JobList jobs={resources.jobs} _clusterId={cid} _namespace={ns} />; return <JobList jobs={resources.jobs} clusterId={cid} namespace={ns} />;
case "cronjobs": case "cronjobs":
return <CronJobList cronJobs={resources.cronjobs} _clusterId={cid} _namespace={ns} />; return <CronJobList cronJobs={resources.cronjobs} clusterId={cid} namespace={ns} />;
case "services": case "services":
return <ServiceList services={resources.services} clusterId={cid} namespace={ns} />; return <ServiceList services={resources.services} clusterId={cid} namespace={ns} />;
case "ingresses": case "ingresses":
return <IngressList ingresses={resources.ingresses} _clusterId={cid} _namespace={ns} />; return <IngressList ingresses={resources.ingresses} clusterId={cid} namespace={ns} />;
case "configmaps": case "configmaps":
return <ConfigMapList configmaps={resources.configmaps} clusterId={cid} namespace={ns} />; return <ConfigMapList configmaps={resources.configmaps} clusterId={cid} namespace={ns} />;
case "secrets": case "secrets":
return <SecretList secrets={resources.secrets} _clusterId={cid} _namespace={ns} />; return <SecretList secrets={resources.secrets} clusterId={cid} namespace={ns} />;
case "hpas": case "hpas":
return <HPAList hpas={resources.hpas} _clusterId={cid} _namespace={ns} />; return <HPAList hpas={resources.hpas} clusterId={cid} namespace={ns} />;
case "pvcs": case "pvcs":
return <PVCList pvcs={resources.pvcs} _clusterId={cid} _namespace={ns} />; return <PVCList pvcs={resources.pvcs} clusterId={cid} namespace={ns} />;
case "pvs": case "pvs":
return <PVList pvs={resources.pvs} _clusterId={cid} />; return <PVList pvs={resources.pvs} clusterId={cid} />;
case "serviceaccounts": case "serviceaccounts":
return <ServiceAccountList serviceAccounts={resources.serviceaccounts} _clusterId={cid} _namespace={ns} />; return <ServiceAccountList serviceAccounts={resources.serviceaccounts} clusterId={cid} namespace={ns} />;
case "roles": case "roles":
return <RoleList roles={resources.roles} _clusterId={cid} _namespace={ns} />; return <RoleList roles={resources.roles} clusterId={cid} namespace={ns} />;
case "clusterroles": case "clusterroles":
return <ClusterRoleList clusterRoles={resources.clusterroles} _clusterId={cid} />; return <ClusterRoleList clusterRoles={resources.clusterroles} clusterId={cid} />;
case "rolebindings": case "rolebindings":
return <RoleBindingList roleBindings={resources.rolebindings} _clusterId={cid} _namespace={ns} />; return <RoleBindingList roleBindings={resources.rolebindings} clusterId={cid} namespace={ns} />;
case "clusterrolebindings": case "clusterrolebindings":
return <ClusterRoleBindingList clusterRoleBindings={resources.clusterrolebindings} _clusterId={cid} />; return <ClusterRoleBindingList clusterRoleBindings={resources.clusterrolebindings} clusterId={cid} />;
case "nodes": case "nodes":
return <NodeList nodes={resources.nodes} clusterId={cid} />; return <NodeList nodes={resources.nodes} clusterId={cid} />;
case "events": case "events":
@ -688,6 +911,142 @@ export function KubernetesPage() {
return <ResourceQuotaList resourcequotas={resources.resourcequotas} clusterId={cid} namespace={ns} />; return <ResourceQuotaList resourcequotas={resources.resourcequotas} clusterId={cid} namespace={ns} />;
case "limitranges": case "limitranges":
return <LimitRangeList limitranges={resources.limitranges} clusterId={cid} namespace={ns} />; return <LimitRangeList limitranges={resources.limitranges} clusterId={cid} namespace={ns} />;
case "poddisruptionbudgets":
return <PodDisruptionBudgetList items={resources.poddisruptionbudgets} clusterId={cid} namespace={ns} />;
case "priorityclasses":
return <PriorityClassList items={resources.priorityclasses} clusterId={cid} />;
case "runtimeclasses":
return <RuntimeClassList items={resources.runtimeclasses} clusterId={cid} />;
case "leases":
return <LeaseList items={resources.leases} clusterId={cid} namespace={ns} />;
case "mutatingwebhooks":
return <MutatingWebhookList items={resources.mutatingwebhooks} clusterId={cid} />;
case "validatingwebhooks":
return <ValidatingWebhookList items={resources.validatingwebhooks} clusterId={cid} />;
case "endpoints":
return <EndpointList items={resources.endpoints} clusterId={cid} namespace={ns} />;
case "endpointslices":
return <EndpointSliceList items={resources.endpointslices} clusterId={cid} namespace={ns} />;
case "ingressclasses":
return <IngressClassList items={resources.ingressclasses} clusterId={cid} />;
case "namespaces":
return <NamespaceList items={resources.namespaces_resource} clusterId={cid} />;
case "helm_charts":
return (
<div className="p-6">
<h2 className="text-xl font-semibold mb-4">Helm Charts</h2>
{resources.helm_charts.length === 0 ? (
<p className="text-muted-foreground">No charts found. Add a Helm repository to browse charts.</p>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm border-collapse">
<thead>
<tr className="border-b text-muted-foreground text-left">
<th className="px-4 py-3 font-medium">Name</th>
<th className="px-4 py-3 font-medium">Repository</th>
<th className="px-4 py-3 font-medium">Chart Version</th>
<th className="px-4 py-3 font-medium">App Version</th>
<th className="px-4 py-3 font-medium">Description</th>
</tr>
</thead>
<tbody>
{resources.helm_charts.map((chart) => (
<tr key={`${chart.repository}-${chart.name}`} className="border-b hover:bg-muted/30 transition-colors">
<td className="px-4 py-3 font-medium">{chart.name}</td>
<td className="px-4 py-3 text-muted-foreground">{chart.repository}</td>
<td className="px-4 py-3 font-mono text-xs">{chart.chart_version}</td>
<td className="px-4 py-3 font-mono text-xs">{chart.app_version}</td>
<td className="px-4 py-3 text-muted-foreground truncate max-w-xs">{chart.description}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
);
case "helm_releases":
return (
<div className="p-6">
<h2 className="text-xl font-semibold mb-4">Helm Releases</h2>
{resources.helm_releases.length === 0 ? (
<p className="text-muted-foreground">No Helm releases found in this namespace.</p>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm border-collapse">
<thead>
<tr className="border-b text-muted-foreground text-left">
<th className="px-4 py-3 font-medium">Name</th>
<th className="px-4 py-3 font-medium">Namespace</th>
<th className="px-4 py-3 font-medium">Chart</th>
<th className="px-4 py-3 font-medium">App Version</th>
<th className="px-4 py-3 font-medium">Status</th>
<th className="px-4 py-3 font-medium">Updated</th>
</tr>
</thead>
<tbody>
{resources.helm_releases.map((rel) => (
<tr key={`${rel.namespace}-${rel.name}`} className="border-b hover:bg-muted/30 transition-colors">
<td className="px-4 py-3 font-medium">{rel.name}</td>
<td className="px-4 py-3 text-muted-foreground">{rel.namespace}</td>
<td className="px-4 py-3 font-mono text-xs">{rel.chart} {rel.chart_version}</td>
<td className="px-4 py-3 font-mono text-xs">{rel.app_version}</td>
<td className="px-4 py-3">
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${
rel.status === "deployed"
? "bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400"
: rel.status === "failed"
? "bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400"
: "bg-muted text-muted-foreground"
}`}>
{rel.status}
</span>
</td>
<td className="px-4 py-3 text-muted-foreground text-xs">{rel.updated}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
);
case "crds":
return (
<div className="p-6">
<h2 className="text-xl font-semibold mb-4">Custom Resource Definitions</h2>
{resources.crds.length === 0 ? (
<p className="text-muted-foreground">No custom resource definitions found.</p>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm border-collapse">
<thead>
<tr className="border-b text-muted-foreground text-left">
<th className="px-4 py-3 font-medium">Name</th>
<th className="px-4 py-3 font-medium">Group</th>
<th className="px-4 py-3 font-medium">Version</th>
<th className="px-4 py-3 font-medium">Kind</th>
<th className="px-4 py-3 font-medium">Scope</th>
<th className="px-4 py-3 font-medium">Age</th>
</tr>
</thead>
<tbody>
{resources.crds.map((crd) => (
<tr key={crd.name} className="border-b hover:bg-muted/30 transition-colors">
<td className="px-4 py-3 font-medium font-mono text-xs">{crd.name}</td>
<td className="px-4 py-3 text-muted-foreground">{crd.group}</td>
<td className="px-4 py-3 font-mono text-xs">{crd.version}</td>
<td className="px-4 py-3">{crd.kind}</td>
<td className="px-4 py-3 text-muted-foreground">{crd.scope}</td>
<td className="px-4 py-3 text-muted-foreground">{crd.age}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
);
default: default:
return null; return null;
} }
@ -776,19 +1135,38 @@ export function KubernetesPage() {
<div className="flex flex-1 overflow-hidden"> <div className="flex flex-1 overflow-hidden">
{/* Sidebar */} {/* Sidebar */}
<aside className="w-56 shrink-0 border-r bg-card overflow-y-auto flex flex-col"> <aside className="w-56 shrink-0 border-r bg-card overflow-y-auto flex flex-col">
{NAV_SECTIONS.map((section) => { {NAV_ENTRIES.map((entry) => {
const Icon = section.icon; if (entry.type === "toplevel") {
const isExpanded = expandedSections[section.label] ?? true; const Icon = entry.icon;
return (
<button
key={entry.id}
onClick={() => setActiveSection(entry.id)}
aria-label={entry.label}
className={`flex items-center gap-2 w-full px-3 py-2 text-xs font-semibold uppercase tracking-wider transition-colors ${
activeSection === entry.id
? "bg-primary/10 text-primary border-l-2 border-primary"
: "text-muted-foreground hover:text-foreground hover:bg-accent"
}`}
>
<Icon className="w-3.5 h-3.5" />
<span>{entry.label}</span>
</button>
);
}
const isExpanded = expandedSections[entry.label] ?? true;
const Icon = entry.icon;
return ( return (
<div key={section.label}> <div key={entry.label}>
<button <button
onClick={() => toggleSection(section.label)} onClick={() => toggleSection(entry.label)}
className="flex items-center justify-between w-full px-3 py-2 text-xs font-semibold uppercase tracking-wider text-muted-foreground hover:text-foreground hover:bg-accent transition-colors" className="flex items-center justify-between w-full px-3 py-2 text-xs font-semibold uppercase tracking-wider text-muted-foreground hover:text-foreground hover:bg-accent transition-colors"
> >
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Icon className="w-3.5 h-3.5" /> <Icon className="w-3.5 h-3.5" />
<span>{section.label}</span> <span>{entry.label}</span>
</div> </div>
{isExpanded ? ( {isExpanded ? (
<ChevronDown className="w-3 h-3" /> <ChevronDown className="w-3 h-3" />
@ -799,7 +1177,7 @@ export function KubernetesPage() {
{isExpanded && ( {isExpanded && (
<div className="pb-1"> <div className="pb-1">
{section.items.map((item) => ( {entry.items.map((item) => (
<button <button
key={item.id} key={item.id}
onClick={() => setActiveSection(item.id)} onClick={() => setActiveSection(item.id)}
@ -866,7 +1244,7 @@ export function KubernetesPage() {
<p className="text-sm text-muted-foreground">No cluster connected.</p> <p className="text-sm text-muted-foreground">No cluster connected.</p>
)} )}
<p className="text-xs text-muted-foreground pt-2 border-t"> <p className="text-xs text-muted-foreground pt-2 border-t">
Navigate to <strong>Cluster Events</strong> to view live cluster events. Navigate to <strong>Events</strong> to view live cluster events.
</p> </p>
</div> </div>
</DialogContent> </DialogContent>

View File

@ -174,8 +174,9 @@ describe("KubernetesPage", () => {
await waitFor(() => { await waitFor(() => {
expect(screen.getByText("Workloads")).toBeInTheDocument(); expect(screen.getByText("Workloads")).toBeInTheDocument();
expect(screen.getByText("Services & Networking")).toBeInTheDocument(); expect(screen.getByText("Network")).toBeInTheDocument();
expect(screen.getByText("Config & Storage")).toBeInTheDocument(); expect(screen.getByText("Config")).toBeInTheDocument();
expect(screen.getByText("Storage")).toBeInTheDocument();
expect(screen.getByText("Access Control")).toBeInTheDocument(); expect(screen.getByText("Access Control")).toBeInTheDocument();
expect(screen.getByText("Cluster")).toBeInTheDocument(); expect(screen.getByText("Cluster")).toBeInTheDocument();
}); });
@ -195,7 +196,7 @@ describe("KubernetesPage", () => {
}); });
}); });
it("renders all Services & Networking nav items", async () => { it("renders all Network nav items", async () => {
renderPage(); renderPage();
await waitFor(() => { await waitFor(() => {