From 4aca21812e838bb7e475e51c289467a8a8c42134 Mon Sep 17 00:00:00 2001
From: th
Date: Sat, 18 Apr 2026 17:21:18 +0200
Subject: [PATCH] =?UTF-8?q?v2.0.8=20=E2=80=94=20oumta=20timeline:=20contin?=
=?UTF-8?q?uous=20fading=20line,=20multi-select=20filter?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- Connector: moved from per-entry pseudo-elements to a single
linear-gradient line on the body. Fades in/out 28px at each end
and no longer has gaps at entry boundaries. Dots and the date
rect's solid background sit naturally on top; everything else
stays transparent so the line threads through cleanly.
- Layout: entry is now a 2-row grid. The optional date rect lives
in row 1 (justify-self:start, left-aligned), while the dot-row
and the events column both pin to row 2 so the time label always
lines up with the first event of the cluster — even when a date
rect sits above. Time+dot `align-self: start` keeps them at the
top of the event section instead of vertically centred through
the whole cluster.
- Filters: state.timelineFilter is a Set now, so clicking multiple
workload pills (or the overflow entries) stacks them as an
any-of filter. "all" clears the set. The overflow dropdown stays
open on pick so several can be toggled without reopening it each
time. Scope label shows "filtered · N" with the active count.
Co-Authored-By: Claude Opus 4.7 (1M context)
---
config.php | 2 +-
electron/package.json | 2 +-
.../oumta-dashboard/oumta-dashboard.css | 70 +++++++++++++++----
.../oumta-dashboard/oumta-dashboard.js | 55 +++++++++------
4 files changed, 92 insertions(+), 37 deletions(-)
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 ? `