snowglobe-cli 0.2.1__tar.gz → 0.2.3__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.1 → snowglobe_cli-0.2.3}/PKG-INFO +1 -1
  2. {snowglobe_cli-0.2.1 → snowglobe_cli-0.2.3}/pyproject.toml +1 -1
  3. {snowglobe_cli-0.2.1 → snowglobe_cli-0.2.3}/snowglobe/core/cost_service.py +371 -7
  4. {snowglobe_cli-0.2.1 → snowglobe_cli-0.2.3}/snowglobe/tui/screens/cost.py +246 -8
  5. {snowglobe_cli-0.2.1 → snowglobe_cli-0.2.3}/snowglobe_cli.egg-info/PKG-INFO +1 -1
  6. {snowglobe_cli-0.2.1 → snowglobe_cli-0.2.3}/LICENSE +0 -0
  7. {snowglobe_cli-0.2.1 → snowglobe_cli-0.2.3}/README.md +0 -0
  8. {snowglobe_cli-0.2.1 → snowglobe_cli-0.2.3}/setup.cfg +0 -0
  9. {snowglobe_cli-0.2.1 → snowglobe_cli-0.2.3}/snowglobe/__init__.py +0 -0
  10. {snowglobe_cli-0.2.1 → snowglobe_cli-0.2.3}/snowglobe/__main__.py +0 -0
  11. {snowglobe_cli-0.2.1 → snowglobe_cli-0.2.3}/snowglobe/cli/__init__.py +0 -0
  12. {snowglobe_cli-0.2.1 → snowglobe_cli-0.2.3}/snowglobe/cli/access.py +0 -0
  13. {snowglobe_cli-0.2.1 → snowglobe_cli-0.2.3}/snowglobe/cli/app.py +0 -0
  14. {snowglobe_cli-0.2.1 → snowglobe_cli-0.2.3}/snowglobe/cli/context.py +0 -0
  15. {snowglobe_cli-0.2.1 → snowglobe_cli-0.2.3}/snowglobe/cli/cost.py +0 -0
  16. {snowglobe_cli-0.2.1 → snowglobe_cli-0.2.3}/snowglobe/cli/debug.py +0 -0
  17. {snowglobe_cli-0.2.1 → snowglobe_cli-0.2.3}/snowglobe/cli/diff.py +0 -0
  18. {snowglobe_cli-0.2.1 → snowglobe_cli-0.2.3}/snowglobe/cli/optimizer.py +0 -0
  19. {snowglobe_cli-0.2.1 → snowglobe_cli-0.2.3}/snowglobe/cli/prompts.py +0 -0
  20. {snowglobe_cli-0.2.1 → snowglobe_cli-0.2.3}/snowglobe/cli/report.py +0 -0
  21. {snowglobe_cli-0.2.1 → snowglobe_cli-0.2.3}/snowglobe/cli/shell.py +0 -0
  22. {snowglobe_cli-0.2.1 → snowglobe_cli-0.2.3}/snowglobe/cli/shell_completer.py +0 -0
  23. {snowglobe_cli-0.2.1 → snowglobe_cli-0.2.3}/snowglobe/collectors/access.py +0 -0
  24. {snowglobe_cli-0.2.1 → snowglobe_cli-0.2.3}/snowglobe/collectors/query_history.py +0 -0
  25. {snowglobe_cli-0.2.1 → snowglobe_cli-0.2.3}/snowglobe/collectors/query_profile.py +0 -0
  26. {snowglobe_cli-0.2.1 → snowglobe_cli-0.2.3}/snowglobe/config/loader.py +0 -0
  27. {snowglobe_cli-0.2.1 → snowglobe_cli-0.2.3}/snowglobe/core/access_service.py +0 -0
  28. {snowglobe_cli-0.2.1 → snowglobe_cli-0.2.3}/snowglobe/core/optimizer.py +0 -0
  29. {snowglobe_cli-0.2.1 → snowglobe_cli-0.2.3}/snowglobe/core/query_service.py +0 -0
  30. {snowglobe_cli-0.2.1 → snowglobe_cli-0.2.3}/snowglobe/core/report_service.py +0 -0
  31. {snowglobe_cli-0.2.1 → snowglobe_cli-0.2.3}/snowglobe/core/risk_service.py +0 -0
  32. {snowglobe_cli-0.2.1 → snowglobe_cli-0.2.3}/snowglobe/engines/access/__init__.py +0 -0
  33. {snowglobe_cli-0.2.1 → snowglobe_cli-0.2.3}/snowglobe/engines/access/explainer.py +0 -0
  34. {snowglobe_cli-0.2.1 → snowglobe_cli-0.2.3}/snowglobe/engines/access/resolver.py +0 -0
  35. {snowglobe_cli-0.2.1 → snowglobe_cli-0.2.3}/snowglobe/engines/ai/cortex_optimizer.py +0 -0
  36. {snowglobe_cli-0.2.1 → snowglobe_cli-0.2.3}/snowglobe/engines/optimizer/query_optimizer.py +0 -0
  37. {snowglobe_cli-0.2.1 → snowglobe_cli-0.2.3}/snowglobe/graphs/__init__.py +0 -0
  38. {snowglobe_cli-0.2.1 → snowglobe_cli-0.2.3}/snowglobe/graphs/role_graph.py +0 -0
  39. {snowglobe_cli-0.2.1 → snowglobe_cli-0.2.3}/snowglobe/graphs/user_graph.py +0 -0
  40. {snowglobe_cli-0.2.1 → snowglobe_cli-0.2.3}/snowglobe/models/__init__.py +0 -0
  41. {snowglobe_cli-0.2.1 → snowglobe_cli-0.2.3}/snowglobe/models/access.py +0 -0
  42. {snowglobe_cli-0.2.1 → snowglobe_cli-0.2.3}/snowglobe/models/access_path.py +0 -0
  43. {snowglobe_cli-0.2.1 → snowglobe_cli-0.2.3}/snowglobe/models/object_ref.py +0 -0
  44. {snowglobe_cli-0.2.1 → snowglobe_cli-0.2.3}/snowglobe/models/object_type.py +0 -0
  45. {snowglobe_cli-0.2.1 → snowglobe_cli-0.2.3}/snowglobe/models/optimizer.py +0 -0
  46. {snowglobe_cli-0.2.1 → snowglobe_cli-0.2.3}/snowglobe/models/privilege.py +0 -0
  47. {snowglobe_cli-0.2.1 → snowglobe_cli-0.2.3}/snowglobe/models/query.py +0 -0
  48. {snowglobe_cli-0.2.1 → snowglobe_cli-0.2.3}/snowglobe/output/__init__.py +0 -0
  49. {snowglobe_cli-0.2.1 → snowglobe_cli-0.2.3}/snowglobe/output/cli.py +0 -0
  50. {snowglobe_cli-0.2.1 → snowglobe_cli-0.2.3}/snowglobe/queries/__init__.py +0 -0
  51. {snowglobe_cli-0.2.1 → snowglobe_cli-0.2.3}/snowglobe/queries/query_history.py +0 -0
  52. {snowglobe_cli-0.2.1 → snowglobe_cli-0.2.3}/snowglobe/snowflake/connection.py +0 -0
  53. {snowglobe_cli-0.2.1 → snowglobe_cli-0.2.3}/snowglobe/state/db.py +0 -0
  54. {snowglobe_cli-0.2.1 → snowglobe_cli-0.2.3}/snowglobe/state/state.py +0 -0
  55. {snowglobe_cli-0.2.1 → snowglobe_cli-0.2.3}/snowglobe/templates/report.md.j2 +0 -0
  56. {snowglobe_cli-0.2.1 → snowglobe_cli-0.2.3}/snowglobe/tests/access_tests.py +0 -0
  57. {snowglobe_cli-0.2.1 → snowglobe_cli-0.2.3}/snowglobe/tui/__init__.py +0 -0
  58. {snowglobe_cli-0.2.1 → snowglobe_cli-0.2.3}/snowglobe/tui/__main__.py +0 -0
  59. {snowglobe_cli-0.2.1 → snowglobe_cli-0.2.3}/snowglobe/tui/app.py +0 -0
  60. {snowglobe_cli-0.2.1 → snowglobe_cli-0.2.3}/snowglobe/tui/screens/__init__.py +0 -0
  61. {snowglobe_cli-0.2.1 → snowglobe_cli-0.2.3}/snowglobe/tui/screens/access.py +0 -0
  62. {snowglobe_cli-0.2.1 → snowglobe_cli-0.2.3}/snowglobe/tui/screens/home.py +0 -0
  63. {snowglobe_cli-0.2.1 → snowglobe_cli-0.2.3}/snowglobe/tui/screens/refresh.py +0 -0
  64. {snowglobe_cli-0.2.1 → snowglobe_cli-0.2.3}/snowglobe/tui/screens/reports.py +0 -0
  65. {snowglobe_cli-0.2.1 → snowglobe_cli-0.2.3}/snowglobe/tui/screens/risk.py +0 -0
  66. {snowglobe_cli-0.2.1 → snowglobe_cli-0.2.3}/snowglobe/tui/screens/tune.py +0 -0
  67. {snowglobe_cli-0.2.1 → snowglobe_cli-0.2.3}/snowglobe/tui/styles.tcss +0 -0
  68. {snowglobe_cli-0.2.1 → snowglobe_cli-0.2.3}/snowglobe/tui/widgets/__init__.py +0 -0
  69. {snowglobe_cli-0.2.1 → snowglobe_cli-0.2.3}/snowglobe/tui/widgets/access_paths.py +0 -0
  70. {snowglobe_cli-0.2.1 → snowglobe_cli-0.2.3}/snowglobe/tui/widgets/cache_badge.py +0 -0
  71. {snowglobe_cli-0.2.1 → snowglobe_cli-0.2.3}/snowglobe/tui/widgets/header.py +0 -0
  72. {snowglobe_cli-0.2.1 → snowglobe_cli-0.2.3}/snowglobe/tui/widgets/nav.py +0 -0
  73. {snowglobe_cli-0.2.1 → snowglobe_cli-0.2.3}/snowglobe_cli.egg-info/SOURCES.txt +0 -0
  74. {snowglobe_cli-0.2.1 → snowglobe_cli-0.2.3}/snowglobe_cli.egg-info/dependency_links.txt +0 -0
  75. {snowglobe_cli-0.2.1 → snowglobe_cli-0.2.3}/snowglobe_cli.egg-info/entry_points.txt +0 -0
  76. {snowglobe_cli-0.2.1 → snowglobe_cli-0.2.3}/snowglobe_cli.egg-info/requires.txt +0 -0
  77. {snowglobe_cli-0.2.1 → snowglobe_cli-0.2.3}/snowglobe_cli.egg-info/top_level.txt +0 -0
  78. {snowglobe_cli-0.2.1 → snowglobe_cli-0.2.3}/tests/test_access.py +0 -0
  79. {snowglobe_cli-0.2.1 → snowglobe_cli-0.2.3}/tests/test_optimizer_engine.py +0 -0
  80. {snowglobe_cli-0.2.1 → snowglobe_cli-0.2.3}/tests/test_output.py +0 -0
  81. {snowglobe_cli-0.2.1 → snowglobe_cli-0.2.3}/tests/test_query_validation.py +0 -0
  82. {snowglobe_cli-0.2.1 → snowglobe_cli-0.2.3}/tests/test_state_db.py +0 -0
  83. {snowglobe_cli-0.2.1 → snowglobe_cli-0.2.3}/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.1
