From 3f4eb1d01f1158b515ffe8d2c04de09aa974b2b1 Mon Sep 17 00:00:00 2001 From: Ramon Smits Date: Thu, 3 Sep 2026 18:44:54 +0200 Subject: [PATCH 01/19] =?UTF-8?q?=F0=9F=90=9B=20Stop=20the=20unbounded=20c?= =?UTF-8?q?apability=20probe=20from=20hammering=20the=20audit=20store?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The very-long query on first view access was not the message list (which is bounded by the default time range) but the hasSuccessfulMessages capability probe: an UNBOUNDED sorted query over the whole audit index, re-fired every 5 seconds by the platform-capabilities poll. It now probes the last 7 days first, falls back to unbounded only when that window is empty, and stops querying entirely once successful messages have been seen — they don't un-exist. --- .../src/components/audit/auditClient.ts | 9 ++-- src/Frontend/src/stores/AuditStore.spec.ts | 15 ++++++ src/Frontend/src/stores/AuditStore.ts | 3 +- .../stores/PlatformCapabilitiesStore.spec.ts | 53 +++++++++++++++++++ .../src/stores/PlatformCapabilitiesStore.ts | 10 +++- 5 files changed, 85 insertions(+), 5 deletions(-) create mode 100644 src/Frontend/src/stores/PlatformCapabilitiesStore.spec.ts diff --git a/src/Frontend/src/components/audit/auditClient.ts b/src/Frontend/src/components/audit/auditClient.ts index 92baf9d698..feb12cf9e6 100644 --- a/src/Frontend/src/components/audit/auditClient.ts +++ b/src/Frontend/src/components/audit/auditClient.ts @@ -22,10 +22,13 @@ class AuditClient { ); } - public async hasSuccessfulMessages(): Promise { + public async hasSuccessfulMessages(from?: Date): Promise { // Fetch the latest 10 messages and check if any are successful - // ideally we would want to filter successful messages server-side, but the API doesn't currently support that - const [, data] = await serviceControlClient.fetchTypedFromServiceControl(`messages2/?page_size=10&sort=time_sent&direction=desc`); + // ideally we would want to filter successful messages server-side, but the API doesn't currently support that. + // An unbounded sorted query scans the whole audit index, so callers should probe a bounded + // window first and only fall back to the unbounded form when the window is empty. + const bound = from ? `&from=${from.toISOString()}` : ""; + const [, data] = await serviceControlClient.fetchTypedFromServiceControl(`messages2/?page_size=10&sort=time_sent&direction=desc${bound}`); return data?.some((msg) => msg.status === MessageStatus.Successful) ?? false; } } diff --git a/src/Frontend/src/stores/AuditStore.spec.ts b/src/Frontend/src/stores/AuditStore.spec.ts index d121db502f..0d75d61440 100644 --- a/src/Frontend/src/stores/AuditStore.spec.ts +++ b/src/Frontend/src/stores/AuditStore.spec.ts @@ -31,6 +31,21 @@ describe("AuditStore refresh", () => { beforeEach(() => { setActivePinia(createPinia()); vi.clearAllMocks(); + localStorage.clear(); + }); + + test("the default time range bounds the very first query", async () => { + fetchTypedFromServiceControl.mockResolvedValue([responseWithTotalCount(0), []]); + const store = useAuditStore(); + + await store.refresh(); + + const url = fetchTypedFromServiceControl.mock.calls[0][0] as string; + const params = new URLSearchParams(url.split("?")[1]); + expect(store.timeRangeFrom).toBe("now-6h"); + expect(params.get("from")).toMatch(/^\d{4}-\d{2}-\d{2}T/); + expect(params.get("to")).toMatch(/^\d{4}-\d{2}-\d{2}T/); + expect(new Date(params.get("to")!).getTime() - new Date(params.get("from")!).getTime()).toBe(6 * 3600 * 1000); }); test("a successful query populates the messages and total count", async () => { diff --git a/src/Frontend/src/stores/AuditStore.ts b/src/Frontend/src/stores/AuditStore.ts index 87511f9ab3..39980348e0 100644 --- a/src/Frontend/src/stores/AuditStore.ts +++ b/src/Frontend/src/stores/AuditStore.ts @@ -69,6 +69,7 @@ export const useAuditStore = defineStore("AuditStore", () => { ); if (activeQuery !== thisQuery) { + // a newer query took over the view state while this one was in flight return; } @@ -77,7 +78,7 @@ export const useAuditStore = defineStore("AuditStore", () => { queryFailed.value = false; } catch { if (thisQuery.signal.aborted) { - // Superseded by a newer query, which owns the view state from here on + // Superseded by a newer query, or the view was left return; } diff --git a/src/Frontend/src/stores/PlatformCapabilitiesStore.spec.ts b/src/Frontend/src/stores/PlatformCapabilitiesStore.spec.ts new file mode 100644 index 0000000000..b2a9e11433 --- /dev/null +++ b/src/Frontend/src/stores/PlatformCapabilitiesStore.spec.ts @@ -0,0 +1,53 @@ +import { beforeEach, describe, expect, test, vi } from "vitest"; +import { createPinia, setActivePinia } from "pinia"; + +const auditClient = vi.hoisted(() => ({ hasSuccessfulMessages: vi.fn() })); +vi.mock("@/components/audit/auditClient", () => ({ default: auditClient })); +vi.mock("@/components/monitoring/monitoringClient", () => ({ default: { isMonitoringEnabled: false } })); +vi.mock("@/components/serviceControlClient", () => ({ default: { url: undefined } })); +vi.mock("@/composables/useConnectionsAndStatsAutoRefresh", () => ({ + default: () => ({ store: { monitoringConnectionState: { unableToConnect: true } } }), +})); + +import { usePlatformCapabilitiesStore } from "@/stores/PlatformCapabilitiesStore"; + +describe("FEATURE: Successful-messages capability probe", () => { + beforeEach(() => { + setActivePinia(createPinia()); + vi.clearAllMocks(); + localStorage.clear(); + }); + + test("EXAMPLE: A bounded window is probed before falling back to the full store", async () => { + auditClient.hasSuccessfulMessages.mockResolvedValueOnce(false).mockResolvedValueOnce(true); + const store = usePlatformCapabilitiesStore(); + + await store.refresh(); + + expect(auditClient.hasSuccessfulMessages).toHaveBeenCalledTimes(2); + expect(auditClient.hasSuccessfulMessages.mock.calls[0][0]).toBeInstanceOf(Date); // bounded first + expect(auditClient.hasSuccessfulMessages.mock.calls[1][0]).toBeUndefined(); // unbounded fallback + expect(store.hasSuccessfulMessages).toBe(true); + }); + + test("EXAMPLE: A hit in the bounded window skips the expensive unbounded probe", async () => { + auditClient.hasSuccessfulMessages.mockResolvedValueOnce(true); + const store = usePlatformCapabilitiesStore(); + + await store.refresh(); + + expect(auditClient.hasSuccessfulMessages).toHaveBeenCalledTimes(1); + expect(store.hasSuccessfulMessages).toBe(true); + }); + + test("EXAMPLE: Once successful messages were seen, later refreshes stop probing", async () => { + auditClient.hasSuccessfulMessages.mockResolvedValue(true); + const store = usePlatformCapabilitiesStore(); + + await store.refresh(); + await store.refresh(); + await store.refresh(); + + expect(auditClient.hasSuccessfulMessages).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/Frontend/src/stores/PlatformCapabilitiesStore.ts b/src/Frontend/src/stores/PlatformCapabilitiesStore.ts index b7a275c12b..f3cea6d332 100644 --- a/src/Frontend/src/stores/PlatformCapabilitiesStore.ts +++ b/src/Frontend/src/stores/PlatformCapabilitiesStore.ts @@ -61,8 +61,16 @@ export const usePlatformCapabilitiesStore = defineStore("PlatformCapabilitiesSto const hasMonitoredEndpoints = ref(false); async function checkForSuccessfulMessages() { + // Successful messages don't un-exist: once seen, never query again. + // The unbounded form of this probe scans the entire audit index (minutes on large + // stores), so probe a recent window first and fall back to unbounded only when + // the window is empty. + if (hasSuccessfulMessages.value) { + return; + } try { - hasSuccessfulMessages.value = await auditClient.hasSuccessfulMessages(); + const sevenDaysAgo = new Date(Date.now() - 7 * 24 * 3600 * 1000); + hasSuccessfulMessages.value = (await auditClient.hasSuccessfulMessages(sevenDaysAgo)) || (await auditClient.hasSuccessfulMessages()); } catch { hasSuccessfulMessages.value = false; } From b34c47b6bf019db2fa7813923fe64601e1b9b03f Mon Sep 17 00:00:00 2001 From: Ramon Smits Date: Tue, 1 Sep 2026 18:20:19 +0200 Subject: [PATCH 02/19] =?UTF-8?q?=F0=9F=90=9B=20Render=20onboarding=20prom?= =?UTF-8?q?pts=20only=20after=20the=20capability=20probe=20has=20answered?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 'no successful audit messages' onboarding banner and dashboard card assumed 'no messages' by default (the flag started false) and un-rendered once the probe proved otherwise — on large stores that meant minutes of a wrong onboarding prompt, and on every fresh session a flicker. hasSuccessfulMessages is now tri-state: null (not yet determined), false (a completed probe found nothing), true. While unknown the auditing capability reports a new Checking status: the All Messages banner renders nothing and the dashboard card shows its loading overlay. A failed probe proves nothing about the data, so it leaves the state unknown and the next tick retries instead of claiming 'no messages'. --- .../src/components/audit/AuditList.spec.ts | 15 +++++++++- .../capabilities/AuditingCapability.ts | 4 +-- .../stores/PlatformCapabilitiesStore.spec.ts | 28 +++++++++++++++++++ .../src/stores/PlatformCapabilitiesStore.ts | 8 ++++-- 4 files changed, 49 insertions(+), 6 deletions(-) diff --git a/src/Frontend/src/components/audit/AuditList.spec.ts b/src/Frontend/src/components/audit/AuditList.spec.ts index ee80387842..ea2824fd5b 100644 --- a/src/Frontend/src/components/audit/AuditList.spec.ts +++ b/src/Frontend/src/components/audit/AuditList.spec.ts @@ -21,8 +21,9 @@ import { type default as Message, MessageStatus } from "@/resources/Message"; // ==================== Mock Setup ==================== vi.mock("@/composables/autoRefresh"); +const auditingStatus = vi.hoisted(() => ({ value: "Available" })); vi.mock("@/components/platformcapabilities/capabilities/AuditingCapability", () => ({ - useAuditingCapability: () => ({ status: { value: "Available" } }), + useAuditingCapability: () => ({ status: auditingStatus }), })); vi.mock("@/components/platformcapabilities/wizards/AuditingWizardPages", () => ({ getAuditingWizardPages: () => [], @@ -209,6 +210,7 @@ async function waitForFirstLoadToComplete() { describe("FEATURE: Audit Messages Query State", () => { beforeEach(() => { vi.clearAllMocks(); + auditingStatus.value = "Available"; localStorage.clear(); }); @@ -310,6 +312,17 @@ describe("FEATURE: Audit Messages Query State", () => { }); }); + describe("RULE: Onboarding prompts render only after the capability probe has answered", () => { + test("EXAMPLE: The banner appears once a completed probe found no messages", async () => { + auditingStatus.value = "Endpoints Not Configured"; + await renderAuditList([]); + + await waitForFirstLoadToComplete(); + + expect(document.querySelector("page-banner-stub")).not.toBeNull(); + }); + }); + 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([]); diff --git a/src/Frontend/src/components/platformcapabilities/capabilities/AuditingCapability.ts b/src/Frontend/src/components/platformcapabilities/capabilities/AuditingCapability.ts index 307f996b6d..a830760bf0 100644 --- a/src/Frontend/src/components/platformcapabilities/capabilities/AuditingCapability.ts +++ b/src/Frontend/src/components/platformcapabilities/capabilities/AuditingCapability.ts @@ -149,8 +149,8 @@ export function useAuditingCapability(): CapabilityComposable { const auditIndicators = computed(() => { const indicators: StatusIndicator[] = []; - // Messages available indicator - show if at least one instance is available - if (hasAvailableAuditInstances(auditInstances.value)) { + // Messages available indicator - show if at least one instance is available and the probe answered + if (hasAvailableAuditInstances(auditInstances.value) && hasSuccessfulMessages.value !== null) { const messagesAvailable = isAllMessagesSupported.value && hasSuccessfulMessages.value; let messageTooltip: string; diff --git a/src/Frontend/src/stores/PlatformCapabilitiesStore.spec.ts b/src/Frontend/src/stores/PlatformCapabilitiesStore.spec.ts index b2a9e11433..f8babf93a4 100644 --- a/src/Frontend/src/stores/PlatformCapabilitiesStore.spec.ts +++ b/src/Frontend/src/stores/PlatformCapabilitiesStore.spec.ts @@ -18,6 +18,34 @@ describe("FEATURE: Successful-messages capability probe", () => { localStorage.clear(); }); + test("EXAMPLE: Before the first probe answers, the state is unknown, not 'no messages'", () => { + const store = usePlatformCapabilitiesStore(); + + expect(store.hasSuccessfulMessages).toBeNull(); + }); + + test("EXAMPLE: A failed probe proves nothing — the state stays unknown and is retried", async () => { + auditClient.hasSuccessfulMessages.mockRejectedValue(new Error("boom")); + const store = usePlatformCapabilitiesStore(); + + await store.refresh(); + expect(store.hasSuccessfulMessages).toBeNull(); + + auditClient.hasSuccessfulMessages.mockReset(); + auditClient.hasSuccessfulMessages.mockResolvedValue(true); + await store.refresh(); + expect(store.hasSuccessfulMessages).toBe(true); + }); + + test("EXAMPLE: A completed probe finding nothing reports false", async () => { + auditClient.hasSuccessfulMessages.mockResolvedValue(false); + const store = usePlatformCapabilitiesStore(); + + await store.refresh(); + + expect(store.hasSuccessfulMessages).toBe(false); + }); + test("EXAMPLE: A bounded window is probed before falling back to the full store", async () => { auditClient.hasSuccessfulMessages.mockResolvedValueOnce(false).mockResolvedValueOnce(true); const store = usePlatformCapabilitiesStore(); diff --git a/src/Frontend/src/stores/PlatformCapabilitiesStore.ts b/src/Frontend/src/stores/PlatformCapabilitiesStore.ts index f3cea6d332..e6297a68f8 100644 --- a/src/Frontend/src/stores/PlatformCapabilitiesStore.ts +++ b/src/Frontend/src/stores/PlatformCapabilitiesStore.ts @@ -57,7 +57,8 @@ function saveVisibility(visibility: PlatformCapabilitiesVisibility): void { export const usePlatformCapabilitiesStore = defineStore("PlatformCapabilitiesStore", () => { const { store: connectionStore } = useConnectionsAndStatsAutoRefresh(); const visibility = ref(loadVisibility()); - const hasSuccessfulMessages = ref(false); + // null = not yet determined: consumers must render nothing promotional until the probe answers + const hasSuccessfulMessages = ref(null); const hasMonitoredEndpoints = ref(false); async function checkForSuccessfulMessages() { @@ -65,14 +66,15 @@ export const usePlatformCapabilitiesStore = defineStore("PlatformCapabilitiesSto // The unbounded form of this probe scans the entire audit index (minutes on large // stores), so probe a recent window first and fall back to unbounded only when // the window is empty. - if (hasSuccessfulMessages.value) { + if (hasSuccessfulMessages.value === true) { return; } try { const sevenDaysAgo = new Date(Date.now() - 7 * 24 * 3600 * 1000); hasSuccessfulMessages.value = (await auditClient.hasSuccessfulMessages(sevenDaysAgo)) || (await auditClient.hasSuccessfulMessages()); } catch { - hasSuccessfulMessages.value = false; + // A failed probe proves nothing about the data; stay unknown and let the next tick retry + hasSuccessfulMessages.value = null; } } From 743e83096612544aba7efa2a39860bef82001634 Mon Sep 17 00:00:00 2001 From: Ramon Smits Date: Thu, 3 Sep 2026 16:09:14 +0200 Subject: [PATCH 03/19] =?UTF-8?q?=F0=9F=92=84=20Move=20Show=20and=20Sort?= =?UTF-8?q?=20to=20the=20results=20line=20and=20align=20the=20filters=20pa?= =?UTF-8?q?nel?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Show (page size) and Sort are result-presentation controls, not query filters — they now sit right of 'Showing X of Y result(s)' instead of competing with the query inputs for space in the filters panel, which also removes their awkward wrapping. The filters panel keeps the query inputs only (search, endpoint, time range), left-aligned; the rule that pushed the last filter to the far right is gone. --- .../src/components/audit/AuditList.vue | 19 ++++- .../src/components/audit/FiltersPanel.vue | 62 +------------- .../src/components/audit/ResultsOptions.vue | 84 +++++++++++++++++++ 3 files changed, 104 insertions(+), 61 deletions(-) create mode 100644 src/Frontend/src/components/audit/ResultsOptions.vue diff --git a/src/Frontend/src/components/audit/AuditList.vue b/src/Frontend/src/components/audit/AuditList.vue index 6ee6faba93..76deddcf99 100644 --- a/src/Frontend/src/components/audit/AuditList.vue +++ b/src/Frontend/src/components/audit/AuditList.vue @@ -4,6 +4,7 @@ import { storeToRefs } from "pinia"; import { useRoute, useRouter } from "vue-router"; import ResultsCount from "@/components/ResultsCount.vue"; import FiltersPanel from "@/components/audit/FiltersPanel.vue"; +import ResultsOptions from "@/components/audit/ResultsOptions.vue"; import AuditListItem from "@/components/audit/AuditListItem.vue"; import { computed, onBeforeMount, onBeforeUnmount, ref, watch } from "vue"; import RefreshConfig from "../RefreshConfig.vue"; @@ -182,8 +183,9 @@ watch(autoRefreshValue, (newValue) => {
-
+
+
@@ -254,6 +256,21 @@ watch(autoRefreshValue, (newValue) => { color: #fff; } +.results-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + flex-wrap: wrap; +} + +/* ResultsCount's root uses the bootstrap .col class (flex-grow: 1), which would + push the options onto their own line; in this row both sides size to content */ +.results-row > * { + flex: 0 1 auto; + width: auto; +} + .results-table { margin-top: 1rem; margin-bottom: 5rem; diff --git a/src/Frontend/src/components/audit/FiltersPanel.vue b/src/Frontend/src/components/audit/FiltersPanel.vue index f2c7a849ad..1330625a01 100644 --- a/src/Frontend/src/components/audit/FiltersPanel.vue +++ b/src/Frontend/src/components/audit/FiltersPanel.vue @@ -1,57 +1,16 @@ @@ -112,11 +59,6 @@ function findKeyByValue(searchValue: string) { min-width: 0; } -.filter:last-child { - flex-grow: 1; - place-content: flex-end; -} - .filter-label { font-weight: bold; padding-block: 0.375rem; diff --git a/src/Frontend/src/components/audit/ResultsOptions.vue b/src/Frontend/src/components/audit/ResultsOptions.vue new file mode 100644 index 0000000000..08ff3042a4 --- /dev/null +++ b/src/Frontend/src/components/audit/ResultsOptions.vue @@ -0,0 +1,84 @@ + + + + + From 886ebb6f3d7dab4ab9bf245517df34984df6416c Mon Sep 17 00:00:00 2001 From: Ramon Smits Date: Thu, 3 Sep 2026 16:11:38 +0200 Subject: [PATCH 04/19] =?UTF-8?q?=F0=9F=92=84=20Format=20result=20counts?= =?UTF-8?q?=20in=20the=20user's=20locale?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Large audit stores easily reach nine-digit totals; 'Showing 100 of 158736340 result(s)' is unreadable. Both numbers now go through Intl.NumberFormat with the user's locale (158,736,340 / 158.736.340). ResultsCount is shared, so the heartbeats views and the throughput queue list pick this up as well. --- .../src/components/ResultsCount.spec.ts | 18 ++++++++++++++++++ src/Frontend/src/components/ResultsCount.vue | 11 +++++++++-- 2 files changed, 27 insertions(+), 2 deletions(-) create mode 100644 src/Frontend/src/components/ResultsCount.spec.ts diff --git a/src/Frontend/src/components/ResultsCount.spec.ts b/src/Frontend/src/components/ResultsCount.spec.ts new file mode 100644 index 0000000000..363162a04a --- /dev/null +++ b/src/Frontend/src/components/ResultsCount.spec.ts @@ -0,0 +1,18 @@ +import { describe, expect, test } from "vitest"; +import { render, screen } from "@testing-library/vue"; +import ResultsCount from "@/components/ResultsCount.vue"; + +describe("FEATURE: Results count", () => { + test("EXAMPLE: Large counts are formatted in the user's locale", () => { + render(ResultsCount, { props: { displayed: 100, total: 158736340 } }); + + const expected = `Showing ${(100).toLocaleString()} of ${(158736340).toLocaleString()} result(s)`; + expect(screen.getByText(expected)).toBeInTheDocument(); + }); + + test("EXAMPLE: Zero results render plainly", () => { + render(ResultsCount, { props: { displayed: 0, total: 0 } }); + + expect(screen.getByText("Showing 0 of 0 result(s)")).toBeInTheDocument(); + }); +}); diff --git a/src/Frontend/src/components/ResultsCount.vue b/src/Frontend/src/components/ResultsCount.vue index 2340aa8c7b..64353a48e2 100644 --- a/src/Frontend/src/components/ResultsCount.vue +++ b/src/Frontend/src/components/ResultsCount.vue @@ -1,13 +1,20 @@ From 7870fdd0d105191e377c1b9cedc0bc02b12510f1 Mon Sep 17 00:00:00 2001 From: Ramon Smits Date: Thu, 3 Sep 2026 17:28:59 +0200 Subject: [PATCH 05/19] =?UTF-8?q?=E2=9C=A8=20Refresh=20lives=20in=20the=20?= =?UTF-8?q?query=20bar=20and=20cancels=20the=20running=20query?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The refresh cluster occupied its own row above the filters; it now sits right-aligned inside the query bar so the whole query surface is one row. While a query is in flight the button flips from Refresh to an enabled Cancel that ticks the elapsed time (Cancel · 3.2s) and aborts the running query through the store — the abort propagates through ServiceControl and terminates the database-side query. Once results land, the results line reports what they cost: 'Showing 100 of 158,736,340 result(s) · took 2.7 s'. ActionButton hard-coupled loading to disabled, which would have left the button dead exactly when a user most wants an escape hatch; it gains disableOnLoading (default true, existing uses unchanged) and the refresh/cancel button opts out. The spec stubs ActionButton with the real disabled semantics so that coupling can't silently return. --- src/Frontend/src/components/ActionButton.vue | 8 ++- .../src/components/RefreshConfig.spec.ts | 57 ++++++++++++++++--- src/Frontend/src/components/RefreshConfig.vue | 43 +++++++++++--- .../src/components/ResultsCount.spec.ts | 13 +++++ src/Frontend/src/components/ResultsCount.vue | 10 +++- .../src/components/audit/AuditList.spec.ts | 20 ++++++- .../src/components/audit/AuditList.vue | 13 +++-- .../src/components/audit/FiltersPanel.vue | 10 ++++ src/Frontend/src/stores/AuditStore.spec.ts | 17 ++++++ src/Frontend/src/stores/AuditStore.ts | 12 ++++ 10 files changed, 178 insertions(+), 25 deletions(-) diff --git a/src/Frontend/src/components/ActionButton.vue b/src/Frontend/src/components/ActionButton.vue index 1b94fe11ab..11f48a338d 100644 --- a/src/Frontend/src/components/ActionButton.vue +++ b/src/Frontend/src/components/ActionButton.vue @@ -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"; @@ -23,6 +26,7 @@ const props = withDefaults(defineProps(), { iconPosition: "left", disabled: false, loading: false, + disableOnLoading: true, type: "button", }); @@ -44,8 +48,8 @@ const sizeClasses = {