snowglobe-cli 0.2.2__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.2 → snowglobe_cli-0.2.4}/PKG-INFO +1 -1
  2. {snowglobe_cli-0.2.2 → snowglobe_cli-0.2.4}/pyproject.toml +1 -1
  3. {snowglobe_cli-0.2.2 → snowglobe_cli-0.2.4}/snowglobe/core/cost_service.py +130 -1
  4. {snowglobe_cli-0.2.2 → snowglobe_cli-0.2.4}/snowglobe/tui/screens/cost.py +220 -25
  5. {snowglobe_cli-0.2.2 → snowglobe_cli-0.2.4}/snowglobe_cli.egg-info/PKG-INFO +1 -1
  6. {snowglobe_cli-0.2.2 → snowglobe_cli-0.2.4}/LICENSE +0 -0
  7. {snowglobe_cli-0.2.2 → snowglobe_cli-0.2.4}/README.md +0 -0
  8. {snowglobe_cli-0.2.2 → snowglobe_cli-0.2.4}/setup.cfg +0 -0
  9. {snowglobe_cli-0.2.2 → snowglobe_cli-0.2.4}/snowglobe/__init__.py +0 -0
  10. {snowglobe_cli-0.2.2 → snowglobe_cli-0.2.4}/snowglobe/__main__.py +0 -0
  11. {snowglobe_cli-0.2.2 → snowglobe_cli-0.2.4}/snowglobe/cli/__init__.py +0 -0
  12. {snowglobe_cli-0.2.2 → snowglobe_cli-0.2.4}/snowglobe/cli/access.py +0 -0
  13. {snowglobe_cli-0.2.2 → snowglobe_cli-0.2.4}/snowglobe/cli/app.py +0 -0
  14. {snowglobe_cli-0.2.2 → snowglobe_cli-0.2.4}/snowglobe/cli/context.py +0 -0
  15. {snowglobe_cli-0.2.2 → snowglobe_cli-0.2.4}/snowglobe/cli/cost.py +0 -0
  16. {snowglobe_cli-0.2.2 → snowglobe_cli-0.2.4}/snowglobe/cli/debug.py +0 -0
  17. {snowglobe_cli-0.2.2 → snowglobe_cli-0.2.4}/snowglobe/cli/diff.py +0 -0
  18. {snowglobe_cli-0.2.2 → snowglobe_cli-0.2.4}/snowglobe/cli/optimizer.py +0 -0
  19. {snowglobe_cli-0.2.2 → snowglobe_cli-0.2.4}/snowglobe/cli/prompts.py +0 -0
  20. {snowglobe_cli-0.2.2 → snowglobe_cli-0.2.4}/snowglobe/cli/report.py +0 -0
  21. {snowglobe_cli-0.2.2 → snowglobe_cli-0.2.4}/snowglobe/cli/shell.py +0 -0
  22. {snowglobe_cli-0.2.2 → snowglobe_cli-0.2.4}/snowglobe/cli/shell_completer.py +0 -0
  23. {snowglobe_cli-0.2.2 → snowglobe_cli-0.2.4}/snowglobe/collectors/access.py +0 -0
  24. {snowglobe_cli-0.2.2 → snowglobe_cli-0.2.4}/snowglobe/collectors/query_history.py +0 -0
  25. {snowglobe_cli-0.2.2 → snowglobe_cli-0.2.4}/snowglobe/collectors/query_profile.py +0 -0
  26. {snowglobe_cli-0.2.2 → snowglobe_cli-0.2.4}/snowglobe/config/loader.py +0 -0
  27. {snowglobe_cli-0.2.2 → snowglobe_cli-0.2.4}/snowglobe/core/access_service.py +0 -0
  28. {snowglobe_cli-0.2.2 → snowglobe_cli-0.2.4}/snowglobe/core/optimizer.py +0 -0
  29. {snowglobe_cli-0.2.2 → snowglobe_cli-0.2.4}/snowglobe/core/query_service.py +0 -0
  30. {snowglobe_cli-0.2.2 → snowglobe_cli-0.2.4}/snowglobe/core/report_service.py +0 -0
  31. {snowglobe_cli-0.2.2 → snowglobe_cli-0.2.4}/snowglobe/core/risk_service.py +0 -0
  32. {snowglobe_cli-0.2.2 → snowglobe_cli-0.2.4}/snowglobe/engines/access/__init__.py +0 -0
  33. {snowglobe_cli-0.2.2 → snowglobe_cli-0.2.4}/snowglobe/engines/access/explainer.py +0 -0
  34. {snowglobe_cli-0.2.2 → snowglobe_cli-0.2.4}/snowglobe/engines/access/resolver.py +0 -0
  35. {snowglobe_cli-0.2.2 → snowglobe_cli-0.2.4}/snowglobe/engines/ai/cortex_optimizer.py +0 -0
  36. {snowglobe_cli-0.2.2 → snowglobe_cli-0.2.4}/snowglobe/engines/optimizer/query_optimizer.py +0 -0
  37. {snowglobe_cli-0.2.2 → snowglobe_cli-0.2.4}/snowglobe/graphs/__init__.py +0 -0
  38. {snowglobe_cli-0.2.2 → snowglobe_cli-0.2.4}/snowglobe/graphs/role_graph.py +0 -0
  39. {snowglobe_cli-0.2.2 → snowglobe_cli-0.2.4}/snowglobe/graphs/user_graph.py +0 -0
  40. {snowglobe_cli-0.2.2 → snowglobe_cli-0.2.4}/snowglobe/models/__init__.py +0 -0
  41. {snowglobe_cli-0.2.2 → snowglobe_cli-0.2.4}/snowglobe/models/access.py +0 -0
  42. {snowglobe_cli-0.2.2 → snowglobe_cli-0.2.4}/snowglobe/models/access_path.py +0 -0
  43. {snowglobe_cli-0.2.2 → snowglobe_cli-0.2.4}/snowglobe/models/object_ref.py +0 -0
  44. {snowglobe_cli-0.2.2 → snowglobe_cli-0.2.4}/snowglobe/models/object_type.py +0 -0
  45. {snowglobe_cli-0.2.2 → snowglobe_cli-0.2.4}/snowglobe/models/optimizer.py +0 -0
  46. {snowglobe_cli-0.2.2 → snowglobe_cli-0.2.4}/snowglobe/models/privilege.py +0 -0
  47. {snowglobe_cli-0.2.2 → snowglobe_cli-0.2.4}/snowglobe/models/query.py +0 -0
  48. {snowglobe_cli-0.2.2 → snowglobe_cli-0.2.4}/snowglobe/output/__init__.py +0 -0
  49. {snowglobe_cli-0.2.2 → snowglobe_cli-0.2.4}/snowglobe/output/cli.py +0 -0
  50. {snowglobe_cli-0.2.2 → snowglobe_cli-0.2.4}/snowglobe/queries/__init__.py +0 -0
  51. {snowglobe_cli-0.2.2 → snowglobe_cli-0.2.4}/snowglobe/queries/query_history.py +0 -0
  52. {snowglobe_cli-0.2.2 → snowglobe_cli-0.2.4}/snowglobe/snowflake/connection.py +0 -0
  53. {snowglobe_cli-0.2.2 → snowglobe_cli-0.2.4}/snowglobe/state/db.py +0 -0
  54. {snowglobe_cli-0.2.2 → snowglobe_cli-0.2.4}/snowglobe/state/state.py +0 -0
  55. {snowglobe_cli-0.2.2 → snowglobe_cli-0.2.4}/snowglobe/templates/report.md.j2 +0 -0
  56. {snowglobe_cli-0.2.2 → snowglobe_cli-0.2.4}/snowglobe/tests/access_tests.py +0 -0
  57. {snowglobe_cli-0.2.2 → snowglobe_cli-0.2.4}/snowglobe/tui/__init__.py +0 -0
  58. {snowglobe_cli-0.2.2 → snowglobe_cli-0.2.4}/snowglobe/tui/__main__.py +0 -0
  59. {snowglobe_cli-0.2.2 → snowglobe_cli-0.2.4}/snowglobe/tui/app.py +0 -0
  60. {snowglobe_cli-0.2.2 → snowglobe_cli-0.2.4}/snowglobe/tui/screens/__init__.py +0 -0
  61. {snowglobe_cli-0.2.2 → snowglobe_cli-0.2.4}/snowglobe/tui/screens/access.py +0 -0
  62. {snowglobe_cli-0.2.2 → snowglobe_cli-0.2.4}/snowglobe/tui/screens/home.py +0 -0
  63. {snowglobe_cli-0.2.2 → snowglobe_cli-0.2.4}/snowglobe/tui/screens/refresh.py +0 -0
  64. {snowglobe_cli-0.2.2 → snowglobe_cli-0.2.4}/snowglobe/tui/screens/reports.py +0 -0
  65. {snowglobe_cli-0.2.2 → snowglobe_cli-0.2.4}/snowglobe/tui/screens/risk.py +0 -0
  66. {snowglobe_cli-0.2.2 → snowglobe_cli-0.2.4}/snowglobe/tui/screens/tune.py +0 -0
  67. {snowglobe_cli-0.2.2 → snowglobe_cli-0.2.4}/snowglobe/tui/styles.tcss +0 -0
  68. {snowglobe_cli-0.2.2 → snowglobe_cli-0.2.4}/snowglobe/tui/widgets/__init__.py +0 -0
  69. {snowglobe_cli-0.2.2 → snowglobe_cli-0.2.4}/snowglobe/tui/widgets/access_paths.py +0 -0
  70. {snowglobe_cli-0.2.2 → snowglobe_cli-0.2.4}/snowglobe/tui/widgets/cache_badge.py +0 -0
  71. {snowglobe_cli-0.2.2 → snowglobe_cli-0.2.4}/snowglobe/tui/widgets/header.py +0 -0
  72. {snowglobe_cli-0.2.2 → snowglobe_cli-0.2.4}/snowglobe/tui/widgets/nav.py +0 -0
  73. {snowglobe_cli-0.2.2 → snowglobe_cli-0.2.4}/snowglobe_cli.egg-info/SOURCES.txt +0 -0
  74. {snowglobe_cli-0.2.2 → snowglobe_cli-0.2.4}/snowglobe_cli.egg-info/dependency_links.txt +0 -0
  75. {snowglobe_cli-0.2.2 → snowglobe_cli-0.2.4}/snowglobe_cli.egg-info/entry_points.txt +0 -0
  76. {snowglobe_cli-0.2.2 → snowglobe_cli-0.2.4}/snowglobe_cli.egg-info/requires.txt +0 -0
  77. {snowglobe_cli-0.2.2 → snowglobe_cli-0.2.4}/snowglobe_cli.egg-info/top_level.txt +0 -0
  78. {snowglobe_cli-0.2.2 → snowglobe_cli-0.2.4}/tests/test_access.py +0 -0
  79. {snowglobe_cli-0.2.2 → snowglobe_cli-0.2.4}/tests/test_optimizer_engine.py +0 -0
  80. {snowglobe_cli-0.2.2 → snowglobe_cli-0.2.4}/tests/test_output.py +0 -0
  81. {snowglobe_cli-0.2.2 → snowglobe_cli-0.2.4}/tests/test_query_validation.py +0 -0
  82. {snowglobe_cli-0.2.2 → snowglobe_cli-0.2.4}/tests/test_state_db.py +0 -0
  83. {snowglobe_cli-0.2.2 → 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.2
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.2"
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,16 +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
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"
53
59
  # Becomes True after the user explicitly picks a view, so Select.Changed
54
60
  # events fired during the initial mount don't auto-trigger a Snowflake fetch.
55
61
  _user_initiated: bool = False
@@ -146,16 +152,37 @@ class CostScreen(Vertical):
146
152
  elif self._current_view == "trend":
147
153
  self._drill_day = key
148
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)
149
181
  elif self._current_view == "drill_day":
