diff --git a/config.php b/config.php index 911f74c..495c345 100644 --- a/config.php +++ b/config.php @@ -1,7 +1,7 @@ * { position: relative; z-index: 1; } +/* Time + dot share a horizontal row so clusters without a date badge + * show the time label LEFT of the dot, not stacked underneath. The row + * lives in grid row 2 so events in the right column (also row 2) stay + * vertically aligned with it — regardless of whether a date badge + * occupies row 1 above. */ +.oumta-tl-row { + display: flex; align-items: center; - gap: 6px; - min-height: 40px; + gap: 8px; + grid-column: 1; + grid-row: 2; + justify-self: end; /* hug the connector line on the right */ + align-self: start; /* don't stretch to the events column's full height — stay at the top */ } .oumta-tl-date { display: flex; flex-direction: column; @@ -281,7 +314,11 @@ border-radius: var(--ui-radius-card); padding: 4px 0 5px; width: 44px; - background: var(--t-bg); + background: var(--t-bg); /* solid, so the connector line behind doesn't strike through the text */ + grid-column: 1; + grid-row: 1; + justify-self: start; /* badge hugs the left edge of the column */ + margin-bottom: 8px; /* spacing before the dot-row below */ } .oumta-tl-date .day { font-family: var(--font-content, monospace); @@ -306,7 +343,12 @@ .oumta-tl-dot { width: 10px; height: 10px; border-radius: 50%; - margin-top: 2px; + /* Dot sits at the right end of the time+dot row, which align-items: + * flex-end of the left column pushes to the right edge (x=80). + * Translate by half the dot width so its centre lines up exactly with + * the connector line (also at x=80). */ + transform: translateX(5px); + flex-shrink: 0; box-shadow: 0 0 0 3px color-mix(in srgb, var(--oumta-dot-color) 22%, transparent); background: var(--oumta-dot-color); } @@ -318,8 +360,10 @@ .oumta-tl-right { display: flex; flex-direction: column; - gap: 8px; + gap: 23px; min-width: 0; + grid-column: 2; + grid-row: 2; /* pinned to the same row as .oumta-tl-row so events line up with the dot */ } .oumta-tl-event { display: flex; flex-direction: column; diff --git a/public/plugins/oumta-dashboard/oumta-dashboard.js b/public/plugins/oumta-dashboard/oumta-dashboard.js index b199263..f1197f2 100644 --- a/public/plugins/oumta-dashboard/oumta-dashboard.js +++ b/public/plugins/oumta-dashboard/oumta-dashboard.js @@ -23,7 +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) + timelineFilter: new Set(), // empty = all events; else any-of workloadIds (multi-select pills) }; // ── Central API surface ───────────────────────────────────────── @@ -314,14 +314,15 @@ function renderTimeline() { scopeEl.textContent = 'events for ' + scope; backEl.classList.remove('hidden'); } else { - scopeEl.textContent = state.timelineFilter ? 'filtered' : 'all events'; + const n = state.timelineFilter.size; + scopeEl.textContent = n === 0 ? 'all events' : `filtered · ${n}`; backEl.classList.add('hidden'); } 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; + if (filter.size === 0) return true; + return filter.has(e.workloadId); }); renderTimelineFilters(); @@ -345,10 +346,10 @@ function renderTimeline() { ` : ''; const left = ` -
- ${badge} -
-
${escapeHtml(formatTimeShort(cluster.ts))}
+ ${badge} +
+ ${escapeHtml(formatTimeShort(cluster.ts))} +
`; const right = `
@@ -370,14 +371,19 @@ function renderTimeline() { return `
${left}${right}
`; }).join(''); - // Tapping a workload name inside an event filters the timeline to it. + // Tapping a workload name inside an event toggles it in the filter set. body.querySelectorAll('.oumta-tl-workload').forEach(el => { - el.addEventListener('click', () => setTimelineFilter(el.dataset.wlid)); + el.addEventListener('click', () => toggleTimelineFilter(el.dataset.wlid)); }); } -function setTimelineFilter(workloadId) { - state.timelineFilter = workloadId || null; +/** Toggle a workload in the multi-select filter set. Passing an empty + * value clears the set (the "all" pill). */ +function toggleTimelineFilter(workloadId) { + const set = state.timelineFilter; + if (!workloadId) { set.clear(); } + else if (set.has(workloadId)) { set.delete(workloadId); } + else { set.add(workloadId); } renderTimeline(); } @@ -402,26 +408,29 @@ function renderTimelineFilters() { const INLINE_LIMIT = 6; const inline = workloads.slice(0, INLINE_LIMIT); const overflow = workloads.slice(INLINE_LIMIT); - const active = state.timelineFilter; + const active = state.timelineFilter; // Set const pill = (id, label, isActive) => ``; - const overflowActive = overflow.includes(active); + // "all" is active whenever the filter set is empty; picking it clears + // the set. The individual pills toggle their workload in the set so + // multiple filters can stack (any-of match). + const overflowActiveCount = overflow.reduce((n, id) => n + (active.has(id) ? 1 : 0), 0); host.innerHTML = [ - pill('', 'all', !active), - ...inline.map(id => pill(id, workloadShort(id), id === active)), + pill('', 'all', active.size === 0), + ...inline.map(id => pill(id, workloadShort(id), active.has(id))), overflow.length ? `
- +
` : '', ].filter(Boolean).join(''); host.querySelectorAll('.oumta-tl-pill[data-wlid]').forEach(btn => { - btn.addEventListener('click', () => setTimelineFilter(btn.dataset.wlid || null)); + btn.addEventListener('click', () => toggleTimelineFilter(btn.dataset.wlid || '')); }); const moreBtn = host.querySelector('[data-tl-more]'); const moreMenu = host.querySelector('.oumta-tl-more-menu'); @@ -430,10 +439,12 @@ function renderTimelineFilters() { e.stopPropagation(); moreMenu.classList.toggle('hidden'); }); + // Overflow picks don't close the menu — multi-select UX relies on + // being able to tick several without the menu collapsing every time. moreMenu.querySelectorAll('button[data-wlid]').forEach(btn => { - btn.addEventListener('click', () => { - setTimelineFilter(btn.dataset.wlid || null); - moreMenu.classList.add('hidden'); + btn.addEventListener('click', (e) => { + e.stopPropagation(); + toggleTimelineFilter(btn.dataset.wlid || ''); }); }); document.addEventListener('click', (e) => {