diff --git a/.vitepress/theme/SandboxPlayground.vue b/.vitepress/theme/SandboxPlayground.vue index 03c02128..4838377c 100644 --- a/.vitepress/theme/SandboxPlayground.vue +++ b/.vitepress/theme/SandboxPlayground.vue @@ -7,6 +7,7 @@ const templates = ref([]), template = ref(''), instances = ref([]), selected = r const tab = ref('command'), command = ref('uname -a'), output = ref(''), events = ref([]), notice = ref('') const filePath = ref('/tmp/sandbase-playground.txt'), content = ref('Hello from Sandbox Playground'), fileStatus = ref('') const evidence = ref('Not loaded'), observation = ref('metrics'), ttl = ref(120), requestExample = ref('Connect to view a request example.') +const eventNotice = ref('Refresh to load lifecycle events for the selected instance.') const pendingCreate = ref(''), observedPause = new Set(), restored = new Set(), saved = new Map(), tokens = new Map() const connectionID = ref(''); let controller const responseSignals = new WeakMap() @@ -82,12 +83,17 @@ async function connect() { await act('Load templates and instances', async () => }) } function forget() { controller?.abort(); controller = null; busy.value = false - template.value = ''; connectionID.value = ''; accepted.value = false; command.value = 'uname -a'; content.value = ''; filePath.value = '/tmp/sandbase-playground.txt'; fileStatus.value = ''; requestExample.value = 'Connect to view a request example.' + eventNotice.value = ''; template.value = ''; connectionID.value = ''; accepted.value = false; command.value = 'uname -a'; content.value = ''; filePath.value = '/tmp/sandbase-playground.txt'; fileStatus.value = ''; requestExample.value = 'Connect to view a request example.' tokens.clear(); catalogLoaded.value = false; key.value = ''; connected.value = false; selected.value = null; instances.value = []; templates.value = []; events.value = []; output.value = ''; evidence.value = ''; saved.clear(); observedPause.clear(); restored.clear(); pendingCreate.value = ''; notice.value = 'Key, connection tokens, and page activity cleared. Requests already sent may still complete. Your sandboxes have not been deleted.' } -async function select(value) { await act('Load instance', async () => { - selected.value = await control(`/sandboxes/${idOf(value)}`); output.value = ''; fileStatus.value = ''; evidence.value = 'Not loaded'; await loadEvents() -}) } +function select(value) { + if (busy.value || idOf(value) === id.value) return + selected.value = value + output.value = ''; content.value = ''; fileStatus.value = ''; evidence.value = 'Not loaded'; events.value = [] + requestExample.value = 'Choose an operation to view its request example.' + eventNotice.value = 'Refresh to load lifecycle events for this instance.' + notice.value = `Selected ${idOf(value)}. ${owned.value ? 'Ready for this instance’s available operations.' : 'Read-only instance. Only instances created in this page session can be changed.'}` +} async function create() { if (!accepted.value || pendingCreate.value) return await act('Create sandbox', async () => { const attempt = crypto.randomUUID(); pendingCreate.value = attempt @@ -159,9 +165,19 @@ async function file(write) { if (!runnable.value) return }) } async function loadEvents() { - const rows = await control(`/events/sandboxes/${id.value}?limit=100`) - for (const event of Array.isArray(rows) ? rows : []) if (!events.value.some(x => x.id === event.id && x.source === 'Sandbox lifecycle')) events.value.unshift({ id: event.id, type: event.type, source: 'Sandbox lifecycle', time: event.timestamp, data: safe(event) }) - events.value = events.value.slice(0, 100) + const signal = controller?.signal + eventNotice.value = 'Loading lifecycle events…' + try { + const rows = await control(`/events/sandboxes/${id.value}?limit=100`) + if (!Array.isArray(rows)) throw new Error('invalid_events') + for (const event of rows) if (!events.value.some(x => x.id === event.id && x.source === 'Sandbox lifecycle')) events.value.unshift({ id: event.id, type: event.type, source: 'Sandbox lifecycle', time: event.timestamp, data: safe(event) }) + events.value = events.value.slice(0, 100) + eventNotice.value = rows.length ? '' : 'No lifecycle events returned yet.' + } catch (e) { + if (signal?.aborted) throw e + eventNotice.value = `Lifecycle events unavailable (${safe(e.message)}). Your selection and completed operations are unchanged.` + if (notice.value === 'Refresh events…') notice.value = 'Events could not be loaded. Your instance selection is unchanged.' + } } async function observe() { if (!id.value) return await act('Load observations', async () => { @@ -201,8 +217,8 @@ onBeforeUnmount(() => { window.removeEventListener('pagehide', forget); forget()

Creation result is unknown. Refresh and reconcile this marker before creating again: {{ pendingCreate }}

Instances

-

No instances loaded

- +

{{ busy ? 'An operation is in progress. Selection unlocks when it finishes.' : 'Click an instance to select it. Refresh to update its status.' }}

No instances loaded

+
{{ id || 'Select an instance' }}{{ selected?.state || 'Disconnected' }}
@@ -213,7 +229,7 @@ onBeforeUnmount(() => { window.removeEventListener('pagehide', forget); forget()

Missing evidence does not mean zero usage. Machine-time records are not final billing.

{{ evidence }}
Request example (credentials omitted)
{{ requestExample }}
- +

Leaving this page does not delete sandboxes. Delete instances created here, including paused ones. After a refresh, use your organization tools to clean up.

@@ -265,6 +281,9 @@ onBeforeUnmount(() => { window.removeEventListener('pagehide', forget); forget() .sp-check input { margin-right: 6px; } .sp .sp-instance { display: block; text-align: left; width: 100%; margin-top: 8px; overflow-wrap: anywhere; font: 11px/1.5 var(--vp-font-family-mono); } .sp-instance small { display: block; margin-top: 6px; color: var(--vp-c-text-2); font: 10px var(--vp-font-family-base); } +.sp .sp-instance:hover:not(:disabled) { border-color: var(--accent); } +.sp-instance-id { display: block; } +.sp-selected { display: block; margin-top: 6px; color: var(--accent); font: 11px var(--vp-font-family-base); } .sp-instance.chosen { border-color: var(--accent); background: var(--vp-c-brand-soft); } .sp.sp .sp-resources > h3:not(:first-child) { margin-top: 24px; } .sp-current { display: flex; justify-content: space-between; gap: 10px; overflow-wrap: anywhere; margin-bottom: 16px; } diff --git a/scripts/test-sandbox-playground.mjs b/scripts/test-sandbox-playground.mjs index 1d8a1ea6..10816ff9 100644 --- a/scripts/test-sandbox-playground.mjs +++ b/scripts/test-sandbox-playground.mjs @@ -95,7 +95,7 @@ async function playgroundHarness(fetcher) { const source = readFileSync(new URL('../.vitepress/theme/SandboxPlayground.vue', import.meta.url), 'utf8') .split('')[0].replace(/^import .*$/gm, '') return new Function('ref', 'computed', 'onMounted', 'onBeforeUnmount', 'window', 'templateLabel', 'redact', 'readCommandStream', 'fetch', - source + '\nreturn { key, connected, accepted, busy, create, forget, act, selected, tokens, events, output, content, requestExample, notice, connect, templates, template, catalogLoaded, pageWindow: window };')( + source + '\nreturn { key, connected, accepted, busy, create, forget, act, selected, tokens, events, output, content, requestExample, notice, connect, templates, template, catalogLoaded, select, instances, loadEvents, eventNotice, connectionID, pageWindow: window };')( ref, computed, fn => fn(), () => {}, new EventTarget(), templateLabel, redact, readCommandStream, fetcher) } @@ -185,3 +185,29 @@ test('redirects are rejected on control, access checks and data requests without } } }) + + +test('instance selection is immediate and independent of detail or event endpoints', async () => { + let calls = 0 + const ui = await playgroundHarness(() => { calls++; throw new Error('network must not be needed for selection') }) + ui.connectionID.value = 'page' + const first = { sandboxID: 'one', state: 'running', metadata: { docsPlayground: 'page' } } + const second = { sandboxID: 'two', state: 'running', metadata: { docsPlayground: 'page' } } + ui.tokens.set('one', 'one-token'); ui.tokens.set('two', 'two-token') + ui.select(first); ui.output.value = 'first output'; ui.content.value = 'first file' + ui.select(second) + assert.equal(ui.selected.value.sandboxID, 'two'); assert.equal(calls, 0) + assert.equal(ui.output.value, ''); assert.equal(ui.content.value, '') + assert.equal(ui.tokens.get('two'), 'two-token'); assert.match(ui.notice.value, /Selected two/) + ui.busy.value = true; ui.select(first); assert.equal(ui.selected.value.sandboxID, 'two') +}) + +test('event 404 does not undo selection or mark a completed creation as failed', async () => { + const ui = await playgroundHarness(async () => Response.json({ error: { code: 'upstream_http_404' } }, { status: 404 })) + ui.select({ sandboxID: 'one', state: 'running' }) + await ui.act('Create sandbox', ui.loadEvents) + assert.equal(ui.selected.value.sandboxID, 'one'); assert.equal(ui.notice.value, 'Create sandbox completed.') + assert.match(ui.eventNotice.value, /Lifecycle events unavailable/); assert.equal(ui.busy.value, false) + await ui.act('Refresh events', ui.loadEvents) + assert.match(ui.notice.value, /selection is unchanged/) +})