150
182
  self._drill_day_service_type = key
151
183
  self._fetch_day_resource_drill(self._drill_day, key)
152
184
  elif self._current_view == "drill_day_resource":
153
- upper = (self._drill_day_service_type or "").upper()
154
- can_drill = (
155
- upper == _WAREHOUSE_SERVICE_TYPE
156
- or any(kw in upper for kw in _AI_SERVICE_KEYWORDS)
157
- )
158
- if can_drill:
185
+ if self._drill_day_resource_kind in ("warehouse", "ai"):
159
186
  self._fetch_day_resource_users(
160
187
  self._drill_day, self._drill_day_service_type, key
161
188
  )
@@ -164,7 +191,7 @@ class CostScreen(Vertical):
164
191
  "No user-level detail available for this resource type.", timeout=3
165
192
  )
166
193
  elif self._current_view == "drill_day_resource_users":
167
- if (self._drill_day_service_type or "").upper() == _WAREHOUSE_SERVICE_TYPE:
194
+ if self._drill_day_resource_kind == "warehouse":
168
195
  self._fetch_day_user_queries(self._drill_day, self._drill_day_resource, key)
169
196
  else:
170
197
  self.app.notify("No query detail available for AI services.", timeout=3)
@@ -221,6 +248,10 @@ class CostScreen(Vertical):
221
248
  self._fetch_warehouse_drill(self._drill_warehouse)
