diff --git a/web/src/defaults.js b/web/src/defaults.js new file mode 100644 index 0000000..01a0a18 --- /dev/null +++ b/web/src/defaults.js @@ -0,0 +1,46 @@ +// defaults.js - demo-friendly defaults so views work with one click (or none). +// Each picker falls back to "" so the form still validates when data is absent. + +export function pickFirst(items) { + const list = Array.isArray(items) ? items : []; + return list.length ? list[0] : null; +} + +// pickLatestSnapshot returns the snapshot with the newest observed time. +export function pickLatestSnapshot(snaps) { + let best = null; + for (const s of Array.isArray(snaps) ? snaps : []) { + const t = s.at ? new Date(s.at).getTime() : 0; + if (!best || t > new Date(best.at).getTime()) best = s; + } + return best; +} + +// pickNamespace returns the most common namespace across streams. +export function pickNamespace(streams) { + const counts = new Map(); + for (const st of Array.isArray(streams) ? streams : []) { + if (!st.namespace) continue; + counts.set(st.namespace, (counts.get(st.namespace) || 0) + 1); + } + let best = ''; + let bestCount = 0; + for (const [ns, c] of counts) { + if (c > bestCount) { best = ns; bestCount = c; } + } + return best; +} + +// pickObject returns the first observed object {namespace, name}. +export function pickObject(events) { + for (const ev of Array.isArray(events) ? events : []) { + if (ev.namespace && ev.name) return { namespace: ev.namespace, name: ev.name }; + } + return null; +} + +// setSelect assigns a value and fires change so dependent options rebuild. +export function setSelect(sel, value) { + sel.value = value || ''; + sel.dispatchEvent(new Event('change')); +} diff --git a/web/src/views/diff.js b/web/src/views/diff.js index e150042..6cdb18a 100644 --- a/web/src/views/diff.js +++ b/web/src/views/diff.js @@ -4,6 +4,7 @@ // list every path with added/removed/modified badges. import { api } from '../api.js'; import { el, asList, badge, fmtTime, field, formCard } from '../util.js'; +import { pickFirst, pickNamespace, setSelect } from '../defaults.js'; const toLocalInput = (d) => { const p = (n) => String(n).padStart(2, '0'); @@ -12,7 +13,9 @@ const toLocalInput = (d) => { export async function viewDiff(mount) { mount.appendChild(el('h1', { textContent: 'JSON diff' })); - const clusters = asList(await api.clusters().catch(() => [])); + const [c, s] = await Promise.all([api.clusters().catch(() => []), api.streams().catch(() => [])]); + const clusters = asList(c); + const streams = asList(s); const clusterSel = el('select'); clusterSel.appendChild(el('option', { value: '', textContent: '— cluster —' })); @@ -22,13 +25,14 @@ export async function viewDiff(mount) { const sinceInput = el('input', { type: 'datetime-local', step: '1', value: toLocalInput(new Date(Date.now() - 3600e3)) }); const untilInput = el('input', { type: 'datetime-local', step: '1', value: toLocalInput(new Date()) }); + const defCluster = pickFirst(clusters); + const defNs = pickNamespace(streams); + if (defCluster) setSelect(clusterSel, defCluster.id); + if (defNs) namespaceInput.value = defNs; + const results = el('div'); - mount.appendChild(formCard([ - field('cluster', clusterSel), - field('namespace', namespaceInput), - field('before (observed)', sinceInput), - field('after (observed)', untilInput), - ], 'show diff', async () => { + + async function run() { results.textContent = ''; if (!clusterSel.value || !namespaceInput.value.trim() || !sinceInput.value || !untilInput.value) { results.appendChild(el('div', { className: 'notice warn', textContent: 'cluster, namespace, before and after are required.' })); @@ -49,8 +53,17 @@ export async function viewDiff(mount) { results.textContent = ''; results.appendChild(el('div', { className: 'notice error', textContent: err.message || String(err) })); } - })); + } + + mount.appendChild(formCard([ + field('cluster', clusterSel), + field('namespace', namespaceInput), + field('before (observed)', sinceInput), + field('after (observed)', untilInput), + ], 'show diff', run)); mount.appendChild(results); + + run(); } function renderDiff(results, data) { diff --git a/web/src/views/plans.js b/web/src/views/plans.js index efd4540..f5a8550 100644 --- a/web/src/views/plans.js +++ b/web/src/views/plans.js @@ -1,13 +1,19 @@ // Replay plans: create (POST /v1/replay-plans), review objects/exclusions, dry-run. import { api } from '../api.js'; import { el, asList, simpleTable, badge, fmtTime, field, formCard } from '../util.js'; +import { pickFirst, pickLatestSnapshot, pickNamespace, setSelect } from '../defaults.js'; export async function viewPlans(mount) { mount.appendChild(el('h1', { textContent: 'Replay plans' })); - const [clusters, snaps] = await Promise.all([api.clusters().catch(() => []), api.snapshots().catch(() => [])]); + const [clusters, snaps, streams] = await Promise.all([ + api.clusters().catch(() => []), + api.snapshots().catch(() => []), + api.streams().catch(() => []), + ]); const clusterList = asList(clusters); const snapshotList = asList(snaps); + const streamList = asList(streams); const clusterSel = el('select'); clusterSel.appendChild(el('option', { value: '', textContent: '— cluster —' })); @@ -29,13 +35,39 @@ export async function viewPlans(mount) { const sourceInput = el('input', { type: 'text', placeholder: 'source namespace' }); const targetInput = el('input', { type: 'text', placeholder: 'target namespace' }); const list = el('div', { className: 'plan-list' }); + let bootstrapped = false; + + const defCluster = pickFirst(clusterList); + const defSnapshot = pickLatestSnapshot(snapshotList); + const defNs = pickNamespace(streamList); + if (defCluster) setSelect(clusterSel, defCluster.id); + fillSnapshots(); + if (defSnapshot && (!clusterSel.value || defSnapshot.cluster_id === clusterSel.value)) snapshotSel.value = defSnapshot.id; + if (defNs) { + sourceInput.value = defNs; + targetInput.value = defNs ? `${defNs}-copy` : ''; + } + + async function create(body) { + list.textContent = ''; + list.appendChild(el('p', { className: 'muted', textContent: 'creating plan…' })); + try { + const plan = await api.createPlan(body); + list.textContent = ''; + if (plan && plan.id) list.appendChild(planCard(plan)); + await load(); + } catch (err) { + list.textContent = ''; + list.appendChild(el('div', { className: 'notice error', textContent: err.message || String(err) })); + } + } mount.appendChild(formCard([ field('cluster', clusterSel), field('snapshot', snapshotSel), field('source namespace', sourceInput), field('target namespace', targetInput), - ], 'create plan', async () => { + ], 'create plan', () => { list.textContent = ''; const body = { cluster_id: clusterSel.value, @@ -47,16 +79,7 @@ export async function viewPlans(mount) { list.appendChild(el('div', { className: 'notice warn', textContent: 'cluster, snapshot, source and target namespace are required.' })); return; } - list.appendChild(el('p', { className: 'muted', textContent: 'creating plan…' })); - try { - const plan = await api.createPlan(body); - list.textContent = ''; - if (plan && plan.id) list.appendChild(planCard(plan)); - await load(); - } catch (err) { - list.textContent = ''; - list.appendChild(el('div', { className: 'notice error', textContent: err.message || String(err) })); - } + create(body); })); mount.appendChild(list); @@ -68,6 +91,7 @@ export async function viewPlans(mount) { list.textContent = ''; if (!plans.length) { list.appendChild(el('p', { className: 'muted', textContent: 'No plans yet — create one above.' })); + bootstrapIfEmpty(); return; } for (const p of plans) list.appendChild(planCard(p)); @@ -77,6 +101,23 @@ export async function viewPlans(mount) { } } await load(); + + // Demo bootstrap: with a real cluster + snapshot, auto-create one plan so a + // visitor sees a populated page without filling the form. + async function bootstrapIfEmpty() { + if (bootstrapped) return; + bootstrapped = true; + const body = { + cluster_id: clusterSel.value, + snapshot_id: snapshotSel.value, + source_namespace: sourceInput.value.trim(), + target_namespace: targetInput.value.trim(), + }; + if (!body.cluster_id || !body.snapshot_id || !body.source_namespace || !body.target_namespace) return; + const plans = asList(await api.plans().catch(() => [])); + if (plans.length) return; + create(body); + } } const itemName = (x) => (x.namespace ? `${x.namespace}/${x.name}` : x.name); diff --git a/web/src/views/timeline.js b/web/src/views/timeline.js index 64a679b..ebbbd70 100644 --- a/web/src/views/timeline.js +++ b/web/src/views/timeline.js @@ -1,12 +1,18 @@ // Object timeline: observed events for one object, with GAP rows shown distinctly. import { api } from '../api.js'; import { el, asList, badge, watchBadge, fmtTime, streamLabel, streamTitle, field, formCard } from '../util.js'; +import { pickFirst, pickObject, setSelect } from '../defaults.js'; export async function viewTimeline(mount) { mount.appendChild(el('h1', { textContent: 'Object timeline' })); - const [clusters, streams] = await Promise.all([api.clusters().catch(() => []), api.streams().catch(() => [])]); + const [clusters, streams, evs] = await Promise.all([ + api.clusters().catch(() => []), + api.streams().catch(() => []), + api.events({ limit: 30 }).catch(() => []), + ]); const clusterList = asList(clusters); const streamList = asList(streams); + const events = asList(evs); const clusterSel = el('select'); clusterSel.appendChild(el('option', { value: '', textContent: '— cluster —' })); @@ -29,14 +35,20 @@ export async function viewTimeline(mount) { const nameInput = el('input', { type: 'text', placeholder: 'name' }); const sinceInput = el('input', { type: 'number', min: '1', max: '8760', value: '24' }); + const defCluster = pickFirst(clusterList); + const defObject = pickObject(events); + if (defCluster) setSelect(clusterSel, defCluster.id); + fillStreams(); + const defStream = streamList.find((st) => st.cluster_id === (defCluster && defCluster.id)) || pickFirst(streamList); + if (defStream) streamSel.value = defStream.id; + if (defObject) { + namespaceInput.value = defObject.namespace; + nameInput.value = defObject.name; + } + const results = el('div'); - mount.appendChild(formCard([ - field('cluster', clusterSel), - field('stream', streamSel), - field('namespace', namespaceInput), - field('name', nameInput), - field('since (hours)', sinceInput), - ], 'show timeline', async () => { + + async function run() { results.textContent = ''; const cluster = clusterSel.value; const stream = streamSel.value; @@ -57,8 +69,18 @@ export async function viewTimeline(mount) { results.textContent = ''; results.appendChild(el('div', { className: 'notice error', textContent: err.message || String(err) })); } - })); + } + + mount.appendChild(formCard([ + field('cluster', clusterSel), + field('stream', streamSel), + field('namespace', namespaceInput), + field('name', nameInput), + field('since (hours)', sinceInput), + ], 'show timeline', run)); mount.appendChild(results); + + run(); } function renderHistory(results, data) {