3
+ Version: 0.2.3
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.1"
3
+ version = "0.2.3"
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,17 @@ 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
53
+ _drill_day_resource_kind: str = "other" # "warehouse" | "ai" | "other"
47
54
  # Becomes True after the user explicitly picks a view, so Select.Changed
48
55
  # events fired during the initial mount don't auto-trigger a Snowflake fetch.
49
56
  _user_initiated: bool = False
@@ -140,6 +147,33 @@ class CostScreen(Vertical):
140
147
  elif self._current_view == "trend":
141
148
  self._drill_day = key
142
149
  self._fetch_day_drill(key)
150
+ elif self._current_view == "drill_day":
151
+ self._drill_day_service_type = key
152
+ self._fetch_day_resource_drill(self._drill_day, key)
153
+ elif self._current_view == "drill_day_resource":
154
+ if self._drill_day_resource_kind in ("warehouse", "ai"):
155
+ self._fetch_day_resource_users(
156
+ self._drill_day, self._drill_day_service_type, key
157
+ )
158
+ else:
159
+ self.app.notify(
160
+ "No user-level detail available for this resource type.", timeout=3
161
+ )
162
+ elif self._current_view == "drill_day_resource_users":
163
+ if self._drill_day_resource_kind == "warehouse":
164
+ self._fetch_day_user_queries(self._drill_day, self._drill_day_resource, key)
165
+ else:
166
+ self.app.notify("No query detail available for AI services.", timeout=3)
167
+ elif self._current_view == "drill_day_user_queries":
168
+ try:
169
+ from textual.widgets import ContentSwitcher, Input
170
+ from snowglobe.tui.screens.tune import TuneScreen
171
+ self.app.query_one(ContentSwitcher).current = "tune"
172
+ tune = self.app.query_one(TuneScreen)
173
+ tune.query_one("#tu-query-id", Input).value = key
174
+ self.app.notify(f"Loaded {key[:24]}… into Tune. Press Analyse.", timeout=4)
175
+ except Exception:
176
+ self.app.notify(f"Query: {key[:24]}…", timeout=4)
143
177
  elif self._current_view == "top_queries":
