mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-24 12:42:38 +00:00
feat(web): PDF export for the app install report + usage telemetry
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 <noreply@anthropic.com>
This commit is contained in:
@@ -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<AnalyticsResponse | null>(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
|
||||
</button>
|
||||
))}
|
||||
<button
|
||||
onClick={handleExportPdf}
|
||||
disabled={loading || !analytics || exporting}
|
||||
className="inline-flex items-center gap-1.5 px-4 py-2 text-sm rounded-md bg-gray-100 text-gray-700 hover:bg-gray-200 disabled:opacity-40 transition-colors"
|
||||
title="Download this report as a PDF"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M4 16v2a2 2 0 002 2h12a2 2 0 002-2v-2M7 10l5 5 5-5M12 15V3"
|
||||
/>
|
||||
</svg>
|
||||
<span>{exporting ? "Exporting…" : "Export PDF"}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+224
-12
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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> = {}): 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<AppReportAnalytics> = {}): 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");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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),
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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<KpiTone, string> = {
|
||||
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<typeof startReport>, 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);
|
||||
}
|
||||
@@ -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<number>();
|
||||
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()}`;
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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" });
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user