snowglobe-cli 0.2.3__tar.gz → 0.2.4__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (83) hide show
  1. {snowglobe_cli-0.2.3 → snowglobe_cli-0.2.4}/PKG-INFO +1 -1
  2. {snowglobe_cli-0.2.3 → snowglobe_cli-0.2.4}/pyproject.toml +1 -1
  3. {snowglobe_cli-0.2.3 → snowglobe_cli-0.2.4}/snowglobe/core/cost_service.py +130 -1
  4. {snowglobe_cli-0.2.3 → snowglobe_cli-0.2.4}/snowglobe/tui/screens/cost.py +215 -24
  5. {snowglobe_cli-0.2.3 → snowglobe_cli-0.2.4}/snowglobe_cli.egg-info/PKG-INFO +1 -1
  6. {snowglobe_cli-0.2.3 → snowglobe_cli-0.2.4}/LICENSE +0 -0
  7. {snowglobe_cli-0.2.3 → snowglobe_cli-0.2.4}/README.md +0 -0
  8. {snowglobe_cli-0.2.3 → snowglobe_cli-0.2.4}/setup.cfg +0 -0
  9. {snowglobe_cli-0.2.3 → snowglobe_cli-0.2.4}/snowglobe/__init__.py +0 -0
  10. {snowglobe_cli-0.2.3 → snowglobe_cli-0.2.4}/snowglobe/__main__.py +0 -0
  11. {snowglobe_cli-0.2.3 → snowglobe_cli-0.2.4}/snowglobe/cli/__init__.py +0 -0
  12. {snowglobe_cli-0.2.3 → snowglobe_cli-0.2.4}/snowglobe/cli/access.py +0 -0
  13. {snowglobe_cli-0.2.3 → snowglobe_cli-0.2.4}/snowglobe/cli/app.py +0 -0
  14. {snowglobe_cli-0.2.3 → snowglobe_cli-0.2.4}/snowglobe/cli/context.py +0 -0
  15. {snowglobe_cli-0.2.3 → snowglobe_cli-0.2.4}/snowglobe/cli/cost.py +0 -0
  16. {snowglobe_cli-0.2.3 → snowglobe_cli-0.2.4}/snowglobe/cli/debug.py +0 -0
  17. {snowglobe_cli-0.2.3 → snowglobe_cli-0.2.4}/snowglobe/cli/diff.py +0 -0
  18. {snowglobe_cli-0.2.3 → snowglobe_cli-0.2.4}/snowglobe/cli/optimizer.py +0 -0
  19. {snowglobe_cli-0.2.3 → snowglobe_cli-0.2.4}/snowglobe/cli/prompts.py +0 -0
  20. {snowglobe_cli-0.2.3 → snowglobe_cli-0.2.4}/snowglobe/cli/report.py +0 -0
  21. {snowglobe_cli-0.2.3 → snowglobe_cli-0.2.4}/snowglobe/cli/shell.py +0 -0
  22. {snowglobe_cli-0.2.3 → snowglobe_cli-0.2.4}/snowglobe/cli/shell_completer.py +0 -0
  23. {snowglobe_cli-0.2.3 → snowglobe_cli-0.2.4}/snowglobe/collectors/access.py +0 -0
  24. {snowglobe_cli-0.2.3 → snowglobe_cli-0.2.4}/snowglobe/collectors/query_history.py +0 -0
  25. {snowglobe_cli-0.2.3 → snowglobe_cli-0.2.4}/snowglobe/collectors/query_profile.py +0 -0
  26. {snowglobe_cli-0.2.3 → snowglobe_cli-0.2.4}/snowglobe/config/loader.py +0 -0
  27. {snowglobe_cli-0.2.3 → snowglobe_cli-0.2.4}/snowglobe/core/access_service.py +0 -0
  28. {snowglobe_cli-0.2.3 → snowglobe_cli-0.2.4}/snowglobe/core/optimizer.py +0 -0
  29. {snowglobe_cli-0.2.3 → snowglobe_cli-0.2.4}/snowglobe/core/query_service.py +0 -0
  30. {snowglobe_cli-0.2.3 → snowglobe_cli-0.2.4}/snowglobe/core/report_service.py +0 -0
  31. {snowglobe_cli-0.2.3 → snowglobe_cli-0.2.4}/snowglobe/core/risk_service.py +0 -0
  32. {snowglobe_cli-0.2.3 → snowglobe_cli-0.2.4}/snowglobe/engines/access/__init__.py +0 -0
  33. {snowglobe_cli-0.2.3 → snowglobe_cli-0.2.4}/snowglobe/engines/access/explainer.py +0 -0
  34. {snowglobe_cli-0.2.3 → snowglobe_cli-0.2.4}/snowglobe/engines/access/resolver.py +0 -0
  35. {snowglobe_cli-0.2.3 → snowglobe_cli-0.2.4}/snowglobe/engines/ai/cortex_optimizer.py +0 -0
  36. {snowglobe_cli-0.2.3 → snowglobe_cli-0.2.4}/snowglobe/engines/optimizer/query_optimizer.py +0 -0
  37. {snowglobe_cli-0.2.3 → snowglobe_cli-0.2.4}/snowglobe/graphs/__init__.py +0 -0
  38. {snowglobe_cli-0.2.3 → snowglobe_cli-0.2.4}/snowglobe/graphs/role_graph.py +0 -0
  39. {snowglobe_cli-0.2.3 → snowglobe_cli-0.2.4}/snowglobe/graphs/user_graph.py +0 -0
  40. {snowglobe_cli-0.2.3 → snowglobe_cli-0.2.4}/snowglobe/models/__init__.py +0 -0
  41. {snowglobe_cli-0.2.3 → snowglobe_cli-0.2.4}/snowglobe/models/access.py +0 -0
  42. {snowglobe_cli-0.2.3 → snowglobe_cli-0.2.4}/snowglobe/models/access_path.py +0 -0
  43. {snowglobe_cli-0.2.3 → snowglobe_cli-0.2.4}/snowglobe/models/object_ref.py +0 -0
  44. {snowglobe_cli-0.2.3 → snowglobe_cli-0.2.4}/snowglobe/models/object_type.py +0 -0
  45. {snowglobe_cli-0.2.3 → snowglobe_cli-0.2.4}/snowglobe/models/optimizer.py +0 -0
  46. {snowglobe_cli-0.2.3 → snowglobe_cli-0.2.4}/snowglobe/models/privilege.py +0 -0
  47. {snowglobe_cli-0.2.3 → snowglobe_cli-0.2.4}/snowglobe/models/query.py +0 -0
  48. {snowglobe_cli-0.2.3 → snowglobe_cli-0.2.4}/snowglobe/output/__init__.py +0 -0
  49. {snowglobe_cli-0.2.3 → snowglobe_cli-0.2.4}/snowglobe/output/cli.py +0 -0
  50. {snowglobe_cli-0.2.3 → snowglobe_cli-0.2.4}/snowglobe/queries/__init__.py +0 -0
  51. {snowglobe_cli-0.2.3 → snowglobe_cli-0.2.4}/snowglobe/queries/query_history.py +0 -0
  52. {snowglobe_cli-0.2.3 → snowglobe_cli-0.2.4}/snowglobe/snowflake/connection.py +0 -0
  53. {snowglobe_cli-0.2.3 → snowglobe_cli-0.2.4}/snowglobe/state/db.py +0 -0
  54. {snowglobe_cli-0.2.3 → snowglobe_cli-0.2.4}/snowglobe/state/state.py +0 -0
  55. {snowglobe_cli-0.2.3 → snowglobe_cli-0.2.4}/snowglobe/templates/report.md.j2 +0 -0
  56. {snowglobe_cli-0.2.3 → snowglobe_cli-0.2.4}/snowglobe/tests/access_tests.py +0 -0
  57. {snowglobe_cli-0.2.3 → snowglobe_cli-0.2.4}/snowglobe/tui/__init__.py +0 -0
  58. {snowglobe_cli-0.2.3 → snowglobe_cli-0.2.4}/snowglobe/tui/__main__.py +0 -0
  59. {snowglobe_cli-0.2.3 → snowglobe_cli-0.2.4}/snowglobe/tui/app.py +0 -0
  60. {snowglobe_cli-0.2.3 → snowglobe_cli-0.2.4}/snowglobe/tui/screens/__init__.py +0 -0
  61. {snowglobe_cli-0.2.3 → snowglobe_cli-0.2.4}/snowglobe/tui/screens/access.py +0 -0
  62. {snowglobe_cli-0.2.3 → snowglobe_cli-0.2.4}/snowglobe/tui/screens/home.py +0 -0
  63. {snowglobe_cli-0.2.3 → snowglobe_cli-0.2.4}/snowglobe/tui/screens/refresh.py +0 -0
  64. {snowglobe_cli-0.2.3 → snowglobe_cli-0.2.4}/snowglobe/tui/screens/reports.py +0 -0
  65. {snowglobe_cli-0.2.3 → snowglobe_cli-0.2.4}/snowglobe/tui/screens/risk.py +0 -0
  66. {snowglobe_cli-0.2.3 → snowglobe_cli-0.2.4}/snowglobe/tui/screens/tune.py +0 -0
  67. {snowglobe_cli-0.2.3 → snowglobe_cli-0.2.4}/snowglobe/tui/styles.tcss +0 -0
  68. {snowglobe_cli-0.2.3 → snowglobe_cli-0.2.4}/snowglobe/tui/widgets/__init__.py +0 -0
  69. {snowglobe_cli-0.2.3 → snowglobe_cli-0.2.4}/snowglobe/tui/widgets/access_paths.py +0 -0
  70. {snowglobe_cli-0.2.3 → snowglobe_cli-0.2.4}/snowglobe/tui/widgets/cache_badge.py +0 -0
  71. {snowglobe_cli-0.2.3 → snowglobe_cli-0.2.4}/snowglobe/tui/widgets/header.py +0 -0
  72. {snowglobe_cli-0.2.3 → snowglobe_cli-0.2.4}/snowglobe/tui/widgets/nav.py +0 -0
  73. {snowglobe_cli-0.2.3 → snowglobe_cli-0.2.4}/snowglobe_cli.egg-info/SOURCES.txt +0 -0
  74. {snowglobe_cli-0.2.3 → snowglobe_cli-0.2.4}/snowglobe_cli.egg-info/dependency_links.txt +0 -0
  75. {snowglobe_cli-0.2.3 → snowglobe_cli-0.2.4}/snowglobe_cli.egg-info/entry_points.txt +0 -0
  76. {snowglobe_cli-0.2.3 → snowglobe_cli-0.2.4}/snowglobe_cli.egg-info/requires.txt +0 -0
  77. {snowglobe_cli-0.2.3 → snowglobe_cli-0.2.4}/snowglobe_cli.egg-info/top_level.txt +0 -0
  78. {snowglobe_cli-0.2.3 → snowglobe_cli-0.2.4}/tests/test_access.py +0 -0
  79. {snowglobe_cli-0.2.3 → snowglobe_cli-0.2.4}/tests/test_optimizer_engine.py +0 -0
  80. {snowglobe_cli-0.2.3 → snowglobe_cli-0.2.4}/tests/test_output.py +0 -0
  81. {snowglobe_cli-0.2.3 → snowglobe_cli-0.2.4}/tests/test_query_validation.py +0 -0
  82. {snowglobe_cli-0.2.3 → snowglobe_cli-0.2.4}/tests/test_state_db.py +0 -0
  83. {snowglobe_cli-0.2.3 → snowglobe_cli-0.2.4}/tests/test_tui_tune.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: snowglobe-cli