222
249
  elif self._current_view == "drill_user" and self._drill_user:
223
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)
224
255
  elif self._current_view == "drill_day" and self._drill_day:
225
256
  self._fetch_day_drill(self._drill_day)
226
257
  elif self._current_view == "drill_day_resource" and self._drill_day and self._drill_day_service_type:
@@ -246,16 +277,30 @@ class CostScreen(Vertical):
246
277
  elif self._current_view == "drill_user":
247
278
  self._drill_user = None
248
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()
249
286
  elif self._current_view == "drill_day":
250
287
  self._drill_day = None
251
288
  self._drill_day_service_type = None
252
- 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()
253
294
  elif self._current_view == "drill_day_resource":
254
295
  self._drill_day_service_type = None
255
296
  self._fetch_day_drill(self._drill_day)
256
297
  elif self._current_view == "drill_day_resource_users":
257
298
  self._drill_day_resource = None
258
- 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)
259
304
  elif self._current_view == "drill_day_user_queries":
260
305
  self._drill_day_user = None
261
306
  self._fetch_day_resource_users(
@@ -317,6 +362,7 @@ class CostScreen(Vertical):
317
362
 
318
363
  def _fetch_trend(self, force: bool = False) -> None:
319
364
  self._current_view = "trend"
365
+ self._reset_table()
320
366
  self._set_status(f"Loading daily trend ({self._days()}d)…")
321
367
  self._trend_worker(days=self._days(), force=force)
322
368
 
@@ -330,6 +376,8 @@ class CostScreen(Vertical):
330
376
  self.app.call_from_thread(self._render_trend, df, days, cache_age)
331
377
 
332
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
333
381
  if df is None or df.empty:
334
382
  self._set_status("No trend data found.")
335
383
  self._clear_table()
@@ -417,6 +465,7 @@ class CostScreen(Vertical):
417
465
 
418
466
  def _fetch_service_drill(self, service_type: str, force: bool = False) -> None:
419
467
  self._current_view = "drill_service"
468
+ self._reset_table()
420
469
  self._set_status(f"Loading daily trend for {service_type} ({self._days()}d)… Esc to return")
421
470
  self._drill_worker(service_type=service_type, days=self._days())
422
471
 
@@ -430,15 +479,17 @@ class CostScreen(Vertical):
430
479
  self.app.call_from_thread(self._render_service_drill, df, service_type, days)
431
480
 
432
481
  def _render_service_drill(self, df: pd.DataFrame, service_type: str, days: int) -> None:
482
+ if self._current_view != "drill_service":
483
+ return
433
484
  if df is None or df.empty:
434
485
  self._set_status(f"No daily data for {service_type} ({days}d). Esc to return.")
435
486
  self._clear_table()
436
487
  return
437
488
  total = float(df["CREDITS"].sum())
438
489
  self._set_status(
439
- 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"
440
492
  )
441
-
442
493
  table = self.query_one(DataTable)
443
494
  self._reset_table()
444
495
  table.add_columns("DATE", "CREDITS", "TREND")
@@ -446,7 +497,8 @@ class CostScreen(Vertical):
446
497
  for _, row in df.iterrows():
447
498
  credits = float(row["CREDITS"])
448
499
  ratio = credits / max_c if max_c > 0 else 0
449
- 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"]))
450
502
 
451
503
  # --- View 4: Warehouses ------------------------------------------
452
504
 
@@ -489,6 +541,7 @@ class CostScreen(Vertical):
489
541
 
490
542
  def _fetch_warehouse_drill(self, warehouse_name: str) -> None:
491
543
  self._current_view = "drill_warehouse"
544
+ self._reset_table()
492
545
  self._set_status(f"Loading daily trend for {warehouse_name} ({self._days()}d)… Esc to return")
493
546
  self._warehouse_drill_worker(warehouse_name=warehouse_name, days=self._days())
494
547
 
@@ -502,13 +555,16 @@ class CostScreen(Vertical):
502
555
  self.app.call_from_thread(self._render_warehouse_drill, df, warehouse_name, days)
503
556
 
504
557
  def _render_warehouse_drill(self, df: pd.DataFrame, warehouse_name: str, days: int) -> None:
558
+ if self._current_view != "drill_warehouse":
559
+ return
505
560
  if df is None or df.empty:
506
561
  self._set_status(f"No daily data for {warehouse_name} ({days}d). Esc to return")
507
562
  self._clear_table()
508
563
  return
509
564
  total = float(df["CREDITS"].sum())
510
565
  self._set_status(
511
- 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"
512
568
  )
513
569
  table = self.query_one(DataTable)
514
570
  self._reset_table()
@@ -517,7 +573,8 @@ class CostScreen(Vertical):
517
573
  for _, row in df.iterrows():
518
574
  credits = float(row["CREDITS"])
519
575
  ratio = credits / max_c if max_c > 0 else 0
520
- 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"]))
521
578
 
522
579
  # --- View 5: Users -----------------------------------------------
523
580
 
@@ -571,6 +628,7 @@ class CostScreen(Vertical):
571
628
 
572
629
  def _fetch_user_drill(self, user_name: str) -> None:
573
630
  self._current_view = "drill_user"
631
+ self._reset_table()
574
632
  self._set_status(f"Loading warehouse detail for {user_name} ({min(self._days(),7)}d)… Esc to return")
575
633
  self._user_drill_worker(user_name=user_name, days=min(self._days(), 7))
576
634
 
@@ -584,6 +642,8 @@ class CostScreen(Vertical):
584
642
  self.app.call_from_thread(self._render_user_drill, df, user_name, days, note)
585
643
 
586
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
587
647
  if df is None or df.empty:
588
648
  self._set_status(f"No attribution data for {user_name} ({days}d). Esc to return")
589
649
  self._clear_table()
@@ -591,7 +651,8 @@ class CostScreen(Vertical):
591
651
  total = float(df["CREDITS"].sum())
592
652
  note_suffix = f" · ⚠ {note}" if note else ""
593
653
  self._set_status(
594
- 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"
595
656
  )
596
657
  no_credits = note is not None
597
658
  table = self.query_one(DataTable)
@@ -605,12 +666,117 @@ class CostScreen(Vertical):
605
666
  credits_str,
606
667
  str(int(row.get("QUERY_COUNT", 0))),
607
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", "")),
608
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
+ )
609
773
 
