From b772bf70286101dfbd7e41570f665eafa7d36e59 Mon Sep 17 00:00:00 2001 From: Marc Seiler Date: Sun, 15 Feb 2026 03:51:53 -0500 Subject: [PATCH 1/8] Implement sidebar sections with full data-backed pages --- ...idebar-sections-feature-plan-2026-02-15.md | 350 ++++++++++++++++++ src-tauri/src/config/mod.rs | 2 +- src-tauri/src/import.rs | 2 +- src-tauri/src/k8s/client.rs | 70 +++- src-tauri/src/k8s/helm.rs | 237 ++++++++++++ src-tauri/src/k8s/metrics.rs | 275 +++++++++++++- src-tauri/src/k8s/mod.rs | 2 + src-tauri/src/k8s/workload.rs | 106 +++++- src-tauri/src/lib.rs | 13 + src/lib/components/WorkloadList.svelte | 32 +- src/lib/components/ui/DataTable.svelte | 6 +- src/routes/cluster/[id]/+page.svelte | 41 +- .../[id]/cluster-role-bindings/+page.svelte | 14 +- src/routes/cluster/[id]/crd/+page.svelte | 10 +- src/routes/cluster/[id]/events/+page.svelte | 109 +++++- .../cluster/[id]/helm/charts/+page.svelte | 96 ++++- .../cluster/[id]/helm/releases/+page.svelte | 109 +++++- .../cluster/[id]/namespaces/+page.svelte | 14 +- src/routes/cluster/[id]/nodes/+page.svelte | 161 +++++++- .../cluster/[id]/role-bindings/+page.svelte | 14 +- .../cluster/[id]/workloads/+page.svelte | 119 +++++- 21 files changed, 1699 insertions(+), 83 deletions(-) create mode 100644 docs/plans/sidebar-sections-feature-plan-2026-02-15.md create mode 100644 src-tauri/src/k8s/helm.rs diff --git a/docs/plans/sidebar-sections-feature-plan-2026-02-15.md b/docs/plans/sidebar-sections-feature-plan-2026-02-15.md new file mode 100644 index 0000000..facfef5 --- /dev/null +++ b/docs/plans/sidebar-sections-feature-plan-2026-02-15.md @@ -0,0 +1,350 @@ +# Kore Sidebar Sections - Full Feature Implementation Plan + +Date: February 15, 2026 +Status: Draft for review (no implementation started) + +## 1. Goal +Implement complete, user-meaningful functionality for every sidebar section in Kore (left icon sidebar + cluster resource sidebar), aligned with Kubernetes operator workflows and existing Kore architecture (Tauri v2 + Svelte 5 runes). + +## 2. What Was Audited +- Sidebar definitions: + - `src/lib/components/IconSidebar.svelte` + - `src/lib/components/ResourceSidebar.svelte` +- Route pages for all sidebar destinations under: + - `src/routes/+page.svelte` + - `src/routes/settings/+page.svelte` + - `src/routes/cluster/[id]/**/+page.svelte` +- Shared list/detail components: + - `src/lib/components/WorkloadList.svelte` + - `src/lib/components/PodDetailDrawer.svelte` + - `src/lib/components/DeploymentDetailDrawer.svelte` +- Backend command surface: + - `src-tauri/src/lib.rs` + - `src-tauri/src/k8s/*` + +## 3. Sidebar Inventory and Current State + +### 3.1 Icon Sidebar +1. Overview (`/`): Implemented (cluster inventory table with open, pin, settings, delete). +2. Add Cluster (`+` button): Implemented via import modal. +3. Bookmarked clusters: Implemented with drag reorder + context menu. +4. App Settings (`/settings`): Implemented (theme settings currently). + +### 3.2 Cluster Sidebar +1. Cluster Settings (`/cluster/[id]/settings`): Implemented (name/icon/description/tags/delete). +2. Namespace selector: Implemented via `activeClusterStore` and `cluster_list_namespaces`. +3. Dashboard (`/cluster/[id]/dashboard`): Implemented (metrics + warning events). +4. Nodes (`/cluster/[id]/nodes`): Placeholder. + +#### Workloads group +- Overview (`/workloads`): Placeholder. +- Pods (`/pods`): Implemented (watch, detail drawer, logs, delete). +- Deployments (`/deployments`): Implemented list + details + delete; edit/delete in drawer still TODO. +- StatefulSets, DaemonSets, ReplicaSets, Jobs, CronJobs: Implemented list/delete via shared `WorkloadList`. + +#### Configuration group +- ConfigMaps, Secrets, ResourceQuotas, LimitRanges, HPA, PDB: Implemented list/delete via shared `WorkloadList`. + +#### Network group +- Services, Endpoints, Ingresses, NetworkPolicies: Implemented list/delete via shared `WorkloadList`. + +#### Storage group +- PVC, PV, StorageClasses: Implemented list/delete via shared `WorkloadList`. + +#### Access Control group +- ServiceAccounts, Roles, ClusterRoles: Implemented list/delete via shared `WorkloadList`. +- RoleBindings (`/role-bindings`): Placeholder. +- ClusterRoleBindings (`/cluster-role-bindings`): Placeholder. + +#### Standalone +- Namespaces (`/namespaces`): Placeholder. +- Events (`/events`): Placeholder. + +#### Helm group +- Releases (`/helm/releases`): Placeholder. +- Charts (`/helm/charts`): Placeholder. + +#### Custom Resources group +- CRDs (`/crd`): Placeholder. + +## 4. User Jobs To Be Done Per Section (Target UX) + +### 4.1 Global/Core +- Discover clusters quickly, pin favorites, switch clusters and namespace safely. +- Understand cluster health at a glance. +- Navigate resources consistently with filter/search/sort and bulk actions. + +### 4.2 Workloads +- Inspect runtime state and rollout health. +- Perform operational actions: scale, restart, delete, view events, view logs. +- Open detailed YAML/spec for troubleshooting. + +### 4.3 Config and Policy +- Inspect and manage config artifacts (ConfigMap/Secret/etc.). +- Validate policy objects (quota, limits, HPA, PDB) and detect drift. + +### 4.4 Network +- Debug service routing and exposure (Service/Endpoints/Ingress). +- Inspect and maintain network segmentation (NetworkPolicy). + +### 4.5 Storage +- Track claim/volume binding, capacity, reclaim behavior. +- Maintain storage classes and clean up unused objects. + +### 4.6 Access Control +- Audit identities/roles/bindings. +- Add/remove/adjust role bindings and cluster role bindings safely. + +### 4.7 Namespaces and Events +- Manage namespace lifecycle. +- Investigate incidents with event timeline and filters. + +### 4.8 Helm +- Manage releases lifecycle (install/upgrade/rollback/uninstall). +- Explore available charts and chart metadata. + +### 4.9 CRDs +- Discover installed CRDs. +- Inspect CRD schemas/versions and browse instances for selected CRD kinds. + +## 5. Gap Analysis + +1. Multiple sidebar routes are placeholders with no data/actions: Nodes, Workloads Overview, Namespaces, Events, RoleBindings, ClusterRoleBindings, Helm Charts/Releases, CRDs. +2. Existing implemented sections are mostly list+delete; missing common operator actions (create/edit/apply YAML, scale/restart/rollout, binding management). +3. Drawer actions are incomplete (`TODO` in deployment/pod edit paths). +4. Backend command coverage is absent for several placeholder pages (not registered in `src-tauri/src/lib.rs`). +5. UX consistency gap: no unified "YAML view/edit/apply" and no standardized details drawer for all resource types. + +## 6. Implementation Plan (Phased) + +## Phase 0 - Foundation and Consistency +Scope: +- Establish shared resource action framework used by all pages. + +Deliverables: +1. Build shared "Resource Details Drawer" pattern with tabs: + - Overview + - YAML (read-only first) + - Events (resource-scoped when possible) +2. Introduce shared action contract for DataTable row actions: + - View details + - Delete + - Open YAML +3. Add consistent empty/loading/error states across all resource pages. +4. Add toast/notification conventions for success/failure paths. + +Backend: +- Add generic read command(s) for manifest retrieval where needed (or per-resource detail commands). + +Acceptance criteria: +- All non-placeholder pages use consistent action semantics and error handling. + +## Phase 1 - Complete Placeholder Sections (MVP) +Scope: +- Replace all placeholders with list + details + delete (where deletion is valid). + +### 1A. Nodes +Frontend: +- Build `nodes/+page.svelte` using `DataTable`. +- Columns: name, status/conditions, roles, version, age, internal IP, pods count (if available). +- Row details drawer with labels/taints/capacity/allocatable. + +Backend: +- Add commands: `cluster_list_nodes`, `cluster_get_node_details`. + +### 1B. Workloads Overview +Frontend: +- Build aggregated summary page with cards + grouped tables: + - Deployments, StatefulSets, DaemonSets, Jobs, CronJobs health snapshot. +- Quick drill-down links into each section. + +Backend: +- Reuse existing list commands; optional aggregate endpoint for efficiency. + +### 1C. Namespaces +Frontend: +- Table with namespace status, age, labels. +- Actions: view details, delete namespace. +- Optional: create namespace (Phase 2 if needed). + +Backend: +- Add commands: `cluster_get_namespaces_full` (or expand existing), `cluster_delete_namespace`. + +### 1D. Events +Frontend: +- Table with type, reason, object, namespace, message, last seen, count. +- Filters: namespace, type, reason, object search. +- Auto-refresh/watch toggle. + +Backend: +- Reuse `cluster_get_events`; add optional streaming/watch command for real-time mode. + +### 1E. RoleBindings and ClusterRoleBindings +Frontend: +- Standard list pages with subject/roleRef columns. +- Details drawer showing subjects, roleRef, metadata. +- Actions: delete. + +Backend: +- Add commands: + - `cluster_list_role_bindings`, `cluster_delete_role_binding` + - `cluster_list_cluster_role_bindings`, `cluster_delete_cluster_role_binding` + +### 1F. Helm Releases and Charts +Frontend: +- Releases page: table + details drawer (status, chart version, app version, namespace, updated). +- Charts page: searchable chart catalog (initially from configured repos). + +Backend: +- Add Helm integration command layer (likely wrapping `helm` binary or helm crate): + - `cluster_list_helm_releases` + - `cluster_list_helm_charts` +- Include capability detection and graceful fallback when Helm unavailable. + +### 1G. CRDs +Frontend: +- CRD table with group, kind, versions, scope, age. +- Details drawer with versions and schema summary. +- Add "View Instances" action (navigates/filter to CR instances view). + +Backend: +- Add commands: `cluster_list_crds`, `cluster_get_crd_details`. +- Plan for CR instance listing command shape (Phase 2). + +Acceptance criteria for Phase 1: +- No sidebar page shows "Placeholder" or "Coming soon". +- Every sidebar destination has real data load, refresh, and at least one meaningful action. + +## Phase 2 - Operator Actions Beyond Delete +Scope: +- Add high-value mutations users expect from a Kubernetes IDE. + +Deliverables: +1. YAML workflow for managed resources: + - View YAML + - Edit YAML + - Apply patch/update +2. Workload actions: + - Deployments/StatefulSets/DaemonSets: scale, restart rollout + - Jobs: rerun (create from template) where practical +3. Namespace create flow. +4. Access control create/edit for RoleBinding/ClusterRoleBinding. +5. Helm release lifecycle actions: + - Install + - Upgrade + - Rollback + - Uninstall + +Backend: +- Add update/apply commands per resource category with validation and clear error messages. + +Acceptance criteria: +- Users can complete common day-2 operations without leaving Kore for kubectl in the primary sections. + +## Phase 3 - Advanced UX and Reliability +Scope: +- Improve performance, observability, and safety. + +Deliverables: +1. Incremental refresh/watch support for major lists (where stable and efficient). +2. RBAC-aware UI states (disable/hide forbidden actions; surface permission errors clearly). +3. Bulk operations for additional resources. +4. Audit trail style activity panel for recent user actions in session. +5. Test hardening and regression coverage. + +Acceptance criteria: +- Measurable reduction in stale data windows and user-facing action failures. + +## 7. Technical Work Breakdown + +### 7.1 Frontend +1. Create generic page scaffolding util for new resource pages. +2. Build missing pages: + - `src/routes/cluster/[id]/nodes/+page.svelte` + - `src/routes/cluster/[id]/workloads/+page.svelte` + - `src/routes/cluster/[id]/namespaces/+page.svelte` + - `src/routes/cluster/[id]/events/+page.svelte` + - `src/routes/cluster/[id]/role-bindings/+page.svelte` + - `src/routes/cluster/[id]/cluster-role-bindings/+page.svelte` + - `src/routes/cluster/[id]/helm/charts/+page.svelte` + - `src/routes/cluster/[id]/helm/releases/+page.svelte` + - `src/routes/cluster/[id]/crd/+page.svelte` +3. Add reusable drawers/components: + - Resource metadata panel + - YAML viewer/editor modal + - Event list widget +4. Upgrade existing pages to consistent action model: + - Pods, Deployments, all `WorkloadList` consumers. + +### 7.2 Backend (Rust/Tauri) +1. Add command APIs for missing resource types: + - Nodes, namespaces (full), bindings, CRDs, Helm. +2. Add detail/read commands to support drawers. +3. Add optional mutate commands for Phase 2 (scale/restart/edit/apply). +4. Register commands in `src-tauri/src/lib.rs` and expose typed response structs. +5. Add validation and error normalization (user-safe messages). + +### 7.3 Stores and State +1. Extend state handling for per-page filters and persisted table state keys. +2. Keep namespace-scoped behavior consistent (cluster-scoped resources ignore namespace filter cleanly). +3. Add action feedback hooks (toasts, pending states, retry). + +## 8. Testing and Quality Gates + +Frontend: +- Component/unit tests for new pages and action menus. +- Behavior tests for search/filter/bulk actions and drawer state. + +Backend: +- Unit tests for mapping and parsing for new resource structs. +- Command-level tests (mocked where appropriate). + +Integration: +- Smoke pass per sidebar route: load, refresh, row action, error path. + +Manual validation checklist: +1. Every sidebar item navigates to a functional page. +2. Namespace switch updates namespaced pages correctly. +3. Cluster-scoped resources behave correctly regardless of namespace selection. +4. Delete and mutation confirmations work and recover gracefully on errors. + +## 9. Risks and Mitigations + +1. Helm portability risk: +- Mitigation: detect Helm availability; provide clear fallback UI state. + +2. RBAC variance across clusters: +- Mitigation: treat permission errors as expected states with explicit messaging. + +3. Real-time watch complexity: +- Mitigation: start with polling + manual refresh for new sections, then add watch incrementally. + +4. YAML mutation safety: +- Mitigation: add confirm step, server-side validation, and precise diff preview before apply (Phase 2). + +## 10. Proposed Execution Order + +1. Foundation consistency refactor (Phase 0). +2. Placeholder replacement for core ops pages first: + - Nodes, Events, Namespaces. +3. Access Control completion: + - RoleBindings, ClusterRoleBindings. +4. CRD browser. +5. Helm pages. +6. Mutation workflows (edit/apply/scale/restart). + +## 11. Definition of Done +A sidebar implementation is done when: +1. Route has production UI (not placeholder). +2. Data loads from backend with loading/error/empty states. +3. At least one meaningful user action is available and tested. +4. Action results are reflected in UI without manual app restart. +5. Page follows Kore theme variables and existing component patterns. + +## 12. Requested Review +Please review and confirm: +1. Phase ordering. +2. Whether Helm should stay in Phase 1 (MVP) or move to Phase 2. +3. Whether YAML edit/apply should be global in Phase 2 or limited to specific resources first. + +After your sign-off, I will implement in the approved sequence. diff --git a/src-tauri/src/config/mod.rs b/src-tauri/src/config/mod.rs index cf8e541..9cb3cb8 100644 --- a/src-tauri/src/config/mod.rs +++ b/src-tauri/src/config/mod.rs @@ -1,8 +1,8 @@ use serde::{Deserialize, Serialize}; use std::fs; -use std::path::{Path, PathBuf}; #[cfg(unix)] use std::os::unix::fs::PermissionsExt; +use std::path::{Path, PathBuf}; #[derive(Serialize, Deserialize, Debug, Clone)] #[allow(dead_code)] diff --git a/src-tauri/src/import.rs b/src-tauri/src/import.rs index 751169f..237573a 100644 --- a/src-tauri/src/import.rs +++ b/src-tauri/src/import.rs @@ -1,9 +1,9 @@ use crate::cluster_manager::ClusterManagerState; use kube::config::Kubeconfig; use serde::{Deserialize, Serialize}; +use std::io::Write; use std::path::{Path, PathBuf}; use tauri::State; -use std::io::Write; const MAX_DISCOVERY_DEPTH: usize = 8; diff --git a/src-tauri/src/k8s/client.rs b/src-tauri/src/k8s/client.rs index 76f7168..1b61efd 100644 --- a/src-tauri/src/k8s/client.rs +++ b/src-tauri/src/k8s/client.rs @@ -1,7 +1,8 @@ use crate::cluster_manager::ClusterManagerState; use crate::config; +use crate::k8s::common::{calculate_age, get_created_at}; use k8s_openapi::api::core::v1::Namespace; -use kube::api::{Api, ListParams}; +use kube::api::{Api, DeleteParams, ListParams}; use kube::config::Kubeconfig; use kube::{Client, Config}; use std::path::PathBuf; @@ -208,3 +209,70 @@ pub async fn cluster_list_namespaces( Ok(namespaces) } + +#[derive(Debug, Clone, serde::Serialize)] +pub struct NamespaceSummary { + pub id: String, + pub name: String, + pub namespace: String, + pub age: String, + pub labels: std::collections::BTreeMap, + pub status: String, + pub images: Vec, + pub created_at: i64, +} + +#[tauri::command] +pub async fn cluster_list_namespaces_detailed( + cluster_id: String, + state: State<'_, ClusterManagerState>, +) -> Result, String> { + let client = create_client_for_cluster(&cluster_id, &state).await?; + let ns_api: Api = Api::all(client); + + let list = ns_api + .list(&ListParams::default()) + .await + .map_err(|e| format!("Failed to list namespaces: {}", e))?; + + let mut namespaces: Vec = list + .items + .into_iter() + .map(|ns| { + let meta = ns.metadata; + let status = ns + .status + .and_then(|s| s.phase) + .unwrap_or_else(|| "Unknown".to_string()); + + NamespaceSummary { + id: meta.uid.clone().unwrap_or_default(), + name: meta.name.clone().unwrap_or_default(), + namespace: "-".to_string(), + age: calculate_age(meta.creation_timestamp.as_ref()), + labels: meta.labels.unwrap_or_default(), + status, + images: vec![], + created_at: get_created_at(meta.creation_timestamp.as_ref()), + } + }) + .collect(); + + namespaces.sort_by(|a, b| b.created_at.cmp(&a.created_at)); + Ok(namespaces) +} + +#[tauri::command] +pub async fn cluster_delete_namespace( + cluster_id: String, + name: String, + state: State<'_, ClusterManagerState>, +) -> Result<(), String> { + let client = create_client_for_cluster(&cluster_id, &state).await?; + let ns_api: Api = Api::all(client); + ns_api + .delete(&name, &DeleteParams::default()) + .await + .map_err(|e| format!("Failed to delete namespace '{}': {}", name, e))?; + Ok(()) +} diff --git a/src-tauri/src/k8s/helm.rs b/src-tauri/src/k8s/helm.rs new file mode 100644 index 0000000..0d6510d --- /dev/null +++ b/src-tauri/src/k8s/helm.rs @@ -0,0 +1,237 @@ +use crate::cluster_manager::ClusterManagerState; +use serde_json::Value; +use std::process::Command; +use tauri::State; + +#[derive(serde::Serialize, Debug)] +pub struct HelmReleaseSummary { + pub id: String, + pub name: String, + pub namespace: String, + pub age: String, + pub labels: std::collections::BTreeMap, + pub status: String, + pub images: Vec, + pub created_at: i64, + pub revision: String, + pub chart: String, + pub app_version: String, + pub updated: String, +} + +#[derive(serde::Serialize, Debug)] +pub struct HelmChartSummary { + pub id: String, + pub name: String, + pub namespace: String, + pub age: String, + pub labels: std::collections::BTreeMap, + pub status: String, + pub images: Vec, + pub created_at: i64, + pub chart: String, + pub version: String, + pub app_version: String, + pub description: String, +} + +#[derive(serde::Serialize, Debug)] +pub struct HelmAvailability { + pub available: bool, + pub version: Option, + pub message: Option, +} + +fn get_cluster_kubeconfig_and_context( + cluster_id: &str, + state: &State<'_, ClusterManagerState>, +) -> Result<(String, String), String> { + let manager = state + .0 + .lock() + .map_err(|e| format!("Failed to acquire lock: {}", e))?; + let cluster = manager + .get_cluster(cluster_id)? + .ok_or_else(|| format!("Cluster '{}' not found", cluster_id))?; + Ok((cluster.config_path, cluster.context_name)) +} + +#[tauri::command] +pub async fn cluster_check_helm_available() -> Result { + let output = Command::new("helm").arg("version").arg("--short").output(); + + match output { + Ok(out) if out.status.success() => { + let version = String::from_utf8_lossy(&out.stdout).trim().to_string(); + Ok(HelmAvailability { + available: true, + version: Some(version), + message: None, + }) + } + Ok(out) => Ok(HelmAvailability { + available: false, + version: None, + message: Some(String::from_utf8_lossy(&out.stderr).trim().to_string()), + }), + Err(err) => Ok(HelmAvailability { + available: false, + version: None, + message: Some(format!("Failed to execute helm: {}", err)), + }), + } +} + +#[tauri::command] +pub async fn cluster_list_helm_releases( + cluster_id: String, + state: State<'_, ClusterManagerState>, +) -> Result, String> { + let (kubeconfig, context_name) = get_cluster_kubeconfig_and_context(&cluster_id, &state)?; + + let output = Command::new("helm") + .args([ + "list", + "--all-namespaces", + "-o", + "json", + "--kubeconfig", + &kubeconfig, + "--kube-context", + &context_name, + ]) + .output() + .map_err(|e| format!("Failed to execute helm list: {}", e))?; + + if !output.status.success() { + return Err(format!( + "helm list failed: {}", + String::from_utf8_lossy(&output.stderr).trim() + )); + } + + let parsed: Value = serde_json::from_slice(&output.stdout) + .map_err(|e| format!("Failed to parse helm list output: {}", e))?; + + let mut releases = Vec::new(); + if let Some(items) = parsed.as_array() { + for item in items { + let name = item + .get("name") + .and_then(|v| v.as_str()) + .unwrap_or_default() + .to_string(); + let namespace = item + .get("namespace") + .and_then(|v| v.as_str()) + .unwrap_or("-") + .to_string(); + let revision = item + .get("revision") + .and_then(|v| v.as_str()) + .unwrap_or("-") + .to_string(); + let updated = item + .get("updated") + .and_then(|v| v.as_str()) + .unwrap_or("-") + .to_string(); + let status = item + .get("status") + .and_then(|v| v.as_str()) + .unwrap_or("unknown") + .to_string(); + let chart = item + .get("chart") + .and_then(|v| v.as_str()) + .unwrap_or("-") + .to_string(); + let app_version = item + .get("app_version") + .and_then(|v| v.as_str()) + .unwrap_or("-") + .to_string(); + + releases.push(HelmReleaseSummary { + id: format!("{}/{}", namespace, name), + name, + namespace, + age: "-".to_string(), + labels: std::collections::BTreeMap::new(), + status, + images: vec![chart.clone()], + created_at: 0, + revision, + chart, + app_version, + updated, + }); + } + } + + Ok(releases) +} + +#[tauri::command] +pub async fn cluster_list_helm_charts( + _cluster_id: String, + _state: State<'_, ClusterManagerState>, +) -> Result, String> { + let output = Command::new("helm") + .args(["search", "repo", "-o", "json"]) + .output() + .map_err(|e| format!("Failed to execute helm search repo: {}", e))?; + + if !output.status.success() { + return Err(format!( + "helm search repo failed: {}", + String::from_utf8_lossy(&output.stderr).trim() + )); + } + + let parsed: Value = serde_json::from_slice(&output.stdout) + .map_err(|e| format!("Failed to parse helm search output: {}", e))?; + + let mut charts = Vec::new(); + if let Some(items) = parsed.as_array() { + for item in items { + let chart = item + .get("name") + .and_then(|v| v.as_str()) + .unwrap_or_default() + .to_string(); + let version = item + .get("version") + .and_then(|v| v.as_str()) + .unwrap_or("-") + .to_string(); + let app_version = item + .get("app_version") + .and_then(|v| v.as_str()) + .unwrap_or("-") + .to_string(); + let description = item + .get("description") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + + charts.push(HelmChartSummary { + id: format!("{}:{}", chart, version), + name: chart.split('/').last().unwrap_or_default().to_string(), + namespace: "-".to_string(), + age: "-".to_string(), + labels: std::collections::BTreeMap::new(), + status: "Available".to_string(), + images: vec![], + created_at: 0, + chart, + version, + app_version, + description, + }); + } + } + + Ok(charts) +} diff --git a/src-tauri/src/k8s/metrics.rs b/src-tauri/src/k8s/metrics.rs index 32c6e44..2ec5beb 100644 --- a/src-tauri/src/k8s/metrics.rs +++ b/src-tauri/src/k8s/metrics.rs @@ -1,5 +1,6 @@ use crate::cluster_manager::ClusterManagerState; use crate::k8s::client::create_client_for_cluster; +use crate::k8s::common::{calculate_age, get_created_at}; use k8s_openapi::api::core::v1::{Event, Node, Pod}; use kube::api::Api; use tauri::State; @@ -29,6 +30,48 @@ pub struct WarningEvent { pub count: i32, } +#[derive(serde::Serialize, Debug)] +pub struct ClusterEventSummary { + pub id: String, + pub name: String, + pub namespace: String, + pub age: String, + pub labels: std::collections::BTreeMap, + pub status: String, + pub images: Vec, + pub created_at: i64, + pub event_type: String, + pub reason: String, + pub message: String, + pub object: String, + pub count: i32, +} + +#[derive(serde::Serialize, Debug)] +pub struct NodeSummary { + pub id: String, + pub name: String, + pub namespace: String, + pub age: String, + pub labels: std::collections::BTreeMap, + pub status: String, + pub images: Vec, + pub created_at: i64, + pub roles: String, + pub version: String, + pub internal_ip: String, + pub os_image: String, + pub kernel_version: String, + pub container_runtime: String, + pub taints: Vec, + pub capacity_cpu: String, + pub capacity_memory: String, + pub capacity_pods: String, + pub allocatable_cpu: String, + pub allocatable_memory: String, + pub allocatable_pods: String, +} + fn parse_cpu(q: &str) -> f64 { if q.ends_with('m') { q.trim_end_matches('m').parse::().unwrap_or(0.0) / 1000.0 @@ -54,6 +97,134 @@ fn parse_memory(q: &str) -> f64 { } } +fn parse_rfc3339_ts(ts: &str) -> Option> { + chrono::DateTime::parse_from_rfc3339(ts) + .ok() + .map(|dt| dt.with_timezone(&chrono::Utc)) +} + +fn format_event_age( + last_timestamp: Option<&k8s_openapi::apimachinery::pkg::apis::meta::v1::Time>, +) -> String { + let Some(last_ts) = last_timestamp else { + return "-".to_string(); + }; + + let Some(last_ts_parsed) = parse_rfc3339_ts(&last_ts.0.to_string()) else { + return "-".to_string(); + }; + + let duration = chrono::Utc::now().signed_duration_since(last_ts_parsed); + if duration.num_days() > 0 { + format!("{}d", duration.num_days()) + } else if duration.num_hours() > 0 { + format!("{}h", duration.num_hours()) + } else if duration.num_minutes() > 0 { + format!("{}m", duration.num_minutes()) + } else { + format!("{}s", duration.num_seconds()) + } +} + +fn map_node_to_summary(node: Node) -> NodeSummary { + let meta = node.metadata; + let status = node.status.unwrap_or_default(); + let spec = node.spec.unwrap_or_default(); + + let ready_status = status + .conditions + .as_ref() + .and_then(|conds| conds.iter().find(|c| c.type_ == "Ready")) + .map(|c| c.status.clone()) + .unwrap_or_else(|| "Unknown".to_string()); + + let status_label = match ready_status.as_str() { + "True" => "Ready", + "False" => "NotReady", + _ => "Unknown", + }; + + let labels = meta.labels.unwrap_or_default(); + let mut roles: Vec = labels + .keys() + .filter_map(|key| { + key.strip_prefix("node-role.kubernetes.io/") + .map(|r| r.to_string()) + }) + .collect(); + if roles.is_empty() { + roles.push("worker".to_string()); + } + roles.sort(); + roles.dedup(); + + let internal_ip = status + .addresses + .as_ref() + .and_then(|addrs| addrs.iter().find(|a| a.type_ == "InternalIP")) + .map(|a| a.address.clone()) + .unwrap_or_else(|| "-".to_string()); + + let taints = spec + .taints + .unwrap_or_default() + .into_iter() + .map(|t| { + if let Some(value) = t.value { + format!("{}={} ({})", t.key, value, t.effect) + } else { + format!("{} ({})", t.key, t.effect) + } + }) + .collect::>(); + + let capacity = status.capacity.unwrap_or_default(); + let allocatable = status.allocatable.unwrap_or_default(); + let node_info = status.node_info.unwrap_or_default(); + + NodeSummary { + id: meta.uid.clone().unwrap_or_default(), + name: meta.name.clone().unwrap_or_default(), + namespace: "-".to_string(), + age: calculate_age(meta.creation_timestamp.as_ref()), + labels, + status: status_label.to_string(), + images: vec![], + created_at: get_created_at(meta.creation_timestamp.as_ref()), + roles: roles.join(","), + version: node_info.kubelet_version, + internal_ip, + os_image: node_info.os_image, + kernel_version: node_info.kernel_version, + container_runtime: node_info.container_runtime_version, + taints, + capacity_cpu: capacity + .get("cpu") + .map(|v| v.0.clone()) + .unwrap_or_else(|| "-".to_string()), + capacity_memory: capacity + .get("memory") + .map(|v| v.0.clone()) + .unwrap_or_else(|| "-".to_string()), + capacity_pods: capacity + .get("pods") + .map(|v| v.0.clone()) + .unwrap_or_else(|| "-".to_string()), + allocatable_cpu: allocatable + .get("cpu") + .map(|v| v.0.clone()) + .unwrap_or_else(|| "-".to_string()), + allocatable_memory: allocatable + .get("memory") + .map(|v| v.0.clone()) + .unwrap_or_else(|| "-".to_string()), + allocatable_pods: allocatable + .get("pods") + .map(|v| v.0.clone()) + .unwrap_or_else(|| "-".to_string()), + } +} + #[tauri::command] pub async fn cluster_get_metrics( cluster_id: String, @@ -157,28 +328,10 @@ pub async fn cluster_get_events( let event_list = events.list(&lp).await.map_err(|e| e.to_string())?; let mut warnings = Vec::new(); - let now = chrono::Utc::now(); for e in event_list.items { if e.type_.as_deref() == Some("Warning") { - let age = if let Some(last_ts) = &e.last_timestamp { - let last_ts_str = last_ts.0.to_string(); - let last_ts_parsed = chrono::DateTime::parse_from_rfc3339(&last_ts_str) - .unwrap() - .with_timezone(&chrono::Utc); - let duration = now.signed_duration_since(last_ts_parsed); - if duration.num_days() > 0 { - format!("{}d", duration.num_days()) - } else if duration.num_hours() > 0 { - format!("{}h", duration.num_hours()) - } else if duration.num_minutes() > 0 { - format!("{}m", duration.num_minutes()) - } else { - format!("{}s", duration.num_seconds()) - } - } else { - "-".to_string() - }; + let age = format_event_age(e.last_timestamp.as_ref()); warnings.push(WarningEvent { message: e.message.unwrap_or_default(), @@ -201,6 +354,90 @@ pub async fn cluster_get_events( Ok(warnings) } +#[tauri::command] +pub async fn cluster_list_events( + cluster_id: String, + namespace: Option, + include_normal: Option, + state: State<'_, ClusterManagerState>, +) -> Result, String> { + let client = create_client_for_cluster(&cluster_id, &state).await?; + let events: Api = if let Some(ns) = namespace.clone() { + Api::namespaced(client, &ns) + } else { + Api::all(client) + }; + + let include_normal = include_normal.unwrap_or(true); + let event_list = events + .list(&kube::api::ListParams::default()) + .await + .map_err(|e| e.to_string())?; + + let mut summaries: Vec = event_list + .items + .into_iter() + .filter(|e| include_normal || e.type_.as_deref() == Some("Warning")) + .map(|e| { + let meta = e.metadata; + let name = meta.name.clone().unwrap_or_default(); + let event_type = e.type_.clone().unwrap_or_else(|| "Normal".to_string()); + let reason = e.reason.clone().unwrap_or_default(); + let message = e.message.clone().unwrap_or_default(); + let object = format!( + "{}/{}", + e.involved_object.kind.clone().unwrap_or_default(), + e.involved_object.name.clone().unwrap_or_default() + ); + let event_namespace = meta + .namespace + .clone() + .or(e.involved_object.namespace.clone()) + .unwrap_or_else(|| "-".to_string()); + let age = format_event_age(e.last_timestamp.as_ref().or(e.first_timestamp.as_ref())); + let created_at = get_created_at(meta.creation_timestamp.as_ref()); + + ClusterEventSummary { + id: meta.uid.clone().unwrap_or_else(|| name.clone()), + name, + namespace: event_namespace, + age, + labels: meta.labels.unwrap_or_default(), + status: format!("{}: {}", event_type, reason), + images: vec![], + created_at, + event_type, + reason, + message, + object, + count: e.count.unwrap_or(1), + } + }) + .collect(); + + summaries.sort_by(|a, b| b.created_at.cmp(&a.created_at)); + Ok(summaries) +} + +#[tauri::command] +pub async fn cluster_list_nodes( + cluster_id: String, + state: State<'_, ClusterManagerState>, +) -> Result, String> { + let client = create_client_for_cluster(&cluster_id, &state).await?; + let nodes: Api = Api::all(client); + let mut list = nodes + .list(&Default::default()) + .await + .map_err(|e| format!("Failed to list nodes: {}", e))? + .items + .into_iter() + .map(map_node_to_summary) + .collect::>(); + list.sort_by(|a, b| b.created_at.cmp(&a.created_at)); + Ok(list) +} + #[cfg(test)] mod tests { use super::*; diff --git a/src-tauri/src/k8s/mod.rs b/src-tauri/src/k8s/mod.rs index b3b9da9..b1ae62a 100644 --- a/src-tauri/src/k8s/mod.rs +++ b/src-tauri/src/k8s/mod.rs @@ -1,6 +1,7 @@ pub mod client; pub mod common; pub mod deployment; +pub mod helm; pub mod metrics; pub mod pod; pub mod statefulset; @@ -9,6 +10,7 @@ pub mod workload; pub use client::*; pub use deployment::*; +pub use helm::*; pub use metrics::*; pub use pod::*; pub use statefulset::*; diff --git a/src-tauri/src/k8s/workload.rs b/src-tauri/src/k8s/workload.rs index 751ac95..6e969ef 100644 --- a/src-tauri/src/k8s/workload.rs +++ b/src-tauri/src/k8s/workload.rs @@ -10,8 +10,9 @@ use k8s_openapi::api::core::v1::{ }; use k8s_openapi::api::networking::v1::{Ingress, NetworkPolicy}; use k8s_openapi::api::policy::v1::PodDisruptionBudget; -use k8s_openapi::api::rbac::v1::{ClusterRole, Role}; +use k8s_openapi::api::rbac::v1::{ClusterRole, ClusterRoleBinding, Role, RoleBinding}; use k8s_openapi::api::storage::v1::StorageClass; +use k8s_openapi::apiextensions_apiserver::pkg::apis::apiextensions::v1::CustomResourceDefinition; use kube::api::Api; use tauri::State; @@ -595,6 +596,91 @@ fn map_cluster_role_to_summary(r: ClusterRole) -> WorkloadSummary { } } +fn map_role_binding_to_summary(r: RoleBinding) -> WorkloadSummary { + let meta = r.metadata; + let role = format!("{}:{}", r.role_ref.kind, r.role_ref.name); + let subjects = r + .subjects + .unwrap_or_default() + .into_iter() + .map(|s| { + if let Some(ns) = s.namespace { + format!("{}:{}/{}", s.kind, ns, s.name) + } else { + format!("{}:{}", s.kind, s.name) + } + }) + .collect::>(); + + WorkloadSummary { + id: meta.uid.clone().unwrap_or_default(), + name: meta.name.clone().unwrap_or_default(), + namespace: meta.namespace.clone().unwrap_or_default(), + age: calculate_age(meta.creation_timestamp.as_ref()), + created_at: get_created_at(meta.creation_timestamp.as_ref()), + labels: meta.labels.unwrap_or_default(), + status: role, + images: subjects, + } +} + +fn map_cluster_role_binding_to_summary(r: ClusterRoleBinding) -> WorkloadSummary { + let meta = r.metadata; + let role = format!("{}:{}", r.role_ref.kind, r.role_ref.name); + let subjects = r + .subjects + .unwrap_or_default() + .into_iter() + .map(|s| { + if let Some(ns) = s.namespace { + format!("{}:{}/{}", s.kind, ns, s.name) + } else { + format!("{}:{}", s.kind, s.name) + } + }) + .collect::>(); + + WorkloadSummary { + id: meta.uid.clone().unwrap_or_default(), + name: meta.name.clone().unwrap_or_default(), + namespace: "-".to_string(), + age: calculate_age(meta.creation_timestamp.as_ref()), + created_at: get_created_at(meta.creation_timestamp.as_ref()), + labels: meta.labels.unwrap_or_default(), + status: role, + images: subjects, + } +} + +fn map_crd_to_summary(c: CustomResourceDefinition) -> WorkloadSummary { + let meta = c.metadata; + let spec = c.spec; + let scope = spec.scope; + let versions = spec + .versions + .iter() + .map(|v| { + if v.storage { + format!("{}*", v.name) + } else { + v.name.clone() + } + }) + .collect::>() + .join(", "); + + WorkloadSummary { + id: meta.uid.clone().unwrap_or_default(), + name: meta.name.clone().unwrap_or_default(), + namespace: "-".to_string(), + age: calculate_age(meta.creation_timestamp.as_ref()), + created_at: get_created_at(meta.creation_timestamp.as_ref()), + labels: meta.labels.unwrap_or_default(), + status: format!("{} ({})", spec.names.kind, scope), + images: vec![versions, spec.group], + } +} + impl_workload_commands!( Deployment, cluster_list_deployments, @@ -710,6 +796,12 @@ impl_workload_commands!( cluster_delete_role, map_role_to_summary ); +impl_workload_commands!( + RoleBinding, + cluster_list_role_bindings, + cluster_delete_role_binding, + map_role_binding_to_summary +); // Cluster Scoped impl_cluster_resource_commands!( @@ -730,3 +822,15 @@ impl_cluster_resource_commands!( cluster_delete_cluster_role, map_cluster_role_to_summary ); +impl_cluster_resource_commands!( + ClusterRoleBinding, + cluster_list_cluster_role_bindings, + cluster_delete_cluster_role_binding, + map_cluster_role_binding_to_summary +); +impl_cluster_resource_commands!( + CustomResourceDefinition, + cluster_list_crds, + cluster_delete_crd, + map_crd_to_summary +); diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 277a0ab..e4863b1 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -45,6 +45,8 @@ pub fn run() { k8s::start_pod_watch, // NEW: Cluster-based k8s commands k8s::cluster_list_namespaces, + k8s::cluster_list_namespaces_detailed, + k8s::cluster_delete_namespace, k8s::cluster_list_pods, k8s::cluster_delete_pod, k8s::cluster_get_pod_events, @@ -52,6 +54,8 @@ pub fn run() { k8s::cluster_start_pod_watch, k8s::cluster_get_metrics, k8s::cluster_get_events, + k8s::cluster_list_events, + k8s::cluster_list_nodes, // Workload commands k8s::cluster_list_deployments, k8s::cluster_delete_deployment, @@ -96,8 +100,17 @@ pub fn run() { k8s::cluster_delete_service_account, k8s::cluster_list_roles, k8s::cluster_delete_role, + k8s::cluster_list_role_bindings, + k8s::cluster_delete_role_binding, k8s::cluster_list_cluster_roles, k8s::cluster_delete_cluster_role, + k8s::cluster_list_cluster_role_bindings, + k8s::cluster_delete_cluster_role_binding, + k8s::cluster_list_crds, + k8s::cluster_delete_crd, + k8s::cluster_check_helm_available, + k8s::cluster_list_helm_releases, + k8s::cluster_list_helm_charts, // Deployment details, pods, and events k8s::cluster_get_deployment_details, k8s::cluster_get_deployment_pods, diff --git a/src/lib/components/WorkloadList.svelte b/src/lib/components/WorkloadList.svelte index 08a0724..171c860 100644 --- a/src/lib/components/WorkloadList.svelte +++ b/src/lib/components/WorkloadList.svelte @@ -1,9 +1,9 @@
+ {#if error} +
+ {error} +
+ + +
+
+ {/if} + {#if Array.isArray(value)} - {#each value.slice(0, 2) as img} + {#each value.slice(0, 2) as img (img)} {img.split('/').pop()} @@ -197,7 +219,7 @@
Images:
    - {#each selectedItem.images as img} + {#each (selectedItem.images || []) as img (img)}
  • {img}
  • {/each}
@@ -206,7 +228,7 @@
Labels:
- {#each Object.entries(selectedItem.labels) as [k, v]} + {#each Object.entries(selectedItem.labels || {}) as [k, v] (k)} {k}: {v} diff --git a/src/lib/components/ui/DataTable.svelte b/src/lib/components/ui/DataTable.svelte index 98aca05..379b515 100644 --- a/src/lib/components/ui/DataTable.svelte +++ b/src/lib/components/ui/DataTable.svelte @@ -36,6 +36,7 @@ showRefresh = true, actions, batchActions, + emptyMessage = "No data available", }: { data: any[]; columns: Column[]; @@ -50,6 +51,7 @@ showRefresh?: boolean; actions?: (row: any) => MenuItem[]; batchActions?: BatchAction[]; + emptyMessage?: string; } = $props(); let sortCol = $state(null); @@ -321,7 +323,7 @@ No data available {emptyMessage} {/if} @@ -335,7 +337,7 @@ > {selectedIds.size} selected
- {#each batchActions as action} + {#each batchActions as action (action.label)}
diff --git a/src/routes/cluster/[id]/cluster-role-bindings/+page.svelte b/src/routes/cluster/[id]/cluster-role-bindings/+page.svelte index 35a2aec..66787e2 100644 --- a/src/routes/cluster/[id]/cluster-role-bindings/+page.svelte +++ b/src/routes/cluster/[id]/cluster-role-bindings/+page.svelte @@ -1,11 +1,9 @@ -
-

Cluster Role Bindings Placeholder

-
+ diff --git a/src/routes/cluster/[id]/crd/+page.svelte b/src/routes/cluster/[id]/crd/+page.svelte index 3ee4085..bc126f2 100644 --- a/src/routes/cluster/[id]/crd/+page.svelte +++ b/src/routes/cluster/[id]/crd/+page.svelte @@ -1,11 +1,5 @@ -
-

CRD Placeholder

-
+ diff --git a/src/routes/cluster/[id]/events/+page.svelte b/src/routes/cluster/[id]/events/+page.svelte index befaa46..2e7698d 100644 --- a/src/routes/cluster/[id]/events/+page.svelte +++ b/src/routes/cluster/[id]/events/+page.svelte @@ -1,11 +1,114 @@ -
-

Events Placeholder

+
+ {#if error} +
+ {error} + +
+ {/if} + +
+ +
+ + + {#snippet children({ column, value })} + {#if column.id === "event_type"} + {value} + {:else if column.id === "message"} + {value} + {:else} + {value} + {/if} + {/snippet} +
diff --git a/src/routes/cluster/[id]/helm/charts/+page.svelte b/src/routes/cluster/[id]/helm/charts/+page.svelte index 3cc8fd7..d0196cc 100644 --- a/src/routes/cluster/[id]/helm/charts/+page.svelte +++ b/src/routes/cluster/[id]/helm/charts/+page.svelte @@ -1,11 +1,101 @@ -
-

Helm Charts Placeholder

+
+ {#if helm?.available} +
Helm: {helm.version}
+ {/if} + + {#if error} +
+ {error} + +
+ {/if} + + + {#snippet children({ column, value })} + {#if column.id === "description"} + {value} + {:else} + {value} + {/if} + {/snippet} +
diff --git a/src/routes/cluster/[id]/helm/releases/+page.svelte b/src/routes/cluster/[id]/helm/releases/+page.svelte index e540405..dcf8e3d 100644 --- a/src/routes/cluster/[id]/helm/releases/+page.svelte +++ b/src/routes/cluster/[id]/helm/releases/+page.svelte @@ -1,11 +1,114 @@ -
-

Helm Releases Placeholder

+
+ {#if helm?.available} +
Helm: {helm.version}
+ {/if} + + {#if error} +
+ {error} + +
+ {/if} + + + {#snippet children({ column, value })} + {#if column.id === "status"} + {value} + {:else} + {value} + {/if} + {/snippet} +
diff --git a/src/routes/cluster/[id]/namespaces/+page.svelte b/src/routes/cluster/[id]/namespaces/+page.svelte index 773b99e..21fbd69 100644 --- a/src/routes/cluster/[id]/namespaces/+page.svelte +++ b/src/routes/cluster/[id]/namespaces/+page.svelte @@ -1,11 +1,9 @@ -
-

Namespaces Placeholder

-
+ diff --git a/src/routes/cluster/[id]/nodes/+page.svelte b/src/routes/cluster/[id]/nodes/+page.svelte index acfc1fe..8038846 100644 --- a/src/routes/cluster/[id]/nodes/+page.svelte +++ b/src/routes/cluster/[id]/nodes/+page.svelte @@ -1 +1,160 @@ -
Nodes list coming soon...
+ + +
+ {#if error} +
+ {error} + +
+ {/if} + + + {#snippet children({ column, value })} + {#if column.id === "status"} + {value} + {:else} + {value} + {/if} + {/snippet} + + + + {#if selectedNode} +
+
+
Status: {selectedNode.status}
+
Roles: {selectedNode.roles}
+
Version: {selectedNode.version}
+
Internal IP: {selectedNode.internal_ip}
+
OS: {selectedNode.os_image}
+
Runtime: {selectedNode.container_runtime}
+
Kernel: {selectedNode.kernel_version}
+
Age: {selectedNode.age}
+
+ +
+

Capacity

+
+
CPU: {selectedNode.capacity_cpu}
+
Memory: {selectedNode.capacity_memory}
+
Pods: {selectedNode.capacity_pods}
+
+
+ +
+

Allocatable

+
+
CPU: {selectedNode.allocatable_cpu}
+
Memory: {selectedNode.allocatable_memory}
+
Pods: {selectedNode.allocatable_pods}
+
+
+ +
+

Taints

+ {#if selectedNode.taints.length > 0} +
    + {#each selectedNode.taints as taint (taint)} +
  • {taint}
  • + {/each} +
+ {:else} +
No taints
+ {/if} +
+
+ {/if} +
+
diff --git a/src/routes/cluster/[id]/role-bindings/+page.svelte b/src/routes/cluster/[id]/role-bindings/+page.svelte index 7562bc8..351fd96 100644 --- a/src/routes/cluster/[id]/role-bindings/+page.svelte +++ b/src/routes/cluster/[id]/role-bindings/+page.svelte @@ -1,11 +1,9 @@ -
-

Role Bindings Placeholder

-
+ diff --git a/src/routes/cluster/[id]/workloads/+page.svelte b/src/routes/cluster/[id]/workloads/+page.svelte index 1fbbb20..d60df1a 100644 --- a/src/routes/cluster/[id]/workloads/+page.svelte +++ b/src/routes/cluster/[id]/workloads/+page.svelte @@ -1,11 +1,124 @@ -
-

Workloads Overview Placeholder

+
+ {#if error} +
+ {error} + +
+ {/if} + +
+ {#each sections as section (section.key)} + +
+

{section.title}

+ + Open + +
+
{loading ? "..." : counts[section.key]}
+
+ {#if recent[section.key].length > 0} + {#each recent[section.key] as item (item.id)} +
+ {item.namespace}/{item.name} +
+ {/each} + {:else} +
No resources found
+ {/if} +
+
+ {/each} +
From 04b2362a6bcfdf175fd0ea68acf2a570362a19df Mon Sep 17 00:00:00 2001 From: Marc Seiler Date: Sun, 15 Feb 2026 13:56:21 -0500 Subject: [PATCH 2/8] Fix duplicate keyed each errors in WorkloadList image lists --- src/lib/components/WorkloadList.svelte | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/lib/components/WorkloadList.svelte b/src/lib/components/WorkloadList.svelte index 171c860..a538262 100644 --- a/src/lib/components/WorkloadList.svelte +++ b/src/lib/components/WorkloadList.svelte @@ -171,7 +171,7 @@ {#if column.id === "images"}
{#if Array.isArray(value)} - {#each value.slice(0, 2) as img (img)} + {#each value.slice(0, 2) as img, i (`${img}-${i}`)} {img.split('/').pop()} @@ -219,7 +219,7 @@
Images:
    - {#each (selectedItem.images || []) as img (img)} + {#each (selectedItem.images || []) as img, i (`${img}-${i}`)}
  • {img}
  • {/each}
From 6dcdd54f91788cb0164f0e65055a123e7877d495 Mon Sep 17 00:00:00 2001 From: Marc Seiler Date: Sun, 15 Feb 2026 18:15:02 -0500 Subject: [PATCH 3/8] Add YAML edit/apply and scale/restart mutation workflows --- src-tauri/src/k8s/mod.rs | 2 + src-tauri/src/k8s/mutate.rs | 177 +++++++++++++++ src-tauri/src/lib.rs | 4 + src/lib/components/WorkloadList.svelte | 209 +++++++++++++++++- .../cluster/[id]/deployments/+page.svelte | 146 +++++++++++- 5 files changed, 530 insertions(+), 8 deletions(-) create mode 100644 src-tauri/src/k8s/mutate.rs diff --git a/src-tauri/src/k8s/mod.rs b/src-tauri/src/k8s/mod.rs index b1ae62a..4d73310 100644 --- a/src-tauri/src/k8s/mod.rs +++ b/src-tauri/src/k8s/mod.rs @@ -3,6 +3,7 @@ pub mod common; pub mod deployment; pub mod helm; pub mod metrics; +pub mod mutate; pub mod pod; pub mod statefulset; pub mod watcher; @@ -12,6 +13,7 @@ pub use client::*; pub use deployment::*; pub use helm::*; pub use metrics::*; +pub use mutate::*; pub use pod::*; pub use statefulset::*; pub use watcher::*; diff --git a/src-tauri/src/k8s/mutate.rs b/src-tauri/src/k8s/mutate.rs new file mode 100644 index 0000000..1e3024f --- /dev/null +++ b/src-tauri/src/k8s/mutate.rs @@ -0,0 +1,177 @@ +use crate::cluster_manager::ClusterManagerState; +use std::io::Write; +use std::process::{Command, Stdio}; +use tauri::State; + +fn get_cluster_kubeconfig_and_context( + cluster_id: &str, + state: &State<'_, ClusterManagerState>, +) -> Result<(String, String), String> { + let manager = state + .0 + .lock() + .map_err(|e| format!("Failed to acquire lock: {}", e))?; + let cluster = manager + .get_cluster(cluster_id)? + .ok_or_else(|| format!("Cluster '{}' not found", cluster_id))?; + Ok((cluster.config_path, cluster.context_name)) +} + +#[tauri::command] +pub async fn cluster_get_resource_yaml( + cluster_id: String, + kind: String, + name: String, + namespace: Option, + state: State<'_, ClusterManagerState>, +) -> Result { + let (kubeconfig, context_name) = get_cluster_kubeconfig_and_context(&cluster_id, &state)?; + + let mut cmd = Command::new("kubectl"); + cmd.args([ + "--kubeconfig", + &kubeconfig, + "--context", + &context_name, + "get", + &kind, + &name, + "-o", + "yaml", + ]); + + if let Some(ns) = namespace { + if !ns.is_empty() && ns != "-" { + cmd.args(["-n", &ns]); + } + } + + let output = cmd + .output() + .map_err(|e| format!("Failed to execute kubectl get: {}", e))?; + + if !output.status.success() { + return Err(format!( + "kubectl get failed: {}", + String::from_utf8_lossy(&output.stderr).trim() + )); + } + + Ok(String::from_utf8_lossy(&output.stdout).to_string()) +} + +#[tauri::command] +pub async fn cluster_apply_resource_yaml( + cluster_id: String, + yaml: String, + state: State<'_, ClusterManagerState>, +) -> Result { + let (kubeconfig, context_name) = get_cluster_kubeconfig_and_context(&cluster_id, &state)?; + + let mut child = Command::new("kubectl") + .args([ + "--kubeconfig", + &kubeconfig, + "--context", + &context_name, + "apply", + "-f", + "-", + ]) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .map_err(|e| format!("Failed to execute kubectl apply: {}", e))?; + + if let Some(stdin) = child.stdin.as_mut() { + stdin + .write_all(yaml.as_bytes()) + .map_err(|e| format!("Failed to write yaml to kubectl stdin: {}", e))?; + } + + let output = child + .wait_with_output() + .map_err(|e| format!("Failed to read kubectl apply output: {}", e))?; + + if !output.status.success() { + return Err(format!( + "kubectl apply failed: {}", + String::from_utf8_lossy(&output.stderr).trim() + )); + } + + Ok(String::from_utf8_lossy(&output.stdout).trim().to_string()) +} + +#[tauri::command] +pub async fn cluster_scale_workload( + cluster_id: String, + kind: String, + namespace: String, + name: String, + replicas: i32, + state: State<'_, ClusterManagerState>, +) -> Result { + let (kubeconfig, context_name) = get_cluster_kubeconfig_and_context(&cluster_id, &state)?; + + let output = Command::new("kubectl") + .args([ + "--kubeconfig", + &kubeconfig, + "--context", + &context_name, + "scale", + &format!("{}/{}", kind, name), + "-n", + &namespace, + "--replicas", + &replicas.to_string(), + ]) + .output() + .map_err(|e| format!("Failed to execute kubectl scale: {}", e))?; + + if !output.status.success() { + return Err(format!( + "kubectl scale failed: {}", + String::from_utf8_lossy(&output.stderr).trim() + )); + } + + Ok(String::from_utf8_lossy(&output.stdout).trim().to_string()) +} + +#[tauri::command] +pub async fn cluster_restart_workload( + cluster_id: String, + kind: String, + namespace: String, + name: String, + state: State<'_, ClusterManagerState>, +) -> Result { + let (kubeconfig, context_name) = get_cluster_kubeconfig_and_context(&cluster_id, &state)?; + + let output = Command::new("kubectl") + .args([ + "--kubeconfig", + &kubeconfig, + "--context", + &context_name, + "rollout", + "restart", + &format!("{}/{}", kind, name), + "-n", + &namespace, + ]) + .output() + .map_err(|e| format!("Failed to execute kubectl rollout restart: {}", e))?; + + if !output.status.success() { + return Err(format!( + "kubectl rollout restart failed: {}", + String::from_utf8_lossy(&output.stderr).trim() + )); + } + + Ok(String::from_utf8_lossy(&output.stdout).trim().to_string()) +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index e4863b1..5fffae7 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -56,6 +56,10 @@ pub fn run() { k8s::cluster_get_events, k8s::cluster_list_events, k8s::cluster_list_nodes, + k8s::cluster_get_resource_yaml, + k8s::cluster_apply_resource_yaml, + k8s::cluster_scale_workload, + k8s::cluster_restart_workload, // Workload commands k8s::cluster_list_deployments, k8s::cluster_delete_deployment, diff --git a/src/lib/components/WorkloadList.svelte b/src/lib/components/WorkloadList.svelte index a538262..7a02674 100644 --- a/src/lib/components/WorkloadList.svelte +++ b/src/lib/components/WorkloadList.svelte @@ -6,7 +6,7 @@ import Button from "$lib/components/ui/Button.svelte"; import DataTable, { type Column } from "$lib/components/ui/DataTable.svelte"; import type { MenuItem } from "$lib/components/ui/Menu.svelte"; - import { Trash2, Eye } from "lucide-svelte"; + import { Trash2, Eye, FilePenLine, Scaling, RotateCw, Save } from "lucide-svelte"; import Drawer from "$lib/components/ui/Drawer.svelte"; let { title, listCommand, deleteCommand } = $props<{ @@ -19,10 +19,16 @@ let loading = $state(false); let search = $state(""); let error = $state(null); - - // Detail Drawer state + + // Detail drawer state let showDrawer = $state(false); let selectedItem = $state(null); + // YAML editor drawer state + let showYamlDrawer = $state(false); + let yamlTarget = $state(null); + let yamlContent = $state(""); + let loadingYaml = $state(false); + let applyingYaml = $state(false); const columns: Column[] = [ { id: "name", label: "Name", sortable: true }, @@ -42,6 +48,47 @@ } }); + const resourceKindByTitle: Record = { + Pods: "pod", + Deployments: "deployment", + StatefulSets: "statefulset", + DaemonSets: "daemonset", + ReplicaSets: "replicaset", + Jobs: "job", + CronJobs: "cronjob", + ConfigMaps: "configmap", + Secrets: "secret", + ResourceQuotas: "resourcequota", + LimitRanges: "limitrange", + HorizontalPodAutoscalers: "hpa", + PodDisruptionBudgets: "pdb", + Services: "service", + Endpoints: "endpoints", + Ingresses: "ingress", + NetworkPolicies: "networkpolicy", + PersistentVolumeClaims: "pvc", + PersistentVolumes: "pv", + StorageClasses: "storageclass", + ServiceAccounts: "serviceaccount", + Roles: "role", + RoleBindings: "rolebinding", + ClusterRoles: "clusterrole", + ClusterRoleBindings: "clusterrolebinding", + Namespaces: "namespace", + CRDs: "crd", + }; + + const scalableKinds = new Set(["deployment", "statefulset", "daemonset"]); + const restartableKinds = new Set(["deployment", "statefulset", "daemonset"]); + + function getKind(): string | null { + return resourceKindByTitle[title] ?? null; + } + + function isNamespaced(row: any): boolean { + return row?.namespace && row.namespace !== "-"; + } + async function loadData() { loading = true; error = null; @@ -96,8 +143,111 @@ } } + async function handleEditYaml(row: any) { + const kind = getKind(); + if (!kind || !activeClusterStore.clusterId) return; + + loadingYaml = true; + error = null; + yamlTarget = row; + showYamlDrawer = true; + + try { + yamlContent = await invoke("cluster_get_resource_yaml", { + clusterId: activeClusterStore.clusterId, + kind, + name: row.name, + namespace: isNamespaced(row) ? row.namespace : null, + }); + } catch (e) { + console.error("Failed to load yaml", e); + error = `Failed to load YAML for ${row.name}.`; + showYamlDrawer = false; + } finally { + loadingYaml = false; + } + } + + async function applyYamlChanges() { + if (!activeClusterStore.clusterId || !yamlContent) return; + + const confirmed = await confirm("Apply YAML changes to the cluster?", { + title: "Apply Resource YAML", + kind: "warning", + }); + if (!confirmed) return; + + applyingYaml = true; + error = null; + try { + await invoke("cluster_apply_resource_yaml", { + clusterId: activeClusterStore.clusterId, + yaml: yamlContent, + }); + showYamlDrawer = false; + await loadData(); + } catch (e) { + console.error("Failed to apply yaml", e); + error = `Failed to apply YAML changes: ${e}`; + } finally { + applyingYaml = false; + } + } + + async function handleScale(row: any) { + const kind = getKind(); + if (!kind || !scalableKinds.has(kind) || !activeClusterStore.clusterId || !isNamespaced(row)) return; + + const input = window.prompt(`Scale ${row.name} to how many replicas?`, "1"); + if (input === null) return; + const replicas = Number.parseInt(input, 10); + if (!Number.isInteger(replicas) || replicas < 0) { + error = "Replica count must be a non-negative integer."; + return; + } + + try { + await invoke("cluster_scale_workload", { + clusterId: activeClusterStore.clusterId, + kind, + namespace: row.namespace, + name: row.name, + replicas, + }); + await loadData(); + } catch (e) { + console.error("Failed to scale workload", e); + error = `Failed to scale ${row.name}.`; + } + } + + async function handleRestart(row: any) { + const kind = getKind(); + if (!kind || !restartableKinds.has(kind) || !activeClusterStore.clusterId || !isNamespaced(row)) return; + + const confirmed = await confirm(`Restart rollout for ${row.name}?`, { + title: "Restart Workload", + kind: "warning", + }); + if (!confirmed) return; + + try { + await invoke("cluster_restart_workload", { + clusterId: activeClusterStore.clusterId, + kind, + namespace: row.namespace, + name: row.name, + }); + await loadData(); + } catch (e) { + console.error("Failed to restart workload", e); + error = `Failed to restart ${row.name}.`; + } + } + function getActions(row: any): MenuItem[] { - return [ + const kind = getKind(); + const actions: MenuItem[] = [ { label: "View Details", action: () => { @@ -106,6 +256,30 @@ }, icon: Eye, }, + { + label: "Edit YAML", + action: () => handleEditYaml(row), + icon: FilePenLine, + }, + ]; + + if (kind && scalableKinds.has(kind) && isNamespaced(row)) { + actions.push({ + label: "Scale", + action: () => handleScale(row), + icon: Scaling, + }); + } + + if (kind && restartableKinds.has(kind) && isNamespaced(row)) { + actions.push({ + label: "Restart", + action: () => handleRestart(row), + icon: RotateCw, + }); + } + + actions.push( { label: "Delete", action: async () => { @@ -130,8 +304,10 @@ }, icon: Trash2, danger: true, - }, - ]; + } + ); + + return actions; } @@ -238,4 +414,25 @@ {/if}
+ + +
+ {#if loadingYaml} +
Loading YAML...
+ {:else} + +
+ + +
+ {/if} +
+
diff --git a/src/routes/cluster/[id]/deployments/+page.svelte b/src/routes/cluster/[id]/deployments/+page.svelte index 08e35e0..6ad2603 100644 --- a/src/routes/cluster/[id]/deployments/+page.svelte +++ b/src/routes/cluster/[id]/deployments/+page.svelte @@ -3,14 +3,17 @@ import { confirm } from "@tauri-apps/plugin-dialog"; import { headerStore } from "$lib/stores/header.svelte"; import { activeClusterStore } from "$lib/stores/activeCluster.svelte"; + import Button from "$lib/components/ui/Button.svelte"; import DataTable, { type Column } from "$lib/components/ui/DataTable.svelte"; + import Drawer from "$lib/components/ui/Drawer.svelte"; import type { MenuItem } from "$lib/components/ui/Menu.svelte"; - import { Trash2, Eye } from "lucide-svelte"; + import { Trash2, Eye, FilePenLine, Scaling, RotateCw, Save } from "lucide-svelte"; import DeploymentDetailDrawer from "$lib/components/DeploymentDetailDrawer.svelte"; let data = $state([]); let loading = $state(false); let search = $state(""); + let error = $state(null); // Detail Drawer state let showDrawer = $state(false); @@ -18,6 +21,11 @@ name: '', namespace: '', }); + let showYamlDrawer = $state(false); + let yamlContent = $state(""); + let yamlTarget = $state<{ name: string; namespace: string } | null>(null); + let loadingYaml = $state(false); + let applyingYaml = $state(false); const columns: Column[] = [ { id: "name", label: "Name", sortable: true }, @@ -39,6 +47,7 @@ async function loadData() { loading = true; + error = null; try { data = await invoke("cluster_list_deployments", { clusterId: activeClusterStore.clusterId, @@ -87,6 +96,95 @@ } } + async function handleEditYaml(row: any) { + if (!activeClusterStore.clusterId) return; + loadingYaml = true; + showYamlDrawer = true; + yamlTarget = { name: row.name, namespace: row.namespace }; + try { + yamlContent = await invoke("cluster_get_resource_yaml", { + clusterId: activeClusterStore.clusterId, + kind: "deployment", + name: row.name, + namespace: row.namespace, + }); + } catch (e) { + console.error("Failed to load deployment yaml", e); + error = `Failed to load YAML for ${row.name}.`; + showYamlDrawer = false; + } finally { + loadingYaml = false; + } + } + + async function handleApplyYaml() { + if (!activeClusterStore.clusterId || !yamlContent.trim()) return; + const confirmed = await confirm("Apply YAML changes to this deployment?", { + title: "Apply Deployment YAML", + kind: "warning", + }); + if (!confirmed) return; + + applyingYaml = true; + try { + await invoke("cluster_apply_resource_yaml", { + clusterId: activeClusterStore.clusterId, + yaml: yamlContent, + }); + showYamlDrawer = false; + await loadData(); + } catch (e) { + console.error("Failed to apply deployment yaml", e); + error = `Failed to apply YAML: ${e}`; + } finally { + applyingYaml = false; + } + } + + async function handleScale(row: any) { + const input = window.prompt(`Scale ${row.name} to how many replicas?`, "1"); + if (input === null) return; + const replicas = Number.parseInt(input, 10); + if (!Number.isInteger(replicas) || replicas < 0) { + error = "Replica count must be a non-negative integer."; + return; + } + + try { + await invoke("cluster_scale_workload", { + clusterId: activeClusterStore.clusterId, + kind: "deployment", + namespace: row.namespace, + name: row.name, + replicas, + }); + await loadData(); + } catch (e) { + console.error("Failed to scale deployment", e); + error = `Failed to scale ${row.name}.`; + } + } + + async function handleRestart(row: any) { + const confirmed = await confirm(`Restart rollout for ${row.name}?`, { + title: "Restart Deployment", + kind: "warning", + }); + if (!confirmed) return; + try { + await invoke("cluster_restart_workload", { + clusterId: activeClusterStore.clusterId, + kind: "deployment", + namespace: row.namespace, + name: row.name, + }); + await loadData(); + } catch (e) { + console.error("Failed to restart deployment", e); + error = `Failed to restart ${row.name}.`; + } + } + function getActions(row: any): MenuItem[] { return [ { @@ -100,6 +198,21 @@ }, icon: Eye, }, + { + label: "Edit YAML", + action: () => handleEditYaml(row), + icon: FilePenLine, + }, + { + label: "Scale", + action: () => handleScale(row), + icon: Scaling, + }, + { + label: "Restart", + action: () => handleRestart(row), + icon: RotateCw, + }, { label: "Delete", action: async () => { @@ -129,6 +242,13 @@
+ {#if error} +
+ {error} + +
+ {/if} + {#if Array.isArray(value)} - {#each value.slice(0, 2) as img} + {#each value.slice(0, 2) as img, i (`${img}-${i}`)} {img.split('/').pop()} @@ -174,4 +295,25 @@ bind:deploymentName={selectedDeployment.name} bind:namespace={selectedDeployment.namespace} /> + + +
+ {#if loadingYaml} +
Loading YAML...
+ {:else} + +
+ + +
+ {/if} +
+
From 85dc289c8f3c978f476b488915e93343177151d8 Mon Sep 17 00:00:00 2001 From: Marc Seiler Date: Mon, 16 Feb 2026 14:41:13 -0500 Subject: [PATCH 4/8] feat: Add YAML editing, syntax highlighting, and code theming (v0.2.0) Major Features: - YAML editor with syntax highlighting (CodeMirror 6) - Edit functionality for all resource types via detail panels - Independent code theme setting (light app with dark code, etc.) - Fixed multi-tab log streaming bug with proper cleanup - Syntax highlighting for static YAML displays Editor & Syntax Highlighting: - Added CodeEditor component with full YAML support - Added YamlDisplay component for read-only syntax highlighting - Theme-aware colors using CSS variables - Line numbers, search, bracket matching, auto-indent YAML Editing Workflow: - Edit button in deployment detail drawer - Edit button in all WorkloadList resource detail panels - Apply changes with confirmation dialog - Backend commands for get/apply YAML Code Theming: - New "Code Editor Theme" setting in Settings page - Options: same-as-app, kore, kore-light, rusty, rusty-light, dracula, alucard - Independent from main app theme - Persists across sessions Log Streaming Fixes: - Each tab maintains independent log subscription - Backend stream registry with tokio broadcast channels - Proper cleanup via stop_stream_logs command - Prevents memory leaks from orphaned streams Workload Mutations: - Scale workload replicas - Restart rollout - Backend commands: cluster_scale_workload, cluster_restart_workload UI/UX Improvements: - Fixed Chart.js "linear scale not registered" error - Removed duplicate App Settings button from ResourceSidebar - Added padding to Settings page - Removed edit button from Pod details (doesn't make sense for pods) Technical: - Added tokio dependency with sync and macros features - Stream management with broadcast channels - CodeMirror 6 packages (~150-250KB bundle increase) - All features support all 6 themes Co-Authored-By: Claude Sonnet 4.5 --- CHANGELOG.md | 118 +++++++++++ package.json | 7 + pnpm-lock.yaml | 188 ++++++++++++++++++ src-tauri/Cargo.lock | 13 ++ src-tauri/Cargo.toml | 1 + src-tauri/src/k8s/pod.rs | 79 ++++++-- src-tauri/src/lib.rs | 1 + src/lib/components/BottomDrawer.svelte | 25 ++- .../components/DeploymentDetailDrawer.svelte | 80 +++++++- src/lib/components/PodDetailDrawer.svelte | 15 +- src/lib/components/ResourceSidebar.svelte | 11 - src/lib/components/WorkloadList.svelte | 20 +- src/lib/components/tabs/LogsTab.svelte | 10 +- src/lib/components/ui/Chart.svelte | 28 ++- src/lib/components/ui/CodeEditor.svelte | 123 ++++++++++++ src/lib/components/ui/YamlDisplay.svelte | 89 +++++++++ src/lib/stores/settings.svelte.ts | 15 ++ src/routes/settings/+page.svelte | 45 +++-- 18 files changed, 794 insertions(+), 74 deletions(-) create mode 100644 CHANGELOG.md create mode 100644 src/lib/components/ui/CodeEditor.svelte create mode 100644 src/lib/components/ui/YamlDisplay.svelte diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..589332b --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,118 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [0.2.0] - 2026-02-16 + +### Added + +#### Sidebar Sections & Navigation +- **Complete sidebar implementation** - All placeholder sections now have functional pages with real data + - Nodes page with detailed node information and status + - Workloads Overview with aggregated health summaries + - Namespaces page with namespace management + - Events page with filtering and real-time updates + - RoleBindings and ClusterRoleBindings pages + - Helm Releases and Charts pages with capability detection + - CRDs page with schema details + +#### YAML Editing & Mutation Workflows +- **YAML Editor with Syntax Highlighting** - Professional code editor for all resources + - CodeMirror 6 integration (~150-250KB bundle increase) + - Full YAML syntax highlighting with theme-aware colors + - Line numbers, auto-indentation, bracket matching + - Search/replace (Cmd/Ctrl+F), undo/redo support + - Read-only mode for static displays + +- **Edit YAML Functionality** - Edit Kubernetes resources directly from info panels + - Edit button in Deployment detail drawer + - Edit button in all WorkloadList resource detail panels + - Apply changes with confirmation dialog + - Automatic refresh after successful apply + +- **Workload Mutation Operations** - Day-2 operations for Deployments, StatefulSets, DaemonSets + - Scale workload replicas + - Restart rollout + - Backend commands: `cluster_scale_workload`, `cluster_restart_workload` + +- **Syntax Highlighting for Static YAML** - Beautiful highlighting in annotations and configs + - `YamlDisplay` component for read-only YAML + - Theme-aware colors matching app theme + - Automatic highlighting for JSON annotations converted to YAML + +#### Theming & Customization +- **Independent Code Theme Setting** - Separate theme for code blocks + - New setting: "Code Editor Theme" in Settings page + - Options: "same-as-app" (default), or any of the 6 themes + - Allows light app with dark code or vice versa + - Persists across sessions + +#### Log Streaming Improvements +- **Multiple Log Tabs Fix** - Each tab now maintains its own log subscription + - Fixed bug where all tabs showed logs from the first tab + - Each tab has independent subscription and log buffer + - Logs continue streaming in background for all tabs + - Proper cleanup when tabs are closed + +- **Stream Cleanup & Resource Management** - Prevent memory leaks + - Backend stream registry using `tokio::sync::broadcast` + - New command: `stop_stream_logs` to cancel streams + - Frontend calls cleanup on tab close + - Automatic cleanup when streams end naturally + +### Changed + +#### UI/UX Improvements +- **Settings Page Layout** - Added proper padding (24px) to prevent content from bumping against sidebar +- **Removed Duplicate Settings Button** - Removed redundant "App Settings" from ResourceSidebar +- **Removed Edit Button from Pod Details** - Editing pods directly doesn't make sense (managed by controllers) + +#### Code Quality +- **Chart.js Components Registration** - Fixed "linear scale not registered" error + - Explicitly registered: LineController, LineElement, PointElement, LinearScale, CategoryScale, Title, Tooltip, Legend, Filler + - Tree-shakeable architecture properly configured + +### Technical Details + +#### Backend (Rust/Tauri) +- Added `tokio` dependency with `sync` and `macros` features +- Stream management with broadcast channels for cancellation +- YAML get/apply commands: `cluster_get_resource_yaml`, `cluster_apply_resource_yaml` +- Workload operations: `cluster_scale_workload`, `cluster_restart_workload` +- Stream control: `stop_stream_logs` + +#### Frontend (Svelte 5 + TypeScript) +- Added CodeMirror 6 packages: + - `codemirror@6.0.2` + - `@codemirror/lang-yaml@6.1.2` + - `@codemirror/language@6.12.1` + - `@lezer/highlight@1.2.3` +- New components: + - `CodeEditor.svelte` - Editable YAML editor with syntax highlighting + - `YamlDisplay.svelte` - Read-only syntax-highlighted YAML display +- Updated stores: + - `settings.svelte.ts` - Added `codeTheme` setting with `effectiveCodeTheme` getter + +#### Theme Support +All features support all 6 themes: +- Kore (dark) +- Kore Light +- Rusty (dark) +- Rusty Light +- Dracula +- Alucard (Dracula Light) + +### Developer Experience +- All sidebar sections now data-backed (no placeholders) +- Consistent UX patterns across all resource types +- Standardized error handling with retry/dismiss options +- Professional code editing experience matching VS Code quality + +--- + +## [0.1.1] - Previous Release + +Initial release with basic functionality. diff --git a/package.json b/package.json index 77ef067..833a3e5 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,12 @@ }, "license": "GPL-3.0-or-later", "dependencies": { + "@codemirror/commands": "^6.10.2", + "@codemirror/lang-yaml": "^6.1.2", + "@codemirror/language": "^6.12.1", + "@codemirror/state": "^6.5.4", + "@codemirror/view": "^6.39.14", + "@lezer/highlight": "^1.2.3", "@tailwindcss/vite": "^4.1.18", "@tauri-apps/api": "^2", "@tauri-apps/plugin-dialog": "~2", @@ -28,6 +34,7 @@ "@tauri-apps/plugin-websocket": "~2.4.2", "@types/js-yaml": "^4.0.9", "chart.js": "^4.5.1", + "codemirror": "^6.0.2", "highlight.js": "^11.11.1", "js-yaml": "^4.1.1", "lucide-svelte": "^0.563.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 25fcaa5..3616e76 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,6 +8,24 @@ importers: .: dependencies: + '@codemirror/commands': + specifier: ^6.10.2 + version: 6.10.2 + '@codemirror/lang-yaml': + specifier: ^6.1.2 + version: 6.1.2 + '@codemirror/language': + specifier: ^6.12.1 + version: 6.12.1 + '@codemirror/state': + specifier: ^6.5.4 + version: 6.5.4 + '@codemirror/view': + specifier: ^6.39.14 + version: 6.39.14 + '@lezer/highlight': + specifier: ^1.2.3 + version: 1.2.3 '@tailwindcss/vite': specifier: ^4.1.18 version: 4.1.18(vite@6.4.1(jiti@2.6.1)(lightningcss@1.30.2)) @@ -38,6 +56,9 @@ importers: chart.js: specifier: ^4.5.1 version: 4.5.1 + codemirror: + specifier: ^6.0.2 + version: 6.0.2 highlight.js: specifier: ^11.11.1 version: 11.11.1 @@ -140,6 +161,30 @@ packages: resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==} engines: {node: '>=18'} + '@codemirror/autocomplete@6.20.0': + resolution: {integrity: sha512-bOwvTOIJcG5FVo5gUUupiwYh8MioPLQ4UcqbcRf7UQ98X90tCa9E1kZ3Z7tqwpZxYyOvh1YTYbmZE9RTfTp5hg==} + + '@codemirror/commands@6.10.2': + resolution: {integrity: sha512-vvX1fsih9HledO1c9zdotZYUZnE4xV0m6i3m25s5DIfXofuprk6cRcLUZvSk3CASUbwjQX21tOGbkY2BH8TpnQ==} + + '@codemirror/lang-yaml@6.1.2': + resolution: {integrity: sha512-dxrfG8w5Ce/QbT7YID7mWZFKhdhsaTNOYjOkSIMt1qmC4VQnXSDSYVHHHn8k6kJUfIhtLo8t1JJgltlxWdsITw==} + + '@codemirror/language@6.12.1': + resolution: {integrity: sha512-Fa6xkSiuGKc8XC8Cn96T+TQHYj4ZZ7RdFmXA3i9xe/3hLHfwPZdM+dqfX0Cp0zQklBKhVD8Yzc8LS45rkqcwpQ==} + + '@codemirror/lint@6.9.4': + resolution: {integrity: sha512-ABc9vJ8DEmvOWuH26P3i8FpMWPQkduD9Rvba5iwb6O3hxASgclm3T3krGo8NASXkHCidz6b++LWlzWIUfEPSWw==} + + '@codemirror/search@6.6.0': + resolution: {integrity: sha512-koFuNXcDvyyotWcgOnZGmY7LZqEOXZaaxD/j6n18TCLx2/9HieZJ5H6hs1g8FiRxBD0DNfs0nXn17g872RmYdw==} + + '@codemirror/state@6.5.4': + resolution: {integrity: sha512-8y7xqG/hpB53l25CIoit9/ngxdfoG+fx+V3SHBrinnhOtLvKHRyAJJuHzkWrR4YXXLX8eXBsejgAAxHUOdW1yw==} + + '@codemirror/view@6.39.14': + resolution: {integrity: sha512-WJcvgHm/6Q7dvGT0YFv/6PSkoc36QlR0VCESS6x9tGsnF1lWLmmYxOgX3HH6v8fo6AvSLgpcs+H0Olre6MKXlg==} + '@csstools/color-helpers@5.1.0': resolution: {integrity: sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==} engines: {node: '>=18'} @@ -355,6 +400,21 @@ packages: '@kurkle/color@0.3.4': resolution: {integrity: sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==} + '@lezer/common@1.5.1': + resolution: {integrity: sha512-6YRVG9vBkaY7p1IVxL4s44n5nUnaNnGM2/AckNgYOnxTG2kWh1vR8BMxPseWPjRNpb5VtXnMpeYAEAADoRV1Iw==} + + '@lezer/highlight@1.2.3': + resolution: {integrity: sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g==} + + '@lezer/lr@1.4.8': + resolution: {integrity: sha512-bPWa0Pgx69ylNlMlPvBPryqeLYQjyJjqPx+Aupm5zydLIF3NE+6MMLT8Yi23Bd9cif9VS00aUebn+6fDIGBcDA==} + + '@lezer/yaml@1.0.4': + resolution: {integrity: sha512-2lrrHqxalACEbxIbsjhqGpSW8kWpUKuY6RHgnSAFZa6qK62wvnPxA8hGOwOoDbwHcOFs5M4o27mjGu+P7TvBmw==} + + '@marijn/find-cluster-break@1.0.2': + resolution: {integrity: sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==} + '@playwright/test@1.58.1': resolution: {integrity: sha512-6LdVIUERWxQMmUSSQi0I53GgCBYgM2RpGngCPY7hSeju+VrKjq3lvs7HpJoPbDiY5QM5EYRtRX5fvrinnMAz3w==} engines: {node: '>=18'} @@ -397,66 +457,79 @@ packages: resolution: {integrity: sha512-F8sWbhZ7tyuEfsmOxwc2giKDQzN3+kuBLPwwZGyVkLlKGdV1nvnNwYD0fKQ8+XS6hp9nY7B+ZeK01EBUE7aHaw==} cpu: [arm] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm-musleabihf@4.57.1': resolution: {integrity: sha512-rGfNUfn0GIeXtBP1wL5MnzSj98+PZe/AXaGBCRmT0ts80lU5CATYGxXukeTX39XBKsxzFpEeK+Mrp9faXOlmrw==} cpu: [arm] os: [linux] + libc: [musl] '@rollup/rollup-linux-arm64-gnu@4.57.1': resolution: {integrity: sha512-MMtej3YHWeg/0klK2Qodf3yrNzz6CGjo2UntLvk2RSPlhzgLvYEB3frRvbEF2wRKh1Z2fDIg9KRPe1fawv7C+g==} cpu: [arm64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm64-musl@4.57.1': resolution: {integrity: sha512-1a/qhaaOXhqXGpMFMET9VqwZakkljWHLmZOX48R0I/YLbhdxr1m4gtG1Hq7++VhVUmf+L3sTAf9op4JlhQ5u1Q==} cpu: [arm64] os: [linux] + libc: [musl] '@rollup/rollup-linux-loong64-gnu@4.57.1': resolution: {integrity: sha512-QWO6RQTZ/cqYtJMtxhkRkidoNGXc7ERPbZN7dVW5SdURuLeVU7lwKMpo18XdcmpWYd0qsP1bwKPf7DNSUinhvA==} cpu: [loong64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-loong64-musl@4.57.1': resolution: {integrity: sha512-xpObYIf+8gprgWaPP32xiN5RVTi/s5FCR+XMXSKmhfoJjrpRAjCuuqQXyxUa/eJTdAE6eJ+KDKaoEqjZQxh3Gw==} cpu: [loong64] os: [linux] + libc: [musl] '@rollup/rollup-linux-ppc64-gnu@4.57.1': resolution: {integrity: sha512-4BrCgrpZo4hvzMDKRqEaW1zeecScDCR+2nZ86ATLhAoJ5FQ+lbHVD3ttKe74/c7tNT9c6F2viwB3ufwp01Oh2w==} cpu: [ppc64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-ppc64-musl@4.57.1': resolution: {integrity: sha512-NOlUuzesGauESAyEYFSe3QTUguL+lvrN1HtwEEsU2rOwdUDeTMJdO5dUYl/2hKf9jWydJrO9OL/XSSf65R5+Xw==} cpu: [ppc64] os: [linux] + libc: [musl] '@rollup/rollup-linux-riscv64-gnu@4.57.1': resolution: {integrity: sha512-ptA88htVp0AwUUqhVghwDIKlvJMD/fmL/wrQj99PRHFRAG6Z5nbWoWG4o81Nt9FT+IuqUQi+L31ZKAFeJ5Is+A==} cpu: [riscv64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-riscv64-musl@4.57.1': resolution: {integrity: sha512-S51t7aMMTNdmAMPpBg7OOsTdn4tySRQvklmL3RpDRyknk87+Sp3xaumlatU+ppQ+5raY7sSTcC2beGgvhENfuw==} cpu: [riscv64] os: [linux] + libc: [musl] '@rollup/rollup-linux-s390x-gnu@4.57.1': resolution: {integrity: sha512-Bl00OFnVFkL82FHbEqy3k5CUCKH6OEJL54KCyx2oqsmZnFTR8IoNqBF+mjQVcRCT5sB6yOvK8A37LNm/kPJiZg==} cpu: [s390x] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-gnu@4.57.1': resolution: {integrity: sha512-ABca4ceT4N+Tv/GtotnWAeXZUZuM/9AQyCyKYyKnpk4yoA7QIAuBt6Hkgpw8kActYlew2mvckXkvx0FfoInnLg==} cpu: [x64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-musl@4.57.1': resolution: {integrity: sha512-HFps0JeGtuOR2convgRRkHCekD7j+gdAuXM+/i6kGzQtFhlCtQkpwtNzkNj6QhCDp7DRJ7+qC/1Vg2jt5iSOFw==} cpu: [x64] os: [linux] + libc: [musl] '@rollup/rollup-openbsd-x64@4.57.1': resolution: {integrity: sha512-H+hXEv9gdVQuDTgnqD+SQffoWoc0Of59AStSzTEj/feWTBAnSfSD3+Dql1ZruJQxmykT/JVY0dE8Ka7z0DH1hw==} @@ -570,24 +643,28 @@ packages: engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [glibc] '@tailwindcss/oxide-linux-arm64-musl@4.1.18': resolution: {integrity: sha512-1px92582HkPQlaaCkdRcio71p8bc8i/ap5807tPRDK/uw953cauQBT8c5tVGkOwrHMfc2Yh6UuxaH4vtTjGvHg==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [musl] '@tailwindcss/oxide-linux-x64-gnu@4.1.18': resolution: {integrity: sha512-v3gyT0ivkfBLoZGF9LyHmts0Isc8jHZyVcbzio6Wpzifg/+5ZJpDiRiUhDLkcr7f/r38SWNe7ucxmGW3j3Kb/g==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [glibc] '@tailwindcss/oxide-linux-x64-musl@4.1.18': resolution: {integrity: sha512-bhJ2y2OQNlcRwwgOAGMY0xTFStt4/wyU6pvI6LSuZpRgKQwxTec0/3Scu91O8ir7qCR3AuepQKLU/kX99FouqQ==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [musl] '@tailwindcss/oxide-wasm32-wasi@4.1.18': resolution: {integrity: sha512-LffYTvPjODiP6PT16oNeUQJzNVyJl1cjIebq/rWWBF+3eDst5JGEFSc5cWxyRCJ0Mxl+KyIkqRxk1XPEs9x8TA==} @@ -648,30 +725,35 @@ packages: engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [glibc] '@tauri-apps/cli-linux-arm64-musl@2.9.6': resolution: {integrity: sha512-02TKUndpodXBCR0oP//6dZWGYcc22Upf2eP27NvC6z0DIqvkBBFziQUcvi2n6SrwTRL0yGgQjkm9K5NIn8s6jw==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [musl] '@tauri-apps/cli-linux-riscv64-gnu@2.9.6': resolution: {integrity: sha512-fmp1hnulbqzl1GkXl4aTX9fV+ubHw2LqlLH1PE3BxZ11EQk+l/TmiEongjnxF0ie4kV8DQfDNJ1KGiIdWe1GvQ==} engines: {node: '>= 10'} cpu: [riscv64] os: [linux] + libc: [glibc] '@tauri-apps/cli-linux-x64-gnu@2.9.6': resolution: {integrity: sha512-vY0le8ad2KaV1PJr+jCd8fUF9VOjwwQP/uBuTJvhvKTloEwxYA/kAjKK9OpIslGA9m/zcnSo74czI6bBrm2sYA==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [glibc] '@tauri-apps/cli-linux-x64-musl@2.9.6': resolution: {integrity: sha512-TOEuB8YCFZTWVDzsO2yW0+zGcoMiPPwcUgdnW1ODnmgfwccpnihDRoks+ABT1e3fHb1ol8QQWsHSCovb3o2ENQ==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [musl] '@tauri-apps/cli-win32-arm64-msvc@2.9.6': resolution: {integrity: sha512-ujmDGMRc4qRLAnj8nNG26Rlz9klJ0I0jmZs2BPpmNNf0gM/rcVHhqbEkAaHPTBVIrtUdf7bGvQAD2pyIiUrBHQ==} @@ -850,10 +932,16 @@ packages: resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} engines: {node: '>=6'} + codemirror@6.0.2: + resolution: {integrity: sha512-VhydHotNW5w1UGK0Qj96BwSk/Zqbp9WbnyK2W/eVMv4QyF41INRGpjUhFJY7/uDNuudSc33a/PKr4iDqRduvHw==} + cookie@0.6.0: resolution: {integrity: sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==} engines: {node: '>= 0.6'} + crelt@1.0.6: + resolution: {integrity: sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==} + css-tree@3.1.0: resolution: {integrity: sha512-0eW44TGN5SQXU1mWSkKwFstI/22X2bG1nYzZTYMAWjylYURhse752YgbE4Cx46AC+bAvI+/dYTPRk1LqSUnu6w==} engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0} @@ -1050,24 +1138,28 @@ packages: engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] + libc: [glibc] lightningcss-linux-arm64-musl@1.30.2: resolution: {integrity: sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] + libc: [musl] lightningcss-linux-x64-gnu@1.30.2: resolution: {integrity: sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] + libc: [glibc] lightningcss-linux-x64-musl@1.30.2: resolution: {integrity: sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] + libc: [musl] lightningcss-win32-arm64-msvc@1.30.2: resolution: {integrity: sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ==} @@ -1217,6 +1309,9 @@ packages: std-env@3.10.0: resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} + style-mod@4.1.3: + resolution: {integrity: sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==} + supports-color@7.2.0: resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} engines: {node: '>=8'} @@ -1364,6 +1459,9 @@ packages: jsdom: optional: true + w3c-keyname@2.2.8: + resolution: {integrity: sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==} + w3c-xmlserializer@5.0.0: resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==} engines: {node: '>=18'} @@ -1456,6 +1554,62 @@ snapshots: '@bcoe/v8-coverage@1.0.2': {} + '@codemirror/autocomplete@6.20.0': + dependencies: + '@codemirror/language': 6.12.1 + '@codemirror/state': 6.5.4 + '@codemirror/view': 6.39.14 + '@lezer/common': 1.5.1 + + '@codemirror/commands@6.10.2': + dependencies: + '@codemirror/language': 6.12.1 + '@codemirror/state': 6.5.4 + '@codemirror/view': 6.39.14 + '@lezer/common': 1.5.1 + + '@codemirror/lang-yaml@6.1.2': + dependencies: + '@codemirror/autocomplete': 6.20.0 + '@codemirror/language': 6.12.1 + '@codemirror/state': 6.5.4 + '@lezer/common': 1.5.1 + '@lezer/highlight': 1.2.3 + '@lezer/lr': 1.4.8 + '@lezer/yaml': 1.0.4 + + '@codemirror/language@6.12.1': + dependencies: + '@codemirror/state': 6.5.4 + '@codemirror/view': 6.39.14 + '@lezer/common': 1.5.1 + '@lezer/highlight': 1.2.3 + '@lezer/lr': 1.4.8 + style-mod: 4.1.3 + + '@codemirror/lint@6.9.4': + dependencies: + '@codemirror/state': 6.5.4 + '@codemirror/view': 6.39.14 + crelt: 1.0.6 + + '@codemirror/search@6.6.0': + dependencies: + '@codemirror/state': 6.5.4 + '@codemirror/view': 6.39.14 + crelt: 1.0.6 + + '@codemirror/state@6.5.4': + dependencies: + '@marijn/find-cluster-break': 1.0.2 + + '@codemirror/view@6.39.14': + dependencies: + '@codemirror/state': 6.5.4 + crelt: 1.0.6 + style-mod: 4.1.3 + w3c-keyname: 2.2.8 + '@csstools/color-helpers@5.1.0': {} '@csstools/css-calc@2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)': @@ -1579,6 +1733,24 @@ snapshots: '@kurkle/color@0.3.4': {} + '@lezer/common@1.5.1': {} + + '@lezer/highlight@1.2.3': + dependencies: + '@lezer/common': 1.5.1 + + '@lezer/lr@1.4.8': + dependencies: + '@lezer/common': 1.5.1 + + '@lezer/yaml@1.0.4': + dependencies: + '@lezer/common': 1.5.1 + '@lezer/highlight': 1.2.3 + '@lezer/lr': 1.4.8 + + '@marijn/find-cluster-break@1.0.2': {} + '@playwright/test@1.58.1': dependencies: playwright: 1.58.1 @@ -1988,8 +2160,20 @@ snapshots: clsx@2.1.1: {} + codemirror@6.0.2: + dependencies: + '@codemirror/autocomplete': 6.20.0 + '@codemirror/commands': 6.10.2 + '@codemirror/language': 6.12.1 + '@codemirror/lint': 6.9.4 + '@codemirror/search': 6.6.0 + '@codemirror/state': 6.5.4 + '@codemirror/view': 6.39.14 + cookie@0.6.0: {} + crelt@1.0.6: {} + css-tree@3.1.0: dependencies: mdn-data: 2.12.2 @@ -2350,6 +2534,8 @@ snapshots: std-env@3.10.0: {} + style-mod@4.1.3: {} + supports-color@7.2.0: dependencies: has-flag: 4.0.0 @@ -2473,6 +2659,8 @@ snapshots: - tsx - yaml + w3c-keyname@2.2.8: {} + w3c-xmlserializer@5.0.0: dependencies: xml-name-validator: 5.0.0 diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 60223b3..794831d 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -2506,6 +2506,7 @@ dependencies = [ "tauri-plugin-updater", "tauri-plugin-websocket", "tempfile", + "tokio", "uuid", ] @@ -5638,9 +5639,21 @@ dependencies = [ "pin-project-lite", "signal-hook-registry", "socket2", + "tokio-macros", "windows-sys 0.61.2", ] +[[package]] +name = "tokio-macros" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + [[package]] name = "tokio-rustls" version = "0.26.4" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 4e0ecf3..10e65e4 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -29,6 +29,7 @@ tauri-plugin-notification = "2" tauri-plugin-websocket = "2" kube = { version = "3.0.1", features = ["runtime", "derive", "rustls-tls"] } k8s-openapi = { version = "0.27.0", features = ["v1_31"] } +tokio = { version = "1", features = ["sync", "macros"] } dirs = "6.0.0" futures = "0.3.31" chrono = "0.4.43" diff --git a/src-tauri/src/k8s/pod.rs b/src-tauri/src/k8s/pod.rs index 1cbb0c5..dde9045 100644 --- a/src-tauri/src/k8s/pod.rs +++ b/src-tauri/src/k8s/pod.rs @@ -6,7 +6,18 @@ use k8s_openapi::api::core::v1::Pod; use kube::api::{DeleteParams, ListParams, LogParams}; use kube::runtime::watcher; use kube::Api; +use std::collections::HashMap; +use std::sync::{Arc, OnceLock}; use tauri::{Emitter, State, Window}; +use tokio::sync::{broadcast, Mutex}; + +// Global state for managing log stream cancellation +type StreamRegistry = Arc>>>; + +fn stream_registry() -> &'static StreamRegistry { + static REGISTRY: OnceLock = OnceLock::new(); + REGISTRY.get_or_init(|| Arc::new(Mutex::new(HashMap::new()))) +} #[derive(serde::Serialize, Clone, Debug)] pub struct ContainerPort { @@ -585,27 +596,48 @@ pub async fn stream_container_logs( ..Default::default() }; + // Create a cancellation channel for this stream + let (cancel_tx, mut cancel_rx) = broadcast::channel::<()>(1); + + // Store the sender in the registry + { + let mut registry = stream_registry().lock().await; + registry.insert(stream_id.clone(), cancel_tx); + } + // Spawn a task to stream logs + let stream_id_clone = stream_id.clone(); tauri::async_runtime::spawn(async move { match pods.log_stream(&pod_name, &log_params).await { Ok(stream) => { let mut lines = stream.lines(); loop { - match lines.try_next().await { - Ok(Some(line)) => { - let event_name = format!("container_logs_{}", stream_id); - if let Err(e) = window.emit(&event_name, line) { - println!("Failed to emit log line: {}", e); - break; - } - } - Ok(None) => { - // Stream ended + tokio::select! { + // Check for cancellation signal + _ = cancel_rx.recv() => { + println!("Stream cancelled: {}", stream_id_clone); break; } - Err(e) => { - println!("Error reading log line: {}", e); - break; + // Process log lines + result = lines.try_next() => { + match result { + Ok(Some(line)) => { + let event_name = format!("container_logs_{}", stream_id_clone); + if let Err(e) = window.emit(&event_name, line) { + println!("Failed to emit log line: {}", e); + break; + } + } + Ok(None) => { + // Stream ended naturally + println!("Stream ended: {}", stream_id_clone); + break; + } + Err(e) => { + println!("Error reading log line: {}", e); + break; + } + } } } } @@ -614,11 +646,32 @@ pub async fn stream_container_logs( println!("Failed to open log stream: {}", e); } } + + // Clean up: remove from registry when stream ends + let mut registry = stream_registry().lock().await; + registry.remove(&stream_id_clone); + println!("Cleaned up stream registry: {}", stream_id_clone); }); Ok(()) } +#[tauri::command] +pub async fn stop_stream_logs(stream_id: String) -> Result<(), String> { + let mut registry = stream_registry().lock().await; + + if let Some(cancel_tx) = registry.remove(&stream_id) { + // Send cancellation signal (ignore errors if no receivers) + let _ = cancel_tx.send(()); + println!("Sent stop signal for stream: {}", stream_id); + Ok(()) + } else { + // Stream not found - it may have already ended + println!("Stream not found in registry: {}", stream_id); + Ok(()) + } +} + #[derive(Clone, serde::Serialize)] #[serde(tag = "type", content = "payload")] pub enum PodEvent { diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 5fffae7..dc0c223 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -42,6 +42,7 @@ pub fn run() { k8s::delete_pod, k8s::get_pod_events, k8s::stream_container_logs, + k8s::stop_stream_logs, k8s::start_pod_watch, // NEW: Cluster-based k8s commands k8s::cluster_list_namespaces, diff --git a/src/lib/components/BottomDrawer.svelte b/src/lib/components/BottomDrawer.svelte index 168616e..78d0c4c 100644 --- a/src/lib/components/BottomDrawer.svelte +++ b/src/lib/components/BottomDrawer.svelte @@ -145,17 +145,22 @@ {#if bottomDrawerStore.open}
-
- {#if bottomDrawerStore.activeTab} - {#if bottomDrawerStore.activeTab.type === "logs"} - - {:else if bottomDrawerStore.activeTab.type === "edit"} -
Edit functionality coming soon...
- {:else} -
Unknown tab type
- {/if} - {:else} +
+ {#if bottomDrawerStore.tabs.length === 0}
Open a pod's logs to get started
+ {:else} + + {#each bottomDrawerStore.tabs as tab} +
+ {#if tab.type === "logs"} + + {:else if tab.type === "edit"} +
Edit functionality coming soon...
+ {:else} +
Unknown tab type
+ {/if} +
+ {/each} {/if}
diff --git a/src/lib/components/DeploymentDetailDrawer.svelte b/src/lib/components/DeploymentDetailDrawer.svelte index 555fa92..32529b4 100644 --- a/src/lib/components/DeploymentDetailDrawer.svelte +++ b/src/lib/components/DeploymentDetailDrawer.svelte @@ -2,8 +2,12 @@ import Drawer from '$lib/components/ui/Drawer.svelte'; import Badge from '$lib/components/ui/Badge.svelte'; import Chart from '$lib/components/ui/Chart.svelte'; - import { Edit, RefreshCw, Trash2 } from 'lucide-svelte'; + import Button from '$lib/components/ui/Button.svelte'; + import CodeEditor from '$lib/components/ui/CodeEditor.svelte'; + import YamlDisplay from '$lib/components/ui/YamlDisplay.svelte'; + import { Edit, RefreshCw, Trash2, Save } from 'lucide-svelte'; import { invoke } from '@tauri-apps/api/core'; + import { confirm } from '@tauri-apps/plugin-dialog'; import { goto } from '$app/navigation'; import { page } from '$app/stores'; import { activeClusterStore } from '$lib/stores/activeCluster.svelte'; @@ -85,6 +89,12 @@ let loading = $state(false); let activeTab = $state<'cpu' | 'memory' | 'network' | 'filesystem'>('cpu'); + // YAML editor drawer state + let showYamlDrawer = $state(false); + let yamlContent = $state(''); + let loadingYaml = $state(false); + let applyingYaml = $state(false); + // Fetch deployment details when drawer opens $effect(() => { if (open && deploymentName && namespace) { @@ -136,9 +146,49 @@ loadDeploymentDetails(); } - function handleEdit() { - // TODO: Implement edit functionality - console.log('Edit deployment:', deploymentName); + async function handleEdit() { + if (!activeClusterStore.clusterId) return; + + loadingYaml = true; + showYamlDrawer = true; + + try { + yamlContent = await invoke('cluster_get_resource_yaml', { + clusterId: activeClusterStore.clusterId, + kind: 'deployment', + name: deploymentName, + namespace: namespace, + }); + } catch (e) { + console.error('Failed to load yaml', e); + showYamlDrawer = false; + } finally { + loadingYaml = false; + } + } + + async function applyYamlChanges() { + if (!activeClusterStore.clusterId || !yamlContent) return; + + const confirmed = await confirm('Apply YAML changes to the cluster?', { + title: 'Apply Resource YAML', + kind: 'warning', + }); + if (!confirmed) return; + + applyingYaml = true; + try { + await invoke('cluster_apply_resource_yaml', { + clusterId: activeClusterStore.clusterId, + yaml: yamlContent, + }); + showYamlDrawer = false; + await loadDeploymentDetails(); // Reload the deployment details after applying + } catch (e) { + console.error('Failed to apply yaml', e); + } finally { + applyingYaml = false; + } } function handleDelete() { @@ -331,7 +381,7 @@
{key}
{#if isYaml} -
{formatted}
+ {:else}
{formatted}
{/if} @@ -486,3 +536,23 @@
No deployment details available
{/if} + + + +
+ {#if loadingYaml} +
Loading YAML...
+ {:else} +
+ +
+
+ + +
+ {/if} +
+
diff --git a/src/lib/components/PodDetailDrawer.svelte b/src/lib/components/PodDetailDrawer.svelte index ba599aa..f1117f6 100644 --- a/src/lib/components/PodDetailDrawer.svelte +++ b/src/lib/components/PodDetailDrawer.svelte @@ -1,7 +1,7 @@ + +
+
+
diff --git a/src/lib/components/ui/YamlDisplay.svelte b/src/lib/components/ui/YamlDisplay.svelte new file mode 100644 index 0000000..f26692e --- /dev/null +++ b/src/lib/components/ui/YamlDisplay.svelte @@ -0,0 +1,89 @@ + + +
+
{@html highlighted}
+
+ + diff --git a/src/lib/stores/settings.svelte.ts b/src/lib/stores/settings.svelte.ts index eb5d2df..c2a0206 100644 --- a/src/lib/stores/settings.svelte.ts +++ b/src/lib/stores/settings.svelte.ts @@ -1,13 +1,16 @@ export type Theme = 'kore' | 'kore-light' | 'rusty' | 'rusty-light' | 'dracula' | 'alucard'; +export type CodeTheme = 'same-as-app' | 'kore' | 'kore-light' | 'rusty' | 'rusty-light' | 'dracula' | 'alucard'; export interface Settings { theme: Theme; + codeTheme: CodeTheme; refreshInterval: number; } class SettingsStore { value = $state({ theme: 'kore', + codeTheme: 'same-as-app', refreshInterval: 5000, }); @@ -30,11 +33,23 @@ class SettingsStore { this.save(); } + setCodeTheme(codeTheme: CodeTheme) { + this.value.codeTheme = codeTheme; + this.save(); + } + setRefreshInterval(ms: number) { this.value.refreshInterval = ms; this.save(); } + get effectiveCodeTheme(): Theme { + if (this.value.codeTheme === 'same-as-app') { + return this.value.theme; + } + return this.value.codeTheme as Theme; + } + save() { if (typeof localStorage !== 'undefined') { localStorage.setItem('app-settings', JSON.stringify(this.value)); diff --git a/src/routes/settings/+page.svelte b/src/routes/settings/+page.svelte index e2cab4c..e42d187 100644 --- a/src/routes/settings/+page.svelte +++ b/src/routes/settings/+page.svelte @@ -1,13 +1,14 @@ -
+

Settings

@@ -25,16 +26,34 @@

Customize the look and feel of the application.

-
- -
- settingsStore.setTheme(val as Theme)} + placeholder="Select Theme" + /> +
+
+ +
+ +

+ Choose a different theme for code blocks and YAML editors +

+
+