diff --git a/.gitea/workflows/pr-review.yml b/.gitea/workflows/pr-review.yml index f815a0bf..4cacd77f 100644 --- a/.gitea/workflows/pr-review.yml +++ b/.gitea/workflows/pr-review.yml @@ -242,7 +242,7 @@ jobs: # Write body to file — passing 100KB+ JSON as a shell arg hits ARG_MAX. jq -cn \ - --arg model "qwen36-35b-a3b-nvfp4" \ + --arg model "qwen3-coder-next" \ --rawfile content /tmp/prompt.txt \ '{model: $model, messages: [{role: "user", content: $content}], stream: false}' \ > /tmp/body.json @@ -359,7 +359,7 @@ jobs: if [ -f "/tmp/pr_review.txt" ] && [ -s "/tmp/pr_review.txt" ]; then REVIEW_BODY=$(head -c 65536 /tmp/pr_review.txt) BODY=$(jq -n \ - --arg body "Automated PR Review (qwen36-35b-a3b-nvfp4 via liteLLM):\n\n${REVIEW_BODY}" \ + --arg body "Automated PR Review (qwen3-coder-next via liteLLM):\n\n${REVIEW_BODY}" \ '{body: $body, event: "COMMENT"}') else BODY=$(jq -n \ diff --git a/TICKET-freelens-feature-inventory.md b/TICKET-freelens-feature-inventory.md new file mode 100644 index 00000000..737f9e5d --- /dev/null +++ b/TICKET-freelens-feature-inventory.md @@ -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 diff --git a/TICKET-kube-ui-feature-parity.md b/TICKET-kube-ui-feature-parity.md new file mode 100644 index 00000000..3511acff --- /dev/null +++ b/TICKET-kube-ui-feature-parity.md @@ -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 diff --git a/eslint.config.js b/eslint.config.js index 87269711..97c8cad8 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -36,7 +36,7 @@ const tsBase = { export default [ { - ignores: ["dist/", "node_modules/", "src-tauri/target/**", "target/**", "coverage/", "tailwind.config.ts"], + ignores: ["dist/", "node_modules/", "src-tauri/target/**", "target/**", "coverage/", "tailwind.config.ts", ".claude/"], }, { files: ["src/**/*.{ts,tsx}"], diff --git a/freelens-feature-inventory.json b/freelens-feature-inventory.json new file mode 100644 index 00000000..858a217d --- /dev/null +++ b/freelens-feature-inventory.json @@ -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: () => ,\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" + } + ] +} diff --git a/scripts/download-helm.sh b/scripts/download-helm.sh new file mode 100644 index 00000000..4001035a --- /dev/null +++ b/scripts/download-helm.sh @@ -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" diff --git a/src-tauri/src/commands/integrations.rs b/src-tauri/src/commands/integrations.rs index 9c4ecd06..4ecdccaf 100644 --- a/src-tauri/src/commands/integrations.rs +++ b/src-tauri/src/commands/integrations.rs @@ -330,6 +330,7 @@ pub async fn initiate_oauth( let port_forwards = app_state.port_forwards.clone(); let refresh_registry = app_state.refresh_registry.clone(); let watchers = app_state.watchers.clone(); + let log_streams = app_state.log_streams.clone(); tokio::spawn(async move { let app_state_for_callback = AppState { @@ -343,6 +344,7 @@ pub async fn initiate_oauth( port_forwards, refresh_registry, watchers, + log_streams, }; while let Some(callback) = callback_rx.recv().await { tracing::info!("Received OAuth callback for state: {}", callback.state); diff --git a/src-tauri/src/commands/kube.rs b/src-tauri/src/commands/kube.rs index 334dbaba..68679252 100644 --- a/src-tauri/src/commands/kube.rs +++ b/src-tauri/src/commands/kube.rs @@ -1,14 +1,16 @@ use crate::kube::portforward::{PortForwardSession, PortForwardSessionConfig}; use crate::kube::ClusterClient; +use crate::shell::helm::locate_helm; use crate::shell::kubectl::locate_kubectl; use crate::state::AppState; use lazy_static::lazy_static; use regex::Regex; use serde::{Deserialize, Serialize}; use serde_json::Value; +use std::collections::HashMap; use std::process::Stdio; use std::sync::Arc; -use tauri::State; +use tauri::{Emitter, State}; use tokio::io::AsyncWriteExt; use tokio::process::Command; use tracing::info; @@ -238,9 +240,9 @@ fn detect_auth_method(kubeconfig: &str, context_name: &str) -> String { .get("contexts") .and_then(|c| c.as_sequence()) .and_then(|contexts| { - contexts.iter().find(|ctx| { - ctx.get("name").and_then(|n| n.as_str()) == Some(context_name) - }) + contexts + .iter() + .find(|ctx| ctx.get("name").and_then(|n| n.as_str()) == Some(context_name)) }) .and_then(|ctx| ctx.get("context")) .and_then(|c| c.get("user")) @@ -252,9 +254,9 @@ fn detect_auth_method(kubeconfig: &str, context_name: &str) -> String { .get("users") .and_then(|u| u.as_sequence()) .and_then(|users| { - users.iter().find(|u| { - u.get("name").and_then(|n| n.as_str()) == Some(user_name.as_str()) - }) + users + .iter() + .find(|u| u.get("name").and_then(|n| n.as_str()) == Some(user_name.as_str())) }) .and_then(|u| u.get("user")); @@ -341,9 +343,20 @@ pub async fn test_kubectl_connection( let healthz_body = String::from_utf8_lossy(&healthz.stdout).trim().to_string(); let healthz_err = String::from_utf8_lossy(&healthz.stderr).trim().to_string(); let connectivity_line = if healthz_ok { - format!("OK ({})", if healthz_body.is_empty() { "cluster reachable" } else { &healthz_body }) + format!( + "OK ({})", + if healthz_body.is_empty() { + "cluster reachable" + } else { + &healthz_body + } + ) } else { - let hint = if healthz_err.is_empty() { "no stderr" } else { healthz_err.lines().last().unwrap_or(&healthz_err) }; + let hint = if healthz_err.is_empty() { + "no stderr" + } else { + healthz_err.lines().last().unwrap_or(&healthz_err) + }; format!("FAIL — {hint}") }; @@ -372,8 +385,16 @@ pub async fn test_kubectl_connection( auth = auth_method, connectivity = connectivity_line, exit = exit_code, - stdout = if stdout.is_empty() { "(none)\n" } else { &stdout }, - stderr = if stderr.is_empty() { "(none)\n" } else { &stderr }, + stdout = if stdout.is_empty() { + "(none)\n" + } else { + &stdout + }, + stderr = if stderr.is_empty() { + "(none)\n" + } else { + &stderr + }, )) } @@ -4898,3 +4919,2994 @@ pub async fn unsubscribe_from_k8s_events( Ok(()) } + +// ───────────────────────────────────────────────────────────────────────────── +// Phase 4: Additional Resource Discovery +// ───────────────────────────────────────────────────────────────────────────── + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ReplicationControllerInfo { + pub name: String, + pub namespace: String, + pub replicas: i32, + pub ready: String, + pub age: String, + pub labels: HashMap, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PodDisruptionBudgetInfo { + pub name: String, + pub namespace: String, + pub min_available: String, + pub max_unavailable: String, + pub allowed_disruptions: i32, + pub age: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PriorityClassInfo { + pub name: String, + pub value: i32, + pub global_default: bool, + pub description: String, + pub age: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RuntimeClassInfo { + pub name: String, + pub handler: String, + pub age: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LeaseInfo { + pub name: String, + pub namespace: String, + pub holder: String, + pub age: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MutatingWebhookConfigurationInfo { + pub name: String, + pub webhooks: i32, + pub age: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ValidatingWebhookConfigurationInfo { + pub name: String, + pub webhooks: i32, + pub age: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct EndpointInfo { + pub name: String, + pub namespace: String, + pub endpoints: String, + pub age: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct EndpointSliceInfo { + pub name: String, + pub namespace: String, + pub address_type: String, + pub ports: String, + pub endpoints: i32, + pub age: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct IngressClassInfo { + pub name: String, + pub controller: String, + pub is_default: bool, + pub age: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct NamespaceResourceInfo { + pub name: String, + pub status: String, + pub age: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CrdInfo { + pub name: String, + pub group: String, + pub version: String, + pub kind: String, + pub scope: String, + pub age: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CustomResourceInfo { + pub name: String, + pub namespace: String, + pub age: String, +} + +// ───────────────────────────────────────────────────────────────────────────── +// list_replicationcontrollers +// ───────────────────────────────────────────────────────────────────────────── + +#[tauri::command] +pub async fn list_replicationcontrollers( + cluster_id: String, + namespace: String, + state: State<'_, AppState>, +) -> Result, String> { + let clusters = state.clusters.lock().await; + let cluster = clusters + .get(&cluster_id) + .ok_or_else(|| format!("Cluster {} not found", cluster_id))?; + + let kubeconfig_content = cluster.kubeconfig_content.as_ref(); + let context = &cluster.context; + + let temp_dir = std::env::temp_dir(); + let temp_path = temp_dir.join(format!("kubeconfig-{}-rcs.yaml", cluster_id)); + let _cleanup = TempFileCleanup(temp_path.clone()); + + write_secure_temp_file(&temp_path, kubeconfig_content) + .map_err(|e| format!("Failed to write kubeconfig temp file: {e}"))?; + + let kubectl_path = locate_kubectl()?; + + let mut kubectl_cmd = Command::new(kubectl_path); + kubectl_cmd.arg("get").arg("replicationcontrollers"); + if namespace.is_empty() { + kubectl_cmd.arg("--all-namespaces"); + } else { + kubectl_cmd.arg("-n").arg(&namespace); + } + let output = kubectl_cmd + .arg("-o") + .arg("json") + .arg("--kubeconfig") + .arg(&temp_path) + .arg("--context") + .arg(context.as_str()) + .output() + .await + .map_err(|e| format!("Failed to execute kubectl: {e}"))?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(stderr.to_string()); + } + + let output_str = String::from_utf8_lossy(&output.stdout); + parse_replicationcontrollers_json(&output_str) +} + +fn parse_replicationcontrollers_json( + json_str: &str, +) -> Result, String> { + let value: Value = serde_json::from_str(json_str) + .map_err(|e| format!("Failed to parse kubectl JSON output: {}", e))?; + + let items = value + .get("items") + .and_then(|i| i.as_array()) + .ok_or("Missing 'items' array in kubectl JSON output")?; + + let mut result = Vec::new(); + for item in items { + let name = item + .get("metadata") + .and_then(|m| m.get("name")) + .and_then(|n| n.as_str()) + .unwrap_or("unknown") + .to_string(); + + let namespace = item + .get("metadata") + .and_then(|m| m.get("namespace")) + .and_then(|n| n.as_str()) + .unwrap_or("default") + .to_string(); + + let replicas = item + .get("spec") + .and_then(|s| s.get("replicas")) + .and_then(|r| r.as_i64()) + .unwrap_or(0) as i32; + + let ready = item + .get("status") + .and_then(|s| s.get("readyReplicas")) + .and_then(|r| r.as_i64()) + .map(|r| format!("{}/{}", r, replicas)) + .unwrap_or_else(|| format!("0/{}", replicas)); + + let age = item + .get("metadata") + .and_then(|m| m.get("creationTimestamp")) + .and_then(|c| c.as_str()) + .map(parse_creation_timestamp) + .unwrap_or("N/A".to_string()); + + let labels = item + .get("metadata") + .and_then(|m| m.get("labels")) + .and_then(|l| l.as_object()) + .map(|l| { + l.iter() + .map(|(k, v)| (k.clone(), v.as_str().unwrap_or("").to_string())) + .collect() + }) + .unwrap_or_default(); + + result.push(ReplicationControllerInfo { + name, + namespace, + replicas, + ready, + age, + labels, + }); + } + + Ok(result) +} + +// ───────────────────────────────────────────────────────────────────────────── +// list_poddisruptionbudgets +// ───────────────────────────────────────────────────────────────────────────── + +#[tauri::command] +pub async fn list_poddisruptionbudgets( + cluster_id: String, + namespace: String, + state: State<'_, AppState>, +) -> Result, String> { + let clusters = state.clusters.lock().await; + let cluster = clusters + .get(&cluster_id) + .ok_or_else(|| format!("Cluster {} not found", cluster_id))?; + + let kubeconfig_content = cluster.kubeconfig_content.as_ref(); + let context = &cluster.context; + + let temp_dir = std::env::temp_dir(); + let temp_path = temp_dir.join(format!("kubeconfig-{}-pdbs.yaml", cluster_id)); + let _cleanup = TempFileCleanup(temp_path.clone()); + + write_secure_temp_file(&temp_path, kubeconfig_content) + .map_err(|e| format!("Failed to write kubeconfig temp file: {e}"))?; + + let kubectl_path = locate_kubectl()?; + + let mut kubectl_cmd = Command::new(kubectl_path); + kubectl_cmd.arg("get").arg("poddisruptionbudgets"); + if namespace.is_empty() { + kubectl_cmd.arg("--all-namespaces"); + } else { + kubectl_cmd.arg("-n").arg(&namespace); + } + let output = kubectl_cmd + .arg("-o") + .arg("json") + .arg("--kubeconfig") + .arg(&temp_path) + .arg("--context") + .arg(context.as_str()) + .output() + .await + .map_err(|e| format!("Failed to execute kubectl: {e}"))?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(stderr.to_string()); + } + + let output_str = String::from_utf8_lossy(&output.stdout); + parse_poddisruptionbudgets_json(&output_str) +} + +fn parse_poddisruptionbudgets_json(json_str: &str) -> Result, String> { + let value: Value = serde_json::from_str(json_str) + .map_err(|e| format!("Failed to parse kubectl JSON output: {}", e))?; + + let items = value + .get("items") + .and_then(|i| i.as_array()) + .ok_or("Missing 'items' array in kubectl JSON output")?; + + let mut result = Vec::new(); + for item in items { + let name = item + .get("metadata") + .and_then(|m| m.get("name")) + .and_then(|n| n.as_str()) + .unwrap_or("unknown") + .to_string(); + + let namespace = item + .get("metadata") + .and_then(|m| m.get("namespace")) + .and_then(|n| n.as_str()) + .unwrap_or("default") + .to_string(); + + let min_available = item + .get("spec") + .and_then(|s| s.get("minAvailable")) + .map(|v| v.to_string().trim_matches('"').to_string()) + .unwrap_or_else(|| "N/A".to_string()); + + let max_unavailable = item + .get("spec") + .and_then(|s| s.get("maxUnavailable")) + .map(|v| v.to_string().trim_matches('"').to_string()) + .unwrap_or_else(|| "N/A".to_string()); + + let allowed_disruptions = item + .get("status") + .and_then(|s| s.get("disruptionsAllowed")) + .and_then(|v| v.as_i64()) + .unwrap_or(0) as i32; + + let age = item + .get("metadata") + .and_then(|m| m.get("creationTimestamp")) + .and_then(|c| c.as_str()) + .map(parse_creation_timestamp) + .unwrap_or("N/A".to_string()); + + result.push(PodDisruptionBudgetInfo { + name, + namespace, + min_available, + max_unavailable, + allowed_disruptions, + age, + }); + } + + Ok(result) +} + +// ───────────────────────────────────────────────────────────────────────────── +// list_priorityclasses (cluster-scoped) +// ───────────────────────────────────────────────────────────────────────────── + +#[tauri::command] +pub async fn list_priorityclasses( + cluster_id: String, + state: State<'_, AppState>, +) -> Result, String> { + let clusters = state.clusters.lock().await; + let cluster = clusters + .get(&cluster_id) + .ok_or_else(|| format!("Cluster {} not found", cluster_id))?; + + let kubeconfig_content = cluster.kubeconfig_content.as_ref(); + let context = &cluster.context; + + let temp_dir = std::env::temp_dir(); + let temp_path = temp_dir.join(format!("kubeconfig-{}-priorityclasses.yaml", cluster_id)); + let _cleanup = TempFileCleanup(temp_path.clone()); + + write_secure_temp_file(&temp_path, kubeconfig_content) + .map_err(|e| format!("Failed to write kubeconfig temp file: {e}"))?; + + let kubectl_path = locate_kubectl()?; + + let output = Command::new(kubectl_path) + .arg("get") + .arg("priorityclasses") + .arg("-o") + .arg("json") + .arg("--kubeconfig") + .arg(&temp_path) + .arg("--context") + .arg(context.as_str()) + .output() + .await + .map_err(|e| format!("Failed to execute kubectl: {e}"))?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(stderr.to_string()); + } + + let output_str = String::from_utf8_lossy(&output.stdout); + parse_priorityclasses_json(&output_str) +} + +fn parse_priorityclasses_json(json_str: &str) -> Result, String> { + let value: Value = serde_json::from_str(json_str) + .map_err(|e| format!("Failed to parse kubectl JSON output: {}", e))?; + + let items = value + .get("items") + .and_then(|i| i.as_array()) + .ok_or("Missing 'items' array in kubectl JSON output")?; + + let mut result = Vec::new(); + for item in items { + let name = item + .get("metadata") + .and_then(|m| m.get("name")) + .and_then(|n| n.as_str()) + .unwrap_or("unknown") + .to_string(); + + let value_int = item.get("value").and_then(|v| v.as_i64()).unwrap_or(0) as i32; + + let global_default = item + .get("globalDefault") + .and_then(|v| v.as_bool()) + .unwrap_or(false); + + let description = item + .get("description") + .and_then(|d| d.as_str()) + .unwrap_or("") + .to_string(); + + let age = item + .get("metadata") + .and_then(|m| m.get("creationTimestamp")) + .and_then(|c| c.as_str()) + .map(parse_creation_timestamp) + .unwrap_or("N/A".to_string()); + + result.push(PriorityClassInfo { + name, + value: value_int, + global_default, + description, + age, + }); + } + + Ok(result) +} + +// ───────────────────────────────────────────────────────────────────────────── +// list_runtimeclasses (cluster-scoped) +// ───────────────────────────────────────────────────────────────────────────── + +#[tauri::command] +pub async fn list_runtimeclasses( + cluster_id: String, + state: State<'_, AppState>, +) -> Result, String> { + let clusters = state.clusters.lock().await; + let cluster = clusters + .get(&cluster_id) + .ok_or_else(|| format!("Cluster {} not found", cluster_id))?; + + let kubeconfig_content = cluster.kubeconfig_content.as_ref(); + let context = &cluster.context; + + let temp_dir = std::env::temp_dir(); + let temp_path = temp_dir.join(format!("kubeconfig-{}-runtimeclasses.yaml", cluster_id)); + let _cleanup = TempFileCleanup(temp_path.clone()); + + write_secure_temp_file(&temp_path, kubeconfig_content) + .map_err(|e| format!("Failed to write kubeconfig temp file: {e}"))?; + + let kubectl_path = locate_kubectl()?; + + let output = Command::new(kubectl_path) + .arg("get") + .arg("runtimeclasses") + .arg("-o") + .arg("json") + .arg("--kubeconfig") + .arg(&temp_path) + .arg("--context") + .arg(context.as_str()) + .output() + .await + .map_err(|e| format!("Failed to execute kubectl: {e}"))?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(stderr.to_string()); + } + + let output_str = String::from_utf8_lossy(&output.stdout); + parse_runtimeclasses_json(&output_str) +} + +fn parse_runtimeclasses_json(json_str: &str) -> Result, String> { + let value: Value = serde_json::from_str(json_str) + .map_err(|e| format!("Failed to parse kubectl JSON output: {}", e))?; + + let items = value + .get("items") + .and_then(|i| i.as_array()) + .ok_or("Missing 'items' array in kubectl JSON output")?; + + let mut result = Vec::new(); + for item in items { + let name = item + .get("metadata") + .and_then(|m| m.get("name")) + .and_then(|n| n.as_str()) + .unwrap_or("unknown") + .to_string(); + + let handler = item + .get("handler") + .and_then(|h| h.as_str()) + .unwrap_or("unknown") + .to_string(); + + let age = item + .get("metadata") + .and_then(|m| m.get("creationTimestamp")) + .and_then(|c| c.as_str()) + .map(parse_creation_timestamp) + .unwrap_or("N/A".to_string()); + + result.push(RuntimeClassInfo { name, handler, age }); + } + + Ok(result) +} + +// ───────────────────────────────────────────────────────────────────────────── +// list_leases +// ───────────────────────────────────────────────────────────────────────────── + +#[tauri::command] +pub async fn list_leases( + cluster_id: String, + namespace: String, + state: State<'_, AppState>, +) -> Result, String> { + let clusters = state.clusters.lock().await; + let cluster = clusters + .get(&cluster_id) + .ok_or_else(|| format!("Cluster {} not found", cluster_id))?; + + let kubeconfig_content = cluster.kubeconfig_content.as_ref(); + let context = &cluster.context; + + let temp_dir = std::env::temp_dir(); + let temp_path = temp_dir.join(format!("kubeconfig-{}-leases.yaml", cluster_id)); + let _cleanup = TempFileCleanup(temp_path.clone()); + + write_secure_temp_file(&temp_path, kubeconfig_content) + .map_err(|e| format!("Failed to write kubeconfig temp file: {e}"))?; + + let kubectl_path = locate_kubectl()?; + + let mut kubectl_cmd = Command::new(kubectl_path); + kubectl_cmd.arg("get").arg("leases"); + if namespace.is_empty() { + kubectl_cmd.arg("--all-namespaces"); + } else { + kubectl_cmd.arg("-n").arg(&namespace); + } + let output = kubectl_cmd + .arg("-o") + .arg("json") + .arg("--kubeconfig") + .arg(&temp_path) + .arg("--context") + .arg(context.as_str()) + .output() + .await + .map_err(|e| format!("Failed to execute kubectl: {e}"))?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(stderr.to_string()); + } + + let output_str = String::from_utf8_lossy(&output.stdout); + parse_leases_json(&output_str) +} + +fn parse_leases_json(json_str: &str) -> Result, String> { + let value: Value = serde_json::from_str(json_str) + .map_err(|e| format!("Failed to parse kubectl JSON output: {}", e))?; + + let items = value + .get("items") + .and_then(|i| i.as_array()) + .ok_or("Missing 'items' array in kubectl JSON output")?; + + let mut result = Vec::new(); + for item in items { + let name = item + .get("metadata") + .and_then(|m| m.get("name")) + .and_then(|n| n.as_str()) + .unwrap_or("unknown") + .to_string(); + + let namespace = item + .get("metadata") + .and_then(|m| m.get("namespace")) + .and_then(|n| n.as_str()) + .unwrap_or("default") + .to_string(); + + let holder = item + .get("spec") + .and_then(|s| s.get("holderIdentity")) + .and_then(|h| h.as_str()) + .unwrap_or("none") + .to_string(); + + let age = item + .get("metadata") + .and_then(|m| m.get("creationTimestamp")) + .and_then(|c| c.as_str()) + .map(parse_creation_timestamp) + .unwrap_or("N/A".to_string()); + + result.push(LeaseInfo { + name, + namespace, + holder, + age, + }); + } + + Ok(result) +} + +// ───────────────────────────────────────────────────────────────────────────── +// list_mutatingwebhookconfigurations (cluster-scoped) +// ───────────────────────────────────────────────────────────────────────────── + +#[tauri::command] +pub async fn list_mutatingwebhookconfigurations( + cluster_id: String, + state: State<'_, AppState>, +) -> Result, String> { + let clusters = state.clusters.lock().await; + let cluster = clusters + .get(&cluster_id) + .ok_or_else(|| format!("Cluster {} not found", cluster_id))?; + + let kubeconfig_content = cluster.kubeconfig_content.as_ref(); + let context = &cluster.context; + + let temp_dir = std::env::temp_dir(); + let temp_path = temp_dir.join(format!("kubeconfig-{}-mwhc.yaml", cluster_id)); + let _cleanup = TempFileCleanup(temp_path.clone()); + + write_secure_temp_file(&temp_path, kubeconfig_content) + .map_err(|e| format!("Failed to write kubeconfig temp file: {e}"))?; + + let kubectl_path = locate_kubectl()?; + + let output = Command::new(kubectl_path) + .arg("get") + .arg("mutatingwebhookconfigurations") + .arg("-o") + .arg("json") + .arg("--kubeconfig") + .arg(&temp_path) + .arg("--context") + .arg(context.as_str()) + .output() + .await + .map_err(|e| format!("Failed to execute kubectl: {e}"))?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(stderr.to_string()); + } + + let output_str = String::from_utf8_lossy(&output.stdout); + parse_mutatingwebhookconfigurations_json(&output_str) +} + +fn parse_mutatingwebhookconfigurations_json( + json_str: &str, +) -> Result, String> { + let value: Value = serde_json::from_str(json_str) + .map_err(|e| format!("Failed to parse kubectl JSON output: {}", e))?; + + let items = value + .get("items") + .and_then(|i| i.as_array()) + .ok_or("Missing 'items' array in kubectl JSON output")?; + + let mut result = Vec::new(); + for item in items { + let name = item + .get("metadata") + .and_then(|m| m.get("name")) + .and_then(|n| n.as_str()) + .unwrap_or("unknown") + .to_string(); + + let webhooks = item + .get("webhooks") + .and_then(|w| w.as_array()) + .map(|w| w.len() as i32) + .unwrap_or(0); + + let age = item + .get("metadata") + .and_then(|m| m.get("creationTimestamp")) + .and_then(|c| c.as_str()) + .map(parse_creation_timestamp) + .unwrap_or("N/A".to_string()); + + result.push(MutatingWebhookConfigurationInfo { + name, + webhooks, + age, + }); + } + + Ok(result) +} + +// ───────────────────────────────────────────────────────────────────────────── +// list_validatingwebhookconfigurations (cluster-scoped) +// ───────────────────────────────────────────────────────────────────────────── + +#[tauri::command] +pub async fn list_validatingwebhookconfigurations( + cluster_id: String, + state: State<'_, AppState>, +) -> Result, String> { + let clusters = state.clusters.lock().await; + let cluster = clusters + .get(&cluster_id) + .ok_or_else(|| format!("Cluster {} not found", cluster_id))?; + + let kubeconfig_content = cluster.kubeconfig_content.as_ref(); + let context = &cluster.context; + + let temp_dir = std::env::temp_dir(); + let temp_path = temp_dir.join(format!("kubeconfig-{}-vwhc.yaml", cluster_id)); + let _cleanup = TempFileCleanup(temp_path.clone()); + + write_secure_temp_file(&temp_path, kubeconfig_content) + .map_err(|e| format!("Failed to write kubeconfig temp file: {e}"))?; + + let kubectl_path = locate_kubectl()?; + + let output = Command::new(kubectl_path) + .arg("get") + .arg("validatingwebhookconfigurations") + .arg("-o") + .arg("json") + .arg("--kubeconfig") + .arg(&temp_path) + .arg("--context") + .arg(context.as_str()) + .output() + .await + .map_err(|e| format!("Failed to execute kubectl: {e}"))?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(stderr.to_string()); + } + + let output_str = String::from_utf8_lossy(&output.stdout); + parse_validatingwebhookconfigurations_json(&output_str) +} + +fn parse_validatingwebhookconfigurations_json( + json_str: &str, +) -> Result, String> { + let value: Value = serde_json::from_str(json_str) + .map_err(|e| format!("Failed to parse kubectl JSON output: {}", e))?; + + let items = value + .get("items") + .and_then(|i| i.as_array()) + .ok_or("Missing 'items' array in kubectl JSON output")?; + + let mut result = Vec::new(); + for item in items { + let name = item + .get("metadata") + .and_then(|m| m.get("name")) + .and_then(|n| n.as_str()) + .unwrap_or("unknown") + .to_string(); + + let webhooks = item + .get("webhooks") + .and_then(|w| w.as_array()) + .map(|w| w.len() as i32) + .unwrap_or(0); + + let age = item + .get("metadata") + .and_then(|m| m.get("creationTimestamp")) + .and_then(|c| c.as_str()) + .map(parse_creation_timestamp) + .unwrap_or("N/A".to_string()); + + result.push(ValidatingWebhookConfigurationInfo { + name, + webhooks, + age, + }); + } + + Ok(result) +} + +// ───────────────────────────────────────────────────────────────────────────── +// list_endpoints +// ───────────────────────────────────────────────────────────────────────────── + +#[tauri::command] +pub async fn list_endpoints( + cluster_id: String, + namespace: String, + state: State<'_, AppState>, +) -> Result, String> { + let clusters = state.clusters.lock().await; + let cluster = clusters + .get(&cluster_id) + .ok_or_else(|| format!("Cluster {} not found", cluster_id))?; + + let kubeconfig_content = cluster.kubeconfig_content.as_ref(); + let context = &cluster.context; + + let temp_dir = std::env::temp_dir(); + let temp_path = temp_dir.join(format!("kubeconfig-{}-endpoints.yaml", cluster_id)); + let _cleanup = TempFileCleanup(temp_path.clone()); + + write_secure_temp_file(&temp_path, kubeconfig_content) + .map_err(|e| format!("Failed to write kubeconfig temp file: {e}"))?; + + let kubectl_path = locate_kubectl()?; + + let mut kubectl_cmd = Command::new(kubectl_path); + kubectl_cmd.arg("get").arg("endpoints"); + if namespace.is_empty() { + kubectl_cmd.arg("--all-namespaces"); + } else { + kubectl_cmd.arg("-n").arg(&namespace); + } + let output = kubectl_cmd + .arg("-o") + .arg("json") + .arg("--kubeconfig") + .arg(&temp_path) + .arg("--context") + .arg(context.as_str()) + .output() + .await + .map_err(|e| format!("Failed to execute kubectl: {e}"))?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(stderr.to_string()); + } + + let output_str = String::from_utf8_lossy(&output.stdout); + parse_endpoints_json(&output_str) +} + +fn parse_endpoints_json(json_str: &str) -> Result, String> { + let value: Value = serde_json::from_str(json_str) + .map_err(|e| format!("Failed to parse kubectl JSON output: {}", e))?; + + let items = value + .get("items") + .and_then(|i| i.as_array()) + .ok_or("Missing 'items' array in kubectl JSON output")?; + + let mut result = Vec::new(); + for item in items { + let name = item + .get("metadata") + .and_then(|m| m.get("name")) + .and_then(|n| n.as_str()) + .unwrap_or("unknown") + .to_string(); + + let namespace = item + .get("metadata") + .and_then(|m| m.get("namespace")) + .and_then(|n| n.as_str()) + .unwrap_or("default") + .to_string(); + + // Collect IP:port pairs from subsets + let endpoints = item + .get("subsets") + .and_then(|s| s.as_array()) + .map(|subsets| { + let mut addrs = Vec::new(); + for subset in subsets { + if let Some(addresses) = subset.get("addresses").and_then(|a| a.as_array()) { + for addr in addresses { + if let Some(ip) = addr.get("ip").and_then(|i| i.as_str()) { + addrs.push(ip.to_string()); + } + } + } + } + addrs.join(", ") + }) + .unwrap_or_else(|| "".to_string()); + + let age = item + .get("metadata") + .and_then(|m| m.get("creationTimestamp")) + .and_then(|c| c.as_str()) + .map(parse_creation_timestamp) + .unwrap_or("N/A".to_string()); + + result.push(EndpointInfo { + name, + namespace, + endpoints, + age, + }); + } + + Ok(result) +} + +// ───────────────────────────────────────────────────────────────────────────── +// list_endpointslices +// ───────────────────────────────────────────────────────────────────────────── + +#[tauri::command] +pub async fn list_endpointslices( + cluster_id: String, + namespace: String, + state: State<'_, AppState>, +) -> Result, String> { + let clusters = state.clusters.lock().await; + let cluster = clusters + .get(&cluster_id) + .ok_or_else(|| format!("Cluster {} not found", cluster_id))?; + + let kubeconfig_content = cluster.kubeconfig_content.as_ref(); + let context = &cluster.context; + + let temp_dir = std::env::temp_dir(); + let temp_path = temp_dir.join(format!("kubeconfig-{}-endpointslices.yaml", cluster_id)); + let _cleanup = TempFileCleanup(temp_path.clone()); + + write_secure_temp_file(&temp_path, kubeconfig_content) + .map_err(|e| format!("Failed to write kubeconfig temp file: {e}"))?; + + let kubectl_path = locate_kubectl()?; + + let mut kubectl_cmd = Command::new(kubectl_path); + kubectl_cmd.arg("get").arg("endpointslices"); + if namespace.is_empty() { + kubectl_cmd.arg("--all-namespaces"); + } else { + kubectl_cmd.arg("-n").arg(&namespace); + } + let output = kubectl_cmd + .arg("-o") + .arg("json") + .arg("--kubeconfig") + .arg(&temp_path) + .arg("--context") + .arg(context.as_str()) + .output() + .await + .map_err(|e| format!("Failed to execute kubectl: {e}"))?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(stderr.to_string()); + } + + let output_str = String::from_utf8_lossy(&output.stdout); + parse_endpointslices_json(&output_str) +} + +fn parse_endpointslices_json(json_str: &str) -> Result, String> { + let value: Value = serde_json::from_str(json_str) + .map_err(|e| format!("Failed to parse kubectl JSON output: {}", e))?; + + let items = value + .get("items") + .and_then(|i| i.as_array()) + .ok_or("Missing 'items' array in kubectl JSON output")?; + + let mut result = Vec::new(); + for item in items { + let name = item + .get("metadata") + .and_then(|m| m.get("name")) + .and_then(|n| n.as_str()) + .unwrap_or("unknown") + .to_string(); + + let namespace = item + .get("metadata") + .and_then(|m| m.get("namespace")) + .and_then(|n| n.as_str()) + .unwrap_or("default") + .to_string(); + + let address_type = item + .get("addressType") + .and_then(|a| a.as_str()) + .unwrap_or("IPv4") + .to_string(); + + let ports = item + .get("ports") + .and_then(|p| p.as_array()) + .map(|ports| { + ports + .iter() + .filter_map(|p| p.get("port").and_then(|v| v.as_u64())) + .map(|p| p.to_string()) + .collect::>() + .join(", ") + }) + .unwrap_or_else(|| "".to_string()); + + let endpoints = item + .get("endpoints") + .and_then(|e| e.as_array()) + .map(|e| e.len() as i32) + .unwrap_or(0); + + let age = item + .get("metadata") + .and_then(|m| m.get("creationTimestamp")) + .and_then(|c| c.as_str()) + .map(parse_creation_timestamp) + .unwrap_or("N/A".to_string()); + + result.push(EndpointSliceInfo { + name, + namespace, + address_type, + ports, + endpoints, + age, + }); + } + + Ok(result) +} + +// ───────────────────────────────────────────────────────────────────────────── +// list_ingressclasses (cluster-scoped) +// ───────────────────────────────────────────────────────────────────────────── + +#[tauri::command] +pub async fn list_ingressclasses( + cluster_id: String, + state: State<'_, AppState>, +) -> Result, String> { + let clusters = state.clusters.lock().await; + let cluster = clusters + .get(&cluster_id) + .ok_or_else(|| format!("Cluster {} not found", cluster_id))?; + + let kubeconfig_content = cluster.kubeconfig_content.as_ref(); + let context = &cluster.context; + + let temp_dir = std::env::temp_dir(); + let temp_path = temp_dir.join(format!("kubeconfig-{}-ingressclasses.yaml", cluster_id)); + let _cleanup = TempFileCleanup(temp_path.clone()); + + write_secure_temp_file(&temp_path, kubeconfig_content) + .map_err(|e| format!("Failed to write kubeconfig temp file: {e}"))?; + + let kubectl_path = locate_kubectl()?; + + let output = Command::new(kubectl_path) + .arg("get") + .arg("ingressclasses") + .arg("-o") + .arg("json") + .arg("--kubeconfig") + .arg(&temp_path) + .arg("--context") + .arg(context.as_str()) + .output() + .await + .map_err(|e| format!("Failed to execute kubectl: {e}"))?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(stderr.to_string()); + } + + let output_str = String::from_utf8_lossy(&output.stdout); + parse_ingressclasses_json(&output_str) +} + +fn parse_ingressclasses_json(json_str: &str) -> Result, String> { + let value: Value = serde_json::from_str(json_str) + .map_err(|e| format!("Failed to parse kubectl JSON output: {}", e))?; + + let items = value + .get("items") + .and_then(|i| i.as_array()) + .ok_or("Missing 'items' array in kubectl JSON output")?; + + let mut result = Vec::new(); + for item in items { + let name = item + .get("metadata") + .and_then(|m| m.get("name")) + .and_then(|n| n.as_str()) + .unwrap_or("unknown") + .to_string(); + + let controller = item + .get("spec") + .and_then(|s| s.get("controller")) + .and_then(|c| c.as_str()) + .unwrap_or("unknown") + .to_string(); + + let is_default = item + .get("metadata") + .and_then(|m| m.get("annotations")) + .and_then(|a| { + a.get("ingressclass.kubernetes.io/is-default-class") + .and_then(|v| v.as_str()) + }) + .map(|v| v == "true") + .unwrap_or(false); + + let age = item + .get("metadata") + .and_then(|m| m.get("creationTimestamp")) + .and_then(|c| c.as_str()) + .map(parse_creation_timestamp) + .unwrap_or("N/A".to_string()); + + result.push(IngressClassInfo { + name, + controller, + is_default, + age, + }); + } + + Ok(result) +} + +// ───────────────────────────────────────────────────────────────────────────── +// list_namespaces_resource (cluster-scoped, distinct from list_namespaces) +// ───────────────────────────────────────────────────────────────────────────── + +#[tauri::command] +pub async fn list_namespaces_resource( + cluster_id: String, + state: State<'_, AppState>, +) -> Result, String> { + let clusters = state.clusters.lock().await; + let cluster = clusters + .get(&cluster_id) + .ok_or_else(|| format!("Cluster {} not found", cluster_id))?; + + let kubeconfig_content = cluster.kubeconfig_content.as_ref(); + let context = &cluster.context; + + let temp_dir = std::env::temp_dir(); + let temp_path = temp_dir.join(format!("kubeconfig-{}-nsres.yaml", cluster_id)); + let _cleanup = TempFileCleanup(temp_path.clone()); + + write_secure_temp_file(&temp_path, kubeconfig_content) + .map_err(|e| format!("Failed to write kubeconfig temp file: {e}"))?; + + let kubectl_path = locate_kubectl()?; + + let output = Command::new(kubectl_path) + .arg("get") + .arg("namespaces") + .arg("-o") + .arg("json") + .arg("--kubeconfig") + .arg(&temp_path) + .arg("--context") + .arg(context.as_str()) + .output() + .await + .map_err(|e| format!("Failed to execute kubectl: {e}"))?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(stderr.to_string()); + } + + let output_str = String::from_utf8_lossy(&output.stdout); + parse_namespaces_resource_json(&output_str) +} + +fn parse_namespaces_resource_json(json_str: &str) -> Result, String> { + let value: Value = serde_json::from_str(json_str) + .map_err(|e| format!("Failed to parse kubectl JSON output: {}", e))?; + + let items = value + .get("items") + .and_then(|i| i.as_array()) + .ok_or("Missing 'items' array in kubectl JSON output")?; + + let mut result = Vec::new(); + for item in items { + let name = item + .get("metadata") + .and_then(|m| m.get("name")) + .and_then(|n| n.as_str()) + .unwrap_or("unknown") + .to_string(); + + let status = item + .get("status") + .and_then(|s| s.get("phase")) + .and_then(|p| p.as_str()) + .unwrap_or("Unknown") + .to_string(); + + let age = item + .get("metadata") + .and_then(|m| m.get("creationTimestamp")) + .and_then(|c| c.as_str()) + .map(parse_creation_timestamp) + .unwrap_or("N/A".to_string()); + + result.push(NamespaceResourceInfo { name, status, age }); + } + + Ok(result) +} + +// ───────────────────────────────────────────────────────────────────────────── +// list_crds (cluster-scoped) +// ───────────────────────────────────────────────────────────────────────────── + +#[tauri::command] +pub async fn list_crds( + cluster_id: String, + state: State<'_, AppState>, +) -> Result, String> { + let clusters = state.clusters.lock().await; + let cluster = clusters + .get(&cluster_id) + .ok_or_else(|| format!("Cluster {} not found", cluster_id))?; + + let kubeconfig_content = cluster.kubeconfig_content.as_ref(); + let context = &cluster.context; + + let temp_dir = std::env::temp_dir(); + let temp_path = temp_dir.join(format!("kubeconfig-{}-crds.yaml", cluster_id)); + let _cleanup = TempFileCleanup(temp_path.clone()); + + write_secure_temp_file(&temp_path, kubeconfig_content) + .map_err(|e| format!("Failed to write kubeconfig temp file: {e}"))?; + + let kubectl_path = locate_kubectl()?; + + let output = Command::new(kubectl_path) + .arg("get") + .arg("customresourcedefinitions") + .arg("-o") + .arg("json") + .arg("--kubeconfig") + .arg(&temp_path) + .arg("--context") + .arg(context.as_str()) + .output() + .await + .map_err(|e| format!("Failed to execute kubectl: {e}"))?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(stderr.to_string()); + } + + let output_str = String::from_utf8_lossy(&output.stdout); + parse_crds_json(&output_str) +} + +fn parse_crds_json(json_str: &str) -> Result, String> { + let value: Value = serde_json::from_str(json_str) + .map_err(|e| format!("Failed to parse kubectl JSON output: {}", e))?; + + let items = value + .get("items") + .and_then(|i| i.as_array()) + .ok_or("Missing 'items' array in kubectl JSON output")?; + + let mut result = Vec::new(); + for item in items { + let name = item + .get("metadata") + .and_then(|m| m.get("name")) + .and_then(|n| n.as_str()) + .unwrap_or("unknown") + .to_string(); + + let group = item + .get("spec") + .and_then(|s| s.get("group")) + .and_then(|g| g.as_str()) + .unwrap_or("unknown") + .to_string(); + + let version = item + .get("spec") + .and_then(|s| s.get("versions")) + .and_then(|v| v.as_array()) + .and_then(|v| v.first()) + .and_then(|v| v.get("name")) + .and_then(|n| n.as_str()) + .unwrap_or("v1") + .to_string(); + + let kind = item + .get("spec") + .and_then(|s| s.get("names")) + .and_then(|n| n.get("kind")) + .and_then(|k| k.as_str()) + .unwrap_or("unknown") + .to_string(); + + let scope = item + .get("spec") + .and_then(|s| s.get("scope")) + .and_then(|s| s.as_str()) + .unwrap_or("Namespaced") + .to_string(); + + let age = item + .get("metadata") + .and_then(|m| m.get("creationTimestamp")) + .and_then(|c| c.as_str()) + .map(parse_creation_timestamp) + .unwrap_or("N/A".to_string()); + + result.push(CrdInfo { + name, + group, + version, + kind, + scope, + age, + }); + } + + Ok(result) +} + +// ───────────────────────────────────────────────────────────────────────────── +// list_custom_resources +// ───────────────────────────────────────────────────────────────────────────── + +#[tauri::command] +pub async fn list_custom_resources( + cluster_id: String, + group: String, + version: String, + resource: String, + namespace: String, + state: State<'_, AppState>, +) -> Result, String> { + validate_resource_name(&group, "group")?; + validate_resource_name(&resource, "resource")?; + + let clusters = state.clusters.lock().await; + let cluster = clusters + .get(&cluster_id) + .ok_or_else(|| format!("Cluster {} not found", cluster_id))?; + + let kubeconfig_content = cluster.kubeconfig_content.as_ref(); + let context = &cluster.context; + + let temp_dir = std::env::temp_dir(); + let temp_path = temp_dir.join(format!("kubeconfig-{}-cr.yaml", cluster_id)); + let _cleanup = TempFileCleanup(temp_path.clone()); + + write_secure_temp_file(&temp_path, kubeconfig_content) + .map_err(|e| format!("Failed to write kubeconfig temp file: {e}"))?; + + let kubectl_path = locate_kubectl()?; + + // Build resource specifier: group/resource (version is part of the API group context) + let resource_spec = format!("{}/{}", group, resource); + + let mut kubectl_cmd = Command::new(kubectl_path); + kubectl_cmd.arg("get").arg(&resource_spec); + if namespace.is_empty() { + kubectl_cmd.arg("--all-namespaces"); + } else { + kubectl_cmd.arg("-n").arg(&namespace); + } + + info!( + cluster_id = %cluster_id, + group = %group, + version = %version, + resource = %resource, + "Listing custom resources" + ); + + let output = kubectl_cmd + .arg("-o") + .arg("json") + .arg("--kubeconfig") + .arg(&temp_path) + .arg("--context") + .arg(context.as_str()) + .output() + .await + .map_err(|e| format!("Failed to execute kubectl: {e}"))?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(stderr.to_string()); + } + + let output_str = String::from_utf8_lossy(&output.stdout); + parse_custom_resources_json(&output_str) +} + +fn parse_custom_resources_json(json_str: &str) -> Result, String> { + let value: Value = serde_json::from_str(json_str) + .map_err(|e| format!("Failed to parse kubectl JSON output: {}", e))?; + + let items = value + .get("items") + .and_then(|i| i.as_array()) + .ok_or("Missing 'items' array in kubectl JSON output")?; + + let mut result = Vec::new(); + for item in items { + let name = item + .get("metadata") + .and_then(|m| m.get("name")) + .and_then(|n| n.as_str()) + .unwrap_or("unknown") + .to_string(); + + let namespace = item + .get("metadata") + .and_then(|m| m.get("namespace")) + .and_then(|n| n.as_str()) + .unwrap_or("") + .to_string(); + + let age = item + .get("metadata") + .and_then(|m| m.get("creationTimestamp")) + .and_then(|c| c.as_str()) + .map(parse_creation_timestamp) + .unwrap_or("N/A".to_string()); + + result.push(CustomResourceInfo { + name, + namespace, + age, + }); + } + + Ok(result) +} + +// ───────────────────────────────────────────────────────────────────────────── +// Phase 5: Action commands +// ───────────────────────────────────────────────────────────────────────────── + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DescribeResponse { + pub output: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ExecSessionResponse { + pub session_id: String, + pub cluster_id: String, + pub namespace: String, + pub pod: String, + pub container: Option, + pub status: String, +} + +#[tauri::command] +pub async fn force_delete_resource( + cluster_id: String, + resource_type: String, + namespace: String, + resource_name: String, + state: State<'_, AppState>, +) -> Result<(), String> { + validate_resource_name(&resource_name, "resource_name")?; + + let clusters = state.clusters.lock().await; + let cluster = clusters + .get(&cluster_id) + .ok_or_else(|| format!("Cluster {} not found", cluster_id))?; + + let kubeconfig_content = cluster.kubeconfig_content.as_ref(); + let context = &cluster.context; + + let temp_dir = std::env::temp_dir(); + let temp_path = temp_dir.join(format!("kubeconfig-{}-force-delete.yaml", cluster_id)); + let _cleanup = TempFileCleanup(temp_path.clone()); + + write_secure_temp_file(&temp_path, kubeconfig_content) + .map_err(|e| format!("Failed to write kubeconfig temp file: {e}"))?; + + let kubectl_path = locate_kubectl()?; + + info!( + cluster_id = %cluster_id, + resource_type = %resource_type, + namespace = %namespace, + resource_name = %resource_name, + "Force deleting resource" + ); + + let output = Command::new(kubectl_path) + .arg("delete") + .arg(&resource_type) + .arg(&resource_name) + .arg("-n") + .arg(&namespace) + .arg("--grace-period=0") + .arg("--force") + .arg("--kubeconfig") + .arg(&temp_path) + .arg("--context") + .arg(context.as_str()) + .output() + .await + .map_err(|e| format!("Failed to execute kubectl: {e}"))?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(stderr.to_string()); + } + + Ok(()) +} + +#[tauri::command] +pub async fn describe_resource( + cluster_id: String, + resource_type: String, + namespace: String, + resource_name: String, + state: State<'_, AppState>, +) -> Result { + validate_resource_name(&resource_name, "resource_name")?; + + let clusters = state.clusters.lock().await; + let cluster = clusters + .get(&cluster_id) + .ok_or_else(|| format!("Cluster {} not found", cluster_id))?; + + let kubeconfig_content = cluster.kubeconfig_content.as_ref(); + let context = &cluster.context; + + let temp_dir = std::env::temp_dir(); + let temp_path = temp_dir.join(format!("kubeconfig-{}-describe.yaml", cluster_id)); + let _cleanup = TempFileCleanup(temp_path.clone()); + + write_secure_temp_file(&temp_path, kubeconfig_content) + .map_err(|e| format!("Failed to write kubeconfig temp file: {e}"))?; + + let kubectl_path = locate_kubectl()?; + let resource_spec = format!("{}/{}", resource_type, resource_name); + + let mut cmd = Command::new(kubectl_path); + cmd.arg("describe").arg(&resource_spec); + + if !namespace.is_empty() { + cmd.arg("-n").arg(&namespace); + } + + let output = cmd + .arg("--kubeconfig") + .arg(&temp_path) + .arg("--context") + .arg(context.as_str()) + .output() + .await + .map_err(|e| format!("Failed to execute kubectl: {e}"))?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(stderr.to_string()); + } + + let output_text = String::from_utf8_lossy(&output.stdout).to_string(); + Ok(DescribeResponse { + output: output_text, + }) +} + +#[tauri::command] +pub async fn get_resource_yaml( + cluster_id: String, + resource_type: String, + namespace: String, + resource_name: String, + state: State<'_, AppState>, +) -> Result { + validate_resource_name(&resource_name, "resource_name")?; + + let clusters = state.clusters.lock().await; + let cluster = clusters + .get(&cluster_id) + .ok_or_else(|| format!("Cluster {} not found", cluster_id))?; + + let kubeconfig_content = cluster.kubeconfig_content.as_ref(); + let context = &cluster.context; + + let temp_dir = std::env::temp_dir(); + let temp_path = temp_dir.join(format!("kubeconfig-{}-getyaml.yaml", cluster_id)); + let _cleanup = TempFileCleanup(temp_path.clone()); + + write_secure_temp_file(&temp_path, kubeconfig_content) + .map_err(|e| format!("Failed to write kubeconfig temp file: {e}"))?; + + let kubectl_path = locate_kubectl()?; + let resource_spec = format!("{}/{}", resource_type, resource_name); + + let mut cmd = Command::new(kubectl_path); + cmd.arg("get").arg(&resource_spec).arg("-o").arg("yaml"); + + if !namespace.is_empty() { + cmd.arg("-n").arg(&namespace); + } + + let output = cmd + .arg("--kubeconfig") + .arg(&temp_path) + .arg("--context") + .arg(context.as_str()) + .output() + .await + .map_err(|e| format!("Failed to execute kubectl: {e}"))?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(stderr.to_string()); + } + + Ok(String::from_utf8_lossy(&output.stdout).to_string()) +} + +#[tauri::command] +pub async fn attach_pod( + cluster_id: String, + namespace: String, + pod_name: String, + container_name: String, + state: State<'_, AppState>, +) -> Result { + validate_resource_name(&pod_name, "pod_name")?; + validate_resource_name(&namespace, "namespace")?; + + let clusters = state.clusters.lock().await; + let cluster = clusters + .get(&cluster_id) + .ok_or_else(|| format!("Cluster {} not found", cluster_id))?; + + let kubeconfig_content = cluster.kubeconfig_content.as_ref(); + let context = &cluster.context; + + let session_id = uuid::Uuid::now_v7().to_string(); + + let temp_dir = std::env::temp_dir(); + let temp_path = temp_dir.join(format!("kubeconfig-{}-attach.yaml", cluster_id)); + let _cleanup = TempFileCleanup(temp_path.clone()); + + write_secure_temp_file(&temp_path, kubeconfig_content) + .map_err(|e| format!("Failed to write kubeconfig temp file: {e}"))?; + + let kubectl_path = locate_kubectl()?; + + let mut cmd = Command::new(kubectl_path); + cmd.arg("attach") + .arg("-it") + .arg(&pod_name) + .arg("-n") + .arg(&namespace); + + if !container_name.is_empty() { + cmd.arg("-c").arg(&container_name); + } + + cmd.arg("--kubeconfig") + .arg(&temp_path) + .arg("--context") + .arg(context.as_str()); + + let output = cmd + .output() + .await + .map_err(|e| format!("Failed to execute kubectl attach: {e}"))?; + + let status = if output.status.success() { + "Completed".to_string() + } else { + let stderr = String::from_utf8_lossy(&output.stderr); + format!("Error: {}", stderr.trim()) + }; + + Ok(ExecSessionResponse { + session_id, + cluster_id, + namespace, + pod: pod_name, + container: if container_name.is_empty() { + None + } else { + Some(container_name) + }, + status, + }) +} + +#[tauri::command] +pub async fn restart_statefulset( + cluster_id: String, + namespace: String, + name: String, + state: State<'_, AppState>, +) -> Result<(), String> { + validate_resource_name(&name, "name")?; + + let clusters = state.clusters.lock().await; + let cluster = clusters + .get(&cluster_id) + .ok_or_else(|| format!("Cluster {} not found", cluster_id))?; + + let kubeconfig_content = cluster.kubeconfig_content.as_ref(); + let context = &cluster.context; + + let temp_dir = std::env::temp_dir(); + let temp_path = temp_dir.join(format!("kubeconfig-{}-restart-sts.yaml", cluster_id)); + let _cleanup = TempFileCleanup(temp_path.clone()); + + write_secure_temp_file(&temp_path, kubeconfig_content) + .map_err(|e| format!("Failed to write kubeconfig temp file: {e}"))?; + + let kubectl_path = locate_kubectl()?; + + let output = Command::new(kubectl_path) + .arg("rollout") + .arg("restart") + .arg(format!("statefulsets/{}", name)) + .arg("-n") + .arg(&namespace) + .arg("--kubeconfig") + .arg(&temp_path) + .arg("--context") + .arg(context.as_str()) + .output() + .await + .map_err(|e| format!("Failed to execute kubectl: {e}"))?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(stderr.to_string()); + } + + Ok(()) +} + +#[tauri::command] +pub async fn restart_daemonset( + cluster_id: String, + namespace: String, + name: String, + state: State<'_, AppState>, +) -> Result<(), String> { + validate_resource_name(&name, "name")?; + + let clusters = state.clusters.lock().await; + let cluster = clusters + .get(&cluster_id) + .ok_or_else(|| format!("Cluster {} not found", cluster_id))?; + + let kubeconfig_content = cluster.kubeconfig_content.as_ref(); + let context = &cluster.context; + + let temp_dir = std::env::temp_dir(); + let temp_path = temp_dir.join(format!("kubeconfig-{}-restart-ds.yaml", cluster_id)); + let _cleanup = TempFileCleanup(temp_path.clone()); + + write_secure_temp_file(&temp_path, kubeconfig_content) + .map_err(|e| format!("Failed to write kubeconfig temp file: {e}"))?; + + let kubectl_path = locate_kubectl()?; + + let output = Command::new(kubectl_path) + .arg("rollout") + .arg("restart") + .arg(format!("daemonsets/{}", name)) + .arg("-n") + .arg(&namespace) + .arg("--kubeconfig") + .arg(&temp_path) + .arg("--context") + .arg(context.as_str()) + .output() + .await + .map_err(|e| format!("Failed to execute kubectl: {e}"))?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(stderr.to_string()); + } + + Ok(()) +} + +#[tauri::command] +pub async fn scale_statefulset( + cluster_id: String, + namespace: String, + name: String, + replicas: i32, + state: State<'_, AppState>, +) -> Result<(), String> { + validate_resource_name(&name, "name")?; + + let clusters = state.clusters.lock().await; + let cluster = clusters + .get(&cluster_id) + .ok_or_else(|| format!("Cluster {} not found", cluster_id))?; + + let kubeconfig_content = cluster.kubeconfig_content.as_ref(); + let context = &cluster.context; + + let temp_dir = std::env::temp_dir(); + let temp_path = temp_dir.join(format!("kubeconfig-{}-scale-sts.yaml", cluster_id)); + let _cleanup = TempFileCleanup(temp_path.clone()); + + write_secure_temp_file(&temp_path, kubeconfig_content) + .map_err(|e| format!("Failed to write kubeconfig temp file: {e}"))?; + + let kubectl_path = locate_kubectl()?; + + let output = Command::new(kubectl_path) + .arg("scale") + .arg(format!("statefulsets/{}", name)) + .arg(format!("--replicas={}", replicas)) + .arg("-n") + .arg(&namespace) + .arg("--kubeconfig") + .arg(&temp_path) + .arg("--context") + .arg(context.as_str()) + .output() + .await + .map_err(|e| format!("Failed to execute kubectl: {e}"))?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(stderr.to_string()); + } + + Ok(()) +} + +#[tauri::command] +pub async fn scale_replicaset( + cluster_id: String, + namespace: String, + name: String, + replicas: i32, + state: State<'_, AppState>, +) -> Result<(), String> { + validate_resource_name(&name, "name")?; + + let clusters = state.clusters.lock().await; + let cluster = clusters + .get(&cluster_id) + .ok_or_else(|| format!("Cluster {} not found", cluster_id))?; + + let kubeconfig_content = cluster.kubeconfig_content.as_ref(); + let context = &cluster.context; + + let temp_dir = std::env::temp_dir(); + let temp_path = temp_dir.join(format!("kubeconfig-{}-scale-rs.yaml", cluster_id)); + let _cleanup = TempFileCleanup(temp_path.clone()); + + write_secure_temp_file(&temp_path, kubeconfig_content) + .map_err(|e| format!("Failed to write kubeconfig temp file: {e}"))?; + + let kubectl_path = locate_kubectl()?; + + let output = Command::new(kubectl_path) + .arg("scale") + .arg(format!("replicasets/{}", name)) + .arg(format!("--replicas={}", replicas)) + .arg("-n") + .arg(&namespace) + .arg("--kubeconfig") + .arg(&temp_path) + .arg("--context") + .arg(context.as_str()) + .output() + .await + .map_err(|e| format!("Failed to execute kubectl: {e}"))?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(stderr.to_string()); + } + + Ok(()) +} + +#[tauri::command] +pub async fn scale_replicationcontroller( + cluster_id: String, + namespace: String, + name: String, + replicas: i32, + state: State<'_, AppState>, +) -> Result<(), String> { + validate_resource_name(&name, "name")?; + + let clusters = state.clusters.lock().await; + let cluster = clusters + .get(&cluster_id) + .ok_or_else(|| format!("Cluster {} not found", cluster_id))?; + + let kubeconfig_content = cluster.kubeconfig_content.as_ref(); + let context = &cluster.context; + + let temp_dir = std::env::temp_dir(); + let temp_path = temp_dir.join(format!("kubeconfig-{}-scale-rc.yaml", cluster_id)); + let _cleanup = TempFileCleanup(temp_path.clone()); + + write_secure_temp_file(&temp_path, kubeconfig_content) + .map_err(|e| format!("Failed to write kubeconfig temp file: {e}"))?; + + let kubectl_path = locate_kubectl()?; + + let output = Command::new(kubectl_path) + .arg("scale") + .arg(format!("replicationcontrollers/{}", name)) + .arg(format!("--replicas={}", replicas)) + .arg("-n") + .arg(&namespace) + .arg("--kubeconfig") + .arg(&temp_path) + .arg("--context") + .arg(context.as_str()) + .output() + .await + .map_err(|e| format!("Failed to execute kubectl: {e}"))?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(stderr.to_string()); + } + + Ok(()) +} + +#[tauri::command] +pub async fn suspend_cronjob( + cluster_id: String, + namespace: String, + name: String, + state: State<'_, AppState>, +) -> Result<(), String> { + validate_resource_name(&name, "name")?; + + let clusters = state.clusters.lock().await; + let cluster = clusters + .get(&cluster_id) + .ok_or_else(|| format!("Cluster {} not found", cluster_id))?; + + let kubeconfig_content = cluster.kubeconfig_content.as_ref(); + let context = &cluster.context; + + let temp_dir = std::env::temp_dir(); + let temp_path = temp_dir.join(format!("kubeconfig-{}-suspend-cj.yaml", cluster_id)); + let _cleanup = TempFileCleanup(temp_path.clone()); + + write_secure_temp_file(&temp_path, kubeconfig_content) + .map_err(|e| format!("Failed to write kubeconfig temp file: {e}"))?; + + let kubectl_path = locate_kubectl()?; + + let output = Command::new(kubectl_path) + .arg("patch") + .arg(format!("cronjob/{}", name)) + .arg("-p") + .arg(r#"{"spec":{"suspend":true}}"#) + .arg("-n") + .arg(&namespace) + .arg("--kubeconfig") + .arg(&temp_path) + .arg("--context") + .arg(context.as_str()) + .output() + .await + .map_err(|e| format!("Failed to execute kubectl: {e}"))?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(stderr.to_string()); + } + + Ok(()) +} + +#[tauri::command] +pub async fn resume_cronjob( + cluster_id: String, + namespace: String, + name: String, + state: State<'_, AppState>, +) -> Result<(), String> { + validate_resource_name(&name, "name")?; + + let clusters = state.clusters.lock().await; + let cluster = clusters + .get(&cluster_id) + .ok_or_else(|| format!("Cluster {} not found", cluster_id))?; + + let kubeconfig_content = cluster.kubeconfig_content.as_ref(); + let context = &cluster.context; + + let temp_dir = std::env::temp_dir(); + let temp_path = temp_dir.join(format!("kubeconfig-{}-resume-cj.yaml", cluster_id)); + let _cleanup = TempFileCleanup(temp_path.clone()); + + write_secure_temp_file(&temp_path, kubeconfig_content) + .map_err(|e| format!("Failed to write kubeconfig temp file: {e}"))?; + + let kubectl_path = locate_kubectl()?; + + let output = Command::new(kubectl_path) + .arg("patch") + .arg(format!("cronjob/{}", name)) + .arg("-p") + .arg(r#"{"spec":{"suspend":false}}"#) + .arg("-n") + .arg(&namespace) + .arg("--kubeconfig") + .arg(&temp_path) + .arg("--context") + .arg(context.as_str()) + .output() + .await + .map_err(|e| format!("Failed to execute kubectl: {e}"))?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(stderr.to_string()); + } + + Ok(()) +} + +#[tauri::command] +pub async fn trigger_cronjob( + cluster_id: String, + namespace: String, + name: String, + state: State<'_, AppState>, +) -> Result<(), String> { + validate_resource_name(&name, "name")?; + + let clusters = state.clusters.lock().await; + let cluster = clusters + .get(&cluster_id) + .ok_or_else(|| format!("Cluster {} not found", cluster_id))?; + + let kubeconfig_content = cluster.kubeconfig_content.as_ref(); + let context = &cluster.context; + + let temp_dir = std::env::temp_dir(); + let temp_path = temp_dir.join(format!("kubeconfig-{}-trigger-cj.yaml", cluster_id)); + let _cleanup = TempFileCleanup(temp_path.clone()); + + write_secure_temp_file(&temp_path, kubeconfig_content) + .map_err(|e| format!("Failed to write kubeconfig temp file: {e}"))?; + + let kubectl_path = locate_kubectl()?; + + let job_name = format!("{}-manual", name); + let from_spec = format!("cronjob/{}", name); + + let output = Command::new(kubectl_path) + .arg("create") + .arg("job") + .arg(&job_name) + .arg("--from") + .arg(&from_spec) + .arg("-n") + .arg(&namespace) + .arg("--kubeconfig") + .arg(&temp_path) + .arg("--context") + .arg(context.as_str()) + .output() + .await + .map_err(|e| format!("Failed to execute kubectl: {e}"))?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(stderr.to_string()); + } + + Ok(()) +} + +#[tauri::command] +pub async fn create_namespace( + cluster_id: String, + name: String, + state: State<'_, AppState>, +) -> Result<(), String> { + validate_resource_name(&name, "name")?; + + let clusters = state.clusters.lock().await; + let cluster = clusters + .get(&cluster_id) + .ok_or_else(|| format!("Cluster {} not found", cluster_id))?; + + let kubeconfig_content = cluster.kubeconfig_content.as_ref(); + let context = &cluster.context; + + let temp_dir = std::env::temp_dir(); + let temp_path = temp_dir.join(format!("kubeconfig-{}-create-ns.yaml", cluster_id)); + let _cleanup = TempFileCleanup(temp_path.clone()); + + write_secure_temp_file(&temp_path, kubeconfig_content) + .map_err(|e| format!("Failed to write kubeconfig temp file: {e}"))?; + + let kubectl_path = locate_kubectl()?; + + info!(cluster_id = %cluster_id, namespace = %name, "Creating namespace"); + + let output = Command::new(kubectl_path) + .arg("create") + .arg("namespace") + .arg(&name) + .arg("--kubeconfig") + .arg(&temp_path) + .arg("--context") + .arg(context.as_str()) + .output() + .await + .map_err(|e| format!("Failed to execute kubectl: {e}"))?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(stderr.to_string()); + } + + Ok(()) +} + +#[tauri::command] +pub async fn delete_namespace( + cluster_id: String, + name: String, + state: State<'_, AppState>, +) -> Result<(), String> { + validate_resource_name(&name, "name")?; + + let clusters = state.clusters.lock().await; + let cluster = clusters + .get(&cluster_id) + .ok_or_else(|| format!("Cluster {} not found", cluster_id))?; + + let kubeconfig_content = cluster.kubeconfig_content.as_ref(); + let context = &cluster.context; + + let temp_dir = std::env::temp_dir(); + let temp_path = temp_dir.join(format!("kubeconfig-{}-delete-ns.yaml", cluster_id)); + let _cleanup = TempFileCleanup(temp_path.clone()); + + write_secure_temp_file(&temp_path, kubeconfig_content) + .map_err(|e| format!("Failed to write kubeconfig temp file: {e}"))?; + + let kubectl_path = locate_kubectl()?; + + info!(cluster_id = %cluster_id, namespace = %name, "Deleting namespace"); + + let output = Command::new(kubectl_path) + .arg("delete") + .arg("namespace") + .arg(&name) + .arg("--kubeconfig") + .arg(&temp_path) + .arg("--context") + .arg(context.as_str()) + .output() + .await + .map_err(|e| format!("Failed to execute kubectl: {e}"))?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(stderr.to_string()); + } + + Ok(()) +} + +// ───────────────────────────────────────────────────────────────────────────── +// Phase 6: Log streaming (Tauri 2.x event channel) +// ───────────────────────────────────────────────────────────────────────────── + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LogStreamConfig { + pub cluster_id: String, + pub namespace: String, + pub pod_name: String, + pub container_name: String, + pub follow: bool, + pub timestamps: bool, + pub tail_lines: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LogLine { + pub stream_id: String, + pub line: String, +} + +#[tauri::command] +pub async fn stream_pod_logs( + config: LogStreamConfig, + state: State<'_, AppState>, + app_handle: tauri::AppHandle, +) -> Result { + validate_resource_name(&config.pod_name, "pod_name")?; + validate_resource_name(&config.namespace, "namespace")?; + + let stream_id = uuid::Uuid::now_v7().to_string(); + + let kubeconfig_content = { + let clusters = state.clusters.lock().await; + let cluster = clusters + .get(&config.cluster_id) + .ok_or_else(|| format!("Cluster {} not found", config.cluster_id))?; + (cluster.kubeconfig_content.clone(), cluster.context.clone()) + }; + + let (kubeconfig_arc, context) = kubeconfig_content; + + let temp_dir = std::env::temp_dir(); + let temp_path = temp_dir.join(format!("kubeconfig-{}-stream.yaml", config.cluster_id)); + + write_secure_temp_file(&temp_path, kubeconfig_arc.as_ref()) + .map_err(|e| format!("Failed to write kubeconfig temp file: {e}"))?; + + let kubectl_path = locate_kubectl()?; + + let mut cmd = Command::new(kubectl_path); + cmd.arg("logs") + .arg(&config.pod_name) + .arg("-n") + .arg(&config.namespace); + + if !config.container_name.is_empty() { + cmd.arg("-c").arg(&config.container_name); + } + + if config.follow { + cmd.arg("-f"); + } + + if config.timestamps { + cmd.arg("--timestamps"); + } + + if let Some(tail) = config.tail_lines { + cmd.arg(format!("--tail={}", tail)); + } + + cmd.arg("--kubeconfig") + .arg(&temp_path) + .arg("--context") + .arg(&context) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()); + + let mut child = cmd + .spawn() + .map_err(|e| format!("Failed to spawn kubectl logs: {e}"))?; + + let stdout = child + .stdout + .take() + .ok_or("Failed to capture kubectl stdout")?; + + let stream_id_clone = stream_id.clone(); + let app_handle_clone = app_handle.clone(); + + let task = tokio::spawn(async move { + let _cleanup = TempFileCleanup(temp_path); + + use tokio::io::{AsyncBufReadExt, BufReader}; + let reader = BufReader::new(stdout); + let mut lines = reader.lines(); + + while let Ok(Some(line)) = lines.next_line().await { + let payload = LogLine { + stream_id: stream_id_clone.clone(), + line, + }; + if let Err(e) = app_handle_clone.emit("pod-log-line", &payload) { + tracing::warn!(stream_id = %stream_id_clone, "Failed to emit log line event: {e}"); + break; + } + } + + let _ = child.wait().await; + }); + + let abort_handle = task.abort_handle(); + + { + let mut streams = state.log_streams.lock().await; + streams.insert(stream_id.clone(), abort_handle); + } + + info!(stream_id = %stream_id, pod = %config.pod_name, "Started pod log stream"); + + Ok(stream_id) +} + +#[tauri::command] +pub async fn stop_log_stream(stream_id: String, state: State<'_, AppState>) -> Result<(), String> { + let mut streams = state.log_streams.lock().await; + if let Some(handle) = streams.remove(&stream_id) { + handle.abort(); + info!(stream_id = %stream_id, "Stopped pod log stream"); + Ok(()) + } else { + Err(format!("Log stream {} not found", stream_id)) + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// Phase 7: Helm commands +// ───────────────────────────────────────────────────────────────────────────── + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct HelmRepository { + pub name: String, + pub url: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct HelmChart { + pub name: String, + pub chart_version: String, + pub app_version: String, + pub description: String, + pub repository: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct HelmRelease { + pub name: String, + pub namespace: String, + pub chart: String, + pub chart_version: String, + pub app_version: String, + pub status: String, + pub updated: String, +} + +#[tauri::command] +pub async fn helm_list_repos( + _cluster_id: String, + _state: State<'_, AppState>, +) -> Result, String> { + let helm_path = locate_helm()?; + + let output = Command::new(helm_path) + .arg("repo") + .arg("list") + .arg("--output") + .arg("json") + .output() + .await + .map_err(|e| format!("Failed to execute helm: {e}"))?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + // helm repo list exits non-zero when no repos are configured — treat as empty list + if stderr.contains("no repositories") || stderr.contains("Error: no repositories") { + return Ok(Vec::new()); + } + return Err(stderr.to_string()); + } + + let output_str = String::from_utf8_lossy(&output.stdout); + parse_helm_repos_json(&output_str) +} + +fn parse_helm_repos_json(json_str: &str) -> Result, String> { + let value: Value = serde_json::from_str(json_str) + .map_err(|e| format!("Failed to parse helm JSON output: {}", e))?; + + let items = value + .as_array() + .ok_or("Expected JSON array from helm repo list")?; + + let mut result = Vec::new(); + for item in items { + let name = item + .get("name") + .and_then(|n| n.as_str()) + .unwrap_or("unknown") + .to_string(); + + let url = item + .get("url") + .and_then(|u| u.as_str()) + .unwrap_or("") + .to_string(); + + result.push(HelmRepository { name, url }); + } + + Ok(result) +} + +#[tauri::command] +pub async fn helm_add_repo( + _cluster_id: String, + name: String, + url: String, + _state: State<'_, AppState>, +) -> Result<(), String> { + validate_resource_name(&name, "repo name")?; + + let helm_path = locate_helm()?; + + info!(repo_name = %name, repo_url = %url, "Adding helm repository"); + + let output = Command::new(helm_path) + .arg("repo") + .arg("add") + .arg(&name) + .arg(&url) + .output() + .await + .map_err(|e| format!("Failed to execute helm: {e}"))?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(stderr.to_string()); + } + + Ok(()) +} + +#[tauri::command] +pub async fn helm_update_repos( + _cluster_id: String, + _state: State<'_, AppState>, +) -> Result<(), String> { + let helm_path = locate_helm()?; + + let output = Command::new(helm_path) + .arg("repo") + .arg("update") + .output() + .await + .map_err(|e| format!("Failed to execute helm: {e}"))?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(stderr.to_string()); + } + + Ok(()) +} + +#[tauri::command] +pub async fn helm_search_repo( + _cluster_id: String, + query: String, + _state: State<'_, AppState>, +) -> Result, String> { + let helm_path = locate_helm()?; + + let output = Command::new(helm_path) + .arg("search") + .arg("repo") + .arg(&query) + .arg("--output") + .arg("json") + .output() + .await + .map_err(|e| format!("Failed to execute helm: {e}"))?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(stderr.to_string()); + } + + let output_str = String::from_utf8_lossy(&output.stdout); + parse_helm_search_json(&output_str) +} + +fn parse_helm_search_json(json_str: &str) -> Result, String> { + let value: Value = serde_json::from_str(json_str) + .map_err(|e| format!("Failed to parse helm JSON output: {}", e))?; + + let items = value + .as_array() + .ok_or("Expected JSON array from helm search repo")?; + + let mut result = Vec::new(); + for item in items { + let name = item + .get("name") + .and_then(|n| n.as_str()) + .unwrap_or("unknown") + .to_string(); + + let chart_version = item + .get("version") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + + let app_version = item + .get("app_version") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + + let description = item + .get("description") + .and_then(|d| d.as_str()) + .unwrap_or("") + .to_string(); + + // Repository is the prefix before the first '/' in the chart name + let repository = name.split('/').next().unwrap_or("").to_string(); + + result.push(HelmChart { + name, + chart_version, + app_version, + description, + repository, + }); + } + + Ok(result) +} + +#[tauri::command] +pub async fn helm_list_releases( + cluster_id: String, + namespace: String, + state: State<'_, AppState>, +) -> Result, String> { + let kubeconfig_content = { + let clusters = state.clusters.lock().await; + let cluster = clusters + .get(&cluster_id) + .ok_or_else(|| format!("Cluster {} not found", cluster_id))?; + (cluster.kubeconfig_content.clone(), cluster.context.clone()) + }; + + let (kubeconfig_arc, context) = kubeconfig_content; + + let temp_dir = std::env::temp_dir(); + let temp_path = temp_dir.join(format!("kubeconfig-{}-helm-list.yaml", cluster_id)); + let _cleanup = TempFileCleanup(temp_path.clone()); + + write_secure_temp_file(&temp_path, kubeconfig_arc.as_ref()) + .map_err(|e| format!("Failed to write kubeconfig temp file: {e}"))?; + + let helm_path = locate_helm()?; + + let mut cmd = Command::new(helm_path); + cmd.arg("list"); + + if namespace.is_empty() { + cmd.arg("--all-namespaces"); + } else { + cmd.arg("-n").arg(&namespace); + } + + let output = cmd + .arg("--output") + .arg("json") + .arg("--kubeconfig") + .arg(&temp_path) + .arg("--kube-context") + .arg(&context) + .output() + .await + .map_err(|e| format!("Failed to execute helm: {e}"))?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(stderr.to_string()); + } + + let output_str = String::from_utf8_lossy(&output.stdout); + parse_helm_releases_json(&output_str) +} + +fn parse_helm_releases_json(json_str: &str) -> Result, String> { + let value: Value = serde_json::from_str(json_str) + .map_err(|e| format!("Failed to parse helm JSON output: {}", e))?; + + let items = value + .as_array() + .ok_or("Expected JSON array from helm list")?; + + let mut result = Vec::new(); + for item in items { + let name = item + .get("name") + .and_then(|n| n.as_str()) + .unwrap_or("unknown") + .to_string(); + + let namespace = item + .get("namespace") + .and_then(|n| n.as_str()) + .unwrap_or("default") + .to_string(); + + let chart = item + .get("chart") + .and_then(|c| c.as_str()) + .unwrap_or("") + .to_string(); + + // chart field is "chartname-version" — split off the version suffix + let (chart_name, chart_version) = if let Some(pos) = chart.rfind('-') { + (chart[..pos].to_string(), chart[pos + 1..].to_string()) + } else { + (chart.clone(), String::new()) + }; + + let app_version = item + .get("app_version") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + + let status = item + .get("status") + .and_then(|s| s.as_str()) + .unwrap_or("unknown") + .to_string(); + + let updated = item + .get("updated") + .and_then(|u| u.as_str()) + .unwrap_or("") + .to_string(); + + result.push(HelmRelease { + name, + namespace, + chart: chart_name, + chart_version, + app_version, + status, + updated, + }); + } + + Ok(result) +} + +#[tauri::command] +pub async fn helm_uninstall( + cluster_id: String, + namespace: String, + release_name: String, + state: State<'_, AppState>, +) -> Result<(), String> { + validate_resource_name(&release_name, "release_name")?; + + let kubeconfig_content = { + let clusters = state.clusters.lock().await; + let cluster = clusters + .get(&cluster_id) + .ok_or_else(|| format!("Cluster {} not found", cluster_id))?; + (cluster.kubeconfig_content.clone(), cluster.context.clone()) + }; + + let (kubeconfig_arc, context) = kubeconfig_content; + + let temp_dir = std::env::temp_dir(); + let temp_path = temp_dir.join(format!("kubeconfig-{}-helm-uninstall.yaml", cluster_id)); + let _cleanup = TempFileCleanup(temp_path.clone()); + + write_secure_temp_file(&temp_path, kubeconfig_arc.as_ref()) + .map_err(|e| format!("Failed to write kubeconfig temp file: {e}"))?; + + let helm_path = locate_helm()?; + + info!(cluster_id = %cluster_id, release = %release_name, namespace = %namespace, "Uninstalling helm release"); + + let output = Command::new(helm_path) + .arg("uninstall") + .arg(&release_name) + .arg("-n") + .arg(&namespace) + .arg("--kubeconfig") + .arg(&temp_path) + .arg("--kube-context") + .arg(&context) + .output() + .await + .map_err(|e| format!("Failed to execute helm: {e}"))?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(stderr.to_string()); + } + + Ok(()) +} + +#[tauri::command] +pub async fn helm_rollback( + cluster_id: String, + namespace: String, + release_name: String, + revision: Option, + state: State<'_, AppState>, +) -> Result<(), String> { + validate_resource_name(&release_name, "release_name")?; + + let kubeconfig_content = { + let clusters = state.clusters.lock().await; + let cluster = clusters + .get(&cluster_id) + .ok_or_else(|| format!("Cluster {} not found", cluster_id))?; + (cluster.kubeconfig_content.clone(), cluster.context.clone()) + }; + + let (kubeconfig_arc, context) = kubeconfig_content; + + let temp_dir = std::env::temp_dir(); + let temp_path = temp_dir.join(format!("kubeconfig-{}-helm-rollback.yaml", cluster_id)); + let _cleanup = TempFileCleanup(temp_path.clone()); + + write_secure_temp_file(&temp_path, kubeconfig_arc.as_ref()) + .map_err(|e| format!("Failed to write kubeconfig temp file: {e}"))?; + + let helm_path = locate_helm()?; + + info!(cluster_id = %cluster_id, release = %release_name, revision = ?revision, "Rolling back helm release"); + + let mut cmd = Command::new(helm_path); + cmd.arg("rollback").arg(&release_name); + + if let Some(rev) = revision { + cmd.arg(rev.to_string()); + } + + let output = cmd + .arg("-n") + .arg(&namespace) + .arg("--kubeconfig") + .arg(&temp_path) + .arg("--kube-context") + .arg(&context) + .output() + .await + .map_err(|e| format!("Failed to execute helm: {e}"))?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(stderr.to_string()); + } + + Ok(()) +} + +// ───────────────────────────────────────────────────────────────────────────── +// Phase 8: New command unit tests +// ───────────────────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod new_command_tests { + use super::*; + + #[test] + fn test_parse_replicationcontrollers_json() { + let json = r#"{"items":[{"metadata":{"name":"my-rc","namespace":"default","creationTimestamp":"2024-01-01T00:00:00Z","labels":{"app":"myapp"}},"spec":{"replicas":3},"status":{"readyReplicas":3}}]}"#; + let result = parse_replicationcontrollers_json(json).unwrap(); + assert_eq!(result.len(), 1); + assert_eq!(result[0].name, "my-rc"); + assert_eq!(result[0].namespace, "default"); + assert_eq!(result[0].replicas, 3); + assert_eq!(result[0].ready, "3/3"); + } + + #[test] + fn test_parse_replicationcontrollers_json_empty() { + let json = r#"{"items":[]}"#; + let result = parse_replicationcontrollers_json(json).unwrap(); + assert!(result.is_empty()); + } + + #[test] + fn test_parse_poddisruptionbudgets_json() { + let json = r#"{"items":[{"metadata":{"name":"my-pdb","namespace":"default","creationTimestamp":"2024-01-01T00:00:00Z"},"spec":{"minAvailable":1},"status":{"disruptionsAllowed":2}}]}"#; + let result = parse_poddisruptionbudgets_json(json).unwrap(); + assert_eq!(result.len(), 1); + assert_eq!(result[0].name, "my-pdb"); + assert_eq!(result[0].allowed_disruptions, 2); + } + + #[test] + fn test_parse_priorityclasses_json() { + let json = r#"{"items":[{"metadata":{"name":"high-priority","creationTimestamp":"2024-01-01T00:00:00Z"},"value":1000,"globalDefault":false,"description":"High priority class"}]}"#; + let result = parse_priorityclasses_json(json).unwrap(); + assert_eq!(result.len(), 1); + assert_eq!(result[0].name, "high-priority"); + assert_eq!(result[0].value, 1000); + assert!(!result[0].global_default); + assert_eq!(result[0].description, "High priority class"); + } + + #[test] + fn test_parse_runtimeclasses_json() { + let json = r#"{"items":[{"metadata":{"name":"gvisor","creationTimestamp":"2024-01-01T00:00:00Z"},"handler":"runsc"}]}"#; + let result = parse_runtimeclasses_json(json).unwrap(); + assert_eq!(result.len(), 1); + assert_eq!(result[0].name, "gvisor"); + assert_eq!(result[0].handler, "runsc"); + } + + #[test] + fn test_parse_leases_json() { + let json = r#"{"items":[{"metadata":{"name":"my-lease","namespace":"kube-system","creationTimestamp":"2024-01-01T00:00:00Z"},"spec":{"holderIdentity":"node-1"}}]}"#; + let result = parse_leases_json(json).unwrap(); + assert_eq!(result.len(), 1); + assert_eq!(result[0].name, "my-lease"); + assert_eq!(result[0].holder, "node-1"); + } + + #[test] + fn test_parse_mutatingwebhookconfigurations_json() { + let json = r#"{"items":[{"metadata":{"name":"my-mwh","creationTimestamp":"2024-01-01T00:00:00Z"},"webhooks":[{"name":"w1.example.com"},{"name":"w2.example.com"}]}]}"#; + let result = parse_mutatingwebhookconfigurations_json(json).unwrap(); + assert_eq!(result.len(), 1); + assert_eq!(result[0].name, "my-mwh"); + assert_eq!(result[0].webhooks, 2); + } + + #[test] + fn test_parse_validatingwebhookconfigurations_json() { + let json = r#"{"items":[{"metadata":{"name":"my-vwh","creationTimestamp":"2024-01-01T00:00:00Z"},"webhooks":[{"name":"v1.example.com"}]}]}"#; + let result = parse_validatingwebhookconfigurations_json(json).unwrap(); + assert_eq!(result.len(), 1); + assert_eq!(result[0].webhooks, 1); + } + + #[test] + fn test_parse_endpoints_json() { + let json = r#"{"items":[{"metadata":{"name":"my-svc","namespace":"default","creationTimestamp":"2024-01-01T00:00:00Z"},"subsets":[{"addresses":[{"ip":"10.0.0.1"},{"ip":"10.0.0.2"}],"ports":[{"port":80}]}]}]}"#; + let result = parse_endpoints_json(json).unwrap(); + assert_eq!(result.len(), 1); + assert_eq!(result[0].name, "my-svc"); + assert!(result[0].endpoints.contains("10.0.0.1")); + } + + #[test] + fn test_parse_endpointslices_json() { + let json = r#"{"items":[{"metadata":{"name":"my-eps","namespace":"default","creationTimestamp":"2024-01-01T00:00:00Z"},"addressType":"IPv4","ports":[{"port":80}],"endpoints":[{"addresses":["10.0.0.1"]},{"addresses":["10.0.0.2"]}]}]}"#; + let result = parse_endpointslices_json(json).unwrap(); + assert_eq!(result.len(), 1); + assert_eq!(result[0].address_type, "IPv4"); + assert_eq!(result[0].endpoints, 2); + assert_eq!(result[0].ports, "80"); + } + + #[test] + fn test_parse_ingressclasses_json() { + let json = r#"{"items":[{"metadata":{"name":"nginx","creationTimestamp":"2024-01-01T00:00:00Z","annotations":{"ingressclass.kubernetes.io/is-default-class":"true"}},"spec":{"controller":"k8s.io/ingress-nginx"}}]}"#; + let result = parse_ingressclasses_json(json).unwrap(); + assert_eq!(result.len(), 1); + assert_eq!(result[0].name, "nginx"); + assert_eq!(result[0].controller, "k8s.io/ingress-nginx"); + assert!(result[0].is_default); + } + + #[test] + fn test_parse_namespaces_resource_json() { + let json = r#"{"items":[{"metadata":{"name":"kube-system","creationTimestamp":"2024-01-01T00:00:00Z"},"status":{"phase":"Active"}}]}"#; + let result = parse_namespaces_resource_json(json).unwrap(); + assert_eq!(result.len(), 1); + assert_eq!(result[0].name, "kube-system"); + assert_eq!(result[0].status, "Active"); + } + + #[test] + fn test_parse_crds_json() { + let json = r#"{"items":[{"metadata":{"name":"foos.example.com","creationTimestamp":"2024-01-01T00:00:00Z"},"spec":{"group":"example.com","versions":[{"name":"v1alpha1"}],"names":{"kind":"Foo"},"scope":"Namespaced"}}]}"#; + let result = parse_crds_json(json).unwrap(); + assert_eq!(result.len(), 1); + assert_eq!(result[0].group, "example.com"); + assert_eq!(result[0].version, "v1alpha1"); + assert_eq!(result[0].kind, "Foo"); + assert_eq!(result[0].scope, "Namespaced"); + } + + #[test] + fn test_parse_custom_resources_json() { + let json = r#"{"items":[{"metadata":{"name":"my-foo","namespace":"default","creationTimestamp":"2024-01-01T00:00:00Z"}}]}"#; + let result = parse_custom_resources_json(json).unwrap(); + assert_eq!(result.len(), 1); + assert_eq!(result[0].name, "my-foo"); + assert_eq!(result[0].namespace, "default"); + } + + #[test] + fn test_parse_helm_repos_json() { + let json = r#"[{"name":"stable","url":"https://charts.helm.sh/stable"},{"name":"bitnami","url":"https://charts.bitnami.com/bitnami"}]"#; + let result = parse_helm_repos_json(json).unwrap(); + assert_eq!(result.len(), 2); + assert_eq!(result[0].name, "stable"); + assert_eq!(result[1].name, "bitnami"); + } + + #[test] + fn test_parse_helm_search_json() { + let json = r#"[{"name":"bitnami/nginx","version":"15.0.0","app_version":"1.25.0","description":"NGINX Open Source is a web server"}]"#; + let result = parse_helm_search_json(json).unwrap(); + assert_eq!(result.len(), 1); + assert_eq!(result[0].name, "bitnami/nginx"); + assert_eq!(result[0].chart_version, "15.0.0"); + assert_eq!(result[0].repository, "bitnami"); + } + + #[test] + fn test_parse_helm_releases_json() { + let json = r#"[{"name":"my-release","namespace":"default","chart":"nginx-15.0.0","app_version":"1.25.0","status":"deployed","updated":"2024-01-01 12:00:00.000000000 +0000 UTC"}]"#; + let result = parse_helm_releases_json(json).unwrap(); + assert_eq!(result.len(), 1); + assert_eq!(result[0].name, "my-release"); + assert_eq!(result[0].chart, "nginx"); + assert_eq!(result[0].chart_version, "15.0.0"); + assert_eq!(result[0].status, "deployed"); + } + + #[test] + fn test_parse_helm_repos_json_empty() { + let json = r#"[]"#; + let result = parse_helm_repos_json(json).unwrap(); + assert!(result.is_empty()); + } + + #[test] + fn test_parse_crds_json_empty() { + let json = r#"{"items":[]}"#; + let result = parse_crds_json(json).unwrap(); + assert!(result.is_empty()); + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 128d4e11..750cf888 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -45,6 +45,7 @@ pub fn run() { port_forwards: Arc::new(tokio::sync::Mutex::new(std::collections::HashMap::new())), refresh_registry: Arc::new(tokio::sync::Mutex::new(crate::kube::RefreshRegistry::new())), watchers: Arc::new(Mutex::new(std::collections::HashMap::new())), + log_streams: Arc::new(tokio::sync::Mutex::new(std::collections::HashMap::new())), }; let stronghold_salt = format!( "tftsr-stronghold-salt-v1-{:x}", @@ -232,6 +233,46 @@ pub fn run() { commands::kube::rollback_deployment, commands::kube::create_resource, commands::kube::edit_resource, + // Phase 4: Additional Resource Discovery + commands::kube::list_replicationcontrollers, + commands::kube::list_poddisruptionbudgets, + commands::kube::list_priorityclasses, + commands::kube::list_runtimeclasses, + commands::kube::list_leases, + commands::kube::list_mutatingwebhookconfigurations, + commands::kube::list_validatingwebhookconfigurations, + commands::kube::list_endpoints, + commands::kube::list_endpointslices, + commands::kube::list_ingressclasses, + commands::kube::list_namespaces_resource, + commands::kube::list_crds, + commands::kube::list_custom_resources, + // Phase 5: Action Commands + commands::kube::force_delete_resource, + commands::kube::describe_resource, + commands::kube::get_resource_yaml, + commands::kube::attach_pod, + commands::kube::restart_statefulset, + commands::kube::restart_daemonset, + commands::kube::scale_statefulset, + commands::kube::scale_replicaset, + commands::kube::scale_replicationcontroller, + commands::kube::suspend_cronjob, + commands::kube::resume_cronjob, + commands::kube::trigger_cronjob, + commands::kube::create_namespace, + commands::kube::delete_namespace, + // Phase 6: Log Streaming + commands::kube::stream_pod_logs, + commands::kube::stop_log_stream, + // Phase 7: Helm Commands + commands::kube::helm_list_repos, + commands::kube::helm_add_repo, + commands::kube::helm_update_repos, + commands::kube::helm_search_repo, + commands::kube::helm_list_releases, + commands::kube::helm_uninstall, + commands::kube::helm_rollback, ]) .run(tauri::generate_context!()) .expect("Error running Troubleshooting and RCA Assistant application"); diff --git a/src-tauri/src/shell/helm.rs b/src-tauri/src/shell/helm.rs new file mode 100644 index 00000000..a1f10676 --- /dev/null +++ b/src-tauri/src/shell/helm.rs @@ -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 { + // 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 + } +} diff --git a/src-tauri/src/shell/mod.rs b/src-tauri/src/shell/mod.rs index fa6a0ab6..8560feed 100644 --- a/src-tauri/src/shell/mod.rs +++ b/src-tauri/src/shell/mod.rs @@ -1,5 +1,6 @@ pub mod classifier; pub mod executor; +pub mod helm; pub mod kubeconfig; pub mod kubectl; @@ -8,5 +9,6 @@ mod tests; pub use classifier::{ClassificationResult, CommandClassifier, CommandTier}; pub use executor::{execute_with_approval, CommandOutput}; +pub use helm::locate_helm; pub use kubeconfig::{auto_detect_kubeconfig, KubeconfigInfo}; pub use kubectl::{execute_kubectl, locate_kubectl}; diff --git a/src-tauri/src/state.rs b/src-tauri/src/state.rs index b8101f05..8266e8f3 100644 --- a/src-tauri/src/state.rs +++ b/src-tauri/src/state.rs @@ -99,6 +99,8 @@ pub struct AppState { pub refresh_registry: Arc>, /// Resource watchers: unsubscribe_id -> receiver pub watchers: Arc>>>, + /// Active pod log streaming tasks: stream_id -> abort handle + pub log_streams: Arc>>, } /// Determine the application data directory. diff --git a/src/components/Kubernetes/AttachModal.tsx b/src/components/Kubernetes/AttachModal.tsx new file mode 100644 index 00000000..2c241a8f --- /dev/null +++ b/src/components/Kubernetes/AttachModal.tsx @@ -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(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 ( + + + + + Attach — {podName} + + +
+
+ + +
+ {error && ( + + {error} + + )} +
+            {output || "Select a container and click Attach."}
+          
+
+
+
+ ); +} diff --git a/src/components/Kubernetes/ClusterRoleBindingList.tsx b/src/components/Kubernetes/ClusterRoleBindingList.tsx index 218349b3..7d39073e 100644 --- a/src/components/Kubernetes/ClusterRoleBindingList.tsx +++ b/src/components/Kubernetes/ClusterRoleBindingList.tsx @@ -1,41 +1,131 @@ -import React from "react"; +import React, { useState } from "react"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui"; +import { Pencil, Trash2 } from "lucide-react"; import type { ClusterRoleBindingInfo } from "@/lib/tauriCommands"; +import { deleteResourceCmd, getResourceYamlCmd } from "@/lib/tauriCommands"; +import { ResourceActionMenu } from "./ResourceActionMenu"; +import { ConfirmDeleteDialog } from "./ConfirmDeleteDialog"; +import { EditResourceModal } from "./EditResourceModal"; interface ClusterRoleBindingListProps { clusterRoleBindings: ClusterRoleBindingInfo[]; - _clusterId: string; + clusterId?: string; + _clusterId?: string; + onRefresh?: () => void; } -export function ClusterRoleBindingList({ clusterRoleBindings, _clusterId }: ClusterRoleBindingListProps) { +type ActiveModal = + | { type: "edit"; crb: ClusterRoleBindingInfo; yaml: string } + | { type: "delete"; crb: ClusterRoleBindingInfo } + | null; + +export function ClusterRoleBindingList({ + clusterRoleBindings, + clusterId, + _clusterId, + onRefresh, +}: ClusterRoleBindingListProps) { + const cid = clusterId ?? _clusterId ?? ""; + const [activeModal, setActiveModal] = useState(null); + const [isDeleting, setIsDeleting] = useState(false); + const [actionError, setActionError] = useState(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 ( -
- - - - Name - Cluster Role - Age - - - - {clusterRoleBindings.length === 0 ? ( + <> + {actionError && ( +

{actionError}

+ )} +
+
+ - - No cluster role bindings found - + Name + Cluster Role + Age + Actions - ) : ( - clusterRoleBindings.map((crb) => ( - - {crb.name} - {crb.cluster_role} - {crb.age} + + + {clusterRoleBindings.length === 0 ? ( + + + No cluster role bindings found + - )) - )} - -
-
+ ) : ( + clusterRoleBindings.map((crb) => ( + + {crb.name} + {crb.cluster_role} + {crb.age} + + openEdit(crb), + }, + { + label: "Delete", + icon: Trash2, + variant: "destructive", + onClick: () => setActiveModal({ type: "delete", crb }), + }, + ]} + /> + + + )) + )} + + + + + {activeModal?.type === "edit" && ( + { setActiveModal(null); onRefresh?.(); }} + /> + )} + + {activeModal?.type === "delete" && ( + { if (!o) setActiveModal(null); }} + resourceType="ClusterRoleBinding" + resourceName={activeModal.crb.name} + isLoading={isDeleting} + onConfirm={handleDelete} + /> + )} + ); } diff --git a/src/components/Kubernetes/ClusterRoleList.tsx b/src/components/Kubernetes/ClusterRoleList.tsx index 56f94c58..84e9a8d7 100644 --- a/src/components/Kubernetes/ClusterRoleList.tsx +++ b/src/components/Kubernetes/ClusterRoleList.tsx @@ -1,39 +1,129 @@ -import React from "react"; +import React, { useState } from "react"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui"; +import { Pencil, Trash2 } from "lucide-react"; import type { ClusterRoleInfo } from "@/lib/tauriCommands"; +import { deleteResourceCmd, getResourceYamlCmd } from "@/lib/tauriCommands"; +import { ResourceActionMenu } from "./ResourceActionMenu"; +import { ConfirmDeleteDialog } from "./ConfirmDeleteDialog"; +import { EditResourceModal } from "./EditResourceModal"; interface ClusterRoleListProps { clusterRoles: ClusterRoleInfo[]; - _clusterId: string; + clusterId?: string; + _clusterId?: string; + onRefresh?: () => void; } -export function ClusterRoleList({ clusterRoles, _clusterId }: ClusterRoleListProps) { +type ActiveModal = + | { type: "edit"; cr: ClusterRoleInfo; yaml: string } + | { type: "delete"; cr: ClusterRoleInfo } + | null; + +export function ClusterRoleList({ + clusterRoles, + clusterId, + _clusterId, + onRefresh, +}: ClusterRoleListProps) { + const cid = clusterId ?? _clusterId ?? ""; + const [activeModal, setActiveModal] = useState(null); + const [isDeleting, setIsDeleting] = useState(false); + const [actionError, setActionError] = useState(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 ( -
- - - - Name - Age - - - - {clusterRoles.length === 0 ? ( + <> + {actionError && ( +

{actionError}

+ )} +
+
+ - - No cluster roles found - + Name + Age + Actions - ) : ( - clusterRoles.map((clusterRole) => ( - - {clusterRole.name} - {clusterRole.age} + + + {clusterRoles.length === 0 ? ( + + + No cluster roles found + - )) - )} - -
-
+ ) : ( + clusterRoles.map((cr) => ( + + {cr.name} + {cr.age} + + openEdit(cr), + }, + { + label: "Delete", + icon: Trash2, + variant: "destructive", + onClick: () => setActiveModal({ type: "delete", cr }), + }, + ]} + /> + + + )) + )} + + + + + {activeModal?.type === "edit" && ( + { setActiveModal(null); onRefresh?.(); }} + /> + )} + + {activeModal?.type === "delete" && ( + { if (!o) setActiveModal(null); }} + resourceType="ClusterRole" + resourceName={activeModal.cr.name} + isLoading={isDeleting} + onConfirm={handleDelete} + /> + )} + ); } diff --git a/src/components/Kubernetes/ConfigMapList.tsx b/src/components/Kubernetes/ConfigMapList.tsx index 64ab1fe9..5f3cd5f9 100644 --- a/src/components/Kubernetes/ConfigMapList.tsx +++ b/src/components/Kubernetes/ConfigMapList.tsx @@ -1,57 +1,127 @@ -import React from "react"; +import React, { useState } from "react"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui"; -import { Button } from "@/components/ui"; +import { Pencil, Trash2 } from "lucide-react"; import type { ConfigMapInfo } from "@/lib/tauriCommands"; +import { deleteResourceCmd, getResourceYamlCmd } from "@/lib/tauriCommands"; +import { ResourceActionMenu } from "./ResourceActionMenu"; +import { ConfirmDeleteDialog } from "./ConfirmDeleteDialog"; +import { EditResourceModal } from "./EditResourceModal"; interface ConfigMapListProps { configmaps: ConfigMapInfo[]; clusterId: string; namespace: string; + onRefresh?: () => void; } -export function ConfigMapList({ configmaps }: ConfigMapListProps) { +type ActiveModal = + | { type: "edit"; cm: ConfigMapInfo; yaml: string } + | { type: "delete"; cm: ConfigMapInfo } + | null; + +export function ConfigMapList({ configmaps, clusterId, namespace, onRefresh }: ConfigMapListProps) { + const [activeModal, setActiveModal] = useState(null); + const [isDeleting, setIsDeleting] = useState(false); + const [actionError, setActionError] = useState(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 ( -
- - - - Name - Namespace - Data Keys - Age - Actions - - - - {configmaps.length === 0 ? ( + <> + {actionError && ( +

{actionError}

+ )} +
+
+ - - No configmaps found - + Name + Namespace + Data Keys + Age + Actions - ) : ( - configmaps.map((configmap) => ( - - {configmap.name} - {configmap.namespace} - {configmap.data_keys} - {configmap.age} - - + + + {configmaps.length === 0 ? ( + + + No configmaps found - )) - )} - -
-
+ ) : ( + configmaps.map((cm) => ( + + {cm.name} + {cm.namespace} + {cm.data_keys} + {cm.age} + + openEdit(cm), + }, + { + label: "Delete", + icon: Trash2, + variant: "destructive", + onClick: () => setActiveModal({ type: "delete", cm }), + }, + ]} + /> + + + )) + )} + + + + + {activeModal?.type === "edit" && ( + { setActiveModal(null); onRefresh?.(); }} + /> + )} + + {activeModal?.type === "delete" && ( + { if (!o) setActiveModal(null); }} + resourceType="ConfigMap" + resourceName={activeModal.cm.name} + isLoading={isDeleting} + onConfirm={handleDelete} + /> + )} + ); } diff --git a/src/components/Kubernetes/ConfirmDeleteDialog.tsx b/src/components/Kubernetes/ConfirmDeleteDialog.tsx new file mode 100644 index 00000000..9e7dc36c --- /dev/null +++ b/src/components/Kubernetes/ConfirmDeleteDialog.tsx @@ -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; + 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 ( + + + + + + {isForce ? `Force Delete ${resourceType}` : `Delete ${resourceType}`} + + + {isForce ? ( + <> + Are you sure you want to force delete{" "} + {resourceName}? +
+ + This will immediately terminate the resource with no grace period. + + + ) : ( + <> + Are you sure you want to delete{" "} + {resourceName}? This + action cannot be undone. + + )} +
+
+ + + + +
+
+ ); +} diff --git a/src/components/Kubernetes/CrdList.tsx b/src/components/Kubernetes/CrdList.tsx new file mode 100644 index 00000000..a49f123c --- /dev/null +++ b/src/components/Kubernetes/CrdList.tsx @@ -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([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [expandedCrd, setExpandedCrd] = useState(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 ( +
+ + Loading CRDs… +
+ ); + } + + return ( +
+
+ + {crds.length} custom resource definition{crds.length !== 1 ? "s" : ""} + + +
+ + {error && ( +
+ {error} +
+ )} + +
+ {crds.length === 0 ? ( +
+ No custom resource definitions found +
+ ) : ( + + + + + + + + + + + + + {crds.map((crd) => { + const isExpanded = expandedCrd === crd.name; + return ( + + handleRowClick(crd)} + > + + + + + + + + {isExpanded && ( + + + + )} + + ); + })} + +
NameKindGroupVersionScopeAge
+
+ {isExpanded ? ( + + ) : ( + + )} + {crd.name} +
+
{crd.kind}{crd.group}{crd.version} + + {crd.scope} + + {crd.age}
+ +
+ )} +
+
+ ); +} diff --git a/src/components/Kubernetes/CronJobList.tsx b/src/components/Kubernetes/CronJobList.tsx index 6570d5d4..8daeabb6 100644 --- a/src/components/Kubernetes/CronJobList.tsx +++ b/src/components/Kubernetes/CronJobList.tsx @@ -1,54 +1,206 @@ -import React from "react"; +import React, { useState } from "react"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui"; +import { PauseCircle, PlayCircle, Play, Pencil, Trash2 } from "lucide-react"; import type { CronJobInfo } from "@/lib/tauriCommands"; +import { + suspendCronjobCmd, + resumeCronjobCmd, + triggerCronjobCmd, + deleteResourceCmd, + getResourceYamlCmd, +} from "@/lib/tauriCommands"; +import { ResourceActionMenu } from "./ResourceActionMenu"; +import { ConfirmDeleteDialog } from "./ConfirmDeleteDialog"; +import { EditResourceModal } from "./EditResourceModal"; interface CronJobListProps { cronJobs: CronJobInfo[]; - _clusterId: string; - _namespace: string; + clusterId?: string; + _clusterId?: string; + namespace?: string; + _namespace?: string; + onRefresh?: () => void; } -export function CronJobList({ cronJobs, _clusterId, _namespace }: CronJobListProps) { +type ActiveModal = + | { type: "edit"; cj: CronJobInfo; yaml: string } + | { type: "delete"; cj: CronJobInfo } + | null; + +export function CronJobList({ + cronJobs, + clusterId, + _clusterId, + namespace, + _namespace, + onRefresh, +}: CronJobListProps) { + const cid = clusterId ?? _clusterId ?? ""; + const ns = namespace ?? _namespace ?? ""; + const [activeModal, setActiveModal] = useState(null); + const [isDeleting, setIsDeleting] = useState(false); + const [actionError, setActionError] = useState(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 ( -
- - - - Name - Namespace - Schedule - Active - Last Schedule - Age - Labels - - - - {cronJobs.length === 0 ? ( + <> + {actionError && ( +

{actionError}

+ )} +
+
+ - - No cron jobs found - + Name + Namespace + Schedule + Active + Last Schedule + Age + Labels + Actions - ) : ( - cronJobs.map((cronJob) => ( - - {cronJob.name} - {cronJob.namespace} - {cronJob.schedule} - {cronJob.active} - {cronJob.last_schedule} - {cronJob.age} - - {Object.entries(cronJob.labels) - .map(([k, v]) => `${k}=${v}`) - .join(", ")} + + + {cronJobs.length === 0 ? ( + + + No cron jobs found - )) - )} - -
-
+ ) : ( + cronJobs.map((cj) => ( + + {cj.name} + {cj.namespace} + {cj.schedule} + {cj.active} + {cj.last_schedule} + {cj.age} + + {Object.entries(cj.labels) + .map(([k, v]) => `${k}=${v}`) + .join(", ")} + + + 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 }), + }, + ]} + /> + + + )) + )} + + + + + {activeModal?.type === "edit" && ( + { setActiveModal(null); onRefresh?.(); }} + /> + )} + + {activeModal?.type === "delete" && ( + { if (!o) setActiveModal(null); }} + resourceType="CronJob" + resourceName={activeModal.cj.name} + isLoading={isDeleting} + onConfirm={handleDelete} + /> + )} + ); } diff --git a/src/components/Kubernetes/CustomResourceList.tsx b/src/components/Kubernetes/CustomResourceList.tsx new file mode 100644 index 00000000..978ac559 --- /dev/null +++ b/src/components/Kubernetes/CustomResourceList.tsx @@ -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([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(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 ( +
+ + Loading {kind} instances… +
+ ); + } + + if (error) { + return ( +
+ {error} +
+ ); + } + + if (items.length === 0) { + return ( +

+ No {kind} instances found. +

+ ); + } + + const showNamespace = items.some((item) => item.namespace !== ""); + + return ( +
+ + + + + {showNamespace && ( + + )} + + + + + {items.map((item) => ( + + + {showNamespace && ( + + )} + + + ))} + +
NameNamespaceAge
{item.name}{item.namespace || "—"}{item.age}
+
+ ); +} diff --git a/src/components/Kubernetes/DaemonSetList.tsx b/src/components/Kubernetes/DaemonSetList.tsx index 317692d5..5c454426 100644 --- a/src/components/Kubernetes/DaemonSetList.tsx +++ b/src/components/Kubernetes/DaemonSetList.tsx @@ -1,50 +1,169 @@ -import React from "react"; +import React, { useState } from "react"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui"; +import { RotateCcw, Pencil, Trash2 } from "lucide-react"; import type { DaemonSetInfo } from "@/lib/tauriCommands"; +import { + restartDaemonsetCmd, + deleteResourceCmd, + getResourceYamlCmd, +} from "@/lib/tauriCommands"; +import { ResourceActionMenu } from "./ResourceActionMenu"; +import { ConfirmDeleteDialog } from "./ConfirmDeleteDialog"; +import { EditResourceModal } from "./EditResourceModal"; interface DaemonSetListProps { daemonsets: DaemonSetInfo[]; clusterId: string; namespace: string; + onRefresh?: () => void; } -export function DaemonSetList({ daemonsets, clusterId: _clusterId, namespace: _namespace }: DaemonSetListProps) { +type ActiveModal = + | { type: "restart"; ds: DaemonSetInfo } + | { type: "edit"; ds: DaemonSetInfo; yaml: string } + | { type: "delete"; ds: DaemonSetInfo } + | null; + +export function DaemonSetList({ daemonsets, clusterId, namespace, onRefresh }: DaemonSetListProps) { + const [activeModal, setActiveModal] = useState(null); + const [isActing, setIsActing] = useState(false); + const [actionError, setActionError] = useState(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 ( -
- - - - Name - Desired - Current - Ready - Up-to-date - Available - Age - - - - {daemonsets.length === 0 ? ( + <> + {actionError && ( +

{actionError}

+ )} +
+
+ - - No daemonsets found - + Name + Desired + Current + Ready + Up-to-date + Available + Age + Actions - ) : ( - daemonsets.map((ds) => ( - - {ds.name} - {ds.desired} - {ds.current} - {ds.ready} - {ds.up_to_date} - {ds.available} - {ds.age} + + + {daemonsets.length === 0 ? ( + + + No daemonsets found + - )) - )} - -
-
+ ) : ( + daemonsets.map((ds) => ( + + {ds.name} + {ds.desired} + {ds.current} + {ds.ready} + {ds.up_to_date} + {ds.available} + {ds.age} + + setActiveModal({ type: "restart", ds }), + }, + { + label: "Edit", + icon: Pencil, + onClick: () => openEdit(ds), + }, + { + label: "Delete", + icon: Trash2, + variant: "destructive", + onClick: () => setActiveModal({ type: "delete", ds }), + }, + ]} + /> + + + )) + )} + + + + + {activeModal?.type === "restart" && ( + { if (!o) setActiveModal(null); }} + resourceType="DaemonSet" + resourceName={activeModal.ds.name} + isLoading={isActing} + onConfirm={handleRestart} + variant="delete" + /> + )} + + {activeModal?.type === "edit" && ( + { setActiveModal(null); onRefresh?.(); }} + /> + )} + + {activeModal?.type === "delete" && ( + { if (!o) setActiveModal(null); }} + resourceType="DaemonSet" + resourceName={activeModal.ds.name} + isLoading={isActing} + onConfirm={handleDelete} + /> + )} + ); } diff --git a/src/components/Kubernetes/DeploymentList.tsx b/src/components/Kubernetes/DeploymentList.tsx index a3407a35..9f535776 100644 --- a/src/components/Kubernetes/DeploymentList.tsx +++ b/src/components/Kubernetes/DeploymentList.tsx @@ -1,89 +1,94 @@ import React, { useState } from "react"; -import { invoke } from "@tauri-apps/api/core"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui"; -import { Button } from "@/components/ui"; -import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui"; -import { Input } from "@/components/ui"; -import { Label } from "@/components/ui"; -import { Alert, AlertDescription } from "@/components/ui"; -import { AlertCircle, RotateCcw, Scale } from "lucide-react"; +import { Scale, RotateCcw, Undo2, Pencil, Trash2 } from "lucide-react"; import type { DeploymentInfo } from "@/lib/tauriCommands"; +import { + scaleDeploymentCmd, + restartDeploymentCmd, + rollbackDeploymentCmd, + deleteResourceCmd, + getResourceYamlCmd, +} from "@/lib/tauriCommands"; +import { ResourceActionMenu } from "./ResourceActionMenu"; +import { ConfirmDeleteDialog } from "./ConfirmDeleteDialog"; +import { ScaleModal } from "./ScaleModal"; +import { EditResourceModal } from "./EditResourceModal"; interface DeploymentListProps { deployments: DeploymentInfo[]; clusterId: string; namespace: string; + onRefresh?: () => void; } -export function DeploymentList({ deployments, clusterId, namespace }: DeploymentListProps) { - const [scalingDeployment, setScalingDeployment] = useState(null); - const [replicas, setReplicas] = useState(""); - const [isScaling, setIsScaling] = useState(false); - const [scaleError, setScaleError] = useState(null); +type ActiveModal = + | { type: "scale"; deployment: DeploymentInfo } + | { type: "restart"; deployment: DeploymentInfo } + | { type: "rollback"; deployment: DeploymentInfo } + | { type: "edit"; deployment: DeploymentInfo; yaml: string } + | { type: "delete"; deployment: DeploymentInfo } + | null; - const [restartingDeployment, setRestartingDeployment] = useState(null); - const [isRestarting, setIsRestarting] = useState(false); - const [restartError, setRestartError] = useState(null); - - const handleScaleChange = (e: React.ChangeEvent) => { - setReplicas(e.target.value); - setScaleError(null); - }; - - const handleScaleSubmit = async () => { - if (!scalingDeployment) return; - - const newReplicas = parseInt(replicas, 10); - if (isNaN(newReplicas) || newReplicas < 0) { - setScaleError("Invalid replica count"); - return; - } - - setIsScaling(true); - setScaleError(null); +export function DeploymentList({ deployments, clusterId, namespace, onRefresh }: DeploymentListProps) { + const [activeModal, setActiveModal] = useState(null); + const [isActing, setIsActing] = useState(false); + const [actionError, setActionError] = useState(null); + const openEdit = async (deployment: DeploymentInfo) => { + setActionError(null); try { - await invoke("scale_deployment", { - clusterId, - namespace, - deploymentName: scalingDeployment.name, - replicas: newReplicas, - }); - - setScalingDeployment(null); - setReplicas(""); + const yaml = await getResourceYamlCmd(clusterId, "deployments", namespace, deployment.name); + setActiveModal({ type: "edit", deployment, yaml }); } catch (err) { - console.error("Failed to scale deployment:", err); - setScaleError(err instanceof Error ? err.message : "Failed to scale deployment"); - } finally { - setIsScaling(false); + setActionError(err instanceof Error ? err.message : String(err)); } }; - const handleRestartSubmit = async () => { - if (!restartingDeployment) return; - - setIsRestarting(true); - setRestartError(null); - + const handleRestart = async () => { + if (activeModal?.type !== "restart") return; + setIsActing(true); try { - await invoke("restart_deployment", { - clusterId, - namespace, - deploymentName: restartingDeployment.name, - }); - - setRestartingDeployment(null); + await restartDeploymentCmd(clusterId, namespace, activeModal.deployment.name); + setActiveModal(null); + onRefresh?.(); } catch (err) { - console.error("Failed to restart deployment:", err); - setRestartError(err instanceof Error ? err.message : "Failed to restart deployment"); + setActionError(err instanceof Error ? err.message : String(err)); } finally { - setIsRestarting(false); + setIsActing(false); + } + }; + + const handleRollback = async () => { + if (activeModal?.type !== "rollback") return; + setIsActing(true); + try { + await rollbackDeploymentCmd(clusterId, namespace, activeModal.deployment.name); + setActiveModal(null); + onRefresh?.(); + } catch (err) { + setActionError(err instanceof Error ? err.message : String(err)); + } finally { + setIsActing(false); + } + }; + + const handleDelete = async () => { + if (activeModal?.type !== "delete") return; + setIsActing(true); + try { + await deleteResourceCmd(clusterId, "deployments", namespace, activeModal.deployment.name); + setActiveModal(null); + onRefresh?.(); + } finally { + setIsActing(false); } }; return ( <> + {actionError && ( +

{actionError}

+ )}
@@ -114,24 +119,36 @@ export function DeploymentList({ deployments, clusterId, namespace }: Deployment {deployment.replicas} {deployment.age} -
- - -
+ setActiveModal({ type: "scale", deployment }), + }, + { + label: "Restart", + icon: RotateCcw, + onClick: () => setActiveModal({ type: "restart", deployment }), + }, + { + label: "Rollback", + icon: Undo2, + onClick: () => setActiveModal({ type: "rollback", deployment }), + }, + { + label: "Edit", + icon: Pencil, + onClick: () => openEdit(deployment), + }, + { + label: "Delete", + icon: Trash2, + variant: "destructive", + onClick: () => setActiveModal({ type: "delete", deployment }), + }, + ]} + />
)) @@ -140,69 +157,68 @@ export function DeploymentList({ deployments, clusterId, namespace }: Deployment
- {/* Scale Dialog */} - setScalingDeployment(null)}> - - - Scale Deployment - -
-
- - - {scaleError && ( - - - {scaleError} - - )} -
-
- - - - -
-
+ {activeModal?.type === "scale" && ( + { if (!o) setActiveModal(null); }} + resourceType="Deployment" + resourceName={activeModal.deployment.name} + currentReplicas={activeModal.deployment.replicas} + onScale={(replicas) => + scaleDeploymentCmd(clusterId, namespace, activeModal.deployment.name, replicas).then(() => { + setActiveModal(null); + onRefresh?.(); + }) + } + /> + )} - {/* Restart Dialog */} - setRestartingDeployment(null)}> - - - Restart Deployment - -
-

