Give connectors a store that survives restarts, and a way to clear it

`ConnectorNode.store` keeps short strings per connector package in the
engine's database (`connector_state`, migration f8c3e5a91d27), so the nodes of
one package share them and `fluksio serve` needs nothing new: get, set and
delete, at most 64 kB a value and 256 keys a package. A superuser lists the
keys (never the values) and clears them through `/connectors/{node_type}/state`,
or under Stored state in a connector node's panel. Documented in the connector
contract.
This commit is contained in:
2026-10-06 17:19:48 +02:00
parent 8a1685741b
commit 243d39b591
15 changed files with 710 additions and 4 deletions
@@ -16,6 +16,7 @@ import {
} from "react"
import {
ConnectorsService,
type DType,
type FlowOrigin,
FlowsService,
@@ -1233,6 +1234,84 @@ const SCAFFOLD_SHAPE = (() => {
)
})()
/**
* What a connector keeps across restarts, such as a certificate it pinned on
* first contact. Clearing it is how a replaced device gets trusted again.
*/
function StoredStateSection({ nodeType }: { nodeType: string }) {
const queryClient = useQueryClient()
const { showSuccessToast, showErrorToast } = useCustomToast()
const [confirming, setConfirming] = useState(false)
const queryKey = ["connector-state", nodeType]
const { data } = useQuery({
queryKey,
queryFn: () => ConnectorsService.readConnectorState({ nodeType }),
})
const clear = useMutation({
mutationFn: () => ConnectorsService.clearConnectorState({ nodeType }),
onSuccess: () => {
showSuccessToast("Stored state cleared")
setConfirming(false)
queryClient.invalidateQueries({ queryKey })
},
onError: () => showErrorToast("The stored state could not be cleared."),
})
if (!data) return null
return (
<div className="grid gap-2" data-testid="connector-state">
<div className="flex items-center gap-1">
<span className={SECTION}>Stored state</span>
<InfoTip label="Stored state">
What {data.connector} keeps across restarts, such as a certificate
pinned on first contact. Every node of this connector shares it, and
clearing it makes them start over.
</InfoTip>
</div>
<div className="flex items-center justify-between gap-3">
<span className="text-sm text-muted-foreground">
{data.count === 0
? "Nothing stored"
: `${data.count} key${data.count === 1 ? "" : "s"}`}
</span>
{confirming ? (
<div className="flex gap-2">
<Button
variant="ghost"
size="sm"
className="h-8"
onClick={() => setConfirming(false)}
>
Keep
</Button>
<Button
variant="destructive"
size="sm"
className="h-8"
disabled={clear.isPending}
onClick={() => clear.mutate()}
data-testid="confirm-clear-state"
>
Clear all
</Button>
</div>
) : (
<Button
variant="outline"
size="sm"
className="h-8"
disabled={data.count === 0}
onClick={() => setConfirming(true)}
data-testid="clear-state"
>
Clear
</Button>
)}
</div>
</div>
)
}
/** The name the node function sees; the engine derives it the same way. */
const portName = (spec: MessageSpec) =>
spec.port || (spec.name ?? "").split(".").pop() || ""
@@ -1487,6 +1566,9 @@ function PanelBody({
onChange={(params) => editNode({ ...node, params })}
/>
) : null}
{nodeType?.plugin && user?.is_superuser ? (
<StoredStateSection nodeType={nodeType.type} />
) : null}
{hasSource ? (
<div className="grid gap-1.5">
<div className="flex items-center gap-1">