3
- Version: 0.2.3
3
+ Version: 0.2.4
4
4
  Summary: Explainable cost and access visibility for Snowflake
5
5
  Author-email: Jaryd Thornton <jaryd90@gmail.com>
6
6
  License-Expression: Apache-2.0
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "snowglobe-cli"
3
- version = "0.2.3"
3
+ version = "0.2.4"
4
4
  description = "Explainable cost and access visibility for Snowflake"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.12.3"
@@ -1171,7 +1171,7 @@ class CostService:
1171
1171
  d = _sq(date)
1172
1172
  svc_upper = service_type.upper()
1173
1173
 
1174
- if svc_upper == "WAREHOUSE_METERING":
1174
+ if "WAREHOUSE" in svc_upper:
1175
1175
  safe_wh = _sq(resource)
1176
1176
  conn = self.context.connect()
1177
1177
  try:
@@ -1335,3 +1335,132 @@ class CostService:
1335
1335
  return df, "Query Attribution not enabled — showing slowest queries"
1336
1336
  except Exception:
1337
1337
  return pd.DataFrame(), "Could not load queries"
1338
+
1339
+ def get_user_warehouse_queries(
1340
+ self, user_name: str, warehouse_name: str, days: int = 7, limit: int = 10
1341
+ ) -> tuple[pd.DataFrame, str | None]:
1342
+ """Top queries for a user on a specific warehouse over the past N days."""
1343
+ safe_user = _sq(user_name)
1344
+ safe_wh = _sq(warehouse_name)
1345
+ conn = self.context.connect()
1346
+ try:
1347
+ with conn:
1348
+ rows = conn.query(f"""
1349
+ SELECT a.QUERY_ID,
1350
+ ROUND(a.CREDITS_ATTRIBUTED_COMPUTE, 4) AS CREDITS,
1351
+ q.QUERY_TYPE,
1352
+ LEFT(q.QUERY_TEXT, 80) AS QUERY_PREVIEW,
1353
+ ROUND(q.TOTAL_ELAPSED_TIME / 1000, 1) AS SECONDS,
1354
+ ROUND(q.BYTES_SCANNED / 1e9, 2) AS GB_SCANNED
1355
+ FROM SNOWFLAKE.ACCOUNT_USAGE.QUERY_ATTRIBUTION_HISTORY a
1356
+ LEFT JOIN SNOWFLAKE.ACCOUNT_USAGE.QUERY_HISTORY q ON a.QUERY_ID = q.QUERY_ID
1357
+ WHERE a.START_TIME >= DATEADD('day', -{days}, CURRENT_TIMESTAMP())
1358
+ AND a.WAREHOUSE_NAME = '{safe_wh}'
1359
+ AND a.USER_NAME = '{safe_user}'
1360
+ ORDER BY a.CREDITS_ATTRIBUTED_COMPUTE DESC
1361
+ LIMIT {limit}
1362
+ """)
1363
+ if not rows:
1364
+ return pd.DataFrame(), None
1365
+ df = pd.DataFrame(rows)
1366
+ df["CREDITS"] = df["CREDITS"].astype(float)
1367
+ return df, None
1368
+ except Exception:
1369
+ pass
1370
+
1371
+ conn2 = self.context.connect()
1372
+ try:
1373
+ with conn2:
1374
+ rows = conn2.query(f"""
1375
+ SELECT QUERY_ID, 0.0 AS CREDITS, QUERY_TYPE,
1376
+ LEFT(QUERY_TEXT, 80) AS QUERY_PREVIEW,
1377
+ ROUND(TOTAL_ELAPSED_TIME / 1000, 1) AS SECONDS,
1378
+ ROUND(BYTES_SCANNED / 1e9, 2) AS GB_SCANNED
1379
+ FROM SNOWFLAKE.ACCOUNT_USAGE.QUERY_HISTORY
1380
+ WHERE START_TIME >= DATEADD('day', -{days}, CURRENT_TIMESTAMP())
1381
+ AND WAREHOUSE_NAME = '{safe_wh}'
1382
+ AND USER_NAME = '{safe_user}'
1383
+ AND EXECUTION_STATUS = 'SUCCESS'
1384
+ ORDER BY TOTAL_ELAPSED_TIME DESC
1385
+ LIMIT {limit}
1386
+ """)
1387
+ if not rows:
1388
+ return pd.DataFrame(), "Query Attribution not enabled — showing slowest queries"
1389
+ df = pd.DataFrame(rows)
1390
+ df["CREDITS"] = df["CREDITS"].astype(float)
1391
+ return df, "Query Attribution not enabled — showing slowest queries"
1392
+ except Exception:
1393
+ return pd.DataFrame(), "Could not load queries"
1394
+
1395
+ def get_ai_service_users(self, service_name: str, days: int = 30) -> tuple[pd.DataFrame, str | None]:
1396
+ """Users of a specific AI/Cortex service over the past N days."""
1397
+ _empty = pd.DataFrame(columns=["USER_NAME", "CREDITS", "REQUESTS"])
1398
+ sql_map = {
1399
+ "Cortex Functions": f"""
1400
+ SELECT u.LOGIN_NAME AS USER_NAME,
1401
+ ROUND(SUM(f.credits), 4) AS CREDITS, COUNT(*) AS REQUESTS
1402
+ FROM SNOWFLAKE.ACCOUNT_USAGE.CORTEX_AI_FUNCTIONS_USAGE_HISTORY f
1403
+ INNER JOIN SNOWFLAKE.ACCOUNT_USAGE.USERS u ON u.USER_ID = f.USER_ID
1404
+ WHERE f.START_TIME >= DATEADD('day', -{days}, CURRENT_TIMESTAMP())
1405
+ AND f.credits > 0
1406
+ GROUP BY 1 ORDER BY 2 DESC LIMIT 50
1407
+ """,
1408
+ "Cortex Analyst": f"""
1409
+ SELECT USERNAME AS USER_NAME,
1410
+ ROUND(SUM(credits), 4) AS CREDITS, COUNT(*) AS REQUESTS
1411
+ FROM SNOWFLAKE.ACCOUNT_USAGE.CORTEX_ANALYST_USAGE_HISTORY
1412
+ WHERE START_TIME >= DATEADD('day', -{days}, CURRENT_TIMESTAMP())
1413
+ AND credits > 0
1414
+ GROUP BY 1 ORDER BY 2 DESC LIMIT 50
1415
+ """,
1416
+ "Cortex Agent": f"""
1417
+ SELECT USER_NAME,
1418
+ ROUND(SUM(token_credits), 4) AS CREDITS, COUNT(*) AS REQUESTS
1419
+ FROM SNOWFLAKE.ACCOUNT_USAGE.CORTEX_AGENT_USAGE_HISTORY
1420
+ WHERE START_TIME >= DATEADD('day', -{days}, CURRENT_TIMESTAMP())
1421
+ AND token_credits > 0
1422
+ GROUP BY 1 ORDER BY 2 DESC LIMIT 50
1423
+ """,
1424
+ "Cortex Code": f"""
1425
+ SELECT USER_NAME,
1426
+ ROUND(SUM(token_credits), 4) AS CREDITS, COUNT(*) AS REQUESTS
1427
+ FROM (
1428
+ SELECT USER_NAME, token_credits
1429
+ FROM SNOWFLAKE.ACCOUNT_USAGE.CORTEX_CODE_CLI_USAGE_HISTORY
1430
+ WHERE USAGE_TIME >= DATEADD('day', -{days}, CURRENT_TIMESTAMP())
1431
+ AND token_credits > 0
1432
+ UNION ALL
1433
+ SELECT USER_NAME, token_credits
1434
+ FROM SNOWFLAKE.ACCOUNT_USAGE.CORTEX_CODE_SNOWSIGHT_USAGE_HISTORY
1435
+ WHERE USAGE_TIME >= DATEADD('day', -{days}, CURRENT_TIMESTAMP())
1436
+ AND token_credits > 0
1437
+ UNION ALL
1438
+ SELECT USER_NAME, token_credits
1439
+ FROM SNOWFLAKE.ACCOUNT_USAGE.CORTEX_CODE_DESKTOP_USAGE_HISTORY
1440
+ WHERE USAGE_TIME >= DATEADD('day', -{days}, CURRENT_TIMESTAMP())
1441
+ AND token_credits > 0
1442
+ ) GROUP BY 1 ORDER BY 2 DESC LIMIT 50
1443
+ """,
1444
+ "Snowflake Intelligence": f"""
1445
+ SELECT USER_NAME,
1446
+ ROUND(SUM(token_credits), 4) AS CREDITS, COUNT(*) AS REQUESTS
1447
+ FROM SNOWFLAKE.ACCOUNT_USAGE.SNOWFLAKE_INTELLIGENCE_USAGE_HISTORY
1448
+ WHERE START_TIME >= DATEADD('day', -{days}, CURRENT_TIMESTAMP())
1449
+ AND token_credits > 0
1450
+ GROUP BY 1 ORDER BY 2 DESC LIMIT 50
1451
+ """,
1452
+ }
1453
+ sql = sql_map.get(service_name)
1454
+ if not sql:
1455
+ return _empty, f"No user breakdown available for: {service_name}"
1456
+ conn = self.context.connect()
1457
+ try:
1458
+ with conn:
1459
+ rows = conn.query(sql)
1460
+ if not rows:
1461
+ return _empty, None
1462
+ df = pd.DataFrame(rows)
1463
+ df["CREDITS"] = df["CREDITS"].astype(float)
1464
+ return df, None
1465
+ except Exception:
1466
+ return _empty, "Could not load user breakdown"
@@ -40,17 +40,22 @@ class CostScreen(Vertical):
40
40
 
