feat(web): prefill demo defaults so views load instantly

- timeline picks the first cluster, stream, and a real observed object
- diff defaults to the first cluster and busiest namespace
- plans default to the first cluster, latest snapshot, and a source/target
  namespace pair, bootstrapping one plan when none exist
This commit is contained in:
lakshit verma 2026-08-06 06:06:02 +05:30
parent fbd7880d87
commit 0baf78ddb7
No known key found for this signature in database
GPG key ID: EB498AFC60A7A01A
4 changed files with 151 additions and 29 deletions

46
web/src/defaults.js Normal file
View file

@ -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'));
}

View file

@ -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) {

View file

@ -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);

View file

@ -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) {