diff --git a/config.php b/config.php index 3c4f5f8..911f74c 100644 --- a/config.php +++ b/config.php @@ -1,7 +1,7 @@ back to all all events + +
No events.
diff --git a/public/plugins/oumta-dashboard/oumta-dashboard.js b/public/plugins/oumta-dashboard/oumta-dashboard.js index 0fa629a..b199263 100644 --- a/public/plugins/oumta-dashboard/oumta-dashboard.js +++ b/public/plugins/oumta-dashboard/oumta-dashboard.js @@ -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 = '
No events.
'; 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 ` -
-
${formatTime(e.timestamp)}
-
${sev}
-
-
${escapeHtml(e.message)}
-
${escapeHtml(e.workloadId)} · ${escapeHtml(e.reason || '')}
-
-
- `; + 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 + ? `
+ ${d.getDate()} + ${TL_MONTHS[d.getMonth()]} +
` + : ''; + const left = ` +
+ ${badge} +
+
${escapeHtml(formatTimeShort(cluster.ts))}
+
`; + const right = ` +
+ ${cluster.events.map(e => { + const esev = eventSev(e); + const wid = e.workloadId || ''; + return ` +
+
+ ${esev} + ${escapeHtml(workloadShort(wid))} + ${escapeHtml(e.reason || '')} +
+
${escapeHtml(e.message || '')}
+
+ `; + }).join('')} +
`; + return `
${left}${right}
`; }).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) => + ``; + + const overflowActive = overflow.includes(active); + host.innerHTML = [ + pill('', 'all', !active), + ...inline.map(id => pill(id, workloadShort(id), id === active)), + overflow.length ? ` +
+ + +
` : '', + ].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 ──────────────────