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.
- {snowglobe_cli-0.1.8 → snowglobe_cli-0.2.0}/PKG-INFO +1 -1
- {snowglobe_cli-0.1.8 → snowglobe_cli-0.2.0}/pyproject.toml +1 -1
- {snowglobe_cli-0.1.8 → snowglobe_cli-0.2.0}/snowglobe/cli/optimizer.py +1 -1
- {snowglobe_cli-0.1.8 → snowglobe_cli-0.2.0}/snowglobe/cli/shell.py +7 -0
- {snowglobe_cli-0.1.8 → snowglobe_cli-0.2.0}/snowglobe/core/cost_service.py +38 -8
- {snowglobe_cli-0.1.8 → snowglobe_cli-0.2.0}/snowglobe/core/report_service.py +9 -1
- {snowglobe_cli-0.1.8 → snowglobe_cli-0.2.0}/snowglobe/output/cli.py +51 -0
- {snowglobe_cli-0.1.8 → snowglobe_cli-0.2.0}/snowglobe/templates/report.md.j2 +12 -0
- {snowglobe_cli-0.1.8 → snowglobe_cli-0.2.0}/snowglobe/tui/screens/cost.py +49 -2
- {snowglobe_cli-0.1.8 → snowglobe_cli-0.2.0}/snowglobe/tui/screens/home.py +42 -19
- {snowglobe_cli-0.1.8 → snowglobe_cli-0.2.0}/snowglobe/tui/screens/reports.py +19 -11
- {snowglobe_cli-0.1.8 → snowglobe_cli-0.2.0}/snowglobe/tui/screens/tune.py +15 -11
- {snowglobe_cli-0.1.8 → snowglobe_cli-0.2.0}/snowglobe_cli.egg-info/PKG-INFO +1 -1
- {snowglobe_cli-0.1.8 → snowglobe_cli-0.2.0}/LICENSE +0 -0
- {snowglobe_cli-0.1.8 → snowglobe_cli-0.2.0}/README.md +0 -0
- {snowglobe_cli-0.1.8 → snowglobe_cli-0.2.0}/setup.cfg +0 -0
- {snowglobe_cli-0.1.8 → snowglobe_cli-0.2.0}/snowglobe/__init__.py +0 -0
- {snowglobe_cli-0.1.8 → snowglobe_cli-0.2.0}/snowglobe/__main__.py +0 -0
- {snowglobe_cli-0.1.8 → snowglobe_cli-0.2.0}/snowglobe/cli/__init__.py +0 -0
- {snowglobe_cli-0.1.8 → snowglobe_cli-0.2.0}/snowglobe/cli/access.py +0 -0
- {snowglobe_cli-0.1.8 → snowglobe_cli-0.2.0}/snowglobe/cli/app.py +0 -0
- {snowglobe_cli-0.1.8 → snowglobe_cli-0.2.0}/snowglobe/cli/context.py +0 -0
- {snowglobe_cli-0.1.8 → snowglobe_cli-0.2.0}/snowglobe/cli/cost.py +0 -0
- {snowglobe_cli-0.1.8 → snowglobe_cli-0.2.0}/snowglobe/cli/debug.py +0 -0
- {snowglobe_cli-0.1.8 → snowglobe_cli-0.2.0}/snowglobe/cli/diff.py +0 -0
- {snowglobe_cli-0.1.8 → snowglobe_cli-0.2.0}/snowglobe/cli/prompts.py +0 -0
- {snowglobe_cli-0.1.8 → snowglobe_cli-0.2.0}/snowglobe/cli/report.py +0 -0
- {snowglobe_cli-0.1.8 → snowglobe_cli-0.2.0}/snowglobe/cli/shell_completer.py +0 -0
- {snowglobe_cli-0.1.8 → snowglobe_cli-0.2.0}/snowglobe/collectors/access.py +0 -0
- {snowglobe_cli-0.1.8 → snowglobe_cli-0.2.0}/snowglobe/collectors/query_history.py +0 -0
- {snowglobe_cli-0.1.8 → snowglobe_cli-0.2.0}/snowglobe/collectors/query_profile.py +0 -0
- {snowglobe_cli-0.1.8 → snowglobe_cli-0.2.0}/snowglobe/config/loader.py +0 -0
- {snowglobe_cli-0.1.8 → snowglobe_cli-0.2.0}/snowglobe/core/access_service.py +0 -0
- {snowglobe_cli-0.1.8 → snowglobe_cli-0.2.0}/snowglobe/core/optimizer.py +0 -0
- {snowglobe_cli-0.1.8 → snowglobe_cli-0.2.0}/snowglobe/core/query_service.py +0 -0
- {snowglobe_cli-0.1.8 → snowglobe_cli-0.2.0}/snowglobe/core/risk_service.py +0 -0
- {snowglobe_cli-0.1.8 → snowglobe_cli-0.2.0}/snowglobe/engines/access/__init__.py +0 -0
- {snowglobe_cli-0.1.8 → snowglobe_cli-0.2.0}/snowglobe/engines/access/explainer.py +0 -0
- {snowglobe_cli-0.1.8 → snowglobe_cli-0.2.0}/snowglobe/engines/access/resolver.py +0 -0
- {snowglobe_cli-0.1.8 → snowglobe_cli-0.2.0}/snowglobe/engines/ai/cortex_optimizer.py +0 -0
- {snowglobe_cli-0.1.8 → snowglobe_cli-0.2.0}/snowglobe/engines/optimizer/query_optimizer.py +0 -0
- {snowglobe_cli-0.1.8 → snowglobe_cli-0.2.0}/snowglobe/graphs/__init__.py +0 -0
- {snowglobe_cli-0.1.8 → snowglobe_cli-0.2.0}/snowglobe/graphs/role_graph.py +0 -0
- {snowglobe_cli-0.1.8 → snowglobe_cli-0.2.0}/snowglobe/graphs/user_graph.py +0 -0
- {snowglobe_cli-0.1.8 → snowglobe_cli-0.2.0}/snowglobe/models/__init__.py +0 -0
- {snowglobe_cli-0.1.8 → snowglobe_cli-0.2.0}/snowglobe/models/access.py +0 -0
- {snowglobe_cli-0.1.8 → snowglobe_cli-0.2.0}/snowglobe/models/access_path.py +0 -0
- {snowglobe_cli-0.1.8 → snowglobe_cli-0.2.0}/snowglobe/models/object_ref.py +0 -0
- {snowglobe_cli-0.1.8 → snowglobe_cli-0.2.0}/snowglobe/models/object_type.py +0 -0
- {snowglobe_cli-0.1.8 → snowglobe_cli-0.2.0}/snowglobe/models/optimizer.py +0 -0
- {snowglobe_cli-0.1.8 → snowglobe_cli-0.2.0}/snowglobe/models/privilege.py +0 -0
- {snowglobe_cli-0.1.8 → snowglobe_cli-0.2.0}/snowglobe/models/query.py +0 -0
- {snowglobe_cli-0.1.8 → snowglobe_cli-0.2.0}/snowglobe/output/__init__.py +0 -0
- {snowglobe_cli-0.1.8 → snowglobe_cli-0.2.0}/snowglobe/queries/__init__.py +0 -0
- {snowglobe_cli-0.1.8 → snowglobe_cli-0.2.0}/snowglobe/queries/query_history.py +0 -0
- {snowglobe_cli-0.1.8 → snowglobe_cli-0.2.0}/snowglobe/snowflake/connection.py +0 -0
- {snowglobe_cli-0.1.8 → snowglobe_cli-0.2.0}/snowglobe/state/db.py +0 -0
- {snowglobe_cli-0.1.8 → snowglobe_cli-0.2.0}/snowglobe/state/state.py +0 -0
- {snowglobe_cli-0.1.8 → snowglobe_cli-0.2.0}/snowglobe/tests/access_tests.py +0 -0
- {snowglobe_cli-0.1.8 → snowglobe_cli-0.2.0}/snowglobe/tui/__init__.py +0 -0
- {snowglobe_cli-0.1.8 → snowglobe_cli-0.2.0}/snowglobe/tui/__main__.py +0 -0
- {snowglobe_cli-0.1.8 → snowglobe_cli-0.2.0}/snowglobe/tui/app.py +0 -0
- {snowglobe_cli-0.1.8 → snowglobe_cli-0.2.0}/snowglobe/tui/screens/__init__.py +0 -0
- {snowglobe_cli-0.1.8 → snowglobe_cli-0.2.0}/snowglobe/tui/screens/access.py +0 -0
- {snowglobe_cli-0.1.8 → snowglobe_cli-0.2.0}/snowglobe/tui/screens/refresh.py +0 -0
- {snowglobe_cli-0.1.8 → snowglobe_cli-0.2.0}/snowglobe/tui/screens/risk.py +0 -0
- {snowglobe_cli-0.1.8 → snowglobe_cli-0.2.0}/snowglobe/tui/styles.tcss +0 -0
- {snowglobe_cli-0.1.8 → snowglobe_cli-0.2.0}/snowglobe/tui/widgets/__init__.py +0 -0
- {snowglobe_cli-0.1.8 → snowglobe_cli-0.2.0}/snowglobe/tui/widgets/access_paths.py +0 -0
- {snowglobe_cli-0.1.8 → snowglobe_cli-0.2.0}/snowglobe/tui/widgets/cache_badge.py +0 -0
- {snowglobe_cli-0.1.8 → snowglobe_cli-0.2.0}/snowglobe/tui/widgets/header.py +0 -0
- {snowglobe_cli-0.1.8 → snowglobe_cli-0.2.0}/snowglobe/tui/widgets/nav.py +0 -0
- {snowglobe_cli-0.1.8 → snowglobe_cli-0.2.0}/snowglobe_cli.egg-info/SOURCES.txt +0 -0
- {snowglobe_cli-0.1.8 → snowglobe_cli-0.2.0}/snowglobe_cli.egg-info/dependency_links.txt +0 -0
- {snowglobe_cli-0.1.8 → snowglobe_cli-0.2.0}/snowglobe_cli.egg-info/entry_points.txt +0 -0
- {snowglobe_cli-0.1.8 → snowglobe_cli-0.2.0}/snowglobe_cli.egg-info/requires.txt +0 -0
- {snowglobe_cli-0.1.8 → snowglobe_cli-0.2.0}/snowglobe_cli.egg-info/top_level.txt +0 -0
- {snowglobe_cli-0.1.8 → snowglobe_cli-0.2.0}/tests/test_access.py +0 -0
- {snowglobe_cli-0.1.8 → snowglobe_cli-0.2.0}/tests/test_optimizer_engine.py +0 -0
- {snowglobe_cli-0.1.8 → snowglobe_cli-0.2.0}/tests/test_output.py +0 -0
- {snowglobe_cli-0.1.8 → snowglobe_cli-0.2.0}/tests/test_query_validation.py +0 -0
- {snowglobe_cli-0.1.8 → snowglobe_cli-0.2.0}/tests/test_state_db.py +0 -0
- {snowglobe_cli-0.1.8 → snowglobe_cli-0.2.0}/tests/test_tui_tune.py +0 -0
|
@@ -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
|
-
|
|
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
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
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
|
-
|
|
852
|
-
|
|
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
|
-
#
|
|
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
|
|
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
|
|
|
@@ -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
|
|
253
|
-
|
|
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
|
|
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
|