Source of truth: https://github.com/carverauto/serviceradar-sdk-dashboard
This SDK contains helpers for browser dashboard packages that target ServiceRadar's dashboard package host interfaces.
The canonical SDK reference — including the React hook surface, composed map patterns, and the local harness walkthrough — lives on the developer portal: developer.serviceradar.cloud/docs/v2/dashboard-sdk. This README mirrors the most important examples for anyone reading the SDK source directly.
Dashboard packages should consume the SDK from npm:
When bootstrapping a new dashboard package from an empty directory, initialize
the directory first so npm treats it as the project root. Otherwise npm may walk
up to a parent package.json and install/audit that parent project instead.
npm init -y
npm prefixnpm prefix should print the dashboard package directory. If it prints a parent
directory, create a local package.json or run npm with --prefix "$PWD".
npm install @carverauto/serviceradar-dashboard-sdk react react-domThis single install pulls in @carverauto/serviceradar-cli transitively as a dependency,
so the serviceradar-cli bin lands in your project's node_modules/.bin/
automatically. Project npm scripts can call serviceradar-cli dashboard <subcommand>
directly; for ad-hoc invocation use npx serviceradar-cli ....
During local SDK development, customer packages may temporarily use a file dependency, but published dashboard packages should depend on the npm package.
The companion CLI lives in the ServiceRadar monorepo at
~/src/serviceradar/js/cli/ and ships separately as @carverauto/serviceradar-cli. It
exposes two subcommand groups:
serviceradar-cli dashboard <init|build|dev|validate|manifest|publish|import>— full dashboard authoring loop (Vite-driven build, HMR dev harness, scaffolder, publish to a ServiceRadar instance).serviceradar-cli auth <login|status|logout>— RFC 8628 device-code login with manual-token fallback. Stores credentials at~/.config/serviceradar/credentials.json(mode0600).
dashboard publish posts a multipart upload to
/api/v1/dashboard-packages; the bearer JWT must carry the
dashboard.publish scope and the user must hold the
cli.dashboard.publish RBAC permission. Same id@version re-pushes are
idempotent when the renderer SHA256 matches; pushes against an enabled
package with different bytes are rejected (409
version_already_published) so operator browsers never silently fetch
swapped renderer code. See the dashboard-sdk publishing
docs
for the full endpoint contract and error envelope.
The legacy serviceradar-dashboard bin name is preserved as a transitional
alias that prints a deprecation notice and routes to
serviceradar-cli dashboard *. Removal scheduled for the release after.
Canonical docs:
developer.serviceradar.cloud/docs/v2/dashboard-sdk.
Dashboard packages are not compiled into ServiceRadar web-ng. ServiceRadar ships the stable host, importer, verifier, SRQL data-frame provider, and shared browser libraries. Customers ship their dashboard package from their own source repository.
The production flow is:
- A dashboard author builds a package in an external repository.
- The build writes a manifest plus renderer artifact, including a SHA256 digest and any signing metadata required by the operator.
- A ServiceRadar admin adds that repository as a dashboard/plugin source.
- ServiceRadar imports the manifest and artifact server-side, verifies the digest/trust policy, and stores the package metadata.
- An admin enables a dashboard instance and chooses its route or dashboard placement.
- At runtime web-ng loads the verified artifact and supplies SRQL data frames, settings, theme, navigation helpers, Mapbox settings, and shared map/deck libraries through the dashboard host API.
This lets customer dashboards update independently from ServiceRadar releases. If the package renderer changes, the customer publishes a new package version and ServiceRadar imports that version; web-ng does not need to be rebuilt.
For fully custom dashboards, use dashboard-browser-module-v1. ServiceRadar
loads an approved same-origin ES module and passes it bounded SRQL frames,
settings, theme, Mapbox settings, and shared map/deck constructors. The package
owns DOM, deck.gl layers, clustering, popups, and interactions.
Trusted browser modules export:
export async function mountDashboard(root, host, api) {
return { destroy() {} }
}Their manifest renderer must declare:
{
"kind": "browser_module",
"interface_version": "dashboard-browser-module-v1",
"artifact": "renderer.js",
"sha256": "...",
"trust": "trusted",
"entrypoint": "mountDashboard"
}This is an admin-approved extension model, not untrusted script execution.
React is the preferred authoring path for browser-module dashboards. The SDK
exports a small React surface that mirrors the existing web-ng React hook pattern
used by the Zen rules editor: mount with createRoot, keep Phoenix/web-ng as
the host shell, and pass all data/theme/navigation through a bounded host API.
import React from "react"
import {
mountReactDashboard,
useDashboardFrame,
useDashboardMapbox,
useDashboardNavigation,
useDashboardSrql,
useDashboardTheme,
} from "@carverauto/serviceradar-dashboard-sdk/react"
function NetworkMap() {
const sites = useDashboardFrame("sites")
const srql = useDashboardSrql()
const theme = useDashboardTheme()
const mapbox = useDashboardMapbox()
const navigation = useDashboardNavigation()
return (
<section data-theme={theme} data-map-style={mapbox.style_dark}>
<button onClick={() => srql.update(srql.build({
entity: "wifi_sites",
include: {site_code: ["DEN"]},
limit: 500,
}))}>
{sites?.results?.length || 0} sites
</button>
<button onClick={() => navigation.toDashboard("network-map")}>
Open map
</button>
</section>
)
}
export const mountDashboard = mountReactDashboard(NetworkMap)The ./react subpath ships TypeScript declarations for the stable browser
host contract. React dashboards can use useDashboardFrames,
useDashboardFrame, useDashboardTheme, useDashboardSrql,
useDashboardFramePagination,
useDashboardSettings, useDashboardMapbox, useDashboardLibraries,
useDashboardCapability, useDashboardNavigation,
useDashboardPreferences, useDashboardSavedQueries, useDashboardPopup, and
useDashboardDetails instead of reaching into raw host internals.
The build output is still a standalone renderer.js artifact. Customer authors
can iterate against the local harness with sample frames/settings and then ship
the same artifact through ServiceRadar package import.
The companion ServiceRadar CLI owns the repeatable package commands that create that artifact: renderer bundling, manifest digest stamping, harness launch, and local import validation. Customer dashboard repositories own the React dashboard code, package identity, frame declarations, sample data, and settings schema.
React dashboards with async setup, such as Mapbox/deck.gl controllers, can opt into an explicit ready lifecycle. This keeps simple dashboards fast while still letting heavier dashboards delay host completion until their controller is mounted:
import React from "react"
import {mountReactDashboard, useDashboardController} from "@carverauto/serviceradar-dashboard-sdk/react"
function MapDashboard() {
const controller = useDashboardController(createMapController)
if (controller.error) return <div role="alert">Map failed to load</div>
return <div ref={controller.ref} />
}
export const mountDashboard = mountReactDashboard(MapDashboard, {waitForReady: true})useDashboardController owns the common imperative-controller lifecycle for
React dashboards: it passes (root, host, api) into your controller factory,
destroys stale controllers on unmount, reports readiness to
mountReactDashboard(..., {waitForReady: true}), and exposes async startup
errors for the component to render.
The SDK ships a layered set of hooks designed for dashboards that need to scale to thousands of rows, decode Arrow IPC frames, drive Mapbox or deck.gl maps, and stay memoized through every host push.
Custom dashboards usually have local filter state (chip toggles, search text,
viewport bounds, drill selections). That state has to be turned into an SRQL
query, deduplicated against the previous one, debounced for fast typing, and
applied through the host's SRQL update API. useDashboardQueryState owns all
of that:
import {useDashboardQueryState} from "@carverauto/serviceradar-dashboard-sdk/react"
const INITIAL = {region: null, ap: null, search: ""}
function FilterBar() {
const queryState = useDashboardQueryState({
initialState: INITIAL,
debounceMs: 350,
buildQuery: (state) => state.region
? `in:wifi_sites region:(${state.region}) limit:500`
: "in:wifi_sites limit:500",
buildFrameQueries: (state) => state.region
? {aps: `in:wifi_aps region:(${state.region}) limit:500`}
: {},
})
return (
<>
<input
value={queryState.state.search}
onChange={(event) => queryState.apply({search: event.target.value})}
/>
{["AMERICAS", "EMEA", "APAC"].map((region) => (
<button key={region} onClick={() => queryState.apply({region})}>
{region}
</button>
))}
<button onClick={() => queryState.reset()}>Reset</button>
{queryState.dirty ? <span>updating…</span> : null}
</>
)
}The hook returns {state, query, frameQueries, dirty, apply, reset, flush, hydrate}.
Identical apply/reset calls are deduped by query+frame-overrides fingerprint —
useDashboardQueryState only invokes api.srql.update when the fingerprint
actually changes. The framework-agnostic core is exposed as
createDashboardQueryState at @carverauto/serviceradar-dashboard-sdk/query-state for
non-React consumers.
useDashboardFrame and useDashboardFrames now bail out when the incoming
frame digest matches the cached one, so identical host pushes do not invalidate
downstream useMemo deps. useFrameRows decodes a frame to a row array with
optional Arrow IPC handling and optional row-shape projection — both are cached
by the SDK so repeated calls with the same shape on the same frame return the
same reference:
import {useFrameRows} from "@carverauto/serviceradar-dashboard-sdk/react"
const SITE_SHAPE = Object.freeze({
site_code: (row) => String(row.site_code || row.iata || "").toUpperCase(),
region: "region",
latitude: (row) => Number(row.latitude ?? row.lat),
longitude: (row) => Number(row.longitude ?? row.lon),
})
function SitesTable() {
const sites = useFrameRows("sites", {decode: "auto", shape: SITE_SHAPE})
return <span>{sites.length} sites</span>
}decode accepts "auto" (default — Arrow IPC if the frame carries it,
otherwise JSON), "arrow", or "json". Apache Arrow is dynamically imported
only when an Arrow path actually decodes — JSON-only dashboards do not pay the
bundle cost. For column-oriented advanced consumers there's also
useArrowTable(frame) which returns the decoded apache-arrow Table once
the lazy decoder loads. Tests can inject a custom decoder via
setArrowDecoder(fn) from @carverauto/serviceradar-dashboard-sdk/arrow.
Responsive dashboards can avoid repeated linear scans by precomputing per-row
Sets and a single lowercase haystack at data load. useIndexedRows provides
that primitive:
import {useFilterState, useIndexedRows} from "@carverauto/serviceradar-dashboard-sdk/react"
const INDEX_BY = {
region: "region",
apFamily: (site) => site.ap_families,
wlcModel: (site) => Object.keys(site.wlc_models || {}),
}
function SiteList({sites}) {
const filters = useFilterState({
initialState: {regions: [], apFamilies: [], wlcModels: [], search: ""},
debounceMs: 350,
debounceFields: ["search"],
})
const indexed = useIndexedRows(sites, {indexBy: INDEX_BY, searchText: ["site_code", "name"]})
const visible = indexed.applyFilters({
region: filters.state.regions,
apFamily: filters.state.apFamilies,
wlcModel: filters.state.wlcModels,
search: filters.debouncedState.search,
})
return (
<ul>
{visible.map((site) => <li key={site.site_code}>{site.site_code}</li>)}
</ul>
)
}indexed.applyFilters returns the rows array via Set intersection rather than
linear scans. Indexes rebuild only when the input row reference changes —
combined with the digest-stable refs from useFrameRows, that means a no-op
host push doesn't rebuild any indexes. useFilterState returns stable
setFilter / toggle / clear callbacks for chip groups and supports a
debounced debouncedState view per field for SRQL-roundtrip drivers.
useFilterState and useDashboardQueryState compose: feed
filters.debouncedState into queryState.apply to drive the SRQL roundtrip,
while filters.state drives the immediate sidebar response.
Mapbox GL JS is injected by the host through api.libraries. Use
useMapboxMap for dashboards that only need the map lifecycle, DOM markers, or
Mapbox sources/layers and do not need deck.gl/luma:
import {useMapboxMap} from "@carverauto/serviceradar-dashboard-sdk/map"
function MapStage() {
const handle = useMapboxMap({
initialViewState: {center: [-98.5, 39.8], zoom: 3.7},
viewportThrottleMs: 120,
onViewStateChange: (next) => console.log(next.zoom),
})
return <div ref={handle.containerRef} className="map-stage" />
}For GPU-backed layers, MapboxOverlay and deck.gl layer constructors are also
injected by the host. useDeckMap composes useMapboxMap, instantiates the
overlay once, and useDeckLayers owns deck layer memoization:
import {useDeckMap, useDeckLayers, scatter, text} from "@carverauto/serviceradar-dashboard-sdk/map"
function MapStage({sites, dark}) {
const handle = useDeckMap({
initialViewState: {center: [-98.5, 39.8], zoom: 3.7},
viewportThrottleMs: 120,
onViewStateChange: (next) => console.log(next.zoom),
})
const accessors = useMemo(() => ({
getPosition: (site) => [site.longitude, site.latitude],
getRadius: 8,
}), [])
const visualProps = useMemo(() => ({
pickable: true,
radiusUnits: "pixels",
getFillColor: dark ? [17, 24, 39, 238] : [255, 255, 255, 248],
getLineColor: [31, 34, 207, 255],
}), [dark])
useDeckLayers(handle, {
sites: scatter("sites", {data: sites, accessors, visualProps, events: {onClick: console.log}}),
labels: text("labels", {
data: sites,
accessors: useMemo(() => ({
getPosition: (site) => [site.longitude, site.latitude],
getText: (site) => site.site_code,
}), []),
visualProps: useMemo(() => ({getSize: 13, background: true}), []),
}),
})
return <div ref={handle.containerRef} className="map-stage" />
}The memoization contract is the load-bearing perf lever: as long as data,
accessors, and visualProps references are stable, useDeckLayers reuses
the underlying deck.gl layer instance and the GPU buffers do not rebuild.
Inline accessors={{getPosition: (s) => [...]}} allocates new functions every
render and forces deck.gl to rebuild — wrap them in useMemo with deps that
reflect what actually drives rendering.
handle exposes {containerRef, ready, viewState, map, overlay, flyTo}. Use
flyTo({center, zoom}) for sidebar-driven map navigation.
Available factory helpers: scatter, text, icon, line, polygon,
path and bitmap. They're thin wrappers that stamp the right kind so the
spec is more readable; you can also write specs by hand.
For floorplans, sorter schematics and equipment halls there is no map. usePlanView
draws a deck.gl canvas in plain 2D coordinates (OrthographicView) with no basemap
and no Mapbox token. Its handle exposes the Deck instance as overlay, so the same
useDeckLayers and layer factories drive it; polygon, path and bitmap join
scatter, text, icon and line for rooms, conveyor runs and floorplan images.
import {bitmap, scatter, useDeckLayers, usePlanView} from "@carverauto/serviceradar-dashboard-sdk/map"
function Concourse({aps}) {
const plan = usePlanView({bounds: [[0, 0], [1200, 600]], onClick: (info) => select(info.object)})
const floorProps = useMemo(() => ({
image: "/assets/concourse-b.png",
bounds: [0, 600, 1200, 0],
}), [])
const accessors = useMemo(() => ({getPosition: (ap) => ap.xy}), [])
const visualProps = useMemo(() => ({
pickable: true,
radiusUnits: "pixels",
getRadius: 6,
}), [])
useDeckLayers(plan, [
bitmap("floor", {visualProps: floorProps}),
scatter("aps", {data: aps, accessors, visualProps}),
])
return <div ref={plan.containerRef} style={{position: "relative", height: 480}} />
}Plan coordinates are yours (metres, image pixels); flipY defaults to image-style
(y grows downward). plan.project([x, y]) returns the screen position of a point,
for anchoring a React popup, and plan.fitBounds(bounds) re-fits the view. The
background follows the dashboard theme. createPlanView is the same controller
without React. The host must inject Deck and OrthographicView; ServiceRadar
and the CLI dev harness do.
A map with thousands of points should draw clusters when zoomed out and the
rows themselves when zoomed in. useScreenLod takes the rows you already
pass to scatter() and useDeckMap().viewState, and returns the data to
draw:
import {scatter, useDeckLayers, useDeckMap, useScreenLod} from "@carverauto/serviceradar-dashboard-sdk/map"
function SitesLayer({sites}) {
const handle = useDeckMap({viewportThrottleMs: 120})
const lod = useScreenLod(sites, {
viewState: handle.viewState,
getPosition: (site) => [site.longitude, site.latitude],
getId: (site) => site.site_code,
radiusPx: 40,
enterZoom: 6,
exitZoom: 4.5,
aggregate: (members) => ({ap_count: members.reduce((sum, site) => sum + site.ap_count, 0)}),
})
const accessors = useMemo(() => ({
getPosition: lod.positionOf,
getRadius: (row) => (lod.isCluster(row) ? 10 + Math.log2(row.__lod_count) * 4 : 6),
}), [lod.positionOf, lod.isCluster])
useDeckLayers(handle, {
sites: scatter("sites", {
data: lod.data,
accessors,
visualProps,
events: {
onClick: ({object}) => {
if (lod.isCluster(object)) handle.flyTo({center: lod.positionOf(object), zoom: lod.enterZoom})
},
},
}),
})
return <div ref={handle.containerRef} />
}bandis"far"or"near". At or aboveenterZoomthe band is near anddatais the input array itself. At or belowexitZoomthe band is far anddataholds one cluster record per cell. Between the two, the band stays whatever it was, so zooming through the gap does not flicker.- A cluster record carries
__lod: "far",__lod_id,__lod_count,__lod_ids(member ids fromgetId, defaultrow.id),__lod_position(the member mean) and whateveraggregate(members)returns. It never copies fields from a member. - Cells are fixed in world pixels at
exitZoom(radiusPxwide), not taken from the current camera. Panning or zooming inside the far band returns the samedatareference and the same__lod_ids, souseDeckLayerskeeps the layer.datachanges only when the rows or the band change. hiddenis the number of rows the clusters stand for.unplacedcounts rows whosegetPositionwas not finite. Those rows are in no cluster.positionOf(row)returns the member mean for a cluster andgetPositionfor a row. Flying to it atenterZoomopens the rows.
getPosition, getId and aggregate may be inline functions. The hook reads
them through a ref, and positionOf keeps one identity for the life of the
component. Changing one of them without changing the rows does not recluster.
screenLod(rows, {view, previous, ...}) is the same logic without React.
view is anything with a numeric zoom. Pass the previous result back as
previous to keep the hysteresis and the far-band data reference.
Mapbox popups are imperative — new mapboxgl.Popup().setHTML(...). To render
React content inside them with managed lifecycle, use useMapPopup:
import {useMapPopup} from "@carverauto/serviceradar-dashboard-sdk/popup"
function MapWithPopup({handle, focusedSite, onClose}) {
const popup = useMapPopup(handle.map, {
closeOnClick: false,
offset: 18,
onClose,
})
useEffect(() => {
if (!focusedSite) {
popup.close()
return
}
popup.open({
coordinates: [focusedSite.longitude, focusedSite.latitude],
content: <SitePopup site={focusedSite} />,
})
}, [focusedSite, popup])
return null
}The popup is created lazily on first open. Subsequent open calls re-render
the React subtree inside the existing popup — they don't recreate it or
re-anchor it unless coordinates change. close (or the user dismissing the
popup) unmounts the React root before removing the popup from the map, so no
React roots leak.
Here is the production pattern in roughly 80 lines — frame ingest, filter state, SRQL roundtrip, indexed local filtering, map, and popup all working together:
import React, {useCallback, useMemo, useState} from "react"
import {
mountReactDashboard,
useDashboardQueryState,
useDashboardTheme,
useFilterState,
useFrameRows,
useIndexedRows,
} from "@carverauto/serviceradar-dashboard-sdk/react"
import {scatter, useDeckLayers, useDeckMap} from "@carverauto/serviceradar-dashboard-sdk/map"
import {useMapPopup} from "@carverauto/serviceradar-dashboard-sdk/popup"
const SITE_SHAPE = Object.freeze({
site_code: (row) => String(row.site_code || row.iata).toUpperCase(),
region: "region",
latitude: (row) => Number(row.latitude ?? row.lat),
longitude: (row) => Number(row.longitude ?? row.lon),
ap_count: (row) => Number(row.ap_count || 0),
})
const INDEX_BY = {region: "region"}
const INITIAL = {regions: [], search: ""}
function NetworkMap() {
const sites = useFrameRows("sites", {decode: "auto", shape: SITE_SHAPE})
const dark = useDashboardTheme() === "dark"
const filters = useFilterState({initialState: INITIAL, debounceMs: 350, debounceFields: ["search"]})
const indexed = useIndexedRows(sites, {indexBy: INDEX_BY, searchText: ["site_code"]})
const queryState = useDashboardQueryState({
initialState: INITIAL,
debounceMs: 350,
buildQuery: (state) => state.regions.length
? `in:wifi_sites region:(${state.regions.join(",")}) limit:500`
: "in:wifi_sites limit:500",
})
// Drive the SRQL roundtrip from debounced filter state
React.useEffect(() => {
queryState.apply(filters.debouncedState)
}, [filters.debouncedState, queryState])
const visible = useMemo(() => indexed.applyFilters({
region: filters.state.regions,
search: filters.debouncedState.search,
}), [indexed, filters.state.regions, filters.debouncedState.search])
const handle = useDeckMap({initialViewState: {center: [-98.5, 39.8], zoom: 3.7}})
const accessors = useMemo(() => ({getPosition: (s) => [s.longitude, s.latitude], getRadius: 8}), [])
const visualProps = useMemo(() => ({
pickable: true,
radiusUnits: "pixels",
getFillColor: dark ? [17, 24, 39, 238] : [255, 255, 255, 248],
}), [dark])
const [focused, setFocused] = useState(null)
useDeckLayers(handle, {
sites: scatter("sites", {
data: visible,
accessors,
visualProps,
events: {onClick: (info) => setFocused(info?.object || null)},
}),
})
const popup = useMapPopup(handle.map, {closeOnClick: false, onClose: () => setFocused(null)})
React.useEffect(() => {
if (!focused) { popup.close(); return }
popup.open({
coordinates: [focused.longitude, focused.latitude],
content: <div><strong>{focused.site_code}</strong> · {focused.ap_count} APs</div>,
})
}, [focused, popup])
return <div ref={handle.containerRef} style={{position: "absolute", inset: 0}} />
}
export const mountDashboard = mountReactDashboard(NetworkMap)This is the pattern — frame data flows through shape projections,
useFilterState owns the local UI response, useDashboardQueryState owns the
SRQL roundtrip, useIndexedRows owns the per-keystroke filter pass,
useDeckMap + useDeckLayers own the map lifecycle and layer memoization, and
useMapPopup owns the React-into-Mapbox popup bridge. Each layer is
independently testable; the framework-agnostic cores
(createDashboardQueryState, createIndexedRows, createReactMapPopupController)
are exposed at /query-state, /filtering, and /popup for non-React
consumers.
Dashboards that declare the camera.stream.view capability can play camera
relay streams. The host opens and closes the relay sessions, runs WebRTC
signaling (falling back to the websocket WebCodecs/MSE player) and owns the
video surfaces; the SDK attaches a host handle to a container element per tile.
import {CameraGrid, cameraKey, useCameraAvailable} from "@carverauto/serviceradar-dashboard-sdk/camera"
function CameraWall({cameras, selected, onSelect}) {
if (!useCameraAvailable()) return null
return (
<CameraGrid
cameras={cameras} // [{camera_source_id, stream_profile_id, label}]
selectedKey={selected ? cameraKey(selected) : null}
onSelect={onSelect}
renderOverlay={(camera, stream) => <Hud camera={camera} state={stream.state} />}
/>
)
}- A tile opens its session when it mounts and closes it when it unmounts or
enabledturns false, so hiding an overlay releases every stream it held. - The host caps a dashboard at nine concurrent sessions.
CameraGridnever renders more than that; a direct tenthopenfails with statelimited. - Tile states:
requesting,connecting,activating(relay still starting),playing,suspended(tab hidden; the host resumes on return),unauthorized(viewer lacks camera permission),limited,unavailable(package or host without the camera API),failed,closed. - The host authorizes every relay request with the viewer's own camera permission; the capability only lets the package ask.
useCameraStream(camera, {enabled})returns{ref, state, error, relaySessionId, close}for custom tiles, andcreateCameraStreamControlleris the framework-free core behind it.- In the local harness, tiles render a generated test pattern so camera dashboards run without a live ServiceRadar.
Dashboards that declare actions.invoke can list and run the plugin actions the
viewer may launch; those that declare events.subscribe receive live OCSF
events as soon as they are persisted. The host runs every action through the
northbound action model (RBAC, audit, action history with the viewer as actor)
and re-checks event access while it streams, so the package never gets more
than the viewer could do in ServiceRadar itself.
import {useDashboardActions, useDashboardEvents, useFrameRefresh} from "@carverauto/serviceradar-dashboard-sdk/live"
function FaultStrip({plcUid}) {
const refresh = useFrameRefresh()
const {actions, invoke, invocations} = useDashboardActions({scope: "device", pluginId: "demo-ot-plc"})
// Refresh frames the moment a matching event lands instead of on the next poll.
useDashboardEvents({log_provider: "plugin:demo-ot-plc", min_severity_id: 3}, () => refresh())
return actions.map((action) => (
<button key={action.id} onClick={() => invoke({actionId: action.id, targets: [{deviceUid: plcUid}]})}>
{action.label}
</button>
))
}invokeresolves with the terminal progress (succeeded,failed,expired,canceled,suppressed,unknown);invocationsholds the latest progress for each invocation id.- Event filters accept
log_provider,log_name,class_uid,device_uid(one value or a list),min_severity_id, and up to eight scalarmetadatafields. All given keys must match. A dashboard may hold eight subscriptions. Anullorundefinedfilter means "not ready" and does not subscribe; pass{}to receive every event. - Without the capability in the manifest,
invokeand the action list reject with aDashboardCapabilityError(code: "capability_denied"), the hook'serroris set, anduseDashboardEventsreports the same error instead of subscribing. - The local harness drives both from the fixture file: see "Harness fixtures for actions and live events" in the CLI README.
An action whose descriptor has requires_confirmation: true (see
actionRequiresConfirmation(action)) is never dispatched on the dashboard's
say-so. The host holds the invoke, shows its own confirmation dialog outside
the dashboard renderer (action label, safety classification, targets and
input), and dispatches only after the operator confirms there. A confirmation
is bound to the viewer, the action, the exact target set and the input, is
single use, and expires after a short time. Do not draw your own "are you
sure?" dialog in front of it: it proves nothing to the host and asks twice.
import {ActionConfirmationDeclinedError, useDashboardActions} from "@carverauto/serviceradar-dashboard-sdk/live"
function RebootButton({action, deviceUid}) {
const {invoke, pendingConfirmation} = useDashboardActions({scope: "device"})
const [note, setNote] = React.useState(null)
const run = () =>
invoke({actionId: action.id, targets: [{deviceUid}]})
.then((progress) => setNote(progress.state))
.catch((error) => {
// Declined, expired or refused by the host: nothing was dispatched.
if (error instanceof ActionConfirmationDeclinedError) setNote(`not run (${error.reason})`)
else setNote(error.message)
})
return (
<button disabled={Boolean(pendingConfirmation)} onClick={run}>
{pendingConfirmation ? "Waiting for confirmation..." : action.label}
{note && ` - ${note}`}
</button>
)
}- While the host dialog is open,
invokestays pending andpendingConfirmationis the waiting{confirmation_id, action_id, state: "pending", expires_in_ms}.confirmationsmaps every confirmation id to its latest state;declinedConfirmationis the most recent one that endeddeclined,expiredorrejected. invoke(request, {onConfirmation})receives the same updates for one call.ActionConfirmationDeclinedErrorcarriesreason("declined","expired","rejected"),code(confirmation_<reason>),actionIdandconfirmationId.- Actions without
requires_confirmationdispatch immediately, as before.
Trusted browser modules can render deck.gl maps directly because the host passes
the map/deck constructors through api.libraries:
export async function mountDashboard(root, host, api) {
const {mapboxgl, MapboxOverlay, ScatterplotLayer, TextLayer} = api.libraries
const map = new mapboxgl.Map({
container: root,
style: "mapbox://styles/mapbox/dark-v11",
center: [-98, 39],
zoom: 3,
})
const overlay = new MapboxOverlay({
interleaved: true,
layers: [
new ScatterplotLayer({
id: "sites",
data: api.frame("sites").results,
getPosition: (row) => [row.longitude, row.latitude],
getRadius: 8,
}),
new TextLayer({
id: "site-labels",
data: api.frame("sites").results,
getPosition: (row) => [row.longitude, row.latitude],
getText: (row) => row.site_code,
}),
],
})
map.addControl(overlay)
return {
destroy() {
map.removeControl(overlay)
map.remove()
},
}
}interleaved: true lets deck.gl share the Mapbox WebGL context, which avoids
allocating a second rendering context and is the expected path for high-volume
map dashboards.
Large dashboard frames can request encoding: "arrow_ipc" in the manifest.
When ServiceRadar can satisfy the frame as Arrow IPC, trusted modules receive a
base64 payload and can decode it through the host API:
const table = await api.arrow.table("sites")If the active SRQL backend cannot emit Arrow for that query, ServiceRadar falls
back to json_rows with the same frame id so packages can stay compatible.
The SDK root also exports small frame helpers for packages that need to branch
between row JSON and raw Arrow IPC bytes without reaching into host internals:
import {frameRows, isArrowFrame, requireArrowFrameBytes} from "@carverauto/serviceradar-dashboard-sdk/frames"
const frame = api.frame("sites")
const rows = frameRows(frame)
if (isArrowFrame(frame)) {
const bytes = requireArrowFrameBytes(frame)
// Hand bytes to an Arrow decoder or renderer-specific table pipeline.
}Browser modules also receive first-class SRQL helpers through api.srql.
Package authors should use these helpers when map/sidebar interactions need to
change the server-side query that hydrates the dashboard:
const current = api.srql.query("sites")
const next = api.srql.build({
entity: "wifi_sites",
search: "ORD",
searchField: "site_code",
exclude: {
region: ["AM-East"],
ap_family: ["2xx", "3xx"],
},
where: ["down_count:>0"],
limit: 500,
})
api.srql.update(next)api.srql.update(query, frameQueries) asks ServiceRadar to push a LiveView
patch, rerun the approved dashboard data frames through SRQL, and remount the
renderer with fresh server-filtered rows. The optional frameQueries object can
override individual frame IDs when a dashboard needs detail frames to use a
different SRQL query from the primary map frame. The old api.setSrqlQuery
alias remains for compatibility, but new packages should prefer api.srql.
Row frames that can exceed the host page size should page through SRQL
cursors instead of raising limit:. Do not put cursor: in the query
string — cursors are request metadata, the same as every other SRQL entity.
const paging = useDashboardFramePagination("results")
if (paging.next) paging.next()api.srql.page(frameId, cursor) re-runs that frame only at the signed
cursor. The query text, dashboard URL, and other frames stay put. On a host
that has not deployed paging yet, page() is a no-op so packages stay
compatible.
A stats: frame (in:composite_results stats:count() as n by check,verdict)
is a GROUP BY, not a truncated row dump — do not page it, and do not treat a
short result as a ceiling. Device labels for a paged results frame come from a
second frame of in:devices uid:(…) with at most 200 uids, matching the SRQL
IN-list cap.
Host actions that affect ServiceRadar-owned state or shell UI stay behind capability checks. React packages should use the SDK hooks rather than direct DOM or URL manipulation:
const preferences = useDashboardPreferences()
const savedQueries = useDashboardSavedQueries()
const popup = useDashboardPopup()
const details = useDashboardDetails()
preferences.set("density", "compact")
savedQueries.apply("in:wifi_sites site_code:(DEN) limit:500")
popup.open({title: "DEN", fields: [{label: "APs", value: 42}]}, {x: 24, y: 36})
details.open({type: "site", site_code: "DEN"})WASM dashboard renderers use the same frame contract through raw data-provider imports:
serviceradar.frame_encoding(index)returns1for Arrow IPC frames.serviceradar.frame_bytes_len(index)returns the raw payload length.serviceradar.frame_bytes_write(index, ptr, len)copies the raw payload into renderer memory.
Go renderers can call srdashboard.DataFrameEncoding(index) and
srdashboard.DataFrameBytes(index). This is the path intended for large custom
topology or map engines that want Arrow IPC instead of row JSON.
if srdashboard.DataFrameEncoding(0) == srdashboard.FrameEncodingArrowIPC {
payload := srdashboard.DataFrameBytes(0)
if srdashboard.LooksLikeArrowIPC(payload) {
// Hand payload to the renderer's Arrow/table pipeline.
}
}Go renderers and build tooling can use srdashboard.BuildSRQL for deterministic
query construction:
query := srdashboard.BuildSRQL(srdashboard.SRQLQuery{
Entity: "wifi_sites",
SearchField: "site_code",
Search: "ORD",
Exclude: map[string][]string{
"region": {"AM-East"},
"ap_family": {"2xx", "3xx"},
},
Where: []string{"down_count:>0"},
Limit: 500,
})If a WASM renderer needs to recompute its render model when live frames arrive, export one of these optional callbacks:
//export sr_dashboard_frames_updated
func framesUpdated() {
// Read fresh frame bytes and emit a new render model if needed.
}sr_dashboard_update is accepted as a compatibility alias.
Dashboard packages still export the stable functions required by web-ng:
alloc_bytesfree_bytessr_dashboard_init_json
The SDK owns the host ABI glue. Customer renderers use
srdashboard.EmitRenderModelJSON to emit constrained ServiceRadar render
models; ServiceRadar owns the deck.gl, Mapbox, popup, and event wiring.
The companion CLI also provides the local dashboard development harness:
npm run devThe harness imports the renderer module, passes sample frames/settings, and
provides api.libraries using browser module imports for Mapbox and deck.gl.
This is intended for customer authors to iterate on layout, filters, popups,
clustering, and map interaction without standing up a ServiceRadar development
environment. The React/Vite build emits a standalone
dashboard-browser-module-v1 renderer.js, computes the SHA256 digest, and
writes dist/manifest.json, dist/sample-frames.json, and
dist/sample-settings.json.
Customer packages can wire scripts like this after installing the SDK:
{
"scripts": {
"dev": "serviceradar-cli dashboard dev",
"build": "serviceradar-cli dashboard build",
"validate": "serviceradar-cli dashboard validate",
"manifest": "serviceradar-cli dashboard manifest",
"publish": "serviceradar-cli dashboard publish",
"import:local": "serviceradar-cli dashboard import"
}
}The CLI reads dashboard.config.mjs, dashboard.config.js,
dashboard.config.json, or package.json#serviceradarDashboard. build
bundles a browser-module renderer with SDK Vite defaults, computes the renderer
SHA256, writes dist/manifest.json, and copies configured sample frames and
settings. dev builds and serves the SDK harness. import verifies the
manifest/artifact digest and can delegate to a local ServiceRadar import command
through SERVICERADAR_DASHBOARD_IMPORT_COMMAND.
Example for a browser-module package:
http://localhost:4177/?manifest=/dashboard/dist/manifest.json&wasm=/dashboard/dist/renderer.js&frames=/dashboard/dist/sample-frames.json&settings=/dashboard/dist/sample-settings.json
The wasm query parameter is currently the generic renderer artifact URL; for
browser modules it points at renderer.js. Mapbox settings should come from the
settings JSON passed to the harness, for example:
{
"mapbox": {
"access_token": "pk...",
"style_dark": "mapbox://styles/mapbox/dark-v11",
"style_light": "mapbox://styles/mapbox/light-v11"
}
}In production, Mapbox settings come from ServiceRadar settings and are exposed
through api.mapbox().
The harness is not an authorization or package-verification substitute. It is a local rendering loop. ServiceRadar production import still verifies manifest shape, artifact digest, trust policy, and capabilities before a dashboard can be enabled.
The package is published as @carverauto/serviceradar-dashboard-sdk. Pull requests and
pushes run npm run ci, which executes JavaScript tests, Go tests, and
npm pack --dry-run.
Release publishing is handled by GitHub Actions via
.github/workflows/npm-publish.yml. The workflow uses npm trusted publishing
with GitHub OIDC, so it does not require NPM_TOKEN.
- Update
package.jsonto the target semver. - Tag the SDK repo as
v<package.json version>. - Push the tag to GitHub (
git push origin "v$(node -p 'require("./package.json").version')"). - Ensure npmjs.com has a trusted publisher for
carverauto/serviceradar-sdk-dashboardand workflow filenamenpm-publish.yml(filename only, not a path). - Let the tag-triggered GitHub workflow publish, or dispatch it with the same tag.
The workflow refuses to publish if the tag does not match the package version.