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
14 changes: 14 additions & 0 deletions src/Frontend/src/components/audit/AuditList.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -363,6 +363,20 @@ describe("FEATURE: Audit Messages Query State", () => {
});
});

describe("RULE: Rows that arrived since the previous refresh are marked so they can animate in", () => {
test("EXAMPLE: Only the rows the store reports as new carry the new-row marker", async () => {
const { store } = await renderAuditList([createMessage("msg-1"), createMessage("msg-2")]);
await waitForFirstLoadToComplete();

store.newMessageIds = ["msg-2"];
await nextTick();

const [first, second] = getMessageItems();
expect(first).not.toHaveClass("new-row");
expect(second).toHaveClass("new-row");
});
});

describe("RULE: A failed query tells the user what happened and what to try", () => {
test("EXAMPLE: The error banner is shown after a failed query", async () => {
const { store } = await renderAuditList([]);
Expand Down
46 changes: 44 additions & 2 deletions src/Frontend/src/components/audit/AuditList.vue
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,8 @@ import { useConfigurationStore } from "@/stores/ConfigurationStore";
import { loadDefaultRange, narrowingPresets, resolveTimeRange, type RangePreset } from "@/components/audit/timeRange";

const store = useAuditStore();
const { messages, totalCount, sortBy, messageFilterString, selectedEndpointName, itemsPerPage, timeRangeFrom, timeRangeTo, queryFailed, queryDurationMs, queryCompletedAt } = storeToRefs(store);
const { messages, newMessageIds, totalCount, sortBy, messageFilterString, selectedEndpointName, itemsPerPage, timeRangeFrom, timeRangeTo, queryFailed, queryDurationMs, queryCompletedAt } = storeToRefs(store);
const newRowIds = computed(() => new Set(newMessageIds.value));
const route = useRoute();
const router = useRouter();
const autoRefreshValue = ref<number | null>(null);
Expand Down Expand Up @@ -223,7 +224,7 @@ watch(autoRefreshValue, (newValue) => {
visible and usable: the refresh button already signals the running query -->
<LoadingSpinner v-if="firstLoad || (isRefreshing && messages.length === 0)" />
<template v-for="message in messages" :key="message.id">
<AuditListItem :message="message" />
<AuditListItem :message="message" :class="{ 'new-row': newRowIds.has(message.id) }" />
</template>
</div>
</div>
Expand Down Expand Up @@ -339,4 +340,45 @@ watch(autoRefreshValue, (newValue) => {
.results-table > :not(.item) {
grid-column: 1 / -1;
}

/* A row that arrived since the previous refresh of the same query slides in and
glows briefly, so what changed is visible without hunting for it. The glow
lasts a few seconds because auto-refresh ticks are seconds apart, and it plays
once per row: the element is new to the DOM (keyed by id), so the animation
starts on insertion and does not restart on later renders. */
.results-table > .new-row {
animation: new-row-arrive 3s ease-out;
}

@keyframes new-row-arrive {
0% {
opacity: 0;
transform: translateY(-0.5rem);
background-color: #d3ebf2;
}
12% {
opacity: 1;
transform: none;
background-color: #d3ebf2;
}
100% {
background-color: transparent;
}
}

@media (prefers-reduced-motion: reduce) {
.results-table > .new-row {
animation: new-row-glow 3s ease-out;
}

@keyframes new-row-glow {
0%,
40% {
background-color: #d3ebf2;
}
100% {
background-color: transparent;
}
}
}
</style>
65 changes: 65 additions & 0 deletions src/Frontend/src/stores/AuditStore.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,55 @@ describe("AuditStore refresh", () => {
expect(store.queryFailed).toBe(false);
});

describe("new rows since the previous result of the same query", () => {
const msg = (id: string) => ({ id });

test("rows that were not in the previous result are marked new", async () => {
const store = useAuditStore();
fetchTypedFromServiceControl.mockResolvedValueOnce([responseWithTotalCount(1), [msg("msg-1")]]);
await store.refresh();

fetchTypedFromServiceControl.mockResolvedValueOnce([responseWithTotalCount(2), [msg("msg-2"), msg("msg-1")]]);
await store.refresh();

expect(store.newMessageIds).toEqual(["msg-2"]);
});

test("the first result is never marked new", async () => {
const store = useAuditStore();
fetchTypedFromServiceControl.mockResolvedValueOnce([responseWithTotalCount(2), [msg("msg-1"), msg("msg-2")]]);

await store.refresh();

expect(store.newMessageIds).toEqual([]);
});

test("a changed query marks nothing new, even when every row differs", async () => {
const store = useAuditStore();
fetchTypedFromServiceControl.mockResolvedValueOnce([responseWithTotalCount(1), [msg("msg-1")]]);
await store.refresh();

store.messageFilterString = "orders";
fetchTypedFromServiceControl.mockResolvedValueOnce([responseWithTotalCount(1), [msg("msg-9")]]);
await store.refresh();

expect(store.newMessageIds).toEqual([]);
});

test("the result after a failed query is not marked new", async () => {
const store = useAuditStore();
fetchTypedFromServiceControl.mockResolvedValueOnce([responseWithTotalCount(1), [msg("msg-1")]]);
await store.refresh();
fetchTypedFromServiceControl.mockRejectedValueOnce(new Error("Internal Server Error"));
await store.refresh();

fetchTypedFromServiceControl.mockResolvedValueOnce([responseWithTotalCount(2), [msg("msg-2"), msg("msg-1")]]);
await store.refresh();

expect(store.newMessageIds).toEqual([]);
});
});

test("a failed query flags the failure instead of throwing", async () => {
fetchTypedFromServiceControl.mockRejectedValue(new Error("Internal Server Error"));
const store = useAuditStore();
Expand Down Expand Up @@ -185,6 +234,22 @@ describe("AuditStore refresh", () => {
expect(store.queryCompletedAt).toBeNull();
});

test("clearResults resets the new-row baseline, so the next result marks nothing new", async () => {
const store = useAuditStore();
fetchTypedFromServiceControl.mockResolvedValueOnce([responseWithTotalCount(1), [{ id: "msg-1" }]]);
await store.refresh();
fetchTypedFromServiceControl.mockResolvedValueOnce([responseWithTotalCount(2), [{ id: "msg-2" }, { id: "msg-1" }]]);
await store.refresh();
expect(store.newMessageIds).toEqual(["msg-2"]);

store.clearResults();
expect(store.newMessageIds).toEqual([]);

fetchTypedFromServiceControl.mockResolvedValueOnce([responseWithTotalCount(3), [{ id: "msg-3" }, { id: "msg-2" }, { id: "msg-1" }]]);
await store.refresh();
expect(store.newMessageIds).toEqual([]);
});

test("a superseded query is not reported as a failure", async () => {
const store = useAuditStore();

Expand Down
26 changes: 26 additions & 0 deletions src/Frontend/src/stores/AuditStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,12 @@ export const useAuditStore = defineStore("AuditStore", () => {
const queryDurationMs = ref<number | null>(null);
const queryCompletedAt = ref<string | null>(null);
const searchHistory = ref(loadSearchHistory());
// Ids of rows in the current results that were absent from the previous result of the
// same query (e.g. arrived through auto-refresh), so the view can animate their arrival.
// Empty for the first result and whenever the query itself changed: then every row is
// different and highlighting them all says nothing.
const newMessageIds = ref<string[]>([]);
let previousResultsQueryKey: string | null = null;
let activeQuery: AbortController | null = null;

async function loadEndpoints() {
Expand Down Expand Up @@ -88,6 +94,21 @@ export const useAuditStore = defineStore("AuditStore", () => {
}

totalCount.value = parseInt(response.headers.get("total-count") ?? "0");
const queryKey = JSON.stringify({
endpoint: selectedEndpointName.value,
from: timeRangeFrom.value,
to: timeRangeTo.value,
filter: messageFilterString.value,
pageSize: itemsPerPage.value,
sort: sortByInstances.value,
});
if (queryKey === previousResultsQueryKey) {
const previousIds = new Set(messages.value.map((m) => m.id));
newMessageIds.value = data.filter((m) => !previousIds.has(m.id)).map((m) => m.id);
} else {
newMessageIds.value = [];
}
previousResultsQueryKey = queryKey;
messages.value = data;
queryFailed.value = false;
queryDurationMs.value = Math.round(performance.now() - started);
Expand All @@ -102,6 +123,8 @@ export const useAuditStore = defineStore("AuditStore", () => {
// and surfaces here as a failed response. Not rethrown: the callers are watchers, so a
// rethrow would only become an unhandled rejection instead of user feedback.
messages.value = [];
newMessageIds.value = [];
previousResultsQueryKey = null;
totalCount.value = 0;
queryFailed.value = true;
} finally {
Expand Down Expand Up @@ -134,6 +157,8 @@ export const useAuditStore = defineStore("AuditStore", () => {
queryFailed.value = false;
queryDurationMs.value = null;
queryCompletedAt.value = null;
newMessageIds.value = [];
previousResultsQueryKey = null;
}

return {
Expand All @@ -143,6 +168,7 @@ export const useAuditStore = defineStore("AuditStore", () => {
loadEndpoints,
sortBy: sortByInstances,
messages,
newMessageIds,
messageFilterString,
selectedEndpointName,
itemsPerPage,
Expand Down