@opengeni/react 0.5.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +109 -2
- package/dist/chunk-DEW2ZNF2.js +1238 -0
- package/dist/chunk-DEW2ZNF2.js.map +1 -0
- package/dist/index.d.ts +173 -50
- package/dist/index.js +1418 -982
- package/dist/index.js.map +1 -1
- package/dist/machines-BD6h9P_s.d.ts +329 -0
- package/dist/machines.d.ts +4 -0
- package/dist/machines.js +33 -0
- package/dist/machines.js.map +1 -0
- package/package.json +8 -2
- package/src/components/desktop-viewer.tsx +11 -1
- package/src/components/enrollment-consent.tsx +245 -0
- package/src/components/enrollment-device-flow.tsx +182 -0
- package/src/components/machine-card.tsx +131 -0
- package/src/components/machine-dock-bar.tsx +84 -0
- package/src/components/machine-metrics.tsx +184 -0
- package/src/components/machine-status-pill.tsx +157 -0
- package/src/components/machines-dashboard.tsx +151 -0
- package/src/components/message-timeline.tsx +106 -8
- package/src/components/workspace-dock.tsx +44 -10
- package/src/hooks/use-codex-accounts.ts +179 -0
- package/src/hooks/use-desktop-stream.ts +28 -2
- package/src/hooks/use-goal.ts +10 -2
- package/src/hooks/use-machines.ts +157 -0
- package/src/hooks/use-relay-frame-stream.ts +335 -0
- package/src/index.ts +15 -0
- package/src/lib/relay-wire.ts +116 -0
- package/src/machines.ts +50 -0
- package/src/timeline/activity-rail.tsx +13 -7
- package/src/timeline/index.ts +1 -0
- package/src/timeline/projection.ts +203 -16
- package/src/timeline/turn-summary.tsx +32 -10
- package/src/timeline/types.ts +29 -3
- package/src/types/machines.ts +67 -0
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { type ReactNode, useCallback, useEffect,
|
|
1
|
+
import { type ReactNode, useCallback, useEffect, useLayoutEffect, useState } from "react";
|
|
2
2
|
import {
|
|
3
3
|
ChevronsLeftRightIcon,
|
|
4
4
|
Maximize2Icon,
|
|
@@ -30,6 +30,9 @@ export type WorkspaceDockProps = {
|
|
|
30
30
|
/** Controlled active tab. Falls back to the first tab. */
|
|
31
31
|
activeTab?: string | undefined;
|
|
32
32
|
onActiveTabChange?: ((id: string) => void) | undefined;
|
|
33
|
+
/** Controlled collapsed state for hosts that expose their own dock toggle. */
|
|
34
|
+
collapsed?: boolean | undefined;
|
|
35
|
+
onCollapsedChange?: ((collapsed: boolean) => void) | undefined;
|
|
33
36
|
/** Persisted layout id (localStorage key) for react-resizable-panels. */
|
|
34
37
|
autoSaveId?: string | undefined;
|
|
35
38
|
/** Default dock width as a percent of the session area. */
|
|
@@ -47,11 +50,15 @@ export type WorkspaceDockProps = {
|
|
|
47
50
|
* `autoSaveId`. Maximize is a mode ABOVE the Group (a `fixed inset-0` overlay) —
|
|
48
51
|
* pushing a Panel to ~100% still fights min sizes and leaves a chat sliver.
|
|
49
52
|
*/
|
|
53
|
+
const useDockLayoutEffect = typeof window === "undefined" ? useEffect : useLayoutEffect;
|
|
54
|
+
|
|
50
55
|
export function WorkspaceDock({
|
|
51
56
|
primary,
|
|
52
57
|
tabs,
|
|
53
58
|
activeTab,
|
|
54
59
|
onActiveTabChange,
|
|
60
|
+
collapsed: collapsedProp,
|
|
61
|
+
onCollapsedChange,
|
|
55
62
|
autoSaveId = "og.session.dock",
|
|
56
63
|
defaultSize = 34,
|
|
57
64
|
minSize = 22,
|
|
@@ -59,9 +66,10 @@ export function WorkspaceDock({
|
|
|
59
66
|
className,
|
|
60
67
|
}: WorkspaceDockProps) {
|
|
61
68
|
const dockPanelRef = usePanelRef();
|
|
62
|
-
const [
|
|
69
|
+
const [internalCollapsed, setInternalCollapsed] = useState(false);
|
|
63
70
|
const [maximized, setMaximized] = useState(false);
|
|
64
71
|
const [internalTab, setInternalTab] = useState(tabs[0]?.id ?? "");
|
|
72
|
+
const collapsed = collapsedProp ?? internalCollapsed;
|
|
65
73
|
|
|
66
74
|
// Persisted layout (width split) keyed by autoSaveId.
|
|
67
75
|
const { defaultLayout, onLayoutChanged } = useDefaultLayout({
|
|
@@ -71,6 +79,8 @@ export function WorkspaceDock({
|
|
|
71
79
|
} as Parameters<typeof useDefaultLayout>[0]);
|
|
72
80
|
|
|
73
81
|
const current = activeTab ?? internalTab;
|
|
82
|
+
const tabIds = tabs.map((tab) => tab.id).join("\u0000");
|
|
83
|
+
const firstTabId = tabs[0]?.id ?? "";
|
|
74
84
|
const setTab = useCallback(
|
|
75
85
|
(id: string) => {
|
|
76
86
|
setInternalTab(id);
|
|
@@ -78,13 +88,34 @@ export function WorkspaceDock({
|
|
|
78
88
|
},
|
|
79
89
|
[onActiveTabChange],
|
|
80
90
|
);
|
|
91
|
+
const setCollapsed = useCallback(
|
|
92
|
+
(next: boolean) => {
|
|
93
|
+
setInternalCollapsed((previous) => (previous === next ? previous : next));
|
|
94
|
+
onCollapsedChange?.(next);
|
|
95
|
+
},
|
|
96
|
+
[onCollapsedChange],
|
|
97
|
+
);
|
|
98
|
+
|
|
99
|
+
useDockLayoutEffect(() => {
|
|
100
|
+
if (collapsedProp === undefined) {
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
if (collapsedProp) {
|
|
104
|
+
dockPanelRef.current?.collapse();
|
|
105
|
+
setMaximized(false);
|
|
106
|
+
} else {
|
|
107
|
+
dockPanelRef.current?.expand();
|
|
108
|
+
}
|
|
109
|
+
}, [collapsedProp, dockPanelRef]);
|
|
81
110
|
|
|
82
111
|
// Keep the active tab valid if the available tabs change.
|
|
83
112
|
useEffect(() => {
|
|
84
|
-
if (
|
|
85
|
-
setTab(
|
|
113
|
+
if (firstTabId && !tabs.some((t) => t.id === current)) {
|
|
114
|
+
setTab(firstTabId);
|
|
86
115
|
}
|
|
87
|
-
|
|
116
|
+
// Depend on tab identity, not the tab content objects. Session live events
|
|
117
|
+
// rebuild tab JSX frequently; only id changes can invalidate the active tab.
|
|
118
|
+
}, [tabIds, firstTabId, current, setTab]);
|
|
88
119
|
|
|
89
120
|
// Esc restores from maximize.
|
|
90
121
|
useEffect(() => {
|
|
@@ -99,11 +130,11 @@ export function WorkspaceDock({
|
|
|
99
130
|
const collapse = useCallback(() => {
|
|
100
131
|
dockPanelRef.current?.collapse();
|
|
101
132
|
setCollapsed(true);
|
|
102
|
-
}, [dockPanelRef]);
|
|
133
|
+
}, [dockPanelRef, setCollapsed]);
|
|
103
134
|
const expand = useCallback(() => {
|
|
104
135
|
dockPanelRef.current?.expand();
|
|
105
136
|
setCollapsed(false);
|
|
106
|
-
}, [dockPanelRef]);
|
|
137
|
+
}, [dockPanelRef, setCollapsed]);
|
|
107
138
|
|
|
108
139
|
const dockChrome = (
|
|
109
140
|
<DockChrome
|
|
@@ -142,16 +173,19 @@ export function WorkspaceDock({
|
|
|
142
173
|
defaultSize={`${defaultSize}%`}
|
|
143
174
|
minSize={`${minSize}%`}
|
|
144
175
|
maxSize={`${maxSize}%`}
|
|
145
|
-
onResize={(size) => {
|
|
176
|
+
onResize={(size, _id, previousSize) => {
|
|
146
177
|
// `asPercentage` is 0..100; treat a near-zero panel as collapsed.
|
|
147
178
|
const isCollapsed = size.asPercentage <= 1;
|
|
148
|
-
|
|
179
|
+
const canInferCollapse = collapsedProp === undefined || previousSize !== undefined;
|
|
180
|
+
if (canInferCollapse && isCollapsed !== collapsed) {
|
|
181
|
+
setCollapsed(isCollapsed);
|
|
182
|
+
}
|
|
149
183
|
}}
|
|
150
184
|
className="min-h-0 min-w-0"
|
|
151
185
|
>
|
|
152
186
|
{/* Hidden behind the overlay while maximized (avoids double-mounting
|
|
153
187
|
the surfaces). */}
|
|
154
|
-
{!maximized && (
|
|
188
|
+
{!collapsed && !maximized && (
|
|
155
189
|
<div className="flex h-full min-h-0 min-w-0 flex-col border-l border-[color:var(--og-color-border,var(--color-border,#2a2a2a))] bg-[color:var(--og-color-bg,var(--color-bg,#0d0d0d))]">
|
|
156
190
|
{dockChrome}
|
|
157
191
|
</div>
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
import { useCallback, useState } from "react";
|
|
2
|
+
import type { CodexAccount, CodexAccountsResponse, CodexRotationSettings, SessionEvent } from "@opengeni/sdk";
|
|
3
|
+
import { useOpenGeni, type ClientOverride } from "../provider";
|
|
4
|
+
import { useMutationRunner, usePolledValue, useSessionEventTrigger, type SessionEventFeedOptions } from "./internal";
|
|
5
|
+
|
|
6
|
+
/** Events that change which Codex account a session runs on (or just ran). */
|
|
7
|
+
export function isCodexAccountEvent(event: Pick<SessionEvent, "type">): boolean {
|
|
8
|
+
return event.type === "codex.account.switched" || event.type === "turn.started";
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* The structural slice of the SDK client the Codex-accounts surface needs. Method
|
|
13
|
+
* NAMES + SIGNATURES match `OpenGeniClient` so the real client satisfies it
|
|
14
|
+
* directly; declared structurally (not a hard Pick) so a test/Geni client can
|
|
15
|
+
* stand in. `getSession` reads the session's pin/last; `pinSessionCodexAccount`
|
|
16
|
+
* is the optional mutation (absent ⇒ the indicator hides the switch affordance).
|
|
17
|
+
*/
|
|
18
|
+
export type CodexAccountsClientLike = {
|
|
19
|
+
listCodexAccounts: (workspaceId: string) => Promise<CodexAccountsResponse>;
|
|
20
|
+
getSession?: (workspaceId: string, sessionId: string) => Promise<{ codexPinnedCredentialId?: string | null; codexLastCredentialId?: string | null }>;
|
|
21
|
+
pinSessionCodexAccount?: (workspaceId: string, sessionId: string, target: string) => Promise<{ pinned: string }>;
|
|
22
|
+
/** Optional (absent ⇒ the card hides live refresh): batched live /wham/usage refresh. */
|
|
23
|
+
refreshCodexUsage?: (workspaceId: string) => Promise<{ usage: Record<string, unknown> }>;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export type UseCodexAccountsOptions = ClientOverride & SessionEventFeedOptions & {
|
|
27
|
+
pollIntervalMs?: number | undefined;
|
|
28
|
+
/** Scope to a session so the hook resolves the pin + the effective account. */
|
|
29
|
+
sessionId?: string | undefined;
|
|
30
|
+
/** Override the client with one implementing `CodexAccountsClientLike`. */
|
|
31
|
+
codexClient?: CodexAccountsClientLike | undefined;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
export type UseCodexAccountsResult = {
|
|
35
|
+
accounts: CodexAccount[];
|
|
36
|
+
/** The workspace ACTIVE account (used when a session is unpinned). */
|
|
37
|
+
activeAccountId: string | null;
|
|
38
|
+
/** The session's PINNED account (null ⇒ following workspace active). */
|
|
39
|
+
pinnedAccountId: string | null;
|
|
40
|
+
/** The account the next turn will run on: pin > workspace active. */
|
|
41
|
+
effectiveAccountId: string | null;
|
|
42
|
+
/** The account the session's last turn ACTUALLY ran on (the "Running on:" source). */
|
|
43
|
+
lastAccountId: string | null;
|
|
44
|
+
settings: CodexRotationSettings;
|
|
45
|
+
loading: boolean;
|
|
46
|
+
refresh: () => Promise<void>;
|
|
47
|
+
/**
|
|
48
|
+
* Trigger a LIVE batched /wham/usage refresh across all accounts, then re-read
|
|
49
|
+
* the cached metadata so the new windows land on `accounts`. Modeled on `pin`.
|
|
50
|
+
* No-op (resolves false) when the client can't refresh usage.
|
|
51
|
+
*/
|
|
52
|
+
refreshUsage: () => Promise<boolean>;
|
|
53
|
+
/** True while a live usage refresh is in flight (drives the bar skeleton). */
|
|
54
|
+
refreshingUsage: boolean;
|
|
55
|
+
/** Pin (or unpin via "auto") the session's account; returns true on success. */
|
|
56
|
+
pin: (target: string) => Promise<boolean>;
|
|
57
|
+
pinning: boolean;
|
|
58
|
+
/** The target of the in-flight pin (for per-row spinner gating). */
|
|
59
|
+
pinningTarget: string | null;
|
|
60
|
+
mutationError: Error | null;
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
const EMPTY_SETTINGS: CodexRotationSettings = { rotationEnabled: false, rotationStrategy: "most_remaining", activeCredentialId: null };
|
|
64
|
+
|
|
65
|
+
type CodexAccountsState = {
|
|
66
|
+
accounts: CodexAccount[];
|
|
67
|
+
activeAccountId: string | null;
|
|
68
|
+
settings: CodexRotationSettings;
|
|
69
|
+
pinnedAccountId: string | null;
|
|
70
|
+
lastAccountId: string | null;
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
const EMPTY_STATE: CodexAccountsState = {
|
|
74
|
+
accounts: [],
|
|
75
|
+
activeAccountId: null,
|
|
76
|
+
settings: EMPTY_SETTINGS,
|
|
77
|
+
pinnedAccountId: null,
|
|
78
|
+
lastAccountId: null,
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* The workspace's Codex accounts + the per-workspace active pointer + (when
|
|
83
|
+
* session-scoped) the session pin and last-ran-on account. Composed like
|
|
84
|
+
* `useMachines`: slow polling (the realtime work is done by the
|
|
85
|
+
* `codex.account.switched` / `turn.started` event trigger) + a `pin` mutation.
|
|
86
|
+
* Dual-consumer safe via the structural `CodexAccountsClientLike` surface.
|
|
87
|
+
*/
|
|
88
|
+
export function useCodexAccounts(options: UseCodexAccountsOptions = {}): UseCodexAccountsResult {
|
|
89
|
+
const { client, workspaceId } = useOpenGeni(options);
|
|
90
|
+
const codexClient = (options.codexClient ?? (client as unknown as CodexAccountsClientLike));
|
|
91
|
+
const sessionId = options.sessionId;
|
|
92
|
+
const sharedEvents = options.events;
|
|
93
|
+
|
|
94
|
+
const load = useCallback(async (): Promise<CodexAccountsState> => {
|
|
95
|
+
const accountsP = codexClient.listCodexAccounts(workspaceId);
|
|
96
|
+
const sessionP = sessionId && codexClient.getSession
|
|
97
|
+
? codexClient.getSession(workspaceId, sessionId).catch(() => null)
|
|
98
|
+
: Promise.resolve(null);
|
|
99
|
+
const [acc, session] = await Promise.all([accountsP, sessionP]);
|
|
100
|
+
return {
|
|
101
|
+
accounts: acc.accounts,
|
|
102
|
+
activeAccountId: acc.activeAccountId,
|
|
103
|
+
settings: acc.settings,
|
|
104
|
+
pinnedAccountId: session?.codexPinnedCredentialId ?? null,
|
|
105
|
+
lastAccountId: session?.codexLastCredentialId ?? null,
|
|
106
|
+
};
|
|
107
|
+
}, [codexClient, workspaceId, sessionId]);
|
|
108
|
+
|
|
109
|
+
const state = usePolledValue(load, { pollIntervalMs: options.pollIntervalMs, enabled: options.enabled });
|
|
110
|
+
const mutation = useMutationRunner();
|
|
111
|
+
const usageMutation = useMutationRunner();
|
|
112
|
+
const [pinningTarget, setPinningTarget] = useState<string | null>(null);
|
|
113
|
+
|
|
114
|
+
// Live flip: a manual switch (P1) or a failover (P3) emits codex.account.switched;
|
|
115
|
+
// turn.started covers the case where the worker recorded the actual account.
|
|
116
|
+
useSessionEventTrigger(
|
|
117
|
+
client,
|
|
118
|
+
workspaceId,
|
|
119
|
+
sessionId,
|
|
120
|
+
isCodexAccountEvent,
|
|
121
|
+
() => void state.refresh(),
|
|
122
|
+
{ enabled: options.enabled ?? true, ...(sharedEvents !== undefined ? { events: sharedEvents } : {}) },
|
|
123
|
+
);
|
|
124
|
+
|
|
125
|
+
const pin = useCallback(
|
|
126
|
+
async (target: string): Promise<boolean> => {
|
|
127
|
+
if (!sessionId || !codexClient.pinSessionCodexAccount) {
|
|
128
|
+
return false;
|
|
129
|
+
}
|
|
130
|
+
setPinningTarget(target);
|
|
131
|
+
const result = await mutation.run(async () => {
|
|
132
|
+
await codexClient.pinSessionCodexAccount!(workspaceId, sessionId, target);
|
|
133
|
+
return true;
|
|
134
|
+
});
|
|
135
|
+
setPinningTarget(null);
|
|
136
|
+
if (result) await state.refresh();
|
|
137
|
+
return result === true;
|
|
138
|
+
},
|
|
139
|
+
[codexClient, workspaceId, sessionId, mutation.run, state.refresh],
|
|
140
|
+
);
|
|
141
|
+
|
|
142
|
+
// Live usage refresh: hit the batched provider read, then re-read cached
|
|
143
|
+
// metadata so the fresh windows land on `accounts`. The provider read writes the
|
|
144
|
+
// cache columns server-side; state.refresh() pulls them back.
|
|
145
|
+
const refreshUsage = useCallback(
|
|
146
|
+
async (): Promise<boolean> => {
|
|
147
|
+
if (!codexClient.refreshCodexUsage) {
|
|
148
|
+
return false;
|
|
149
|
+
}
|
|
150
|
+
const result = await usageMutation.run(async () => {
|
|
151
|
+
await codexClient.refreshCodexUsage!(workspaceId);
|
|
152
|
+
return true;
|
|
153
|
+
});
|
|
154
|
+
if (result) await state.refresh();
|
|
155
|
+
return result === true;
|
|
156
|
+
},
|
|
157
|
+
[codexClient, workspaceId, usageMutation.run, state.refresh],
|
|
158
|
+
);
|
|
159
|
+
|
|
160
|
+
const data = state.data ?? EMPTY_STATE;
|
|
161
|
+
const effectiveAccountId = data.pinnedAccountId ?? data.activeAccountId;
|
|
162
|
+
|
|
163
|
+
return {
|
|
164
|
+
accounts: data.accounts,
|
|
165
|
+
activeAccountId: data.activeAccountId,
|
|
166
|
+
pinnedAccountId: data.pinnedAccountId,
|
|
167
|
+
effectiveAccountId,
|
|
168
|
+
lastAccountId: data.lastAccountId,
|
|
169
|
+
settings: data.settings,
|
|
170
|
+
loading: state.loading,
|
|
171
|
+
refresh: state.refresh,
|
|
172
|
+
refreshUsage,
|
|
173
|
+
refreshingUsage: usageMutation.mutating,
|
|
174
|
+
pin,
|
|
175
|
+
pinning: mutation.mutating,
|
|
176
|
+
pinningTarget,
|
|
177
|
+
mutationError: mutation.mutationError,
|
|
178
|
+
};
|
|
179
|
+
}
|
|
@@ -7,17 +7,21 @@ import {
|
|
|
7
7
|
type DesktopStreamCapability,
|
|
8
8
|
} from "@opengeni/sdk";
|
|
9
9
|
import { type RefObject, useEffect, useRef, useState } from "react";
|
|
10
|
+
import { type DesktopWebSocketFactory, useRelayFrameStream } from "./use-relay-frame-stream";
|
|
10
11
|
|
|
11
12
|
export type UseDesktopStreamOptions = {
|
|
12
13
|
/** The desktop cell of the negotiated capabilities (`capabilities.DesktopStream`). */
|
|
13
14
|
capability: DesktopStreamCapability | null;
|
|
14
|
-
/** The mount target. RFB attaches here on connect. */
|
|
15
|
+
/** The mount target. RFB (or the frame canvas) attaches here on connect. */
|
|
15
16
|
containerRef: RefObject<HTMLDivElement | null>;
|
|
16
17
|
/** Read-only by default (v1 ruling H). interactive only when cap.mode allows. */
|
|
17
18
|
interactive?: boolean | undefined;
|
|
18
19
|
scaleViewport?: boolean | undefined;
|
|
19
20
|
/** Custom RFB factory (tests / a WebRTC swap). Defaults to a lazy @novnc/novnc. */
|
|
20
21
|
rfbFactory?: DesktopRfbFactory | undefined;
|
|
22
|
+
/** Custom socket factory for the `relay-frames` transport (tests). Defaults to
|
|
23
|
+
* `new WebSocket(url)`. Mirrors `rfbFactory` for the frame renderer. */
|
|
24
|
+
webSocketFactory?: DesktopWebSocketFactory | undefined;
|
|
21
25
|
};
|
|
22
26
|
|
|
23
27
|
export type UseDesktopStreamResult = {
|
|
@@ -57,9 +61,26 @@ async function defaultRfbFactory(): Promise<DesktopRfbFactory> {
|
|
|
57
61
|
* `interactive` prop → `RFB.viewOnly`. v1 always resolves to read-only. On a
|
|
58
62
|
* capability `url` change (a rotation), the old RFB disconnects and a fresh one
|
|
59
63
|
* connects to the new URL — a brief "desktop blink", acceptable on rollover.
|
|
64
|
+
*
|
|
65
|
+
* Transport dispatch: a Modal box negotiates `transport: "vnc-ws"` and drives the
|
|
66
|
+
* noVNC RFB below. A SELF-HOSTED machine negotiates `transport: "relay-frames"`
|
|
67
|
+
* (PNG-per-frame over the relay, view-only) — that path is delegated to
|
|
68
|
+
* `useRelayFrameStream`, which paints a `<canvas>`. Both hooks are ALWAYS called
|
|
69
|
+
* (rules of hooks); each is dormant for the other's transport, and we return the
|
|
70
|
+
* result of whichever owns the surface. The public interface is identical either
|
|
71
|
+
* way, so `DesktopViewer` is transport-agnostic.
|
|
60
72
|
*/
|
|
61
73
|
export function useDesktopStream(options: UseDesktopStreamOptions): UseDesktopStreamResult {
|
|
62
|
-
const { capability, containerRef, interactive, scaleViewport, rfbFactory } =
|
|
74
|
+
const { capability, containerRef, interactive, scaleViewport, rfbFactory, webSocketFactory } =
|
|
75
|
+
options;
|
|
76
|
+
|
|
77
|
+
// The self-hosted PNG-frame path. Always invoked (rules of hooks); dormant
|
|
78
|
+
// unless `transport === "relay-frames"`, in which case it owns the surface.
|
|
79
|
+
const frameStream = useRelayFrameStream({
|
|
80
|
+
capability,
|
|
81
|
+
containerRef,
|
|
82
|
+
...(webSocketFactory ? { webSocketFactory } : {}),
|
|
83
|
+
});
|
|
63
84
|
const [state, setState] = useState<DesktopConnectionState>("idle");
|
|
64
85
|
const [error, setError] = useState<Error | null>(null);
|
|
65
86
|
const [nonce, setNonce] = useState(0);
|
|
@@ -210,5 +231,10 @@ export function useDesktopStream(options: UseDesktopStreamOptions): UseDesktopSt
|
|
|
210
231
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
211
232
|
}, [scaleViewport, state]);
|
|
212
233
|
|
|
234
|
+
// Delegate to whichever transport owns the surface. For `relay-frames` the
|
|
235
|
+
// noVNC effect above is dormant (it bailed to idle) and the frame renderer
|
|
236
|
+
// drives; for `vnc-ws` (and everything else) the noVNC path drives while the
|
|
237
|
+
// frame renderer stays idle. Same public shape either way.
|
|
238
|
+
if (transport === "relay-frames") return frameStream;
|
|
213
239
|
return { state, error, reconnect };
|
|
214
240
|
}
|
package/src/hooks/use-goal.ts
CHANGED
|
@@ -42,6 +42,8 @@ export type UseGoalResult = {
|
|
|
42
42
|
export function useGoal(sessionId: string | null | undefined, options: UseGoalOptions = {}): UseGoalResult {
|
|
43
43
|
const { client, workspaceId } = useOpenGeni(options);
|
|
44
44
|
const enabled = (options.enabled ?? true) && Boolean(sessionId);
|
|
45
|
+
const sharedEvents = options.events;
|
|
46
|
+
const sharedFeed = sharedEvents !== undefined;
|
|
45
47
|
const [goal, setGoal] = useState<SessionGoal | null>(null);
|
|
46
48
|
const [loading, setLoading] = useState(enabled);
|
|
47
49
|
const [error, setError] = useState<Error | null>(null);
|
|
@@ -87,6 +89,12 @@ export function useGoal(sessionId: string | null | undefined, options: UseGoalOp
|
|
|
87
89
|
setLoading(false);
|
|
88
90
|
return;
|
|
89
91
|
}
|
|
92
|
+
if (sharedFeed) {
|
|
93
|
+
setLoading(false);
|
|
94
|
+
return () => {
|
|
95
|
+
generation.current += 1;
|
|
96
|
+
};
|
|
97
|
+
}
|
|
90
98
|
setLoading(true);
|
|
91
99
|
void load();
|
|
92
100
|
const pollIntervalMs = options.pollIntervalMs;
|
|
@@ -100,12 +108,12 @@ export function useGoal(sessionId: string | null | undefined, options: UseGoalOp
|
|
|
100
108
|
clearInterval(timer);
|
|
101
109
|
generation.current += 1;
|
|
102
110
|
};
|
|
103
|
-
}, [load, enabled, workspaceId, sessionId, options.pollIntervalMs]);
|
|
111
|
+
}, [load, enabled, workspaceId, sessionId, options.pollIntervalMs, sharedFeed]);
|
|
104
112
|
|
|
105
113
|
const scheduleRefresh = useDebouncedCallback(() => void load());
|
|
106
114
|
useSessionEventTrigger(client, workspaceId, sessionId, isGoalEvent, scheduleRefresh, {
|
|
107
115
|
enabled,
|
|
108
|
-
...(
|
|
116
|
+
...(sharedEvents !== undefined ? { events: sharedEvents } : {}),
|
|
109
117
|
});
|
|
110
118
|
|
|
111
119
|
const pause = useCallback(
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import { useCallback, useState } from "react";
|
|
2
|
+
import { useOpenGeni, type ClientOverride } from "../provider";
|
|
3
|
+
import { useMutationRunner, usePolledValue } from "./internal";
|
|
4
|
+
import type { MachinesResponse, MachineView, MetricSample } from "../types/machines";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The slice of the SDK client the Machines surface needs. The method NAMES +
|
|
8
|
+
* SIGNATURES match M10's `OpenGeniClient` (`listMachines`, `machineMetricsSeries`)
|
|
9
|
+
* so the real SDK client satisfies this surface DIRECTLY for the read paths — no
|
|
10
|
+
* adapter needed. It is declared structurally (not a hard `OpenGeniClient` Pick)
|
|
11
|
+
* so a test/demo/Geni-frontend client can stand in, keeping the hook dual-consumer
|
|
12
|
+
* safe (works in apps/web AND the separate Geni frontend).
|
|
13
|
+
*
|
|
14
|
+
* The active-sandbox SWAP is now a typed SDK call (`swapActiveSandbox`, the M7
|
|
15
|
+
* user-authenticated REST equivalent of the `sandbox_swap` MCP tool). The real
|
|
16
|
+
* SDK client satisfies it structurally, so the default attach path is wired
|
|
17
|
+
* WHENEVER a sessionId is in scope (the swap is session-scoped). `attachMachine`
|
|
18
|
+
* stays an OPTIONAL escape hatch for a host that wants to supply its own swap
|
|
19
|
+
* adapter; when neither it nor a sessionId is present, attach is a no-op and the
|
|
20
|
+
* card hides the button.
|
|
21
|
+
*/
|
|
22
|
+
export type MachinesClientLike = {
|
|
23
|
+
/** GET /v1/workspaces/:ws/machines — the dashboard list + active pointer. */
|
|
24
|
+
listMachines: (workspaceId: string, options?: { sessionId?: string }) => Promise<MachinesResponse>;
|
|
25
|
+
/** GET .../machines/:enrollmentId/metrics/series — the downsampled history. */
|
|
26
|
+
machineMetricsSeries?: (
|
|
27
|
+
workspaceId: string,
|
|
28
|
+
enrollmentId: string,
|
|
29
|
+
options?: { window?: "15m" | "1h" | "6h" | "24h" },
|
|
30
|
+
) => Promise<MetricSample[]>;
|
|
31
|
+
/**
|
|
32
|
+
* POST .../sessions/:sessionId/active-sandbox — swap the session's active
|
|
33
|
+
* sandbox to a machine. The default swap path; the real SDK client provides it.
|
|
34
|
+
*/
|
|
35
|
+
swapActiveSandbox?: (
|
|
36
|
+
workspaceId: string,
|
|
37
|
+
sessionId: string,
|
|
38
|
+
request: { target: string },
|
|
39
|
+
) => Promise<unknown>;
|
|
40
|
+
/**
|
|
41
|
+
* Host-supplied swap adapter (an escape hatch). When present it wins over the
|
|
42
|
+
* default `swapActiveSandbox` path. Session-scoped, like the swap it backs.
|
|
43
|
+
*/
|
|
44
|
+
attachMachine?: (workspaceId: string, sessionId: string, sandboxId: string) => Promise<unknown>;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
export type UseMachinesOptions = ClientOverride & {
|
|
48
|
+
pollIntervalMs?: number | undefined;
|
|
49
|
+
enabled?: boolean | undefined;
|
|
50
|
+
/** Scope the list to a session (adds the synthetic Modal group box + pointer). */
|
|
51
|
+
sessionId?: string | undefined;
|
|
52
|
+
/**
|
|
53
|
+
* Override the client with one implementing `MachinesClientLike`. Defaults to
|
|
54
|
+
* the provider client cast to the surface (the real SDK client satisfies the
|
|
55
|
+
* read paths). An app supplies an adapter to wire `attachMachine` (the swap).
|
|
56
|
+
*/
|
|
57
|
+
machinesClient?: MachinesClientLike | undefined;
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
export type UseMachinesResult = {
|
|
61
|
+
machines: MachineView[];
|
|
62
|
+
activeSandboxId: string | null;
|
|
63
|
+
activeEpoch: number;
|
|
64
|
+
loading: boolean;
|
|
65
|
+
error: Error | null;
|
|
66
|
+
refresh: () => Promise<void>;
|
|
67
|
+
/** Attach/swap the session's active sandbox to a machine (returns the new pointer). */
|
|
68
|
+
attach: (sandboxId: string) => Promise<boolean>;
|
|
69
|
+
/** Whether the host wired an attach/swap path (drives the card affordance). */
|
|
70
|
+
canAttach: boolean;
|
|
71
|
+
/** Fetch a downsampled metric series for one enrolled machine. */
|
|
72
|
+
fetchSeries: (enrollmentId: string, window?: "15m" | "1h" | "6h" | "24h") => Promise<MetricSample[]>;
|
|
73
|
+
attaching: boolean;
|
|
74
|
+
/** The sandbox id of the in-flight attach (for per-card spinner gating). */
|
|
75
|
+
attachingSandboxId: string | null;
|
|
76
|
+
mutationError: Error | null;
|
|
77
|
+
clearMutationError: () => void;
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
const EMPTY: MachinesResponse = { activeSandboxId: null, activeEpoch: 0, machines: [] };
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* The workspace Machines fleet: the selfhosted enrollments + the session's Modal
|
|
84
|
+
* box, each with latest metrics + state, plus the active-sandbox pointer. Polls
|
|
85
|
+
* the M10 `GET /machines` endpoint and exposes attach/swap + a metric-series
|
|
86
|
+
* fetch. Renders via `<MachinesDashboard>`. Dual-consumer safe: it reads only the
|
|
87
|
+
* structural `MachinesClientLike` surface, so it works in apps/web AND the Geni
|
|
88
|
+
* frontend (each provides its own client/adapter).
|
|
89
|
+
*/
|
|
90
|
+
export function useMachines(options: UseMachinesOptions = {}): UseMachinesResult {
|
|
91
|
+
const { client, workspaceId } = useOpenGeni(options);
|
|
92
|
+
const machinesClient = (options.machinesClient ?? (client as unknown as MachinesClientLike)) satisfies MachinesClientLike;
|
|
93
|
+
const sessionId = options.sessionId;
|
|
94
|
+
|
|
95
|
+
const load = useCallback(async () => {
|
|
96
|
+
return await machinesClient.listMachines(workspaceId, sessionId ? { sessionId } : undefined);
|
|
97
|
+
}, [machinesClient, workspaceId, sessionId]);
|
|
98
|
+
|
|
99
|
+
const state = usePolledValue(load, { pollIntervalMs: options.pollIntervalMs, enabled: options.enabled });
|
|
100
|
+
const mutation = useMutationRunner();
|
|
101
|
+
// The sandbox id of the in-flight attach (drives the per-card spinner).
|
|
102
|
+
const [attachingSandboxId, setAttachingSandboxId] = useState<string | null>(null);
|
|
103
|
+
|
|
104
|
+
const data = state.data ?? EMPTY;
|
|
105
|
+
// The swap is session-scoped: a host adapter (`attachMachine`) wins; otherwise
|
|
106
|
+
// the default `swapActiveSandbox` path is wired whenever a sessionId is in
|
|
107
|
+
// scope. Either way attach needs a sessionId to point at.
|
|
108
|
+
const canAttach =
|
|
109
|
+
sessionId !== undefined &&
|
|
110
|
+
(typeof machinesClient.attachMachine === "function" ||
|
|
111
|
+
typeof machinesClient.swapActiveSandbox === "function");
|
|
112
|
+
|
|
113
|
+
const attach = useCallback(
|
|
114
|
+
async (sandboxId: string): Promise<boolean> => {
|
|
115
|
+
if (sessionId === undefined) return false;
|
|
116
|
+
const runSwap = machinesClient.attachMachine
|
|
117
|
+
? () => machinesClient.attachMachine!(workspaceId, sessionId, sandboxId)
|
|
118
|
+
: machinesClient.swapActiveSandbox
|
|
119
|
+
? () => machinesClient.swapActiveSandbox!(workspaceId, sessionId, { target: sandboxId })
|
|
120
|
+
: null;
|
|
121
|
+
if (!runSwap) return false;
|
|
122
|
+
setAttachingSandboxId(sandboxId);
|
|
123
|
+
const result = await mutation.run(async () => {
|
|
124
|
+
await runSwap();
|
|
125
|
+
return true;
|
|
126
|
+
});
|
|
127
|
+
setAttachingSandboxId(null);
|
|
128
|
+
if (result) await state.refresh();
|
|
129
|
+
return result === true;
|
|
130
|
+
},
|
|
131
|
+
[machinesClient, workspaceId, sessionId, mutation.run, state.refresh],
|
|
132
|
+
);
|
|
133
|
+
|
|
134
|
+
const fetchSeries = useCallback(
|
|
135
|
+
async (enrollmentId: string, window: "15m" | "1h" | "6h" | "24h" = "1h"): Promise<MetricSample[]> => {
|
|
136
|
+
if (!machinesClient.machineMetricsSeries) return [];
|
|
137
|
+
return await machinesClient.machineMetricsSeries(workspaceId, enrollmentId, { window });
|
|
138
|
+
},
|
|
139
|
+
[machinesClient, workspaceId],
|
|
140
|
+
);
|
|
141
|
+
|
|
142
|
+
return {
|
|
143
|
+
machines: data.machines,
|
|
144
|
+
activeSandboxId: data.activeSandboxId,
|
|
145
|
+
activeEpoch: data.activeEpoch,
|
|
146
|
+
loading: state.loading,
|
|
147
|
+
error: state.error,
|
|
148
|
+
refresh: state.refresh,
|
|
149
|
+
attach,
|
|
150
|
+
canAttach,
|
|
151
|
+
fetchSeries,
|
|
152
|
+
attaching: mutation.mutating,
|
|
153
|
+
attachingSandboxId,
|
|
154
|
+
mutationError: mutation.mutationError,
|
|
155
|
+
clearMutationError: mutation.clearMutationError,
|
|
156
|
+
};
|
|
157
|
+
}
|