From 541dc32ffdec34de32d8fd6419a8edc0be824015 Mon Sep 17 00:00:00 2001 From: Oliver Kieselbach Date: Thu, 20 Aug 2026 00:57:39 +0200 Subject: [PATCH] feat(web): PDF export for the app install report + usage telemetry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds an "Export PDF" button to the app detail page that renders the full 90/30/7-day install report (KPIs, both charts, failure codes, device model correlation, version breakdown, detection-rule/regression callouts) as a branded A4 PDF, drawn data-driven with jsPDF — no DOM capture, so the output is light-palette and theme-independent. jsPDF stays out of the route bundle via on-click dynamic import (verified: separate async chunk). Sample messages and the affected-sessions list are deliberately excluded: the PDF is meant to be emailed outside the org. Tracks app_report_pdf_exported in Application Insights (windowDays, scope, durationMs, pageCount, success/errorName — chunk-load failures included). Generic chrome (header/footer/tables/KPI cells/callouts) lives in utils/pdf/pdfPrimitives.ts for reuse by future report exports. Co-Authored-By: Claude Fable 5 --- .../app/apps/detail/page.tsx | 62 ++++ .../autopilot-monitor-web/package-lock.json | 236 +++++++++++++- src/Web/autopilot-monitor-web/package.json | 1 + .../utils/pdf/__tests__/appReportData.test.ts | 245 +++++++++++++++ .../pdf/__tests__/appReportPdf.smoke.test.ts | 74 +++++ .../utils/pdf/__tests__/chartMath.test.ts | 87 ++++++ .../utils/pdf/appReportData.ts | 293 ++++++++++++++++++ .../utils/pdf/appReportPdf.ts | 180 +++++++++++ .../utils/pdf/chartMath.ts | 53 ++++ .../utils/pdf/pdfCharts.ts | 164 ++++++++++ .../utils/pdf/pdfPrimitives.ts | 218 +++++++++++++ .../utils/pdf/pdfTheme.ts | 44 +++ 12 files changed, 1645 insertions(+), 12 deletions(-) create mode 100644 src/Web/autopilot-monitor-web/utils/pdf/__tests__/appReportData.test.ts create mode 100644 src/Web/autopilot-monitor-web/utils/pdf/__tests__/appReportPdf.smoke.test.ts create mode 100644 src/Web/autopilot-monitor-web/utils/pdf/__tests__/chartMath.test.ts create mode 100644 src/Web/autopilot-monitor-web/utils/pdf/appReportData.ts create mode 100644 src/Web/autopilot-monitor-web/utils/pdf/appReportPdf.ts create mode 100644 src/Web/autopilot-monitor-web/utils/pdf/chartMath.ts create mode 100644 src/Web/autopilot-monitor-web/utils/pdf/pdfCharts.ts create mode 100644 src/Web/autopilot-monitor-web/utils/pdf/pdfPrimitives.ts create mode 100644 src/Web/autopilot-monitor-web/utils/pdf/pdfTheme.ts diff --git a/src/Web/autopilot-monitor-web/app/apps/detail/page.tsx b/src/Web/autopilot-monitor-web/app/apps/detail/page.tsx index d10cbba7..a2326c29 100644 --- a/src/Web/autopilot-monitor-web/app/apps/detail/page.tsx +++ b/src/Web/autopilot-monitor-web/app/apps/detail/page.tsx @@ -11,6 +11,10 @@ import { useNotifications } from "../../../contexts/NotificationContext"; import { scopedApi } from "@/lib/scopedApi"; import { authenticatedFetch, TokenExpiredError } from "@/lib/authenticatedFetch"; import { getErrorCodeEntry, formatErrorCode } from "@/utils/errorCodeMap"; +import { trackEvent } from "@/lib/appInsights"; +// Static import is safe: appReportData is pure data prep with no jsPDF dependency — +// jsPDF itself only loads with the dynamic import inside handleExportPdf. +import { scopeLabel } from "@/utils/pdf/appReportData"; import { useAggregatedAdminScope } from "@/hooks"; import { GlobalAdminBanner, globalAdminSubtitle } from "@/components/GlobalAdminBanner"; import { TenantScopeSelector } from "@/components/TenantScopeSelector"; @@ -215,6 +219,7 @@ function AppDetailContent() { const [days, setDays] = useState<7 | 30 | 90>(initialDays as 7 | 30 | 90); const [analytics, setAnalytics] = useState(null); const [loading, setLoading] = useState(true); + const [exporting, setExporting] = useState(false); const progress = useFetchProgress("appAnalytics.lastFetchMs"); const { begin: progressBegin, finish: progressFinish } = progress; @@ -370,6 +375,47 @@ function AppDetailContent() { } }; + const handleExportPdf = async () => { + if (!analytics || exporting) return; + setExporting(true); + const t0 = performance.now(); + const baseProps = { + appName, + appType: analytics.appType, + windowDays: days, + scope: scope.isAggregatedGlobalView ? "aggregated" : "tenant", + }; + try { + // Lazy import keeps jsPDF out of the route bundle; a failed chunk load + // (deploy skew, offline) lands in the catch below and is tracked too. + const { generateAppReportPdf } = await import("@/utils/pdf/appReportPdf"); + const result = generateAppReportPdf({ + analytics, + appName, + days, + scopeLabel: scopeLabel(scope), + generatedAt: new Date(), + }); + trackEvent("app_report_pdf_exported", { + ...baseProps, + success: true, + durationMs: Math.round(performance.now() - t0), + pageCount: result.pageCount, + }); + } catch (err) { + trackEvent("app_report_pdf_exported", { + ...baseProps, + success: false, + durationMs: Math.round(performance.now() - t0), + errorName: err instanceof Error ? err.name : "UnknownError", + }); + console.error("PDF export failed", err); + addNotification("error", "Export Failed", "Unable to generate the PDF report.", "app-pdf-export-error"); + } finally { + setExporting(false); + } + }; + // Single fetch effect: re-runs when scope, app, days, or tenant selection change. // Gated on scopeInitialized so we don't waste a backend hit fetching the // wrong scope before the GA default-to-own-tenant has settled. @@ -442,6 +488,22 @@ function AppDetailContent() { {d} Days ))} + diff --git a/src/Web/autopilot-monitor-web/package-lock.json b/src/Web/autopilot-monitor-web/package-lock.json index f289fff1..ab4f9bb9 100644 --- a/src/Web/autopilot-monitor-web/package-lock.json +++ b/src/Web/autopilot-monitor-web/package-lock.json @@ -16,6 +16,7 @@ "@xyflow/react": "^12.11.3", "dagre": "^0.8.5", "fflate": "^0.8.3", + "jspdf": "^4.2.1", "leaflet": "^1.9.4", "next": "^16.3.1", "react": "^19.2.8", @@ -1423,9 +1424,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1442,9 +1440,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1461,9 +1456,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1480,9 +1472,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2077,6 +2066,19 @@ "undici-types": "~6.21.0" } }, + "node_modules/@types/pako": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/@types/pako/-/pako-2.0.4.tgz", + "integrity": "sha512-VWDCbrLeVXJM9fihYodcLiIv0ku+AlOa/TQ1SvYOaBuyrSKgEcro95LJyIsJ4vSo6BXIxOKxiJAat04CmST9Fw==", + "license": "MIT" + }, + "node_modules/@types/raf": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/@types/raf/-/raf-3.4.3.tgz", + "integrity": "sha512-c4YAvMedbPZ5tEyxzQdMoOhhJ4RD3rngZIdwC2/qDN3d7JpEhB6fiBRKVY1lg5B7Wk+uPBjn5f39j1/2MY1oOw==", + "license": "MIT", + "optional": true + }, "node_modules/@types/react": { "version": "19.2.18", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.18.tgz", @@ -2097,6 +2099,13 @@ "@types/react": "^19.2.0" } }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "license": "MIT", + "optional": true + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.65.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.65.0.tgz", @@ -3196,6 +3205,16 @@ "dev": true, "license": "MIT" }, + "node_modules/base64-arraybuffer": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz", + "integrity": "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 0.6.0" + } + }, "node_modules/baseline-browser-mapping": { "version": "2.11.0", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.0.tgz", @@ -3369,6 +3388,26 @@ ], "license": "CC-BY-4.0" }, + "node_modules/canvg": { + "version": "3.0.11", + "resolved": "https://registry.npmjs.org/canvg/-/canvg-3.0.11.tgz", + "integrity": "sha512-5ON+q7jCTgMp9cjpu4Jo6XbvfYwSB2Ow3kzHKfIyJfaCAOHLbdKPQqGKgfED/R5B+3TFFfe8pegYA+b423SRyA==", + "license": "MIT", + "optional": true, + "dependencies": { + "@babel/runtime": "^7.12.5", + "@types/raf": "^3.4.0", + "core-js": "^3.8.3", + "raf": "^3.4.1", + "regenerator-runtime": "^0.13.7", + "rgbcolor": "^1.0.1", + "stackblur-canvas": "^2.0.0", + "svg-pathdata": "^6.0.3" + }, + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/chai": { "version": "6.2.2", "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", @@ -3499,6 +3538,21 @@ "dev": true, "license": "MIT" }, + "node_modules/core-js": { + "version": "3.50.0", + "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.50.0.tgz", + "integrity": "sha512-BRWgOLKkFeCgRudR6zrs8p9XJZcE14grzKMMssoYrk6krtuEZ7MTKPIY5RzOnqsEKIR9kst7wNzphttraT+Yqw==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "engines": { + "node": "*" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/core-js" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -3514,6 +3568,16 @@ "node": ">= 8" } }, + "node_modules/css-line-break": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/css-line-break/-/css-line-break-2.1.0.tgz", + "integrity": "sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w==", + "license": "MIT", + "optional": true, + "dependencies": { + "utrie": "^1.0.2" + } + }, "node_modules/cssesc": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", @@ -3912,6 +3976,16 @@ "csstype": "^3.0.2" } }, + "node_modules/dompurify": { + "version": "3.4.14", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.14.tgz", + "integrity": "sha512-dVoH9z+MY+C9IilgGCk3YfFqjLi3fChm2OiKJMzh6axrJ5qwxqWaZamgmHrpv22CN/KdbZJuGEGgfQoL00LTdg==", + "license": "(MPL-2.0 OR Apache-2.0)", + "optional": true, + "optionalDependencies": { + "@types/trusted-types": "^2.0.7" + } + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -4703,6 +4777,17 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-png": { + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/fast-png/-/fast-png-6.4.0.tgz", + "integrity": "sha512-kAqZq1TlgBjZcLr5mcN6NP5Rv4V2f22z00c3g8vRrwkcqjerx7BEhPbOnWCPqaHUl2XWQBJQvOT/FQhdMT7X/Q==", + "license": "MIT", + "dependencies": { + "@types/pako": "^2.0.3", + "iobuffer": "^5.3.2", + "pako": "^2.1.0" + } + }, "node_modules/fastq": { "version": "1.20.1", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", @@ -5168,6 +5253,20 @@ "dev": true, "license": "MIT" }, + "node_modules/html2canvas": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/html2canvas/-/html2canvas-1.4.1.tgz", + "integrity": "sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==", + "license": "MIT", + "optional": true, + "dependencies": { + "css-line-break": "^2.1.0", + "text-segmentation": "^1.0.3" + }, + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -5229,6 +5328,12 @@ "node": ">=12" } }, + "node_modules/iobuffer": { + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/iobuffer/-/iobuffer-5.4.0.tgz", + "integrity": "sha512-DRebOWuqDvxunfkNJAlc3IzWIPD5xVxwUNbHr7xKB8E6aLJxIPfNX3CoMJghcFjpv6RWQsrcJbghtEwSPoJqMA==", + "license": "MIT" + }, "node_modules/is-array-buffer": { "version": "3.0.5", "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", @@ -5772,6 +5877,23 @@ "json5": "lib/cli.js" } }, + "node_modules/jspdf": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/jspdf/-/jspdf-4.2.1.tgz", + "integrity": "sha512-YyAXyvnmjTbR4bHQRLzex3CuINCDlQnBqoSYyjJwTP2x9jDLuKDzy7aKUl0hgx3uhcl7xzg32agn5vlie6HIlQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.6", + "fast-png": "^6.2.0", + "fflate": "^0.8.1" + }, + "optionalDependencies": { + "canvg": "^3.0.11", + "core-js": "^3.6.0", + "dompurify": "^3.3.1", + "html2canvas": "^1.0.0-rc.5" + } + }, "node_modules/jsx-ast-utils": { "version": "3.3.5", "resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz", @@ -6637,6 +6759,22 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/pako": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/pako/-/pako-2.2.0.tgz", + "integrity": "sha512-zJq6RP/5q+TO2OpFV3FHzlPnFjmkb7Nc99a5SNjJE+uu/PkpChs+NIZSSzbBoD+6kjiISXjfYdwj1ZRQ81dz/w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "(MIT AND Zlib)" + }, "node_modules/parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", @@ -6684,6 +6822,13 @@ "dev": true, "license": "MIT" }, + "node_modules/performance-now": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz", + "integrity": "sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==", + "license": "MIT", + "optional": true + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -6964,6 +7109,16 @@ ], "license": "MIT" }, + "node_modules/raf": { + "version": "3.4.1", + "resolved": "https://registry.npmjs.org/raf/-/raf-3.4.1.tgz", + "integrity": "sha512-Sq4CW4QhwOHE8ucn6J34MqtZCeWFP2aQSmrlroYgqAV1PjStIhJXxYuTgUIfkEk7zTLjmIjLmU5q+fbD1NnOJA==", + "license": "MIT", + "optional": true, + "dependencies": { + "performance-now": "^2.1.0" + } + }, "node_modules/react": { "version": "19.2.8", "resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz", @@ -7120,6 +7275,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/regenerator-runtime": { + "version": "0.13.11", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz", + "integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==", + "license": "MIT", + "optional": true + }, "node_modules/regexp.prototype.flags": { "version": "1.5.4", "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz", @@ -7199,6 +7361,16 @@ "node": ">=0.10.0" } }, + "node_modules/rgbcolor": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/rgbcolor/-/rgbcolor-1.0.1.tgz", + "integrity": "sha512-9aZLIrhRaD97sgVhtJOW6ckOEh6/GnvQtdVNfdZ6s67+3/XwLS9lBcQYzEEhYVeUowN7pRzMLsyGhK2i/xvWbw==", + "license": "MIT OR SEE LICENSE IN FEEL-FREE.md", + "optional": true, + "engines": { + "node": ">= 0.8.15" + } + }, "node_modules/rolldown": { "version": "1.1.5", "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.5.tgz", @@ -7580,6 +7752,16 @@ "dev": true, "license": "MIT" }, + "node_modules/stackblur-canvas": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/stackblur-canvas/-/stackblur-canvas-2.7.0.tgz", + "integrity": "sha512-yf7OENo23AGJhBriGx0QivY5JP6Y1HbrrDI6WLt6C5auYZXlQrheoY8hD4ibekFKz1HOfE48Ww8kMWMnJD/zcQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=0.1.14" + } + }, "node_modules/std-env": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.0.0.tgz", @@ -7809,6 +7991,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/svg-pathdata": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/svg-pathdata/-/svg-pathdata-6.0.3.tgz", + "integrity": "sha512-qsjeeq5YjBZ5eMdFuUa4ZosMLxgr5RZ+F+Y1OrDhuOCEInRMA3x74XdBtggJcj9kOeInz0WE+LgCPDkZFlBYJw==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/tailwindcss": { "version": "3.4.19", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz", @@ -7877,6 +8069,16 @@ "node": ">= 6" } }, + "node_modules/text-segmentation": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/text-segmentation/-/text-segmentation-1.0.3.tgz", + "integrity": "sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw==", + "license": "MIT", + "optional": true, + "dependencies": { + "utrie": "^1.0.2" + } + }, "node_modules/thenify": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", @@ -8330,6 +8532,16 @@ "dev": true, "license": "MIT" }, + "node_modules/utrie": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/utrie/-/utrie-1.0.2.tgz", + "integrity": "sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw==", + "license": "MIT", + "optional": true, + "dependencies": { + "base64-arraybuffer": "^1.0.2" + } + }, "node_modules/victory-vendor": { "version": "36.9.2", "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-36.9.2.tgz", diff --git a/src/Web/autopilot-monitor-web/package.json b/src/Web/autopilot-monitor-web/package.json index bb1f99a0..6bf7ffec 100644 --- a/src/Web/autopilot-monitor-web/package.json +++ b/src/Web/autopilot-monitor-web/package.json @@ -23,6 +23,7 @@ "@xyflow/react": "^12.11.3", "dagre": "^0.8.5", "fflate": "^0.8.3", + "jspdf": "^4.2.1", "leaflet": "^1.9.4", "next": "^16.3.1", "react": "^19.2.8", diff --git a/src/Web/autopilot-monitor-web/utils/pdf/__tests__/appReportData.test.ts b/src/Web/autopilot-monitor-web/utils/pdf/__tests__/appReportData.test.ts new file mode 100644 index 00000000..78b294b1 --- /dev/null +++ b/src/Web/autopilot-monitor-web/utils/pdf/__tests__/appReportData.test.ts @@ -0,0 +1,245 @@ +import { describe, it, expect } from "vitest"; +import { + appReportFileName, + scopeLabel, + prepareReportModel, + formatReportDate, + type AppReportAnalytics, + type AppReportInput, +} from "../appReportData"; + +function makeAnalytics(overrides: Partial = {}): AppReportAnalytics { + return { + appType: "MSI", + windowDays: 90, + bucket: "week", + summary: { + totalInstalls: 912, + succeeded: 888, + skipped: 20, + unmeasured: 3, + failed: 1, + failureRate: 0.1, + avgDurationSeconds: 55, + p95DurationSeconds: 110, + trend: "stable", + trendDelta: 0, + flakinessScore: 0.02, + }, + timeSeries: [ + { bucketStart: "2026-08-03T00:00:00Z", installs: 10, succeeded: 9, failed: 1, failureRate: 10, avgDurationSeconds: 50 }, + ], + versionBreakdown: [], + topFailureCodes: [], + detectionLiesCount: 0, + deviceModelBreakdown: [], + versionRegressions: [], + ...overrides, + }; +} + +function makeInput(overrides: Partial = {}): AppReportInput { + return { + analytics: makeAnalytics(overrides), + appName: "RealmJoin Agent (Device)", + days: 90, + scopeLabel: "All tenants (aggregated)", + generatedAt: new Date(2026, 7, 18), // Aug 18, 2026 local + }; +} + +describe("appReportFileName", () => { + it("slugifies the app name with window and date", () => { + expect(appReportFileName("RealmJoin Agent (Device)", 90, new Date(2026, 7, 18))).toBe( + "app-report-realmjoin-agent-device-90d-20260818.pdf" + ); + }); + + it("falls back to 'app' when the name has no latin characters", () => { + expect(appReportFileName("日本語アプリ", 30, new Date(2026, 0, 5))).toBe( + "app-report-app-30d-20260105.pdf" + ); + }); + + it("caps overlong names without a trailing dash", () => { + const name = "A".repeat(100) + " suffix"; + const file = appReportFileName(name, 7, new Date(2026, 7, 18)); + const slug = file.replace("app-report-", "").replace("-7d-20260818.pdf", ""); + expect(slug.length).toBeLessThanOrEqual(60); + expect(slug.endsWith("-")).toBe(false); + }); +}); + +describe("scopeLabel", () => { + it("labels the aggregated global view", () => { + expect( + scopeLabel({ isAggregatedGlobalView: true, selectedTenantName: undefined, selectedTenantId: "" }) + ).toBe("All tenants (aggregated)"); + }); + + it("prefers the friendly tenant name", () => { + expect( + scopeLabel({ isAggregatedGlobalView: false, selectedTenantName: "contoso.com", selectedTenantId: "guid" }) + ).toBe("Tenant: contoso.com"); + }); + + it("falls back to a shortened tenant id, then to 'Current tenant'", () => { + expect( + scopeLabel({ + isAggregatedGlobalView: false, + selectedTenantName: undefined, + selectedTenantId: "12345678-abcd-ef00-0000-000000000000", + }) + ).toBe("Tenant: 12345678…"); + expect( + scopeLabel({ isAggregatedGlobalView: false, selectedTenantName: undefined, selectedTenantId: "" }) + ).toBe("Current tenant"); + }); +}); + +describe("prepareReportModel", () => { + it("builds meta line, file name, and 8 KPIs", () => { + const model = prepareReportModel(makeInput()); + expect(model.metaLine).toBe( + "90 day window · weekly buckets · All tenants (aggregated) · Generated Aug 18, 2026" + ); + expect(model.fileName).toBe("app-report-realmjoin-agent-device-90d-20260818.pdf"); + expect(model.kpis).toHaveLength(8); + expect(model.kpis[0]).toMatchObject({ label: "Total installs", value: "912" }); + expect(model.kpis[1].hint).toBe("+20 skipped (not applicable)"); + expect(model.kpis[3]).toMatchObject({ value: "0.1%", tone: "default" }); + expect(model.kpis[6].value).toBe("stable"); + expect(model.kpis[7].value).toBe("2%"); + }); + + it("marks elevated failure rates with warning/danger tones", () => { + const warn = prepareReportModel( + makeInput({ summary: { ...makeAnalytics().summary, failureRate: 7.5 } }) + ); + expect(warn.kpis[3].tone).toBe("warning"); + const danger = prepareReportModel( + makeInput({ summary: { ...makeAnalytics().summary, failureRate: 25 } }) + ); + expect(danger.kpis[3].tone).toBe("danger"); + }); + + it("renders trend deltas as signed values (Helvetica has no arrow glyphs)", () => { + const improving = prepareReportModel( + makeInput({ summary: { ...makeAnalytics().summary, trend: "improving", trendDelta: -1.23 } }) + ); + expect(improving.kpis[6].value).toBe("-1.2 pp"); + expect(improving.kpis[6].hint).toBe("improving"); + const worsening = prepareReportModel( + makeInput({ summary: { ...makeAnalytics().summary, trend: "worsening", trendDelta: 2.5 } }) + ); + expect(worsening.kpis[6].value).toBe("+2.5 pp"); + }); + + it("truncates failure codes to 10 rows and reports the remainder", () => { + const codes = Array.from({ length: 12 }, (_, i) => ({ + code: `0x8007000${i}`, + exitCode: null, + count: 12 - i, + })); + const model = prepareReportModel(makeInput({ topFailureCodes: codes })); + expect(model.failureCodes.rows).toHaveLength(10); + expect(model.failureCodes.moreCount).toBe(2); + }); + + it("enriches known error codes and labels unknown ones", () => { + const model = prepareReportModel( + makeInput({ + topFailureCodes: [ + { code: "0x80070005", exitCode: null, count: 3 }, + { code: "0xDEADBEEF", exitCode: null, count: 1 }, + ], + }) + ); + expect(model.failureCodes.rows[0][1]).not.toBe("Unknown code"); + expect(model.failureCodes.rows[0][1].length).toBeGreaterThan(0); + expect(model.failureCodes.rows[1][1]).toBe("Unknown code"); + }); + + it("sorts device models by lift and truncates to 10", () => { + const models = Array.from({ length: 12 }, (_, i) => ({ + manufacturer: "Contoso", + model: `Model ${i}`, + installs: 10, + failed: i, + failureRate: i * 5, + liftVsBaseline: i * 0.3, + })); + const model = prepareReportModel(makeInput({ deviceModelBreakdown: models })); + expect(model.deviceModels.rows).toHaveLength(10); + expect(model.deviceModels.moreCount).toBe(2); + expect(model.deviceModels.rows[0][1]).toBe("Model 11"); + expect(model.deviceModels.rows[0][5]).toBe("3.30x"); + }); + + it("shows version durations only when measured", () => { + const model = prepareReportModel( + makeInput({ + versionBreakdown: [ + { appVersion: "1.0", installs: 5, failed: 0, failureRate: 0, measuredInstalls: 5, medianDurationSeconds: 90, p95DurationSeconds: 120 }, + { appVersion: "1.1", installs: 3, failed: 1, failureRate: 33.3, measuredInstalls: 0, medianDurationSeconds: 0, p95DurationSeconds: 0 }, + ], + }) + ); + expect(model.versions.rows[0][4]).toBe("2m"); + expect(model.versions.rows[1][4]).toBe("—"); + expect(model.versions.rows[1][5]).toBe("—"); + }); + + it("emits detection-lies text with singular/plural forms", () => { + expect(prepareReportModel(makeInput({ detectionLiesCount: 0 })).detectionLiesText).toBeNull(); + expect(prepareReportModel(makeInput({ detectionLiesCount: 1 })).detectionLiesText).toContain( + "1 install was reported as Succeeded" + ); + expect(prepareReportModel(makeInput({ detectionLiesCount: 18 })).detectionLiesText).toContain( + "18 installs were reported as Succeeded" + ); + }); + + it("formats regression lines and caps them at 3", () => { + const reg = { + currentVersion: "2.0", + previousVersion: "1.9", + currentMedianSeconds: 300, + previousMedianSeconds: 120, + currentMeasuredCount: 40, + previousMeasuredCount: 60, + lift: 2.5, + }; + const model = prepareReportModel(makeInput({ versionRegressions: [reg, reg, reg, reg] })); + expect(model.regressionLines).toHaveLength(3); + expect(model.regressionLines[0]).toBe( + "Duration regression: median install duration rose from 2.0 to 5.0 min after version 2.0 (40 measured installs vs 60 on 1.9 — lift 2.5x)" + ); + }); + + it("produces a valid model for an app with no data at all", () => { + const model = prepareReportModel( + makeInput({ + summary: { + totalInstalls: 0, succeeded: 0, skipped: 0, unmeasured: 0, failed: 0, + failureRate: 0, avgDurationSeconds: 0, p95DurationSeconds: 0, + trend: "stable", trendDelta: null, flakinessScore: 0, + }, + timeSeries: [], + }) + ); + expect(model.kpis[0].value).toBe("0"); + expect(model.kpis[4].value).toBe("—"); + expect(model.kpis[6].value).toBe("—"); + expect(model.timeSeries).toEqual([]); + expect(model.failureCodes.rows).toEqual([]); + expect(model.failureCodes.moreCount).toBe(0); + }); +}); + +describe("formatReportDate", () => { + it("is locale-independent", () => { + expect(formatReportDate(new Date(2026, 0, 5))).toBe("Jan 5, 2026"); + expect(formatReportDate(new Date(2026, 11, 31))).toBe("Dec 31, 2026"); + }); +}); diff --git a/src/Web/autopilot-monitor-web/utils/pdf/__tests__/appReportPdf.smoke.test.ts b/src/Web/autopilot-monitor-web/utils/pdf/__tests__/appReportPdf.smoke.test.ts new file mode 100644 index 00000000..47af1636 --- /dev/null +++ b/src/Web/autopilot-monitor-web/utils/pdf/__tests__/appReportPdf.smoke.test.ts @@ -0,0 +1,74 @@ +// Smoke test: assemble a full report document under node (jsPDF is DOM-free for +// document assembly; only the download step in generateAppReportPdf needs a browser). +import { describe, it, expect } from "vitest"; +import { buildAppReportDoc } from "../appReportPdf"; +import { prepareReportModel, type AppReportInput } from "../appReportData"; + +function makeInput(): AppReportInput { + const timeSeries = Array.from({ length: 13 }, (_, i) => ({ + bucketStart: new Date(Date.UTC(2026, 4, 25 + i * 7)).toISOString(), + installs: 10 + i * 15, + succeeded: 9 + i * 15, + failed: i % 4 === 0 ? 1 : 0, + failureRate: i % 4 === 0 ? 5 : 0, + avgDurationSeconds: 90 - i * 3, + })); + return { + analytics: { + appType: "MSI", + windowDays: 90, + bucket: "week", + summary: { + totalInstalls: 912, succeeded: 888, skipped: 20, unmeasured: 3, failed: 1, + failureRate: 0.1, avgDurationSeconds: 55, p95DurationSeconds: 110, + trend: "stable", trendDelta: 0, flakinessScore: 0.02, + }, + timeSeries, + versionBreakdown: [ + { appVersion: "1.2.0", installs: 500, failed: 1, failureRate: 0.2, measuredInstalls: 480, medianDurationSeconds: 52, p95DurationSeconds: 100 }, + ], + topFailureCodes: [ + { code: "esp_apps_install_failure", exitCode: 1618, count: 1 }, + ], + detectionLiesCount: 18, + deviceModelBreakdown: [ + { manufacturer: "Dell Inc.", model: "Latitude 5440", installs: 200, failed: 1, failureRate: 0.5, liftVsBaseline: 1.1 }, + ], + versionRegressions: [], + }, + appName: "RealmJoin Agent (Device)", + days: 90, + scopeLabel: "All tenants (aggregated)", + generatedAt: new Date(2026, 7, 18), + }; +} + +describe("buildAppReportDoc", () => { + it("assembles a multi-section report without throwing", () => { + const input = makeInput(); + const doc = buildAppReportDoc(prepareReportModel(input), input.generatedAt); + expect(doc.getNumberOfPages()).toBeGreaterThanOrEqual(1); + const raw = doc.output(); + expect(raw.startsWith("%PDF")).toBe(true); + }); + + it("handles an app with no data at all", () => { + const input = makeInput(); + input.analytics = { + ...input.analytics, + timeSeries: [], + versionBreakdown: [], + topFailureCodes: [], + detectionLiesCount: 0, + deviceModelBreakdown: [], + versionRegressions: [], + summary: { + totalInstalls: 0, succeeded: 0, skipped: 0, unmeasured: 0, failed: 0, + failureRate: 0, avgDurationSeconds: 0, p95DurationSeconds: 0, + trend: "stable", trendDelta: null, flakinessScore: 0, + }, + }; + const doc = buildAppReportDoc(prepareReportModel(input), input.generatedAt); + expect(doc.getNumberOfPages()).toBeGreaterThanOrEqual(1); + }); +}); diff --git a/src/Web/autopilot-monitor-web/utils/pdf/__tests__/chartMath.test.ts b/src/Web/autopilot-monitor-web/utils/pdf/__tests__/chartMath.test.ts new file mode 100644 index 00000000..74e19daf --- /dev/null +++ b/src/Web/autopilot-monitor-web/utils/pdf/__tests__/chartMath.test.ts @@ -0,0 +1,87 @@ +import { describe, it, expect } from "vitest"; +import { niceTicks, linearScale, tickIndices, formatBucketLabel } from "../chartMath"; + +describe("niceTicks", () => { + it("returns a safe default for zero", () => { + expect(niceTicks(0)).toEqual({ max: 1, ticks: [0, 1] }); + }); + + it("returns a safe default for negative and non-finite input", () => { + expect(niceTicks(-5)).toEqual({ max: 1, ticks: [0, 1] }); + expect(niceTicks(NaN)).toEqual({ max: 1, ticks: [0, 1] }); + expect(niceTicks(Infinity)).toEqual({ max: 1, ticks: [0, 1] }); + }); + + it("rounds a small maximum up to a nice value", () => { + const { max, ticks } = niceTicks(7); + expect(max).toBe(8); + expect(ticks[0]).toBe(0); + expect(ticks[ticks.length - 1]).toBe(8); + }); + + it("rounds 93 up to 100 with clean steps", () => { + const { max, ticks } = niceTicks(93); + expect(max).toBe(100); + expect(ticks).toEqual([0, 25, 50, 75, 100]); + }); + + it("keeps an exact multiple as the maximum", () => { + const { max } = niceTicks(100); + expect(max).toBe(100); + }); + + it("produces float-clean ticks for fractional maxima", () => { + const { ticks } = niceTicks(0.7); + for (const t of ticks) { + // No accumulation artifacts like 0.30000000000000004. + expect(String(t).length).toBeLessThanOrEqual(6); + } + }); +}); + +describe("linearScale", () => { + it("maps the domain onto the range linearly", () => { + const scale = linearScale(100, 50); + expect(scale(0)).toBe(0); + expect(scale(50)).toBe(25); + expect(scale(100)).toBe(50); + }); + + it("returns a zero scale for an empty domain", () => { + expect(linearScale(0, 50)(10)).toBe(0); + }); +}); + +describe("tickIndices", () => { + it("returns all indices when n fits within maxLabels", () => { + expect(tickIndices(7, 10)).toEqual([0, 1, 2, 3, 4, 5, 6]); + }); + + it("returns evenly spaced indices including first and last for 90/10", () => { + const idx = tickIndices(90, 10); + expect(idx.length).toBeLessThanOrEqual(10); + expect(idx[0]).toBe(0); + expect(idx[idx.length - 1]).toBe(89); + // Strictly ascending, no duplicates. + for (let i = 1; i < idx.length; i++) { + expect(idx[i]).toBeGreaterThan(idx[i - 1]); + } + }); + + it("handles empty and degenerate inputs", () => { + expect(tickIndices(0, 10)).toEqual([]); + expect(tickIndices(5, 0)).toEqual([]); + expect(tickIndices(1, 10)).toEqual([0]); + }); +}); + +describe("formatBucketLabel", () => { + it("formats an ISO date as UTC M/D", () => { + expect(formatBucketLabel("2026-08-03T00:00:00Z")).toBe("8/3"); + expect(formatBucketLabel("2026-12-25T00:00:00Z")).toBe("12/25"); + }); + + it("returns the raw string for an unparseable date", () => { + expect(formatBucketLabel("not-a-date")).toBe("not-a-date"); + }); +}); diff --git a/src/Web/autopilot-monitor-web/utils/pdf/appReportData.ts b/src/Web/autopilot-monitor-web/utils/pdf/appReportData.ts new file mode 100644 index 00000000..127b89e8 --- /dev/null +++ b/src/Web/autopilot-monitor-web/utils/pdf/appReportData.ts @@ -0,0 +1,293 @@ +// Pure data preparation for the app install report PDF — no jsPDF import, so the +// page can statically import the helpers here without pulling jsPDF into the +// route chunk (jsPDF enters only via the lazily imported appReportPdf module). + +import { getErrorCodeEntry, formatErrorCode } from "@/utils/errorCodeMap"; + +// Structural mirror of the fields the report uses from the page's module-local +// AnalyticsResponse. A field rename on the page surfaces as a compile error at +// the generateAppReportPdf call site — intentional, cheap coupling. +export interface ReportTimeSeriesPoint { + bucketStart: string; + installs: number; + succeeded: number; + failed: number; + failureRate: number; + avgDurationSeconds: number; +} + +export interface AppReportAnalytics { + appType: string; + windowDays: number; + bucket: "day" | "week"; + summary: { + totalInstalls: number; + succeeded: number; + skipped: number; + unmeasured: number; + failed: number; + failureRate: number; + avgDurationSeconds: number; + p95DurationSeconds: number; + trend: "improving" | "worsening" | "stable"; + trendDelta: number | null; + flakinessScore: number; + }; + timeSeries: ReportTimeSeriesPoint[]; + versionBreakdown: Array<{ + appVersion: string; + installs: number; + failed: number; + failureRate: number; + measuredInstalls: number; + medianDurationSeconds: number; + p95DurationSeconds: number; + }>; + topFailureCodes: Array<{ + code: string; + exitCode: number | null; + count: number; + }>; + detectionLiesCount: number; + deviceModelBreakdown: Array<{ + manufacturer: string; + model: string; + installs: number; + failed: number; + failureRate: number; + liftVsBaseline: number; + }>; + versionRegressions: Array<{ + currentVersion: string; + previousVersion: string; + currentMedianSeconds: number; + previousMedianSeconds: number; + currentMeasuredCount: number; + previousMeasuredCount: number; + lift: number; + }>; +} + +export interface AppReportInput { + analytics: AppReportAnalytics; + appName: string; + days: number; + scopeLabel: string; + generatedAt: Date; +} + +export type KpiTone = "default" | "success" | "danger" | "warning"; + +export interface ReportKpi { + label: string; + value: string; + tone: KpiTone; + hint?: string; +} + +export interface ReportTable { + rows: string[][]; + /** Rows cut off by the per-table cap — rendered as a trailing "+N more" note. */ + moreCount: number; +} + +export interface AppReportModel { + appName: string; + appType: string; + metaLine: string; + fileName: string; + kpis: ReportKpi[]; + detectionLiesText: string | null; + regressionLines: string[]; + bucket: "day" | "week"; + timeSeries: ReportTimeSeriesPoint[]; + failureCodes: ReportTable; + deviceModels: ReportTable; + versions: ReportTable; +} + +const MAX_FAILURE_CODE_ROWS = 10; +const MAX_DEVICE_MODEL_ROWS = 10; +const MAX_VERSION_ROWS = 8; +const MAX_REGRESSION_LINES = 3; +const MAX_SLUG_LENGTH = 60; + +const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]; + +/** Locale-independent "Aug 20, 2026" (local time of the exporting user). */ +export function formatReportDate(date: Date): string { + return `${MONTHS[date.getMonth()]} ${date.getDate()}, ${date.getFullYear()}`; +} + +/** `app-report-realmjoin-agent-device-90d-20260818.pdf` */ +export function appReportFileName(appName: string, days: number, date: Date): string { + const slug = + appName + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, "") + .slice(0, MAX_SLUG_LENGTH) + .replace(/-+$/, "") || "app"; + const y = date.getFullYear(); + const m = String(date.getMonth() + 1).padStart(2, "0"); + const d = String(date.getDate()).padStart(2, "0"); + return `app-report-${slug}-${days}d-${y}${m}${d}.pdf`; +} + +/** Human-readable tenant scope for the report title block. */ +export function scopeLabel(scope: { + isAggregatedGlobalView: boolean; + selectedTenantName?: string; + selectedTenantId: string; +}): string { + if (scope.isAggregatedGlobalView) return "All tenants (aggregated)"; + if (scope.selectedTenantName) return `Tenant: ${scope.selectedTenantName}`; + if (scope.selectedTenantId) return `Tenant: ${scope.selectedTenantId.slice(0, 8)}…`; + return "Current tenant"; +} + +// Same formatting rules as the app detail page. +function formatDuration(s: number): string { + if (!s) return "—"; + if (s < 60) return `${Math.round(s)}s`; + if (s < 3600) return `${Math.round(s / 60)}m`; + return `${(s / 3600).toFixed(1)}h`; +} + +// PDF variant of the page's trendText: built-in Helvetica (WinAnsi) has no +// arrow glyphs, so improving/worsening render as signed deltas instead. +function trendText(trend: string, delta: number | null): string { + if (delta == null) return "—"; + if (trend === "improving") return `-${Math.abs(delta).toFixed(1)} pp`; + if (trend === "worsening") return `+${Math.abs(delta).toFixed(1)} pp`; + return "stable"; +} + +function failureRateTone(rate: number): KpiTone { + if (rate >= 20) return "danger"; + if (rate >= 5) return "warning"; + return "default"; +} + +/** Build the render-ready report model from the page's analytics response. */ +export function prepareReportModel(input: AppReportInput): AppReportModel { + const { analytics, appName, days, generatedAt } = input; + const s = analytics.summary; + + const kpis: ReportKpi[] = [ + { label: "Total installs", value: String(s.totalInstalls), tone: "default" }, + { + label: "Succeeded", + value: String(s.succeeded), + tone: "success", + hint: s.skipped > 0 ? `+${s.skipped} skipped (not applicable)` : undefined, + }, + { label: "Failed", value: String(s.failed), tone: s.failed > 0 ? "danger" : "default" }, + { + label: "Failure rate", + value: `${s.failureRate.toFixed(1)}%`, + tone: failureRateTone(s.failureRate), + }, + { + label: "Avg duration", + value: formatDuration(s.avgDurationSeconds), + tone: "default", + hint: + s.unmeasured > 0 + ? `measured installs only — ${s.unmeasured} without observed start` + : "measured installs only", + }, + { label: "P95 duration", value: formatDuration(s.p95DurationSeconds), tone: "default" }, + { + label: "Trend", + value: trendText(s.trend, s.trendDelta), + tone: "default", + hint: s.trendDelta != null && s.trend !== "stable" ? s.trend : undefined, + }, + { + label: "Flakiness", + value: `${(s.flakinessScore * 100).toFixed(0)}%`, + tone: "default", + hint: "% of installs with retries", + }, + ]; + + const failureCodeRows = analytics.topFailureCodes.slice(0, MAX_FAILURE_CODE_ROWS).map((row) => { + const entry = getErrorCodeEntry(row.code); + const exitEntry = row.exitCode != null ? getErrorCodeEntry(row.exitCode) : null; + const exitDisplay = + row.exitCode != null + ? exitEntry + ? `${row.exitCode} (${exitEntry.description.slice(0, 24)}${exitEntry.description.length > 24 ? "…" : ""})` + : String(row.exitCode) + : "—"; + return [ + formatErrorCode(row.code), + entry ? entry.description : "Unknown code", + exitDisplay, + String(row.count), + ]; + }); + + const deviceModelRows = [...analytics.deviceModelBreakdown] + .sort((a, b) => b.liftVsBaseline - a.liftVsBaseline || b.installs - a.installs) + .slice(0, MAX_DEVICE_MODEL_ROWS) + .map((row) => [ + row.manufacturer, + row.model, + String(row.installs), + String(row.failed), + `${row.failureRate.toFixed(1)}%`, + `${row.liftVsBaseline.toFixed(2)}x`, + ]); + + const versionRows = analytics.versionBreakdown.slice(0, MAX_VERSION_ROWS).map((row) => [ + row.appVersion, + String(row.installs), + String(row.failed), + `${row.failureRate.toFixed(1)}%`, + row.measuredInstalls > 0 ? formatDuration(row.medianDurationSeconds) : "—", + row.measuredInstalls > 0 ? formatDuration(row.p95DurationSeconds) : "—", + ]); + + const regressionLines = (analytics.versionRegressions ?? []) + .slice(0, MAX_REGRESSION_LINES) + .map( + (reg) => + `Duration regression: median install duration rose from ${(reg.previousMedianSeconds / 60).toFixed(1)} to ` + + `${(reg.currentMedianSeconds / 60).toFixed(1)} min after version ${reg.currentVersion} ` + + `(${reg.currentMeasuredCount} measured installs vs ${reg.previousMeasuredCount} on ${reg.previousVersion} — lift ${reg.lift}x)` + ); + + const detectionLiesText = + analytics.detectionLiesCount > 0 + ? `${analytics.detectionLiesCount} install${analytics.detectionLiesCount === 1 ? " was" : "s were"} ` + + "reported as Succeeded but the detection rule did not find the app afterwards. Check the detection rule." + : null; + + return { + appName, + appType: analytics.appType, + metaLine: + `${analytics.windowDays} day window · ${analytics.bucket === "day" ? "daily" : "weekly"} buckets · ` + + `${input.scopeLabel} · Generated ${formatReportDate(generatedAt)}`, + fileName: appReportFileName(appName, days, generatedAt), + kpis, + detectionLiesText, + regressionLines, + bucket: analytics.bucket, + timeSeries: analytics.timeSeries, + failureCodes: { + rows: failureCodeRows, + moreCount: Math.max(0, analytics.topFailureCodes.length - MAX_FAILURE_CODE_ROWS), + }, + deviceModels: { + rows: deviceModelRows, + moreCount: Math.max(0, analytics.deviceModelBreakdown.length - MAX_DEVICE_MODEL_ROWS), + }, + versions: { + rows: versionRows, + moreCount: Math.max(0, analytics.versionBreakdown.length - MAX_VERSION_ROWS), + }, + }; +} diff --git a/src/Web/autopilot-monitor-web/utils/pdf/appReportPdf.ts b/src/Web/autopilot-monitor-web/utils/pdf/appReportPdf.ts new file mode 100644 index 00000000..1823bb61 --- /dev/null +++ b/src/Web/autopilot-monitor-web/utils/pdf/appReportPdf.ts @@ -0,0 +1,180 @@ +// App install report PDF builder — the entry point of the lazily loaded PDF +// chunk. This is the ONLY module that imports jsPDF at runtime; the page loads +// it via `await import("@/utils/pdf/appReportPdf")` on button click, so jsPDF +// never enters the route bundle. + +import { jsPDF } from "jspdf"; +import { pdfColors, pdfPage, contentW } from "./pdfTheme"; +import { + startReport, + ensureSpace, + sectionTitle, + kpiRow, + calloutBox, + drawTable, + finalizeFooters, + truncateToWidth, + type KpiCell, +} from "./pdfPrimitives"; +import { drawStackedBarChart, drawLineChart } from "./pdfCharts"; +import { + prepareReportModel, + formatReportDate, + type AppReportInput, + type AppReportModel, + type KpiTone, +} from "./appReportData"; + +export interface AppReportResult { + pageCount: number; + fileName: string; +} + +const KPI_TONE_COLORS: Record = { + default: pdfColors.text, + success: pdfColors.success, + danger: pdfColors.danger, + warning: pdfColors.warning, +}; + +const BAR_CHART_H = 58; +const LINE_CHART_H = 52; + +/** Generate the report and hand it to the browser as a file download. */ +export function generateAppReportPdf(input: AppReportInput): AppReportResult { + const model = prepareReportModel(input); + const doc = buildAppReportDoc(model, input.generatedAt); + downloadBlob(doc.output("blob"), model.fileName); + return { pageCount: doc.getNumberOfPages(), fileName: model.fileName }; +} + +/** Document assembly, separated from the browser download for testability. */ +export function buildAppReportDoc(model: AppReportModel, generatedAt: Date): jsPDF { + const doc = new jsPDF({ orientation: "portrait", unit: "mm", format: "a4" }); + const ctx = startReport(doc, "App Install Report"); + + // Title block: app name (wrapped) + appType badge + meta line. + doc.setFont("helvetica", "bold"); + doc.setFontSize(16); + doc.setTextColor(pdfColors.text); + const nameLines = doc.splitTextToSize(model.appName, contentW - 30) as string[]; + ctx.y += 4; + doc.text(nameLines, pdfPage.margin, ctx.y + 4); + if (model.appType) { + const lastLine = nameLines[nameLines.length - 1] ?? ""; + const badgeX = pdfPage.margin + doc.getTextWidth(lastLine) + 3; + const badgeY = ctx.y + (nameLines.length - 1) * 6.5; + doc.setFontSize(7); + const badgeText = truncateToWidth(doc, model.appType, 24); + const badgeW = doc.getTextWidth(badgeText) + 4; + doc.setFillColor(pdfColors.badgeBg); + doc.roundedRect(badgeX, badgeY, badgeW, 5, 1, 1, "F"); + doc.setTextColor(pdfColors.badgeText); + doc.text(badgeText, badgeX + 2, badgeY + 3.5); + } + ctx.y += nameLines.length * 6.5 + 2; + doc.setFont("helvetica", "normal"); + doc.setFontSize(8.5); + doc.setTextColor(pdfColors.subtle); + doc.text(truncateToWidth(doc, model.metaLine, contentW), pdfPage.margin, ctx.y + 3); + ctx.y += 8; + + const kpiCells: KpiCell[] = model.kpis.map((kpi) => ({ + label: kpi.label, + value: kpi.value, + valueColor: KPI_TONE_COLORS[kpi.tone], + hint: kpi.hint, + })); + kpiRow(ctx, kpiCells, 4); + ctx.y += 2; + + if (model.detectionLiesText) { + calloutBox(ctx, `Heads up: ${model.detectionLiesText}`); + } + for (const line of model.regressionLines) { + calloutBox(ctx, line); + } + + sectionTitle(ctx, "Installs over time (success vs failure)"); + ensureSpace(ctx, BAR_CHART_H + 2); + drawStackedBarChart(doc, { x: pdfPage.margin, y: ctx.y, w: contentW, h: BAR_CHART_H }, model.timeSeries); + ctx.y += BAR_CHART_H + 2; + + sectionTitle(ctx, "Avg Install Duration over time"); + ensureSpace(ctx, LINE_CHART_H + 2); + drawLineChart(doc, { x: pdfPage.margin, y: ctx.y, w: contentW, h: LINE_CHART_H }, model.timeSeries); + ctx.y += LINE_CHART_H + 2; + + sectionTitle(ctx, "Top Failure Codes"); + if (model.failureCodes.rows.length > 0) { + drawTable(ctx, { + columns: [ + { header: "Code", width: 30 }, + { header: "Description", width: 88 }, + { header: "Exit code", width: 44 }, + { header: "Count", width: 18, align: "right" }, + ], + rows: model.failureCodes.rows, + moreCount: model.failureCodes.moreCount, + }); + } else { + emptySectionNote(ctx, "No failures recorded in this window."); + } + + sectionTitle(ctx, "Device Model Correlation"); + if (model.deviceModels.rows.length > 0) { + drawTable(ctx, { + columns: [ + { header: "Manufacturer", width: 34 }, + { header: "Model", width: 66 }, + { header: "Installs", width: 20, align: "right" }, + { header: "Failed", width: 20, align: "right" }, + { header: "Failure rate", width: 22, align: "right" }, + { header: "Lift vs base", width: 18, align: "right" }, + ], + rows: model.deviceModels.rows, + moreCount: model.deviceModels.moreCount, + }); + } else { + emptySectionNote(ctx, "Not enough installs per device model to compute correlation."); + } + + if (model.versions.rows.length > 0) { + sectionTitle(ctx, "Version Breakdown"); + drawTable(ctx, { + columns: [ + { header: "Version", width: 60 }, + { header: "Installs", width: 22, align: "right" }, + { header: "Failed", width: 22, align: "right" }, + { header: "Failure rate", width: 26, align: "right" }, + { header: "Median dur.", width: 25, align: "right" }, + { header: "P95 dur.", width: 25, align: "right" }, + ], + rows: model.versions.rows, + moreCount: model.versions.moreCount, + }); + } + + finalizeFooters(doc, `Generated by Autopilot Monitor · ${formatReportDate(generatedAt)}`); + return doc; +} + +function emptySectionNote(ctx: ReturnType, text: string) { + ensureSpace(ctx, 8); + ctx.doc.setFont("helvetica", "italic"); + ctx.doc.setFontSize(8); + ctx.doc.setTextColor(pdfColors.faint); + ctx.doc.text(text, pdfPage.margin + 2, ctx.y + 3); + ctx.y += 8; +} + +// Same blob-anchor pattern as every other export in the app +// (cf. app/admin/components/SessionExportSection.tsx). +function downloadBlob(blob: Blob, fileName: string) { + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = fileName; + a.click(); + URL.revokeObjectURL(url); +} diff --git a/src/Web/autopilot-monitor-web/utils/pdf/chartMath.ts b/src/Web/autopilot-monitor-web/utils/pdf/chartMath.ts new file mode 100644 index 00000000..74a0069a --- /dev/null +++ b/src/Web/autopilot-monitor-web/utils/pdf/chartMath.ts @@ -0,0 +1,53 @@ +// Pure chart math for the PDF chart renderers — no jsPDF dependency, unit-testable. + +/** + * Round a data maximum up to a "nice" axis maximum (1/2/2.5/5 progression) and + * return evenly spaced tick values from 0 to that maximum. + */ +export function niceTicks(maxValue: number, tickCount = 4): { max: number; ticks: number[] } { + if (!Number.isFinite(maxValue) || maxValue <= 0) { + return { max: 1, ticks: [0, 1] }; + } + const rawStep = maxValue / tickCount; + const magnitude = Math.pow(10, Math.floor(Math.log10(rawStep))); + const normalized = rawStep / magnitude; + const niceFactor = + normalized <= 1 ? 1 : normalized <= 2 ? 2 : normalized <= 2.5 ? 2.5 : normalized <= 5 ? 5 : 10; + const step = niceFactor * magnitude; + const max = Math.ceil(maxValue / step) * step; + const ticks: number[] = []; + // Float-safe loop: derive each tick from the integer index, not by accumulation. + const count = Math.round(max / step); + for (let i = 0; i <= count; i++) { + ticks.push(Number((i * step).toPrecision(12))); + } + return { max, ticks }; +} + +/** Map a data value in [0, domainMax] to a length in [0, range]. */ +export function linearScale(domainMax: number, range: number): (v: number) => number { + if (!Number.isFinite(domainMax) || domainMax <= 0) return () => 0; + return (v: number) => (v / domainMax) * range; +} + +/** + * Pick up to maxLabels evenly spaced indices out of n buckets, always including + * the first and last — so 7 daily buckets label all days while 90 label ~10. + */ +export function tickIndices(n: number, maxLabels: number): number[] { + if (n <= 0 || maxLabels <= 0) return []; + if (n <= maxLabels) return Array.from({ length: n }, (_, i) => i); + const step = (n - 1) / (maxLabels - 1); + const indices = new Set(); + for (let i = 0; i < maxLabels; i++) { + indices.add(Math.round(i * step)); + } + return [...indices].sort((a, b) => a - b); +} + +/** UTC M/D bucket label — mirrors formatBucketTick on the app detail page. */ +export function formatBucketLabel(iso: string): string { + const d = new Date(iso); + if (isNaN(d.getTime())) return iso; + return `${d.getUTCMonth() + 1}/${d.getUTCDate()}`; +} diff --git a/src/Web/autopilot-monitor-web/utils/pdf/pdfCharts.ts b/src/Web/autopilot-monitor-web/utils/pdf/pdfCharts.ts new file mode 100644 index 00000000..d365045d --- /dev/null +++ b/src/Web/autopilot-monitor-web/utils/pdf/pdfCharts.ts @@ -0,0 +1,164 @@ +// jsPDF renderers for the two report charts, drawn directly from timeSeries +// data with a print-friendly light palette (no DOM capture, theme-independent). + +import type { jsPDF } from "jspdf"; +import { pdfColors } from "./pdfTheme"; +import { niceTicks, linearScale, tickIndices, formatBucketLabel } from "./chartMath"; +import type { ReportTimeSeriesPoint } from "./appReportData"; + +export interface ChartRegion { + x: number; + y: number; + w: number; + h: number; +} + +const Y_LABEL_W = 12; +const X_LABEL_H = 6; +const LEGEND_H = 5; +const MAX_X_LABELS = 10; + +interface PlotArea { + x: number; + y: number; + w: number; + h: number; +} + +function plotArea(region: ChartRegion): PlotArea { + return { + x: region.x + Y_LABEL_W, + y: region.y + LEGEND_H, + w: region.w - Y_LABEL_W, + h: region.h - LEGEND_H - X_LABEL_H, + }; +} + +function drawEmptyPlaceholder(doc: jsPDF, region: ChartRegion) { + doc.setDrawColor(pdfColors.border); + doc.setLineWidth(0.3); + doc.roundedRect(region.x, region.y, region.w, region.h, 1, 1, "S"); + doc.setFont("helvetica", "normal"); + doc.setFontSize(9); + doc.setTextColor(pdfColors.faint); + doc.text("No data in this window", region.x + region.w / 2, region.y + region.h / 2, { + align: "center", + }); +} + +/** Gridlines + y tick labels + x bucket labels shared by both charts. */ +function drawAxes( + doc: jsPDF, + plot: PlotArea, + yTicks: number[], + yMax: number, + yUnit: string, + points: ReportTimeSeriesPoint[] +) { + const yScale = linearScale(yMax, plot.h); + doc.setFont("helvetica", "normal"); + doc.setFontSize(6.5); + for (const tick of yTicks) { + const y = plot.y + plot.h - yScale(tick); + doc.setDrawColor(pdfColors.border); + doc.setLineWidth(0.1); + doc.line(plot.x, y, plot.x + plot.w, y); + doc.setTextColor(pdfColors.subtle); + doc.text(`${tick}${yUnit}`, plot.x - 1.5, y + 1, { align: "right" }); + } + // Baseline slightly stronger than the gridlines. + doc.setDrawColor(pdfColors.subtle); + doc.setLineWidth(0.2); + doc.line(plot.x, plot.y + plot.h, plot.x + plot.w, plot.y + plot.h); + + const n = points.length; + const slotW = plot.w / n; + doc.setTextColor(pdfColors.subtle); + for (const i of tickIndices(n, MAX_X_LABELS)) { + const cx = plot.x + slotW * (i + 0.5); + doc.text(formatBucketLabel(points[i].bucketStart), cx, plot.y + plot.h + 4, { + align: "center", + }); + } +} + +function drawLegend( + doc: jsPDF, + region: ChartRegion, + entries: Array<{ label: string; color: string }> +) { + doc.setFont("helvetica", "normal"); + doc.setFontSize(6.5); + let x = region.x + region.w; + // Right-aligned: lay the entries out from the right edge backwards. + for (const entry of [...entries].reverse()) { + const labelW = doc.getTextWidth(entry.label); + x -= labelW; + doc.setTextColor(pdfColors.subtle); + doc.text(entry.label, x, region.y + 3); + x -= 3.5; + doc.setFillColor(entry.color); + doc.rect(x, region.y + 0.8, 2.5, 2.5, "F"); + x -= 5; + } +} + +/** "Installs over time": succeeded (green) with failed (red) stacked on top. */ +export function drawStackedBarChart(doc: jsPDF, region: ChartRegion, points: ReportTimeSeriesPoint[]) { + if (points.length === 0) { + drawEmptyPlaceholder(doc, region); + return; + } + const plot = plotArea(region); + const { max, ticks } = niceTicks(Math.max(...points.map((p) => p.succeeded + p.failed))); + drawLegend(doc, region, [ + { label: "Succeeded", color: pdfColors.success }, + { label: "Failed", color: pdfColors.danger }, + ]); + drawAxes(doc, plot, ticks, max, "", points); + const yScale = linearScale(max, plot.h); + const slotW = plot.w / points.length; + const barW = Math.max(0.4, slotW * 0.7); + points.forEach((p, i) => { + const x = plot.x + slotW * i + (slotW - barW) / 2; + const succeededH = yScale(p.succeeded); + const failedH = yScale(p.failed); + if (succeededH > 0) { + doc.setFillColor(pdfColors.success); + doc.rect(x, plot.y + plot.h - succeededH, barW, succeededH, "F"); + } + if (failedH > 0) { + doc.setFillColor(pdfColors.danger); + doc.rect(x, plot.y + plot.h - succeededH - failedH, barW, failedH, "F"); + } + }); +} + +/** "Avg Install Duration over time": blue polyline, dot markers when sparse. */ +export function drawLineChart(doc: jsPDF, region: ChartRegion, points: ReportTimeSeriesPoint[]) { + if (points.length === 0) { + drawEmptyPlaceholder(doc, region); + return; + } + const plot = plotArea(region); + const { max, ticks } = niceTicks(Math.max(...points.map((p) => p.avgDurationSeconds))); + drawLegend(doc, region, [{ label: "Avg duration", color: pdfColors.primary }]); + drawAxes(doc, plot, ticks, max, "s", points); + const yScale = linearScale(max, plot.h); + const slotW = plot.w / points.length; + const coords = points.map((p, i) => ({ + x: plot.x + slotW * (i + 0.5), + y: plot.y + plot.h - yScale(p.avgDurationSeconds), + })); + doc.setDrawColor(pdfColors.primary); + doc.setLineWidth(0.5); + for (let i = 1; i < coords.length; i++) { + doc.line(coords[i - 1].x, coords[i - 1].y, coords[i].x, coords[i].y); + } + if (points.length <= 31) { + doc.setFillColor(pdfColors.primary); + for (const c of coords) { + doc.circle(c.x, c.y, 0.7, "F"); + } + } +} diff --git a/src/Web/autopilot-monitor-web/utils/pdf/pdfPrimitives.ts b/src/Web/autopilot-monitor-web/utils/pdf/pdfPrimitives.ts new file mode 100644 index 00000000..64dd74be --- /dev/null +++ b/src/Web/autopilot-monitor-web/utils/pdf/pdfPrimitives.ts @@ -0,0 +1,218 @@ +// Generic report chrome for jsPDF-generated documents: page header/footer, +// section titles, KPI rows, callout boxes, and a simple fixed-column table. +// App-agnostic on purpose — future report exports (SLA, session report) can +// reuse this module unchanged. +// +// Type-only jsPDF import: the runtime jsPDF instance is created by the caller +// (inside the lazily loaded report builder), so this file adds no bundle weight +// to anything that imports it statically. + +import type { jsPDF } from "jspdf"; +import { pdfColors, pdfPage, contentW, contentTop, contentBottom } from "./pdfTheme"; + +export interface PdfContext { + doc: jsPDF; + /** Current write cursor (top of the next content block), in mm. */ + y: number; + /** Report title repeated in the header band of every page. */ + reportTitle: string; +} + +export interface TableColumn { + header: string; + /** Column width in mm; widths should sum to contentW. */ + width: number; + align?: "left" | "right"; +} + +export interface TableSpec { + columns: TableColumn[]; + rows: string[][]; + /** Trailing italic "+N more …" note when the source data was truncated. */ + moreCount?: number; + zebra?: boolean; +} + +function drawPageHeader(doc: jsPDF, reportTitle: string) { + const y = pdfPage.margin + 4; + doc.setFont("helvetica", "bold"); + doc.setFontSize(10); + doc.setTextColor(pdfColors.brand); + doc.text("Autopilot Monitor", pdfPage.margin, y); + doc.setFont("helvetica", "normal"); + doc.setFontSize(9); + doc.setTextColor(pdfColors.subtle); + doc.text(reportTitle, pdfPage.w - pdfPage.margin, y, { align: "right" }); + doc.setDrawColor(pdfColors.border); + doc.setLineWidth(0.3); + doc.line(pdfPage.margin, y + 3, pdfPage.w - pdfPage.margin, y + 3); +} + +/** Start a new report document: draws the first page's header and returns the cursor context. */ +export function startReport(doc: jsPDF, reportTitle: string): PdfContext { + drawPageHeader(doc, reportTitle); + return { doc, y: contentTop + 2, reportTitle }; +} + +/** Page-break guard: if the next block of `needed` mm doesn't fit, start a new page. */ +export function ensureSpace(ctx: PdfContext, needed: number) { + if (ctx.y + needed <= contentBottom) return; + ctx.doc.addPage(); + drawPageHeader(ctx.doc, ctx.reportTitle); + ctx.y = contentTop + 2; +} + +export function sectionTitle(ctx: PdfContext, text: string) { + ensureSpace(ctx, 14); + ctx.y += 3; + ctx.doc.setFont("helvetica", "bold"); + ctx.doc.setFontSize(11); + ctx.doc.setTextColor(pdfColors.text); + ctx.doc.text(text, pdfPage.margin, ctx.y + 4); + ctx.y += 8; +} + +/** Truncate text with an ellipsis so it fits maxWidth at the doc's CURRENT font settings. */ +export function truncateToWidth(doc: jsPDF, text: string, maxWidth: number): string { + if (doc.getTextWidth(text) <= maxWidth) return text; + let t = text; + while (t.length > 1 && doc.getTextWidth(`${t}…`) > maxWidth) { + t = t.slice(0, -1); + } + return `${t}…`; +} + +export interface KpiCell { + label: string; + value: string; + /** Resolved hex color for the value text. */ + valueColor: string; + hint?: string; +} + +const KPI_CELL_H = 17; +const KPI_GAP = 3; + +/** One row of bordered KPI cells, `perRow` across the content width. */ +export function kpiRow(ctx: PdfContext, kpis: KpiCell[], perRow = 4) { + const rows = Math.ceil(kpis.length / perRow); + ensureSpace(ctx, rows * (KPI_CELL_H + KPI_GAP)); + const cellW = (contentW - (perRow - 1) * KPI_GAP) / perRow; + kpis.forEach((kpi, i) => { + const col = i % perRow; + const row = Math.floor(i / perRow); + const x = pdfPage.margin + col * (cellW + KPI_GAP); + const y = ctx.y + row * (KPI_CELL_H + KPI_GAP); + ctx.doc.setDrawColor(pdfColors.border); + ctx.doc.setLineWidth(0.3); + ctx.doc.roundedRect(x, y, cellW, KPI_CELL_H, 1, 1, "S"); + ctx.doc.setFont("helvetica", "normal"); + ctx.doc.setFontSize(6.5); + ctx.doc.setTextColor(pdfColors.subtle); + ctx.doc.text(truncateToWidth(ctx.doc, kpi.label.toUpperCase(), cellW - 6), x + 3, y + 5); + ctx.doc.setFont("helvetica", "bold"); + ctx.doc.setFontSize(12); + ctx.doc.setTextColor(kpi.valueColor); + ctx.doc.text(truncateToWidth(ctx.doc, kpi.value, cellW - 6), x + 3, y + 11); + if (kpi.hint) { + ctx.doc.setFont("helvetica", "normal"); + ctx.doc.setFontSize(6); + ctx.doc.setTextColor(pdfColors.faint); + ctx.doc.text(truncateToWidth(ctx.doc, kpi.hint, cellW - 6), x + 3, y + 15); + } + }); + ctx.y += rows * (KPI_CELL_H + KPI_GAP); +} + +/** Amber warning callout with a left accent bar; wraps the text to the content width. */ +export function calloutBox(ctx: PdfContext, text: string) { + ctx.doc.setFont("helvetica", "normal"); + ctx.doc.setFontSize(8.5); + const lines = ctx.doc.splitTextToSize(text, contentW - 10) as string[]; + const boxH = lines.length * 4 + 6; + ensureSpace(ctx, boxH + 3); + ctx.doc.setFillColor(pdfColors.warningBg); + ctx.doc.roundedRect(pdfPage.margin, ctx.y, contentW, boxH, 1, 1, "F"); + ctx.doc.setFillColor(pdfColors.warningAccent); + ctx.doc.rect(pdfPage.margin, ctx.y, 1.2, boxH, "F"); + ctx.doc.setTextColor(pdfColors.warning); + ctx.doc.text(lines, pdfPage.margin + 5, ctx.y + 5); + ctx.y += boxH + 3; +} + +const TABLE_HEADER_H = 7; +const TABLE_ROW_H = 6; + +function drawTableHeader(ctx: PdfContext, columns: TableColumn[]) { + ctx.doc.setFillColor(pdfColors.headerBand); + ctx.doc.rect(pdfPage.margin, ctx.y, contentW, TABLE_HEADER_H, "F"); + ctx.doc.setFont("helvetica", "bold"); + ctx.doc.setFontSize(7); + ctx.doc.setTextColor(pdfColors.subtle); + let x = pdfPage.margin; + for (const col of columns) { + const tx = col.align === "right" ? x + col.width - 2 : x + 2; + ctx.doc.text(col.header.toUpperCase(), tx, ctx.y + 4.7, { + align: col.align === "right" ? "right" : "left", + }); + x += col.width; + } + ctx.y += TABLE_HEADER_H; +} + +/** Fixed-column table with zebra striping and header repetition after page breaks. */ +export function drawTable(ctx: PdfContext, spec: TableSpec) { + const { columns, rows, moreCount = 0, zebra = true } = spec; + ensureSpace(ctx, TABLE_HEADER_H + TABLE_ROW_H * 2); + drawTableHeader(ctx, columns); + rows.forEach((row, rowIndex) => { + // Re-draw the header when the row would spill past the footer. + if (ctx.y + TABLE_ROW_H > contentBottom) { + ensureSpace(ctx, TABLE_HEADER_H + TABLE_ROW_H); + drawTableHeader(ctx, columns); + } + if (zebra && rowIndex % 2 === 1) { + ctx.doc.setFillColor(pdfColors.zebra); + ctx.doc.rect(pdfPage.margin, ctx.y, contentW, TABLE_ROW_H, "F"); + } + ctx.doc.setFont("helvetica", "normal"); + ctx.doc.setFontSize(8); + ctx.doc.setTextColor(pdfColors.text); + let x = pdfPage.margin; + row.forEach((cell, i) => { + const col = columns[i]; + if (!col) return; + const text = truncateToWidth(ctx.doc, cell, col.width - 4); + const tx = col.align === "right" ? x + col.width - 2 : x + 2; + ctx.doc.text(text, tx, ctx.y + 4.2, { align: col.align === "right" ? "right" : "left" }); + x += col.width; + }); + ctx.doc.setDrawColor(pdfColors.border); + ctx.doc.setLineWidth(0.1); + ctx.doc.line(pdfPage.margin, ctx.y + TABLE_ROW_H, pdfPage.margin + contentW, ctx.y + TABLE_ROW_H); + ctx.y += TABLE_ROW_H; + }); + if (moreCount > 0) { + ensureSpace(ctx, TABLE_ROW_H); + ctx.doc.setFont("helvetica", "italic"); + ctx.doc.setFontSize(7.5); + ctx.doc.setTextColor(pdfColors.faint); + ctx.doc.text(`+${moreCount} more not shown`, pdfPage.margin + 2, ctx.y + 4); + ctx.y += TABLE_ROW_H; + } + ctx.y += 2; +} + +/** Stamp the footer on every page — call once, after all content is placed. */ +export function finalizeFooters(doc: jsPDF, generatedLabel: string) { + const total = doc.getNumberOfPages(); + const y = pdfPage.h - pdfPage.margin; + for (let p = 1; p <= total; p++) { + doc.setPage(p); + doc.setFont("helvetica", "normal"); + doc.setFontSize(7); + doc.setTextColor(pdfColors.faint); + doc.text(generatedLabel, pdfPage.margin, y); + doc.text(`Page ${p} of ${total}`, pdfPage.w - pdfPage.margin, y, { align: "right" }); + } +} diff --git a/src/Web/autopilot-monitor-web/utils/pdf/pdfTheme.ts b/src/Web/autopilot-monitor-web/utils/pdf/pdfTheme.ts new file mode 100644 index 00000000..ea3e0117 --- /dev/null +++ b/src/Web/autopilot-monitor-web/utils/pdf/pdfTheme.ts @@ -0,0 +1,44 @@ +// Print palette + A4 layout constants for generated PDF reports. +// +// Deliberately separate from components/charts/chartTheme.ts: those colors are +// tuned for dark on-screen surfaces (gray-400 axis text, #374151 grid) and look +// washed out on white paper. PDFs always render on a fixed light background, +// independent of the user's on-screen theme. + +export const pdfColors = { + text: "#111827", + subtle: "#6b7280", + faint: "#9ca3af", + border: "#e5e7eb", + headerBand: "#f3f4f6", + zebra: "#f9fafb", + brand: "#16a34a", + success: "#059669", + danger: "#dc2626", + warning: "#b45309", + warningBg: "#fffbeb", + warningAccent: "#f59e0b", + primary: "#2563eb", + badgeBg: "#dbeafe", + badgeText: "#1e40af", +} as const; + +/** A4 portrait geometry in millimetres. */ +export const pdfPage = { + w: 210, + h: 297, + margin: 15, + /** Height reserved under the top margin for the repeating page header band. */ + headerH: 12, + /** Height reserved above the bottom margin for the page footer. */ + footerH: 10, +} as const; + +/** Usable content width between the side margins. */ +export const contentW = pdfPage.w - 2 * pdfPage.margin; + +/** First y a content block may start at (below the page header). */ +export const contentTop = pdfPage.margin + pdfPage.headerH; + +/** Last y a content block may extend to (above the footer). */ +export const contentBottom = pdfPage.h - pdfPage.margin - pdfPage.footerH;