kdash 1.1.0

A fast and simple dashboard for Kubernetes
use anyhow::anyhow;
use async_trait::async_trait;
use k8s_openapi::api::core::v1::{Node, Pod};
use kube::{
  api::{ListParams, ObjectMeta},
  Api,
};
use kubectl_view_allocations::{
  extract_allocatable_from_nodes, extract_allocatable_from_pods,
  extract_utilizations_from_pod_metrics, make_qualifiers,
  metrics::{PodMetrics, Usage},
  qty::Qty,
  tree::provide_prefix,
  Resource, UsedMode,
};
use ratatui::{
  layout::{Constraint, Rect},
  widgets::{Cell, Row, Table},
  Frame,
};
use serde::{Deserialize, Serialize};
use tokio::sync::MutexGuard;

use super::{models::AppResource, utils, ActiveBlock, App};
use crate::app::{key_binding::DEFAULT_KEYBINDING, models::FilterableTable};
use crate::{
  network::Network,
  ui::utils::{
    action_hint, default_part, filter_cursor_position, filter_status_parts, help_part,
    layout_block_active_span, loading, mixed_bold_line, style_caution, style_highlight,
    style_primary, style_success, table_header_style, text_matches_filter, title_with_dual_style,
  },
};

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct NodeMetrics {
  metadata: kube::api::ObjectMeta,
  usage: Usage,
  timestamp: String,
  window: String,
}

// custom impl since metrics API doesn't exist on kube-rs
impl k8s_openapi::Resource for NodeMetrics {
  const GROUP: &'static str = "metrics.k8s.io";
  const KIND: &'static str = "node";
  const VERSION: &'static str = "v1beta1";
  const API_VERSION: &'static str = "metrics.k8s.io/v1beta1";
  const URL_PATH_SEGMENT: &'static str = "nodes";
  type Scope = k8s_openapi::ClusterResourceScope;
}

impl k8s_openapi::Metadata for NodeMetrics {
  type Ty = ObjectMeta;

  fn metadata(&self) -> &Self::Ty {
    &self.metadata
  }

  fn metadata_mut(&mut self) -> &mut Self::Ty {
    &mut self.metadata
  }
}

#[derive(Clone, Default, Debug, PartialEq)]
pub struct KubeNodeMetrics {
  pub name: String,
  pub cpu: String,
  pub cpu_percent: f64,
  pub mem: String,
  pub mem_percent: f64,
}

impl KubeNodeMetrics {
  pub fn from_api(metric: &NodeMetrics, app: &MutexGuard<'_, App>) -> Self {
    let name = metric.metadata.name.clone().unwrap_or_default();

    let (cpu_percent, mem_percent) = match app.data.node_metrics.iter().find(|it| it.name == name) {
      Some(nm) => (nm.cpu_percent, nm.mem_percent),
      None => (0f64, 0f64),
    };

    KubeNodeMetrics {
      name,
      cpu: utils::cpu_to_milli(metric.usage.cpu.trim_matches('"').to_owned()),
      mem: utils::mem_to_mi(metric.usage.memory.trim_matches('"').to_owned()),
      cpu_percent,
      mem_percent,
    }
  }
}

pub struct UtilizationResource {}

