snowglobe-cli 0.2.0__tar.gz → 0.2.2__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.2.0 → snowglobe_cli-0.2.2}/PKG-INFO +1 -1
- {snowglobe_cli-0.2.0 → snowglobe_cli-0.2.2}/pyproject.toml +1 -1
- {snowglobe_cli-0.2.0 → snowglobe_cli-0.2.2}/snowglobe/core/cost_service.py +371 -7
- {snowglobe_cli-0.2.0 → snowglobe_cli-0.2.2}/snowglobe/tui/screens/cost.py +244 -9
- {snowglobe_cli-0.2.0 → snowglobe_cli-0.2.2}/snowglobe/tui/screens/home.py +23 -12
- {snowglobe_cli-0.2.0 → snowglobe_cli-0.2.2}/snowglobe/tui/styles.tcss +16 -4
- {snowglobe_cli-0.2.0 → snowglobe_cli-0.2.2}/snowglobe_cli.egg-info/PKG-INFO +1 -1
- {snowglobe_cli-0.2.0 → snowglobe_cli-0.2.2}/LICENSE +0 -0
- {snowglobe_cli-0.2.0 → snowglobe_cli-0.2.2}/README.md +0 -0
- {snowglobe_cli-0.2.0 → snowglobe_cli-0.2.2}/setup.cfg +0 -0
- {snowglobe_cli-0.2.0 → snowglobe_cli-0.2.2}/snowglobe/__init__.py +0 -0
- {snowglobe_cli-0.2.0 → snowglobe_cli-0.2.2}/snowglobe/__main__.py +0 -0
- {snowglobe_cli-0.2.0 → snowglobe_cli-0.2.2}/snowglobe/cli/__init__.py +0 -0
- {snowglobe_cli-0.2.0 → snowglobe_cli-0.2.2}/snowglobe/cli/access.py +0 -0
- {snowglobe_cli-0.2.0 → snowglobe_cli-0.2.2}/snowglobe/cli/app.py +0 -0
- {snowglobe_cli-0.2.0 → snowglobe_cli-0.2.2}/snowglobe/cli/context.py +0 -0
- {snowglobe_cli-0.2.0 → snowglobe_cli-0.2.2}/snowglobe/cli/cost.py +0 -0
- {snowglobe_cli-0.2.0 → snowglobe_cli-0.2.2}/snowglobe/cli/debug.py +0 -0
- {snowglobe_cli-0.2.0 → snowglobe_cli-0.2.2}/snowglobe/cli/diff.py +0 -0
- {snowglobe_cli-0.2.0 → snowglobe_cli-0.2.2}/snowglobe/cli/optimizer.py +0 -0
- {snowglobe_cli-0.2.0 → snowglobe_cli-0.2.2}/snowglobe/cli/prompts.py +0 -0
- {snowglobe_cli-0.2.0 → snowglobe_cli-0.2.2}/snowglobe/cli/report.py +0 -0
- {snowglobe_cli-0.2.0 → snowglobe_cli-0.2.2}/snowglobe/cli/shell.py +0 -0
- {snowglobe_cli-0.2.0 → snowglobe_cli-0.2.2}/snowglobe/cli/shell_completer.py +0 -0
- {snowglobe_cli-0.2.0 → snowglobe_cli-0.2.2}/snowglobe/collectors/access.py +0 -0
- {snowglobe_cli-0.2.0 → snowglobe_cli-0.2.2}/snowglobe/collectors/query_history.py +0 -0
- {snowglobe_cli-0.2.0 → snowglobe_cli-0.2.2}/snowglobe/collectors/query_profile.py +0 -0
- {snowglobe_cli-0.2.0 → snowglobe_cli-0.2.2}/snowglobe/config/loader.py +0 -0
- {snowglobe_cli-0.2.0 → snowglobe_cli-0.2.2}/snowglobe/core/access_service.py +0 -0
- {snowglobe_cli-0.2.0 → snowglobe_cli-0.2.2}/snowglobe/core/optimizer.py +0 -0
- {snowglobe_cli-0.2.0 → snowglobe_cli-0.2.2}/snowglobe/core/query_service.py +0 -0
- {snowglobe_cli-0.2.0 → snowglobe_cli-0.2.2}/snowglobe/core/report_service.py +0 -0
- {snowglobe_cli-0.2.0 → snowglobe_cli-0.2.2}/snowglobe/core/risk_service.py +0 -0
- {snowglobe_cli-0.2.0 → snowglobe_cli-0.2.2}/snowglobe/engines/access/__init__.py +0 -0
- {snowglobe_cli-0.2.0 → snowglobe_cli-0.2.2}/snowglobe/engines/access/explainer.py +0 -0
- {snowglobe_cli-0.2.0 → snowglobe_cli-0.2.2}/snowglobe/engines/access/resolver.py +0 -0
- {snowglobe_cli-0.2.0 → snowglobe_cli-0.2.2}/snowglobe/engines/ai/cortex_optimizer.py +0 -0
- {snowglobe_cli-0.2.0 → snowglobe_cli-0.2.2}/snowglobe/engines/optimizer/query_optimizer.py +0 -0
- {snowglobe_cli-0.2.0 → snowglobe_cli-0.2.2}/snowglobe/graphs/__init__.py +0 -0
- {snowglobe_cli-0.2.0 → snowglobe_cli-0.2.2}/snowglobe/graphs/role_graph.py +0 -0
- {snowglobe_cli-0.2.0 → snowglobe_cli-0.2.2}/snowglobe/graphs/user_graph.py +0 -0
- {snowglobe_cli-0.2.0 → snowglobe_cli-0.2.2}/snowglobe/models/__init__.py +0 -0
- {snowglobe_cli-0.2.0 → snowglobe_cli-0.2.2}/snowglobe/models/access.py +0 -0
- {snowglobe_cli-0.2.0 → snowglobe_cli-0.2.2}/snowglobe/models/access_path.py +0 -0
- {snowglobe_cli-0.2.0 → snowglobe_cli-0.2.2}/snowglobe/models/object_ref.py +0 -0
- {snowglobe_cli-0.2.0 → snowglobe_cli-0.2.2}/snowglobe/models/object_type.py +0 -0
- {snowglobe_cli-0.2.0 → snowglobe_cli-0.2.2}/snowglobe/models/optimizer.py +0 -0
- {snowglobe_cli-0.2.0 → snowglobe_cli-0.2.2}/snowglobe/models/privilege.py +0 -0
- {snowglobe_cli-0.2.0 → snowglobe_cli-0.2.2}/snowglobe/models/query.py +0 -0
- {snowglobe_cli-0.2.0 → snowglobe_cli-0.2.2}/snowglobe/output/__init__.py +0 -0
- {snowglobe_cli-0.2.0 → snowglobe_cli-0.2.2}/snowglobe/output/cli.py +0 -0
- {snowglobe_cli-0.2.0 → snowglobe_cli-0.2.2}/snowglobe/queries/__init__.py +0 -0
- {snowglobe_cli-0.2.0 → snowglobe_cli-0.2.2}/snowglobe/queries/query_history.py +0 -0
- {snowglobe_cli-0.2.0 → snowglobe_cli-0.2.2}/snowglobe/snowflake/connection.py +0 -0
- {snowglobe_cli-0.2.0 → snowglobe_cli-0.2.2}/snowglobe/state/db.py +0 -0
- {snowglobe_cli-0.2.0 → snowglobe_cli-0.2.2}/snowglobe/state/state.py +0 -0
- {snowglobe_cli-0.2.0 → snowglobe_cli-0.2.2}/snowglobe/templates/report.md.j2 +0 -0
- {snowglobe_cli-0.2.0 → snowglobe_cli-0.2.2}/snowglobe/tests/access_tests.py +0 -0
- {snowglobe_cli-0.2.0 → snowglobe_cli-0.2.2}/snowglobe/tui/__init__.py +0 -0
- {snowglobe_cli-0.2.0 → snowglobe_cli-0.2.2}/snowglobe/tui/__main__.py +0 -0
- {snowglobe_cli-0.2.0 → snowglobe_cli-0.2.2}/snowglobe/tui/app.py +0 -0
- {snowglobe_cli-0.2.0 → snowglobe_cli-0.2.2}/snowglobe/tui/screens/__init__.py +0 -0
- {snowglobe_cli-0.2.0 → snowglobe_cli-0.2.2}/snowglobe/tui/screens/access.py +0 -0
- {snowglobe_cli-0.2.0 → snowglobe_cli-0.2.2}/snowglobe/tui/screens/refresh.py +0 -0
- {snowglobe_cli-0.2.0 → snowglobe_cli-0.2.2}/snowglobe/tui/screens/reports.py +0 -0
- {snowglobe_cli-0.2.0 → snowglobe_cli-0.2.2}/snowglobe/tui/screens/risk.py +0 -0
- {snowglobe_cli-0.2.0 → snowglobe_cli-0.2.2}/snowglobe/tui/screens/tune.py +0 -0
- {snowglobe_cli-0.2.0 → snowglobe_cli-0.2.2}/snowglobe/tui/widgets/__init__.py +0 -0
- {snowglobe_cli-0.2.0 → snowglobe_cli-0.2.2}/snowglobe/tui/widgets/access_paths.py +0 -0
- {snowglobe_cli-0.2.0 → snowglobe_cli-0.2.2}/snowglobe/tui/widgets/cache_badge.py +0 -0
- {snowglobe_cli-0.2.0 → snowglobe_cli-0.2.2}/snowglobe/tui/widgets/header.py +0 -0
- {snowglobe_cli-0.2.0 → snowglobe_cli-0.2.2}/snowglobe/tui/widgets/nav.py +0 -0
- {snowglobe_cli-0.2.0 → snowglobe_cli-0.2.2}/snowglobe_cli.egg-info/SOURCES.txt +0 -0
- {snowglobe_cli-0.2.0 → snowglobe_cli-0.2.2}/snowglobe_cli.egg-info/dependency_links.txt +0 -0
- {snowglobe_cli-0.2.0 → snowglobe_cli-0.2.2}/snowglobe_cli.egg-info/entry_points.txt +0 -0
- {snowglobe_cli-0.2.0 → snowglobe_cli-0.2.2}/snowglobe_cli.egg-info/requires.txt +0 -0
- {snowglobe_cli-0.2.0 → snowglobe_cli-0.2.2}/snowglobe_cli.egg-info/top_level.txt +0 -0
- {snowglobe_cli-0.2.0 → snowglobe_cli-0.2.2}/tests/test_access.py +0 -0
- {snowglobe_cli-0.2.0 → snowglobe_cli-0.2.2}/tests/test_optimizer_engine.py +0 -0
- {snowglobe_cli-0.2.0 → snowglobe_cli-0.2.2}/tests/test_output.py +0 -0
- {snowglobe_cli-0.2.0 → snowglobe_cli-0.2.2}/tests/test_query_validation.py +0 -0
- {snowglobe_cli-0.2.0 → snowglobe_cli-0.2.2}/tests/test_state_db.py +0 -0
- {snowglobe_cli-0.2.0 → snowglobe_cli-0.2.2}/tests/test_tui_tune.py +0 -0
|
@@ -952,22 +952,386 @@ class CostService:
|
|
|
952
952
|
# View may not exist if no MVs are used
|
|
953
953
|
return pd.DataFrame(), None
|
|
954
954
|
|
|
955
|
-
def
|
|
955
|
+
def get_day_warehouse_breakdown(self, date: str) -> pd.DataFrame:
|
|
956
956
|
"""Warehouse credit breakdown for a single calendar day."""
|
|
957
957
|
conn = self.context.connect()
|
|
958
958
|
with conn:
|
|
959
959
|
rows = conn.query(f"""
|
|
960
|
-
SELECT WAREHOUSE_NAME,
|
|
960
|
+
SELECT WAREHOUSE_NAME AS RESOURCE_NAME,
|
|
961
961
|
ROUND(SUM(CREDITS_USED), 4) AS CREDITS
|
|
962
962
|
FROM SNOWFLAKE.ACCOUNT_USAGE.WAREHOUSE_METERING_HISTORY
|
|
963
|
-
WHERE START_TIME::DATE = '{date}'
|
|
963
|
+
WHERE START_TIME::DATE = '{_sq(date)}'
|
|
964
964
|
AND WAREHOUSE_NAME IS NOT NULL
|
|
965
|
-
GROUP BY 1
|
|
966
|
-
ORDER BY 2 DESC
|
|
967
|
-
LIMIT 30
|
|
965
|
+
GROUP BY 1 ORDER BY 2 DESC LIMIT 50
|
|
968
966
|
""")
|
|
969
967
|
if not rows:
|
|
970
|
-
return pd.DataFrame(columns=["
|
|
968
|
+
return pd.DataFrame(columns=["RESOURCE_NAME", "CREDITS"])
|
|
971
969
|
df = pd.DataFrame(rows)
|
|
972
970
|
df["CREDITS"] = df["CREDITS"].astype(float)
|
|
973
971
|
return df
|
|
972
|
+
|
|
973
|
+
def get_day_service_breakdown(self, date: str) -> pd.DataFrame:
|
|
974
|
+
"""All service types and credit totals for a single calendar day."""
|
|
975
|
+
conn = self.context.connect()
|
|
976
|
+
with conn:
|
|
977
|
+
rows = conn.query(f"""
|
|
978
|
+
SELECT SERVICE_TYPE,
|
|
979
|
+
ROUND(SUM(CREDITS_BILLED), 4) AS CREDITS
|
|
980
|
+
FROM SNOWFLAKE.ACCOUNT_USAGE.METERING_DAILY_HISTORY
|
|
981
|
+
WHERE USAGE_DATE = '{_sq(date)}'
|
|
982
|
+
AND SERVICE_TYPE IS NOT NULL
|
|
983
|
+
GROUP BY 1 ORDER BY 2 DESC
|
|
984
|
+
""")
|
|
985
|
+
if not rows:
|
|
986
|
+
return pd.DataFrame(columns=["SERVICE_TYPE", "CREDITS"])
|
|
987
|
+
df = pd.DataFrame(rows)
|
|
988
|
+
df["CREDITS"] = df["CREDITS"].astype(float)
|
|
989
|
+
return df
|
|
990
|
+
|
|
991
|
+
def get_day_resource_breakdown(self, date: str, service_type: str) -> tuple[pd.DataFrame, str, bool]:
|
|
992
|
+
"""
|
|
993
|
+
Resource-level credits for a service type on a single calendar day.
|
|
994
|
+
Returns (df, resource_label, found).
|
|
995
|
+
found=False when service_type has no known backing view.
|
|
996
|
+
"""
|
|
997
|
+
_empty = pd.DataFrame(columns=["RESOURCE_NAME", "CREDITS"])
|
|
998
|
+
d = _sq(date)
|
|
999
|
+
s = service_type.upper()
|
|
1000
|
+
|
|
1001
|
+
candidates = [
|
|
1002
|
+
(
|
|
1003
|
+
s == "WAREHOUSE_METERING",
|
|
1004
|
+
"Warehouse",
|
|
1005
|
+
f"""SELECT WAREHOUSE_NAME AS RESOURCE_NAME,
|
|
1006
|
+
ROUND(SUM(CREDITS_USED), 4) AS CREDITS
|
|
1007
|
+
FROM SNOWFLAKE.ACCOUNT_USAGE.WAREHOUSE_METERING_HISTORY
|
|
1008
|
+
WHERE START_TIME::DATE = '{d}' AND WAREHOUSE_NAME IS NOT NULL
|
|
1009
|
+
GROUP BY 1 ORDER BY 2 DESC LIMIT 50""",
|
|
1010
|
+
),
|
|
1011
|
+
(
|
|
1012
|
+
"PIPE" in s or "SNOWPIPE" in s,
|
|
1013
|
+
"Pipe",
|
|
1014
|
+
f"""SELECT PIPE_NAME AS RESOURCE_NAME,
|
|
1015
|
+
ROUND(SUM(CREDITS_USED), 4) AS CREDITS
|
|
1016
|
+
FROM SNOWFLAKE.ACCOUNT_USAGE.PIPE_USAGE_HISTORY
|
|
1017
|
+
WHERE START_TIME::DATE = '{d}'
|
|
1018
|
+
GROUP BY 1 ORDER BY 2 DESC LIMIT 50""",
|
|
1019
|
+
),
|
|
1020
|
+
(
|
|
1021
|
+
"TASK" in s,
|
|
1022
|
+
"Task",
|
|
1023
|
+
f"""SELECT DATABASE_NAME || '.' || SCHEMA_NAME || '.' || TASK_NAME AS RESOURCE_NAME,
|
|
1024
|
+
ROUND(SUM(CREDITS_USED), 4) AS CREDITS
|
|
1025
|
+
FROM SNOWFLAKE.ACCOUNT_USAGE.SERVERLESS_TASK_HISTORY
|
|
1026
|
+
WHERE START_TIME::DATE = '{d}'
|
|
1027
|
+
GROUP BY 1 ORDER BY 2 DESC LIMIT 50""",
|
|
1028
|
+
),
|
|
1029
|
+
(
|
|
1030
|
+
"CONTAINER" in s or "SPCS" in s or "SNOWPARK" in s,
|
|
1031
|
+
"Compute Pool",
|
|
1032
|
+
f"""SELECT COMPUTE_POOL_NAME AS RESOURCE_NAME,
|
|
1033
|
+
ROUND(SUM(CREDITS_USED), 4) AS CREDITS
|
|
1034
|
+
FROM SNOWFLAKE.ACCOUNT_USAGE.SNOWPARK_CONTAINER_SERVICES_HISTORY
|
|
1035
|
+
WHERE START_TIME::DATE = '{d}'
|
|
1036
|
+
GROUP BY 1 ORDER BY 2 DESC LIMIT 50""",
|
|
1037
|
+
),
|
|
1038
|
+
(
|
|
1039
|
+
"CLUSTERING" in s,
|
|
1040
|
+
"Table",
|
|
1041
|
+
f"""SELECT DATABASE_NAME || '.' || SCHEMA_NAME || '.' || TABLE_NAME AS RESOURCE_NAME,
|
|
1042
|
+
ROUND(SUM(CREDITS_USED), 4) AS CREDITS
|
|
1043
|
+
FROM SNOWFLAKE.ACCOUNT_USAGE.AUTOMATIC_CLUSTERING_HISTORY
|
|
1044
|
+
WHERE START_TIME::DATE = '{d}'
|
|
1045
|
+
GROUP BY 1 ORDER BY 2 DESC LIMIT 50""",
|
|
1046
|
+
),
|
|
1047
|
+
(
|
|
1048
|
+
"SEARCH" in s,
|
|
1049
|
+
"Table",
|
|
1050
|
+
f"""SELECT DATABASE_NAME || '.' || SCHEMA_NAME || '.' || TABLE_NAME AS RESOURCE_NAME,
|
|
1051
|
+
ROUND(SUM(CREDITS_USED), 4) AS CREDITS
|
|
1052
|
+
FROM SNOWFLAKE.ACCOUNT_USAGE.SEARCH_OPTIMIZATION_HISTORY
|
|
1053
|
+
WHERE START_TIME::DATE = '{d}'
|
|
1054
|
+
GROUP BY 1 ORDER BY 2 DESC LIMIT 50""",
|
|
1055
|
+
),
|
|
1056
|
+
(
|
|
1057
|
+
"REPLICATION" in s,
|
|
1058
|
+
"Replication Group",
|
|
1059
|
+
f"""SELECT REPLICATION_GROUP_NAME AS RESOURCE_NAME,
|
|
1060
|
+
ROUND(SUM(CREDITS_USED), 4) AS CREDITS
|
|
1061
|
+
FROM SNOWFLAKE.ACCOUNT_USAGE.REPLICATION_GROUP_USAGE_HISTORY
|
|
1062
|
+
WHERE START_TIME::DATE = '{d}'
|
|
1063
|
+
GROUP BY 1 ORDER BY 2 DESC LIMIT 50""",
|
|
1064
|
+
),
|
|
1065
|
+
(
|
|
1066
|
+
"MATERIALIZED" in s or s == "MV",
|
|
1067
|
+
"Materialized View",
|
|
1068
|
+
f"""SELECT DATABASE_NAME || '.' || SCHEMA_NAME || '.' || TABLE_NAME AS RESOURCE_NAME,
|
|
1069
|
+
ROUND(SUM(CREDITS_USED), 4) AS CREDITS
|
|
1070
|
+
FROM SNOWFLAKE.ACCOUNT_USAGE.MATERIALIZED_VIEW_REFRESH_HISTORY
|
|
1071
|
+
WHERE START_TIME::DATE = '{d}'
|
|
1072
|
+
GROUP BY 1 ORDER BY 2 DESC LIMIT 50""",
|
|
1073
|
+
),
|
|
1074
|
+
]
|
|
1075
|
+
|
|
1076
|
+
conn = self.context.connect()
|
|
1077
|
+
for matches, label, sql in candidates:
|
|
1078
|
+
if not matches:
|
|
1079
|
+
continue
|
|
1080
|
+
try:
|
|
1081
|
+
with conn:
|
|
1082
|
+
rows = conn.query(sql)
|
|
1083
|
+
if not rows:
|
|
1084
|
+
return _empty, label, True
|
|
1085
|
+
df = pd.DataFrame(rows)
|
|
1086
|
+
df["CREDITS"] = df["CREDITS"].astype(float)
|
|
1087
|
+
return df, label, True
|
|
1088
|
+
except Exception:
|
|
1089
|
+
return _empty, label, True
|
|
1090
|
+
|
|
1091
|
+
return _empty, service_type, False
|
|
1092
|
+
|
|
1093
|
+
def get_day_ai_breakdown(self, date: str) -> tuple[pd.DataFrame, bool]:
|
|
1094
|
+
"""Cortex AI service credit breakdown for a single calendar day."""
|
|
1095
|
+
d = _sq(date)
|
|
1096
|
+
sources = [
|
|
1097
|
+
("Cortex Functions", f"""
|
|
1098
|
+
SELECT u.LOGIN_NAME AS USER_NAME, 'Cortex Functions' AS SERVICE,
|
|
1099
|
+
f.credits AS TOKEN_CREDITS
|
|
1100
|
+
FROM SNOWFLAKE.ACCOUNT_USAGE.CORTEX_AI_FUNCTIONS_USAGE_HISTORY f
|
|
1101
|
+
INNER JOIN SNOWFLAKE.ACCOUNT_USAGE.USERS u ON u.USER_ID = f.USER_ID
|
|
1102
|
+
WHERE f.START_TIME::DATE = '{d}' AND f.credits > 0
|
|
1103
|
+
"""),
|
|
1104
|
+
("Cortex Analyst", f"""
|
|
1105
|
+
SELECT USERNAME AS USER_NAME, 'Cortex Analyst' AS SERVICE,
|
|
1106
|
+
credits AS TOKEN_CREDITS
|
|
1107
|
+
FROM SNOWFLAKE.ACCOUNT_USAGE.CORTEX_ANALYST_USAGE_HISTORY
|
|
1108
|
+
WHERE START_TIME::DATE = '{d}' AND credits > 0
|
|
1109
|
+
"""),
|
|
1110
|
+
("Cortex Agent", f"""
|
|
1111
|
+
SELECT USER_NAME, 'Cortex Agent' AS SERVICE, token_credits AS TOKEN_CREDITS
|
|
1112
|
+
FROM SNOWFLAKE.ACCOUNT_USAGE.CORTEX_AGENT_USAGE_HISTORY
|
|
1113
|
+
WHERE START_TIME::DATE = '{d}' AND token_credits > 0
|
|
1114
|
+
"""),
|
|
1115
|
+
("Cortex Code", f"""
|
|
1116
|
+
SELECT USER_NAME, 'Cortex Code' AS SERVICE, token_credits AS TOKEN_CREDITS
|
|
1117
|
+
FROM SNOWFLAKE.ACCOUNT_USAGE.CORTEX_CODE_CLI_USAGE_HISTORY
|
|
1118
|
+
WHERE USAGE_TIME::DATE = '{d}' AND token_credits > 0
|
|
1119
|
+
UNION ALL
|
|
1120
|
+
SELECT USER_NAME, 'Cortex Code', token_credits
|
|
1121
|
+
FROM SNOWFLAKE.ACCOUNT_USAGE.CORTEX_CODE_SNOWSIGHT_USAGE_HISTORY
|
|
1122
|
+
WHERE USAGE_TIME::DATE = '{d}' AND token_credits > 0
|
|
1123
|
+
UNION ALL
|
|
1124
|
+
SELECT USER_NAME, 'Cortex Code', token_credits
|
|
1125
|
+
FROM SNOWFLAKE.ACCOUNT_USAGE.CORTEX_CODE_DESKTOP_USAGE_HISTORY
|
|
1126
|
+
WHERE USAGE_TIME::DATE = '{d}' AND token_credits > 0
|
|
1127
|
+
"""),
|
|
1128
|
+
("Snowflake Intelligence", f"""
|
|
1129
|
+
SELECT USER_NAME, 'Snowflake Intelligence' AS SERVICE,
|
|
1130
|
+
token_credits AS TOKEN_CREDITS
|
|
1131
|
+
FROM SNOWFLAKE.ACCOUNT_USAGE.SNOWFLAKE_INTELLIGENCE_USAGE_HISTORY
|
|
1132
|
+
WHERE START_TIME::DATE = '{d}' AND token_credits > 0
|
|
1133
|
+
"""),
|
|
1134
|
+
]
|
|
1135
|
+
frames = []
|
|
1136
|
+
any_missing = False
|
|
1137
|
+
conn = self.context.connect()
|
|
1138
|
+
with conn:
|
|
1139
|
+
for _, sql in sources:
|
|
1140
|
+
try:
|
|
1141
|
+
rows = conn.query(sql)
|
|
1142
|
+
if rows:
|
|
1143
|
+
frames.append(pd.DataFrame(rows))
|
|
1144
|
+
except Exception:
|
|
1145
|
+
any_missing = True
|
|
1146
|
+
|
|
1147
|
+
_empty = pd.DataFrame(columns=["RESOURCE_NAME", "CREDITS", "REQUEST_COUNT"])
|
|
1148
|
+
if not frames:
|
|
1149
|
+
return _empty, any_missing
|
|
1150
|
+
|
|
1151
|
+
raw = pd.concat(frames, ignore_index=True)
|
|
1152
|
+
raw["TOKEN_CREDITS"] = raw["TOKEN_CREDITS"].astype(float)
|
|
1153
|
+
df = (
|
|
1154
|
+
raw.groupby("SERVICE")
|
|
1155
|
+
.agg(CREDITS=("TOKEN_CREDITS", "sum"), REQUEST_COUNT=("TOKEN_CREDITS", "count"))
|
|
1156
|
+
.reset_index()
|
|
1157
|
+
.rename(columns={"SERVICE": "RESOURCE_NAME"})
|
|
1158
|
+
.sort_values("CREDITS", ascending=False)
|
|
1159
|
+
.reset_index(drop=True)
|
|
1160
|
+
)
|
|
1161
|
+
df["CREDITS"] = df["CREDITS"].round(4)
|
|
1162
|
+
return df, any_missing
|
|
1163
|
+
|
|
1164
|
+
def get_day_resource_users(self, date: str, service_type: str, resource: str) -> tuple[pd.DataFrame, str | None]:
|
|
1165
|
+
"""
|
|
1166
|
+
User breakdown for a specific resource on a single calendar day.
|
|
1167
|
+
Supports WAREHOUSE_METERING (QUERY_ATTRIBUTION_HISTORY) and AI/Cortex types.
|
|
1168
|
+
Returns (df[USER_NAME, CREDITS, REQUESTS], note).
|
|
1169
|
+
"""
|
|
1170
|
+
_empty = pd.DataFrame(columns=["USER_NAME", "CREDITS", "REQUESTS"])
|
|
1171
|
+
d = _sq(date)
|
|
1172
|
+
svc_upper = service_type.upper()
|
|
1173
|
+
|
|
1174
|
+
if svc_upper == "WAREHOUSE_METERING":
|
|
1175
|
+
safe_wh = _sq(resource)
|
|
1176
|
+
conn = self.context.connect()
|
|
1177
|
+
try:
|
|
1178
|
+
with conn:
|
|
1179
|
+
rows = conn.query(f"""
|
|
1180
|
+
SELECT USER_NAME,
|
|
1181
|
+
ROUND(SUM(CREDITS_ATTRIBUTED_COMPUTE), 4) AS CREDITS,
|
|
1182
|
+
COUNT(*) AS REQUESTS
|
|
1183
|
+
FROM SNOWFLAKE.ACCOUNT_USAGE.QUERY_ATTRIBUTION_HISTORY
|
|
1184
|
+
WHERE START_TIME::DATE = '{d}'
|
|
1185
|
+
AND WAREHOUSE_NAME = '{safe_wh}'
|
|
1186
|
+
GROUP BY 1 ORDER BY 2 DESC LIMIT 50
|
|
1187
|
+
""")
|
|
1188
|
+
if not rows:
|
|
1189
|
+
return _empty, None
|
|
1190
|
+
df = pd.DataFrame(rows)
|
|
1191
|
+
df["CREDITS"] = df["CREDITS"].astype(float)
|
|
1192
|
+
return df, None
|
|
1193
|
+
except Exception:
|
|
1194
|
+
pass
|
|
1195
|
+
conn2 = self.context.connect()
|
|
1196
|
+
try:
|
|
1197
|
+
with conn2:
|
|
1198
|
+
rows = conn2.query(f"""
|
|
1199
|
+
SELECT USER_NAME, 0.0 AS CREDITS, COUNT(*) AS REQUESTS
|
|
1200
|
+
FROM SNOWFLAKE.ACCOUNT_USAGE.QUERY_HISTORY
|
|
1201
|
+
WHERE START_TIME::DATE = '{d}'
|
|
1202
|
+
AND WAREHOUSE_NAME = '{safe_wh}'
|
|
1203
|
+
AND EXECUTION_STATUS = 'SUCCESS'
|
|
1204
|
+
GROUP BY 1 ORDER BY 3 DESC LIMIT 50
|
|
1205
|
+
""")
|
|
1206
|
+
if not rows:
|
|
1207
|
+
return _empty, None
|
|
1208
|
+
df = pd.DataFrame(rows)
|
|
1209
|
+
df["CREDITS"] = df["CREDITS"].astype(float)
|
|
1210
|
+
return df, "Query Attribution not enabled — showing query counts only"
|
|
1211
|
+
except Exception:
|
|
1212
|
+
return _empty, None
|
|
1213
|
+
|
|
1214
|
+
_ai_kw = ("AI", "CORTEX", "INTELLIGENCE")
|
|
1215
|
+
if not any(kw in svc_upper for kw in _ai_kw):
|
|
1216
|
+
return _empty, "No user-level detail available for this service type"
|
|
1217
|
+
|
|
1218
|
+
sql_map = {
|
|
1219
|
+
"Cortex Functions": f"""
|
|
1220
|
+
SELECT u.LOGIN_NAME AS USER_NAME,
|
|
1221
|
+
ROUND(SUM(f.credits), 4) AS CREDITS, COUNT(*) AS REQUESTS
|
|
1222
|
+
FROM SNOWFLAKE.ACCOUNT_USAGE.CORTEX_AI_FUNCTIONS_USAGE_HISTORY f
|
|
1223
|
+
INNER JOIN SNOWFLAKE.ACCOUNT_USAGE.USERS u ON u.USER_ID = f.USER_ID
|
|
1224
|
+
WHERE f.START_TIME::DATE = '{d}' AND f.credits > 0
|
|
1225
|
+
GROUP BY 1 ORDER BY 2 DESC LIMIT 50
|
|
1226
|
+
""",
|
|
1227
|
+
"Cortex Analyst": f"""
|
|
1228
|
+
SELECT USERNAME AS USER_NAME,
|
|
1229
|
+
ROUND(SUM(credits), 4) AS CREDITS, COUNT(*) AS REQUESTS
|
|
1230
|
+
FROM SNOWFLAKE.ACCOUNT_USAGE.CORTEX_ANALYST_USAGE_HISTORY
|
|
1231
|
+
WHERE START_TIME::DATE = '{d}' AND credits > 0
|
|
1232
|
+
GROUP BY 1 ORDER BY 2 DESC LIMIT 50
|
|
1233
|
+
""",
|
|
1234
|
+
"Cortex Agent": f"""
|
|
1235
|
+
SELECT USER_NAME,
|
|
1236
|
+
ROUND(SUM(token_credits), 4) AS CREDITS, COUNT(*) AS REQUESTS
|
|
1237
|
+
FROM SNOWFLAKE.ACCOUNT_USAGE.CORTEX_AGENT_USAGE_HISTORY
|
|
1238
|
+
WHERE START_TIME::DATE = '{d}' AND token_credits > 0
|
|
1239
|
+
GROUP BY 1 ORDER BY 2 DESC LIMIT 50
|
|
1240
|
+
""",
|
|
1241
|
+
"Cortex Code": f"""
|
|
1242
|
+
SELECT USER_NAME,
|
|
1243
|
+
ROUND(SUM(token_credits), 4) AS CREDITS, COUNT(*) AS REQUESTS
|
|
1244
|
+
FROM (
|
|
1245
|
+
SELECT USER_NAME, token_credits
|
|
1246
|
+
FROM SNOWFLAKE.ACCOUNT_USAGE.CORTEX_CODE_CLI_USAGE_HISTORY
|
|
1247
|
+
WHERE USAGE_TIME::DATE = '{d}' AND token_credits > 0
|
|
1248
|
+
UNION ALL
|
|
1249
|
+
SELECT USER_NAME, token_credits
|
|
1250
|
+
FROM SNOWFLAKE.ACCOUNT_USAGE.CORTEX_CODE_SNOWSIGHT_USAGE_HISTORY
|
|
1251
|
+
WHERE USAGE_TIME::DATE = '{d}' AND token_credits > 0
|
|
1252
|
+
UNION ALL
|
|
1253
|
+
SELECT USER_NAME, token_credits
|
|
1254
|
+
FROM SNOWFLAKE.ACCOUNT_USAGE.CORTEX_CODE_DESKTOP_USAGE_HISTORY
|
|
1255
|
+
WHERE USAGE_TIME::DATE = '{d}' AND token_credits > 0
|
|
1256
|
+
) GROUP BY 1 ORDER BY 2 DESC LIMIT 50
|
|
1257
|
+
""",
|
|
1258
|
+
"Snowflake Intelligence": f"""
|
|
1259
|
+
SELECT USER_NAME,
|
|
1260
|
+
ROUND(SUM(token_credits), 4) AS CREDITS, COUNT(*) AS REQUESTS
|
|
1261
|
+
FROM SNOWFLAKE.ACCOUNT_USAGE.SNOWFLAKE_INTELLIGENCE_USAGE_HISTORY
|
|
1262
|
+
WHERE START_TIME::DATE = '{d}' AND token_credits > 0
|
|
1263
|
+
GROUP BY 1 ORDER BY 2 DESC LIMIT 50
|
|
1264
|
+
""",
|
|
1265
|
+
}
|
|
1266
|
+
sql = sql_map.get(resource)
|
|
1267
|
+
if not sql:
|
|
1268
|
+
return _empty, f"No user breakdown available for: {resource}"
|
|
1269
|
+
conn = self.context.connect()
|
|
1270
|
+
try:
|
|
1271
|
+
with conn:
|
|
1272
|
+
rows = conn.query(sql)
|
|
1273
|
+
if not rows:
|
|
1274
|
+
return _empty, None
|
|
1275
|
+
df = pd.DataFrame(rows)
|
|
1276
|
+
df["CREDITS"] = df["CREDITS"].astype(float)
|
|
1277
|
+
return df, None
|
|
1278
|
+
except Exception:
|
|
1279
|
+
return _empty, "Could not load user breakdown"
|
|
1280
|
+
|
|
1281
|
+
def get_day_user_queries(self, date: str, warehouse: str, user: str, limit: int = 3) -> tuple[pd.DataFrame, str | None]:
|
|
1282
|
+
"""Top queries for a user on a specific warehouse+date.
|
|
1283
|
+
Uses QUERY_ATTRIBUTION_HISTORY; falls back to QUERY_HISTORY ranked by elapsed time."""
|
|
1284
|
+
d = _sq(date)
|
|
1285
|
+
safe_wh = _sq(warehouse)
|
|
1286
|
+
safe_user = _sq(user)
|
|
1287
|
+
conn = self.context.connect()
|
|
1288
|
+
try:
|
|
1289
|
+
with conn:
|
|
1290
|
+
rows = conn.query(f"""
|
|
1291
|
+
SELECT a.QUERY_ID,
|
|
1292
|
+
ROUND(a.CREDITS_ATTRIBUTED_COMPUTE, 4) AS CREDITS,
|
|
1293
|
+
q.QUERY_TYPE,
|
|
1294
|
+
LEFT(q.QUERY_TEXT, 80) AS QUERY_PREVIEW,
|
|
1295
|
+
ROUND(q.TOTAL_ELAPSED_TIME / 1000, 1) AS SECONDS,
|
|
1296
|
+
ROUND(q.BYTES_SCANNED / 1e9, 2) AS GB_SCANNED
|
|
1297
|
+
FROM SNOWFLAKE.ACCOUNT_USAGE.QUERY_ATTRIBUTION_HISTORY a
|
|
1298
|
+
LEFT JOIN SNOWFLAKE.ACCOUNT_USAGE.QUERY_HISTORY q ON a.QUERY_ID = q.QUERY_ID
|
|
1299
|
+
WHERE a.START_TIME::DATE = '{d}'
|
|
1300
|
+
AND a.WAREHOUSE_NAME = '{safe_wh}'
|
|
1301
|
+
AND a.USER_NAME = '{safe_user}'
|
|
1302
|
+
ORDER BY a.CREDITS_ATTRIBUTED_COMPUTE DESC
|
|
1303
|
+
LIMIT {limit}
|
|
1304
|
+
""")
|
|
1305
|
+
if not rows:
|
|
1306
|
+
return pd.DataFrame(), None
|
|
1307
|
+
df = pd.DataFrame(rows)
|
|
1308
|
+
df["CREDITS"] = df["CREDITS"].astype(float)
|
|
1309
|
+
return df, None
|
|
1310
|
+
except Exception:
|
|
1311
|
+
pass
|
|
1312
|
+
|
|
1313
|
+
conn2 = self.context.connect()
|
|
1314
|
+
try:
|
|
1315
|
+
with conn2:
|
|
1316
|
+
rows = conn2.query(f"""
|
|
1317
|
+
SELECT QUERY_ID,
|
|
1318
|
+
0.0 AS CREDITS,
|
|
1319
|
+
QUERY_TYPE,
|
|
1320
|
+
LEFT(QUERY_TEXT, 80) AS QUERY_PREVIEW,
|
|
1321
|
+
ROUND(TOTAL_ELAPSED_TIME / 1000, 1) AS SECONDS,
|
|
1322
|
+
ROUND(BYTES_SCANNED / 1e9, 2) AS GB_SCANNED
|
|
1323
|
+
FROM SNOWFLAKE.ACCOUNT_USAGE.QUERY_HISTORY
|
|
1324
|
+
WHERE START_TIME::DATE = '{d}'
|
|
1325
|
+
AND WAREHOUSE_NAME = '{safe_wh}'
|
|
1326
|
+
AND USER_NAME = '{safe_user}'
|
|
1327
|
+
AND EXECUTION_STATUS = 'SUCCESS'
|
|
1328
|
+
ORDER BY TOTAL_ELAPSED_TIME DESC
|
|
1329
|
+
LIMIT {limit}
|
|
1330
|
+
""")
|
|
1331
|
+
if not rows:
|
|
1332
|
+
return pd.DataFrame(), "Query Attribution not enabled — showing slowest queries"
|
|
1333
|
+
df = pd.DataFrame(rows)
|
|
1334
|
+
df["CREDITS"] = df["CREDITS"].astype(float)
|
|
1335
|
+
return df, "Query Attribution not enabled — showing slowest queries"
|
|
1336
|
+
except Exception:
|
|
1337
|
+
return pd.DataFrame(), "Could not load queries"
|
|
@@ -20,6 +20,8 @@ from textual.widgets import Button, DataTable, Label, ListItem, ListView, Select
|
|
|
20
20
|
|
|
21
21
|
|
|
22
22
|
_BAR_WIDTH = 24 # max █ characters in trend column
|
|
23
|
+
_WAREHOUSE_SERVICE_TYPE = "WAREHOUSE_METERING"
|
|
24
|
+
_AI_SERVICE_KEYWORDS = ("AI", "CORTEX", "INTELLIGENCE")
|
|
23
25
|
|
|
24
26
|
|
|
25
27
|
def _bar(pct_or_ratio: float, width: int = _BAR_WIDTH) -> str:
|
|
@@ -38,12 +40,16 @@ class CostScreen(Vertical):
|
|
|
38
40
|
|
|
39
41
|
# Active view: "summary" | "trend" | "top_queries" | "warehouses" | "users"
|
|
40
42
|
# | "ai" | "ai_users" | "services" | "storage" | "replication" | "mv" | "budget"
|
|
41
|
-
# | "drill_service" | "drill_warehouse" | "drill_user"
|
|
43
|
+
# | "drill_service" | "drill_warehouse" | "drill_user"
|
|
44
|
+
# | "drill_day" | "drill_day_resource" | "drill_day_resource_users" | "drill_day_user_queries"
|
|
42
45
|
_current_view: str = "summary"
|
|
43
46
|
_drill_service: str | None = None
|
|
44
47
|
_drill_warehouse: str | None = None
|
|
45
48
|
_drill_user: str | None = None
|
|
46
49
|
_drill_day: str | None = None
|
|
50
|
+
_drill_day_service_type: str | None = None
|
|
51
|
+
_drill_day_resource: str | None = None
|
|
52
|
+
_drill_day_user: str | None = None
|
|
47
53
|
# Becomes True after the user explicitly picks a view, so Select.Changed
|
|
48
54
|
# events fired during the initial mount don't auto-trigger a Snowflake fetch.
|
|
49
55
|
_user_initiated: bool = False
|
|
@@ -140,6 +146,38 @@ class CostScreen(Vertical):
|
|
|
140
146
|
elif self._current_view == "trend":
|
|
141
147
|
self._drill_day = key
|
|
142
148
|
self._fetch_day_drill(key)
|
|
149
|
+
elif self._current_view == "drill_day":
|
|
150
|
+
self._drill_day_service_type = key
|
|
151
|
+
self._fetch_day_resource_drill(self._drill_day, key)
|
|
152
|
+
elif self._current_view == "drill_day_resource":
|
|
153
|
+
upper = (self._drill_day_service_type or "").upper()
|
|
154
|
+
can_drill = (
|
|
155
|
+
upper == _WAREHOUSE_SERVICE_TYPE
|
|
156
|
+
or any(kw in upper for kw in _AI_SERVICE_KEYWORDS)
|
|
157
|
+
)
|
|
158
|
+
if can_drill:
|
|
159
|
+
self._fetch_day_resource_users(
|
|
160
|
+
self._drill_day, self._drill_day_service_type, key
|
|
161
|
+
)
|
|
162
|
+
else:
|
|
163
|
+
self.app.notify(
|
|
164
|
+
"No user-level detail available for this resource type.", timeout=3
|
|
165
|
+
)
|
|
166
|
+
elif self._current_view == "drill_day_resource_users":
|
|
167
|
+
if (self._drill_day_service_type or "").upper() == _WAREHOUSE_SERVICE_TYPE:
|
|
168
|
+
self._fetch_day_user_queries(self._drill_day, self._drill_day_resource, key)
|
|
169
|
+
else:
|
|
170
|
+
self.app.notify("No query detail available for AI services.", timeout=3)
|
|
171
|
+
elif self._current_view == "drill_day_user_queries":
|
|
172
|
+
try:
|
|
173
|
+
from textual.widgets import ContentSwitcher, Input
|
|
174
|
+
from snowglobe.tui.screens.tune import TuneScreen
|
|
175
|
+
self.app.query_one(ContentSwitcher).current = "tune"
|
|
176
|
+
tune = self.app.query_one(TuneScreen)
|
|
177
|
+
tune.query_one("#tu-query-id", Input).value = key
|
|
178
|
+
self.app.notify(f"Loaded {key[:24]}… into Tune. Press Analyse.", timeout=4)
|
|
179
|
+
except Exception:
|
|
180
|
+
self.app.notify(f"Query: {key[:24]}…", timeout=4)
|
|
143
181
|
elif self._current_view == "top_queries":
|
|
144
182
|
# Hand off to Tune via the App's content switcher.
|
|
145
183
|
try:
|
|
@@ -185,6 +223,18 @@ class CostScreen(Vertical):
|
|
|
185
223
|
self._fetch_user_drill(self._drill_user)
|
|
186
224
|
elif self._current_view == "drill_day" and self._drill_day:
|
|
187
225
|
self._fetch_day_drill(self._drill_day)
|
|
226
|
+
elif self._current_view == "drill_day_resource" and self._drill_day and self._drill_day_service_type:
|
|
227
|
+
self._fetch_day_resource_drill(self._drill_day, self._drill_day_service_type)
|
|
228
|
+
elif (self._current_view == "drill_day_resource_users"
|
|
229
|
+
and self._drill_day and self._drill_day_service_type and self._drill_day_resource):
|
|
230
|
+
self._fetch_day_resource_users(
|
|
231
|
+
self._drill_day, self._drill_day_service_type, self._drill_day_resource
|
|
232
|
+
)
|
|
233
|
+
elif (self._current_view == "drill_day_user_queries"
|
|
234
|
+
and self._drill_day and self._drill_day_resource and self._drill_day_user):
|
|
235
|
+
self._fetch_day_user_queries(
|
|
236
|
+
self._drill_day, self._drill_day_resource, self._drill_day_user
|
|
237
|
+
)
|
|
188
238
|
|
|
189
239
|
def action_back_from_drill(self) -> None:
|
|
190
240
|
if self._current_view == "drill_service":
|
|
@@ -198,7 +248,19 @@ class CostScreen(Vertical):
|
|
|
198
248
|
self._fetch_users()
|
|
199
249
|
elif self._current_view == "drill_day":
|
|
200
250
|
self._drill_day = None
|
|
251
|
+
self._drill_day_service_type = None
|
|
201
252
|
self._fetch_trend()
|
|
253
|
+
elif self._current_view == "drill_day_resource":
|
|
254
|
+
self._drill_day_service_type = None
|
|
255
|
+
self._fetch_day_drill(self._drill_day)
|
|
256
|
+
elif self._current_view == "drill_day_resource_users":
|
|
257
|
+
self._drill_day_resource = None
|
|
258
|
+
self._fetch_day_resource_drill(self._drill_day, self._drill_day_service_type)
|
|
259
|
+
elif self._current_view == "drill_day_user_queries":
|
|
260
|
+
self._drill_day_user = None
|
|
261
|
+
self._fetch_day_resource_users(
|
|
262
|
+
self._drill_day, self._drill_day_service_type, self._drill_day_resource
|
|
263
|
+
)
|
|
202
264
|
|
|
203
265
|
# --- Days helper --------------------------------------------------
|
|
204
266
|
|
|
@@ -545,42 +607,215 @@ class CostScreen(Vertical):
|
|
|
545
607
|
avg_str,
|
|
546
608
|
)
|
|
547
609
|
|
|
548
|
-
# --- Drill: day
|
|
610
|
+
# --- Drill: day breakdown (Level 1 — service types) --------------
|
|
549
611
|
|
|
550
612
|
def _fetch_day_drill(self, date: str) -> None:
|
|
551
613
|
self._current_view = "drill_day"
|
|
552
|
-
self._set_status(f"Loading breakdown for {date}… Esc to return")
|
|
614
|
+
self._set_status(f"Loading service breakdown for {date}… Esc to return")
|
|
553
615
|
self._day_drill_worker(date=date)
|
|
554
616
|
|
|
555
617
|
@work(thread=True, exclusive=True, group="cost")
|
|
556
618
|
def _day_drill_worker(self, *, date: str) -> None:
|
|
557
619
|
try:
|
|
558
|
-
df = self.app.get_cost_service().
|
|
620
|
+
df = self.app.get_cost_service().get_day_service_breakdown(date)
|
|
559
621
|
except Exception as e:
|
|
560
622
|
self.app.call_from_thread(self._fetch_failed, e)
|
|
561
623
|
return
|
|
562
624
|
self.app.call_from_thread(self._render_day_drill, df, date)
|
|
563
625
|
|
|
564
626
|
def _render_day_drill(self, df: pd.DataFrame, date: str) -> None:
|
|
565
|
-
|
|
627
|
+
self._reset_table()
|
|
628
|
+
table = self.query_one(DataTable)
|
|
566
629
|
if df is None or df.empty:
|
|
567
|
-
table.add_columns("(no
|
|
630
|
+
table.add_columns("(no activity on this date)")
|
|
568
631
|
self._set_status(f"{date} · no data · Esc to return")
|
|
569
632
|
return
|
|
570
633
|
total = float(df["CREDITS"].sum())
|
|
571
634
|
max_c = float(df["CREDITS"].max()) if total > 0 else 1.0
|
|
572
|
-
table.add_columns("
|
|
635
|
+
table.add_columns("SERVICE TYPE", "CREDITS", "% OF DAY", "BAR")
|
|
573
636
|
for _, row in df.iterrows():
|
|
574
637
|
credits = float(row["CREDITS"])
|
|
575
638
|
pct = (credits / total * 100) if total > 0 else 0
|
|
576
639
|
table.add_row(
|
|
577
|
-
str(row["
|
|
640
|
+
str(row["SERVICE_TYPE"]),
|
|
578
641
|
f"{credits:,.4f}",
|
|
579
642
|
f"{pct:.1f}%",
|
|
580
643
|
_bar(credits / max_c),
|
|
644
|
+
key=str(row["SERVICE_TYPE"]),
|
|
645
|
+
)
|
|
646
|
+
self._set_status(
|
|
647
|
+
f"Day breakdown — {date} · {total:,.4f} credits · click a service to drill · Esc to return"
|
|
648
|
+
)
|
|
649
|
+
|
|
650
|
+
# --- Drill: resource breakdown (Level 2 — all service types) -----
|
|
651
|
+
|
|
652
|
+
def _fetch_day_resource_drill(self, date: str, service_type: str) -> None:
|
|
653
|
+
self._current_view = "drill_day_resource"
|
|
654
|
+
self._drill_day_service_type = service_type
|
|
655
|
+
self._set_status(f"Loading {service_type} breakdown for {date}… Esc to return")
|
|
656
|
+
upper = service_type.upper()
|
|
657
|
+
is_ai = any(kw in upper for kw in _AI_SERVICE_KEYWORDS)
|
|
658
|
+
self._day_resource_drill_worker(date=date, service_type=service_type, is_ai=is_ai)
|
|
659
|
+
|
|
660
|
+
@work(thread=True, exclusive=True, group="cost")
|
|
661
|
+
def _day_resource_drill_worker(self, *, date: str, service_type: str, is_ai: bool) -> None:
|
|
662
|
+
try:
|
|
663
|
+
svc = self.app.get_cost_service()
|
|
664
|
+
if is_ai:
|
|
665
|
+
df, any_missing = svc.get_day_ai_breakdown(date)
|
|
666
|
+
label = "AI Service"
|
|
667
|
+
found = True
|
|
668
|
+
note = "some Cortex views unavailable" if any_missing else None
|
|
669
|
+
else:
|
|
670
|
+
df, label, found = svc.get_day_resource_breakdown(date, service_type)
|
|
671
|
+
note = None
|
|
672
|
+
except Exception as e:
|
|
673
|
+
self.app.call_from_thread(self._fetch_failed, e)
|
|
674
|
+
return
|
|
675
|
+
self.app.call_from_thread(
|
|
676
|
+
self._render_day_resource_drill, df, date, service_type, label, found, note
|
|
677
|
+
)
|
|
678
|
+
|
|
679
|
+
def _render_day_resource_drill(self, df: pd.DataFrame, date: str, service_type: str,
|
|
680
|
+
label: str, found: bool, note: str | None) -> None:
|
|
681
|
+
self._reset_table()
|
|
682
|
+
table = self.query_one(DataTable)
|
|
683
|
+
note_suffix = f" · ⚠ {note}" if note else ""
|
|
684
|
+
if not found:
|
|
685
|
+
table.add_columns("(no detail available for this service type)")
|
|
686
|
+
self._set_status(
|
|
687
|
+
f"{service_type} — {date} · no resource detail available · Esc to return"
|
|
688
|
+
)
|
|
689
|
+
return
|
|
690
|
+
if df is None or df.empty:
|
|
691
|
+
table.add_columns(f"(no {label.lower()} activity on this date)")
|
|
692
|
+
self._set_status(f"{service_type} — {date} · no data{note_suffix} · Esc to return")
|
|
693
|
+
return
|
|
694
|
+
total = float(df["CREDITS"].sum())
|
|
695
|
+
max_c = float(df["CREDITS"].max()) if total > 0 else 1.0
|
|
696
|
+
has_requests = "REQUEST_COUNT" in df.columns
|
|
697
|
+
if has_requests:
|
|
698
|
+
table.add_columns(label.upper(), "CREDITS", "REQUESTS", "% OF SERVICE", "BAR")
|
|
699
|
+
else:
|
|
700
|
+
table.add_columns(label.upper(), "CREDITS", "% OF SERVICE", "BAR")
|
|
701
|
+
for _, row in df.iterrows():
|
|
702
|
+
credits = float(row["CREDITS"])
|
|
703
|
+
pct = (credits / total * 100) if total > 0 else 0
|
|
704
|
+
cells = [str(row["RESOURCE_NAME"]), f"{credits:,.4f}"]
|
|
705
|
+
if has_requests:
|
|
706
|
+
cells.append(str(int(row.get("REQUEST_COUNT", 0))))
|
|
707
|
+
cells += [f"{pct:.1f}%", _bar(credits / max_c)]
|
|
708
|
+
table.add_row(*cells, key=str(row["RESOURCE_NAME"]))
|
|
709
|
+
upper = service_type.upper()
|
|
710
|
+
can_drill_users = (
|
|
711
|
+
upper == _WAREHOUSE_SERVICE_TYPE
|
|
712
|
+
or any(kw in upper for kw in _AI_SERVICE_KEYWORDS)
|
|
713
|
+
)
|
|
714
|
+
user_hint = " · click to see users" if can_drill_users else ""
|
|
715
|
+
self._set_status(
|
|
716
|
+
f"{service_type} — {date} · {total:,.4f} credits{note_suffix}{user_hint} · Esc to return"
|
|
717
|
+
)
|
|
718
|
+
|
|
719
|
+
# --- Drill: user breakdown (Level 3) -----------------------------
|
|
720
|
+
|
|
721
|
+
def _fetch_day_resource_users(self, date: str, service_type: str, resource: str) -> None:
|
|
722
|
+
self._current_view = "drill_day_resource_users"
|
|
723
|
+
self._drill_day_resource = resource
|
|
724
|
+
self._set_status(f"Loading users for {resource} on {date}… Esc to return")
|
|
725
|
+
self._day_resource_users_worker(date=date, service_type=service_type, resource=resource)
|
|
726
|
+
|
|
727
|
+
@work(thread=True, exclusive=True, group="cost")
|
|
728
|
+
def _day_resource_users_worker(self, *, date: str, service_type: str, resource: str) -> None:
|
|
729
|
+
try:
|
|
730
|
+
df, note = self.app.get_cost_service().get_day_resource_users(
|
|
731
|
+
date, service_type, resource
|
|
732
|
+
)
|
|
733
|
+
except Exception as e:
|
|
734
|
+
self.app.call_from_thread(self._fetch_failed, e)
|
|
735
|
+
return
|
|
736
|
+
self.app.call_from_thread(self._render_day_resource_users, df, date, resource, note)
|
|
737
|
+
|
|
738
|
+
def _render_day_resource_users(self, df: pd.DataFrame, date: str,
|
|
739
|
+
resource: str, note: str | None) -> None:
|
|
740
|
+
self._reset_table()
|
|
741
|
+
table = self.query_one(DataTable)
|
|
742
|
+
note_suffix = f" · ⚠ {note}" if note else ""
|
|
743
|
+
if note and "No user-level" in note:
|
|
744
|
+
table.add_columns("(no user-level detail available for this resource type)")
|
|
745
|
+
self._set_status(f"{resource} — {date}{note_suffix} · Esc to return")
|
|
746
|
+
return
|
|
747
|
+
if df is None or df.empty:
|
|
748
|
+
table.add_columns("(no users found)")
|
|
749
|
+
self._set_status(f"{resource} — {date} · no users{note_suffix} · Esc to return")
|
|
750
|
+
return
|
|
751
|
+
total = float(df["CREDITS"].sum())
|
|
752
|
+
max_c = float(df["CREDITS"].max()) if total > 0 else 1.0
|
|
753
|
+
table.add_columns("USER", "CREDITS", "QUERIES", "% OF RESOURCE", "BAR")
|
|
754
|
+
for _, row in df.iterrows():
|
|
755
|
+
credits = float(row["CREDITS"])
|
|
756
|
+
pct = (credits / total * 100) if total > 0 else 0
|
|
757
|
+
table.add_row(
|
|
758
|
+
str(row["USER_NAME"]),
|
|
759
|
+
f"{credits:,.4f}",
|
|
760
|
+
str(int(row.get("REQUESTS", 0))),
|
|
761
|
+
f"{pct:.1f}%",
|
|
762
|
+
_bar(credits / max_c),
|
|
763
|
+
key=str(row["USER_NAME"]),
|
|
764
|
+
)
|
|
765
|
+
query_hint = (
|
|
766
|
+
" · click user to see top queries"
|
|
767
|
+
if (self._drill_day_service_type or "").upper() == _WAREHOUSE_SERVICE_TYPE
|
|
768
|
+
else ""
|
|
769
|
+
)
|
|
770
|
+
self._set_status(
|
|
771
|
+
f"{resource} users — {date} · {total:,.4f} credits{note_suffix}{query_hint} · Esc to return"
|
|
772
|
+
)
|
|
773
|
+
|
|
774
|
+
# --- Drill: top queries for a user (Level 4) ----------------------
|
|
775
|
+
|
|
776
|
+
def _fetch_day_user_queries(self, date: str, warehouse: str, user: str) -> None:
|
|
777
|
+
self._current_view = "drill_day_user_queries"
|
|
778
|
+
self._drill_day_user = user
|
|
779
|
+
self._set_status(
|
|
780
|
+
f"Loading top queries for {user} on {warehouse} ({date})… Esc to return"
|
|
781
|
+
)
|
|
782
|
+
self._day_user_queries_worker(date=date, warehouse=warehouse, user=user)
|
|
783
|
+
|
|
784
|
+
@work(thread=True, exclusive=True, group="cost")
|
|
785
|
+
def _day_user_queries_worker(self, *, date: str, warehouse: str, user: str) -> None:
|
|
786
|
+
try:
|
|
787
|
+
df, note = self.app.get_cost_service().get_day_user_queries(
|
|
788
|
+
date, warehouse, user, limit=3
|
|
789
|
+
)
|
|
790
|
+
except Exception as e:
|
|
791
|
+
self.app.call_from_thread(self._fetch_failed, e)
|
|
792
|
+
return
|
|
793
|
+
self.app.call_from_thread(self._render_day_user_queries, df, date, warehouse, user, note)
|
|
794
|
+
|
|
795
|
+
def _render_day_user_queries(self, df: pd.DataFrame, date: str,
|
|
796
|
+
warehouse: str, user: str, note: str | None) -> None:
|
|
797
|
+
self._reset_table()
|
|
798
|
+
table = self.query_one(DataTable)
|
|
799
|
+
note_suffix = f" · ⚠ {note}" if note else ""
|
|
800
|
+
if df is None or df.empty:
|
|
801
|
+
table.add_columns("(no queries found)")
|
|
802
|
+
self._set_status(
|
|
803
|
+
f"{user} on {warehouse} — {date} · no queries{note_suffix} · Esc to return"
|
|
804
|
+
)
|
|
805
|
+
return
|
|
806
|
+
table.add_columns("CREDITS", "TYPE", "SECONDS", "GB", "PREVIEW")
|
|
807
|
+
for _, row in df.iterrows():
|
|
808
|
+
table.add_row(
|
|
809
|
+
f"{float(row.get('CREDITS', 0)):,.4f}",
|
|
810
|
+
str(row.get("QUERY_TYPE", "")),
|
|
811
|
+
f"{float(row.get('SECONDS', 0)):.1f}",
|
|
812
|
+
f"{float(row.get('GB_SCANNED', 0)):.2f}",
|
|
813
|
+
str(row.get("QUERY_PREVIEW", ""))[:48],
|
|
814
|
+
key=str(row.get("QUERY_ID", "")),
|
|
581
815
|
)
|
|
582
816
|
self._set_status(
|
|
583
|
-
f"
|
|
817
|
+
f"Top queries — {user} on {warehouse} — {date}{note_suffix}"
|
|
818
|
+
f" · ⏎ open in Tune · Esc to return"
|
|
584
819
|
)
|
|
585
820
|
|
|
586
821
|
# --- View 6: AI services -----------------------------------------
|
|
@@ -46,7 +46,7 @@ class HomeScreen(Vertical):
|
|
|
46
46
|
with Vertical(id="home-trend", classes="panel"):
|
|
47
47
|
yield Static("30d spend trend", classes="panel-title")
|
|
48
48
|
yield DataTable(id="home-trend-table",
|
|
49
|
-
cursor_type="
|
|
49
|
+
cursor_type="row", zebra_stripes=True)
|
|
50
50
|
with Vertical(id="home-queries", classes="panel"):
|
|
51
51
|
yield Static("Recent expensive queries (7d)", classes="panel-title")
|
|
52
52
|
yield DataTable(id="home-queries-table",
|
|
@@ -193,6 +193,7 @@ class HomeScreen(Vertical):
|
|
|
193
193
|
str(row["DATE"]),
|
|
194
194
|
f"{credits:,.2f}",
|
|
195
195
|
"█" * bar_width,
|
|
196
|
+
key=str(row["DATE"]),
|
|
196
197
|
)
|
|
197
198
|
|
|
198
199
|
# --- Recent expensive queries (one Snowflake call on mount) -----
|
|
@@ -229,17 +230,27 @@ class HomeScreen(Vertical):
|
|
|
229
230
|
)
|
|
230
231
|
|
|
231
232
|
def on_data_table_row_selected(self, event: DataTable.RowSelected) -> None:
|
|
232
|
-
# Drill: open the query in the Tune screen.
|
|
233
233
|
key = event.row_key.value if event.row_key else None
|
|
234
234
|
if not key:
|
|
235
235
|
return
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
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)
|
|
@@ -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;
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|