@flrande/bak-extension 0.6.15 → 0.6.17

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.
package/src/content.ts CHANGED
@@ -2,6 +2,9 @@ import type {
2
2
  AccessibilityNode,
3
3
  ConsoleEntry,
4
4
  ElementMapItem,
5
+ InspectPageDateControl,
6
+ InspectPageModeGroup,
7
+ InspectPageModeOption,
5
8
  Locator,
6
9
  NetworkEntry,
7
10
  PageDomSummary,
@@ -25,6 +28,7 @@ import {
25
28
  sampleValue,
26
29
  type InlineJsonInspectionSource
27
30
  } from './dynamic-data-tools.js';
31
+ import { buildNetworkEntryDerivedFields, clampNetworkListLimit, networkEntryMatchesFilters } from './network-tools.js';
28
32
  import { inferSafeName, redactElementText, redactHtmlSnapshot, type RedactTextOptions } from './privacy.js';
29
33
  import { unsupportedLocatorHint } from './limitations.js';
30
34
 
@@ -1619,30 +1623,22 @@ function networkSnapshotEntries(): NetworkEntry[] {
1619
1623
  }
1620
1624
 
1621
1625
  function filterNetworkEntries(params: Record<string, unknown>): NetworkEntry[] {
1622
- const urlIncludes = typeof params.urlIncludes === 'string' ? params.urlIncludes : '';
1623
- const method = typeof params.method === 'string' ? params.method.toUpperCase() : '';
1624
- const status = typeof params.status === 'number' ? params.status : undefined;
1625
- const sinceTs = typeof params.sinceTs === 'number' ? params.sinceTs : undefined;
1626
- const limit = typeof params.limit === 'number' ? Math.max(1, Math.min(500, Math.floor(params.limit))) : 50;
1627
-
1628
- return networkSnapshotEntries()
1629
- .filter((entry) => {
1630
- if (typeof sinceTs === 'number' && entry.ts < sinceTs) {
1631
- return false;
1632
- }
1633
- if (urlIncludes && !entry.url.includes(urlIncludes)) {
1634
- return false;
1635
- }
1636
- if (method && entry.method.toUpperCase() !== method) {
1637
- return false;
1638
- }
1639
- if (typeof status === 'number' && entry.status !== status) {
1640
- return false;
1641
- }
1642
- return true;
1643
- })
1644
- .slice(-limit)
1645
- .reverse();
1626
+ const limit = clampNetworkListLimit(typeof params.limit === 'number' ? params.limit : undefined, 50);
1627
+ const ordered = networkSnapshotEntries()
1628
+ .map((entry) => ({ ...entry, ...buildNetworkEntryDerivedFields(entry) }))
1629
+ .filter((entry) =>
1630
+ networkEntryMatchesFilters(entry, {
1631
+ urlIncludes: typeof params.urlIncludes === 'string' ? params.urlIncludes : undefined,
1632
+ status: typeof params.status === 'number' ? params.status : undefined,
1633
+ method: typeof params.method === 'string' ? params.method : undefined,
1634
+ domain: typeof params.domain === 'string' ? params.domain : undefined,
1635
+ resourceType: typeof params.resourceType === 'string' ? params.resourceType : undefined,
1636
+ kind: typeof params.kind === 'string' ? (params.kind as NetworkEntry['kind']) : undefined,
1637
+ sinceTs: typeof params.sinceTs === 'number' ? params.sinceTs : undefined
1638
+ })
1639
+ )
1640
+ .slice(-limit);
1641
+ return params.tail === true ? ordered : ordered.reverse();
1646
1642
  }
1647
1643
 
1648
1644
  function filterNetworkEntrySections(entry: NetworkEntry, include: unknown): NetworkEntry {
@@ -1662,12 +1658,26 @@ function filterNetworkEntrySections(entry: NetworkEntry, include: unknown): Netw
1662
1658
  delete clone.requestHeaders;
1663
1659
  delete clone.requestBodyPreview;
1664
1660
  delete clone.requestBodyTruncated;
1661
+ if (clone.preview) {
1662
+ clone.preview = { ...clone.preview };
1663
+ delete clone.preview.request;
1664
+ if (!clone.preview.query && !clone.preview.request && !clone.preview.response) {
1665
+ delete clone.preview;
1666
+ }
1667
+ }
1665
1668
  }
1666
1669
  if (!sections.has('response')) {
1667
1670
  delete clone.responseHeaders;
1668
1671
  delete clone.responseBodyPreview;
1669
1672
  delete clone.responseBodyTruncated;
1670
1673
  delete clone.binary;
1674
+ if (clone.preview) {
1675
+ clone.preview = { ...clone.preview };
1676
+ delete clone.preview.response;
1677
+ if (!clone.preview.query && !clone.preview.request && !clone.preview.response) {
1678
+ delete clone.preview;
1679
+ }
1680
+ }
1671
1681
  }
1672
1682
  return clone;
1673
1683
  }
