snowglobe-cli 0.1.9__tar.gz → 0.2.1__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.9 → snowglobe_cli-0.2.1}/PKG-INFO +1 -1
  2. {snowglobe_cli-0.1.9 → snowglobe_cli-0.2.1}/pyproject.toml +1 -1
  3. {snowglobe_cli-0.1.9 → snowglobe_cli-0.2.1}/snowglobe/core/cost_service.py +20 -0
  4. {snowglobe_cli-0.1.9 → snowglobe_cli-0.2.1}/snowglobe/core/report_service.py +9 -1
  5. {snowglobe_cli-0.1.9 → snowglobe_cli-0.2.1}/snowglobe/templates/report.md.j2 +12 -0
  6. {snowglobe_cli-0.1.9 → snowglobe_cli-0.2.1}/snowglobe/tui/screens/cost.py +50 -2
  7. {snowglobe_cli-0.1.9 → snowglobe_cli-0.2.1}/snowglobe/tui/screens/home.py +64 -30
  8. {snowglobe_cli-0.1.9 → snowglobe_cli-0.2.1}/snowglobe/tui/screens/reports.py +19 -11
  9. {snowglobe_cli-0.1.9 → snowglobe_cli-0.2.1}/snowglobe/tui/screens/tune.py +12 -9
  10. {snowglobe_cli-0.1.9 → snowglobe_cli-0.2.1}/snowglobe/tui/styles.tcss +16 -4
  11. {snowglobe_cli-0.1.9 → snowglobe_cli-0.2.1}/snowglobe_cli.egg-info/PKG-INFO +1 -1
  12. {snowglobe_cli-0.1.9 → snowglobe_cli-0.2.1}/LICENSE +0 -0
  13. {snowglobe_cli-0.1.9 → snowglobe_cli-0.2.1}/README.md +0 -0
  14. {snowglobe_cli-0.1.9 → snowglobe_cli-0.2.1}/setup.cfg +0 -0
  15. {snowglobe_cli-0.1.9 → snowglobe_cli-0.2.1}/snowglobe/__init__.py +0 -0
  16. {snowglobe_cli-0.1.9 → snowglobe_cli-0.2.1}/snowglobe/__main__.py +0 -0
  17. {snowglobe_cli-0.1.9 → snowglobe_cli-0.2.1}/snowglobe/cli/__init__.py +0 -0
  18. {snowglobe_cli-0.1.9 → snowglobe_cli-0.2.1}/snowglobe/cli/access.py +0 -0
  19. {snowglobe_cli-0.1.9 → snowglobe_cli-0.2.1}/snowglobe/cli/app.py +0 -0
  20. {snowglobe_cli-0.1.9 → snowglobe_cli-0.2.1}/snowglobe/cli/context.py +0 -0
  21. {snowglobe_cli-0.1.9 → snowglobe_cli-0.2.1}/snowglobe/cli/cost.py +0 -0
  22. {snowglobe_cli-0.1.9 → snowglobe_cli-0.2.1}/snowglobe/cli/debug.py +0 -0
  23. {snowglobe_cli-0.1.9 → snowglobe_cli-0.2.1}/snowglobe/cli/diff.py +0 -0
  24. {snowglobe_cli-0.1.9 → snowglobe_cli-0.2.1}/snowglobe/cli/optimizer.py +0 -0
  25. {snowglobe_cli-0.1.9 → snowglobe_cli-0.2.1}/snowglobe/cli/prompts.py +0 -0
  26. {snowglobe_cli-0.1.9 → snowglobe_cli-0.2.1}/snowglobe/cli/report.py +0 -0
  27. {snowglobe_cli-0.1.9 → snowglobe_cli-0.2.1}/snowglobe/cli/shell.py +0 -0
  28. {snowglobe_cli-0.1.9 → snowglobe_cli-0.2.1}/snowglobe/cli/shell_completer.py +0 -0
  29. {snowglobe_cli-0.1.9 → snowglobe_cli-0.2.1}/snowglobe/collectors/access.py +0 -0
  30. {snowglobe_cli-0.1.9 → snowglobe_cli-0.2.1}/snowglobe/collectors/query_history.py +0 -0
  31. {snowglobe_cli-0.1.9 → snowglobe_cli-0.2.1}/snowglobe/collectors/query_profile.py +0 -0
  32. {snowglobe_cli-0.1.9 → snowglobe_cli-0.2.1}/snowglobe/config/loader.py +0 -0
  33. {snowglobe_cli-0.1.9 → snowglobe_cli-0.2.1}/snowglobe/core/access_service.py +0 -0
  34. {snowglobe_cli-0.1.9 → snowglobe_cli-0.2.1}/snowglobe/core/optimizer.py +0 -0
  35. {snowglobe_cli-0.1.9 → snowglobe_cli-0.2.1}/snowglobe/core/query_service.py +0 -0
  36. {snowglobe_cli-0.1.9 → snowglobe_cli-0.2.1}/snowglobe/core/risk_service.py +0 -0
  37. {snowglobe_cli-0.1.9 → snowglobe_cli-0.2.1}/snowglobe/engines/access/__init__.py +0 -0
  38. {snowglobe_cli-0.1.9 → snowglobe_cli-0.2.1}/snowglobe/engines/access/explainer.py +0 -0
  39. {snowglobe_cli-0.1.9 → snowglobe_cli-0.2.1}/snowglobe/engines/access/resolver.py +0 -0
  40. {snowglobe_cli-0.1.9 → snowglobe_cli-0.2.1}/snowglobe/engines/ai/cortex_optimizer.py +0 -0
  41. {snowglobe_cli-0.1.9 → snowglobe_cli-0.2.1}/snowglobe/engines/optimizer/query_optimizer.py +0 -0
  42. {snowglobe_cli-0.1.9 → snowglobe_cli-0.2.1}/snowglobe/graphs/__init__.py +0 -0
  43. {snowglobe_cli-0.1.9 → snowglobe_cli-0.2.1}/snowglobe/graphs/role_graph.py +0 -0
  44. {snowglobe_cli-0.1.9 → snowglobe_cli-0.2.1}/snowglobe/graphs/user_graph.py +0 -0
  45. {snowglobe_cli-0.1.9 → snowglobe_cli-0.2.1}/snowglobe/models/__init__.py +0 -0
  46. {snowglobe_cli-0.1.9 → snowglobe_cli-0.2.1}/snowglobe/models/access.py +0 -0
  47. {snowglobe_cli-0.1.9 → snowglobe_cli-0.2.1}/snowglobe/models/access_path.py +0 -0
  48. {snowglobe_cli-0.1.9 → snowglobe_cli-0.2.1}/snowglobe/models/object_ref.py +0 -0
  49. {snowglobe_cli-0.1.9 → snowglobe_cli-0.2.1}/snowglobe/models/object_type.py +0 -0
  50. {snowglobe_cli-0.1.9 → snowglobe_cli-0.2.1}/snowglobe/models/optimizer.py +0 -0
  51. {snowglobe_cli-0.1.9 → snowglobe_cli-0.2.1}/snowglobe/models/privilege.py +0 -0
  52. {snowglobe_cli-0.1.9 → snowglobe_cli-0.2.1}/snowglobe/models/query.py +0 -0
  53. {snowglobe_cli-0.1.9 → snowglobe_cli-0.2.1}/snowglobe/output/__init__.py +0 -0
  54. {snowglobe_cli-0.1.9 → snowglobe_cli-0.2.1}/snowglobe/output/cli.py +0 -0
  55. {snowglobe_cli-0.1.9 → snowglobe_cli-0.2.1}/snowglobe/queries/__init__.py +0 -0
  56. {snowglobe_cli-0.1.9 → snowglobe_cli-0.2.1}/snowglobe/queries/query_history.py +0 -0
  57. {snowglobe_cli-0.1.9 → snowglobe_cli-0.2.1}/snowglobe/snowflake/connection.py +0 -0
  58. {snowglobe_cli-0.1.9 → snowglobe_cli-0.2.1}/snowglobe/state/db.py +0 -0
  59. {snowglobe_cli-0.1.9 → snowglobe_cli-0.2.1}/snowglobe/state/state.py +0 -0
  60. {snowglobe_cli-0.1.9 → snowglobe_cli-0.2.1}/snowglobe/tests/access_tests.py +0 -0
  61. {snowglobe_cli-0.1.9 → snowglobe_cli-0.2.1}/snowglobe/tui/__init__.py +0 -0
  62. {snowglobe_cli-0.1.9 → snowglobe_cli-0.2.1}/snowglobe/tui/__main__.py +0 -0
  63. {snowglobe_cli-0.1.9 → snowglobe_cli-0.2.1}/snowglobe/tui/app.py +0 -0
  64. {snowglobe_cli-0.1.9 → snowglobe_cli-0.2.1}/snowglobe/tui/screens/__init__.py +0 -0
  65. {snowglobe_cli-0.1.9 → snowglobe_cli-0.2.1}/snowglobe/tui/screens/access.py +0 -0
  66. {snowglobe_cli-0.1.9 → snowglobe_cli-0.2.1}/snowglobe/tui/screens/refresh.py +0 -0
  67. {snowglobe_cli-0.1.9 → snowglobe_cli-0.2.1}/snowglobe/tui/screens/risk.py +0 -0
  68. {snowglobe_cli-0.1.9 → snowglobe_cli-0.2.1}/snowglobe/tui/widgets/__init__.py +0 -0
  69. {snowglobe_cli-0.1.9 → snowglobe_cli-0.2.1}/snowglobe/tui/widgets/access_paths.py +0 -0
  70. {snowglobe_cli-0.1.9 → snowglobe_cli-0.2.1}/snowglobe/tui/widgets/cache_badge.py +0 -0
  71. {snowglobe_cli-0.1.9 → snowglobe_cli-0.2.1}/snowglobe/tui/widgets/header.py +0 -0
  72. {snowglobe_cli-0.1.9 → snowglobe_cli-0.2.1}/snowglobe/tui/widgets/nav.py +0 -0
  73. {snowglobe_cli-0.1.9 → snowglobe_cli-0.2.1}/snowglobe_cli.egg-info/SOURCES.txt +0 -0
  74. {snowglobe_cli-0.1.9 → snowglobe_cli-0.2.1}/snowglobe_cli.egg-info/dependency_links.txt +0 -0
  75. {snowglobe_cli-0.1.9 → snowglobe_cli-0.2.1}/snowglobe_cli.egg-info/entry_points.txt +0 -0
  76. {snowglobe_cli-0.1.9 → snowglobe_cli-0.2.1}/snowglobe_cli.egg-info/requires.txt +0 -0
  77. {snowglobe_cli-0.1.9 → snowglobe_cli-0.2.1}/snowglobe_cli.egg-info/top_level.txt +0 -0
  78. {snowglobe_cli-0.1.9 → snowglobe_cli-0.2.1}/tests/test_access.py +0 -0
  79. {snowglobe_cli-0.1.9 → snowglobe_cli-0.2.1}/tests/test_optimizer_engine.py +0 -0
  80. {snowglobe_cli-0.1.9 → snowglobe_cli-0.2.1}/tests/test_output.py +0 -0
  81. {snowglobe_cli-0.1.9 → snowglobe_cli-0.2.1}/tests/test_query_validation.py +0 -0
  82. {snowglobe_cli-0.1.9 → snowglobe_cli-0.2.1}/tests/test_state_db.py +0 -0
  83. {snowglobe_cli-0.1.9 → snowglobe_cli-0.2.1}/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.9
