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:
1 parent
0553960a57
commit
9f90ea516a
5 files changed
+340
-48
No files matched your search
+1
-1
@@ -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,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>
|
||||
|
||||
@@ -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 ──────────────────
|
||||
|
||||
Reference in new issue
Block a user