144
178
  # Hand off to Tune via the App's content switcher.
145
179
  try:
@@ -185,6 +219,18 @@ class CostScreen(Vertical):
185
219
  self._fetch_user_drill(self._drill_user)
186
220
  elif self._current_view == "drill_day" and self._drill_day:
187
221
  self._fetch_day_drill(self._drill_day)
222
+ elif self._current_view == "drill_day_resource" and self._drill_day and self._drill_day_service_type:
223
+ self._fetch_day_resource_drill(self._drill_day, self._drill_day_service_type)
224
+ elif (self._current_view == "drill_day_resource_users"
225
+ and self._drill_day and self._drill_day_service_type and self._drill_day_resource):
226
+ self._fetch_day_resource_users(
227
+ self._drill_day, self._drill_day_service_type, self._drill_day_resource
228
+ )
229
+ elif (self._current_view == "drill_day_user_queries"
230
+ and self._drill_day and self._drill_day_resource and self._drill_day_user):
231
+ self._fetch_day_user_queries(
232
+ self._drill_day, self._drill_day_resource, self._drill_day_user
233
+ )
188
234
 
189
235
  def action_back_from_drill(self) -> None:
190
236
  if self._current_view == "drill_service":
@@ -198,7 +244,19 @@ class CostScreen(Vertical):
198
244
  self._fetch_users()