3
+ Version: 0.2.1
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.9"
3
+ version = "0.2.1"
4
4
  description = "Explainable cost and access visibility for Snowflake"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.12.3"
@@ -951,3 +951,23 @@ class CostService:
951
951
  except Exception:
952
952
  # View may not exist if no MVs are used
953
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,
@@ -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,45 @@ 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
+ self._reset_table()
566
+ table = self.query_one(DataTable)
567
+ if df is None or df.empty:
568
+ table.add_columns("(no warehouse activity on this date)")
569
+ self._set_status(f"{date} · no data · Esc to return")
570
+ return
571
+ total = float(df["CREDITS"].sum())
572
+ max_c = float(df["CREDITS"].max()) if total > 0 else 1.0
573
+ table.add_columns("WAREHOUSE", "CREDITS", "% OF DAY", "BAR")
574
+ for _, row in df.iterrows():
575
+ credits = float(row["CREDITS"])
576
+ pct = (credits / total * 100) if total > 0 else 0
577
+ table.add_row(
578
+ str(row["WAREHOUSE_NAME"]),
579
+ f"{credits:,.4f}",
580
+ f"{pct:.1f}%",
581
+ _bar(credits / max_c),
582
+ )
583
+ self._set_status(
584
+ f"Day detail — {date} · {total:,.4f} credits · Esc to return"
585
+ )
586
+
539
587
  # --- View 6: AI services -----------------------------------------
