omni_dev/daemon/services/
snowflake.rs1use anyhow::{anyhow, bail, Context, Result};
13use async_trait::async_trait;
14use chrono::Utc;
15use serde_json::{json, Value};
16
17use crate::daemon::service::{DaemonService, MenuAction, MenuItem, MenuSnapshot, ServiceStatus};
18use crate::snowflake::session::SessionInfo;
19use crate::snowflake::{QueryRequest, SnowflakeEngine, SnowflakeEngineConfig};
20
21pub const SERVICE_NAME: &str = "snowflake";
23
24pub struct SnowflakeService {
26 engine: SnowflakeEngine,
27}
28
29impl SnowflakeService {
30 #[must_use]
35 pub fn new(config: SnowflakeEngineConfig) -> Self {
36 let engine = SnowflakeEngine::new(config);
37 engine.start_heartbeat();
38 Self { engine }
39 }
40
41 fn handle_disconnect(&self, payload: &Value) -> Result<Value> {
49 if payload.get("all").and_then(Value::as_bool) == Some(true) {
50 let count = self.engine.disconnect_all();
51 return Ok(json!({ "disconnected": count > 0, "count": count }));
52 }
53 if let Some(id) = payload.get("id").and_then(Value::as_u64) {
54 let removed = self.engine.disconnect_by_id(id);
55 return Ok(json!({ "disconnected": removed, "count": u64::from(removed) }));
56 }
57 let account = payload
58 .get("account")
59 .and_then(Value::as_str)
60 .ok_or_else(|| anyhow!("`disconnect` requires `account`, `id`, or `all`"))?;
61 let user = payload
62 .get("user")
63 .and_then(Value::as_str)
64 .ok_or_else(|| anyhow!("`disconnect` requires `user`"))?;
65 let removed = self.engine.disconnect(account, user);
66 Ok(json!({ "disconnected": removed, "count": u64::from(removed) }))
67 }
68}
69
70#[async_trait]
71impl DaemonService for SnowflakeService {
72 fn name(&self) -> &'static str {
73 SERVICE_NAME
74 }
75
76 async fn handle(&self, op: &str, payload: Value) -> Result<Value> {
77 match op {
78 "query" => {
79 let req: QueryRequest =
80 serde_json::from_value(payload).context("invalid `query` payload")?;
81 if req.sql.trim().is_empty() {
82 bail!("`query` requires a non-empty `sql`");
83 }
84 self.engine.query(req).await
85 }
86 "sessions" => Ok(json!({ "sessions": self.engine.sessions() })),
87 "disconnect" => self.handle_disconnect(&payload),
88 other => bail!("unknown snowflake op: {other}"),
89 }
90 }
91
92 fn menu(&self) -> MenuSnapshot {
93 let sessions = self.engine.sessions();
94 let items = if sessions.is_empty() {
95 vec![MenuItem::Label("No sessions".to_string())]
96 } else {
97 session_menu_items(&sessions)
98 };
99 MenuSnapshot {
100 title: "Snowflake".to_string(),
101 items,
102 }
103 }
104
105 async fn menu_action(&self, action_id: &str) -> Result<()> {
106 if action_id == "disconnect-all" {
107 self.engine.disconnect_all();
108 return Ok(());
109 }
110 if let Some(id) = action_id.strip_prefix("disconnect:") {
111 let id: u64 = id
112 .parse()
113 .with_context(|| format!("invalid session id in action {action_id}"))?;
114 self.engine.disconnect_by_id(id);
115 return Ok(());
116 }
117 bail!("unknown snowflake menu action: {action_id}")
118 }
119
120 async fn status(&self) -> ServiceStatus {
121 let sessions = self.engine.sessions();
122 let live: usize = sessions.iter().map(|s| s.sessions).sum();
123 ServiceStatus {
124 name: SERVICE_NAME.to_string(),
125 healthy: true,
126 summary: format!("{} pool(s), {live} session(s)", sessions.len()),
127 detail: json!({ "sessions": sessions }),
128 }
129 }
130
131 async fn shutdown(&self) {
132 self.engine.shutdown().await;
133 }
134}
135
136fn session_menu_items(sessions: &[SessionInfo]) -> Vec<MenuItem> {
140 let mut items = Vec::new();
141 for session in sessions {
142 items.push(MenuItem::Label(format!(
143 "{} · {} · {}/{} sessions · {} queries",
144 session.account,
145 session.user,
146 session.sessions,
147 session.max_sessions,
148 session.query_count
149 )));
150 for member in &session.members {
153 let state = if let Some(running) = &member.running {
154 let secs = (Utc::now() - running.started_at).num_seconds().max(0);
155 format!("running {secs}s: {}", running.sql)
156 } else if member.busy {
157 "busy".to_string()
158 } else {
159 let idle = (Utc::now() - member.last_used).num_seconds().max(0);
160 format!("idle {idle}s · {} queries", member.query_count)
161 };
162 items.push(MenuItem::Label(format!(
163 " #{} {} · {state}",
164 member.id,
165 member.context.summary(),
166 )));
167 }
168 }
169 items.push(MenuItem::Separator);
170 for session in sessions {
171 items.push(MenuItem::Action(MenuAction {
172 id: format!("disconnect:{}", session.id),
173 label: format!("Disconnect {} · {}", session.account, session.user),
174 enabled: true,
175 }));
176 }
177 items.push(MenuItem::Action(MenuAction {
178 id: "disconnect-all".to_string(),
179 label: "Disconnect all".to_string(),
180 enabled: true,
181 }));
182 items
183}
184
185#[cfg(test)]
186#[allow(clippy::unwrap_used, clippy::expect_used)]
187mod tests {
188 use super::*;
189
190 fn offline_service() -> SnowflakeService {
193 SnowflakeService::new(SnowflakeEngineConfig::default())
194 }
195
196 #[tokio::test]
197 async fn name_and_unknown_op() {
198 let svc = offline_service();
199 assert_eq!(svc.name(), "snowflake");
200 assert!(svc.handle("frobnicate", Value::Null).await.is_err());
201 }
202
203 #[tokio::test]
204 async fn sessions_op_is_empty_initially() {
205 let svc = offline_service();
206 let payload = svc.handle("sessions", Value::Null).await.unwrap();
207 assert_eq!(payload, json!({ "sessions": [] }));
208 }
209
210 #[tokio::test]
211 async fn empty_sql_is_rejected_before_auth() {
212 let svc = offline_service();
213 assert!(svc.handle("query", json!({ "sql": " " })).await.is_err());
214 }
215
216 #[tokio::test]
217 async fn query_without_account_errors_not_panics() {
218 let svc = offline_service();
219 let err = svc
221 .handle("query", json!({ "sql": "SELECT 1" }))
222 .await
223 .unwrap_err();
224 assert!(err.to_string().contains("account"));
225 }
226
227 #[tokio::test]
228 async fn disconnect_requires_account_and_user() {
229 let svc = offline_service();
230 assert!(svc.handle("disconnect", json!({})).await.is_err());
231 assert!(svc
232 .handle("disconnect", json!({ "account": "ACCT" }))
233 .await
234 .is_err());
235 let payload = svc
237 .handle("disconnect", json!({ "account": "ACCT", "user": "me" }))
238 .await
239 .unwrap();
240 assert_eq!(payload, json!({ "disconnected": false, "count": 0 }));
241 }
242
243 #[tokio::test]
244 async fn disconnect_by_id_and_all_selectors() {
245 let svc = offline_service();
246 let by_id = svc.handle("disconnect", json!({ "id": 7 })).await.unwrap();
249 assert_eq!(by_id, json!({ "disconnected": false, "count": 0 }));
250 let all = svc
251 .handle("disconnect", json!({ "all": true }))
252 .await
253 .unwrap();
254 assert_eq!(all, json!({ "disconnected": false, "count": 0 }));
255 }
256
257 #[tokio::test]
258 async fn menu_and_status_shape_with_no_sessions() {
259 let svc = offline_service();
260 let menu = svc.menu();
261 assert_eq!(menu.title, "Snowflake");
262 assert!(matches!(
263 menu.items.first(),
264 Some(MenuItem::Label(text)) if text == "No sessions"
265 ));
266 let status = svc.status().await;
267 assert_eq!(status.name, "snowflake");
268 assert!(status.healthy);
269 assert_eq!(status.summary, "0 pool(s), 0 session(s)");
270 }
271
272 #[tokio::test]
273 async fn menu_actions_route_and_reject_unknown() {
274 let svc = offline_service();
275 svc.menu_action("disconnect-all").await.unwrap();
277 svc.menu_action("disconnect:7").await.unwrap();
278 assert!(svc.menu_action("disconnect:not-a-number").await.is_err());
279 assert!(svc.menu_action("bogus").await.is_err());
280 svc.shutdown().await;
281 }
282
283 #[test]
284 fn session_menu_items_render_each_member_state_and_actions() {
285 use crate::snowflake::session::{MemberInfo, QueryContext, RunningQuery};
286
287 let now = Utc::now();
288 let wh_ctx = QueryContext {
289 warehouse: Some("WH".to_string()),
290 role: Some("R".to_string()),
291 ..QueryContext::default()
292 };
293 let sessions = vec![SessionInfo {
294 id: 5,
295 account: "ACME".to_string(),
296 user: "me".to_string(),
297 created_at: now,
298 last_used: now,
299 query_count: 9,
300 sessions: 3,
301 max_sessions: 4,
302 members: vec![
303 MemberInfo {
304 id: 1,
305 busy: true,
306 context: wh_ctx,
307 last_used: now,
308 query_count: 3,
309 running: Some(RunningQuery {
310 sql: "SELECT 42".to_string(),
311 started_at: now,
312 }),
313 },
314 MemberInfo {
315 id: 2,
316 busy: true,
317 context: QueryContext::default(),
318 last_used: now,
319 query_count: 1,
320 running: None,
321 },
322 MemberInfo {
323 id: 3,
324 busy: false,
325 context: QueryContext::default(),
326 last_used: now,
327 query_count: 0,
328 running: None,
329 },
330 ],
331 }];
332
333 let items = session_menu_items(&sessions);
334 let labels: Vec<&str> = items
335 .iter()
336 .filter_map(|i| match i {
337 MenuItem::Label(t) => Some(t.as_str()),
338 _ => None,
339 })
340 .collect();
341 assert!(labels
342 .iter()
343 .any(|l| l.contains("ACME · me · 3/4 sessions · 9 queries")));
344 assert!(labels
345 .iter()
346 .any(|l| l.contains("running") && l.contains("SELECT 42") && l.contains("WH/R")));
347 assert!(labels.iter().any(|l| l.contains("busy")));
348 assert!(labels
349 .iter()
350 .any(|l| l.contains("idle") && l.contains("(default)")));
351
352 assert!(items.iter().any(|i| matches!(i, MenuItem::Separator)));
353 let action_ids: Vec<&str> = items
354 .iter()
355 .filter_map(|i| match i {
356 MenuItem::Action(a) => Some(a.id.as_str()),
357 _ => None,
358 })
359 .collect();
360 assert!(action_ids.contains(&"disconnect:5"));
361 assert!(action_ids.contains(&"disconnect-all"));
362 }
363}