@invisibleloop/pulse 0.1.21
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/commands/build-page.md +59 -0
- package/.claude/commands/new-doc-page.md +45 -0
- package/.claude/commands/verify.md +52 -0
- package/.claude/pulse-checklist.md +111 -0
- package/.claude/settings.local.json +102 -0
- package/.github/workflows/ci.yml +22 -0
- package/.github/workflows/publish.yml +41 -0
- package/.pulse/load-reports/home/1773432711417.json +22 -0
- package/CLAUDE.md +383 -0
- package/README.md +95 -0
- package/docs/.claude/pulse-checklist.md +111 -0
- package/docs/public/.pulse-ui-version +1 -0
- package/docs/public/dist/accessibility.boot-5DVTARJU.js +115 -0
- package/docs/public/dist/actions.boot-P66HKQEM.js +164 -0
- package/docs/public/dist/auth.boot-IMAJAUPH.js +140 -0
- package/docs/public/dist/caching.boot-DVR6KDE7.js +53 -0
- package/docs/public/dist/components--accordion.boot-3HVKMNWC.js +11 -0
- package/docs/public/dist/components--alert.boot-GCEXOZAC.js +6 -0
- package/docs/public/dist/components--app-badge.boot-DVT3GCHJ.js +6 -0
- package/docs/public/dist/components--avatar.boot-PSW24EVA.js +5 -0
- package/docs/public/dist/components--badge.boot-TYDY2RMK.js +7 -0
- package/docs/public/dist/components--banner.boot-EI5PZSZK.js +7 -0
- package/docs/public/dist/components--breadcrumbs.boot-SMA2E2GO.js +34 -0
- package/docs/public/dist/components--button.boot-J54BQM2E.js +23 -0
- package/docs/public/dist/components--card.boot-PZGNDIB6.js +138 -0
- package/docs/public/dist/components--carousel.boot-TP6LPFZZ.js +12 -0
- package/docs/public/dist/components--charts.boot-2EOYQWKL.js +108 -0
- package/docs/public/dist/components--checkbox.boot-DS5BSL6T.js +54 -0
- package/docs/public/dist/components--cluster.boot-HHVIBBJG.js +9 -0
- package/docs/public/dist/components--code-window.boot-2GR2DV33.js +20 -0
- package/docs/public/dist/components--container.boot-7LOOGK2K.js +5 -0
- package/docs/public/dist/components--cta.boot-FSNZ5YRT.js +11 -0
- package/docs/public/dist/components--divider.boot-3NI2C3QG.js +6 -0
- package/docs/public/dist/components--empty.boot-YX2UR3PV.js +7 -0
- package/docs/public/dist/components--feature.boot-MUD7NSUO.js +13 -0
- package/docs/public/dist/components--fieldset.boot-J7BYHMKF.js +19 -0
- package/docs/public/dist/components--fileupload.boot-NIKVTTPD.js +52 -0
- package/docs/public/dist/components--footer.boot-EYUK5FRG.js +14 -0
- package/docs/public/dist/components--grid.boot-URDQVDDR.js +59 -0
- package/docs/public/dist/components--heading.boot-BPQKU43E.js +44 -0
- package/docs/public/dist/components--hero.boot-4RAPRGAB.js +17 -0
- package/docs/public/dist/components--icons.boot-ZITNU5JP.js +68 -0
- package/docs/public/dist/components--image.boot-XEEGHQZF.js +19 -0
- package/docs/public/dist/components--input.boot-SGASZG5K.js +7 -0
- package/docs/public/dist/components--list.boot-W3XC5MHD.js +55 -0
- package/docs/public/dist/components--media.boot-5VFIETZO.js +13 -0
- package/docs/public/dist/components--modal.boot-RZUYXBN2.js +47 -0
- package/docs/public/dist/components--nav.boot-ODBOHU7O.js +33 -0
- package/docs/public/dist/components--pricing.boot-4AQ4ZVBY.js +21 -0
- package/docs/public/dist/components--progress.boot-GHAGYZOK.js +30 -0
- package/docs/public/dist/components--prose.boot-QANJL6JI.js +67 -0
- package/docs/public/dist/components--pullquote.boot-Q2WMNAZU.js +22 -0
- package/docs/public/dist/components--radio.boot-TJRDQ2OL.js +75 -0
- package/docs/public/dist/components--rating.boot-QBAN6DEL.js +38 -0
- package/docs/public/dist/components--search.boot-PXH5O5AG.js +17 -0
- package/docs/public/dist/components--section.boot-AQGIYHWW.js +12 -0
- package/docs/public/dist/components--segmented.boot-BEVTKEJO.js +33 -0
- package/docs/public/dist/components--select.boot-47X5RHOC.js +10 -0
- package/docs/public/dist/components--slider.boot-PSRRX7XL.js +47 -0
- package/docs/public/dist/components--spinner.boot-MZ5MO2OH.js +22 -0
- package/docs/public/dist/components--stack.boot-DI4NJXBF.js +9 -0
- package/docs/public/dist/components--stat.boot-QMFUWBQT.js +9 -0
- package/docs/public/dist/components--stepper.boot-34PP2NEV.js +22 -0
- package/docs/public/dist/components--table.boot-FCQGSFIQ.js +11 -0
- package/docs/public/dist/components--testimonial.boot-DWQPDKYG.js +11 -0
- package/docs/public/dist/components--textarea.boot-QVXLBOJ5.js +4 -0
- package/docs/public/dist/components--timeline.boot-26LN52P2.js +95 -0
- package/docs/public/dist/components--toggle.boot-IQQEI76S.js +29 -0
- package/docs/public/dist/components--tooltip.boot-LGHCO6NN.js +9 -0
- package/docs/public/dist/components.boot-SE6PQ4P7.js +103 -0
- package/docs/public/dist/config.boot-DTRRWUE6.js +126 -0
- package/docs/public/dist/constraints.boot-DUHDZBMC.js +71 -0
- package/docs/public/dist/deploy.boot-SLAD3NI2.js +163 -0
- package/docs/public/dist/docs-8e3d4b5c.css +1 -0
- package/docs/public/dist/extending.boot-UA3CN243.js +159 -0
- package/docs/public/dist/faq.boot-6EQAWLQR.js +43 -0
- package/docs/public/dist/getting-started.boot-TDKIFL5U.js +86 -0
- package/docs/public/dist/guard.boot-AUHAWTG4.js +80 -0
- package/docs/public/dist/home.boot-BVQXRH32.js +383 -0
- package/docs/public/dist/how-it-works.boot-LTWAKWKW.js +104 -0
- package/docs/public/dist/hydration.boot-JRM6IPJL.js +78 -0
- package/docs/public/dist/images.boot-M6ZVKTZS.js +80 -0
- package/docs/public/dist/manifest.json +94 -0
- package/docs/public/dist/meta.boot-7NXGPHR4.js +79 -0
- package/docs/public/dist/mutations.boot-F6F43UDX.js +79 -0
- package/docs/public/dist/navigation.boot-AOXWS3ZF.js +57 -0
- package/docs/public/dist/performance.boot-C3UPCOBK.js +98 -0
- package/docs/public/dist/persist.boot-WT32PQOQ.js +61 -0
- package/docs/public/dist/project-structure.boot-FB3LRVJ4.js +63 -0
- package/docs/public/dist/prompt-examples.boot-YKR4VDK4.js +31 -0
- package/docs/public/dist/pulse-ui-81a85c03.css +1 -0
- package/docs/public/dist/raw-responses.boot-M4KA5YXL.js +104 -0
- package/docs/public/dist/routing.boot-FNX5FDGH.js +70 -0
- package/docs/public/dist/runtime-B73WLANC.js +1 -0
- package/docs/public/dist/runtime-KO4BHUQ3.js +49 -0
- package/docs/public/dist/runtime-L2HNXIHW.js +59 -0
- package/docs/public/dist/runtime-QFURDKA2.js +5 -0
- package/docs/public/dist/runtime-UVPXO4IR.js +375 -0
- package/docs/public/dist/runtime-VMJA3Z4N.js +10 -0
- package/docs/public/dist/runtime-ZJ4FXT5O.js +11 -0
- package/docs/public/dist/server-api.boot-K7X3LCFB.js +219 -0
- package/docs/public/dist/server-data.boot-Y7HQYC4R.js +157 -0
- package/docs/public/dist/slash-commands.boot-V2UV7OW2.js +26 -0
- package/docs/public/dist/spec.boot-2WU7ZHCV.js +159 -0
- package/docs/public/dist/state.boot-B24GUE3R.js +73 -0
- package/docs/public/dist/store.boot-TLIB4XHH.js +150 -0
- package/docs/public/dist/streaming.boot-W2DZSMW4.js +80 -0
- package/docs/public/dist/stripe.boot-QN3C2GEL.js +164 -0
- package/docs/public/dist/supabase.boot-BG4XXLZE.js +303 -0
- package/docs/public/dist/testing.boot-6U4WKMTE.js +130 -0
- package/docs/public/dist/validation.boot-PQHYGW5B.js +100 -0
- package/docs/public/docs.css +2020 -0
- package/docs/public/menu.js +83 -0
- package/docs/public/pulse-ui.css +2739 -0
- package/docs/public/pulse-ui.js +236 -0
- package/docs/server.js +192 -0
- package/docs/src/lib/component-page.js +47 -0
- package/docs/src/lib/highlight.js +255 -0
- package/docs/src/lib/layout.js +131 -0
- package/docs/src/lib/metrics-store.js +6 -0
- package/docs/src/lib/nav.js +159 -0
- package/docs/src/lib/stats.js +81 -0
- package/docs/src/pages/accessibility.js +157 -0
- package/docs/src/pages/actions.js +191 -0
- package/docs/src/pages/auth.js +177 -0
- package/docs/src/pages/caching.js +95 -0
- package/docs/src/pages/components/accordion.js +48 -0
- package/docs/src/pages/components/alert.js +35 -0
- package/docs/src/pages/components/app-badge.js +41 -0
- package/docs/src/pages/components/avatar.js +35 -0
- package/docs/src/pages/components/badge.js +36 -0
- package/docs/src/pages/components/banner.js +45 -0
- package/docs/src/pages/components/breadcrumbs.js +94 -0
- package/docs/src/pages/components/button.js +84 -0
- package/docs/src/pages/components/card.js +225 -0
- package/docs/src/pages/components/carousel.js +72 -0
- package/docs/src/pages/components/charts.js +278 -0
- package/docs/src/pages/components/checkbox.js +129 -0
- package/docs/src/pages/components/cluster.js +47 -0
- package/docs/src/pages/components/code-window.js +57 -0
- package/docs/src/pages/components/container.js +40 -0
- package/docs/src/pages/components/cta.js +53 -0
- package/docs/src/pages/components/divider.js +37 -0
- package/docs/src/pages/components/empty.js +36 -0
- package/docs/src/pages/components/feature.js +60 -0
- package/docs/src/pages/components/fieldset.js +65 -0
- package/docs/src/pages/components/fileupload.js +127 -0
- package/docs/src/pages/components/footer.js +58 -0
- package/docs/src/pages/components/grid.js +165 -0
- package/docs/src/pages/components/heading.js +107 -0
- package/docs/src/pages/components/hero.js +65 -0
- package/docs/src/pages/components/icons.js +285 -0
- package/docs/src/pages/components/image.js +71 -0
- package/docs/src/pages/components/input.js +51 -0
- package/docs/src/pages/components/list.js +112 -0
- package/docs/src/pages/components/media.js +51 -0
- package/docs/src/pages/components/modal.js +111 -0
- package/docs/src/pages/components/nav.js +86 -0
- package/docs/src/pages/components/pricing.js +68 -0
- package/docs/src/pages/components/progress.js +102 -0
- package/docs/src/pages/components/prose.js +111 -0
- package/docs/src/pages/components/pullquote.js +71 -0
- package/docs/src/pages/components/radio.js +194 -0
- package/docs/src/pages/components/rating.js +106 -0
- package/docs/src/pages/components/search.js +61 -0
- package/docs/src/pages/components/section.js +59 -0
- package/docs/src/pages/components/segmented.js +121 -0
- package/docs/src/pages/components/select.js +45 -0
- package/docs/src/pages/components/slider.js +114 -0
- package/docs/src/pages/components/spinner.js +73 -0
- package/docs/src/pages/components/stack.js +48 -0
- package/docs/src/pages/components/stat.js +55 -0
- package/docs/src/pages/components/stepper.js +66 -0
- package/docs/src/pages/components/table.js +45 -0
- package/docs/src/pages/components/testimonial.js +49 -0
- package/docs/src/pages/components/textarea.js +31 -0
- package/docs/src/pages/components/timeline.js +227 -0
- package/docs/src/pages/components/toggle.js +84 -0
- package/docs/src/pages/components/tooltip.js +48 -0
- package/docs/src/pages/components.js +204 -0
- package/docs/src/pages/config.js +193 -0
- package/docs/src/pages/constraints.js +99 -0
- package/docs/src/pages/deploy.js +233 -0
- package/docs/src/pages/extending.js +198 -0
- package/docs/src/pages/faq.js +96 -0
- package/docs/src/pages/getting-started.js +106 -0
- package/docs/src/pages/guard.js +121 -0
- package/docs/src/pages/home.js +401 -0
- package/docs/src/pages/how-it-works.js +183 -0
- package/docs/src/pages/hydration.js +98 -0
- package/docs/src/pages/images.js +121 -0
- package/docs/src/pages/meta.js +120 -0
- package/docs/src/pages/mutations.js +106 -0
- package/docs/src/pages/navigation.js +85 -0
- package/docs/src/pages/performance.js +157 -0
- package/docs/src/pages/persist.js +88 -0
- package/docs/src/pages/project-structure.js +90 -0
- package/docs/src/pages/prompt-examples.js +186 -0
- package/docs/src/pages/raw-responses.js +124 -0
- package/docs/src/pages/routing.js +99 -0
- package/docs/src/pages/server-api.js +281 -0
- package/docs/src/pages/server-data.js +185 -0
- package/docs/src/pages/slash-commands.js +55 -0
- package/docs/src/pages/spec.js +207 -0
- package/docs/src/pages/state.js +101 -0
- package/docs/src/pages/store.js +181 -0
- package/docs/src/pages/streaming.js +108 -0
- package/docs/src/pages/stripe.js +193 -0
- package/docs/src/pages/supabase.js +323 -0
- package/docs/src/pages/testing.js +198 -0
- package/docs/src/pages/validation.js +138 -0
- package/examples/contact.js +166 -0
- package/examples/counter.js +94 -0
- package/examples/dev.server.js +91 -0
- package/examples/examples.test.js +394 -0
- package/examples/pricing.js +244 -0
- package/examples/products.js +191 -0
- package/examples/quiz.js +208 -0
- package/examples/shared.js +78 -0
- package/examples/todos.js +162 -0
- package/package.json +75 -0
- package/public/.pulse-ui-version +1 -0
- package/public/chippy-bird.css +246 -0
- package/public/examples/contact.css +119 -0
- package/public/examples/counter.css +79 -0
- package/public/examples/pricing.css +132 -0
- package/public/examples/products.css +100 -0
- package/public/examples/quiz.css +200 -0
- package/public/examples/todos.css +137 -0
- package/public/favicon.ico +0 -0
- package/public/log-dashboard.css +383 -0
- package/public/pulse-ui.css +2740 -0
- package/public/pulse-ui.js +236 -0
- package/public/pulse.css +149 -0
- package/scripts/build.js +411 -0
- package/src/agent/checklist.md +111 -0
- package/src/agent/coverage-check.js +66 -0
- package/src/agent/guide-components.md +274 -0
- package/src/agent/guide-examples.md +54 -0
- package/src/agent/guide-routing.md +36 -0
- package/src/agent/guide-server.md +258 -0
- package/src/agent/guide-spec.md +103 -0
- package/src/agent/guide-styles.md +191 -0
- package/src/agent/guide.md +979 -0
- package/src/agent/identity.md +106 -0
- package/src/agent/workflow.md +108 -0
- package/src/cli/cli.test.js +82 -0
- package/src/cli/dev.js +195 -0
- package/src/cli/discover.js +113 -0
- package/src/cli/index.js +361 -0
- package/src/cli/load-report.js +91 -0
- package/src/cli/load-runner.js +121 -0
- package/src/cli/report-server.js +723 -0
- package/src/cli/report.js +116 -0
- package/src/cli/scaffold.archive.js +1371 -0
- package/src/cli/scaffold.js +349 -0
- package/src/cli/start.js +74 -0
- package/src/html.js +19 -0
- package/src/mcp/server.js +884 -0
- package/src/mcp/validate-worker.js +110 -0
- package/src/runtime/image.js +74 -0
- package/src/runtime/image.test.js +111 -0
- package/src/runtime/index.js +621 -0
- package/src/runtime/navigate.js +146 -0
- package/src/runtime/runtime.test.js +773 -0
- package/src/runtime/ssr.js +464 -0
- package/src/runtime/ssr.test.js +421 -0
- package/src/runtime/store.js +92 -0
- package/src/runtime/toast.js +163 -0
- package/src/server/index.js +1386 -0
- package/src/server/server.test.js +1248 -0
- package/src/spec/schema.js +428 -0
- package/src/spec/schema.test.js +291 -0
- package/src/store/index.js +102 -0
- package/src/store/store.test.js +210 -0
- package/src/testing/html.js +283 -0
- package/src/testing/index.js +249 -0
- package/src/testing/testing.test.js +450 -0
- package/src/ui/accordion.js +28 -0
- package/src/ui/alert.js +43 -0
- package/src/ui/app-badge.js +48 -0
- package/src/ui/avatar.js +47 -0
- package/src/ui/badge.js +24 -0
- package/src/ui/banner.js +26 -0
- package/src/ui/breadcrumbs.js +38 -0
- package/src/ui/button.js +66 -0
- package/src/ui/card.js +34 -0
- package/src/ui/carousel.js +59 -0
- package/src/ui/charts.js +321 -0
- package/src/ui/checkbox.js +65 -0
- package/src/ui/cluster.js +44 -0
- package/src/ui/code-window.js +39 -0
- package/src/ui/container.js +24 -0
- package/src/ui/cta.js +37 -0
- package/src/ui/divider.js +29 -0
- package/src/ui/empty.js +33 -0
- package/src/ui/feature.js +33 -0
- package/src/ui/fieldset.js +37 -0
- package/src/ui/fileupload.js +89 -0
- package/src/ui/footer.js +38 -0
- package/src/ui/grid.js +36 -0
- package/src/ui/heading.js +45 -0
- package/src/ui/hero.js +37 -0
- package/src/ui/icons.js +161 -0
- package/src/ui/index.js +89 -0
- package/src/ui/input.js +74 -0
- package/src/ui/list.js +36 -0
- package/src/ui/media.js +44 -0
- package/src/ui/modal.js +80 -0
- package/src/ui/nav.js +61 -0
- package/src/ui/pricing.js +56 -0
- package/src/ui/progress.js +62 -0
- package/src/ui/prose.js +29 -0
- package/src/ui/pullquote.js +34 -0
- package/src/ui/radio.js +102 -0
- package/src/ui/rating.js +93 -0
- package/src/ui/search.js +77 -0
- package/src/ui/section.js +69 -0
- package/src/ui/segmented.js +50 -0
- package/src/ui/select.js +77 -0
- package/src/ui/slider.js +84 -0
- package/src/ui/spinner.js +34 -0
- package/src/ui/stack.js +36 -0
- package/src/ui/stat.js +52 -0
- package/src/ui/stepper.js +46 -0
- package/src/ui/switch.js +57 -0
- package/src/ui/table.js +45 -0
- package/src/ui/testimonial.js +48 -0
- package/src/ui/textarea.js +72 -0
- package/src/ui/timeline.js +72 -0
- package/src/ui/tooltip.js +28 -0
- package/src/ui/ui.test.js +1241 -0
- package/src/ui/uiimage.js +65 -0
- package/tsconfig.json +13 -0
- package/types/html.d.ts +17 -0
- package/types/image.d.ts +70 -0
- package/types/index.d.ts +7 -0
- package/types/navigate.d.ts +38 -0
- package/types/runtime.d.ts +63 -0
- package/types/schema.d.ts +243 -0
- package/types/server.d.ts +145 -0
- package/types/ssr.d.ts +110 -0
- package/types/testing.d.ts +154 -0
- package/types/ui.d.ts +704 -0
|
@@ -0,0 +1,723 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pulse — Combined report server
|
|
3
|
+
*
|
|
4
|
+
* Serves a unified dashboard for both Lighthouse audit history and load test
|
|
5
|
+
* results, reading from .pulse/reports/ and .pulse/load-reports/.
|
|
6
|
+
*
|
|
7
|
+
* Usage:
|
|
8
|
+
* node src/cli/report-server.js [--root /path/to/project] [--port 3001]
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import http from 'http'
|
|
12
|
+
import fs from 'fs'
|
|
13
|
+
import path from 'path'
|
|
14
|
+
|
|
15
|
+
const args = process.argv.slice(2)
|
|
16
|
+
const rootArg = args.indexOf('--root')
|
|
17
|
+
const portArg = args.indexOf('--port')
|
|
18
|
+
|
|
19
|
+
const ROOT = rootArg !== -1 ? path.resolve(args[rootArg + 1]) : process.cwd()
|
|
20
|
+
const PORT = portArg !== -1 ? parseInt(args[portArg + 1], 10) : 3001
|
|
21
|
+
const REPORTS_DIR = path.join(ROOT, '.pulse', 'reports')
|
|
22
|
+
const LOAD_REPORTS_DIR = path.join(ROOT, '.pulse', 'load-reports')
|
|
23
|
+
const THIRTY_DAYS = 30 * 24 * 60 * 60 * 1000
|
|
24
|
+
|
|
25
|
+
// ---------------------------------------------------------------------------
|
|
26
|
+
// Data — Lighthouse
|
|
27
|
+
// ---------------------------------------------------------------------------
|
|
28
|
+
|
|
29
|
+
function allSlugs() {
|
|
30
|
+
const perf = fs.existsSync(REPORTS_DIR)
|
|
31
|
+
? fs.readdirSync(REPORTS_DIR).filter(f => fs.statSync(path.join(REPORTS_DIR, f)).isDirectory())
|
|
32
|
+
: []
|
|
33
|
+
const load = fs.existsSync(LOAD_REPORTS_DIR)
|
|
34
|
+
? fs.readdirSync(LOAD_REPORTS_DIR).filter(f => fs.statSync(path.join(LOAD_REPORTS_DIR, f)).isDirectory())
|
|
35
|
+
: []
|
|
36
|
+
return [...new Set([...perf, ...load])].sort()
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function loadReports(slug) {
|
|
40
|
+
const dir = path.join(REPORTS_DIR, slug)
|
|
41
|
+
const cutoff = Date.now() - THIRTY_DAYS
|
|
42
|
+
if (!fs.existsSync(dir)) return []
|
|
43
|
+
return fs.readdirSync(dir)
|
|
44
|
+
.filter(f => f.endsWith('.json'))
|
|
45
|
+
.sort()
|
|
46
|
+
.map(f => { try { return JSON.parse(fs.readFileSync(path.join(dir, f), 'utf8')) } catch { return null } })
|
|
47
|
+
.filter(r => r && new Date(r.timestamp).getTime() >= cutoff)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// ---------------------------------------------------------------------------
|
|
51
|
+
// Data — Load tests
|
|
52
|
+
// ---------------------------------------------------------------------------
|
|
53
|
+
|
|
54
|
+
function loadLoadReports(slug) {
|
|
55
|
+
const dir = path.join(LOAD_REPORTS_DIR, slug)
|
|
56
|
+
const cutoff = Date.now() - THIRTY_DAYS
|
|
57
|
+
if (!fs.existsSync(dir)) return []
|
|
58
|
+
return fs.readdirSync(dir)
|
|
59
|
+
.filter(f => f.endsWith('.json'))
|
|
60
|
+
.sort()
|
|
61
|
+
.map(f => { try { return JSON.parse(fs.readFileSync(path.join(dir, f), 'utf8')) } catch { return null } })
|
|
62
|
+
.filter(r => r && new Date(r.timestamp).getTime() >= cutoff)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// ---------------------------------------------------------------------------
|
|
66
|
+
// Data — Bundles
|
|
67
|
+
// ---------------------------------------------------------------------------
|
|
68
|
+
|
|
69
|
+
function bundleSizes(slug) {
|
|
70
|
+
const manifestPath = path.join(ROOT, 'public', 'dist', 'manifest.json')
|
|
71
|
+
if (!fs.existsSync(manifestPath)) return null
|
|
72
|
+
try {
|
|
73
|
+
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'))
|
|
74
|
+
const sizes = {}
|
|
75
|
+
const runtimeEntry = Object.entries(manifest).find(([k]) => k.includes('runtime'))
|
|
76
|
+
if (runtimeEntry) {
|
|
77
|
+
const file = path.join(ROOT, 'public', runtimeEntry[1])
|
|
78
|
+
if (fs.existsSync(file)) sizes.runtime = fs.statSync(file).size
|
|
79
|
+
}
|
|
80
|
+
const pageEntry = Object.entries(manifest).find(([k]) =>
|
|
81
|
+
!k.includes('runtime') && (k.includes(slug) || k.includes(slug.replace(/-/g, '/')))
|
|
82
|
+
)
|
|
83
|
+
if (pageEntry) {
|
|
84
|
+
const file = path.join(ROOT, 'public', pageEntry[1])
|
|
85
|
+
if (fs.existsSync(file)) sizes.page = fs.statSync(file).size
|
|
86
|
+
}
|
|
87
|
+
return Object.keys(sizes).length ? sizes : null
|
|
88
|
+
} catch { return null }
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// ---------------------------------------------------------------------------
|
|
92
|
+
// Visual helpers
|
|
93
|
+
// ---------------------------------------------------------------------------
|
|
94
|
+
|
|
95
|
+
function scoreColor(n) {
|
|
96
|
+
if (n === undefined || n === null) return '#444'
|
|
97
|
+
if (n >= 90) return '#4ade80'
|
|
98
|
+
if (n >= 50) return '#fb923c'
|
|
99
|
+
return '#f87171'
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function metricColor(key, val, prev) {
|
|
103
|
+
if (prev === undefined) return '#9b8dff'
|
|
104
|
+
const loBetter = ['lcp', 'cls', 'fcp', 'tbt', 'ttfb', 'si', 'pageWeight', 'jsBytes', 'cssBytes', 'requests',
|
|
105
|
+
'latency.mean', 'latency.p50', 'latency.p95', 'latency.p99', 'latency.max', 'errors']
|
|
106
|
+
const improved = loBetter.includes(key) ? val < prev : val > prev
|
|
107
|
+
return improved ? '#4ade80' : val === prev ? '#666' : '#f87171'
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function deltaLabel(key, cur, prv, decimals = 0) {
|
|
111
|
+
if (prv === undefined) return ''
|
|
112
|
+
const d = cur - prv
|
|
113
|
+
if (d === 0) return '<span style="color:#555">—</span>'
|
|
114
|
+
const loBetter = ['lcp', 'cls', 'fcp', 'tbt', 'ttfb', 'si', 'pageWeight', 'jsBytes', 'cssBytes', 'requests',
|
|
115
|
+
'latency.mean', 'latency.p50', 'latency.p95', 'latency.p99', 'latency.max', 'errors']
|
|
116
|
+
const good = loBetter.includes(key) ? d < 0 : d > 0
|
|
117
|
+
const color = good ? '#4ade80' : '#f87171'
|
|
118
|
+
const arrow = d < 0 ? '▼' : '▲'
|
|
119
|
+
return `<span style="color:${color}">${arrow}${Math.abs(d).toFixed(decimals)}</span>`
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function scoreGauge(score, size = 80) {
|
|
123
|
+
const display = score !== undefined && score !== null ? score : '—'
|
|
124
|
+
const pct = typeof score === 'number' ? score : 0
|
|
125
|
+
const color = scoreColor(score)
|
|
126
|
+
const r = 15.9
|
|
127
|
+
const circ = 2 * Math.PI * r
|
|
128
|
+
const fill = (pct / 100) * circ
|
|
129
|
+
const gap = circ - fill
|
|
130
|
+
const s = size
|
|
131
|
+
return `<svg width="${s}" height="${s}" viewBox="0 0 36 36" role="img">
|
|
132
|
+
<circle cx="18" cy="18" r="${r}" fill="none" stroke="#1e1e2a" stroke-width="3.2"/>
|
|
133
|
+
<circle cx="18" cy="18" r="${r}" fill="none" stroke="${color}" stroke-width="3.2"
|
|
134
|
+
stroke-dasharray="${fill.toFixed(2)} ${gap.toFixed(2)}"
|
|
135
|
+
stroke-linecap="round"
|
|
136
|
+
transform="rotate(-90 18 18)"/>
|
|
137
|
+
<text x="18" y="21" text-anchor="middle" font-size="8.5" font-weight="800" fill="${color}"
|
|
138
|
+
font-family="system-ui,sans-serif">${display}</text>
|
|
139
|
+
</svg>`
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function sparkline(values, { width = 140, height = 36, yMin, yMax } = {}) {
|
|
143
|
+
const vals = values.filter(v => v !== undefined && v !== null)
|
|
144
|
+
if (!vals.length) return `<svg width="${width}" height="${height}"></svg>`
|
|
145
|
+
const lo = yMin ?? Math.min(...vals)
|
|
146
|
+
const hi = yMax ?? Math.max(...vals)
|
|
147
|
+
const range = hi - lo || 1
|
|
148
|
+
const pts = vals.map((v, i) => {
|
|
149
|
+
const x = vals.length < 2 ? width / 2 : (i / (vals.length - 1)) * (width - 4) + 2
|
|
150
|
+
const y = (height - 4) - ((v - lo) / range) * (height - 8) + 2
|
|
151
|
+
return [+x.toFixed(1), +y.toFixed(1)]
|
|
152
|
+
})
|
|
153
|
+
const last = pts[pts.length - 1]
|
|
154
|
+
if (pts.length === 1) {
|
|
155
|
+
return `<svg width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">
|
|
156
|
+
<circle cx="${last[0]}" cy="${last[1]}" r="2.5" fill="#9b8dff"/></svg>`
|
|
157
|
+
}
|
|
158
|
+
const line = pts.map(([x, y]) => `${x},${y}`).join(' ')
|
|
159
|
+
const area = `M${pts[0][0]},${height} L${pts.map(([x, y]) => `${x},${y}`).join(' L')} L${last[0]},${height} Z`
|
|
160
|
+
return `<svg width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">
|
|
161
|
+
<path d="${area}" fill="#9b8dff18"/>
|
|
162
|
+
<polyline points="${line}" fill="none" stroke="#9b8dff" stroke-width="1.5"
|
|
163
|
+
stroke-linejoin="round" stroke-linecap="round"/>
|
|
164
|
+
<circle cx="${last[0]}" cy="${last[1]}" r="2.5" fill="#9b8dff"/></svg>`
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const SUSPECT_ZERO_SCORES = new Set(['performance'])
|
|
168
|
+
const SUSPECT_ZERO_METRICS = new Set(['lcp', 'fcp', 'ttfb', 'si', 'pageWeight', 'jsBytes', 'cssBytes', 'requests'])
|
|
169
|
+
|
|
170
|
+
function validScore(key, val) {
|
|
171
|
+
if (val === undefined || val === null) return undefined
|
|
172
|
+
if (val === 0 && SUSPECT_ZERO_SCORES.has(key)) return undefined
|
|
173
|
+
return val
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function validMetric(key, val) {
|
|
177
|
+
if (val === undefined || val === null) return undefined
|
|
178
|
+
if (val === 0 && SUSPECT_ZERO_METRICS.has(key)) return undefined
|
|
179
|
+
return val
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function fmtBytes(b) {
|
|
183
|
+
if (b === undefined || b === null || b === 0) return '—'
|
|
184
|
+
if (b < 1024) return `${b} B`
|
|
185
|
+
return `${(b / 1024).toFixed(1)} kB`
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// ---------------------------------------------------------------------------
|
|
189
|
+
// Lighthouse sections
|
|
190
|
+
// ---------------------------------------------------------------------------
|
|
191
|
+
|
|
192
|
+
const CATEGORIES = [
|
|
193
|
+
{ key: 'performance', label: 'Performance' },
|
|
194
|
+
{ key: 'accessibility', label: 'Accessibility' },
|
|
195
|
+
{ key: 'bestPractices', label: 'Best Practices' },
|
|
196
|
+
{ key: 'seo', label: 'SEO' },
|
|
197
|
+
]
|
|
198
|
+
|
|
199
|
+
const WEB_VITALS = [
|
|
200
|
+
{ key: 'lcp', label: 'LCP', unit: 'ms', decimals: 0, desc: 'Largest Contentful Paint' },
|
|
201
|
+
{ key: 'cls', label: 'CLS', unit: '', decimals: 2, desc: 'Cumulative Layout Shift' },
|
|
202
|
+
{ key: 'fcp', label: 'FCP', unit: 'ms', decimals: 0, desc: 'First Contentful Paint' },
|
|
203
|
+
{ key: 'tbt', label: 'TBT', unit: 'ms', decimals: 0, desc: 'Total Blocking Time' },
|
|
204
|
+
{ key: 'ttfb', label: 'TTFB', unit: 'ms', decimals: 0, desc: 'Time to First Byte' },
|
|
205
|
+
{ key: 'si', label: 'Speed Index', unit: 'ms', decimals: 0, desc: 'Speed Index' },
|
|
206
|
+
]
|
|
207
|
+
|
|
208
|
+
const PAGE_WEIGHT = [
|
|
209
|
+
{ key: 'pageWeight', label: 'Page weight', unit: 'kB', decimals: 1, desc: 'Total transfer size' },
|
|
210
|
+
{ key: 'jsBytes', label: 'JS', unit: 'kB', decimals: 1, desc: 'JavaScript transfer size' },
|
|
211
|
+
{ key: 'cssBytes', label: 'CSS', unit: 'kB', decimals: 1, desc: 'CSS transfer size' },
|
|
212
|
+
{ key: 'requests', label: 'Requests', unit: '', decimals: 0, desc: 'Total network requests' },
|
|
213
|
+
]
|
|
214
|
+
|
|
215
|
+
function renderScoreRow(reports) {
|
|
216
|
+
const latest = reports[reports.length - 1]
|
|
217
|
+
const prev = reports[reports.length - 2]
|
|
218
|
+
return `<section class="score-row">
|
|
219
|
+
${CATEGORIES.map(({ key, label }) => {
|
|
220
|
+
const cur = validScore(key, latest?.scores?.[key])
|
|
221
|
+
const prv = validScore(key, prev?.scores?.[key])
|
|
222
|
+
const vals = reports.map(r => validScore(key, r.scores?.[key])).filter(n => n !== undefined)
|
|
223
|
+
return `
|
|
224
|
+
<div class="score-card">
|
|
225
|
+
<div class="score-gauge">${scoreGauge(cur, 80)}</div>
|
|
226
|
+
<div class="score-info">
|
|
227
|
+
<div class="score-label">${label}</div>
|
|
228
|
+
<div class="score-delta">${cur !== undefined && prv !== undefined ? deltaLabel(key, cur, prv) : cur === undefined ? '<span style="color:#555">no data</span>' : ''}</div>
|
|
229
|
+
<div class="score-spark">${sparkline(vals, { yMin: 0, yMax: 100, width: 100, height: 28 })}</div>
|
|
230
|
+
</div>
|
|
231
|
+
</div>`
|
|
232
|
+
}).join('')}
|
|
233
|
+
</section>`
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function renderMetricGroup(title, defs, reports) {
|
|
237
|
+
const latest = reports[reports.length - 1]
|
|
238
|
+
const prev = reports[reports.length - 2]
|
|
239
|
+
const rows = defs.map(({ key, label, unit, decimals, desc }) => {
|
|
240
|
+
const raw = validMetric(key, latest?.metrics?.[key])
|
|
241
|
+
const prv = validMetric(key, prev?.metrics?.[key])
|
|
242
|
+
if (raw === undefined) return ''
|
|
243
|
+
const cur = raw
|
|
244
|
+
const fmt = v => typeof v === 'number' ? (decimals ? v.toFixed(decimals) : v) : v
|
|
245
|
+
const vals = reports.map(r => validMetric(key, r.metrics?.[key])).filter(n => n !== undefined)
|
|
246
|
+
const lo = Math.min(...vals) * 0.8
|
|
247
|
+
const hi = Math.max(...vals) * 1.2 || 1
|
|
248
|
+
const col = metricColor(key, cur, prv)
|
|
249
|
+
return `
|
|
250
|
+
<div class="metric-row">
|
|
251
|
+
<span class="metric-label" title="${desc}">${label}</span>
|
|
252
|
+
<span class="metric-value" style="color:${col}">${fmt(cur)}<span class="metric-unit">${unit}</span></span>
|
|
253
|
+
<span class="metric-delta">${prv !== undefined ? deltaLabel(key, cur, prv, decimals) : ''}</span>
|
|
254
|
+
<span class="metric-spark">${sparkline(vals, { yMin: lo, yMax: hi, width: 120, height: 28 })}</span>
|
|
255
|
+
</div>`
|
|
256
|
+
}).filter(Boolean).join('')
|
|
257
|
+
if (!rows) return ''
|
|
258
|
+
return `
|
|
259
|
+
<section class="panel">
|
|
260
|
+
<h3 class="panel-title">${title}</h3>
|
|
261
|
+
${rows}
|
|
262
|
+
</section>`
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function renderBundlePanel(slug, reports) {
|
|
266
|
+
const sizes = bundleSizes(slug)
|
|
267
|
+
const latest = reports[reports.length - 1]
|
|
268
|
+
const jsBytes = latest?.metrics?.jsBytes
|
|
269
|
+
const cssBytes = latest?.metrics?.cssBytes
|
|
270
|
+
if (!sizes && jsBytes == null && cssBytes == null) return ''
|
|
271
|
+
const items = []
|
|
272
|
+
if (sizes?.runtime !== undefined) items.push(`
|
|
273
|
+
<div class="bundle-item">
|
|
274
|
+
<span class="bundle-name">Runtime bundle</span>
|
|
275
|
+
<span class="bundle-size">${fmtBytes(sizes.runtime)}</span>
|
|
276
|
+
<span class="bundle-note">shared across all pages</span>
|
|
277
|
+
</div>`)
|
|
278
|
+
if (sizes?.page !== undefined) items.push(`
|
|
279
|
+
<div class="bundle-item">
|
|
280
|
+
<span class="bundle-name">Page bundle</span>
|
|
281
|
+
<span class="bundle-size">${fmtBytes(sizes.page)}</span>
|
|
282
|
+
<span class="bundle-note">this page only</span>
|
|
283
|
+
</div>`)
|
|
284
|
+
if (jsBytes !== undefined) {
|
|
285
|
+
const vals = reports.map(r => r.metrics?.jsBytes).filter(n => n !== undefined)
|
|
286
|
+
items.push(`
|
|
287
|
+
<div class="bundle-item">
|
|
288
|
+
<span class="bundle-name">JS transferred</span>
|
|
289
|
+
<span class="bundle-size">${fmtBytes(jsBytes)}</span>
|
|
290
|
+
<span class="bundle-spark">${sparkline(vals, { width: 80, height: 24 })}</span>
|
|
291
|
+
</div>`)
|
|
292
|
+
}
|
|
293
|
+
if (cssBytes !== undefined) {
|
|
294
|
+
const vals = reports.map(r => r.metrics?.cssBytes).filter(n => n !== undefined)
|
|
295
|
+
items.push(`
|
|
296
|
+
<div class="bundle-item">
|
|
297
|
+
<span class="bundle-name">CSS transferred</span>
|
|
298
|
+
<span class="bundle-size">${fmtBytes(cssBytes)}</span>
|
|
299
|
+
<span class="bundle-spark">${sparkline(vals, { width: 80, height: 24 })}</span>
|
|
300
|
+
</div>`)
|
|
301
|
+
}
|
|
302
|
+
if (!items.length) return ''
|
|
303
|
+
return `
|
|
304
|
+
<section class="panel">
|
|
305
|
+
<h3 class="panel-title">Bundle sizes</h3>
|
|
306
|
+
<div class="bundle-grid">${items.join('')}</div>
|
|
307
|
+
</section>`
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function renderHistory(reports) {
|
|
311
|
+
const rows = [...reports].reverse().slice(0, 20).map(r => {
|
|
312
|
+
const time = new Date(r.timestamp).toLocaleString(undefined, { dateStyle: 'short', timeStyle: 'short' })
|
|
313
|
+
const scores = CATEGORIES.map(({ key }) => {
|
|
314
|
+
const v = validScore(key, r.scores?.[key])
|
|
315
|
+
return `<td style="color:${scoreColor(v)};font-weight:700">${v ?? '—'}</td>`
|
|
316
|
+
}).join('')
|
|
317
|
+
return `<tr><td class="time-col">${time}</td>${scores}</tr>`
|
|
318
|
+
}).join('')
|
|
319
|
+
return `
|
|
320
|
+
<section class="panel">
|
|
321
|
+
<h3 class="panel-title">Audit history <span class="panel-sub">· last 30 days</span></h3>
|
|
322
|
+
<table class="history-table">
|
|
323
|
+
<thead><tr><th>Time</th>${CATEGORIES.map(c => `<th>${c.label}</th>`).join('')}</tr></thead>
|
|
324
|
+
<tbody>${rows}</tbody>
|
|
325
|
+
</table>
|
|
326
|
+
</section>`
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
function renderPerfTab(slug, reports) {
|
|
330
|
+
if (!reports.length) return '<p class="empty-state">No Lighthouse audits yet. Run <code>/pulse-report</code> to capture one.</p>'
|
|
331
|
+
const latest = reports[reports.length - 1]
|
|
332
|
+
const count = reports.length
|
|
333
|
+
const when = new Date(latest.timestamp).toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'short' })
|
|
334
|
+
const scores = latest.scores || {}
|
|
335
|
+
const present = CATEGORIES.filter(({ key }) => validScore(key, scores[key]) !== undefined)
|
|
336
|
+
const allGood = present.length === 4 && present.every(({ key }) => validScore(key, scores[key]) >= 90)
|
|
337
|
+
const anyBad = present.some(({ key }) => validScore(key, scores[key]) < 50)
|
|
338
|
+
const incomplete = present.length < 4
|
|
339
|
+
const health = incomplete
|
|
340
|
+
? ['Incomplete — re-run audit', '#fb923c']
|
|
341
|
+
: allGood ? ['All scores 90+', '#4ade80']
|
|
342
|
+
: anyBad ? ['Needs attention', '#f87171']
|
|
343
|
+
: ['Room to improve', '#fb923c']
|
|
344
|
+
const missingScores = CATEGORIES.filter(({ key }) => validScore(key, scores[key]) === undefined).map(c => c.label)
|
|
345
|
+
const staleBanner = missingScores.length
|
|
346
|
+
? `<div class="stale-banner">Missing scores: <strong>${missingScores.join(', ')}</strong> — run <code>/pulse-report</code> to capture a full audit.</div>`
|
|
347
|
+
: ''
|
|
348
|
+
return `
|
|
349
|
+
${staleBanner}
|
|
350
|
+
<div class="page-header">
|
|
351
|
+
<div>
|
|
352
|
+
<h2 class="page-heading">${latest.url || '/' + slug}</h2>
|
|
353
|
+
<p class="page-meta">${count} audit${count !== 1 ? 's' : ''} · last run ${when}</p>
|
|
354
|
+
</div>
|
|
355
|
+
<div class="health-badge" style="color:${health[1]};border-color:${health[1]}20;background:${health[1]}10">${health[0]}</div>
|
|
356
|
+
</div>
|
|
357
|
+
${renderScoreRow(reports)}
|
|
358
|
+
${renderMetricGroup('Core Web Vitals', WEB_VITALS, reports)}
|
|
359
|
+
${renderMetricGroup('Page weight', PAGE_WEIGHT, reports)}
|
|
360
|
+
${renderBundlePanel(slug, reports)}
|
|
361
|
+
${renderHistory(reports)}`
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
// ---------------------------------------------------------------------------
|
|
365
|
+
// Load test sections
|
|
366
|
+
// ---------------------------------------------------------------------------
|
|
367
|
+
|
|
368
|
+
const LOAD_LATENCY = [
|
|
369
|
+
{ key: 'mean', label: 'Mean', unit: 'ms', decimals: 1, desc: 'Average response time' },
|
|
370
|
+
{ key: 'p50', label: 'P50', unit: 'ms', decimals: 0, desc: '50th percentile latency' },
|
|
371
|
+
{ key: 'p95', label: 'P95', unit: 'ms', decimals: 0, desc: '95th percentile latency' },
|
|
372
|
+
{ key: 'p99', label: 'P99', unit: 'ms', decimals: 0, desc: '99th percentile latency' },
|
|
373
|
+
{ key: 'max', label: 'Max', unit: 'ms', decimals: 0, desc: 'Worst-case latency' },
|
|
374
|
+
]
|
|
375
|
+
|
|
376
|
+
function fmtRps(v) {
|
|
377
|
+
if (v === undefined || v === null) return '—'
|
|
378
|
+
if (v >= 100) return Math.round(v).toLocaleString()
|
|
379
|
+
if (v >= 10) return v.toFixed(1)
|
|
380
|
+
return v.toFixed(2)
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
function renderLoadTab(slug, reports) {
|
|
384
|
+
if (!reports.length) return '<p class="empty-state">No load tests yet. Run <code>/pulse-load</code> to capture one.</p>'
|
|
385
|
+
|
|
386
|
+
const latest = reports[reports.length - 1]
|
|
387
|
+
const prev = reports[reports.length - 2]
|
|
388
|
+
const count = reports.length
|
|
389
|
+
const when = new Date(latest.timestamp).toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'short' })
|
|
390
|
+
const isLocalhost = latest.url?.includes('localhost') || latest.url?.includes('127.0.0.1')
|
|
391
|
+
|
|
392
|
+
// Summary row
|
|
393
|
+
const summaryItems = [
|
|
394
|
+
{ label: 'Req / sec', value: fmtRps(latest.rps) },
|
|
395
|
+
{ label: 'Connections', value: latest.config?.connections ?? '—' },
|
|
396
|
+
{ label: 'Duration', value: latest.config?.duration ? `${latest.config.duration}s` : '—' },
|
|
397
|
+
{ label: 'Total req', value: latest.requests?.total ?? '—' },
|
|
398
|
+
{ label: 'Errors', value: latest.requests?.errors ?? 0, color: (latest.requests?.errors || 0) > 0 ? '#f87171' : '#4ade80' },
|
|
399
|
+
]
|
|
400
|
+
|
|
401
|
+
const summary = `
|
|
402
|
+
<section class="load-summary">
|
|
403
|
+
${summaryItems.map(({ label, value, color }) => `
|
|
404
|
+
<div class="load-stat">
|
|
405
|
+
<div class="load-stat-value" style="${color ? `color:${color}` : ''}">${value}</div>
|
|
406
|
+
<div class="load-stat-label">${label}</div>
|
|
407
|
+
</div>`).join('')}
|
|
408
|
+
</section>`
|
|
409
|
+
|
|
410
|
+
// Latency panel
|
|
411
|
+
const latencyRows = LOAD_LATENCY.map(({ key, label, unit, decimals, desc }) => {
|
|
412
|
+
const cur = latest.latency?.[key]
|
|
413
|
+
const prv = prev?.latency?.[key]
|
|
414
|
+
if (cur === undefined) return ''
|
|
415
|
+
const vals = reports.map(r => r.latency?.[key]).filter(n => n !== undefined)
|
|
416
|
+
const lo = Math.min(...vals) * 0.8
|
|
417
|
+
const hi = Math.max(...vals) * 1.2 || 1
|
|
418
|
+
const col = metricColor('latency.' + key, cur, prv)
|
|
419
|
+
const fmt = v => decimals ? v.toFixed(decimals) : Math.round(v)
|
|
420
|
+
return `
|
|
421
|
+
<div class="metric-row">
|
|
422
|
+
<span class="metric-label" title="${desc}">${label}</span>
|
|
423
|
+
<span class="metric-value" style="color:${col}">${fmt(cur)}<span class="metric-unit">${unit}</span></span>
|
|
424
|
+
<span class="metric-delta">${prv !== undefined ? deltaLabel('latency.' + key, cur, prv, decimals) : ''}</span>
|
|
425
|
+
<span class="metric-spark">${sparkline(vals, { yMin: lo, yMax: hi, width: 120, height: 28 })}</span>
|
|
426
|
+
</div>`
|
|
427
|
+
}).filter(Boolean).join('')
|
|
428
|
+
|
|
429
|
+
const rpsVals = reports.map(r => r.rps).filter(n => n !== undefined)
|
|
430
|
+
const rpsPanel = `
|
|
431
|
+
<section class="panel">
|
|
432
|
+
<h3 class="panel-title">Throughput</h3>
|
|
433
|
+
<div class="metric-row">
|
|
434
|
+
<span class="metric-label">Req / sec</span>
|
|
435
|
+
<span class="metric-value" style="color:${metricColor('rps', latest.rps, prev?.rps)}">${fmtRps(latest.rps)}</span>
|
|
436
|
+
<span class="metric-delta">${prev?.rps !== undefined ? deltaLabel('rps', latest.rps, prev.rps, 1) : ''}</span>
|
|
437
|
+
<span class="metric-spark">${sparkline(rpsVals, { width: 120, height: 28 })}</span>
|
|
438
|
+
</div>
|
|
439
|
+
</section>`
|
|
440
|
+
|
|
441
|
+
const latencyPanel = latencyRows ? `
|
|
442
|
+
<section class="panel">
|
|
443
|
+
<h3 class="panel-title">Latency percentiles</h3>
|
|
444
|
+
${latencyRows}
|
|
445
|
+
</section>` : ''
|
|
446
|
+
|
|
447
|
+
// History table
|
|
448
|
+
const histRows = [...reports].reverse().slice(0, 20).map(r => {
|
|
449
|
+
const time = new Date(r.timestamp).toLocaleString(undefined, { dateStyle: 'short', timeStyle: 'short' })
|
|
450
|
+
const errCol = (r.requests?.errors || 0) > 0 ? '#f87171' : '#4ade80'
|
|
451
|
+
return `<tr>
|
|
452
|
+
<td class="time-col">${time}</td>
|
|
453
|
+
<td>${r.config?.duration ?? '—'}s</td>
|
|
454
|
+
<td>${r.config?.connections ?? '—'}</td>
|
|
455
|
+
<td style="font-weight:700;color:#9b8dff">${fmtRps(r.rps)}</td>
|
|
456
|
+
<td>${r.latency?.p50 ?? '—'}</td>
|
|
457
|
+
<td>${r.latency?.p95 ?? '—'}</td>
|
|
458
|
+
<td>${r.latency?.p99 ?? '—'}</td>
|
|
459
|
+
<td style="color:${errCol};font-weight:700">${r.requests?.errors ?? 0}</td>
|
|
460
|
+
</tr>`
|
|
461
|
+
}).join('')
|
|
462
|
+
|
|
463
|
+
const histPanel = `
|
|
464
|
+
<section class="panel">
|
|
465
|
+
<h3 class="panel-title">Test history <span class="panel-sub">· last 30 days</span></h3>
|
|
466
|
+
<table class="history-table">
|
|
467
|
+
<thead><tr>
|
|
468
|
+
<th>Time</th><th>Duration</th><th>Connections</th>
|
|
469
|
+
<th>Req/s</th><th>P50</th><th>P95</th><th>P99</th><th>Errors</th>
|
|
470
|
+
</tr></thead>
|
|
471
|
+
<tbody>${histRows}</tbody>
|
|
472
|
+
</table>
|
|
473
|
+
</section>`
|
|
474
|
+
|
|
475
|
+
const localhostBanner = isLocalhost
|
|
476
|
+
? `<div class="stale-banner">These results are from <strong>localhost</strong> — no network latency, OS scheduling, or real-world conditions. Run against a staging environment for production-representative numbers.</div>`
|
|
477
|
+
: ''
|
|
478
|
+
|
|
479
|
+
return `
|
|
480
|
+
${localhostBanner}
|
|
481
|
+
<div class="page-header">
|
|
482
|
+
<div>
|
|
483
|
+
<h2 class="page-heading">${latest.url || '/' + slug}</h2>
|
|
484
|
+
<p class="page-meta">${count} test${count !== 1 ? 's' : ''} · last run ${when}</p>
|
|
485
|
+
</div>
|
|
486
|
+
</div>
|
|
487
|
+
${summary}
|
|
488
|
+
${rpsPanel}
|
|
489
|
+
${latencyPanel}
|
|
490
|
+
${histPanel}`
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
// ---------------------------------------------------------------------------
|
|
494
|
+
// Index
|
|
495
|
+
// ---------------------------------------------------------------------------
|
|
496
|
+
|
|
497
|
+
function renderIndex(slugs) {
|
|
498
|
+
if (!slugs.length) return `
|
|
499
|
+
<div class="empty-state">
|
|
500
|
+
<p>No reports yet.</p>
|
|
501
|
+
<p>Run <code>/pulse-report</code> to audit a page or <code>/pulse-load</code> to run a load test.</p>
|
|
502
|
+
</div>`
|
|
503
|
+
|
|
504
|
+
const cards = slugs.map(slug => {
|
|
505
|
+
const reports = loadReports(slug)
|
|
506
|
+
const loadRpts = loadLoadReports(slug)
|
|
507
|
+
const latest = reports[reports.length - 1]
|
|
508
|
+
const latestLd = loadRpts[loadRpts.length - 1]
|
|
509
|
+
const scores = latest?.scores
|
|
510
|
+
|
|
511
|
+
const gauges = CATEGORIES.map(({ key, label }) =>
|
|
512
|
+
`<span title="${label}">${scoreGauge(validScore(key, scores?.[key]), 44)}</span>`
|
|
513
|
+
).join('')
|
|
514
|
+
|
|
515
|
+
const loadBadge = latestLd
|
|
516
|
+
? `<span class="load-badge" title="Latest load test">${latestLd.rps?.toFixed(0) ?? '—'} req/s</span>`
|
|
517
|
+
: ''
|
|
518
|
+
|
|
519
|
+
return `
|
|
520
|
+
<a href="/${slug}" class="index-card">
|
|
521
|
+
<span class="index-url">${latest?.url || latestLd?.url || '/' + slug}</span>
|
|
522
|
+
<span class="index-gauges">${gauges}</span>
|
|
523
|
+
${loadBadge}
|
|
524
|
+
</a>`
|
|
525
|
+
}).join('')
|
|
526
|
+
|
|
527
|
+
return `<h2 class="page-heading" style="margin-bottom:1.25rem">Pages</h2><div class="index-list">${cards}</div>`
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
// ---------------------------------------------------------------------------
|
|
531
|
+
// Layout + CSS
|
|
532
|
+
// ---------------------------------------------------------------------------
|
|
533
|
+
|
|
534
|
+
const CSS = `
|
|
535
|
+
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
|
|
536
|
+
:root{
|
|
537
|
+
--bg:#0c0c11;--surface:#14141c;--surface2:#1a1a25;
|
|
538
|
+
--border:#22222f;--text:#dddde8;--muted:#666678;
|
|
539
|
+
--accent:#9b8dff;--radius:10px;
|
|
540
|
+
}
|
|
541
|
+
html{font-size:14px}
|
|
542
|
+
body{font-family:system-ui,sans-serif;background:var(--bg);color:var(--text);line-height:1.5;min-height:100vh}
|
|
543
|
+
a{color:var(--accent);text-decoration:none}
|
|
544
|
+
code{font-family:ui-monospace,monospace;font-size:.85em;background:var(--surface2);padding:2px 6px;border-radius:4px}
|
|
545
|
+
|
|
546
|
+
.layout{display:flex;min-height:100vh}
|
|
547
|
+
|
|
548
|
+
/* Sidebar */
|
|
549
|
+
.sidebar{width:210px;flex-shrink:0;background:var(--surface);border-right:1px solid var(--border);padding:1.25rem 0}
|
|
550
|
+
.sidebar-logo{display:flex;align-items:center;gap:.5rem;font-weight:700;font-size:.95rem;padding:.2rem 1.1rem 1.25rem;color:var(--text)}
|
|
551
|
+
.sidebar-logo svg{color:var(--accent)}
|
|
552
|
+
.sidebar-section{font-size:.6rem;font-weight:700;letter-spacing:.1em;text-transform:uppercase;color:var(--muted);padding:.75rem 1.1rem .25rem}
|
|
553
|
+
.sidebar-link{display:block;padding:.35rem 1.1rem;font-size:.8rem;color:var(--muted);border-left:2px solid transparent;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
|
554
|
+
.sidebar-link:hover{color:var(--text);background:var(--surface2)}
|
|
555
|
+
.sidebar-link.active{color:var(--text);border-left-color:var(--accent)}
|
|
556
|
+
|
|
557
|
+
/* Main */
|
|
558
|
+
.main{flex:1;padding:2rem 2.25rem;max-width:960px;overflow:auto}
|
|
559
|
+
|
|
560
|
+
/* Tabs */
|
|
561
|
+
.tabs{display:flex;gap:0;border-bottom:1px solid var(--border);margin-bottom:1.75rem}
|
|
562
|
+
.tab{padding:.5rem 1.1rem;font-size:.8rem;font-weight:600;color:var(--muted);border-bottom:2px solid transparent;margin-bottom:-1px;transition:color .1s}
|
|
563
|
+
.tab:hover{color:var(--text)}
|
|
564
|
+
.tab.active{color:var(--accent);border-bottom-color:var(--accent)}
|
|
565
|
+
|
|
566
|
+
/* Page header */
|
|
567
|
+
.page-header{display:flex;align-items:flex-start;justify-content:space-between;gap:1rem;margin-bottom:1.75rem}
|
|
568
|
+
.page-heading{font-size:1.2rem;font-weight:700;margin-bottom:.2rem}
|
|
569
|
+
.page-meta{color:var(--muted);font-size:.78rem}
|
|
570
|
+
.health-badge{font-size:.72rem;font-weight:700;letter-spacing:.05em;text-transform:uppercase;border:1px solid;border-radius:20px;padding:.25rem .75rem;white-space:nowrap}
|
|
571
|
+
|
|
572
|
+
/* Score row */
|
|
573
|
+
.score-row{display:grid;grid-template-columns:repeat(4,1fr);gap:.75rem;margin-bottom:1.25rem}
|
|
574
|
+
.score-card{background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);padding:1rem;display:flex;align-items:center;gap:.875rem}
|
|
575
|
+
.score-gauge svg{display:block;flex-shrink:0}
|
|
576
|
+
.score-info{flex:1;min-width:0}
|
|
577
|
+
.score-label{font-size:.65rem;font-weight:700;letter-spacing:.07em;text-transform:uppercase;color:var(--muted);margin-bottom:.15rem}
|
|
578
|
+
.score-delta{font-size:.8rem;min-height:1.1rem;margin-bottom:.35rem}
|
|
579
|
+
.score-spark svg{display:block}
|
|
580
|
+
|
|
581
|
+
/* Load summary */
|
|
582
|
+
.load-summary{display:flex;gap:.75rem;margin-bottom:1.25rem;flex-wrap:wrap}
|
|
583
|
+
.load-stat{background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);padding:.875rem 1.25rem;min-width:110px;text-align:center}
|
|
584
|
+
.load-stat-value{font-size:1.5rem;font-weight:800;color:var(--accent);line-height:1;margin-bottom:.3rem}
|
|
585
|
+
.load-stat-label{font-size:.6rem;font-weight:700;letter-spacing:.08em;text-transform:uppercase;color:var(--muted)}
|
|
586
|
+
|
|
587
|
+
/* Panels */
|
|
588
|
+
.panel{background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);padding:1.1rem;margin-bottom:1.1rem}
|
|
589
|
+
.panel-title{font-size:.6rem;font-weight:700;letter-spacing:.1em;text-transform:uppercase;color:var(--muted);margin-bottom:.875rem}
|
|
590
|
+
.panel-sub{font-weight:400;text-transform:none;letter-spacing:0;font-size:.7rem}
|
|
591
|
+
|
|
592
|
+
/* Metric rows */
|
|
593
|
+
.metric-row{display:grid;grid-template-columns:100px 110px 70px 1fr;align-items:center;gap:1rem;padding:.45rem 0;border-bottom:1px solid var(--border)}
|
|
594
|
+
.metric-row:last-child{border-bottom:none}
|
|
595
|
+
.metric-label{font-size:.72rem;font-weight:600;color:var(--muted);cursor:default}
|
|
596
|
+
.metric-value{font-size:1.05rem;font-weight:800}
|
|
597
|
+
.metric-unit{font-size:.65rem;color:var(--muted);margin-left:1px;font-weight:400}
|
|
598
|
+
.metric-delta{font-size:.78rem}
|
|
599
|
+
.metric-spark svg{display:block}
|
|
600
|
+
|
|
601
|
+
/* Bundle */
|
|
602
|
+
.bundle-grid{display:flex;flex-direction:column;gap:.6rem}
|
|
603
|
+
.bundle-item{display:flex;align-items:center;gap:1rem;padding:.35rem 0;border-bottom:1px solid var(--border)}
|
|
604
|
+
.bundle-item:last-child{border-bottom:none}
|
|
605
|
+
.bundle-name{font-size:.78rem;font-weight:600;color:var(--muted);width:140px;flex-shrink:0}
|
|
606
|
+
.bundle-size{font-size:.95rem;font-weight:800;width:80px}
|
|
607
|
+
.bundle-note{font-size:.72rem;color:var(--muted)}
|
|
608
|
+
.bundle-spark svg{display:block}
|
|
609
|
+
|
|
610
|
+
/* History */
|
|
611
|
+
.history-table{width:100%;border-collapse:collapse;font-size:.8rem}
|
|
612
|
+
.history-table th{text-align:left;font-size:.6rem;font-weight:700;letter-spacing:.08em;text-transform:uppercase;color:var(--muted);padding:.4rem .7rem;border-bottom:1px solid var(--border)}
|
|
613
|
+
.history-table td{padding:.42rem .7rem;border-bottom:1px solid var(--border)}
|
|
614
|
+
.history-table tr:last-child td{border-bottom:none}
|
|
615
|
+
.time-col{color:var(--muted)!important;font-size:.74rem!important;font-weight:400!important}
|
|
616
|
+
|
|
617
|
+
/* Index */
|
|
618
|
+
.index-list{display:flex;flex-direction:column;gap:.5rem}
|
|
619
|
+
.index-card{display:flex;align-items:center;gap:1.25rem;background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);padding:.75rem 1.1rem;transition:border-color .15s}
|
|
620
|
+
.index-card:hover{border-color:var(--accent)}
|
|
621
|
+
.index-url{flex:1;font-size:.875rem;color:var(--text)}
|
|
622
|
+
.index-gauges{display:flex;gap:.5rem;align-items:center}
|
|
623
|
+
.index-gauges svg{display:block}
|
|
624
|
+
.load-badge{font-size:.7rem;font-weight:700;color:#9b8dff;background:#9b8dff18;border:1px solid #9b8dff30;border-radius:20px;padding:.2rem .6rem;white-space:nowrap}
|
|
625
|
+
|
|
626
|
+
.stale-banner{background:#fb923c12;border:1px solid #fb923c40;border-radius:var(--radius);padding:.6rem 1rem;font-size:.78rem;color:#fb923c;margin-bottom:1.25rem}
|
|
627
|
+
.stale-banner strong{font-weight:700}
|
|
628
|
+
.stale-banner code{background:#fb923c18;color:#fb923c}
|
|
629
|
+
.empty-state{display:flex;flex-direction:column;align-items:center;justify-content:center;gap:.75rem;padding:5rem 2rem;color:var(--muted);text-align:center}
|
|
630
|
+
`
|
|
631
|
+
|
|
632
|
+
function renderHTML({ title, content, slugs, activeSlug, activeTab, hasBoth }) {
|
|
633
|
+
const links = slugs.map(slug => {
|
|
634
|
+
const reports = loadReports(slug)
|
|
635
|
+
const url = reports[reports.length - 1]?.url
|
|
636
|
+
const loadRpts = loadLoadReports(slug)
|
|
637
|
+
const ldUrl = loadRpts[loadRpts.length - 1]?.url
|
|
638
|
+
const display = url || ldUrl || '/' + slug
|
|
639
|
+
return `<a href="/${slug}" class="sidebar-link${slug === activeSlug ? ' active' : ''}">${display}</a>`
|
|
640
|
+
}).join('')
|
|
641
|
+
|
|
642
|
+
const tabs = activeSlug && hasBoth ? `
|
|
643
|
+
<div class="tabs">
|
|
644
|
+
<a href="/${activeSlug}" class="tab${activeTab === 'perf' ? ' active' : ''}">Performance</a>
|
|
645
|
+
<a href="/${activeSlug}/load" class="tab${activeTab === 'load' ? ' active' : ''}">Load Tests</a>
|
|
646
|
+
</div>` : activeSlug ? `
|
|
647
|
+
<div class="tabs">
|
|
648
|
+
${hasBoth === false && activeTab === 'perf'
|
|
649
|
+
? `<a href="/${activeSlug}" class="tab active">Performance</a><a href="/${activeSlug}/load" class="tab" style="opacity:.4">Load Tests</a>`
|
|
650
|
+
: `<a href="/${activeSlug}" class="tab" style="opacity:.4">Performance</a><a href="/${activeSlug}/load" class="tab active">Load Tests</a>`
|
|
651
|
+
}
|
|
652
|
+
</div>` : ''
|
|
653
|
+
|
|
654
|
+
return `<!DOCTYPE html>
|
|
655
|
+
<html lang="en">
|
|
656
|
+
<head>
|
|
657
|
+
<meta charset="UTF-8">
|
|
658
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
659
|
+
<title>${title} — Pulse Reports</title>
|
|
660
|
+
<style>${CSS}</style>
|
|
661
|
+
</head>
|
|
662
|
+
<body>
|
|
663
|
+
<div class="layout">
|
|
664
|
+
<aside class="sidebar">
|
|
665
|
+
<a href="/" class="sidebar-logo">
|
|
666
|
+
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
|
667
|
+
<path d="M13 2L4.5 13.5H11L10 22L19.5 10.5H13L13 2Z" fill="currentColor" stroke="currentColor" stroke-width="1" stroke-linejoin="round"/>
|
|
668
|
+
</svg>
|
|
669
|
+
Pulse Reports
|
|
670
|
+
</a>
|
|
671
|
+
${slugs.length ? `<div class="sidebar-section">Pages</div>${links}` : ''}
|
|
672
|
+
</aside>
|
|
673
|
+
<main id="main-content" class="main">
|
|
674
|
+
${tabs}
|
|
675
|
+
${content}
|
|
676
|
+
</main>
|
|
677
|
+
</div>
|
|
678
|
+
</body>
|
|
679
|
+
</html>`
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
// ---------------------------------------------------------------------------
|
|
683
|
+
// Server
|
|
684
|
+
// ---------------------------------------------------------------------------
|
|
685
|
+
|
|
686
|
+
http.createServer((req, res) => {
|
|
687
|
+
const urlPath = req.url.split('?')[0]
|
|
688
|
+
const parts = urlPath.slice(1).split('/')
|
|
689
|
+
const slug = parts[0] || ''
|
|
690
|
+
const tab = parts[1] || 'perf' // 'perf' | 'load'
|
|
691
|
+
const slugs = allSlugs()
|
|
692
|
+
|
|
693
|
+
let title, content, activeTab = tab, hasBoth = false
|
|
694
|
+
|
|
695
|
+
if (!slug) {
|
|
696
|
+
title = 'Overview'
|
|
697
|
+
content = renderIndex(slugs)
|
|
698
|
+
} else if (slugs.includes(slug)) {
|
|
699
|
+
const perfReports = loadReports(slug)
|
|
700
|
+
const loadReports_ = loadLoadReports(slug)
|
|
701
|
+
hasBoth = perfReports.length > 0 && loadReports_.length > 0
|
|
702
|
+
|
|
703
|
+
if (tab === 'load') {
|
|
704
|
+
title = (loadReports_[loadReports_.length - 1]?.url || '/' + slug) + ' — Load'
|
|
705
|
+
content = renderLoadTab(slug, loadReports_)
|
|
706
|
+
activeTab = 'load'
|
|
707
|
+
} else {
|
|
708
|
+
title = perfReports[perfReports.length - 1]?.url || '/' + slug
|
|
709
|
+
content = renderPerfTab(slug, perfReports)
|
|
710
|
+
activeTab = 'perf'
|
|
711
|
+
}
|
|
712
|
+
} else {
|
|
713
|
+
res.writeHead(404, { 'Content-Type': 'text/plain' })
|
|
714
|
+
res.end('Not found')
|
|
715
|
+
return
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store' })
|
|
719
|
+
res.end(renderHTML({ title, content, slugs, activeSlug: slug, activeTab, hasBoth }))
|
|
720
|
+
|
|
721
|
+
}).listen(PORT, () => {
|
|
722
|
+
console.log(`\n⚡ Pulse report server running at http://localhost:${PORT}\n`)
|
|
723
|
+
})
|