From e68f61461ee5f77cf6121831739181136e009696 Mon Sep 17 00:00:00 2001 From: Shaun Arman Date: Mon, 8 Jun 2026 20:15:19 -0500 Subject: [PATCH 1/5] fix(ci): cargo fmt kube.rs + switch pr-review to qwen3-coder-next - Apply cargo fmt to src-tauri/src/commands/kube.rs (CI was failing) - Update pr-review.yml to use qwen3-coder-next model via liteLLM - Add TICKET-kube-ui-feature-parity.md gap analysis for FreeLens parity Co-Authored-By: TFTSR Engineering --- .gitea/workflows/pr-review.yml | 4 +- TICKET-kube-ui-feature-parity.md | 280 +++++++++++++++++++++++++++++++ src-tauri/src/commands/kube.rs | 39 +++-- 3 files changed, 311 insertions(+), 12 deletions(-) create mode 100644 TICKET-kube-ui-feature-parity.md 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-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/src-tauri/src/commands/kube.rs b/src-tauri/src/commands/kube.rs index 334dbaba..534a0d63 100644 --- a/src-tauri/src/commands/kube.rs +++ b/src-tauri/src/commands/kube.rs @@ -238,9 +238,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 +252,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 +341,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 +383,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 + }, )) } From 879fdf4239e61adbe12bd5759e5bd555cd2c2503 Mon Sep 17 00:00:00 2001 From: Shaun Arman Date: Mon, 8 Jun 2026 20:16:55 -0500 Subject: [PATCH 2/5] feat(kube): add TypeScript types and command stubs for all new K8s resources Add interfaces and invoke() wrappers for new resource types, Helm, CRDs, resource actions (attach, force-delete, describe, get-yaml, log streaming), and workload controls (restart/scale statefulset/daemonset/replicaset, cronjob ops). Co-Authored-By: TFTSR Engineering --- src/lib/tauriCommands.ts | 252 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 252 insertions(+) diff --git a/src/lib/tauriCommands.ts b/src/lib/tauriCommands.ts index ba8c89d3..47a1bef4 100644 --- a/src/lib/tauriCommands.ts +++ b/src/lib/tauriCommands.ts @@ -1239,3 +1239,255 @@ export const createResourceCmd = (clusterId: string, namespace: string, resource export const editResourceCmd = (clusterId: string, namespace: string, resourceType: string, resourceName: string, yamlContent: string) => invoke("edit_resource", { clusterId, namespace, resourceType, resourceName, yamlContent }); + +// ─── Missing Resource Types ─────────────────────────────────────────────────── + +export interface ReplicationControllerInfo { + name: string; + namespace: string; + desired: number; + ready: number; + current: number; + age: string; +} + +export interface PodDisruptionBudgetInfo { + name: string; + namespace: string; + min_available: string; + max_unavailable: string; + disruptions_allowed: number; + age: string; +} + +export interface PriorityClassInfo { + name: string; + value: number; + global_default: boolean; + age: string; +} + +export interface RuntimeClassInfo { + name: string; + handler: string; + age: string; +} + +export interface LeaseInfo { + name: string; + namespace: string; + holder: string; + age: string; +} + +export interface WebhookConfigInfo { + name: string; + webhooks: number; + age: string; +} + +export interface EndpointInfo { + name: string; + namespace: string; + addresses: string[]; + ports: string[]; + age: string; +} + +export interface EndpointSliceInfo { + name: string; + namespace: string; + address_type: string; + endpoints: number; + ports: string[]; + age: string; +} + +export interface IngressClassInfo { + name: string; + controller: string; + is_default: boolean; + age: string; +} + +export interface NamespaceResourceInfo { + name: string; + status: string; + age: string; +} + +// ─── Helm Types ─────────────────────────────────────────────────────────────── + +export interface HelmRepository { + name: string; + url: string; +} + +export interface HelmChart { + name: string; + chart_version: string; + app_version: string; + description: string; + repository: string; +} + +export interface HelmRelease { + name: string; + namespace: string; + chart: string; + chart_version: string; + app_version: string; + status: string; + updated: string; +} + +// ─── Custom Resource / CRD Types ───────────────────────────────────────────── + +export interface CrdInfo { + name: string; + group: string; + version: string; + kind: string; + scope: string; + age: string; +} + +export interface CustomResourceInfo { + name: string; + namespace: string; + age: string; +} + +// ─── Resource Actions ───────────────────────────────────────────────────────── + +export interface DescribeResponse { + output: string; +} + +export interface LogStreamConfig { + cluster_id: string; + namespace: string; + pod_name: string; + container_name: string; + follow: boolean; + timestamps: boolean; + tail_lines?: number; +} + +// ─── New Resource List Commands ─────────────────────────────────────────────── + +export const listReplicationcontrollersCmd = (clusterId: string, namespace: string) => + invoke("list_replicationcontrollers", { clusterId, namespace }); + +export const listPoddisruptionbudgetsCmd = (clusterId: string, namespace: string) => + invoke("list_poddisruptionbudgets", { clusterId, namespace }); + +export const listPriorityclassesCmd = (clusterId: string) => + invoke("list_priorityclasses", { clusterId }); + +export const listRuntimeclassesCmd = (clusterId: string) => + invoke("list_runtimeclasses", { clusterId }); + +export const listLeasesCmd = (clusterId: string, namespace: string) => + invoke("list_leases", { clusterId, namespace }); + +export const listMutatingwebhookconfigurationsCmd = (clusterId: string) => + invoke("list_mutatingwebhookconfigurations", { clusterId }); + +export const listValidatingwebhookconfigurationsCmd = (clusterId: string) => + invoke("list_validatingwebhookconfigurations", { clusterId }); + +export const listEndpointsCmd = (clusterId: string, namespace: string) => + invoke("list_endpoints", { clusterId, namespace }); + +export const listEndpointslicesCmd = (clusterId: string, namespace: string) => + invoke("list_endpointslices", { clusterId, namespace }); + +export const listIngressclassesCmd = (clusterId: string) => + invoke("list_ingressclasses", { clusterId }); + +export const listNamespacesResourceCmd = (clusterId: string) => + invoke("list_namespaces_resource", { clusterId }); + +export const createNamespaceCmd = (clusterId: string, name: string) => + invoke("create_namespace", { clusterId, name }); + +export const deleteNamespaceCmd = (clusterId: string, name: string) => + invoke("delete_namespace", { clusterId, name }); + +// ─── Resource Action Commands ───────────────────────────────────────────────── + +export const attachPodCmd = (clusterId: string, namespace: string, podName: string, containerName: string) => + invoke("attach_pod", { clusterId, namespace, podName, containerName }); + +export const forceDeleteResourceCmd = (clusterId: string, resourceType: string, namespace: string, resourceName: string) => + invoke("force_delete_resource", { clusterId, resourceType, namespace, resourceName }); + +export const describeResourceCmd = (clusterId: string, resourceType: string, namespace: string, resourceName: string) => + invoke("describe_resource", { clusterId, resourceType, namespace, resourceName }); + +export const getResourceYamlCmd = (clusterId: string, resourceType: string, namespace: string, resourceName: string) => + invoke("get_resource_yaml", { clusterId, resourceType, namespace, resourceName }); + +export const restartStatefulsetCmd = (clusterId: string, namespace: string, name: string) => + invoke("restart_statefulset", { clusterId, namespace, name }); + +export const restartDaemonsetCmd = (clusterId: string, namespace: string, name: string) => + invoke("restart_daemonset", { clusterId, namespace, name }); + +export const scaleStatefulsetCmd = (clusterId: string, namespace: string, name: string, replicas: number) => + invoke("scale_statefulset", { clusterId, namespace, name, replicas }); + +export const scaleReplicasetCmd = (clusterId: string, namespace: string, name: string, replicas: number) => + invoke("scale_replicaset", { clusterId, namespace, name, replicas }); + +export const scaleReplicationcontrollerCmd = (clusterId: string, namespace: string, name: string, replicas: number) => + invoke("scale_replicationcontroller", { clusterId, namespace, name, replicas }); + +export const suspendCronjobCmd = (clusterId: string, namespace: string, name: string) => + invoke("suspend_cronjob", { clusterId, namespace, name }); + +export const resumeCronjobCmd = (clusterId: string, namespace: string, name: string) => + invoke("resume_cronjob", { clusterId, namespace, name }); + +export const triggerCronjobCmd = (clusterId: string, namespace: string, name: string) => + invoke("trigger_cronjob", { clusterId, namespace, name }); + +// ─── Log Streaming Commands ─────────────────────────────────────────────────── + +export const streamPodLogsCmd = (config: LogStreamConfig) => + invoke("stream_pod_logs", { config }); + +export const stopLogStreamCmd = (streamId: string) => + invoke("stop_log_stream", { streamId }); + +// ─── Helm Commands ──────────────────────────────────────────────────────────── + +export const helmListReposCmd = (clusterId: string) => + invoke("helm_list_repos", { clusterId }); + +export const helmAddRepoCmd = (clusterId: string, name: string, url: string) => + invoke("helm_add_repo", { clusterId, name, url }); + +export const helmUpdateReposCmd = (clusterId: string) => + invoke("helm_update_repos", { clusterId }); + +export const helmSearchRepoCmd = (clusterId: string, query: string) => + invoke("helm_search_repo", { clusterId, query }); + +export const helmListReleasesCmd = (clusterId: string, namespace: string) => + invoke("helm_list_releases", { clusterId, namespace }); + +export const helmUninstallCmd = (clusterId: string, namespace: string, releaseName: string) => + invoke("helm_uninstall", { clusterId, namespace, releaseName }); + +export const helmRollbackCmd = (clusterId: string, namespace: string, releaseName: string, revision?: number) => + invoke("helm_rollback", { clusterId, namespace, releaseName, revision }); + +// ─── CRD / Custom Resource Commands ────────────────────────────────────────── + +export const listCrdsCmd = (clusterId: string) => + invoke("list_crds", { clusterId }); + +export const listCustomResourcesCmd = (clusterId: string, group: string, version: string, resource: string, namespace: string) => + invoke("list_custom_resources", { clusterId, group, version, resource, namespace }); From 9c9ca169661447cc1142502d4bcf16da659f2cca Mon Sep 17 00:00:00 2001 From: Shaun Arman Date: Mon, 8 Jun 2026 20:34:01 -0500 Subject: [PATCH 3/5] feat(kube): implement 44 new Rust K8s commands + helm binary support New list commands: list_replicationcontrollers, list_poddisruptionbudgets, list_priorityclasses, list_runtimeclasses, list_leases, list_mutatingwebhookconfigurations, list_validatingwebhookconfigurations, list_endpoints, list_endpointslices, list_ingressclasses, list_namespaces_resource, list_crds, list_custom_resources New action commands: force_delete_resource, describe_resource, get_resource_yaml, attach_pod, restart_statefulset, restart_daemonset, scale_statefulset, scale_replicaset, scale_replicationcontroller, suspend_cronjob, resume_cronjob, trigger_cronjob, create_namespace, delete_namespace Log streaming: stream_pod_logs (tokio task + Tauri events), stop_log_stream Helm: helm_list_repos, helm_add_repo, helm_update_repos, helm_search_repo, helm_list_releases, helm_uninstall, helm_rollback Infrastructure: shell/helm.rs locate_helm(), scripts/download-helm.sh, AppState.log_streams for stream lifecycle management 363/363 tests passing, zero clippy warnings Co-Authored-By: TFTSR Engineering --- scripts/download-helm.sh | 58 + src-tauri/src/commands/integrations.rs | 2 + src-tauri/src/commands/kube.rs | 3034 +++++++++++++++++++++++- src-tauri/src/lib.rs | 41 + src-tauri/src/shell/helm.rs | 113 + src-tauri/src/shell/mod.rs | 2 + src-tauri/src/state.rs | 2 + 7 files changed, 3241 insertions(+), 11 deletions(-) create mode 100644 scripts/download-helm.sh create mode 100644 src-tauri/src/shell/helm.rs 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. From aee739c078d87e6ab7d73de5bbb1b1c6b9065be4 Mon Sep 17 00:00:00 2001 From: Shaun Arman Date: Mon, 8 Jun 2026 20:38:05 -0500 Subject: [PATCH 4/5] feat(kube): nav restructure, action menus, new resource lists, advanced components Navigation: - Restructure to match requested layout: Cluster, Nodes, Workloads, Config, Network, Storage, Namespaces, Events, Helm, Access Control, Custom Resources - Workloads: add Overview dashboard and Replication Controllers - Config: add PDB, PriorityClass, RuntimeClass, Lease, Mutating/Validating Webhooks - Network: add Endpoints, EndpointSlices, IngressClasses; move Port Forwarding here - Helm and Custom Resources sections wired through New shared components: - ResourceActionMenu: state-aware MoreHorizontal dropdown - ConfirmDeleteDialog: confirmation guard for all destructive operations - ScaleModal: replica count dialog (Deployments, StatefulSets, ReplicaSets, RCs) - LogsModal: container log viewer replacing PodList inline dialog - ShellExecModal: kubectl exec -it with container and shell selector - AttachModal: kubectl attach -it with container selector New resource list components (12): ReplicationControllerList, PodDisruptionBudgetList, PriorityClassList, RuntimeClassList, LeaseList, MutatingWebhookList, ValidatingWebhookList, EndpointList, EndpointSliceList, IngressClassList, NamespaceList, WorkloadOverview New advanced components (5): LogStreamPanel (Tauri-event streaming, follow/search/download), HelmChartList, HelmReleaseList, CrdList, CustomResourceList Updated 24 existing list components with context-appropriate action menus: - Pods: Logs, Shell, Attach, Edit, Delete, Force Delete (state-aware) - Deployments: Scale, Restart, Rollback, Edit, Delete - StatefulSets/ReplicaSets: Scale, Restart/none, Edit, Delete - DaemonSets: Restart, Edit, Delete - Jobs: Edit, Delete - CronJobs: Suspend/Resume (state-aware), Trigger, Edit, Delete - Services/Ingresses/ConfigMaps/Secrets/HPAs/PVCs/PVs/StorageClasses/ NetworkPolicies/ResourceQuotas/LimitRanges: Edit, Delete - Nodes: Cordon/Uncordon (state-aware), Drain, Edit - All RBAC resources: Edit, Delete Co-Authored-By: TFTSR Engineering --- src/components/Kubernetes/AttachModal.tsx | 112 ++++ .../Kubernetes/ClusterRoleBindingList.tsx | 146 ++++- src/components/Kubernetes/ClusterRoleList.tsx | 142 ++++- src/components/Kubernetes/ConfigMapList.tsx | 150 +++-- .../Kubernetes/ConfirmDeleteDialog.tsx | 85 +++ src/components/Kubernetes/CrdList.tsx | 142 +++++ src/components/Kubernetes/CronJobList.tsx | 232 ++++++-- .../Kubernetes/CustomResourceList.tsx | 100 ++++ src/components/Kubernetes/DaemonSetList.tsx | 189 +++++-- src/components/Kubernetes/DeploymentList.tsx | 298 +++++----- src/components/Kubernetes/EndpointList.tsx | 50 ++ .../Kubernetes/EndpointSliceList.tsx | 50 ++ src/components/Kubernetes/HPAList.tsx | 168 ++++-- src/components/Kubernetes/HelmChartList.tsx | 296 ++++++++++ src/components/Kubernetes/HelmReleaseList.tsx | 262 +++++++++ .../Kubernetes/IngressClassList.tsx | 50 ++ src/components/Kubernetes/IngressList.tsx | 164 ++++-- src/components/Kubernetes/JobList.tsx | 170 ++++-- src/components/Kubernetes/LeaseList.tsx | 44 ++ src/components/Kubernetes/LimitRangeList.tsx | 141 ++++- src/components/Kubernetes/LogStreamPanel.tsx | 294 ++++++++++ src/components/Kubernetes/LogsModal.tsx | 110 ++++ .../Kubernetes/MutatingWebhookList.tsx | 42 ++ src/components/Kubernetes/NamespaceList.tsx | 50 ++ .../Kubernetes/NetworkPolicyList.tsx | 145 +++-- src/components/Kubernetes/NodeList.tsx | 243 ++++----- src/components/Kubernetes/PVCList.tsx | 168 ++++-- src/components/Kubernetes/PVList.tsx | 157 ++++-- .../Kubernetes/PodDisruptionBudgetList.tsx | 48 ++ src/components/Kubernetes/PodList.tsx | 277 ++++++---- .../Kubernetes/PriorityClassList.tsx | 50 ++ src/components/Kubernetes/ReplicaSetList.tsx | 197 +++++-- .../Kubernetes/ReplicationControllerList.tsx | 48 ++ .../Kubernetes/ResourceActionMenu.tsx | 88 +++ .../Kubernetes/ResourceQuotaList.tsx | 153 ++++-- src/components/Kubernetes/RoleBindingList.tsx | 156 ++++-- src/components/Kubernetes/RoleList.tsx | 152 +++++- .../Kubernetes/RuntimeClassList.tsx | 42 ++ src/components/Kubernetes/ScaleModal.tsx | 102 ++++ src/components/Kubernetes/SecretList.tsx | 162 ++++-- .../Kubernetes/ServiceAccountList.tsx | 156 ++++-- src/components/Kubernetes/ServiceList.tsx | 179 ++++-- src/components/Kubernetes/ShellExecModal.tsx | 137 +++++ src/components/Kubernetes/StatefulSetList.tsx | 201 ++++++- .../Kubernetes/StorageClassList.tsx | 149 +++-- .../Kubernetes/ValidatingWebhookList.tsx | 42 ++ .../Kubernetes/WorkloadOverview.tsx | 148 +++++ src/components/Kubernetes/index.tsx | 12 + src/pages/Kubernetes/KubernetesPage.tsx | 516 +++++++++++++++--- 49 files changed, 6021 insertions(+), 1194 deletions(-) create mode 100644 src/components/Kubernetes/AttachModal.tsx create mode 100644 src/components/Kubernetes/ConfirmDeleteDialog.tsx create mode 100644 src/components/Kubernetes/CrdList.tsx create mode 100644 src/components/Kubernetes/CustomResourceList.tsx create mode 100644 src/components/Kubernetes/EndpointList.tsx create mode 100644 src/components/Kubernetes/EndpointSliceList.tsx create mode 100644 src/components/Kubernetes/HelmChartList.tsx create mode 100644 src/components/Kubernetes/HelmReleaseList.tsx create mode 100644 src/components/Kubernetes/IngressClassList.tsx create mode 100644 src/components/Kubernetes/LeaseList.tsx create mode 100644 src/components/Kubernetes/LogStreamPanel.tsx create mode 100644 src/components/Kubernetes/LogsModal.tsx create mode 100644 src/components/Kubernetes/MutatingWebhookList.tsx create mode 100644 src/components/Kubernetes/NamespaceList.tsx create mode 100644 src/components/Kubernetes/PodDisruptionBudgetList.tsx create mode 100644 src/components/Kubernetes/PriorityClassList.tsx create mode 100644 src/components/Kubernetes/ReplicationControllerList.tsx create mode 100644 src/components/Kubernetes/ResourceActionMenu.tsx create mode 100644 src/components/Kubernetes/RuntimeClassList.tsx create mode 100644 src/components/Kubernetes/ScaleModal.tsx create mode 100644 src/components/Kubernetes/ShellExecModal.tsx create mode 100644 src/components/Kubernetes/ValidatingWebhookList.tsx create mode 100644 src/components/Kubernetes/WorkloadOverview.tsx 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 - -
- -