#[async_trait]
impl AppResource for UtilizationResource {
  fn render(_block: ActiveBlock, f: &mut Frame<'_>, app: &mut App, area: Rect) {
    let left_title = format!(
      " Resource Utilization (ns: [{}]) [{}] ",
      app
        .data
        .selected
        .ns
        .as_ref()
        .unwrap_or(&String::from("all")),
      app.data.metrics.count_label(),
    );
    let group_by_value = format!(": {:?}", app.utilization_group_by);
    let title = title_with_dual_style(
      left_title.clone(),
      {
        let mut parts =
          filter_status_parts(&app.data.metrics.filter, app.data.metrics.filter_active);
        if !app.data.metrics.filter_active {
          parts.push(help_part(" | ".to_string()));
          parts.push(help_part(action_hint(
            "group by",
            DEFAULT_KEYBINDING.cycle_group_by.key,
          )));
          parts.push(default_part(group_by_value.clone()));
          parts.push(default_part(" ".to_string()));
        }
        mixed_bold_line(parts, app.light_theme)
      },
      app.light_theme,
    );
    let block = layout_block_active_span(title, app.light_theme);

    if !app.data.metrics.items.is_empty() {
      let data = &app.data.metrics.items;
      let filter = app.data.metrics.filter.to_lowercase();
      let has_filter = !filter.is_empty();

      let prefixes = provide_prefix(data, |parent, item| parent.0.len() + 1 == item.0.len());

      // Create the table
      let mut filtered_indices: Vec<usize> = Vec::new();
      let mut rows: Vec<Row<'_>> = vec![];
      for (idx, ((k, oqtys), prefix)) in data.iter().zip(prefixes.iter()).enumerate() {
        if !utilization_matches_filter(&filter, k) {
          continue;
        }
        let column0 = format!(
          "{} {}",
          prefix,
          k.last().map(|x| x.as_str()).unwrap_or("???")
        );
        if let Some(qtys) = oqtys {
          let style = if qtys.requested > qtys.limit || qtys.utilization > qtys.limit {
            style_caution(app.light_theme)
          } else if is_empty(&qtys.requested) || is_empty(&qtys.limit) {
            style_primary(app.light_theme)
          } else {
            style_success(app.light_theme)
          };

          let row = Row::new(vec![
            Cell::from(column0),
            make_table_cell(&qtys.utilization, &qtys.allocatable),
            make_table_cell(&qtys.requested, &qtys.allocatable),
            make_table_cell(&qtys.limit, &qtys.allocatable),
            make_table_cell(&qtys.allocatable, &None),
            make_table_cell(&qtys.calc_free(UsedMode::default()), &None),
          ])
          .style(style);
          rows.push(row);
          if has_filter {
            filtered_indices.push(idx);
          }
        }
      }

      if has_filter {
        let max = filtered_indices.len().saturating_sub(1);
        if let Some(sel) = app.data.metrics.state.selected() {
          if sel > max {
            app.data.metrics.state.select(Some(max));
          }
        }
      }
      app.data.metrics.filtered_indices = filtered_indices;

      let table = Table::new(
        rows,
        [
          Constraint::Percentage(50),
          Constraint::Percentage(10),
          Constraint::Percentage(10),
          Constraint::Percentage(10),
          Constraint::Percentage(10),
          Constraint::Percentage(10),
        ],
      )
      .header(table_header_style(
        vec![
          "Resource",
          "Utilization",
          "Requested",
          "Limit",
          "Allocatable",
          "Free",
        ],
        app.light_theme,
      ))
      .block(block)
      .row_highlight_style(style_highlight());

      f.render_stateful_widget(table, area, &mut app.data.metrics.state);
    } else {
      loading(f, block, area, app.is_loading(), app.light_theme);
    }

    if app.data.metrics.filter_active {
      f.set_cursor_position(filter_cursor_position(
        area,
        left_title.chars().count() + 1,
        &app.data.metrics.filter,
      ));
    }
  }

