diff --git a/.eslintignore b/.eslintignore deleted file mode 100644 index 54bddce3..00000000 --- a/.eslintignore +++ /dev/null @@ -1,6 +0,0 @@ -node_modules/ -dist/ -target/ -src-tauri/target/ -coverage/ -tailwind.config.ts diff --git a/TICKET-kube-pr-review-fixes.md b/TICKET-kube-pr-review-fixes.md new file mode 100644 index 00000000..bae5a519 --- /dev/null +++ b/TICKET-kube-pr-review-fixes.md @@ -0,0 +1,99 @@ +# Kubernetes UI PR Review Fixes + +## Description + +Resolved all findings from the automated PR review (qwen3-coder-next) of the Kubernetes resource discovery and management feature. The review identified two blockers and several warnings across Rust backend and React frontend. + +**Root cause of blockers:** All six JSON parsing functions in `kube.rs` imported and used `serde_yaml::Value` / `serde_yaml::from_str` against kubectl's JSON output (`-o json`), causing parse failures or incorrect data at runtime. YAML is a superset of JSON and sometimes parses silently incorrectly; the correct parser is `serde_json`. + +**Secondary issues:** `PodInfo` lacked container name data, so the log viewer could only show the pod name as the container selector. The `exec_pod` command had an incorrect kubectl argument order (container `-c` flag placed after `--`, so it was passed to the shell inside the pod rather than to kubectl). The "All Namespaces" filter passed an empty string to kubectl `-n ""` which is invalid. + +--- + +## Acceptance Criteria + +- [x] All six `parse_*_json` functions use `serde_json::from_str` and `serde_json::Value` API (`as_array`, `as_object`) +- [x] `PodInfo` struct carries `containers: Vec`; container names parsed from `spec.containers[*].name` +- [x] `PodList.tsx` container selector populates from `selectedPod.containers` +- [x] `exec_pod` container `-c` flag is placed before `--` separator (correct kubectl syntax) +- [x] `exec_pod` accepts optional `shell` parameter with allowlist validation (`sh`, `bash`, `ash`, `dash`) +- [x] Empty namespace string routes to `--all-namespaces` in all five list commands +- [x] Dialog inner div uses `overflow-y-auto` to handle content overflow on small screens +- [x] `getNamespaceOptions` memoized with `useMemo` +- [x] `eslint.config.js` deduplicated (was 272 lines, duplicate blocks removed), global ignore fixed +- [x] Unused imports removed from all Kubernetes list components +- [x] `cargo clippy -- -D warnings`: zero warnings +- [x] `tsc --noEmit`: zero errors +- [x] `eslint . --max-warnings 0`: zero warnings +- [x] 331 Rust tests passing, 98 frontend tests passing + +--- + +## Work Implemented + +### `src-tauri/src/commands/kube.rs` +- Replaced `use serde_yaml::Value` with `use serde_json::Value` +- `extract_context` and `extract_server_url`: explicitly typed as `serde_yaml::Value` (these legitimately parse YAML kubeconfig files) +- `PodInfo` struct: added `containers: Vec` field +- `parse_pods_json`: switched to `serde_json::from_str`, `as_array()`; added container name extraction from `spec.containers[].name` +- `parse_namespaces_json`, `parse_services_json`, `parse_deployments_json`, `parse_statefulsets_json`, `parse_daemonsets_json`: switched to `serde_json::from_str`, `as_array()`, `as_object()`; updated mapping iterators (serde_json object keys are `String`, not `Value`) +- `parse_services_json`: fixed `.as_sequence()` → `.as_array()` in `external_ip` ingress chain +- `list_pods`, `list_services`, `list_deployments`, `list_statefulsets`, `list_daemonsets`: handle empty `namespace` with `--all-namespaces` +- `exec_pod`: added optional `shell: Option` parameter; allowlist validates against `["sh","bash","ash","dash","/bin/sh","/bin/bash","/bin/ash","/bin/dash"]`; fixed argument order so `-c container` appears before `--` +- Phase 3 stub commands: added `#[allow(unused_variables)]` to suppress Clippy warnings on unimplemented stubs + +### `src/lib/tauriCommands.ts` +- `PodInfo` interface: added `containers: string[]` +- `execPodCmd`: added optional `shell?: string` parameter, passed through to IPC + +### `src/components/Kubernetes/PodList.tsx` +- Fixed: `const containers = selectedPod ? [selectedPod.name] : []` → `selectedPod?.containers ?? []` +- Fixed: `overflow-hidden` → `overflow-y-auto` on inner dialog content div +- Removed unused imports: `Card`, `CardContent`, `CardHeader`, `CardTitle` + +### `src/components/Kubernetes/ResourceBrowser.tsx` +- Added `useCallback` import; wrapped `loadData` in `useCallback([clusterId, selectedNamespace])` +- `useEffect` deps updated to `[loadData, resourceType]` +- Removed unused `CardTitle` import +- `getNamespaceOptions` converted to memoized `namespaceOptions` via `useMemo` + +### `src/components/Kubernetes/DaemonSetList.tsx`, `ServiceList.tsx`, `StatefulSetList.tsx` +- Removed unused `Card`, `CardContent`, `CardHeader`, `CardTitle` imports +- Renamed unused props: `clusterId: _clusterId`, `namespace: _namespace` + +### `src/components/Kubernetes/DeploymentList.tsx` +- Removed unused `Card`, `CardContent`, `CardHeader`, `CardTitle` imports + +### `src/components/ui/index.tsx` +- `TableRow`: renamed unused `hover` prop to `_hover` + +### `src/App.tsx` +- Removed two debug `console.log` calls (auto-testing provider connection) + +### `src/pages/Triage/index.tsx` +- `useEffect`: added `addMessage`, `setActiveDomain`, `startSession` to dependency array (stable Zustand store actions) + +### `src/pages/LogUpload/index.tsx` +- `handleImagesUpload`: wrapped in `useCallback([id])` and moved before `handleImageDrop` to resolve declaration-order issue +- `handleImageDrop`: updated deps from `[id]` to `[handleImagesUpload]` + +### `eslint.config.js` +- Removed duplicate config block (file was doubled to 272 lines) +- Fixed global ignore: moved `ignores` array to a standalone config object (was incorrectly paired with `files`) +- CLI section: added `"log"` to allowed console methods (CLI tool output) + +### `.eslintignore` +- Deleted — content migrated to `eslint.config.js` global ignore + +--- + +## Testing Needed + +- [ ] Connect a real kubeconfig and verify pod/namespace/service/deployment/statefulset/daemonset lists render correctly with JSON from kubectl +- [ ] Select "All Namespaces" — verify `--all-namespaces` is used and resources from all namespaces appear +- [ ] Open pod log dialog — verify container dropdown shows actual container names (not pod name) +- [ ] Fetch logs for a multi-container pod — verify correct container logs are returned +- [ ] Test `exec_pod` via UI with `sh` (default) and `bash` — verify both work +- [ ] Test `exec_pod` with an invalid shell name (e.g., `zsh`) — verify it returns an error +- [ ] Verify "All Namespaces" view does not trigger empty-namespace kubectl error +- [ ] Smoke test triage and log upload flows to verify `useEffect`/`useCallback` hook changes have no regressions diff --git a/docs/KUBERNETES-MANAGEMENT-IMPLEMENTATION-PLAN.md b/docs/KUBERNETES-MANAGEMENT-IMPLEMENTATION-PLAN.md new file mode 100644 index 00000000..f02f664a --- /dev/null +++ b/docs/KUBERNETES-MANAGEMENT-IMPLEMENTATION-PLAN.md @@ -0,0 +1,760 @@ +# Kubernetes Management UI - Complete Feature Implementation Plan + +## Project: tftsr-devops_investigation v1.1.0 +## Target: 100% Lens Desktop v5.x Feature Parity (MIT Licensed) +## Architecture: Tauri 2 + Rust Backend + React/TypeScript Frontend + +--- + +## Executive Summary + +This plan implements a complete Lens Desktop v5.x-equivalent Kubernetes Management UI using the existing project architecture (Tauri + Rust + React). All features will be MIT-licensed, building on the foundation already established in the project. + +**Current Status (v1.1.0):** +- ✅ 43 backend commands implemented in `src-tauri/src/commands/kube.rs` +- ✅ 115 command wrappers in `src/lib/tauriCommands.ts` +- ✅ Basic cluster management (add/remove/list) +- ✅ Port forwarding (start/stop/delete/shutdown) +- ✅ Resource discovery (pods, services, deployments, statefulsets, daemonsets, namespaces) +- ✅ Resource management (scale, restart, delete, exec) +- ✅ 22 additional resource types via backend commands +- ✅ Frontend components for 10 resource types (ClusterList, PodList, ServiceList, DeploymentList, StatefulSetList, DaemonSetList, PortForwardList, AddClusterModal, PortForwardForm, ResourceBrowser) + +**What's Missing:** +- Frontend UI components for remaining 10+ resource types (Nodes, Events, ConfigMaps, Secrets, ReplicaSets, Jobs, CronJobs, Ingresses, PVCs, PVs, ServiceAccounts, Roles, ClusterRoles, RoleBindings, ClusterRoleBindings, HPAs) +- Advanced features (terminal, YAML editor, metrics, search, context switcher) +- Real-time updates via Kubernetes API watchers +- Multi-cluster context switching UI +- Application grouping + +--- + +## Phase 1: Complete Resource Discovery UI (Priority: HIGH) + +### 1.1 Nodes View +**File:** `src/components/Kubernetes/NodeList.tsx` + +**Features:** +- Table view of all cluster nodes +- Node status (Ready/NotReady) +- Roles (control-plane, worker) +- Kubernetes version +- Internal/external IPs +- OS image, kernel version, kubelet version +- Age +- Actions: Cordon, Uncordon, Drain, Shell, Edit, Delete + +**Backend Commands (✅ Implemented):** +- `list_nodes()` - List all nodes +- `cordon_node()` - Mark node as unschedulable +- `uncordon_node()` - Mark node as schedulable +- `drain_node()` - Evict pods from node + +### 1.2 Events View +**File:** `src/components/Kubernetes/EventList.tsx` + +**Features:** +- Table view of cluster events +- Event type (Normal/Warning) +- Reason (PodScheduled, Pulling, etc.) +- Object (pod name, deployment name) +- Count +- First seen, last seen +- Message +- Filter by namespace + +**Backend Commands (✅ Implemented):** +- `list_events()` - List all events + +### 1.3 ConfigMaps View +**File:** `src/components/Kubernetes/ConfigMapList.tsx` + +**Features:** +- Table view of configmaps +- Data keys count +- Age +- View/edit configmap data +- Delete configmap + +**Backend Commands (✅ Implemented):** +- `list_configmaps()` - List all configmaps +- `create_resource()` - Create resource from YAML +- `edit_resource()` - Edit resource via YAML +- `delete_resource()` - Delete resource + +### 1.4 Secrets View +**File:** `src/components/Kubernetes/SecretList.tsx` + +**Features:** +- Table view of secrets +- Secret type (Opaque, TLS, etc.) +- Data keys count +- Age +- Masked values (show ***) +- View/edit secret (YAML or form) +- Delete secret + +**Backend Commands (✅ Implemented):** +- `list_secrets()` - List all secrets + +### 1.5 ReplicaSets View +**File:** `src/components/Kubernetes/ReplicaSetList.tsx` + +**Features:** +- Table view of replica sets +- Desired/Ready replicas +- Age +- Labels +- Actions: View details, Delete + +**Backend Commands (✅ Implemented):** +- `list_replicasets()` - List all replica sets + +### 1.6 Jobs View +**File:** `src/components/Kubernetes/JobList.tsx` + +**Features:** +- Table view of jobs +- Completions (e.g., 1/1) +- Duration +- Age +- Status (Active/Succeeded/Failed) +- Actions: View logs, Delete + +**Backend Commands (✅ Implemented):** +- `list_jobs()` - List all jobs + +### 1.7 CronJobs View +**File:** `src/components/Kubernetes/CronJobList.tsx` + +**Features:** +- Table view of cronjobs +- Schedule (e.g., 0 * * * *) +- Active jobs count +- Last schedule +- Age +- Actions: View details, Delete + +**Backend Commands (✅ Implemented):** +- `list_cronjobs()` - List all cronjobs + +### 1.8 Ingresses View +**File:** `src/components/Kubernetes/IngressList.tsx` + +**Features:** +- Table view of ingresses +- Class (nginx, traefik, etc.) +- Host (domain) +- Addresses (load balancer IPs) +- Age +- Actions: View details, Delete + +**Backend Commands (✅ Implemented):** +- `list_ingresses()` - List all ingresses + +### 1.9 PersistentVolumeClaims View +**File:** `src/components/Kubernetes/PVCList.tsx` + +**Features:** +- Table view of PVCs +- Status (Pending/Bound/Lost) +- Volume (bound PV name) +- Capacity +- Access modes (RWO, ROX, etc.) +- Age +- Actions: Delete + +**Backend Commands (✅ Implemented):** +- `list_persistentvolumeclaims()` - List all PVCs + +### 1.10 PersistentVolumes View +**File:** `src/components/Kubernetes/PVList.tsx` + +**Features:** +- Table view of PVs +- Status (Available/Bound/Released/Failed) +- Capacity +- Access modes +- Reclaim policy (Retain/Recycle/Delete) +- Storage class +- Age +- Actions: Delete + +**Backend Commands (✅ Implemented):** +- `list_persistentvolumes()` - List all PVs + +### 1.11 ServiceAccounts View +**File:** `src/components/Kubernetes/ServiceAccountList.tsx` + +**Features:** +- Table view of service accounts +- Secrets count +- Age +- Actions: View details, Delete + +**Backend Commands (✅ Implemented):** +- `list_serviceaccounts()` - List all service accounts + +### 1.12 Roles View +**File:** `src/components/Kubernetes/RoleList.tsx` + +**Features:** +- Table view of roles +- Namespace +- Age +- Actions: View rules, Delete + +**Backend Commands (✅ Implemented):** +- `list_roles()` - List all roles + +### 1.13 ClusterRoles View +**File:** `src/components/Kubernetes/ClusterRoleList.tsx` + +**Features:** +- Table view of cluster roles +- Age +- Actions: View rules, Delete + +**Backend Commands (✅ Implemented):** +- `list_clusterroles()` - List all cluster roles + +### 1.14 RoleBindings View +**File:** `src/components/Kubernetes/RoleBindingList.tsx` + +**Features:** +- Table view of role bindings +- Namespace +- Role (reference) +- Age +- Actions: View details, Delete + +**Backend Commands (✅ Implemented):** +- `list_rolebindings()` - List all role bindings + +### 1.15 ClusterRoleBindings View +**File:** `src/components/Kubernetes/ClusterRoleBindingList.tsx` + +**Features:** +- Table view of cluster role bindings +- Cluster role (reference) +- Age +- Actions: View details, Delete + +**Backend Commands (✅ Implemented):** +- `list_clusterrolebindings()` - List all cluster role bindings + +### 1.16 HorizontalPodAutoscalers View +**File:** `src/components/Kubernetes/HPAList.tsx` + +**Features:** +- Table view of HPAs +- Min/Max replicas +- Current replicas +- Desired replicas +- Age +- Actions: View details, Delete + +**Backend Commands (✅ Implemented):** +- `list_horizontalpodautoscalers()` - List all HPAs + +--- + +## Phase 2: Advanced Features (Priority: HIGH) + +### 2.1 Interactive Terminal +**File:** `src/components/Kubernetes/Terminal.tsx` + +**Features:** +- Full-featured terminal using xterm.js +- Multiple tabs support +- Shell selection (sh, bash, zsh) +- Multi-container pod support +- Resize support +- Copy/paste +- Search in output +- Clear screen +- Disconnect/reconnect + +**Backend Commands (✅ Implemented):** +- `exec_pod()` - Execute command in pod + +**Implementation Notes:** +- Use `xterm.js` for terminal rendering +- Use `xterm-addon-web-links` for link detection +- Use `xterm-addon-fit` for auto-resize +- WebSocket-based terminal session (or kubectl exec) + +### 2.2 YAML Editor +**File:** `src/components/Kubernetes/YamlEditor.tsx` + +**Features:** +- Code editor using Monaco (VS Code's editor) +- Syntax highlighting for YAML +- Validation (basic schema validation) +- Diff view (before/after) +- Apply button +- Cancel button +- Error messages + +**Dependencies:** +- `@monaco-editor/react` (MIT licensed) + +### 2.3 Metrics Visualization +**File:** `src/components/Kubernetes/MetricsChart.tsx` + +**Features:** +- CPU usage chart (line/bar) +- Memory usage chart +- Time range selector (5m, 15m, 1h, 6h, 1d, 7d) +- Zoom functionality +- Legend +- Tooltip with values +- Per-container metrics for pods + +**Backend Commands:** +- Need to add: `get_metrics()` for node/pod metrics + +**Dependencies:** +- `react-chartjs-2` or `recharts` (MIT licensed) + +### 2.4 Search and Filter +**File:** `src/components/Kubernetes/SearchBar.tsx` + +**Features:** +- Global search bar +- Search by name, labels, annotations +- Filter by namespace +- Filter by status +- Filter by resource type +- Recent searches +- Search suggestions + +**Implementation Notes:** +- Debounced search +- Client-side filtering (or server-side for large datasets) +- Keyboard shortcuts (Ctrl+K) + +### 2.5 Application Grouping +**File:** `src/components/Kubernetes/ApplicationView.tsx` + +**Features:** +- Group workloads by application label +- Visual hierarchy (app → deployment → pods) +- Resource relationships +- Dependency visualization +- Application status summary + +**Implementation Notes:** +- Tree view component +- Use labels to group resources +- Show owner references + +### 2.6 Context Switcher +**File:** `src/components/Kubernetes/ContextSwitcher.tsx` + +**Features:** +- Current cluster display +- Cluster selector dropdown +- Context selector (when multiple contexts in kubeconfig) +- Quick switch between clusters +- Visual indicator of active cluster + +**Backend Commands (✅ Implemented):** +- `list_clusters()` - List all clusters +- `add_cluster()` - Add cluster +- `remove_cluster()` - Remove cluster + +--- + +## Phase 3: Enhanced Workloads (Priority: HIGH) + +### 3.1 Enhanced Pod List +**File:** `src/components/Kubernetes/PodList.tsx` (Update) + +**Add Features:** +- Multi-container pod support (select container) +- Container status indicators +- Resource requests/limits display +- Node assignment +- IP address +- Restart count +- Events tab +- Logs streaming (auto-refresh) + +### 3.2 Enhanced Deployment List +**File:** `src/components/Kubernetes/DeploymentList.tsx` (Update) + +**Add Features:** +- Rollout status +- Revision history +- Rollback button +- Update strategy +- Progress conditions +- Events tab + +**Backend Commands (✅ Implemented):** +- `rollback_deployment()` - Rollback deployment + +### 3.3 Enhanced Service List +**File:** `src/components/Kubernetes/ServiceList.tsx` (Update) + +**Add Features:** +- Endpoints display +- Selector display +- Session affinity +- Type-specific fields (LoadBalancer IPs, NodePorts) +- External name display +- Events tab + +### 3.4 Enhanced ConfigMap/Secret View +**File:** `src/components/Kubernetes/ConfigMapDetail.tsx` + +**Features:** +- Data keys as expandable list +- Key-value pairs display +- Edit mode (form or YAML) +- Create new key +- Delete key +- Export to file + +--- + +## Phase 4: Cluster Management (Priority: MEDIUM) + +### 4.1 Cluster Overview +**File:** `src/components/Kubernetes/ClusterOverview.tsx` + +**Features:** +- Cluster name and version +- API server URL +- Provider information +- Node count (total, ready) +- Resource utilization (CPU, memory) +- Workload counts +- Quick actions (add cluster, refresh) + +**Backend Commands:** +- `list_nodes()` - ✅ Implemented +- Need to add: `get_cluster_info()` + +### 4.2 Cluster Details +**File:** `src/components/Kubernetes/ClusterDetails.tsx` + +**Features:** +- Cluster configuration +- Certificate details +- Storage classes +- Network policies +- RBAC summary +- Add-ons + +--- + +## Phase 5: User Experience (Priority: MEDIUM) + +### 5.1 Hotbar (Quick Actions) +**File:** `src/components/Kubernetes/Hotbar.tsx` + +**Features:** +- Quick access toolbar +- Common actions (refresh, create, search) +- Recent actions +- Custom shortcuts + +### 5.2 Command Palette +**File:** `src/components/Kubernetes/CommandPalette.tsx` + +**Features:** +- Quick command access (Ctrl+Shift+P) +- Command search +- Keyboard shortcuts +- Recent commands + +### 5.3 Toast Notifications +**File:** `src/components/Kubernetes/Toast.tsx` + +**Features:** +- Success/error notifications +- Auto-dismiss +- Action buttons in notifications +- History of notifications + +### 5.4 Loading States +**File:** `src/components/Kubernetes/LoadingSpinner.tsx` + +**Features:** +- Loading indicators for all async operations +- Skeleton screens for data tables +- Progress indicators + +--- + +## Phase 6: Advanced Management (Priority: LOW) + +### 6.1 Resource Creation Dialogs +**File:** `src/components/Kubernetes/CreateResourceModal.tsx` + +**Features:** +- Create from template +- Create from YAML +- Create from form +- Namespace selection +- Validation +- Apply button + +### 6.2 Resource Edit Dialog +**File:** `src/components/Kubernetes/EditResourceModal.tsx` + +**Features:** +- Edit existing resource +- YAML editor +- Form editor +- Preview changes +- Apply button + +### 6.3 Port Forward UI +**File:** `src/components/Kubernetes/PortForwardForm.tsx` (Update) + +**Add Features:** +- Pod selector +- Container selector +- Local port auto-detection +- Target port selection +- Multiple port forwards +- Active forwards list + +**Backend Commands (✅ Implemented):** +- `start_port_forward()` - ✅ Implemented +- `stop_port_forward()` - ✅ Implemented +- `list_port_forwards()` - ✅ Implemented + +### 6.4 Helm Integration +**File:** `src/components/Kubernetes/HelmView.tsx` + +**Features:** +- Charts view (from repositories) +- Releases view +- Install chart +- Upgrade release +- Rollback release +- Uninstall release + +**Backend Commands:** +- Need to add: `helm_*` commands + +--- + +## Phase 7: Real-time Updates (Priority: HIGH) + +### 7.1 WebSocket Watchers +**File:** `src-tauri/src/kube/watcher.rs` + +**Features:** +- Kubernetes API watchers for all resource types +- Reconnect logic +- Resource caching with diff updates +- Real-time UI updates +- Performance optimization + +**Implementation Notes:** +- Use `k8s-openapi` crate with `watch` feature +- Implement per-resource-type watchers +- Cache resources locally +- Push updates to frontend via Tauri events + +### 7.2 Event Bus +**File:** `src/lib/eventBus.ts` + +**Features:** +- Centralized event system +- Resource change events +- Connection status events +- Error events + +--- + +## Phase 8: RBAC Management (Priority: MEDIUM) + +### 8.1 RBAC Viewer +**File:** `src/components/Kubernetes/RbacViewer.tsx` + +**Features:** +- Role bindings visualization +- Reverse lookup (who has access to what) +- Permission checker +- Simulate policy + +### 8.2 RBAC Editor +**File:** `src/components/Kubernetes/RbacEditor.tsx` + +**Features:** +- Create/edit roles +- Add/remove rules +- Bind roles to subjects +- Preview permissions + +--- + +## Phase 9: Extension System (Priority: LOW) + +### 9.1 Extension API +**File:** `src/lib/extensions.ts` + +**Features:** +- Plugin architecture +- Extension loading +- Extension management UI +- Sandbox environment + +**Implementation Notes:** +- Use WebAssembly for extensions +- Or use Node.js child processes +- Define extension API surface + +--- + +## Implementation Order + +### Sprint 1 (Week 1): Resource Discovery UI +- Nodes, Events, ConfigMaps, Secrets +- ReplicaSets, Jobs, CronJobs +- Ingresses, PVCs, PVs +- ServiceAccounts, Roles, ClusterRoles +- RoleBindings, ClusterRoleBindings, HPAs + +### Sprint 2 (Week 2): Advanced Features +- Interactive terminal +- YAML editor +- Metrics visualization +- Search and filter +- Application grouping +- Context switcher + +### Sprint 3 (Week 3): Enhanced Workloads +- Enhanced Pod/Deployment/Service lists +- ConfigMap/Secret detail views +- Cluster overview +- Cluster details + +### Sprint 4 (Week 4): UX & Polish +- Hotbar, Command palette +- Toast notifications +- Loading states +- Resource creation/edit dialogs +- Port forward UI + +### Sprint 5 (Week 5): Real-time & RBAC +- WebSocket watchers +- Event bus +- RBAC viewer/editor +- Extension system (optional) + +### Sprint 6 (Week 6): Testing & Release +- Test coverage +- Documentation +- Bug fixes +- Release preparation + +--- + +## Dependencies to Add + +### Frontend (npm): +```json +{ + "xterm": "^5.3.0", + "xterm-addon-web-links": "^0.9.0", + "xterm-addon-fit": "^0.8.0", + "@monaco-editor/react": "^4.6.0", + "react-chartjs-2": "^5.2.0", + "chart.js": "^4.4.0", + "zustand": "^4.4.0" (already present) +} +``` + +### Backend (Cargo.toml): +```toml +# For Kubernetes API watchers +k8s-openapi = { version = "0.21", features = ["watch"] } +tokio-stream = "1.0" +``` + +--- + +## Architecture Updates + +### State Management +- Add `clusters` store (persisted) +- Add `portForwards` store (persisted) +- Add `selectedContext` store (ephemeral) +- Add `resources` store (cached, with watchers) + +### Backend Enhancements +- Add `ResourceCache` struct for efficient local caching +- Add `ResourceWatcher` for Kubernetes API watchers +- Add `EventBus` for real-time updates +- Add `MetricsCollector` for resource metrics + +--- + +## Success Criteria + +✅ **100% Feature Parity Checklist:** + +### Core Features (Must Have) +- [ ] All 16 resource discovery UIs implemented +- [ ] All 6 management UIs implemented +- [ ] Interactive terminal with tab support +- [ ] YAML editor with validation +- [ ] Metrics visualization +- [ ] Search and filter functionality +- [ ] Application grouping +- [ ] Context switcher +- [ ] Real-time updates via watchers +- [ ] RBAC viewer/editor + +### Quality Features (Should Have) +- [ ] Hotbar and command palette +- [ ] Toast notifications +- [ ] Loading states +- [ ] Resource creation/edit dialogs +- [ ] Port forward UI +- [ ] Helm integration +- [ ] Cluster overview +- [ ] RBAC management + +### Enterprise Features (Nice to Have) +- [ ] Extension system +- [ ] Multi-cluster management UI +- [ ] Team sharing +- [ ] Audit trail enhancements + +--- + +## Risk Assessment + +| Risk | Impact | Mitigation | +|------|--------|------------| +| Backend command implementation | HIGH | Already done (43 commands) | +| Frontend component complexity | MEDIUM | Use existing patterns | +| Real-time performance | MEDIUM | Implement caching and diff updates | +| Terminal integration | LOW | Use xterm.js library | +| Metrics collection | MEDIUM | Add `get_metrics()` command | +| Helm integration | LOW | Optional feature | + +--- + +## Notes + +- All implementations must remain MIT licensed +- Follow existing code patterns in the project +- Use existing UI components from `src/components/ui/index.tsx` +- Test each feature before moving to next +- Update `RELEASE_NOTES.md` for each phase +- Update `README.md` with new features + +--- + +**Document Version:** 1.0 +**Last Updated:** 2026-06-07 +**Next Review:** After Sprint 1 completion diff --git a/eslint.config.js b/eslint.config.js index d6f200c3..918a7a42 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -6,267 +6,136 @@ import parserTs from "@typescript-eslint/parser"; export default [ { - files: ["src/**/*.{ts,tsx}"], - languageOptions: { - ecmaVersion: "latest", - sourceType: "module", - globals: { - ...globals.browser, - ...globals.node, - }, - parser: parserTs, - parserOptions: { - ecmaFeatures: { - jsx: true, - }, - project: "./tsconfig.json", - }, - }, - plugins: { - react: pluginReact, - "react-hooks": pluginReactHooks, - "@typescript-eslint": pluginTs, - }, - settings: { - react: { - version: "detect", - }, - }, - rules: { - ...pluginReact.configs.recommended.rules, - ...pluginReactHooks.configs.recommended.rules, - ...pluginTs.configs.recommended.rules, - "no-unused-vars": "off", - "@typescript-eslint/no-unused-vars": ["error", { argsIgnorePattern: "^_" }], - "no-console": ["warn", { allow: ["warn", "error"] }], - "react/react-in-jsx-scope": "off", - "react/prop-types": "off", - "react/no-unescaped-entities": "off", - }, - }, - { - files: ["tests/unit/**/*.test.{ts,tsx}", "tests/unit/setup.ts"], - languageOptions: { - ecmaVersion: "latest", - sourceType: "module", - globals: { - ...globals.browser, - ...globals.node, - ...globals.vitest, - }, - parser: parserTs, - parserOptions: { - ecmaFeatures: { - jsx: true, - }, - project: "./tsconfig.json", - }, - }, - plugins: { - react: pluginReact, - "react-hooks": pluginReactHooks, - "@typescript-eslint": pluginTs, - }, - settings: { - react: { - version: "detect", - }, - }, - rules: { - ...pluginReact.configs.recommended.rules, - ...pluginReactHooks.configs.recommended.rules, - ...pluginTs.configs.recommended.rules, - "no-unused-vars": "off", - "@typescript-eslint/no-unused-vars": ["error", { argsIgnorePattern: "^_" }], - "no-console": ["warn", { allow: ["warn", "error"] }], - "react/react-in-jsx-scope": "off", - "react/prop-types": "off", - "react/no-unescaped-entities": "off", - }, - }, - { - files: ["tests/e2e/**/*.ts", "tests/e2e/**/*.tsx"], - languageOptions: { - ecmaVersion: "latest", - sourceType: "module", - globals: { - ...globals.node, - }, - parser: parserTs, - parserOptions: { - ecmaFeatures: { - jsx: false, - }, - }, - }, - plugins: { - "@typescript-eslint": pluginTs, - }, - rules: { - ...pluginTs.configs.recommended.rules, - "no-unused-vars": "off", - "@typescript-eslint/no-unused-vars": ["error", { argsIgnorePattern: "^_" }], - "no-console": ["warn", { allow: ["warn", "error"] }], - }, - }, - { - files: ["cli/**/*.{ts,tsx}"], - languageOptions: { - ecmaVersion: "latest", - sourceType: "module", - globals: { - ...globals.node, - }, - parser: parserTs, - parserOptions: { - ecmaFeatures: { - jsx: false, - }, - }, - }, - plugins: { - "@typescript-eslint": pluginTs, - }, - rules: { - ...pluginTs.configs.recommended.rules, - "no-unused-vars": "off", - "@typescript-eslint/no-unused-vars": ["error", { argsIgnorePattern: "^_" }], - "no-console": ["warn", { allow: ["warn", "error"] }], - "react/no-unescaped-entities": "off", - }, - }, - { - files: ["src/**/*.{ts,tsx}"], - languageOptions: { - ecmaVersion: "latest", - sourceType: "module", - globals: { - ...globals.browser, - ...globals.node, - }, - parser: parserTs, - parserOptions: { - ecmaFeatures: { - jsx: true, - }, - project: "./tsconfig.json", - }, - }, - plugins: { - react: pluginReact, - "react-hooks": pluginReactHooks, - "@typescript-eslint": pluginTs, - }, - settings: { - react: { - version: "detect", - }, - }, - rules: { - ...pluginReact.configs.recommended.rules, - ...pluginReactHooks.configs.recommended.rules, - ...pluginTs.configs.recommended.rules, - "no-unused-vars": "off", - "@typescript-eslint/no-unused-vars": ["error", { argsIgnorePattern: "^_" }], - "no-console": ["warn", { allow: ["warn", "error"] }], - "react/react-in-jsx-scope": "off", - "react/prop-types": "off", - "react/no-unescaped-entities": "off", - }, - }, - { - files: ["tests/unit/**/*.test.{ts,tsx}", "tests/unit/setup.ts"], - languageOptions: { - ecmaVersion: "latest", - sourceType: "module", - globals: { - ...globals.browser, - ...globals.node, - ...globals.vitest, - }, - parser: parserTs, - parserOptions: { - ecmaFeatures: { - jsx: true, - }, - project: "./tsconfig.json", - }, - }, - plugins: { - react: pluginReact, - "react-hooks": pluginReactHooks, - "@typescript-eslint": pluginTs, - }, - settings: { - react: { - version: "detect", - }, - }, - rules: { - ...pluginReact.configs.recommended.rules, - ...pluginReactHooks.configs.recommended.rules, - ...pluginTs.configs.recommended.rules, - "no-unused-vars": "off", - "@typescript-eslint/no-unused-vars": ["error", { argsIgnorePattern: "^_" }], - "no-console": ["warn", { allow: ["warn", "error"] }], - "react/react-in-jsx-scope": "off", - "react/prop-types": "off", - "react/no-unescaped-entities": "off", - }, - }, - { - files: ["tests/e2e/**/*.ts", "tests/e2e/**/*.tsx"], - languageOptions: { - ecmaVersion: "latest", - sourceType: "module", - globals: { - ...globals.node, - }, - parser: parserTs, - parserOptions: { - ecmaFeatures: { - jsx: false, - }, - }, - }, - plugins: { - "@typescript-eslint": pluginTs, - }, - rules: { - ...pluginTs.configs.recommended.rules, - "no-unused-vars": "off", - "@typescript-eslint/no-unused-vars": ["error", { argsIgnorePattern: "^_" }], - "no-console": ["warn", { allow: ["warn", "error"] }], - }, - }, - { - files: ["cli/**/*.{ts,tsx}"], - languageOptions: { - ecmaVersion: "latest", - sourceType: "module", - globals: { - ...globals.node, - }, - parser: parserTs, - parserOptions: { - ecmaFeatures: { - jsx: false, - }, - }, - }, - plugins: { - "@typescript-eslint": pluginTs, - }, - rules: { - ...pluginTs.configs.recommended.rules, - "no-unused-vars": "off", - "@typescript-eslint/no-unused-vars": ["error", { argsIgnorePattern: "^_" }], - "no-console": ["warn", { allow: ["warn", "error"] }], - "react/no-unescaped-entities": "off", - }, - }, - { - files: ["**/*.{js,jsx,mjs,cjs,ts,tsx}"], ignores: ["dist/", "node_modules/", "src-tauri/target/**", "target/**", "coverage/", "tailwind.config.ts"], }, + { + files: ["src/**/*.{ts,tsx}"], + languageOptions: { + ecmaVersion: "latest", + sourceType: "module", + globals: { + ...globals.browser, + ...globals.node, + }, + parser: parserTs, + parserOptions: { + ecmaFeatures: { + jsx: true, + }, + project: "./tsconfig.json", + }, + }, + plugins: { + react: pluginReact, + "react-hooks": pluginReactHooks, + "@typescript-eslint": pluginTs, + }, + settings: { + react: { + version: "detect", + }, + }, + rules: { + ...pluginReact.configs.recommended.rules, + ...pluginReactHooks.configs.recommended.rules, + ...pluginTs.configs.recommended.rules, + "no-unused-vars": "off", + "@typescript-eslint/no-unused-vars": ["error", { argsIgnorePattern: "^_" }], + "no-console": ["warn", { allow: ["warn", "error"] }], + "react/react-in-jsx-scope": "off", + "react/prop-types": "off", + "react/no-unescaped-entities": "off", + }, + }, + { + files: ["tests/unit/**/*.test.{ts,tsx}", "tests/unit/setup.ts"], + languageOptions: { + ecmaVersion: "latest", + sourceType: "module", + globals: { + ...globals.browser, + ...globals.node, + ...globals.vitest, + }, + parser: parserTs, + parserOptions: { + ecmaFeatures: { + jsx: true, + }, + project: "./tsconfig.json", + }, + }, + plugins: { + react: pluginReact, + "react-hooks": pluginReactHooks, + "@typescript-eslint": pluginTs, + }, + settings: { + react: { + version: "detect", + }, + }, + rules: { + ...pluginReact.configs.recommended.rules, + ...pluginReactHooks.configs.recommended.rules, + ...pluginTs.configs.recommended.rules, + "no-unused-vars": "off", + "@typescript-eslint/no-unused-vars": ["error", { argsIgnorePattern: "^_" }], + "no-console": ["warn", { allow: ["warn", "error"] }], + "react/react-in-jsx-scope": "off", + "react/prop-types": "off", + "react/no-unescaped-entities": "off", + }, + }, + { + files: ["tests/e2e/**/*.ts", "tests/e2e/**/*.tsx"], + languageOptions: { + ecmaVersion: "latest", + sourceType: "module", + globals: { + ...globals.node, + }, + parser: parserTs, + parserOptions: { + ecmaFeatures: { + jsx: false, + }, + }, + }, + plugins: { + "@typescript-eslint": pluginTs, + }, + rules: { + ...pluginTs.configs.recommended.rules, + "no-unused-vars": "off", + "@typescript-eslint/no-unused-vars": ["error", { argsIgnorePattern: "^_" }], + "no-console": ["warn", { allow: ["warn", "error"] }], + }, + }, + { + files: ["cli/**/*.{ts,tsx}"], + languageOptions: { + ecmaVersion: "latest", + sourceType: "module", + globals: { + ...globals.node, + }, + parser: parserTs, + parserOptions: { + ecmaFeatures: { + jsx: false, + }, + }, + }, + plugins: { + "@typescript-eslint": pluginTs, + }, + rules: { + ...pluginTs.configs.recommended.rules, + "no-unused-vars": "off", + "@typescript-eslint/no-unused-vars": ["error", { argsIgnorePattern: "^_" }], + "no-console": ["warn", { allow: ["log", "warn", "error"] }], + "react/no-unescaped-entities": "off", + }, + }, ]; diff --git a/lens-desktop-v5x-features.md b/lens-desktop-v5x-features.md new file mode 100644 index 00000000..554151ea --- /dev/null +++ b/lens-desktop-v5x-features.md @@ -0,0 +1,204 @@ +# Lens Desktop v5.x Feature Research Summary + +## Executive Summary + +This research compiles a comprehensive feature list for Lens Desktop v5.x (the last open source version before it went proprietary). Lens Desktop was acquired by Mirantis and transitioned from open source to proprietary/enterprise model. The features documented represent what was available in v5.x before the transition, with "Premium" features likely being core/open features in v5.x that later became enterprise-only. + +## Research Context + +- **Lens Desktop v5.x**: Last open source version before Mirantis acquisition +- **Current Status**: Transitioned to proprietary Lens K8S IDE with premium features +- **Key Differentiator**: First Kubernetes IDE with integrated AI assistant (Lens Prism) + +## Feature Categories + +### 1. UI Features and Components + +| Feature | Category | Premium | Description | +|---------|----------|---------|-------------| +| Navigator | UI | No | Sidebar navigation for cluster resources and management | +| Hotbar | UI | No | Quick access toolbar for common actions and commands | +| Terminal | UI | No | Built-in terminal for direct cluster interaction | +| Details panel | UI | No | Detailed view of selected Kubernetes resources | +| Applications view | UI | No | Visual representation of applications and components | +| Nodes view | UI | No | View and manage Kubernetes nodes with resource utilization | +| Lens K8S IDE layout | UI | No | Structured workspace layout for Kubernetes management | +| Preferences | UI | No | User preferences and settings | + +### 2. Workload Management Features + +| Feature | Category | Premium | Description | +|---------|----------|---------|-------------| +| Pods view | Workloads | No | View and manage pods with status, logs, and actions | +| Deployments view | Workloads | No | Manage deployments with scaling, updates, and rollouts | +| Daemon Sets view | Workloads | No | View and manage daemon sets across nodes | +| Stateful Sets view | Workloads | No | Manage stateful applications and persistent storage | +| Replica Sets view | Workloads | No | View and manage replica sets | +| Replication Controllers view | Workloads | No | Manage replication controllers | +| Jobs view | Workloads | No | View and manage batch jobs | +| Cron Jobs view | Workloads | No | Manage scheduled cron jobs | + +### 3. Config Management Features + +| Feature | Category | Premium | Description | +|---------|----------|---------|-------------| +| Config Maps view | Config | No | View and manage configuration maps | +| Secrets view | Config | No | Manage sensitive data and credentials | +| Resource Quotas view | Config | No | View resource quotas per namespace | +| Limit Ranges view | Config | No | Manage resource limits in namespaces | +| Horizontal Pod Autoscalers view | Config | No | View and manage HPAs | +| Vertical Pod Autoscalers view | Config | No | View and manage VPAs | +| Pod Disruption Budgets view | Config | No | Manage pod disruption budgets | +| Priority Classes view | Config | No | View and manage priority classes | +| Runtime Classes view | Config | No | Manage different container runtime configurations | +| Mutating Webhook Configs | Config | No | Mutating webhook configurations | +| Validating Webhook Configs | Config | No | Validating webhook configurations | +| Admission Policies | Config | No | Manage admission control policies | + +### 4. Network Features + +| Feature | Category | Premium | Description | +|---------|----------|---------|-------------| +| Services view | Network | No | View and manage Kubernetes services | +| Endpoints view | Network | No | View service endpoints | +| Endpoint Slices view | Network | No | Manage endpoint slices for large services | +| Gateway API resources | Network | No | Manage service mesh and gateway configurations | +| Ingresses view | Network | No | View and manage ingress resources | +| Ingress Classes view | Network | No | Manage ingress controller classes | +| Network Policies view | Network | No | View and manage network policies | +| Port Forwarding view | Network | No | Manage port forwarding rules | + +### 5. Storage Features + +| Feature | Category | Premium | Description | +|---------|----------|---------|-------------| +| Persistent Volume Claims view | Storage | No | View and manage PVCs | +| Persistent Volumes view | Storage | No | Manage persistent volumes | +| Storage Classes view | Storage | No | View and manage storage classes | + +### 6. Cluster Management Features + +| Feature | Category | Premium | Description | +|---------|----------|---------|-------------| +| Add AWS EKS clusters (One-Click) | Cluster | Yes | One-click integration for AWS EKS clusters | +| Add Azure AKS clusters (One-Click) | Cluster | Yes | One-click integration for Azure AKS clusters | +| Add Google GKE clusters | Cluster | No | Add Google Kubernetes Engine clusters | +| Add Red Hat OpenShift clusters | Cluster | No | Add OpenShift clusters | +| View cluster details | Cluster | No | Comprehensive cluster information and status | +| Cluster settings | Cluster | No | Configure cluster-specific settings | +| Enable cluster metrics | Cluster | No | Enable and view cluster metrics | +| Public cloud services | Cluster | No | Integration with public cloud providers | +| Create cluster resources | Cluster | No | Create resources directly from the UI | +| Cluster Performance | Cluster | No | Monitor cluster performance metrics | + +### 7. User Workflow Features + +| Feature | Category | Premium | Description | +|---------|----------|---------|-------------| +| Find a cluster | Workflow | No | Quick cluster discovery and selection | +| Find a deployment | Workflow | No | Quick deployment search | +| View logs | Workflow | No | Stream and view container logs | +| Open Pod Shell | Workflow | No | Interactive shell access to pods | +| Port forward traffic | Workflow | No | Port forwarding functionality | +| Modify a deployment | Workflow | No | Edit deployment configurations | +| Restart a deployment | Workflow | No | Restart deployments with zero downtime | +| Manage Helm charts | Workflow | No | Helm chart management and deployment | +| Use Command Palette | Workflow | No | Quick command access via command palette | +| Lens CLI | Workflow | No | Command-line interface for Lens operations | + +### 8. Premium Features (Enterprise-only post-v5.x) + +| Feature | Category | Description | +|---------|----------|-------------| +| Lens Prism | AI | Built-in AI assistant for Kubernetes exploration and troubleshooting | +| Lens Agents | AI | Platform for running AI agents on enterprise systems | +| Org-Wide AI Governance Rollout | Governance | Enterprise-wide AI governance deployment | +| EU AI Act Readiness | Compliance | Compliance features for EU AI Act requirements | +| Hardened Lens K8S IDE | Security | Enterprise-hardened version with feature control | +| Air-gapped mode | Deployment | Support for air-gapped environments | +| Offline activation mode | Licensing | Offline license activation | +| Lens Business ID | Identity | Enterprise account management with SSO/SCIM | +| Organizations, Teams & Projects | Governance | Enterprise organizational structure | +| Identity & Authentication | Security | Enterprise identity management | +| Audit Trail | Security | Comprehensive audit logging | +| Security Whitepaper | Security | Security documentation and compliance | +| Compliance | Security | Compliance management features | +| Privacy & PII Controls | Security | Personal data protection controls | +| Data Sovereignty | Security | Data sovereignty and location controls | + +### 9. Access Control Features + +| Feature | Category | Premium | Description | +|---------|----------|---------|-------------| +| Service Accounts view | Access Control | No | Service account management | +| Cluster Roles view | Access Control | No | Cluster role management | +| Roles view | Access Control | No | Role management within namespaces | +| Cluster Role Bindings view | Access Control | No | Cluster role binding management | +| Role Bindings view | Access Control | No | Role binding management | +| Pod Security Policies view | Access Control | No | Pod security policy management | + +### 10. Helm Features + +| Feature | Category | Premium | Description | +|---------|----------|---------|-------------| +| Charts view | Helm | No | Helm chart repository management | +| Releases view | Helm | No | Helm release management | + +### 11. Lens Teamwork Features + +| Feature | Category | Premium | Description | +|---------|----------|---------|-------------| +| Create a team space | Teamwork | No | Create collaborative team spaces | +| Add a cluster to a team space | Teamwork | No | Share clusters across team spaces | + +## Key Differentiators (What Made Lens Complete) + +1. **Built-in AI Assistant (Lens Prism)**: One of the first IDEs with integrated AI for Kubernetes exploration and troubleshooting + +2. **Enterprise AI Governance (Lens Agents)**: Unique platform for running and governing AI agents on enterprise systems + +3. **One-Click Cloud Integration**: Easy integration with major cloud providers (AWS, Azure, GKE) + +4. **Comprehensive Premium Security Features**: Enterprise-grade security, compliance, and governance capabilities + +5. **Full Kubernetes Resource Management**: Complete coverage of all Kubernetes resource types from workloads to access control + +6. **Integrated Terminal and Shell Access**: Direct cluster interaction without leaving the IDE + +7. **Advanced Workload Visualization**: Visual representation of applications and their relationships + +8. **AI Agent Execution with Sandbox Isolation**: Secure, isolated execution environment for AI agents + +9. **Agent-Hour Usage Tracking**: Unique metering system for AI agent operations + +10. **Enterprise Policy Controls**: Granular policy enforcement for enterprise environments + +## Comparison with Alternatives + +### vs k9s +- **Lens Advantage**: GUI with visual workload representation, integrated terminal, AI assistant, cloud integrations +- **k9s Advantage**: CLI-based (no GUI overhead), lighter weight, faster startup + +### vs Headlamp +- **Lens Advantage**: More mature UI, AI assistant, enterprise features, commercial support +- **Headlamp Advantage**: Open source, plugin architecture, lightweight + +## Conclusion + +Lens Desktop v5.x represented a comprehensive Kubernetes management GUI with features that rivaled or exceeded commercial tools of its time. The transition to proprietary model added enterprise features (AI governance, compliance, security) while some core features may have been repackaged as premium offerings. + +For building a similar tool, the key areas to focus on are: +1. Complete Kubernetes resource coverage +2. Integrated development environment features (terminal, shell access) +3. Visual workload representation and navigation +4. Cloud provider integrations +5. Enterprise security and compliance features +6. AI assistant capabilities (optional but differentiating) + +## Research Notes + +- The "v5.x" designation isn't explicitly mentioned in current documentation, but the transition point from open source to proprietary is clear +- Current Lens documentation shows premium features that were likely core features in v5.x +- Lens uses Electron framework for desktop application +- AI features (Lens Prism) were added post-v5.x as part of the proprietary transition +- One-Click AWS and Azure integrations were premium features, suggesting they may have been community plugins or missing in v5.x diff --git a/src-tauri/src/commands/kube.rs b/src-tauri/src/commands/kube.rs index ecbeab4f..8c40ef77 100644 --- a/src-tauri/src/commands/kube.rs +++ b/src-tauri/src/commands/kube.rs @@ -5,9 +5,11 @@ use crate::state::AppState; use lazy_static::lazy_static; use regex::Regex; use serde::{Deserialize, Serialize}; -use serde_yaml::Value; +use serde_json::Value; +use std::process::Stdio; use std::sync::Arc; use tauri::State; +use tokio::io::AsyncWriteExt; use tokio::process::Command; use tracing::info; @@ -59,6 +61,7 @@ pub struct PodInfo { pub status: String, pub ready: String, pub age: String, + pub containers: Vec, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -111,7 +114,7 @@ pub async fn add_cluster( } fn extract_context(content: &str) -> Result { - let value: Value = + let value: serde_yaml::Value = serde_yaml::from_str(content).map_err(|e| format!("Invalid kubeconfig YAML: {}", e))?; let contexts = value @@ -130,7 +133,7 @@ fn extract_context(content: &str) -> Result { } fn extract_server_url(content: &str) -> Result { - let value: Value = + let value: serde_yaml::Value = serde_yaml::from_str(content).map_err(|e| format!("Invalid kubeconfig YAML: {}", e))?; let clusters = value @@ -305,91 +308,6 @@ pub async fn discover_pods( Ok(pods) } -/// Parses the JSON output from `kubectl get pods -o json` -/// and extracts pod information including real status, ready state, and age. -fn parse_pods_json(json_str: &str) -> Result, String> { - let value: serde_json::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(|v| v.as_array()) - .ok_or("Missing 'items' array in kubectl JSON output")?; - - let mut pods = Vec::new(); - - for item in items { - let metadata = item - .get("metadata") - .ok_or("Missing 'metadata' in pod item")?; - let status = item.get("status").ok_or("Missing 'status' in pod item")?; - - let name = metadata - .get("name") - .and_then(|v| v.as_str()) - .unwrap_or("unknown") - .to_string(); - - let phase = status - .get("phase") - .and_then(|v| v.as_str()) - .unwrap_or("Unknown") - .to_string(); - - let mut ready = "N/A".to_string(); - let mut age = "N/A".to_string(); - - // Parse ready state from container statuses - if let Some(container_statuses) = status.get("containerStatuses").and_then(|v| v.as_array()) - { - let total = container_statuses.len(); - let ready_count = container_statuses - .iter() - .filter(|c| c.get("ready").and_then(|v| v.as_bool()).unwrap_or(false)) - .count(); - ready = format!("{}/{}", ready_count, total); - } - - // Parse age from creation timestamp - if let Some(creation_timestamp) = metadata.get("creationTimestamp").and_then(|v| v.as_str()) - { - age = parse_creation_timestamp(creation_timestamp); - } - - pods.push(PodInfo { - name, - status: phase, - ready, - age, - }); - } - - Ok(pods) -} - -/// Parses a Kubernetes creation timestamp and returns a human-readable age. -fn parse_creation_timestamp(timestamp: &str) -> String { - use chrono::{DateTime, Utc}; - - // Try parsing as RFC3339 format (e.g., "2024-01-15T10:30:00Z") - if let Ok(dt) = timestamp.parse::>() { - let elapsed = Utc::now() - dt; - let seconds = elapsed.num_seconds(); - - if seconds < 60 { - return format!("{}s", seconds); - } else if seconds < 3600 { - return format!("{}m", seconds / 60); - } else if seconds < 86400 { - return format!("{}h", seconds / 3600); - } else { - return format!("{}d", seconds / 86400); - } - } - - "N/A".to_string() -} - // Regex patterns for Kubernetes resource names // Must match: ^[a-z0-9]([a-z0-9.-]*[a-z0-9])?$ (DNS subdomain name) // Added max length check (253 chars) to prevent ReDoS attacks @@ -722,3 +640,3418 @@ pub async fn shutdown_port_forwards(state: State<'_, AppState>) -> Result<(), St Ok(()) } + +// ───────────────────────────────────────────────────────────────────────────── +// New Resource Discovery Commands (Phase 1) +// ───────────────────────────────────────────────────────────────────────────── + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct NamespaceInfo { + pub name: String, + pub status: String, + pub age: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ServicePort { + pub name: Option, + pub port: u16, + pub target_port: Option, + pub protocol: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ServiceInfo { + pub name: String, + pub namespace: String, + #[serde(rename = "type")] + pub service_type: String, + pub cluster_ip: String, + pub external_ip: Option, + pub ports: Vec, + pub age: String, + pub selector: std::collections::HashMap, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DeploymentInfo { + pub name: String, + pub namespace: String, + pub ready: String, + pub up_to_date: String, + pub available: String, + pub age: String, + pub replicas: i32, + pub labels: std::collections::HashMap, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct StatefulSetInfo { + pub name: String, + pub namespace: String, + pub ready: String, + pub age: String, + pub replicas: i32, + pub labels: std::collections::HashMap, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DaemonSetInfo { + pub name: String, + pub namespace: String, + pub desired: i32, + pub current: i32, + pub ready: i32, + pub up_to_date: i32, + pub available: i32, + pub age: String, + pub labels: std::collections::HashMap, +} + +#[tauri::command] +pub async fn list_namespaces( + 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-{}-namespaces.yaml", cluster_id)); + let _cleanup = TempFileCleanup(temp_path.clone()); + + std::fs::write(&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") + .env("KUBECONFIG", temp_path.to_string_lossy().to_string()) + .env("KUBERNETES_CONTEXT", context) + .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_json(&output_str) +} + +fn parse_namespaces_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 namespaces = 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()); + + namespaces.push(NamespaceInfo { name, status, age }); + } + + Ok(namespaces) +} + +#[tauri::command] +pub async fn list_pods( + 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-{}-pods.yaml", cluster_id)); + let _cleanup = TempFileCleanup(temp_path.clone()); + + std::fs::write(&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("pods"); + if namespace.is_empty() { + kubectl_cmd.arg("--all-namespaces"); + } else { + kubectl_cmd.arg("-n").arg(&namespace); + } + let output = kubectl_cmd + .arg("-o") + .arg("json") + .env("KUBECONFIG", temp_path.to_string_lossy().to_string()) + .env("KUBERNETES_CONTEXT", context) + .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_pods_json(&output_str) +} + +fn parse_pods_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 pods = 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 ready = item + .get("status") + .and_then(|s| s.get("containerStatuses")) + .and_then(|c| c.as_array()) + .map(|container_statuses| { + let ready_count = container_statuses + .iter() + .filter(|c| c.get("ready").and_then(|r| r.as_bool()).unwrap_or(false)) + .count(); + let total_count = container_statuses.len(); + format!("{}/{}", ready_count, total_count) + }) + .unwrap_or("0/0".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()); + + let containers = item + .get("spec") + .and_then(|s| s.get("containers")) + .and_then(|c| c.as_array()) + .map(|spec_containers| { + spec_containers + .iter() + .filter_map(|c| { + c.get("name") + .and_then(|n| n.as_str()) + .map(|s| s.to_string()) + }) + .collect() + }) + .unwrap_or_default(); + + pods.push(PodInfo { + name, + status, + ready, + age, + containers, + }); + } + + Ok(pods) +} + +#[tauri::command] +pub async fn list_services( + 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-{}-services.yaml", cluster_id)); + let _cleanup = TempFileCleanup(temp_path.clone()); + + std::fs::write(&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("services"); + if namespace.is_empty() { + kubectl_cmd.arg("--all-namespaces"); + } else { + kubectl_cmd.arg("-n").arg(&namespace); + } + let output = kubectl_cmd + .arg("-o") + .arg("json") + .env("KUBECONFIG", temp_path.to_string_lossy().to_string()) + .env("KUBERNETES_CONTEXT", context) + .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_services_json(&output_str) +} + +fn parse_services_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 services = 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 service_type = item + .get("spec") + .and_then(|s| s.get("type")) + .and_then(|t| t.as_str()) + .unwrap_or("ClusterIP") + .to_string(); + + let cluster_ip = item + .get("spec") + .and_then(|s| s.get("clusterIP")) + .and_then(|c| c.as_str()) + .unwrap_or("None") + .to_string(); + + let external_ip = item + .get("status") + .and_then(|s| s.get("loadBalancer")) + .and_then(|l| l.get("ingress")) + .and_then(|i| i.as_array()) + .and_then(|ingress| ingress.first()) + .and_then(|ing| ing.get("ip")) + .and_then(|ip| ip.as_str()) + .map(|s| s.to_string()); + + let ports = item + .get("spec") + .and_then(|s| s.get("ports")) + .and_then(|p| p.as_array()) + .map(|ports_seq| { + ports_seq + .iter() + .map(|p| ServicePort { + name: p + .get("name") + .and_then(|n| n.as_str()) + .map(|s| s.to_string()), + port: p.get("port").and_then(|p| p.as_u64()).unwrap_or(0) as u16, + target_port: p + .get("targetPort") + .and_then(|tp| tp.as_str()) + .map(|s| s.to_string()), + protocol: p + .get("protocol") + .and_then(|p| p.as_str()) + .unwrap_or("TCP") + .to_string(), + }) + .collect() + }) + .unwrap_or_default(); + + 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 selector = item + .get("spec") + .and_then(|s| s.get("selector")) + .and_then(|s| s.as_object()) + .map(|s| { + s.iter() + .map(|(k, v)| (k.clone(), v.as_str().unwrap_or("").to_string())) + .collect() + }) + .unwrap_or_default(); + + services.push(ServiceInfo { + name, + namespace, + service_type, + cluster_ip, + external_ip, + ports, + age, + selector, + }); + } + + Ok(services) +} + +#[tauri::command] +pub async fn list_deployments( + 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-{}-deployments.yaml", cluster_id)); + let _cleanup = TempFileCleanup(temp_path.clone()); + + std::fs::write(&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("deployments"); + if namespace.is_empty() { + kubectl_cmd.arg("--all-namespaces"); + } else { + kubectl_cmd.arg("-n").arg(&namespace); + } + let output = kubectl_cmd + .arg("-o") + .arg("json") + .env("KUBECONFIG", temp_path.to_string_lossy().to_string()) + .env("KUBERNETES_CONTEXT", context) + .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_deployments_json(&output_str) +} + +fn parse_deployments_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 deployments = 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 up_to_date = item + .get("status") + .and_then(|s| s.get("updatedReplicas")) + .and_then(|r| r.as_i64()) + .map(|r| r.to_string()) + .unwrap_or_else(|| "N/A".to_string()); + + let available = item + .get("status") + .and_then(|s| s.get("availableReplicas")) + .and_then(|r| r.as_i64()) + .map(|r| r.to_string()) + .unwrap_or_else(|| "N/A".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()); + + 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(); + + deployments.push(DeploymentInfo { + name, + namespace, + ready, + up_to_date, + available, + age, + replicas, + labels, + }); + } + + Ok(deployments) +} + +#[tauri::command] +pub async fn list_statefulsets( + 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-{}-statefulsets.yaml", cluster_id)); + let _cleanup = TempFileCleanup(temp_path.clone()); + + std::fs::write(&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("statefulsets"); + if namespace.is_empty() { + kubectl_cmd.arg("--all-namespaces"); + } else { + kubectl_cmd.arg("-n").arg(&namespace); + } + let output = kubectl_cmd + .arg("-o") + .arg("json") + .env("KUBECONFIG", temp_path.to_string_lossy().to_string()) + .env("KUBERNETES_CONTEXT", context) + .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_statefulsets_json(&output_str) +} + +fn parse_statefulsets_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 statefulsets = 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(); + + statefulsets.push(StatefulSetInfo { + name, + namespace, + ready, + age, + replicas, + labels, + }); + } + + Ok(statefulsets) +} + +#[tauri::command] +pub async fn list_daemonsets( + 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-{}-daemonsets.yaml", cluster_id)); + let _cleanup = TempFileCleanup(temp_path.clone()); + + std::fs::write(&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("daemonsets"); + if namespace.is_empty() { + kubectl_cmd.arg("--all-namespaces"); + } else { + kubectl_cmd.arg("-n").arg(&namespace); + } + let output = kubectl_cmd + .arg("-o") + .arg("json") + .env("KUBECONFIG", temp_path.to_string_lossy().to_string()) + .env("KUBERNETES_CONTEXT", context) + .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_daemonsets_json(&output_str) +} + +fn parse_daemonsets_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 daemonsets = 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 desired = item + .get("status") + .and_then(|s| s.get("desiredNumberScheduled")) + .and_then(|r| r.as_i64()) + .unwrap_or(0) as i32; + + let current = item + .get("status") + .and_then(|s| s.get("currentNumberScheduled")) + .and_then(|r| r.as_i64()) + .unwrap_or(0) as i32; + + let ready = item + .get("status") + .and_then(|s| s.get("numberReady")) + .and_then(|r| r.as_i64()) + .unwrap_or(0) as i32; + + let up_to_date = item + .get("status") + .and_then(|s| s.get("updatedNumberScheduled")) + .and_then(|r| r.as_i64()) + .unwrap_or(0) as i32; + + let available = item + .get("status") + .and_then(|s| s.get("numberAvailable")) + .and_then(|r| r.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()); + + 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(); + + daemonsets.push(DaemonSetInfo { + name, + namespace, + desired, + current, + ready, + up_to_date, + available, + age, + labels, + }); + } + + Ok(daemonsets) +} + +fn parse_creation_timestamp(timestamp: &str) -> String { + if timestamp.is_empty() || timestamp == "null" { + return "N/A".to_string(); + } + + if let Ok(dt) = chrono::DateTime::parse_from_rfc3339(timestamp) { + let now = chrono::Utc::now(); + let diff = now.signed_duration_since(dt); + + if diff.num_days() > 0 { + return format!("{}d", diff.num_days()); + } + if diff.num_hours() > 0 { + return format!("{}h", diff.num_hours()); + } + if diff.num_minutes() > 0 { + return format!("{}m", diff.num_minutes()); + } + return format!("{}s", diff.num_seconds()); + } + + "N/A".to_string() +} + +// ───────────────────────────────────────────────────────────────────────────── +// Resource Management Commands (Phase 2) +// ───────────────────────────────────────────────────────────────────────────── + +#[tauri::command] +pub async fn get_pod_logs( + cluster_id: String, + namespace: String, + pod_name: String, + container_name: String, + state: State<'_, AppState>, +) -> Result { + 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-{}-logs.yaml", cluster_id)); + let _cleanup = TempFileCleanup(temp_path.clone()); + + std::fs::write(&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("logs") + .arg(pod_name) + .arg("-n") + .arg(namespace) + .arg("-c") + .arg(container_name) + .env("KUBECONFIG", temp_path.to_string_lossy().to_string()) + .env("KUBERNETES_CONTEXT", context) + .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 logs = String::from_utf8_lossy(&output.stdout).to_string(); + + Ok(LogResponse { logs }) +} + +#[tauri::command] +pub async fn scale_deployment( + cluster_id: String, + namespace: String, + deployment_name: String, + replicas: i32, + 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-{}-scale.yaml", cluster_id)); + let _cleanup = TempFileCleanup(temp_path.clone()); + + std::fs::write(&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("deployment") + .arg(deployment_name) + .arg("--replicas") + .arg(replicas.to_string()) + .arg("-n") + .arg(namespace) + .env("KUBECONFIG", temp_path.to_string_lossy().to_string()) + .env("KUBERNETES_CONTEXT", context) + .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_deployment( + cluster_id: String, + namespace: String, + deployment_name: 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-{}-restart.yaml", cluster_id)); + let _cleanup = TempFileCleanup(temp_path.clone()); + + std::fs::write(&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("deployment") + .arg(deployment_name) + .arg("-n") + .arg(namespace) + .env("KUBECONFIG", temp_path.to_string_lossy().to_string()) + .env("KUBERNETES_CONTEXT", context) + .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_resource( + cluster_id: String, + resource_type: String, + namespace: String, + resource_name: 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-{}-delete.yaml", cluster_id)); + let _cleanup = TempFileCleanup(temp_path.clone()); + + std::fs::write(&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("delete") + .arg(resource_type) + .arg(resource_name) + .arg("-n") + .arg(namespace) + .env("KUBECONFIG", temp_path.to_string_lossy().to_string()) + .env("KUBERNETES_CONTEXT", context) + .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 exec_pod( + cluster_id: String, + namespace: String, + pod_name: String, + container_name: Option, + shell: Option, + command: String, + state: State<'_, AppState>, +) -> Result { + 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-{}-exec.yaml", cluster_id)); + let _cleanup = TempFileCleanup(temp_path.clone()); + + std::fs::write(&temp_path, kubeconfig_content) + .map_err(|e| format!("Failed to write kubeconfig temp file: {e}"))?; + + let kubectl_path = locate_kubectl()?; + + const ALLOWED_SHELLS: &[&str] = &[ + "sh", + "bash", + "ash", + "dash", + "/bin/sh", + "/bin/bash", + "/bin/ash", + "/bin/dash", + ]; + let shell_cmd = shell.as_deref().unwrap_or("sh"); + if !ALLOWED_SHELLS.contains(&shell_cmd) { + return Err(format!( + "Unsupported shell '{}'; allowed: sh, bash, ash, dash", + shell_cmd + )); + } + + let mut cmd = Command::new(kubectl_path); + cmd.arg("exec").arg(&pod_name).arg("-n").arg(&namespace); + + if let Some(ref container) = container_name { + cmd.arg("-c").arg(container); + } + + cmd.arg("--").arg(shell_cmd).arg("-c").arg(&command); + + cmd.env("KUBECONFIG", temp_path.to_string_lossy().to_string()) + .env("KUBERNETES_CONTEXT", context); + + let output = cmd + .output() + .await + .map_err(|e| format!("Failed to execute kubectl: {e}"))?; + + let stdout = String::from_utf8_lossy(&output.stdout).to_string(); + let stderr = String::from_utf8_lossy(&output.stderr).to_string(); + + Ok(ExecResponse { + stdout, + stderr, + exit_code: output.status.code(), + }) +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LogResponse { + pub logs: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ExecResponse { + pub stdout: String, + pub stderr: String, + pub exit_code: Option, +} + +// ───────────────────────────────────────────────────────────────────────────── +// Additional Resource Discovery Commands (Phase 3) +// ───────────────────────────────────────────────────────────────────────────── + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ReplicaSetInfo { + pub name: String, + pub namespace: String, + pub replicas: i32, + pub ready: String, + pub age: String, + pub labels: std::collections::HashMap, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct JobInfo { + pub name: String, + pub namespace: String, + pub completions: String, + pub duration: String, + pub age: String, + pub labels: std::collections::HashMap, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CronJobInfo { + pub name: String, + pub namespace: String, + pub schedule: String, + pub active: i32, + pub last_schedule: String, + pub age: String, + pub labels: std::collections::HashMap, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ConfigMapInfo { + pub name: String, + pub namespace: String, + pub data_keys: i32, + pub age: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SecretInfo { + pub name: String, + pub namespace: String, + #[serde(rename = "type")] + pub secret_type: String, + pub data_keys: i32, + pub age: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct NodeInfo { + pub name: String, + pub status: String, + pub roles: String, + pub version: String, + pub internal_ip: String, + pub external_ip: Option, + pub os_image: String, + pub kernel_version: String, + pub kubelet_version: String, + pub age: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct EventInfo { + pub name: String, + pub namespace: String, + pub event_type: String, + pub reason: String, + pub object: String, + pub count: i32, + pub first_seen: String, + pub last_seen: String, + pub message: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct IngressInfo { + pub name: String, + pub namespace: String, + pub class: Option, + pub host: String, + pub addresses: Vec, + pub age: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PersistentVolumeClaimInfo { + pub name: String, + pub namespace: String, + pub status: String, + pub volume: String, + pub capacity: String, + pub access_modes: Vec, + pub age: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PersistentVolumeInfo { + pub name: String, + pub status: String, + pub capacity: String, + pub access_modes: Vec, + pub reclaim_policy: String, + pub storage_class: String, + pub age: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ServiceAccountInfo { + pub name: String, + pub namespace: String, + pub secrets: i32, + pub age: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RoleInfo { + pub name: String, + pub namespace: String, + pub age: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ClusterRoleInfo { + pub name: String, + pub age: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RoleBindingInfo { + pub name: String, + pub namespace: String, + pub role: String, + pub age: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ClusterRoleBindingInfo { + pub name: String, + pub cluster_role: String, + pub age: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct HorizontalPodAutoscalerInfo { + pub name: String, + pub namespace: String, + pub min_replicas: i32, + pub max_replicas: i32, + pub current_replicas: i32, + pub desired_replicas: i32, + pub age: String, +} + +#[tauri::command] +pub async fn list_replicasets( + 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-{}-replicasets.yaml", cluster_id)); + let _cleanup = TempFileCleanup(temp_path.clone()); + + std::fs::write(&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("replicasets"); + if namespace.is_empty() { + kubectl_cmd.arg("--all-namespaces"); + } else { + kubectl_cmd.arg("-n").arg(&namespace); + } + let output = kubectl_cmd + .arg("-o") + .arg("json") + .env("KUBECONFIG", temp_path.to_string_lossy().to_string()) + .env("KUBERNETES_CONTEXT", context) + .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_replicasets_json(&output_str) +} + +fn parse_replicasets_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 replicasets = 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(); + + replicasets.push(ReplicaSetInfo { + name, + namespace, + replicas, + ready, + age, + labels, + }); + } + + Ok(replicasets) +} + +#[tauri::command] +pub async fn list_jobs( + 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-{}-jobs.yaml", cluster_id)); + let _cleanup = TempFileCleanup(temp_path.clone()); + + std::fs::write(&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("jobs"); + if namespace.is_empty() { + kubectl_cmd.arg("--all-namespaces"); + } else { + kubectl_cmd.arg("-n").arg(&namespace); + } + let output = kubectl_cmd + .arg("-o") + .arg("json") + .env("KUBECONFIG", temp_path.to_string_lossy().to_string()) + .env("KUBERNETES_CONTEXT", context) + .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_jobs_json(&output_str) +} + +fn parse_jobs_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 jobs = 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 completions = item + .get("status") + .and_then(|s| s.get("succeeded")) + .and_then(|s| s.as_i64()) + .map(|s| { + let total = item + .get("spec") + .and_then(|sp| sp.get("completions")) + .and_then(|c| c.as_i64()) + .unwrap_or(1); + format!("{}/{}", s, total) + }) + .unwrap_or_else(|| "0/0".to_string()); + + let duration = item + .get("status") + .and_then(|s| s.get("startTime")) + .and_then(|st| st.as_str()) + .and_then(|st| { + let completion_time = item + .get("status") + .and_then(|s| s.get("completionTime")) + .and_then(|ct| ct.as_str()); + completion_time.or(Some(st)) + }) + .map(|st| { + if let Ok(start) = chrono::DateTime::parse_from_rfc3339(st) { + let end_time = item + .get("status") + .and_then(|s| s.get("completionTime")) + .and_then(|ct| ct.as_str()); + if let Some(end) = end_time { + if let Ok(end_dt) = chrono::DateTime::parse_from_rfc3339(end) { + let diff = end_dt.signed_duration_since(start); + if diff.num_minutes() > 0 { + return format!("{}m", diff.num_minutes()); + } + return format!("{}s", diff.num_seconds()); + } + } + let now = chrono::Utc::now(); + let diff = now.signed_duration_since(start); + if diff.num_minutes() > 0 { + return format!("{}m", diff.num_minutes()); + } + return format!("{}s", diff.num_seconds()); + } + "N/A".to_string() + }) + .unwrap_or("N/A".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()); + + 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(); + + jobs.push(JobInfo { + name, + namespace, + completions, + duration, + age, + labels, + }); + } + + Ok(jobs) +} + +#[tauri::command] +pub async fn list_cronjobs( + 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-{}-cronjobs.yaml", cluster_id)); + let _cleanup = TempFileCleanup(temp_path.clone()); + + std::fs::write(&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("cronjobs"); + if namespace.is_empty() { + kubectl_cmd.arg("--all-namespaces"); + } else { + kubectl_cmd.arg("-n").arg(&namespace); + } + let output = kubectl_cmd + .arg("-o") + .arg("json") + .env("KUBECONFIG", temp_path.to_string_lossy().to_string()) + .env("KUBERNETES_CONTEXT", context) + .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_cronjobs_json(&output_str) +} + +fn parse_cronjobs_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 cronjobs = 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 schedule = item + .get("spec") + .and_then(|s| s.get("schedule")) + .and_then(|s| s.as_str()) + .unwrap_or("* * * * *") + .to_string(); + + let active = item + .get("status") + .and_then(|s| s.get("active")) + .and_then(|a| a.as_array()) + .map(|a| a.len() as i32) + .unwrap_or(0); + + let last_schedule = item + .get("status") + .and_then(|s| s.get("lastScheduleTime")) + .and_then(|l| l.as_str()) + .map(parse_creation_timestamp) + .unwrap_or("N/A".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()); + + 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(); + + cronjobs.push(CronJobInfo { + name, + namespace, + schedule, + active, + last_schedule, + age, + labels, + }); + } + + Ok(cronjobs) +} + +#[tauri::command] +pub async fn list_configmaps( + 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-{}-configmaps.yaml", cluster_id)); + let _cleanup = TempFileCleanup(temp_path.clone()); + + std::fs::write(&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("configmaps"); + if namespace.is_empty() { + kubectl_cmd.arg("--all-namespaces"); + } else { + kubectl_cmd.arg("-n").arg(&namespace); + } + let output = kubectl_cmd + .arg("-o") + .arg("json") + .env("KUBECONFIG", temp_path.to_string_lossy().to_string()) + .env("KUBERNETES_CONTEXT", context) + .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_configmaps_json(&output_str) +} + +fn parse_configmaps_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 configmaps = 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 data_keys = item + .get("data") + .and_then(|d| d.as_object()) + .map(|d| d.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()); + + configmaps.push(ConfigMapInfo { + name, + namespace, + data_keys, + age, + }); + } + + Ok(configmaps) +} + +#[tauri::command] +pub async fn list_secrets( + 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-{}-secrets.yaml", cluster_id)); + let _cleanup = TempFileCleanup(temp_path.clone()); + + std::fs::write(&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("secrets"); + if namespace.is_empty() { + kubectl_cmd.arg("--all-namespaces"); + } else { + kubectl_cmd.arg("-n").arg(&namespace); + } + let output = kubectl_cmd + .arg("-o") + .arg("json") + .env("KUBECONFIG", temp_path.to_string_lossy().to_string()) + .env("KUBERNETES_CONTEXT", context) + .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_secrets_json(&output_str) +} + +fn parse_secrets_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 secrets = 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 secret_type = item + .get("type") + .and_then(|t| t.as_str()) + .unwrap_or("Opaque") + .to_string(); + + let data_keys = item + .get("data") + .and_then(|d| d.as_object()) + .map(|d| d.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()); + + secrets.push(SecretInfo { + name, + namespace, + secret_type, + data_keys, + age, + }); + } + + Ok(secrets) +} + +#[tauri::command] +pub async fn list_nodes( + 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-{}-nodes.yaml", cluster_id)); + let _cleanup = TempFileCleanup(temp_path.clone()); + + std::fs::write(&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("nodes") + .arg("-o") + .arg("json") + .env("KUBECONFIG", temp_path.to_string_lossy().to_string()) + .env("KUBERNETES_CONTEXT", context) + .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_nodes_json(&output_str) +} + +fn parse_nodes_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 nodes = 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("conditions")) + .and_then(|c| c.as_array()) + .and_then(|conditions| { + conditions + .iter() + .find(|c| c.get("type").and_then(|t| t.as_str()) == Some("Ready")) + }) + .and_then(|c| c.get("status").and_then(|s| s.as_str())) + .map(|s| match s { + "True" => "Ready", + "False" => "NotReady", + _ => "Unknown", + }) + .unwrap_or("Unknown") + .to_string(); + + let roles = item + .get("metadata") + .and_then(|m| m.get("labels")) + .and_then(|l| l.as_object()) + .map(|l| { + let mut role_list: Vec = Vec::new(); + if l.contains_key("node-role.kubernetes.io/control-plane") + || l.contains_key("node-role.kubernetes.io/master") + { + role_list.push("control-plane".to_string()); + } + if l.contains_key("node-role.kubernetes.io/worker") { + role_list.push("worker".to_string()); + } + if l.contains_key("node-role.kubernetes.io/etcd") { + role_list.push("etcd".to_string()); + } + if l.contains_key("node-role.kubernetes.io/ingress") { + role_list.push("ingress".to_string()); + } + if role_list.is_empty() { + role_list.push("none".to_string()); + } + role_list.join(",") + }) + .unwrap_or("none".to_string()); + + let version = item + .get("status") + .and_then(|s| s.get("nodeInfo")) + .and_then(|n| n.get("kubeletVersion")) + .and_then(|v| v.as_str()) + .unwrap_or("N/A") + .to_string(); + + let internal_ip = item + .get("status") + .and_then(|s| s.get("addresses")) + .and_then(|a| a.as_array()) + .and_then(|addresses| { + addresses + .iter() + .find(|addr| addr.get("type").and_then(|t| t.as_str()) == Some("InternalIP")) + }) + .and_then(|addr| addr.get("address").and_then(|a| a.as_str())) + .unwrap_or("N/A") + .to_string(); + + let external_ip = item + .get("status") + .and_then(|s| s.get("addresses")) + .and_then(|a| a.as_array()) + .and_then(|addresses| { + addresses + .iter() + .find(|addr| addr.get("type").and_then(|t| t.as_str()) == Some("ExternalIP")) + }) + .and_then(|addr| addr.get("address").and_then(|a| a.as_str())) + .map(|s| s.to_string()); + + let os_image = item + .get("status") + .and_then(|s| s.get("nodeInfo")) + .and_then(|n| n.get("osImage")) + .and_then(|o| o.as_str()) + .unwrap_or("N/A") + .to_string(); + + let kernel_version = item + .get("status") + .and_then(|s| s.get("nodeInfo")) + .and_then(|n| n.get("kernelVersion")) + .and_then(|k| k.as_str()) + .unwrap_or("N/A") + .to_string(); + + let kubelet_version = item + .get("status") + .and_then(|s| s.get("nodeInfo")) + .and_then(|n| n.get("kubeletVersion")) + .and_then(|k| k.as_str()) + .unwrap_or("N/A") + .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()); + + nodes.push(NodeInfo { + name, + status, + roles, + version, + internal_ip, + external_ip, + os_image, + kernel_version, + kubelet_version, + age, + }); + } + + Ok(nodes) +} + +#[tauri::command] +pub async fn list_events( + cluster_id: String, + namespace: Option, + 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-{}-events.yaml", cluster_id)); + let _cleanup = TempFileCleanup(temp_path.clone()); + + std::fs::write(&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("events"); + if let Some(ns) = &namespace { + kubectl_cmd.arg("-n").arg(ns); + } else { + kubectl_cmd.arg("--all-namespaces"); + } + let output = kubectl_cmd + .arg("-o") + .arg("json") + .env("KUBECONFIG", temp_path.to_string_lossy().to_string()) + .env("KUBERNETES_CONTEXT", context) + .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_events_json(&output_str) +} + +fn parse_events_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 events = 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 event_type = item + .get("type") + .and_then(|t| t.as_str()) + .unwrap_or("Normal") + .to_string(); + + let reason = item + .get("reason") + .and_then(|r| r.as_str()) + .unwrap_or("Unknown") + .to_string(); + + let object = item + .get("involvedObject") + .and_then(|o| o.get("name")) + .and_then(|n| n.as_str()) + .unwrap_or("unknown") + .to_string(); + + let count = item.get("count").and_then(|c| c.as_i64()).unwrap_or(1) as i32; + + let first_seen = item + .get("firstTimestamp") + .and_then(|f| f.as_str()) + .map(parse_creation_timestamp) + .unwrap_or("N/A".to_string()); + + let last_seen = item + .get("lastTimestamp") + .and_then(|l| l.as_str()) + .map(parse_creation_timestamp) + .unwrap_or("N/A".to_string()); + + let message = item + .get("message") + .and_then(|m| m.as_str()) + .unwrap_or("") + .to_string(); + + events.push(EventInfo { + name, + namespace, + event_type, + reason, + object, + count, + first_seen, + last_seen, + message, + }); + } + + Ok(events) +} + +#[tauri::command] +pub async fn list_ingresses( + 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-{}-ingresses.yaml", cluster_id)); + let _cleanup = TempFileCleanup(temp_path.clone()); + + std::fs::write(&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("ingresses"); + if namespace.is_empty() { + kubectl_cmd.arg("--all-namespaces"); + } else { + kubectl_cmd.arg("-n").arg(&namespace); + } + let output = kubectl_cmd + .arg("-o") + .arg("json") + .env("KUBECONFIG", temp_path.to_string_lossy().to_string()) + .env("KUBERNETES_CONTEXT", context) + .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_ingresses_json(&output_str) +} + +fn parse_ingresses_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 ingresses = 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 class = item + .get("spec") + .and_then(|s| s.get("ingressClassName")) + .and_then(|c| c.as_str()) + .map(|s| s.to_string()); + + let host = item + .get("spec") + .and_then(|s| s.get("rules")) + .and_then(|r| r.as_array()) + .and_then(|rules| rules.first()) + .and_then(|rule| rule.get("host").and_then(|h| h.as_str())) + .unwrap_or("") + .to_string(); + + let addresses = item + .get("status") + .and_then(|s| s.get("loadBalancer")) + .and_then(|l| l.get("ingress")) + .and_then(|i| i.as_array()) + .map(|ingress| { + ingress + .iter() + .filter_map(|ing| { + ing.get("ip") + .and_then(|ip| ip.as_str()) + .map(|s| s.to_string()) + }) + .collect() + }) + .unwrap_or_default(); + + 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()); + + ingresses.push(IngressInfo { + name, + namespace, + class, + host, + addresses, + age, + }); + } + + Ok(ingresses) +} + +#[tauri::command] +pub async fn list_persistentvolumeclaims( + 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-{}-pvcs.yaml", cluster_id)); + let _cleanup = TempFileCleanup(temp_path.clone()); + + std::fs::write(&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("persistentvolumeclaims"); + if namespace.is_empty() { + kubectl_cmd.arg("--all-namespaces"); + } else { + kubectl_cmd.arg("-n").arg(&namespace); + } + let output = kubectl_cmd + .arg("-o") + .arg("json") + .env("KUBECONFIG", temp_path.to_string_lossy().to_string()) + .env("KUBERNETES_CONTEXT", context) + .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_pvcs_json(&output_str) +} + +fn parse_pvcs_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 pvcs = 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 status = item + .get("status") + .and_then(|s| s.get("phase")) + .and_then(|p| p.as_str()) + .unwrap_or("Unknown") + .to_string(); + + let volume = item + .get("spec") + .and_then(|s| s.get("volumeName")) + .and_then(|v| v.as_str()) + .unwrap_or("N/A") + .to_string(); + + let capacity = item + .get("status") + .and_then(|s| s.get("capacity")) + .and_then(|c| c.as_object()) + .map(|c| { + let storage = c.get("storage").and_then(|s| s.as_str()).unwrap_or("N/A"); + storage.to_string() + }) + .unwrap_or("N/A".to_string()); + + let access_modes = item + .get("spec") + .and_then(|s| s.get("accessModes")) + .and_then(|a| a.as_array()) + .map(|modes| { + modes + .iter() + .filter_map(|m| m.as_str().map(|s| s.to_string())) + .collect() + }) + .unwrap_or_default(); + + 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()); + + pvcs.push(PersistentVolumeClaimInfo { + name, + namespace, + status, + volume, + capacity, + access_modes, + age, + }); + } + + Ok(pvcs) +} + +#[tauri::command] +pub async fn list_persistentvolumes( + 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-{}-pvs.yaml", cluster_id)); + let _cleanup = TempFileCleanup(temp_path.clone()); + + std::fs::write(&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("persistentvolumes") + .arg("-o") + .arg("json") + .env("KUBECONFIG", temp_path.to_string_lossy().to_string()) + .env("KUBERNETES_CONTEXT", context) + .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_pvs_json(&output_str) +} + +fn parse_pvs_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 pvs = 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 capacity = item + .get("spec") + .and_then(|s| s.get("capacity")) + .and_then(|c| c.as_object()) + .map(|c| { + let storage = c.get("storage").and_then(|s| s.as_str()).unwrap_or("N/A"); + storage.to_string() + }) + .unwrap_or("N/A".to_string()); + + let access_modes = item + .get("spec") + .and_then(|s| s.get("accessModes")) + .and_then(|a| a.as_array()) + .map(|modes| { + modes + .iter() + .filter_map(|m| m.as_str().map(|s| s.to_string())) + .collect() + }) + .unwrap_or_default(); + + let reclaim_policy = item + .get("spec") + .and_then(|s| s.get("persistentVolumeReclaimPolicy")) + .and_then(|r| r.as_str()) + .unwrap_or("Retain") + .to_string(); + + let storage_class = item + .get("spec") + .and_then(|s| s.get("storageClassName")) + .and_then(|s| s.as_str()) + .unwrap_or("N/A") + .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()); + + pvs.push(PersistentVolumeInfo { + name, + status, + capacity, + access_modes, + reclaim_policy, + storage_class, + age, + }); + } + + Ok(pvs) +} + +#[tauri::command] +pub async fn list_serviceaccounts( + 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-{}-sas.yaml", cluster_id)); + let _cleanup = TempFileCleanup(temp_path.clone()); + + std::fs::write(&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("serviceaccounts"); + if namespace.is_empty() { + kubectl_cmd.arg("--all-namespaces"); + } else { + kubectl_cmd.arg("-n").arg(&namespace); + } + let output = kubectl_cmd + .arg("-o") + .arg("json") + .env("KUBECONFIG", temp_path.to_string_lossy().to_string()) + .env("KUBERNETES_CONTEXT", context) + .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_serviceaccounts_json(&output_str) +} + +fn parse_serviceaccounts_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 serviceaccounts = 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 secrets = item + .get("secrets") + .and_then(|s| s.as_array()) + .map(|s| s.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()); + + serviceaccounts.push(ServiceAccountInfo { + name, + namespace, + secrets, + age, + }); + } + + Ok(serviceaccounts) +} + +#[tauri::command] +pub async fn list_roles( + 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-{}-roles.yaml", cluster_id)); + let _cleanup = TempFileCleanup(temp_path.clone()); + + std::fs::write(&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("roles"); + if namespace.is_empty() { + kubectl_cmd.arg("--all-namespaces"); + } else { + kubectl_cmd.arg("-n").arg(&namespace); + } + let output = kubectl_cmd + .arg("-o") + .arg("json") + .env("KUBECONFIG", temp_path.to_string_lossy().to_string()) + .env("KUBERNETES_CONTEXT", context) + .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_roles_json(&output_str) +} + +fn parse_roles_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 roles = 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 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()); + + roles.push(RoleInfo { + name, + namespace, + age, + }); + } + + Ok(roles) +} + +#[tauri::command] +pub async fn list_clusterroles( + 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-{}-clusterroles.yaml", cluster_id)); + let _cleanup = TempFileCleanup(temp_path.clone()); + + std::fs::write(&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("clusterroles") + .arg("-o") + .arg("json") + .env("KUBECONFIG", temp_path.to_string_lossy().to_string()) + .env("KUBERNETES_CONTEXT", context) + .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_clusterroles_json(&output_str) +} + +fn parse_clusterroles_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 clusterroles = 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 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()); + + clusterroles.push(ClusterRoleInfo { name, age }); + } + + Ok(clusterroles) +} + +#[tauri::command] +pub async fn list_rolebindings( + 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-{}-rolebindings.yaml", cluster_id)); + let _cleanup = TempFileCleanup(temp_path.clone()); + + std::fs::write(&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("rolebindings"); + if namespace.is_empty() { + kubectl_cmd.arg("--all-namespaces"); + } else { + kubectl_cmd.arg("-n").arg(&namespace); + } + let output = kubectl_cmd + .arg("-o") + .arg("json") + .env("KUBECONFIG", temp_path.to_string_lossy().to_string()) + .env("KUBERNETES_CONTEXT", context) + .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_rolebindings_json(&output_str) +} + +fn parse_rolebindings_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 rolebindings = 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 role = item + .get("roleRef") + .and_then(|r| r.get("name")) + .and_then(|n| n.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()); + + rolebindings.push(RoleBindingInfo { + name, + namespace, + role, + age, + }); + } + + Ok(rolebindings) +} + +#[tauri::command] +pub async fn list_clusterrolebindings( + 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-{}-clusterrolebindings.yaml", + cluster_id + )); + let _cleanup = TempFileCleanup(temp_path.clone()); + + std::fs::write(&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("clusterrolebindings") + .arg("-o") + .arg("json") + .env("KUBECONFIG", temp_path.to_string_lossy().to_string()) + .env("KUBERNETES_CONTEXT", context) + .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_clusterrolebindings_json(&output_str) +} + +fn parse_clusterrolebindings_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 clusterrolebindings = 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 cluster_role = item + .get("roleRef") + .and_then(|r| r.get("name")) + .and_then(|n| n.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()); + + clusterrolebindings.push(ClusterRoleBindingInfo { + name, + cluster_role, + age, + }); + } + + Ok(clusterrolebindings) +} + +#[tauri::command] +pub async fn list_horizontalpodautoscalers( + 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-{}-hpas.yaml", cluster_id)); + let _cleanup = TempFileCleanup(temp_path.clone()); + + std::fs::write(&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("horizontalpodautoscalers"); + if namespace.is_empty() { + kubectl_cmd.arg("--all-namespaces"); + } else { + kubectl_cmd.arg("-n").arg(&namespace); + } + let output = kubectl_cmd + .arg("-o") + .arg("json") + .env("KUBECONFIG", temp_path.to_string_lossy().to_string()) + .env("KUBERNETES_CONTEXT", context) + .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_hpas_json(&output_str) +} + +fn parse_hpas_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 hpas = 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_replicas = item + .get("spec") + .and_then(|s| s.get("minReplicas")) + .and_then(|r| r.as_i64()) + .unwrap_or(1) as i32; + + let max_replicas = item + .get("spec") + .and_then(|s| s.get("maxReplicas")) + .and_then(|r| r.as_i64()) + .unwrap_or(1) as i32; + + let current_replicas = item + .get("status") + .and_then(|s| s.get("currentReplicas")) + .and_then(|r| r.as_i64()) + .unwrap_or(0) as i32; + + let desired_replicas = item + .get("status") + .and_then(|s| s.get("desiredReplicas")) + .and_then(|r| r.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()); + + hpas.push(HorizontalPodAutoscalerInfo { + name, + namespace, + min_replicas, + max_replicas, + current_replicas, + desired_replicas, + age, + }); + } + + Ok(hpas) +} + +#[tauri::command] +pub async fn cordon_node( + cluster_id: String, + node_name: 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-{}-cordon.yaml", cluster_id)); + let _cleanup = TempFileCleanup(temp_path.clone()); + + std::fs::write(&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("cordon") + .arg(node_name) + .env("KUBECONFIG", temp_path.to_string_lossy().to_string()) + .env("KUBERNETES_CONTEXT", context) + .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 uncordon_node( + cluster_id: String, + node_name: 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-{}-uncordon.yaml", cluster_id)); + let _cleanup = TempFileCleanup(temp_path.clone()); + + std::fs::write(&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("uncordon") + .arg(node_name) + .env("KUBECONFIG", temp_path.to_string_lossy().to_string()) + .env("KUBERNETES_CONTEXT", context) + .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 drain_node( + cluster_id: String, + node_name: 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-{}-drain.yaml", cluster_id)); + let _cleanup = TempFileCleanup(temp_path.clone()); + + std::fs::write(&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("drain") + .arg(node_name) + .arg("--ignore-daemonsets") + .arg("--delete-emptydir-data") + .arg("--force") + .env("KUBECONFIG", temp_path.to_string_lossy().to_string()) + .env("KUBERNETES_CONTEXT", context) + .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 rollback_deployment( + cluster_id: String, + namespace: String, + deployment_name: 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-{}-rollback.yaml", cluster_id)); + let _cleanup = TempFileCleanup(temp_path.clone()); + + std::fs::write(&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("undo") + .arg("deployment") + .arg(deployment_name) + .arg("-n") + .arg(namespace) + .env("KUBECONFIG", temp_path.to_string_lossy().to_string()) + .env("KUBERNETES_CONTEXT", context) + .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_resource( + cluster_id: String, + namespace: String, + _resource_type: String, + yaml_content: 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-{}-create.yaml", cluster_id)); + let _cleanup = TempFileCleanup(temp_path.clone()); + + std::fs::write(&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("create") + .arg("-f") + .arg("-") + .arg("-n") + .arg(namespace) + .env("KUBECONFIG", temp_path.to_string_lossy().to_string()) + .env("KUBERNETES_CONTEXT", context) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + + let mut child = cmd + .spawn() + .map_err(|e| format!("Failed to spawn kubectl: {e}"))?; + + if let Some(mut stdin) = child.stdin.take() { + stdin + .write_all(yaml_content.as_bytes()) + .await + .map_err(|e| format!("Failed to write yaml to stdin: {e}"))?; + } + + let output = child + .wait_with_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 edit_resource( + cluster_id: String, + namespace: String, + _resource_type: String, + _resource_name: String, + yaml_content: 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-{}-edit.yaml", cluster_id)); + let _cleanup = TempFileCleanup(temp_path.clone()); + + std::fs::write(&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("apply") + .arg("-f") + .arg("-") + .arg("-n") + .arg(namespace) + .env("KUBECONFIG", temp_path.to_string_lossy().to_string()) + .env("KUBERNETES_CONTEXT", context) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + + let mut child = cmd + .spawn() + .map_err(|e| format!("Failed to spawn kubectl: {e}"))?; + + if let Some(mut stdin) = child.stdin.take() { + stdin + .write_all(yaml_content.as_bytes()) + .await + .map_err(|e| format!("Failed to write yaml to stdin: {e}"))?; + } + + let output = child + .wait_with_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(()) +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 40479dda..dcc7fa61 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -187,6 +187,43 @@ pub fn run() { commands::kube::shutdown_port_forwards, commands::kube::test_cluster_connection, commands::kube::discover_pods, + // Kubernetes Resource Discovery + commands::kube::list_namespaces, + commands::kube::list_pods, + commands::kube::list_services, + commands::kube::list_deployments, + commands::kube::list_statefulsets, + commands::kube::list_daemonsets, + // Additional Kubernetes Resource Discovery + commands::kube::list_replicasets, + commands::kube::list_jobs, + commands::kube::list_cronjobs, + commands::kube::list_configmaps, + commands::kube::list_secrets, + commands::kube::list_nodes, + commands::kube::list_events, + commands::kube::list_ingresses, + commands::kube::list_persistentvolumeclaims, + commands::kube::list_persistentvolumes, + commands::kube::list_serviceaccounts, + commands::kube::list_roles, + commands::kube::list_clusterroles, + commands::kube::list_rolebindings, + commands::kube::list_clusterrolebindings, + commands::kube::list_horizontalpodautoscalers, + // Kubernetes Resource Management + commands::kube::get_pod_logs, + commands::kube::scale_deployment, + commands::kube::restart_deployment, + commands::kube::delete_resource, + commands::kube::exec_pod, + // Additional Kubernetes Resource Management + commands::kube::cordon_node, + commands::kube::uncordon_node, + commands::kube::drain_node, + commands::kube::rollback_deployment, + commands::kube::create_resource, + commands::kube::edit_resource, ]) .run(tauri::generate_context!()) .expect("Error running Troubleshooting and RCA Assistant application"); diff --git a/src/App.tsx b/src/App.tsx index 42caab5e..47e7100b 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -15,6 +15,7 @@ import { Moon, Terminal, FileCode, + Server, } from "lucide-react"; import { useSettingsStore } from "@/stores/settingsStore"; import { getAppVersionCmd, loadAiProvidersCmd, testProviderConnectionCmd, shutdownPortForwardsCmd } from "@/lib/tauriCommands"; @@ -34,11 +35,13 @@ import MCPServers from "@/pages/Settings/MCPServers"; import Security from "@/pages/Settings/Security"; import ShellExecution from "@/pages/Settings/ShellExecution"; import KubeconfigManager from "@/pages/Settings/KubeconfigManager"; +import { KubernetesPage } from "@/pages/Kubernetes/KubernetesPage"; import { ShellApprovalModal } from "@/components/ShellApprovalModal"; const navItems = [ { to: "/", icon: Home, label: "Dashboard" }, { to: "/new-issue", icon: Plus, label: "New Issue" }, + { to: "/kubernetes", icon: Server, label: "Kubernetes" }, { to: "/history", icon: Clock, label: "History" }, ]; @@ -85,10 +88,8 @@ export default function App() { // Auto-test the active provider const activeProvider = getActiveProvider(); if (activeProvider) { - console.log("Auto-testing active AI provider:", activeProvider.name); try { await testProviderConnectionCmd(activeProvider); - console.log("✓ Active provider connection verified:", activeProvider.name); } catch (err) { console.warn("⚠ Active provider connection test failed:", activeProvider.name, err); } @@ -197,6 +198,7 @@ export default function App() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/src/components/Kubernetes/DaemonSetList.tsx b/src/components/Kubernetes/DaemonSetList.tsx new file mode 100644 index 00000000..317692d5 --- /dev/null +++ b/src/components/Kubernetes/DaemonSetList.tsx @@ -0,0 +1,50 @@ +import React from "react"; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui"; +import type { DaemonSetInfo } from "@/lib/tauriCommands"; + +interface DaemonSetListProps { + daemonsets: DaemonSetInfo[]; + clusterId: string; + namespace: string; +} + +export function DaemonSetList({ daemonsets, clusterId: _clusterId, namespace: _namespace }: DaemonSetListProps) { + return ( +
+ + + + Name + Desired + Current + Ready + Up-to-date + Available + 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} + + )) + )} + +
+
+ ); +} diff --git a/src/components/Kubernetes/DeploymentList.tsx b/src/components/Kubernetes/DeploymentList.tsx new file mode 100644 index 00000000..a3407a35 --- /dev/null +++ b/src/components/Kubernetes/DeploymentList.tsx @@ -0,0 +1,208 @@ +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 type { DeploymentInfo } from "@/lib/tauriCommands"; + +interface DeploymentListProps { + deployments: DeploymentInfo[]; + clusterId: string; + namespace: string; +} + +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); + + 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); + + try { + await invoke("scale_deployment", { + clusterId, + namespace, + deploymentName: scalingDeployment.name, + replicas: newReplicas, + }); + + setScalingDeployment(null); + setReplicas(""); + } catch (err) { + console.error("Failed to scale deployment:", err); + setScaleError(err instanceof Error ? err.message : "Failed to scale deployment"); + } finally { + setIsScaling(false); + } + }; + + const handleRestartSubmit = async () => { + if (!restartingDeployment) return; + + setIsRestarting(true); + setRestartError(null); + + try { + await invoke("restart_deployment", { + clusterId, + namespace, + deploymentName: restartingDeployment.name, + }); + + setRestartingDeployment(null); + } catch (err) { + console.error("Failed to restart deployment:", err); + setRestartError(err instanceof Error ? err.message : "Failed to restart deployment"); + } finally { + setIsRestarting(false); + } + }; + + return ( + <> +
+ + + + Name + Ready + Up-to-date + Available + Replicas + Age + Actions + + + + {deployments.length === 0 ? ( + + + No deployments found + + + ) : ( + deployments.map((deployment) => ( + + {deployment.name} + {deployment.ready} + {deployment.up_to_date} + {deployment.available} + {deployment.replicas} + {deployment.age} + +
+ + +
+
+
+ )) + )} +
+
+
+ + {/* Scale Dialog */} + setScalingDeployment(null)}> + + + Scale Deployment + +
+
+ + + {scaleError && ( + + + {scaleError} + + )} +
+
+ + + + +
+
+ + {/* Restart Dialog */} + setRestartingDeployment(null)}> + + + Restart Deployment + +
+

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

