snowglobe-cli 0.1.8__tar.gz → 0.2.0__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.1.8 → snowglobe_cli-0.2.0}/PKG-INFO +1 -1
  2. {snowglobe_cli-0.1.8 → snowglobe_cli-0.2.0}/pyproject.toml +1 -1
  3. {snowglobe_cli-0.1.8 → snowglobe_cli-0.2.0}/snowglobe/cli/optimizer.py +1 -1
  4. {snowglobe_cli-0.1.8 → snowglobe_cli-0.2.0}/snowglobe/cli/shell.py +7 -0
  5. {snowglobe_cli-0.1.8 → snowglobe_cli-0.2.0}/snowglobe/core/cost_service.py +38 -8
  6. {snowglobe_cli-0.1.8 → snowglobe_cli-0.2.0}/snowglobe/core/report_service.py +9 -1
  7. {snowglobe_cli-0.1.8 → snowglobe_cli-0.2.0}/snowglobe/output/cli.py +51 -0
  8. {snowglobe_cli-0.1.8 → snowglobe_cli-0.2.0}/snowglobe/templates/report.md.j2 +12 -0
  9. {snowglobe_cli-0.1.8 → snowglobe_cli-0.2.0}/snowglobe/tui/screens/cost.py +49 -2
  10. {snowglobe_cli-0.1.8 → snowglobe_cli-0.2.0}/snowglobe/tui/screens/home.py +42 -19
  11. {snowglobe_cli-0.1.8 → snowglobe_cli-0.2.0}/snowglobe/tui/screens/reports.py +19 -11
  12. {snowglobe_cli-0.1.8 → snowglobe_cli-0.2.0}/snowglobe/tui/screens/tune.py +15 -11
  13. {snowglobe_cli-0.1.8 → snowglobe_cli-0.2.0}/snowglobe_cli.egg-info/PKG-INFO +1 -1
  14. {snowglobe_cli-0.1.8 → snowglobe_cli-0.2.0}/LICENSE +0 -0
  15. {snowglobe_cli-0.1.8 → snowglobe_cli-0.2.0}/README.md +0 -0
  16. {snowglobe_cli-0.1.8 → snowglobe_cli-0.2.0}/setup.cfg +0 -0
  17. {snowglobe_cli-0.1.8 → snowglobe_cli-0.2.0}/snowglobe/__init__.py +0 -0
  18. {snowglobe_cli-0.1.8 → snowglobe_cli-0.2.0}/snowglobe/__main__.py +0 -0
  19. {snowglobe_cli-0.1.8 → snowglobe_cli-0.2.0}/snowglobe/cli/__init__.py +0 -0
  20. {snowglobe_cli-0.1.8 → snowglobe_cli-0.2.0}/snowglobe/cli/access.py +0 -0
  21. {snowglobe_cli-0.1.8 → snowglobe_cli-0.2.0}/snowglobe/cli/app.py +0 -0
  22. {snowglobe_cli-0.1.8 → snowglobe_cli-0.2.0}/snowglobe/cli/context.py +0 -0
  23. {snowglobe_cli-0.1.8 → snowglobe_cli-0.2.0}/snowglobe/cli/cost.py +0 -0
  24. {snowglobe_cli-0.1.8 → snowglobe_cli-0.2.0}/snowglobe/cli/debug.py +0 -0
  25. {snowglobe_cli-0.1.8 → snowglobe_cli-0.2.0}/snowglobe/cli/diff.py +0 -0
  26. {snowglobe_cli-0.1.8 → snowglobe_cli-0.2.0}/snowglobe/cli/prompts.py +0 -0
  27. {snowglobe_cli-0.1.8 → snowglobe_cli-0.2.0}/snowglobe/cli/report.py +0 -0
  28. {snowglobe_cli-0.1.8 → snowglobe_cli-0.2.0}/snowglobe/cli/shell_completer.py +0 -0
  29. {snowglobe_cli-0.1.8 → snowglobe_cli-0.2.0}/snowglobe/collectors/access.py +0 -0
  30. {snowglobe_cli-0.1.8 → snowglobe_cli-0.2.0}/snowglobe/collectors/query_history.py +0 -0
  31. {snowglobe_cli-0.1.8 → snowglobe_cli-0.2.0}/snowglobe/collectors/query_profile.py +0 -0
  32. {snowglobe_cli-0.1.8 → snowglobe_cli-0.2.0}/snowglobe/config/loader.py +0 -0
  33. {snowglobe_cli-0.1.8 → snowglobe_cli-0.2.0}/snowglobe/core/access_service.py +0 -0
  34. {snowglobe_cli-0.1.8 → snowglobe_cli-0.2.0}/snowglobe/core/optimizer.py +0 -0
  35. {snowglobe_cli-0.1.8 → snowglobe_cli-0.2.0}/snowglobe/core/query_service.py +0 -0
  36. {snowglobe_cli-0.1.8 → snowglobe_cli-0.2.0}/snowglobe/core/risk_service.py +0 -0
  37. {snowglobe_cli-0.1.8 → snowglobe_cli-0.2.0}/snowglobe/engines/access/__init__.py +0 -0
  38. {snowglobe_cli-0.1.8 → snowglobe_cli-0.2.0}/snowglobe/engines/access/explainer.py +0 -0
  39. {snowglobe_cli-0.1.8 → snowglobe_cli-0.2.0}/snowglobe/engines/access/resolver.py +0 -0
  40. {snowglobe_cli-0.1.8 → snowglobe_cli-0.2.0}/snowglobe/engines/ai/cortex_optimizer.py +0 -0
  41. {snowglobe_cli-0.1.8 → snowglobe_cli-0.2.0}/snowglobe/engines/optimizer/query_optimizer.py +0 -0
  42. {snowglobe_cli-0.1.8 → snowglobe_cli-0.2.0}/snowglobe/graphs/__init__.py +0 -0
  43. {snowglobe_cli-0.1.8 → snowglobe_cli-0.2.0}/snowglobe/graphs/role_graph.py +0 -0
  44. {snowglobe_cli-0.1.8 → snowglobe_cli-0.2.0}/snowglobe/graphs/user_graph.py +0 -0
  45. {snowglobe_cli-0.1.8 → snowglobe_cli-0.2.0}/snowglobe/models/__init__.py +0 -0
  46. {snowglobe_cli-0.1.8 → snowglobe_cli-0.2.0}/snowglobe/models/access.py +0 -0
  47. {snowglobe_cli-0.1.8 → snowglobe_cli-0.2.0}/snowglobe/models/access_path.py +0 -0
  48. {snowglobe_cli-0.1.8 → snowglobe_cli-0.2.0}/snowglobe/models/object_ref.py +0 -0
  49. {snowglobe_cli-0.1.8 → snowglobe_cli-0.2.0}/snowglobe/models/object_type.py +0 -0
  50. {snowglobe_cli-0.1.8 → snowglobe_cli-0.2.0}/snowglobe/models/optimizer.py +0 -0
  51. {snowglobe_cli-0.1.8 → snowglobe_cli-0.2.0}/snowglobe/models/privilege.py +0 -0
  52. {snowglobe_cli-0.1.8 → snowglobe_cli-0.2.0}/snowglobe/models/query.py +0 -0
  53. {snowglobe_cli-0.1.8 → snowglobe_cli-0.2.0}/snowglobe/output/__init__.py +0 -0
  54. {snowglobe_cli-0.1.8 → snowglobe_cli-0.2.0}/snowglobe/queries/__init__.py +0 -0
  55. {snowglobe_cli-0.1.8 → snowglobe_cli-0.2.0}/snowglobe/queries/query_history.py +0 -0
  56. {snowglobe_cli-0.1.8 → snowglobe_cli-0.2.0}/snowglobe/snowflake/connection.py +0 -0
  57. {snowglobe_cli-0.1.8 → snowglobe_cli-0.2.0}/snowglobe/state/db.py +0 -0
  58. {snowglobe_cli-0.1.8 → snowglobe_cli-0.2.0}/snowglobe/state/state.py +0 -0
  59. {snowglobe_cli-0.1.8 → snowglobe_cli-0.2.0}/snowglobe/tests/access_tests.py +0 -0
  60. {snowglobe_cli-0.1.8 → snowglobe_cli-0.2.0}/snowglobe/tui/__init__.py +0 -0
  61. {snowglobe_cli-0.1.8 → snowglobe_cli-0.2.0}/snowglobe/tui/__main__.py +0 -0
  62. {snowglobe_cli-0.1.8 → snowglobe_cli-0.2.0}/snowglobe/tui/app.py +0 -0
  63. {snowglobe_cli-0.1.8 → snowglobe_cli-0.2.0}/snowglobe/tui/screens/__init__.py +0 -0
  64. {snowglobe_cli-0.1.8 → snowglobe_cli-0.2.0}/snowglobe/tui/screens/access.py +0 -0
  65. {snowglobe_cli-0.1.8 → snowglobe_cli-0.2.0}/snowglobe/tui/screens/refresh.py +0 -0
  66. {snowglobe_cli-0.1.8 → snowglobe_cli-0.2.0}/snowglobe/tui/screens/risk.py +0 -0
  67. {snowglobe_cli-0.1.8 → snowglobe_cli-0.2.0}/snowglobe/tui/styles.tcss +0 -0
  68. {snowglobe_cli-0.1.8 → snowglobe_cli-0.2.0}/snowglobe/tui/widgets/__init__.py +0 -0
  69. {snowglobe_cli-0.1.8 → snowglobe_cli-0.2.0}/snowglobe/tui/widgets/access_paths.py +0 -0
  70. {snowglobe_cli-0.1.8 → snowglobe_cli-0.2.0}/snowglobe/tui/widgets/cache_badge.py +0 -0
  71. {snowglobe_cli-0.1.8 → snowglobe_cli-0.2.0}/snowglobe/tui/widgets/header.py +0 -0
  72. {snowglobe_cli-0.1.8 → snowglobe_cli-0.2.0}/snowglobe/tui/widgets/nav.py +0 -0
  73. {snowglobe_cli-0.1.8 → snowglobe_cli-0.2.0}/snowglobe_cli.egg-info/SOURCES.txt +0 -0
  74. {snowglobe_cli-0.1.8 → snowglobe_cli-0.2.0}/snowglobe_cli.egg-info/dependency_links.txt +0 -0
  75. {snowglobe_cli-0.1.8 → snowglobe_cli-0.2.0}/snowglobe_cli.egg-info/entry_points.txt +0 -0
  76. {snowglobe_cli-0.1.8 → snowglobe_cli-0.2.0}/snowglobe_cli.egg-info/requires.txt +0 -0
  77. {snowglobe_cli-0.1.8 → snowglobe_cli-0.2.0}/snowglobe_cli.egg-info/top_level.txt +0 -0
  78. {snowglobe_cli-0.1.8 → snowglobe_cli-0.2.0}/tests/test_access.py +0 -0
  79. {snowglobe_cli-0.1.8 → snowglobe_cli-0.2.0}/tests/test_optimizer_engine.py +0 -0
  80. {snowglobe_cli-0.1.8 → snowglobe_cli-0.2.0}/tests/test_output.py +0 -0
  81. {snowglobe_cli-0.1.8 → snowglobe_cli-0.2.0}/tests/test_query_validation.py +0 -0
  82. {snowglobe_cli-0.1.8 → snowglobe_cli-0.2.0}/tests/test_state_db.py +0 -0
  83. {snowglobe_cli-0.1.8 → snowglobe_cli-0.2.0}/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.1.8