199
245
  elif self._current_view == "drill_day":
200
246
  self._drill_day = None
247
+ self._drill_day_service_type = None
201
248
  self._fetch_trend()
249
+ elif self._current_view == "drill_day_resource":
250
+ self._drill_day_service_type = None
251
+ self._fetch_day_drill(self._drill_day)
252
+ elif self._current_view == "drill_day_resource_users":
253
+ self._drill_day_resource = None
254
+ self._fetch_day_resource_drill(self._drill_day, self._drill_day_service_type)
255
+ elif self._current_view == "drill_day_user_queries":
256
+ self._drill_day_user = None
257
+ self._fetch_day_resource_users(
258
+ self._drill_day, self._drill_day_service_type, self._drill_day_resource
259
+ )
202
260
 
203
261
  # --- Days helper --------------------------------------------------
204
262
 
@@ -545,17 +603,17 @@ class CostScreen(Vertical):
545
603
  avg_str,
546
604
  )
547
605
 
548
- # --- Drill: day detail -------------------------------------------
606
+ # --- Drill: day breakdown (Level 1 — service types) --------------
549
607
 
550
608
  def _fetch_day_drill(self, date: str) -> None:
551
609
  self._current_view = "drill_day"
552
- self._set_status(f"Loading breakdown for {date}… Esc to return")
610
+ self._set_status(f"Loading service breakdown for {date}… Esc to return")
553
611
  self._day_drill_worker(date=date)
