Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
3f4eb1d
🐛 Stop the unbounded capability probe from hammering the audit store
ramonsmits Sep 3, 2026
b34c47b
🐛 Render onboarding prompts only after the capability probe has answered
ramonsmits Sep 1, 2026
743e830
💄 Move Show and Sort to the results line and align the filters panel
ramonsmits Sep 3, 2026
886ebb6
💄 Format result counts in the user's locale
ramonsmits Sep 3, 2026
7870fdd
✨ Refresh lives in the query bar and cancels the running query
ramonsmits Sep 3, 2026
b2e8b66
💄 Show an auto-refresh countdown ring inside the refresh button
ramonsmits Sep 3, 2026
7493115
♿ Expose the auto-refresh countdown to screen readers as text
ramonsmits Sep 7, 2026
9e23d43
🐛 Forget the query timing when the results are cleared
ramonsmits Sep 7, 2026
8f11ca8
✨ Per-browser search history with rerun
ramonsmits Sep 3, 2026
717d38c
✨ Show recent searches under the search field while it has focus
ramonsmits Sep 7, 2026
ccc9418
✨ Age-adaptive timestamps with a relative label, and when the query ran
ramonsmits Sep 3, 2026
1f67fe4
💄 Aligned row columns with the age on the right
ramonsmits Sep 4, 2026
65a0949
🐛 Forget when the results were fetched when they are cleared
ramonsmits Sep 7, 2026
bb7f0d2
✨ Animate rows that arrived since the previous refresh
ramonsmits Sep 7, 2026
cb6b0a0
🐛 Reset the new-row baseline when the results are cleared
ramonsmits Sep 7, 2026
7491e86
✨ Surface incomplete scatter-gather results and server query timeouts
ramonsmits Sep 4, 2026
8d03336
🐛 Show the missing instance as host and port instead of its base64 id
ramonsmits Sep 7, 2026
4cce93d
🐛 Forget the incomplete-results state when the results are cleared
ramonsmits Sep 7, 2026
e832b41
🐛 Stop the refresh button from jumping when a query is in progress
johnsimons Sep 7, 2026
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
12 changes: 9 additions & 3 deletions src/Frontend/src/components/ActionButton.vue
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@ interface Props {
iconPosition?: "left" | "right";
disabled?: boolean;
loading?: boolean;
/** Loading normally disables the button; opt out for buttons that stay
* actionable while work runs (e.g. a cancel button). */
disableOnLoading?: boolean;
tooltip?: string;
ariaLabel?: string;
type?: "button" | "submit" | "reset";
Expand All @@ -23,6 +26,7 @@ const props = withDefaults(defineProps<Props>(), {
iconPosition: "left",
disabled: false,
loading: false,
disableOnLoading: true,
type: "button",
});

Expand All @@ -44,13 +48,15 @@ const sizeClasses = {
<template>
<button
class="btn"
:class="[variantClasses[props.variant], sizeClasses[props.size], { disabled: props.disabled || props.loading }]"
:disabled="props.disabled || props.loading"
:class="[variantClasses[props.variant], sizeClasses[props.size], { disabled: props.disabled || (props.loading && props.disableOnLoading) }]"
:disabled="props.disabled || (props.loading && props.disableOnLoading)"
:type="props.type"
:aria-label="props.ariaLabel"
v-tippy="props.tooltip"
>
<FAIcon v-if="props.icon && props.iconPosition === 'left' && !props.loading" :icon="props.icon" class="icon-left" />
<slot v-if="!props.loading" name="icon">
<FAIcon v-if="props.icon && props.iconPosition === 'left'" :icon="props.icon" class="icon-left" />
</slot>
<FAIcon v-if="props.loading" class="rotate" :icon="faRefresh" />
<span v-if="$slots.default" class="button-text">
<slot />
Expand Down
89 changes: 89 additions & 0 deletions src/Frontend/src/components/AdaptiveTimestamp.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import { describe, expect, test, vi, beforeEach, afterEach } from "vitest";
import { cleanup, render, screen } from "@testing-library/vue";
import AdaptiveTimestamp from "@/components/AdaptiveTimestamp.vue";
import { useTimestampZone } from "@/composables/timestampZone";

const NOW = new Date("2026-09-03T15:00:00");

function renderAt(date: Date) {
cleanup();
render(AdaptiveTimestamp, { props: { dateUtc: date.toISOString() } });
return {
absolute: screen.getByTestId("adaptive-absolute").textContent!,
relative: screen.getByTestId("adaptive-relative").textContent!,
};
}

describe("FEATURE: Age-adaptive timestamps", () => {
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(NOW);
});

afterEach(() => {
vi.useRealTimers();
useTimestampZone().zone.value = "local";
});

test("EXAMPLE: Today shows the time only", () => {
const date = new Date("2026-09-03T13:00:00");
const { absolute, relative } = renderAt(date);

expect(absolute).toBe(date.toLocaleTimeString());
expect(relative).toContain("2 hours ago");
});

test("EXAMPLE: Yesterday is labeled yesterday", () => {
const date = new Date("2026-09-02T13:00:00");
const { absolute, relative } = renderAt(date);

expect(absolute).toBe(`yesterday ${date.toLocaleTimeString()}`);
expect(relative).toContain("1 day ago");
});

test("EXAMPLE: Within the past week the weekday is named", () => {
const date = new Date("2026-08-31T13:00:00"); // Monday
const { absolute, relative } = renderAt(date);

expect(absolute).toBe(`${date.toLocaleDateString(undefined, { weekday: "long" })} ${date.toLocaleTimeString()}`);
expect(relative).toContain("3 days ago");
});

test("EXAMPLE: Older dates keep the regular browser format", () => {
const date = new Date("2026-08-20T13:00:00");
const { absolute, relative } = renderAt(date);

expect(absolute).toBe(date.toLocaleString());
expect(relative).toContain("2 weeks ago");
});

test("EXAMPLE: The part prop renders only one half, for split layouts", () => {
cleanup();
render(AdaptiveTimestamp, { props: { dateUtc: new Date("2026-09-03T13:00:00").toISOString(), part: "relative" } });
expect(screen.queryByTestId("adaptive-absolute")).not.toBeInTheDocument();
expect(screen.getByTestId("adaptive-relative").textContent).toBe("2 hours ago");

cleanup();
render(AdaptiveTimestamp, { props: { dateUtc: new Date("2026-09-03T13:00:00").toISOString(), part: "absolute" } });
expect(screen.queryByTestId("adaptive-relative")).not.toBeInTheDocument();
expect(screen.getByTestId("adaptive-absolute")).toBeInTheDocument();
});

test("EXAMPLE: In UTC mode the wall-clock time is UTC", () => {
const { zone } = useTimestampZone();
zone.value = "utc";

const date = new Date("2026-09-03T13:00:00");
const utcWall = new Date(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate(), date.getUTCHours(), date.getUTCMinutes(), date.getUTCSeconds());

// toContain: near the UTC midnight boundary the day prefix may differ from local mode
expect(renderAt(date).absolute).toContain(utcWall.toLocaleTimeString());
});

test("EXAMPLE: The coarse relative label covers moments to years", () => {
expect(renderAt(new Date("2026-09-03T14:59:50")).relative).toContain("moments ago");
expect(renderAt(new Date("2026-09-03T14:30:00")).relative).toContain("30 minutes ago");
expect(renderAt(new Date("2026-06-03T15:00:00")).relative).toContain("3 months ago");
expect(renderAt(new Date("2024-09-03T15:00:00")).relative).toContain("2 years ago");
});
});
37 changes: 37 additions & 0 deletions src/Frontend/src/components/AdaptiveTimestamp.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref } from "vue";
import { useDateFormatter } from "@/composables/dateFormatter";
import { useTimestampZone } from "@/composables/timestampZone";

