grafatui 0.1.12

A Grafana-like TUI for Prometheus
use anyhow::Result;

use super::{DashboardImport, GridPos, ImportDiagnostic, QueryPanel, TemplateQueryVar, model};

pub(super) fn finish(dashboard: model::Dashboard) -> Result<DashboardImport> {
    let mut out = DashboardImport {
        title: dashboard.title,
        refresh_rate_ms: dashboard.refresh.as_deref().and_then(parse_refresh_rate_ms),
        skipped_panels: dashboard.skipped_panels,
        diagnostics: dashboard.diagnostics,
        ..DashboardImport::default()
    };
    import_variables(&mut out, dashboard.variables);
    import_panels(&mut out, dashboard.panels)?;
    Ok(out)
}

fn import_variables(out: &mut DashboardImport, variables: Vec<model::Variable>) {
    for variable in variables {
        let value = variable
            .current
            .as_ref()
            .and_then(|current| current.value.as_ref())
            .or(variable
                .current
                .as_ref()
                .and_then(|current| current.text.as_ref()));
        if let Some(value) = value {
            let mut value = match value {
                serde_json::Value::String(value) => value.clone(),
                serde_json::Value::Array(values) => values
                    .iter()
                    .find_map(|value| value.as_str())
                    .unwrap_or("")
                    .to_string(),
                serde_json::Value::Number(value) => value.to_string(),
                _ => String::new(),
            };
            if value == "$__all" {
                value = variable
                    .all_value
                    .clone()
                    .unwrap_or_else(|| ".*".to_string());
            }
            if !value.is_empty() {
                out.vars.insert(variable.name.clone(), value);
            }
        }

        if variable.kind.as_deref() == Some("query")
            && !current_is_all(variable.current.as_ref())
            && let (Some(query), Some(query_path)) = (variable.query, variable.query_path)
        {
            out.query_vars.push(TemplateQueryVar {
                name: variable.name,
                query,
                regex: variable.regex.filter(|regex| !regex.trim().is_empty()),
                query_path,
            });
        }
    }
}

fn current_is_all(current: Option<&model::VariableCurrent>) -> bool {
    current.is_some_and(|current| {
        value_is_all(current.value.as_ref()) || value_is_all(current.text.as_ref())
    })
}

fn value_is_all(value: Option<&serde_json::Value>) -> bool {
    match value {
        Some(serde_json::Value::String(value)) => {
            value == "$__all" || value.eq_ignore_ascii_case("all")
        }
        Some(serde_json::Value::Array(values)) => {
            values.iter().any(|value| value_is_all(Some(value)))
        }
        _ => false,
    }
}

fn import_panels(out: &mut DashboardImport, panels: Vec<model::Panel>) -> Result<()> {
    for panel in panels {
        let panel_type = match panel.kind.as_str() {
            "graph" | "timeseries" => crate::app::PanelType::Graph,
            "stat" => crate::app::PanelType::Stat,
            "gauge" => crate::app::PanelType::Gauge,
            "bargauge" => crate::app::PanelType::BarGauge,
            "table" => crate::app::PanelType::Table,
            "heatmap" => crate::app::PanelType::Heatmap,
            _ => crate::app::PanelType::Unknown,
        };

        if panel_type == crate::app::PanelType::Unknown {
            if !panel.kind.is_empty() && panel.kind != "row" {
                out.skipped_panels += 1;
                out.diagnostics.push(ImportDiagnostic::new(
                    "skipped_panel",
                    panel.source_path,
                    format!(
                        "unsupported panel type `{}` skipped for panel `{}`",
                        panel.kind, panel.title
                    ),
                ));
            }
            continue;
        }

        let mut exprs = Vec::new();
        let mut expr_paths = Vec::new();
        let mut legends = Vec::new();
        let mut query_modes = Vec::new();
        for target in panel.targets {
            if target.hidden {
                continue;
            }
            if let Some(expr) = target.expr {
                exprs.push(expr);
                expr_paths.push(target.expr_path);
                legends.push(target.legend_format);
                query_modes.push(query_mode_for_target(target.instant, panel_type));
            }
        }

        let mut thresholds = None;
        let mut min = None;
        let mut max = None;
        let mut autogrid = None;
        let mut display = crate::ui::DisplayFormat::default();
        let mut graph_options = crate::app::GraphOptions::default();

        if let Some(path) = panel.transformations_path {
            out.diagnostics.push(ImportDiagnostic::new(
                "ignored_field",
                path,
                "`transformations` are not supported yet; queries will run without Grafana transformations",
            ));
        }

        if let Some(path) = panel.reduce_options_path {
            out.diagnostics.push(ImportDiagnostic::new(
                "ignored_field",
                path,
                "`options.reduceOptions` is not supported yet; Grafatui will use default value selection",
            ));
        }

        if let Some(defaults) = panel.field_defaults {
            if let Some(path) = defaults.mappings_path {
                out.diagnostics.push(ImportDiagnostic::new(
                    "ignored_field",
                    path,
                    "`fieldConfig.defaults.mappings` is not supported yet; value mappings will be ignored",
                ));
            }
            graph_options = graph_options_from_custom(defaults.custom.as_ref());
            display = crate::ui::DisplayFormat {
                unit: defaults.unit,
                decimals: defaults.decimals,
                no_value: defaults.no_value,
            };
            min = defaults.min;
            max = defaults.max;
            autogrid = defaults
                .custom
                .as_ref()
                .and_then(|custom| custom.axis_grid_show);
            thresholds = thresholds_from_model(defaults.thresholds, defaults.custom.as_ref());
        }

        if !exprs.is_empty() {
            let options = match panel_type {
                crate::app::PanelType::Graph => crate::app::PanelOptions::Graph(graph_options),
                _ => crate::app::PanelOptions::None,
            };
            out.queries.push(QueryPanel {
                title: panel.title,
                exprs,
                expr_paths,
                legends,
                query_modes,
                grid: panel.grid.map(|grid| GridPos {
                    x: grid.x,
                    y: grid.y,
                    w: grid.w,
                    h: grid.h,
                }),
                panel_type,
                thresholds,
                min,
                max,
                autogrid,
                display,
                options,
            });
        } else if panel.count_as_skipped_if_empty {
            out.skipped_panels += 1;
        }
    }
    Ok(())
}

