1use anyhow::Error;
2use clap::ValueEnum;
3use crossterm::event::{self, Event};
4use ratatui::{
5 layout::Constraint,
6 style::{Color, Style, Stylize},
7 widgets::{Row, Table, TableState},
8 Frame,
9};
10use serde::{Deserialize, Serialize};
11use serde_json::Value;
12
13#[derive(Debug, Clone, Serialize, Deserialize)]
16pub struct QueryRequest {
17 query: Query,
18 refresh: QueryRefresh,
19}
20
21#[derive(Debug, Clone, Serialize, Deserialize)]
22#[serde(tag = "kind")]
23pub enum Query {
24 HogQLQuery { query: String },
25}
26
27#[derive(Debug, Clone, Serialize, Deserialize)]
28#[serde(rename_all = "snake_case")]
29pub enum QueryRefresh {
30 Blocking,
31}
32
33#[derive(Debug, Clone, Deserialize, Serialize)]
34pub struct HogQLQueryResponse {
35 pub cache_key: Option<String>,
36 pub cache_target_age: Option<String>,
37 pub clickhouse: Option<String>, #[serde(default, deserialize_with = "null_is_empty")]
39 pub columns: Vec<String>, pub error: Option<String>,
41 #[serde(default, deserialize_with = "null_is_empty")]
42 pub explain: Vec<String>,
43 #[serde(default, rename = "hasMore", deserialize_with = "null_is_false")]
44 pub has_more: bool,
45 pub hogql: Option<String>, #[serde(default, deserialize_with = "null_is_false")]
47 pub is_cached: bool,
48 pub last_refresh: Option<String>, pub next_allowed_client_refresh_time: Option<String>, pub offset: Option<i64>, pub query: Option<String>, #[serde(default, deserialize_with = "null_is_empty")]
53 pub types: Vec<(String, String)>,
54 #[serde(default, deserialize_with = "null_is_empty")]
55 pub results: Vec<Vec<Value>>,
56 #[serde(default, deserialize_with = "null_is_empty")]
57 pub timings: Vec<Timing>,
58}
59
60#[derive(Debug, Clone, Deserialize, Serialize)]
61pub struct HogQLQueryErrorResponse {
62 pub code: String,
63 pub detail: String,
64 #[serde(rename = "type")]
65 pub error_type: String,
66}
67
68#[derive(Debug, Clone, Deserialize, Serialize)]
69pub struct Timing {
70 k: String,
71 t: f64,
72}
73
74#[derive(Debug, Clone, Copy, ValueEnum)]
75pub enum OutputMode {
76 Print,
77 Tui,
78}
79
80pub fn run_query(host: &str, to_run: &str, output: OutputMode) -> Result<(), Error> {
81 let client = reqwest::blocking::Client::new();
82 let creds = crate::utils::auth::load_token()?;
83 let query_endpoint = format!("{}/api/environments/{}/query", host, creds.env_id);
84
85 let request = QueryRequest {
86 query: Query::HogQLQuery {
87 query: to_run.to_string(),
88 },
89 refresh: QueryRefresh::Blocking,
90 };
91
92 let response = client
93 .post(&query_endpoint)
94 .json(&request)
95 .bearer_auth(creds.token)
96 .send()?;
97
98 let code = response.status();
99 let body = response.text()?;
100
101 let value: Value = serde_json::from_str(&body)?;
102
103 if code.is_client_error() {
104 let error: HogQLQueryErrorResponse = serde_json::from_value(value)?;
105 println!("{}", serde_json::to_string_pretty(&error)?);
106 return Ok(());
107 }
108
109 let response: HogQLQueryResponse = serde_json::from_value(value)?;
110
111 match output {
112 OutputMode::Print => {
113 for res in response.results {
114 println!("{}", serde_json::to_string(&res)?);
115 }
116 }
117 OutputMode::Tui => {
118 draw_output(&response)?;
119 }
120 }
121
122 Ok(())
123}
124
125fn draw_output(response: &HogQLQueryResponse) -> Result<(), Error> {
126 let mut terminal = ratatui::init();
127 let mut table_state = TableState::default();
128
129 loop {
130 let draw_frame = |f: &mut Frame| draw_table(f, response, &mut table_state);
131 terminal.draw(draw_frame).expect("failed to draw frame");
132 if matches!(event::read().expect("failed to read event"), Event::Key(_)) {
133 break;
134 }
135 }
136 ratatui::restore();
137 Ok(())
138}
139
140fn draw_table(f: &mut Frame, response: &HogQLQueryResponse, table_state: &mut TableState) {
141 let cols = &response.columns;
142 let widths = cols.iter().map(|_| Constraint::Fill(1)).collect::<Vec<_>>();
143 let mut rows: Vec<Row> = Vec::with_capacity(response.results.len());
144 for row in &response.results {
145 let mut row_data = Vec::with_capacity(cols.len());
146 for _ in cols {
147 let value = row[row_data.len()].to_string();
148 row_data.push(value.to_string());
149 }
150 rows.push(Row::new(row_data));
151 }
152 let table = Table::new(rows, widths)
153 .column_spacing(1)
154 .header(Row::new(cols.clone()).style(Style::new().bold().bg(Color::LightBlue)))
155 .block(
156 ratatui::widgets::Block::default()
157 .title("Query Results (press any key to exit)")
158 .title_style(Style::new().bold().fg(Color::White).bg(Color::DarkGray))
159 .borders(ratatui::widgets::Borders::ALL)
160 .border_style(Style::new().fg(Color::White).bg(Color::DarkGray)),
161 )
162 .row_highlight_style(Style::new().bold().bg(Color::Blue))
163 .highlight_symbol(">>");
164
165 f.render_stateful_widget(table, f.area(), table_state);
166}
167
168fn null_is_empty<'de, D, T>(deserializer: D) -> Result<Vec<T>, D::Error>
169where
170 D: serde::Deserializer<'de>,
171 T: serde::Deserialize<'de>,
172{
173 let opt = Option::deserialize(deserializer)?;
174 match opt {
175 Some(v) => Ok(v),
176 None => Ok(Vec::new()),
177 }
178}
179
180fn null_is_false<'de, D>(deserializer: D) -> Result<bool, D::Error>
181where
182 D: serde::Deserializer<'de>,
183{
184 let opt = Option::deserialize(deserializer)?;
185 match opt {
186 Some(v) => Ok(v),
187 None => Ok(false),
188 }
189}