540
588
 
541
589
  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="row", 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,40 @@ 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
+ key=str(row["DATE"]),
197
+ )
198
+
175
199
  # --- Recent expensive queries (one Snowflake call on mount) -----
176
200
 
177
201
  @work(thread=True, exclusive=True, group="cost")
@@ -206,17 +230,27 @@ class HomeScreen(Vertical):
206
230
  )
207
231
 
208
232
  def on_data_table_row_selected(self, event: DataTable.RowSelected) -> None:
209
- # Drill: open the query in the Tune screen.
210
233
  key = event.row_key.value if event.row_key else None
211
234
  if not key:
212
235
  return
213
- try:
214
- from textual.widgets import ContentSwitcher, Input
215
- from snowglobe.tui.screens.tune import TuneScreen
216
- switcher = self.app.query_one(ContentSwitcher)
217
- switcher.current = "tune"
218
- tune = self.app.query_one(TuneScreen)
219
- tune.query_one("#tu-query-id", Input).value = key
220
- self.app.notify(f"Loaded {key[:24]}… into Tune. Press Analyse.", timeout=4)
221
- except Exception:
222
- self.app.notify(f"Query: {key[:24]}", timeout=4)
236
+
237
+ if event.data_table.id == "home-trend-table":
238
+ # Drill into that day on the Cost screen.
239
+ try:
240
+ from textual.widgets import ContentSwitcher
241
+ from snowglobe.tui.screens.cost import CostScreen
242
+ self.app.query_one(ContentSwitcher).current = "cost"
243
+ self.app.query_one(CostScreen)._fetch_day_drill(key)
244
+ except Exception as e:
245
+ self.app.notify(f"Could not open day detail: {e}", severity="warning", timeout=4)
246
+ else:
247
+ # Queries table — open in Tune.
248
+ try:
249
+ from textual.widgets import ContentSwitcher, Input
250
+ from snowglobe.tui.screens.tune import TuneScreen
251
+ self.app.query_one(ContentSwitcher).current = "tune"
252
+ tune = self.app.query_one(TuneScreen)
253
+ tune.query_one("#tu-query-id", Input).value = key
254
+ self.app.notify(f"Loaded {key[:24]}… into Tune. Press Analyse.", timeout=4)
255
+ except Exception:
256
+ self.app.notify(f"Query: {key[:24]}…", timeout=4)
@@ -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
 