- This will trigger a rolling restart of the deployment. -

- {restartError && ( - - - {restartError} - - )} -
- - - - -
-
+ {activeModal?.type === "restart" && ( + { if (!o) setActiveModal(null); }} + resourceType="Deployment" + resourceName={activeModal.deployment.name} + isLoading={isActing} + onConfirm={handleRestart} + variant="delete" + /> + )} + + {activeModal?.type === "rollback" && ( + { if (!o) setActiveModal(null); }} + resourceType="Deployment" + resourceName={activeModal.deployment.name} + isLoading={isActing} + onConfirm={handleRollback} + variant="delete" + /> + )} + + {activeModal?.type === "edit" && ( + { setActiveModal(null); onRefresh?.(); }} + /> + )} + + {activeModal?.type === "delete" && ( + { if (!o) setActiveModal(null); }} + resourceType="Deployment" + resourceName={activeModal.deployment.name} + isLoading={isActing} + onConfirm={handleDelete} + /> + )} ); } diff --git a/src/components/Kubernetes/EndpointList.tsx b/src/components/Kubernetes/EndpointList.tsx new file mode 100644 index 00000000..f13f47f7 --- /dev/null +++ b/src/components/Kubernetes/EndpointList.tsx @@ -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 ( +
+ + + + Name + Namespace + Addresses + Ports + Age + + + + {items.length === 0 ? ( + + + No endpoints found + + + ) : ( + items.map((ep) => ( + + {ep.name} + {ep.namespace} + + {ep.addresses.length > 0 ? ep.addresses.join(", ") : "—"} + + + {ep.ports.length > 0 ? ep.ports.join(", ") : "—"} + + {ep.age} + + )) + )} + +
+
+ ); +} diff --git a/src/components/Kubernetes/EndpointSliceList.tsx b/src/components/Kubernetes/EndpointSliceList.tsx new file mode 100644 index 00000000..c55833a3 --- /dev/null +++ b/src/components/Kubernetes/EndpointSliceList.tsx @@ -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 ( +
+ + + + Name + Namespace + Address Type + Endpoints + Ports + Age + + + + {items.length === 0 ? ( + + + No endpoint slices found + + + ) : ( + items.map((eps) => ( + + {eps.name} + {eps.namespace} + {eps.address_type} + {eps.endpoints} + + {eps.ports.length > 0 ? eps.ports.join(", ") : "—"} + + {eps.age} + + )) + )} + +
+
+ ); +} diff --git a/src/components/Kubernetes/HPAList.tsx b/src/components/Kubernetes/HPAList.tsx index d486106d..ec2de8db 100644 --- a/src/components/Kubernetes/HPAList.tsx +++ b/src/components/Kubernetes/HPAList.tsx @@ -1,50 +1,144 @@ -import React from "react"; +import React, { useState } from "react"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui"; +import { Pencil, Trash2 } from "lucide-react"; import type { HorizontalPodAutoscalerInfo } from "@/lib/tauriCommands"; +import { deleteResourceCmd, getResourceYamlCmd } from "@/lib/tauriCommands"; +import { ResourceActionMenu } from "./ResourceActionMenu"; +import { ConfirmDeleteDialog } from "./ConfirmDeleteDialog"; +import { EditResourceModal } from "./EditResourceModal"; interface HPAListProps { hpas: HorizontalPodAutoscalerInfo[]; - _clusterId: string; - _namespace: string; + clusterId?: string; + _clusterId?: string; + namespace?: string; + _namespace?: string; + onRefresh?: () => void; } -export function HPAList({ hpas, _clusterId, _namespace }: HPAListProps) { +type ActiveModal = + | { type: "edit"; hpa: HorizontalPodAutoscalerInfo; yaml: string } + | { type: "delete"; hpa: HorizontalPodAutoscalerInfo } + | null; + +export function HPAList({ + hpas, + clusterId, + _clusterId, + namespace, + _namespace, + onRefresh, +}: HPAListProps) { + const cid = clusterId ?? _clusterId ?? ""; + const ns = namespace ?? _namespace ?? ""; + const [activeModal, setActiveModal] = useState(null); + const [isDeleting, setIsDeleting] = useState(false); + const [actionError, setActionError] = useState(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 ( -
- - - - Name - Namespace - Min Replicas - Max Replicas - Current Replicas - Desired Replicas - Age - - - - {hpas.length === 0 ? ( + <> + {actionError && ( +

{actionError}

+ )} +
+
+ - - No HPAs found - + Name + Namespace + Min Replicas + Max Replicas + Current Replicas + Desired Replicas + Age + Actions - ) : ( - hpas.map((hpa) => ( - - {hpa.name} - {hpa.namespace} - {hpa.min_replicas} - {hpa.max_replicas} - {hpa.current_replicas} - {hpa.desired_replicas} - {hpa.age} + + + {hpas.length === 0 ? ( + + + No HPAs found + - )) - )} - -
-
+ ) : ( + hpas.map((hpa) => ( + + {hpa.name} + {hpa.namespace} + {hpa.min_replicas} + {hpa.max_replicas} + {hpa.current_replicas} + {hpa.desired_replicas} + {hpa.age} + + openEdit(hpa), + }, + { + label: "Delete", + icon: Trash2, + variant: "destructive", + onClick: () => setActiveModal({ type: "delete", hpa }), + }, + ]} + /> + + + )) + )} + + + + + {activeModal?.type === "edit" && ( + { setActiveModal(null); onRefresh?.(); }} + /> + )} + + {activeModal?.type === "delete" && ( + { if (!o) setActiveModal(null); }} + resourceType="HPA" + resourceName={activeModal.hpa.name} + isLoading={isDeleting} + onConfirm={handleDelete} + /> + )} + ); } diff --git a/src/components/Kubernetes/HelmChartList.tsx b/src/components/Kubernetes/HelmChartList.tsx new file mode 100644 index 00000000..c38115c5 --- /dev/null +++ b/src/components/Kubernetes/HelmChartList.tsx @@ -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([]); + const [charts, setCharts] = useState([]); + const [selectedRepo, setSelectedRepo] = useState(null); + const [search, setSearch] = useState(""); + const [loading, setLoading] = useState(false); + const [updatingRepos, setUpdatingRepos] = useState(false); + const [error, setError] = useState(null); + const [expandedChart, setExpandedChart] = useState(null); + + const [addRepoOpen, setAddRepoOpen] = useState(false); + const [newRepoName, setNewRepoName] = useState(""); + const [newRepoUrl, setNewRepoUrl] = useState(""); + const [addingRepo, setAddingRepo] = useState(false); + const [addRepoError, setAddRepoError] = useState(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 ( +
+ {/* Toolbar */} +
+ + +
+ + setSearch(e.target.value)} + className="pl-9" + /> +
+
+ + {error && ( +
+ {error} +
+ )} + +
+ {/* Repository sidebar */} +
+
+ Repositories +
+
setSelectedRepo(null)} + > + All repositories +
+ {repos.map((repo) => ( +
setSelectedRepo(repo.name)} + > + {repo.name} +
+ ))} + {repos.length === 0 && !loading && ( +
No repos
+ )} +
+ + {/* Charts table */} +
+ {loading ? ( +
+ + Loading charts… +
+ ) : repos.length === 0 ? ( +
+

No helm repositories configured.

+

Add a repository to get started.

+
+ ) : filteredCharts.length === 0 ? ( +
+ No charts match your search. +
+ ) : ( + + + + + + + + + + + + {filteredCharts.map((chart) => { + const key = `${chart.repository}/${chart.name}`; + const isExpanded = expandedChart === key; + return ( + + setExpandedChart(isExpanded ? null : key)} + > + + + + + + + {isExpanded && ( + + + + )} + + ); + })} + +
NameVersionApp VersionRepositoryDescription
+
+ {isExpanded ? ( + + ) : ( + + )} + {chart.name.includes("/") ? chart.name.split("/").slice(1).join("/") : chart.name} +
+
{chart.chart_version}{chart.app_version || "—"} + + {chart.repository} + + + {chart.description || "—"} +
+
+
+ {chart.repository}/{chart.name} +
+
{chart.description || "No description available."}
+
+ Chart: {chart.chart_version} + {chart.app_version && App: {chart.app_version}} +
+
+
+ )} +
+
+ + {/* Add Repository Dialog */} + + + + Add Helm Repository + +
+
+ + setNewRepoName(e.target.value)} + /> +
+
+ + setNewRepoUrl(e.target.value)} + /> +
+ {addRepoError && ( +
+ {addRepoError} +
+ )} +
+ + + + +
+
+
+ ); +} diff --git a/src/components/Kubernetes/HelmReleaseList.tsx b/src/components/Kubernetes/HelmReleaseList.tsx new file mode 100644 index 00000000..78deae87 --- /dev/null +++ b/src/components/Kubernetes/HelmReleaseList.tsx @@ -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([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [openMenuId, setOpenMenuId] = useState(null); + const [confirmAction, setConfirmAction] = useState(null); + const [actionInProgress, setActionInProgress] = useState(false); + const [actionError, setActionError] = useState(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 ( +
+ + Loading releases… +
+ ); + } + + return ( +
+
+ + {releases.length} release{releases.length !== 1 ? "s" : ""} + + +
+ + {error && ( +
+ {error} +
+ )} + +
+ + + + Name + Namespace + Chart + Chart Version + App Version + Status + Updated + + + + + {releases.length === 0 ? ( + + + No releases found + + + ) : ( + releases.map((release) => { + const menuKey = `${release.namespace}/${release.name}`; + return ( + + {release.name} + {release.namespace} + {release.chart} + {release.chart_version} + {release.app_version || "—"} + + + {statusLabel(release.status)} + + + {release.updated} + +
+ + {openMenuId === menuKey && ( +
setOpenMenuId(null)} + > + + +
+ )} +
+
+
+ ); + }) + )} +
+
+
+ + {/* Confirm dialog */} + { if (!o) setConfirmAction(null); }}> + + + + {confirmAction?.type === "rollback" ? "Rollback Release" : "Uninstall Release"} + + +

+ {confirmAction?.type === "rollback" ? ( + <> + Roll back {confirmAction.release.name} to the + previous revision? This cannot be undone without a re-deploy. + + ) : ( + <> + Permanently uninstall {confirmAction?.release.name}? + All Kubernetes resources created by this release will be removed. + + )} +

+ {actionError && ( +
+ {actionError} +
+ )} + + + + +
+
+
+ ); +} diff --git a/src/components/Kubernetes/IngressClassList.tsx b/src/components/Kubernetes/IngressClassList.tsx new file mode 100644 index 00000000..383efe4b --- /dev/null +++ b/src/components/Kubernetes/IngressClassList.tsx @@ -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 ( +
+ + + + Name + Controller + Default + Age + + + + {items.length === 0 ? ( + + + No ingress classes found + + + ) : ( + items.map((ic) => ( + + {ic.name} + {ic.controller} + + {ic.is_default ? ( + Yes + ) : ( + No + )} + + {ic.age} + + )) + )} + +
+
+ ); +} diff --git a/src/components/Kubernetes/IngressList.tsx b/src/components/Kubernetes/IngressList.tsx index 59fee670..93ae5cf7 100644 --- a/src/components/Kubernetes/IngressList.tsx +++ b/src/components/Kubernetes/IngressList.tsx @@ -1,48 +1,142 @@ -import React from "react"; +import React, { useState } from "react"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui"; +import { Pencil, Trash2 } from "lucide-react"; import type { IngressInfo } from "@/lib/tauriCommands"; +import { deleteResourceCmd, getResourceYamlCmd } from "@/lib/tauriCommands"; +import { ResourceActionMenu } from "./ResourceActionMenu"; +import { ConfirmDeleteDialog } from "./ConfirmDeleteDialog"; +import { EditResourceModal } from "./EditResourceModal"; interface IngressListProps { ingresses: IngressInfo[]; - _clusterId: string; - _namespace: string; + clusterId?: string; + _clusterId?: string; + namespace?: string; + _namespace?: string; + onRefresh?: () => void; } -export function IngressList({ ingresses, _clusterId, _namespace }: IngressListProps) { +type ActiveModal = + | { type: "edit"; ingress: IngressInfo; yaml: string } + | { type: "delete"; ingress: IngressInfo } + | null; + +export function IngressList({ + ingresses, + clusterId, + _clusterId, + namespace, + _namespace, + onRefresh, +}: IngressListProps) { + const cid = clusterId ?? _clusterId ?? ""; + const ns = namespace ?? _namespace ?? ""; + const [activeModal, setActiveModal] = useState(null); + const [isDeleting, setIsDeleting] = useState(false); + const [actionError, setActionError] = useState(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 ( -
- - - - Name - Namespace - Class - Host - Addresses - Age - - - - {ingresses.length === 0 ? ( + <> + {actionError && ( +

{actionError}

+ )} +
+
+ - - No ingresses found - + Name + Namespace + Class + Host + Addresses + Age + Actions - ) : ( - ingresses.map((ingress) => ( - - {ingress.name} - {ingress.namespace} - {ingress.class || "-"} - {ingress.host} - {ingress.addresses.join(", ")} - {ingress.age} + + + {ingresses.length === 0 ? ( + + + No ingresses found + - )) - )} - -
-
+ ) : ( + ingresses.map((ingress) => ( + + {ingress.name} + {ingress.namespace} + {ingress.class || "-"} + {ingress.host} + {ingress.addresses.join(", ")} + {ingress.age} + + openEdit(ingress), + }, + { + label: "Delete", + icon: Trash2, + variant: "destructive", + onClick: () => setActiveModal({ type: "delete", ingress }), + }, + ]} + /> + + + )) + )} + + + + + {activeModal?.type === "edit" && ( + { setActiveModal(null); onRefresh?.(); }} + /> + )} + + {activeModal?.type === "delete" && ( + { if (!o) setActiveModal(null); }} + resourceType="Ingress" + resourceName={activeModal.ingress.name} + isLoading={isDeleting} + onConfirm={handleDelete} + /> + )} + ); } diff --git a/src/components/Kubernetes/JobList.tsx b/src/components/Kubernetes/JobList.tsx index f589d3ce..226b21d3 100644 --- a/src/components/Kubernetes/JobList.tsx +++ b/src/components/Kubernetes/JobList.tsx @@ -1,52 +1,146 @@ -import React from "react"; +import React, { useState } from "react"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui"; +import { Pencil, Trash2 } from "lucide-react"; import type { JobInfo } from "@/lib/tauriCommands"; +import { deleteResourceCmd, getResourceYamlCmd } from "@/lib/tauriCommands"; +import { ResourceActionMenu } from "./ResourceActionMenu"; +import { ConfirmDeleteDialog } from "./ConfirmDeleteDialog"; +import { EditResourceModal } from "./EditResourceModal"; interface JobListProps { jobs: JobInfo[]; - _clusterId: string; - _namespace: string; + clusterId?: string; + _clusterId?: string; + namespace?: string; + _namespace?: string; + onRefresh?: () => void; } -export function JobList({ jobs, _clusterId, _namespace }: JobListProps) { +type ActiveModal = + | { type: "edit"; job: JobInfo; yaml: string } + | { type: "delete"; job: JobInfo } + | null; + +export function JobList({ + jobs, + clusterId, + _clusterId, + namespace, + _namespace, + onRefresh, +}: JobListProps) { + const cid = clusterId ?? _clusterId ?? ""; + const ns = namespace ?? _namespace ?? ""; + const [activeModal, setActiveModal] = useState(null); + const [isDeleting, setIsDeleting] = useState(false); + const [actionError, setActionError] = useState(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 ( -
- - - - Name - Namespace - Completions - Duration - Age - Labels - - - - {jobs.length === 0 ? ( + <> + {actionError && ( +

{actionError}

+ )} +
+
+ - - No jobs found - + Name + Namespace + Completions + Duration + Age + Labels + Actions - ) : ( - jobs.map((job) => ( - - {job.name} - {job.namespace} - {job.completions} - {job.duration} - {job.age} - - {Object.entries(job.labels) - .map(([k, v]) => `${k}=${v}`) - .join(", ")} + + + {jobs.length === 0 ? ( + + + No jobs found - )) - )} - -
-
+ ) : ( + jobs.map((job) => ( + + {job.name} + {job.namespace} + {job.completions} + {job.duration} + {job.age} + + {Object.entries(job.labels) + .map(([k, v]) => `${k}=${v}`) + .join(", ")} + + + openEdit(job), + }, + { + label: "Delete", + icon: Trash2, + variant: "destructive", + onClick: () => setActiveModal({ type: "delete", job }), + }, + ]} + /> + + + )) + )} + + + + + {activeModal?.type === "edit" && ( + { setActiveModal(null); onRefresh?.(); }} + /> + )} + + {activeModal?.type === "delete" && ( + { if (!o) setActiveModal(null); }} + resourceType="Job" + resourceName={activeModal.job.name} + isLoading={isDeleting} + onConfirm={handleDelete} + /> + )} + ); } diff --git a/src/components/Kubernetes/LeaseList.tsx b/src/components/Kubernetes/LeaseList.tsx new file mode 100644 index 00000000..797cd409 --- /dev/null +++ b/src/components/Kubernetes/LeaseList.tsx @@ -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 ( +
+ + + + Name + Namespace + Holder + Age + + + + {items.length === 0 ? ( + + + No leases found + + + ) : ( + items.map((lease) => ( + + {lease.name} + {lease.namespace} + {lease.holder || "—"} + {lease.age} + + )) + )} + +
+
+ ); +} diff --git a/src/components/Kubernetes/LimitRangeList.tsx b/src/components/Kubernetes/LimitRangeList.tsx index f1328882..c64bbfcc 100644 --- a/src/components/Kubernetes/LimitRangeList.tsx +++ b/src/components/Kubernetes/LimitRangeList.tsx @@ -1,44 +1,127 @@ -import React from "react"; +import React, { useState } from "react"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui"; +import { Pencil, Trash2 } from "lucide-react"; import type { LimitRangeInfo } from "@/lib/tauriCommands"; +import { deleteResourceCmd, getResourceYamlCmd } from "@/lib/tauriCommands"; +import { ResourceActionMenu } from "./ResourceActionMenu"; +import { ConfirmDeleteDialog } from "./ConfirmDeleteDialog"; +import { EditResourceModal } from "./EditResourceModal"; interface LimitRangeListProps { limitranges: LimitRangeInfo[]; clusterId: string; namespace: string; + onRefresh?: () => void; } -export function LimitRangeList({ limitranges }: LimitRangeListProps) { +type ActiveModal = + | { type: "edit"; lr: LimitRangeInfo; yaml: string } + | { type: "delete"; lr: LimitRangeInfo } + | null; + +export function LimitRangeList({ limitranges, clusterId, namespace, onRefresh }: LimitRangeListProps) { + const [activeModal, setActiveModal] = useState(null); + const [isDeleting, setIsDeleting] = useState(false); + const [actionError, setActionError] = useState(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 ( -
- - - - Name - Namespace - Limits - Age - - - - {limitranges.length === 0 ? ( + <> + {actionError && ( +

{actionError}

+ )} +
+
+ - - No limit ranges found - + Name + Namespace + Limits + Age + Actions - ) : ( - limitranges.map((lr) => ( - - {lr.name} - {lr.namespace} - {lr.limit_count} - {lr.age} + + + {limitranges.length === 0 ? ( + + + No limit ranges found + - )) - )} - -
-
+ ) : ( + limitranges.map((lr) => ( + + {lr.name} + {lr.namespace} + {lr.limit_count} + {lr.age} + + openEdit(lr), + }, + { + label: "Delete", + icon: Trash2, + variant: "destructive", + onClick: () => setActiveModal({ type: "delete", lr }), + }, + ]} + /> + + + )) + )} + + + + + {activeModal?.type === "edit" && ( + { setActiveModal(null); onRefresh?.(); }} + /> + )} + + {activeModal?.type === "delete" && ( + { if (!o) setActiveModal(null); }} + resourceType="LimitRange" + resourceName={activeModal.lr.name} + isLoading={isDeleting} + onConfirm={handleDelete} + /> + )} + ); } diff --git a/src/components/Kubernetes/LogStreamPanel.tsx b/src/components/Kubernetes/LogStreamPanel.tsx new file mode 100644 index 00000000..843c1b5d --- /dev/null +++ b/src/components/Kubernetes/LogStreamPanel.tsx @@ -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( + containers[0] ?? "" + ); + const [follow, setFollow] = useState(true); + const [timestamps, setTimestamps] = useState(false); + const [tailLines, setTailLines] = useState(100); + const [lines, setLines] = useState([]); + const [streaming, setStreaming] = useState(false); + const [search, setSearch] = useState(""); + const [error, setError] = useState(null); + + const streamIdRef = useRef(null); + const unlistenRef = useRef(null); + const bottomRef = useRef(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 ( + + + + + Log Stream — {podName} + + + +
+ {/* Controls row */} +
+ + + + + + +
+ Tail lines: + + 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" + /> +
+ +
+ {!streaming ? ( + + ) : ( + + )} + + +
+
+ + {/* Search bar */} +
+ + setSearch(e.target.value)} + className="pl-9" + /> +
+ + {error && ( +
+ {error} +
+ )} + + {/* Log output */} +
+ {displayLines.length === 0 ? ( + + {streaming ? "Waiting for log data…" : "No logs to display. Press Stream to begin."} + + ) : ( + <> + {(search.trim() !== "" ? lines : displayLines).map((line, i) => { + const matches = search.trim() !== "" && line.includes(search); + const visible = search.trim() === "" || matches; + return ( +
+ {matches && search.trim() !== "" ? ( + highlightMatch(line, search) + ) : ( + line + )} +
+ ); + })} +
+ + )} +
+ +
+ {lines.length.toLocaleString()} line{lines.length !== 1 ? "s" : ""} + {search.trim() !== "" && ` — ${filteredLines.length.toLocaleString()} matching`} +
+
+ +
+ ); +} + +function highlightMatch(line: string, search: string): React.ReactNode { + const idx = line.indexOf(search); + if (idx === -1) return line; + return ( + <> + {line.slice(0, idx)} + {line.slice(idx, idx + search.length)} + {line.slice(idx + search.length)} + + ); +} diff --git a/src/components/Kubernetes/LogsModal.tsx b/src/components/Kubernetes/LogsModal.tsx new file mode 100644 index 00000000..a5811b90 --- /dev/null +++ b/src/components/Kubernetes/LogsModal.tsx @@ -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(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 ( + + + + + Logs — {podName} + + +
+
+ + +
+ {error && ( + + {error} + + )} +
+            {logs || "No logs. Select a container and click Fetch Logs."}
+          
+
+
+
+ ); +} diff --git a/src/components/Kubernetes/MutatingWebhookList.tsx b/src/components/Kubernetes/MutatingWebhookList.tsx new file mode 100644 index 00000000..9aa7ae9a --- /dev/null +++ b/src/components/Kubernetes/MutatingWebhookList.tsx @@ -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 ( +
+ + + + Name + Webhooks + Age + + + + {items.length === 0 ? ( + + + No mutating webhook configurations found + + + ) : ( + items.map((wh) => ( + + {wh.name} + {wh.webhooks} + {wh.age} + + )) + )} + +
+
+ ); +} diff --git a/src/components/Kubernetes/NamespaceList.tsx b/src/components/Kubernetes/NamespaceList.tsx new file mode 100644 index 00000000..0a961209 --- /dev/null +++ b/src/components/Kubernetes/NamespaceList.tsx @@ -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 ( +
+ + + + Name + Status + Age + + + + {items.length === 0 ? ( + + + No namespaces found + + + ) : ( + items.map((ns) => ( + + {ns.name} + + {ns.status} + + {ns.age} + + )) + )} + +
+
+ ); +} diff --git a/src/components/Kubernetes/NetworkPolicyList.tsx b/src/components/Kubernetes/NetworkPolicyList.tsx index 234e5679..cbb0fe52 100644 --- a/src/components/Kubernetes/NetworkPolicyList.tsx +++ b/src/components/Kubernetes/NetworkPolicyList.tsx @@ -1,46 +1,129 @@ -import React from "react"; +import React, { useState } from "react"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui"; +import { Pencil, Trash2 } from "lucide-react"; import type { NetworkPolicyInfo } from "@/lib/tauriCommands"; +import { deleteResourceCmd, getResourceYamlCmd } from "@/lib/tauriCommands"; +import { ResourceActionMenu } from "./ResourceActionMenu"; +import { ConfirmDeleteDialog } from "./ConfirmDeleteDialog"; +import { EditResourceModal } from "./EditResourceModal"; interface NetworkPolicyListProps { networkpolicies: NetworkPolicyInfo[]; clusterId: string; namespace: string; + onRefresh?: () => void; } -export function NetworkPolicyList({ networkpolicies }: NetworkPolicyListProps) { +type ActiveModal = + | { type: "edit"; np: NetworkPolicyInfo; yaml: string } + | { type: "delete"; np: NetworkPolicyInfo } + | null; + +export function NetworkPolicyList({ networkpolicies, clusterId, namespace, onRefresh }: NetworkPolicyListProps) { + const [activeModal, setActiveModal] = useState(null); + const [isDeleting, setIsDeleting] = useState(false); + const [actionError, setActionError] = useState(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 ( -
- - - - Name - Namespace - Pod Selector - Policy Types - Age - - - - {networkpolicies.length === 0 ? ( + <> + {actionError && ( +

{actionError}

+ )} +
+
+ - - No network policies found - + Name + Namespace + Pod Selector + Policy Types + Age + Actions - ) : ( - networkpolicies.map((np) => ( - - {np.name} - {np.namespace} - {np.pod_selector} - {np.policy_types.join(", ") || "—"} - {np.age} + + + {networkpolicies.length === 0 ? ( + + + No network policies found + - )) - )} - -
-
+ ) : ( + networkpolicies.map((np) => ( + + {np.name} + {np.namespace} + {np.pod_selector} + {np.policy_types.join(", ") || "—"} + {np.age} + + openEdit(np), + }, + { + label: "Delete", + icon: Trash2, + variant: "destructive", + onClick: () => setActiveModal({ type: "delete", np }), + }, + ]} + /> + + + )) + )} + + + + + {activeModal?.type === "edit" && ( + { setActiveModal(null); onRefresh?.(); }} + /> + )} + + {activeModal?.type === "delete" && ( + { if (!o) setActiveModal(null); }} + resourceType="NetworkPolicy" + resourceName={activeModal.np.name} + isLoading={isDeleting} + onConfirm={handleDelete} + /> + )} + ); } diff --git a/src/components/Kubernetes/NodeList.tsx b/src/components/Kubernetes/NodeList.tsx index ad2b68be..77a0097b 100644 --- a/src/components/Kubernetes/NodeList.tsx +++ b/src/components/Kubernetes/NodeList.tsx @@ -1,24 +1,33 @@ import React, { useState } from "react"; -import { invoke } from "@tauri-apps/api/core"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui"; import { Badge } from "@/components/ui"; -import { Button } from "@/components/ui"; -import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui"; -import { AlertCircle, Terminal } from "lucide-react"; -import { Alert, AlertDescription } from "@/components/ui"; +import { ShieldOff, ShieldCheck, Trash2, Pencil } from "lucide-react"; import type { NodeInfo } from "@/lib/tauriCommands"; +import { + cordonNodeCmd, + uncordonNodeCmd, + drainNodeCmd, + getResourceYamlCmd, +} from "@/lib/tauriCommands"; +import { ResourceActionMenu } from "./ResourceActionMenu"; +import { ConfirmDeleteDialog } from "./ConfirmDeleteDialog"; +import { EditResourceModal } from "./EditResourceModal"; interface NodeListProps { nodes: NodeInfo[]; clusterId: string; + onRefresh?: () => void; } -export function NodeList({ nodes, clusterId }: NodeListProps) { - const [selectedNode, setSelectedNode] = useState(null); - const [isCordoning, setIsCordoning] = useState(false); - const [isUncordoning, setIsUncordoning] = useState(false); - const [isDraining, setIsDraining] = useState(false); - const [error, setError] = useState(null); +type ActiveModal = + | { type: "drain"; node: NodeInfo } + | { type: "edit"; node: NodeInfo; yaml: string } + | null; + +export function NodeList({ nodes, clusterId, onRefresh }: NodeListProps) { + const [activeModal, setActiveModal] = useState(null); + const [isActing, setIsActing] = useState(false); + const [actionError, setActionError] = useState(null); const getNodeStatusColor = (status: string) => { switch (status.toLowerCase()) { @@ -33,53 +42,59 @@ export function NodeList({ nodes, clusterId }: NodeListProps) { } }; - const handleCordon = async () => { - if (!selectedNode) return; - - setIsCordoning(true); - setError(null); + const isSchedulingDisabled = (node: NodeInfo) => + node.status.toLowerCase().includes("schedulingdisabled") || + node.roles.toLowerCase().includes("schedulingdisabled"); + + const handleCordon = async (node: NodeInfo) => { + setActionError(null); try { - await invoke("cordon_node", { clusterId, nodeName: selectedNode.name }); - setSelectedNode(null); + await cordonNodeCmd(clusterId, node.name); + onRefresh?.(); } catch (err) { - setError(err instanceof Error ? err.message : "Failed to cordon node"); - } finally { - setIsCordoning(false); + setActionError(err instanceof Error ? err.message : String(err)); } }; - const handleUncordon = async () => { - if (!selectedNode) return; - - setIsUncordoning(true); - setError(null); + const handleUncordon = async (node: NodeInfo) => { + setActionError(null); try { - await invoke("uncordon_node", { clusterId, nodeName: selectedNode.name }); - setSelectedNode(null); + await uncordonNodeCmd(clusterId, node.name); + onRefresh?.(); } catch (err) { - setError(err instanceof Error ? err.message : "Failed to uncordon node"); - } finally { - setIsUncordoning(false); + setActionError(err instanceof Error ? err.message : String(err)); } }; const handleDrain = async () => { - if (!selectedNode) return; - - setIsDraining(true); - setError(null); + if (activeModal?.type !== "drain") return; + setIsActing(true); try { - await invoke("drain_node", { clusterId, nodeName: selectedNode.name }); - setSelectedNode(null); + await drainNodeCmd(clusterId, activeModal.node.name); + setActiveModal(null); + onRefresh?.(); } catch (err) { - setError(err instanceof Error ? err.message : "Failed to drain node"); + setActionError(err instanceof Error ? err.message : String(err)); } finally { - setIsDraining(false); + setIsActing(false); + } + }; + + const openEdit = async (node: NodeInfo) => { + setActionError(null); + try { + const yaml = await getResourceYamlCmd(clusterId, "nodes", "", node.name); + setActiveModal({ type: "edit", node, yaml }); + } catch (err) { + setActionError(err instanceof Error ? err.message : String(err)); } }; return ( <> + {actionError && ( +

{actionError}

+ )}
@@ -116,14 +131,33 @@ export function NodeList({ nodes, clusterId }: NodeListProps) { {node.os_image} {node.age} - + handleCordon(node), + }, + { + label: "Uncordon", + icon: ShieldCheck, + hidden: !isSchedulingDisabled(node), + onClick: () => handleUncordon(node), + }, + { + label: "Drain", + icon: Trash2, + variant: "destructive", + onClick: () => setActiveModal({ type: "drain", node }), + }, + { + label: "Edit", + icon: Pencil, + onClick: () => openEdit(node), + }, + ]} + /> )) @@ -132,101 +166,28 @@ export function NodeList({ nodes, clusterId }: NodeListProps) {
- {/* Node Management Dialog */} - {selectedNode && ( - { - if (!open) { - setSelectedNode(null); - setError(null); - } - }}> - - - - - Manage Node: {selectedNode.name} - - + {activeModal?.type === "drain" && ( + { if (!o) setActiveModal(null); }} + resourceType="Node" + resourceName={activeModal.node.name} + isLoading={isActing} + onConfirm={handleDrain} + variant="force-delete" + /> + )} -
- {/* Node Details */} -
-
-

Status

-

{selectedNode.status}

-
-
-

Roles

-

{selectedNode.roles}

-
-
-

Version

-

{selectedNode.version}

-
-
-

OS Image

-

{selectedNode.os_image}

-
-
-

Kernel

-

{selectedNode.kernel_version}

-
-
-

Kubelet

-

{selectedNode.kubelet_version}

-
-
-

Internal IP

-

{selectedNode.internal_ip}

-
- {selectedNode.external_ip && ( -
-

External IP

-

{selectedNode.external_ip}

-
- )} -
- - {/* Action Buttons */} -
- {selectedNode.roles.toLowerCase().includes("schedulingdisabled") ? ( - - ) : ( - - )} - - -
- - {error && ( - - - {error} - - )} -
-
-
+ {activeModal?.type === "edit" && ( + { setActiveModal(null); onRefresh?.(); }} + /> )} ); diff --git a/src/components/Kubernetes/PVCList.tsx b/src/components/Kubernetes/PVCList.tsx index 0d461754..7f8f03ac 100644 --- a/src/components/Kubernetes/PVCList.tsx +++ b/src/components/Kubernetes/PVCList.tsx @@ -1,50 +1,144 @@ -import React from "react"; +import React, { useState } from "react"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui"; +import { Pencil, Trash2 } from "lucide-react"; import type { PersistentVolumeClaimInfo } from "@/lib/tauriCommands"; +import { deleteResourceCmd, getResourceYamlCmd } from "@/lib/tauriCommands"; +import { ResourceActionMenu } from "./ResourceActionMenu"; +import { ConfirmDeleteDialog } from "./ConfirmDeleteDialog"; +import { EditResourceModal } from "./EditResourceModal"; interface PVCListProps { pvcs: PersistentVolumeClaimInfo[]; - _clusterId: string; - _namespace: string; + clusterId?: string; + _clusterId?: string; + namespace?: string; + _namespace?: string; + onRefresh?: () => void; } -export function PVCList({ pvcs, _clusterId, _namespace }: PVCListProps) { +type ActiveModal = + | { type: "edit"; pvc: PersistentVolumeClaimInfo; yaml: string } + | { type: "delete"; pvc: PersistentVolumeClaimInfo } + | null; + +export function PVCList({ + pvcs, + clusterId, + _clusterId, + namespace, + _namespace, + onRefresh, +}: PVCListProps) { + const cid = clusterId ?? _clusterId ?? ""; + const ns = namespace ?? _namespace ?? ""; + const [activeModal, setActiveModal] = useState(null); + const [isDeleting, setIsDeleting] = useState(false); + const [actionError, setActionError] = useState(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 ( -
- - - - Name - Namespace - Status - Volume - Capacity - Access Modes - Age - - - - {pvcs.length === 0 ? ( + <> + {actionError && ( +

{actionError}

+ )} +
+
+ - - No PVCs found - + Name + Namespace + Status + Volume + Capacity + Access Modes + Age + Actions - ) : ( - pvcs.map((pvc) => ( - - {pvc.name} - {pvc.namespace} - {pvc.status} - {pvc.volume} - {pvc.capacity} - {pvc.access_modes.join(", ")} - {pvc.age} + + + {pvcs.length === 0 ? ( + + + No PVCs found + - )) - )} - -
-
+ ) : ( + pvcs.map((pvc) => ( + + {pvc.name} + {pvc.namespace} + {pvc.status} + {pvc.volume} + {pvc.capacity} + {pvc.access_modes.join(", ")} + {pvc.age} + + openEdit(pvc), + }, + { + label: "Delete", + icon: Trash2, + variant: "destructive", + onClick: () => setActiveModal({ type: "delete", pvc }), + }, + ]} + /> + + + )) + )} + + + + + {activeModal?.type === "edit" && ( + { setActiveModal(null); onRefresh?.(); }} + /> + )} + + {activeModal?.type === "delete" && ( + { if (!o) setActiveModal(null); }} + resourceType="PVC" + resourceName={activeModal.pvc.name} + isLoading={isDeleting} + onConfirm={handleDelete} + /> + )} + ); } diff --git a/src/components/Kubernetes/PVList.tsx b/src/components/Kubernetes/PVList.tsx index bb42678e..7c85b813 100644 --- a/src/components/Kubernetes/PVList.tsx +++ b/src/components/Kubernetes/PVList.tsx @@ -1,49 +1,134 @@ -import React from "react"; +import React, { useState } from "react"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui"; +import { Pencil, Trash2 } from "lucide-react"; import type { PersistentVolumeInfo } from "@/lib/tauriCommands"; +import { deleteResourceCmd, getResourceYamlCmd } from "@/lib/tauriCommands"; +import { ResourceActionMenu } from "./ResourceActionMenu"; +import { ConfirmDeleteDialog } from "./ConfirmDeleteDialog"; +import { EditResourceModal } from "./EditResourceModal"; interface PVListProps { pvs: PersistentVolumeInfo[]; - _clusterId: string; + clusterId?: string; + _clusterId?: string; + onRefresh?: () => void; } -export function PVList({ pvs, _clusterId }: PVListProps) { +type ActiveModal = + | { type: "edit"; pv: PersistentVolumeInfo; yaml: string } + | { type: "delete"; pv: PersistentVolumeInfo } + | null; + +export function PVList({ pvs, clusterId, _clusterId, onRefresh }: PVListProps) { + const cid = clusterId ?? _clusterId ?? ""; + const [activeModal, setActiveModal] = useState(null); + const [isDeleting, setIsDeleting] = useState(false); + const [actionError, setActionError] = useState(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 ( -
- - - - Name - Status - Capacity - Access Modes - Reclaim Policy - Storage Class - Age - - - - {pvs.length === 0 ? ( + <> + {actionError && ( +

{actionError}

+ )} +
+
+ - - No PVs found - + Name + Status + Capacity + Access Modes + Reclaim Policy + Storage Class + Age + Actions - ) : ( - pvs.map((pv) => ( - - {pv.name} - {pv.status} - {pv.capacity} - {pv.access_modes.join(", ")} - {pv.reclaim_policy} - {pv.storage_class} - {pv.age} + + + {pvs.length === 0 ? ( + + + No PVs found + - )) - )} - -
-
+ ) : ( + pvs.map((pv) => ( + + {pv.name} + {pv.status} + {pv.capacity} + {pv.access_modes.join(", ")} + {pv.reclaim_policy} + {pv.storage_class} + {pv.age} + + openEdit(pv), + }, + { + label: "Delete", + icon: Trash2, + variant: "destructive", + onClick: () => setActiveModal({ type: "delete", pv }), + }, + ]} + /> + + + )) + )} + + + + + {activeModal?.type === "edit" && ( + { setActiveModal(null); onRefresh?.(); }} + /> + )} + + {activeModal?.type === "delete" && ( + { if (!o) setActiveModal(null); }} + resourceType="PersistentVolume" + resourceName={activeModal.pv.name} + isLoading={isDeleting} + onConfirm={handleDelete} + /> + )} + ); } diff --git a/src/components/Kubernetes/PodDisruptionBudgetList.tsx b/src/components/Kubernetes/PodDisruptionBudgetList.tsx new file mode 100644 index 00000000..8287054c --- /dev/null +++ b/src/components/Kubernetes/PodDisruptionBudgetList.tsx @@ -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 ( +
+ + + + Name + Namespace + Min Available + Max Unavailable + Disruptions Allowed + Age + + + + {items.length === 0 ? ( + + + No pod disruption budgets found + + + ) : ( + items.map((pdb) => ( + + {pdb.name} + {pdb.namespace} + {pdb.min_available} + {pdb.max_unavailable} + {pdb.disruptions_allowed} + {pdb.age} + + )) + )} + +
+
+ ); +} diff --git a/src/components/Kubernetes/PodList.tsx b/src/components/Kubernetes/PodList.tsx index e214918a..29d5dd5a 100644 --- a/src/components/Kubernetes/PodList.tsx +++ b/src/components/Kubernetes/PodList.tsx @@ -1,28 +1,36 @@ import React, { useState } from "react"; -import { invoke } from "@tauri-apps/api/core"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui"; import { Badge } from "@/components/ui"; -import { Button } from "@/components/ui"; -import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui"; -import { Textarea } from "@/components/ui"; -import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui"; -import { Terminal, FileText, RotateCcw } from "lucide-react"; -import { Alert, AlertDescription } from "@/components/ui"; -import type { PodInfo, LogResponse } from "@/lib/tauriCommands"; +import { FileText, Terminal, Link, Pencil, Trash2, Zap } from "lucide-react"; +import type { PodInfo } from "@/lib/tauriCommands"; +import { deleteResourceCmd, forceDeleteResourceCmd, getResourceYamlCmd } from "@/lib/tauriCommands"; +import { ResourceActionMenu } from "./ResourceActionMenu"; +import { ConfirmDeleteDialog } from "./ConfirmDeleteDialog"; +import { LogsModal } from "./LogsModal"; +import { ShellExecModal } from "./ShellExecModal"; +import { AttachModal } from "./AttachModal"; +import { EditResourceModal } from "./EditResourceModal"; interface PodListProps { pods: PodInfo[]; clusterId: string; namespace: string; + onRefresh?: () => void; } -export function PodList({ pods, clusterId, namespace }: PodListProps) { - const [selectedPod, setSelectedPod] = useState(null); - const [selectedContainer, setSelectedContainer] = useState(""); - const [logs, setLogs] = useState(""); - const [isFetchingLogs, setIsFetchingLogs] = useState(false); - const [error, setError] = useState(null); - const [isDialogOpen, setIsDialogOpen] = useState(false); +type ActiveModal = + | { type: "logs"; pod: PodInfo } + | { type: "shell"; pod: PodInfo } + | { type: "attach"; pod: PodInfo } + | { type: "edit"; pod: PodInfo; yaml: string } + | { type: "delete"; pod: PodInfo } + | { type: "force-delete"; pod: PodInfo } + | null; + +export function PodList({ pods, clusterId, namespace, onRefresh }: PodListProps) { + const [activeModal, setActiveModal] = useState(null); + const [isDeleting, setIsDeleting] = useState(false); + const [editError, setEditError] = useState(null); const getPodStatusColor = (status: string) => { switch (status.toLowerCase()) { @@ -41,37 +49,41 @@ export function PodList({ pods, clusterId, namespace }: PodListProps) { } }; - const fetchLogs = async () => { - if (!selectedPod || !selectedContainer) return; - - setIsFetchingLogs(true); - setError(null); + const openEdit = async (pod: PodInfo) => { + setEditError(null); try { - const response = await invoke("get_pod_logs", { - clusterId, - namespace, - podName: selectedPod.name, - containerName: selectedContainer, - }); - setLogs(response.logs); + const yaml = await getResourceYamlCmd(clusterId, "pods", namespace, pod.name); + setActiveModal({ type: "edit", pod, yaml }); } catch (err) { - console.error("Failed to fetch logs:", err); - setError(err instanceof Error ? err.message : "Failed to fetch logs"); - } finally { - setIsFetchingLogs(false); + setEditError(err instanceof Error ? err.message : String(err)); } }; - const handleContainerChange = (container: string) => { - setSelectedContainer(container); - setLogs(""); - setError(null); + const handleDelete = async (force: boolean) => { + const modal = activeModal; + if (!modal || (modal.type !== "delete" && modal.type !== "force-delete")) return; + setIsDeleting(true); + try { + if (force) { + await forceDeleteResourceCmd(clusterId, "pods", namespace, modal.pod.name); + } else { + await deleteResourceCmd(clusterId, "pods", namespace, modal.pod.name); + } + setActiveModal(null); + onRefresh?.(); + } finally { + setIsDeleting(false); + } }; - const containers = selectedPod?.containers ?? []; + const currentPod = + activeModal && activeModal.type !== "edit" ? activeModal.pod : null; return ( <> + {editError && ( +

{editError}

+ )}
@@ -102,91 +114,46 @@ export function PodList({ pods, clusterId, namespace }: PodListProps) { {pod.ready} {pod.age} - - - - - {pod.name} - {namespace} namespace - -
- {selectedPod && ( -
-
- Container: - - -
- - {error && ( - - {error} - - )} - - {}}> - - Logs - Details - -
- -