@agent-native/core 0.114.13 → 0.114.16

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 (163) hide show
  1. package/bin/agent-native.js +35 -14
  2. package/bin/launcher.js +34 -0
  3. package/corpus/README.md +2 -2
  4. package/corpus/core/CHANGELOG.md +21 -0
  5. package/corpus/core/bin/agent-native.js +35 -14
  6. package/corpus/core/bin/launcher.js +34 -0
  7. package/corpus/core/docs/content/deployment.mdx +1 -1
  8. package/corpus/core/docs/content/frames.mdx +1 -1
  9. package/corpus/core/docs/content/integrations.mdx +27 -27
  10. package/corpus/core/docs/content/pr-visual-recap.mdx +17 -13
  11. package/corpus/core/docs/content/workspace-management.mdx +1 -1
  12. package/corpus/core/package.json +2 -1
  13. package/corpus/core/src/agent/engine/builder-engine.ts +4 -0
  14. package/corpus/core/src/client/AgentPanel.tsx +10 -4
  15. package/corpus/core/src/client/ConnectBuilderCard.tsx +5 -1
  16. package/corpus/core/src/client/chat/markdown-renderer.tsx +3 -5
  17. package/corpus/core/src/client/error-format.ts +2 -1
  18. package/corpus/core/src/client/index.ts +1 -0
  19. package/corpus/core/src/client/integrations/IntegrationsPanel.tsx +2 -18
  20. package/corpus/core/src/client/resources/McpIntegrationDialog.tsx +43 -32
  21. package/corpus/core/src/client/resources/mcp-integration-catalog.ts +4 -2
  22. package/corpus/core/src/client/settings/BackgroundAgentSection.tsx +5 -1
  23. package/corpus/core/src/client/settings/SettingsPanel.tsx +4 -4
  24. package/corpus/core/src/client/settings/useBuilderStatus.ts +3 -0
  25. package/corpus/core/src/localization/default-messages.ts +1 -7
  26. package/corpus/core/src/server/builder-browser.ts +4 -0
  27. package/corpus/core/src/server/builder-design-systems.ts +6 -1
  28. package/corpus/core/src/server/fusion-app.ts +5 -1
  29. package/corpus/core/src/shared/builder-link-tracking.ts +41 -0
  30. package/corpus/core/src/shared/index.ts +1 -0
  31. package/corpus/templates/analytics/AGENTS.md +10 -1
  32. package/corpus/templates/analytics/actions/save-dashboard-report-subscription.ts +10 -2
  33. package/corpus/templates/analytics/app/components/dashboard/SqlChart.tsx +5 -4
  34. package/corpus/templates/analytics/app/pages/adhoc/sql-dashboard/index.tsx +20 -32
  35. package/corpus/templates/analytics/app/pages/adhoc/sql-dashboard/report-panel-window.ts +62 -0
  36. package/corpus/templates/analytics/changelog/2026-07-21-chart-series-controls-stay-open-while-moving-from-the-legend.md +6 -0
  37. package/corpus/templates/analytics/changelog/2026-07-21-dashboard-email-captures-complete-large-dashboards-in-chunks.md +6 -0
  38. package/corpus/templates/analytics/seeds/dashboards/agent-native-templates-first-party.json +4 -4
  39. package/corpus/templates/analytics/server/jobs/dashboard-report.ts +1 -8
  40. package/corpus/templates/analytics/server/lib/dashboard-report-subscriptions.ts +39 -7
  41. package/corpus/templates/analytics/server/lib/dashboard-report.ts +341 -251
  42. package/corpus/templates/analytics/server/lib/first-party-dashboard-repair.ts +114 -0
  43. package/corpus/templates/analytics/server/lib/first-party-metric-catalog.ts +84 -6
  44. package/corpus/templates/analytics/server/plugins/db.ts +13 -0
  45. package/corpus/templates/clips/app/components/library/bulk-action-toolbar.tsx +25 -8
  46. package/corpus/templates/clips/app/components/library/library-grid.tsx +118 -9
  47. package/corpus/templates/clips/app/components/library/library-layout.tsx +1 -1
  48. package/corpus/templates/clips/app/components/library/recording-card.tsx +10 -8
  49. package/corpus/templates/clips/app/components/player/video-player.tsx +10 -4
  50. package/corpus/templates/clips/app/i18n/en-US.ts +6 -0
  51. package/corpus/templates/clips/app/routes/_app.trash.tsx +16 -1
  52. package/corpus/templates/clips/changelog/2026-07-21-choose-which-whisper-model-to-use-in-settings.md +6 -0
  53. package/corpus/templates/clips/changelog/2026-07-21-fixed-organization-recording-visibility-default.md +6 -0
  54. package/corpus/templates/clips/changelog/2026-07-21-meeting-notes-stop-when-calls-end.md +6 -0
  55. package/corpus/templates/clips/desktop/src/app.tsx +90 -78
  56. package/corpus/templates/clips/desktop/src/hooks/useMeetingTranscription.ts +2 -0
  57. package/corpus/templates/clips/desktop/src/hooks/useWhisperSettings.ts +172 -0
  58. package/corpus/templates/clips/desktop/src/lib/meeting-call-app.ts +41 -0
  59. package/corpus/templates/clips/desktop/src/lib/silence-events.ts +1 -0
  60. package/corpus/templates/clips/desktop/src/shared/config.ts +1 -0
  61. package/corpus/templates/clips/desktop/src/styles.css +80 -7
  62. package/corpus/templates/clips/desktop/src-tauri/src/config.rs +32 -0
  63. package/corpus/templates/clips/desktop/src-tauri/src/lib.rs +3 -0
  64. package/corpus/templates/clips/desktop/src-tauri/src/silence_detector.rs +268 -20
  65. package/corpus/templates/clips/desktop/src-tauri/src/whisper_model.rs +285 -164
  66. package/corpus/templates/clips/desktop/src-tauri/src/whisper_speech.rs +18 -7
  67. package/corpus/templates/clips/learnings.defaults.md +1 -1
  68. package/corpus/templates/clips/server/plugins/db.ts +11 -0
  69. package/corpus/templates/clips/shared/builder-credits.ts +1 -1
  70. package/corpus/templates/content/app/components/editor/database/DatabaseView.tsx +20 -38
  71. package/corpus/templates/content/app/components/editor/previewDocumentSaveController.ts +40 -1
  72. package/corpus/templates/design/app/components/design/FusionAppBanner.tsx +9 -1
  73. package/corpus/templates/design/app/components/design/edit-panel/component-section.tsx +5 -1
  74. package/corpus/templates/design/app/pages/DesignSystemSetup.tsx +9 -1
  75. package/corpus/templates/slides/app/components/design-system/DesignSystemSetup.tsx +9 -1
  76. package/dist/agent/engine/builder-engine.d.ts.map +1 -1
  77. package/dist/agent/engine/builder-engine.js +4 -0
  78. package/dist/agent/engine/builder-engine.js.map +1 -1
  79. package/dist/client/AgentPanel.d.ts.map +1 -1
  80. package/dist/client/AgentPanel.js +10 -3
  81. package/dist/client/AgentPanel.js.map +1 -1
  82. package/dist/client/ConnectBuilderCard.d.ts.map +1 -1
  83. package/dist/client/ConnectBuilderCard.js +5 -1
  84. package/dist/client/ConnectBuilderCard.js.map +1 -1
  85. package/dist/client/chat/markdown-renderer.d.ts.map +1 -1
  86. package/dist/client/chat/markdown-renderer.js +3 -2
  87. package/dist/client/chat/markdown-renderer.js.map +1 -1
  88. package/dist/client/error-format.d.ts +1 -1
  89. package/dist/client/error-format.d.ts.map +1 -1
  90. package/dist/client/error-format.js +1 -1
  91. package/dist/client/error-format.js.map +1 -1
  92. package/dist/client/index.d.ts +1 -0
  93. package/dist/client/index.d.ts.map +1 -1
  94. package/dist/client/index.js +1 -0
  95. package/dist/client/index.js.map +1 -1
  96. package/dist/client/integrations/IntegrationsPanel.d.ts.map +1 -1
  97. package/dist/client/integrations/IntegrationsPanel.js +3 -4
  98. package/dist/client/integrations/IntegrationsPanel.js.map +1 -1
  99. package/dist/client/resources/McpIntegrationDialog.d.ts.map +1 -1
  100. package/dist/client/resources/McpIntegrationDialog.js +21 -7
  101. package/dist/client/resources/McpIntegrationDialog.js.map +1 -1
  102. package/dist/client/resources/mcp-integration-catalog.d.ts.map +1 -1
  103. package/dist/client/resources/mcp-integration-catalog.js +2 -2
  104. package/dist/client/resources/mcp-integration-catalog.js.map +1 -1
  105. package/dist/client/settings/BackgroundAgentSection.d.ts.map +1 -1
  106. package/dist/client/settings/BackgroundAgentSection.js +5 -1
  107. package/dist/client/settings/BackgroundAgentSection.js.map +1 -1
  108. package/dist/client/settings/SettingsPanel.js +1 -1
  109. package/dist/client/settings/SettingsPanel.js.map +1 -1
  110. package/dist/client/settings/useBuilderStatus.d.ts.map +1 -1
  111. package/dist/client/settings/useBuilderStatus.js +2 -0
  112. package/dist/client/settings/useBuilderStatus.js.map +1 -1
  113. package/dist/collab/struct-routes.d.ts +1 -1
  114. package/dist/file-upload/actions/upload-image.d.ts +1 -1
  115. package/dist/localization/default-messages.d.ts +0 -6
  116. package/dist/localization/default-messages.d.ts.map +1 -1
  117. package/dist/localization/default-messages.js +1 -7
  118. package/dist/localization/default-messages.js.map +1 -1
  119. package/dist/observability/routes.d.ts +5 -5
  120. package/dist/resources/handlers.d.ts +1 -1
  121. package/dist/secrets/routes.d.ts +9 -9
  122. package/dist/server/builder-browser.d.ts.map +1 -1
  123. package/dist/server/builder-browser.js +4 -0
  124. package/dist/server/builder-browser.js.map +1 -1
  125. package/dist/server/builder-design-systems.d.ts.map +1 -1
  126. package/dist/server/builder-design-systems.js +6 -1
  127. package/dist/server/builder-design-systems.js.map +1 -1
  128. package/dist/server/fusion-app.d.ts.map +1 -1
  129. package/dist/server/fusion-app.js +2 -1
  130. package/dist/server/fusion-app.js.map +1 -1
  131. package/dist/server/transcribe-voice.d.ts +1 -1
  132. package/dist/shared/builder-link-tracking.d.ts +14 -0
  133. package/dist/shared/builder-link-tracking.d.ts.map +1 -0
  134. package/dist/shared/builder-link-tracking.js +26 -0
  135. package/dist/shared/builder-link-tracking.js.map +1 -0
  136. package/dist/shared/index.d.ts +1 -0
  137. package/dist/shared/index.d.ts.map +1 -1
  138. package/dist/shared/index.js +1 -0
  139. package/dist/shared/index.js.map +1 -1
  140. package/docs/content/deployment.mdx +1 -1
  141. package/docs/content/frames.mdx +1 -1
  142. package/docs/content/integrations.mdx +27 -27
  143. package/docs/content/pr-visual-recap.mdx +17 -13
  144. package/docs/content/workspace-management.mdx +1 -1
  145. package/package.json +3 -2
  146. package/src/agent/engine/builder-engine.ts +4 -0
  147. package/src/client/AgentPanel.tsx +10 -4
  148. package/src/client/ConnectBuilderCard.tsx +5 -1
  149. package/src/client/chat/markdown-renderer.tsx +3 -5
  150. package/src/client/error-format.ts +2 -1
  151. package/src/client/index.ts +1 -0
  152. package/src/client/integrations/IntegrationsPanel.tsx +2 -18
  153. package/src/client/resources/McpIntegrationDialog.tsx +43 -32
  154. package/src/client/resources/mcp-integration-catalog.ts +4 -2
  155. package/src/client/settings/BackgroundAgentSection.tsx +5 -1
  156. package/src/client/settings/SettingsPanel.tsx +4 -4
  157. package/src/client/settings/useBuilderStatus.ts +3 -0
  158. package/src/localization/default-messages.ts +1 -7
  159. package/src/server/builder-browser.ts +4 -0
  160. package/src/server/builder-design-systems.ts +6 -1
  161. package/src/server/fusion-app.ts +5 -1
  162. package/src/shared/builder-link-tracking.ts +41 -0
  163. package/src/shared/index.ts +1 -0
