diff --git a/src/app/state.rs b/src/app/state.rs index d075095..3f8003e 100644 --- a/src/app/state.rs +++ b/src/app/state.rs @@ -2,9 +2,10 @@ use crate::{ commands::palette::CommandAction, config::settings::Settings, domain::{menu::MenuItem, screen::Screen}, - project::{ProjectCapabilities, ProjectContext, RuntimeCapabilities}, + project::{runtime, ProjectCapabilities, ProjectContext, RuntimeCapabilities}, storage::history::ProjectHistory, ui::{ + menus::CapabilityMenuGenerator, state::{Notification, UiState}, theme::ThemeName, }, @@ -56,6 +57,17 @@ impl AppState { self.ui .push_notification(Notification::warning(message.into())); } + + pub fn refresh_runtime(&mut self) { + self.runtime = runtime::detect(&self.capabilities); + self.menus = CapabilityMenuGenerator::generate(&self.capabilities, &self.runtime); + self.actions = crate::commands::palette::generate_actions( + &self.project, + &self.capabilities, + &self.runtime, + ); + self.status_message = "Runtime refreshed".to_string(); + } } #[derive(Debug, Clone)] diff --git a/src/config/paths.rs b/src/config/paths.rs index 91a278a..36fe213 100644 --- a/src/config/paths.rs +++ b/src/config/paths.rs @@ -23,6 +23,14 @@ pub fn doctor_report_file() -> PathBuf { config_dir().join("doctor_report.json") } +pub fn command_history_file() -> PathBuf { + config_dir().join("command_history.yaml") +} + +pub fn project_cache_file() -> PathBuf { + config_dir().join("project_cache.yaml") +} + #[cfg(test)] mod tests { use super::*; @@ -38,4 +46,10 @@ mod tests { let path = doctor_report_file(); assert!(path.to_string_lossy().contains("doctor_report.json")); } + + #[test] + fn command_history_path_is_under_config_dir() { + let path = command_history_file(); + assert!(path.to_string_lossy().contains("command_history.yaml")); + } } diff --git a/src/kubernetes/command.rs b/src/kubernetes/command.rs new file mode 100644 index 0000000..0b4d481 --- /dev/null +++ b/src/kubernetes/command.rs @@ -0,0 +1,44 @@ +use anyhow::{Context, Result}; +use std::process::Command; + +pub fn run_kubectl(args: &[&str]) -> Result { + let output = Command::new("kubectl") + .args(args) + .output() + .with_context(|| format!("Failed to run kubectl {}", args.join(" ")))?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + anyhow::bail!("kubectl {} failed: {}", args.join(" "), stderr.trim()); + } + + Ok(String::from_utf8_lossy(&output.stdout).to_string()) +} + +pub fn non_empty_lines(output: &str) -> impl Iterator { + output + .lines() + .map(str::trim) + .filter(|line| !line.is_empty()) +} + +pub fn kubectl_list(resource: &str, namespace: Option<&str>) -> Result { + let mut args = vec!["get", resource, "--no-headers"]; + if let Some(namespace) = namespace { + args.push("-n"); + args.push(namespace); + } + + run_kubectl(&args) +} + +#[cfg(test)] +mod tests { + use super::non_empty_lines; + + #[test] + fn trims_empty_lines() { + let lines = non_empty_lines(" a \n\n b\n").collect::>(); + assert_eq!(lines, vec!["a", "b"]); + } +} diff --git a/src/kubernetes/configmaps.rs b/src/kubernetes/configmaps.rs index 6b360ce..8b4fae6 100644 --- a/src/kubernetes/configmaps.rs +++ b/src/kubernetes/configmaps.rs @@ -2,3 +2,31 @@ pub struct ConfigMapSummary { pub name: String, } + +pub fn list(namespace: &str) -> anyhow::Result> { + let output = crate::kubernetes::command::kubectl_list("configmaps", Some(namespace))?; + Ok(parse_names(&output)) +} + +pub fn get(name: &str, namespace: &str) -> anyhow::Result { + crate::kubernetes::command::run_kubectl(&[ + "get", + "configmap", + name, + "-n", + namespace, + "-o", + "yaml", + ]) +} + +fn parse_names(output: &str) -> Vec { + crate::kubernetes::command::non_empty_lines(output) + .filter_map(|line| { + let name = line.split_whitespace().next()?; + Some(ConfigMapSummary { + name: name.to_string(), + }) + }) + .collect() +} diff --git a/src/kubernetes/deployments.rs b/src/kubernetes/deployments.rs index 4645217..638037f 100644 --- a/src/kubernetes/deployments.rs +++ b/src/kubernetes/deployments.rs @@ -4,3 +4,57 @@ pub struct DeploymentSummary { pub namespace: String, pub ready: String, } + +pub fn list(namespace: &str) -> anyhow::Result> { + let output = crate::kubernetes::command::kubectl_list("deployments", Some(namespace))?; + Ok(parse_deployments(namespace, &output)) +} + +pub fn get(name: &str, namespace: &str) -> anyhow::Result { + crate::kubernetes::command::run_kubectl(&[ + "get", + "deployment", + name, + "-n", + namespace, + "-o", + "yaml", + ]) +} + +pub fn scale(name: &str, namespace: &str, replicas: u16) -> anyhow::Result { + crate::kubernetes::command::run_kubectl(&[ + "scale", + "deployment", + name, + "-n", + namespace, + "--replicas", + &replicas.to_string(), + ]) +} + +fn parse_deployments(namespace: &str, output: &str) -> Vec { + crate::kubernetes::command::non_empty_lines(output) + .filter_map(|line| { + let columns = line.split_whitespace().collect::>(); + Some(DeploymentSummary { + name: columns.first()?.to_string(), + namespace: namespace.to_string(), + ready: columns.get(1).unwrap_or(&"unknown").to_string(), + }) + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::parse_deployments; + + #[test] + fn parses_deployment_rows() { + let deployments = parse_deployments("default", "web 2/2 2 2 3m\n"); + assert_eq!(deployments[0].name, "web"); + assert_eq!(deployments[0].ready, "2/2"); + } +} diff --git a/src/kubernetes/events.rs b/src/kubernetes/events.rs index 93ce2a0..d6111ce 100644 --- a/src/kubernetes/events.rs +++ b/src/kubernetes/events.rs @@ -3,3 +3,27 @@ pub struct KubernetesEvent { pub reason: String, pub message: String, } + +pub fn list(namespace: &str) -> anyhow::Result> { + let output = crate::kubernetes::command::run_kubectl(&[ + "get", + "events", + "-n", + namespace, + "--no-headers", + ])?; + Ok(parse_events(&output)) +} + +fn parse_events(output: &str) -> Vec { + crate::kubernetes::command::non_empty_lines(output) + .filter_map(|line| { + let columns = line.split_whitespace().collect::>(); + let reason = columns.get(2).or_else(|| columns.first())?; + Some(KubernetesEvent { + reason: reason.to_string(), + message: line.to_string(), + }) + }) + .collect() +} diff --git a/src/kubernetes/ingress.rs b/src/kubernetes/ingress.rs index 19ca097..0a838eb 100644 --- a/src/kubernetes/ingress.rs +++ b/src/kubernetes/ingress.rs @@ -3,3 +3,37 @@ pub struct IngressSummary { pub name: String, pub host: String, } + +pub fn list(namespace: &str) -> anyhow::Result> { + let output = crate::kubernetes::command::kubectl_list("ingress", Some(namespace))?; + Ok(parse_ingress(&output)) +} + +pub fn get(name: &str, namespace: &str) -> anyhow::Result { + crate::kubernetes::command::run_kubectl(&[ + "get", "ingress", name, "-n", namespace, "-o", "yaml", + ]) +} + +fn parse_ingress(output: &str) -> Vec { + crate::kubernetes::command::non_empty_lines(output) + .filter_map(|line| { + let columns = line.split_whitespace().collect::>(); + Some(IngressSummary { + name: columns.first()?.to_string(), + host: columns.get(2).unwrap_or(&"").to_string(), + }) + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::parse_ingress; + + #[test] + fn parses_ingress() { + let ingress = parse_ingress("web nginx app.example.com 1.2.3.4 80 1m\n"); + assert_eq!(ingress[0].host, "app.example.com"); + } +} diff --git a/src/kubernetes/mod.rs b/src/kubernetes/mod.rs index 39e4a01..7b6f6c6 100644 --- a/src/kubernetes/mod.rs +++ b/src/kubernetes/mod.rs @@ -1,3 +1,4 @@ +pub mod command; pub mod configmaps; pub mod deployments; pub mod events; diff --git a/src/kubernetes/namespaces.rs b/src/kubernetes/namespaces.rs index 6a76f7a..5bbbe55 100644 --- a/src/kubernetes/namespaces.rs +++ b/src/kubernetes/namespaces.rs @@ -2,3 +2,23 @@ pub struct NamespaceSummary { pub name: String, } + +pub fn list() -> anyhow::Result> { + let output = crate::kubernetes::command::kubectl_list("namespaces", None)?; + Ok(parse_namespaces(&output)) +} + +pub fn get(name: &str) -> anyhow::Result { + crate::kubernetes::command::run_kubectl(&["get", "namespace", name, "-o", "yaml"]) +} + +fn parse_namespaces(output: &str) -> Vec { + crate::kubernetes::command::non_empty_lines(output) + .filter_map(|line| { + let name = line.split_whitespace().next()?; + Some(NamespaceSummary { + name: name.to_string(), + }) + }) + .collect() +} diff --git a/src/kubernetes/pods.rs b/src/kubernetes/pods.rs index 8aaabd4..338daec 100644 --- a/src/kubernetes/pods.rs +++ b/src/kubernetes/pods.rs @@ -4,3 +4,50 @@ pub struct PodSummary { pub namespace: String, pub status: String, } + +pub fn list(namespace: &str) -> anyhow::Result> { + let output = crate::kubernetes::command::kubectl_list("pods", Some(namespace))?; + Ok(parse_pods(namespace, &output)) +} + +pub fn get(name: &str, namespace: &str) -> anyhow::Result { + crate::kubernetes::command::run_kubectl(&["get", "pod", name, "-n", namespace, "-o", "yaml"]) +} + +pub fn delete(name: &str, namespace: &str) -> anyhow::Result { + crate::kubernetes::command::run_kubectl(&["delete", "pod", name, "-n", namespace]) +} + +pub fn logs(name: &str, namespace: &str, tail: Option) -> anyhow::Result> { + let tail = tail.unwrap_or(100).to_string(); + let output = + crate::kubernetes::command::run_kubectl(&["logs", name, "-n", namespace, "--tail", &tail])?; + Ok(crate::kubernetes::command::non_empty_lines(&output) + .map(str::to_string) + .collect()) +} + +fn parse_pods(namespace: &str, output: &str) -> Vec { + crate::kubernetes::command::non_empty_lines(output) + .filter_map(|line| { + let columns = line.split_whitespace().collect::>(); + Some(PodSummary { + name: columns.first()?.to_string(), + namespace: namespace.to_string(), + status: columns.get(2).unwrap_or(&"Unknown").to_string(), + }) + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::parse_pods; + + #[test] + fn parses_pod_rows() { + let pods = parse_pods("default", "web-abc 1/1 Running 0 1m\n"); + assert_eq!(pods[0].name, "web-abc"); + assert_eq!(pods[0].status, "Running"); + } +} diff --git a/src/kubernetes/secrets.rs b/src/kubernetes/secrets.rs index ee9a970..b4d994e 100644 --- a/src/kubernetes/secrets.rs +++ b/src/kubernetes/secrets.rs @@ -2,3 +2,23 @@ pub struct SecretSummary { pub name: String, } + +pub fn list(namespace: &str) -> anyhow::Result> { + let output = crate::kubernetes::command::kubectl_list("secrets", Some(namespace))?; + Ok(parse_names(&output)) +} + +pub fn get(name: &str, namespace: &str) -> anyhow::Result { + crate::kubernetes::command::run_kubectl(&["get", "secret", name, "-n", namespace, "-o", "yaml"]) +} + +fn parse_names(output: &str) -> Vec { + crate::kubernetes::command::non_empty_lines(output) + .filter_map(|line| { + let name = line.split_whitespace().next()?; + Some(SecretSummary { + name: name.to_string(), + }) + }) + .collect() +} diff --git a/src/kubernetes/services.rs b/src/kubernetes/services.rs index 73ca78b..45964af 100644 --- a/src/kubernetes/services.rs +++ b/src/kubernetes/services.rs @@ -3,3 +3,37 @@ pub struct ServiceSummary { pub name: String, pub namespace: String, } + +pub fn list(namespace: &str) -> anyhow::Result> { + let output = crate::kubernetes::command::kubectl_list("services", Some(namespace))?; + Ok(parse_services(namespace, &output)) +} + +pub fn get(name: &str, namespace: &str) -> anyhow::Result { + crate::kubernetes::command::run_kubectl(&[ + "get", "service", name, "-n", namespace, "-o", "yaml", + ]) +} + +fn parse_services(namespace: &str, output: &str) -> Vec { + crate::kubernetes::command::non_empty_lines(output) + .filter_map(|line| { + let name = line.split_whitespace().next()?; + Some(ServiceSummary { + name: name.to_string(), + namespace: namespace.to_string(), + }) + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::parse_services; + + #[test] + fn parses_services() { + let services = parse_services("default", "web ClusterIP 10.0.0.1 80/TCP 1m\n"); + assert_eq!(services[0].name, "web"); + } +} diff --git a/src/main.rs b/src/main.rs index 8f96978..636b111 100644 --- a/src/main.rs +++ b/src/main.rs @@ -114,12 +114,8 @@ fn main() -> anyhow::Result<()> { run_deploy(cli.project, &environment)?; } None => { - let state = startup::initialize_with_options( - cli.project, - StartupOptions { - force_first_launch: cli.first_launch, - }, - )?; + let state = + startup::initialize_with_options(cli.project, interactive_startup_options())?; ui::dashboard::run(state)?; } } @@ -127,6 +123,12 @@ fn main() -> anyhow::Result<()> { Ok(()) } +fn interactive_startup_options() -> StartupOptions { + StartupOptions { + force_first_launch: true, + } +} + fn run_doctor(full: bool, json: bool) -> anyhow::Result<()> { let report = if full { let settings = config::settings::Settings::load_or_default(&config::paths::config_file())?; @@ -214,3 +216,13 @@ fn chrono_timestamp() -> String { .unwrap_or_default(); format!("epoch:{}", duration.as_secs()) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn plain_interactive_launch_starts_at_folder_selection() { + assert!(interactive_startup_options().force_first_launch); + } +} diff --git a/src/monitoring/events.rs b/src/monitoring/events.rs index 53d4b0c..168529e 100644 --- a/src/monitoring/events.rs +++ b/src/monitoring/events.rs @@ -2,3 +2,25 @@ pub struct MonitoringEvent { pub message: String, } + +pub fn kubernetes_events(namespace: &str) -> anyhow::Result> { + Ok(crate::kubernetes::events::list(namespace)? + .into_iter() + .map(|event| MonitoringEvent { + message: format!("{}: {}", event.reason, event.message), + }) + .collect()) +} + +#[cfg(test)] +mod tests { + use super::MonitoringEvent; + + #[test] + fn event_holds_message() { + let event = MonitoringEvent { + message: "rolled out".to_string(), + }; + assert_eq!(event.message, "rolled out"); + } +} diff --git a/src/monitoring/health.rs b/src/monitoring/health.rs index 4867790..fe6f5f9 100644 --- a/src/monitoring/health.rs +++ b/src/monitoring/health.rs @@ -5,3 +5,57 @@ pub enum HealthStatus { Warning, Critical, } + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct HealthReport { + pub docker: HealthStatus, + pub kubernetes: HealthStatus, + pub registry: HealthStatus, +} + +impl HealthReport { + pub fn overall(&self) -> HealthStatus { + if [self.docker, self.kubernetes, self.registry].contains(&HealthStatus::Critical) { + HealthStatus::Critical + } else if [self.docker, self.kubernetes, self.registry].contains(&HealthStatus::Warning) { + HealthStatus::Warning + } else if [self.docker, self.kubernetes, self.registry].contains(&HealthStatus::Unknown) { + HealthStatus::Unknown + } else { + HealthStatus::Healthy + } + } +} + +pub fn from_runtime(runtime: &crate::project::RuntimeCapabilities) -> HealthReport { + HealthReport { + docker: if runtime.docker_running { + HealthStatus::Healthy + } else { + HealthStatus::Warning + }, + kubernetes: if runtime.cluster_connected { + HealthStatus::Healthy + } else { + HealthStatus::Warning + }, + registry: if runtime.registry_connected { + HealthStatus::Healthy + } else { + HealthStatus::Unknown + }, + } +} + +#[cfg(test)] +mod tests { + use crate::project::RuntimeCapabilities; + + use super::{from_runtime, HealthStatus}; + + #[test] + fn health_report_rolls_up_warning() { + let report = from_runtime(&RuntimeCapabilities::default()); + assert_eq!(report.overall(), HealthStatus::Warning); + } +} diff --git a/src/monitoring/logs.rs b/src/monitoring/logs.rs index 8571698..633769f 100644 --- a/src/monitoring/logs.rs +++ b/src/monitoring/logs.rs @@ -3,3 +3,62 @@ pub struct LogEntry { pub source: String, pub message: String, } + +pub fn docker_logs(tail: u16) -> anyhow::Result> { + let containers = crate::docker::containers::list()?; + let Some(container) = containers.first() else { + return Ok(Vec::new()); + }; + let lines = crate::docker::logs::fetch(&container.id, tail.into())?; + Ok(lines + .into_iter() + .map(|line| LogEntry { + source: format!("docker/{}", container.name), + message: line.message, + }) + .collect()) +} + +pub fn compose_logs(root: &std::path::Path, tail: u16) -> anyhow::Result> { + let request = crate::compose::logs::ComposeLogRequest { + tail: Some(tail.into()), + ..Default::default() + }; + Ok(crate::compose::logs::fetch(&request, root)? + .into_iter() + .map(|message| LogEntry { + source: "compose".to_string(), + message, + }) + .collect()) +} + +pub fn pod_logs(namespace: &str, tail: u16) -> anyhow::Result> { + let pods = crate::kubernetes::pods::list(namespace)?; + let Some(pod) = pods.first() else { + return Ok(Vec::new()); + }; + Ok( + crate::kubernetes::pods::logs(&pod.name, namespace, Some(tail))? + .into_iter() + .map(|message| LogEntry { + source: format!("pod/{}", pod.name), + message, + }) + .collect(), + ) +} + +#[cfg(test)] +mod tests { + use super::LogEntry; + + #[test] + fn log_entry_fields() { + let entry = LogEntry { + source: "app".to_string(), + message: "ready".to_string(), + }; + assert_eq!(entry.source, "app"); + } +} diff --git a/src/monitoring/metrics.rs b/src/monitoring/metrics.rs index 25de84e..20f4203 100644 --- a/src/monitoring/metrics.rs +++ b/src/monitoring/metrics.rs @@ -3,3 +3,33 @@ pub struct ResourceMetrics { pub cpu_percent: f32, pub memory_percent: f32, } + +impl ResourceMetrics { + pub fn empty() -> Self { + Self { + cpu_percent: 0.0, + memory_percent: 0.0, + } + } +} + +#[derive(Debug, Clone, PartialEq)] +pub struct MonitoringSnapshot { + pub health: crate::monitoring::health::HealthReport, + pub metrics: ResourceMetrics, + pub logs: Vec, + pub events: Vec, +} + +pub fn snapshot( + runtime: &crate::project::RuntimeCapabilities, + logs: Vec, + events: Vec, +) -> MonitoringSnapshot { + MonitoringSnapshot { + health: crate::monitoring::health::from_runtime(runtime), + metrics: ResourceMetrics::empty(), + logs, + events, + } +} diff --git a/src/plugins/loader.rs b/src/plugins/loader.rs index bb89aab..9a611bf 100644 --- a/src/plugins/loader.rs +++ b/src/plugins/loader.rs @@ -1,5 +1,67 @@ -use crate::plugins::registry::PluginRegistry; +use std::path::Path; + +use anyhow::{Context, Result}; + +use crate::plugins::registry::{PluginDefinition, PluginRegistry}; pub fn load_installed() -> PluginRegistry { - PluginRegistry::default() + load_from_dir(&crate::config::paths::config_dir().join("plugins")).unwrap_or_default() +} + +pub fn load_from_dir(path: &Path) -> Result { + let mut registry = PluginRegistry::default(); + if !path.exists() { + return Ok(registry); + } + + for entry in std::fs::read_dir(path) + .with_context(|| format!("Unable to read plugin directory {}", path.display()))? + { + let entry = entry?; + let path = entry.path(); + let is_yaml = path + .extension() + .and_then(|extension| extension.to_str()) + .map(|extension| matches!(extension, "yaml" | "yml")) + .unwrap_or(false); + if !is_yaml { + continue; + } + + let content = std::fs::read_to_string(&path) + .with_context(|| format!("Unable to read plugin manifest {}", path.display()))?; + let plugin: PluginDefinition = serde_yaml::from_str(&content) + .with_context(|| format!("Unable to parse plugin manifest {}", path.display()))?; + registry.register(plugin); + } + + Ok(registry) +} + +#[cfg(test)] +mod tests { + use std::time::{SystemTime, UNIX_EPOCH}; + + use super::load_from_dir; + + #[test] + fn loads_yaml_plugin_manifests() { + let dir = std::env::temp_dir().join(format!( + "kdc-plugins-{}", + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write( + dir.join("aws.yaml"), + "name: aws\ncapabilities:\n - aws\nmenus:\n - id: aws.eks\n label: EKS\n", + ) + .unwrap(); + + let registry = load_from_dir(&dir).unwrap(); + assert_eq!(registry.names(), vec!["aws"]); + std::fs::remove_dir_all(dir).unwrap(); + } } diff --git a/src/plugins/manager.rs b/src/plugins/manager.rs index 30737ce..072c845 100644 --- a/src/plugins/manager.rs +++ b/src/plugins/manager.rs @@ -4,3 +4,19 @@ use crate::plugins::registry::PluginRegistry; pub struct PluginManager { pub registry: PluginRegistry, } + +impl PluginManager { + pub fn load() -> Self { + Self { + registry: crate::plugins::loader::load_installed(), + } + } + + pub fn capabilities(&self) -> Vec<&str> { + self.registry + .plugins + .iter() + .flat_map(|plugin| plugin.capabilities.iter().map(String::as_str)) + .collect() + } +} diff --git a/src/plugins/registry.rs b/src/plugins/registry.rs index a2820a5..bc65571 100644 --- a/src/plugins/registry.rs +++ b/src/plugins/registry.rs @@ -1,4 +1,43 @@ -#[derive(Debug, Clone, Default, PartialEq, Eq)] +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] pub struct PluginRegistry { - pub names: Vec, + pub plugins: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct PluginDefinition { + pub name: String, + #[serde(default)] + pub capabilities: Vec, + #[serde(default)] + pub menus: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct PluginMenu { + pub id: String, + pub label: String, + #[serde(default = "default_screen")] + pub screen: String, +} + +fn default_screen() -> String { + "settings".to_string() +} + +impl PluginRegistry { + pub fn names(&self) -> Vec<&str> { + self.plugins + .iter() + .map(|plugin| plugin.name.as_str()) + .collect() + } + + pub fn register(&mut self, plugin: PluginDefinition) { + self.plugins.retain(|existing| existing.name != plugin.name); + self.plugins.push(plugin); + self.plugins + .sort_by(|left, right| left.name.cmp(&right.name)); + } } diff --git a/src/plugins/traits.rs b/src/plugins/traits.rs index e762a0f..0afd27d 100644 --- a/src/plugins/traits.rs +++ b/src/plugins/traits.rs @@ -4,3 +4,33 @@ pub trait Plugin { fn name(&self) -> &str; fn register(&self) -> Vec; } + +pub fn menu_items_from_registry( + registry: &crate::plugins::registry::PluginRegistry, +) -> Vec { + registry + .plugins + .iter() + .flat_map(|plugin| { + plugin.menus.iter().map(|menu| { + MenuItem::visible( + &menu.id, + &menu.label, + screen_from_plugin(&menu.screen), + None, + ) + }) + }) + .collect() +} + +fn screen_from_plugin(screen: &str) -> crate::domain::screen::Screen { + match screen { + "docker" => crate::domain::screen::Screen::Docker, + "compose" => crate::domain::screen::Screen::Compose, + "kubernetes" => crate::domain::screen::Screen::Kubernetes, + "monitoring" => crate::domain::screen::Screen::Monitoring, + "deployments" => crate::domain::screen::Screen::Deployments, + _ => crate::domain::screen::Screen::Settings, + } +} diff --git a/src/storage/sqlite.rs b/src/storage/sqlite.rs index 2489e8d..5ef366b 100644 --- a/src/storage/sqlite.rs +++ b/src/storage/sqlite.rs @@ -1,6 +1,112 @@ use std::path::PathBuf; +use anyhow::{Context, Result}; +use serde::{Deserialize, Serialize}; + #[derive(Debug, Clone, PartialEq, Eq)] pub struct DatabaseConfig { pub path: PathBuf, } + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct CommandHistoryStore { + pub records: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct CommandHistoryRecord { + pub action_id: String, + pub success: bool, + pub message: String, +} + +impl CommandHistoryStore { + pub fn record( + &mut self, + action_id: impl Into, + success: bool, + message: impl Into, + ) { + self.records.insert( + 0, + CommandHistoryRecord { + action_id: action_id.into(), + success, + message: message.into(), + }, + ); + self.records.truncate(100); + } + + pub fn load_or_default(path: &std::path::Path) -> Result { + if !path.exists() { + return Ok(Self::default()); + } + let content = std::fs::read_to_string(path) + .with_context(|| format!("Unable to read command history from {}", path.display()))?; + serde_yaml::from_str(&content) + .with_context(|| format!("Unable to parse command history from {}", path.display())) + } + + pub fn save(&self, path: &std::path::Path) -> Result<()> { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent) + .with_context(|| format!("Unable to create {}", parent.display()))?; + } + let content = serde_yaml::to_string(self).context("Unable to serialize command history")?; + std::fs::write(path, content) + .with_context(|| format!("Unable to write command history to {}", path.display())) + } +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct ProjectCacheStore { + pub projects: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ProjectCacheEntry { + pub root: PathBuf, + pub stack: String, + pub capabilities: String, +} + +impl ProjectCacheStore { + pub fn upsert(&mut self, entry: ProjectCacheEntry) { + self.projects.retain(|project| project.root != entry.root); + self.projects.insert(0, entry); + self.projects.truncate(50); + } +} + +#[cfg(test)] +mod tests { + use std::path::PathBuf; + + use super::{CommandHistoryStore, ProjectCacheEntry, ProjectCacheStore}; + + #[test] + fn command_history_is_newest_first() { + let mut store = CommandHistoryStore::default(); + store.record("a", true, "ok"); + store.record("b", false, "no"); + assert_eq!(store.records[0].action_id, "b"); + } + + #[test] + fn project_cache_upserts() { + let mut cache = ProjectCacheStore::default(); + cache.upsert(ProjectCacheEntry { + root: PathBuf::from("/tmp/app"), + stack: "Rust".to_string(), + capabilities: "docker=false".to_string(), + }); + cache.upsert(ProjectCacheEntry { + root: PathBuf::from("/tmp/app"), + stack: "Node.js".to_string(), + capabilities: "docker=true".to_string(), + }); + assert_eq!(cache.projects.len(), 1); + assert_eq!(cache.projects[0].stack, "Node.js"); + } +} diff --git a/src/ui/dashboard.rs b/src/ui/dashboard.rs index e41afb7..7ac1c49 100644 --- a/src/ui/dashboard.rs +++ b/src/ui/dashboard.rs @@ -18,6 +18,7 @@ use crate::{ deploy, domain::screen::Screen, project::analyzer, + services::executor::{CommandExecutor, KdcExecutor}, ui::{ command_palette, folder_picker, state::{FirstLaunchChoice, Notification, NotificationLevel, UiPhase}, @@ -69,10 +70,12 @@ fn run_loop(terminal: &mut DefaultTerminal, state: &mut AppState) -> io::Result< match (key.code, key.modifiers) { (KeyCode::Char('q'), _) => break, + (KeyCode::Esc, _) if state.ui.has_execution_output() => { + state.ui.clear_execution_output() + } (KeyCode::Char('p'), KeyModifiers::CONTROL) => state.ui.palette.open(), (KeyCode::Char('r'), KeyModifiers::CONTROL) => { - state.ui.start_scanning(); - state.status_message = "Refreshing project scan".to_string(); + refresh_project(state)?; } (KeyCode::Char('b'), KeyModifiers::CONTROL) => { route_action(state, "docker.build") @@ -84,9 +87,18 @@ fn run_loop(terminal: &mut DefaultTerminal, state: &mut AppState) -> io::Result< router::route_to(state, Screen::Monitoring) } (KeyCode::Char('t'), _) => cycle_theme(state), - (KeyCode::Down, _) | (KeyCode::Char('j'), _) => navigation::move_next(state), - (KeyCode::Up, _) | (KeyCode::Char('k'), _) => navigation::move_previous(state), - (KeyCode::Enter, _) => router::select_current_menu(state), + (KeyCode::Down, _) | (KeyCode::Char('j'), _) => { + state.ui.clear_execution_output(); + navigation::move_next(state); + } + (KeyCode::Up, _) | (KeyCode::Char('k'), _) => { + state.ui.clear_execution_output(); + navigation::move_previous(state); + } + (KeyCode::Enter, _) => { + state.ui.clear_execution_output(); + router::select_current_menu(state); + } _ => {} } } @@ -843,12 +855,10 @@ fn handle_palette_key(state: &mut AppState, code: KeyCode) { KeyCode::Enter => { let selected = command_palette::search_actions(&state.actions, &state.ui.palette.query) .get(state.ui.palette.selected) - .map(|action| (action.screen, action.label.clone())); - if let Some((screen, label)) = selected { - router::route_to(state, screen); - state.status_message = format!("Selected {label}"); + .map(|action| (action.id.clone(), action.screen, action.label.clone())); + if let Some((id, screen, label)) = selected { state.ui.palette.close(); - state.ui.push_notification(Notification::success(label)); + execute_action(state, &id, screen, &label); } } _ => {} @@ -870,9 +880,7 @@ fn route_action(state: &mut AppState, id: &str) { }); if let Some((screen, enabled, label, reason)) = action { if enabled { - router::route_to(state, screen); - state.status_message = format!("Selected {label}"); - state.ui.push_notification(Notification::success(label)); + execute_action(state, id, screen, &label); } else { state.ui.push_notification(Notification::warning( reason.unwrap_or_else(|| "Action unavailable".to_string()), @@ -881,6 +889,55 @@ fn route_action(state: &mut AppState, id: &str) { } } +fn execute_action(state: &mut AppState, id: &str, screen: Screen, label: &str) { + router::route_to(state, screen); + let executor = KdcExecutor::new(&state.project); + match executor.execute(id) { + Ok(result) => { + state.status_message = result.message.clone(); + let mut output = if result.output_lines.is_empty() { + vec![result.message.clone()] + } else { + result.output_lines + }; + if !result.success { + output.insert(0, result.message.clone()); + } + state.ui.show_execution_output(label.to_string(), output); + if result.success { + state + .ui + .push_notification(Notification::success(result.message)); + } else { + state + .ui + .push_notification(Notification::warning(result.message)); + } + } + Err(err) => { + let message = format!("{label} failed: {err}"); + state.status_message = message.clone(); + state + .ui + .show_execution_output(label.to_string(), vec![message.clone()]); + state.ui.push_notification(Notification::warning(message)); + } + } +} + +fn refresh_project(state: &mut AppState) -> io::Result<()> { + let root = state.project.root.clone(); + let active_theme = state.ui.active_theme; + let mut refreshed = startup::initialize(root).map_err(io::Error::other)?; + refreshed.ui.active_theme = active_theme; + refreshed.ui.start_scanning(); + refreshed + .ui + .push_notification(Notification::info("Project and runtime refreshed")); + *state = refreshed; + Ok(()) +} + fn reload_project(state: &mut AppState, path: PathBuf) -> io::Result<()> { let mut new_state = startup::initialize(path.clone()).map_err(io::Error::other)?; new_state.ui.active_theme = state.ui.active_theme; diff --git a/src/utils/test_support.rs b/src/utils/test_support.rs index 2d5739a..56a944d 100644 --- a/src/utils/test_support.rs +++ b/src/utils/test_support.rs @@ -103,9 +103,57 @@ case "$1" in fi ;; get) - if [ "$2" = "nodes" ]; then - echo "node1 node2" - fi + case "$2" in + nodes) + echo "node1 node2" + ;; + deployments) + echo "web 2/2 2 2 3m" + ;; + deployment) + echo "apiVersion: apps/v1" + echo "kind: Deployment" + ;; + pods) + echo "web-abc 1/1 Running 0 1m" + ;; + pod) + echo "apiVersion: v1" + echo "kind: Pod" + ;; + services) + echo "web ClusterIP 10.0.0.1 80/TCP 1m" + ;; + service) + echo "apiVersion: v1" + echo "kind: Service" + ;; + ingress) + echo "web nginx app.example.com 1.2.3.4 80 1m" + ;; + configmaps) + echo "app-config 1 1m" + ;; + secrets) + echo "app-secret Opaque 1 1m" + ;; + namespaces) + echo "default Active 1d" + ;; + events) + echo "1m Normal Started pod/web Started container" + ;; + esac + ;; + logs) + echo "pod log 1" + echo "pod log 2" + ;; + delete) + echo "$2 \"$3\" deleted" + ;; + scale) + echo "deployment.apps/$3 scaled" ;; rollout) case "$2" in