grafatui 0.1.12

A Grafana-like TUI for Prometheus
use anyhow::{Context, Result};
use serde::Deserialize;
use serde_json::Value;

use super::model;

#[derive(Debug, Deserialize)]
struct RawDashboard {
    title: Option<String>,
    refresh: Option<Value>,
    panels: Option<Vec<RawPanel>>,
    templating: Option<RawTemplating>,
}

#[derive(Debug, Deserialize)]
struct RawTemplating {
    list: Option<Vec<RawVar>>,
}

#[derive(Debug, Deserialize)]
struct RawVar {
    name: String,
    #[serde(rename = "type")]
    var_type: Option<String>,
    query: Option<RawVarQuery>,
    definition: Option<String>,
    regex: Option<String>,
    current: Option<RawVarCurrent>,
    #[serde(rename = "allValue")]
    all_value: Option<String>,
}

#[derive(Debug, Deserialize)]
#[serde(untagged)]
enum RawVarQuery {
    String(String),
    Object { query: Option<String> },
}

#[derive(Debug, Deserialize)]
struct RawVarCurrent {
    text: Option<Value>,
    value: Option<Value>,
}

#[derive(Debug, Deserialize)]
struct RawPanel {
    #[serde(rename = "type")]
    panel_type: String,
    title: Option<String>,
    targets: Option<Vec<RawTarget>>,
    #[serde(rename = "gridPos")]
    grid_pos: Option<RawGridPos>,
    panels: Option<Vec<RawPanel>>,
    #[serde(rename = "fieldConfig")]
    field_config: Option<RawFieldConfig>,
    options: Option<RawPanelOptions>,
}

#[derive(Debug, Deserialize)]
struct RawPanelOptions {
    #[serde(rename = "reduceOptions")]
    reduce_options: Option<Value>,
}

#[derive(Debug, Deserialize)]
struct RawFieldConfig {
    defaults: Option<RawFieldConfigDefaults>,
}

#[derive(Debug, Deserialize)]
struct RawFieldConfigDefaults {
    unit: Option<String>,
    decimals: Option<usize>,
    #[serde(rename = "noValue")]
    no_value: Option<String>,
    min: Option<f64>,
    max: Option<f64>,
    thresholds: Option<RawThresholds>,
    custom: Option<RawCustom>,
    mappings: Option<Value>,
}

#[derive(Debug, Deserialize)]
struct RawCustom {
    #[serde(rename = "drawStyle")]
    draw_style: Option<String>,
    #[serde(rename = "showPoints")]
    show_points: Option<String>,
    #[serde(rename = "fillOpacity")]
    fill_opacity: Option<u16>,
    #[serde(rename = "axisPlacement")]
    axis_placement: Option<String>,
    #[serde(rename = "lineInterpolation")]
    line_interpolation: Option<String>,
    stacking: Option<RawStacking>,
    #[serde(rename = "axisGridShow")]
    axis_grid_show: Option<bool>,
    #[serde(rename = "thresholdsStyle")]
    thresholds_style: Option<RawThresholdsStyle>,
}

#[derive(Debug, Deserialize)]
struct RawThresholdsStyle {
    mode: Option<String>,
}

#[derive(Debug, Deserialize)]
struct RawStacking {
    mode: Option<String>,
}

#[derive(Debug, Deserialize)]
struct RawThresholds {
    mode: Option<String>,
    steps: Option<Vec<RawThresholdStep>>,
}

#[derive(Debug, Deserialize)]
struct RawThresholdStep {
    value: Option<f64>,
    color: Option<String>,
}

#[derive(Debug, Deserialize)]
struct RawTarget {
    expr: Option<String>,
    #[serde(rename = "legendFormat")]
    legend_format: Option<String>,
    instant: Option<bool>,
    hide: Option<bool>,
}

#[derive(Debug, Deserialize)]
struct RawGridPos {
    x: i32,
    y: i32,
    w: i32,
    h: i32,
}