3
+ Version: 0.2.0
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.1.8"
3
+ version = "0.2.0"
4
4
  description = "Explainable cost and access visibility for Snowflake"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.12.3"
@@ -51,7 +51,7 @@ def query(
51
51
  if not no_ai:
52
52
  typer.echo("\nGenerating AI suggestions...")
53
53
  ai = optimizer_service.ai_suggestion(model=model)
54
- typer.echo(ai)
54
+ cli.print_ai_suggestion(ai)
55
55
 
56
56
 
57
57
  @opt_app.command()
@@ -1321,6 +1321,13 @@ def _cmd_optimize(ctx: SnowglobeContext, args: list):
1321
1321
  opt_exp = optimizer_service.expensive_operators()
1322
1322
  typer.echo(cli.format_expensive_operators(opt_exp))
1323
1323
 
1324
+ typer.echo("\nGenerating AI suggestions (Cortex)…")
1325
+ try:
1326
+ ai = optimizer_service.ai_suggestion()
1327
+ cli.print_ai_suggestion(ai)
1328
+ except Exception as e:
1329
+ typer.secho(f" AI suggestion failed: {e}", fg=typer.colors.YELLOW)
1330
+
1324
1331
 
1325
1332
  def _cmd_status(ctx: SnowglobeContext, args: list):
1326
1333
  """Show current shell working state."""
@@ -830,26 +830,36 @@ class CostService:
830
830
 
831
831
  # --- Budget status (Snowflake-native budgets) ---
832
832
 
833
- def get_budget_status(self) -> tuple[pd.DataFrame, str | None]:
833
+ def get_budget_status(self, days: int = 30) -> tuple[pd.DataFrame, str | None]:
834
834
  """
835
835
  Surface Snowflake-native budget status.
836
- Calls the account root budget spending history and lists custom budgets.
836
+ GET_SPENDING_HISTORY is a stored procedure, not a table function must use CALL.
837
837
  Returns (df, error_message) — error_message is set if budgets are not activated.
838
838
  """
839
839
  conn = self.context.connect()
840
840
  try:
841
841
  with conn:
842
- # Get spending history from account budget
843
- rows = conn.query("""
844
- SELECT *
845
- FROM TABLE(SNOWFLAKE.LOCAL.ACCOUNT_ROOT_BUDGET!GET_SPENDING_HISTORY())
842
+ rows = conn.query(f"""
843
+ CALL SNOWFLAKE.LOCAL.ACCOUNT_ROOT_BUDGET!GET_SPENDING_HISTORY(
844
+ TIME_LOWER_BOUND => DATEADD('days', -{int(days)}, CURRENT_TIMESTAMP()),
845
+ TIME_UPPER_BOUND => CURRENT_TIMESTAMP()
846
+ )
846
847
  """)
847
848
  df = pd.DataFrame(rows)
848
849
  return df, None
849
850
  except Exception as e:
850
851
  err_msg = str(e)
851
- if "not activated" in err_msg.lower() or "does not exist" in err_msg.lower():
852
- return pd.DataFrame(), "Budgets are not activated on this account. Use Snowsight or CALL SNOWFLAKE.LOCAL.ACCOUNT_ROOT_BUDGET!ACTIVATE() to enable."
852
+ not_activated = (
853
+ "ACCOUNT_ROOT_BUDGET_NOT_ACTIVATED" in err_msg
854
+ or "not activated" in err_msg.lower()
855
+ or "does not exist" in err_msg.lower()
856
+ )
857
+ if not_activated:
858
+ return pd.DataFrame(), (
859
+ "Snowflake budgets are not activated on this account.\n\n"
860
+ "To enable, run the following in a Snowsight worksheet as ACCOUNTADMIN:\n\n"
861
+ " CALL SNOWFLAKE.LOCAL.ACCOUNT_ROOT_BUDGET!ACTIVATE();"
862
+ )
853
863
  return pd.DataFrame(), f"Could not retrieve budget status: {err_msg}"
854
864
 
855
865
  # --- Replication costs ---
@@ -941,3 +951,23 @@ class CostService:
941
951
  except Exception:
942
952
  # View may not exist if no MVs are used
943
953
  return pd.DataFrame(), None
954
+
955
+ def get_day_detail(self, date: str) -> pd.DataFrame:
956
+ """Warehouse credit breakdown for a single calendar day."""
957
+ conn = self.context.connect()
958
+ with conn:
959
+ rows = conn.query(f"""
960
+ SELECT WAREHOUSE_NAME,
961
+ ROUND(SUM(CREDITS_USED), 4) AS CREDITS
962
+ FROM SNOWFLAKE.ACCOUNT_USAGE.WAREHOUSE_METERING_HISTORY
963
+ WHERE START_TIME::DATE = '{date}'
964
+ AND WAREHOUSE_NAME IS NOT NULL
965
+ GROUP BY 1
966
+ ORDER BY 2 DESC
967
+ LIMIT 30
968
+ """)
969
+ if not rows:
970
+ return pd.DataFrame(columns=["WAREHOUSE_NAME", "CREDITS"])
971
+ df = pd.DataFrame(rows)
972
+ df["CREDITS"] = df["CREDITS"].astype(float)
973
+ return df
@@ -37,8 +37,15 @@ class ReportService:
37
37
  ai_df, _, _ = self.cost_service.get_ai_costs(days)
38
38
  ai_total = float(ai_df["TOTAL_CREDITS"].sum()) if not ai_df.empty else 0
39
39
 
40
- # Storage
40
+ # AI costs by user — exclude zero-cost users
41
+ ai_users_df, _, _ = self.cost_service.get_ai_costs_by_user(days)
42
+ if not ai_users_df.empty:
43
+ ai_users_df = ai_users_df[ai_users_df["TOTAL_CREDITS"] > 0].reset_index(drop=True)
44
+
45
+ # Storage — exclude databases with no stored data
41
46
  storage_df, _ = self.cost_service.get_storage_usage(days)
47
+ if not storage_df.empty:
48
+ storage_df = storage_df[storage_df["TOTAL_TB"] > 0].reset_index(drop=True)
42
49
  storage_total_tb = float(storage_df["TOTAL_TB"].sum()) if not storage_df.empty else 0
43
50
  storage_total_cost = float(storage_df["EST_MONTHLY_COST"].sum()) if not storage_df.empty else 0
44
51
  storage_rate = self.cost_service.get_storage_rate()
@@ -56,6 +63,7 @@ class ReportService:
56
63
  "cost_total": cost_total,
57
64
  "ai_costs": ai_df.to_dict("records") if not ai_df.empty else [],
58
65
  "ai_total": ai_total,
66
+ "ai_users": ai_users_df.to_dict("records") if not ai_users_df.empty else [],
59
67
  "storage": storage_df.to_dict("records") if not storage_df.empty else [],
60
68
  "storage_total_tb": storage_total_tb,
61
69
  "storage_total_cost": storage_total_cost,
@@ -311,6 +311,57 @@ def format_query_insights(query_id, insights):
311
311
  return output
312
312
 
313
313
 
314
+ def format_ai_suggestion(result) -> str:
315
+ """
316
+ Convert the Cortex AI result (dict or string) to readable Markdown.
317
+ Safe to pass to a Textual Markdown widget or print as plain text.
318
+ """
319
+ import json
320
+
321
+ if isinstance(result, str):
322
+ try:
323
+ result = json.loads(result)
324
+ except (json.JSONDecodeError, ValueError):
325
+ return result # plain message — "No AI suggestions", error text, etc.
326
+
327
+ if not isinstance(result, dict):
328
+ return str(result)
329
+
330
+ lines = ["## AI Optimization Suggestions", ""]
331
+
332
+ summary = result.get("summary", "")
333
+ if summary:
334
+ lines += ["**Summary**", "", summary, ""]
335
+
336
+ for i, opt in enumerate(result.get("optimizations", []), 1):
337
+ problem = opt.get("problem", "")
338
+ solution = opt.get("solution", "")
339
+ explanation = opt.get("explanation", "")
340
+ sql = opt.get("sql", "").strip()
341
+
342
+ lines += ["---", "", f"### {i}. {problem}", ""]
343
+ if solution:
344
+ lines += [f"**Solution:** {solution}", ""]
345
+ if explanation:
346
+ lines += [f"**Explanation:** {explanation}", ""]
347
+ if sql:
348
+ lines += ["```sql", sql, "```", ""]
349
+
350
+ improvement = result.get("expected_improvement", "")
351
+ if improvement:
352
+ lines += ["---", "", f"**Expected Improvement:** {improvement}"]
353
+
354
+ return "\n".join(lines)
355
+
356
+
357
+ def print_ai_suggestion(result) -> None:
358
+ """Render the AI suggestion as formatted Markdown in the terminal via Rich."""
359
+ from rich.console import Console
360
+ from rich.markdown import Markdown as RichMarkdown
361
+
362
+ Console().print(RichMarkdown(format_ai_suggestion(result)))
363
+
364
+
314
365
  def format_drift_text(drift: dict) -> str:
315
366
  """Format access drift detection results."""
316
367
  if drift.get("error"):
@@ -30,6 +30,18 @@
30
30
 
31
31
  ---
32
32
 
33
+ ## AI Costs by User
34
+
35
+ | User | Functions | Analyst | Agent | Code | Intelligence | Total |
36
+ |---|---:|---:|---:|---:|---:|---:|
37
+ {% for row in ai_users -%}
38
+ | {{ row.USER_NAME }} | {{ "%.2f"|format(row.CORTEX_FUNCTIONS) }} | {{ "%.2f"|format(row.CORTEX_ANALYST) }} | {{ "%.2f"|format(row.CORTEX_AGENT) }} | {{ "%.2f"|format(row.CORTEX_CODE) }} | {{ "%.2f"|format(row.SNOWFLAKE_INTELLIGENCE) }} | {{ "%.2f"|format(row.TOTAL_CREDITS) }} |
39
+ {% else -%}
40
+ *No AI usage in this period.*
41
+ {% endfor -%}
42
+
43
+ ---
44
+
33
45
  ## Storage
34
46
 
35
47
  | Database | Size (TB) | Est. Monthly Cost |
@@ -38,11 +38,12 @@ class CostScreen(Vertical):
38
38
 
39
39
  # Active view: "summary" | "trend" | "top_queries" | "warehouses" | "users"
40
40
  # | "ai" | "ai_users" | "services" | "storage" | "replication" | "mv" | "budget"
41
- # | "drill_service" | "drill_warehouse" | "drill_user"
41
+ # | "drill_service" | "drill_warehouse" | "drill_user" | "drill_day"
42
42
  _current_view: str = "summary"
43
43
  _drill_service: str | None = None
44
44
  _drill_warehouse: str | None = None
45
45
  _drill_user: str | None = None
46
+ _drill_day: str | None = None
46
47
  # Becomes True after the user explicitly picks a view, so Select.Changed
47
48
  # events fired during the initial mount don't auto-trigger a Snowflake fetch.
48
49
  _user_initiated: bool = False
@@ -136,6 +137,9 @@ class CostScreen(Vertical):
136
137
  elif self._current_view == "users":
137
138
  self._drill_user = key
138
139
  self._fetch_user_drill(key)
140
+ elif self._current_view == "trend":
141
+ self._drill_day = key
142
+ self._fetch_day_drill(key)
139
143
  elif self._current_view == "top_queries":
140
144
  # Hand off to Tune via the App's content switcher.
141
145
  try:
@@ -179,6 +183,8 @@ class CostScreen(Vertical):
179
183
  self._fetch_warehouse_drill(self._drill_warehouse)
180
184
  elif self._current_view == "drill_user" and self._drill_user:
181
185
  self._fetch_user_drill(self._drill_user)
186
+ elif self._current_view == "drill_day" and self._drill_day:
187
+ self._fetch_day_drill(self._drill_day)
182
188
 
183
189
  def action_back_from_drill(self) -> None:
184
190
  if self._current_view == "drill_service":
@@ -190,6 +196,9 @@ class CostScreen(Vertical):
190
196
  elif self._current_view == "drill_user":
191
197
  self._drill_user = None
192
198
  self._fetch_users()
199
+ elif self._current_view == "drill_day":
200
+ self._drill_day = None
201
+ self._fetch_trend()
193
202
 
194
203
  # --- Days helper --------------------------------------------------
195
204
 
@@ -268,7 +277,7 @@ class CostScreen(Vertical):
268
277
  avg = float(df["CREDITS"].mean())
269
278
  suffix = f" · cached {cache_age}m ago" if cache_age else ""
270
279
  self._set_status(
271
- f"Daily trend — {days}d · total {total:,.2f} · avg/day {avg:,.2f}{suffix}"
280
+ f"Daily trend — {days}d · total {total:,.2f} · avg/day {avg:,.2f}{suffix} · click a date to drill in"
272
281
  )
273
282
 
274
283
  table = self.query_one(DataTable)
@@ -536,6 +545,44 @@ class CostScreen(Vertical):
536
545
  avg_str,
537
546
  )
538
547
 
548
+ # --- Drill: day detail -------------------------------------------
549
+
550
+ def _fetch_day_drill(self, date: str) -> None:
551
+ self._current_view = "drill_day"
552
+ self._set_status(f"Loading breakdown for {date}… Esc to return")
553
+ self._day_drill_worker(date=date)
554
+
555
+ @work(thread=True, exclusive=True, group="cost")
556
+ def _day_drill_worker(self, *, date: str) -> None:
557
+ try:
558
+ df = self.app.get_cost_service().get_day_detail(date)
559
+ except Exception as e:
560
+ self.app.call_from_thread(self._fetch_failed, e)
561
+ return
562
+ self.app.call_from_thread(self._render_day_drill, df, date)
563
+
564
+ def _render_day_drill(self, df: pd.DataFrame, date: str) -> None:
565
+ table = self._reset_table()
566
+ if df is None or df.empty:
567
+ table.add_columns("(no warehouse activity on this date)")
568
+ self._set_status(f"{date} · no data · Esc to return")
569
+ return
570
+ total = float(df["CREDITS"].sum())
571
+ max_c = float(df["CREDITS"].max()) if total > 0 else 1.0
572
+ table.add_columns("WAREHOUSE", "CREDITS", "% OF DAY", "BAR")
573
+ for _, row in df.iterrows():
574
+ credits = float(row["CREDITS"])
575
+ pct = (credits / total * 100) if total > 0 else 0
576
+ table.add_row(
577
+ str(row["WAREHOUSE_NAME"]),
578
+ f"{credits:,.4f}",
579
+ f"{pct:.1f}%",
580
+ _bar(credits / max_c),
581
+ )
582
+ self._set_status(
583
+ f"Day detail — {date} · {total:,.4f} credits · Esc to return"
584
+ )
585
+
539
586
  # --- View 6: AI services -----------------------------------------
540
587
 
541
588
  def _fetch_ai(self, force: bool = False) -> None:
@@ -1,13 +1,8 @@
1
1
  """Home — landing dashboard.
2
2
 
3
- Three KPI cards (Cache / Connection / This week) at the top, quick-action
4
- hotkeys + recent expensive queries DataTable below. KPI data comes from
5
- local SQLite (instant); recent queries fire one `get_top_queries(7, 5)`
6
- worker on mount.
7
-
8
- Hotkeys jump to common workflows:
9
- a Access check c Cost summary r Refresh cache
10
- w Who-access s Risk scan
3
+ Three KPI cards (Cache / Connection / This week) at the top.
4
+ Below: 30d spend trend (left) and recent expensive queries (right).
5
+ Both panels fire Snowflake workers on mount; KPI data is local/instant.
11
6
  """
12
7
  from datetime import datetime, timezone
13
8
 
@@ -48,16 +43,10 @@ class HomeScreen(Vertical):
48
43
  yield Static("loading…", id="kpi-week", classes="kpi-body")
49
44
 
50
45
  with Horizontal(id="home-body"):
51
- with Vertical(id="home-actions", classes="panel"):
52
- yield Static("Quick actions", classes="panel-title")
53
- yield Static(
54
- "[a] Access check\n"
55
- "[w] Who-access\n"
56
- "[c] Cost\n"
57
- "\\[s] Risk scan\n"
58
- "\\[r] Refresh\n",
59
- classes="actions-list",
60
- )
46
+ with Vertical(id="home-trend", classes="panel"):
47
+ yield Static("30d spend trend", classes="panel-title")
48
+ yield DataTable(id="home-trend-table",
49
+ cursor_type="none", zebra_stripes=True)
61
50
  with Vertical(id="home-queries", classes="panel"):
62
51
  yield Static("Recent expensive queries (7d)", classes="panel-title")
63
52
  yield DataTable(id="home-queries-table",
@@ -69,7 +58,8 @@ class HomeScreen(Vertical):
69
58
  self._refresh_kpis()
70
59
  # Re-render KPIs whenever the app-level cache age ticks (every 30s).
71
60
  self.watch(self.app, "cache_age_minutes", lambda _v: self._refresh_kpis())
72
- # Fire one query fetch when Home first mounts.
61
+ # Fire both Snowflake workers on mount (different groups → concurrent).
62
+ self._fetch_trend()
73
63
  self._fetch_queries()
74
64
 
75
65
  # --- Hotkey action -----------------------------------------------
@@ -172,6 +162,39 @@ class HomeScreen(Vertical):
172
162
  )
173
163
  self.query_one("#kpi-week", Static).update(text)
174
164
 
165
+ # --- 30d spend trend (one Snowflake call on mount) ---------------
166
+
167
+ @work(thread=True, exclusive=True, group="trend")
168
+ def _fetch_trend(self) -> None:
169
+ try:
170
+ df, _ = self.app.get_cost_service().get_daily_trend(days=30)
171
+ except Exception as e:
172
+ self.app.call_from_thread(self._trend_failed, e)
173
+ return
174
+ self.app.call_from_thread(self._render_trend, df)
175
+
176
+ def _trend_failed(self, err: Exception) -> None:
177
+ table = self.query_one("#home-trend-table", DataTable)
178
+ table.clear(columns=True)
179
+ table.add_columns(f"(could not load — {err})")
180
+
181
+ def _render_trend(self, df: pd.DataFrame) -> None:
182
+ table = self.query_one("#home-trend-table", DataTable)
183
+ table.clear(columns=True)
184
+ if df is None or df.empty:
185
+ table.add_columns("(no trend data)")
186
+ return
187
+ table.add_columns("DATE", "CREDITS", "TREND")
188
+ max_c = float(df["CREDITS"].max()) if not df.empty else 1.0
189
+ for _, row in df.iterrows():
190
+ credits = float(row["CREDITS"])
191
+ bar_width = max(1, int(credits / max_c * 20)) if max_c > 0 else 0
192
+ table.add_row(
193
+ str(row["DATE"]),
194
+ f"{credits:,.2f}",
195
+ "█" * bar_width,
196
+ )
197
+
175
198
  # --- Recent expensive queries (one Snowflake call on mount) -----
176
199
 
177
200
  @work(thread=True, exclusive=True, group="cost")
@@ -14,7 +14,7 @@ from typing import Any
14
14
 
15
15
  from textual import work
16
16
  from textual.app import ComposeResult
17
- from textual.containers import Horizontal, Vertical
17
+ from textual.containers import Horizontal, Vertical, VerticalScroll
18
18
  from textual.suggester import SuggestFromList
19
19
  from textual.widgets import Button, Input, Markdown, Select, Static
20
20
 
@@ -50,11 +50,11 @@ class ReportsScreen(Vertical):
50
50
  yield Select(_TYPES, value="full", id="rp-type",
51
51
  classes="form-select", allow_blank=False)
52
52
 
53
- with Horizontal(classes="form-row"):
53
+ with Horizontal(classes="form-row", id="rp-days-row"):
54
54
  yield Static("Window:", classes="form-label")
55
55
  yield Input(value="30", id="rp-days", classes="form-input")
56
56
 
57
- with Horizontal(classes="form-row"):
57
+ with Horizontal(classes="form-row", id="rp-top-row"):
58
58
  yield Static("Top queries:", classes="form-label")
59
59
  yield Input(value="10", id="rp-top", classes="form-input")
60
60
 
@@ -76,15 +76,16 @@ class ReportsScreen(Vertical):
76
76
  "Preview will appear here after Generate. Save writes to the output path.",
77
77
  id="rp-status", classes="hint",
78
78
  )
79
- yield Markdown(
80
- "No report yet — fill the form and click Generate.",
81
- id="rp-preview",
82
- )
79
+ with VerticalScroll(id="rp-preview-scroll"):
80
+ yield Markdown(
81
+ "No report yet — fill the form and click Generate.",
82
+ id="rp-preview",
83
+ )
83
84
 
84
85
  # --- Lifecycle ----------------------------------------------------
85
86
 
86
87
  def on_mount(self) -> None:
87
- # Username suggester from user_graph (only useful for user reports).
88
+ self._update_form_visibility("full")
88
89
  self.watch(self.app, "user_graph",
89
90
  lambda _v: self._refresh_user_suggester())
90
91
 
@@ -103,9 +104,16 @@ class ReportsScreen(Vertical):
103
104
 
104
105
  def on_select_changed(self, event: Select.Changed) -> None:
105
106
  if event.select.id == "rp-type":
106
- # Update the default output path to match the type
107
- new_path = _default_path(str(event.value))
108
- self.query_one("#rp-path", Input).value = new_path
107
+ rtype = str(event.value)
108
+ self.query_one("#rp-path", Input).value = _default_path(rtype)
109
+ self._update_form_visibility(rtype)
110
+
111
+ def _update_form_visibility(self, rtype: str) -> None:
112
+ is_user = rtype == "user"
113
+ is_full = rtype == "full"
114
+ self.query_one("#rp-days-row").display = not is_user
115
+ self.query_one("#rp-top-row").display = is_full
116
+ self.query_one("#rp-user-row").display = is_user
109
117
 
110
118
  def on_button_pressed(self, event: Button.Pressed) -> None:
111
119
  if event.button.id == "rp-generate":
@@ -17,7 +17,7 @@ from rich.syntax import Syntax
17
17
 
18
18
  from textual import work
19
19
  from textual.app import ComposeResult
20
- from textual.containers import Horizontal, Vertical
20
+ from textual.containers import Horizontal, Vertical, VerticalScroll
21
21
  from textual.widgets import (
22
22
  Button, DataTable, Input, Markdown, Static, TabbedContent, TabPane, Tree,
23
23
  )
@@ -46,13 +46,15 @@ class TuneScreen(Vertical):
46
46
  with Horizontal(id="tu-body"):
47
47
  with Vertical(id="tu-sql-pane", classes="panel"):
48
48
  yield Static("SQL", classes="panel-title")
49
- yield Static("Run an analysis to see SQL here.",
50
- id="tu-sql", classes="sql-pane")
49
+ with VerticalScroll(id="tu-sql-scroll"):
50
+ yield Static("Run an analysis to see SQL here.",
51
+ id="tu-sql", classes="sql-pane")
51
52
  with Vertical(id="tu-analysis-pane", classes="panel"):
52
53
  yield Static("Analysis", classes="panel-title")
53
54
  with TabbedContent(initial="tu-tab-heuristics"):
54
55
  with TabPane("Heuristics", id="tu-tab-heuristics"):
55
- yield Static("Pending.", id="tu-heuristics")
56
+ with VerticalScroll(id="tu-heuristics-scroll"):
57
+ yield Static("Pending.", id="tu-heuristics")
56
58
  with TabPane("Insights", id="tu-tab-insights"):
57
59
  yield DataTable(id="tu-insights-table",
58
60
  cursor_type="row", zebra_stripes=True)
@@ -65,11 +67,12 @@ class TuneScreen(Vertical):
65
67
  with Horizontal(classes="actions-row"):
66
68
  yield Button("Generate AI suggestion (Cortex)",
67
69
  id="tu-ai-run", variant="warning")
68
- yield Markdown(
69
- "AI suggestions will appear here after you Analyse "
70
- "and then click Generate.",
71
- id="tu-ai",
72
- )
70
+ with VerticalScroll(id="tu-ai-scroll"):
71
+ yield Markdown(
72
+ "AI suggestions will appear here after you Analyse "
73
+ "and then click Generate.",
74
+ id="tu-ai",
75
+ )
73
76
 
74
77
  # --- Events -------------------------------------------------------
75
78
 
@@ -249,6 +252,7 @@ class TuneScreen(Vertical):
249
252
  self.query_one("#tu-ai-run", Button).disabled = False
250
253
  self.app.notify(f"AI generation failed: {err}", severity="error", timeout=8)
251
254
 
252
- def _render_ai(self, text: str) -> None:
253
- self.query_one("#tu-ai", Markdown).update(text or "(empty AI response)")
255
+ def _render_ai(self, text) -> None:
256
+ from snowglobe.output.cli import format_ai_suggestion
257
+ self.query_one("#tu-ai", Markdown).update(format_ai_suggestion(text) or "(empty AI response)")
254
258
  self.query_one("#tu-ai-run", Button).disabled = False
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: snowglobe-cli
3
- Version: 0.1.8
3
+ Version: 0.2.0
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