@@ -1936,7 +1946,7 @@ function describeTables(): TableHandle[] {
1936
1946
  for (const [index, table] of htmlTables.entries()) {
1937
1947
  const handle: TableHandle = {
1938
1948
  id: buildTableId(table.closest('.dataTables_wrapper') ? 'dataTables' : 'html', index),
1939
- name: (table.getAttribute('aria-label') || table.getAttribute('data-testid') || table.id || `table-${index + 1}`).trim(),
1949
+ label: (table.getAttribute('aria-label') || table.getAttribute('data-testid') || table.id || `table-${index + 1}`).trim(),
1940
1950
  kind: table.closest('.dataTables_wrapper') ? 'dataTables' : 'html',
1941
1951
  selector: table.id ? `#${table.id}` : undefined,
1942
1952
  rowCount: table.querySelectorAll('tbody tr').length || table.querySelectorAll('tr').length,
@@ -1949,7 +1959,7 @@ function describeTables(): TableHandle[] {
1949
1959
  const kind: TableHandle['kind'] = grid.className.includes('ag-') ? 'ag-grid' : 'aria-grid';
1950
1960
  const handle: TableHandle = {
1951
1961
  id: buildTableId(kind, index),
1952
- name: (grid.getAttribute('aria-label') || grid.getAttribute('data-testid') || grid.id || `grid-${index + 1}`).trim(),
1962
+ label: (grid.getAttribute('aria-label') || grid.getAttribute('data-testid') || grid.id || `grid-${index + 1}`).trim(),
1953
1963
  kind,
1954
1964
  selector: grid.id ? `#${grid.id}` : undefined,
1955
1965
  rowCount: gridRowNodes(grid).length,
@@ -1962,7 +1972,13 @@ function describeTables(): TableHandle[] {
1962
1972
 
1963
1973
  function resolveTable(handleId: string): { table: TableHandle; element: Element | null } | null {
1964
1974
  const tables = describeTables();
1965
- const handle = tables.find((candidate) => candidate.id === handleId);
1975
+ const normalizedHandleId = handleId.trim().toLowerCase();
1976
+ const handle = tables.find((candidate) => {
1977
+ if (candidate.id === handleId) {
1978
+ return true;
1979
+ }
1980
+ return typeof candidate.label === 'string' && candidate.label.trim().toLowerCase() === normalizedHandleId;
1981
+ });
1966
1982
  if (!handle) {
1967
1983
  return null;
1968
1984
  }
@@ -2635,6 +2651,225 @@ function collectInlineJsonSources(root: ParentNode): InlineJsonInspectionSource[
2635
2651
  .filter((item): item is InlineJsonInspectionSource => item !== null);
2636
2652
  }
2637
2653
 
2654
+ const MODE_OPTION_PATTERN = /\b(latest|historical|history|archive|archived|live|intraday|today|yesterday|session|completed)\b/i;
2655
+
2656
+ function selectorHintForElement(element: Element): string {
2657
+ if (element instanceof HTMLElement && element.id) {
2658
+ return `#${element.id}`;
2659
+ }
2660
+ if (element instanceof HTMLElement && element.getAttribute('name')) {
2661
+ return `${element.tagName.toLowerCase()}[name="${element.getAttribute('name')}"]`;
2662
+ }
2663
+ const role = element.getAttribute('role');
2664
+ if (role) {
2665
+ return `${element.tagName.toLowerCase()}[role="${role}"]`;
2666
+ }
2667
+ return element.tagName.toLowerCase();
2668
+ }
2669
+
2670
+ function cleanControlText(value: string | null | undefined): string | undefined {
2671
+ if (typeof value !== 'string') {
2672
+ return undefined;
2673
+ }
2674
+ const normalized = value.replace(/\s+/g, ' ').trim();
2675
+ return normalized.length > 0 ? normalized : undefined;
2676
+ }
2677
+
2678
+ function extractElementLabel(element: Element): string | undefined {
2679
+ if (element instanceof HTMLInputElement && element.labels && element.labels.length > 0) {
2680
+ return cleanControlText(element.labels[0]?.textContent);
2681
+ }
2682
+ const ariaLabel = cleanControlText(element.getAttribute('aria-label'));
2683
+ if (ariaLabel) {
2684
+ return ariaLabel;
2685
+ }
2686
+ if (element instanceof HTMLElement && element.id) {
2687
+ const label = document.querySelector(`label[for="${CSS.escape(element.id)}"]`);
2688
+ if (label) {
2689
+ return cleanControlText(label.textContent);
2690
+ }
2691
+ }
2692
+ const fieldset = element.closest('fieldset');
2693
+ if (fieldset) {
2694
+ const legend = fieldset.querySelector('legend');
2695
+ const legendText = cleanControlText(legend?.textContent);
2696
+ if (legendText) {
2697
+ return legendText;
2698
+ }
2699
+ }
2700
+ return cleanControlText(element.parentElement?.getAttribute('aria-label') ?? element.parentElement?.getAttribute('data-label'));
2701
+ }
2702
+
2703
+ function inferModeOption(element: Element): InspectPageModeOption | null {
2704
+ const label =
2705
+ cleanControlText(
2706
+ element instanceof HTMLInputElement && element.type === 'radio'
2707
+ ? element.labels?.[0]?.textContent ?? element.value
2708
+ : element instanceof HTMLOptionElement
2709
+ ? element.textContent ?? element.value
2710
+ : element.textContent ?? element.getAttribute('value') ?? element.getAttribute('aria-label')
2711
+ ) ?? cleanControlText(element.getAttribute('value'));
2712
+ if (!label || !MODE_OPTION_PATTERN.test(label)) {
2713
+ return null;
2714
+ }
2715
+ const value =
2716
+ cleanControlText(
2717
+ element instanceof HTMLInputElement || element instanceof HTMLOptionElement
2718
+ ? element.value
2719
+ : element.getAttribute('value') ?? label
2720
+ ) ?? label;
2721
+ const selected =
2722
+ (element instanceof HTMLOptionElement && element.selected) ||
2723
+ (element instanceof HTMLInputElement && element.checked) ||
2724
+ element.getAttribute('aria-selected') === 'true' ||
2725
+ element.getAttribute('aria-pressed') === 'true' ||
2726
+ element.classList.contains('active') ||
2727
+ element.classList.contains('selected') ||
2728
+ element.classList.contains('current');
2729
+ return {
2730
+ label,
2731
+ value,
2732
+ selected
2733
+ };
2734
+ }
2735
+
2736
+ function buildModeGroup(
2737
+ elements: Element[],
2738
+ controlType: InspectPageModeGroup['controlType'],
2739
+ container?: Element | null
2740
+ ): InspectPageModeGroup | null {
2741
+ const options = elements.map((element) => inferModeOption(element)).filter((item): item is InspectPageModeOption => item !== null);
2742
+ const deduped = options.filter((option, index, array) => array.findIndex((candidate) => candidate.label === option.label) === index);
2743
+ if (deduped.length < 2) {
2744
+ return null;
2745
+ }
2746
+ return {
2747
+ controlType,
2748
+ label: container ? extractElementLabel(container) : undefined,
2749
+ selectorHint: container ? selectorHintForElement(container) : selectorHintForElement(elements[0]!),
2750
+ options: deduped
2751
+ };
2752
+ }
2753
+
2754
+ function collectModeGroups(root: ParentNode): InspectPageModeGroup[] {
2755
+ const groups: InspectPageModeGroup[] = [];
2756
+ const handled = new Set<Element>();
2757
+
2758
+ for (const select of Array.from(root.querySelectorAll('select'))) {
2759
+ const options = Array.from(select.querySelectorAll('option'));
2760
+ const group = buildModeGroup(options, 'select', select);
2761
+ if (!group) {
2762
+ continue;
2763
+ }
2764
+ options.forEach((option) => handled.add(option));
2765
+ groups.push(group);
2766
+ }
2767
+
2768
+ for (const container of Array.from(root.querySelectorAll('[role="tablist"], [role="radiogroup"], fieldset'))) {
2769
+ const role = container.getAttribute('role');
2770
+ const elements =
2771
+ role === 'tablist'
2772
+ ? Array.from(container.querySelectorAll('[role="tab"]'))
2773
+ : role === 'radiogroup'
2774
+ ? Array.from(container.querySelectorAll('[role="radio"], input[type="radio"]'))
2775
+ : Array.from(container.querySelectorAll('input[type="radio"], button'));
2776
+ const untouched = elements.filter((element) => !handled.has(element));
2777
+ const group = buildModeGroup(untouched, role === 'tablist' ? 'tabs' : role === 'radiogroup' ? 'radio' : 'buttons', container);
2778
+ if (!group) {
2779
+ continue;
2780
+ }
2781
+ untouched.forEach((element) => handled.add(element));
2782
+ groups.push(group);
2783
+ }
2784
+
2785
+ const candidateParents = new Set<HTMLElement>();
2786
+ for (const button of Array.from(root.querySelectorAll('button'))) {
2787
+ if (button.parentElement) {
2788
+ candidateParents.add(button.parentElement);
2789
+ }
2790
+ }
2791
+ for (const parent of candidateParents) {
2792
+ const directButtons = Array.from(parent.children).filter((child): child is HTMLButtonElement => child instanceof HTMLButtonElement);
2793
+ if (directButtons.length < 2 || directButtons.length > 6 || directButtons.every((button) => handled.has(button))) {
2794
+ continue;
2795
+ }
2796
+ const group = buildModeGroup(
2797
+ directButtons.filter((button) => !handled.has(button)),
2798
+ 'buttons',
2799
+ parent
2800
+ );
2801
+ if (!group) {
2802
+ continue;
2803
+ }
2804
+ directButtons.forEach((button) => handled.add(button));
2805
+ groups.push(group);
2806
+ }
2807
+
2808
+ return groups.slice(0, 6);
2809
+ }
2810
+
2811
+ function normalizeDateValue(value: string | null | undefined): string | undefined {
2812
+ const normalized = cleanControlText(value);
2813
+ if (!normalized) {
2814
+ return undefined;
2815
+ }
2816
+ return Number.isFinite(Date.parse(normalized)) || /^\d{4}-\d{2}-\d{2}$/.test(normalized) ? normalized : undefined;
2817
+ }
2818
+
2819
+ function collectDateControls(root: ParentNode): InspectPageDateControl[] {
2820
+ const controls: InspectPageDateControl[] = [];
2821
+
2822
+ for (const input of Array.from(root.querySelectorAll<HTMLInputElement>('input[type="date"], input[type="datetime-local"], input[type="month"], input[type="week"]'))) {
2823
+ controls.push({
2824
+ controlType: 'input',
2825
+ label: extractElementLabel(input),
2826
+ selectorHint: selectorHintForElement(input),
2827
+ value: normalizeDateValue(input.value),
2828
+ min: normalizeDateValue(input.min),
2829
+ max: normalizeDateValue(input.max),
2830
+ dataMaxDate: normalizeDateValue(input.getAttribute('data-max-date'))
2831
+ });
2832
+ }
2833
+
2834
+ for (const select of Array.from(root.querySelectorAll('select'))) {
2835
+ const optionValues = Array.from(select.querySelectorAll('option'))
2836
+ .map((option) => normalizeDateValue(option.value) ?? normalizeDateValue(option.textContent))
2837
+ .filter((value): value is string => typeof value === 'string');
2838
+ const dataMaxDate = normalizeDateValue(select.getAttribute('data-max-date'));
2839
+ if (optionValues.length === 0 && !dataMaxDate) {
2840
+ continue;
2841
+ }
2842
+ controls.push({
2843
+ controlType: 'select',
2844
+ label: extractElementLabel(select),
2845
+ selectorHint: selectorHintForElement(select),
2846
+ value: normalizeDateValue((select as HTMLSelectElement).value),
2847
+ dataMaxDate,
2848
+ options: [...new Set(optionValues)].slice(0, 8)
2849
+ });
2850
+ }
2851
+
2852
+ for (const element of Array.from(root.querySelectorAll<HTMLElement>('[data-max-date], [data-date]'))) {
2853
+ if (element instanceof HTMLInputElement || element instanceof HTMLSelectElement) {
2854
+ continue;
2855
+ }
2856
+ const dataMaxDate = normalizeDateValue(element.getAttribute('data-max-date'));
2857
+ const value = normalizeDateValue(element.getAttribute('data-date'));
2858
+ if (!dataMaxDate && !value) {
2859
+ continue;
2860
+ }
2861
+ controls.push({
2862
+ controlType: 'dataset',
2863
+ label: extractElementLabel(element),
2864
+ selectorHint: selectorHintForElement(element),
2865
+ value,
2866
+ dataMaxDate
2867
+ });
2868
+ }
2869
+
2870
+ return controls.slice(0, 10);
2871
+ }
2872
+
2638
2873
  async function collectInspectionState(params: Record<string, unknown> = {}): Promise<Record<string, unknown>> {
2639
2874
  const rootResult = resolveRootForLocator();
2640
2875
  if (!rootResult.ok) {
@@ -2656,6 +2891,8 @@ async function collectInspectionState(params: Record<string, unknown> = {}): Pro
2656
2891
  const previewGlobals = await globalsPreview();
2657
2892
  const suspiciousGlobals = [...new Set(scripts.flatMap((script) => script.suspectedVars))].slice(0, 50);
2658
2893
  const inlineJsonSources = collectInlineJsonSources(root);
2894
+ const modeGroups = collectModeGroups(root);
2895
+ const dateControls = collectDateControls(root);
2659
2896
  return {
2660
2897
  url: metadata.url,
2661
2898
  title: metadata.title,
@@ -2675,6 +2912,8 @@ async function collectInspectionState(params: Record<string, unknown> = {}): Pro
2675
2912
  cookies: cookieMetadata(),
2676
2913
  frames: collectFrames(),
2677
2914
  inlineJsonSources,
2915
+ modeGroups,
2916
+ dateControls,
2678
2917
  tables: describeTables(),
2679
2918
  timers: {
2680
2919
  timeouts: 0,
@@ -2,8 +2,12 @@ import type {
2
2
  DynamicDataSchemaHint,
3
3
  FreshnessTimestampCategory,
4
4
  InspectPageDataCandidateProbe,
5
+ InspectPageCurrentMode,
6
+ InspectPageDateControl,
5
7
  InspectPageDataRecommendation,
6
8
  InspectPageDataResult,
9
+ InspectPageModeGroup,
10
+ InspectPagePrimaryEndpoint,
7
11
  InspectPageDataSource,
8
12
  InspectPageDataSourceMapping,
9
13
  NetworkEntry,
@@ -59,6 +63,7 @@ export interface SourceMappingInput {
59
63
  windowSources: InspectPageDataCandidateProbe[];
60
64
  inlineJsonSources: InlineJsonInspectionSource[];
61
65
  recentNetwork: NetworkEntry[];
66
+ pageUrl?: string;
62
67
  now?: number;
63
68
  }
64
69
 
@@ -67,6 +72,7 @@ export interface SourceMappingReport {
67
72
  sourceMappings: InspectPageDataSourceMapping[];
68
73
  recommendedNextActions: InspectPageDataRecommendation[];
69
74
  sourceAnalyses: DynamicSourceAnalysis[];
75
+ primaryEndpoint: InspectPagePrimaryEndpoint | null;
70
76
  }
71
77
 
72
78
  export interface ReplaySchemaMatch {
@@ -530,9 +536,9 @@ function scoreSourceMapping(table: TableAnalysis, source: DynamicSourceAnalysis,
530
536
  }
531
537
 
532
538
  const explicitReferenceHit =
533
- table.table.name.toLowerCase().includes(source.source.label.toLowerCase()) ||
539
+ table.table.label.toLowerCase().includes(source.source.label.toLowerCase()) ||
534
540
  (table.table.selector ?? '').toLowerCase().includes(source.source.label.toLowerCase()) ||
535
- source.source.label.toLowerCase().includes(table.table.name.toLowerCase());
541
+ source.source.label.toLowerCase().includes(table.table.label.toLowerCase());
536
542
  if (explicitReferenceHit) {
537
543
  basis.push({
538
544
  type: 'explicitReference',
@@ -609,8 +615,8 @@ function buildRecommendedNextActions(
609
615
  if (source.source.type === 'networkResponse') {
610
616
  const requestId = source.source.sourceId.replace(/^networkResponse:/, '');
611
617
  pushRecommendation({
612
- title: `Replay ${requestId} with table schema`,
613
- command: `bak network replay --request-id ${requestId} --mode json --with-schema auto`,
618
+ title: `Clone ${requestId} into a reusable fetch template`,
619
+ command: `bak network clone ${requestId}`,
614
620
  note: `Recent response mapped to ${mapping.tableId} with ${mapping.confidence} confidence.`
615
621
  });
616
622
  continue;
@@ -632,6 +638,122 @@ function buildRecommendedNextActions(
632
638
  return recommendations.slice(0, 6);
633
639
  }
634
640
 
641
+ function confidenceRank(confidence: InspectPageDataSourceMapping['confidence']): number {
642
+ switch (confidence) {
643
+ case 'high':
644
+ return 0;
645
+ case 'medium':
646
+ return 1;
647
+ case 'low':
648
+ return 2;
649
+ default:
650
+ return 3;
651
+ }
652
+ }
653
+
654
+ function isSameOrigin(pageUrl: string | undefined, requestUrl: string): boolean {
655
+ if (!pageUrl) {
656
+ return false;
657
+ }
658
+ try {
659
+ const page = new URL(pageUrl);
660
+ const request = new URL(requestUrl, page);
661
+ return page.origin === request.origin;
662
+ } catch {
663
+ return false;
664
+ }
665
+ }
666
+
667
+ function selectPrimaryEndpoint(
668
+ recentNetwork: NetworkEntry[],
669
+ mappings: InspectPageDataSourceMapping[],
670
+ pageUrl?: string
671
+ ): InspectPagePrimaryEndpoint | null {
672
+ const mapped = mappings
673
+ .filter((mapping) => mapping.sourceId.startsWith('networkResponse:'))
674
+ .map((mapping) => ({
675
+ mapping,
676
+ entry: recentNetwork.find((entry) => entry.id === mapping.sourceId.replace(/^networkResponse:/, ''))
677
+ }))
678
+ .filter((candidate): candidate is { mapping: InspectPageDataSourceMapping; entry: NetworkEntry } => candidate.entry !== undefined)
679
+ .sort((left, right) => {
680
+ return (
681
+ confidenceRank(left.mapping.confidence) - confidenceRank(right.mapping.confidence) ||
682
+ right.entry.ts - left.entry.ts ||
683
+ left.entry.id.localeCompare(right.entry.id)
684
+ );
685
+ })[0];
686
+
687
+ if (mapped) {
688
+ return {
689
+ requestId: mapped.entry.id,
690
+ url: mapped.entry.url,
691
+ method: mapped.entry.method,
692
+ status: mapped.entry.status,
693
+ kind: mapped.entry.kind,
694
+ resourceType: mapped.entry.resourceType,
695
+ contentType: mapped.entry.contentType,
696
+ sameOrigin: isSameOrigin(pageUrl, mapped.entry.url),
697
+ matchedTableId: mapped.mapping.tableId,
698
+ matchedSourceId: mapped.mapping.sourceId,
699
+ reason: `Mapped to ${mapped.mapping.tableId} with ${mapped.mapping.confidence} confidence`
700
+ };
701
+ }
702
+
703
+ const fallback = recentNetwork
704
+ .filter((entry) => (entry.kind === 'fetch' || entry.kind === 'xhr') && entry.status >= 200 && entry.status < 400)
705
+ .sort((left, right) => right.ts - left.ts)[0];
706
+ if (!fallback) {
707
+ return null;
708
+ }
709
+ return {
710
+ requestId: fallback.id,
711
+ url: fallback.url,
712
+ method: fallback.method,
713
+ status: fallback.status,
714
+ kind: fallback.kind,
715
+ resourceType: fallback.resourceType,
716
+ contentType: fallback.contentType,
717
+ sameOrigin: isSameOrigin(pageUrl, fallback.url),
718
+ reason: `Latest successful ${fallback.kind.toUpperCase()} request observed on the page`
719
+ };
720
+ }
721
+
722
+ export function summarizeAvailableModes(modeGroups: InspectPageModeGroup[]): string[] {
723
+ return [...new Set(modeGroups.flatMap((group) => group.options.map((option) => option.label)))];
724
+ }
725
+
726
+ export function selectCurrentMode(modeGroups: InspectPageModeGroup[]): InspectPageCurrentMode | null {
727
+ for (const group of modeGroups) {
728
+ const selected = group.options.find((option) => option.selected);
729
+ if (selected) {
730
+ return {
731
+ controlType: group.controlType,
732
+ label: selected.label,
733
+ value: selected.value,
734
+ groupLabel: group.label
735
+ };
736
+ }
737
+ }
738
+ return null;
739
+ }
740
+
741
+ export function deriveLatestArchiveDate(dateControls: InspectPageDateControl[]): string | null {
742
+ const candidates = dateControls.flatMap((control) => [
743
+ control.value,
744
+ control.min,
745
+ control.max,
746
+ control.dataMaxDate,
747
+ ...(Array.isArray(control.options) ? control.options : [])
748
+ ]);
749
+ const dated = candidates
750
+ .filter((value): value is string => typeof value === 'string')
751
+ .map((value) => ({ value, parsed: Date.parse(value) }))
752
+ .filter((item) => Number.isFinite(item.parsed))
753
+ .sort((left, right) => right.parsed - left.parsed);
754
+ return dated[0]?.value ?? null;
755
+ }
756
+
635
757
  export function buildSourceMappingReport(input: SourceMappingInput): SourceMappingReport {
636
758
  const now = typeof input.now === 'number' ? input.now : Date.now();
637
759
  const windowAnalyses = buildWindowSources(input.windowSources);
@@ -653,7 +775,8 @@ export function buildSourceMappingReport(input: SourceMappingInput): SourceMappi
653
775
  dataSources: sourceAnalyses.map((analysis) => analysis.source),
654
776
  sourceMappings,
655
777
  recommendedNextActions: buildRecommendedNextActions(input.tables, sourceMappings, sourceAnalyses),
656
- sourceAnalyses
778
+ sourceAnalyses,
779
+ primaryEndpoint: selectPrimaryEndpoint(input.recentNetwork, sourceMappings, input.pageUrl)
657
780
  };
658
781
  }
659
782
 
@@ -752,6 +875,7 @@ export function selectReplaySchemaMatch(
752
875
  }
753
876
 
754
877
  export function buildInspectPageDataResult(input: {
878
+ pageUrl?: string;
755
879
  suspiciousGlobals: string[];
756
880
  tables: TableHandle[];
757
881
  visibleTimestamps: string[];
@@ -760,19 +884,29 @@ export function buildInspectPageDataResult(input: {
760
884
  recentNetwork: NetworkEntry[];
761
885
  tableAnalyses: TableAnalysis[];
762
886
  inlineJsonSources: InlineJsonInspectionSource[];
887
+ modeGroups: InspectPageModeGroup[];
888
+ dateControls: InspectPageDateControl[];
763
889
  now?: number;
764
- }): Pick<InspectPageDataResult, 'dataSources' | 'sourceMappings' | 'recommendedNextActions'> {
890
+ }): Pick<
891
+ InspectPageDataResult,
892
+ 'dataSources' | 'sourceMappings' | 'recommendedNextActions' | 'availableModes' | 'currentMode' | 'latestArchiveDate' | 'primaryEndpoint'
893
+ > {
765
894
  const report = buildSourceMappingReport({
766
895
  tables: input.tableAnalyses,
767
896
  windowSources: input.pageDataCandidates,
768
897
  inlineJsonSources: input.inlineJsonSources,
769
898
  recentNetwork: input.recentNetwork,
899
+ pageUrl: input.pageUrl,
770
900
  now: input.now
771
901
  });
772
902
  return {
773
903
  dataSources: report.dataSources,
774
904
  sourceMappings: report.sourceMappings,
775
- recommendedNextActions: report.recommendedNextActions
905
+ recommendedNextActions: report.recommendedNextActions,
906
+ availableModes: summarizeAvailableModes(input.modeGroups),
907
+ currentMode: selectCurrentMode(input.modeGroups),
908
+ latestArchiveDate: deriveLatestArchiveDate(input.dateControls),
909
+ primaryEndpoint: report.primaryEndpoint
776
910
  };
777
911
  }
778
912