@aiwg/cockpit 2026.6.3
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 +337 -0
- package/bridge/package.json +13 -0
- package/bridge/src/public/index.html +395 -0
- package/bridge/src/server.mjs +1132 -0
- package/bridge/src/smoke.mjs +158 -0
- package/contrib/aiwg-core.json +15 -0
- package/contrib/contribution.schema.json +69 -0
- package/desktop/README.md +42 -0
- package/desktop/src-tauri/Cargo.toml +17 -0
- package/desktop/src-tauri/build.rs +3 -0
- package/desktop/src-tauri/frontend/index.html +11 -0
- package/desktop/src-tauri/src/main.rs +55 -0
- package/desktop/src-tauri/tauri.conf.json +20 -0
- package/package.json +49 -0
- package/runtime-docs/README.md +38 -0
- package/shell-core/runtime.mjs +45 -0
- package/shell-core/smoke.mjs +36 -0
- package/vscode/README.md +25 -0
- package/vscode/extension.js +59 -0
- package/vscode/package.json +29 -0
- package/web/dist/assets/index-B5anpdS1.js +67 -0
- package/web/dist/assets/index-CP3BF6uZ.css +32 -0
- package/web/dist/index.html +14 -0
- package/web/index.html +13 -0
- package/web/package.json +31 -0
- package/web/src/App.test.tsx +237 -0
- package/web/src/App.tsx +255 -0
- package/web/src/api.ts +20 -0
- package/web/src/components/Actions.tsx +60 -0
- package/web/src/components/Approvals.tsx +62 -0
- package/web/src/components/CapabilitySearch.tsx +73 -0
- package/web/src/components/Explore.tsx +41 -0
- package/web/src/components/Inventory.tsx +124 -0
- package/web/src/components/LaunchInstanceModal.test.tsx +97 -0
- package/web/src/components/LaunchInstanceModal.tsx +236 -0
- package/web/src/components/Library.tsx +76 -0
- package/web/src/components/Running.tsx +60 -0
- package/web/src/components/Sessions.test.tsx +125 -0
- package/web/src/components/Sessions.tsx +189 -0
- package/web/src/components/StartSessionModal.test.tsx +86 -0
- package/web/src/components/StartSessionModal.tsx +168 -0
- package/web/src/components/Welcome.tsx +474 -0
- package/web/src/main.tsx +11 -0
- package/web/src/styles.css +254 -0
- package/web/src/types.ts +47 -0
- package/web/src/useDebounce.ts +10 -0
- package/web/src/useSession.ts +220 -0
- package/web/src/util.test.ts +30 -0
- package/web/src/util.ts +17 -0
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
2
|
+
import { api } from '../api';
|
|
3
|
+
import { fmtId, capRef } from '../util';
|
|
4
|
+
import { CapabilitySearch } from './CapabilitySearch';
|
|
5
|
+
import type { Instance, SessionInfo, CapabilityResult } from '../types';
|
|
6
|
+
import type { SessionApi } from '../useSession';
|
|
7
|
+
|
|
8
|
+
export function Sessions({ session, composer, setComposer, onRequestStart, refreshMs = 5_000 }: { session: SessionApi; composer: string; setComposer: (v: string) => void; onRequestStart: (instanceId?: string) => void; refreshMs?: number }) {
|
|
9
|
+
const [instances, setInstances] = useState<Instance[]>([]);
|
|
10
|
+
const [sessions, setSessions] = useState<SessionInfo[]>([]);
|
|
11
|
+
const [instId, setInstId] = useState('');
|
|
12
|
+
const [attachUrl, setAttachUrl] = useState('');
|
|
13
|
+
const [backendKey, setBackendKey] = useState('');
|
|
14
|
+
const [showPicker, setShowPicker] = useState(false);
|
|
15
|
+
const [endingSession, setEndingSession] = useState('');
|
|
16
|
+
const [sessionErr, setSessionErr] = useState('');
|
|
17
|
+
const inputRef = useRef<HTMLInputElement>(null);
|
|
18
|
+
|
|
19
|
+
const insertCap = (r: CapabilityResult) => {
|
|
20
|
+
const sep = composer && !composer.endsWith(' ') ? ' ' : '';
|
|
21
|
+
setComposer(composer + sep + capRef(r.type, r.name));
|
|
22
|
+
setShowPicker(false);
|
|
23
|
+
inputRef.current?.focus();
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
const refreshInventory = useCallback(() => {
|
|
27
|
+
return api<{ instances: Instance[] }>('/api/inventory')
|
|
28
|
+
.then((d) => {
|
|
29
|
+
const sessionable = dedupeInstances(d.instances).filter((i) => i.state === 'running' && i.session_backends?.some((b) => b.available));
|
|
30
|
+
setInstances(sessionable);
|
|
31
|
+
setInstId((currentId) => {
|
|
32
|
+
if (currentId && sessionable.some((i) => i.id === currentId)) return currentId;
|
|
33
|
+
return sessionable[0]?.id ?? '';
|
|
34
|
+
});
|
|
35
|
+
setBackendKey((currentBackend) => {
|
|
36
|
+
if (currentBackend && sessionable.some((i) => i.session_backends.some((b) => `${b.mode}:${b.backend}` === currentBackend && b.available))) return currentBackend;
|
|
37
|
+
const firstBackend = sessionable[0]?.session_backends.find((b) => b.available) ?? sessionable[0]?.session_backends[0];
|
|
38
|
+
return firstBackend ? `${firstBackend.mode}:${firstBackend.backend}` : '';
|
|
39
|
+
});
|
|
40
|
+
})
|
|
41
|
+
.catch(() => {});
|
|
42
|
+
}, []);
|
|
43
|
+
|
|
44
|
+
useEffect(() => {
|
|
45
|
+
refreshInventory();
|
|
46
|
+
const timer = window.setInterval(refreshInventory, refreshMs);
|
|
47
|
+
return () => window.clearInterval(timer);
|
|
48
|
+
}, [refreshInventory, refreshMs]);
|
|
49
|
+
|
|
50
|
+
const loadSessions = useCallback((id: string) => {
|
|
51
|
+
if (!id) return;
|
|
52
|
+
api<{ sessions: SessionInfo[] }>(`/api/sessions?instance=${encodeURIComponent(id)}`)
|
|
53
|
+
.then((d) => {
|
|
54
|
+
const nextSessions = d.sessions ?? [];
|
|
55
|
+
setSessions(nextSessions);
|
|
56
|
+
setAttachUrl((currentUrl) => {
|
|
57
|
+
if (currentUrl && nextSessions.some((s) => s.attach_url === currentUrl)) return currentUrl;
|
|
58
|
+
return nextSessions[0]?.attach_url ?? '';
|
|
59
|
+
});
|
|
60
|
+
})
|
|
61
|
+
.catch((e) => { setSessions([]); setAttachUrl(''); setSessionErr((e as Error).message); });
|
|
62
|
+
}, []);
|
|
63
|
+
useEffect(() => { loadSessions(instId); }, [instId, loadSessions]);
|
|
64
|
+
|
|
65
|
+
const send = () => { if (session.sendInput(composer)) setComposer(''); };
|
|
66
|
+
const attached = session.state.attached;
|
|
67
|
+
const requestedReplayRole = session.state.role === 'controller' ? 'controller' : 'observer';
|
|
68
|
+
const current = instances.find((i) => i.id === instId);
|
|
69
|
+
const backends = current?.session_backends ?? [];
|
|
70
|
+
const selectedBackend = backends.find((b) => `${b.mode}:${b.backend}` === backendKey) ?? backends.find((b) => b.available) ?? backends[0];
|
|
71
|
+
const selectedSession = sessions.find((s) => s.attach_url === attachUrl);
|
|
72
|
+
useEffect(() => {
|
|
73
|
+
if (!session.state.url) return;
|
|
74
|
+
const sessionStillListed = sessions.some((s) => s.attach_url === session.state.url);
|
|
75
|
+
if (sessions.length && !sessionStillListed) session.detach();
|
|
76
|
+
}, [session.state.url, session.detach, sessions]);
|
|
77
|
+
useEffect(() => {
|
|
78
|
+
if (!current) return;
|
|
79
|
+
const valid = current.session_backends.some((b) => `${b.mode}:${b.backend}` === backendKey);
|
|
80
|
+
if (!valid) {
|
|
81
|
+
const next = current.session_backends.find((b) => b.available) ?? current.session_backends[0];
|
|
82
|
+
setBackendKey(next ? `${next.mode}:${next.backend}` : '');
|
|
83
|
+
}
|
|
84
|
+
}, [backendKey, current]);
|
|
85
|
+
// Starting now routes through the shared picker (#1640/#1641) so this tab and the
|
|
86
|
+
// dashboard verb share one params/clobber/error path. The selects below remain for
|
|
87
|
+
// attaching to / observing / driving sessions that already exist.
|
|
88
|
+
const endSelectedSession = async () => {
|
|
89
|
+
if (!current || !selectedSession) return;
|
|
90
|
+
const label = selectedSession.session_name ?? selectedSession.id;
|
|
91
|
+
if (!confirm(`End session ${label}? This closes the PTY and detaches connected views.`)) return;
|
|
92
|
+
setEndingSession(selectedSession.id);
|
|
93
|
+
setSessionErr('');
|
|
94
|
+
try {
|
|
95
|
+
await api(`/api/instances/${encodeURIComponent(current.id)}/sessions/${encodeURIComponent(selectedSession.id)}`, { method: 'DELETE' });
|
|
96
|
+
if (session.state.url === selectedSession.attach_url) session.detach();
|
|
97
|
+
await loadSessions(current.id);
|
|
98
|
+
} catch (e) {
|
|
99
|
+
setSessionErr((e as Error).message);
|
|
100
|
+
} finally {
|
|
101
|
+
setEndingSession('');
|
|
102
|
+
}
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
return (
|
|
106
|
+
<>
|
|
107
|
+
<p className="hint">
|
|
108
|
+
<strong>Workspace.</strong> Attach to a single session on one instance to observe or drive it. Start a
|
|
109
|
+
new session above, or pick a live task from the <strong>Running</strong> fleet board and open it here.
|
|
110
|
+
</p>
|
|
111
|
+
<div className="controls">
|
|
112
|
+
<label htmlFor="sel-instance">Instance</label>
|
|
113
|
+
<select id="sel-instance" value={instId} onChange={(e) => setInstId(e.target.value)}>
|
|
114
|
+
{instances.map((i) => <option key={i.id} value={i.id}>{i.launch_context?.name ?? fmtId(i.id)} · {i.loadout}</option>)}
|
|
115
|
+
{!instances.length && <option value="">— no session-capable running instances —</option>}
|
|
116
|
+
</select>
|
|
117
|
+
<label htmlFor="sel-backend">Mode</label>
|
|
118
|
+
<select id="sel-backend" value={backendKey} onChange={(e) => setBackendKey(e.target.value)}>
|
|
119
|
+
{backends.length
|
|
120
|
+
? backends.map((b) => <option key={`${b.mode}:${b.backend}`} value={`${b.mode}:${b.backend}`} disabled={!b.available}>{b.mode} · {b.backend}{b.available ? '' : ` — ${b.reason ?? 'unsupported'}`}</option>)
|
|
121
|
+
: <option value="">— not advertised —</option>}
|
|
122
|
+
</select>
|
|
123
|
+
<button onClick={() => onRequestStart(instId)}>Start…</button>
|
|
124
|
+
<label htmlFor="sel-session">Session</label>
|
|
125
|
+
<select id="sel-session" value={attachUrl} onChange={(e) => setAttachUrl(e.target.value)}>
|
|
126
|
+
{sessions.length
|
|
127
|
+
? sessions.map((s) => <option key={s.id} value={s.attach_url}>{s.id} · {s.mode ?? s.session_class ?? 'managed'}/{s.backend ?? s.session_backend ?? 'tmux'} · {current?.launch_context?.name ?? fmtId(instId)}</option>)
|
|
128
|
+
: <option value="">— no sessions —</option>}
|
|
129
|
+
</select>
|
|
130
|
+
<button disabled={!attachUrl || (attached && session.state.role === 'observer')} onClick={() => session.attach(attachUrl, false, 'observer')}>Observe</button>
|
|
131
|
+
<button disabled={!attachUrl || selectedBackend?.drive === false || (attached && session.state.role === 'controller')} onClick={() => session.attach(attachUrl, false, 'controller')}>
|
|
132
|
+
{attached && session.state.role === 'observer' ? 'Take Control' : 'Drive'}
|
|
133
|
+
</button>
|
|
134
|
+
<button disabled={!attached || selectedBackend?.keyframe === false} onClick={session.requestKeyframe}>Keyframe</button>
|
|
135
|
+
<button disabled={!attached} onClick={() => session.replay(attachUrl, requestedReplayRole)}>Reattach + replay</button>
|
|
136
|
+
<button
|
|
137
|
+
disabled={!selectedSession || endingSession === selectedSession?.id}
|
|
138
|
+
onClick={endSelectedSession}
|
|
139
|
+
title="Terminate the selected PTY session on the agent"
|
|
140
|
+
>
|
|
141
|
+
{endingSession === selectedSession?.id ? 'Ending…' : 'End Session'}
|
|
142
|
+
</button>
|
|
143
|
+
<button disabled={!attached} onClick={session.detach}>Detach</button>
|
|
144
|
+
{session.state.role && <span className={`badge ${session.state.role}`}>{session.state.role}</span>}
|
|
145
|
+
</div>
|
|
146
|
+
{sessionErr && <p className="err">Session action failed: {sessionErr}</p>}
|
|
147
|
+
{current && (
|
|
148
|
+
<p className="hint">
|
|
149
|
+
{current.runtime_posture.label} · {current.transport.label} ({current.transport.mode}) · attach starts as observe unless control is explicitly granted.
|
|
150
|
+
{attached && session.state.role === 'observer' ? ' Click Take Control to re-attach with write access.' : ''}
|
|
151
|
+
{selectedBackend && !selectedBackend.available ? ` ${selectedBackend.reason ?? 'Selected backend is unavailable.'}` : ''}
|
|
152
|
+
</p>
|
|
153
|
+
)}
|
|
154
|
+
<div className="terminal" ref={session.openTerminal} role="log" aria-label="Session output" />
|
|
155
|
+
{showPicker && (
|
|
156
|
+
<div className="picker">
|
|
157
|
+
<p className="hint" style={{ marginTop: 0 }}>Pick a capability to insert into the command — then Send to inject it. (Lookup is UI; the agent runs it.)</p>
|
|
158
|
+
<CapabilitySearch compact autoFocus onPick={insertCap} />
|
|
159
|
+
</div>
|
|
160
|
+
)}
|
|
161
|
+
<div className="inputrow">
|
|
162
|
+
<button aria-label="Insert a capability" title="Insert a capability (search)" onClick={() => setShowPicker((v) => !v)}>+</button>
|
|
163
|
+
<input
|
|
164
|
+
ref={inputRef}
|
|
165
|
+
value={composer}
|
|
166
|
+
onChange={(e) => setComposer(e.target.value)}
|
|
167
|
+
onKeyDown={(e) => { if (e.key === 'Enter') send(); }}
|
|
168
|
+
placeholder={session.isController ? 'Type to drive the session…' : 'Observing — input is read-only'}
|
|
169
|
+
disabled={!session.isController}
|
|
170
|
+
aria-label="Session input"
|
|
171
|
+
/>
|
|
172
|
+
<button disabled={!session.isController} onClick={send}>Send</button>
|
|
173
|
+
</div>
|
|
174
|
+
<p className="hint">
|
|
175
|
+
Pick or start a session, then Attach. Cockpit requests observe-first access; drive/control is explicit and denial reasons stay visible through the session stream.
|
|
176
|
+
Actions inject their command right here.
|
|
177
|
+
</p>
|
|
178
|
+
</>
|
|
179
|
+
);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function dedupeInstances(instances: Instance[]) {
|
|
183
|
+
const seen = new Set<string>();
|
|
184
|
+
return instances.filter((instance) => {
|
|
185
|
+
if (seen.has(instance.id)) return false;
|
|
186
|
+
seen.add(instance.id);
|
|
187
|
+
return true;
|
|
188
|
+
});
|
|
189
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import { render, screen, cleanup, waitFor, fireEvent } from '@testing-library/react';
|
|
3
|
+
import { StartSessionModal } from './StartSessionModal';
|
|
4
|
+
import type { SessionApi } from '../useSession';
|
|
5
|
+
|
|
6
|
+
// Minimal routed fetch mock: inventory on open, POST session on start.
|
|
7
|
+
const INSTANCE = {
|
|
8
|
+
id: 'inst-aaaaaaaa-1111', runtime: 'container', loadout: 'agentic-dev', state: 'running', tenant: 'default',
|
|
9
|
+
card_url: '', runtime_posture: { kind: 'container', isolation: 'shared-kernel', label: 'container' },
|
|
10
|
+
host_daemon: { status: 'unknown' }, transport: { mode: 'mtls', trust: 'secure', label: 'mTLS', source: 't' },
|
|
11
|
+
launch_context: { loadout: 'agentic-dev' },
|
|
12
|
+
session_backends: [{ mode: 'managed', backend: 'tmux', available: true, drive: true }],
|
|
13
|
+
};
|
|
14
|
+
function mockFetch(postImpl?: () => Response | Promise<Response>) {
|
|
15
|
+
return vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
|
|
16
|
+
const url = String(input);
|
|
17
|
+
const ok = (body: unknown) => new Response(JSON.stringify(body), { status: 200, headers: { 'content-type': 'application/json' } });
|
|
18
|
+
if (url.includes('/api/inventory')) return ok({ instances: [INSTANCE] });
|
|
19
|
+
if (url.includes('/sessions') && init?.method === 'POST') {
|
|
20
|
+
return postImpl ? postImpl() : ok({ id: 'sess-x', attach_url: 'ws://x/agents/i/sessions/sess-x/attach' });
|
|
21
|
+
}
|
|
22
|
+
return new Response('{}', { status: 404 });
|
|
23
|
+
}) as unknown as typeof fetch;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function stubSession(attached = false): SessionApi {
|
|
27
|
+
return {
|
|
28
|
+
state: { attached, role: null, url: null },
|
|
29
|
+
attach: vi.fn(), detach: vi.fn(), replay: vi.fn(), requestKeyframe: vi.fn(),
|
|
30
|
+
sendInput: vi.fn(), openTerminal: vi.fn(), isController: false,
|
|
31
|
+
} as unknown as SessionApi;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
beforeEach(() => { (window as unknown as { __COCKPIT_TOKEN__: string }).__COCKPIT_TOKEN__ = 't'; });
|
|
35
|
+
afterEach(() => { cleanup(); vi.restoreAllMocks(); });
|
|
36
|
+
|
|
37
|
+
describe('StartSessionModal (#1640/#1641)', () => {
|
|
38
|
+
it('renders nothing when closed', () => {
|
|
39
|
+
globalThis.fetch = mockFetch();
|
|
40
|
+
const { container } = render(<StartSessionModal open={false} onClose={() => {}} session={stubSession()} onStarted={() => {}} />);
|
|
41
|
+
expect(container.querySelector('[role="dialog"]')).toBeNull();
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('surfaces the selected instance loadout when opened', async () => {
|
|
45
|
+
globalThis.fetch = mockFetch();
|
|
46
|
+
render(<StartSessionModal open onClose={() => {}} session={stubSession()} onStarted={() => {}} />);
|
|
47
|
+
expect(await screen.findByRole('dialog', { name: /start a session/i })).toBeTruthy();
|
|
48
|
+
expect(screen.getByText('Instance loadout')).toBeTruthy();
|
|
49
|
+
expect(screen.getByText('agentic-dev')).toBeTruthy();
|
|
50
|
+
expect(screen.queryByRole('option', { name: /Security Audit/ })).toBeNull();
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('explains that attached sessions stay running when starting another session', async () => {
|
|
54
|
+
globalThis.fetch = mockFetch();
|
|
55
|
+
render(<StartSessionModal open onClose={() => {}} session={stubSession(true)} onStarted={() => {}} />);
|
|
56
|
+
expect(await screen.findByText(/existing sessions keep running/i)).toBeTruthy();
|
|
57
|
+
expect(screen.getByRole('button', { name: /start another session/i })).toBeTruthy();
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('starts: POSTs with explicit params, attaches, then closes', async () => {
|
|
61
|
+
globalThis.fetch = mockFetch();
|
|
62
|
+
const session = stubSession();
|
|
63
|
+
const onStarted = vi.fn(), onClose = vi.fn();
|
|
64
|
+
render(<StartSessionModal open onClose={onClose} session={session} onStarted={onStarted} />);
|
|
65
|
+
const startBtn = await screen.findByRole('button', { name: /start session/i });
|
|
66
|
+
await waitFor(() => expect((startBtn as HTMLButtonElement).disabled).toBe(false));
|
|
67
|
+
fireEvent.click(startBtn);
|
|
68
|
+
await waitFor(() => expect(session.attach).toHaveBeenCalledWith(expect.stringContaining('/attach'), false, 'observer'));
|
|
69
|
+
const postCall = (globalThis.fetch as unknown as ReturnType<typeof vi.fn>).mock.calls.find((c) => String(c[0]).includes('/sessions') && c[1]?.method === 'POST');
|
|
70
|
+
expect(String(postCall?.[0])).toMatch(/mode=managed&backend=tmux/);
|
|
71
|
+
expect(String(postCall?.[0])).not.toContain('loadout=');
|
|
72
|
+
expect(onStarted).toHaveBeenCalled();
|
|
73
|
+
expect(onClose).toHaveBeenCalled();
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('shows the failure inline instead of an alert()', async () => {
|
|
77
|
+
globalThis.fetch = mockFetch(() => new Response('{"error":"boom"}', { status: 500 }));
|
|
78
|
+
const alertSpy = vi.spyOn(window, 'alert').mockImplementation(() => {});
|
|
79
|
+
render(<StartSessionModal open onClose={() => {}} session={stubSession()} onStarted={() => {}} />);
|
|
80
|
+
const startBtn = await screen.findByRole('button', { name: /start session/i });
|
|
81
|
+
await waitFor(() => expect((startBtn as HTMLButtonElement).disabled).toBe(false));
|
|
82
|
+
fireEvent.click(startBtn);
|
|
83
|
+
await waitFor(() => expect(screen.getByText(/→ 500/)).toBeTruthy());
|
|
84
|
+
expect(alertSpy).not.toHaveBeenCalled();
|
|
85
|
+
});
|
|
86
|
+
});
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import { useEffect, useState } from 'react';
|
|
2
|
+
import { api } from '../api';
|
|
3
|
+
import { fmtId } from '../util';
|
|
4
|
+
import type { Instance } from '../types';
|
|
5
|
+
import type { SessionApi } from '../useSession';
|
|
6
|
+
|
|
7
|
+
// The single home for starting a session (#1640/#1641). Both the dashboard "Start a
|
|
8
|
+
// session" verb and the Sessions-tab Start button open this picker, so neither launches
|
|
9
|
+
// blind with defaults. Operator picks instance · runtime · backend · posture,
|
|
10
|
+
// confirms, and only then do we POST with explicit params and attach. Failures render
|
|
11
|
+
// inline here — never an alert() that reads as "nothing happened".
|
|
12
|
+
interface Props {
|
|
13
|
+
open: boolean;
|
|
14
|
+
onClose: () => void;
|
|
15
|
+
session: SessionApi;
|
|
16
|
+
onStarted: () => void; // switch to the Sessions workspace after attach
|
|
17
|
+
initialInstanceId?: string; // pre-select when opened from a specific instance/board
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function StartSessionModal({ open, onClose, session, onStarted, initialInstanceId }: Props) {
|
|
21
|
+
const [instances, setInstances] = useState<Instance[]>([]);
|
|
22
|
+
const [instId, setInstId] = useState('');
|
|
23
|
+
const [loadoutId, setLoadoutId] = useState('');
|
|
24
|
+
const [backendKey, setBackendKey] = useState('');
|
|
25
|
+
const [posture, setPosture] = useState<'observer' | 'controller'>('observer');
|
|
26
|
+
const [busy, setBusy] = useState(false);
|
|
27
|
+
const [err, setErr] = useState('');
|
|
28
|
+
|
|
29
|
+
// Load instances when the picker opens (not on mount, so a
|
|
30
|
+
// closed modal never fetches — keeps the app shell test/inert).
|
|
31
|
+
useEffect(() => {
|
|
32
|
+
if (!open) return;
|
|
33
|
+
setErr('');
|
|
34
|
+
let cancelled = false;
|
|
35
|
+
(async () => {
|
|
36
|
+
try {
|
|
37
|
+
const inv = await api<{ instances: Instance[] }>('/api/inventory');
|
|
38
|
+
if (cancelled) return;
|
|
39
|
+
const sessionable = dedupeInstances(inv.instances).filter((i) => i.state === 'running');
|
|
40
|
+
setInstances(sessionable);
|
|
41
|
+
const pick = sessionable.find((i) => i.id === initialInstanceId)
|
|
42
|
+
?? sessionable.find((i) => i.session_backends?.some((b) => b.available))
|
|
43
|
+
?? sessionable[0];
|
|
44
|
+
if (pick) {
|
|
45
|
+
setInstId(pick.id);
|
|
46
|
+
setLoadoutId(pick.launch_context?.loadout ?? pick.loadout ?? '');
|
|
47
|
+
const first = pick.session_backends?.find((b) => b.available) ?? pick.session_backends?.[0];
|
|
48
|
+
setBackendKey(first ? `${first.mode}:${first.backend}` : '');
|
|
49
|
+
} else {
|
|
50
|
+
setErr('No stack connected — start an executor first.');
|
|
51
|
+
}
|
|
52
|
+
} catch (e) {
|
|
53
|
+
if (!cancelled) setErr((e as Error).message);
|
|
54
|
+
}
|
|
55
|
+
})();
|
|
56
|
+
return () => { cancelled = true; };
|
|
57
|
+
}, [open, initialInstanceId]);
|
|
58
|
+
|
|
59
|
+
const current = instances.find((i) => i.id === instId);
|
|
60
|
+
const backends = current?.session_backends ?? [];
|
|
61
|
+
const selectedBackend = backends.find((b) => `${b.mode}:${b.backend}` === backendKey)
|
|
62
|
+
?? backends.find((b) => b.available) ?? backends[0];
|
|
63
|
+
|
|
64
|
+
// When the chosen instance changes, default loadout + backend to that instance.
|
|
65
|
+
useEffect(() => {
|
|
66
|
+
if (!current) return;
|
|
67
|
+
setLoadoutId(current.launch_context?.loadout ?? current.loadout ?? '');
|
|
68
|
+
const first = current.session_backends?.find((b) => b.available) ?? current.session_backends?.[0];
|
|
69
|
+
setBackendKey(first ? `${first.mode}:${first.backend}` : '');
|
|
70
|
+
}, [instId]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
71
|
+
|
|
72
|
+
if (!open) return null;
|
|
73
|
+
|
|
74
|
+
const replacing = session.state.attached;
|
|
75
|
+
const canStart = !busy && !!current && !!selectedBackend?.available;
|
|
76
|
+
|
|
77
|
+
const start = async () => {
|
|
78
|
+
if (!current || !selectedBackend) return;
|
|
79
|
+
setBusy(true); setErr('');
|
|
80
|
+
try {
|
|
81
|
+
const qs = new URLSearchParams({ mode: selectedBackend.mode, backend: selectedBackend.backend });
|
|
82
|
+
const s = await api<{ id: string; attach_url: string }>(
|
|
83
|
+
`/api/instances/${encodeURIComponent(current.id)}/sessions?${qs}`, { method: 'POST' },
|
|
84
|
+
);
|
|
85
|
+
session.attach(s.attach_url, false, posture);
|
|
86
|
+
onStarted();
|
|
87
|
+
onClose();
|
|
88
|
+
} catch (e) {
|
|
89
|
+
setErr((e as Error).message); // inline — the operator sees exactly why
|
|
90
|
+
setBusy(false);
|
|
91
|
+
}
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
return (
|
|
95
|
+
<div className="modal-backdrop" role="presentation" onClick={onClose}>
|
|
96
|
+
<div
|
|
97
|
+
className="modal"
|
|
98
|
+
role="dialog"
|
|
99
|
+
aria-modal="true"
|
|
100
|
+
aria-labelledby="start-session-title"
|
|
101
|
+
onClick={(e) => e.stopPropagation()}
|
|
102
|
+
onKeyDown={(e) => { if (e.key === 'Escape') onClose(); }}
|
|
103
|
+
>
|
|
104
|
+
<h2 id="start-session-title">Start a session</h2>
|
|
105
|
+
|
|
106
|
+
{err && <p className="err">{err}</p>}
|
|
107
|
+
{replacing && (
|
|
108
|
+
<p className="hint warn">You have an attached view. Starting creates another managed session and switches Cockpit to it; existing sessions keep running.</p>
|
|
109
|
+
)}
|
|
110
|
+
|
|
111
|
+
<div className="form-grid">
|
|
112
|
+
<label htmlFor="ss-instance">Instance</label>
|
|
113
|
+
<select id="ss-instance" value={instId} onChange={(e) => setInstId(e.target.value)}>
|
|
114
|
+
{instances.map((i) => (
|
|
115
|
+
<option key={i.id} value={i.id}>{fmtId(i.id)} · {i.runtime} · {i.state}</option>
|
|
116
|
+
))}
|
|
117
|
+
</select>
|
|
118
|
+
|
|
119
|
+
<label>Runtime</label>
|
|
120
|
+
<span className="ro">{current ? `${current.runtime_posture.label} (${current.runtime})` : '—'}</span>
|
|
121
|
+
|
|
122
|
+
<label>Instance loadout</label>
|
|
123
|
+
<span className="ro">{loadoutId || 'unknown'}{current?.launch_context?.image_ref ? ` · ${current.launch_context.image_ref}` : ''}</span>
|
|
124
|
+
|
|
125
|
+
<label htmlFor="ss-backend">Backend</label>
|
|
126
|
+
<select id="ss-backend" value={backendKey} onChange={(e) => setBackendKey(e.target.value)}>
|
|
127
|
+
{backends.length
|
|
128
|
+
? backends.map((b) => (
|
|
129
|
+
<option key={`${b.mode}:${b.backend}`} value={`${b.mode}:${b.backend}`} disabled={!b.available}>
|
|
130
|
+
{b.mode} · {b.backend}{b.available ? '' : ` — ${b.reason ?? 'unsupported'}`}
|
|
131
|
+
</option>))
|
|
132
|
+
: <option value="">— not advertised —</option>}
|
|
133
|
+
</select>
|
|
134
|
+
|
|
135
|
+
<label htmlFor="ss-posture">Posture</label>
|
|
136
|
+
<select
|
|
137
|
+
id="ss-posture"
|
|
138
|
+
value={posture}
|
|
139
|
+
onChange={(e) => setPosture(e.target.value as 'observer' | 'controller')}
|
|
140
|
+
>
|
|
141
|
+
<option value="observer">Observe (read-only)</option>
|
|
142
|
+
<option value="controller" disabled={selectedBackend?.drive === false}>Drive (control)</option>
|
|
143
|
+
</select>
|
|
144
|
+
</div>
|
|
145
|
+
|
|
146
|
+
{selectedBackend && !selectedBackend.available && (
|
|
147
|
+
<p className="hint">{selectedBackend.reason ?? 'Selected backend is unavailable on this instance.'}</p>
|
|
148
|
+
)}
|
|
149
|
+
|
|
150
|
+
<div className="modal-actions">
|
|
151
|
+
<button onClick={onClose} disabled={busy}>Cancel</button>
|
|
152
|
+
<button className="cta" onClick={start} disabled={!canStart}>
|
|
153
|
+
{busy ? 'Starting…' : replacing ? 'Start another session' : 'Start session'}
|
|
154
|
+
</button>
|
|
155
|
+
</div>
|
|
156
|
+
</div>
|
|
157
|
+
</div>
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function dedupeInstances(instances: Instance[]) {
|
|
162
|
+
const seen = new Set<string>();
|
|
163
|
+
return instances.filter((instance) => {
|
|
164
|
+
if (seen.has(instance.id)) return false;
|
|
165
|
+
seen.add(instance.id);
|
|
166
|
+
return true;
|
|
167
|
+
});
|
|
168
|
+
}
|