@@ -15,6 +15,10 @@ import {
15
15
  EMBED_TOKEN_QUERY_PARAM,
16
16
  } from "@agent-native/core/shared";
17
17
 
18
+ import {
19
+ listReportablePanelIds,
20
+ REPORT_PANEL_CHUNK_SIZE,
21
+ } from "../../app/pages/adhoc/sql-dashboard/report-panel-window";
18
22
  import type {
19
23
  DashboardFilter,
20
24
  FilterType,
@@ -22,6 +26,7 @@ import type {
22
26
  } from "../../app/pages/adhoc/sql-dashboard/types";
23
27
  import {
24
28
  getReportDashboard,
29
+ normalizeDashboardReportRecipients,
25
30
  type AccessCtx,
26
31
  type DashboardReportSubscription,
27
32
  } from "./dashboard-report-subscriptions";
@@ -34,6 +39,7 @@ type ReportSnapshot = {
34
39
  dashboardUrl: string;
35
40
  reportSettingsUrl: string;
36
41
  generatedAt: string;
42
+ panelIds: string[];
37
43
  };
38
44
 
39
45
  const DATE_FILTER_TYPES: ReadonlySet<FilterType> = new Set([
@@ -49,23 +55,21 @@ const DASHBOARD_REPORT_CID = "dashboard-report-snapshot";
49
55
  const LOCAL_SCREENSHOT_TIMEOUT_MS = 90_000;
50
56
  const SERVERLESS_SCREENSHOT_TIMEOUT_MS = 90_000;
51
57
  const SERVERLESS_SECOND_READY_TIMEOUT_MS = 45_000;
52
- // Keep enough room under Netlify's 300s background-function limit for the
53
- // bounded browser-close/profile-cleanup steps after each failed attempt and
54
- // for the final email send. The three attempts total 225s; cleanup can spend
55
- // up to 60s, leaving a small delivery buffer.
56
- const SERVERLESS_FULL_ATTEMPT_TIMEOUT_MS = 110_000;
57
- const SERVERLESS_LIGHTWEIGHT_ATTEMPT_TIMEOUT_MS = 70_000;
58
+ // Reserve 90s under Netlify's 300s background-function limit for bounded
59
+ // browser cleanup plus delivery to the capped recipient list.
60
+ const SERVERLESS_CHUNKED_ATTEMPT_TIMEOUT_MS = 210_000;
58
61
  const BROWSER_CLEANUP_TIMEOUT_MS = 10_000;
59
62
  const SCREENSHOT_VIEWPORT_PADDING = 64;
63
+ const MAX_DASHBOARD_REPORT_CHUNKS = 10;
64
+ const MAX_DASHBOARD_REPORT_ATTACHMENT_BYTES = 14 * 1024 * 1024;
60
65
 
61
66
  type DashboardScreenshotAttempt = {
62
- label: "full" | "full-lightweight" | "limited";
67
+ label: "chunked";
63
68
  viewport: { width: number; height: number };
64
69
  captureScale?: number;
65
70
  readyTimeout?: number;
66
71
  secondReadyTimeout?: number;
67
72
  totalTimeout?: number;
68
- reportPanelLimit?: number;
69
73
  };
70
74
 
71
75
  type LaunchedScreenshotBrowser = {
@@ -209,6 +213,7 @@ async function collectReportSnapshot(
209
213
  reportSettings: true,
210
214
  }),
211
215
  generatedAt: new Date().toISOString(),
216
+ panelIds: listReportablePanelIds(config.panels),
212
217
  };
213
218
  }
214
219
 
@@ -351,6 +356,37 @@ function secondReadyTimeoutMs(): number {
351
356
  : screenshotTimeoutMs();
352
357
  }
353
358
 
359
+ function boundedStageTimeout(capMs: number, deadlineAt?: number): number {
360
+ if (!deadlineAt) return capMs;
361
+ const remainingMs = deadlineAt - Date.now();
362
+ if (remainingMs <= 0) throw new Error("capture deadline exhausted");
363
+ return Math.max(1, Math.min(capMs, remainingMs));
364
+ }
365
+
366
+ async function runWithinCaptureDeadline<T>(
367
+ operation: () => Promise<T>,
368
+ deadlineAt?: number,
369
+ ): Promise<T> {
370
+ if (!deadlineAt) return operation();
371
+ const timeoutMs = deadlineAt - Date.now();
372
+ if (timeoutMs <= 0) throw new Error("capture deadline exhausted");
373
+
374
+ let timeout: ReturnType<typeof setTimeout> | undefined;
375
+ try {
376
+ return await Promise.race([
377
+ operation(),
378
+ new Promise<never>((_, reject) => {
379
+ timeout = setTimeout(
380
+ () => reject(new Error("capture deadline exhausted")),
381
+ timeoutMs,
382
+ );
383
+ }),
384
+ ]);
385
+ } finally {
386
+ if (timeout) clearTimeout(timeout);
387
+ }
388
+ }
389
+
354
390
  function positiveIntEnv(name: string): number | null {
355
391
  const raw = process.env[name]?.trim();
356
392
  if (!raw) return null;
@@ -392,12 +428,6 @@ async function waitForDashboardReportReady(
392
428
  };
393
429
  })()`)
394
430
  .catch(() => null);
395
- if (detail?.ready === "true") {
396
- console.warn(
397
- "[dashboard-report] Dashboard surface stayed partially loaded; capturing the available panels.",
398
- );
399
- return false;
400
- }
401
431
  const message = detail
402
432
  ? `${err?.message ?? String(err)}; dashboard state: ${JSON.stringify(detail)}`
403
433
  : `${err?.message ?? String(err)}; dashboard page was not inspectable`;
@@ -405,6 +435,34 @@ async function waitForDashboardReportReady(
405
435
  }
406
436
  }
407
437
 
438
+ async function assertDashboardReportPanelWindow(
439
+ page: any,
440
+ expectedPanelIds: string[],
441
+ ): Promise<void> {
442
+ const rawPanelIds = await page.evaluate(`(() =>
443
+ document
444
+ .querySelector("[data-dashboard-report-capture]")
445
+ ?.getAttribute("data-dashboard-report-panel-ids") ?? null
446
+ )()`);
447
+ let actualPanelIds: unknown;
448
+ try {
449
+ actualPanelIds =
450
+ typeof rawPanelIds === "string" ? JSON.parse(rawPanelIds) : null;
451
+ } catch {
452
+ actualPanelIds = null;
453
+ }
454
+ if (
455
+ !Array.isArray(actualPanelIds) ||
456
+ actualPanelIds.some((panelId) => typeof panelId !== "string") ||
457
+ actualPanelIds.length !== expectedPanelIds.length ||
458
+ actualPanelIds.some((panelId, index) => panelId !== expectedPanelIds[index])
459
+ ) {
460
+ throw new Error(
461
+ `report chunk panel mismatch; expected=${JSON.stringify(expectedPanelIds)} actual=${JSON.stringify(actualPanelIds)}`,
462
+ );
463
+ }
464
+ }
465
+
408
466
  async function scrollDashboardForLazyRendering(page: any): Promise<void> {
409
467
  await page.evaluate(`(async () => {
410
468
  const wait = (ms) =>
@@ -483,6 +541,15 @@ const DIAGNOSTICS_PROBE_TIMEOUT_MS = 2_000;
483
541
  const DIAGNOSTICS_MAX_LENGTH = 700;
484
542
  const DIAGNOSTICS_COLLECTOR_LIMIT = 5;
485
543
 
544
+ // netlify.toml's memory = "2gb" is plan-gated and can be silently ignored, so
545
+ // capture the actual lambda memory ceiling alongside current RSS.
546
+ function memoryDiagnostics(): string {
547
+ const lambdaMemoryMb =
548
+ process.env.AWS_LAMBDA_FUNCTION_MEMORY_SIZE ?? "unknown";
549
+ const rssMb = Math.round(process.memoryUsage().rss / 1024 / 1024);
550
+ return `lambdaMemoryMb=${lambdaMemoryMb} rssMb=${rssMb}`;
551
+ }
552
+
486
553
  // Best-effort page inspection used when the report surface never becomes
487
554
  // visible, so failures carry enough state to tell wrong-page/wedged-renderer/
488
555
  // auth-bounce apart. Must never throw and must stay bounded even if the page
@@ -492,6 +559,7 @@ async function collectPageDiagnostics(
492
559
  consoleErrors: string[],
493
560
  failedRequests: string[],
494
561
  ): Promise<string> {
562
+ const memory = memoryDiagnostics();
495
563
  try {
496
564
  let responsive = true;
497
565
  let probeTimeout: ReturnType<typeof setTimeout> | undefined;
@@ -512,7 +580,7 @@ async function collectPageDiagnostics(
512
580
  }
513
581
 
514
582
  if (!responsive) {
515
- return `page unresponsive (renderer hung or crashed); consoleErrors=${JSON.stringify(
583
+ return `${memory} page unresponsive (renderer hung or crashed); consoleErrors=${JSON.stringify(
516
584
  consoleErrors,
517
585
  )} failedRequests=${JSON.stringify(failedRequests)}`.slice(
518
586
  0,
@@ -557,7 +625,7 @@ async function collectPageDiagnostics(
557
625
  if (detailsTimeout) clearTimeout(detailsTimeout);
558
626
  }
559
627
 
560
- return `page state: ${JSON.stringify({
628
+ return `${memory} page state: ${JSON.stringify({
561
629
  url,
562
630
  title,
563
631
  bodyText,
@@ -565,88 +633,85 @@ async function collectPageDiagnostics(
565
633
  failedRequests,
566
634
  })}`.slice(0, DIAGNOSTICS_MAX_LENGTH);
567
635
  } catch {
568
- return "diagnostics unavailable";
636
+ return `${memory} diagnostics unavailable`;
569
637
  }
570
638
  }
571
639
 
572
- async function captureDashboardPng(
640
+ function reportChunkOffsets(panelCount: number): number[] {
641
+ if (panelCount === 0) return [0];
642
+ return Array.from(
643
+ { length: Math.ceil(panelCount / REPORT_PANEL_CHUNK_SIZE) },
644
+ (_, index) => index * REPORT_PANEL_CHUNK_SIZE,
645
+ );
646
+ }
647
+
648
+ function screenshotUrlForChunk(
573
649
  sub: DashboardReportSubscription,
574
650
  snapshot: ReportSnapshot,
575
- attempt: DashboardScreenshotAttempt,
576
- ): Promise<Buffer> {
651
+ offset: number,
652
+ ): { token: string; url: string } {
577
653
  const targetPath = buildDashboardPath(
578
654
  snapshot.dashboardId,
579
655
  snapshot.filters,
580
- {
581
- reportScreenshot: true,
582
- },
656
+ { reportScreenshot: true },
657
+ );
658
+ const targetUrl = new URL(targetPath, "https://agent-native.invalid/");
659
+ targetUrl.searchParams.set("reportPanelOffset", String(offset));
660
+ targetUrl.searchParams.set(
661
+ "reportPanelLimit",
662
+ String(REPORT_PANEL_CHUNK_SIZE),
583
663
  );
664
+ const signedTargetPath = `${targetUrl.pathname}${targetUrl.search}`;
584
665
  const token = signEmbedSessionToken({
585
666
  ownerEmail: sub.ownerEmail,
586
667
  orgId: sub.orgId,
587
- targetPath,
668
+ targetPath: signedTargetPath,
588
669
  scope: `dashboard-report-screenshot:${sub.id}`,
589
670
  ttlSeconds: 5 * 60,
590
671
  });
591
- const screenshotUrl = new URL(targetPath, `${dashboardBaseUrl()}/`);
592
- screenshotUrl.searchParams.set(EMBED_MODE_QUERY_PARAM, "1");
593
- screenshotUrl.searchParams.set(EMBED_TOKEN_QUERY_PARAM, token);
594
- if (attempt.reportPanelLimit) {
595
- screenshotUrl.searchParams.set(
596
- "reportPanelLimit",
597
- String(attempt.reportPanelLimit),
598
- );
599
- }
672
+ const url = new URL(signedTargetPath, `${dashboardBaseUrl()}/`);
673
+ url.searchParams.set(EMBED_MODE_QUERY_PARAM, "1");
674
+ url.searchParams.set(EMBED_TOKEN_QUERY_PARAM, token);
675
+ return { token, url: url.toString() };
676
+ }
600
677
 
601
- let browser: any;
602
- let cleanup = async () => {};
603
- let newPage = async (): Promise<any> => {
604
- throw new Error("Screenshot browser did not provide a page factory");
605
- };
606
- let launchPromise: Promise<LaunchedScreenshotBrowser> | undefined;
607
- let launchTimeout: ReturnType<typeof setTimeout> | undefined;
608
- let captureStage = "launching the screenshot browser";
609
- let attemptTimedOut = false;
610
- let lastDiagnostics: string | null = null;
611
- const attemptTimeout = attempt.totalTimeout
612
- ? setTimeout(() => {
613
- attemptTimedOut = true;
614
- if (browser) void browser.close().catch(() => {});
615
- }, attempt.totalTimeout)
616
- : null;
678
+ async function captureDashboardChunk(
679
+ page: any,
680
+ attempt: DashboardScreenshotAttempt,
681
+ token: string,
682
+ screenshotUrl: string,
683
+ expectedPanelIds: string[],
684
+ deadlineAt?: number,
685
+ ): Promise<Buffer> {
686
+ const timeout = screenshotTimeoutMs();
687
+ let captureStage = "pre-seeding the embed session";
688
+ const consoleErrors: string[] = [];
689
+ const failedRequests: string[] = [];
690
+ page.on("console", (msg: any) => {
691
+ if (
692
+ msg.type() !== "error" ||
693
+ consoleErrors.length >= DIAGNOSTICS_COLLECTOR_LIMIT
694
+ )
695
+ return;
696
+ consoleErrors.push(msg.text().slice(0, 160));
697
+ });
698
+ page.on("requestfailed", (req: any) => {
699
+ if (failedRequests.length >= DIAGNOSTICS_COLLECTOR_LIMIT) return;
700
+ failedRequests.push(
701
+ `${req.method()} ${req.url().slice(0, 120)}: ${req.failure()?.errorText ?? "failed"}`,
702
+ );
703
+ });
704
+ page.on("response", (res: any) => {
705
+ if (
706
+ res.status() < 400 ||
707
+ failedRequests.length >= DIAGNOSTICS_COLLECTOR_LIMIT
708
+ )
709
+ return;
710
+ failedRequests.push(
711
+ `${res.request().method()} ${res.url().slice(0, 120)}: HTTP ${res.status()}`,
712
+ );
713
+ });
617
714
  try {
618
- launchPromise = launchScreenshotBrowser(attempt.viewport);
619
- const launched = attempt.totalTimeout
620
- ? await Promise.race([
621
- launchPromise,
622
- new Promise<never>((_, reject) => {
623
- launchTimeout = setTimeout(() => {
624
- attemptTimedOut = true;
625
- reject(
626
- new Error(
627
- `${attempt.label} browser launch exceeded ${attempt.totalTimeout}ms`,
628
- ),
629
- );
630
- }, attempt.totalTimeout);
631
- }),
632
- ])
633
- : await launchPromise;
634
- browser = launched.browser;
635
- cleanup = launched.cleanup;
636
- newPage = launched.newPage;
637
- if (attemptTimedOut) {
638
- throw new Error("Screenshot browser launch exceeded attempt timeout");
639
- }
640
-
641
- const timeout = screenshotTimeoutMs();
642
- const page = await newPage();
643
-
644
- // Belt-and-braces auth: the query token authenticates only after the
645
- // client-side bootstrap harvests it from the URL, which gives the
646
- // stripped-down serverless browser several ways to end up session-less.
647
- // Seeding the same signed token as a cookie authenticates every request
648
- // server-side with no client cooperation. Never abort the capture over
649
- // this — the query token path still exists.
650
715
  try {
651
716
  await page.context().addCookies([
652
717
  {
@@ -661,81 +726,170 @@ async function captureDashboardPng(
661
726
  errorMessage(err),
662
727
  );
663
728
  }
664
-
665
- // Bounded diagnostics collectors so a failed wait carries evidence of
666
- // wrong-page/wedged-renderer/auth-bounce instead of a bare timeout.
667
- const consoleErrors: string[] = [];
668
- page.on("console", (msg: any) => {
669
- if (msg.type() !== "error") return;
670
- if (consoleErrors.length >= DIAGNOSTICS_COLLECTOR_LIMIT) return;
671
- consoleErrors.push(msg.text().slice(0, 160));
672
- });
673
- const failedRequests: string[] = [];
674
- page.on("requestfailed", (req: any) => {
675
- if (failedRequests.length >= DIAGNOSTICS_COLLECTOR_LIMIT) return;
676
- failedRequests.push(
677
- `${req.method()} ${req.url().slice(0, 120)}: ${req.failure()?.errorText ?? "failed"}`,
678
- );
679
- });
680
- page.on("response", (res: any) => {
681
- if (res.status() < 400) return;
682
- if (failedRequests.length >= DIAGNOSTICS_COLLECTOR_LIMIT) return;
683
- failedRequests.push(
684
- `${res.request().method()} ${res.url().slice(0, 120)}: HTTP ${res.status()}`,
685
- );
686
- });
687
-
688
- page.setDefaultTimeout(timeout);
729
+ page.setDefaultTimeout(boundedStageTimeout(timeout, deadlineAt));
689
730
  await page.emulateMedia({ media: "screen", colorScheme: "light" });
690
- await page.addInitScript(() => {
691
- window.localStorage.setItem("theme", "light");
692
- });
693
- await page.goto(screenshotUrl.toString(), {
731
+ await page.addInitScript(() =>
732
+ window.localStorage.setItem("theme", "light"),
733
+ );
734
+ captureStage = "navigating to the report chunk";
735
+ await page.goto(screenshotUrl, {
694
736
  waitUntil: "domcontentloaded",
695
- timeout,
737
+ timeout: boundedStageTimeout(timeout, deadlineAt),
696
738
  });
697
-
698
- captureStage = "waiting for the report surface";
699
739
  const capture = page.locator("[data-dashboard-report-capture]");
740
+ captureStage = "waiting for the report chunk surface";
700
741
  try {
701
- await capture.waitFor({ state: "visible", timeout });
742
+ await capture.waitFor({
743
+ state: "visible",
744
+ timeout: boundedStageTimeout(timeout, deadlineAt),
745
+ });
702
746
  } catch (err) {
703
- lastDiagnostics = errorMessage(
747
+ const diagnostics = errorMessage(
704
748
  await collectPageDiagnostics(page, consoleErrors, failedRequests),
705
749
  );
706
- throw new Error(`${errorMessage(err)}; ${lastDiagnostics}`);
750
+ throw new Error(`${errorMessage(err)}; ${diagnostics}`);
707
751
  }
708
- captureStage = "waiting for dashboard queries";
709
- await waitForDashboardReportReady(page, attempt.readyTimeout ?? timeout);
710
- captureStage = "rendering lazy dashboard panels";
752
+ captureStage = "waiting for report chunk queries";
753
+ await waitForDashboardReportReady(
754
+ page,
755
+ boundedStageTimeout(attempt.readyTimeout ?? timeout, deadlineAt),
756
+ );
757
+ captureStage = "validating the report chunk panels";
758
+ await assertDashboardReportPanelWindow(page, expectedPanelIds);
759
+ captureStage = "rendering lazy report chunk panels";
711
760
  await scrollDashboardForLazyRendering(page);
712
- captureStage = "waiting for lazy dashboard panels";
761
+ captureStage = "waiting for lazy report chunk panels";
713
762
  await waitForDashboardReportReady(
714
763
  page,
715
- attempt.secondReadyTimeout ?? secondReadyTimeoutMs(),
764
+ boundedStageTimeout(
765
+ attempt.secondReadyTimeout ?? secondReadyTimeoutMs(),
766
+ deadlineAt,
767
+ ),
716
768
  );
717
-
718
- captureStage = "sizing the dashboard capture";
769
+ captureStage = "revalidating the report chunk panels";
770
+ await assertDashboardReportPanelWindow(page, expectedPanelIds);
771
+ captureStage = "sizing the report chunk";
719
772
  await fitViewportWidthToDashboardCapture(page, capture, attempt.viewport);
720
773
  await scaleDashboardCapture(page, attempt.captureScale);
721
774
  await capture.scrollIntoViewIfNeeded();
722
- captureStage = "rasterizing the dashboard PNG";
775
+ captureStage = "rasterizing the report chunk PNG";
776
+ page.setDefaultTimeout(boundedStageTimeout(timeout, deadlineAt));
723
777
  const image = await capture.screenshot({
724
778
  type: "png",
725
779
  animations: "disabled",
726
780
  });
727
- if (!image?.length) {
728
- throw new Error("Dashboard screenshot was empty");
729
- }
781
+ if (!image?.length) throw new Error("Dashboard screenshot was empty");
730
782
  return Buffer.from(image);
783
+ } catch (err) {
784
+ throw new Error(`${captureStage}: ${errorMessage(err)}`);
785
+ }
786
+ }
787
+
788
+ async function captureDashboardPngChunks(
789
+ sub: DashboardReportSubscription,
790
+ snapshot: ReportSnapshot,
791
+ ): Promise<Buffer[]> {
792
+ const offsets = reportChunkOffsets(snapshot.panelIds.length);
793
+ if (offsets.length > MAX_DASHBOARD_REPORT_CHUNKS) {
794
+ throw new Error(
795
+ `complete dashboard requires ${offsets.length} image chunks; email delivery supports at most ${MAX_DASHBOARD_REPORT_CHUNKS}`,
796
+ );
797
+ }
798
+ const serverless = isServerlessBrowserRuntime();
799
+ const attempt: DashboardScreenshotAttempt = {
800
+ label: "chunked",
801
+ viewport: { width: 1200, height: 1400 },
802
+ captureScale: 0.7,
803
+ ...(serverless
804
+ ? {
805
+ readyTimeout: 35_000,
806
+ secondReadyTimeout: 15_000,
807
+ totalTimeout: SERVERLESS_CHUNKED_ATTEMPT_TIMEOUT_MS,
808
+ }
809
+ : {}),
810
+ };
811
+ let browser: any;
812
+ let cleanup = async () => {};
813
+ let launchPromise: Promise<LaunchedScreenshotBrowser> | undefined;
814
+ let launchTimeout: ReturnType<typeof setTimeout> | undefined;
815
+ let attemptTimedOut = false;
816
+ const deadlineAt = attempt.totalTimeout
817
+ ? Date.now() + attempt.totalTimeout
818
+ : undefined;
819
+ const attemptTimeout = attempt.totalTimeout
820
+ ? setTimeout(() => {
821
+ attemptTimedOut = true;
822
+ if (browser) void browser.close().catch(() => {});
823
+ }, attempt.totalTimeout)
824
+ : null;
825
+ try {
826
+ launchPromise = launchScreenshotBrowser(attempt.viewport);
827
+ const launched = attempt.totalTimeout
828
+ ? await Promise.race([
829
+ launchPromise,
830
+ new Promise<never>((_, reject) => {
831
+ launchTimeout = setTimeout(
832
+ () =>
833
+ reject(
834
+ new Error(
835
+ `${attempt.label} browser launch exceeded ${attempt.totalTimeout}ms`,
836
+ ),
837
+ ),
838
+ attempt.totalTimeout,
839
+ );
840
+ }),
841
+ ])
842
+ : await launchPromise;
843
+ browser = launched.browser;
844
+ cleanup = launched.cleanup;
845
+ const images: Buffer[] = [];
846
+ let attachmentBytes = 0;
847
+ for (const offset of offsets) {
848
+ if (attemptTimedOut)
849
+ throw new Error(`capture exceeded ${attempt.totalTimeout}ms`);
850
+ const page = await runWithinCaptureDeadline(
851
+ () => launched.newPage(),
852
+ deadlineAt,
853
+ );
854
+ try {
855
+ const { token, url } = screenshotUrlForChunk(sub, snapshot, offset);
856
+ const expectedPanelIds = snapshot.panelIds.slice(
857
+ offset,
858
+ offset + REPORT_PANEL_CHUNK_SIZE,
859
+ );
860
+ const image = await runWithinCaptureDeadline(
861
+ () =>
862
+ captureDashboardChunk(
863
+ page,
864
+ attempt,
865
+ token,
866
+ url,
867
+ expectedPanelIds,
868
+ deadlineAt,
869
+ ),
870
+ deadlineAt,
871
+ );
872
+ attachmentBytes += image.length;
873
+ if (attachmentBytes > MAX_DASHBOARD_REPORT_ATTACHMENT_BYTES) {
874
+ throw new Error(
875
+ `complete dashboard images total ${attachmentBytes} bytes; email delivery supports at most ${MAX_DASHBOARD_REPORT_ATTACHMENT_BYTES} bytes`,
876
+ );
877
+ }
878
+ images.push(image);
879
+ } finally {
880
+ await runBoundedBrowserCleanup("Failed to close screenshot page", () =>
881
+ page.close(),
882
+ );
883
+ }
884
+ }
885
+ return images;
731
886
  } catch (err) {
732
887
  if (attemptTimedOut) {
733
888
  throw new Error(
734
- `${attempt.label} capture exceeded ${attempt.totalTimeout}ms while ${captureStage}` +
735
- (lastDiagnostics ? `; ${lastDiagnostics}` : ""),
889
+ `${memoryDiagnostics()} ${attempt.label} capture exceeded ${attempt.totalTimeout}ms: ${errorMessage(err)}`,
736
890
  );
737
891
  }
738
- throw new Error(`${captureStage}: ${errorMessage(err)}`);
892
+ throw new Error(`${attempt.label}: ${errorMessage(err)}`);
739
893
  } finally {
740
894
  if (attemptTimeout) clearTimeout(attemptTimeout);
741
895
  if (launchTimeout) clearTimeout(launchTimeout);
@@ -751,9 +905,7 @@ async function captureDashboardPng(
751
905
  lateBrowser.cleanup,
752
906
  );
753
907
  },
754
- () => {
755
- // The launch rejection was already handled by the race above.
756
- },
908
+ () => {},
757
909
  );
758
910
  }
759
911
  if (browser) {
@@ -773,91 +925,36 @@ function errorMessage(err: unknown): string {
773
925
  );
774
926
  }
775
927
 
776
- function storedAttemptError(message: string): string {
777
- const normalized = message.replace(/\s+/g, " ").trim();
778
- return normalized.length > 400 ? `${normalized.slice(0, 399)}…` : normalized;
779
- }
780
-
781
928
  async function captureDashboardPngWithFallback(
782
929
  sub: DashboardReportSubscription,
783
930
  snapshot: ReportSnapshot,
784
- options?: { includeLimitedFallback?: boolean },
785
931
  ): Promise<{
786
- png: Buffer | null;
787
- mode: "full" | "full-lightweight" | "limited" | "none";
932
+ pngs: Buffer[] | null;
933
+ mode: "full" | "none";
788
934
  error?: string;
789
935
  }> {
790
- const serverless = isServerlessBrowserRuntime();
791
- const attempts: DashboardScreenshotAttempt[] = [
792
- {
793
- label: "full",
794
- viewport: { width: 1440, height: 1800 },
795
- captureScale: 0.85,
796
- ...(serverless
797
- ? {
798
- readyTimeout: 45_000,
799
- secondReadyTimeout: 25_000,
800
- totalTimeout: SERVERLESS_FULL_ATTEMPT_TIMEOUT_MS,
801
- }
802
- : {}),
803
- },
804
- {
805
- label: "full-lightweight",
806
- viewport: { width: 1200, height: 1400 },
807
- captureScale: 0.7,
808
- ...(serverless
809
- ? {
810
- readyTimeout: 35_000,
811
- secondReadyTimeout: 15_000,
812
- totalTimeout: SERVERLESS_LIGHTWEIGHT_ATTEMPT_TIMEOUT_MS,
813
- }
814
- : {}),
815
- },
816
- ];
817
- if (options?.includeLimitedFallback) {
818
- // Only added on the final sweep after the 1-hour retry window, when the
819
- // alternative is a no-image fallback email. All three serverless attempt
820
- // totalTimeouts sum to 225s, leaving room for bounded cleanup and email
821
- // delivery under the 300s Netlify background-function timeout.
822
- attempts.push({
823
- label: "limited",
824
- viewport: { width: 1200, height: 1400 },
825
- captureScale: 0.7,
826
- reportPanelLimit: 8,
827
- ...(serverless
828
- ? {
829
- readyTimeout: 25_000,
830
- secondReadyTimeout: 10_000,
831
- totalTimeout: 45_000,
832
- }
833
- : {}),
834
- });
835
- }
836
- const errors: string[] = [];
837
-
838
- for (const attempt of attempts) {
839
- try {
840
- const png = await captureDashboardPng(sub, snapshot, attempt);
841
- return {
842
- png,
843
- mode: attempt.label,
844
- ...(errors.length ? { error: errors.join(" | ") } : {}),
845
- };
846
- } catch (err) {
847
- const attemptError = errorMessage(err);
848
- errors.push(`${attempt.label}: ${storedAttemptError(attemptError)}`);
849
- console.error(
850
- `[dashboard-report] ${attempt.label} screenshot failed for subscription ${sub.id}:`,
851
- attemptError,
936
+ try {
937
+ const pngs = await captureDashboardPngChunks(sub, snapshot);
938
+ if (isServerlessBrowserRuntime()) {
939
+ console.info(
940
+ `[dashboard-report] complete chunked capture succeeded: chunks=${pngs.length} ${memoryDiagnostics()}`,
852
941
  );
853
942
  }
943
+ return {
944
+ pngs,
945
+ mode: "full",
946
+ };
947
+ } catch (err) {
948
+ const rawError = errorMessage(err).replace(/\s+/g, " ").trim();
949
+ const error = rawError.includes("lambdaMemoryMb=")
950
+ ? rawError
951
+ : `${rawError}; ${memoryDiagnostics()}`;
952
+ console.error(
953
+ `[dashboard-report] complete chunked screenshot failed for subscription ${sub.id}:`,
954
+ error,
955
+ );
956
+ return { pngs: null, mode: "none", error };
854
957
  }
855
-
856
- return {
857
- png: null,
858
- mode: "none",
859
- ...(errors.length ? { error: errors.join(" | ") } : {}),
860
- };
861
958
  }
862
959
 
863
960
  function reportDate(snapshot: ReportSnapshot): string {
@@ -872,7 +969,7 @@ function renderReportEmailHtml(
872
969
  snapshot: ReportSnapshot,
873
970
  options: {
874
971
  screenshotAttached: boolean;
875
- screenshotMode: "full" | "full-lightweight" | "limited" | "none";
972
+ screenshotCount: number;
876
973
  },
877
974
  ): string {
878
975
  const title = escapeHtml(snapshot.title);
@@ -880,18 +977,18 @@ function renderReportEmailHtml(
880
977
  const reportSettingsUrl = escapeHtml(snapshot.reportSettingsUrl);
881
978
  const date = escapeHtml(reportDate(snapshot));
882
979
  const screenshotBlock = options.screenshotAttached
883
- ? `<a href="${dashboardUrl}" style="display:block;text-decoration:none;">
884
- <img src="cid:${DASHBOARD_REPORT_CID}" alt="${title}" width="100%" style="display:block;width:100%;max-width:1280px;height:auto;border:0;outline:0;border-radius:0;" />
885
- </a>`
980
+ ? Array.from(
981
+ { length: options.screenshotCount },
982
+ (
983
+ _,
984
+ index,
985
+ ) => `<a href="${dashboardUrl}" style="display:block;text-decoration:none;">
986
+ <img src="cid:${DASHBOARD_REPORT_CID}-${index + 1}" alt="${title}${options.screenshotCount > 1 ? ` (part ${index + 1})` : ""}" width="100%" style="display:block;width:100%;max-width:1280px;height:auto;border:0;outline:0;border-radius:0;" />
987
+ </a>`,
988
+ ).join("\n")
886
989
  : `<div style="margin:18px 0;padding:14px 16px;border:1px solid #e5e7eb;border-radius:8px;background:#f9fafb;color:#374151;font-size:14px;line-height:1.5;">
887
990
  The dashboard image was unavailable for this run. Open the live dashboard to view the latest report.
888
991
  </div>`;
889
- const limitedScreenshotNotice =
890
- options.screenshotMode === "limited"
891
- ? `<div style="margin:12px 0 0;padding:12px 14px;border:1px solid #f3c46b;border-radius:8px;background:#fff8e6;color:#6b4f14;font-size:13px;line-height:1.45;">
892
- This is a limited fallback image and may omit some dashboard panels. <a href="${dashboardUrl}" style="color:#2563eb;text-decoration:none;">Open the full dashboard</a> to see every panel.
893
- </div>`
894
- : "";
895
992
 
896
993
  return `<!doctype html>
897
994
  <html>
@@ -900,7 +997,6 @@ function renderReportEmailHtml(
900
997
  Here's your report of <a href="${dashboardUrl}" style="color:#2563eb;text-decoration:none;">${title}</a> for ${date}
901
998
  </p>
902
999
  ${screenshotBlock}
903
- ${limitedScreenshotNotice}
904
1000
  <p style="margin:18px 0 0;color:#525866;font-size:13px;line-height:1.45;">
905
1001
  <a href="${dashboardUrl}" style="color:#2563eb;text-decoration:none;">Open dashboard</a>
906
1002
  <span style="color:#9ca3af;"> · </span>
@@ -914,7 +1010,6 @@ function renderReportText(
914
1010
  snapshot: ReportSnapshot,
915
1011
  options: {
916
1012
  screenshotAttached: boolean;
917
- screenshotMode: "full" | "full-lightweight" | "limited" | "none";
918
1013
  },
919
1014
  ): string {
920
1015
  const lines = [
@@ -926,21 +1021,17 @@ function renderReportText(
926
1021
  if (!options.screenshotAttached) {
927
1022
  lines.push("Dashboard image unavailable for this run.");
928
1023
  }
929
- if (options.screenshotMode === "limited") {
930
- lines.push(
931
- `This is a limited fallback image and may omit some dashboard panels. Open the full dashboard: ${snapshot.dashboardUrl}`,
932
- );
933
- }
934
1024
  return lines.join("\n");
935
1025
  }
936
1026
 
937
- function reportFilename(title: string): string {
1027
+ function reportFilename(title: string, part: number, total: number): string {
938
1028
  const slug = title
939
1029
  .toLowerCase()
940
1030
  .replace(/[^a-z0-9]+/g, "-")
941
1031
  .replace(/^-|-$/g, "")
942
1032
  .slice(0, 64);
943
- return `${slug || "dashboard"}-report.png`;
1033
+ const suffix = total > 1 ? `-part-${part}` : "";
1034
+ return `${slug || "dashboard"}-report${suffix}.png`;
944
1035
  }
945
1036
 
946
1037
  export async function sendDashboardReportSubscription(
@@ -948,71 +1039,70 @@ export async function sendDashboardReportSubscription(
948
1039
  options: {
949
1040
  requireScreenshot?: boolean;
950
1041
  skipEmailWithoutScreenshot?: boolean;
951
- allowLimitedFallback?: boolean;
952
1042
  } = {},
953
1043
  ): Promise<{
954
1044
  dashboardUrl: string;
955
1045
  recipientCount: number;
956
1046
  screenshotAttached: boolean;
957
- screenshotMode: "full" | "full-lightweight" | "limited" | "none";
1047
+ screenshotMode: "full" | "none";
958
1048
  screenshotError?: string;
959
1049
  emailsSent: boolean;
960
1050
  }> {
1051
+ const recipients = normalizeDashboardReportRecipients(sub.recipients);
961
1052
  const snapshot = await collectReportSnapshot(sub);
962
- const capture = await captureDashboardPngWithFallback(sub, snapshot, {
963
- includeLimitedFallback: options?.allowLimitedFallback,
964
- });
965
- if (!capture.png && options.requireScreenshot) {
1053
+ const capture = await captureDashboardPngWithFallback(sub, snapshot);
1054
+ if (!capture.pngs && options.requireScreenshot) {
966
1055
  throw new Error(
967
1056
  capture.error
968
1057
  ? `Dashboard screenshot unavailable: ${capture.error}`
969
1058
  : "Dashboard screenshot unavailable",
970
1059
  );
971
1060
  }
972
- if (!capture.png && options.skipEmailWithoutScreenshot) {
1061
+ if (!capture.pngs && options.skipEmailWithoutScreenshot) {
973
1062
  return {
974
1063
  dashboardUrl: snapshot.dashboardUrl,
975
- recipientCount: sub.recipients.length,
1064
+ recipientCount: recipients.length,
976
1065
  screenshotAttached: false,
977
1066
  screenshotMode: capture.mode,
978
1067
  emailsSent: false,
979
1068
  ...(capture.error ? { screenshotError: capture.error } : {}),
980
1069
  };
981
1070
  }
982
- const screenshotAttached = Boolean(capture.png);
1071
+ const screenshotAttached = Boolean(capture.pngs?.length);
983
1072
  const html = renderReportEmailHtml(snapshot, {
984
1073
  screenshotAttached,
985
- screenshotMode: capture.mode,
1074
+ screenshotCount: capture.pngs?.length ?? 0,
986
1075
  });
987
1076
  const text = renderReportText(snapshot, {
988
1077
  screenshotAttached,
989
- screenshotMode: capture.mode,
990
1078
  });
991
1079
  const subject = `Daily dashboard: ${snapshot.title}`;
992
1080
 
993
- for (const to of sub.recipients) {
1081
+ for (const to of recipients) {
994
1082
  await sendEmail({
995
1083
  to,
996
1084
  subject,
997
1085
  html,
998
1086
  text,
999
- attachments: capture.png
1000
- ? [
1001
- {
1002
- filename: reportFilename(snapshot.title),
1003
- content: capture.png,
1004
- contentType: "image/png",
1005
- contentId: DASHBOARD_REPORT_CID,
1006
- disposition: "inline",
1007
- },
1008
- ]
1087
+ attachments: capture.pngs
1088
+ ? capture.pngs.map((png, index) => ({
1089
+ filename: reportFilename(
1090
+ snapshot.title,
1091
+ index + 1,
1092
+ capture.pngs!.length,
1093
+ ),
1094
+ content: png,
1095
+ contentType: "image/png",
1096
+ contentId: `${DASHBOARD_REPORT_CID}-${index + 1}`,
1097
+ disposition: "inline",
1098
+ }))
1009
1099
  : undefined,
1010
1100
  });
1011
1101
  }
1012
1102
 
1013
1103
  return {
1014
1104
  dashboardUrl: snapshot.dashboardUrl,
1015
- recipientCount: sub.recipients.length,
1105
+ recipientCount: recipients.length,
1016
1106
  screenshotAttached,
1017
1107
  screenshotMode: capture.mode,
1018
1108
  emailsSent: true,