pub(super) fn adapt(value: Value) -> Result<model::Dashboard> {
    let raw: RawDashboard =
        serde_json::from_value(value).context("parsing Grafana Classic dashboard JSON")?;
    let mut dashboard = model::Dashboard {
        title: raw.title.unwrap_or_default(),
        refresh: raw
            .refresh
            .and_then(|value| value.as_str().map(str::to_owned)),
        ..model::Dashboard::default()
    };
    dashboard.variables = raw
        .templating
        .and_then(|templating| templating.list)
        .unwrap_or_default()
        .into_iter()
        .enumerate()
        .map(|(index, variable)| variable.normalize(index))
        .collect();
    normalize_panels(
        raw.panels.unwrap_or_default(),
        "panels",
        &mut dashboard.panels,
    );
    Ok(dashboard)
}

impl RawVar {
    fn query_string(&self) -> Option<String> {
        let query = self
            .query
            .as_ref()
            .and_then(|query| match query {
                RawVarQuery::String(query) => Some(query.as_str()),
                RawVarQuery::Object { query } => query.as_deref(),
            })
            .or(self.definition.as_deref())?;
        let query = query.trim();
        (!query.is_empty()).then(|| query.to_string())
    }

    fn normalize(self, index: usize) -> model::Variable {
        let query = self.query_string();
        model::Variable {
            name: self.name,
            kind: self.var_type,
            current: self.current.map(|current| model::VariableCurrent {
                text: current.text,
                value: current.value,
            }),
            query_path: query
                .is_some()
                .then(|| format!("templating.list[{index}].query")),
            query,
            regex: self.regex,
            all_value: self.all_value,
            source_path: format!("templating.list[{index}]"),
        }
    }
}

fn normalize_panels(panels: Vec<RawPanel>, path: &str, out: &mut Vec<model::Panel>) {
    for (index, panel) in panels.into_iter().enumerate() {
        let source_path = format!("{path}[{index}]");
        if let Some(children) = panel.panels {
            normalize_panels(children, &format!("{source_path}.panels"), out);
        }

        let field_defaults = panel.field_config.and_then(|config| {
            config.defaults.map(|defaults| model::FieldDefaults {
                unit: defaults.unit,
                decimals: defaults.decimals,
                no_value: defaults.no_value,
                min: defaults.min,
                max: defaults.max,
                thresholds: defaults.thresholds.map(|thresholds| model::Thresholds {
                    mode: thresholds.mode,
                    steps: thresholds
                        .steps
                        .unwrap_or_default()
                        .into_iter()
                        .map(|step| model::ThresholdStep {
                            value: step.value,
                            color: step.color,
                        })
                        .collect(),
                }),
                custom: defaults.custom.map(|custom| model::GraphCustom {
                    draw_style: custom.draw_style,
                    show_points: custom.show_points,
                    fill_opacity: custom.fill_opacity,
                    axis_placement: custom.axis_placement,
                    line_interpolation: custom.line_interpolation,
                    stacking_mode: custom.stacking.and_then(|stacking| stacking.mode),
                    axis_grid_show: custom.axis_grid_show,
                    thresholds_style_mode: custom.thresholds_style.and_then(|style| style.mode),
                }),
                mappings_path: defaults
                    .mappings
                    .as_ref()
                    .is_some_and(non_empty_json_value)
                    .then(|| format!("{source_path}.fieldConfig.defaults.mappings")),
            })
        });
        out.push(model::Panel {
            kind: panel.panel_type,
            title: panel.title.unwrap_or_default(),
            source_path: source_path.clone(),
            targets: panel
                .targets
                .unwrap_or_default()
                .into_iter()
                .enumerate()
                .map(|(index, target)| model::Target {
                    expr: target.expr,
                    expr_path: format!("{source_path}.targets[{index}].expr"),
                    legend_format: target.legend_format,
                    instant: target.instant,
                    hidden: target.hide == Some(true),
                })
                .collect(),
            count_as_skipped_if_empty: false,
            grid: panel.grid_pos.map(|grid| model::GridPos {
                x: grid.x,
                y: grid.y,
                w: grid.w,
                h: grid.h,
            }),
            field_defaults,
            reduce_options_path: panel
                .options
                .and_then(|options| options.reduce_options)
                .is_some()
                .then(|| format!("{source_path}.options.reduceOptions")),
            transformations_path: None,
        });
    }
}

fn non_empty_json_value(value: &Value) -> bool {
    match value {
        Value::Array(values) => !values.is_empty(),
        Value::Object(values) => !values.is_empty(),
        Value::String(value) => !value.trim().is_empty(),
        Value::Null => false,
        Value::Bool(_) | Value::Number(_) => true,
    }
}