41
41
  # Active view: "summary" | "trend" | "top_queries" | "warehouses" | "users"
42
42
  # | "ai" | "ai_users" | "services" | "storage" | "replication" | "mv" | "budget"
43
- # | "drill_service" | "drill_warehouse" | "drill_user"
43
+ # | "drill_service" | "drill_warehouse" | "drill_user" | "drill_user_queries"
44
+ # | "drill_ai_service"
44
45
  # | "drill_day" | "drill_day_resource" | "drill_day_resource_users" | "drill_day_user_queries"
45
46
  _current_view: str = "summary"
46
47
  _drill_service: str | None = None
47
48
  _drill_warehouse: str | None = None
48
49
  _drill_user: str | None = None
50
+ _drill_user_warehouse: str | None = None # warehouse selected in drill_user
51
+ _drill_ai_service: str | None = None # AI service selected in ai view
49
52
  _drill_day: str | None = None
50
53
  _drill_day_service_type: str | None = None
51
54
  _drill_day_resource: str | None = None
52
55
  _drill_day_user: str | None = None
53
- _drill_day_resource_kind: str = "other" # "warehouse" | "ai" | "other"
56
+ _drill_day_resource_kind: str = "other" # "warehouse" | "ai" | "other"
57
+ _drill_day_parent: str = "trend" # "trend" | "drill_service"
58
+ _drill_resource_users_parent: str = "drill_day_resource" # or "drill_warehouse"
54
59
  # Becomes True after the user explicitly picks a view, so Select.Changed
