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.
Files changed (83) hide show
  1. {snowglobe_cli-0.2.0 → snowglobe_cli-0.2.2}/PKG-INFO +1 -1
  2. {snowglobe_cli-0.2.0 → snowglobe_cli-0.2.2}/pyproject.toml +1 -1
  3. {snowglobe_cli-0.2.0 → snowglobe_cli-0.2.2}/snowglobe/core/cost_service.py +371 -7
  4. {snowglobe_cli-0.2.0 → snowglobe_cli-0.2.2}/snowglobe/tui/screens/cost.py +244 -9
  5. {snowglobe_cli-0.2.0 → snowglobe_cli-0.2.2}/snowglobe/tui/screens/home.py +23 -12
  6. {snowglobe_cli-0.2.0 → snowglobe_cli-0.2.2}/snowglobe/tui/styles.tcss +16 -4
  7. {snowglobe_cli-0.2.0 → snowglobe_cli-0.2.2}/snowglobe_cli.egg-info/PKG-INFO +1 -1
  8. {snowglobe_cli-0.2.0 → snowglobe_cli-0.2.2}/LICENSE +0 -0
  9. {snowglobe_cli-0.2.0 → snowglobe_cli-0.2.2}/README.md +0 -0
  10. {snowglobe_cli-0.2.0 → snowglobe_cli-0.2.2}/setup.cfg +0 -0
  11. {snowglobe_cli-0.2.0 → snowglobe_cli-0.2.2}/snowglobe/__init__.py +0 -0
  12. {snowglobe_cli-0.2.0 → snowglobe_cli-0.2.2}/snowglobe/__main__.py +0 -0
  13. {snowglobe_cli-0.2.0 → snowglobe_cli-0.2.2}/snowglobe/cli/__init__.py +0 -0
  14. {snowglobe_cli-0.2.0 → snowglobe_cli-0.2.2}/snowglobe/cli/access.py +0 -0
  15. {snowglobe_cli-0.2.0 → snowglobe_cli-0.2.2}/snowglobe/cli/app.py +0 -0
  16. {snowglobe_cli-0.2.0 → snowglobe_cli-0.2.2}/snowglobe/cli/context.py +0 -0
  17. {snowglobe_cli-0.2.0 → snowglobe_cli-0.2.2}/snowglobe/cli/cost.py +0 -0
  18. {snowglobe_cli-0.2.0 → snowglobe_cli-0.2.2}/snowglobe/cli/debug.py +0 -0
  19. {snowglobe_cli-0.2.0 → snowglobe_cli-0.2.2}/snowglobe/cli/diff.py +0 -0
  20. {snowglobe_cli-0.2.0 → snowglobe_cli-0.2.2}/snowglobe/cli/optimizer.py +0 -0
  21. {snowglobe_cli-0.2.0 → snowglobe_cli-0.2.2}/snowglobe/cli/prompts.py +0 -0
  22. {snowglobe_cli-0.2.0 → snowglobe_cli-0.2.2}/snowglobe/cli/report.py +0 -0
  23. {snowglobe_cli-0.2.0 → snowglobe_cli-0.2.2}/snowglobe/cli/shell.py +0 -0
  24. {snowglobe_cli-0.2.0 → snowglobe_cli-0.2.2}/snowglobe/cli/shell_completer.py +0 -0
  25. {snowglobe_cli-0.2.0 → snowglobe_cli-0.2.2}/snowglobe/collectors/access.py +0 -0
  26. {snowglobe_cli-0.2.0 → snowglobe_cli-0.2.2}/snowglobe/collectors/query_history.py +0 -0
  27. {snowglobe_cli-0.2.0 → snowglobe_cli-0.2.2}/snowglobe/collectors/query_profile.py +0 -0
  28. {snowglobe_cli-0.2.0 → snowglobe_cli-0.2.2}/snowglobe/config/loader.py +0 -0
  29. {snowglobe_cli-0.2.0 → snowglobe_cli-0.2.2}/snowglobe/core/access_service.py +0 -0
  30. {snowglobe_cli-0.2.0 → snowglobe_cli-0.2.2}/snowglobe/core/optimizer.py +0 -0
  31. {snowglobe_cli-0.2.0 → snowglobe_cli-0.2.2}/snowglobe/core/query_service.py +0 -0
  32. {snowglobe_cli-0.2.0 → snowglobe_cli-0.2.2}/snowglobe/core/report_service.py +0 -0
  33. {snowglobe_cli-0.2.0 → snowglobe_cli-0.2.2}/snowglobe/core/risk_service.py +0 -0
  34. {snowglobe_cli-0.2.0 → snowglobe_cli-0.2.2}/snowglobe/engines/access/__init__.py +0 -0
  35. {snowglobe_cli-0.2.0 → snowglobe_cli-0.2.2}/snowglobe/engines/access/explainer.py +0 -0
  36. {snowglobe_cli-0.2.0 → snowglobe_cli-0.2.2}/snowglobe/engines/access/resolver.py +0 -0
  37. {snowglobe_cli-0.2.0 → snowglobe_cli-0.2.2}/snowglobe/engines/ai/cortex_optimizer.py +0 -0
  38. {snowglobe_cli-0.2.0 → snowglobe_cli-0.2.2}/snowglobe/engines/optimizer/query_optimizer.py +0 -0
  39. {snowglobe_cli-0.2.0 → snowglobe_cli-0.2.2}/snowglobe/graphs/__init__.py +0 -0
  40. {snowglobe_cli-0.2.0 → snowglobe_cli-0.2.2}/snowglobe/graphs/role_graph.py +0 -0
  41. {snowglobe_cli-0.2.0 → snowglobe_cli-0.2.2}/snowglobe/graphs/user_graph.py +0 -0
  42. {snowglobe_cli-0.2.0 → snowglobe_cli-0.2.2}/snowglobe/models/__init__.py +0 -0
  43. {snowglobe_cli-0.2.0 → snowglobe_cli-0.2.2}/snowglobe/models/access.py +0 -0
  44. {snowglobe_cli-0.2.0 → snowglobe_cli-0.2.2}/snowglobe/models/access_path.py +0 -0
  45. {snowglobe_cli-0.2.0 → snowglobe_cli-0.2.2}/snowglobe/models/object_ref.py +0 -0
  46. {snowglobe_cli-0.2.0 → snowglobe_cli-0.2.2}/snowglobe/models/object_type.py +0 -0
  47. {snowglobe_cli-0.2.0 → snowglobe_cli-0.2.2}/snowglobe/models/optimizer.py +0 -0
  48. {snowglobe_cli-0.2.0 → snowglobe_cli-0.2.2}/snowglobe/models/privilege.py +0 -0
  49. {snowglobe_cli-0.2.0 → snowglobe_cli-0.2.2}/snowglobe/models/query.py +0 -0
  50. {snowglobe_cli-0.2.0 → snowglobe_cli-0.2.2}/snowglobe/output/__init__.py +0 -0
  51. {snowglobe_cli-0.2.0 → snowglobe_cli-0.2.2}/snowglobe/output/cli.py +0 -0
  52. {snowglobe_cli-0.2.0 → snowglobe_cli-0.2.2}/snowglobe/queries/__init__.py +0 -0
  53. {snowglobe_cli-0.2.0 → snowglobe_cli-0.2.2}/snowglobe/queries/query_history.py +0 -0
  54. {snowglobe_cli-0.2.0 → snowglobe_cli-0.2.2}/snowglobe/snowflake/connection.py +0 -0
  55. {snowglobe_cli-0.2.0 → snowglobe_cli-0.2.2}/snowglobe/state/db.py +0 -0
  56. {snowglobe_cli-0.2.0 → snowglobe_cli-0.2.2}/snowglobe/state/state.py +0 -0
  57. {snowglobe_cli-0.2.0 → snowglobe_cli-0.2.2}/snowglobe/templates/report.md.j2 +0 -0
  58. {snowglobe_cli-0.2.0 → snowglobe_cli-0.2.2}/snowglobe/tests/access_tests.py +0 -0
  59. {snowglobe_cli-0.2.0 → snowglobe_cli-0.2.2}/snowglobe/tui/__init__.py +0 -0
  60. {snowglobe_cli-0.2.0 → snowglobe_cli-0.2.2}/snowglobe/tui/__main__.py +0 -0
  61. {snowglobe_cli-0.2.0 → snowglobe_cli-0.2.2}/snowglobe/tui/app.py +0 -0
  62. {snowglobe_cli-0.2.0 → snowglobe_cli-0.2.2}/snowglobe/tui/screens/__init__.py +0 -0
  63. {snowglobe_cli-0.2.0 → snowglobe_cli-0.2.2}/snowglobe/tui/screens/access.py +0 -0
  64. {snowglobe_cli-0.2.0 → snowglobe_cli-0.2.2}/snowglobe/tui/screens/refresh.py +0 -0
  65. {snowglobe_cli-0.2.0 → snowglobe_cli-0.2.2}/snowglobe/tui/screens/reports.py +0 -0
  66. {snowglobe_cli-0.2.0 → snowglobe_cli-0.2.2}/snowglobe/tui/screens/risk.py +0 -0
  67. {snowglobe_cli-0.2.0 → snowglobe_cli-0.2.2}/snowglobe/tui/screens/tune.py +0 -0
  68. {snowglobe_cli-0.2.0 → snowglobe_cli-0.2.2}/snowglobe/tui/widgets/__init__.py +0 -0
  69. {snowglobe_cli-0.2.0 → snowglobe_cli-0.2.2}/snowglobe/tui/widgets/access_paths.py +0 -0
  70. {snowglobe_cli-0.2.0 → snowglobe_cli-0.2.2}/snowglobe/tui/widgets/cache_badge.py +0 -0
  71. {snowglobe_cli-0.2.0 → snowglobe_cli-0.2.2}/snowglobe/tui/widgets/header.py +0 -0
  72. {snowglobe_cli-0.2.0 → snowglobe_cli-0.2.2}/snowglobe/tui/widgets/nav.py +0 -0
  73. {snowglobe_cli-0.2.0 → snowglobe_cli-0.2.2}/snowglobe_cli.egg-info/SOURCES.txt +0 -0
  74. {snowglobe_cli-0.2.0 → snowglobe_cli-0.2.2}/snowglobe_cli.egg-info/dependency_links.txt +0 -0
  75. {snowglobe_cli-0.2.0 → snowglobe_cli-0.2.2}/snowglobe_cli.egg-info/entry_points.txt +0 -0
  76. {snowglobe_cli-0.2.0 → snowglobe_cli-0.2.2}/snowglobe_cli.egg-info/requires.txt +0 -0
  77. {snowglobe_cli-0.2.0 → snowglobe_cli-0.2.2}/snowglobe_cli.egg-info/top_level.txt +0 -0
  78. {snowglobe_cli-0.2.0 → snowglobe_cli-0.2.2}/tests/test_access.py +0 -0
  79. {snowglobe_cli-0.2.0 → snowglobe_cli-0.2.2}/tests/test_optimizer_engine.py +0 -0
  80. {snowglobe_cli-0.2.0 → snowglobe_cli-0.2.2}/tests/test_output.py +0 -0
  81. {snowglobe_cli-0.2.0 → snowglobe_cli-0.2.2}/tests/test_query_validation.py +0 -0
  82. {snowglobe_cli-0.2.0 → snowglobe_cli-0.2.2}/tests/test_state_db.py +0 -0
  83. {snowglobe_cli-0.2.0 → snowglobe_cli-0.2.2}/tests/test_tui_tune.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: snowglobe-cli
