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:
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user