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:
@@ -565,6 +565,29 @@ export const ChatItemSchema = {
|
||||
title: 'ChatItem'
|
||||
} as const;
|
||||
|
||||
export const ConnectorStatePublicSchema = {
|
||||
properties: {
|
||||
connector: {
|
||||
type: 'string',
|
||||
title: 'Connector'
|
||||
},
|
||||
data: {
|
||||
items: {
|
||||
'$ref': '#/components/schemas/StoredKey'
|
||||
},
|
||||
type: 'array',
|
||||
title: 'Data'
|
||||
},
|
||||
count: {
|
||||
type: 'integer',
|
||||
title: 'Count'
|
||||
}
|
||||
},
|
||||
type: 'object',
|
||||
required: ['connector', 'data', 'count'],
|
||||
title: 'ConnectorStatePublic'
|
||||
} as const;
|
||||
|
||||
export const ControlNodeSchema = {
|
||||
properties: {
|
||||
id: {
|
||||
@@ -3669,6 +3692,27 @@ export const ShareRequestSchema = {
|
||||
title: 'ShareRequest'
|
||||
} as const;
|
||||
|
||||
export const StoredKeySchema = {
|
||||
properties: {
|
||||
key: {
|
||||
type: 'string',
|
||||
title: 'Key'
|
||||
},
|
||||
size: {
|
||||
type: 'integer',
|
||||
title: 'Size'
|
||||
},
|
||||
updated_at: {
|
||||
type: 'string',
|
||||
format: 'date-time',
|
||||
title: 'Updated At'
|
||||
}
|
||||
},
|
||||
type: 'object',
|
||||
required: ['key', 'size', 'updated_at'],
|
||||
title: 'StoredKey'
|
||||
} as const;
|
||||
|
||||
export const SubscriptionSchema = {
|
||||
properties: {
|
||||
endpoint: {
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -142,6 +142,12 @@ export type ChatItem = {
|
||||
error?: (string | null);
|
||||
};
|
||||
|
||||
export type ConnectorStatePublic = {
|
||||
connector: string;
|
||||
data: Array<StoredKey>;
|
||||
count: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* A wren node the chat can talk to: where it sends and where it reads.
|
||||
*/
|
||||
@@ -1235,6 +1241,12 @@ export type ShareRequest = {
|
||||
lib_name: string;
|
||||
};
|
||||
|
||||
export type StoredKey = {
|
||||
key: string;
|
||||
size: number;
|
||||
updated_at: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* What a browser handed us. Opaque apart from the endpoint's host.
|
||||
*/
|
||||
@@ -1553,6 +1565,25 @@ export type CloudAddRemoteUserResponse = (UserPublic);
|
||||
|
||||
export type CloudDisconnectResponse = (Message);
|
||||
|
||||
export type ConnectorsReadConnectorStateData = {
|
||||
nodeType: string;
|
||||
};
|
||||
|
||||
export type ConnectorsReadConnectorStateResponse = (ConnectorStatePublic);
|
||||
|
||||
export type ConnectorsClearConnectorStateData = {
|
||||
nodeType: string;
|
||||
};
|
||||
|
||||
export type ConnectorsClearConnectorStateResponse = (Message);
|
||||
|
||||
export type ConnectorsDeleteConnectorStateData = {
|
||||
key: string;
|
||||
nodeType: string;
|
||||
};
|
||||
|
||||
export type ConnectorsDeleteConnectorStateResponse = (Message);
|
||||
|
||||
export type DashboardsReadDashboardsResponse = (DashboardsPublic);
|
||||
|
||||
export type DashboardsReadDashboardData = {
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user