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