// part: render only one half — lets a layout place the absolute time and the
// age in separate (grid) cells while both keep the dual-zone tooltip
const props = defineProps<{ dateUtc: string; part?: "absolute" | "relative" }>();

const { formatAdaptiveDate, formatCoarseRelative, formatDateTooltip } = useDateFormatter();
const { zone } = useTimestampZone();

// tick so the relative label stays honest and the adaptive form rolls over at midnight
const now = ref(new Date());
let timer: number | undefined;
onMounted(() => {
timer = window.setInterval(() => (now.value = new Date()), 5000);
});
onBeforeUnmount(() => window.clearInterval(timer));

const absolute = computed(() => formatAdaptiveDate(props.dateUtc, () => now.value, zone.value));
const relative = computed(() => formatCoarseRelative(props.dateUtc, () => now.value));
const tooltip = computed(() => formatDateTooltip(props.dateUtc));
</script>

<template>
<span class="adaptive-timestamp" :title="tooltip">
<span v-if="props.part !== 'relative'" data-testid="adaptive-absolute">{{ absolute }}</span>
<span v-if="props.part !== 'absolute'" class="relative" data-testid="adaptive-relative">{{ props.part === "relative" ? "" : " · " }}{{ relative }}</span>
</span>
</template>

<style scoped>
.relative {
color: #999;
}
</style>
18 changes: 11 additions & 7 deletions src/Frontend/src/components/AutoRefreshIndicator.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,19 +9,23 @@ describe("FEATURE: Auto refresh indicator", () => {
expect(screen.queryByTestId("auto-refresh-indicator")).not.toBeInTheDocument();
});