3
- Version: 0.2.0
3
+ Version: 0.2.2
4
4
  Summary: Explainable cost and access visibility for Snowflake
5
5
  Author-email: Jaryd Thornton <jaryd90@gmail.com>
6
6
  License-Expression: Apache-2.0
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "snowglobe-cli"
3
- version = "0.2.0"
3
+ version = "0.2.2"
4
4
  description = "Explainable cost and access visibility for Snowflake"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.12.3"
@@ -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 get_day_detail(self, date: str) -> pd.DataFrame:
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=["WAREHOUSE_NAME", "CREDITS"])
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" | "drill_day"
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 detail -------------------------------------------
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().get_day_detail(date)
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
- table = self._reset_table()
627
+ self._reset_table()
628
+ table = self.query_one(DataTable)
566
629
  if df is None or df.empty:
567
- table.add_columns("(no warehouse activity on this date)")
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("WAREHOUSE", "CREDITS", "% OF DAY", "BAR")
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["WAREHOUSE_NAME"]),
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"Day detail — {date} · {total:,.4f} credits · Esc to return"
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="none", zebra_stripes=True)
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
- try:
237
- from textual.widgets import ContentSwitcher, Input
238
- from snowglobe.tui.screens.tune import TuneScreen
239
- switcher = self.app.query_one(ContentSwitcher)
240
- switcher.current = "tune"
241
- tune = self.app.query_one(TuneScreen)
242
- tune.query_one("#tu-query-id", Input).value = key
243
- self.app.notify(f"Loaded {key[:24]}… into Tune. Press Analyse.", timeout=4)
244
- except Exception:
245
- self.app.notify(f"Query: {key[:24]}", timeout=4)
236
+
237
+ if event.data_table.id == "home-trend-table":
238
+ # Drill into that day on the Cost screen.
239
+ try:
240
+ from textual.widgets import ContentSwitcher
241
+ from snowglobe.tui.screens.cost import CostScreen
242
+ self.app.query_one(ContentSwitcher).current = "cost"
243
+ self.app.query_one(CostScreen)._fetch_day_drill(key)
244
+ except Exception as e:
245
+ self.app.notify(f"Could not open day detail: {e}", severity="warning", timeout=4)
246
+ else:
247
+ # Queries table — open in Tune.
248
+ try:
249
+ from textual.widgets import ContentSwitcher, Input
250
+ from snowglobe.tui.screens.tune import TuneScreen
251
+ self.app.query_one(ContentSwitcher).current = "tune"
252
+ tune = self.app.query_one(TuneScreen)
253
+ tune.query_one("#tu-query-id", Input).value = key
254
+ self.app.notify(f"Loaded {key[:24]}… into Tune. Press Analyse.", timeout=4)
255
+ except Exception:
256
+ self.app.notify(f"Query: {key[:24]}…", timeout=4)
@@ -365,16 +365,22 @@ Tree > .tree--label-highlighted {
365
365
  margin-right: 1;
366
366
  }
