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:
@@ -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,
|
||||
|
||||
@@ -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<void> {
|
||||
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 `<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> {
|
||||
if (_dashboardLoading) return;
|
||||
set_dashboardLoading(true);
|
||||
@@ -1143,8 +1275,8 @@ export async function loadDashboard(forceFullRender: boolean = false): Promise<v
|
||||
existingPerf.style.display = perfVisible ? '' : 'none';
|
||||
}
|
||||
const dynamic = container.querySelector('.dashboard-dynamic');
|
||||
if (dynamic && dynamic.innerHTML !== dynamicHtml) {
|
||||
dynamic.innerHTML = dynamicHtml;
|
||||
if (dynamic) {
|
||||
_reconcileDynamicSections(dynamic as HTMLElement, dynamicHtml);
|
||||
}
|
||||
_applyGlobalLayoutAttrs();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user