fn query_mode_for_target(
    instant: Option<bool>,
    panel_type: crate::app::PanelType,
) -> crate::app::QueryMode {
    match instant {
        Some(true) => crate::app::QueryMode::Instant,
        Some(false) => crate::app::QueryMode::Range,
        None => default_query_mode_for_panel(panel_type),
    }
}

fn default_query_mode_for_panel(panel_type: crate::app::PanelType) -> crate::app::QueryMode {
    match panel_type {
        crate::app::PanelType::Gauge
        | crate::app::PanelType::BarGauge
        | crate::app::PanelType::Table => crate::app::QueryMode::Instant,
        _ => crate::app::QueryMode::Range,
    }
}

fn graph_options_from_custom(custom: Option<&model::GraphCustom>) -> crate::app::GraphOptions {
    let Some(custom) = custom else {
        return crate::app::GraphOptions::default();
    };
    crate::app::GraphOptions {
        draw_style: parse_graph_draw_style(custom.draw_style.as_deref()),
        show_points: parse_graph_point_mode(custom.show_points.as_deref()),
        fill_opacity: custom.fill_opacity.map(|value| value.min(100) as u8),
        axis_placement: parse_graph_axis_placement(custom.axis_placement.as_deref()),
        line_interpolation: custom
            .line_interpolation
            .as_ref()
            .filter(|value| !value.trim().is_empty())
            .cloned(),
        stacking: parse_graph_stacking_mode(custom.stacking_mode.as_deref()),
    }
}

fn thresholds_from_model(
    thresholds: Option<model::Thresholds>,
    custom: Option<&model::GraphCustom>,
) -> Option<crate::app::Thresholds> {
    let thresholds = thresholds?;
    let mode = match thresholds.mode.as_deref() {
        Some("percentage") => crate::app::ThresholdMode::Percentage,
        _ => crate::app::ThresholdMode::Absolute,
    };
    let mut steps: Vec<_> = thresholds
        .steps
        .into_iter()
        .map(|step| {
            let color = step.color.unwrap_or_else(|| "green".to_string());
            crate::app::ThresholdStep {
                value: step.value,
                color: crate::theme::parse_grafana_color(&color),
            }
        })
        .collect();
    steps.sort_by(|a, b| {
        let a_value = a.value.unwrap_or(f64::NEG_INFINITY);
        let b_value = b.value.unwrap_or(f64::NEG_INFINITY);
        a_value
            .partial_cmp(&b_value)
            .unwrap_or(std::cmp::Ordering::Equal)
    });
    (!steps.is_empty()).then(|| crate::app::Thresholds {
        mode,
        steps,
        style: Some(
            custom
                .and_then(|custom| custom.thresholds_style_mode.clone())
                .unwrap_or_else(|| "line".to_string()),
        ),
    })
}

fn parse_graph_draw_style(value: Option<&str>) -> crate::app::GraphDrawStyle {
    match value {
        Some("points") => crate::app::GraphDrawStyle::Points,
        Some("bars") => crate::app::GraphDrawStyle::Bars,
        _ => crate::app::GraphDrawStyle::Line,
    }
}

fn parse_graph_point_mode(value: Option<&str>) -> crate::app::GraphPointMode {
    match value {
        Some("always") => crate::app::GraphPointMode::Always,
        Some("never") => crate::app::GraphPointMode::Never,
        _ => crate::app::GraphPointMode::Auto,
    }
}

fn parse_graph_axis_placement(value: Option<&str>) -> crate::app::GraphAxisPlacement {
    match value {
        Some("hidden") => crate::app::GraphAxisPlacement::Hidden,
        _ => crate::app::GraphAxisPlacement::Visible,
    }
}

fn parse_graph_stacking_mode(value: Option<&str>) -> crate::app::GraphStackingMode {
    match value {
        Some("normal") => crate::app::GraphStackingMode::Normal,
        Some("percent") => crate::app::GraphStackingMode::Percent,
        _ => crate::app::GraphStackingMode::Off,
    }
}

fn parse_refresh_rate_ms(refresh: &str) -> Option<u64> {
    let refresh = refresh.trim();
    if refresh.is_empty()
        || refresh.eq_ignore_ascii_case("false")
        || refresh.eq_ignore_ascii_case("off")
    {
        return None;
    }
    let duration = humantime::parse_duration(refresh).ok()?;
    u64::try_from(duration.as_millis()).ok()
}