55
60
  # events fired during the initial mount don't auto-trigger a Snowflake fetch.
56
61
  _user_initiated: bool = False
@@ -147,6 +152,32 @@ class CostScreen(Vertical):
147
152
  elif self._current_view == "trend":
148
153
  self._drill_day = key
149
154
  self._fetch_day_drill(key)
155
+ elif self._current_view == "drill_service":
156
+ # Date row in service daily trend → full day drill (same chain as Trend)
157
+ self._drill_day_parent = "drill_service"
158
+ self._fetch_day_drill(key)
159
+ elif self._current_view == "drill_warehouse":
160
+ # Date row in warehouse daily trend → jump to Level 3 (users on that day)
161
+ self._drill_day = key
162
+ self._drill_day_service_type = "WAREHOUSE_METERING"
163
+ self._drill_day_resource_kind = "warehouse"
164
+ self._drill_resource_users_parent = "drill_warehouse"
165
+ self._fetch_day_resource_users(key, "WAREHOUSE_METERING", self._drill_warehouse)
166
+ elif self._current_view == "drill_user":
167
+ # Warehouse row in user detail → top queries for user+warehouse
168
+ self._fetch_user_queries(self._drill_user, key)
169
+ elif self._current_view == "ai":
170
+ self._fetch_ai_service_users(key)
171
+ elif self._current_view == "drill_user_queries":
172
+ try:
173
+ from textual.widgets import ContentSwitcher, Input
174
+ from snowglobe.tui.screens.tune import TuneScreen
175
+ self.app.query_one(ContentSwitcher).current = "tune"
176
+ tune = self.app.query_one(TuneScreen)
177
+ tune.query_one("#tu-query-id", Input).value = key
178
+ self.app.notify(f"Loaded {key[:24]}… into Tune. Press Analyse.", timeout=4)
179
+ except Exception:
180
+ self.app.notify(f"Query: {key[:24]}…", timeout=4)
150
181
  elif self._current_view == "drill_day":