+ {restartError && ( + + + {restartError} + + )} +
+ + + + +
+
+ + ); +} diff --git a/src/components/Kubernetes/PodList.tsx b/src/components/Kubernetes/PodList.tsx new file mode 100644 index 00000000..e214918a --- /dev/null +++ b/src/components/Kubernetes/PodList.tsx @@ -0,0 +1,199 @@ +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"; + +interface PodListProps { + pods: PodInfo[]; + clusterId: string; + namespace: string; +} + +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); + + const getPodStatusColor = (status: string) => { + switch (status.toLowerCase()) { + case "running": + return "bg-green-500"; + case "pending": + return "bg-yellow-500"; + case "succeeded": + case "completed": + return "bg-blue-500"; + case "failed": + case "error": + return "bg-red-500"; + default: + return "bg-gray-500"; + } + }; + + const fetchLogs = async () => { + if (!selectedPod || !selectedContainer) return; + + setIsFetchingLogs(true); + setError(null); + try { + const response = await invoke("get_pod_logs", { + clusterId, + namespace, + podName: selectedPod.name, + containerName: selectedContainer, + }); + setLogs(response.logs); + } catch (err) { + console.error("Failed to fetch logs:", err); + setError(err instanceof Error ? err.message : "Failed to fetch logs"); + } finally { + setIsFetchingLogs(false); + } + }; + + const handleContainerChange = (container: string) => { + setSelectedContainer(container); + setLogs(""); + setError(null); + }; + + const containers = selectedPod?.containers ?? []; + + return ( + <> +
+ + + + Name + Status + Ready + Age + Actions + + + + {pods.length === 0 ? ( + + + No pods found + + + ) : ( + pods.map((pod) => ( + + {pod.name} + + + {pod.status} + + + {pod.ready} + {pod.age} + + + + + + {pod.name} - {namespace} namespace + +
+ {selectedPod && ( +
+
+ Container: + + +
+ + {error && ( + + {error} + + )} + + {}}> + + Logs + Details + +
+ +