@@ -365,16 +365,22 @@ Tree > .tree--label-highlighted {
365
365
  margin-right: 1;
366
366
  }
367
367
 
368
- #tu-sql {
368
+ #tu-sql-scroll {
369
369
  height: 1fr;
370
+ }
371
+
372
+ #tu-sql {
370
373
  background: $background;
371
374
  padding: 1;
372
375
  }
373
376
 
377
+ #tu-heuristics-scroll {
378
+ height: 1fr;
379
+ }
380
+
374
381
  #tu-heuristics {
375
382
  padding: 1;
376
383
  color: $foreground;
377
- height: 1fr;
378
384
  }
379
385
 
380
386
  #tu-insights-table,
@@ -388,11 +394,14 @@ Tree > .tree--label-highlighted {
388
394
  height: 1fr;
389
395
  }
390
396
 
397
+ #tu-ai-scroll {
398
+ height: 1fr;
399
+ }
400
+
391
401
  #tu-ai {
392
402
  background: $background;
393
403
  color: $foreground;
394
404
  padding: 1;
395
- height: 1fr;
396
405
  }
397
406
 
398
407
  /* --- Reports screen --- */
@@ -402,8 +411,11 @@ Tree > .tree--label-highlighted {
402
411
  margin-bottom: 1;
403
412
  }
404
413
 
405
- #rp-preview {
414
+ #rp-preview-scroll {
406
415
  height: 1fr;
416
+ }
417
+
418
+ #rp-preview {
407
419
  background: $background;
408
420
  border: round $primary;
409
421
  padding: 1;
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: snowglobe-cli
3
- Version: 0.1.9
3
+ Version: 0.2.1
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