v2.0.7 — oumta-dashboard: timeline overhaul

Two-column timestrahl layout + workload quick-filter.

- Left column (64px): date badge (big day / small month e.g. 18 APR)
  shown only on the first cluster of each day, coloured dot, and a
  compact HH:MM time (or "just now" when <60s old).
- Dot colour = dominant severity of the cluster
  (error > warning > recovered > image > info).
- Events within a 60s tolerance window collapse into one entry so
  simultaneous rollouts of multiple deployments appear as a single
  dot with each sub-event listed in the right column.
- Workload quick-pick pills above the timeline. "all" + up to six
  inline pills (workloads sorted by recency); the rest collapses into
  a "+N more" dropdown. Clicking a workload name inline inside an
  event also applies the filter. Filter hides itself when the view is
  scoped to a single workload (opened via the overview attention list).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
thandClaude Opus 4.7 committed 2026-04-18 16:25:40 +02:00
1 parent 0553960a57
commit 9f90ea516a
5 files changed
+340 -48

No files matched your search

+186 -25
View File
@@ -23,6 +23,7 @@ const state = {
pollHandle: null,
sseHandle: null,
data: { overview: null, workloads: [], timeline: [], scopeWorkload: null },
timelineFilter: null, // null = all events, else workloadId (pill quick-pick)
};
// ── Central API surface ─────────────────────────────────────────
@@ -238,6 +239,73 @@ function renderWorkloads() {
});
}
// ── Timeline helpers ──────────────────────────────────────────
const TL_CLUSTER_WINDOW_MS = 60_000; // events within 60s collapse into one entry
const TL_SEV_RANK = { error: 5, warning: 4, recovered: 3, image: 2, info: 1 };
const TL_MONTHS = ['JAN', 'FEB', 'MAR', 'APR', 'MAY', 'JUN', 'JUL', 'AUG', 'SEP', 'OCT', 'NOV', 'DEC'];
/** Normalise an event's severity. Two post-hoc classifications matter:
* "-> healthy" transitions are recoveries, and ImageRolled reasons are
* real image pushes (vs. plain revision bumps which are just info). */
function eventSev(e) {
const isRecovery = typeof e.message === 'string' && e.message.endsWith('-> healthy');
const isImageRoll = e.reason === 'ImageRolled';
if (isRecovery) return 'recovered';
if (isImageRoll) return 'image';
return e.severity || 'info';
}
/** Workload short-name for the filter pill (strip namespace prefix). */
function workloadShort(id) {
if (!id) return '?';
const parts = id.split('/');
return parts[parts.length - 1] || id;
}
/** Greedy clustering: walk events (assumed newest-first) and start a new
* cluster whenever the gap exceeds the tolerance window. */
function clusterEvents(events) {
const clusters = [];
let current = null;
for (const e of events) {
const ts = Date.parse(e.timestamp);
if (!current || isNaN(ts) || Math.abs(current.anchorTs - ts) > TL_CLUSTER_WINDOW_MS) {
current = { anchorTs: ts, ts: e.timestamp, events: [e] };
clusters.push(current);
} else {
current.events.push(e);
}
}
return clusters;
}
function dominantSev(events) {
let best = null;
for (const e of events) {
const sev = eventSev(e);
if (!best || TL_SEV_RANK[sev] > TL_SEV_RANK[best]) best = sev;
}
return best || 'info';
}
function sameDay(a, b) {
const da = new Date(a), db = new Date(b);
return da.getFullYear() === db.getFullYear()
&& da.getMonth() === db.getMonth()
&& da.getDate() === db.getDate();
}
function formatTimeShort(iso) {
const d = new Date(iso);
if (isNaN(d.getTime())) return iso;
const now = new Date();
if (Math.abs(now - d) < 60_000) return 'just now';
const hh = String(d.getHours()).padStart(2, '0');
const mm = String(d.getMinutes()).padStart(2, '0');
return `${hh}:${mm}`;
}
function renderTimeline() {
const scope = state.data.scopeWorkload;
const scopeEl = $('oumta-timeline-scope');
@@ -246,39 +314,132 @@ function renderTimeline() {
scopeEl.textContent = 'events for ' + scope;
backEl.classList.remove('hidden');
} else {
scopeEl.textContent = 'all events';
scopeEl.textContent = state.timelineFilter ? 'filtered' : 'all events';
backEl.classList.add('hidden');
}
const events = scope
? (state.data.timeline.filter(e => e.workloadId === scope))
: state.data.timeline;
const filter = state.timelineFilter;
const events = state.data.timeline.filter(e => {
if (scope) return e.workloadId === scope;
if (filter) return e.workloadId === filter;
return true;
});
renderTimelineFilters();
const body = $('oumta-timeline-body');
if (!events.length) {
body.innerHTML = '<div class="oumta-empty">No events.</div>';
return;
}
body.innerHTML = events.map(e => {
// Recovery = letzter State-Uebergang endet in "-> healthy".
// Image-Rollout = vom Backend markiert via reason "ImageRolled".
// Beide kriegen eigene Badges, damit man Erholungen und neue
// Images von generischen Info-Events (nur-Revision-Rollouts)
// trennen kann.
const isRecovery = typeof e.message === 'string' && e.message.endsWith('-> healthy');
const isImageRoll = e.reason === 'ImageRolled';
let sev = e.severity;
if (isRecovery) sev = 'recovered';
else if (isImageRoll) sev = 'image';
return `
<div class="oumta-timeline-entry">
<div class="oumta-timeline-time">${formatTime(e.timestamp)}</div>
<div class="oumta-timeline-severity" data-sev="${sev}">${sev}</div>
<div>
<div class="oumta-timeline-message">${escapeHtml(e.message)}</div>
<div class="oumta-timeline-meta">${escapeHtml(e.workloadId)} · ${escapeHtml(e.reason || '')}</div>
</div>
</div>
`;
const clusters = clusterEvents(events);
let lastDate = null;
body.innerHTML = clusters.map(cluster => {
const sev = dominantSev(cluster.events);
const showDate = !lastDate || !sameDay(lastDate, cluster.ts);
lastDate = cluster.ts;
const d = new Date(cluster.ts);
const badge = showDate
? `<div class="oumta-tl-date" title="${escapeAttr(d.toDateString())}">
<span class="day">${d.getDate()}</span>
<span class="mon">${TL_MONTHS[d.getMonth()]}</span>
</div>`
: '';
const left = `
<div class="oumta-tl-left">
${badge}
<div class="oumta-tl-dot" data-sev="${sev}"></div>
<div class="oumta-tl-time">${escapeHtml(formatTimeShort(cluster.ts))}</div>
</div>`;
const right = `
<div class="oumta-tl-right">
${cluster.events.map(e => {
const esev = eventSev(e);
const wid = e.workloadId || '';
return `
<div class="oumta-tl-event">
<div class="oumta-tl-event-head">
<span class="oumta-tl-severity" data-sev="${esev}">${esev}</span>
<span class="oumta-tl-workload" data-wlid="${escapeAttr(wid)}" title="filter by this workload">${escapeHtml(workloadShort(wid))}</span>
<span class="oumta-tl-reason">${escapeHtml(e.reason || '')}</span>
</div>
<div class="oumta-tl-message">${escapeHtml(e.message || '')}</div>
</div>
`;
}).join('')}
</div>`;
return `<div class="oumta-timeline-entry">${left}${right}</div>`;
}).join('');
// Tapping a workload name inside an event filters the timeline to it.
body.querySelectorAll('.oumta-tl-workload').forEach(el => {
el.addEventListener('click', () => setTimelineFilter(el.dataset.wlid));
});
}
function setTimelineFilter(workloadId) {
state.timelineFilter = workloadId || null;
renderTimeline();
}
/** Render the workload quick-pick bar. "all" + distinct workloads seen in
* the current timeline, capped at N inline pills — overflow collapses
* into a dropdown. Deduplicated by workloadId, ranked by most recent
* event so the pills you care about stay visible. */
function renderTimelineFilters() {
const host = $('oumta-timeline-filters');
if (!host) return;
// In workload-detail scope (opened via a card click) the filter bar is
// redundant — we're already showing one workload's events.
if (state.data.scopeWorkload) { host.innerHTML = ''; return; }
const seen = new Map(); // workloadId -> earliest-seen index (= recency)
state.data.timeline.forEach((e, i) => {
if (e.workloadId && !seen.has(e.workloadId)) seen.set(e.workloadId, i);
});
const workloads = [...seen.keys()];
if (!workloads.length) { host.innerHTML = ''; return; }
const INLINE_LIMIT = 6;
const inline = workloads.slice(0, INLINE_LIMIT);
const overflow = workloads.slice(INLINE_LIMIT);
const active = state.timelineFilter;
const pill = (id, label, isActive) =>
`<button class="oumta-tl-pill${isActive ? ' active' : ''}" data-wlid="${escapeAttr(id)}">${escapeHtml(label)}</button>`;
const overflowActive = overflow.includes(active);
host.innerHTML = [
pill('', 'all', !active),
...inline.map(id => pill(id, workloadShort(id), id === active)),
overflow.length ? `
<div class="oumta-tl-more">
<button class="oumta-tl-pill${overflowActive ? ' active' : ''}" data-tl-more="1">+${overflow.length} more<i class="ph ph-caret-down" style="font-size:10px;margin-left:4px;"></i></button>
<div class="oumta-tl-more-menu hidden">
${overflow.map(id => `<button class="${id === active ? 'active' : ''}" data-wlid="${escapeAttr(id)}">${escapeHtml(workloadShort(id))}</button>`).join('')}
</div>
</div>` : '',
].filter(Boolean).join('');
host.querySelectorAll('.oumta-tl-pill[data-wlid]').forEach(btn => {
btn.addEventListener('click', () => setTimelineFilter(btn.dataset.wlid || null));
});
const moreBtn = host.querySelector('[data-tl-more]');
const moreMenu = host.querySelector('.oumta-tl-more-menu');
if (moreBtn && moreMenu) {
moreBtn.addEventListener('click', (e) => {
e.stopPropagation();
moreMenu.classList.toggle('hidden');
});
moreMenu.querySelectorAll('button[data-wlid]').forEach(btn => {
btn.addEventListener('click', () => {
setTimelineFilter(btn.dataset.wlid || null);
moreMenu.classList.add('hidden');
});
});
document.addEventListener('click', (e) => {
if (!moreMenu.contains(e.target) && e.target !== moreBtn) moreMenu.classList.add('hidden');
}, { once: true });
}
}
// ── Workload → detail-as-timeline navigation ──────────────────