151
182
  self._drill_day_service_type = key
152
183
  self._fetch_day_resource_drill(self._drill_day, key)
@@ -217,6 +248,10 @@ class CostScreen(Vertical):
217
248
  self._fetch_warehouse_drill(self._drill_warehouse)
218
249
  elif self._current_view == "drill_user" and self._drill_user:
219
250
  self._fetch_user_drill(self._drill_user)
251
+ elif self._current_view == "drill_user_queries" and self._drill_user and self._drill_user_warehouse:
252
+ self._fetch_user_queries(self._drill_user, self._drill_user_warehouse)
253
+ elif self._current_view == "drill_ai_service" and self._drill_ai_service:
254
+ self._fetch_ai_service_users(self._drill_ai_service)
220
255
  elif self._current_view == "drill_day" and self._drill_day:
221
256
  self._fetch_day_drill(self._drill_day)
222
257
  elif self._current_view == "drill_day_resource" and self._drill_day and self._drill_day_service_type:
@@ -242,16 +277,30 @@ class CostScreen(Vertical):
242
277
  elif self._current_view == "drill_user":
243
278
  self._drill_user = None
244
279
  self._fetch_users()
280
+ elif self._current_view == "drill_user_queries":
281
+ self._drill_user_warehouse = None
282
+ self._fetch_user_drill(self._drill_user)
283
+ elif self._current_view == "drill_ai_service":
284
+ self._drill_ai_service = None
285
+ self._fetch_ai()
245
286
  elif self._current_view == "drill_day":
246
287
  self._drill_day = None
247
288
  self._drill_day_service_type = None
248
- self._fetch_trend()
289
+ if self._drill_day_parent == "drill_service":
290
+ self._drill_day_parent = "trend"
291
+ self._fetch_service_drill(self._drill_service)
292
+ else:
293
+ self._fetch_trend()
249
294
  elif self._current_view == "drill_day_resource":
250
295
  self._drill_day_service_type = None
251
296
  self._fetch_day_drill(self._drill_day)
252
297
  elif self._current_view == "drill_day_resource_users":
253
298
  self._drill_day_resource = None
254
- self._fetch_day_resource_drill(self._drill_day, self._drill_day_service_type)
299
+ if self._drill_resource_users_parent == "drill_warehouse":
300
+ self._drill_resource_users_parent = "drill_day_resource"
301
+ self._fetch_warehouse_drill(self._drill_warehouse)
302
+ else:
303
+ self._fetch_day_resource_drill(self._drill_day, self._drill_day_service_type)
255
304
  elif self._current_view == "drill_day_user_queries":
256
305
  self._drill_day_user = None