367
367
 
368
- #tu-sql {
368
+ #tu-sql-scroll {
369
369
  height: 1fr;
370
+ }
371
+
372
+ #tu-sql {
370
373
  background: $background;
371
374
  padding: 1;
372
375
  }
373
376
 
377
+ #tu-heuristics-scroll {
378
+ height: 1fr;
379
+ }
380
+
374
381
  #tu-heuristics {
375
382
  padding: 1;
376
383
  color: $foreground;
377
- height: 1fr;
378
384
  }
379
385
 
380
386
  #tu-insights-table,
@@ -388,11 +394,14 @@ Tree > .tree--label-highlighted {
388
394
  height: 1fr;
389
395
  }
390
396
 
397
+ #tu-ai-scroll {
398
+ height: 1fr;
399
+ }
400
+
391
401
  #tu-ai {
392
402
  background: $background;
393
403
  color: $foreground;
394
404
  padding: 1;
395
- height: 1fr;
396
405
  }
397
406
 
398
407
  /* --- Reports screen --- */
@@ -402,8 +411,11 @@ Tree > .tree--label-highlighted {
402
411
  margin-bottom: 1;
403
412
  }
404
413
 
405
- #rp-preview {
414
+ #rp-preview-scroll {
406
415
  height: 1fr;
416
+ }
417
+
418
+ #rp-preview {
407
419
  background: $background;
408
420
  border: round $primary;
409
421
  padding: 1;
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: snowglobe-cli
3
- Version: 0.2.0
3
+ Version: 0.2.2
4
4
  Summary: Explainable cost and access visibility for Snowflake
5
5
  Author-email: Jaryd Thornton <jaryd90@gmail.com>
6
6
  License-Expression: Apache-2.0
File without changes
File without changes
File without changes