554
612
 
555
613
  @work(thread=True, exclusive=True, group="cost")
556
614
  def _day_drill_worker(self, *, date: str) -> None:
557
615
  try:
558
- df = self.app.get_cost_service().get_day_detail(date)
616
+ df = self.app.get_cost_service().get_day_service_breakdown(date)
559
617
  except Exception as e:
560
618
  self.app.call_from_thread(self._fetch_failed, e)
561
619
  return
@@ -565,23 +623,203 @@ class CostScreen(Vertical):
565
623
  self._reset_table()
566
624
  table = self.query_one(DataTable)
567
625
  if df is None or df.empty:
568
- table.add_columns("(no warehouse activity on this date)")
626
+ table.add_columns("(no activity on this date)")
569
627
  self._set_status(f"{date} · no data · Esc to return")
570
628
  return
571
629
  total = float(df["CREDITS"].sum())
572
630
  max_c = float(df["CREDITS"].max()) if total > 0 else 1.0
573
- table.add_columns("WAREHOUSE", "CREDITS", "% OF DAY", "BAR")
631
+ table.add_columns("SERVICE TYPE", "CREDITS", "% OF DAY", "BAR")
574
632
  for _, row in df.iterrows():
575
633
  credits = float(row["CREDITS"])
576
634
  pct = (credits / total * 100) if total > 0 else 0