257
306
  self._fetch_day_resource_users(
@@ -313,6 +362,7 @@ class CostScreen(Vertical):
313
362
 
314
363
  def _fetch_trend(self, force: bool = False) -> None:
315
364
  self._current_view = "trend"
365
+ self._reset_table()
316
366
  self._set_status(f"Loading daily trend ({self._days()}d)…")
317
367
  self._trend_worker(days=self._days(), force=force)
318
368
 
@@ -326,6 +376,8 @@ class CostScreen(Vertical):
326
376
  self.app.call_from_thread(self._render_trend, df, days, cache_age)
327
377
 
328
378
  def _render_trend(self, df: pd.DataFrame, days: int, cache_age: int | None) -> None:
379
+ if self._current_view != "trend":
380
+ return # stale callback
329
381
  if df is None or df.empty:
330
382
  self._set_status("No trend data found.")
331
383
  self._clear_table()
@@ -413,6 +465,7 @@ class CostScreen(Vertical):
413
465
 
414
466
  def _fetch_service_drill(self, service_type: str, force: bool = False) -> None:
415
467
  self._current_view = "drill_service"
468
+ self._reset_table()
416
469
  self._set_status(f"Loading daily trend for {service_type} ({self._days()}d)… Esc to return")
417
470
  self._drill_worker(service_type=service_type, days=self._days())
418
471
 
@@ -426,15 +479,17 @@ class CostScreen(Vertical):
426
479
  self.app.call_from_thread(self._render_service_drill, df, service_type, days)
427
480
 
428
481
  def _render_service_drill(self, df: pd.DataFrame, service_type: str, days: int) -> None:
482
+ if self._current_view != "drill_service":
483
+ return
429
484
  if df is None or df.empty:
430
485
  self._set_status(f"No daily data for {service_type} ({days}d). Esc to return.")
431
486
  self._clear_table()
432
487
  return
433
488
  total = float(df["CREDITS"].sum())
434
489
  self._set_status(
435
- f"{service_type} daily trend — {days}d · total {total:,.2f} · Esc to return"
490
+ f"{service_type} daily trend — {days}d · total {total:,.2f}"
491
+ f" · click date to drill · Esc to return"
436
492
  )
437
-
438
493
  table = self.query_one(DataTable)
439
494
  self._reset_table()
440
495
  table.add_columns("DATE", "CREDITS", "TREND")
@@ -442,7 +497,8 @@ class CostScreen(Vertical):
442
497
  for _, row in df.iterrows():
443
498
  credits = float(row["CREDITS"])
444
499
  ratio = credits / max_c if max_c > 0 else 0
445
- table.add_row(str(row["DATE"]), f"{credits:,.2f}", _bar(ratio))
500
+ table.add_row(str(row["DATE"]), f"{credits:,.2f}", _bar(ratio),
501
+ key=str(row["DATE"]))
446
502
 
447
503
  # --- View 4: Warehouses ------------------------------------------
448
504
 
@@ -485,6 +541,7 @@ class CostScreen(Vertical):
485
541
 
486
542
  def _fetch_warehouse_drill(self, warehouse_name: str) -> None:
487
543
  self._current_view = "drill_warehouse"
544
+ self._reset_table()
488
545
  self._set_status(f"Loading daily trend for {warehouse_name} ({self._days()}d)… Esc to return")
489
546
  self._warehouse_drill_worker(warehouse_name=warehouse_name, days=self._days())
490
547
 
@@ -498,13 +555,16 @@ class CostScreen(Vertical):
498
555
  self.app.call_from_thread(self._render_warehouse_drill, df, warehouse_name, days)
499
556
 
500
557
  def _render_warehouse_drill(self, df: pd.DataFrame, warehouse_name: str, days: int) -> None:
558
+ if self._current_view != "drill_warehouse":
559
+ return
501
560
  if df is None or df.empty:
502
561
  self._set_status(f"No daily data for {warehouse_name} ({days}d). Esc to return")
503
562
  self._clear_table()
504
563
  return
505
564
  total = float(df["CREDITS"].sum())
506
565
  self._set_status(
507
- f"{warehouse_name} daily — {days}d · total {total:,.2f} · Esc to return"
566
+ f"{warehouse_name} daily — {days}d · total {total:,.2f}"
567
+ f" · click date to see users · Esc to return"
508
568
  )
509
569
  table = self.query_one(DataTable)
510
570
  self._reset_table()
@@ -513,7 +573,8 @@ class CostScreen(Vertical):
513
573
  for _, row in df.iterrows():
514
574
  credits = float(row["CREDITS"])
515
575
  ratio = credits / max_c if max_c > 0 else 0
516
- table.add_row(str(row["DATE"]), f"{credits:,.2f}", _bar(ratio))
576
+ table.add_row(str(row["DATE"]), f"{credits:,.2f}", _bar(ratio),
577
+ key=str(row["DATE"]))
517
578
 
518
579
  # --- View 5: Users -----------------------------------------------
519
580
 
@@ -567,6 +628,7 @@ class CostScreen(Vertical):
567
628
 
568
629
  def _fetch_user_drill(self, user_name: str) -> None:
569
630
  self._current_view = "drill_user"
631
+ self._reset_table()
570
632
  self._set_status(f"Loading warehouse detail for {user_name} ({min(self._days(),7)}d)… Esc to return")
571
633
  self._user_drill_worker(user_name=user_name, days=min(self._days(), 7))
572
634
 
@@ -580,6 +642,8 @@ class CostScreen(Vertical):
580
642
  self.app.call_from_thread(self._render_user_drill, df, user_name, days, note)
581
643
 
582
644
  def _render_user_drill(self, df: pd.DataFrame, user_name: str, days: int, note: str | None) -> None:
645
+ if self._current_view != "drill_user":
646
+ return
583
647
  if df is None or df.empty:
584
648
  self._set_status(f"No attribution data for {user_name} ({days}d). Esc to return")
585
649
  self._clear_table()
@@ -587,7 +651,8 @@ class CostScreen(Vertical):
587
651
  total = float(df["CREDITS"].sum())
588
652
  note_suffix = f" · ⚠ {note}" if note else ""
589
653
  self._set_status(
590
- f"{user_name} per-warehouse — {days}d · total {total:,.4f}{note_suffix} · Esc to return"
654
+ f"{user_name} per-warehouse — {days}d · total {total:,.4f}{note_suffix}"
655
+ f" · click warehouse for top queries · Esc to return"
591
656
  )
592
657
  no_credits = note is not None
593
658
  table = self.query_one(DataTable)
@@ -601,12 +666,117 @@ class CostScreen(Vertical):
601
666
  credits_str,
602
667
  str(int(row.get("QUERY_COUNT", 0))),
603
668
  avg_str,
669
+ key=str(row.get("WAREHOUSE_NAME", "")),
670
+ )
671
+
672
+ # --- Drill: top queries for user+warehouse (from drill_user) -----
673
+
674
+ def _fetch_user_queries(self, user_name: str, warehouse_name: str) -> None:
675
+ self._current_view = "drill_user_queries"
676
+ self._drill_user_warehouse = warehouse_name
677
+ self._reset_table()
678
+ self._set_status(
679
+ f"Loading top queries for {user_name} on {warehouse_name}… Esc to return"
680
+ )
681
+ self._user_queries_worker(
682
+ user_name=user_name, warehouse_name=warehouse_name, days=min(self._days(), 7)
683
+ )
684
+
685
+ @work(thread=True, exclusive=True, group="cost")
686
+ def _user_queries_worker(self, *, user_name: str, warehouse_name: str, days: int) -> None:
687
+ try:
688
+ df, note = self.app.get_cost_service().get_user_warehouse_queries(
689
+ user_name, warehouse_name, days
690
+ )
691
+ except Exception as e:
692
+ self.app.call_from_thread(self._fetch_failed, e)
693
+ return
694
+ self.app.call_from_thread(self._render_user_queries, df, user_name, warehouse_name, note)
695
+
696
+ def _render_user_queries(
697
+ self, df: pd.DataFrame, user_name: str, warehouse_name: str, note: str | None
698
+ ) -> None:
699
+ if self._current_view != "drill_user_queries":
700
+ return
701
+ self._reset_table()
702
+ table = self.query_one(DataTable)
703
+ note_suffix = f" · ⚠ {note}" if note else ""
704
+ if df is None or df.empty:
705
+ table.add_columns("(no queries found)")
706
+ self._set_status(
707
+ f"{user_name} on {warehouse_name} · no queries{note_suffix} · Esc to return"
708
+ )
709
+ return
710
+ table.add_columns("CREDITS", "TYPE", "SECONDS", "GB", "PREVIEW")
711
+ for _, row in df.iterrows():
712
+ table.add_row(
713
+ f"{float(row.get('CREDITS', 0)):,.4f}",
714
+ str(row.get("QUERY_TYPE", "")),
715
+ f"{float(row.get('SECONDS', 0)):.1f}",
716
+ f"{float(row.get('GB_SCANNED', 0)):.2f}",
717
+ str(row.get("QUERY_PREVIEW", ""))[:48],
718
+ key=str(row.get("QUERY_ID", "")),
604
719
  )
