@pulssse/react
v0.1.0
Published
React dashboard widgets for Pulse. You supply MetricData through a queryFn.
Maintainers
Readme
@pulssse/react
React widgets for a Pulse dashboard. You keep the data source. The package draws the charts.
You pass a queryFn that returns MetricData. Prometheus, Postgres, and Pulse auth stay in the host. Widgets refetch on a timer through TanStack Query.
import { DashboardView, PulseProvider, parseDashboard } from "@pulssse/react";
import { createHttpQueryFn } from "@pulssse/react/core";
const queryFn = createHttpQueryFn("/api/metrics/query");
const dashboard = parseDashboard(dashboardJson);
export function Ops() {
return (
<PulseProvider queryFn={queryFn}>
<DashboardView dashboard={dashboard} />
</PulseProvider>
);
}Install
npm install @pulssse/reactPeer dependencies, same majors as this package:
npm install react react-dom @tanstack/react-query recharts @base-ui/react \
class-variance-authority clsx lucide-react tailwind-mergezod is a real dependency. React 19, Recharts 3, TanStack Query 5.
The published import field points at TypeScript source so Tailwind can see class names. Compile that source in the host app.
Next.js
const nextConfig = {
transpilePackages: ["@pulssse/react"],
};Vite. Deduplicate React if you ever file: or link this package. Two copies of React produce a blank page and "invalid hook call":
import path from "node:path";
import { defineConfig } from "vite";
export default defineConfig({
resolve: {
dedupe: ["react", "react-dom", "@tanstack/react-query", "@base-ui/react"],
alias: {
react: path.resolve(import.meta.dirname, "node_modules/react"),
"react-dom": path.resolve(import.meta.dirname, "node_modules/react-dom"),
},
},
});CSS
Widgets use Tailwind v4 utility classes plus CSS variables from @pulssse/react/styles.css. If Tailwind never scans the package source, the charts render unstyled.
@import "tailwindcss";
@import "@pulssse/react/styles.css";
@source "../node_modules/@pulssse/react/src";Adjust the @source path so it reaches node_modules/@pulssse/react/src from that CSS file. Dark mode uses a .dark ancestor.
queryFn
Widgets fetch only through queryFn.
type MetricQueryFn = (
body: ExecuteQueryBody,
signal?: AbortSignal,
) => Promise<MetricData>;ExecuteQueryBody:
{
query: string;
variables?: Record<string, string>;
range?: { start: string; end: string; step: string };
transform?: TransformConfig | TransformConfig[];
datasourceId?: string;
}range.start and range.end are ISO timestamps. query is whatever string you put on the widget. Pulse does not interpret PromQL. Your function can treat it as a metric name, a SQL snippet, or a route key.
Keep queryFn stable. Module scope or useMemo. A new function every render rebuilds provider context and redraws every widget.
Throw MetricRequestError for a typed widget error:
import { MetricRequestError } from "@pulssse/react/core";
throw new MetricRequestError({
type: "timeout",
message: "Prometheus did not answer in time",
});type is query | timeout | invalid-data | authorization | provider.
MetricData
Return one of three shapes. Timestamps are Unix seconds.
{ type: "scalar", value: 12.4, labels: { instance: "api-1" } }
{
type: "series",
series: [
{
id: "api-1",
name: "api-1",
labels: { instance: "api-1" },
points: [{ timestamp: 1710000000, value: 0.42 }],
},
],
}
{
type: "table",
columns: ["route", "method", "tps"],
rows: [{ route: "query.index", method: "GET", tps: 0.0069 }],
}| Widget type | Needs |
| --- | --- |
| line, area, bar | series with at least one point |
| stat, gauge | scalar, or a single series (latest point) |
| table | table, series, or scalar |
| pie | series or table |
| heatmap | accepted as series; no renderer yet |
HTTP helper
If your API already returns Pulse's envelope, use createHttpQueryFn(url). It POSTs JSON ExecuteQueryBody and unwraps:
{ "data": { "type": "scalar", "value": 1, "labels": {} } }or
{ "error": { "type": "query", "message": "unknown metric" } }Mapping a product payload
Map product JSON in queryFn. DashboardView only accepts MetricData. One HTTP envelope can feed several widgets if you branch on body.query.
Example product payload:
{
"tps": 0.0069,
"window": "5m",
"range": "15m",
"metric": "api_requests_total",
"series": [{ "route": "query.index", "method": "GET", "tps": 0.0069 }],
"history": [{ "timestamp": 1710000000, "tps": 0 }]
}import type { MetricQueryFn } from "@pulssse/react/core";
const queryFn: MetricQueryFn = async (body, signal) => {
const res = await fetch("/api/metrics", { signal });
const json = await res.json();
if (body.query === "tps") {
return { type: "scalar", value: json.tps, labels: { window: json.window } };
}
if (body.query === "tps_history") {
return {
type: "series",
series: [
{
id: "tps",
name: "tps",
labels: { metric: json.metric },
points: json.history.map((p: { timestamp: number; tps: number }) => ({
timestamp: p.timestamp,
value: p.tps,
})),
},
],
};
}
return {
type: "table",
columns: ["route", "method", "tps"],
rows: json.series,
};
};Use display: { unit: "requests/sec" } on the stat and line widgets for that data.
Dashboard JSON
Parse unknown JSON with parseDashboard or loadDashboard (same thing). schemaVersion must be 1.
{
"schemaVersion": 1,
"id": "api",
"name": "API",
"description": "Edge request rate",
"variables": [
{
"name": "env",
"label": "Environment",
"type": "custom",
"options": ["production", "staging"],
"default": "production"
}
],
"widgets": [
{
"id": "tps",
"title": "TPS",
"type": "stat",
"query": "tps",
"display": { "unit": "requests/sec" },
"layout": { "x": 0, "y": 0, "w": 3, "h": 2 }
},
{
"id": "history",
"title": "TPS",
"type": "line",
"query": "tps_history",
"display": { "unit": "requests/sec" },
"layout": { "x": 0, "y": 2, "w": 12, "h": 4 }
}
]
}Layout is a 12-column grid. w is column span (clamped 1–12). h is stored but the grid currently sizes by content. Widgets sort by y then x.
Variables. The picker uses options. Values go into queryFn as body.variables. Names match /^[a-zA-Z_][a-zA-Z0-9_]*$/. type: "query" is stored on the schema and ignored here.
Transforms on a widget (optional): latest, average, sum, min, max, { type: "group", by: ["route"] }, { type: "sort", direction: "desc" }, { type: "limit", count: 10 }. The widget copies them onto body.transform. This package does not run them. Call applyTransform or transform from @pulssse/react/core inside queryFn, or do the work on the server.
Display. unit is none | percent | bytes | bytes/sec | seconds | milliseconds | requests/sec | operations/sec | short. labelTemplate interpolates labels, e.g. {{instance}}. min / max matter for gauges. decimals is 0–8.
DashboardView shows a 15m / 1h / 6h range picker unless you pass range or relativeRange. Default refetch is 15 seconds. Pass refetchInterval={false} to stop.
Pieces you can use without a full dashboard
import {
MetricBar,
MetricWidget,
Sparkline,
useMetricQuery,
} from "@pulssse/react";
<MetricWidget widget={widget} relativeRange="1h" />
<Sparkline values={[0.1, 0.2, null, 0.4]} />
<MetricBar label="CPU" value={72} warn={75} crit={90} />useMetricQuery needs PulseProvider above it. It returns a TanStack Query result whose data is MetricData.
const q = useMetricQuery({
query: "tps_history",
relativeRange: "15m",
variables: { env: "production" },
refetchInterval: 15_000,
});Pass queryKey on PulseProvider when two trees share a QueryClient and should not share cache entries. Pass your own queryClient if the host app already has one.
Exports
| Import | What |
| --- | --- |
| @pulssse/react | Provider, dashboard, widgets, charts, Sparkline, MetricBar |
| @pulssse/react/core | Types, parseDashboard, createHttpQueryFn, transforms, formatters. No React components. |
| @pulssse/react/styles.css | Tokens (--background, --chart-1 through --chart-5, --status-ok, --status-warn) |
@pulssse/react re-exports core, so import { parseDashboard } from "@pulssse/react" works. Use /core from Node or a non-React bundle.
Outside this package
Prometheus, dashboard files on disk, auth, and logs live in the Pulse app. This package is the widgets plus the MetricData contract.
Before npm publish, run npm run build in this directory so dist/*.d.ts exists. Scoped packages need publishConfig.access set to public, which is already in package.json.