test("EXAMPLE: A countdown bar is shown while waiting for the next refresh", () => {
test("EXAMPLE: A countdown ring is shown while waiting for the next refresh", () => {
render(AutoRefreshIndicator, { props: { nextRefreshAt: Date.now() + 2500, intervalMs: 5000, refreshing: false } });

expect(screen.getByTestId("auto-refresh-indicator")).toBeInTheDocument();
const bar = screen.getByTestId("auto-refresh-countdown");
const width = parseFloat(bar.style.width);
expect(width).toBeGreaterThan(25);
expect(width).toBeLessThan(75);
const indicator = screen.getByTestId("auto-refresh-indicator");
expect(indicator.dataset.state).toBe("countdown");
const ring = screen.getByTestId("auto-refresh-countdown");
// roughly half depleted: dash offset strictly between empty (0) and full circumference
const offset = parseFloat(ring.getAttribute("stroke-dashoffset")!);
const circumference = 2 * Math.PI * 6;
expect(offset).toBeGreaterThan(circumference * 0.25);
expect(offset).toBeLessThan(circumference * 0.75);
});

test("EXAMPLE: A waiting state is shown when the refresh is due but the query is still running", () => {
test("EXAMPLE: A waiting spinner ring is shown when the refresh is due but the query is still running", () => {
render(AutoRefreshIndicator, { props: { nextRefreshAt: Date.now(), intervalMs: 5000, refreshing: true } });

expect(screen.getByTestId("auto-refresh-indicator").dataset.state).toBe("waiting");
expect(screen.getByTestId("auto-refresh-waiting")).toBeInTheDocument();
expect(screen.queryByTestId("auto-refresh-countdown")).not.toBeInTheDocument();
});
Expand Down
90 changes: 60 additions & 30 deletions src/Frontend/src/components/AutoRefreshIndicator.vue
Original file line number Diff line number Diff line change
Expand Up @@ -19,50 +19,80 @@ const fraction = computed(() => {
});

const secondsLeft = computed(() => (props.nextRefreshAt === null ? null : Math.max(0, Math.ceil((props.nextRefreshAt - now.value) / 1000))));

// r=6.75 in a 16x16 viewBox (filling the box like a FontAwesome glyph does);
// the arc depletes as the next refresh approaches
const circumference = 2 * Math.PI * 6.75;
const dashOffset = computed(() => (fraction.value === null ? 0 : circumference * (1 - fraction.value)));

const label = computed(() => (props.refreshing ? "Waiting for query results…" : `Auto refresh in ${secondsLeft.value}s`));
</script>

<!-- Purely visual: hidden from assistive tech. The host exposes the countdown as text
(see RefreshConfig), so a screen reader never gets an SVG or a label that changes
every second as the name of the button this ring sits in. -->
<template>
<div
v-if="fraction !== null"
class="auto-refresh-indicator"
role="timer"
:aria-label="refreshing ? 'Waiting for query results' : `Auto refresh in ${secondsLeft} seconds`"
:title="refreshing ? 'Waiting for query results…' : `Auto refresh in ${secondsLeft}s`"
data-testid="auto-refresh-indicator"
>
<div v-if="refreshing" class="bar waiting" data-testid="auto-refresh-waiting" />
<div v-else class="bar" :style="{ width: `${fraction * 100}%` }" data-testid="auto-refresh-countdown" />
</div>
<span v-if="fraction !== null" class="auto-refresh-indicator" aria-hidden="true" :title="label" data-testid="auto-refresh-indicator" :data-state="refreshing ? 'waiting' : 'countdown'">
<svg viewBox="0 0 16 16" aria-hidden="true">
<circle class="track" cx="8" cy="8" r="6.75" />
<circle v-if="!refreshing" class="progress" cx="8" cy="8" r="6.75" :stroke-dasharray="circumference" :stroke-dashoffset="dashOffset" data-testid="auto-refresh-countdown" />
<circle v-else class="waiting" cx="8" cy="8" r="6.75" :stroke-dasharray="`${circumference / 5} ${circumference / 5}`" data-testid="auto-refresh-waiting" />
</svg>
</span>
</template>

<style scoped>
.auto-refresh-indicator {
width: 100%;
height: 3px;
margin-top: 0.25rem;
background-color: #e0e0e0;
border-radius: 2px;
overflow: hidden;
/* mirror FontAwesome's sizing so the ring occupies exactly the arrow icon's box */
display: inline-block;
line-height: 0;
vertical-align: -0.125em;
}

svg {
width: 1em;
height: 1em;
}

.bar {
height: 100%;
background-color: #00729c;
transition: width 250ms linear;
circle {
fill: none;
stroke-width: 2.4;
}

.bar.waiting {
width: 100%;
animation: waiting-pulse 1.2s ease-in-out infinite;
.track {
stroke: #e3e3e3;
}

.progress {
stroke: #00729c;
stroke-linecap: round;
transform: rotate(-90deg);
transform-origin: center;
transition: stroke-dashoffset 250ms linear;
}

.waiting {
stroke: #b0b0b0;
animation: ring-spin 1.6s linear infinite;
transform-origin: center;
}

@media (prefers-reduced-motion: reduce) {
.progress {
transition: none;
}

.waiting {
animation: none;
}
}

@keyframes waiting-pulse {
0%,
100% {
opacity: 0.35;
@keyframes ring-spin {
from {
transform: rotate(0deg);
}
50% {
opacity: 0.9;
to {
transform: rotate(360deg);
}
}
</style>
Loading
Loading