577
635
  table.add_row(
578
- str(row["WAREHOUSE_NAME"]),
636
+ str(row["SERVICE_TYPE"]),
579
637
  f"{credits:,.4f}",
580
638
  f"{pct:.1f}%",
581
639
  _bar(credits / max_c),
640
+ key=str(row["SERVICE_TYPE"]),
641
+ )
642
+ self._set_status(
643
+ f"Day breakdown — {date} · {total:,.4f} credits · click a service to drill · Esc to return"
644
+ )
645
+
646
+ # --- Drill: resource breakdown (Level 2 — all service types) -----
647
+
648
+ def _fetch_day_resource_drill(self, date: str, service_type: str) -> None:
649
+ self._current_view = "drill_day_resource"
650
+ self._drill_day_service_type = service_type
651
+ self._set_status(f"Loading {service_type} breakdown for {date}… Esc to return")
652
+ upper = service_type.upper()
653
+ is_ai = any(kw in upper for kw in _AI_SERVICE_KEYWORDS)
654
+ self._day_resource_drill_worker(date=date, service_type=service_type, is_ai=is_ai)
655
+
656
+ @work(thread=True, exclusive=True, group="cost")
657
+ def _day_resource_drill_worker(self, *, date: str, service_type: str, is_ai: bool) -> None:
658
+ try:
659
+ svc = self.app.get_cost_service()
660
+ if is_ai:
661
+ df, any_missing = svc.get_day_ai_breakdown(date)
662
+ label = "AI Service"
663
+ found = True
664
+ note = "some Cortex views unavailable" if any_missing else None
665
+ else:
666
+ df, label, found = svc.get_day_resource_breakdown(date, service_type)
667
+ note = None
668
+ except Exception as e:
669
+ self.app.call_from_thread(self._fetch_failed, e)
670
+ return
671
+ self.app.call_from_thread(
672
+ self._render_day_resource_drill, df, date, service_type, label, found, note, is_ai
673
+ )
674
+
675
+ def _render_day_resource_drill(self, df: pd.DataFrame, date: str, service_type: str,
676
+ label: str, found: bool, note: str | None,
677
+ is_ai: bool = False) -> None:
678
+ # Store kind so navigation decisions don't depend on the SERVICE_TYPE string.
679
+ if is_ai:
680
+ self._drill_day_resource_kind = "ai"
681
+ elif label == "Warehouse":
682
+ self._drill_day_resource_kind = "warehouse"
683
+ else:
684
+ self._drill_day_resource_kind = "other"
685
+
686
+ self._reset_table()
687
+ table = self.query_one(DataTable)
688
+ note_suffix = f" · ⚠ {note}" if note else ""
689
+ if not found:
690
+ table.add_columns("(no detail available for this service type)")
691
+ self._set_status(
692
+ f"{service_type} — {date} · no resource detail available · Esc to return"
693
+ )
694
+ return
695
+ if df is None or df.empty:
696
+ table.add_columns(f"(no {label.lower()} activity on this date)")
697
+ self._set_status(f"{service_type} — {date} · no data{note_suffix} · Esc to return")
698
+ return
699
+ total = float(df["CREDITS"].sum())
700
+ max_c = float(df["CREDITS"].max()) if total > 0 else 1.0
701
+ has_requests = "REQUEST_COUNT" in df.columns
702
+ if has_requests:
703
+ table.add_columns(label.upper(), "CREDITS", "REQUESTS", "% OF SERVICE", "BAR")
704
+ else:
705
+ table.add_columns(label.upper(), "CREDITS", "% OF SERVICE", "BAR")
706
+ for _, row in df.iterrows():
707
+ credits = float(row["CREDITS"])
708
+ pct = (credits / total * 100) if total > 0 else 0
709
+ cells = [str(row["RESOURCE_NAME"]), f"{credits:,.4f}"]
710
+ if has_requests:
711
+ cells.append(str(int(row.get("REQUEST_COUNT", 0))))
712
+ cells += [f"{pct:.1f}%", _bar(credits / max_c)]
713
+ table.add_row(*cells, key=str(row["RESOURCE_NAME"]))
714
+ user_hint = (
715
+ " · click to see users"
716
+ if self._drill_day_resource_kind in ("warehouse", "ai")
717
+ else ""
718
+ )
719
+ self._set_status(
720
+ f"{service_type} — {date} · {total:,.4f} credits{note_suffix}{user_hint} · Esc to return"
721
+ )
722
+
723
+ # --- Drill: user breakdown (Level 3) -----------------------------
724
+
725
+ def _fetch_day_resource_users(self, date: str, service_type: str, resource: str) -> None:
726
+ self._current_view = "drill_day_resource_users"
727
+ self._drill_day_resource = resource
728
+ self._set_status(f"Loading users for {resource} on {date}… Esc to return")
729
+ self._day_resource_users_worker(date=date, service_type=service_type, resource=resource)
730
+
731
+ @work(thread=True, exclusive=True, group="cost")
732
+ def _day_resource_users_worker(self, *, date: str, service_type: str, resource: str) -> None:
733
+ try:
734
+ df, note = self.app.get_cost_service().get_day_resource_users(
735
+ date, service_type, resource
736
+ )
737
+ except Exception as e:
738
+ self.app.call_from_thread(self._fetch_failed, e)
739
+ return
740
+ self.app.call_from_thread(self._render_day_resource_users, df, date, resource, note)
741
+
742
+ def _render_day_resource_users(self, df: pd.DataFrame, date: str,
743
+ resource: str, note: str | None) -> None:
744
+ self._reset_table()
745
+ table = self.query_one(DataTable)
746
+ note_suffix = f" · ⚠ {note}" if note else ""
747
+ if note and "No user-level" in note:
748
+ table.add_columns("(no user-level detail available for this resource type)")
749
+ self._set_status(f"{resource} — {date}{note_suffix} · Esc to return")
750
+ return
751
+ if df is None or df.empty:
752
+ table.add_columns("(no users found)")
753
+ self._set_status(f"{resource} — {date} · no users{note_suffix} · Esc to return")
754
+ return
755
+ total = float(df["CREDITS"].sum())
756
+ max_c = float(df["CREDITS"].max()) if total > 0 else 1.0
757
+ table.add_columns("USER", "CREDITS", "QUERIES", "% OF RESOURCE", "BAR")
758
+ for _, row in df.iterrows():
759
+ credits = float(row["CREDITS"])
760
+ pct = (credits / total * 100) if total > 0 else 0
761
+ table.add_row(
762
+ str(row["USER_NAME"]),
763
+ f"{credits:,.4f}",
764
+ str(int(row.get("REQUESTS", 0))),
765
+ f"{pct:.1f}%",
766
+ _bar(credits / max_c),
767
+ key=str(row["USER_NAME"]),
768
+ )
769
+ query_hint = (
770
+ " · click user to see top queries"
771
+ if self._drill_day_resource_kind == "warehouse"
772
+ else ""
773
+ )
774
+ self._set_status(
775
+ f"{resource} users — {date} · {total:,.4f} credits{note_suffix}{query_hint} · Esc to return"
776
+ )
777
+
778
+ # --- Drill: top queries for a user (Level 4) ----------------------
779
+
780
+ def _fetch_day_user_queries(self, date: str, warehouse: str, user: str) -> None:
781
+ self._current_view = "drill_day_user_queries"
782
+ self._drill_day_user = user
783
+ self._set_status(
784
+ f"Loading top queries for {user} on {warehouse} ({date})… Esc to return"
785
+ )
786
+ self._day_user_queries_worker(date=date, warehouse=warehouse, user=user)
787
+
788
+ @work(thread=True, exclusive=True, group="cost")
789
+ def _day_user_queries_worker(self, *, date: str, warehouse: str, user: str) -> None:
790
+ try:
791
+ df, note = self.app.get_cost_service().get_day_user_queries(
792
+ date, warehouse, user, limit=3
793
+ )
794
+ except Exception as e:
795
+ self.app.call_from_thread(self._fetch_failed, e)
796
+ return
797
+ self.app.call_from_thread(self._render_day_user_queries, df, date, warehouse, user, note)
798
+
799
+ def _render_day_user_queries(self, df: pd.DataFrame, date: str,
800
+ warehouse: str, user: str, note: str | None) -> None:
801
+ self._reset_table()
802
+ table = self.query_one(DataTable)
803
+ note_suffix = f" · ⚠ {note}" if note else ""
804
+ if df is None or df.empty:
805
+ table.add_columns("(no queries found)")
806
+ self._set_status(
807
+ f"{user} on {warehouse} — {date} · no queries{note_suffix} · Esc to return"
808
+ )
809
+ return
810
+ table.add_columns("CREDITS", "TYPE", "SECONDS", "GB", "PREVIEW")
811
+ for _, row in df.iterrows():
812
+ table.add_row(
813
+ f"{float(row.get('CREDITS', 0)):,.4f}",
814
+ str(row.get("QUERY_TYPE", "")),
815
+ f"{float(row.get('SECONDS', 0)):.1f}",
816
+ f"{float(row.get('GB_SCANNED', 0)):.2f}",
817
+ str(row.get("QUERY_PREVIEW", ""))[:48],
818
+ key=str(row.get("QUERY_ID", "")),
582
819
  )
583
820
  self._set_status(
584
- f"Day detail — {date} · {total:,.4f} credits · Esc to return"
821
+ f"Top queries — {user} on {warehouse} {date}{note_suffix}"
822
+ f" · ⏎ open in Tune · Esc to return"
585
823
  )
586
824
 
587
825
  # --- View 6: AI services -----------------------------------------
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: snowglobe-cli
3
- Version: 0.2.1
3
+ Version: 0.2.3
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