720
+ self._set_status(
721
+ f"Top queries — {user_name} on {warehouse_name}{note_suffix}"
722
+ f" · ⏎ open in Tune · Esc to return"
723
+ )
724
+
725
+ # --- Drill: users of an AI service (from ai view) ----------------
726
+
727
+ def _fetch_ai_service_users(self, service_name: str) -> None:
728
+ self._current_view = "drill_ai_service"
729
+ self._drill_ai_service = service_name
730
+ self._reset_table()
731
+ self._set_status(
732
+ f"Loading users for {service_name} ({self._days()}d)… Esc to return"
733
+ )
734
+ self._ai_service_users_worker(service_name=service_name, days=self._days())
735
+
736
+ @work(thread=True, exclusive=True, group="cost")
737
+ def _ai_service_users_worker(self, *, service_name: str, days: int) -> None:
738
+ try:
739
+ df, note = self.app.get_cost_service().get_ai_service_users(service_name, days)
740
+ except Exception as e:
741
+ self.app.call_from_thread(self._fetch_failed, e)
742
+ return
743
+ self.app.call_from_thread(self._render_ai_service_users, df, service_name, days, note)
744
+
745
+ def _render_ai_service_users(
746
+ self, df: pd.DataFrame, service_name: str, days: int, note: str | None
747
+ ) -> None:
748
+ if self._current_view != "drill_ai_service":
749
+ return
750
+ self._reset_table()
751
+ table = self.query_one(DataTable)
752
+ note_suffix = f" · ⚠ {note}" if note else ""
753
+ if df is None or df.empty:
754
+ table.add_columns("(no users found)")
755
+ self._set_status(f"{service_name} · no users{note_suffix} · Esc to return")
756
+ return
757
+ total = float(df["CREDITS"].sum())
758
+ max_c = float(df["CREDITS"].max()) if total > 0 else 1.0
759
+ table.add_columns("USER", "CREDITS", "REQUESTS", "% OF SERVICE", "BAR")
760
+ for _, row in df.iterrows():
761
+ credits = float(row["CREDITS"])
762
+ pct = (credits / total * 100) if total > 0 else 0
763
+ table.add_row(
764
+ str(row["USER_NAME"]),
765
+ f"{credits:,.4f}",
766
+ str(int(row.get("REQUESTS", 0))),
767
+ f"{pct:.1f}%",
768
+ _bar(credits / max_c),
769
+ )
770
+ self._set_status(
771
+ f"{service_name} users — {days}d · total {total:,.4f}{note_suffix} · Esc to return"
772
+ )
605
773
 
606
774
  # --- Drill: day breakdown (Level 1 — service types) --------------
607
775
 
608
776
  def _fetch_day_drill(self, date: str) -> None:
777
+ self._drill_day = date # always set — this may be called from home.py or back-nav
609
778
  self._current_view = "drill_day"
779
+ self._reset_table() # clear immediately so stale rows can't be re-clicked while loading
610
780
  self._set_status(f"Loading service breakdown for {date}… Esc to return")
611
781
  self._day_drill_worker(date=date)
612
782
 
@@ -620,6 +790,8 @@ class CostScreen(Vertical):
620
790
  self.app.call_from_thread(self._render_day_drill, df, date)
621
791
 
622
792
  def _render_day_drill(self, df: pd.DataFrame, date: str) -> None:
793
+ if self._current_view != "drill_day":
794
+ return # stale callback — view advanced before this fired
623
795
  self._reset_table()
624
796
  table = self.query_one(DataTable)
625
797
  if df is None or df.empty:
