snowglobe-cli 0.1.9__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.
- {snowglobe_cli-0.1.9 → snowglobe_cli-0.2.0}/PKG-INFO +1 -1
- {snowglobe_cli-0.1.9 → snowglobe_cli-0.2.0}/pyproject.toml +1 -1
- {snowglobe_cli-0.1.9 → snowglobe_cli-0.2.0}/snowglobe/core/cost_service.py +20 -0
- {snowglobe_cli-0.1.9 → snowglobe_cli-0.2.0}/snowglobe/core/report_service.py +9 -1
- {snowglobe_cli-0.1.9 → snowglobe_cli-0.2.0}/snowglobe/templates/report.md.j2 +12 -0
- {snowglobe_cli-0.1.9 → snowglobe_cli-0.2.0}/snowglobe/tui/screens/cost.py +49 -2
- {snowglobe_cli-0.1.9 → snowglobe_cli-0.2.0}/snowglobe/tui/screens/home.py +42 -19
- {snowglobe_cli-0.1.9 → snowglobe_cli-0.2.0}/snowglobe/tui/screens/reports.py +19 -11
- {snowglobe_cli-0.1.9 → snowglobe_cli-0.2.0}/snowglobe/tui/screens/tune.py +12 -9
- {snowglobe_cli-0.1.9 → snowglobe_cli-0.2.0}/snowglobe_cli.egg-info/PKG-INFO +1 -1
- {snowglobe_cli-0.1.9 → snowglobe_cli-0.2.0}/LICENSE +0 -0
- {snowglobe_cli-0.1.9 → snowglobe_cli-0.2.0}/README.md +0 -0
- {snowglobe_cli-0.1.9 → snowglobe_cli-0.2.0}/setup.cfg +0 -0
- {snowglobe_cli-0.1.9 → snowglobe_cli-0.2.0}/snowglobe/__init__.py +0 -0
- {snowglobe_cli-0.1.9 → snowglobe_cli-0.2.0}/snowglobe/__main__.py +0 -0
- {snowglobe_cli-0.1.9 → snowglobe_cli-0.2.0}/snowglobe/cli/__init__.py +0 -0
- {snowglobe_cli-0.1.9 → snowglobe_cli-0.2.0}/snowglobe/cli/access.py +0 -0
- {snowglobe_cli-0.1.9 → snowglobe_cli-0.2.0}/snowglobe/cli/app.py +0 -0
- {snowglobe_cli-0.1.9 → snowglobe_cli-0.2.0}/snowglobe/cli/context.py +0 -0
- {snowglobe_cli-0.1.9 → snowglobe_cli-0.2.0}/snowglobe/cli/cost.py +0 -0
- {snowglobe_cli-0.1.9 → snowglobe_cli-0.2.0}/snowglobe/cli/debug.py +0 -0
- {snowglobe_cli-0.1.9 → snowglobe_cli-0.2.0}/snowglobe/cli/diff.py +0 -0
- {snowglobe_cli-0.1.9 → snowglobe_cli-0.2.0}/snowglobe/cli/optimizer.py +0 -0
- {snowglobe_cli-0.1.9 → snowglobe_cli-0.2.0}/snowglobe/cli/prompts.py +0 -0
- {snowglobe_cli-0.1.9 → snowglobe_cli-0.2.0}/snowglobe/cli/report.py +0 -0
- {snowglobe_cli-0.1.9 → snowglobe_cli-0.2.0}/snowglobe/cli/shell.py +0 -0
- {snowglobe_cli-0.1.9 → snowglobe_cli-0.2.0}/snowglobe/cli/shell_completer.py +0 -0
- {snowglobe_cli-0.1.9 → snowglobe_cli-0.2.0}/snowglobe/collectors/access.py +0 -0
- {snowglobe_cli-0.1.9 → snowglobe_cli-0.2.0}/snowglobe/collectors/query_history.py +0 -0
- {snowglobe_cli-0.1.9 → snowglobe_cli-0.2.0}/snowglobe/collectors/query_profile.py +0 -0
- {snowglobe_cli-0.1.9 → snowglobe_cli-0.2.0}/snowglobe/config/loader.py +0 -0
- {snowglobe_cli-0.1.9 → snowglobe_cli-0.2.0}/snowglobe/core/access_service.py +0 -0
- {snowglobe_cli-0.1.9 → snowglobe_cli-0.2.0}/snowglobe/core/optimizer.py +0 -0
- {snowglobe_cli-0.1.9 → snowglobe_cli-0.2.0}/snowglobe/core/query_service.py +0 -0
- {snowglobe_cli-0.1.9 → snowglobe_cli-0.2.0}/snowglobe/core/risk_service.py +0 -0
- {snowglobe_cli-0.1.9 → snowglobe_cli-0.2.0}/snowglobe/engines/access/__init__.py +0 -0
- {snowglobe_cli-0.1.9 → snowglobe_cli-0.2.0}/snowglobe/engines/access/explainer.py +0 -0
- {snowglobe_cli-0.1.9 → snowglobe_cli-0.2.0}/snowglobe/engines/access/resolver.py +0 -0
- {snowglobe_cli-0.1.9 → snowglobe_cli-0.2.0}/snowglobe/engines/ai/cortex_optimizer.py +0 -0
- {snowglobe_cli-0.1.9 → snowglobe_cli-0.2.0}/snowglobe/engines/optimizer/query_optimizer.py +0 -0
- {snowglobe_cli-0.1.9 → snowglobe_cli-0.2.0}/snowglobe/graphs/__init__.py +0 -0
- {snowglobe_cli-0.1.9 → snowglobe_cli-0.2.0}/snowglobe/graphs/role_graph.py +0 -0
- {snowglobe_cli-0.1.9 → snowglobe_cli-0.2.0}/snowglobe/graphs/user_graph.py +0 -0
- {snowglobe_cli-0.1.9 → snowglobe_cli-0.2.0}/snowglobe/models/__init__.py +0 -0
- {snowglobe_cli-0.1.9 → snowglobe_cli-0.2.0}/snowglobe/models/access.py +0 -0
- {snowglobe_cli-0.1.9 → snowglobe_cli-0.2.0}/snowglobe/models/access_path.py +0 -0
- {snowglobe_cli-0.1.9 → snowglobe_cli-0.2.0}/snowglobe/models/object_ref.py +0 -0
- {snowglobe_cli-0.1.9 → snowglobe_cli-0.2.0}/snowglobe/models/object_type.py +0 -0
- {snowglobe_cli-0.1.9 → snowglobe_cli-0.2.0}/snowglobe/models/optimizer.py +0 -0
- {snowglobe_cli-0.1.9 → snowglobe_cli-0.2.0}/snowglobe/models/privilege.py +0 -0
- {snowglobe_cli-0.1.9 → snowglobe_cli-0.2.0}/snowglobe/models/query.py +0 -0
- {snowglobe_cli-0.1.9 → snowglobe_cli-0.2.0}/snowglobe/output/__init__.py +0 -0
- {snowglobe_cli-0.1.9 → snowglobe_cli-0.2.0}/snowglobe/output/cli.py +0 -0
- {snowglobe_cli-0.1.9 → snowglobe_cli-0.2.0}/snowglobe/queries/__init__.py +0 -0
- {snowglobe_cli-0.1.9 → snowglobe_cli-0.2.0}/snowglobe/queries/query_history.py +0 -0
- {snowglobe_cli-0.1.9 → snowglobe_cli-0.2.0}/snowglobe/snowflake/connection.py +0 -0
- {snowglobe_cli-0.1.9 → snowglobe_cli-0.2.0}/snowglobe/state/db.py +0 -0
- {snowglobe_cli-0.1.9 → snowglobe_cli-0.2.0}/snowglobe/state/state.py +0 -0
- {snowglobe_cli-0.1.9 → snowglobe_cli-0.2.0}/snowglobe/tests/access_tests.py +0 -0
- {snowglobe_cli-0.1.9 → snowglobe_cli-0.2.0}/snowglobe/tui/__init__.py +0 -0
- {snowglobe_cli-0.1.9 → snowglobe_cli-0.2.0}/snowglobe/tui/__main__.py +0 -0
- {snowglobe_cli-0.1.9 → snowglobe_cli-0.2.0}/snowglobe/tui/app.py +0 -0
- {snowglobe_cli-0.1.9 → snowglobe_cli-0.2.0}/snowglobe/tui/screens/__init__.py +0 -0
- {snowglobe_cli-0.1.9 → snowglobe_cli-0.2.0}/snowglobe/tui/screens/access.py +0 -0
- {snowglobe_cli-0.1.9 → snowglobe_cli-0.2.0}/snowglobe/tui/screens/refresh.py +0 -0
- {snowglobe_cli-0.1.9 → snowglobe_cli-0.2.0}/snowglobe/tui/screens/risk.py +0 -0
- {snowglobe_cli-0.1.9 → snowglobe_cli-0.2.0}/snowglobe/tui/styles.tcss +0 -0
- {snowglobe_cli-0.1.9 → snowglobe_cli-0.2.0}/snowglobe/tui/widgets/__init__.py +0 -0
- {snowglobe_cli-0.1.9 → snowglobe_cli-0.2.0}/snowglobe/tui/widgets/access_paths.py +0 -0
- {snowglobe_cli-0.1.9 → snowglobe_cli-0.2.0}/snowglobe/tui/widgets/cache_badge.py +0 -0
- {snowglobe_cli-0.1.9 → snowglobe_cli-0.2.0}/snowglobe/tui/widgets/header.py +0 -0
- {snowglobe_cli-0.1.9 → snowglobe_cli-0.2.0}/snowglobe/tui/widgets/nav.py +0 -0
- {snowglobe_cli-0.1.9 → snowglobe_cli-0.2.0}/snowglobe_cli.egg-info/SOURCES.txt +0 -0
- {snowglobe_cli-0.1.9 → snowglobe_cli-0.2.0}/snowglobe_cli.egg-info/dependency_links.txt +0 -0
- {snowglobe_cli-0.1.9 → snowglobe_cli-0.2.0}/snowglobe_cli.egg-info/entry_points.txt +0 -0
- {snowglobe_cli-0.1.9 → snowglobe_cli-0.2.0}/snowglobe_cli.egg-info/requires.txt +0 -0
- {snowglobe_cli-0.1.9 → snowglobe_cli-0.2.0}/snowglobe_cli.egg-info/top_level.txt +0 -0
- {snowglobe_cli-0.1.9 → snowglobe_cli-0.2.0}/tests/test_access.py +0 -0
- {snowglobe_cli-0.1.9 → snowglobe_cli-0.2.0}/tests/test_optimizer_engine.py +0 -0
- {snowglobe_cli-0.1.9 → snowglobe_cli-0.2.0}/tests/test_output.py +0 -0
- {snowglobe_cli-0.1.9 → snowglobe_cli-0.2.0}/tests/test_query_validation.py +0 -0
- {snowglobe_cli-0.1.9 → snowglobe_cli-0.2.0}/tests/test_state_db.py +0 -0
- {snowglobe_cli-0.1.9 → snowglobe_cli-0.2.0}/tests/test_tui_tune.py +0 -0
|
@@ -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
|
-
#
|
|
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,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
|
|
4
|
-
|
|
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-
|
|
52
|
-
yield Static("
|
|
53
|
-
yield
|
|
54
|
-
|
|
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
|
|
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
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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
|
-
|
|
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
|
-
|
|
107
|
-
|
|
108
|
-
self.
|
|
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
|
-
|
|
50
|
-
|
|
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
|
-
|
|
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
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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
|
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|