Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion internal/admin/dashboard/static/dist/index.html

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

40 changes: 33 additions & 7 deletions web/dashboard/src/pages/audit-logs/audit-logic.js
Original file line number Diff line number Diff line change
Expand Up @@ -249,13 +249,39 @@ export function auditLogFromSessions(payload) {
};
}

// auditThreadChildEntries drops the head row from a session_id page so the
// unfolded children list holds only the older requests.
export function auditThreadChildEntries(entries, head) {
const headKeys = new Set(auditEntryIdentityKeys(head));
return (Array.isArray(entries) ? entries : []).filter((entry) => {
return !auditEntryIdentityKeys(entry).some((key) => headKeys.has(key));
});
// mergeAuditThreadChildren folds a fetched session_id page into a thread's
// children slot: head rows are dropped (the unfolded list holds only the
// older requests), and still-live entries from the previous partial list that
// the server does not return yet (displaced from the head list before being
// persisted) are preserved on top. preservedCount lets the caller keep the
// thread total honest.
export function mergeAuditThreadChildren(previousList, fetchedEntries, heads) {
const headKeys = new Set(
(Array.isArray(heads) ? heads : []).flatMap((head) =>
auditEntryIdentityKeys(head),
),
);
const fetched = (Array.isArray(fetchedEntries) ? fetchedEntries : []).filter(
(entry) => !auditEntryIdentityKeys(entry).some((key) => headKeys.has(key)),
);
const knownKeys = new Set([
...headKeys,
...fetched.flatMap((entry) => auditEntryIdentityKeys(entry)),
]);
const preserved = (
previousList && Array.isArray(previousList.entries)
? previousList.entries
: []
).filter(
(entry) =>
entry &&
entry._live &&
!auditEntryIdentityKeys(entry).some((key) => knownKeys.has(key)),
);
return {
entries: [...preserved, ...fetched],
preservedCount: preserved.length,
};
}

export function toggleExpandedThread(expanded, sessionId) {
Expand Down
74 changes: 60 additions & 14 deletions web/dashboard/src/pages/audit-logs/auditList.svelte.js
Original file line number Diff line number Diff line change
Expand Up @@ -24,10 +24,10 @@ import {
auditLogFromSessions,
auditLogWithLiveEntries,
auditSessionId,
auditThreadChildEntries,
buildAuditLogQuery,
buildAuditSessionQuery,
markExpandedEntry,
mergeAuditThreadChildren,
pruneExpandedEntries,
pruneThreadMap,
toggleExpandedThread,
Expand Down Expand Up @@ -203,17 +203,55 @@ class AuditListStore {
this.auditExpandedThreads,
sessionId,
);
if (expandedNow && !liveLogs.auditThreadChildren[sessionId]) {
// A partial list (loaded: false) holds only live-displaced entries; the
// full session page still needs the fetch.
const list = liveLogs.auditThreadChildren[sessionId];
if (expandedNow && !(list && (list.loaded || list.loading))) {
await this.fetchThreadEntries(entry);
}
}

async fetchThreadEntries(head) {
const sessionId = auditSessionId(head);
if (!sessionId) return;
const previous = liveLogs.auditThreadChildren[sessionId];
liveLogs.auditThreadChildren = {
...liveLogs.auditThreadChildren,
[sessionId]: { loading: true, entries: [], total: 0 },
[sessionId]: {
loading: true,
loaded: false,
entries:
previous && Array.isArray(previous.entries) ? previous.entries : [],
total: Number((previous && previous.total) || 0),
},
};
// On a failed/stale fetch, keep any live-displaced entries but drop the
// loading placeholder so the next expand retries (leaving it would render
// a spinner forever). With nothing to show, also collapse the thread —
// left expanded, the next click would read as a collapse and push the
// retry two clicks away.
const restore = () => {
const lists = { ...liveLogs.auditThreadChildren };
const current = lists[sessionId];
const entries =
current && Array.isArray(current.entries) ? current.entries : [];
if (entries.length > 0) {
lists[sessionId] = {
loading: false,
loaded: false,
entries,
total: entries.length,
};
} else {
delete lists[sessionId];
if (this.auditExpandedThreads[sessionId]) {
this.auditExpandedThreads = toggleExpandedThread(
this.auditExpandedThreads,
sessionId,
);
}
}
liveLogs.auditThreadChildren = lists;
};
Comment thread
coderabbitai[bot] marked this conversation as resolved.
try {
const qs = buildAuditSessionQuery({
Expand All @@ -224,28 +262,36 @@ class AuditListStore {
label: "audit session",
});
if (result.stale) {
// Silently drop the loading placeholder so the next toggle retries
// (leaving it would render a spinner forever).
const next = { ...liveLogs.auditThreadChildren };
delete next[sessionId];
liveLogs.auditThreadChildren = next;
restore();
return;
}
if (!result.ok) throw new Error("audit session fetch failed");
// Re-read the slot and the on-screen head: live events during the fetch
// may have displaced more rows into it or replaced the thread head.
// Only the CURRENT head is excluded from the children — when the head
// changed mid-flight, the original head is now a demoted child that the
// fetched page must keep contributing.
const current = liveLogs.auditThreadChildren[sessionId];
const currentHead = this.auditLog.entries.find(
(entry) => auditSessionId(entry) === sessionId,
);
const merged = mergeAuditThreadChildren(
current,
result.data.entries,
currentHead ? [currentHead] : [head],
);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
liveLogs.auditThreadChildren = {
...liveLogs.auditThreadChildren,
[sessionId]: {
loading: false,
entries: auditThreadChildEntries(result.data.entries, head),
total: Number(result.data.total || 0),
loaded: true,
entries: merged.entries,
total: Number(result.data.total || 0) + merged.preservedCount,
},
};
} catch (e) {
console.error("Failed to fetch audit session entries:", e);
// Drop the placeholder so the next toggle retries the fetch.
const next = { ...liveLogs.auditThreadChildren };
delete next[sessionId];
liveLogs.auditThreadChildren = next;
restore();
}
}

Expand Down
101 changes: 48 additions & 53 deletions web/dashboard/src/pages/audit-logs/live-logs-logic.js
Original file line number Diff line number Diff line change
Expand Up @@ -132,66 +132,33 @@ export function liveLogsMethods() {
const currentEntries = (this.auditLog && Array.isArray(this.auditLog.entries)) ? this.auditLog.entries : [];
const index = currentEntries.findIndex((entry) => matchesLiveAuditKey(entry, key, requestID));
const previous = index >= 0 ? currentEntries[index] || {} : {};
if (eventType === 'audit.detail') {
// A detail event IS the fetched detail: re-triggering the detail
// fetch for it would loop.
const isDetail = eventType === 'audit.detail';
const patch = isDetail
// The detail entry carries the full payload, so the slim-list
// marker must not survive the merge from the previous row.
const patch = { ...incoming, _detail_loaded: true, _response_partial: false, bodies_omitted: false };
if (index >= 0) {
const merged = this.mergeLiveAuditPatch(previous, patch);
currentEntries.splice(index, 1, merged);
this.auditLog.entries = [...currentEntries];
// Regrouping may demote this row to a thread child; the
// conversation hook still targets the updated row itself.
this.regroupLiveAuditHead(merged);
this.notifyLiveConversation(merged);
return merged;
}
const child = this.mergeLiveAuditChild(incoming, patch);
if (child) {
this.notifyLiveConversation(child);
return child;
}
if (!this.auditLiveInsertAllowed()) return;
this.auditLog.entries = [this.mergeLiveAuditUsagePatch(patch), ...currentEntries].slice(0, this.auditLog.limit || 25);
this.auditLog.total = Number(this.auditLog.total || 0) + 1;
return this.auditLog.entries[0];
}
const liveState = this.liveAuditStateAfter(previous._live_state, eventType);
const auditFlushed = this.liveAuditEventFlushed(previous._live_state) || this.liveAuditEventFlushed(liveState);
const patch = { ...incoming, _live: true, _live_state: liveState, _audit_flushed: auditFlushed };
if (!auditFlushed) {
patch._live_pending = true;
} else {
patch._live_pending = false;
}
// A stream event's response body is a partial reconstruction of a
// still-running stream; the flag drops once a settled state
// delivers the real body. Other events leave the previous flag
// untouched.
if (eventType === 'audit.stream') {
patch._response_partial = true;
} else if (this.liveAuditStateSettled(eventType)) {
patch._response_partial = false;
}
? { ...incoming, _detail_loaded: true, _response_partial: false, bodies_omitted: false }
: this.liveAuditPatch(previous, incoming, eventType);
if (index >= 0) {
const merged = this.mergeLiveAuditPatch(previous, patch);
currentEntries.splice(index, 1, merged);
this.auditLog.entries = [...currentEntries];
// Regrouping may demote this row to a thread child; the detail
// and conversation hooks still target the updated row itself.
this.regroupLiveAuditHead(merged);
this.fetchExpandedAuditDetailIfReady(merged);
if (!isDetail) this.fetchExpandedAuditDetailIfReady(merged);
this.notifyLiveConversation(merged);
return merged;
}
const child = this.mergeLiveAuditChild(incoming, patch);
if (child) {
this.fetchExpandedAuditDetailIfReady(child);
if (!isDetail) this.fetchExpandedAuditDetailIfReady(child);
this.notifyLiveConversation(child);
return child;
}
if (!this.auditLiveInsertAllowed()) return;
if (this.auditGroupSessions) {
if (!isDetail && this.auditGroupSessions) {
const folded = this.foldLiveAuditIntoThread(patch);
if (folded) {
this.fetchExpandedAuditDetailIfReady(folded);
Expand All @@ -202,11 +169,35 @@ export function liveLogsMethods() {
this.auditLog.entries = [this.mergeLiveAuditUsagePatch(patch), ...currentEntries].slice(0, this.auditLog.limit || 25);
this.auditLog.total = Number(this.auditLog.total || 0) + 1;
const inserted = this.auditLog.entries[0];
this.fetchExpandedAuditDetailIfReady(inserted);
if (!isDetail) this.fetchExpandedAuditDetailIfReady(inserted);
this.notifyLiveConversation(inserted);
return inserted;
},

// liveAuditPatch stamps the live lifecycle state onto an incoming
// event's data, ratcheting _live_state forward from the previous row.
liveAuditPatch(previous, incoming, eventType) {
const liveState = this.liveAuditStateAfter(previous._live_state, eventType);
const auditFlushed = this.liveAuditEventFlushed(previous._live_state) || this.liveAuditEventFlushed(liveState);
const patch = {
...incoming,
_live: true,
_live_state: liveState,
_audit_flushed: auditFlushed,
_live_pending: !auditFlushed
};
// A stream event's response body is a partial reconstruction of a
// still-running stream; the flag drops once a settled state
// delivers the real body. Other events leave the previous flag
// untouched.
if (eventType === 'audit.stream') {
patch._response_partial = true;
} else if (this.liveAuditStateSettled(eventType)) {
patch._response_partial = false;
}
return patch;
},

// --- Session-thread grouping ---------------------------------------
// With "Group by session" on, list entries are thread heads (carrying
// session_count) and each unfolded thread keeps its older entries in
Expand Down Expand Up @@ -241,8 +232,8 @@ export function liveLogsMethods() {
// stamps the context) joins its thread once a later event delivers the
// session id: the NEWEST of the two rows becomes the thread head — the
// event that happens to complete last is not necessarily the newest
// request — the other moves into the loaded children, and the two rows
// collapse into one thread (total shrinks by one).
// request — the other is retained in the thread's children slot, and
// the two rows collapse into one thread (total shrinks by one).
regroupLiveAuditHead(entry) {
if (!this.auditGroupSessions) return null;
const sessionId = String((entry && entry.session_id) || '').trim();
Expand Down Expand Up @@ -300,21 +291,25 @@ export function liveLogsMethods() {
return newHead;
},

// prependLiveAuditThreadChild retains a displaced thread member in the
// session's children slot. When the thread was never expanded it
// creates a partial list (loaded: false, so expanding still triggers
// the full fetch) — dropping the entry instead would make its later
// live events look like brand-new requests and inflate the thread
// count on every event.
prependLiveAuditThreadChild(sessionId, entry) {
const lists = this.auditThreadChildren;
const list = lists && lists[sessionId];
// Not loaded yet: the lazy children fetch will include this entry.
if (!list || !Array.isArray(list.entries)) return;
const lists = this.auditThreadChildren || {};
const list = lists[sessionId];
const child = { ...entry };
delete child.session_count;
this.auditThreadChildren = {
...lists,
[sessionId]: {
const next = list && Array.isArray(list.entries)
? {
...list,
entries: [child, ...list.entries],
total: Number(list.total || list.entries.length) + 1
}
};
: { loading: false, loaded: false, entries: [child], total: 1 };
this.auditThreadChildren = { ...lists, [sessionId]: next };
},

removeLiveAuditThreadChild(id, requestID) {
Expand Down
Loading