  async fn get_resource(nw: &Network<'_>) {
    let mut resources: Vec<Resource> = vec![];

    let node_api: Api<Node> = Api::all(nw.client.clone());
    match node_api.list(&ListParams::default()).await {
      Ok(node_list) => {
        if let Err(e) = extract_allocatable_from_nodes(node_list.items, &mut resources).await {
          nw.handle_error(anyhow!("Failed to extract node allocation metrics. {}", e))
            .await;
        }
      }
      Err(e) => {
        nw.handle_error(anyhow!("Failed to extract node allocation metrics. {}", e))
          .await
      }
    }

    let pod_api: Api<Pod> = nw.get_namespaced_api().await;
    match pod_api.list(&ListParams::default()).await {
      Ok(pod_list) => {
        if let Err(e) = extract_allocatable_from_pods(pod_list.items, &mut resources, &[]).await {
          nw.handle_error(anyhow!("Failed to extract pod allocation metrics. {}", e))
            .await;
        }
      }
      Err(e) => {
        nw.handle_error(anyhow!("Failed to extract pod allocation metrics. {}", e))
          .await
      }
    }

    let api_pod_metrics: Api<PodMetrics> = Api::all(nw.client.clone());

    match api_pod_metrics
    .list(&ListParams::default())
    .await
    {
      Ok(pod_metrics) => {
        if let Err(e) = extract_utilizations_from_pod_metrics(pod_metrics, &mut resources).await {
          nw.handle_error(anyhow!("Failed to extract pod utilization metrics. {}", e)).await;
        }
      }
      Err(_e) => nw.handle_error(anyhow!("Failed to extract pod utilization metrics. Make sure you have a metrics-server deployed on your cluster.")).await,
    };

    let mut app = nw.app.lock().await;

    let data = make_qualifiers(&resources, &app.utilization_group_by, &[]);

    app.data.metrics.set_items(data);
  }
}

fn utilization_matches_filter(filter: &str, qualifiers: &[String]) -> bool {
  filter.is_empty()
    || qualifiers
      .iter()
      .any(|part| text_matches_filter(filter, part))
    || text_matches_filter(filter, &qualifiers.join(" "))
}

fn make_table_cell<'a>(oqty: &Option<Qty>, o100: &Option<Qty>) -> Cell<'a> {
  let txt = match oqty {
    None => "__".into(),
    Some(ref qty) => match o100 {
      None => format!("{}", qty.adjust_scale()),
      Some(q100) => format!("{} ({:.0}%)", qty.adjust_scale(), qty.calc_percentage(q100)),
    },
  };
  Cell::from(txt)
}

fn is_empty(oqty: &Option<Qty>) -> bool {
  match oqty {
    Some(qty) => qty.is_zero(),
    None => true,
  }
}

#[cfg(test)]
mod tests {
  use tokio::sync::Mutex;

  use super::*;
  use crate::app::test_utils::load_resource_from_file;

  #[tokio::test]
  async fn test_kube_node_metrics_from_api() {
    let node_metrics = load_resource_from_file("node_metrics");
    assert_eq!(node_metrics.items.len(), 2);

    let mut app = App::default();
    app.data.node_metrics = vec![KubeNodeMetrics {
      name: "k3d-my-kdash-cluster-server-0".into(),
      cpu: "".into(),
      cpu_percent: 10f64,
      mem: "".into(),
      mem_percent: 20f64,
    }];
    let app = Mutex::new(app);
    let app = app.lock().await;

    let metrics = node_metrics
      .iter()
      .map(|it| KubeNodeMetrics::from_api(it, &app))
      .collect::<Vec<_>>();
    assert_eq!(metrics.len(), 2);
    assert_eq!(
      metrics[0],
      KubeNodeMetrics {
        name: "k3d-my-kdash-cluster-server-0".into(),
        cpu: "162m".into(),
        cpu_percent: 10f64,
        mem: "569Mi".into(),
        mem_percent: 20f64,
      }
    );
    assert_eq!(
      metrics[1],
      KubeNodeMetrics {
        name: "k3d-my-kdash-cluster-server-1".into(),
        cpu: "102m".into(),
        cpu_percent: 0f64,
        mem: "276Mi".into(),
        mem_percent: 0f64,
      }
    );
  }

  #[test]
  fn test_utilization_matches_filter() {
    let qualifiers = vec![
      "namespace-a".to_string(),
      "pod-web-123".to_string(),
      "container-1".to_string(),
    ];

    assert!(utilization_matches_filter("", &qualifiers));
    assert!(utilization_matches_filter("pod-web", &qualifiers));
    assert!(utilization_matches_filter("namespace-*", &qualifiers));
    assert!(utilization_matches_filter(
      "namespace-a pod-web-123",
      &qualifiers
    ));
    assert!(!utilization_matches_filter("db", &qualifiers));
  }
}