@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
package/web/src/api.ts
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
// Authed control-surface client. The Bridge injects window.__COCKPIT_TOKEN__ into
|
|
2
|
+
// the served page; every /api call carries it as a bearer token.
|
|
3
|
+
declare global {
|
|
4
|
+
interface Window { __COCKPIT_TOKEN__?: string }
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
const TOKEN = (typeof window !== 'undefined' && window.__COCKPIT_TOKEN__) || '';
|
|
8
|
+
|
|
9
|
+
export function apiRaw(path: string, opts: RequestInit = {}): Promise<Response> {
|
|
10
|
+
return fetch(path, { ...opts, headers: { ...(opts.headers || {}), authorization: `Bearer ${TOKEN}` } });
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export async function api<T = unknown>(path: string, opts: RequestInit = {}): Promise<T> {
|
|
14
|
+
const r = await apiRaw(path, opts);
|
|
15
|
+
if (!r.ok) throw new Error(`${path} → ${r.status}`);
|
|
16
|
+
if (r.status === 204) return {} as T;
|
|
17
|
+
return r.json() as Promise<T>;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export { TOKEN };
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { useEffect, useState } from 'react';
|
|
2
|
+
import { api } from '../api';
|
|
3
|
+
import type { ContribAction } from '../types';
|
|
4
|
+
import type { SessionApi } from '../useSession';
|
|
5
|
+
|
|
6
|
+
// Actions INJECT a command into an agentic session — the Cockpit never runs the CLI.
|
|
7
|
+
export function Actions({ session, setComposer, goSessions }: { session: SessionApi; setComposer: (v: string) => void; goSessions: () => void }) {
|
|
8
|
+
const [actions, setActions] = useState<ContribAction[]>([]);
|
|
9
|
+
const [err, setErr] = useState('');
|
|
10
|
+
const [note, setNote] = useState('');
|
|
11
|
+
|
|
12
|
+
useEffect(() => {
|
|
13
|
+
api<{ actions: ContribAction[] }>('/api/contributions').then((d) => setActions(d.actions)).catch((e) => setErr((e as Error).message));
|
|
14
|
+
}, []);
|
|
15
|
+
|
|
16
|
+
const inject = (a: ContribAction) => {
|
|
17
|
+
let command = a.inject.command;
|
|
18
|
+
if (a.inject.needs_args) {
|
|
19
|
+
const extra = prompt(`Arguments for ${a.title}${a.inject.args_hint ? ` (${a.inject.args_hint})` : ''}:`, '');
|
|
20
|
+
if (extra === null) return;
|
|
21
|
+
if (extra.trim()) command += ' ' + extra.trim();
|
|
22
|
+
}
|
|
23
|
+
if (session.isController && session.sendInput(command)) {
|
|
24
|
+
setNote(`Injected "${command}" into the attached session — the agent runs it.`);
|
|
25
|
+
} else {
|
|
26
|
+
setComposer(command); // prefill the session composer; attach (drive) or start one, then Send
|
|
27
|
+
setNote(`Ready to inject "${command}". Attach to a session (drive) or start one, then Send.`);
|
|
28
|
+
}
|
|
29
|
+
goSessions(); // actions target the sessions surface
|
|
30
|
+
};
|
|
31
|
+
const copyCommand = async (a: ContribAction) => {
|
|
32
|
+
await navigator.clipboard?.writeText(a.inject.command);
|
|
33
|
+
setNote(`Copied "${a.inject.command}" to the clipboard.`);
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
return (
|
|
37
|
+
<>
|
|
38
|
+
<p className="hint">
|
|
39
|
+
Actions are <strong>contributed declaratively</strong>. Clicking one <strong>injects a command into an agentic session</strong>
|
|
40
|
+
{' '}(focused, else it offers a new one); the agent runs it. The Cockpit never runs the CLI — agents do.
|
|
41
|
+
</p>
|
|
42
|
+
{err && <p className="err">{err}</p>}
|
|
43
|
+
<div className="controls" role="group" aria-label="Contributed actions">
|
|
44
|
+
{actions.length
|
|
45
|
+
? actions.map((a) => (
|
|
46
|
+
<span className="action-cluster" key={a.id}>
|
|
47
|
+
<button className="act" title={`injects: ${a.inject.command}`} onClick={() => inject(a)}>
|
|
48
|
+
{a.icon ? a.icon + ' ' : ''}{a.title}
|
|
49
|
+
</button>
|
|
50
|
+
<button className="copy-command" aria-label={`Copy CLI command for ${a.title}`} title={a.inject.command} onClick={() => copyCommand(a)}>
|
|
51
|
+
Copy CLI
|
|
52
|
+
</button>
|
|
53
|
+
</span>
|
|
54
|
+
))
|
|
55
|
+
: <p className="empty">No contributed actions.</p>}
|
|
56
|
+
</div>
|
|
57
|
+
<p className="empty">{note || 'Output appears in the session terminal — actions drop a command into a session and the agent executes it.'}</p>
|
|
58
|
+
</>
|
|
59
|
+
);
|
|
60
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { useCallback, useEffect, useState } from 'react';
|
|
2
|
+
import { api } from '../api';
|
|
3
|
+
import { fmtId } from '../util';
|
|
4
|
+
import type { Approval, ResponseNeeded } from '../types';
|
|
5
|
+
|
|
6
|
+
export function Approvals({ responses = [], goSessions }: { responses?: ResponseNeeded[]; goSessions?: () => void }) {
|
|
7
|
+
const [items, setItems] = useState<Approval[] | null>(null);
|
|
8
|
+
const [err, setErr] = useState('');
|
|
9
|
+
|
|
10
|
+
const load = useCallback(() => {
|
|
11
|
+
api<{ approvals: Approval[] }>('/api/approvals?status=pending')
|
|
12
|
+
.then((d) => { setItems(d.approvals); setErr(''); }).catch((e) => setErr((e as Error).message));
|
|
13
|
+
}, []);
|
|
14
|
+
useEffect(() => { load(); }, [load]);
|
|
15
|
+
|
|
16
|
+
const decide = (id: string, decision: 'approve' | 'deny') =>
|
|
17
|
+
api(`/api/approvals/${encodeURIComponent(id)}?decision=${decision}`, { method: 'POST' })
|
|
18
|
+
.then(load).catch((e) => alert((e as Error).message));
|
|
19
|
+
|
|
20
|
+
return (
|
|
21
|
+
<>
|
|
22
|
+
<p className="hint">Unified response-needed inbox — formal <code>hitl-prompt/v1</code> approvals, provider prompts, and agent sessions waiting for human input.</p>
|
|
23
|
+
{err && <p className="err">{err}</p>}
|
|
24
|
+
{responses.length > 0 && (
|
|
25
|
+
<section className="response-needed" aria-label="Sessions waiting for response">
|
|
26
|
+
<h2>Response Needed</h2>
|
|
27
|
+
{responses.map((r) => (
|
|
28
|
+
<article key={r.id} className="response-item">
|
|
29
|
+
<div>
|
|
30
|
+
<strong>{r.source === 'pty' ? 'Interactive session prompt' : r.source}</strong>
|
|
31
|
+
<p>{r.prompt}</p>
|
|
32
|
+
<span><code>{fmtId(r.instance_id)}</code> · {r.status}</span>
|
|
33
|
+
</div>
|
|
34
|
+
{goSessions && <button onClick={goSessions}>Open Session</button>}
|
|
35
|
+
</article>
|
|
36
|
+
))}
|
|
37
|
+
</section>
|
|
38
|
+
)}
|
|
39
|
+
{!items ? <p className="empty">Loading…</p>
|
|
40
|
+
: !items.length && !responses.length ? <p className="empty">No pending approvals or responses.</p>
|
|
41
|
+
: items.length ? (
|
|
42
|
+
<table>
|
|
43
|
+
<caption>{items.length} approval request(s) awaiting your decision</caption>
|
|
44
|
+
<thead><tr><th scope="col">Request</th><th scope="col">Risk</th><th scope="col">Instance</th><th scope="col">Decision</th></tr></thead>
|
|
45
|
+
<tbody>
|
|
46
|
+
{items.map((p) => (
|
|
47
|
+
<tr key={p.id}>
|
|
48
|
+
<td>{p.prompt}</td>
|
|
49
|
+
<td><span className={`badge ${p.risk === 'high' ? 'high' : ''}`}>{p.risk}</span></td>
|
|
50
|
+
<td><code title={p.instance_id}>{fmtId(p.instance_id)}</code></td>
|
|
51
|
+
<td style={{ whiteSpace: 'nowrap' }}>
|
|
52
|
+
<button onClick={() => decide(p.id, 'approve')}>Approve</button>{' '}
|
|
53
|
+
<button onClick={() => decide(p.id, 'deny')}>Deny</button>
|
|
54
|
+
</td>
|
|
55
|
+
</tr>
|
|
56
|
+
))}
|
|
57
|
+
</tbody>
|
|
58
|
+
</table>
|
|
59
|
+
) : null}
|
|
60
|
+
</>
|
|
61
|
+
);
|
|
62
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { useEffect, useRef, useState } from 'react';
|
|
2
|
+
import { api } from '../api';
|
|
3
|
+
import { useDebounced } from '../useDebounce';
|
|
4
|
+
import type { CapabilityResult } from '../types';
|
|
5
|
+
|
|
6
|
+
const TYPES = ['all', 'skill', 'agent', 'command', 'rule', 'flow'];
|
|
7
|
+
|
|
8
|
+
// Tenor-style capability search — modeled on fortemi-react's SearchBar + SearchResults
|
|
9
|
+
// (debounced input, Ctrl/⌘-K focus, card grid with rank + snippet + trigger tags),
|
|
10
|
+
// wired to the AIWG registry via the Bridge (read-only catalog data; never execution).
|
|
11
|
+
export function CapabilitySearch({ onPick, autoFocus, compact }: {
|
|
12
|
+
onPick: (c: CapabilityResult) => void;
|
|
13
|
+
autoFocus?: boolean;
|
|
14
|
+
compact?: boolean;
|
|
15
|
+
}) {
|
|
16
|
+
const [q, setQ] = useState('');
|
|
17
|
+
const [type, setType] = useState('all');
|
|
18
|
+
const [results, setResults] = useState<CapabilityResult[] | null>(null);
|
|
19
|
+
const [err, setErr] = useState('');
|
|
20
|
+
const debounced = useDebounced(q, 300);
|
|
21
|
+
const inputRef = useRef<HTMLInputElement>(null);
|
|
22
|
+
|
|
23
|
+
useEffect(() => { if (autoFocus) inputRef.current?.focus(); }, [autoFocus]);
|
|
24
|
+
useEffect(() => {
|
|
25
|
+
const onKey = (e: KeyboardEvent) => {
|
|
26
|
+
if ((e.ctrlKey || e.metaKey) && e.key === 'k') { e.preventDefault(); inputRef.current?.focus(); }
|
|
27
|
+
};
|
|
28
|
+
window.addEventListener('keydown', onKey);
|
|
29
|
+
return () => window.removeEventListener('keydown', onKey);
|
|
30
|
+
}, []);
|
|
31
|
+
useEffect(() => {
|
|
32
|
+
const term = debounced.trim();
|
|
33
|
+
if (!term) { setResults(null); return; }
|
|
34
|
+
let live = true;
|
|
35
|
+
api<{ results: CapabilityResult[] }>(`/api/capabilities?q=${encodeURIComponent(term)}&type=${encodeURIComponent(type)}&limit=12`)
|
|
36
|
+
.then((d) => { if (live) { setResults(d.results); setErr(''); } })
|
|
37
|
+
.catch((e) => { if (live) setErr((e as Error).message); });
|
|
38
|
+
return () => { live = false; };
|
|
39
|
+
}, [debounced, type]);
|
|
40
|
+
|
|
41
|
+
return (
|
|
42
|
+
<div>
|
|
43
|
+
<div className="controls">
|
|
44
|
+
<input ref={inputRef} type="search" value={q} onChange={(e) => setQ(e.target.value)}
|
|
45
|
+
placeholder="Search capabilities… (Ctrl/⌘-K)" aria-label="Search capabilities" style={{ minWidth: compact ? 220 : 300 }} />
|
|
46
|
+
<select value={type} onChange={(e) => setType(e.target.value)} aria-label="Capability type filter">
|
|
47
|
+
{TYPES.map((t) => <option key={t} value={t}>{t}</option>)}
|
|
48
|
+
</select>
|
|
49
|
+
</div>
|
|
50
|
+
{err && <p className="err">{err}</p>}
|
|
51
|
+
{results === null
|
|
52
|
+
? <p className="hint">Type to search skills, agents, commands, rules, flows.</p>
|
|
53
|
+
: !results.length
|
|
54
|
+
? <p className="empty">No matches.</p>
|
|
55
|
+
: (
|
|
56
|
+
<div className="capgrid" role="listbox" aria-label="Capability results">
|
|
57
|
+
{results.map((r) => (
|
|
58
|
+
<button key={r.path} className="capcard" role="option" aria-selected={false} onClick={() => onPick(r)}>
|
|
59
|
+
<div className="capcard-head">
|
|
60
|
+
<span className="badge">{r.type}</span> <strong>{r.name}</strong>
|
|
61
|
+
<span className="capcard-score">{(r.score ?? 0).toFixed(2)}</span>
|
|
62
|
+
</div>
|
|
63
|
+
<div className="capcard-cap">{(r.capability || r.title || '').slice(0, 110)}</div>
|
|
64
|
+
{r.triggers?.length
|
|
65
|
+
? <div className="capcard-tags">{r.triggers.slice(0, 3).map((t) => <span key={t} className="tag">{t}</span>)}</div>
|
|
66
|
+
: null}
|
|
67
|
+
</button>
|
|
68
|
+
))}
|
|
69
|
+
</div>
|
|
70
|
+
)}
|
|
71
|
+
</div>
|
|
72
|
+
);
|
|
73
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { useState } from 'react';
|
|
2
|
+
import { api } from '../api';
|
|
3
|
+
import { CapabilitySearch } from './CapabilitySearch';
|
|
4
|
+
import type { CapabilityResult } from '../types';
|
|
5
|
+
|
|
6
|
+
export function Explore() {
|
|
7
|
+
const [body, setBody] = useState<{ type: string; name: string; body: string } | null>(null);
|
|
8
|
+
const [err, setErr] = useState('');
|
|
9
|
+
|
|
10
|
+
const show = (r: CapabilityResult) => {
|
|
11
|
+
setBody(null); setErr('');
|
|
12
|
+
// Fetch by the discovered path — deterministic even when a name is shared by two
|
|
13
|
+
// artifacts (e.g. two `aiwg-steward` agents), which would otherwise 502 (#1643).
|
|
14
|
+
const qs = `type=${encodeURIComponent(r.type)}&name=${encodeURIComponent(r.name)}&path=${encodeURIComponent(r.path)}`;
|
|
15
|
+
api<{ type: string; name: string; body: string }>(`/api/show?${qs}`)
|
|
16
|
+
.then(setBody).catch((e) => setErr((e as Error).message));
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
return (
|
|
20
|
+
<>
|
|
21
|
+
<p className="hint">
|
|
22
|
+
Read-only catalog from the AIWG registry — display, not execution. To <em>run</em> a capability, inject it into a
|
|
23
|
+
session (Actions/Sessions). Search modeled on the fortemi-react patterns.
|
|
24
|
+
</p>
|
|
25
|
+
{err && <p className="err">{err}</p>}
|
|
26
|
+
<div className="grid2">
|
|
27
|
+
<CapabilitySearch onPick={show} autoFocus />
|
|
28
|
+
<div role="region" aria-label="Capability detail">
|
|
29
|
+
{!body
|
|
30
|
+
? <p className="empty">Select a capability to inspect its definition.</p>
|
|
31
|
+
: (
|
|
32
|
+
<>
|
|
33
|
+
<div style={{ marginBottom: 8 }}><span className="badge">{body.type}</span> <strong>{body.name}</strong></div>
|
|
34
|
+
<div className="terminal" style={{ maxHeight: '52vh' }}>{body.body}</div>
|
|
35
|
+
</>
|
|
36
|
+
)}
|
|
37
|
+
</div>
|
|
38
|
+
</div>
|
|
39
|
+
</>
|
|
40
|
+
);
|
|
41
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { useCallback, useEffect, useState } from 'react';
|
|
2
|
+
import { api } from '../api';
|
|
3
|
+
import { fmtId } from '../util';
|
|
4
|
+
import type { Instance } from '../types';
|
|
5
|
+
|
|
6
|
+
interface Inv { count: number; fetched_at: string; instances: Instance[] }
|
|
7
|
+
|
|
8
|
+
export function Inventory({ onStartSession, onLaunchInstance }: { onStartSession?: (instanceId?: string) => void; onLaunchInstance?: () => void }) {
|
|
9
|
+
const [data, setData] = useState<Inv | null>(null);
|
|
10
|
+
const [err, setErr] = useState('');
|
|
11
|
+
const [actionErr, setActionErr] = useState('');
|
|
12
|
+
|
|
13
|
+
const load = useCallback(() => {
|
|
14
|
+
api<Inv>('/api/inventory').then((d) => { setData(d); setErr(''); }).catch((e) => setErr((e as Error).message));
|
|
15
|
+
}, []);
|
|
16
|
+
useEffect(() => { load(); }, [load]);
|
|
17
|
+
|
|
18
|
+
const control = (path: string, method: string) =>
|
|
19
|
+
api(path, { method }).then(() => { setActionErr(''); load(); }).catch((e) => setActionErr((e as Error).message));
|
|
20
|
+
|
|
21
|
+
if (err) return <p className="err">Could not load inventory: {err}</p>;
|
|
22
|
+
if (!data) return <p className="empty">Loading…</p>;
|
|
23
|
+
if (!data.instances.length) {
|
|
24
|
+
return (
|
|
25
|
+
<section className="empty-state">
|
|
26
|
+
<h2>No instances</h2>
|
|
27
|
+
<p className="hint">Bridge is connected, but no host, Docker, or VM targets are registered.</p>
|
|
28
|
+
{onLaunchInstance && <button className="cta" onClick={onLaunchInstance}>+ New instance + session</button>}
|
|
29
|
+
</section>
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
return (
|
|
34
|
+
<>
|
|
35
|
+
<div className="section-toolbar">
|
|
36
|
+
<div>
|
|
37
|
+
<h2>Agent instances</h2>
|
|
38
|
+
<p className="hint">{data.count} target(s) · {new Date(data.fetched_at).toLocaleTimeString()}</p>
|
|
39
|
+
</div>
|
|
40
|
+
{onLaunchInstance && <button className="cta" onClick={onLaunchInstance}>+ New instance + session</button>}
|
|
41
|
+
</div>
|
|
42
|
+
{actionErr && <p className="err">Action failed: {actionErr}</p>}
|
|
43
|
+
<table>
|
|
44
|
+
<caption>Available instance deployments</caption>
|
|
45
|
+
<thead>
|
|
46
|
+
<tr>
|
|
47
|
+
<th scope="col">Instance</th><th scope="col">Runtime</th><th scope="col">Loadout</th>
|
|
48
|
+
<th scope="col">Transport</th><th scope="col">Host daemon</th><th scope="col">State</th><th scope="col">Tenant</th><th scope="col">Manage</th>
|
|
49
|
+
</tr>
|
|
50
|
+
</thead>
|
|
51
|
+
<tbody>
|
|
52
|
+
{data.instances.map((i) => (
|
|
53
|
+
<tr key={i.id}>
|
|
54
|
+
{/*
|
|
55
|
+
Runtime state and session readiness are separate: Docker can be
|
|
56
|
+
running while the embedded agent is still failing registration.
|
|
57
|
+
*/}
|
|
58
|
+
{(() => {
|
|
59
|
+
const sessionReady = i.session_backends?.some((b) => b.available);
|
|
60
|
+
const unavailableReason = i.session_backends?.find((b) => !b.available)?.reason;
|
|
61
|
+
return (
|
|
62
|
+
<>
|
|
63
|
+
<td>
|
|
64
|
+
<code title={i.id}>{i.launch_context?.name ?? fmtId(i.id)}</code>
|
|
65
|
+
{i.launch_context?.name && <div className="cell-note">{fmtId(i.id)}</div>}
|
|
66
|
+
</td>
|
|
67
|
+
<td>
|
|
68
|
+
<span className={`badge isolation-${i.runtime_posture.isolation}`} title={i.runtime_posture.warning || i.runtime_posture.label}>
|
|
69
|
+
{i.runtime_posture.label}
|
|
70
|
+
</span>
|
|
71
|
+
{i.runtime_posture.warning && <div className="cell-note">{i.runtime_posture.warning}</div>}
|
|
72
|
+
</td>
|
|
73
|
+
<td>
|
|
74
|
+
{i.loadout}
|
|
75
|
+
{i.launch_context?.image_ref && <div className="cell-note">{i.launch_context.image_ref}</div>}
|
|
76
|
+
{i.launch_context?.source && <div className="cell-note">{i.launch_context.source}</div>}
|
|
77
|
+
</td>
|
|
78
|
+
<td>
|
|
79
|
+
<span className={`badge trust-${i.transport.trust}`} title={`${i.transport.source}${i.transport.evidence ? `: ${i.transport.evidence}` : ''}`}>
|
|
80
|
+
{i.transport.label}
|
|
81
|
+
</span>
|
|
82
|
+
<div className="cell-note">{i.transport.mode}{i.transport.stale ? ' · stale' : ''}</div>
|
|
83
|
+
</td>
|
|
84
|
+
<td>
|
|
85
|
+
<span className={`badge daemon-${i.host_daemon.status}`}>{i.host_daemon.status.replace('_', ' ')}</span>
|
|
86
|
+
{i.host_daemon.detail && <div className="cell-note">{i.host_daemon.detail}</div>}
|
|
87
|
+
{i.host_daemon.operator_command && <code title="Operator start command">{i.host_daemon.operator_command}</code>}
|
|
88
|
+
</td>
|
|
89
|
+
<td><span className={`state ${i.state}`}><span className="dot" aria-hidden="true" />{i.state}</span></td>
|
|
90
|
+
<td>{i.tenant}</td>
|
|
91
|
+
<td className="manage-actions">
|
|
92
|
+
{i.state === 'running' && onStartSession && (
|
|
93
|
+
<button
|
|
94
|
+
className="cta"
|
|
95
|
+
aria-label={`Start session on ${fmtId(i.id)}`}
|
|
96
|
+
disabled={!sessionReady}
|
|
97
|
+
title={!sessionReady ? unavailableReason : undefined}
|
|
98
|
+
onClick={() => onStartSession(i.id)}
|
|
99
|
+
>
|
|
100
|
+
New Session
|
|
101
|
+
</button>
|
|
102
|
+
)}{' '}
|
|
103
|
+
{i.state === 'running'
|
|
104
|
+
? <button aria-label={`Stop instance ${fmtId(i.id)}`} onClick={() => control(`/api/instances/${encodeURIComponent(i.id)}/stop`, 'POST')}>Stop Instance</button>
|
|
105
|
+
: <button aria-label={`Start instance ${fmtId(i.id)}`} onClick={() => control(`/api/instances/${encodeURIComponent(i.id)}/start`, 'POST')}>Start Instance</button>}{' '}
|
|
106
|
+
<button
|
|
107
|
+
aria-label={`Destroy instance ${fmtId(i.id)}`}
|
|
108
|
+
disabled={i.state !== 'running' && i.runtime === 'docker'}
|
|
109
|
+
title={i.state !== 'running' && i.runtime === 'docker' ? 'Sandbox reports this stopped Docker row but admin-v2 no longer has a destroyable instance record.' : undefined}
|
|
110
|
+
onClick={() => { if (confirm(`Destroy ${fmtId(i.id)}? This cannot be undone.`)) control(`/api/instances/${encodeURIComponent(i.id)}`, 'DELETE'); }}
|
|
111
|
+
>
|
|
112
|
+
Destroy
|
|
113
|
+
</button>
|
|
114
|
+
</td>
|
|
115
|
+
</>
|
|
116
|
+
);
|
|
117
|
+
})()}
|
|
118
|
+
</tr>
|
|
119
|
+
))}
|
|
120
|
+
</tbody>
|
|
121
|
+
</table>
|
|
122
|
+
</>
|
|
123
|
+
);
|
|
124
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import { render, screen, cleanup, waitFor, fireEvent } from '@testing-library/react';
|
|
3
|
+
import { LaunchInstanceModal } from './LaunchInstanceModal';
|
|
4
|
+
|
|
5
|
+
const HOST_INSTANCE = {
|
|
6
|
+
id: 'host-aaaaaaaa-1111',
|
|
7
|
+
runtime: 'host',
|
|
8
|
+
loadout: 'host-tools',
|
|
9
|
+
state: 'running',
|
|
10
|
+
tenant: 'default',
|
|
11
|
+
card_url: '',
|
|
12
|
+
runtime_posture: { kind: 'host', isolation: 'least', label: 'host' },
|
|
13
|
+
host_daemon: { status: 'available' },
|
|
14
|
+
transport: { mode: 'mtls', trust: 'secure', label: 'mTLS', source: 'test' },
|
|
15
|
+
launch_context: { name: 'local-host', loadout: 'host-tools' },
|
|
16
|
+
session_backends: [{ mode: 'managed', backend: 'tmux', available: true, drive: true }],
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
const LOADOUTS = [
|
|
20
|
+
{ id: 'host-tools', label: 'host-tools', description: 'Host tools', runtimes: ['host'] },
|
|
21
|
+
{ id: 'full-suite', label: 'full-suite', description: 'All providers', runtimes: ['docker', 'container', 'qemu', 'vm'] },
|
|
22
|
+
{ id: 'agentic-dev', label: 'agentic-dev', description: 'Developer tools', runtimes: ['docker', 'container'] },
|
|
23
|
+
];
|
|
24
|
+
|
|
25
|
+
function mockFetch() {
|
|
26
|
+
return vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
|
|
27
|
+
const url = String(input);
|
|
28
|
+
const ok = (body: unknown) => new Response(JSON.stringify(body), { status: 200, headers: { 'content-type': 'application/json' } });
|
|
29
|
+
if (url.includes('/api/loadouts')) return ok({ loadouts: LOADOUTS });
|
|
30
|
+
if (url.includes('/api/inventory')) return ok({ instances: [HOST_INSTANCE] });
|
|
31
|
+
if (url.includes('/api/instances') && init?.method === 'POST') return ok({ instance_id: 'docker-1' });
|
|
32
|
+
return new Response('{}', { status: 404 });
|
|
33
|
+
}) as unknown as typeof fetch;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
beforeEach(() => { (window as unknown as { __COCKPIT_TOKEN__: string }).__COCKPIT_TOKEN__ = 't'; });
|
|
37
|
+
afterEach(() => { cleanup(); vi.restoreAllMocks(); });
|
|
38
|
+
|
|
39
|
+
describe('LaunchInstanceModal', () => {
|
|
40
|
+
it('uses an existing host target instead of provisioning unsupported host instances', async () => {
|
|
41
|
+
globalThis.fetch = mockFetch();
|
|
42
|
+
const onLaunched = vi.fn();
|
|
43
|
+
const onClose = vi.fn();
|
|
44
|
+
render(<LaunchInstanceModal open onClose={onClose} onLaunched={onLaunched} />);
|
|
45
|
+
|
|
46
|
+
expect(await screen.findByRole('dialog', { name: /new instance/i })).toBeTruthy();
|
|
47
|
+
expect(await screen.findByRole('option', { name: /local-host - host-tools/i })).toBeTruthy();
|
|
48
|
+
expect(screen.getByText('host-tools')).toBeTruthy();
|
|
49
|
+
expect(screen.queryByRole('option', { name: /full-suite/i })).toBeNull();
|
|
50
|
+
|
|
51
|
+
fireEvent.click(screen.getByRole('button', { name: /start host session/i }));
|
|
52
|
+
await waitFor(() => expect(onLaunched).toHaveBeenCalledWith('host-aaaaaaaa-1111', true));
|
|
53
|
+
const postCall = (globalThis.fetch as unknown as ReturnType<typeof vi.fn>).mock.calls
|
|
54
|
+
.find((call) => String(call[0]).includes('/api/instances') && call[1]?.method === 'POST');
|
|
55
|
+
expect(postCall).toBeUndefined();
|
|
56
|
+
expect(onClose).toHaveBeenCalled();
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('keeps Docker on the real provisioning path with Docker loadouts and images', async () => {
|
|
60
|
+
globalThis.fetch = mockFetch();
|
|
61
|
+
const onLaunched = vi.fn();
|
|
62
|
+
render(<LaunchInstanceModal open onClose={() => {}} onLaunched={onLaunched} />);
|
|
63
|
+
|
|
64
|
+
fireEvent.change(await screen.findByLabelText('Runtime'), { target: { value: 'docker' } });
|
|
65
|
+
expect(await screen.findByRole('option', { name: /agentic-dev/i })).toBeTruthy();
|
|
66
|
+
expect(screen.getByRole('option', { name: /Codex - agentic\/codex:latest/i })).toBeTruthy();
|
|
67
|
+
|
|
68
|
+
fireEvent.click(screen.getByRole('button', { name: /create \+ start session/i }));
|
|
69
|
+
await waitFor(() => expect(onLaunched).toHaveBeenCalledWith('docker-1', true, undefined));
|
|
70
|
+
const postCall = (globalThis.fetch as unknown as ReturnType<typeof vi.fn>).mock.calls
|
|
71
|
+
.find((call) => String(call[0]).includes('/api/instances') && call[1]?.method === 'POST');
|
|
72
|
+
expect(JSON.parse(String(postCall?.[1]?.body))).toMatchObject({
|
|
73
|
+
runtime: 'docker',
|
|
74
|
+
loadout: 'agentic-dev',
|
|
75
|
+
image: 'agentic/codex:latest',
|
|
76
|
+
agentshare: true,
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('passes a VM SSH public key path when launching QEMU', async () => {
|
|
81
|
+
globalThis.fetch = mockFetch();
|
|
82
|
+
const onLaunched = vi.fn();
|
|
83
|
+
render(<LaunchInstanceModal open onClose={() => {}} onLaunched={onLaunched} />);
|
|
84
|
+
|
|
85
|
+
fireEvent.change(await screen.findByLabelText('Runtime'), { target: { value: 'qemu' } });
|
|
86
|
+
fireEvent.change(await screen.findByLabelText('SSH public key'), { target: { value: '~/.ssh/agentic_ed25519.pub' } });
|
|
87
|
+
fireEvent.click(screen.getByRole('button', { name: /create \+ start session/i }));
|
|
88
|
+
|
|
89
|
+
await waitFor(() => expect(onLaunched).toHaveBeenCalledWith('docker-1', true, undefined));
|
|
90
|
+
const postCall = (globalThis.fetch as unknown as ReturnType<typeof vi.fn>).mock.calls
|
|
91
|
+
.find((call) => String(call[0]).includes('/api/instances') && call[1]?.method === 'POST');
|
|
92
|
+
expect(JSON.parse(String(postCall?.[1]?.body))).toMatchObject({
|
|
93
|
+
runtime: 'qemu',
|
|
94
|
+
ssh_key: '~/.ssh/agentic_ed25519.pub',
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
});
|