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

+1 -1
View File
@@ -1,7 +1,7 @@
<?php
define('APP_NAME', 'hyprcrd');
define('APP_VERSION', '2.0.6');
define('APP_VERSION', '2.0.7');
// APP_DEBUG: gates verbose error responses (curl traces, upstream bodies).
// Off by default; enable via env for local troubleshooting only.
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "hyprcrd",
"version": "2.0.6",
"version": "2.0.7",
"description": "hyprcrd — self-hosted card manager",
"main": "main.js",
"scripts": {
@@ -197,32 +197,160 @@
}
.oumta-btn-ghost:hover { border-color: var(--t-accent); color: var(--t-accent); }
.oumta-timeline-body {
display: flex; flex-direction: column; gap: 2px;
border-left: 2px solid var(--t-border);
padding-left: 16px;
/* Workload filter pills: inline quick-pick. Horizontal-scrolls if the row
* runs out of space; an overflow dropdown is injected from JS when the
* pill count would blow out past the container. */
.oumta-tl-filters {
display: flex; gap: 6px;
margin-bottom: 16px;
flex-wrap: wrap;
align-items: center;
}
.oumta-timeline-entry {
display: grid;
grid-template-columns: 140px 80px 1fr;
gap: 10px;
padding: 8px 0;
border-bottom: 1px solid var(--t-border);
align-items: baseline;
}
.oumta-timeline-time { font-family: var(--font-content, monospace); font-size: 11px; color: var(--t-dim); }
.oumta-timeline-severity {
.oumta-tl-pill {
font-family: var(--font-pixel, 'Silkscreen', cursive);
font-size: 9px;
text-transform: uppercase;
letter-spacing: 0.5px;
padding: 4px 9px;
background: transparent;
border: 1px solid var(--t-border);
color: var(--t-muted);
cursor: pointer;
border-radius: var(--ui-radius-button);
transition: all 0.12s;
white-space: nowrap;
}
.oumta-timeline-severity[data-sev="info"] { color: var(--t-accent); }
.oumta-timeline-severity[data-sev="warning"] { color: #fb923c; }
.oumta-timeline-severity[data-sev="error"] { color: var(--t-danger); }
.oumta-timeline-severity[data-sev="recovered"] { color: #4ade80; }
.oumta-timeline-severity[data-sev="image"] { color: #60a5fa; }
.oumta-timeline-message { font-size: 12px; color: var(--t-bright); }
.oumta-timeline-meta { font-size: 10px; color: var(--t-dim); margin-top: 2px; }
.oumta-tl-pill:hover { border-color: var(--t-accent); color: var(--t-accent); }
.oumta-tl-pill.active { background: var(--t-accent); border-color: var(--t-accent); color: var(--t-bg); }
.oumta-tl-more {
position: relative;
}
.oumta-tl-more-menu {
position: absolute;
top: calc(100% + 4px); left: 0;
background: var(--t-surface);
border: 1px solid var(--t-border);
border-radius: var(--ui-radius-card);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
padding: 4px;
z-index: 10;
min-width: 200px;
max-height: 300px;
overflow-y: auto;
}
.oumta-tl-more-menu button {
display: block; width: 100%; text-align: left;
padding: 6px 10px;
background: transparent; border: none;
color: var(--t-text);
font-family: var(--font-content, monospace);
font-size: 11px;
cursor: pointer;
border-radius: var(--ui-radius-input);
}
.oumta-tl-more-menu button:hover { background: var(--t-bg); color: var(--t-bright); }
.oumta-tl-more-menu button.active { color: var(--t-accent); }
/* Timeline body: two-column layout (left = date badge + time + dot, right
* = event list). Clusters of events that fired within the same tolerance
* window get a single row with all sub-events listed in the right column. */
.oumta-timeline-body {
display: flex; flex-direction: column;
}
.oumta-timeline-entry {
display: grid;
grid-template-columns: 64px 1fr;
gap: 14px;
padding: 10px 0 14px;
position: relative;
align-items: flex-start;
}
.oumta-timeline-entry + .oumta-timeline-entry { border-top: 1px solid var(--t-border); }
.oumta-tl-left {
display: flex; flex-direction: column;
align-items: center;
gap: 6px;
min-height: 40px;
}
.oumta-tl-date {
display: flex; flex-direction: column;
align-items: center;
line-height: 1;
border: 1px solid var(--t-border);
border-radius: var(--ui-radius-card);
padding: 4px 0 5px;
width: 44px;
background: var(--t-bg);
}
.oumta-tl-date .day {
font-family: var(--font-content, monospace);
font-size: 18px; font-weight: 700;
color: var(--t-bright);
}
.oumta-tl-date .mon {
font-family: var(--font-pixel, 'Silkscreen', cursive);
font-size: 8px;
color: var(--t-dim);
text-transform: uppercase;
letter-spacing: 0.5px;
margin-top: 1px;
}
.oumta-tl-time {
font-family: var(--font-content, monospace);
font-size: 10px;
color: var(--t-dim);
margin-top: 2px;
text-align: center;
}
.oumta-tl-dot {
width: 10px; height: 10px;
border-radius: 50%;
margin-top: 2px;
box-shadow: 0 0 0 3px color-mix(in srgb, var(--oumta-dot-color) 22%, transparent);
background: var(--oumta-dot-color);
}
.oumta-tl-dot[data-sev="info"] { --oumta-dot-color: var(--t-accent); }
.oumta-tl-dot[data-sev="warning"] { --oumta-dot-color: #fb923c; }
.oumta-tl-dot[data-sev="error"] { --oumta-dot-color: var(--t-danger); }
.oumta-tl-dot[data-sev="recovered"] { --oumta-dot-color: #4ade80; }
.oumta-tl-dot[data-sev="image"] { --oumta-dot-color: #60a5fa; }
.oumta-tl-right {
display: flex; flex-direction: column;
gap: 8px;
min-width: 0;
}
.oumta-tl-event {
display: flex; flex-direction: column;
gap: 2px;
min-width: 0;
}
.oumta-tl-event-head {
display: flex; align-items: baseline; gap: 8px;
flex-wrap: wrap;
}
.oumta-tl-severity {
font-family: var(--font-pixel, 'Silkscreen', cursive);
font-size: 9px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.oumta-tl-severity[data-sev="info"] { color: var(--t-accent); }
.oumta-tl-severity[data-sev="warning"] { color: #fb923c; }
.oumta-tl-severity[data-sev="error"] { color: var(--t-danger); }
.oumta-tl-severity[data-sev="recovered"] { color: #4ade80; }
.oumta-tl-severity[data-sev="image"] { color: #60a5fa; }
.oumta-tl-workload {
font-family: var(--font-content, monospace);
font-size: 11px;
color: var(--t-muted);
cursor: pointer;
transition: color 0.1s;
}
.oumta-tl-workload:hover { color: var(--t-accent); }
.oumta-tl-reason { font-size: 10px; color: var(--t-dim); }
.oumta-tl-message { font-size: 12px; color: var(--t-bright); line-height: 1.4; word-break: break-word; }
/* ── Live indicator ── */
.oumta-dot {
@@ -107,6 +107,9 @@
<button id="oumta-timeline-back" class="hidden oumta-btn-ghost"><i class="ph ph-caret-left text-sm"></i> back to all</button>
<span id="oumta-timeline-scope" class="font-pixel text-xs text-t-accent">all events</span>
</div>
<!-- Workload filter: quick-pick pills by default, a dropdown
trigger appears only when the pills don't fit. -->
<div id="oumta-timeline-filters" class="oumta-tl-filters"></div>
<div id="oumta-timeline-body" class="oumta-timeline-body">
<div class="oumta-empty">No events.</div>
</div>
+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 ──────────────────