Files
app/frontend/src/components/Dashboard/publish.tsx
T

123 lines
4.9 KiB
TypeScript

import { useEffect, useState } from "react"
import type { ApiError, WidgetDef } from "@/client"
import { useLiveValue } from "@/components/Flow/liveStore"
import useCustomToast from "@/hooks/useCustomToast"
import { handleError } from "@/utils"
// The transmit ring's rule lives with the dashboard's own geometry, and CSS is
// chunked per entry — so the sheet is pulled in wherever the pulse is drawn.
import "./ui/core/core.css"
import { useDataContext } from "./dataContext"
import { usePublishMessage } from "./queries"
import { useLocked } from "./settings"
/**
* How long a control shows what it sent before falling back to the engine.
*
* ponytail: a flat 3 s rather than anything the engine tells us. A publish the
* server takes but nothing ever echoes — a message no flow consumes — would
* otherwise leave the tile holding a value that is not the truth, forever.
*/
const HOLD_MS = 3000
/** Whether what came back over the socket is what this control sent. */
function confirms(live: unknown, sent: unknown): boolean {
// Readings are scalars nearly always; a dropdown may carry a record and a
// colour wheel an array, and comparing those as text is cheaper than a deep
// walk for the same answer.
return live === sent || JSON.stringify(live) === JSON.stringify(sent)
}
/**
* Publishing, with the value shown as sent until the engine confirms it.
*
* A control publishes over HTTP and reads the result back over the socket, so
* between the two the live value is still the old one — a handle let go of
* would snap back to it. The hold ends when the echo matches, when the publish
* is refused, or on `HOLD_MS`; success is silent, because the echo is the
* confirmation.
*
* A locked dashboard is gated here and only here: every control publishes
* through this hook, so one check covers all of them and a control added later
* inherits it. `locked` is handed back so each control can also *read* as
* disabled — a dashboard that silently swallows a press looks broken rather
* than locked.
*
* ponytail: this is a read-only surface, not an authorisation boundary. The
* server still takes a publish from a panel credential whose dashboard says
* locked, because the credential's own allowlist is what bounds it. Making it
* a real lock means carrying the flag into `_panel_may`.
*
* Its own module rather than `widgets.tsx`, which every widget file is
* imported *by*: a control drawn in a file of its own can only reach this
* without closing that circle if it does not sit there.
*/
export function usePublish(widget: WidgetDef, dashboard: string) {
const cfg = (widget.config ?? {}) as Record<string, unknown>
const target = cfg.target == null ? "" : String(cfg.target)
// A dashboard opened against a run or a replay is a picture of something
// that already happened; there is nothing for a control to publish to.
const context = useDataContext()
const locked = useLocked() || Boolean(context?.readOnly)
const publish = usePublishMessage()
const live = useLiveValue(target || undefined)
const { showErrorToast } = useCustomToast()
// Boxed: holding `false` or `null` is not the same as holding nothing.
const [held, setHeld] = useState<{ value: unknown } | null>(null)
useEffect(() => {
if (!held) return
const timer = setTimeout(() => setHeld(null), HOLD_MS)
return () => clearTimeout(timer)
}, [held])
useEffect(() => {
if (held && confirms(live?.value, held.value)) setHeld(null)
}, [held, live])
return {
target,
/** What the control draws: what it sent, until the engine answers. */
value: held ? held.value : live?.value,
/** Whether this dashboard is read-only; controls draw themselves disabled. */
locked,
send: (value: unknown) => {
if (!target || locked) return
setHeld({ value })
publish.mutate(
{
name: target,
value,
dashboard,
widget: widget.id,
label: widget.title || widget.id,
kind: widget.type,
},
{
onError: (error) => {
// Back to the engine's own value, and say which message refused it
// — a panel showing several controls cannot tell them apart.
setHeld(null)
handleError.call(
(detail: string) => showErrorToast(`${target}: ${detail}`),
error as ApiError,
)
},
},
)
},
/**
* The in-flight pulse, drawn as a ring around the whole tile.
*
* A marker rather than the ring itself: the body a control sits in is a
* query container, which is a containing block for anything absolute
* inside it and clips besides — so the frame draws the ring when it sees
* this, and a control need only say that it is publishing.
*/
pulse: publish.isPending ? (
<span aria-hidden className="widget-transmit" />
) : null,
pending: publish.isPending,
}
}