{"_id":"@carverauto/serviceradar-dashboard-sdk","_rev":"5-824943b912b1915bd71ffc6c28378871","name":"@carverauto/serviceradar-dashboard-sdk","dist-tags":{"latest":"0.4.0"},"versions":{"0.1.1":{"name":"@carverauto/serviceradar-dashboard-sdk","version":"0.1.1","license":"UNLICENSED","_id":"@carverauto/serviceradar-dashboard-sdk@0.1.1","maintainers":[{"name":"mfreeman451","email":"mfreeman@carverauto.dev"}],"dist":{"shasum":"3e1de8bb0831a8b786764de712e866e7291f7fac","tarball":"https://registry.npmjs.org/@carverauto/serviceradar-dashboard-sdk/-/serviceradar-dashboard-sdk-0.1.1.tgz","fileCount":28,"integrity":"sha512-tcHdEm+cQ62BASbQWNU69I0flhlW6lnkwk98HJAeTSi1SkBmdtLmbZ4W3IUT0e+9EwoZCgkrOpVrPzafgDrNgA==","signatures":[{"sig":"MEQCIH/e4e124OPDXRysoD8eifvHwRveFQXrnF7xB5llzHuRAiB9yOYaxRF/qUczQEcnSUp5Oqbpwp/1unViUSwa/8P8Og==","keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U"}],"unpackedSize":117292},"type":"module","exports":{".":{"types":"./src/index.d.ts","default":"./src/index.js"},"./map":{"types":"./src/map.d.ts","default":"./src/map.js"},"./srql":{"types":"./src/srql.d.ts","default":"./src/srql.js"},"./arrow":{"types":"./src/arrow.d.ts","default":"./src/arrow.js"},"./popup":{"types":"./src/popup.d.ts","default":"./src/popup.js"},"./react":{"types":"./src/react.d.ts","default":"./src/react.js"},"./config":{"types":"./src/config.d.ts","default":"./src/config.js"},"./frames":{"types":"./src/frames.d.ts","default":"./src/frames.js"},"./filtering":{"types":"./src/filtering.d.ts","default":"./src/filtering.js"},"./query-state":{"types":"./src/query-state.d.ts","default":"./src/query-state.js"}},"gitHead":"b17b1d9a8ac5d018ff52b0f9ac77c6c1614430d4","scripts":{"ci":"npm test && npm run pack:check","test":"npm run test:js && go test ./...","test:js":"node --test tests/*.test.mjs","pack:check":"npm pack --dry-run"},"_npmUser":{"name":"mfreeman451","email":"mfreeman@carverauto.dev"},"repository":{"url":"git+ssh://git@code.carverauto.dev/carverauto/serviceradar-sdk-dashboard.git","type":"git"},"_npmVersion":"10.8.2","description":"ServiceRadar dashboard package SDK for browser module and WASM renderers.","directories":{},"_nodeVersion":"20.20.2","dependencies":{"@carverauto/serviceradar-cli":"^0.1.0"},"publishConfig":{"access":"public"},"_hasShrinkwrap":false,"devDependencies":{"react":"^19.2.5","react-dom":"^19.2.5"},"peerDependencies":{"react":">=19.2.5","react-dom":">=19.2.5"},"_npmOperationalInternal":{"tmp":"tmp/serviceradar-dashboard-sdk_0.1.1_1777959539055_0.2653593170321902","host":"s3://npm-registry-packages-npm-production"}},"0.1.4":{"name":"@carverauto/serviceradar-dashboard-sdk","version":"0.1.4","license":"UNLICENSED","_id":"@carverauto/serviceradar-dashboard-sdk@0.1.4","maintainers":[{"name":"mfreeman451","email":"mfreeman@carverauto.dev"}],"dist":{"shasum":"c627d160d33ce6359d29dc89bf848509e9c4134b","tarball":"https://registry.npmjs.org/@carverauto/serviceradar-dashboard-sdk/-/serviceradar-dashboard-sdk-0.1.4.tgz","fileCount":28,"integrity":"sha512-c1UXhINn+LD3feard6hpZYD4xYZW0tOkDshuhnEyZ0VEauvOGVRjkoGNEunkP7coapzj27K/VlBHOaSqCfVi0g==","signatures":[{"sig":"MEUCIQDqsMnzRfy1RAeNzCKMuZSCOE6b6ESmiqv0U/2gHeF6hQIgU4st5R8omX6Yn3+YrnIPvZeB/CXVagqrPCaRWEvLxIo=","keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U"}],"unpackedSize":116538},"type":"module","exports":{".":{"types":"./src/index.d.ts","default":"./src/index.js"},"./map":{"types":"./src/map.d.ts","default":"./src/map.js"},"./srql":{"types":"./src/srql.d.ts","default":"./src/srql.js"},"./arrow":{"types":"./src/arrow.d.ts","default":"./src/arrow.js"},"./popup":{"types":"./src/popup.d.ts","default":"./src/popup.js"},"./react":{"types":"./src/react.d.ts","default":"./src/react.js"},"./config":{"types":"./src/config.d.ts","default":"./src/config.js"},"./frames":{"types":"./src/frames.d.ts","default":"./src/frames.js"},"./filtering":{"types":"./src/filtering.d.ts","default":"./src/filtering.js"},"./query-state":{"types":"./src/query-state.d.ts","default":"./src/query-state.js"}},"gitHead":"1ae62a74b81b596677b7d1a7e6707e2bd9ca3d6a","scripts":{"ci":"npm test && npm run pack:check","test":"npm run test:js && go test ./...","test:js":"node --test tests/*.test.mjs","pack:check":"npm pack --dry-run"},"_npmUser":{"name":"mfreeman451","email":"mfreeman@carverauto.dev"},"repository":{"url":"git+ssh://git@code.carverauto.dev/carverauto/serviceradar-sdk-dashboard.git","type":"git"},"_npmVersion":"10.8.2","description":"ServiceRadar dashboard package SDK for browser module and WASM renderers.","directories":{},"_nodeVersion":"20.20.2","dependencies":{"@carverauto/serviceradar-cli":"^0.1.4"},"publishConfig":{"access":"public"},"_hasShrinkwrap":false,"devDependencies":{"react":"^19.2.5","react-dom":"^19.2.5"},"peerDependencies":{"react":">=19.2.5","react-dom":">=19.2.5"},"_npmOperationalInternal":{"tmp":"tmp/serviceradar-dashboard-sdk_0.1.4_1778023445718_0.07288311636767153","host":"s3://npm-registry-packages-npm-production"}},"0.2.0":{"name":"@carverauto/serviceradar-dashboard-sdk","version":"0.2.0","license":"UNLICENSED","_id":"@carverauto/serviceradar-dashboard-sdk@0.2.0","maintainers":[{"name":"mfreeman451","email":"mfreeman@carverauto.dev"}],"homepage":"https://github.com/carverauto/serviceradar-sdk-dashboard#readme","bugs":{"url":"https://github.com/carverauto/serviceradar-sdk-dashboard/issues"},"dist":{"shasum":"fc5d1d84735e641b864898286138c792833427b7","tarball":"https://registry.npmjs.org/@carverauto/serviceradar-dashboard-sdk/-/serviceradar-dashboard-sdk-0.2.0.tgz","fileCount":28,"integrity":"sha512-fj5pz870qaNa/oMt9a/sEvQkRSoh1Cuv7lxJEt1XAecNzJXSiL5kZaNgQVXkkEDfUu5Na2DNpbhbqGdB7KU1/A==","signatures":[{"sig":"MEUCIQDOm7eYkfD110GvzrlvL/1x8mmXW3WuOs/zODu/UZt6EgIgXn0H6jHRT225adpUiOrKNP/uzfGXtjU/yR4Z/Q6TbHo=","keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U"},{"sig":"MEQCIFYgoK8X4qzXWl/iqHdINeFD4f6dxl0TyythlYCHP0brAiAhuRPRc4kg9YsYCGVZL9f+jI1L6Do+VtwT6I9dm55XLg==","keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U"}],"attestations":{"url":"https://registry.npmjs.org/-/npm/v1/attestations/@carverauto%2fserviceradar-dashboard-sdk@0.2.0","provenance":{"predicateType":"https://slsa.dev/provenance/v1"}},"unpackedSize":119789},"type":"module","exports":{".":{"types":"./src/index.d.ts","default":"./src/index.js"},"./map":{"types":"./src/map.d.ts","default":"./src/map.js"},"./srql":{"types":"./src/srql.d.ts","default":"./src/srql.js"},"./arrow":{"types":"./src/arrow.d.ts","default":"./src/arrow.js"},"./popup":{"types":"./src/popup.d.ts","default":"./src/popup.js"},"./react":{"types":"./src/react.d.ts","default":"./src/react.js"},"./config":{"types":"./src/config.d.ts","default":"./src/config.js"},"./frames":{"types":"./src/frames.d.ts","default":"./src/frames.js"},"./filtering":{"types":"./src/filtering.d.ts","default":"./src/filtering.js"},"./query-state":{"types":"./src/query-state.d.ts","default":"./src/query-state.js"}},"gitHead":"043c962c6b3853136b2efa8c585e3345e925251a","scripts":{"ci":"npm test && npm run pack:check","test":"npm run test:js && go test ./...","test:js":"node --test tests/*.test.mjs","pack:check":"npm pack --dry-run"},"_npmUser":{"name":"GitHub Actions","email":"npm-oidc-no-reply@github.com","trustedPublisher":{"id":"github","oidcConfigId":"oidc:25d0b87b-d251-489c-9f6c-5a4e4424b909"}},"repository":{"url":"git+https://github.com/carverauto/serviceradar-sdk-dashboard.git","type":"git"},"_npmVersion":"12.0.2","description":"ServiceRadar dashboard package SDK for browser module and WASM renderers.","directories":{},"_nodeVersion":"24.19.0","dependencies":{"@carverauto/serviceradar-cli":"^0.1.4"},"publishConfig":{"access":"public"},"_hasShrinkwrap":false,"devDependencies":{"react":"^19.2.5","react-dom":"^19.2.5"},"peerDependencies":{"react":">=19.2.5","react-dom":">=19.2.5"},"_npmOperationalInternal":{"tmp":"tmp/serviceradar-dashboard-sdk_0.2.0_1787974127853_0.10399113940515181","host":"s3://npm-registry-packages-npm-production"}},"0.3.0":{"name":"@carverauto/serviceradar-dashboard-sdk","version":"0.3.0","license":"UNLICENSED","_id":"@carverauto/serviceradar-dashboard-sdk@0.3.0","maintainers":[{"name":"mfreeman451","email":"mfreeman@carverauto.dev"}],"homepage":"https://github.com/carverauto/serviceradar-sdk-dashboard#readme","bugs":{"url":"https://github.com/carverauto/serviceradar-sdk-dashboard/issues"},"dist":{"shasum":"b73024316981226a73184397c0610c7271444342","tarball":"https://registry.npmjs.org/@carverauto/serviceradar-dashboard-sdk/-/serviceradar-dashboard-sdk-0.3.0.tgz","fileCount":28,"integrity":"sha512-We/x6sA8mM6/fQTm4mBTuTZcd3QRSr3E4vkk5+qNmBiFmm3MHh9ake9ElbNomc/GEmgy39WuXuk2EXfvf4pMfA==","signatures":[{"sig":"MEUCIQDBS7aocrr3+AqRamEsh40S1clRCWlMOxwTyIc39P51QwIgW51H5V7pw0wwHnWoIex5rDduB447iWPnxOfC7Fko4fE=","keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U"},{"sig":"MEUCIQDANKQfzrT6LdKBP96YIMUzwK4topIay7E5NMzaHzle0QIgRD5Gh69zIyJblA+TSU8Lxs7hYl0qNK+q36xLFLEmI+g=","keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U"}],"attestations":{"url":"https://registry.npmjs.org/-/npm/v1/attestations/@carverauto%2fserviceradar-dashboard-sdk@0.3.0","provenance":{"predicateType":"https://slsa.dev/provenance/v1"}},"unpackedSize":132571},"type":"module","exports":{".":{"types":"./src/index.d.ts","default":"./src/index.js"},"./map":{"types":"./src/map.d.ts","default":"./src/map.js"},"./srql":{"types":"./src/srql.d.ts","default":"./src/srql.js"},"./arrow":{"types":"./src/arrow.d.ts","default":"./src/arrow.js"},"./popup":{"types":"./src/popup.d.ts","default":"./src/popup.js"},"./react":{"types":"./src/react.d.ts","default":"./src/react.js"},"./config":{"types":"./src/config.d.ts","default":"./src/config.js"},"./frames":{"types":"./src/frames.d.ts","default":"./src/frames.js"},"./filtering":{"types":"./src/filtering.d.ts","default":"./src/filtering.js"},"./query-state":{"types":"./src/query-state.d.ts","default":"./src/query-state.js"}},"gitHead":"617198af5e70f94a460478fb349f20687c2f33ec","scripts":{"ci":"npm test && npm run pack:check","test":"npm run test:js && go test ./...","test:js":"node --test tests/*.test.mjs","pack:check":"npm pack --dry-run"},"_npmUser":{"name":"GitHub Actions","email":"npm-oidc-no-reply@github.com","trustedPublisher":{"id":"github","oidcConfigId":"oidc:25d0b87b-d251-489c-9f6c-5a4e4424b909"}},"repository":{"url":"git+https://github.com/carverauto/serviceradar-sdk-dashboard.git","type":"git"},"_npmVersion":"12.1.0","description":"ServiceRadar dashboard package SDK for browser module and WASM renderers.","directories":{},"_nodeVersion":"24.21.0","dependencies":{"@carverauto/serviceradar-cli":"^0.1.4"},"publishConfig":{"access":"public"},"_hasShrinkwrap":false,"devDependencies":{"react":"^19.2.5","react-dom":"^19.2.5"},"peerDependencies":{"react":">=19.2.5","react-dom":">=19.2.5"},"_npmOperationalInternal":{"tmp":"tmp/serviceradar-dashboard-sdk_0.3.0_1790457925861_0.9598086901661942","host":"s3://npm-registry-packages-npm-production"}},"0.4.0":{"_id":"@carverauto/serviceradar-dashboard-sdk@0.4.0","bugs":{"url":"https://github.com/carverauto/serviceradar-sdk-dashboard/issues"},"dist":{"shasum":"408fe542c21091373bb3bd41294460a948b6c1d7","tarball":"https://registry.npmjs.org/@carverauto/serviceradar-dashboard-sdk/-/serviceradar-dashboard-sdk-0.4.0.tgz","fileCount":32,"integrity":"sha512-ocgLLRznwk95/2IVqo1t+2GFGnivTJ2HycUK9oNjT5eNd5U0xCuWcMZFQ90bHyui9h4YpOjtWRGRdUF/5DaTBg==","signatures":[{"sig":"MEUCIGsZJBA1435cTyYJ/s4WFVw0TtiZbrVO/0zhEaMS4sGHAiEA6pJLsXQhcvGyRV4eR8tQVjwhmzzxvXuxqchW6lKSXKM=","keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U"},{"keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U","sig":"MEYCIQCphSxFI9K6mf58hndKmLQP7WhJF48IIUpYrERjV0Ni6AIhAOVTYF4F1h2zYIB8SxkZJ49AdAzYPq5RZTSbGfU+qdhU"}],"unpackedSize":168830},"name":"@carverauto/serviceradar-dashboard-sdk","type":"module","exports":{".":{"types":"./src/index.d.ts","default":"./src/index.js"},"./map":{"types":"./src/map.d.ts","default":"./src/map.js"},"./live":{"types":"./src/live.d.ts","default":"./src/live.js"},"./srql":{"types":"./src/srql.d.ts","default":"./src/srql.js"},"./arrow":{"types":"./src/arrow.d.ts","default":"./src/arrow.js"},"./popup":{"types":"./src/popup.d.ts","default":"./src/popup.js"},"./react":{"types":"./src/react.d.ts","default":"./src/react.js"},"./camera":{"types":"./src/camera.d.ts","default":"./src/camera.js"},"./config":{"types":"./src/config.d.ts","default":"./src/config.js"},"./frames":{"types":"./src/frames.d.ts","default":"./src/frames.js"},"./filtering":{"types":"./src/filtering.d.ts","default":"./src/filtering.js"},"./query-state":{"types":"./src/query-state.d.ts","default":"./src/query-state.js"}},"gitHead":"70517e404cd5ee2682ce88f9e52e0527d00103fd","license":"UNLICENSED","scripts":{"ci":"npm test && npm run pack:check","test":"npm run test:js && go test ./...","test:js":"node --test tests/*.test.mjs","pack:check":"npm pack --dry-run"},"version":"0.4.0","_npmUser":{"name":"mfreeman451","email":"mfreeman@carverauto.dev"},"homepage":"https://github.com/carverauto/serviceradar-sdk-dashboard#readme","repository":{"url":"git+https://github.com/carverauto/serviceradar-sdk-dashboard.git","type":"git"},"_npmVersion":"10.9.8","description":"ServiceRadar dashboard package SDK for browser module and WASM renderers.","directories":{},"maintainers":[{"name":"mfreeman451","email":"mfreeman@carverauto.dev"}],"_nodeVersion":"22.23.2","dependencies":{"@carverauto/serviceradar-cli":"^0.1.4"},"publishConfig":{"access":"public"},"_hasShrinkwrap":false,"devDependencies":{"react":"^19.2.5","react-dom":"^19.2.5"},"peerDependencies":{"react":">=19.2.5","react-dom":">=19.2.5"},"_npmOperationalInternal":{"host":"s3://npm-registry-packages-npm-production","tmp":"tmp/serviceradar-dashboard-sdk_0.4.0_1790587263420_0.8645834215327484"}}},"time":{"created":"2026-05-05T05:38:58.992Z","modified":"2026-09-28T09:21:03.749Z","0.1.1":"2026-05-05T05:38:59.254Z","0.1.4":"2026-05-05T23:24:05.876Z","0.2.0":"2026-08-29T03:28:47.947Z","0.3.0":"2026-09-26T21:25:25.940Z","0.4.0":"2026-09-28T09:21:03.514Z"},"bugs":{"url":"https://github.com/carverauto/serviceradar-sdk-dashboard/issues"},"license":"UNLICENSED","homepage":"https://github.com/carverauto/serviceradar-sdk-dashboard#readme","repository":{"url":"git+https://github.com/carverauto/serviceradar-sdk-dashboard.git","type":"git"},"description":"ServiceRadar dashboard package SDK for browser module and WASM renderers.","maintainers":[{"name":"mfreeman451","email":"mfreeman@carverauto.dev"}],"readme":"# ServiceRadar Dashboard SDK\n\nSource of truth: https://github.com/carverauto/serviceradar-sdk-dashboard\n\nThis SDK contains helpers for browser dashboard packages that target\nServiceRadar's dashboard package host interfaces.\n\nThe canonical SDK reference — including the React hook surface, composed map\npatterns, and the local harness walkthrough — lives on the developer portal:\n[**developer.serviceradar.cloud/docs/v2/dashboard-sdk**](https://developer.serviceradar.cloud/docs/v2/dashboard-sdk).\nThis README mirrors the most important examples for anyone reading the SDK\nsource directly.\n\n## Install\n\nDashboard packages should consume the SDK from npm:\n\nWhen bootstrapping a new dashboard package from an empty directory, initialize\nthe directory first so npm treats it as the project root. Otherwise npm may walk\nup to a parent `package.json` and install/audit that parent project instead.\n\n```bash\nnpm init -y\nnpm prefix\n```\n\n`npm prefix` should print the dashboard package directory. If it prints a parent\ndirectory, create a local `package.json` or run npm with `--prefix \"$PWD\"`.\n\n```bash\nnpm install @carverauto/serviceradar-dashboard-sdk react react-dom\n```\n\nThis single install pulls in `@carverauto/serviceradar-cli` transitively as a dependency,\nso the `serviceradar-cli` bin lands in your project's `node_modules/.bin/`\nautomatically. Project npm scripts can call `serviceradar-cli dashboard <subcommand>`\ndirectly; for ad-hoc invocation use `npx serviceradar-cli ...`.\n\nDuring local SDK development, customer packages may temporarily use a file\ndependency, but published dashboard packages should depend on the npm package.\n\n## CLI\n\nThe companion CLI lives in the ServiceRadar monorepo at\n`~/src/serviceradar/js/cli/` and ships separately as `@carverauto/serviceradar-cli`. It\nexposes two subcommand groups:\n\n- `serviceradar-cli dashboard <init|build|dev|validate|manifest|publish|import>`\n  — full dashboard authoring loop (Vite-driven build, HMR dev harness,\n  scaffolder, publish to a ServiceRadar instance).\n- `serviceradar-cli auth <login|status|logout>` — RFC 8628 device-code login\n  with manual-token fallback. Stores credentials at\n  `~/.config/serviceradar/credentials.json` (mode `0600`).\n\n`dashboard publish` posts a multipart upload to\n`/api/v1/dashboard-packages`; the bearer JWT must carry the\n`dashboard.publish` scope and the user must hold the\n`cli.dashboard.publish` RBAC permission. Same `id@version` re-pushes are\nidempotent when the renderer SHA256 matches; pushes against an enabled\npackage with different bytes are rejected (409\n`version_already_published`) so operator browsers never silently fetch\nswapped renderer code. See the [dashboard-sdk publishing\ndocs](https://developer.serviceradar.cloud/docs/v2/dashboard-sdk#publishing)\nfor the full endpoint contract and error envelope.\n\nThe legacy `serviceradar-dashboard` bin name is preserved as a transitional\nalias that prints a deprecation notice and routes to\n`serviceradar-cli dashboard *`. Removal scheduled for the release after.\n\nCanonical docs:\n[`developer.serviceradar.cloud/docs/v2/dashboard-sdk`](https://developer.serviceradar.cloud/docs/v2/dashboard-sdk).\n\n## Deployment Model\n\nDashboard packages are not compiled into ServiceRadar web-ng. ServiceRadar\nships the stable host, importer, verifier, SRQL data-frame provider, and shared\nbrowser libraries. Customers ship their dashboard package from their own source\nrepository.\n\nThe production flow is:\n\n1. A dashboard author builds a package in an external repository.\n2. The build writes a manifest plus renderer artifact, including a SHA256 digest\n   and any signing metadata required by the operator.\n3. A ServiceRadar admin adds that repository as a dashboard/plugin source.\n4. ServiceRadar imports the manifest and artifact server-side, verifies the\n   digest/trust policy, and stores the package metadata.\n5. An admin enables a dashboard instance and chooses its route or dashboard\n   placement.\n6. At runtime web-ng loads the verified artifact and supplies SRQL data frames,\n   settings, theme, navigation helpers, Mapbox settings, and shared map/deck\n   libraries through the dashboard host API.\n\nThis lets customer dashboards update independently from ServiceRadar releases.\nIf the package renderer changes, the customer publishes a new package version\nand ServiceRadar imports that version; web-ng does not need to be rebuilt.\n\n## Trusted Browser Modules\n\nFor fully custom dashboards, use `dashboard-browser-module-v1`. ServiceRadar\nloads an approved same-origin ES module and passes it bounded SRQL frames,\nsettings, theme, Mapbox settings, and shared map/deck constructors. The package\nowns DOM, deck.gl layers, clustering, popups, and interactions.\n\nTrusted browser modules export:\n\n```js\nexport async function mountDashboard(root, host, api) {\n  return { destroy() {} }\n}\n```\n\nTheir manifest renderer must declare:\n\n```json\n{\n  \"kind\": \"browser_module\",\n  \"interface_version\": \"dashboard-browser-module-v1\",\n  \"artifact\": \"renderer.js\",\n  \"sha256\": \"...\",\n  \"trust\": \"trusted\",\n  \"entrypoint\": \"mountDashboard\"\n}\n```\n\nThis is an admin-approved extension model, not untrusted script execution.\n\n## React Dashboard SDK\n\nReact is the preferred authoring path for browser-module dashboards. The SDK\nexports a small React surface that mirrors the existing web-ng React hook pattern\nused by the Zen rules editor: mount with `createRoot`, keep Phoenix/web-ng as\nthe host shell, and pass all data/theme/navigation through a bounded host API.\n\n```jsx\nimport React from \"react\"\nimport {\n  mountReactDashboard,\n  useDashboardFrame,\n  useDashboardMapbox,\n  useDashboardNavigation,\n  useDashboardSrql,\n  useDashboardTheme,\n} from \"@carverauto/serviceradar-dashboard-sdk/react\"\n\nfunction NetworkMap() {\n  const sites = useDashboardFrame(\"sites\")\n  const srql = useDashboardSrql()\n  const theme = useDashboardTheme()\n  const mapbox = useDashboardMapbox()\n  const navigation = useDashboardNavigation()\n\n  return (\n    <section data-theme={theme} data-map-style={mapbox.style_dark}>\n      <button onClick={() => srql.update(srql.build({\n        entity: \"wifi_sites\",\n        include: {site_code: [\"DEN\"]},\n        limit: 500,\n      }))}>\n        {sites?.results?.length || 0} sites\n      </button>\n      <button onClick={() => navigation.toDashboard(\"network-map\")}>\n        Open map\n      </button>\n    </section>\n  )\n}\n\nexport const mountDashboard = mountReactDashboard(NetworkMap)\n```\n\nThe `./react` subpath ships TypeScript declarations for the stable browser\nhost contract. React dashboards can use `useDashboardFrames`,\n`useDashboardFrame`, `useDashboardTheme`, `useDashboardSrql`,\n`useDashboardFramePagination`,\n`useDashboardSettings`, `useDashboardMapbox`, `useDashboardLibraries`,\n`useDashboardCapability`, `useDashboardNavigation`,\n`useDashboardPreferences`, `useDashboardSavedQueries`, `useDashboardPopup`, and\n`useDashboardDetails` instead of reaching into raw host internals.\n\nThe build output is still a standalone `renderer.js` artifact. Customer authors\ncan iterate against the local harness with sample frames/settings and then ship\nthe same artifact through ServiceRadar package import.\n\nThe companion ServiceRadar CLI owns the repeatable package commands that create\nthat artifact: renderer bundling, manifest digest stamping, harness launch, and\nlocal import validation. Customer dashboard repositories own the React dashboard\ncode, package identity, frame declarations, sample data, and settings schema.\n\nReact dashboards with async setup, such as Mapbox/deck.gl controllers, can opt\ninto an explicit ready lifecycle. This keeps simple dashboards fast while still\nletting heavier dashboards delay host completion until their controller is\nmounted:\n\n```jsx\nimport React from \"react\"\nimport {mountReactDashboard, useDashboardController} from \"@carverauto/serviceradar-dashboard-sdk/react\"\n\nfunction MapDashboard() {\n  const controller = useDashboardController(createMapController)\n\n  if (controller.error) return <div role=\"alert\">Map failed to load</div>\n\n  return <div ref={controller.ref} />\n}\n\nexport const mountDashboard = mountReactDashboard(MapDashboard, {waitForReady: true})\n```\n\n`useDashboardController` owns the common imperative-controller lifecycle for\nReact dashboards: it passes `(root, host, api)` into your controller factory,\ndestroys stale controllers on unmount, reports readiness to\n`mountReactDashboard(..., {waitForReady: true})`, and exposes async startup\nerrors for the component to render.\n\n## Production-Grade React Hooks\n\nThe SDK ships a layered set of hooks designed for dashboards that need to scale\nto thousands of rows, decode Arrow IPC frames, drive Mapbox or deck.gl maps, and\nstay memoized through every host push.\n\n### Query state — `useDashboardQueryState`\n\nCustom dashboards usually have local filter state (chip toggles, search text,\nviewport bounds, drill selections). That state has to be turned into an SRQL\nquery, deduplicated against the previous one, debounced for fast typing, and\napplied through the host's SRQL update API. `useDashboardQueryState` owns all\nof that:\n\n```jsx\nimport {useDashboardQueryState} from \"@carverauto/serviceradar-dashboard-sdk/react\"\n\nconst INITIAL = {region: null, ap: null, search: \"\"}\n\nfunction FilterBar() {\n  const queryState = useDashboardQueryState({\n    initialState: INITIAL,\n    debounceMs: 350,\n    buildQuery: (state) => state.region\n      ? `in:wifi_sites region:(${state.region}) limit:500`\n      : \"in:wifi_sites limit:500\",\n    buildFrameQueries: (state) => state.region\n      ? {aps: `in:wifi_aps region:(${state.region}) limit:500`}\n      : {},\n  })\n\n  return (\n    <>\n      <input\n        value={queryState.state.search}\n        onChange={(event) => queryState.apply({search: event.target.value})}\n      />\n      {[\"AMERICAS\", \"EMEA\", \"APAC\"].map((region) => (\n        <button key={region} onClick={() => queryState.apply({region})}>\n          {region}\n        </button>\n      ))}\n      <button onClick={() => queryState.reset()}>Reset</button>\n      {queryState.dirty ? <span>updating…</span> : null}\n    </>\n  )\n}\n```\n\nThe hook returns `{state, query, frameQueries, dirty, apply, reset, flush, hydrate}`.\nIdentical apply/reset calls are deduped by query+frame-overrides fingerprint —\n`useDashboardQueryState` only invokes `api.srql.update` when the fingerprint\nactually changes. The framework-agnostic core is exposed as\n`createDashboardQueryState` at `@carverauto/serviceradar-dashboard-sdk/query-state` for\nnon-React consumers.\n\n### Frame data — `useFrameRows`, `useArrowTable`, `useDashboardFrame`\n\n`useDashboardFrame` and `useDashboardFrames` now bail out when the incoming\nframe digest matches the cached one, so identical host pushes do not invalidate\ndownstream `useMemo` deps. `useFrameRows` decodes a frame to a row array with\noptional Arrow IPC handling and optional row-shape projection — both are cached\nby the SDK so repeated calls with the same shape on the same frame return the\nsame reference:\n\n```jsx\nimport {useFrameRows} from \"@carverauto/serviceradar-dashboard-sdk/react\"\n\nconst SITE_SHAPE = Object.freeze({\n  site_code: (row) => String(row.site_code || row.iata || \"\").toUpperCase(),\n  region: \"region\",\n  latitude: (row) => Number(row.latitude ?? row.lat),\n  longitude: (row) => Number(row.longitude ?? row.lon),\n})\n\nfunction SitesTable() {\n  const sites = useFrameRows(\"sites\", {decode: \"auto\", shape: SITE_SHAPE})\n  return <span>{sites.length} sites</span>\n}\n```\n\n`decode` accepts `\"auto\"` (default — Arrow IPC if the frame carries it,\notherwise JSON), `\"arrow\"`, or `\"json\"`. Apache Arrow is dynamically imported\nonly when an Arrow path actually decodes — JSON-only dashboards do not pay the\nbundle cost. For column-oriented advanced consumers there's also\n`useArrowTable(frame)` which returns the decoded `apache-arrow` `Table` once\nthe lazy decoder loads. Tests can inject a custom decoder via\n`setArrowDecoder(fn)` from `@carverauto/serviceradar-dashboard-sdk/arrow`.\n\n### Indexed local filtering — `useIndexedRows`, `useFilterState`\n\nResponsive dashboards can avoid repeated linear scans by precomputing per-row\nSets and a single lowercase haystack at data load. `useIndexedRows` provides\nthat primitive:\n\n```jsx\nimport {useFilterState, useIndexedRows} from \"@carverauto/serviceradar-dashboard-sdk/react\"\n\nconst INDEX_BY = {\n  region: \"region\",\n  apFamily: (site) => site.ap_families,\n  wlcModel: (site) => Object.keys(site.wlc_models || {}),\n}\n\nfunction SiteList({sites}) {\n  const filters = useFilterState({\n    initialState: {regions: [], apFamilies: [], wlcModels: [], search: \"\"},\n    debounceMs: 350,\n    debounceFields: [\"search\"],\n  })\n\n  const indexed = useIndexedRows(sites, {indexBy: INDEX_BY, searchText: [\"site_code\", \"name\"]})\n\n  const visible = indexed.applyFilters({\n    region: filters.state.regions,\n    apFamily: filters.state.apFamilies,\n    wlcModel: filters.state.wlcModels,\n    search: filters.debouncedState.search,\n  })\n\n  return (\n    <ul>\n      {visible.map((site) => <li key={site.site_code}>{site.site_code}</li>)}\n    </ul>\n  )\n}\n```\n\n`indexed.applyFilters` returns the rows array via Set intersection rather than\nlinear scans. Indexes rebuild only when the input row reference changes —\ncombined with the digest-stable refs from `useFrameRows`, that means a no-op\nhost push doesn't rebuild any indexes. `useFilterState` returns stable\n`setFilter` / `toggle` / `clear` callbacks for chip groups and supports a\ndebounced `debouncedState` view per field for SRQL-roundtrip drivers.\n\n`useFilterState` and `useDashboardQueryState` compose: feed\n`filters.debouncedState` into `queryState.apply` to drive the SRQL roundtrip,\nwhile `filters.state` drives the immediate sidebar response.\n\n### Map runtime — `useMapboxMap`, `useDeckMap`, `useDeckLayers`\n\nMapbox GL JS is injected by the host through `api.libraries`. Use\n`useMapboxMap` for dashboards that only need the map lifecycle, DOM markers, or\nMapbox sources/layers and do not need deck.gl/luma:\n\n```jsx\nimport {useMapboxMap} from \"@carverauto/serviceradar-dashboard-sdk/map\"\n\nfunction MapStage() {\n  const handle = useMapboxMap({\n    initialViewState: {center: [-98.5, 39.8], zoom: 3.7},\n    viewportThrottleMs: 120,\n    onViewStateChange: (next) => console.log(next.zoom),\n  })\n\n  return <div ref={handle.containerRef} className=\"map-stage\" />\n}\n```\n\nFor GPU-backed layers, `MapboxOverlay` and deck.gl layer constructors are also\ninjected by the host. `useDeckMap` composes `useMapboxMap`, instantiates the\noverlay once, and `useDeckLayers` owns deck layer memoization:\n\n```jsx\nimport {useDeckMap, useDeckLayers, scatter, text} from \"@carverauto/serviceradar-dashboard-sdk/map\"\n\nfunction MapStage({sites, dark}) {\n  const handle = useDeckMap({\n    initialViewState: {center: [-98.5, 39.8], zoom: 3.7},\n    viewportThrottleMs: 120,\n    onViewStateChange: (next) => console.log(next.zoom),\n  })\n\n  const accessors = useMemo(() => ({\n    getPosition: (site) => [site.longitude, site.latitude],\n    getRadius: 8,\n  }), [])\n\n  const visualProps = useMemo(() => ({\n    pickable: true,\n    radiusUnits: \"pixels\",\n    getFillColor: dark ? [17, 24, 39, 238] : [255, 255, 255, 248],\n    getLineColor: [31, 34, 207, 255],\n  }), [dark])\n\n  useDeckLayers(handle, {\n    sites: scatter(\"sites\", {data: sites, accessors, visualProps, events: {onClick: console.log}}),\n    labels: text(\"labels\", {\n      data: sites,\n      accessors: useMemo(() => ({\n        getPosition: (site) => [site.longitude, site.latitude],\n        getText: (site) => site.site_code,\n      }), []),\n      visualProps: useMemo(() => ({getSize: 13, background: true}), []),\n    }),\n  })\n\n  return <div ref={handle.containerRef} className=\"map-stage\" />\n}\n```\n\nThe memoization contract is the load-bearing perf lever: as long as `data`,\n`accessors`, and `visualProps` references are stable, `useDeckLayers` reuses\nthe underlying deck.gl layer instance and the GPU buffers do not rebuild.\nInline `accessors={{getPosition: (s) => [...]}}` allocates new functions every\nrender and forces deck.gl to rebuild — wrap them in `useMemo` with deps that\nreflect what actually drives rendering.\n\n`handle` exposes `{containerRef, ready, viewState, map, overlay, flyTo}`. Use\n`flyTo({center, zoom})` for sidebar-driven map navigation.\n\nAvailable factory helpers: `scatter`, `text`, `icon`, `line`, `polygon`,\n`path` and `bitmap`. They're thin wrappers that stamp the right `kind` so the\nspec is more readable; you can also write specs by hand.\n\n### Plan views — `usePlanView`, `fitPlanBounds`\n\nFor floorplans, sorter schematics and equipment halls there is no map. `usePlanView`\ndraws a deck.gl canvas in plain 2D coordinates (`OrthographicView`) with no basemap\nand no Mapbox token. Its handle exposes the Deck instance as `overlay`, so the same\n`useDeckLayers` and layer factories drive it; `polygon`, `path` and `bitmap` join\n`scatter`, `text`, `icon` and `line` for rooms, conveyor runs and floorplan images.\n\n```jsx\nimport {bitmap, scatter, useDeckLayers, usePlanView} from \"@carverauto/serviceradar-dashboard-sdk/map\"\n\nfunction Concourse({aps}) {\n  const plan = usePlanView({bounds: [[0, 0], [1200, 600]], onClick: (info) => select(info.object)})\n  const floorProps = useMemo(() => ({\n    image: \"/assets/concourse-b.png\",\n    bounds: [0, 600, 1200, 0],\n  }), [])\n  const accessors = useMemo(() => ({getPosition: (ap) => ap.xy}), [])\n  const visualProps = useMemo(() => ({\n    pickable: true,\n    radiusUnits: \"pixels\",\n    getRadius: 6,\n  }), [])\n  useDeckLayers(plan, [\n    bitmap(\"floor\", {visualProps: floorProps}),\n    scatter(\"aps\", {data: aps, accessors, visualProps}),\n  ])\n  return <div ref={plan.containerRef} style={{position: \"relative\", height: 480}} />\n}\n```\n\nPlan coordinates are yours (metres, image pixels); `flipY` defaults to image-style\n(y grows downward). `plan.project([x, y])` returns the screen position of a point,\nfor anchoring a React popup, and `plan.fitBounds(bounds)` re-fits the view. The\nbackground follows the dashboard theme. `createPlanView` is the same controller\nwithout React. The host must inject `Deck` and `OrthographicView`; ServiceRadar\nand the CLI dev harness do.\n\n### Screen-space level of detail — `useScreenLod`, `screenLod`\n\nA map with thousands of points should draw clusters when zoomed out and the\nrows themselves when zoomed in. `useScreenLod` takes the rows you already\npass to `scatter()` and `useDeckMap().viewState`, and returns the `data` to\ndraw:\n\n```jsx\nimport {scatter, useDeckLayers, useDeckMap, useScreenLod} from \"@carverauto/serviceradar-dashboard-sdk/map\"\n\nfunction SitesLayer({sites}) {\n  const handle = useDeckMap({viewportThrottleMs: 120})\n  const lod = useScreenLod(sites, {\n    viewState: handle.viewState,\n    getPosition: (site) => [site.longitude, site.latitude],\n    getId: (site) => site.site_code,\n    radiusPx: 40,\n    enterZoom: 6,\n    exitZoom: 4.5,\n    aggregate: (members) => ({ap_count: members.reduce((sum, site) => sum + site.ap_count, 0)}),\n  })\n\n  const accessors = useMemo(() => ({\n    getPosition: lod.positionOf,\n    getRadius: (row) => (lod.isCluster(row) ? 10 + Math.log2(row.__lod_count) * 4 : 6),\n  }), [lod.positionOf, lod.isCluster])\n\n  useDeckLayers(handle, {\n    sites: scatter(\"sites\", {\n      data: lod.data,\n      accessors,\n      visualProps,\n      events: {\n        onClick: ({object}) => {\n          if (lod.isCluster(object)) handle.flyTo({center: lod.positionOf(object), zoom: lod.enterZoom})\n        },\n      },\n    }),\n  })\n\n  return <div ref={handle.containerRef} />\n}\n```\n\n- `band` is `\"far\"` or `\"near\"`. At or above `enterZoom` the band is near and\n  `data` is the input array itself. At or below `exitZoom` the band is far and\n  `data` holds one cluster record per cell. Between the two, the band stays\n  whatever it was, so zooming through the gap does not flicker.\n- A cluster record carries `__lod: \"far\"`, `__lod_id`, `__lod_count`,\n  `__lod_ids` (member ids from `getId`, default `row.id`), `__lod_position`\n  (the member mean) and whatever `aggregate(members)` returns. It never\n  copies fields from a member.\n- Cells are fixed in world pixels at `exitZoom` (`radiusPx` wide), not taken\n  from the current camera. Panning or zooming inside the far band returns the\n  same `data` reference and the same `__lod_id`s, so `useDeckLayers` keeps\n  the layer. `data` changes only when the rows or the band change.\n- `hidden` is the number of rows the clusters stand for. `unplaced` counts\n  rows whose `getPosition` was not finite. Those rows are in no cluster.\n- `positionOf(row)` returns the member mean for a cluster and `getPosition`\n  for a row. Flying to it at `enterZoom` opens the rows.\n\n`getPosition`, `getId` and `aggregate` may be inline functions. The hook reads\nthem through a ref, and `positionOf` keeps one identity for the life of the\ncomponent. Changing one of them without changing the rows does not recluster.\n\n`screenLod(rows, {view, previous, ...})` is the same logic without React.\n`view` is anything with a numeric `zoom`. Pass the previous result back as\n`previous` to keep the hysteresis and the far-band `data` reference.\n\n### React-mounted Mapbox popups — `useMapPopup`\n\nMapbox popups are imperative — `new mapboxgl.Popup().setHTML(...)`. To render\nReact content inside them with managed lifecycle, use `useMapPopup`:\n\n```jsx\nimport {useMapPopup} from \"@carverauto/serviceradar-dashboard-sdk/popup\"\n\nfunction MapWithPopup({handle, focusedSite, onClose}) {\n  const popup = useMapPopup(handle.map, {\n    closeOnClick: false,\n    offset: 18,\n    onClose,\n  })\n\n  useEffect(() => {\n    if (!focusedSite) {\n      popup.close()\n      return\n    }\n    popup.open({\n      coordinates: [focusedSite.longitude, focusedSite.latitude],\n      content: <SitePopup site={focusedSite} />,\n    })\n  }, [focusedSite, popup])\n\n  return null\n}\n```\n\nThe popup is created lazily on first `open`. Subsequent `open` calls re-render\nthe React subtree inside the existing popup — they don't recreate it or\nre-anchor it unless coordinates change. `close` (or the user dismissing the\npopup) unmounts the React root before removing the popup from the map, so no\nReact roots leak.\n\n### A composed example\n\nHere is the production pattern in roughly 80 lines — frame ingest, filter\nstate, SRQL roundtrip, indexed local filtering, map, and popup all working\ntogether:\n\n```jsx\nimport React, {useCallback, useMemo, useState} from \"react\"\nimport {\n  mountReactDashboard,\n  useDashboardQueryState,\n  useDashboardTheme,\n  useFilterState,\n  useFrameRows,\n  useIndexedRows,\n} from \"@carverauto/serviceradar-dashboard-sdk/react\"\nimport {scatter, useDeckLayers, useDeckMap} from \"@carverauto/serviceradar-dashboard-sdk/map\"\nimport {useMapPopup} from \"@carverauto/serviceradar-dashboard-sdk/popup\"\n\nconst SITE_SHAPE = Object.freeze({\n  site_code: (row) => String(row.site_code || row.iata).toUpperCase(),\n  region: \"region\",\n  latitude: (row) => Number(row.latitude ?? row.lat),\n  longitude: (row) => Number(row.longitude ?? row.lon),\n  ap_count: (row) => Number(row.ap_count || 0),\n})\n\nconst INDEX_BY = {region: \"region\"}\nconst INITIAL = {regions: [], search: \"\"}\n\nfunction NetworkMap() {\n  const sites = useFrameRows(\"sites\", {decode: \"auto\", shape: SITE_SHAPE})\n  const dark = useDashboardTheme() === \"dark\"\n\n  const filters = useFilterState({initialState: INITIAL, debounceMs: 350, debounceFields: [\"search\"]})\n  const indexed = useIndexedRows(sites, {indexBy: INDEX_BY, searchText: [\"site_code\"]})\n\n  const queryState = useDashboardQueryState({\n    initialState: INITIAL,\n    debounceMs: 350,\n    buildQuery: (state) => state.regions.length\n      ? `in:wifi_sites region:(${state.regions.join(\",\")}) limit:500`\n      : \"in:wifi_sites limit:500\",\n  })\n\n  // Drive the SRQL roundtrip from debounced filter state\n  React.useEffect(() => {\n    queryState.apply(filters.debouncedState)\n  }, [filters.debouncedState, queryState])\n\n  const visible = useMemo(() => indexed.applyFilters({\n    region: filters.state.regions,\n    search: filters.debouncedState.search,\n  }), [indexed, filters.state.regions, filters.debouncedState.search])\n\n  const handle = useDeckMap({initialViewState: {center: [-98.5, 39.8], zoom: 3.7}})\n\n  const accessors = useMemo(() => ({getPosition: (s) => [s.longitude, s.latitude], getRadius: 8}), [])\n  const visualProps = useMemo(() => ({\n    pickable: true,\n    radiusUnits: \"pixels\",\n    getFillColor: dark ? [17, 24, 39, 238] : [255, 255, 255, 248],\n  }), [dark])\n\n  const [focused, setFocused] = useState(null)\n\n  useDeckLayers(handle, {\n    sites: scatter(\"sites\", {\n      data: visible,\n      accessors,\n      visualProps,\n      events: {onClick: (info) => setFocused(info?.object || null)},\n    }),\n  })\n\n  const popup = useMapPopup(handle.map, {closeOnClick: false, onClose: () => setFocused(null)})\n\n  React.useEffect(() => {\n    if (!focused) { popup.close(); return }\n    popup.open({\n      coordinates: [focused.longitude, focused.latitude],\n      content: <div><strong>{focused.site_code}</strong> · {focused.ap_count} APs</div>,\n    })\n  }, [focused, popup])\n\n  return <div ref={handle.containerRef} style={{position: \"absolute\", inset: 0}} />\n}\n\nexport const mountDashboard = mountReactDashboard(NetworkMap)\n```\n\nThis is the pattern — frame data flows through shape projections,\n`useFilterState` owns the local UI response, `useDashboardQueryState` owns the\nSRQL roundtrip, `useIndexedRows` owns the per-keystroke filter pass,\n`useDeckMap` + `useDeckLayers` own the map lifecycle and layer memoization, and\n`useMapPopup` owns the React-into-Mapbox popup bridge. Each layer is\nindependently testable; the framework-agnostic cores\n(`createDashboardQueryState`, `createIndexedRows`, `createReactMapPopupController`)\nare exposed at `/query-state`, `/filtering`, and `/popup` for non-React\nconsumers.\n\n### Live camera streams — `useCameraStream`, `CameraTile`, `CameraGrid`\n\nDashboards that declare the `camera.stream.view` capability can play camera\nrelay streams. The host opens and closes the relay sessions, runs WebRTC\nsignaling (falling back to the websocket WebCodecs/MSE player) and owns the\nvideo surfaces; the SDK attaches a host handle to a container element per tile.\n\n```js\nimport {CameraGrid, cameraKey, useCameraAvailable} from \"@carverauto/serviceradar-dashboard-sdk/camera\"\n\nfunction CameraWall({cameras, selected, onSelect}) {\n  if (!useCameraAvailable()) return null\n\n  return (\n    <CameraGrid\n      cameras={cameras}            // [{camera_source_id, stream_profile_id, label}]\n      selectedKey={selected ? cameraKey(selected) : null}\n      onSelect={onSelect}\n      renderOverlay={(camera, stream) => <Hud camera={camera} state={stream.state} />}\n    />\n  )\n}\n```\n\n- A tile opens its session when it mounts and closes it when it unmounts or\n  `enabled` turns false, so hiding an overlay releases every stream it held.\n- The host caps a dashboard at nine concurrent sessions. `CameraGrid` never\n  renders more than that; a direct tenth `open` fails with state `limited`.\n- Tile states: `requesting`, `connecting`, `activating` (relay still starting),\n  `playing`, `suspended` (tab hidden; the host resumes on return),\n  `unauthorized` (viewer lacks camera permission), `limited`, `unavailable`\n  (package or host without the camera API), `failed`, `closed`.\n- The host authorizes every relay request with the viewer's own camera\n  permission; the capability only lets the package ask.\n- `useCameraStream(camera, {enabled})` returns `{ref, state, error,\n  relaySessionId, close}` for custom tiles, and `createCameraStreamController`\n  is the framework-free core behind it.\n- In the local harness, tiles render a generated test pattern so camera\n  dashboards run without a live ServiceRadar.\n\n### Actions and live events — `useDashboardActions`, `useDashboardEvents`, `useFrameRefresh`\n\nDashboards that declare `actions.invoke` can list and run the plugin actions the\nviewer may launch; those that declare `events.subscribe` receive live OCSF\nevents as soon as they are persisted. The host runs every action through the\nnorthbound action model (RBAC, audit, action history with the viewer as actor)\nand re-checks event access while it streams, so the package never gets more\nthan the viewer could do in ServiceRadar itself.\n\n```jsx\nimport {useDashboardActions, useDashboardEvents, useFrameRefresh} from \"@carverauto/serviceradar-dashboard-sdk/live\"\n\nfunction FaultStrip({plcUid}) {\n  const refresh = useFrameRefresh()\n  const {actions, invoke, invocations} = useDashboardActions({scope: \"device\", pluginId: \"demo-ot-plc\"})\n\n  // Refresh frames the moment a matching event lands instead of on the next poll.\n  useDashboardEvents({log_provider: \"plugin:demo-ot-plc\", min_severity_id: 3}, () => refresh())\n\n  return actions.map((action) => (\n    <button key={action.id} onClick={() => invoke({actionId: action.id, targets: [{deviceUid: plcUid}]})}>\n      {action.label}\n    </button>\n  ))\n}\n```\n\n- `invoke` resolves with the terminal progress (`succeeded`, `failed`,\n  `expired`, `canceled`, `suppressed`, `unknown`); `invocations` holds the\n  latest progress for each invocation id.\n- Event filters accept `log_provider`, `log_name`, `class_uid`, `device_uid`\n  (one value or a list), `min_severity_id`, and up to eight scalar `metadata`\n  fields. All given keys must match. A dashboard may hold eight subscriptions.\n  A `null` or `undefined` filter means \"not ready\" and does not subscribe; pass\n  `{}` to receive every event.\n- The local harness drives both from the fixture file: see \"Harness fixtures\n  for actions and live events\" in the CLI README.\n\n## Lower-Level Surfaces\n\nTrusted browser modules can render deck.gl maps directly because the host passes\nthe map/deck constructors through `api.libraries`:\n\n```js\nexport async function mountDashboard(root, host, api) {\n  const {mapboxgl, MapboxOverlay, ScatterplotLayer, TextLayer} = api.libraries\n\n  const map = new mapboxgl.Map({\n    container: root,\n    style: \"mapbox://styles/mapbox/dark-v11\",\n    center: [-98, 39],\n    zoom: 3,\n  })\n\n  const overlay = new MapboxOverlay({\n    interleaved: true,\n    layers: [\n      new ScatterplotLayer({\n        id: \"sites\",\n        data: api.frame(\"sites\").results,\n        getPosition: (row) => [row.longitude, row.latitude],\n        getRadius: 8,\n      }),\n      new TextLayer({\n        id: \"site-labels\",\n        data: api.frame(\"sites\").results,\n        getPosition: (row) => [row.longitude, row.latitude],\n        getText: (row) => row.site_code,\n      }),\n    ],\n  })\n\n  map.addControl(overlay)\n\n  return {\n    destroy() {\n      map.removeControl(overlay)\n      map.remove()\n    },\n  }\n}\n```\n\n`interleaved: true` lets deck.gl share the Mapbox WebGL context, which avoids\nallocating a second rendering context and is the expected path for high-volume\nmap dashboards.\n\nLarge dashboard frames can request `encoding: \"arrow_ipc\"` in the manifest.\nWhen ServiceRadar can satisfy the frame as Arrow IPC, trusted modules receive a\nbase64 payload and can decode it through the host API:\n\n```js\nconst table = await api.arrow.table(\"sites\")\n```\n\nIf the active SRQL backend cannot emit Arrow for that query, ServiceRadar falls\nback to `json_rows` with the same frame id so packages can stay compatible.\nThe SDK root also exports small frame helpers for packages that need to branch\nbetween row JSON and raw Arrow IPC bytes without reaching into host internals:\n\n```js\nimport {frameRows, isArrowFrame, requireArrowFrameBytes} from \"@carverauto/serviceradar-dashboard-sdk/frames\"\n\nconst frame = api.frame(\"sites\")\nconst rows = frameRows(frame)\n\nif (isArrowFrame(frame)) {\n  const bytes = requireArrowFrameBytes(frame)\n  // Hand bytes to an Arrow decoder or renderer-specific table pipeline.\n}\n```\n\nBrowser modules also receive first-class SRQL helpers through `api.srql`.\nPackage authors should use these helpers when map/sidebar interactions need to\nchange the server-side query that hydrates the dashboard:\n\n```js\nconst current = api.srql.query(\"sites\")\nconst next = api.srql.build({\n  entity: \"wifi_sites\",\n  search: \"ORD\",\n  searchField: \"site_code\",\n  exclude: {\n    region: [\"AM-East\"],\n    ap_family: [\"2xx\", \"3xx\"],\n  },\n  where: [\"down_count:>0\"],\n  limit: 500,\n})\n\napi.srql.update(next)\n```\n\n`api.srql.update(query, frameQueries)` asks ServiceRadar to push a LiveView\npatch, rerun the approved dashboard data frames through SRQL, and remount the\nrenderer with fresh server-filtered rows. The optional `frameQueries` object can\noverride individual frame IDs when a dashboard needs detail frames to use a\ndifferent SRQL query from the primary map frame. The old `api.setSrqlQuery`\nalias remains for compatibility, but new packages should prefer `api.srql`.\n\nRow frames that can exceed the host page size should page through SRQL\ncursors instead of raising `limit:`. Do **not** put `cursor:` in the query\nstring — cursors are request metadata, the same as every other SRQL entity.\n\n```js\nconst paging = useDashboardFramePagination(\"results\")\n\nif (paging.next) paging.next()\n```\n\n`api.srql.page(frameId, cursor)` re-runs **that frame only** at the signed\ncursor. The query text, dashboard URL, and other frames stay put. On a host\nthat has not deployed paging yet, `page()` is a no-op so packages stay\ncompatible.\n\nA `stats:` frame (`in:composite_results stats:count() as n by check,verdict`)\nis a GROUP BY, not a truncated row dump — do not page it, and do not treat a\nshort result as a ceiling. Device labels for a paged results frame come from a\nsecond frame of `in:devices uid:(…)` with at most 200 uids, matching the SRQL\nIN-list cap.\n\nHost actions that affect ServiceRadar-owned state or shell UI stay behind\ncapability checks. React packages should use the SDK hooks rather than direct\nDOM or URL manipulation:\n\n```js\nconst preferences = useDashboardPreferences()\nconst savedQueries = useDashboardSavedQueries()\nconst popup = useDashboardPopup()\nconst details = useDashboardDetails()\n\npreferences.set(\"density\", \"compact\")\nsavedQueries.apply(\"in:wifi_sites site_code:(DEN) limit:500\")\npopup.open({title: \"DEN\", fields: [{label: \"APs\", value: 42}]}, {x: 24, y: 36})\ndetails.open({type: \"site\", site_code: \"DEN\"})\n```\n\nWASM dashboard renderers use the same frame contract through raw data-provider\nimports:\n\n- `serviceradar.frame_encoding(index)` returns `1` for Arrow IPC frames.\n- `serviceradar.frame_bytes_len(index)` returns the raw payload length.\n- `serviceradar.frame_bytes_write(index, ptr, len)` copies the raw payload into\n  renderer memory.\n\nGo renderers can call `srdashboard.DataFrameEncoding(index)` and\n`srdashboard.DataFrameBytes(index)`. This is the path intended for large custom\ntopology or map engines that want Arrow IPC instead of row JSON.\n\n```go\nif srdashboard.DataFrameEncoding(0) == srdashboard.FrameEncodingArrowIPC {\n  payload := srdashboard.DataFrameBytes(0)\n  if srdashboard.LooksLikeArrowIPC(payload) {\n    // Hand payload to the renderer's Arrow/table pipeline.\n  }\n}\n```\n\nGo renderers and build tooling can use `srdashboard.BuildSRQL` for deterministic\nquery construction:\n\n```go\nquery := srdashboard.BuildSRQL(srdashboard.SRQLQuery{\n\tEntity:      \"wifi_sites\",\n\tSearchField: \"site_code\",\n\tSearch:      \"ORD\",\n\tExclude: map[string][]string{\n\t\t\"region\":    {\"AM-East\"},\n\t\t\"ap_family\": {\"2xx\", \"3xx\"},\n\t},\n\tWhere: []string{\"down_count:>0\"},\n\tLimit: 500,\n})\n```\n\nIf a WASM renderer needs to recompute its render model when live frames arrive,\nexport one of these optional callbacks:\n\n```go\n//export sr_dashboard_frames_updated\nfunc framesUpdated() {\n  // Read fresh frame bytes and emit a new render model if needed.\n}\n```\n\n`sr_dashboard_update` is accepted as a compatibility alias.\n\n## WASM Render Models\n\nDashboard packages still export the stable functions required by web-ng:\n\n- `alloc_bytes`\n- `free_bytes`\n- `sr_dashboard_init_json`\n\nThe SDK owns the host ABI glue. Customer renderers use\n`srdashboard.EmitRenderModelJSON` to emit constrained ServiceRadar render\nmodels; ServiceRadar owns the deck.gl, Mapbox, popup, and event wiring.\n\n## Local Harness\n\nThe companion CLI also provides the local dashboard development harness:\n\n```bash\nnpm run dev\n```\n\nThe harness imports the renderer module, passes sample frames/settings, and\nprovides `api.libraries` using browser module imports for Mapbox and deck.gl.\nThis is intended for customer authors to iterate on layout, filters, popups,\nclustering, and map interaction without standing up a ServiceRadar development\nenvironment. The React/Vite build emits a standalone\n`dashboard-browser-module-v1` `renderer.js`, computes the SHA256 digest, and\nwrites `dist/manifest.json`, `dist/sample-frames.json`, and\n`dist/sample-settings.json`.\n\nCustomer packages can wire scripts like this after installing the SDK:\n\n```json\n{\n  \"scripts\": {\n    \"dev\": \"serviceradar-cli dashboard dev\",\n    \"build\": \"serviceradar-cli dashboard build\",\n    \"validate\": \"serviceradar-cli dashboard validate\",\n    \"manifest\": \"serviceradar-cli dashboard manifest\",\n    \"publish\": \"serviceradar-cli dashboard publish\",\n    \"import:local\": \"serviceradar-cli dashboard import\"\n  }\n}\n```\n\nThe CLI reads `dashboard.config.mjs`, `dashboard.config.js`,\n`dashboard.config.json`, or `package.json#serviceradarDashboard`. `build`\nbundles a browser-module renderer with SDK Vite defaults, computes the renderer\nSHA256, writes `dist/manifest.json`, and copies configured sample frames and\nsettings. `dev` builds and serves the SDK harness. `import` verifies the\nmanifest/artifact digest and can delegate to a local ServiceRadar import command\nthrough `SERVICERADAR_DASHBOARD_IMPORT_COMMAND`.\n\nExample for a browser-module package:\n\n```text\nhttp://localhost:4177/?manifest=/dashboard/dist/manifest.json&wasm=/dashboard/dist/renderer.js&frames=/dashboard/dist/sample-frames.json&settings=/dashboard/dist/sample-settings.json\n```\n\nThe `wasm` query parameter is currently the generic renderer artifact URL; for\nbrowser modules it points at `renderer.js`. Mapbox settings should come from the\nsettings JSON passed to the harness, for example:\n\n```json\n{\n  \"mapbox\": {\n    \"access_token\": \"pk...\",\n    \"style_dark\": \"mapbox://styles/mapbox/dark-v11\",\n    \"style_light\": \"mapbox://styles/mapbox/light-v11\"\n  }\n}\n```\n\nIn production, Mapbox settings come from ServiceRadar settings and are exposed\nthrough `api.mapbox()`.\n\nThe harness is not an authorization or package-verification substitute. It is a\nlocal rendering loop. ServiceRadar production import still verifies manifest\nshape, artifact digest, trust policy, and capabilities before a dashboard can be\nenabled.\n\n## npm Release\n\nThe package is published as `@carverauto/serviceradar-dashboard-sdk`. Pull requests and\npushes run `npm run ci`, which executes JavaScript tests, Go tests, and\n`npm pack --dry-run`.\n\nRelease publishing is handled by GitHub Actions via\n`.github/workflows/npm-publish.yml`. The workflow uses npm trusted publishing\nwith GitHub OIDC, so it does not require `NPM_TOKEN`.\n\n1. Update `package.json` to the target semver.\n2. Tag the SDK repo as `v<package.json version>`.\n3. Push the tag to GitHub (`git push origin \"v$(node -p 'require(\"./package.json\").version')\"`).\n4. Ensure npmjs.com has a trusted publisher for\n   `carverauto/serviceradar-sdk-dashboard` and workflow filename\n   `npm-publish.yml` (filename only, not a path).\n5. Let the tag-triggered GitHub workflow publish, or dispatch it with the same\n   tag.\n\nThe workflow refuses to publish if the tag does not match the package version.\n","readmeFilename":"README.md"}