@@ -648,13 +820,28 @@ class CostScreen(Vertical):
648
820
  def _fetch_day_resource_drill(self, date: str, service_type: str) -> None:
649
821
  self._current_view = "drill_day_resource"
650
822
  self._drill_day_service_type = service_type
651
- self._set_status(f"Loading {service_type} breakdown for {date}… Esc to return")
652
823
  upper = service_type.upper()
653
824
  is_ai = any(kw in upper for kw in _AI_SERVICE_KEYWORDS)
654
- self._day_resource_drill_worker(date=date, service_type=service_type, is_ai=is_ai)
825
+ is_warehouse = "WAREHOUSE" in upper
826
+ # Set kind synchronously — before async work — so navigation is correct
827
+ # even if the worker fails and the render never fires.
828
+ if is_ai:
829
+ self._drill_day_resource_kind = "ai"
830
+ elif is_warehouse:
831
+ self._drill_day_resource_kind = "warehouse"
832
+ else:
833
+ self._drill_day_resource_kind = "other"
834
+ # Clear table immediately so stale Level 1 rows can't be clicked while loading.
835
+ self._reset_table()
836
+ self._set_status(f"Loading {service_type} breakdown for {date}… Esc to return")
837
+ self._day_resource_drill_worker(
838
+ date=date, service_type=service_type, is_ai=is_ai, is_warehouse=is_warehouse
839
+ )
655
840
 
656
841
  @work(thread=True, exclusive=True, group="cost")
657
- def _day_resource_drill_worker(self, *, date: str, service_type: str, is_ai: bool) -> None:
842
+ def _day_resource_drill_worker(
843
+ self, *, date: str, service_type: str, is_ai: bool, is_warehouse: bool
844
+ ) -> None:
658
845
  try:
659
846
  svc = self.app.get_cost_service()
660
847
  if is_ai:
@@ -662,6 +849,11 @@ class CostScreen(Vertical):
662
849
  label = "AI Service"
663
850
  found = True
664
851
  note = "some Cortex views unavailable" if any_missing else None
852
+ elif is_warehouse:
853
+ df = svc.get_day_warehouse_breakdown(date)
854
+ label = "Warehouse"
855
+ found = True
856
+ note = None
665
857
  else:
666
858
  df, label, found = svc.get_day_resource_breakdown(date, service_type)
667
859
  note = None
@@ -669,20 +861,13 @@ class CostScreen(Vertical):
669
861
  self.app.call_from_thread(self._fetch_failed, e)
670
862
  return
671
863
  self.app.call_from_thread(
672
- self._render_day_resource_drill, df, date, service_type, label, found, note, is_ai
864
+ self._render_day_resource_drill, df, date, service_type, label, found, note
673
865
  )
674
866
 
675
867
  def _render_day_resource_drill(self, df: pd.DataFrame, date: str, service_type: str,
676
- label: str, found: bool, note: str | None,
677
- is_ai: bool = False) -> None:
678
- # Store kind so navigation decisions don't depend on the SERVICE_TYPE string.
679
- if is_ai:
680
- self._drill_day_resource_kind = "ai"
681
- elif label == "Warehouse":
682
- self._drill_day_resource_kind = "warehouse"
683
- else:
684
- self._drill_day_resource_kind = "other"
685
-
868
+ label: str, found: bool, note: str | None) -> None:
869
+ if self._current_view != "drill_day_resource":
870
+ return # stale callback
686
871
  self._reset_table()
687
872
  table = self.query_one(DataTable)
688
873
  note_suffix = f" · ⚠ {note}" if note else ""
@@ -725,6 +910,7 @@ class CostScreen(Vertical):
725
910
  def _fetch_day_resource_users(self, date: str, service_type: str, resource: str) -> None:
726
911
  self._current_view = "drill_day_resource_users"
727
912
  self._drill_day_resource = resource
913
+ self._reset_table()
728
914
  self._set_status(f"Loading users for {resource} on {date}… Esc to return")
729
915
  self._day_resource_users_worker(date=date, service_type=service_type, resource=resource)
730
916
 
@@ -741,6 +927,8 @@ class CostScreen(Vertical):
741
927
 
742
928
  def _render_day_resource_users(self, df: pd.DataFrame, date: str,
743
929
  resource: str, note: str | None) -> None:
930
+ if self._current_view != "drill_day_resource_users":
931
+ return # stale callback
744
932
  self._reset_table()
745
933
  table = self.query_one(DataTable)
746
934
  note_suffix = f" · ⚠ {note}" if note else ""
@@ -780,6 +968,7 @@ class CostScreen(Vertical):
780
968
  def _fetch_day_user_queries(self, date: str, warehouse: str, user: str) -> None:
781
969
  self._current_view = "drill_day_user_queries"
782
970
  self._drill_day_user = user
971
+ self._reset_table()
783
972
  self._set_status(
784
973
  f"Loading top queries for {user} on {warehouse} ({date})… Esc to return"
785
974
  )
@@ -798,6 +987,8 @@ class CostScreen(Vertical):
798
987
 
799
988
  def _render_day_user_queries(self, df: pd.DataFrame, date: str,
800
989
  warehouse: str, user: str, note: str | None) -> None:
990
+ if self._current_view != "drill_day_user_queries":
991
+ return # stale callback
801
992
  self._reset_table()
802
993
  table = self.query_one(DataTable)
803
994
  note_suffix = f" · ⚠ {note}" if note else ""
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: snowglobe-cli
3
- Version: 0.2.3
3
+ Version: 0.2.4
4
4
  Summary: Explainable cost and access visibility for Snowflake
5
5
  Author-email: Jaryd Thornton <jaryd90@gmail.com>
6
6
  License-Expression: Apache-2.0
File without changes
File without changes
File without changes