Build dashboards you can actually look at and press

Widgets bind to a message name and read it live off the socket the editor
already had — lifted out of the flow editor so a dashboard route gets the
same values, which also gives the home page live data for free.

The input widgets close the loop the other way: a slider publishes into
the graph and whatever consumes that message runs. Verified end to end in
the running app — moving a slider set a flow input, and the stat bound to
what the flow computed from it followed.

View mode is plain CSS grid. A wall panel that only displays should not
download the code that lets someone drag things around, and it now does
not. Editing is a widget picker, a per-widget width control and a
settings card fed by the message catalog.

No new dependencies: the slider is a range input, the gauge is an arc,
and the markdown is a five-line subset. Charts are the one widget still
missing — they need a charting library and the chart tokens the design
guidelines reserved — so they are stored and validated but not offered.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011LF61rxW1FG5YCD2J9YqjY
This commit is contained in:
2026-08-16 09:33:02 +02:00
co-authored by Claude Fable 5
parent 895bd89b2b
commit be6d4fc13c
15 changed files with 1388 additions and 7 deletions
@@ -0,0 +1,56 @@
import { useMutation, useQueryClient } from "@tanstack/react-query"
import {
type DashboardDef_Input,
DashboardsService,
MessagesService,
} from "@/client"
export const dashboardKeys = {
all: ["dashboards"] as const,
detail: (name: string) => ["dashboards", name] as const,
messages: ["messages"] as const,
history: (message: string) => ["messages", message, "history"] as const,
}
export const dashboardsQueryOptions = () => ({
queryKey: dashboardKeys.all,
queryFn: () => DashboardsService.readDashboards(),
})
export const dashboardQueryOptions = (name: string) => ({
queryKey: dashboardKeys.detail(name),
queryFn: () => DashboardsService.readDashboard({ name }),
})
/** Every message any flow declares — what a widget can be pointed at. */
export const messageCatalogQueryOptions = () => ({
queryKey: dashboardKeys.messages,
queryFn: () => MessagesService.readMessages(),
})
export const messageHistoryQueryOptions = (message: string) => ({
queryKey: dashboardKeys.history(message),
queryFn: () => MessagesService.readMessageHistory({ name: message }),
})
/** Saving a dashboard, carrying the version it was based on. */
export function useSaveDashboard(name: string) {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (body: DashboardDef_Input) =>
DashboardsService.saveDashboard({ name, requestBody: body }),
onSuccess: (saved) => {
queryClient.setQueryData(dashboardKeys.detail(name), saved)
queryClient.invalidateQueries({ queryKey: dashboardKeys.all })
},
})
}
/** What an input widget does: put a value into the graph. */
export function usePublishMessage() {
return useMutation({
mutationFn: ({ name, value }: { name: string; value: unknown }) =>
MessagesService.publishMessage({ name, requestBody: { value } }),
})
}