brakit 0.9.2 → 0.10.0

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.
@@ -15,7 +15,7 @@ var __export = (target, all) => {
15
15
  };
16
16
 
17
17
  // src/constants/config.ts
18
- var MAX_REQUEST_ENTRIES, DEFAULT_MAX_BODY_CAPTURE, DEFAULT_API_LIMIT, MAX_TELEMETRY_ENTRIES, MAX_TAB_NAME_LENGTH, MAX_INGEST_BYTES, TERMINAL_TRUNCATE_LENGTH, SENSITIVE_MASK_MIN_LENGTH, SENSITIVE_MASK_VISIBLE_CHARS, MAX_JSON_BODY_BYTES, ANALYSIS_DEBOUNCE_MS, ISSUE_ID_HASH_LENGTH, ISSUES_DATA_VERSION, SENSITIVE_MASK_PLACEHOLDER, PROJECT_HASH_LENGTH, SECRET_SCAN_ARRAY_LIMIT, PII_SCAN_ARRAY_LIMIT, MIN_SECRET_VALUE_LENGTH, FULL_RECORD_MIN_FIELDS, LIST_PII_MIN_ITEMS, MAX_API_LIMIT, MAX_OBJECT_SCAN_DEPTH, MAX_UNIQUE_ENDPOINTS, MAX_ACCUMULATOR_ENTRIES, ISSUE_PRUNE_TTL_MS, FLOW_GAP_MS, SLOW_REQUEST_THRESHOLD_MS, MIN_POLLING_SEQUENCE, ENDPOINT_TRUNCATE_LENGTH, N1_QUERY_THRESHOLD, ERROR_RATE_THRESHOLD_PCT, MIN_REQUESTS_FOR_INSIGHT, HIGH_QUERY_COUNT_PER_REQ, CROSS_ENDPOINT_MIN_ENDPOINTS, CROSS_ENDPOINT_PCT, CROSS_ENDPOINT_MIN_OCCURRENCES, REDUNDANT_QUERY_MIN_COUNT, LARGE_RESPONSE_BYTES, HIGH_ROW_COUNT, OVERFETCH_MIN_REQUESTS, OVERFETCH_MIN_FIELDS, OVERFETCH_MIN_INTERNAL_IDS, OVERFETCH_NULL_RATIO, REGRESSION_PCT_THRESHOLD, REGRESSION_MIN_INCREASE_MS, REGRESSION_MIN_REQUESTS, QUERY_COUNT_REGRESSION_RATIO, OVERFETCH_MANY_FIELDS, OVERFETCH_UNWRAP_MIN_SIZE, MAX_DUPLICATE_INSIGHTS, INSIGHT_WINDOW_PER_ENDPOINT, CLEAN_HITS_FOR_RESOLUTION, STALE_ISSUE_TTL_MS, STRICT_MODE_MAX_GAP_MS, BASELINE_MIN_SESSIONS, BASELINE_MIN_REQUESTS_PER_SESSION, BASELINE_PENDING_POINTS_MIN, METRICS_DIR, METRICS_FILE, PORT_FILE, ISSUES_FILE, METRICS_FLUSH_INTERVAL_MS, METRICS_MAX_SESSIONS, METRICS_MAX_DATA_POINTS, ISSUES_FLUSH_INTERVAL_MS, SSE_HEARTBEAT_INTERVAL_MS, NOISE_HOSTS, NOISE_PATH_PATTERNS, VALID_ISSUE_STATES, VALID_ISSUE_CATEGORIES, VALID_AI_FIX_STATUSES, TELEMETRY_EVENT_SETUP_COMPLETED, TELEMETRY_EVENT_FIRST_REQUEST, TELEMETRY_EVENT_DASHBOARD_VIEWED, TELEMETRY_EVENT_SESSION, EXIT_REASON_CLEAN, EXIT_REASON_SIGINT, EXIT_REASON_SIGTERM, DETAIL_PREVIEW_LENGTH, KNOWN_DEPENDENCY_NAMES;
18
+ var MAX_REQUEST_ENTRIES, DEFAULT_MAX_BODY_CAPTURE, DEFAULT_API_LIMIT, MAX_TELEMETRY_ENTRIES, MAX_TAB_NAME_LENGTH, MAX_INGEST_BYTES, TERMINAL_TRUNCATE_LENGTH, SENSITIVE_MASK_MIN_LENGTH, SENSITIVE_MASK_VISIBLE_CHARS, MAX_JSON_BODY_BYTES, ANALYSIS_DEBOUNCE_MS, ISSUE_ID_HASH_LENGTH, ISSUES_DATA_VERSION, SENSITIVE_MASK_PLACEHOLDER, PROJECT_HASH_LENGTH, SECRET_SCAN_ARRAY_LIMIT, PII_SCAN_ARRAY_LIMIT, MIN_SECRET_VALUE_LENGTH, FULL_RECORD_MIN_FIELDS, LIST_PII_MIN_ITEMS, MAX_API_LIMIT, MAX_OBJECT_SCAN_DEPTH, MAX_UNIQUE_ENDPOINTS, MAX_ACCUMULATOR_ENTRIES, ISSUE_PRUNE_TTL_MS, FLOW_GAP_MS, SLOW_REQUEST_THRESHOLD_MS, MIN_POLLING_SEQUENCE, ENDPOINT_TRUNCATE_LENGTH, N1_QUERY_THRESHOLD, ERROR_RATE_THRESHOLD_PCT, MIN_REQUESTS_FOR_INSIGHT, HIGH_QUERY_COUNT_PER_REQ, CROSS_ENDPOINT_MIN_ENDPOINTS, CROSS_ENDPOINT_PCT, CROSS_ENDPOINT_MIN_OCCURRENCES, REDUNDANT_QUERY_MIN_COUNT, LARGE_RESPONSE_BYTES, HIGH_ROW_COUNT, OVERFETCH_MIN_REQUESTS, OVERFETCH_MIN_FIELDS, OVERFETCH_MIN_INTERNAL_IDS, OVERFETCH_NULL_RATIO, REGRESSION_PCT_THRESHOLD, REGRESSION_MIN_INCREASE_MS, REGRESSION_MIN_REQUESTS, QUERY_COUNT_REGRESSION_RATIO, OVERFETCH_MANY_FIELDS, OVERFETCH_UNWRAP_MIN_SIZE, MAX_DUPLICATE_INSIGHTS, INSIGHT_WINDOW_PER_ENDPOINT, CLEAN_HITS_FOR_RESOLUTION, STALE_ISSUE_TTL_MS, STRICT_MODE_MAX_GAP_MS, BASELINE_MIN_SESSIONS, BASELINE_MIN_REQUESTS_PER_SESSION, BASELINE_PENDING_POINTS_MIN, METRICS_DIR, METRICS_FILE, PORT_FILE, ISSUES_FILE, METRICS_FLUSH_INTERVAL_MS, METRICS_MAX_SESSIONS, METRICS_MAX_DATA_POINTS, ISSUES_FLUSH_INTERVAL_MS, SSE_HEARTBEAT_INTERVAL_MS, NOISE_HOSTS, NOISE_PATH_PATTERNS, VALID_ISSUE_STATES, VALID_ISSUE_CATEGORIES, VALID_AI_FIX_STATUSES, TELEMETRY_EVENT_SETUP_COMPLETED, TELEMETRY_EVENT_FIRST_REQUEST, TELEMETRY_EVENT_DASHBOARD_VIEWED, TELEMETRY_EVENT_SESSION, TELEMETRY_EVENT_GRAPH_FEATURE, EXIT_REASON_CLEAN, EXIT_REASON_SIGINT, EXIT_REASON_SIGTERM, DETAIL_PREVIEW_LENGTH, KNOWN_DEPENDENCY_NAMES;
19
19
  var init_config = __esm({
20
20
  "src/constants/config.ts"() {
21
21
  "use strict";
@@ -94,6 +94,7 @@ var init_config = __esm({
94
94
  TELEMETRY_EVENT_FIRST_REQUEST = "first_request";
95
95
  TELEMETRY_EVENT_DASHBOARD_VIEWED = "dashboard_viewed";
96
96
  TELEMETRY_EVENT_SESSION = "session";
97
+ TELEMETRY_EVENT_GRAPH_FEATURE = "graph_feature";
97
98
  EXIT_REASON_CLEAN = "clean";
98
99
  EXIT_REASON_SIGINT = "sigint";
99
100
  EXIT_REASON_SIGTERM = "sigterm";
@@ -118,7 +119,7 @@ var init_config = __esm({
118
119
  });
119
120
 
120
121
  // src/constants/labels.ts
121
- var DASHBOARD_PREFIX, DASHBOARD_API_REQUESTS, DASHBOARD_API_EVENTS, DASHBOARD_API_FLOWS, DASHBOARD_API_CLEAR, DASHBOARD_API_LOGS, DASHBOARD_API_FETCHES, DASHBOARD_API_ERRORS, DASHBOARD_API_QUERIES, DASHBOARD_API_INGEST, DASHBOARD_API_METRICS, DASHBOARD_API_ACTIVITY, DASHBOARD_API_METRICS_LIVE, DASHBOARD_API_INSIGHTS, DASHBOARD_API_SECURITY, DASHBOARD_API_TAB, DASHBOARD_API_FINDINGS, DASHBOARD_API_FINDINGS_REPORT, VALID_TABS_TUPLE, VALID_TABS, BRAKIT_REQUEST_ID_HEADER, BRAKIT_FETCH_ID_HEADER, SENSITIVE_HEADER_NAMES, HTTP_OK, HTTP_NO_CONTENT, HTTP_BAD_REQUEST, HTTP_NOT_FOUND, HTTP_METHOD_NOT_ALLOWED, HTTP_PAYLOAD_TOO_LARGE, HTTP_INTERNAL_ERROR, SECURITY_HEADERS, CONTENT_ENCODING_GZIP, CONTENT_ENCODING_BR, CONTENT_ENCODING_DEFLATE, SEVERITY_ICON, SSE_EVENT_FETCH, SSE_EVENT_LOG, SSE_EVENT_ERROR, SSE_EVENT_QUERY, SSE_EVENT_ISSUES, SDK_EVENT_REQUEST, SDK_EVENT_DB_QUERY, SDK_EVENT_FETCH, SDK_EVENT_LOG, SDK_EVENT_ERROR, SDK_EVENT_AUTH_CHECK, POSTHOG_HOST, POSTHOG_CAPTURE_PATH, POSTHOG_REQUEST_TIMEOUT_MS, SPEED_BUCKET_THRESHOLDS, TIMELINE_FETCH, TIMELINE_LOG, TIMELINE_ERROR, TIMELINE_QUERY;
122
+ var DASHBOARD_PREFIX, DASHBOARD_API_REQUESTS, DASHBOARD_API_EVENTS, DASHBOARD_API_FLOWS, DASHBOARD_API_CLEAR, DASHBOARD_API_LOGS, DASHBOARD_API_FETCHES, DASHBOARD_API_ERRORS, DASHBOARD_API_QUERIES, DASHBOARD_API_INGEST, DASHBOARD_API_METRICS, DASHBOARD_API_ACTIVITY, DASHBOARD_API_METRICS_LIVE, DASHBOARD_API_INSIGHTS, DASHBOARD_API_SECURITY, DASHBOARD_API_TAB, DASHBOARD_API_FINDINGS, DASHBOARD_API_FINDINGS_REPORT, DASHBOARD_API_GRAPH, VALID_TABS_TUPLE, VALID_TABS, BRAKIT_REQUEST_ID_HEADER, BRAKIT_FETCH_ID_HEADER, SENSITIVE_HEADER_NAMES, HTTP_OK, HTTP_NO_CONTENT, HTTP_BAD_REQUEST, HTTP_NOT_FOUND, HTTP_METHOD_NOT_ALLOWED, HTTP_PAYLOAD_TOO_LARGE, HTTP_INTERNAL_ERROR, SECURITY_HEADERS, CONTENT_ENCODING_GZIP, CONTENT_ENCODING_BR, CONTENT_ENCODING_DEFLATE, SEVERITY_ICON, SSE_EVENT_FETCH, SSE_EVENT_LOG, SSE_EVENT_ERROR, SSE_EVENT_QUERY, SSE_EVENT_ISSUES, SDK_EVENT_REQUEST, SDK_EVENT_DB_QUERY, SDK_EVENT_FETCH, SDK_EVENT_LOG, SDK_EVENT_ERROR, SDK_EVENT_AUTH_CHECK, POSTHOG_HOST, POSTHOG_CAPTURE_PATH, POSTHOG_REQUEST_TIMEOUT_MS, SPEED_BUCKET_THRESHOLDS, TIMELINE_FETCH, TIMELINE_LOG, TIMELINE_ERROR, TIMELINE_QUERY;
122
123
  var init_labels = __esm({
123
124
  "src/constants/labels.ts"() {
124
125
  "use strict";
@@ -140,6 +141,7 @@ var init_labels = __esm({
140
141
  DASHBOARD_API_TAB = `${DASHBOARD_PREFIX}/api/tab`;
141
142
  DASHBOARD_API_FINDINGS = `${DASHBOARD_PREFIX}/api/findings`;
142
143
  DASHBOARD_API_FINDINGS_REPORT = `${DASHBOARD_PREFIX}/api/findings/report`;
144
+ DASHBOARD_API_GRAPH = `${DASHBOARD_PREFIX}/api/graph`;
143
145
  VALID_TABS_TUPLE = [
144
146
  "overview",
145
147
  "actions",
@@ -149,7 +151,8 @@ var init_labels = __esm({
149
151
  "errors",
150
152
  "logs",
151
153
  "performance",
152
- "security"
154
+ "security",
155
+ "graph"
153
156
  ];
154
157
  VALID_TABS = new Set(VALID_TABS_TUPLE);
155
158
  BRAKIT_REQUEST_ID_HEADER = "x-brakit-request-id";
@@ -173,7 +176,7 @@ var init_labels = __esm({
173
176
  "x-content-type-options": "nosniff",
174
177
  "x-frame-options": "DENY",
175
178
  "referrer-policy": "no-referrer",
176
- "content-security-policy": "default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; connect-src 'self'; img-src data:"
179
+ "content-security-policy": "default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; connect-src 'self'; img-src data: blob:"
177
180
  };
178
181
  CONTENT_ENCODING_GZIP = "gzip";
179
182
  CONTENT_ENCODING_BR = "br";
@@ -486,7 +489,15 @@ var init_adapter_registry = __esm({
486
489
  function normalizeSQL(sql) {
487
490
  if (!sql) return { op: "OTHER", table: "" };
488
491
  const trimmed = sql.trim();
489
- const keyword = trimmed.split(/\s+/, 1)[0].toUpperCase();
492
+ let spaceIdx = -1;
493
+ for (let i = 0; i < trimmed.length; i++) {
494
+ const c = trimmed[i];
495
+ if (c === " " || c === " " || c === "\n" || c === "\r") {
496
+ spaceIdx = i;
497
+ break;
498
+ }
499
+ }
500
+ const keyword = (spaceIdx === -1 ? trimmed : trimmed.slice(0, spaceIdx)).toUpperCase();
490
501
  const op = VALID_OPS.has(keyword) ? keyword : "OTHER";
491
502
  const table = trimmed.match(TABLE_RE)?.[1] ?? "";
492
503
  return { op, table };
@@ -865,8 +876,51 @@ var init_adapters = __esm({
865
876
  });
866
877
 
867
878
  // src/utils/endpoint.ts
879
+ function isUUID(s) {
880
+ if (s.length !== UUID_LEN) return false;
881
+ for (let i = 0; i < s.length; i++) {
882
+ const c = s[i];
883
+ if (i === 8 || i === 13 || i === 18 || i === 23) {
884
+ if (c !== "-") return false;
885
+ } else {
886
+ if (!isHexChar(c)) return false;
887
+ }
888
+ }
889
+ return true;
890
+ }
891
+ function isHexChar(c) {
892
+ const code = c.charCodeAt(0);
893
+ return code >= 48 && code <= 57 || code >= 65 && code <= 70 || code >= 97 && code <= 102;
894
+ }
895
+ function isNumericId(s) {
896
+ if (s.length === 0) return false;
897
+ for (let i = 0; i < s.length; i++) {
898
+ const code = s.charCodeAt(i);
899
+ if (code < 48 || code > 57) return false;
900
+ }
901
+ return true;
902
+ }
903
+ function isHexHash(s) {
904
+ if (s.length < MIN_HEX_LEN) return false;
905
+ for (let i = 0; i < s.length; i++) {
906
+ if (!isHexChar(s[i])) return false;
907
+ }
908
+ return true;
909
+ }
910
+ function isAlphanumericToken(s) {
911
+ if (s.length < MIN_TOKEN_LEN) return false;
912
+ let hasLetter = false;
913
+ let hasDigit = false;
914
+ for (let i = 0; i < s.length; i++) {
915
+ const code = s.charCodeAt(i);
916
+ if (code >= 65 && code <= 90 || code >= 97 && code <= 122) hasLetter = true;
917
+ else if (code >= 48 && code <= 57) hasDigit = true;
918
+ else if (code !== 95 && code !== 45) return false;
919
+ }
920
+ return hasLetter && hasDigit;
921
+ }
868
922
  function isDynamicSegment(segment) {
869
- return UUID_RE.test(segment) || NUMERIC_ID_RE.test(segment) || HEX_HASH_RE.test(segment) || ALPHA_TOKEN_RE.test(segment);
923
+ return isUUID(segment) || isNumericId(segment) || isHexHash(segment) || isAlphanumericToken(segment);
870
924
  }
871
925
  function normalizePath(path) {
872
926
  const qIdx = path.indexOf("?");
@@ -877,22 +931,24 @@ function getEndpointKey(method, path) {
877
931
  return `${method} ${normalizePath(path)}`;
878
932
  }
879
933
  function extractEndpointFromDesc(desc) {
880
- return desc.match(ENDPOINT_PREFIX_RE)?.[1] ?? null;
934
+ const spaceIdx = desc.indexOf(" ");
935
+ if (spaceIdx <= 0) return null;
936
+ const secondSpace = desc.indexOf(" ", spaceIdx + 1);
937
+ if (secondSpace === -1) return desc;
938
+ return desc.slice(0, secondSpace);
881
939
  }
882
940
  function stripQueryString(path) {
883
941
  const i = path.indexOf("?");
884
942
  return i === -1 ? path : path.slice(0, i);
885
943
  }
886
- var UUID_RE, NUMERIC_ID_RE, HEX_HASH_RE, ALPHA_TOKEN_RE, DYNAMIC_SEGMENT_PLACEHOLDER, ENDPOINT_PREFIX_RE;
944
+ var UUID_LEN, MIN_HEX_LEN, MIN_TOKEN_LEN, DYNAMIC_SEGMENT_PLACEHOLDER;
887
945
  var init_endpoint = __esm({
888
946
  "src/utils/endpoint.ts"() {
889
947
  "use strict";
890
- UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
891
- NUMERIC_ID_RE = /^\d+$/;
892
- HEX_HASH_RE = /^[0-9a-f]{12,}$/i;
893
- ALPHA_TOKEN_RE = /^(?=.*[a-zA-Z])(?=.*\d)[a-zA-Z0-9_-]{8,}$/;
948
+ UUID_LEN = 36;
949
+ MIN_HEX_LEN = 12;
950
+ MIN_TOKEN_LEN = 8;
894
951
  DYNAMIC_SEGMENT_PLACEHOLDER = ":id";
895
- ENDPOINT_PREFIX_RE = /^(\S+\s+\S+)/;
896
952
  }
897
953
  });
898
954
 
@@ -913,6 +969,10 @@ var init_http_status = __esm({
913
969
  });
914
970
 
915
971
  // src/analysis/categorize.ts
972
+ function isAuthPath(path) {
973
+ const lower = path.toLowerCase();
974
+ return lower.startsWith("/api/auth") || lower.startsWith("/clerk") || lower.startsWith("/api/clerk");
975
+ }
916
976
  function detectCategory(req) {
917
977
  const { method, url, statusCode, responseHeaders } = req;
918
978
  if (req.isStatic) return "static";
@@ -921,7 +981,7 @@ function detectCategory(req) {
921
981
  return "auth-handshake";
922
982
  }
923
983
  const effectivePath = getEffectivePath(req);
924
- if (/^\/api\/auth/i.test(effectivePath) || /^\/(api\/)?clerk/i.test(effectivePath)) {
984
+ if (isAuthPath(effectivePath)) {
925
985
  return "auth-check";
926
986
  }
927
987
  if (method === "POST" && !effectivePath.startsWith("/api/")) {
@@ -945,6 +1005,13 @@ function detectCategory(req) {
945
1005
  }
946
1006
  return "unknown";
947
1007
  }
1008
+ function hasAuthCredentials(req) {
1009
+ if (req.headers["authorization"]) return true;
1010
+ const cookie = (req.headers["cookie"] || "").toLowerCase();
1011
+ if (cookie && AUTH_COOKIE_NAMES.some((name) => cookie.includes(name))) return true;
1012
+ if (req.statusCode === 401) return true;
1013
+ return false;
1014
+ }
948
1015
  function getEffectivePath(req) {
949
1016
  const rewrite = req.responseHeaders["x-middleware-rewrite"];
950
1017
  if (!rewrite) return req.path;
@@ -955,9 +1022,21 @@ function getEffectivePath(req) {
955
1022
  return rewrite.startsWith("/") ? rewrite : req.path;
956
1023
  }
957
1024
  }
1025
+ var AUTH_COOKIE_NAMES;
958
1026
  var init_categorize = __esm({
959
1027
  "src/analysis/categorize.ts"() {
960
1028
  "use strict";
1029
+ AUTH_COOKIE_NAMES = [
1030
+ "__session=",
1031
+ "__clerk",
1032
+ "__host-next-auth",
1033
+ "next-auth.session-token=",
1034
+ "auth_token=",
1035
+ "session_id=",
1036
+ "access_token=",
1037
+ "_session=",
1038
+ "appsession="
1039
+ ];
961
1040
  }
962
1041
  });
963
1042
 
@@ -1016,8 +1095,11 @@ function generateHumanLabel(req, category) {
1016
1095
  return failed ? `${req.method} ${req.path} failed` : `${req.method} ${req.path}`;
1017
1096
  }
1018
1097
  }
1098
+ function stripApiPrefix(s) {
1099
+ return s.startsWith("/api/") ? s.slice(5) : s;
1100
+ }
1019
1101
  function prettifyEndpoint(name) {
1020
- const cleaned = name.replace(/^\/api\//, "").replace(/\//g, " ").replace(/\.\.\./g, "").trim();
1102
+ const cleaned = stripApiPrefix(name).split("/").join(" ").split("...").join("").trim();
1021
1103
  if (!cleaned) return "data";
1022
1104
  return cleaned.split(" ").map((word) => {
1023
1105
  if (word.endsWith("ses") || word.endsWith("us") || word.endsWith("ss"))
@@ -1029,22 +1111,8 @@ function prettifyEndpoint(name) {
1029
1111
  }
1030
1112
  function deriveActionVerb(method, endpointName) {
1031
1113
  const lower = endpointName.toLowerCase();
1032
- const VERB_PATTERNS = [
1033
- [/enhance/, "Enhanced"],
1034
- [/generate/, "Generated"],
1035
- [/create/, "Created"],
1036
- [/update/, "Updated"],
1037
- [/delete|remove/, "Deleted"],
1038
- [/send/, "Sent"],
1039
- [/upload/, "Uploaded"],
1040
- [/save/, "Saved"],
1041
- [/submit/, "Submitted"],
1042
- [/login|signin/, "Logged in"],
1043
- [/logout|signout/, "Logged out"],
1044
- [/register|signup/, "Registered"]
1045
- ];
1046
- for (const [pattern, verb] of VERB_PATTERNS) {
1047
- if (pattern.test(lower)) return verb;
1114
+ for (const [keyword, verb] of VERB_MAP) {
1115
+ if (lower.includes(keyword)) return verb;
1048
1116
  }
1049
1117
  switch (method) {
1050
1118
  case "POST":
@@ -1059,24 +1127,45 @@ function deriveActionVerb(method, endpointName) {
1059
1127
  }
1060
1128
  }
1061
1129
  function getEndpointName(path) {
1062
- const parts = path.replace(/^\/api\//, "").split("/");
1130
+ const parts = stripApiPrefix(path).split("/");
1063
1131
  if (parts.length <= 2) return parts.join("/");
1064
1132
  return parts.map((p) => p.length > ENDPOINT_TRUNCATE_LENGTH ? "..." : p).join("/");
1065
1133
  }
1066
1134
  function prettifyPageName(path) {
1067
- const clean = path.replace(/^\//, "").replace(/\/$/, "");
1135
+ let clean = path;
1136
+ if (clean.startsWith("/")) clean = clean.slice(1);
1137
+ if (clean.endsWith("/")) clean = clean.slice(0, -1);
1068
1138
  if (!clean) return "Home";
1069
- return clean.split("/").map((s) => capitalize(s.replace(/[-_]/g, " "))).join(" ");
1139
+ return clean.split("/").map((s) => capitalize(s.split("-").join(" ").split("_").join(" "))).join(" ");
1070
1140
  }
1071
1141
  function capitalize(s) {
1072
1142
  return s.charAt(0).toUpperCase() + s.slice(1);
1073
1143
  }
1144
+ var VERB_MAP;
1074
1145
  var init_label = __esm({
1075
1146
  "src/analysis/label.ts"() {
1076
1147
  "use strict";
1077
1148
  init_constants();
1078
1149
  init_categorize();
1079
1150
  init_http_status();
1151
+ VERB_MAP = [
1152
+ ["enhance", "Enhanced"],
1153
+ ["generate", "Generated"],
1154
+ ["create", "Created"],
1155
+ ["update", "Updated"],
1156
+ ["delete", "Deleted"],
1157
+ ["remove", "Deleted"],
1158
+ ["send", "Sent"],
1159
+ ["upload", "Uploaded"],
1160
+ ["save", "Saved"],
1161
+ ["submit", "Submitted"],
1162
+ ["login", "Logged in"],
1163
+ ["signin", "Logged in"],
1164
+ ["logout", "Logged out"],
1165
+ ["signout", "Logged out"],
1166
+ ["register", "Registered"],
1167
+ ["signup", "Registered"]
1168
+ ];
1080
1169
  }
1081
1170
  });
1082
1171
 
@@ -1522,33 +1611,85 @@ var init_handlers = __esm({
1522
1611
 
1523
1612
  // src/utils/static-patterns.ts
1524
1613
  function isStaticPath(urlPath) {
1525
- return STATIC_PATTERNS.some((p) => p.test(urlPath));
1614
+ const dotIdx = urlPath.lastIndexOf(".");
1615
+ if (dotIdx !== -1) {
1616
+ const ext = urlPath.slice(dotIdx).toLowerCase();
1617
+ if (STATIC_EXTENSIONS.has(ext)) return true;
1618
+ }
1619
+ return STATIC_PREFIXES.some((p) => urlPath.startsWith(p));
1526
1620
  }
1527
1621
  function isHealthCheckPath(urlPath) {
1528
- return HEALTH_CHECK_PATTERNS.some((p) => p.test(urlPath));
1622
+ return HEALTH_CHECK_PATHS.has(urlPath.toLowerCase());
1529
1623
  }
1530
- var STATIC_PATTERNS, HEALTH_CHECK_PATTERNS;
1624
+ var STATIC_EXTENSIONS, STATIC_PREFIXES, HEALTH_CHECK_PATHS;
1531
1625
  var init_static_patterns = __esm({
1532
1626
  "src/utils/static-patterns.ts"() {
1533
1627
  "use strict";
1534
- STATIC_PATTERNS = [
1535
- /\.(?:js|css|map|ico|png|jpg|jpeg|gif|svg|webp|woff2?|ttf|eot)$/,
1536
- /^\/favicon/,
1537
- /^\/node_modules\//,
1538
- // Framework-specific static/internal paths
1539
- /^\/_next\//,
1540
- /^\/__nextjs/,
1541
- /^\/@vite\//,
1542
- /^\/__vite/
1543
- ];
1544
- HEALTH_CHECK_PATTERNS = [
1545
- /^\/health(z|check)?$/i,
1546
- /^\/ping$/i,
1547
- /^\/(ready|readiness|liveness)$/i,
1548
- /^\/status$/i,
1549
- /^\/__health$/i,
1550
- /^\/api\/health(z|check)?$/i
1628
+ STATIC_EXTENSIONS = /* @__PURE__ */ new Set([
1629
+ ".js",
1630
+ ".css",
1631
+ ".map",
1632
+ ".ico",
1633
+ ".png",
1634
+ ".jpg",
1635
+ ".jpeg",
1636
+ ".gif",
1637
+ ".svg",
1638
+ ".webp",
1639
+ ".woff",
1640
+ ".woff2",
1641
+ ".ttf",
1642
+ ".eot"
1643
+ ]);
1644
+ STATIC_PREFIXES = [
1645
+ "/favicon",
1646
+ "/node_modules/",
1647
+ // Next.js
1648
+ "/_next/",
1649
+ "/__nextjs",
1650
+ // Vite (also used by Nuxt, Astro in dev)
1651
+ "/@vite/",
1652
+ "/__vite",
1653
+ "/@fs/",
1654
+ "/@id/",
1655
+ // Remix
1656
+ "/__remix",
1657
+ // Nuxt
1658
+ "/_nuxt/",
1659
+ "/__nuxt",
1660
+ // Astro
1661
+ "/@astro",
1662
+ "/_astro/",
1663
+ // Django
1664
+ "/static/",
1665
+ "/media/",
1666
+ "/__debug__/",
1667
+ // Flask / Werkzeug
1668
+ "/_debugtoolbar/",
1669
+ // FastAPI / Starlette
1670
+ "/openapi.json",
1671
+ "/docs",
1672
+ "/redoc",
1673
+ // Rails
1674
+ "/assets/",
1675
+ "/packs/",
1676
+ // Browser probes
1677
+ "/.well-known/"
1551
1678
  ];
1679
+ HEALTH_CHECK_PATHS = /* @__PURE__ */ new Set([
1680
+ "/health",
1681
+ "/healthz",
1682
+ "/healthcheck",
1683
+ "/ping",
1684
+ "/ready",
1685
+ "/readiness",
1686
+ "/liveness",
1687
+ "/status",
1688
+ "/__health",
1689
+ "/api/health",
1690
+ "/api/healthz",
1691
+ "/api/healthcheck"
1692
+ ]);
1552
1693
  }
1553
1694
  });
1554
1695
 
@@ -1977,6 +2118,42 @@ var init_issues = __esm({
1977
2118
  }
1978
2119
  });
1979
2120
 
2121
+ // src/dashboard/api/graph.ts
2122
+ function createGraphHandler(services) {
2123
+ return (req, res) => {
2124
+ if (!requireGet(req, res)) return;
2125
+ const url = parseRequestUrl(req);
2126
+ const rawCluster = url.searchParams.get("cluster") ?? void 0;
2127
+ const rawNode = url.searchParams.get("node") ?? void 0;
2128
+ const rawLevel = url.searchParams.get("level") ?? void 0;
2129
+ const rawGrouping = url.searchParams.get("grouping") ?? void 0;
2130
+ const cluster = rawCluster && rawCluster.length <= MAX_PARAM_LENGTH ? rawCluster : void 0;
2131
+ const node = rawNode && rawNode.length <= MAX_PARAM_LENGTH ? rawNode : void 0;
2132
+ const level = rawLevel && VALID_LEVELS.has(rawLevel) ? rawLevel : void 0;
2133
+ const grouping = rawGrouping && VALID_GROUPINGS.has(rawGrouping) ? rawGrouping : void 0;
2134
+ const { graphBuilder, metricsStore } = services;
2135
+ graphBuilder.enrichWithMetrics((endpointKey) => {
2136
+ const metrics = metricsStore.getEndpoint(endpointKey);
2137
+ if (!metrics || metrics.sessions.length === 0) return void 0;
2138
+ const latest = metrics.sessions[metrics.sessions.length - 1];
2139
+ return latest.p95DurationMs;
2140
+ });
2141
+ const data = graphBuilder.getApiResponse({ cluster, node, level, grouping });
2142
+ sendJson(req, res, HTTP_OK, data);
2143
+ };
2144
+ }
2145
+ var VALID_LEVELS, VALID_GROUPINGS, MAX_PARAM_LENGTH;
2146
+ var init_graph = __esm({
2147
+ "src/dashboard/api/graph.ts"() {
2148
+ "use strict";
2149
+ init_labels();
2150
+ init_shared2();
2151
+ VALID_LEVELS = /* @__PURE__ */ new Set(["endpoints", "clusters"]);
2152
+ VALID_GROUPINGS = /* @__PURE__ */ new Set(["path", "auth-boundary", "data-domain"]);
2153
+ MAX_PARAM_LENGTH = 200;
2154
+ }
2155
+ });
2156
+
1980
2157
  // src/dashboard/sse.ts
1981
2158
  function createSSEHandler(services) {
1982
2159
  const clients = /* @__PURE__ */ new Set();
@@ -2478,26 +2655,128 @@ var init_response = __esm({
2478
2655
  });
2479
2656
 
2480
2657
  // src/analysis/rules/patterns.ts
2481
- var SECRET_KEYS, TOKEN_PARAMS, SAFE_PARAMS, STACK_TRACE_RE, DB_CONN_RE, SQL_FRAGMENT_RE, SECRET_VAL_RE, LOG_SECRET_RE, MASKED_RE, EMAIL_RE, INTERNAL_ID_KEYS, INTERNAL_ID_SUFFIX, SELF_SERVICE_PATH, SENSITIVE_FIELD_NAMES, SELECT_STAR_RE, SELECT_DOT_STAR_RE, RULE_HINTS;
2658
+ var SECRET_KEY_SET, SECRET_KEYS, TOKEN_PARAM_SET, TOKEN_PARAMS, SAFE_PARAM_SET, SAFE_PARAMS, INTERNAL_ID_KEY_SET, INTERNAL_ID_KEYS, INTERNAL_ID_SUFFIX, SENSITIVE_FIELD_SET, SENSITIVE_FIELD_NAMES, SELF_SERVICE_SEGMENTS, SELF_SERVICE_PATH, MASKED_LITERALS, MASKED_RE, DB_PROTOCOLS, DB_CONN_RE, SELECT_STAR_RE, SELECT_DOT_STAR_RE, STACK_TRACE_RE, SQL_FRAGMENT_RE, SECRET_VAL_RE, LOG_SECRET_RE, EMAIL_RE, RULE_HINTS;
2482
2659
  var init_patterns = __esm({
2483
2660
  "src/analysis/rules/patterns.ts"() {
2484
2661
  "use strict";
2485
- SECRET_KEYS = /^(password|passwd|secret|api_key|apiKey|api_secret|apiSecret|private_key|privateKey|client_secret|clientSecret)$/;
2486
- TOKEN_PARAMS = /^(token|api_key|apiKey|secret|password|access_token|session_id|sessionId)$/;
2487
- SAFE_PARAMS = /^(_rsc|__clerk_handshake|__clerk_db_jwt|callback|code|state|nonce|redirect_uri|utm_|fbclid|gclid)$/;
2662
+ SECRET_KEY_SET = /* @__PURE__ */ new Set([
2663
+ "password",
2664
+ "passwd",
2665
+ "secret",
2666
+ "api_key",
2667
+ "apiKey",
2668
+ "api_secret",
2669
+ "apiSecret",
2670
+ "private_key",
2671
+ "privateKey",
2672
+ "client_secret",
2673
+ "clientSecret"
2674
+ ]);
2675
+ SECRET_KEYS = { test: (s) => SECRET_KEY_SET.has(s) };
2676
+ TOKEN_PARAM_SET = /* @__PURE__ */ new Set([
2677
+ "token",
2678
+ "api_key",
2679
+ "apiKey",
2680
+ "secret",
2681
+ "password",
2682
+ "access_token",
2683
+ "session_id",
2684
+ "sessionId"
2685
+ ]);
2686
+ TOKEN_PARAMS = { test: (s) => TOKEN_PARAM_SET.has(s) };
2687
+ SAFE_PARAM_SET = /* @__PURE__ */ new Set([
2688
+ "_rsc",
2689
+ "__clerk_handshake",
2690
+ "__clerk_db_jwt",
2691
+ "callback",
2692
+ "code",
2693
+ "state",
2694
+ "nonce",
2695
+ "redirect_uri",
2696
+ "utm_",
2697
+ "fbclid",
2698
+ "gclid"
2699
+ ]);
2700
+ SAFE_PARAMS = { test: (s) => SAFE_PARAM_SET.has(s) };
2701
+ INTERNAL_ID_KEY_SET = /* @__PURE__ */ new Set([
2702
+ "id",
2703
+ "_id",
2704
+ "userId",
2705
+ "user_id",
2706
+ "createdBy",
2707
+ "updatedBy",
2708
+ "organizationId",
2709
+ "org_id",
2710
+ "tenantId",
2711
+ "tenant_id"
2712
+ ]);
2713
+ INTERNAL_ID_KEYS = { test: (s) => INTERNAL_ID_KEY_SET.has(s) };
2714
+ INTERNAL_ID_SUFFIX = {
2715
+ test: (s) => s.endsWith("Id") || s.endsWith("_id")
2716
+ };
2717
+ SENSITIVE_FIELD_SET = /* @__PURE__ */ new Set([
2718
+ "phone",
2719
+ "phonenumber",
2720
+ "phone_number",
2721
+ "ssn",
2722
+ "socialsecuritynumber",
2723
+ "social_security_number",
2724
+ "dateofbirth",
2725
+ "date_of_birth",
2726
+ "dob",
2727
+ "address",
2728
+ "streetaddress",
2729
+ "street_address",
2730
+ "creditcard",
2731
+ "credit_card",
2732
+ "cardnumber",
2733
+ "card_number",
2734
+ "bankaccount",
2735
+ "bank_account",
2736
+ "passport",
2737
+ "passportnumber",
2738
+ "passport_number",
2739
+ "nationalid",
2740
+ "national_id"
2741
+ ]);
2742
+ SENSITIVE_FIELD_NAMES = {
2743
+ test: (s) => SENSITIVE_FIELD_SET.has(s.toLowerCase())
2744
+ };
2745
+ SELF_SERVICE_SEGMENTS = /* @__PURE__ */ new Set(["me", "account", "profile", "settings", "self"]);
2746
+ SELF_SERVICE_PATH = {
2747
+ test: (path) => {
2748
+ const segments = path.toLowerCase().split(/[/?#]/);
2749
+ return segments.some((seg) => SELF_SERVICE_SEGMENTS.has(seg));
2750
+ }
2751
+ };
2752
+ MASKED_LITERALS = ["[REDACTED]", "[FILTERED]", "CHANGE_ME"];
2753
+ MASKED_RE = {
2754
+ test: (s) => {
2755
+ const upper = s.toUpperCase();
2756
+ if (MASKED_LITERALS.some((m) => upper.includes(m))) return true;
2757
+ if (s.length > 0 && s.split("").every((c) => c === "*")) return true;
2758
+ if (s.length >= 3 && s.split("").every((c) => c === "x" || c === "X")) return true;
2759
+ return false;
2760
+ }
2761
+ };
2762
+ DB_PROTOCOLS = ["postgres://", "mysql://", "mongodb://", "redis://"];
2763
+ DB_CONN_RE = {
2764
+ test: (s) => DB_PROTOCOLS.some((p) => s.includes(p))
2765
+ };
2766
+ SELECT_STAR_RE = {
2767
+ test: (s) => {
2768
+ const t = s.trimStart().toUpperCase();
2769
+ return t.startsWith("SELECT *") || t.startsWith("SELECT *");
2770
+ }
2771
+ };
2772
+ SELECT_DOT_STAR_RE = {
2773
+ test: (s) => s.toUpperCase().includes(".* FROM")
2774
+ };
2488
2775
  STACK_TRACE_RE = /at\s+.+\(.+:\d+:\d+\)|at\s+Module\._compile|at\s+Object\.<anonymous>|at\s+processTicksAndRejections|Traceback \(most recent call last\)|File ".+", line \d+/;
2489
- DB_CONN_RE = /(postgres|mysql|mongodb|redis):\/\//;
2490
2776
  SQL_FRAGMENT_RE = /\b(SELECT\s+[\w.*]+\s+FROM|INSERT\s+INTO|UPDATE\s+\w+\s+SET|DELETE\s+FROM)\b/i;
2491
2777
  SECRET_VAL_RE = /(api_key|apiKey|secret|token)\s*[:=]\s*["']?[A-Za-z0-9_\-.+/]{8,}/;
2492
2778
  LOG_SECRET_RE = /(password|secret|token|api_key|apiKey)\s*[:=]\s*["']?[A-Za-z0-9_\-.+/]{8,}/i;
2493
- MASKED_RE = /^\*+$|\[REDACTED\]|\[FILTERED\]|CHANGE_ME|^x{3,}$/i;
2494
2779
  EMAIL_RE = /[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/;
2495
- INTERNAL_ID_KEYS = /^(id|_id|userId|user_id|createdBy|updatedBy|organizationId|org_id|tenantId|tenant_id)$/;
2496
- INTERNAL_ID_SUFFIX = /Id$|_id$/;
2497
- SELF_SERVICE_PATH = /\/(?:me|account|profile|settings|self)(?=\/|\?|#|$)/i;
2498
- SENSITIVE_FIELD_NAMES = /^(phone|phoneNumber|phone_number|ssn|socialSecurityNumber|social_security_number|dateOfBirth|date_of_birth|dob|address|streetAddress|street_address|creditCard|credit_card|cardNumber|card_number|bankAccount|bank_account|passport|passportNumber|passport_number|nationalId|national_id)$/i;
2499
- SELECT_STAR_RE = /^SELECT\s+\*/i;
2500
- SELECT_DOT_STAR_RE = /\.\*\s+FROM/i;
2501
2780
  RULE_HINTS = {
2502
2781
  "exposed-secret": "Never include secret fields in API responses. Strip sensitive fields before returning.",
2503
2782
  "token-in-url": "Pass tokens in the Authorization header, not URL query parameters.",
@@ -3956,7 +4235,7 @@ var init_src = __esm({
3956
4235
  init_engine();
3957
4236
  init_insights2();
3958
4237
  init_insights();
3959
- VERSION = "0.9.2";
4238
+ VERSION = "0.10.0";
3960
4239
  }
3961
4240
  });
3962
4241
 
@@ -4041,6 +4320,7 @@ function getLayoutStyles() {
4041
4320
  .sidebar-item:hover .item-icon{opacity:.8}
4042
4321
  .sidebar-item .item-label{flex:1}
4043
4322
  .sidebar-item .item-count{font-size:12px;font-family:var(--mono);color:var(--text-muted);background:var(--bg-muted);padding:2px 8px;border-radius:10px;min-width:24px;text-align:center}
4323
+ .sidebar-beta{font-size:9px;color:#6366f1;background:#eef2ff;border:1px solid #e0e7ff;border-radius:4px;padding:0 5px;margin-left:auto;line-height:16px}
4044
4324
  .sidebar-item.disabled{opacity:.35;cursor:default;pointer-events:none}
4045
4325
  .sidebar-item .coming-soon{font-size:10px;color:var(--text-muted);background:var(--bg-muted);padding:2px 8px;border-radius:10px;font-weight:600;letter-spacing:.3px}
4046
4326
  .sidebar-footer{padding:16px 24px;border-top:1px solid var(--border-subtle);font-size:12px;color:var(--text-muted);font-family:var(--mono)}
@@ -4066,6 +4346,7 @@ function getLayoutStyles() {
4066
4346
  .main-content{flex:1;overflow-y:auto}
4067
4347
  bk-dashboard{display:contents}
4068
4348
  bk-overview-view,bk-flows-view,bk-requests-view,bk-fetches-view,bk-queries-view,bk-errors-view,bk-logs-view,bk-security-view,bk-performance-view,bk-timeline-panel,bk-empty-state{display:block}
4349
+ bk-graph-view{display:block}
4069
4350
  bk-method-badge,bk-status-pill,bk-duration-label,bk-copy-button{display:inline-flex;flex-shrink:0}
4070
4351
  bk-stat-card{display:inline-flex}
4071
4352
  bk-toast{display:block;position:fixed;top:0;left:0;right:0;z-index:100;pointer-events:none}
@@ -4442,7 +4723,7 @@ span.perf-breakdown-dot.perf-breakdown-app{background:var(--breakdown-app)}
4442
4723
  .perf-trend-faster{color:var(--green)}
4443
4724
  `;
4444
4725
  }
4445
- var init_graph = __esm({
4726
+ var init_graph2 = __esm({
4446
4727
  "src/dashboard/styles/graph.ts"() {
4447
4728
  "use strict";
4448
4729
  }
@@ -4779,9 +5060,121 @@ var init_timeline = __esm({
4779
5060
  }
4780
5061
  });
4781
5062
 
5063
+ // src/dashboard/styles/graph-view.ts
5064
+ function getGraphViewStyles() {
5065
+ return `
5066
+ .graph-wrapper{display:flex;flex-direction:column;height:calc(100vh - 120px);outline:none}
5067
+
5068
+ /* Toolbar \u2014 centered search, layers left, flow picker right */
5069
+ .graph-toolbar{display:flex;align-items:center;gap:10px;padding:8px 16px;border-bottom:1px solid var(--border)}
5070
+
5071
+ /* Layer toggles */
5072
+ .graph-layer-toggles{display:flex;gap:4px;flex-shrink:0}
5073
+ .graph-layer-btn{display:flex;align-items:center;gap:3px;font-size:10px;font-weight:500;padding:3px 8px;border:1px solid var(--border);border-radius:12px;background:var(--bg);color:var(--text-muted);cursor:pointer;transition:all .15s;white-space:nowrap}
5074
+ .graph-layer-btn:hover{border-color:var(--text-muted);color:var(--text)}
5075
+ .graph-layer-btn.active{background:var(--bg-card);font-weight:600}
5076
+
5077
+ /* Search \u2014 takes remaining space, centered */
5078
+ .graph-search{flex:1;position:relative;display:flex;align-items:center;max-width:360px;margin:0 auto}
5079
+ .graph-search-icon{position:absolute;left:10px;color:var(--text-muted);font-size:13px;pointer-events:none;opacity:0.5}
5080
+ .graph-search-input{width:100%;font-size:11px;padding:6px 28px 6px 28px;border:1px solid var(--border);border-radius:8px;background:var(--bg);color:var(--text);font-family:var(--mono);outline:none;transition:border-color .15s,box-shadow .15s}
5081
+ .graph-search-input:focus{border-color:#6366f1;box-shadow:0 0 0 3px rgba(99,102,241,.1)}
5082
+ .graph-search-input::placeholder{color:var(--text-muted);opacity:0.5}
5083
+ .graph-search-clear{position:absolute;right:8px;background:none;border:none;color:var(--text-muted);cursor:pointer;font-size:11px;padding:0 4px;line-height:1;border-radius:3px}
5084
+ .graph-search-clear:hover{color:var(--text);background:var(--bg-card)}
5085
+
5086
+ /* Flow picker */
5087
+ .graph-flow-picker{font-size:10px;padding:4px 8px;border:1px solid var(--border);border-radius:8px;background:var(--bg);color:var(--text);cursor:pointer;font-family:var(--mono);max-width:200px;flex-shrink:0}
5088
+
5089
+ /* Auth legend \u2014 inline in toolbar */
5090
+ .graph-auth-legend{display:flex;gap:8px;align-items:center;font-size:10px;color:var(--text-muted);flex-shrink:0}
5091
+ .graph-auth-legend-item{display:flex;align-items:center;gap:3px;white-space:nowrap}
5092
+
5093
+ /* Canvas */
5094
+ .graph-body{display:flex;flex:1;min-height:0}
5095
+ .graph-canvas{flex:1;overflow:hidden;padding:0;position:relative;min-height:0}
5096
+ .graph-svg{display:block}
5097
+ .graph-col-header{fill:#c4c4cc;font-size:9px;font-weight:600;font-family:'Inter',system-ui,sans-serif;letter-spacing:1.5px}
5098
+
5099
+ /* Floating controls \u2014 bottom-center pill */
5100
+ .graph-float{position:absolute;top:12px;right:12px;display:flex;align-items:center;gap:2px;background:var(--bg-card);border:1px solid var(--border);border-radius:10px;padding:4px 6px;box-shadow:0 2px 12px rgba(0,0,0,.08);z-index:10}
5101
+ .graph-float-btn{background:none;border:none;color:var(--text-muted);cursor:pointer;font-size:13px;padding:4px 8px;line-height:1;border-radius:6px;transition:all .12s;white-space:nowrap}
5102
+ .graph-float-btn:hover{background:var(--bg);color:var(--text)}
5103
+ .graph-float-btn-accent{font-size:11px;font-weight:600;color:#6366f1}
5104
+ .graph-float-btn-accent:hover{background:rgba(99,102,241,.08);color:#4f46e5}
5105
+ .graph-float-zoom{font-size:10px;color:var(--text-muted);font-family:var(--mono);min-width:36px;text-align:center;user-select:none}
5106
+ .graph-float-sep{width:1px;height:16px;background:var(--border);margin:0 2px;flex-shrink:0}
5107
+
5108
+ /* Empty & loading states */
5109
+ .graph-empty{display:flex;flex-direction:column;align-items:center;justify-content:center;min-height:400px;color:var(--text-muted);text-align:center;padding:40px}
5110
+ .graph-empty-icon{font-size:40px;opacity:0.25;margin-bottom:12px}
5111
+ .graph-empty-title{font-size:15px;font-weight:600;color:var(--text);margin-bottom:6px}
5112
+ .graph-empty-desc{font-size:12px;max-width:320px;line-height:1.5}
5113
+ .graph-loading{display:flex;align-items:center;justify-content:center;min-height:400px;color:var(--text-muted);font-size:13px}
5114
+
5115
+ /* Detail panel */
5116
+ .graph-detail{width:320px;border-left:1px solid var(--border);overflow-y:auto;padding:16px;background:var(--bg-card);flex-shrink:0;max-height:calc(100vh - 160px)}
5117
+ .graph-detail-head{display:flex;justify-content:space-between;align-items:flex-start;margin-bottom:12px}
5118
+ .graph-detail-badge{font-size:11px;font-weight:500;text-transform:uppercase;letter-spacing:0.5px;margin-bottom:4px}
5119
+ .graph-detail-name{font-size:14px;font-weight:700;color:var(--text);word-break:break-all;font-family:var(--mono)}
5120
+ .graph-detail-close{background:none;border:none;color:var(--text-muted);cursor:pointer;font-size:16px;padding:0 4px;line-height:1}
5121
+ .graph-detail-close:hover{color:var(--text)}
5122
+
5123
+ .graph-detail-auth-badge{display:inline-block;font-size:10px;font-weight:500;color:#059669;background:#ecfdf5;border:1px solid #a7f3d0;border-radius:4px;padding:1px 6px;margin-top:4px}
5124
+ .graph-detail-mw-badge{display:inline-block;font-size:10px;font-weight:500;color:#6b7280;background:#f3f4f6;border:1px solid #d1d5db;border-radius:4px;padding:1px 6px;margin-top:4px;margin-left:4px}
5125
+
5126
+ /* Detail tabs */
5127
+ .graph-detail-tabs{display:flex;gap:2px;margin-bottom:12px;border-bottom:1px solid var(--border);padding-bottom:0}
5128
+ .graph-detail-tab{background:none;border:none;border-bottom:2px solid transparent;color:var(--text-muted);cursor:pointer;font-size:11px;font-weight:500;padding:6px 10px;transition:all .12s}
5129
+ .graph-detail-tab:hover{color:var(--text)}
5130
+ .graph-detail-tab.active{color:#6366f1;border-bottom-color:#6366f1}
5131
+
5132
+ /* Detail stats */
5133
+ .graph-detail-stats{display:grid;grid-template-columns:1fr 1fr;gap:8px;margin-bottom:12px}
5134
+ .graph-detail-stat{background:var(--bg);border-radius:var(--radius-sm);padding:10px 12px}
5135
+ .graph-detail-val{font-size:20px;font-weight:700;font-family:var(--mono);color:var(--text);line-height:1.2}
5136
+ .graph-detail-lbl{font-size:9px;color:var(--text-muted);text-transform:uppercase;letter-spacing:0.6px;margin-top:2px}
5137
+
5138
+ /* Detail sections */
5139
+ .graph-detail-sec{font-size:10px;color:var(--text-muted);text-transform:uppercase;letter-spacing:0.8px;margin:12px 0 8px;padding-top:10px;border-top:1px solid var(--border)}
5140
+ .graph-detail-conn{display:flex;align-items:center;gap:6px;font-size:12px;color:var(--text);padding:6px 8px;background:var(--bg);border-radius:var(--radius-sm);margin-bottom:4px;font-family:var(--mono)}
5141
+ .graph-detail-edge-dot{width:6px;height:6px;border-radius:50%;flex-shrink:0}
5142
+ .graph-detail-edge-type{font-size:10px;font-weight:600;text-transform:uppercase;min-width:42px}
5143
+ .graph-detail-dim{color:var(--text-muted);font-size:10px;margin-left:auto;white-space:nowrap}
5144
+ .graph-detail-sql{font-size:10px;color:var(--text-muted);padding:8px 10px;background:var(--bg);border-radius:var(--radius-sm);font-family:var(--mono);word-break:break-all;line-height:1.5;margin:0 0 4px;white-space:pre-wrap;border:1px solid var(--border)}
5145
+
5146
+ /* Security findings in detail */
5147
+ .graph-detail-finding{padding:8px 10px;background:var(--bg);border-radius:var(--radius-sm);margin-bottom:6px;border:1px solid var(--border)}
5148
+ .graph-detail-finding-title{font-size:12px;font-weight:600;color:var(--text);margin-top:4px}
5149
+ .graph-detail-finding-meta{font-size:10px;color:var(--text-muted);margin-top:2px;font-family:var(--mono)}
5150
+ .graph-detail-severity{font-size:9px;font-weight:700;text-transform:uppercase;letter-spacing:0.5px;padding:1px 6px;border-radius:3px}
5151
+ .graph-detail-severity-critical{background:#fef2f2;color:#dc2626;border:1px solid #fecaca}
5152
+ .graph-detail-severity-warning{background:#fffbeb;color:#d97706;border:1px solid #fde68a}
5153
+ .graph-detail-severity-info{background:#eff6ff;color:#2563eb;border:1px solid #bfdbfe}
5154
+
5155
+ /* Issues in detail */
5156
+ .graph-detail-issue-summary{margin-bottom:12px}
5157
+ .graph-detail-hint{font-size:11px;color:var(--text-muted);line-height:1.5;margin:0}
5158
+ .graph-detail-empty{font-size:12px;color:var(--text-muted);padding:16px;text-align:center}
5159
+
5160
+ /* Pulse animation for critical security badges */
5161
+ @keyframes graph-pulse{0%,100%{opacity:1}50%{opacity:0.5}}
5162
+ .graph-pulse{animation:graph-pulse 2s ease-in-out infinite}
5163
+
5164
+ /* Flow edge animation */
5165
+ @keyframes graph-flow-dash{to{stroke-dashoffset:-24}}
5166
+ .graph-flow-edge{animation:graph-flow-dash 1s linear infinite}
5167
+ `;
5168
+ }
5169
+ var init_graph_view = __esm({
5170
+ "src/dashboard/styles/graph-view.ts"() {
5171
+ "use strict";
5172
+ }
5173
+ });
5174
+
4782
5175
  // src/dashboard/styles.ts
4783
5176
  function getStyles() {
4784
- return getBaseStyles() + getLayoutStyles() + getFlowStyles() + getRequestStyles() + getPerformanceStyles() + getOverviewStyles() + getSecurityStyles() + getTimelineStyles();
5177
+ return getBaseStyles() + getLayoutStyles() + getFlowStyles() + getRequestStyles() + getPerformanceStyles() + getOverviewStyles() + getSecurityStyles() + getTimelineStyles() + getGraphViewStyles();
4785
5178
  }
4786
5179
  var init_styles = __esm({
4787
5180
  "src/dashboard/styles.ts"() {
@@ -4790,10 +5183,11 @@ var init_styles = __esm({
4790
5183
  init_layout();
4791
5184
  init_flows();
4792
5185
  init_requests();
4793
- init_graph();
5186
+ init_graph2();
4794
5187
  init_overview();
4795
5188
  init_security2();
4796
5189
  init_timeline();
5190
+ init_graph_view();
4797
5191
  }
4798
5192
  });
4799
5193
 
@@ -4976,6 +5370,12 @@ function recordDashboardOpened() {
4976
5370
  request_count_at_open: session.requestCount
4977
5371
  });
4978
5372
  }
5373
+ function recordGraphFeature(feature, detail) {
5374
+ trackEvent(TELEMETRY_EVENT_GRAPH_FEATURE, {
5375
+ feature,
5376
+ ...detail ? { detail } : {}
5377
+ });
5378
+ }
4979
5379
  function recordSetupCompleted(info) {
4980
5380
  session.frameworkCandidates = info.frameworkCandidates;
4981
5381
  session.adaptersFailed = info.adaptersFailed;
@@ -5108,11 +5508,19 @@ function createDashboardHandler(services) {
5108
5508
  issueStore,
5109
5509
  services.bus
5110
5510
  );
5511
+ routes[DASHBOARD_API_GRAPH] = createGraphHandler(services);
5111
5512
  routes[DASHBOARD_API_TAB] = (req, res) => {
5112
- const raw = (req.url ?? "").split("tab=")[1];
5113
- if (raw) {
5114
- const tab = decodeURIComponent(raw).slice(0, MAX_TAB_NAME_LENGTH);
5115
- if (VALID_TABS.has(tab) && isTelemetryEnabled()) recordTabViewed(tab);
5513
+ if (isTelemetryEnabled()) {
5514
+ const url = new URL(req.url ?? "/", "http://localhost");
5515
+ const tab = url.searchParams.get("tab");
5516
+ if (tab && tab.length <= MAX_TAB_NAME_LENGTH && VALID_TABS.has(tab)) {
5517
+ recordTabViewed(tab);
5518
+ }
5519
+ const event = url.searchParams.get("event");
5520
+ if (event && event.length <= MAX_TAB_NAME_LENGTH) {
5521
+ const detail = url.searchParams.get("detail") ?? void 0;
5522
+ recordGraphFeature(event, detail?.slice(0, MAX_TAB_NAME_LENGTH));
5523
+ }
5116
5524
  }
5117
5525
  res.writeHead(HTTP_NO_CONTENT);
5118
5526
  res.end();
@@ -5141,6 +5549,7 @@ var init_router = __esm({
5141
5549
  init_labels();
5142
5550
  init_api();
5143
5551
  init_issues();
5552
+ init_graph();
5144
5553
  init_sse();
5145
5554
  init_page();
5146
5555
  init_telemetry();
@@ -5667,6 +6076,622 @@ var init_store = __esm({
5667
6076
  }
5668
6077
  });
5669
6078
 
6079
+ // src/graph/constants.ts
6080
+ var MAX_PATTERNS_PER_EDGE, CLUSTER_SPLIT_THRESHOLD, COMMON_PATH_PREFIXES, PENDING_BUFFER_MAX, PENDING_EVICTION_TARGET, PENDING_TTL_MS;
6081
+ var init_constants2 = __esm({
6082
+ "src/graph/constants.ts"() {
6083
+ "use strict";
6084
+ MAX_PATTERNS_PER_EDGE = 10;
6085
+ CLUSTER_SPLIT_THRESHOLD = 15;
6086
+ COMMON_PATH_PREFIXES = /* @__PURE__ */ new Set(["api", "v1", "v2", "v3", "v4"]);
6087
+ PENDING_BUFFER_MAX = 500;
6088
+ PENDING_EVICTION_TARGET = 200;
6089
+ PENDING_TTL_MS = 6e4;
6090
+ }
6091
+ });
6092
+
6093
+ // src/graph/graph-builder.ts
6094
+ function shouldSkipRequest(req) {
6095
+ if (req.isStatic || req.isHealthCheck) return true;
6096
+ if (isDashboardRequest(req.path)) return true;
6097
+ return false;
6098
+ }
6099
+ function extractHostname(url) {
6100
+ try {
6101
+ const parsed = new URL(url);
6102
+ const host = parsed.hostname;
6103
+ if (host === "localhost" || host === "127.0.0.1" || host === "::1")
6104
+ return null;
6105
+ return host;
6106
+ } catch {
6107
+ return null;
6108
+ }
6109
+ }
6110
+ function makeEdgeId(source, target) {
6111
+ return `${source} -> ${target}`;
6112
+ }
6113
+ function upsertNode(graph, id, type, label, now) {
6114
+ let node = graph.nodes.get(id);
6115
+ if (!node) {
6116
+ node = {
6117
+ id,
6118
+ type,
6119
+ label,
6120
+ stats: {
6121
+ requestCount: 0,
6122
+ avgLatencyMs: 0,
6123
+ errorRate: 0,
6124
+ avgQueryCount: 0,
6125
+ lastSeenAt: now,
6126
+ firstSeenAt: now
6127
+ }
6128
+ };
6129
+ graph.nodes.set(id, node);
6130
+ }
6131
+ node.stats.lastSeenAt = now;
6132
+ return node;
6133
+ }
6134
+ function upsertEdge(graph, source, target, type, now) {
6135
+ const id = makeEdgeId(source, target);
6136
+ let edge = graph.edges.get(id);
6137
+ if (!edge) {
6138
+ edge = {
6139
+ id,
6140
+ source,
6141
+ target,
6142
+ type,
6143
+ stats: {
6144
+ frequency: 0,
6145
+ avgLatencyMs: 0,
6146
+ lastSeenAt: now,
6147
+ firstSeenAt: now
6148
+ }
6149
+ };
6150
+ graph.edges.set(id, edge);
6151
+ }
6152
+ edge.stats.lastSeenAt = now;
6153
+ return edge;
6154
+ }
6155
+ function updateRollingAvg(current, newValue, count) {
6156
+ return Math.round(current + (newValue - current) / count);
6157
+ }
6158
+ var GraphBuilder;
6159
+ var init_graph_builder = __esm({
6160
+ "src/graph/graph-builder.ts"() {
6161
+ "use strict";
6162
+ init_endpoint();
6163
+ init_normalize();
6164
+ init_router();
6165
+ init_categorize();
6166
+ init_constants2();
6167
+ GraphBuilder = class {
6168
+ constructor(bus, requestStore) {
6169
+ this.bus = bus;
6170
+ this.requestStore = requestStore;
6171
+ this.graph = {
6172
+ nodes: /* @__PURE__ */ new Map(),
6173
+ edges: /* @__PURE__ */ new Map(),
6174
+ metadata: { totalObservations: 0, lastUpdatedAt: Date.now() }
6175
+ };
6176
+ /**
6177
+ * Buffered telemetry events waiting for their parent request to complete.
6178
+ * Key = parentRequestId, value = pending queries/fetches.
6179
+ */
6180
+ this.pending = /* @__PURE__ */ new Map();
6181
+ /** Accumulated request categories per endpoint node. */
6182
+ this.nodeCategories = /* @__PURE__ */ new Map();
6183
+ /** Latest analysis snapshot — refreshed on every analysis:updated event. */
6184
+ this.latestAnalysis = null;
6185
+ this.cleanupUnsubs = [];
6186
+ }
6187
+ start() {
6188
+ this.cleanupUnsubs.push(
6189
+ this.bus.on("request:completed", (req) => this.handleRequest(req)),
6190
+ this.bus.on("telemetry:query", (q) => this.handleQuery(q)),
6191
+ this.bus.on("telemetry:fetch", (f) => this.handleFetch(f)),
6192
+ this.bus.on(
6193
+ "analysis:updated",
6194
+ (update) => this.handleAnalysisUpdate(update)
6195
+ )
6196
+ );
6197
+ }
6198
+ stop() {
6199
+ for (const unsub of this.cleanupUnsubs) unsub();
6200
+ this.cleanupUnsubs.length = 0;
6201
+ }
6202
+ getGraph() {
6203
+ return this.graph;
6204
+ }
6205
+ /**
6206
+ * Enrich endpoint nodes with p95 from an external metrics source.
6207
+ * Called by the API handler which has access to MetricsStore.
6208
+ */
6209
+ enrichWithMetrics(getP95) {
6210
+ for (const node of this.graph.nodes.values()) {
6211
+ if (node.type !== "endpoint") continue;
6212
+ const key = node.label;
6213
+ const p95 = getP95(key);
6214
+ if (p95 !== void 0) {
6215
+ if (!node.annotations) node.annotations = {};
6216
+ node.annotations.p95Ms = Math.round(p95);
6217
+ }
6218
+ }
6219
+ }
6220
+ getApiResponse(options) {
6221
+ const grouping = options?.grouping ?? "path";
6222
+ const clusters = this.computeClusters(grouping);
6223
+ if (options?.level === "endpoints") {
6224
+ return this.getEndpointView();
6225
+ }
6226
+ if (options?.node) {
6227
+ return this.getNodeNeighborhood(options.node, clusters);
6228
+ }
6229
+ if (options?.cluster) {
6230
+ return this.getClusterExpanded(options.cluster, clusters);
6231
+ }
6232
+ return this.getClusterView(clusters);
6233
+ }
6234
+ clear() {
6235
+ this.graph.nodes.clear();
6236
+ this.graph.edges.clear();
6237
+ this.graph.metadata.totalObservations = 0;
6238
+ this.pending.clear();
6239
+ this.nodeCategories.clear();
6240
+ this.latestAnalysis = null;
6241
+ }
6242
+ handleAnalysisUpdate(update) {
6243
+ this.latestAnalysis = update;
6244
+ this.enrichNodesFromAnalysis();
6245
+ }
6246
+ enrichNodesFromAnalysis() {
6247
+ if (!this.latestAnalysis) return;
6248
+ const { findings, insights, issues } = this.latestAnalysis;
6249
+ for (const node of this.graph.nodes.values()) {
6250
+ if (node.type !== "endpoint") continue;
6251
+ if (node.annotations) {
6252
+ node.annotations.securityFindings = void 0;
6253
+ node.annotations.insights = void 0;
6254
+ node.annotations.openIssueCount = void 0;
6255
+ }
6256
+ }
6257
+ for (const f of findings) {
6258
+ if (!f.endpoint) continue;
6259
+ const nodeId = `endpoint:${f.endpoint}`;
6260
+ const node = this.graph.nodes.get(nodeId);
6261
+ if (!node) continue;
6262
+ if (!node.annotations) node.annotations = {};
6263
+ if (!node.annotations.securityFindings)
6264
+ node.annotations.securityFindings = [];
6265
+ const existing = node.annotations.securityFindings.find(
6266
+ (s) => s.rule === f.rule
6267
+ );
6268
+ if (existing) {
6269
+ existing.count = f.count;
6270
+ } else {
6271
+ node.annotations.securityFindings.push({
6272
+ rule: f.rule,
6273
+ severity: f.severity,
6274
+ title: f.title,
6275
+ count: f.count
6276
+ });
6277
+ }
6278
+ }
6279
+ const endpointNodesByLabel = /* @__PURE__ */ new Map();
6280
+ for (const node of this.graph.nodes.values()) {
6281
+ if (node.type === "endpoint") endpointNodesByLabel.set(node.label, node);
6282
+ }
6283
+ for (const insight of insights) {
6284
+ if (!insight.nav) continue;
6285
+ for (const [label, node] of endpointNodesByLabel) {
6286
+ if (insight.title.includes(label) || insight.desc.includes(label)) {
6287
+ if (!node.annotations) node.annotations = {};
6288
+ if (!node.annotations.insights) node.annotations.insights = [];
6289
+ if (!node.annotations.insights.some(
6290
+ (i) => i.type === insight.type && i.title === insight.title
6291
+ )) {
6292
+ node.annotations.insights.push({
6293
+ type: insight.type,
6294
+ severity: insight.severity,
6295
+ title: insight.title
6296
+ });
6297
+ }
6298
+ }
6299
+ }
6300
+ }
6301
+ for (const si of issues) {
6302
+ if (!si.issue.endpoint) continue;
6303
+ const nodeId = `endpoint:${si.issue.endpoint}`;
6304
+ const node = this.graph.nodes.get(nodeId);
6305
+ if (!node) continue;
6306
+ if (si.state === "open" || si.state === "regressed") {
6307
+ if (!node.annotations) node.annotations = {};
6308
+ node.annotations.openIssueCount = (node.annotations.openIssueCount ?? 0) + 1;
6309
+ }
6310
+ }
6311
+ for (const insight of insights) {
6312
+ if (insight.type !== "n1" && insight.type !== "redundant-query") continue;
6313
+ for (const edge of this.graph.edges.values()) {
6314
+ if (edge.type !== "reads" && edge.type !== "writes") continue;
6315
+ const sourceNode = this.graph.nodes.get(edge.source);
6316
+ if (sourceNode?.annotations?.insights?.some(
6317
+ (i) => i.type === insight.type
6318
+ )) {
6319
+ if (!edge.annotations) edge.annotations = {};
6320
+ edge.annotations.hasIssue = true;
6321
+ }
6322
+ }
6323
+ }
6324
+ }
6325
+ handleRequest(req) {
6326
+ if (shouldSkipRequest(req)) return;
6327
+ const now = Date.now();
6328
+ const endpointKey = getEndpointKey(req.method, req.path);
6329
+ const nodeId = `endpoint:${endpointKey}`;
6330
+ const node = upsertNode(this.graph, nodeId, "endpoint", endpointKey, now);
6331
+ node.stats.requestCount++;
6332
+ const category = detectCategory(req);
6333
+ let cats = this.nodeCategories.get(nodeId);
6334
+ if (!cats) {
6335
+ cats = /* @__PURE__ */ new Set();
6336
+ this.nodeCategories.set(nodeId, cats);
6337
+ }
6338
+ cats.add(category);
6339
+ if (!node.annotations) node.annotations = {};
6340
+ node.annotations.categories = [...cats];
6341
+ node.annotations.isMiddleware = cats.has("middleware");
6342
+ if (!node.annotations.hasAuth) {
6343
+ node.annotations.hasAuth = cats.has("auth-check") || cats.has("auth-handshake") || hasAuthCredentials(req);
6344
+ }
6345
+ node.stats.avgLatencyMs = updateRollingAvg(
6346
+ node.stats.avgLatencyMs,
6347
+ req.durationMs,
6348
+ node.stats.requestCount
6349
+ );
6350
+ const isError = req.statusCode >= 400 ? 1 : 0;
6351
+ node.stats.errorRate = node.stats.errorRate + (isError - node.stats.errorRate) / node.stats.requestCount;
6352
+ this.graph.metadata.totalObservations++;
6353
+ this.graph.metadata.lastUpdatedAt = now;
6354
+ const buffered = this.pending.get(req.id);
6355
+ if (buffered) {
6356
+ let queryCount = 0;
6357
+ for (const query of buffered.queries) {
6358
+ this.processQuery(query, nodeId, now);
6359
+ queryCount++;
6360
+ }
6361
+ for (const fetch of buffered.fetches) {
6362
+ this.processFetch(fetch, nodeId, now);
6363
+ }
6364
+ this.pending.delete(req.id);
6365
+ if (queryCount > 0) {
6366
+ node.stats.avgQueryCount = updateRollingAvg(
6367
+ node.stats.avgQueryCount,
6368
+ queryCount,
6369
+ node.stats.requestCount
6370
+ );
6371
+ }
6372
+ }
6373
+ const now2 = Date.now();
6374
+ for (const [key, entry] of this.pending) {
6375
+ if (now2 - entry.createdAt > PENDING_TTL_MS) this.pending.delete(key);
6376
+ }
6377
+ if (this.pending.size > PENDING_BUFFER_MAX) {
6378
+ const keys = [...this.pending.keys()];
6379
+ for (let i = 0; i < keys.length - PENDING_EVICTION_TARGET; i++) {
6380
+ this.pending.delete(keys[i]);
6381
+ }
6382
+ }
6383
+ }
6384
+ handleQuery(query) {
6385
+ if (!query.parentRequestId || !query.table) return;
6386
+ let pending2 = this.pending.get(query.parentRequestId);
6387
+ if (!pending2) {
6388
+ pending2 = { queries: [], fetches: [], createdAt: Date.now() };
6389
+ this.pending.set(query.parentRequestId, pending2);
6390
+ }
6391
+ pending2.queries.push(query);
6392
+ }
6393
+ handleFetch(fetch) {
6394
+ if (!fetch.parentRequestId) return;
6395
+ const hostname = extractHostname(fetch.url);
6396
+ if (!hostname) return;
6397
+ let pending2 = this.pending.get(fetch.parentRequestId);
6398
+ if (!pending2) {
6399
+ pending2 = { queries: [], fetches: [], createdAt: Date.now() };
6400
+ this.pending.set(fetch.parentRequestId, pending2);
6401
+ }
6402
+ pending2.fetches.push(fetch);
6403
+ }
6404
+ // ── Process buffered events (called after request completes) ──
6405
+ processQuery(query, endpointNodeId, now) {
6406
+ if (!query.table) return;
6407
+ const tableNodeId = `table:${query.table}`;
6408
+ upsertNode(this.graph, tableNodeId, "table", query.table, now);
6409
+ const edgeType = query.normalizedOp === "SELECT" ? "reads" : "writes";
6410
+ const edge = upsertEdge(
6411
+ this.graph,
6412
+ endpointNodeId,
6413
+ tableNodeId,
6414
+ edgeType,
6415
+ now
6416
+ );
6417
+ edge.stats.frequency++;
6418
+ edge.stats.avgLatencyMs = updateRollingAvg(
6419
+ edge.stats.avgLatencyMs,
6420
+ query.durationMs,
6421
+ edge.stats.frequency
6422
+ );
6423
+ if (query.sql) {
6424
+ const normalized = normalizeQueryParams(query.sql);
6425
+ if (normalized) {
6426
+ if (!edge.patterns) edge.patterns = [];
6427
+ if (!edge.patterns.includes(normalized) && edge.patterns.length < MAX_PATTERNS_PER_EDGE) {
6428
+ edge.patterns.push(normalized);
6429
+ }
6430
+ }
6431
+ }
6432
+ }
6433
+ processFetch(fetch, endpointNodeId, now) {
6434
+ const hostname = extractHostname(fetch.url);
6435
+ if (!hostname) return;
6436
+ const externalNodeId = `external:${hostname}`;
6437
+ upsertNode(this.graph, externalNodeId, "external", hostname, now);
6438
+ const edge = upsertEdge(
6439
+ this.graph,
6440
+ endpointNodeId,
6441
+ externalNodeId,
6442
+ "fetches",
6443
+ now
6444
+ );
6445
+ edge.stats.frequency++;
6446
+ edge.stats.avgLatencyMs = updateRollingAvg(
6447
+ edge.stats.avgLatencyMs,
6448
+ fetch.durationMs,
6449
+ edge.stats.frequency
6450
+ );
6451
+ }
6452
+ // ── Clustering ──
6453
+ computeClusters(strategy = "path") {
6454
+ const endpointNodes = [...this.graph.nodes.values()].filter(
6455
+ (n) => n.type === "endpoint"
6456
+ );
6457
+ if (strategy === "auth-boundary") {
6458
+ return this.clusterByAuthBoundary(endpointNodes);
6459
+ }
6460
+ if (strategy === "data-domain") {
6461
+ return this.clusterByDataDomain(endpointNodes);
6462
+ }
6463
+ return this.clusterByPath(endpointNodes);
6464
+ }
6465
+ clusterByPath(endpointNodes) {
6466
+ const groups = /* @__PURE__ */ new Map();
6467
+ for (const node of endpointNodes) {
6468
+ const parts = node.label.split(" ");
6469
+ const path = parts[1] || parts[0];
6470
+ const segments = path.split("/").filter(Boolean);
6471
+ let i = 0;
6472
+ while (i < segments.length && COMMON_PATH_PREFIXES.has(segments[i])) {
6473
+ i++;
6474
+ }
6475
+ const groupKey = segments[i] || segments[0] || "root";
6476
+ if (!groups.has(groupKey)) groups.set(groupKey, []);
6477
+ groups.get(groupKey).push(node.id);
6478
+ }
6479
+ const clusters = /* @__PURE__ */ new Map();
6480
+ for (const [key, children] of groups) {
6481
+ if (children.length > CLUSTER_SPLIT_THRESHOLD) {
6482
+ const subGroups = /* @__PURE__ */ new Map();
6483
+ for (const childId of children) {
6484
+ const node = this.graph.nodes.get(childId);
6485
+ const parts = node.label.split(" ");
6486
+ const path = parts[1] || parts[0];
6487
+ const segments = path.split("/").filter(Boolean);
6488
+ let idx = segments.indexOf(key);
6489
+ if (idx === -1) idx = 0;
6490
+ const subKey = `${key}/${segments[idx + 1] || "root"}`;
6491
+ if (!subGroups.has(subKey)) subGroups.set(subKey, []);
6492
+ subGroups.get(subKey).push(childId);
6493
+ }
6494
+ for (const [subKey, subChildren] of subGroups) {
6495
+ clusters.set(subKey, this.buildCluster(subKey, subChildren));
6496
+ }
6497
+ } else {
6498
+ clusters.set(key, this.buildCluster(key, children));
6499
+ }
6500
+ }
6501
+ return clusters;
6502
+ }
6503
+ clusterByAuthBoundary(endpointNodes) {
6504
+ const authed = [];
6505
+ const unauthed = [];
6506
+ for (const node of endpointNodes) {
6507
+ if (node.annotations?.hasAuth) {
6508
+ authed.push(node.id);
6509
+ } else {
6510
+ unauthed.push(node.id);
6511
+ }
6512
+ }
6513
+ const clusters = /* @__PURE__ */ new Map();
6514
+ if (authed.length > 0)
6515
+ clusters.set("authenticated", this.buildCluster("authenticated", authed));
6516
+ if (unauthed.length > 0)
6517
+ clusters.set(
6518
+ "unauthenticated",
6519
+ this.buildCluster("unauthenticated", unauthed)
6520
+ );
6521
+ return clusters;
6522
+ }
6523
+ clusterByDataDomain(endpointNodes) {
6524
+ const endpointTables = /* @__PURE__ */ new Map();
6525
+ for (const edge of this.graph.edges.values()) {
6526
+ if (edge.type !== "reads" && edge.type !== "writes") continue;
6527
+ const tableLabel = this.graph.nodes.get(edge.target)?.label;
6528
+ if (!tableLabel) continue;
6529
+ let tables = endpointTables.get(edge.source);
6530
+ if (!tables) {
6531
+ tables = /* @__PURE__ */ new Set();
6532
+ endpointTables.set(edge.source, tables);
6533
+ }
6534
+ tables.add(tableLabel);
6535
+ }
6536
+ const groups = /* @__PURE__ */ new Map();
6537
+ for (const node of endpointNodes) {
6538
+ const tables = endpointTables.get(node.id);
6539
+ const groupKey = tables && tables.size > 0 ? [...tables].sort().join("+") : "no-db";
6540
+ if (!groups.has(groupKey)) groups.set(groupKey, []);
6541
+ groups.get(groupKey).push(node.id);
6542
+ }
6543
+ const clusters = /* @__PURE__ */ new Map();
6544
+ for (const [key, children] of groups) {
6545
+ clusters.set(key, this.buildCluster(key, children));
6546
+ }
6547
+ return clusters;
6548
+ }
6549
+ buildCluster(key, children) {
6550
+ let totalLatency = 0;
6551
+ let totalRequests = 0;
6552
+ let totalErrors = 0;
6553
+ let totalQueries = 0;
6554
+ let firstSeen = Infinity;
6555
+ let lastSeen = 0;
6556
+ for (const childId of children) {
6557
+ const node = this.graph.nodes.get(childId);
6558
+ if (!node) continue;
6559
+ totalRequests += node.stats.requestCount;
6560
+ totalLatency += node.stats.avgLatencyMs * node.stats.requestCount;
6561
+ totalErrors += node.stats.errorRate * node.stats.requestCount;
6562
+ totalQueries += node.stats.avgQueryCount * node.stats.requestCount;
6563
+ firstSeen = Math.min(firstSeen, node.stats.firstSeenAt);
6564
+ lastSeen = Math.max(lastSeen, node.stats.lastSeenAt);
6565
+ }
6566
+ return {
6567
+ id: `cluster:${key}`,
6568
+ label: key,
6569
+ children,
6570
+ stats: {
6571
+ requestCount: totalRequests,
6572
+ avgLatencyMs: totalRequests > 0 ? Math.round(totalLatency / totalRequests) : 0,
6573
+ errorRate: totalRequests > 0 ? totalErrors / totalRequests : 0,
6574
+ avgQueryCount: totalRequests > 0 ? Math.round(totalQueries / totalRequests * 10) / 10 : 0,
6575
+ lastSeenAt: lastSeen || Date.now(),
6576
+ firstSeenAt: firstSeen === Infinity ? Date.now() : firstSeen
6577
+ }
6578
+ };
6579
+ }
6580
+ // ── API response builders ──
6581
+ getEndpointView() {
6582
+ const allNodes = [...this.graph.nodes.values()];
6583
+ const allEdges = [...this.graph.edges.values()];
6584
+ return {
6585
+ nodes: allNodes,
6586
+ edges: allEdges,
6587
+ clusters: [],
6588
+ metadata: this.graph.metadata
6589
+ };
6590
+ }
6591
+ getClusterView(clusters) {
6592
+ const clusterArr = [...clusters.values()];
6593
+ const otherNodes = [...this.graph.nodes.values()].filter(
6594
+ (n) => n.type !== "endpoint"
6595
+ );
6596
+ const endpointToCluster = /* @__PURE__ */ new Map();
6597
+ for (const c of clusterArr) {
6598
+ for (const childId of c.children) {
6599
+ endpointToCluster.set(childId, c.id);
6600
+ }
6601
+ }
6602
+ const edgeAgg = /* @__PURE__ */ new Map();
6603
+ for (const edge of this.graph.edges.values()) {
6604
+ const sourceCluster = endpointToCluster.get(edge.source) ?? edge.source;
6605
+ const target = edge.target;
6606
+ const aggId = makeEdgeId(sourceCluster, target);
6607
+ let agg = edgeAgg.get(aggId);
6608
+ if (!agg) {
6609
+ agg = {
6610
+ id: aggId,
6611
+ source: sourceCluster,
6612
+ target,
6613
+ type: edge.type,
6614
+ stats: { ...edge.stats },
6615
+ patterns: edge.patterns ? [...edge.patterns] : void 0
6616
+ };
6617
+ edgeAgg.set(aggId, agg);
6618
+ } else {
6619
+ agg.stats.frequency += edge.stats.frequency;
6620
+ agg.stats.avgLatencyMs = Math.round(
6621
+ (agg.stats.avgLatencyMs + edge.stats.avgLatencyMs) / 2
6622
+ );
6623
+ agg.stats.lastSeenAt = Math.max(
6624
+ agg.stats.lastSeenAt,
6625
+ edge.stats.lastSeenAt
6626
+ );
6627
+ agg.stats.firstSeenAt = Math.min(
6628
+ agg.stats.firstSeenAt,
6629
+ edge.stats.firstSeenAt
6630
+ );
6631
+ }
6632
+ }
6633
+ return {
6634
+ nodes: otherNodes,
6635
+ edges: [...edgeAgg.values()],
6636
+ clusters: clusterArr,
6637
+ metadata: this.graph.metadata
6638
+ };
6639
+ }
6640
+ getClusterExpanded(clusterId, clusters) {
6641
+ const clusterKey = clusterId.startsWith("cluster:") ? clusterId.slice(8) : clusterId;
6642
+ const cluster = clusters.get(clusterKey);
6643
+ if (!cluster) return this.getClusterView(clusters);
6644
+ const childNodes = [];
6645
+ const connectedNodeIds = /* @__PURE__ */ new Set();
6646
+ const relevantEdges = [];
6647
+ for (const childId of cluster.children) {
6648
+ const node = this.graph.nodes.get(childId);
6649
+ if (node) childNodes.push(node);
6650
+ for (const edge of this.graph.edges.values()) {
6651
+ if (edge.source === childId || edge.target === childId) {
6652
+ relevantEdges.push(edge);
6653
+ if (edge.source !== childId) connectedNodeIds.add(edge.source);
6654
+ if (edge.target !== childId) connectedNodeIds.add(edge.target);
6655
+ }
6656
+ }
6657
+ }
6658
+ for (const nodeId of connectedNodeIds) {
6659
+ if (!cluster.children.includes(nodeId)) {
6660
+ const node = this.graph.nodes.get(nodeId);
6661
+ if (node) childNodes.push(node);
6662
+ }
6663
+ }
6664
+ return {
6665
+ nodes: childNodes,
6666
+ edges: relevantEdges,
6667
+ clusters: [cluster],
6668
+ metadata: this.graph.metadata
6669
+ };
6670
+ }
6671
+ getNodeNeighborhood(nodeId, clusters) {
6672
+ const centerNode = this.graph.nodes.get(nodeId);
6673
+ if (!centerNode) return this.getClusterView(clusters);
6674
+ const nodes = [centerNode];
6675
+ const edges = [];
6676
+ for (const edge of this.graph.edges.values()) {
6677
+ if (edge.source === nodeId || edge.target === nodeId) {
6678
+ edges.push(edge);
6679
+ const otherId = edge.source === nodeId ? edge.target : edge.source;
6680
+ const otherNode = this.graph.nodes.get(otherId);
6681
+ if (otherNode) nodes.push(otherNode);
6682
+ }
6683
+ }
6684
+ return {
6685
+ nodes,
6686
+ edges,
6687
+ clusters: [],
6688
+ metadata: this.graph.metadata
6689
+ };
6690
+ }
6691
+ };
6692
+ }
6693
+ });
6694
+
5670
6695
  // src/output/terminal.ts
5671
6696
  import pc from "picocolors";
5672
6697
  function print(line) {
@@ -6182,6 +7207,9 @@ async function doSetup() {
6182
7207
  hooks_installed: ["fetch", "console", "error"],
6183
7208
  setup_duration_ms: setupDurationMs
6184
7209
  });
7210
+ const graphBuilder = new GraphBuilder(bus, stores.requestStore);
7211
+ graphBuilder.start();
7212
+ services.graphBuilder = graphBuilder;
6185
7213
  const dataDir = getProjectDataDir(cwd);
6186
7214
  const analysisServices = startAnalysis(bus, stores, dataDir, services);
6187
7215
  const config = {
@@ -6252,6 +7280,7 @@ var init_setup = __esm({
6252
7280
  init_store();
6253
7281
  init_issue_store();
6254
7282
  init_engine();
7283
+ init_graph_builder();
6255
7284
  init_terminal();
6256
7285
  init_src();
6257
7286
  init_constants();