610
774
  # --- Drill: day breakdown (Level 1 — service types) --------------
611
775
 
612
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
613
778
  self._current_view = "drill_day"
779
+ self._reset_table() # clear immediately so stale rows can't be re-clicked while loading
614
780
  self._set_status(f"Loading service breakdown for {date}… Esc to return")
615
781
  self._day_drill_worker(date=date)
616
782
 
@@ -624,6 +790,8 @@ class CostScreen(Vertical):
624
790
  self.app.call_from_thread(self._render_day_drill, df, date)
625
791
 
626
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
627
795
  self._reset_table()
628
796
  table = self.query_one(DataTable)
629
797
  if df is None or df.empty:
@@ -652,13 +820,28 @@ class CostScreen(Vertical):
652
820
  def _fetch_day_resource_drill(self, date: str, service_type: str) -> None:
653
821
  self._current_view = "drill_day_resource"
654
822
  self._drill_day_service_type = service_type
655
- self._set_status(f"Loading {service_type} breakdown for {date}… Esc to return")
656
823
  upper = service_type.upper()
657
824
  is_ai = any(kw in upper for kw in _AI_SERVICE_KEYWORDS)
658
- 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
+ )
659
840
 
660
841
  @work(thread=True, exclusive=True, group="cost")
661
- 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:
662
845
  try:
