From 77284e8e7bc7b77b32719c4a1687d5251f9cab4f Mon Sep 17 00:00:00 2001 From: "alexei.dolgolyov" Date: Wed, 10 Jun 2026 12:28:13 +0300 Subject: [PATCH] fix(activity-log): dashboard section reconciliation + activity column alignment - dashboard full re-render now reconciles sections (only replaces changed ones) instead of wholesale .dashboard-dynamic innerHTML swap -> editing an entity no longer jumps the whole dashboard - Recent Activity widget live DOM + perf strip preserved across re-renders; widget skips re-fetch when already populated (no flash) - sweep stray non-section nodes so empty->populated doesn't leave an orphan 'no targets' banner (review-caught regression) - Activity list rows use a CSS grid (fixed badge/actor columns) so message column aligns consistently across rows --- plans/activity-log/PLAN.md | 3 + .../src/ledgrab/static/css/activity-log.css | 17 ++- .../ledgrab/static/js/features/dashboard.ts | 138 +++++++++++++++++- 3 files changed, 150 insertions(+), 8 deletions(-) diff --git a/plans/activity-log/PLAN.md b/plans/activity-log/PLAN.md index e32bc99..fe17718 100644 --- a/plans/activity-log/PLAN.md +++ b/plans/activity-log/PLAN.md @@ -113,6 +113,9 @@ is an on-demand CSV/JSON **export** (no separate backup subsystem). | Manual test | Recent Activity widget missing from Customize Dashboard | 🟡 Warning | resolved — registered as a first-class dashboard section (show/hide/reorder; pre-existing layouts preserved) | | Manual test | Activity widget live event rebuilt the whole dashboard | 🟡 Warning (perf) | resolved — surgical list update; single listener with teardown | | Manual test | Relative-time labels static (never tick) | 🟡 Warning | resolved — shared `ensureRelativeTimeTicker` (single 30s interval, visibility-aware) | +| Manual test | Dashboard fully rebuilt/jumped on entity edits (pre-existing `forceFullRender` wholesale innerHTML) | 🟡 Warning (UX) | resolved — section-level reconciliation (`_reconcileDynamicSections`): only changed sections replaced; widget live DOM + perf strip preserved | +| Manual test | Activity list columns slightly misaligned | 🔵 Note | resolved — CSS grid with fixed badge/actor columns | +| Manual test | Reconciler left orphan "no targets" node on empty→populated | 🟡 Warning (regression, caught in review) | resolved — sweep non-section top-level children | ## Final Review diff --git a/server/src/ledgrab/static/css/activity-log.css b/server/src/ledgrab/static/css/activity-log.css index 7ed7dc6..9ed094d 100644 --- a/server/src/ledgrab/static/css/activity-log.css +++ b/server/src/ledgrab/static/css/activity-log.css @@ -330,7 +330,11 @@ .al-entry-row { display: grid; - grid-template-columns: 24px 80px auto 1fr 2fr auto 20px; + /* icon | time | badge | actor | message | entity | chevron + badge is fixed so all category names (AUTH…CAPTURE) occupy identical + width; actor is capped so long usernames don't push the message over; + message takes all remaining space. */ + grid-template-columns: 24px 80px 78px minmax(0, 110px) 1fr auto 20px; align-items: center; gap: var(--space-sm); padding: var(--space-xs) var(--space-md); @@ -383,7 +387,7 @@ [data-theme="light"] .al-cat-capture { background: rgba(255, 152, 0, 0.08); } [data-theme="light"] .al-cat-system { background: rgba(120, 120, 120, 0.08); } -/* Actor */ +/* Actor — constrained by its grid column (minmax(0, 110px)) */ .al-actor { font-size: 0.8125rem; font-family: var(--font-mono); @@ -391,16 +395,18 @@ white-space: nowrap; overflow: hidden; text-overflow: ellipsis; - max-width: 160px; + min-width: 0; } -/* Message */ +/* Message — min-width:0 lets the 1fr column actually truncate */ .al-msg { font-size: 0.8125rem; color: var(--text-color); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; + min-width: 0; + text-align: left; } /* Entity crosslink */ @@ -562,7 +568,8 @@ @media (max-width: 900px) { .al-entry-row { - grid-template-columns: 20px 70px auto 1fr 18px; + /* icon | time | badge (fixed) | message | chevron — actor+entity hidden */ + grid-template-columns: 20px 70px 78px 1fr 18px; } /* Hide actor and entity link at small widths */ .al-actor, diff --git a/server/src/ledgrab/static/js/features/dashboard.ts b/server/src/ledgrab/static/js/features/dashboard.ts index 4820051..d21bc21 100644 --- a/server/src/ledgrab/static/js/features/dashboard.ts +++ b/server/src/ledgrab/static/js/features/dashboard.ts @@ -580,11 +580,22 @@ function renderDashboardPlaylist(playlist: ScenePlaylist): string { const RECENT_ACTIVITY_LIMIT = 5; let _recentActivityLiveListener: ((e: Event) => void) | null = null; -/** Fetch recent entries and populate the widget list container. */ +/** Fetch recent entries and populate the widget list container. + * Skips the network fetch when the widget already contains live entries + * (dal-list class is set by _renderRecentActivityList on first mount) so + * unrelated dashboard re-renders never re-fetch or flash the widget. */ async function _loadRecentActivityWidget(): Promise { const list = document.getElementById('dashboard-recent-activity-list'); if (!list) return; + // Widget already populated — only wire up live listener and ticker; + // don't re-fetch or overwrite the live content. + if (list.classList.contains('dal-list')) { + ensureRelativeTimeTicker(); + _startRecentActivityLive(); + return; + } + const entries = await fetchRecentEntries(RECENT_ACTIVITY_LIMIT); _renderRecentActivityList(list, entries); @@ -748,6 +759,127 @@ function _sectionContent(sectionKey: string, itemsHtml: string): string { return ``; } +/** + * Reconcile the `.dashboard-dynamic` container against newly-built HTML + * without a wholesale innerHTML replacement. + * + * Algorithm: + * 1. Parse `newHtml` into a detached container. + * 2. Build a map of existing live sections keyed by data-section. + * 3. For each section in the new HTML (in order): + * a. If the live DOM has that section AND it is content-stable + * (recent-activity with live list) OR its outerHTML hasn't + * changed — keep the live element. + * b. Otherwise replace / insert with the new element. + * 4. Remove sections that no longer appear in the new HTML. + * 5. Re-order to match the new order (move nodes, no recreation). + * + * The `recent-activity` section is treated as content-stable once + * its list has been populated (dal-list class), mirroring the perf- + * persistent pattern. The freshly-built loading placeholder in + * `newHtml` is never compared against the live-entry list — instead + * the live DOM node is always kept when it has real content. + */ +function _reconcileDynamicSections(dynamic: HTMLElement, newHtml: string): void { + // Parse incoming HTML into a scratch container. + const scratch = document.createElement('div'); + scratch.innerHTML = newHtml; + + // Gather incoming sections in order. + const incoming = Array.from( + scratch.querySelectorAll(':scope > .dashboard-section[data-section]') + ); + + // If the new HTML contains non-section top-level nodes (e.g. the + // `.dashboard-no-targets` placeholder shown when there are no entities), + // fall back to a simple innerHTML swap — this path is rare and the + // no-entities state doesn't have live widgets worth preserving. + const totalTopLevel = scratch.children.length; + if (totalTopLevel !== incoming.length) { + if (dynamic.innerHTML !== newHtml) dynamic.innerHTML = newHtml; + return; + } + + // Drop any stray non-section top-level nodes left over from a previous + // state (e.g. the `.dashboard-no-targets` placeholder shown when there + // were zero entities). The reconcile pass below only manages + // `.dashboard-section` children, so without this sweep that orphan node + // would linger over the freshly-populated dashboard. + for (const child of Array.from(dynamic.children)) { + if (!child.matches('.dashboard-section[data-section]')) child.remove(); + } + + // Index live sections by key. + const liveMap = new Map(); + for (const el of Array.from( + dynamic.querySelectorAll(':scope > .dashboard-section[data-section]') + )) { + liveMap.set(el.dataset.section as string, el); + } + + // Collect the keys that should remain (in new order). + const newKeys = new Set(incoming.map(el => el.dataset.section as string)); + + // Remove sections that are no longer present. + for (const [key, el] of liveMap) { + if (!newKeys.has(key)) el.remove(); + } + + // Walk incoming sections in order and reconcile each one. + let insertBefore: HTMLElement | null = null; // node to insert before (null = append) + for (let i = incoming.length - 1; i >= 0; i--) { + const newEl = incoming[i]; + const key = newEl.dataset.section as string; + const live = liveMap.get(key); + + let keep: HTMLElement; + + if (live) { + // Content-stable guard: the recent-activity section must not be + // replaced once it holds live entries — the new HTML only has the + // loading placeholder and would wipe the list. + const isRecentActivity = key === 'recent-activity'; + const raList = isRecentActivity + ? live.querySelector('#dashboard-recent-activity-list') + : null; + const raIsPopulated = raList !== null && raList.classList.contains('dal-list'); + + if (raIsPopulated) { + // Always keep the live recent-activity section as-is. + keep = live; + } else if (live.outerHTML === newEl.outerHTML) { + // Unchanged section — keep live DOM, no mutation. + keep = live; + } else { + // Section content changed — replace. + live.replaceWith(newEl); + liveMap.set(key, newEl); + keep = newEl; + } + } else { + // New section — insert it. + dynamic.appendChild(newEl); // temporary placement; ordering pass below + liveMap.set(key, newEl); + keep = newEl; + } + + // Re-order: after the ordering loop (reverse walk) each `keep` + // should end up just before the node we placed in the previous + // iteration (i+1). Using insertBefore to build correct order. + if (insertBefore === null) { + // Last in order — move to end of dynamic. + if (keep.nextElementSibling !== null || keep.parentElement !== dynamic) { + dynamic.appendChild(keep); + } + } else { + if (keep.nextElementSibling !== insertBefore || keep.parentElement !== dynamic) { + dynamic.insertBefore(keep, insertBefore); + } + } + insertBefore = keep; + } +} + export async function loadDashboard(forceFullRender: boolean = false): Promise { if (_dashboardLoading) return; set_dashboardLoading(true); @@ -1143,8 +1275,8 @@ export async function loadDashboard(forceFullRender: boolean = false): Promise