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
This commit is contained in:
2026-06-10 12:28:13 +03:00
parent ff1ff06cb5
commit 77284e8e7b
3 changed files with 150 additions and 8 deletions
+3
View File
@@ -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 | 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 | 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 | 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 ## Final Review
+12 -5
View File
@@ -330,7 +330,11 @@
.al-entry-row { .al-entry-row {
display: grid; 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; align-items: center;
gap: var(--space-sm); gap: var(--space-sm);
padding: var(--space-xs) var(--space-md); 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-capture { background: rgba(255, 152, 0, 0.08); }
[data-theme="light"] .al-cat-system { background: rgba(120, 120, 120, 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 { .al-actor {
font-size: 0.8125rem; font-size: 0.8125rem;
font-family: var(--font-mono); font-family: var(--font-mono);
@@ -391,16 +395,18 @@
white-space: nowrap; white-space: nowrap;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
max-width: 160px; min-width: 0;
} }
/* Message */ /* Message — min-width:0 lets the 1fr column actually truncate */
.al-msg { .al-msg {
font-size: 0.8125rem; font-size: 0.8125rem;
color: var(--text-color); color: var(--text-color);
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
min-width: 0;
text-align: left;
} }
/* Entity crosslink */ /* Entity crosslink */
@@ -562,7 +568,8 @@
@media (max-width: 900px) { @media (max-width: 900px) {
.al-entry-row { .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 */ /* Hide actor and entity link at small widths */
.al-actor, .al-actor,
@@ -580,11 +580,22 @@ function renderDashboardPlaylist(playlist: ScenePlaylist): string {
const RECENT_ACTIVITY_LIMIT = 5; const RECENT_ACTIVITY_LIMIT = 5;
let _recentActivityLiveListener: ((e: Event) => void) | null = null; 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<void> { async function _loadRecentActivityWidget(): Promise<void> {
const list = document.getElementById('dashboard-recent-activity-list'); const list = document.getElementById('dashboard-recent-activity-list');
if (!list) return; 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); const entries = await fetchRecentEntries(RECENT_ACTIVITY_LIMIT);
_renderRecentActivityList(list, entries); _renderRecentActivityList(list, entries);
@@ -748,6 +759,127 @@ function _sectionContent(sectionKey: string, itemsHtml: string): string {
return `<div class="dashboard-section-content"${isCollapsed ? ' style="display:none"' : ''}>${itemsHtml}</div>`; return `<div class="dashboard-section-content"${isCollapsed ? ' style="display:none"' : ''}>${itemsHtml}</div>`;
} }
/**
* 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<HTMLElement>(':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<string, HTMLElement>();
for (const el of Array.from(
dynamic.querySelectorAll<HTMLElement>(':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<void> { export async function loadDashboard(forceFullRender: boolean = false): Promise<void> {
if (_dashboardLoading) return; if (_dashboardLoading) return;
set_dashboardLoading(true); set_dashboardLoading(true);
@@ -1143,8 +1275,8 @@ export async function loadDashboard(forceFullRender: boolean = false): Promise<v
existingPerf.style.display = perfVisible ? '' : 'none'; existingPerf.style.display = perfVisible ? '' : 'none';
} }
const dynamic = container.querySelector('.dashboard-dynamic'); const dynamic = container.querySelector('.dashboard-dynamic');
if (dynamic && dynamic.innerHTML !== dynamicHtml) { if (dynamic) {
dynamic.innerHTML = dynamicHtml; _reconcileDynamicSections(dynamic as HTMLElement, dynamicHtml);
} }
_applyGlobalLayoutAttrs(); _applyGlobalLayoutAttrs();
} }