663
846
  svc = self.app.get_cost_service()
664
847
  if is_ai:
@@ -666,6 +849,11 @@ class CostScreen(Vertical):
666
849
  label = "AI Service"
667
850
  found = True
668
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
669
857
  else:
670
858
  df, label, found = svc.get_day_resource_breakdown(date, service_type)
671
859
  note = None
@@ -678,6 +866,8 @@ class CostScreen(Vertical):
678
866
 
679
867
  def _render_day_resource_drill(self, df: pd.DataFrame, date: str, service_type: str,
680
868
  label: str, found: bool, note: str | None) -> None:
869
+ if self._current_view != "drill_day_resource":
870
+ return # stale callback
681
871
  self._reset_table()
682
872
  table = self.query_one(DataTable)
683
873
  note_suffix = f" · ⚠ {note}" if note else ""
@@ -706,12 +896,11 @@ class CostScreen(Vertical):
706
896
  cells.append(str(int(row.get("REQUEST_COUNT", 0))))
707
897
  cells += [f"{pct:.1f}%", _bar(credits / max_c)]
708
898
  table.add_row(*cells, key=str(row["RESOURCE_NAME"]))
709
- upper = service_type.upper()
710
- can_drill_users = (
711
- upper == _WAREHOUSE_SERVICE_TYPE
712
- or any(kw in upper for kw in _AI_SERVICE_KEYWORDS)
899
+ user_hint = (
900
+ " · click to see users"
901
+ if self._drill_day_resource_kind in ("warehouse", "ai")
902
+ else ""
713
903
  )
714
- user_hint = " · click to see users" if can_drill_users else ""
715
904
  self._set_status(
716
905
  f"{service_type} — {date} · {total:,.4f} credits{note_suffix}{user_hint} · Esc to return"
717
906
  )
@@ -721,6 +910,7 @@ class CostScreen(Vertical):
721
910
  def _fetch_day_resource_users(self, date: str, service_type: str, resource: str) -> None:
722
911
  self._current_view = "drill_day_resource_users"
723
912
  self._drill_day_resource = resource
913
+ self._reset_table()
724
914
  self._set_status(f"Loading users for {resource} on {date}… Esc to return")
725
915
  self._day_resource_users_worker(date=date, service_type=service_type, resource=resource)
726
916
 
@@ -737,6 +927,8 @@ class CostScreen(Vertical):
737
927
 
738
928
  def _render_day_resource_users(self, df: pd.DataFrame, date: str,
739
929
  resource: str, note: str | None) -> None:
930
+ if self._current_view != "drill_day_resource_users":
931
+ return # stale callback
740
932
  self._reset_table()
741
933
  table = self.query_one(DataTable)
742
934
  note_suffix = f" · ⚠ {note}" if note else ""
@@ -764,7 +956,7 @@ class CostScreen(Vertical):
764
956
  )
765
957
  query_hint = (
766
958
  " · click user to see top queries"
767
- if (self._drill_day_service_type or "").upper() == _WAREHOUSE_SERVICE_TYPE
959
+ if self._drill_day_resource_kind == "warehouse"
768
960
  else ""
769
961
  )
770
962
  self._set_status(
@@ -776,6 +968,7 @@ class CostScreen(Vertical):
776
968
  def _fetch_day_user_queries(self, date: str, warehouse: str, user: str) -> None:
777
969
  self._current_view = "drill_day_user_queries"
778
970
  self._drill_day_user = user
971
+ self._reset_table()
779
972
  self._set_status(
780
973
  f"Loading top queries for {user} on {warehouse} ({date})… Esc to return"
781
974
  )
@@ -794,6 +987,8 @@ class CostScreen(Vertical):
794
987
 
795
988
  def _render_day_user_queries(self, df: pd.DataFrame, date: str,
796
989
  warehouse: str, user: str, note: str | None) -> None:
990
+ if self._current_view != "drill_day_user_queries":
991
+ return # stale callback
797
992
  self._reset_table()
798
993
  table = self.query_one(DataTable)
799
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.2
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