feat(web): useTopologyEditor skeleton + explicit streamLive gate
Phase B step 1 of DEBT-030: introduce a status-aware editor hook that
wraps useMazeApi. Every primitive currently pass-throughs to direct
CRUD and returns {kind: 'applied', data} — behavior is unchanged.
Follow-up commits route active/degraded topologies through
enqueueMutation when status != pending.
Also tighten the SSE LIVE indicator: flip setStreamLive(true) only on
snapshot, mutation.*, or status events, not on any incidental frame.
This commit is contained in:
@@ -15,6 +15,7 @@ import { DEFAULT_SERVICES } from './data';
|
|||||||
import type { Archetype, ServiceDef } from './data';
|
import type { Archetype, ServiceDef } from './data';
|
||||||
import type { Net, MazeNode, Edge, DeckyNode } from './types';
|
import type { Net, MazeNode, Edge, DeckyNode } from './types';
|
||||||
import { useMazeApi } from './useMazeApi';
|
import { useMazeApi } from './useMazeApi';
|
||||||
|
import { useTopologyEditor } from './useTopologyEditor';
|
||||||
import { useMazeInteraction, type PaletteDrag } from './useMazeInteraction';
|
import { useMazeInteraction, type PaletteDrag } from './useMazeInteraction';
|
||||||
import { useLayoutPersistor } from './useMazeLayoutStore';
|
import { useLayoutPersistor } from './useMazeLayoutStore';
|
||||||
import { useTopologyStream, type TopologyStreamEvent } from './useTopologyStream';
|
import { useTopologyStream, type TopologyStreamEvent } from './useTopologyStream';
|
||||||
@@ -53,6 +54,8 @@ const MazeNET: React.FC = () => {
|
|||||||
|
|
||||||
const canvasRef = useRef<HTMLDivElement>(null);
|
const canvasRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const editor = useTopologyEditor({ api, topoStatus, topoVersion });
|
||||||
|
|
||||||
const flashErr = useCallback((err: unknown, fallback: string) => {
|
const flashErr = useCallback((err: unknown, fallback: string) => {
|
||||||
const msg = (err as { response?: { data?: { detail?: string } }; message?: string })
|
const msg = (err as { response?: { data?: { detail?: string } }; message?: string })
|
||||||
?.response?.data?.detail ?? (err as Error)?.message ?? fallback;
|
?.response?.data?.detail ?? (err as Error)?.message ?? fallback;
|
||||||
@@ -82,7 +85,9 @@ const MazeNET: React.FC = () => {
|
|||||||
const name = isDmz ? `dmz-${hex4()}` : `subnet-${hex4()}`;
|
const name = isDmz ? `dmz-${hex4()}` : `subnet-${hex4()}`;
|
||||||
try {
|
try {
|
||||||
const subnet = await api.getNextSubnet().catch(() => undefined);
|
const subnet = await api.getNextSubnet().catch(() => undefined);
|
||||||
const lan = await api.createLan(topologyId, { name, is_dmz: isDmz, x, y, ...(subnet ? { subnet } : {}) });
|
const lanRes = await editor.createLan(topologyId, { name, is_dmz: isDmz, x, y, ...(subnet ? { subnet } : {}) });
|
||||||
|
if (lanRes.kind !== 'applied') return;
|
||||||
|
const lan = lanRes.data;
|
||||||
const net: Net = {
|
const net: Net = {
|
||||||
id: lan.id, label: lan.name.toUpperCase(), cidr: lan.subnet,
|
id: lan.id, label: lan.name.toUpperCase(), cidr: lan.subnet,
|
||||||
kind: isDmz ? 'dmz' : 'subnet', x, y, w, h,
|
kind: isDmz ? 'dmz' : 'subnet', x, y, w, h,
|
||||||
@@ -91,14 +96,16 @@ const MazeNET: React.FC = () => {
|
|||||||
|
|
||||||
if (isDmz) {
|
if (isDmz) {
|
||||||
const gwName = `dmz-gateway-${hex4()}`;
|
const gwName = `dmz-gateway-${hex4()}`;
|
||||||
const gw = await api.createDecky(topologyId, {
|
const gwRes = await editor.createDecky(topologyId, {
|
||||||
name: gwName, services: ['ssh'], x: 20, y: 40,
|
name: gwName, services: ['ssh'], x: 20, y: 40,
|
||||||
decky_config: { archetype: 'deaddeck', forwards_l3: true },
|
decky_config: { archetype: 'deaddeck', forwards_l3: true },
|
||||||
});
|
});
|
||||||
await api.attachEdge(topologyId, {
|
if (gwRes.kind !== 'applied') return;
|
||||||
|
const gw = gwRes.data;
|
||||||
|
await editor.attachEdge(topologyId, {
|
||||||
decky_uuid: gw.uuid, lan_id: lan.id,
|
decky_uuid: gw.uuid, lan_id: lan.id,
|
||||||
is_bridge: true, forwards_l3: true,
|
is_bridge: true, forwards_l3: true,
|
||||||
});
|
}, gw.name, lan.name);
|
||||||
const gwNode: DeckyNode = {
|
const gwNode: DeckyNode = {
|
||||||
kind: 'decky', id: gw.uuid, netId: lan.id, name: gw.name,
|
kind: 'decky', id: gw.uuid, netId: lan.id, name: gw.name,
|
||||||
archetype: 'deaddeck', services: ['ssh'], status: 'idle',
|
archetype: 'deaddeck', services: ['ssh'], status: 'idle',
|
||||||
@@ -123,11 +130,15 @@ const MazeNET: React.FC = () => {
|
|||||||
const ny = Math.max(28, Math.round(world.y - net.y - 24));
|
const ny = Math.max(28, Math.round(world.y - net.y - 24));
|
||||||
const name = `decky-${hex4()}`;
|
const name = `decky-${hex4()}`;
|
||||||
try {
|
try {
|
||||||
const decky = await api.createDecky(topologyId, {
|
const dRes = await editor.createDecky(topologyId, {
|
||||||
name, services: dServices, x: nx, y: ny,
|
name, services: dServices, x: nx, y: ny,
|
||||||
decky_config: { archetype: archSlug },
|
decky_config: { archetype: archSlug },
|
||||||
});
|
});
|
||||||
await api.attachEdge(topologyId, { decky_uuid: decky.uuid, lan_id: overNetId });
|
if (dRes.kind !== 'applied') return;
|
||||||
|
const decky = dRes.data;
|
||||||
|
await editor.attachEdge(topologyId,
|
||||||
|
{ decky_uuid: decky.uuid, lan_id: overNetId },
|
||||||
|
decky.name, net.label);
|
||||||
const node: DeckyNode = {
|
const node: DeckyNode = {
|
||||||
kind: 'decky', id: decky.uuid, netId: overNetId, name: decky.name,
|
kind: 'decky', id: decky.uuid, netId: overNetId, name: decky.name,
|
||||||
archetype: archSlug, services: dServices, status: 'idle', x: nx, y: ny,
|
archetype: archSlug, services: dServices, status: 'idle', x: nx, y: ny,
|
||||||
@@ -146,7 +157,8 @@ const MazeNET: React.FC = () => {
|
|||||||
if (target.services.includes(drag.slug)) return;
|
if (target.services.includes(drag.slug)) return;
|
||||||
const nextServices = [...target.services, drag.slug];
|
const nextServices = [...target.services, drag.slug];
|
||||||
try {
|
try {
|
||||||
await api.updateDecky(topologyId, overNodeId, { services: nextServices });
|
const r = await editor.updateDecky(topologyId, overNodeId, target.name, { services: nextServices });
|
||||||
|
if (r.kind !== 'applied') return;
|
||||||
setNodes((p) => p.map((n) => n.id === overNodeId && n.kind === 'decky'
|
setNodes((p) => p.map((n) => n.id === overNodeId && n.kind === 'decky'
|
||||||
? { ...n, services: nextServices }
|
? { ...n, services: nextServices }
|
||||||
: n));
|
: n));
|
||||||
@@ -155,7 +167,7 @@ const MazeNET: React.FC = () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[api, archetypes, flashErr, nets, nodes, topologyId],
|
[api, archetypes, editor, flashErr, nets, nodes, topologyId],
|
||||||
);
|
);
|
||||||
|
|
||||||
/* ── Cross-net reparent via node drag (detach + attach edge) ─── */
|
/* ── Cross-net reparent via node drag (detach + attach edge) ─── */
|
||||||
@@ -167,12 +179,18 @@ const MazeNET: React.FC = () => {
|
|||||||
(e: { decky_uuid: string; lan_id: string; id: string }) =>
|
(e: { decky_uuid: string; lan_id: string; id: string }) =>
|
||||||
e.decky_uuid === nodeId && e.lan_id === fromNetId,
|
e.decky_uuid === nodeId && e.lan_id === fromNetId,
|
||||||
);
|
);
|
||||||
if (existingEdge) await api.detachEdge(topologyId, existingEdge.id);
|
const node = nodes.find((n) => n.id === nodeId);
|
||||||
await api.attachEdge(topologyId, { decky_uuid: nodeId, lan_id: toNetId });
|
const fromNet = nets.find((n) => n.id === fromNetId);
|
||||||
|
const toNet = nets.find((n) => n.id === toNetId);
|
||||||
|
const nodeName = node?.kind === 'decky' ? node.name : '';
|
||||||
|
if (existingEdge) {
|
||||||
|
await editor.detachEdge(topologyId, existingEdge.id, nodeName, fromNet?.label ?? '');
|
||||||
|
}
|
||||||
|
await editor.attachEdge(topologyId, { decky_uuid: nodeId, lan_id: toNetId }, nodeName, toNet?.label ?? '');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
flashErr(err, 'reparent failed');
|
flashErr(err, 'reparent failed');
|
||||||
}
|
}
|
||||||
}, [api, flashErr, topologyId]);
|
}, [editor, flashErr, nets, nodes, topologyId]);
|
||||||
|
|
||||||
/* Port→port edges stay UI-only (backend edges are decky↔LAN). */
|
/* Port→port edges stay UI-only (backend edges are decky↔LAN). */
|
||||||
const onAddEdge = useCallback((fromId: string, toId: string) => {
|
const onAddEdge = useCallback((fromId: string, toId: string) => {
|
||||||
@@ -195,8 +213,11 @@ const MazeNET: React.FC = () => {
|
|||||||
/* Cascade delete members first — backend will otherwise 400 on orphan risk. */
|
/* Cascade delete members first — backend will otherwise 400 on orphan risk. */
|
||||||
const members = nodes.filter((n) => n.netId === id && n.kind === 'decky');
|
const members = nodes.filter((n) => n.netId === id && n.kind === 'decky');
|
||||||
try {
|
try {
|
||||||
for (const m of members) await api.deleteDecky(topologyId, m.id);
|
for (const m of members) {
|
||||||
await api.deleteLan(topologyId, id);
|
const mName = m.kind === 'decky' ? m.name : '';
|
||||||
|
await editor.deleteDecky(topologyId, m.id, mName);
|
||||||
|
}
|
||||||
|
await editor.deleteLan(topologyId, id, net.label);
|
||||||
setNets((p) => p.filter((n) => n.id !== id));
|
setNets((p) => p.filter((n) => n.id !== id));
|
||||||
setNodes((p) => p.filter((n) => n.netId !== id));
|
setNodes((p) => p.filter((n) => n.netId !== id));
|
||||||
setEdges((p) => p.filter((e) => {
|
setEdges((p) => p.filter((e) => {
|
||||||
@@ -215,7 +236,7 @@ const MazeNET: React.FC = () => {
|
|||||||
if (!node || node.kind === 'observed') return;
|
if (!node || node.kind === 'observed') return;
|
||||||
if (node.kind === 'decky' && node.decky_config?.forwards_l3) return;
|
if (node.kind === 'decky' && node.decky_config?.forwards_l3) return;
|
||||||
try {
|
try {
|
||||||
await api.deleteDecky(topologyId, id);
|
await editor.deleteDecky(topologyId, id, node.kind === 'decky' ? node.name : '');
|
||||||
setNodes((p) => p.filter((n) => n.id !== id));
|
setNodes((p) => p.filter((n) => n.id !== id));
|
||||||
setEdges((p) => p.filter((e) => e.from !== id && e.to !== id));
|
setEdges((p) => p.filter((e) => e.from !== id && e.to !== id));
|
||||||
setSelection(null);
|
setSelection(null);
|
||||||
@@ -235,11 +256,16 @@ const MazeNET: React.FC = () => {
|
|||||||
if (!n || n.kind !== 'decky') return;
|
if (!n || n.kind !== 'decky') return;
|
||||||
const name = `${n.name.replace(/-[0-9a-f]{4}$/, '')}-${hex4()}`;
|
const name = `${n.name.replace(/-[0-9a-f]{4}$/, '')}-${hex4()}`;
|
||||||
try {
|
try {
|
||||||
const decky = await api.createDecky(topologyId, {
|
const dRes = await editor.createDecky(topologyId, {
|
||||||
name, services: [...n.services], x: n.x + 24, y: n.y + 24,
|
name, services: [...n.services], x: n.x + 24, y: n.y + 24,
|
||||||
decky_config: { archetype: n.archetype },
|
decky_config: { archetype: n.archetype },
|
||||||
});
|
});
|
||||||
await api.attachEdge(topologyId, { decky_uuid: decky.uuid, lan_id: n.netId });
|
if (dRes.kind !== 'applied') return;
|
||||||
|
const decky = dRes.data;
|
||||||
|
const parentNet = nets.find((net) => net.id === n.netId);
|
||||||
|
await editor.attachEdge(topologyId,
|
||||||
|
{ decky_uuid: decky.uuid, lan_id: n.netId },
|
||||||
|
decky.name, parentNet?.label ?? '');
|
||||||
const copy: DeckyNode = {
|
const copy: DeckyNode = {
|
||||||
kind: 'decky', id: decky.uuid, netId: n.netId, name: decky.name,
|
kind: 'decky', id: decky.uuid, netId: n.netId, name: decky.name,
|
||||||
archetype: n.archetype, services: [...n.services], status: 'idle',
|
archetype: n.archetype, services: [...n.services], status: 'idle',
|
||||||
@@ -256,7 +282,8 @@ const MazeNET: React.FC = () => {
|
|||||||
if (!n || n.kind !== 'decky' || n.services.includes(slug)) return;
|
if (!n || n.kind !== 'decky' || n.services.includes(slug)) return;
|
||||||
const nextServices = [...n.services, slug];
|
const nextServices = [...n.services, slug];
|
||||||
try {
|
try {
|
||||||
await api.updateDecky(topologyId, id, { services: nextServices });
|
const r = await editor.updateDecky(topologyId, id, n.name, { services: nextServices });
|
||||||
|
if (r.kind !== 'applied') return;
|
||||||
setNodes((p) => p.map((x) => x.id === id && x.kind === 'decky'
|
setNodes((p) => p.map((x) => x.id === id && x.kind === 'decky'
|
||||||
? { ...x, services: nextServices } : x));
|
? { ...x, services: nextServices } : x));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -324,11 +351,15 @@ const MazeNET: React.FC = () => {
|
|||||||
onClick: async () => {
|
onClick: async () => {
|
||||||
const name = `decky-${hex4()}`;
|
const name = `decky-${hex4()}`;
|
||||||
try {
|
try {
|
||||||
const decky = await api.createDecky(topologyId, {
|
const dRes = await editor.createDecky(topologyId, {
|
||||||
name, services: [...a.services], x: 20, y: 40,
|
name, services: [...a.services], x: 20, y: 40,
|
||||||
decky_config: { archetype: a.slug },
|
decky_config: { archetype: a.slug },
|
||||||
});
|
});
|
||||||
await api.attachEdge(topologyId, { decky_uuid: decky.uuid, lan_id: id });
|
if (dRes.kind !== 'applied') return;
|
||||||
|
const decky = dRes.data;
|
||||||
|
await editor.attachEdge(topologyId,
|
||||||
|
{ decky_uuid: decky.uuid, lan_id: id },
|
||||||
|
decky.name, net.label);
|
||||||
const node: DeckyNode = {
|
const node: DeckyNode = {
|
||||||
kind: 'decky', id: decky.uuid, netId: id, name: decky.name,
|
kind: 'decky', id: decky.uuid, netId: id, name: decky.name,
|
||||||
archetype: a.slug, services: [...a.services], status: 'idle',
|
archetype: a.slug, services: [...a.services], status: 'idle',
|
||||||
@@ -432,7 +463,12 @@ const MazeNET: React.FC = () => {
|
|||||||
const [streamLive, setStreamLive] = useState(false);
|
const [streamLive, setStreamLive] = useState(false);
|
||||||
const streamEnabled = topoStatus === 'active' || topoStatus === 'degraded';
|
const streamEnabled = topoStatus === 'active' || topoStatus === 'degraded';
|
||||||
const onStreamEvent = useCallback((event: TopologyStreamEvent) => {
|
const onStreamEvent = useCallback((event: TopologyStreamEvent) => {
|
||||||
setStreamLive(true);
|
// Flip LIVE only on named, purposeful events — not incidental keepalives.
|
||||||
|
if (event.name === 'snapshot'
|
||||||
|
|| event.name.startsWith('mutation.')
|
||||||
|
|| event.name === 'status') {
|
||||||
|
setStreamLive(true);
|
||||||
|
}
|
||||||
if (event.name === 'mutation.applied'
|
if (event.name === 'mutation.applied'
|
||||||
|| event.name === 'mutation.failed'
|
|| event.name === 'mutation.failed'
|
||||||
|| event.name === 'status') {
|
|| event.name === 'status') {
|
||||||
|
|||||||
132
decnet_web/src/components/MazeNET/useTopologyEditor.ts
Normal file
132
decnet_web/src/components/MazeNET/useTopologyEditor.ts
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
/**
|
||||||
|
* Status-aware topology editor — wraps {@link useMazeApi} so the MazeNET
|
||||||
|
* editor can call one set of primitives regardless of whether the
|
||||||
|
* topology is ``pending`` (direct CRUD) or ``active|degraded`` (mutation
|
||||||
|
* queue via :func:`enqueueMutation`).
|
||||||
|
*
|
||||||
|
* Phase B scaffolding — for now every primitive is a pass-through to
|
||||||
|
* the direct-CRUD method on ``useMazeApi``. Behavior is unchanged from
|
||||||
|
* calling ``api.*`` directly. Status branching lands one primitive at
|
||||||
|
* a time in the follow-up commits so each change is small and
|
||||||
|
* reviewable.
|
||||||
|
*
|
||||||
|
* The ``*Name`` arguments (``deckyName``, ``lanName``, …) are unused in
|
||||||
|
* this pass — they're captured on the call site now so the signatures
|
||||||
|
* don't change when the enqueue branches are added: mutation ops are
|
||||||
|
* name-keyed while direct CRUD is uuid-keyed, and forcing the caller
|
||||||
|
* to plumb both through the editor hook up-front avoids a
|
||||||
|
* signature-churn commit later.
|
||||||
|
*/
|
||||||
|
import { useMemo } from 'react';
|
||||||
|
import type {
|
||||||
|
CreateDeckyBody,
|
||||||
|
CreateLanBody,
|
||||||
|
DeckyRow,
|
||||||
|
EdgeRow,
|
||||||
|
LANRow,
|
||||||
|
UseMazeApi,
|
||||||
|
} from './useMazeApi';
|
||||||
|
|
||||||
|
export interface UseTopologyEditorOptions {
|
||||||
|
api: UseMazeApi;
|
||||||
|
/** Current topology status from :func:`getTopology`. */
|
||||||
|
topoStatus: string;
|
||||||
|
/** Last-known topology version for optimistic concurrency. */
|
||||||
|
topoVersion: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tagged result for every primitive. ``applied`` = backend wrote the
|
||||||
|
* row synchronously (pending path) and the caller can update local
|
||||||
|
* state with ``data``. ``enqueued`` = the mutator will apply the
|
||||||
|
* change asynchronously; the caller must NOT touch local state and
|
||||||
|
* should wait for the SSE ``mutation.applied`` refetch to reflect
|
||||||
|
* truth.
|
||||||
|
*/
|
||||||
|
export type PrimitiveResult<T> =
|
||||||
|
| { kind: 'applied'; data: T }
|
||||||
|
| { kind: 'enqueued'; mutationId: string };
|
||||||
|
|
||||||
|
export interface UseTopologyEditor {
|
||||||
|
createLan(topologyId: string, body: CreateLanBody): Promise<PrimitiveResult<LANRow>>;
|
||||||
|
updateLan(
|
||||||
|
topologyId: string,
|
||||||
|
lanId: string,
|
||||||
|
lanName: string,
|
||||||
|
patch: Partial<LANRow>,
|
||||||
|
): Promise<PrimitiveResult<LANRow>>;
|
||||||
|
deleteLan(
|
||||||
|
topologyId: string,
|
||||||
|
lanId: string,
|
||||||
|
lanName: string,
|
||||||
|
): Promise<PrimitiveResult<void>>;
|
||||||
|
|
||||||
|
createDecky(topologyId: string, body: CreateDeckyBody): Promise<PrimitiveResult<DeckyRow>>;
|
||||||
|
updateDecky(
|
||||||
|
topologyId: string,
|
||||||
|
uuid: string,
|
||||||
|
deckyName: string,
|
||||||
|
patch: Partial<DeckyRow>,
|
||||||
|
): Promise<PrimitiveResult<DeckyRow>>;
|
||||||
|
deleteDecky(
|
||||||
|
topologyId: string,
|
||||||
|
uuid: string,
|
||||||
|
deckyName: string,
|
||||||
|
): Promise<PrimitiveResult<void>>;
|
||||||
|
|
||||||
|
attachEdge(
|
||||||
|
topologyId: string,
|
||||||
|
body: { decky_uuid: string; lan_id: string; is_bridge?: boolean; forwards_l3?: boolean },
|
||||||
|
deckyName: string,
|
||||||
|
lanName: string,
|
||||||
|
): Promise<PrimitiveResult<EdgeRow>>;
|
||||||
|
detachEdge(
|
||||||
|
topologyId: string,
|
||||||
|
edgeId: string,
|
||||||
|
deckyName: string,
|
||||||
|
lanName: string,
|
||||||
|
): Promise<PrimitiveResult<void>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useTopologyEditor(
|
||||||
|
opts: UseTopologyEditorOptions,
|
||||||
|
): UseTopologyEditor {
|
||||||
|
const { api } = opts;
|
||||||
|
// topoStatus / topoVersion intentionally unused this pass — see module
|
||||||
|
// docstring. They'll drive the enqueue branch in the next commits.
|
||||||
|
|
||||||
|
return useMemo<UseTopologyEditor>(() => ({
|
||||||
|
async createLan(topologyId, body) {
|
||||||
|
const data = await api.createLan(topologyId, body);
|
||||||
|
return { kind: 'applied', data };
|
||||||
|
},
|
||||||
|
async updateLan(topologyId, lanId, _lanName, patch) {
|
||||||
|
const data = await api.updateLan(topologyId, lanId, patch);
|
||||||
|
return { kind: 'applied', data };
|
||||||
|
},
|
||||||
|
async deleteLan(topologyId, lanId, _lanName) {
|
||||||
|
await api.deleteLan(topologyId, lanId);
|
||||||
|
return { kind: 'applied', data: undefined };
|
||||||
|
},
|
||||||
|
async createDecky(topologyId, body) {
|
||||||
|
const data = await api.createDecky(topologyId, body);
|
||||||
|
return { kind: 'applied', data };
|
||||||
|
},
|
||||||
|
async updateDecky(topologyId, uuid, _deckyName, patch) {
|
||||||
|
const data = await api.updateDecky(topologyId, uuid, patch);
|
||||||
|
return { kind: 'applied', data };
|
||||||
|
},
|
||||||
|
async deleteDecky(topologyId, uuid, _deckyName) {
|
||||||
|
await api.deleteDecky(topologyId, uuid);
|
||||||
|
return { kind: 'applied', data: undefined };
|
||||||
|
},
|
||||||
|
async attachEdge(topologyId, body, _deckyName, _lanName) {
|
||||||
|
const data = await api.attachEdge(topologyId, body);
|
||||||
|
return { kind: 'applied', data };
|
||||||
|
},
|
||||||
|
async detachEdge(topologyId, edgeId, _deckyName, _lanName) {
|
||||||
|
await api.detachEdge(topologyId, edgeId);
|
||||||
|
return { kind: 'applied', data: undefined };
|
||||||
|
},
|
||||||
|
}), [api]);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user